Skip to main content

cli/shells/
install.rs

1use super::links::link_conflict_render_lines;
2#[cfg(test)]
3use super::profile::append_path_to_shell_config;
4use super::profile::{
5    managed_shell_profile_path, shell_source_command, source_command_activation_hint_lines,
6};
7use super::report::{
8    ShellUpgradeReport, link_report_summary_parts, shell_cache_summary_parts, style_bold,
9    style_dim, style_green, style_symbol, style_yellow,
10};
11use super::{PathUpdateStatus, get_shell_config_path, metadata};
12use crate::config::Config;
13use crate::output;
14use crate::presentation::{LifecycleReporter, PresentationEvent, TerminalRenderer};
15use anyhow::{Context, Result};
16use shine_core::lifecycle::{
17    LifecycleEffect, LifecycleOperation, LifecycleOutcomeV1, LifecycleResultV1, LifecycleStatus,
18};
19use shine_core::runtime::{PlanningInputVersions, ShellPlanRequest};
20use std::collections::BTreeSet;
21#[cfg(test)]
22use std::path::Path;
23
24const SHELL_TEMPLATE: &str = r#"# Shell preset metadata for shine.
25description = "My shell helper commands."
26
27[[files]]
28source = "my_tool.sh"
29target = "mytool"
30needs_source = false
31# Optional: limit a file to specific platforms.
32# platforms = ["macos"]    # exact: macos/linux/windows; unix groups macOS + Linux
33
34[files.permissions]
35schema_version = 1
36
37# PowerShell scripts are also supported:
38# source = "my_tool.ps1"
39
40# Cross-platform Bun helpers (requires `bun` on PATH; shine never installs it):
41# [[files]]
42# source = "my_tool.ts"     # .ts / .js / .mts / .mjs
43# target = "mytool"
44# runtime = "bun"
45# platforms = ["unix", "windows"]
46# description = "What mytool does."  # or a `// ...` header at the top of my_tool.ts
47# transforms = ["template"] # opt into @@VAR@@ env substitution (static, needs `shine upgrade`)
48# env = ["API_URL", "SERVICE_TOKEN=API_TOKEN"]  # inject shine values at launch; read via Bun.env
49# [files.permissions]
50# schema_version = 1
51# commands = ["bun"]
52# environment = [
53#   { name = "API_URL", sensitivity = "plain" },
54#   { name = "SERVICE_TOKEN", sensitivity = "secret" },
55# ]
56"#;
57
58pub async fn handle_init_template(force: bool) -> Result<()> {
59    let dir = std::env::current_dir().context("reading current directory")?;
60    let (path, overwritten) =
61        shine_core::init_template::write_shine_toml_template(&dir, force, SHELL_TEMPLATE)?;
62    if overwritten {
63        println!("Updated shell preset template: {}", path.display());
64    } else {
65        println!("Created shell preset template: {}", path.display());
66    }
67    Ok(())
68}
69
70pub async fn handle_install(config: &Config, target: Option<&str>, force: bool) -> Result<()> {
71    handle_install_approved(config, target, force, true).await
72}
73
74pub async fn handle_install_approved(
75    config: &Config,
76    target: Option<&str>,
77    force: bool,
78    yes: bool,
79) -> Result<()> {
80    let mut renderer = TerminalRenderer::stdio();
81    handle_install_with_reporter(config, target, force, yes, &mut renderer)
82        .await
83        .map(|_| ())
84}
85
86#[cfg(test)]
87pub(crate) async fn handle_install_with_result(
88    config: &Config,
89    target: Option<&str>,
90    force: bool,
91) -> Result<LifecycleResultV1> {
92    let mut renderer = TerminalRenderer::stdio();
93    handle_install_with_reporter(config, target, force, true, &mut renderer).await
94}
95
96async fn handle_install_with_reporter(
97    config: &Config,
98    target: Option<&str>,
99    force: bool,
100    yes: bool,
101    reporter: &mut dyn LifecycleReporter,
102) -> Result<LifecycleResultV1> {
103    for line in crate::config::presets_note_lines(config) {
104        reporter.emit(PresentationEvent::stdout(line));
105    }
106    let selection = target.map(metadata::parse_lifecycle_target).transpose()?;
107    let category_filter = selection.map(|target| target.category);
108    let reviewed = crate::lifecycle_plan::review_plans(
109        config,
110        [crate::lifecycle_plan::LifecyclePlanRequest::shell(
111            ShellPlanRequest {
112                operation: LifecycleOperation::Install,
113                target: target.map(str::to_string),
114                force,
115                purge: false,
116                input_versions: PlanningInputVersions::default(),
117            },
118            config,
119        )],
120        yes,
121    )
122    .await?
123    .into_iter()
124    .next()
125    .expect("one reviewed Shell Plan");
126    let runtime = crate::lifecycle_plan::prepare_runtime(config, &reviewed).await?;
127    let core_report = match crate::lifecycle_plan::execute_reviewed(
128        config,
129        runtime,
130        reviewed,
131        shine_core::frontend::ExecutionOptions::default(),
132        &mut shine_core::runtime::NullObserver,
133        &mut crate::presentation::TerminalInteraction,
134    )
135    .await?
136    {
137        shine_core::frontend::OperationDetails::ShellInstall(report) => *report,
138        _ => unreachable!("reviewed operation result type"),
139    };
140    if !config.is_external_presets {
141        reporter.emit(PresentationEvent::stdout(output::summary_line_text(
142            "Shell Presets",
143            &shell_cache_summary_parts(&core_report.cache),
144        )));
145    }
146    if config.is_external_presets
147        && config.external_shell_mode == crate::config::ExternalShellMode::Snapshot
148    {
149        let summary = if core_report.snapshots_updated > 0 {
150            style_green(&format!("{} updated", core_report.snapshots_updated))
151        } else {
152            style_dim("up to date")
153        };
154        reporter.emit(PresentationEvent::stdout(output::summary_line_text(
155            "Shell Snapshots",
156            &[summary],
157        )));
158    }
159    reporter.emit(PresentationEvent::stdout(output::summary_line_text(
160        "Bin Links",
161        &link_report_summary_parts(&core_report.links),
162    )));
163    for line in link_conflict_render_lines(config, &core_report.links.conflicts, category_filter) {
164        reporter.emit(PresentationEvent::stdout(line));
165    }
166    let shell_config_path = get_shell_config_path(&config.shell_type, &config.home_dir)?;
167    let shell_update = core_report
168        .profile
169        .as_ref()
170        .expect("Core Shell install profile report");
171    let profile_path = managed_shell_profile_path(config);
172    if shell_update.profile_updated {
173        reporter.emit(PresentationEvent::stdout(output::detail_line_text(
174            "Shell Profile",
175            &style_green("updated"),
176            Some(profile_path.display().to_string()),
177        )));
178    }
179    match &shell_update.config_status {
180        PathUpdateStatus::AlreadyConfigured => {
181            reporter.emit(PresentationEvent::stdout(output::detail_line_text(
182                "Shell Config",
183                &style_dim("up to date"),
184                Some(shell_config_path.display().to_string()),
185            )));
186        }
187        PathUpdateStatus::Updated(path) => {
188            reporter.emit(PresentationEvent::stdout(output::detail_line_text(
189                "Shell Config",
190                &style_green("updated"),
191                Some(path.display().to_string()),
192            )));
193        }
194    }
195    for line in source_command_activation_hint_lines(
196        config,
197        &shell_config_path,
198        &core_report.source_commands,
199    ) {
200        reporter.emit(PresentationEvent::stdout(line));
201    }
202
203    Ok(core_report.lifecycle)
204}
205
206/// Resolve and validate a shell installation plan without extracting presets,
207/// rendering templates, creating links, updating manifests, or editing shell
208/// profiles.
209pub async fn handle_install_dry_run(config: &Config, target: Option<&str>) -> Result<()> {
210    let mut renderer = TerminalRenderer::stdio();
211    handle_install_dry_run_with_reporter(config, target, &mut renderer)
212        .await
213        .map(|_| ())
214}
215
216async fn handle_install_dry_run_with_reporter(
217    config: &Config,
218    target: Option<&str>,
219    reporter: &mut dyn LifecycleReporter,
220) -> Result<LifecycleResultV1> {
221    for line in crate::config::presets_note_lines(config) {
222        reporter.emit(PresentationEvent::stdout(line));
223    }
224    let core_report = crate::core_runtime::from_config(config)
225        .await?
226        .preview_install_shells(shine_core::runtime::ShellLifecycleRequest {
227            target: target.map(str::to_string),
228            dry_run: true,
229            force: false,
230        })
231        .await?;
232    for (command, target, source) in &core_report.planned_links {
233        reporter.emit(PresentationEvent::stdout(format!(
234            "Would link shell command {command}: {} -> {}",
235            target.display(),
236            source.display()
237        )));
238    }
239    reporter.emit(PresentationEvent::stdout(
240        "Dry run: no shell files, links, manifests, or profiles were changed.",
241    ));
242    Ok(core_report.lifecycle)
243}
244
245pub async fn handle_upgrade_installed(
246    config: &Config,
247    verbose: bool,
248    sep: &mut crate::output::SectionSeparator,
249) -> Result<ShellUpgradeReport> {
250    handle_upgrade_installed_with_result_approved(config, verbose, true, sep)
251        .await
252        .map(|(report, _)| report)
253}
254
255pub(crate) async fn handle_upgrade_installed_with_result_approved(
256    config: &Config,
257    verbose: bool,
258    yes: bool,
259    sep: &mut crate::output::SectionSeparator,
260) -> Result<(ShellUpgradeReport, LifecycleResultV1)> {
261    handle_upgrade_installed_target_with_result_approved(config, None, verbose, yes, sep).await
262}
263
264pub(crate) async fn handle_upgrade_installed_with_result_prepared(
265    config: &Config,
266    verbose: bool,
267    prepared: crate::lifecycle_plan::PreparedLifecyclePlan,
268    sep: &mut crate::output::SectionSeparator,
269) -> Result<(ShellUpgradeReport, LifecycleResultV1)> {
270    let mut renderer = TerminalRenderer::stdio_with_separator(sep);
271    handle_upgrade_installed_target_with_prepared_reporter(
272        config,
273        None,
274        verbose,
275        prepared,
276        &mut renderer,
277    )
278    .await
279}
280
281pub async fn handle_upgrade_installed_target(
282    config: &Config,
283    category_filter: Option<&str>,
284    verbose: bool,
285    sep: &mut crate::output::SectionSeparator,
286) -> Result<ShellUpgradeReport> {
287    handle_upgrade_installed_target_with_result_approved(
288        config,
289        category_filter,
290        verbose,
291        true,
292        sep,
293    )
294    .await
295    .map(|(report, _)| report)
296}
297
298#[cfg(test)]
299pub(crate) async fn handle_upgrade_installed_target_with_result(
300    config: &Config,
301    category_filter: Option<&str>,
302    verbose: bool,
303    sep: &mut crate::output::SectionSeparator,
304) -> Result<(ShellUpgradeReport, LifecycleResultV1)> {
305    handle_upgrade_installed_target_with_result_approved(
306        config,
307        category_filter,
308        verbose,
309        true,
310        sep,
311    )
312    .await
313}
314
315pub(crate) async fn handle_upgrade_installed_target_with_result_approved(
316    config: &Config,
317    category_filter: Option<&str>,
318    verbose: bool,
319    yes: bool,
320    sep: &mut crate::output::SectionSeparator,
321) -> Result<(ShellUpgradeReport, LifecycleResultV1)> {
322    let mut renderer = TerminalRenderer::stdio_with_separator(sep);
323    handle_upgrade_installed_target_with_reporter(
324        config,
325        category_filter,
326        verbose,
327        yes,
328        &mut renderer,
329    )
330    .await
331}
332
333async fn handle_upgrade_installed_target_with_reporter(
334    config: &Config,
335    category_filter: Option<&str>,
336    verbose: bool,
337    yes: bool,
338    reporter: &mut dyn LifecycleReporter,
339) -> Result<(ShellUpgradeReport, LifecycleResultV1)> {
340    let reviewed = crate::lifecycle_plan::review_upgrade_plans(
341        config,
342        [crate::lifecycle_plan::LifecyclePlanRequest::shell(
343            ShellPlanRequest {
344                operation: LifecycleOperation::Upgrade,
345                target: category_filter.map(str::to_string),
346                force: false,
347                purge: false,
348                input_versions: PlanningInputVersions::default(),
349            },
350            config,
351        )],
352        yes,
353        verbose,
354    )
355    .await?
356    .into_iter()
357    .next()
358    .expect("one reviewed Shell Plan");
359    let runtime = crate::lifecycle_plan::prepare_runtime(config, &reviewed).await?;
360    handle_upgrade_installed_target_with_prepared_reporter(
361        config,
362        category_filter,
363        verbose,
364        crate::lifecycle_plan::PreparedLifecyclePlan { reviewed, runtime },
365        reporter,
366    )
367    .await
368}
369
370async fn handle_upgrade_installed_target_with_prepared_reporter(
371    config: &Config,
372    category_filter: Option<&str>,
373    verbose: bool,
374    prepared: crate::lifecycle_plan::PreparedLifecyclePlan,
375    reporter: &mut dyn LifecycleReporter,
376) -> Result<(ShellUpgradeReport, LifecycleResultV1)> {
377    let crate::lifecycle_plan::PreparedLifecyclePlan { reviewed, runtime } = prepared;
378    let core = match crate::lifecycle_plan::execute_reviewed(
379        config,
380        runtime,
381        reviewed,
382        shine_core::frontend::ExecutionOptions::default(),
383        &mut shine_core::runtime::NullObserver,
384        &mut crate::presentation::TerminalInteraction,
385    )
386    .await?
387    {
388        shine_core::frontend::OperationDetails::ShellUpgrade(report) => *report,
389        _ => unreachable!("reviewed operation result type"),
390    };
391    if core.runs.is_empty() {
392        if verbose {
393            reporter.emit(PresentationEvent::stdout(style_dim(
394                "No installed shell presets found.",
395            )));
396        }
397        return Ok((ShellUpgradeReport::default(), core.lifecycle));
398    }
399
400    let snapshots_updated = core.runs.iter().map(|run| run.snapshots_updated).sum();
401    let templates_updated = core
402        .runs
403        .iter()
404        .map(|run| run.templates.updated.len())
405        .sum();
406    let links_created = core.runs.iter().map(|run| run.links.created.len()).sum();
407    let links_updated = core
408        .runs
409        .iter()
410        .map(|run| run.links.overwritten.len())
411        .sum();
412    let link_conflicts = core.runs.iter().map(|run| run.links.conflicts.len()).sum();
413    let path_changed = core.runs.iter().any(|run| {
414        run.profile.as_ref().is_some_and(|profile| {
415            profile.profile_updated || matches!(profile.config_status, PathUpdateStatus::Updated(_))
416        })
417    });
418    let has_visible_result = should_print_upgrade_section(
419        verbose,
420        !core.updated_categories.is_empty(),
421        link_conflicts > 0,
422        path_changed,
423    );
424    if has_visible_result {
425        reporter.emit(PresentationEvent::SectionStart);
426        if verbose {
427            let installed_categories = core
428                .runs
429                .iter()
430                .flat_map(|run| run.categories.iter().map(|category| category.name.as_str()))
431                .collect::<BTreeSet<_>>()
432                .len();
433            reporter.emit(PresentationEvent::stdout(output::summary_line_text(
434                "Shell Presets",
435                &[style_dim(&format!(
436                    "{installed_categories} installed categories"
437                ))],
438            )));
439        } else {
440            reporter.emit(PresentationEvent::stdout(style_bold("Shell Presets")));
441        }
442        for category in &core.updated_categories {
443            reporter.emit(PresentationEvent::stdout(format!(
444                "  {} {category}",
445                style_symbol("✓")
446            )));
447        }
448        if verbose && snapshots_updated > 0 {
449            reporter.emit(PresentationEvent::stdout(format!(
450                "  {} {}",
451                style_symbol("✓"),
452                style_green(&format!("{snapshots_updated} snapshot(s) updated"))
453            )));
454        }
455        if verbose && templates_updated > 0 {
456            reporter.emit(PresentationEvent::stdout(output::summary_line_text(
457                "Templates",
458                &[style_green(&format!("{templates_updated} rendered"))],
459            )));
460        }
461        if should_print_link_summary(verbose, link_conflicts) {
462            let parts = vec![
463                (links_created > 0).then(|| style_green(&format!("{links_created} created"))),
464                (links_updated > 0).then(|| style_green(&format!("{links_updated} updated"))),
465                (link_conflicts > 0).then(|| style_yellow(&format!("{link_conflicts} conflicts"))),
466            ]
467            .into_iter()
468            .flatten()
469            .collect::<Vec<_>>();
470            if !parts.is_empty() {
471                reporter.emit(PresentationEvent::stdout(output::summary_line_text(
472                    "Bin Links",
473                    &parts,
474                )));
475            }
476        }
477        for run in &core.runs {
478            for line in link_conflict_render_lines(config, &run.links.conflicts, category_filter) {
479                reporter.emit(PresentationEvent::stdout(line));
480            }
481        }
482        if path_changed
483            && let Some(path) = core.runs.iter().find_map(|run| {
484                run.profile
485                    .as_ref()
486                    .and_then(|profile| match &profile.config_status {
487                        PathUpdateStatus::Updated(path) => Some(path),
488                        PathUpdateStatus::AlreadyConfigured => None,
489                    })
490            })
491        {
492            reporter.emit(PresentationEvent::stdout(output::detail_line_text(
493                "Shell Config",
494                &style_green("updated"),
495                Some(path.display().to_string()),
496            )));
497        }
498    }
499    Ok((
500        ShellUpgradeReport {
501            updated_targets: core.updated_targets,
502            updated_categories: core.updated_categories,
503            snapshots_updated,
504            templates_updated,
505            links_created,
506            links_updated,
507            link_conflicts,
508            path_changed,
509        },
510        core.lifecycle,
511    ))
512}
513fn should_print_upgrade_section(
514    verbose: bool,
515    targets_updated: bool,
516    has_link_conflict: bool,
517    path_changed: bool,
518) -> bool {
519    verbose || targets_updated || has_link_conflict || path_changed
520}
521
522fn should_print_link_summary(verbose: bool, conflict_count: usize) -> bool {
523    verbose || conflict_count > 0
524}
525
526pub(crate) async fn collect_update_lifecycle_result(config: &Config) -> Result<LifecycleResultV1> {
527    let mut result = LifecycleResultV1::new(LifecycleOperation::Update, false);
528    for row in crate::status::build_shell_rows(config)
529        .await?
530        .into_iter()
531        .filter(|row| row.is_installed)
532    {
533        let mut effects = Vec::new();
534        if row.changes.iter().any(|change| {
535            matches!(
536                change,
537                crate::status::UpdateChange::ContentChanged
538                    | crate::status::UpdateChange::SourceRelocated { .. }
539                    | crate::status::UpdateChange::DeploymentChanged {
540                        field: "snapshot",
541                        ..
542                    }
543            )
544        }) {
545            effects.push(LifecycleEffect::CacheWritePreviewed);
546        }
547        if row.changes.iter().any(|change| {
548            matches!(
549                change,
550                crate::status::UpdateChange::ManifestEntryMissing { .. }
551            )
552        }) {
553            effects.push(LifecycleEffect::ReceiptWritePreviewed);
554        }
555        if row.changes.iter().any(|change| {
556            !matches!(
557                change,
558                crate::status::UpdateChange::ManifestEntryMissing { .. }
559            )
560        }) {
561            effects.push(LifecycleEffect::ResourceWritePreviewed);
562        }
563        let outcome = LifecycleOutcomeV1::new(
564            format!(
565                "shell/{}/{}",
566                row.category,
567                row.label.split('/').next_back().unwrap_or(&row.label)
568            ),
569            None::<String>,
570            if row.link_conflict {
571                LifecycleStatus::Conflict
572            } else if row.preset_missing {
573                LifecycleStatus::Preserved
574            } else if row.status_sym == "↑" {
575                LifecycleStatus::Pending
576            } else {
577                LifecycleStatus::Unchanged
578            },
579            if row.link_conflict {
580                vec![LifecycleEffect::UserResourcePreserved]
581            } else {
582                effects
583            },
584        );
585        result.push(if row.link_conflict {
586            outcome.with_diagnostic_code("shell_command_conflict")
587        } else if row.preset_missing {
588            outcome.with_diagnostic_code("shell_preset_missing")
589        } else {
590            outcome
591        });
592    }
593    Ok(result)
594}
595
596pub async fn handle_completion_install(config: &Config) -> Result<()> {
597    let completion = crate::core_runtime::from_config(config)
598        .await?
599        .install_shell_completion(false)
600        .await?;
601    let shell_config_path = get_shell_config_path(&config.shell_type, &config.home_dir)?;
602    let shell_update = completion.profile;
603    let profile_path = managed_shell_profile_path(config);
604
605    if shell_update.profile_updated {
606        output::detail_line(
607            "Shell Profile",
608            &style_green("updated"),
609            Some(profile_path.display().to_string()),
610        );
611    } else {
612        output::detail_line(
613            "Shell Profile",
614            &style_dim("up to date"),
615            Some(profile_path.display().to_string()),
616        );
617    }
618
619    match shell_update.config_status {
620        PathUpdateStatus::AlreadyConfigured => {
621            output::detail_line(
622                "Shell Config",
623                &style_dim("up to date"),
624                Some(shell_config_path.display().to_string()),
625            );
626        }
627        PathUpdateStatus::Updated(path) => {
628            output::detail_line(
629                "Shell Config",
630                &style_green("updated"),
631                Some(path.display().to_string()),
632            );
633        }
634    }
635
636    if !super::profile::supports_completion_registration(&config.shell_type) {
637        let shell: &'static str = config.shell_type.into();
638        output::detail_line(
639            "Completion",
640            &style_yellow("unsupported"),
641            Some(format!("{shell}; PATH setup was installed")),
642        );
643    }
644
645    output::hint_line(
646        "Next Step",
647        &format!(
648            "run `{}` once, or open a new shell",
649            shell_source_command(&config.shell_type, &shell_config_path)
650        ),
651    );
652    Ok(())
653}
654
655#[cfg(test)]
656mod tests {
657    use super::super::ShellType;
658    #[cfg(unix)]
659    use super::super::uninstall::handle_uninstall;
660    use super::*;
661    use crate::config::Config;
662    use std::path::PathBuf;
663    use tokio::fs;
664
665    #[test]
666    fn upgrade_section_hides_no_op_by_default_and_shows_verbose_or_changes() {
667        assert!(!should_print_upgrade_section(false, false, false, false));
668        assert!(should_print_upgrade_section(true, false, false, false));
669        assert!(should_print_upgrade_section(false, true, false, false));
670        assert!(should_print_upgrade_section(false, false, true, false));
671        assert!(should_print_upgrade_section(false, false, false, true));
672    }
673
674    #[test]
675    fn bin_link_summary_is_verbose_only_unless_there_is_a_conflict() {
676        assert!(!should_print_link_summary(false, 0));
677        assert!(should_print_link_summary(true, 0));
678        assert!(should_print_link_summary(false, 1));
679    }
680
681    async fn make_temp_dir() -> PathBuf {
682        crate::test_support::make_temp_dir("shine-shell").await
683    }
684
685    #[tokio::test]
686    async fn install_dry_run_does_not_materialize_shell_state() {
687        let dir = make_temp_dir().await;
688        let category = dir.join("presets/shell/custom");
689        fs::create_dir_all(&category).await.unwrap();
690        fs::write(
691            category.join("shine.toml"),
692            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"tool\"\n",
693        )
694        .await
695        .unwrap();
696        fs::write(category.join("tool.sh"), b"#!/bin/sh\necho tool\n")
697            .await
698            .unwrap();
699        let mut config = Config::new_for_test(&dir);
700        config.is_external_presets = true;
701
702        handle_install_dry_run(&config, Some("custom"))
703            .await
704            .unwrap();
705
706        assert!(!config.bin_dir().exists());
707        assert!(!config.shine_dir().join("installed/shell").exists());
708        assert!(!config.shine_dir().join("shell-manifest.toml").exists());
709        assert!(!config.home_dir.join(".zshrc").exists());
710        fs::remove_dir_all(&dir).await.unwrap();
711    }
712
713    #[tokio::test]
714    async fn command_scoped_install_activates_only_selected_command() {
715        let dir = make_temp_dir().await;
716        let config = Config::new_for_test(&dir);
717        fs::create_dir_all(config.bin_dir()).await.unwrap();
718
719        let lifecycle = handle_install_with_result(&config, Some("utils/shine-env-export"), false)
720            .await
721            .unwrap();
722
723        assert_eq!(lifecycle.outcomes.len(), 1);
724        assert_eq!(lifecycle.outcomes[0].target, "shell/utils/shine-env-export");
725        assert_eq!(lifecycle.outcomes[0].status, LifecycleStatus::Changed);
726
727        let selected = crate::bin_links::command_path_for_name(
728            config.bin_dir(),
729            std::ffi::OsStr::new("shine-env-export"),
730        );
731        let sibling = crate::bin_links::command_path_for_name(
732            config.bin_dir(),
733            std::ffi::OsStr::new("shine-theme-sync"),
734        );
735        assert!(selected.exists());
736        assert!(!sibling.exists());
737
738        let manifest =
739            crate::shells::deployment::ShellManifest::load(&shine_core::runtime::RealHost, &config)
740                .await
741                .unwrap();
742        assert!(manifest.find("shell/utils/shine-env-export").is_some());
743        assert!(manifest.find("shell/utils/shine-theme-sync").is_none());
744
745        let rows = crate::status::build_shell_rows(&config).await.unwrap();
746        let selected_row = rows
747            .iter()
748            .find(|row| row.label == "utils/shine-env-export")
749            .unwrap();
750        let sibling_row = rows
751            .iter()
752            .find(|row| row.label == "utils/shine-theme-sync")
753            .unwrap();
754        assert!(selected_row.is_installed);
755        assert!(!sibling_row.is_installed);
756        assert_eq!(sibling_row.status_text, "not installed");
757
758        fs::remove_dir_all(&dir).await.unwrap();
759    }
760
761    #[tokio::test]
762    async fn command_scoped_install_preserves_sibling_manifest_entries() {
763        let dir = make_temp_dir().await;
764        let config = Config::new_for_test(&dir);
765        fs::create_dir_all(config.bin_dir()).await.unwrap();
766
767        handle_install(&config, Some("utils/shine-env-export"), false)
768            .await
769            .unwrap();
770        handle_install(&config, Some("utils/shine-theme-sync"), false)
771            .await
772            .unwrap();
773
774        let manifest =
775            crate::shells::deployment::ShellManifest::load(&shine_core::runtime::RealHost, &config)
776                .await
777                .unwrap();
778        assert!(manifest.find("shell/utils/shine-env-export").is_some());
779        assert!(manifest.find("shell/utils/shine-theme-sync").is_some());
780
781        fs::remove_dir_all(&dir).await.unwrap();
782    }
783
784    #[tokio::test]
785    async fn command_scoped_install_rejects_unknown_targets_before_writing() {
786        let dir = make_temp_dir().await;
787        let config = Config::new_for_test(&dir);
788
789        let error = handle_install(&config, Some("utils/not-a-command"), false)
790            .await
791            .unwrap_err()
792            .to_string();
793
794        assert!(error.contains("not-a-command"), "{error}");
795        assert!(!config.bin_dir().exists());
796        assert!(!config.presets_dir().join("shell/utils").exists());
797
798        fs::remove_dir_all(&dir).await.unwrap();
799    }
800
801    #[tokio::test]
802    async fn future_manifest_rejects_install_before_shell_mutation() {
803        let dir = make_temp_dir().await;
804        let config = Config::new_for_test(&dir);
805        fs::write(
806            config.shine_dir().join("shell-manifest.toml"),
807            "schema_version = 2\nentries = []\n",
808        )
809        .await
810        .unwrap();
811
812        let error = handle_install(&config, Some("utils/shine-env-export"), false)
813            .await
814            .unwrap_err();
815
816        assert!(error.to_string().contains("newer than this Shine supports"));
817        assert!(!config.presets_dir().join("shell/utils").exists());
818        assert!(!config.bin_dir().exists());
819        assert!(!config.home_dir.join(".zshrc").exists());
820        fs::remove_dir_all(&dir).await.unwrap();
821    }
822
823    #[tokio::test]
824    async fn category_upgrade_repairs_only_installed_commands() {
825        let dir = make_temp_dir().await;
826        let config = Config::new_for_test(&dir);
827        fs::create_dir_all(config.bin_dir()).await.unwrap();
828        handle_install(&config, Some("utils/shine-env-export"), false)
829            .await
830            .unwrap();
831        let selected = crate::bin_links::command_path_for_name(
832            config.bin_dir(),
833            std::ffi::OsStr::new("shine-env-export"),
834        );
835        let sibling = crate::bin_links::command_path_for_name(
836            config.bin_dir(),
837            std::ffi::OsStr::new("shine-theme-sync"),
838        );
839        shine_core::runtime::unlink_managed_command_with_host(
840            &shine_core::runtime::RealHost,
841            config.bin_dir(),
842            std::ffi::OsStr::new("shine-env-export"),
843            &[config.presets_dir().join("shell/utils")],
844            false,
845        )
846        .await
847        .unwrap();
848
849        let pending = collect_update_lifecycle_result(&config).await.unwrap();
850        let selected_pending = pending
851            .outcomes
852            .iter()
853            .find(|outcome| outcome.target == "shell/utils/shine-env-export")
854            .unwrap();
855        assert_eq!(selected_pending.status, LifecycleStatus::Pending);
856        assert!(
857            selected_pending
858                .effects
859                .contains(&LifecycleEffect::ResourceWritePreviewed)
860        );
861
862        let mut separator = crate::output::SectionSeparator::new();
863        handle_upgrade_installed_target(&config, Some("utils"), false, &mut separator)
864            .await
865            .unwrap();
866
867        assert!(selected.exists());
868        assert!(!sibling.exists());
869
870        fs::remove_dir_all(&dir).await.unwrap();
871    }
872
873    #[tokio::test]
874    async fn external_snapshot_is_shared_but_only_selected_command_is_installed() {
875        let dir = make_temp_dir().await;
876        let mut config = Config::new_for_test(&dir);
877        config.is_external_presets = true;
878        let source_extension = if config.shell_type == ShellType::PowerShell {
879            "ps1"
880        } else {
881            "sh"
882        };
883        let first_source = format!("one.{source_extension}");
884        let second_source = format!("two.{source_extension}");
885        let category = dir.join("presets/shell/custom");
886        fs::create_dir_all(&category).await.unwrap();
887        fs::write(
888            category.join("shine.toml"),
889            format!(
890                "[[files]]\nsource = \"{first_source}\"\ntarget = \"one\"\n[files.permissions]\nschema_version = 1\n\n[[files]]\nsource = \"{second_source}\"\ntarget = \"two\"\n[files.permissions]\nschema_version = 1\n"
891            ),
892        )
893        .await
894        .unwrap();
895        fs::write(category.join(&first_source), b"echo one\n")
896            .await
897            .unwrap();
898        fs::write(category.join(&second_source), b"echo two\n")
899            .await
900            .unwrap();
901        fs::create_dir_all(config.bin_dir()).await.unwrap();
902
903        handle_install(&config, Some("custom/one"), false)
904            .await
905            .unwrap();
906
907        assert!(
908            config
909                .installed_shell_dir()
910                .join("custom")
911                .join(first_source)
912                .exists()
913        );
914        assert!(
915            config
916                .installed_shell_dir()
917                .join("custom")
918                .join(second_source)
919                .exists()
920        );
921        assert!(
922            crate::bin_links::command_path_for_name(config.bin_dir(), std::ffi::OsStr::new("one"),)
923                .exists()
924        );
925        assert!(
926            !crate::bin_links::command_path_for_name(
927                config.bin_dir(),
928                std::ffi::OsStr::new("two"),
929            )
930            .exists()
931        );
932        let rows = crate::status::build_shell_rows(&config).await.unwrap();
933        let sibling = rows.iter().find(|row| row.label == "custom/two").unwrap();
934        assert!(!sibling.is_installed);
935        assert_eq!(sibling.status_text, "not installed");
936        assert!(sibling.changes.is_empty());
937
938        fs::remove_dir_all(&dir).await.unwrap();
939    }
940
941    #[cfg(unix)]
942    #[tokio::test]
943    async fn structured_snapshot_lifecycle_covers_update_upgrade_and_uninstall() {
944        let dir = make_temp_dir().await;
945        let category = dir.join("presets/shell/custom");
946        fs::create_dir_all(&category).await.unwrap();
947        fs::write(
948            category.join("shine.toml"),
949            b"[[files]]\nsource = \"one.sh\"\ntarget = \"one\"\n[files.permissions]\nschema_version = 1\n\n[[files]]\nsource = \"two.sh\"\ntarget = \"two\"\n[files.permissions]\nschema_version = 1\n",
950        )
951        .await
952        .unwrap();
953        fs::write(category.join("one.sh"), b"#!/bin/sh\necho one\n")
954            .await
955            .unwrap();
956        fs::write(category.join("two.sh"), b"#!/bin/sh\necho two\n")
957            .await
958            .unwrap();
959        let mut config = Config::new_for_test(&dir);
960        config.is_external_presets = true;
961        fs::create_dir_all(config.bin_dir()).await.unwrap();
962
963        let install = handle_install_with_result(&config, Some("custom/one"), false)
964            .await
965            .unwrap();
966        assert!(install.outcomes.iter().any(|outcome| {
967            outcome.target == "shell/custom/one" && outcome.status == LifecycleStatus::Changed
968        }));
969        let sibling =
970            crate::bin_links::command_path_for_name(config.bin_dir(), std::ffi::OsStr::new("two"));
971        assert!(!sibling.exists());
972        assert!(config.installed_shell_dir().join("custom/two.sh").exists());
973
974        fs::write(category.join("one.sh"), b"#!/bin/sh\necho updated\n")
975            .await
976            .unwrap();
977        let update = collect_update_lifecycle_result(&config).await.unwrap();
978        let pending = update
979            .outcomes
980            .iter()
981            .find(|outcome| outcome.target == "shell/custom/one")
982            .unwrap();
983        assert_eq!(pending.status, LifecycleStatus::Pending);
984        assert!(
985            pending
986                .effects
987                .contains(&LifecycleEffect::CacheWritePreviewed)
988        );
989
990        let mut separator = crate::output::SectionSeparator::new();
991        let (report, upgrade) = handle_upgrade_installed_target_with_result(
992            &config,
993            Some("custom"),
994            false,
995            &mut separator,
996        )
997        .await
998        .unwrap();
999        assert_eq!(report.updated_targets, ["custom/one"]);
1000        assert!(upgrade.outcomes.iter().any(|outcome| {
1001            outcome.target == "shell/custom/one" && outcome.status == LifecycleStatus::Changed
1002        }));
1003        assert!(!sibling.exists());
1004        assert_eq!(
1005            fs::read(category.join("one.sh")).await.unwrap(),
1006            b"#!/bin/sh\necho updated\n"
1007        );
1008
1009        let current = collect_update_lifecycle_result(&config).await.unwrap();
1010        assert!(current.outcomes.iter().any(|outcome| {
1011            outcome.target == "shell/custom/one" && outcome.status == LifecycleStatus::Unchanged
1012        }));
1013
1014        let uninstall = super::super::uninstall::handle_uninstall_with_result(
1015            &config,
1016            Some("custom/one"),
1017            false,
1018            false,
1019        )
1020        .await
1021        .unwrap();
1022        assert!(uninstall.outcomes.iter().any(|outcome| {
1023            outcome.target == "shell/custom/one" && outcome.status == LifecycleStatus::Changed
1024        }));
1025        assert!(!config.installed_shell_dir().join("custom").exists());
1026        assert!(category.join("one.sh").exists());
1027        assert!(category.join("two.sh").exists());
1028        assert!(!sibling.exists());
1029
1030        fs::remove_dir_all(&dir).await.unwrap();
1031    }
1032
1033    #[cfg(unix)]
1034    async fn make_executable(path: &Path) {
1035        use std::os::unix::fs::PermissionsExt;
1036        let mut perms = fs::metadata(path).await.unwrap().permissions();
1037        perms.set_mode(perms.mode() | 0o111);
1038        fs::set_permissions(path, perms).await.unwrap();
1039    }
1040
1041    fn wrapper_marker(command: &str, shell: &ShellType) -> String {
1042        match shell {
1043            ShellType::PowerShell => format!("\nfunction {command} {{ . (Join-Path $shineBin"),
1044            ShellType::Fish => format!("\nfunction {command}"),
1045            _ => format!("\n{command}() {{ source"),
1046        }
1047    }
1048
1049    #[cfg(unix)]
1050    fn managed_profile_source_marker(shell: &ShellType) -> &'static str {
1051        match shell {
1052            ShellType::PowerShell => ". (Join-Path $HOME 'shell/profile.ps1')",
1053            ShellType::Fish => "source \"$HOME/shell/config.fish\"",
1054            ShellType::Bash | ShellType::Zsh | ShellType::Elvish => {
1055                "source \"$HOME/shell/profile.sh\""
1056            }
1057        }
1058    }
1059
1060    #[cfg(unix)]
1061    fn managed_profile_path_marker(shell: &ShellType) -> &'static str {
1062        match shell {
1063            ShellType::PowerShell => "$shinePathEntries",
1064            ShellType::Fish => "fish_add_path",
1065            ShellType::Bash | ShellType::Zsh | ShellType::Elvish => "export PATH",
1066        }
1067    }
1068
1069    #[cfg(unix)]
1070    #[tokio::test]
1071    async fn install_then_uninstall_roundtrip() {
1072        let dir = make_temp_dir().await;
1073        let config = Config::new_for_test(&dir);
1074        fs::create_dir_all(config.presets_dir()).await.unwrap();
1075        fs::create_dir_all(config.bin_dir()).await.unwrap();
1076
1077        handle_install(&config, None, false).await.unwrap();
1078        assert!(
1079            config
1080                .presets_dir()
1081                .join("shell/proxy/set_proxy.sh")
1082                .exists(),
1083            "preset should exist after install"
1084        );
1085        let first_bin_entry = fs::read_dir(config.bin_dir())
1086            .await
1087            .unwrap()
1088            .next_entry()
1089            .await
1090            .unwrap();
1091        assert!(
1092            first_bin_entry.is_some(),
1093            "bin dir should have symlinks after install"
1094        );
1095        // symlinks use stem names (no .sh suffix)
1096        assert!(
1097            config.bin_dir().join("setproxy").exists(),
1098            "bin link should use configured rename"
1099        );
1100        assert!(!config.bin_dir().join("set_proxy").exists());
1101        assert!(
1102            managed_shell_profile_path(&config).exists(),
1103            "managed shell profile should exist after install"
1104        );
1105
1106        handle_uninstall(&config, None, false, false).await.unwrap();
1107        assert!(
1108            !config
1109                .presets_dir()
1110                .join("shell/proxy/set_proxy.sh")
1111                .exists(),
1112            "preset should be gone after uninstall"
1113        );
1114        let mut rd = fs::read_dir(config.bin_dir()).await.unwrap();
1115        assert!(
1116            rd.next_entry().await.unwrap().is_none(),
1117            "bin dir should be empty after uninstall"
1118        );
1119        assert!(
1120            !managed_shell_profile_path(&config).exists(),
1121            "managed shell profile should be removed after full uninstall"
1122        );
1123
1124        // Idempotency: second uninstall must not error
1125        handle_uninstall(&config, None, false, false).await.unwrap();
1126
1127        fs::remove_dir_all(&dir).await.unwrap();
1128    }
1129
1130    #[tokio::test]
1131    async fn append_writes_snippet_to_shell_config() {
1132        let dir = make_temp_dir().await;
1133        let config = Config::new_for_test(&dir);
1134
1135        append_path_to_shell_config(&config, false, &[])
1136            .await
1137            .unwrap();
1138
1139        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
1140        let content = fs::read_to_string(&config_path).await.unwrap();
1141        assert!(
1142            content.contains(super::super::SENTINEL_START),
1143            "sentinel should be present"
1144        );
1145    }
1146
1147    #[tokio::test]
1148    async fn completion_install_updates_profile_without_installing_presets() {
1149        let dir = make_temp_dir().await;
1150        let config = Config::new_for_test(&dir);
1151
1152        handle_completion_install(&config).await.unwrap();
1153
1154        let profile = fs::read_to_string(managed_shell_profile_path(&config))
1155            .await
1156            .unwrap();
1157        let completion_marker = match config.shell_type {
1158            ShellType::Bash => "COMPLETE=bash shine",
1159            ShellType::Zsh => "COMPLETE=zsh shine",
1160            ShellType::PowerShell => "$env:COMPLETE = 'powershell'",
1161            ShellType::Fish | ShellType::Elvish => {
1162                panic!("native default shell should support completion registration")
1163            }
1164        };
1165        assert!(
1166            profile.contains(completion_marker),
1167            "profile should register shine completion: {profile}"
1168        );
1169        assert!(
1170            !config.presets_dir().join("shell/proxy").exists(),
1171            "completion install must not extract or install shell presets"
1172        );
1173    }
1174
1175    #[tokio::test]
1176    async fn append_is_idempotent() {
1177        let dir = make_temp_dir().await;
1178        let config = Config::new_for_test(&dir);
1179
1180        append_path_to_shell_config(&config, false, &[])
1181            .await
1182            .unwrap();
1183        append_path_to_shell_config(&config, false, &[])
1184            .await
1185            .unwrap();
1186
1187        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
1188        let content = fs::read_to_string(&config_path).await.unwrap();
1189        let count = content.matches(super::super::SENTINEL_START).count();
1190        assert_eq!(count, 1, "sentinel should appear exactly once");
1191    }
1192
1193    #[tokio::test]
1194    async fn append_is_idempotent_with_source_wrappers() {
1195        let dir = make_temp_dir().await;
1196        let config = Config::new_for_test(&dir);
1197        let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
1198
1199        append_path_to_shell_config(&config, false, &source_commands)
1200            .await
1201            .unwrap();
1202        append_path_to_shell_config(&config, false, &source_commands)
1203            .await
1204            .unwrap();
1205
1206        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
1207        let content = fs::read_to_string(&config_path).await.unwrap();
1208        assert_eq!(
1209            content.matches(super::super::SENTINEL_START).count(),
1210            1,
1211            "sentinel should appear exactly once"
1212        );
1213        assert!(
1214            !content.contains("setproxy()"),
1215            "source wrappers should live in the managed profile: {content}"
1216        );
1217
1218        let profile_path = managed_shell_profile_path(&config);
1219        let profile = fs::read_to_string(&profile_path).await.unwrap();
1220        let setproxy_marker = wrapper_marker("setproxy", &config.shell_type);
1221        let usetproxy_marker = wrapper_marker("usetproxy", &config.shell_type);
1222        assert_eq!(
1223            profile.matches(&setproxy_marker).count(),
1224            1,
1225            "setproxy wrapper should not be duplicated: {content}"
1226        );
1227        assert_eq!(
1228            profile.matches(&usetproxy_marker).count(),
1229            1,
1230            "usetproxy wrapper should not be duplicated: {content}"
1231        );
1232
1233        fs::remove_dir_all(&dir).await.unwrap();
1234    }
1235
1236    #[cfg(unix)]
1237    #[tokio::test]
1238    async fn append_writes_source_entry_and_managed_profile() {
1239        let dir = make_temp_dir().await;
1240        let config = Config::new_for_test(&dir);
1241        let source_commands = vec!["setproxy".to_string()];
1242
1243        append_path_to_shell_config(&config, false, &source_commands)
1244            .await
1245            .unwrap();
1246
1247        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
1248        let content = fs::read_to_string(&config_path).await.unwrap();
1249        assert!(
1250            content.contains(managed_profile_source_marker(&config.shell_type)),
1251            "shell config should only source managed profile: {content}"
1252        );
1253        assert!(
1254            !content.contains("export PATH"),
1255            "shell config should not contain direct PATH setup: {content}"
1256        );
1257        assert!(
1258            !content.contains("setproxy()"),
1259            "shell config should not contain direct wrapper functions: {content}"
1260        );
1261
1262        let profile = fs::read_to_string(managed_shell_profile_path(&config))
1263            .await
1264            .unwrap();
1265        assert!(
1266            profile.contains(managed_profile_path_marker(&config.shell_type)),
1267            "managed profile should contain PATH setup: {profile}"
1268        );
1269        assert!(
1270            profile.contains(&wrapper_marker("setproxy", &config.shell_type)),
1271            "managed profile should contain source wrapper: {profile}"
1272        );
1273
1274        fs::remove_dir_all(&dir).await.unwrap();
1275    }
1276
1277    #[cfg(windows)]
1278    #[tokio::test]
1279    async fn append_writes_both_windows_powershell_profiles() {
1280        let dir = make_temp_dir().await;
1281        let mut config = Config::new_for_test(&dir);
1282        config.shell_type = ShellType::PowerShell;
1283        let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
1284
1285        append_path_to_shell_config(&config, false, &source_commands)
1286            .await
1287            .unwrap();
1288
1289        let profile = fs::read_to_string(managed_shell_profile_path(&config))
1290            .await
1291            .unwrap();
1292        for config_path in
1293            super::super::get_shell_config_paths(&config.shell_type, &config.home_dir).unwrap()
1294        {
1295            let content = fs::read_to_string(&config_path).await.unwrap();
1296            assert!(
1297                content.contains(". (Join-Path $HOME 'shell/profile.ps1')"),
1298                "PowerShell profile should source managed shine profile from {}: {content}",
1299                config_path.display()
1300            );
1301        }
1302        assert!(
1303            profile.contains("function setproxy"),
1304            "managed PowerShell profile should contain setproxy wrapper: {profile}"
1305        );
1306        assert!(
1307            profile.contains("function usetproxy"),
1308            "managed PowerShell profile should contain usetproxy wrapper: {profile}"
1309        );
1310
1311        fs::remove_dir_all(&dir).await.unwrap();
1312    }
1313
1314    #[cfg(unix)]
1315    #[tokio::test]
1316    async fn append_refreshes_stale_sentinel_with_managed_profile_source() {
1317        let dir = make_temp_dir().await;
1318        let config = Config::new_for_test(&dir);
1319        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
1320        if let Some(parent) = config_path.parent() {
1321            fs::create_dir_all(parent).await.unwrap();
1322        }
1323        let sentinel_start = super::super::SENTINEL_START;
1324        let sentinel_end = "# <<< shine <<<";
1325        fs::write(
1326            &config_path,
1327            format!(
1328                "before\n\n{sentinel_start}\nif [[ \":$PATH:\" != *\":$HOME/.shine/bin:\"* ]]; then\n  export PATH=\"$HOME/.shine/bin:$PATH\"\nfi\n{sentinel_end}\nafter\n"
1329            ),
1330        )
1331        .await
1332        .unwrap();
1333
1334        let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
1335        let update = append_path_to_shell_config(&config, false, &source_commands)
1336            .await
1337            .unwrap();
1338
1339        assert!(
1340            matches!(update.config_status, PathUpdateStatus::Updated(_)),
1341            "stale sentinel should be refreshed"
1342        );
1343        let content = fs::read_to_string(&config_path).await.unwrap();
1344        assert!(
1345            content.contains(managed_profile_source_marker(&config.shell_type)),
1346            "shell config should source managed profile: {content}"
1347        );
1348        assert!(
1349            !content.contains("export PATH"),
1350            "stale PATH setup should be removed from shell config: {content}"
1351        );
1352        assert!(
1353            !content.contains("setproxy()"),
1354            "source wrappers should not be added directly to shell config: {content}"
1355        );
1356        assert!(
1357            content.contains("before"),
1358            "non-managed content should be preserved"
1359        );
1360        assert!(
1361            content.contains("after"),
1362            "non-managed content should be preserved"
1363        );
1364        let profile = fs::read_to_string(managed_shell_profile_path(&config))
1365            .await
1366            .unwrap();
1367        let setproxy_marker = wrapper_marker("setproxy", &config.shell_type);
1368        let usetproxy_marker = wrapper_marker("usetproxy", &config.shell_type);
1369        assert!(
1370            profile.contains(&setproxy_marker),
1371            "setproxy wrapper should be added to managed profile: {profile}"
1372        );
1373        assert!(
1374            profile.contains(&usetproxy_marker),
1375            "usetproxy wrapper should be added to managed profile: {profile}"
1376        );
1377
1378        fs::remove_dir_all(&dir).await.unwrap();
1379    }
1380
1381    #[tokio::test]
1382    async fn installed_source_commands_for_categories_are_scoped() {
1383        let dir = make_temp_dir().await;
1384        let config = Config::new_for_test(&dir);
1385        fs::create_dir_all(config.presets_dir()).await.unwrap();
1386        fs::create_dir_all(config.bin_dir()).await.unwrap();
1387
1388        handle_install(&config, Some("agent"), false).await.unwrap();
1389        handle_install(&config, Some("proxy"), false).await.unwrap();
1390
1391        let commands = crate::core_runtime::from_config(&config)
1392            .await
1393            .unwrap()
1394            .installed_shell_source_commands(Some("proxy"))
1395            .await
1396            .unwrap();
1397
1398        assert_eq!(
1399            commands,
1400            vec!["setproxy".to_string(), "usetproxy".to_string()]
1401        );
1402        assert!(!commands.contains(&"ccenv".to_string()));
1403
1404        fs::remove_dir_all(&dir).await.unwrap();
1405    }
1406
1407    #[cfg(unix)]
1408    #[tokio::test]
1409    async fn external_presets_install_links_disk_scripts_without_extraction() {
1410        let dir = make_temp_dir().await;
1411        // new_for_test sets presets_dir = dir/presets, bin_dir = dir/bin
1412        // Create a script in presets_dir/shell/custom/ to simulate user-managed presets.
1413        let cat_dir = dir.join("presets/shell/custom");
1414        fs::create_dir_all(&cat_dir).await.unwrap();
1415        let script = cat_dir.join("my_tool.sh");
1416        fs::write(&script, b"#!/bin/bash\n# My tool.\necho hi\n")
1417            .await
1418            .unwrap();
1419        fs::write(
1420            cat_dir.join("shine.toml"),
1421            b"[[files]]\nsource = \"my_tool.sh\"\ntarget = \"my_tool\"\n[files.permissions]\nschema_version = 1\n",
1422        )
1423        .await
1424        .unwrap();
1425        use std::os::unix::fs::PermissionsExt;
1426        let mut perms = fs::metadata(&script).await.unwrap().permissions();
1427        perms.set_mode(perms.mode() | 0o111);
1428        fs::set_permissions(&script, perms).await.unwrap();
1429
1430        let mut config = Config::new_for_test(&dir);
1431        config.is_external_presets = true;
1432        fs::create_dir_all(config.bin_dir()).await.unwrap();
1433
1434        handle_install(&config, Some("custom"), false)
1435            .await
1436            .unwrap();
1437
1438        // The script must NOT have been extracted from embedded assets into
1439        // presets_dir — only the user script and its metadata are present.
1440        let count = {
1441            let mut rd = fs::read_dir(&cat_dir).await.unwrap();
1442            let mut n = 0u32;
1443            while rd.next_entry().await.unwrap().is_some() {
1444                n += 1;
1445            }
1446            n
1447        };
1448        assert_eq!(count, 2, "no embedded assets should have been extracted");
1449
1450        // A bin symlink for the script should have been created.
1451        let link = config.bin_dir().join("my_tool");
1452        assert!(link.exists(), "bin symlink should point at disk script");
1453
1454        fs::remove_dir_all(&dir).await.unwrap();
1455    }
1456
1457    #[cfg(unix)]
1458    #[tokio::test]
1459    async fn external_presets_install_applies_metadata_rename() {
1460        let dir = make_temp_dir().await;
1461        let cat_dir = dir.join("presets/shell/custom");
1462        fs::create_dir_all(&cat_dir).await.unwrap();
1463        fs::write(
1464            cat_dir.join("shine.toml"),
1465            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\n[files.permissions]\nschema_version = 1\n",
1466        )
1467        .await
1468        .unwrap();
1469        let script = cat_dir.join("set_proxy.sh");
1470        fs::write(&script, b"#!/bin/bash\n# Set proxy.\necho hi\n")
1471            .await
1472            .unwrap();
1473        use std::os::unix::fs::PermissionsExt;
1474        let mut perms = fs::metadata(&script).await.unwrap().permissions();
1475        perms.set_mode(perms.mode() | 0o111);
1476        fs::set_permissions(&script, perms).await.unwrap();
1477
1478        let mut config = Config::new_for_test(&dir);
1479        config.is_external_presets = true;
1480        fs::create_dir_all(config.bin_dir()).await.unwrap();
1481
1482        handle_install(&config, Some("custom"), false)
1483            .await
1484            .unwrap();
1485
1486        assert!(config.bin_dir().join("setproxy").exists());
1487        assert!(!config.bin_dir().join("set_proxy").exists());
1488
1489        fs::remove_dir_all(&dir).await.unwrap();
1490    }
1491
1492    #[cfg(unix)]
1493    #[tokio::test]
1494    async fn external_presets_install_links_non_executable_source_scripts() {
1495        let dir = make_temp_dir().await;
1496        let cat_dir = dir.join("presets/shell/proxy");
1497        fs::create_dir_all(&cat_dir).await.unwrap();
1498        fs::write(
1499            cat_dir.join("shine.toml"),
1500            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n[files.permissions]\nschema_version = 1\n[[files]]\nsource = \"uset_proxy.sh\"\ntarget = \"usetproxy\"\nneeds_source = true\n[files.permissions]\nschema_version = 1\n",
1501        )
1502        .await
1503        .unwrap();
1504        fs::write(
1505            &cat_dir.join("set_proxy.sh"),
1506            b"#!/bin/bash\n# Set proxy.\n",
1507        )
1508        .await
1509        .unwrap();
1510        fs::write(
1511            &cat_dir.join("uset_proxy.sh"),
1512            b"#!/bin/bash\n# Unset proxy.\n",
1513        )
1514        .await
1515        .unwrap();
1516
1517        let mut config = Config::new_for_test(&dir);
1518        config.is_external_presets = true;
1519        fs::create_dir_all(config.bin_dir()).await.unwrap();
1520
1521        handle_install(&config, Some("proxy"), false).await.unwrap();
1522
1523        assert!(config.bin_dir().join("setproxy").exists());
1524        assert!(config.bin_dir().join("usetproxy").exists());
1525
1526        fs::remove_dir_all(&dir).await.unwrap();
1527    }
1528
1529    #[tokio::test]
1530    async fn init_template_creates_parseable_shell_metadata() {
1531        let dir = make_temp_dir().await;
1532        let cat_dir = dir.join("presets/shell/custom");
1533        fs::create_dir_all(&cat_dir).await.unwrap();
1534
1535        let (path, overwritten) =
1536            shine_core::init_template::write_shine_toml_template(&cat_dir, false, SHELL_TEMPLATE)
1537                .unwrap();
1538        fs::write(
1539            cat_dir.join("my_tool.sh"),
1540            b"#!/bin/bash\n# My tool.\necho hi\n",
1541        )
1542        .await
1543        .unwrap();
1544
1545        let mut config = Config::new_for_test(&dir);
1546        config.shell_type = ShellType::Zsh;
1547        let categories = metadata::load_installed_categories(&config, Some("custom"))
1548            .await
1549            .unwrap();
1550
1551        assert_eq!(path, cat_dir.join("shine.toml"));
1552        assert!(!overwritten);
1553        assert_eq!(categories.len(), 1);
1554        assert_eq!(
1555            categories[0].description.as_deref(),
1556            Some("My shell helper commands.")
1557        );
1558        assert_eq!(
1559            categories[0].files[0].source_rel,
1560            PathBuf::from("my_tool.sh")
1561        );
1562        assert_eq!(categories[0].files[0].command_name, "mytool");
1563        assert!(!categories[0].files[0].needs_source);
1564        assert_eq!(
1565            categories[0].files[0]
1566                .permissions
1567                .as_ref()
1568                .map(|permissions| permissions.schema_version),
1569            Some(1)
1570        );
1571
1572        fs::remove_dir_all(&dir).await.unwrap();
1573    }
1574
1575    #[tokio::test]
1576    async fn init_template_refuses_existing_file_unless_forced() {
1577        let dir = make_temp_dir().await;
1578        fs::write(dir.join("shine.toml"), b"old").await.unwrap();
1579
1580        let err = shine_core::init_template::write_shine_toml_template(&dir, false, SHELL_TEMPLATE)
1581            .unwrap_err();
1582        assert!(
1583            err.to_string().contains("use --force to overwrite"),
1584            "unexpected error: {err:#}"
1585        );
1586        assert_eq!(fs::read(dir.join("shine.toml")).await.unwrap(), b"old");
1587
1588        let (_path, overwritten) =
1589            shine_core::init_template::write_shine_toml_template(&dir, true, SHELL_TEMPLATE)
1590                .unwrap();
1591        assert!(overwritten);
1592        let content = fs::read_to_string(dir.join("shine.toml")).await.unwrap();
1593        assert!(content.contains("target = \"mytool\""));
1594
1595        fs::remove_dir_all(&dir).await.unwrap();
1596    }
1597
1598    #[cfg(unix)]
1599    #[tokio::test]
1600    async fn template_render_error_does_not_link_raw_script() {
1601        let dir = make_temp_dir().await;
1602        let cat_dir = dir.join("presets/shell/proxy");
1603        fs::create_dir_all(&cat_dir).await.unwrap();
1604        fs::write(
1605            cat_dir.join("shine.toml"),
1606            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n[files.permissions]\nschema_version = 1\n",
1607        )
1608        .await
1609        .unwrap();
1610        let script = cat_dir.join("set_proxy.sh");
1611        fs::write(
1612            &script,
1613            b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
1614        )
1615        .await
1616        .unwrap();
1617        make_executable(&script).await;
1618
1619        let mut config = Config::new_for_test(&dir);
1620        config.is_external_presets = true;
1621        fs::create_dir_all(config.bin_dir()).await.unwrap();
1622        fs::write(config.rendered_dir(), b"not a directory")
1623            .await
1624            .unwrap();
1625
1626        let err = handle_install(&config, Some("proxy"), false)
1627            .await
1628            .expect_err("install should fail when rendered_dir cannot be created");
1629
1630        assert!(
1631            err.to_string()
1632                .contains("creating rendered script directory"),
1633            "unexpected error: {err:#}"
1634        );
1635        assert!(
1636            !config.bin_dir().join("setproxy").exists(),
1637            "failed render must not link the raw template script"
1638        );
1639
1640        fs::remove_dir_all(&dir).await.unwrap();
1641    }
1642
1643    #[tokio::test]
1644    async fn embedded_agent_installs_bun_launcher_without_rendering_credentials() {
1645        let dir = make_temp_dir().await;
1646        let config = Config::new_for_test(&dir);
1647        fs::create_dir_all(config.presets_dir()).await.unwrap();
1648        fs::create_dir_all(config.bin_dir()).await.unwrap();
1649
1650        handle_install(&config, Some("agent"), false).await.unwrap();
1651
1652        let source = config.presets_dir().join("shell/agent/cc.ts");
1653        assert!(source.exists());
1654        assert!(!config.rendered_dir().join("shell/agent/cc.ts").exists());
1655        let launcher = crate::bin_links::command_path_for_name(
1656            config.bin_dir(),
1657            std::ffi::OsStr::new("ccenv"),
1658        );
1659        let launcher_content = fs::read_to_string(&launcher).await.unwrap();
1660        assert!(launcher_content.contains("shine-managed"));
1661        let recorded_target = launcher_content
1662            .lines()
1663            .find_map(|line| line.strip_prefix("# shine-target: "))
1664            .expect("launcher should record its source target");
1665        assert_eq!(
1666            fs::canonicalize(recorded_target).await.unwrap(),
1667            fs::canonicalize(&source).await.unwrap()
1668        );
1669        assert!(launcher_content.contains("bun"));
1670
1671        let source_commands = crate::core_runtime::from_config(&config)
1672            .await
1673            .unwrap()
1674            .installed_shell_source_commands(None)
1675            .await
1676            .unwrap();
1677        assert!(!source_commands.contains(&"ccenv".to_string()));
1678
1679        fs::remove_dir_all(&dir).await.unwrap();
1680    }
1681
1682    #[cfg(unix)]
1683    #[tokio::test]
1684    async fn embedded_source_and_link_upgrade_report_target_once() {
1685        let dir = make_temp_dir().await;
1686        let config = Config::new_for_test(&dir);
1687        fs::create_dir_all(config.presets_dir()).await.unwrap();
1688        fs::create_dir_all(config.bin_dir()).await.unwrap();
1689        handle_install(&config, Some("utils"), false).await.unwrap();
1690
1691        let source = config.presets_dir().join("shell/utils/copyfile.sh");
1692        fs::write(&source, b"#!/bin/sh\necho stale\n")
1693            .await
1694            .unwrap();
1695        make_executable(&source).await;
1696
1697        let stale_source = dir.join("stale-copyfile.sh");
1698        fs::write(&stale_source, b"#!/bin/sh\necho stale link\n")
1699            .await
1700            .unwrap();
1701        make_executable(&stale_source).await;
1702        let link = config.bin_dir().join("copyfile");
1703        fs::remove_file(&link).await.unwrap();
1704        fs::symlink(&stale_source, &link).await.unwrap();
1705
1706        let mut separator = crate::output::SectionSeparator::new();
1707        let report = handle_upgrade_installed(&config, false, &mut separator)
1708            .await
1709            .unwrap();
1710
1711        assert_eq!(report.updated_targets, vec!["utils/copyfile"]);
1712        assert_eq!(report.updated_categories, vec!["utils"]);
1713        assert_eq!(report.links_updated, 1);
1714        assert_eq!(fs::read_link(&link).await.unwrap(), source);
1715        fs::remove_dir_all(&dir).await.unwrap();
1716    }
1717
1718    #[cfg(unix)]
1719    #[tokio::test]
1720    async fn external_presets_upgrade_does_not_install_preset_only_scripts() {
1721        let dir = make_temp_dir().await;
1722        let proxy_dir = dir.join("presets/shell/proxy");
1723        let extra_dir = dir.join("presets/shell/extra");
1724        fs::create_dir_all(&proxy_dir).await.unwrap();
1725        fs::create_dir_all(&extra_dir).await.unwrap();
1726
1727        fs::write(
1728            proxy_dir.join("shine.toml"),
1729            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n[files.permissions]\nschema_version = 1\n",
1730        )
1731        .await
1732        .unwrap();
1733        let setproxy = proxy_dir.join("set_proxy.sh");
1734        fs::write(
1735            &setproxy,
1736            b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
1737        )
1738        .await
1739        .unwrap();
1740        make_executable(&setproxy).await;
1741
1742        let extra_tool = extra_dir.join("extra_tool.sh");
1743        fs::write(&extra_tool, b"#!/bin/bash\n# Extra tool.\necho extra\n")
1744            .await
1745            .unwrap();
1746        make_executable(&extra_tool).await;
1747
1748        let mut config = Config::new_for_test(&dir);
1749        config.is_external_presets = true;
1750        fs::create_dir_all(config.bin_dir()).await.unwrap();
1751
1752        handle_install(&config, Some("proxy"), false).await.unwrap();
1753        assert!(config.bin_dir().join("setproxy").exists());
1754        assert!(
1755            !config.bin_dir().join("extra_tool").exists(),
1756            "extra preset should start as present but not installed"
1757        );
1758
1759        fs::write(
1760            &setproxy,
1761            b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
1762        )
1763        .await
1764        .unwrap();
1765        make_executable(&setproxy).await;
1766
1767        let mut sep = crate::output::SectionSeparator::new();
1768        let report = handle_upgrade_installed(&config, false, &mut sep)
1769            .await
1770            .unwrap();
1771
1772        assert_eq!(
1773            report.templates_updated, 1,
1774            "changed shell template should be reported under shell presets"
1775        );
1776        assert_eq!(report.updated_targets, vec!["proxy/setproxy"]);
1777        assert_eq!(report.updated_categories, vec!["proxy"]);
1778        assert!(config.bin_dir().join("setproxy").exists());
1779        assert!(
1780            !config.bin_dir().join("extra_tool").exists(),
1781            "upgrade must not install preset-only scripts"
1782        );
1783
1784        fs::remove_dir_all(&dir).await.unwrap();
1785    }
1786
1787    #[cfg(unix)]
1788    #[tokio::test]
1789    async fn external_bun_preset_installs_launcher_and_uninstall_removes_it() {
1790        let dir = make_temp_dir().await;
1791        let cat_dir = dir.join("presets/shell/custom");
1792        fs::create_dir_all(&cat_dir).await.unwrap();
1793        fs::write(
1794            cat_dir.join("shine.toml"),
1795            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n[files.permissions]\nschema_version = 1\n",
1796        )
1797        .await
1798        .unwrap();
1799        // A non-executable .ts source: bun launchers do not require the exec bit.
1800        fs::write(cat_dir.join("tool.ts"), b"console.log('hi')\n")
1801            .await
1802            .unwrap();
1803
1804        let mut config = Config::new_for_test(&dir);
1805        config.is_external_presets = true;
1806        fs::create_dir_all(config.bin_dir()).await.unwrap();
1807
1808        handle_install(&config, Some("custom"), false)
1809            .await
1810            .unwrap();
1811
1812        let launcher = config.bin_dir().join("mytool");
1813        assert!(launcher.exists(), "bun launcher should be installed");
1814        assert!(!launcher.is_symlink(), "bun launcher is a regular file");
1815        let content = fs::read_to_string(&launcher).await.unwrap();
1816        assert!(content.contains("exec bun --no-install"));
1817        assert!(
1818            content.contains(
1819                &config
1820                    .installed_shell_dir()
1821                    .join("custom/tool.ts")
1822                    .display()
1823                    .to_string()
1824            )
1825        );
1826        assert!(
1827            !config.bin_dir().join("tool").exists(),
1828            "command should use the target rename, not the .ts stem"
1829        );
1830
1831        handle_uninstall(&config, Some("custom"), false, false)
1832            .await
1833            .unwrap();
1834        assert!(
1835            !launcher.exists(),
1836            "managed bun launcher must be removed on uninstall"
1837        );
1838        assert!(
1839            cat_dir.join("tool.ts").exists(),
1840            "external source must be preserved"
1841        );
1842
1843        fs::remove_dir_all(&dir).await.unwrap();
1844    }
1845
1846    #[cfg(unix)]
1847    #[tokio::test]
1848    async fn external_bun_preset_with_env_wraps_launcher_in_shine_env_run() {
1849        let dir = make_temp_dir().await;
1850        let cat_dir = dir.join("presets/shell/custom");
1851        fs::create_dir_all(&cat_dir).await.unwrap();
1852        fs::write(
1853            cat_dir.join("shine.toml"),
1854            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\nenv = [\"API_URL\", \"SERVICE_TOKEN=API_TOKEN\"]\n[files.permissions]\nschema_version = 1\nenvironment = [{ name = \"API_URL\", sensitivity = \"plain\" }, { name = \"SERVICE_TOKEN\", sensitivity = \"plain\" }]\n",
1855        )
1856        .await
1857        .unwrap();
1858        fs::write(cat_dir.join("tool.ts"), b"console.log(Bun.env.API_URL)\n")
1859            .await
1860            .unwrap();
1861
1862        let mut config = Config::new_for_test(&dir);
1863        config.is_external_presets = true;
1864        fs::create_dir_all(config.bin_dir()).await.unwrap();
1865
1866        handle_install(&config, Some("custom"), false)
1867            .await
1868            .unwrap();
1869
1870        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1871            .await
1872            .unwrap();
1873        assert!(launcher.contains("command -v shine"));
1874        assert!(launcher.contains(
1875            "exec shine env run --no-workspace --with 'API_URL' --with 'SERVICE_TOKEN=API_TOKEN' -- bun --no-install "
1876        ));
1877
1878        fs::remove_dir_all(&dir).await.unwrap();
1879    }
1880
1881    #[cfg(unix)]
1882    #[tokio::test]
1883    async fn external_bun_preset_with_locked_package_uses_fallback_and_records_hash() {
1884        let dir = make_temp_dir().await;
1885        let cat_dir = dir.join("presets/shell/custom");
1886        fs::create_dir_all(&cat_dir).await.unwrap();
1887        fs::write(
1888            cat_dir.join("shine.toml"),
1889            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n[files.permissions]\nschema_version = 1\n",
1890        )
1891        .await
1892        .unwrap();
1893        fs::write(cat_dir.join("tool.ts"), b"import 'zod'\n")
1894            .await
1895            .unwrap();
1896        fs::write(
1897            cat_dir.join("package.json"),
1898            b"{\"dependencies\":{\"zod\":\"4.0.0\"}}",
1899        )
1900        .await
1901        .unwrap();
1902        fs::write(cat_dir.join("bun.lock"), b"lockfileVersion = 1\n")
1903            .await
1904            .unwrap();
1905        fs::create_dir_all(cat_dir.join("node_modules/zod"))
1906            .await
1907            .unwrap();
1908        fs::write(cat_dir.join("node_modules/zod/index.js"), b"export {}")
1909            .await
1910            .unwrap();
1911
1912        let mut config = Config::new_for_test(&dir);
1913        config.is_external_presets = true;
1914        fs::create_dir_all(config.bin_dir()).await.unwrap();
1915        handle_install(&config, Some("custom"), false)
1916            .await
1917            .unwrap();
1918
1919        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1920            .await
1921            .unwrap();
1922        assert!(launcher.contains("exec bun --install=fallback"));
1923        assert!(
1924            !config
1925                .installed_shell_dir()
1926                .join("custom/node_modules")
1927                .exists()
1928        );
1929        let manifest =
1930            crate::shells::deployment::ShellManifest::load(&shine_core::runtime::RealHost, &config)
1931                .await
1932                .unwrap();
1933        let entry = manifest.find("shell/custom/mytool").unwrap();
1934        assert_eq!(entry.bun_dependencies.as_deref(), Some("locked"));
1935        assert!(entry.dependency_hash.is_some());
1936
1937        fs::remove_dir_all(&dir).await.unwrap();
1938    }
1939
1940    #[cfg(unix)]
1941    #[tokio::test]
1942    async fn external_bun_preset_with_template_transform_targets_rendered_copy() {
1943        let dir = make_temp_dir().await;
1944        let cat_dir = dir.join("presets/shell/custom");
1945        fs::create_dir_all(&cat_dir).await.unwrap();
1946        fs::write(
1947            cat_dir.join("shine.toml"),
1948            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n[files.permissions]\nschema_version = 1\n",
1949        )
1950        .await
1951        .unwrap();
1952        fs::write(cat_dir.join("tool.ts"), b"const host = '@@PROXY_HOST@@'\n")
1953            .await
1954            .unwrap();
1955
1956        let mut config = Config::new_for_test(&dir);
1957        config.is_external_presets = true;
1958        config
1959            .env
1960            .insert("PROXY_HOST".into(), "proxy.example".into());
1961        fs::create_dir_all(config.bin_dir()).await.unwrap();
1962
1963        handle_install(&config, Some("custom"), false)
1964            .await
1965            .unwrap();
1966
1967        let rendered = config.rendered_dir().join("shell/custom/tool.ts");
1968        assert!(
1969            rendered.exists(),
1970            "template transform should render the .ts"
1971        );
1972        assert!(
1973            fs::read_to_string(&rendered)
1974                .await
1975                .unwrap()
1976                .contains("proxy.example"),
1977            "rendered bun script should have @@PROXY_HOST@@ substituted"
1978        );
1979        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1980            .await
1981            .unwrap();
1982        assert!(
1983            launcher.contains(&rendered.display().to_string()),
1984            "launcher must target the rendered copy: {launcher}"
1985        );
1986
1987        fs::remove_dir_all(&dir).await.unwrap();
1988    }
1989
1990    #[cfg(unix)]
1991    #[tokio::test]
1992    async fn live_transformed_bun_renders_again_on_demand() {
1993        let dir = make_temp_dir().await;
1994        let cat_dir = dir.join("presets/shell/custom");
1995        fs::create_dir_all(&cat_dir).await.unwrap();
1996        fs::write(
1997            cat_dir.join("shine.toml"),
1998            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n[files.permissions]\nschema_version = 1\n",
1999        )
2000        .await
2001        .unwrap();
2002        let source = cat_dir.join("tool.ts");
2003        fs::write(&source, b"console.log('@@PROXY_HOST@@')\n")
2004            .await
2005            .unwrap();
2006
2007        let mut config = Config::new_for_test(&dir);
2008        config.is_external_presets = true;
2009        config.external_shell_mode = crate::config::ExternalShellMode::Live;
2010        config
2011            .env
2012            .insert("PROXY_HOST".into(), "first.example".into());
2013        fs::create_dir_all(config.bin_dir()).await.unwrap();
2014        handle_install(&config, Some("custom"), false)
2015            .await
2016            .unwrap();
2017
2018        let rendered = config.rendered_dir().join("shell/custom/tool.ts");
2019        assert!(
2020            fs::read_to_string(&rendered)
2021                .await
2022                .unwrap()
2023                .contains("first.example")
2024        );
2025        config
2026            .env
2027            .insert("PROXY_HOST".into(), "second.example".into());
2028        crate::shells::deployment::handle_render_live(&config, "shell/custom/mytool")
2029            .await
2030            .unwrap();
2031        assert!(
2032            fs::read_to_string(&rendered)
2033                .await
2034                .unwrap()
2035                .contains("second.example")
2036        );
2037        let last_good = fs::read(&rendered).await.unwrap();
2038        fs::write(&source, b"console.log('@@MISSING_LIVE_VALUE@@')\n")
2039            .await
2040            .unwrap();
2041        assert!(
2042            crate::shells::deployment::handle_render_live(&config, "shell/custom/mytool")
2043                .await
2044                .is_err()
2045        );
2046        assert_eq!(
2047            fs::read(&rendered).await.unwrap(),
2048            last_good,
2049            "failed live transform must preserve the last-known-good output"
2050        );
2051
2052        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
2053            .await
2054            .unwrap();
2055        assert!(launcher.contains("__shell-render"));
2056        assert!(launcher.contains("--config-dir"));
2057        fs::remove_dir_all(&dir).await.unwrap();
2058    }
2059
2060    #[cfg(unix)]
2061    #[tokio::test]
2062    async fn snapshot_upgrade_applies_external_raw_source_change() {
2063        let dir = make_temp_dir().await;
2064        let cat_dir = dir.join("presets/shell/custom");
2065        fs::create_dir_all(&cat_dir).await.unwrap();
2066        fs::write(
2067            cat_dir.join("shine.toml"),
2068            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n[files.permissions]\nschema_version = 1\n",
2069        )
2070        .await
2071        .unwrap();
2072        let source = cat_dir.join("tool.sh");
2073        fs::write(&source, b"#!/bin/sh\necho first\n")
2074            .await
2075            .unwrap();
2076
2077        let mut config = Config::new_for_test(&dir);
2078        config.is_external_presets = true;
2079        fs::create_dir_all(config.bin_dir()).await.unwrap();
2080        handle_install(&config, Some("custom"), false)
2081            .await
2082            .unwrap();
2083        let installed = config.installed_shell_dir().join("custom/tool.sh");
2084        assert!(
2085            fs::read_to_string(&installed)
2086                .await
2087                .unwrap()
2088                .contains("first")
2089        );
2090
2091        fs::write(&source, b"#!/bin/sh\necho second\n")
2092            .await
2093            .unwrap();
2094        let mut separator = crate::output::SectionSeparator::new();
2095        let report = handle_upgrade_installed(&config, false, &mut separator)
2096            .await
2097            .unwrap();
2098        assert_eq!(report.snapshots_updated, 1);
2099        assert_eq!(report.updated_targets, vec!["custom/mytool"]);
2100        assert_eq!(report.updated_categories, vec!["custom"]);
2101        assert!(
2102            fs::read_to_string(&installed)
2103                .await
2104                .unwrap()
2105                .contains("second")
2106        );
2107        assert_eq!(
2108            fs::read_link(config.bin_dir().join("mytool"))
2109                .await
2110                .unwrap(),
2111            installed
2112        );
2113        fs::remove_dir_all(&dir).await.unwrap();
2114    }
2115
2116    #[cfg(unix)]
2117    #[tokio::test]
2118    async fn upgrade_migrates_legacy_external_link_to_snapshot() {
2119        let dir = make_temp_dir().await;
2120        let cat_dir = dir.join("presets/shell/custom");
2121        fs::create_dir_all(&cat_dir).await.unwrap();
2122        fs::write(
2123            cat_dir.join("shine.toml"),
2124            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n[files.permissions]\nschema_version = 1\n",
2125        )
2126        .await
2127        .unwrap();
2128        let source = cat_dir.join("tool.sh");
2129        fs::write(&source, b"#!/bin/sh\necho legacy\n")
2130            .await
2131            .unwrap();
2132        let mut config = Config::new_for_test(&dir);
2133        config.is_external_presets = true;
2134        fs::create_dir_all(config.bin_dir()).await.unwrap();
2135        fs::symlink(&source, config.bin_dir().join("mytool"))
2136            .await
2137            .unwrap();
2138
2139        let mut separator = crate::output::SectionSeparator::new();
2140        let report = handle_upgrade_installed(&config, false, &mut separator)
2141            .await
2142            .unwrap();
2143        assert_eq!(report.snapshots_updated, 1);
2144        assert_eq!(
2145            fs::read_link(config.bin_dir().join("mytool"))
2146                .await
2147                .unwrap(),
2148            config.installed_shell_dir().join("custom/tool.sh")
2149        );
2150        assert!(
2151            crate::shells::deployment::ShellManifest::load(&shine_core::runtime::RealHost, &config)
2152                .await
2153                .unwrap()
2154                .find("shell/custom/mytool")
2155                .is_some()
2156        );
2157        fs::remove_dir_all(&dir).await.unwrap();
2158    }
2159
2160    #[cfg(unix)]
2161    #[tokio::test]
2162    async fn upgrade_switches_snapshot_raw_link_to_explicit_live_source() {
2163        let dir = make_temp_dir().await;
2164        let cat_dir = dir.join("presets/shell/custom");
2165        fs::create_dir_all(&cat_dir).await.unwrap();
2166        fs::write(
2167            cat_dir.join("shine.toml"),
2168            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n[files.permissions]\nschema_version = 1\n",
2169        )
2170        .await
2171        .unwrap();
2172        let source = cat_dir.join("tool.sh");
2173        fs::write(&source, b"#!/bin/sh\necho live\n").await.unwrap();
2174        let mut config = Config::new_for_test(&dir);
2175        config.is_external_presets = true;
2176        fs::create_dir_all(config.bin_dir()).await.unwrap();
2177        handle_install(&config, Some("custom"), false)
2178            .await
2179            .unwrap();
2180
2181        config.external_shell_mode = crate::config::ExternalShellMode::Live;
2182        let mut separator = crate::output::SectionSeparator::new();
2183        handle_upgrade_installed(&config, false, &mut separator)
2184            .await
2185            .unwrap();
2186        assert_eq!(
2187            fs::read_link(config.bin_dir().join("mytool"))
2188                .await
2189                .unwrap(),
2190            source
2191        );
2192        let manifest =
2193            crate::shells::deployment::ShellManifest::load(&shine_core::runtime::RealHost, &config)
2194                .await
2195                .unwrap();
2196        assert_eq!(
2197            manifest.find("shell/custom/mytool").unwrap().mode,
2198            crate::config::ExternalShellMode::Live
2199        );
2200        fs::remove_dir_all(&dir).await.unwrap();
2201    }
2202}