Skip to main content

cli/shells/
install.rs

1use super::links::{build_link_specs, print_link_conflicts};
2use super::profile::{
3    append_path_to_shell_config, managed_shell_profile_path, print_source_command_activation_hint,
4    shell_source_command,
5};
6use super::report::{
7    ShellUpgradeReport, link_report_summary_parts, preset_extract_summary_parts,
8    upgrade_link_report_summary_parts,
9};
10use super::template::{ScriptTemplate, apply_template_to_scripts};
11use super::{PathUpdateStatus, get_shell_config_path, metadata};
12use crate::colors;
13use crate::config::Config;
14use crate::output;
15use anyhow::{Context, Result};
16use std::path::Path;
17
18const SHELL_TEMPLATE: &str = r#"# Shell preset metadata for shine.
19description = "My shell helper commands."
20
21[[files]]
22source = "my_tool.sh"
23target = "mytool"
24needs_source = false
25# Optional: limit a file to specific platforms.
26# platforms = ["unix"]      # or ["windows"]
27
28# PowerShell scripts are also supported:
29# source = "my_tool.ps1"
30
31# Cross-platform Bun helpers (requires `bun` on PATH; shine never installs it):
32# [[files]]
33# source = "my_tool.ts"     # .ts / .js / .mts / .mjs
34# target = "mytool"
35# runtime = "bun"
36# platforms = ["unix", "windows"]
37# description = "What mytool does."  # or a `// ...` header at the top of my_tool.ts
38# transforms = ["template"] # opt into @@VAR@@ env substitution (static, needs `shine upgrade`)
39# env = ["API_URL", "SERVICE_TOKEN=API_TOKEN"]  # inject shine values at launch; read via Bun.env
40"#;
41
42pub async fn handle_init_template(force: bool) -> Result<()> {
43    let dir = std::env::current_dir().context("reading current directory")?;
44    let (path, overwritten) =
45        utils::init_template::write_shine_toml_template(&dir, force, SHELL_TEMPLATE)?;
46    if overwritten {
47        println!("Updated shell preset template: {}", path.display());
48    } else {
49        println!("Created shell preset template: {}", path.display());
50    }
51    Ok(())
52}
53
54pub async fn handle_install(config: &Config, category: Option<&str>, force: bool) -> Result<()> {
55    crate::config::print_presets_note(config);
56    let prefix = match category {
57        Some(cat) => format!("shell/{cat}"),
58        None => "shell".to_string(),
59    };
60
61    // When using the default presets directory, extract the embedded assets first.
62    if !config.is_external_presets {
63        let report = crate::presets::extract_prefix(&prefix, config.presets_dir(), force).await?;
64        output::summary_line("Shell Presets", &preset_extract_summary_parts(&report));
65    }
66
67    let categories = metadata::load_active_categories(config, category).await?;
68    super::deployment::validate_snapshot_categories(config, &categories).await?;
69    let snapshots_updated =
70        super::deployment::materialize_snapshot_categories(config, &categories).await?;
71    if config.is_external_presets
72        && config.external_shell_mode == crate::config::ExternalShellMode::Snapshot
73    {
74        let summary = if snapshots_updated > 0 {
75            colors::green(&format!("{snapshots_updated} updated"))
76        } else {
77            colors::dim("up to date")
78        };
79        output::summary_line("Shell Snapshots", &[summary]);
80    }
81    // Build (template_source, rendered_dest) pairs for all scripts.
82    // apply_template_to_scripts renders source → rendered_dir, never modifies presets_dir.
83    let script_pairs = build_script_pairs(config, &categories);
84
85    // Apply env-variable substitution to scripts that opt in via `# shine-template: true`.
86    // Output goes to rendered_dir; presets_dir templates are left untouched.
87    apply_template_to_scripts(config, &script_pairs).await?;
88
89    // Symlinks point to the rendered file when one was produced, otherwise to the
90    // raw source in presets_dir (non-template scripts).
91    let link_specs = build_link_specs(config, &categories);
92    let link_report =
93        crate::bin_links::link_executables_with_names(config.bin_dir(), &link_specs, force).await?;
94    super::deployment::update_manifest(config, &categories).await?;
95
96    output::summary_line("Bin Links", &link_report_summary_parts(&link_report));
97    print_link_conflicts(config, &link_report.conflicts, category);
98
99    let source_commands = installed_source_commands(config).await?;
100    let installed_commands = installed_source_commands_for_categories(config, &categories).await?;
101
102    let shell_config_path = get_shell_config_path(&config.shell_type, &config.home_dir)?;
103    let shell_update = append_path_to_shell_config(config, force, &source_commands).await?;
104    let profile_path = managed_shell_profile_path(config);
105    if shell_update.profile_updated {
106        output::detail_line(
107            "Shell Profile",
108            &colors::green("updated"),
109            Some(profile_path.display().to_string()),
110        );
111    }
112    match shell_update.config_status {
113        PathUpdateStatus::AlreadyConfigured => {
114            output::detail_line(
115                "Shell Config",
116                &colors::dim("up to date"),
117                Some(shell_config_path.display().to_string()),
118            );
119        }
120        PathUpdateStatus::Updated(path) => {
121            output::detail_line(
122                "Shell Config",
123                &colors::green("updated"),
124                Some(path.display().to_string()),
125            );
126        }
127    }
128    print_source_command_activation_hint(config, &shell_config_path, &installed_commands);
129    Ok(())
130}
131
132pub async fn handle_upgrade_installed(
133    config: &Config,
134    verbose: bool,
135    sep: &mut crate::output::SectionSeparator,
136) -> Result<ShellUpgradeReport> {
137    handle_upgrade_installed_target(config, None, verbose, sep).await
138}
139
140pub async fn handle_upgrade_installed_target(
141    config: &Config,
142    category_filter: Option<&str>,
143    verbose: bool,
144    sep: &mut crate::output::SectionSeparator,
145) -> Result<ShellUpgradeReport> {
146    let all_categories = if config.is_external_presets {
147        metadata::load_installed_categories(config, None).await?
148    } else {
149        metadata::load_embedded_categories(None)?
150    };
151
152    let installed_commands: Vec<(String, String)> = all_categories
153        .iter()
154        .filter(|cat| category_filter.is_none_or(|filter| cat.name == filter))
155        .flat_map(|cat| {
156            cat.files.iter().filter_map(|file| {
157                let link = crate::bin_links::command_path_for_name(
158                    config.bin_dir(),
159                    std::ffi::OsStr::new(&file.command_name),
160                );
161                shell_link_exists(&link).then(|| (cat.name.clone(), file.command_name.clone()))
162            })
163        })
164        .collect();
165
166    if installed_commands.is_empty() {
167        if let Some(category) = category_filter {
168            anyhow::bail!("shell preset is not installed: {category}");
169        }
170        if verbose {
171            println!("{}", colors::dim("No installed shell presets found."));
172        }
173        return Ok(ShellUpgradeReport::default());
174    }
175
176    let installed_categories: std::collections::BTreeSet<String> = installed_commands
177        .iter()
178        .map(|(cat_name, _)| cat_name.clone())
179        .collect();
180
181    if !config.is_external_presets {
182        for category in &installed_categories {
183            let prefix = format!("shell/{category}");
184            let _ = crate::presets::extract_prefix(&prefix, config.presets_dir(), true).await?;
185        }
186    }
187
188    let categories = metadata::load_installed_categories(config, None).await?;
189    let mut categories: Vec<_> = categories
190        .into_iter()
191        .filter(|cat| installed_categories.contains(&cat.name))
192        .collect();
193    for cat in &mut categories {
194        cat.files.retain(|file| {
195            installed_commands.contains(&(cat.name.clone(), file.command_name.clone()))
196        });
197    }
198
199    super::deployment::validate_snapshot_categories(config, &categories).await?;
200    let snapshots_updated =
201        super::deployment::materialize_snapshot_categories(config, &categories).await?;
202
203    let script_pairs = build_script_pairs(config, &categories);
204    let template_report = apply_template_to_scripts(config, &script_pairs).await?;
205
206    let link_specs = build_link_specs(config, &categories);
207    let link_report =
208        crate::bin_links::link_executables_with_names(config.bin_dir(), &link_specs, true).await?;
209    super::deployment::update_manifest(config, &categories).await?;
210
211    let link_parts = upgrade_link_report_summary_parts(&link_report, verbose);
212
213    let source_commands = installed_source_commands(config).await?;
214
215    let shell_update = append_path_to_shell_config(config, false, &source_commands).await?;
216    let updated_shell_config = match shell_update.config_status {
217        PathUpdateStatus::AlreadyConfigured => None,
218        PathUpdateStatus::Updated(path) => Some(path),
219    };
220
221    let has_visible_result = should_print_upgrade_section(
222        verbose,
223        snapshots_updated > 0,
224        !template_report.updated.is_empty(),
225        !link_parts.is_empty(),
226        updated_shell_config.is_some(),
227    );
228    if has_visible_result {
229        sep.begin();
230        if verbose {
231            output::summary_line(
232                "Shell Presets",
233                &[colors::dim(&format!(
234                    "{} installed categories",
235                    installed_categories.len()
236                ))],
237            );
238        } else {
239            println!("{}", colors::bold("Shell Presets"));
240        }
241
242        if snapshots_updated > 0 {
243            println!(
244                "  {} {}",
245                colors::symbol("✓"),
246                colors::green(&format!("{snapshots_updated} snapshot(s) updated"))
247            );
248        }
249        for name in &template_report.updated {
250            println!("  {} {}", colors::symbol("✓"), name);
251        }
252        if !link_parts.is_empty() {
253            output::summary_line("Bin Links", &link_parts);
254        }
255        print_link_conflicts(config, &link_report.conflicts, None);
256        if let Some(path) = &updated_shell_config {
257            output::detail_line(
258                "Shell Config",
259                &colors::green("updated"),
260                Some(path.display().to_string()),
261            );
262        }
263    }
264
265    Ok(ShellUpgradeReport {
266        snapshots_updated,
267        templates_updated: template_report.updated.len(),
268        links_created: link_report.created.len(),
269        links_updated: link_report.overwritten.len(),
270        link_conflicts: link_report.conflicts.len(),
271        path_changed: updated_shell_config.is_some(),
272    })
273}
274
275fn should_print_upgrade_section(
276    verbose: bool,
277    snapshots_updated: bool,
278    templates_updated: bool,
279    has_link_result: bool,
280    path_changed: bool,
281) -> bool {
282    verbose || snapshots_updated || templates_updated || has_link_result || path_changed
283}
284
285pub async fn handle_completion_install(config: &Config) -> Result<()> {
286    let source_commands = installed_source_commands(config).await?;
287    let shell_config_path = get_shell_config_path(&config.shell_type, &config.home_dir)?;
288    let shell_update = append_path_to_shell_config(config, false, &source_commands).await?;
289    let profile_path = managed_shell_profile_path(config);
290
291    if shell_update.profile_updated {
292        output::detail_line(
293            "Shell Profile",
294            &colors::green("updated"),
295            Some(profile_path.display().to_string()),
296        );
297    } else {
298        output::detail_line(
299            "Shell Profile",
300            &colors::dim("up to date"),
301            Some(profile_path.display().to_string()),
302        );
303    }
304
305    match shell_update.config_status {
306        PathUpdateStatus::AlreadyConfigured => {
307            output::detail_line(
308                "Shell Config",
309                &colors::dim("up to date"),
310                Some(shell_config_path.display().to_string()),
311            );
312        }
313        PathUpdateStatus::Updated(path) => {
314            output::detail_line(
315                "Shell Config",
316                &colors::green("updated"),
317                Some(path.display().to_string()),
318            );
319        }
320    }
321
322    if !super::profile::supports_completion_registration(&config.shell_type) {
323        let shell: &'static str = config.shell_type.into();
324        output::detail_line(
325            "Completion",
326            &colors::yellow("unsupported"),
327            Some(format!("{shell}; PATH setup was installed")),
328        );
329    }
330
331    output::hint_line(
332        "Next Step",
333        &format!(
334            "run `{}` once, or open a new shell",
335            shell_source_command(&config.shell_type, &shell_config_path)
336        ),
337    );
338    Ok(())
339}
340
341fn shell_link_exists(link: &Path) -> bool {
342    link.exists()
343        || std::fs::symlink_metadata(link)
344            .map(|meta| meta.file_type().is_symlink())
345            .unwrap_or(false)
346}
347
348/// For each script that declares `# shine-template: true`, read the template from
349/// `source_path` (presets_dir — never modified), substitute env variables from
350fn build_script_pairs(
351    config: &Config,
352    categories: &[metadata::ShellCategory],
353) -> Vec<ScriptTemplate> {
354    categories
355        .iter()
356        .flat_map(|cat| {
357            cat.files.iter().map(|file| {
358                let source =
359                    super::deployment::deployment_source_path(config, &cat.name, &file.source_rel);
360                let rendered =
361                    super::deployment::rendered_path(config, &cat.name, &file.source_rel);
362                ScriptTemplate {
363                    source_path: source,
364                    rendered_path: rendered,
365                    display_name: format!("{}/{}", cat.name, file.command_name),
366                    transforms: file.transforms.clone(),
367                }
368            })
369        })
370        .collect()
371}
372
373pub(super) async fn installed_source_commands(config: &Config) -> Result<Vec<String>> {
374    let categories = metadata::load_installed_categories(config, None).await?;
375    installed_source_commands_for_categories(config, &categories).await
376}
377
378async fn installed_source_commands_for_categories(
379    config: &Config,
380    categories: &[metadata::ShellCategory],
381) -> Result<Vec<String>> {
382    let mut commands = categories
383        .iter()
384        .flat_map(|cat| cat.files.iter())
385        .filter(|file| file.needs_source)
386        .filter(|file| {
387            let link = crate::bin_links::command_path_for_name(
388                config.bin_dir(),
389                std::ffi::OsStr::new(&file.command_name),
390            );
391            shell_link_exists(&link)
392        })
393        .map(|file| file.command_name.clone())
394        .collect::<Vec<_>>();
395    commands.sort();
396    commands.dedup();
397    Ok(commands)
398}
399
400#[cfg(test)]
401mod tests {
402    use super::super::ShellType;
403    use super::super::uninstall::handle_uninstall;
404    use super::*;
405    use crate::config::Config;
406    use std::path::PathBuf;
407    use tokio::fs;
408
409    #[test]
410    fn upgrade_section_hides_no_op_by_default_and_shows_verbose_or_changes() {
411        assert!(!should_print_upgrade_section(
412            false, false, false, false, false
413        ));
414        assert!(should_print_upgrade_section(
415            true, false, false, false, false
416        ));
417        assert!(should_print_upgrade_section(
418            false, true, false, false, false
419        ));
420        assert!(should_print_upgrade_section(
421            false, false, true, false, false
422        ));
423        assert!(should_print_upgrade_section(
424            false, false, false, true, false
425        ));
426        assert!(should_print_upgrade_section(
427            false, false, false, false, true
428        ));
429    }
430
431    async fn make_temp_dir() -> PathBuf {
432        crate::test_support::make_temp_dir("shine-shell").await
433    }
434
435    #[cfg(unix)]
436    async fn make_executable(path: &Path) {
437        use std::os::unix::fs::PermissionsExt;
438        let mut perms = fs::metadata(path).await.unwrap().permissions();
439        perms.set_mode(perms.mode() | 0o111);
440        fs::set_permissions(path, perms).await.unwrap();
441    }
442
443    fn wrapper_marker(command: &str, shell: &ShellType) -> String {
444        match shell {
445            ShellType::PowerShell => format!("\nfunction {command} {{ . (Join-Path $shineBin"),
446            ShellType::Fish => format!("\nfunction {command}"),
447            _ => format!("\n{command}() {{ source"),
448        }
449    }
450
451    fn managed_profile_source_marker(shell: &ShellType) -> &'static str {
452        match shell {
453            ShellType::PowerShell => ". (Join-Path $HOME 'shell/profile.ps1')",
454            ShellType::Fish => "source \"$HOME/shell/config.fish\"",
455            ShellType::Bash | ShellType::Zsh | ShellType::Elvish => {
456                "source \"$HOME/shell/profile.sh\""
457            }
458        }
459    }
460
461    fn managed_profile_path_marker(shell: &ShellType) -> &'static str {
462        match shell {
463            ShellType::PowerShell => "$shinePathEntries",
464            ShellType::Fish => "fish_add_path",
465            ShellType::Bash | ShellType::Zsh | ShellType::Elvish => "export PATH",
466        }
467    }
468
469    #[cfg(unix)]
470    #[tokio::test]
471    async fn install_then_uninstall_roundtrip() {
472        let dir = make_temp_dir().await;
473        let config = Config::new_for_test(&dir);
474        fs::create_dir_all(config.presets_dir()).await.unwrap();
475        fs::create_dir_all(config.bin_dir()).await.unwrap();
476
477        handle_install(&config, None, false).await.unwrap();
478        assert!(
479            config
480                .presets_dir()
481                .join("shell/proxy/set_proxy.sh")
482                .exists(),
483            "preset should exist after install"
484        );
485        let first_bin_entry = fs::read_dir(config.bin_dir())
486            .await
487            .unwrap()
488            .next_entry()
489            .await
490            .unwrap();
491        assert!(
492            first_bin_entry.is_some(),
493            "bin dir should have symlinks after install"
494        );
495        // symlinks use stem names (no .sh suffix)
496        assert!(
497            config.bin_dir().join("setproxy").exists(),
498            "bin link should use configured rename"
499        );
500        assert!(!config.bin_dir().join("set_proxy").exists());
501        assert!(
502            managed_shell_profile_path(&config).exists(),
503            "managed shell profile should exist after install"
504        );
505
506        handle_uninstall(&config, None, false, false).await.unwrap();
507        assert!(
508            !config
509                .presets_dir()
510                .join("shell/proxy/set_proxy.sh")
511                .exists(),
512            "preset should be gone after uninstall"
513        );
514        let mut rd = fs::read_dir(config.bin_dir()).await.unwrap();
515        assert!(
516            rd.next_entry().await.unwrap().is_none(),
517            "bin dir should be empty after uninstall"
518        );
519        assert!(
520            !managed_shell_profile_path(&config).exists(),
521            "managed shell profile should be removed after full uninstall"
522        );
523
524        // Idempotency: second uninstall must not error
525        handle_uninstall(&config, None, false, false).await.unwrap();
526
527        fs::remove_dir_all(&dir).await.unwrap();
528    }
529
530    #[tokio::test]
531    async fn append_writes_snippet_to_shell_config() {
532        let dir = make_temp_dir().await;
533        let config = Config::new_for_test(&dir);
534
535        append_path_to_shell_config(&config, false, &[])
536            .await
537            .unwrap();
538
539        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
540        let content = fs::read_to_string(&config_path).await.unwrap();
541        assert!(
542            content.contains(super::super::SENTINEL_START),
543            "sentinel should be present"
544        );
545    }
546
547    #[tokio::test]
548    async fn completion_install_updates_profile_without_installing_presets() {
549        let dir = make_temp_dir().await;
550        let config = Config::new_for_test(&dir);
551
552        handle_completion_install(&config).await.unwrap();
553
554        let profile = fs::read_to_string(managed_shell_profile_path(&config))
555            .await
556            .unwrap();
557        let shell_name: &'static str = config.shell_type.into();
558        assert!(
559            profile.contains(&format!("COMPLETE={shell_name} shine")),
560            "profile should register shine completion: {profile}"
561        );
562        assert!(
563            !config.presets_dir().join("shell/proxy").exists(),
564            "completion install must not extract or install shell presets"
565        );
566    }
567
568    #[tokio::test]
569    async fn append_is_idempotent() {
570        let dir = make_temp_dir().await;
571        let config = Config::new_for_test(&dir);
572
573        append_path_to_shell_config(&config, false, &[])
574            .await
575            .unwrap();
576        append_path_to_shell_config(&config, false, &[])
577            .await
578            .unwrap();
579
580        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
581        let content = fs::read_to_string(&config_path).await.unwrap();
582        let count = content.matches(super::super::SENTINEL_START).count();
583        assert_eq!(count, 1, "sentinel should appear exactly once");
584    }
585
586    #[tokio::test]
587    async fn append_is_idempotent_with_source_wrappers() {
588        let dir = make_temp_dir().await;
589        let config = Config::new_for_test(&dir);
590        let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
591
592        append_path_to_shell_config(&config, false, &source_commands)
593            .await
594            .unwrap();
595        append_path_to_shell_config(&config, false, &source_commands)
596            .await
597            .unwrap();
598
599        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
600        let content = fs::read_to_string(&config_path).await.unwrap();
601        assert_eq!(
602            content.matches(super::super::SENTINEL_START).count(),
603            1,
604            "sentinel should appear exactly once"
605        );
606        assert!(
607            !content.contains("setproxy()"),
608            "source wrappers should live in the managed profile: {content}"
609        );
610
611        let profile_path = managed_shell_profile_path(&config);
612        let profile = fs::read_to_string(&profile_path).await.unwrap();
613        let setproxy_marker = wrapper_marker("setproxy", &config.shell_type);
614        let usetproxy_marker = wrapper_marker("usetproxy", &config.shell_type);
615        assert_eq!(
616            profile.matches(&setproxy_marker).count(),
617            1,
618            "setproxy wrapper should not be duplicated: {content}"
619        );
620        assert_eq!(
621            profile.matches(&usetproxy_marker).count(),
622            1,
623            "usetproxy wrapper should not be duplicated: {content}"
624        );
625
626        fs::remove_dir_all(&dir).await.unwrap();
627    }
628
629    #[tokio::test]
630    async fn append_writes_source_entry_and_managed_profile() {
631        let dir = make_temp_dir().await;
632        let config = Config::new_for_test(&dir);
633        let source_commands = vec!["setproxy".to_string()];
634
635        append_path_to_shell_config(&config, false, &source_commands)
636            .await
637            .unwrap();
638
639        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
640        let content = fs::read_to_string(&config_path).await.unwrap();
641        assert!(
642            content.contains(managed_profile_source_marker(&config.shell_type)),
643            "shell config should only source managed profile: {content}"
644        );
645        assert!(
646            !content.contains("export PATH"),
647            "shell config should not contain direct PATH setup: {content}"
648        );
649        assert!(
650            !content.contains("setproxy()"),
651            "shell config should not contain direct wrapper functions: {content}"
652        );
653
654        let profile = fs::read_to_string(managed_shell_profile_path(&config))
655            .await
656            .unwrap();
657        assert!(
658            profile.contains(managed_profile_path_marker(&config.shell_type)),
659            "managed profile should contain PATH setup: {profile}"
660        );
661        assert!(
662            profile.contains(&wrapper_marker("setproxy", &config.shell_type)),
663            "managed profile should contain source wrapper: {profile}"
664        );
665
666        fs::remove_dir_all(&dir).await.unwrap();
667    }
668
669    #[cfg(windows)]
670    #[tokio::test]
671    async fn append_writes_both_windows_powershell_profiles() {
672        let dir = make_temp_dir().await;
673        let mut config = Config::new_for_test(&dir);
674        config.shell_type = ShellType::PowerShell;
675        let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
676
677        append_path_to_shell_config(&config, false, &source_commands)
678            .await
679            .unwrap();
680
681        let profile = fs::read_to_string(managed_shell_profile_path(&config))
682            .await
683            .unwrap();
684        for config_path in
685            super::super::get_shell_config_paths(&config.shell_type, &config.home_dir).unwrap()
686        {
687            let content = fs::read_to_string(&config_path).await.unwrap();
688            assert!(
689                content.contains(". (Join-Path $HOME 'shell/profile.ps1')"),
690                "PowerShell profile should source managed shine profile from {}: {content}",
691                config_path.display()
692            );
693        }
694        assert!(
695            profile.contains("function setproxy"),
696            "managed PowerShell profile should contain setproxy wrapper: {profile}"
697        );
698        assert!(
699            profile.contains("function usetproxy"),
700            "managed PowerShell profile should contain usetproxy wrapper: {profile}"
701        );
702
703        fs::remove_dir_all(&dir).await.unwrap();
704    }
705
706    #[tokio::test]
707    async fn append_refreshes_stale_sentinel_with_managed_profile_source() {
708        let dir = make_temp_dir().await;
709        let config = Config::new_for_test(&dir);
710        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
711        if let Some(parent) = config_path.parent() {
712            fs::create_dir_all(parent).await.unwrap();
713        }
714        let sentinel_start = super::super::SENTINEL_START;
715        let sentinel_end = "# <<< shine <<<";
716        fs::write(
717            &config_path,
718            format!(
719                "before\n\n{sentinel_start}\nif [[ \":$PATH:\" != *\":$HOME/.shine/bin:\"* ]]; then\n  export PATH=\"$HOME/.shine/bin:$PATH\"\nfi\n{sentinel_end}\nafter\n"
720            ),
721        )
722        .await
723        .unwrap();
724
725        let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
726        let update = append_path_to_shell_config(&config, false, &source_commands)
727            .await
728            .unwrap();
729
730        assert!(
731            matches!(update.config_status, PathUpdateStatus::Updated(_)),
732            "stale sentinel should be refreshed"
733        );
734        let content = fs::read_to_string(&config_path).await.unwrap();
735        assert!(
736            content.contains(managed_profile_source_marker(&config.shell_type)),
737            "shell config should source managed profile: {content}"
738        );
739        assert!(
740            !content.contains("export PATH"),
741            "stale PATH setup should be removed from shell config: {content}"
742        );
743        assert!(
744            !content.contains("setproxy()"),
745            "source wrappers should not be added directly to shell config: {content}"
746        );
747        assert!(
748            content.contains("before"),
749            "non-managed content should be preserved"
750        );
751        assert!(
752            content.contains("after"),
753            "non-managed content should be preserved"
754        );
755        let profile = fs::read_to_string(managed_shell_profile_path(&config))
756            .await
757            .unwrap();
758        let setproxy_marker = wrapper_marker("setproxy", &config.shell_type);
759        let usetproxy_marker = wrapper_marker("usetproxy", &config.shell_type);
760        assert!(
761            profile.contains(&setproxy_marker),
762            "setproxy wrapper should be added to managed profile: {profile}"
763        );
764        assert!(
765            profile.contains(&usetproxy_marker),
766            "usetproxy wrapper should be added to managed profile: {profile}"
767        );
768
769        fs::remove_dir_all(&dir).await.unwrap();
770    }
771
772    #[tokio::test]
773    async fn installed_source_commands_for_categories_are_scoped() {
774        let dir = make_temp_dir().await;
775        let config = Config::new_for_test(&dir);
776        fs::create_dir_all(config.presets_dir()).await.unwrap();
777        fs::create_dir_all(config.bin_dir()).await.unwrap();
778
779        handle_install(&config, Some("agent"), false).await.unwrap();
780        handle_install(&config, Some("proxy"), false).await.unwrap();
781
782        let proxy_only = metadata::load_installed_categories(&config, Some("proxy"))
783            .await
784            .unwrap();
785        let commands = installed_source_commands_for_categories(&config, &proxy_only)
786            .await
787            .unwrap();
788
789        assert_eq!(
790            commands,
791            vec!["setproxy".to_string(), "usetproxy".to_string()]
792        );
793        assert!(!commands.contains(&"ccenv".to_string()));
794
795        fs::remove_dir_all(&dir).await.unwrap();
796    }
797
798    #[cfg(unix)]
799    #[tokio::test]
800    async fn external_presets_install_links_disk_scripts_without_extraction() {
801        let dir = make_temp_dir().await;
802        // new_for_test sets presets_dir = dir/presets, bin_dir = dir/bin
803        // Create a script in presets_dir/shell/custom/ to simulate user-managed presets.
804        let cat_dir = dir.join("presets/shell/custom");
805        fs::create_dir_all(&cat_dir).await.unwrap();
806        let script = cat_dir.join("my_tool.sh");
807        fs::write(&script, b"#!/bin/bash\n# My tool.\necho hi\n")
808            .await
809            .unwrap();
810        use std::os::unix::fs::PermissionsExt;
811        let mut perms = fs::metadata(&script).await.unwrap().permissions();
812        perms.set_mode(perms.mode() | 0o111);
813        fs::set_permissions(&script, perms).await.unwrap();
814
815        let mut config = Config::new_for_test(&dir);
816        config.is_external_presets = true;
817        fs::create_dir_all(config.bin_dir()).await.unwrap();
818
819        handle_install(&config, Some("custom"), false)
820            .await
821            .unwrap();
822
823        // The script must NOT have been extracted from embedded assets into
824        // presets_dir — the only file there is the one we created above.
825        let count = {
826            let mut rd = fs::read_dir(&cat_dir).await.unwrap();
827            let mut n = 0u32;
828            while rd.next_entry().await.unwrap().is_some() {
829                n += 1;
830            }
831            n
832        };
833        assert_eq!(count, 1, "no embedded assets should have been extracted");
834
835        // A bin symlink for the script should have been created.
836        let link = config.bin_dir().join("my_tool");
837        assert!(link.exists(), "bin symlink should point at disk script");
838
839        fs::remove_dir_all(&dir).await.unwrap();
840    }
841
842    #[cfg(unix)]
843    #[tokio::test]
844    async fn external_presets_install_applies_metadata_rename() {
845        let dir = make_temp_dir().await;
846        let cat_dir = dir.join("presets/shell/custom");
847        fs::create_dir_all(&cat_dir).await.unwrap();
848        fs::write(
849            cat_dir.join("shine.toml"),
850            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\n",
851        )
852        .await
853        .unwrap();
854        let script = cat_dir.join("set_proxy.sh");
855        fs::write(&script, b"#!/bin/bash\n# Set proxy.\necho hi\n")
856            .await
857            .unwrap();
858        use std::os::unix::fs::PermissionsExt;
859        let mut perms = fs::metadata(&script).await.unwrap().permissions();
860        perms.set_mode(perms.mode() | 0o111);
861        fs::set_permissions(&script, perms).await.unwrap();
862
863        let mut config = Config::new_for_test(&dir);
864        config.is_external_presets = true;
865        fs::create_dir_all(config.bin_dir()).await.unwrap();
866
867        handle_install(&config, Some("custom"), false)
868            .await
869            .unwrap();
870
871        assert!(config.bin_dir().join("setproxy").exists());
872        assert!(!config.bin_dir().join("set_proxy").exists());
873
874        fs::remove_dir_all(&dir).await.unwrap();
875    }
876
877    #[cfg(unix)]
878    #[tokio::test]
879    async fn external_presets_install_links_non_executable_source_scripts() {
880        let dir = make_temp_dir().await;
881        let cat_dir = dir.join("presets/shell/proxy");
882        fs::create_dir_all(&cat_dir).await.unwrap();
883        fs::write(
884            cat_dir.join("shine.toml"),
885            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n[[files]]\nsource = \"uset_proxy.sh\"\ntarget = \"usetproxy\"\nneeds_source = true\n",
886        )
887        .await
888        .unwrap();
889        fs::write(
890            &cat_dir.join("set_proxy.sh"),
891            b"#!/bin/bash\n# Set proxy.\n",
892        )
893        .await
894        .unwrap();
895        fs::write(
896            &cat_dir.join("uset_proxy.sh"),
897            b"#!/bin/bash\n# Unset proxy.\n",
898        )
899        .await
900        .unwrap();
901
902        let mut config = Config::new_for_test(&dir);
903        config.is_external_presets = true;
904        fs::create_dir_all(config.bin_dir()).await.unwrap();
905
906        handle_install(&config, Some("proxy"), false).await.unwrap();
907
908        assert!(config.bin_dir().join("setproxy").exists());
909        assert!(config.bin_dir().join("usetproxy").exists());
910
911        fs::remove_dir_all(&dir).await.unwrap();
912    }
913
914    #[tokio::test]
915    async fn init_template_creates_parseable_shell_metadata() {
916        let dir = make_temp_dir().await;
917        let cat_dir = dir.join("presets/shell/custom");
918        fs::create_dir_all(&cat_dir).await.unwrap();
919
920        let (path, overwritten) =
921            utils::init_template::write_shine_toml_template(&cat_dir, false, SHELL_TEMPLATE)
922                .unwrap();
923        fs::write(
924            cat_dir.join("my_tool.sh"),
925            b"#!/bin/bash\n# My tool.\necho hi\n",
926        )
927        .await
928        .unwrap();
929
930        let config = Config::new_for_test(&dir);
931        let categories = metadata::load_installed_categories(&config, Some("custom"))
932            .await
933            .unwrap();
934
935        assert_eq!(path, cat_dir.join("shine.toml"));
936        assert!(!overwritten);
937        assert_eq!(categories.len(), 1);
938        assert_eq!(
939            categories[0].description.as_deref(),
940            Some("My shell helper commands.")
941        );
942        assert_eq!(
943            categories[0].files[0].source_rel,
944            PathBuf::from("my_tool.sh")
945        );
946        assert_eq!(categories[0].files[0].command_name, "mytool");
947        assert!(!categories[0].files[0].needs_source);
948
949        fs::remove_dir_all(&dir).await.unwrap();
950    }
951
952    #[tokio::test]
953    async fn init_template_refuses_existing_file_unless_forced() {
954        let dir = make_temp_dir().await;
955        fs::write(dir.join("shine.toml"), b"old").await.unwrap();
956
957        let err = utils::init_template::write_shine_toml_template(&dir, false, SHELL_TEMPLATE)
958            .unwrap_err();
959        assert!(
960            err.to_string().contains("use --force to overwrite"),
961            "unexpected error: {err:#}"
962        );
963        assert_eq!(fs::read(dir.join("shine.toml")).await.unwrap(), b"old");
964
965        let (_path, overwritten) =
966            utils::init_template::write_shine_toml_template(&dir, true, SHELL_TEMPLATE).unwrap();
967        assert!(overwritten);
968        let content = fs::read_to_string(dir.join("shine.toml")).await.unwrap();
969        assert!(content.contains("target = \"mytool\""));
970
971        fs::remove_dir_all(&dir).await.unwrap();
972    }
973
974    #[cfg(unix)]
975    #[tokio::test]
976    async fn template_render_error_does_not_link_raw_script() {
977        let dir = make_temp_dir().await;
978        let cat_dir = dir.join("presets/shell/proxy");
979        fs::create_dir_all(&cat_dir).await.unwrap();
980        fs::write(
981            cat_dir.join("shine.toml"),
982            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
983        )
984        .await
985        .unwrap();
986        let script = cat_dir.join("set_proxy.sh");
987        fs::write(
988            &script,
989            b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
990        )
991        .await
992        .unwrap();
993        make_executable(&script).await;
994
995        let mut config = Config::new_for_test(&dir);
996        config.is_external_presets = true;
997        fs::create_dir_all(config.bin_dir()).await.unwrap();
998        fs::write(config.rendered_dir(), b"not a directory")
999            .await
1000            .unwrap();
1001
1002        let err = handle_install(&config, Some("proxy"), false)
1003            .await
1004            .expect_err("install should fail when rendered_dir cannot be created");
1005
1006        assert!(
1007            err.to_string()
1008                .contains("creating rendered script directory"),
1009            "unexpected error: {err:#}"
1010        );
1011        assert!(
1012            !config.bin_dir().join("setproxy").exists(),
1013            "failed render must not link the raw template script"
1014        );
1015
1016        fs::remove_dir_all(&dir).await.unwrap();
1017    }
1018
1019    #[tokio::test]
1020    async fn embedded_agent_installs_bun_launcher_without_rendering_credentials() {
1021        let dir = make_temp_dir().await;
1022        let config = Config::new_for_test(&dir);
1023        fs::create_dir_all(config.presets_dir()).await.unwrap();
1024        fs::create_dir_all(config.bin_dir()).await.unwrap();
1025
1026        handle_install(&config, Some("agent"), false).await.unwrap();
1027
1028        let source = config.presets_dir().join("shell/agent/cc.ts");
1029        assert!(source.exists());
1030        assert!(!config.rendered_dir().join("shell/agent/cc.ts").exists());
1031        let launcher = crate::bin_links::command_path_for_name(
1032            config.bin_dir(),
1033            std::ffi::OsStr::new("ccenv"),
1034        );
1035        let launcher_content = fs::read_to_string(&launcher).await.unwrap();
1036        assert!(launcher_content.contains("shine-managed"));
1037        assert!(launcher_content.contains(&source.display().to_string()));
1038        assert!(launcher_content.contains("bun"));
1039
1040        let source_commands = installed_source_commands(&config).await.unwrap();
1041        assert!(!source_commands.contains(&"ccenv".to_string()));
1042
1043        fs::remove_dir_all(&dir).await.unwrap();
1044    }
1045
1046    #[cfg(unix)]
1047    #[tokio::test]
1048    async fn external_presets_upgrade_does_not_install_preset_only_scripts() {
1049        let dir = make_temp_dir().await;
1050        let proxy_dir = dir.join("presets/shell/proxy");
1051        let extra_dir = dir.join("presets/shell/extra");
1052        fs::create_dir_all(&proxy_dir).await.unwrap();
1053        fs::create_dir_all(&extra_dir).await.unwrap();
1054
1055        fs::write(
1056            proxy_dir.join("shine.toml"),
1057            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1058        )
1059        .await
1060        .unwrap();
1061        let setproxy = proxy_dir.join("set_proxy.sh");
1062        fs::write(
1063            &setproxy,
1064            b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
1065        )
1066        .await
1067        .unwrap();
1068        make_executable(&setproxy).await;
1069
1070        let extra_tool = extra_dir.join("extra_tool.sh");
1071        fs::write(&extra_tool, b"#!/bin/bash\n# Extra tool.\necho extra\n")
1072            .await
1073            .unwrap();
1074        make_executable(&extra_tool).await;
1075
1076        let mut config = Config::new_for_test(&dir);
1077        config.is_external_presets = true;
1078        fs::create_dir_all(config.bin_dir()).await.unwrap();
1079
1080        handle_install(&config, Some("proxy"), false).await.unwrap();
1081        assert!(config.bin_dir().join("setproxy").exists());
1082        assert!(
1083            !config.bin_dir().join("extra_tool").exists(),
1084            "extra preset should start as present but not installed"
1085        );
1086
1087        fs::write(
1088            &setproxy,
1089            b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
1090        )
1091        .await
1092        .unwrap();
1093        make_executable(&setproxy).await;
1094
1095        let mut sep = crate::output::SectionSeparator::new();
1096        let report = handle_upgrade_installed(&config, false, &mut sep)
1097            .await
1098            .unwrap();
1099
1100        assert_eq!(
1101            report.templates_updated, 1,
1102            "changed shell template should be reported under shell presets"
1103        );
1104        assert!(config.bin_dir().join("setproxy").exists());
1105        assert!(
1106            !config.bin_dir().join("extra_tool").exists(),
1107            "upgrade must not install preset-only scripts"
1108        );
1109
1110        fs::remove_dir_all(&dir).await.unwrap();
1111    }
1112
1113    #[cfg(unix)]
1114    #[tokio::test]
1115    async fn external_bun_preset_installs_launcher_and_uninstall_removes_it() {
1116        let dir = make_temp_dir().await;
1117        let cat_dir = dir.join("presets/shell/custom");
1118        fs::create_dir_all(&cat_dir).await.unwrap();
1119        fs::write(
1120            cat_dir.join("shine.toml"),
1121            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n",
1122        )
1123        .await
1124        .unwrap();
1125        // A non-executable .ts source: bun launchers do not require the exec bit.
1126        fs::write(cat_dir.join("tool.ts"), b"console.log('hi')\n")
1127            .await
1128            .unwrap();
1129
1130        let mut config = Config::new_for_test(&dir);
1131        config.is_external_presets = true;
1132        fs::create_dir_all(config.bin_dir()).await.unwrap();
1133
1134        handle_install(&config, Some("custom"), false)
1135            .await
1136            .unwrap();
1137
1138        let launcher = config.bin_dir().join("mytool");
1139        assert!(launcher.exists(), "bun launcher should be installed");
1140        assert!(!launcher.is_symlink(), "bun launcher is a regular file");
1141        let content = fs::read_to_string(&launcher).await.unwrap();
1142        assert!(content.contains("exec bun"));
1143        assert!(
1144            content.contains(
1145                &config
1146                    .installed_shell_dir()
1147                    .join("custom/tool.ts")
1148                    .display()
1149                    .to_string()
1150            )
1151        );
1152        assert!(
1153            !config.bin_dir().join("tool").exists(),
1154            "command should use the target rename, not the .ts stem"
1155        );
1156
1157        handle_uninstall(&config, Some("custom"), false, false)
1158            .await
1159            .unwrap();
1160        assert!(
1161            !launcher.exists(),
1162            "managed bun launcher must be removed on uninstall"
1163        );
1164        assert!(
1165            cat_dir.join("tool.ts").exists(),
1166            "external source must be preserved"
1167        );
1168
1169        fs::remove_dir_all(&dir).await.unwrap();
1170    }
1171
1172    #[cfg(unix)]
1173    #[tokio::test]
1174    async fn external_bun_preset_with_env_wraps_launcher_in_shine_env_run() {
1175        let dir = make_temp_dir().await;
1176        let cat_dir = dir.join("presets/shell/custom");
1177        fs::create_dir_all(&cat_dir).await.unwrap();
1178        fs::write(
1179            cat_dir.join("shine.toml"),
1180            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\nenv = [\"API_URL\", \"SERVICE_TOKEN=API_TOKEN\"]\n",
1181        )
1182        .await
1183        .unwrap();
1184        fs::write(cat_dir.join("tool.ts"), b"console.log(Bun.env.API_URL)\n")
1185            .await
1186            .unwrap();
1187
1188        let mut config = Config::new_for_test(&dir);
1189        config.is_external_presets = true;
1190        fs::create_dir_all(config.bin_dir()).await.unwrap();
1191
1192        handle_install(&config, Some("custom"), false)
1193            .await
1194            .unwrap();
1195
1196        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1197            .await
1198            .unwrap();
1199        assert!(launcher.contains("command -v shine"));
1200        assert!(launcher.contains(
1201            "exec shine env run --no-workspace --with 'API_URL' --with 'SERVICE_TOKEN=API_TOKEN' -- bun "
1202        ));
1203
1204        fs::remove_dir_all(&dir).await.unwrap();
1205    }
1206
1207    #[cfg(unix)]
1208    #[tokio::test]
1209    async fn external_bun_preset_with_template_transform_targets_rendered_copy() {
1210        let dir = make_temp_dir().await;
1211        let cat_dir = dir.join("presets/shell/custom");
1212        fs::create_dir_all(&cat_dir).await.unwrap();
1213        fs::write(
1214            cat_dir.join("shine.toml"),
1215            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n",
1216        )
1217        .await
1218        .unwrap();
1219        fs::write(cat_dir.join("tool.ts"), b"const host = '@@PROXY_HOST@@'\n")
1220            .await
1221            .unwrap();
1222
1223        let mut config = Config::new_for_test(&dir);
1224        config.is_external_presets = true;
1225        config
1226            .env
1227            .insert("PROXY_HOST".into(), "proxy.example".into());
1228        fs::create_dir_all(config.bin_dir()).await.unwrap();
1229
1230        handle_install(&config, Some("custom"), false)
1231            .await
1232            .unwrap();
1233
1234        let rendered = config.rendered_dir().join("shell/custom/tool.ts");
1235        assert!(
1236            rendered.exists(),
1237            "template transform should render the .ts"
1238        );
1239        assert!(
1240            fs::read_to_string(&rendered)
1241                .await
1242                .unwrap()
1243                .contains("proxy.example"),
1244            "rendered bun script should have @@PROXY_HOST@@ substituted"
1245        );
1246        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1247            .await
1248            .unwrap();
1249        assert!(
1250            launcher.contains(&rendered.display().to_string()),
1251            "launcher must target the rendered copy: {launcher}"
1252        );
1253
1254        fs::remove_dir_all(&dir).await.unwrap();
1255    }
1256
1257    #[cfg(unix)]
1258    #[tokio::test]
1259    async fn live_transformed_bun_renders_again_on_demand() {
1260        let dir = make_temp_dir().await;
1261        let cat_dir = dir.join("presets/shell/custom");
1262        fs::create_dir_all(&cat_dir).await.unwrap();
1263        fs::write(
1264            cat_dir.join("shine.toml"),
1265            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n",
1266        )
1267        .await
1268        .unwrap();
1269        let source = cat_dir.join("tool.ts");
1270        fs::write(&source, b"console.log('@@PROXY_HOST@@')\n")
1271            .await
1272            .unwrap();
1273
1274        let mut config = Config::new_for_test(&dir);
1275        config.is_external_presets = true;
1276        config.external_shell_mode = crate::config::ExternalShellMode::Live;
1277        config
1278            .env
1279            .insert("PROXY_HOST".into(), "first.example".into());
1280        fs::create_dir_all(config.bin_dir()).await.unwrap();
1281        handle_install(&config, Some("custom"), false)
1282            .await
1283            .unwrap();
1284
1285        let rendered = config.rendered_dir().join("shell/custom/tool.ts");
1286        assert!(
1287            fs::read_to_string(&rendered)
1288                .await
1289                .unwrap()
1290                .contains("first.example")
1291        );
1292        config
1293            .env
1294            .insert("PROXY_HOST".into(), "second.example".into());
1295        crate::shells::deployment::handle_render_live(&config, "shell/custom/mytool")
1296            .await
1297            .unwrap();
1298        assert!(
1299            fs::read_to_string(&rendered)
1300                .await
1301                .unwrap()
1302                .contains("second.example")
1303        );
1304        let last_good = fs::read(&rendered).await.unwrap();
1305        fs::write(&source, b"console.log('@@MISSING_LIVE_VALUE@@')\n")
1306            .await
1307            .unwrap();
1308        assert!(
1309            crate::shells::deployment::handle_render_live(&config, "shell/custom/mytool")
1310                .await
1311                .is_err()
1312        );
1313        assert_eq!(
1314            fs::read(&rendered).await.unwrap(),
1315            last_good,
1316            "failed live transform must preserve the last-known-good output"
1317        );
1318
1319        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1320            .await
1321            .unwrap();
1322        assert!(launcher.contains("__shell-render"));
1323        assert!(launcher.contains("--config-dir"));
1324        fs::remove_dir_all(&dir).await.unwrap();
1325    }
1326
1327    #[cfg(unix)]
1328    #[tokio::test]
1329    async fn snapshot_upgrade_applies_external_raw_source_change() {
1330        let dir = make_temp_dir().await;
1331        let cat_dir = dir.join("presets/shell/custom");
1332        fs::create_dir_all(&cat_dir).await.unwrap();
1333        fs::write(
1334            cat_dir.join("shine.toml"),
1335            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1336        )
1337        .await
1338        .unwrap();
1339        let source = cat_dir.join("tool.sh");
1340        fs::write(&source, b"#!/bin/sh\necho first\n")
1341            .await
1342            .unwrap();
1343
1344        let mut config = Config::new_for_test(&dir);
1345        config.is_external_presets = true;
1346        fs::create_dir_all(config.bin_dir()).await.unwrap();
1347        handle_install(&config, Some("custom"), false)
1348            .await
1349            .unwrap();
1350        let installed = config.installed_shell_dir().join("custom/tool.sh");
1351        assert!(
1352            fs::read_to_string(&installed)
1353                .await
1354                .unwrap()
1355                .contains("first")
1356        );
1357
1358        fs::write(&source, b"#!/bin/sh\necho second\n")
1359            .await
1360            .unwrap();
1361        let mut separator = crate::output::SectionSeparator::new();
1362        let report = handle_upgrade_installed(&config, false, &mut separator)
1363            .await
1364            .unwrap();
1365        assert_eq!(report.snapshots_updated, 1);
1366        assert!(
1367            fs::read_to_string(&installed)
1368                .await
1369                .unwrap()
1370                .contains("second")
1371        );
1372        assert_eq!(
1373            fs::read_link(config.bin_dir().join("mytool"))
1374                .await
1375                .unwrap(),
1376            installed
1377        );
1378        fs::remove_dir_all(&dir).await.unwrap();
1379    }
1380
1381    #[cfg(unix)]
1382    #[tokio::test]
1383    async fn upgrade_migrates_legacy_external_link_to_snapshot() {
1384        let dir = make_temp_dir().await;
1385        let cat_dir = dir.join("presets/shell/custom");
1386        fs::create_dir_all(&cat_dir).await.unwrap();
1387        fs::write(
1388            cat_dir.join("shine.toml"),
1389            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1390        )
1391        .await
1392        .unwrap();
1393        let source = cat_dir.join("tool.sh");
1394        fs::write(&source, b"#!/bin/sh\necho legacy\n")
1395            .await
1396            .unwrap();
1397        let mut config = Config::new_for_test(&dir);
1398        config.is_external_presets = true;
1399        fs::create_dir_all(config.bin_dir()).await.unwrap();
1400        fs::symlink(&source, config.bin_dir().join("mytool"))
1401            .await
1402            .unwrap();
1403
1404        let mut separator = crate::output::SectionSeparator::new();
1405        let report = handle_upgrade_installed(&config, false, &mut separator)
1406            .await
1407            .unwrap();
1408        assert_eq!(report.snapshots_updated, 1);
1409        assert_eq!(
1410            fs::read_link(config.bin_dir().join("mytool"))
1411                .await
1412                .unwrap(),
1413            config.installed_shell_dir().join("custom/tool.sh")
1414        );
1415        assert!(
1416            crate::shells::deployment::ShellManifest::load(&config)
1417                .await
1418                .unwrap()
1419                .find("shell/custom/mytool")
1420                .is_some()
1421        );
1422        fs::remove_dir_all(&dir).await.unwrap();
1423    }
1424
1425    #[cfg(unix)]
1426    #[tokio::test]
1427    async fn upgrade_switches_snapshot_raw_link_to_explicit_live_source() {
1428        let dir = make_temp_dir().await;
1429        let cat_dir = dir.join("presets/shell/custom");
1430        fs::create_dir_all(&cat_dir).await.unwrap();
1431        fs::write(
1432            cat_dir.join("shine.toml"),
1433            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1434        )
1435        .await
1436        .unwrap();
1437        let source = cat_dir.join("tool.sh");
1438        fs::write(&source, b"#!/bin/sh\necho live\n").await.unwrap();
1439        let mut config = Config::new_for_test(&dir);
1440        config.is_external_presets = true;
1441        fs::create_dir_all(config.bin_dir()).await.unwrap();
1442        handle_install(&config, Some("custom"), false)
1443            .await
1444            .unwrap();
1445
1446        config.external_shell_mode = crate::config::ExternalShellMode::Live;
1447        let mut separator = crate::output::SectionSeparator::new();
1448        handle_upgrade_installed(&config, false, &mut separator)
1449            .await
1450            .unwrap();
1451        assert_eq!(
1452            fs::read_link(config.bin_dir().join("mytool"))
1453                .await
1454                .unwrap(),
1455            source
1456        );
1457        let manifest = crate::shells::deployment::ShellManifest::load(&config)
1458            .await
1459            .unwrap();
1460        assert_eq!(
1461            manifest.find("shell/custom/mytool").unwrap().mode,
1462            crate::config::ExternalShellMode::Live
1463        );
1464        fs::remove_dir_all(&dir).await.unwrap();
1465    }
1466}