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