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 = ["macos"]    # exact: macos/linux/windows; unix groups macOS + Linux
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, target: Option<&str>, force: bool) -> Result<()> {
56    crate::config::print_presets_note(config);
57    let selection = target.map(metadata::parse_lifecycle_target).transpose()?;
58    let category_filter = selection.map(|target| target.category);
59    let mut categories = match selection {
60        Some(target) => metadata::load_active_target(config, target).await?,
61        None => metadata::load_active_categories(config, None).await?,
62    };
63    if categories.is_empty() {
64        anyhow::bail!("no shell preset categories found");
65    }
66
67    let prefix = match category_filter {
68        Some(category) => format!("shell/{category}"),
69        None => "shell".to_string(),
70    };
71
72    // When using the default presets directory, extract the embedded assets first.
73    if !config.is_external_presets {
74        let report = crate::presets::extract_prefix(&prefix, config.presets_dir(), force).await?;
75        output::summary_line("Shell Presets", &preset_extract_summary_parts(&report));
76    }
77
78    // Embedded extraction populates the installed preset cache but must not expand a
79    // command-scoped selection into every command in its category.
80    if let Some(selection) = selection {
81        categories = metadata::load_active_target(config, selection).await?;
82    }
83    super::deployment::validate_snapshot_categories(config, &categories).await?;
84    let snapshots_updated =
85        super::deployment::materialize_snapshot_categories(config, &categories).await?;
86    if config.is_external_presets
87        && config.external_shell_mode == crate::config::ExternalShellMode::Snapshot
88    {
89        let summary = if snapshots_updated > 0 {
90            colors::green(&format!("{snapshots_updated} updated"))
91        } else {
92            colors::dim("up to date")
93        };
94        output::summary_line("Shell Snapshots", &[summary]);
95    }
96    // Build (template_source, rendered_dest) pairs for all scripts.
97    // apply_template_to_scripts renders source → rendered_dir, never modifies presets_dir.
98    let script_pairs = build_script_pairs(config, &categories);
99
100    // Apply env-variable substitution to scripts that opt in via `# shine-template: true`.
101    // Output goes to rendered_dir; presets_dir templates are left untouched.
102    apply_template_to_scripts(config, &script_pairs).await?;
103
104    // Symlinks point to the rendered file when one was produced, otherwise to the
105    // raw source in presets_dir (non-template scripts).
106    let link_specs = build_link_specs(config, &categories)?;
107    let link_report =
108        crate::bin_links::link_executables_with_names(config.bin_dir(), &link_specs, force).await?;
109    let manifest_scope = if selection.is_some_and(|target| target.command.is_some()) {
110        super::deployment::ManifestUpdateScope::Commands
111    } else {
112        super::deployment::ManifestUpdateScope::Categories
113    };
114    super::deployment::update_manifest(config, &categories, manifest_scope).await?;
115
116    output::summary_line("Bin Links", &link_report_summary_parts(&link_report));
117    print_link_conflicts(config, &link_report.conflicts, category_filter);
118
119    let source_commands = installed_source_commands(config).await?;
120    let installed_commands = installed_source_commands_for_categories(config, &categories).await?;
121
122    let shell_config_path = get_shell_config_path(&config.shell_type, &config.home_dir)?;
123    let shell_update = append_path_to_shell_config(config, force, &source_commands).await?;
124    let profile_path = managed_shell_profile_path(config);
125    if shell_update.profile_updated {
126        output::detail_line(
127            "Shell Profile",
128            &colors::green("updated"),
129            Some(profile_path.display().to_string()),
130        );
131    }
132    match shell_update.config_status {
133        PathUpdateStatus::AlreadyConfigured => {
134            output::detail_line(
135                "Shell Config",
136                &colors::dim("up to date"),
137                Some(shell_config_path.display().to_string()),
138            );
139        }
140        PathUpdateStatus::Updated(path) => {
141            output::detail_line(
142                "Shell Config",
143                &colors::green("updated"),
144                Some(path.display().to_string()),
145            );
146        }
147    }
148    print_source_command_activation_hint(config, &shell_config_path, &installed_commands);
149    Ok(())
150}
151
152/// Resolve and validate a shell installation plan without extracting presets,
153/// rendering templates, creating links, updating manifests, or editing shell
154/// profiles.
155pub async fn handle_install_dry_run(config: &Config, target: Option<&str>) -> Result<()> {
156    crate::config::print_presets_note(config);
157    let selection = target.map(metadata::parse_lifecycle_target).transpose()?;
158    let categories = match selection {
159        Some(target) => metadata::load_active_target(config, target).await?,
160        None => metadata::load_active_categories(config, None).await?,
161    };
162    if categories.is_empty() {
163        anyhow::bail!("no shell preset categories found");
164    }
165    super::deployment::validate_snapshot_categories(config, &categories).await?;
166    let specs = build_link_specs(config, &categories)?;
167    let mut command_names = BTreeSet::new();
168    for spec in &specs {
169        let command = spec.link_name.to_string_lossy().to_string();
170        if !command_names.insert(command.clone()) {
171            anyhow::bail!("duplicate requested shell command: {command}");
172        }
173        let target = crate::bin_links::command_path_for_name(config.bin_dir(), &spec.link_name);
174        println!(
175            "Would link shell command {command}: {} -> {}",
176            target.display(),
177            spec.source.display()
178        );
179    }
180    println!("Dry run: no shell files, links, manifests, or profiles were changed.");
181    Ok(())
182}
183
184pub async fn handle_upgrade_installed(
185    config: &Config,
186    verbose: bool,
187    sep: &mut crate::output::SectionSeparator,
188) -> Result<ShellUpgradeReport> {
189    handle_upgrade_installed_target(config, None, verbose, sep).await
190}
191
192pub async fn handle_upgrade_installed_target(
193    config: &Config,
194    category_filter: Option<&str>,
195    verbose: bool,
196    sep: &mut crate::output::SectionSeparator,
197) -> Result<ShellUpgradeReport> {
198    let all_categories = if config.is_external_presets {
199        metadata::load_installed_categories(config, None).await?
200    } else {
201        metadata::load_embedded_categories(None)?
202    };
203    let shell_manifest = super::deployment::ShellManifest::load(config).await?;
204
205    let installed_commands: Vec<(String, String)> = all_categories
206        .iter()
207        .filter(|cat| category_filter.is_none_or(|filter| cat.name == filter))
208        .flat_map(|cat| {
209            cat.files.iter().filter_map(|file| {
210                let link = crate::bin_links::command_path_for_name(
211                    config.bin_dir(),
212                    std::ffi::OsStr::new(&file.command_name),
213                );
214                let canonical = format!("shell/{}/{}", cat.name, file.command_name);
215                (shell_link_exists(&link) || shell_manifest.find(&canonical).is_some())
216                    .then(|| (cat.name.clone(), file.command_name.clone()))
217            })
218        })
219        .collect();
220
221    if installed_commands.is_empty() {
222        if let Some(category) = category_filter {
223            anyhow::bail!("shell preset is not installed: {category}");
224        }
225        if verbose {
226            println!("{}", colors::dim("No installed shell presets found."));
227        }
228        return Ok(ShellUpgradeReport::default());
229    }
230
231    let installed_categories: std::collections::BTreeSet<String> = installed_commands
232        .iter()
233        .map(|(cat_name, _)| cat_name.clone())
234        .collect();
235
236    let pending_targets = pending_upgrade_targets(config, &installed_commands).await?;
237
238    if !config.is_external_presets {
239        for category in &installed_categories {
240            let prefix = format!("shell/{category}");
241            let _ = crate::presets::extract_prefix(&prefix, config.presets_dir(), true).await?;
242        }
243    }
244
245    let categories = metadata::load_installed_categories(config, None).await?;
246    let mut categories: Vec<_> = categories
247        .into_iter()
248        .filter(|cat| installed_categories.contains(&cat.name))
249        .collect();
250    for cat in &mut categories {
251        cat.files.retain(|file| {
252            installed_commands.contains(&(cat.name.clone(), file.command_name.clone()))
253        });
254    }
255
256    super::deployment::validate_snapshot_categories(config, &categories).await?;
257    let snapshots_updated =
258        super::deployment::materialize_snapshot_categories(config, &categories).await?;
259
260    let script_pairs = build_script_pairs(config, &categories);
261    let template_report = apply_template_to_scripts(config, &script_pairs).await?;
262
263    let link_specs = build_link_specs(config, &categories)?;
264    let link_report =
265        crate::bin_links::link_executables_with_names(config.bin_dir(), &link_specs, true).await?;
266    super::deployment::update_manifest(
267        config,
268        &categories,
269        super::deployment::ManifestUpdateScope::Categories,
270    )
271    .await?;
272
273    let link_parts = upgrade_link_report_summary_parts(&link_report, verbose);
274
275    let source_commands = installed_source_commands(config).await?;
276
277    let shell_update = append_path_to_shell_config(config, false, &source_commands).await?;
278    let updated_shell_config = match shell_update.config_status {
279        PathUpdateStatus::AlreadyConfigured => None,
280        PathUpdateStatus::Updated(path) => Some(path),
281    };
282
283    let remaining_targets = pending_upgrade_targets(config, &installed_commands).await?;
284    let mut updated_targets = pending_targets
285        .difference(&remaining_targets)
286        .cloned()
287        .collect::<BTreeSet<_>>();
288    updated_targets.extend(template_report.updated.iter().cloned());
289    for link in link_report.created.iter().chain(&link_report.overwritten) {
290        let Some(command) = link.file_name().and_then(|name| name.to_str()) else {
291            continue;
292        };
293        updated_targets.extend(
294            installed_commands
295                .iter()
296                .filter(|(_, installed_command)| installed_command == command)
297                .map(|(category, installed_command)| format!("{category}/{installed_command}")),
298        );
299    }
300    let updated_targets = updated_targets.into_iter().collect::<Vec<_>>();
301    let updated_categories = lifecycle_categories(&updated_targets);
302
303    let has_visible_result = should_print_upgrade_section(
304        verbose,
305        !updated_categories.is_empty(),
306        !link_report.conflicts.is_empty(),
307        updated_shell_config.is_some(),
308    );
309    if has_visible_result {
310        sep.begin();
311        if verbose {
312            output::summary_line(
313                "Shell Presets",
314                &[colors::dim(&format!(
315                    "{} installed categories",
316                    installed_categories.len()
317                ))],
318            );
319        } else {
320            println!("{}", colors::bold("Shell Presets"));
321        }
322
323        for category in &updated_categories {
324            println!("  {} {category}", colors::symbol("✓"));
325        }
326        if verbose && snapshots_updated > 0 {
327            println!(
328                "  {} {}",
329                colors::symbol("✓"),
330                colors::green(&format!("{snapshots_updated} snapshot(s) updated"))
331            );
332        }
333        if verbose && !template_report.updated.is_empty() {
334            output::summary_line(
335                "Templates",
336                &[colors::green(&format!(
337                    "{} rendered",
338                    template_report.updated.len()
339                ))],
340            );
341        }
342        if should_print_link_summary(verbose, link_report.conflicts.len()) {
343            if verbose && !link_parts.is_empty() {
344                output::summary_line("Bin Links", &link_parts);
345            } else if !link_report.conflicts.is_empty() {
346                output::summary_line(
347                    "Bin Links",
348                    &[colors::yellow(&format!(
349                        "{} conflicts",
350                        link_report.conflicts.len()
351                    ))],
352                );
353            }
354        }
355        print_link_conflicts(config, &link_report.conflicts, None);
356        if let Some(path) = &updated_shell_config {
357            output::detail_line(
358                "Shell Config",
359                &colors::green("updated"),
360                Some(path.display().to_string()),
361            );
362        }
363    }
364
365    Ok(ShellUpgradeReport {
366        updated_targets,
367        updated_categories,
368        snapshots_updated,
369        templates_updated: template_report.updated.len(),
370        links_created: link_report.created.len(),
371        links_updated: link_report.overwritten.len(),
372        link_conflicts: link_report.conflicts.len(),
373        path_changed: updated_shell_config.is_some(),
374    })
375}
376
377fn should_print_upgrade_section(
378    verbose: bool,
379    targets_updated: bool,
380    has_link_conflict: bool,
381    path_changed: bool,
382) -> bool {
383    verbose || targets_updated || has_link_conflict || path_changed
384}
385
386fn should_print_link_summary(verbose: bool, conflict_count: usize) -> bool {
387    verbose || conflict_count > 0
388}
389
390fn lifecycle_categories(targets: &[String]) -> Vec<String> {
391    targets
392        .iter()
393        .filter_map(|target| {
394            target
395                .split_once('/')
396                .map(|(category, _)| category.to_string())
397        })
398        .collect::<BTreeSet<_>>()
399        .into_iter()
400        .collect()
401}
402
403async fn pending_upgrade_targets(
404    config: &Config,
405    installed_commands: &[(String, String)],
406) -> Result<BTreeSet<String>> {
407    let installed_targets = installed_commands
408        .iter()
409        .map(|(category, command)| format!("{category}/{command}"))
410        .collect::<BTreeSet<_>>();
411    Ok(crate::status::build_shell_rows(config)
412        .await?
413        .into_iter()
414        .filter(|row| row.status_sym == "↑" && installed_targets.contains(&row.label))
415        .map(|row| row.label)
416        .collect())
417}
418
419pub async fn handle_completion_install(config: &Config) -> Result<()> {
420    let source_commands = installed_source_commands(config).await?;
421    let shell_config_path = get_shell_config_path(&config.shell_type, &config.home_dir)?;
422    let shell_update = append_path_to_shell_config(config, false, &source_commands).await?;
423    let profile_path = managed_shell_profile_path(config);
424
425    if shell_update.profile_updated {
426        output::detail_line(
427            "Shell Profile",
428            &colors::green("updated"),
429            Some(profile_path.display().to_string()),
430        );
431    } else {
432        output::detail_line(
433            "Shell Profile",
434            &colors::dim("up to date"),
435            Some(profile_path.display().to_string()),
436        );
437    }
438
439    match shell_update.config_status {
440        PathUpdateStatus::AlreadyConfigured => {
441            output::detail_line(
442                "Shell Config",
443                &colors::dim("up to date"),
444                Some(shell_config_path.display().to_string()),
445            );
446        }
447        PathUpdateStatus::Updated(path) => {
448            output::detail_line(
449                "Shell Config",
450                &colors::green("updated"),
451                Some(path.display().to_string()),
452            );
453        }
454    }
455
456    if !super::profile::supports_completion_registration(&config.shell_type) {
457        let shell: &'static str = config.shell_type.into();
458        output::detail_line(
459            "Completion",
460            &colors::yellow("unsupported"),
461            Some(format!("{shell}; PATH setup was installed")),
462        );
463    }
464
465    output::hint_line(
466        "Next Step",
467        &format!(
468            "run `{}` once, or open a new shell",
469            shell_source_command(&config.shell_type, &shell_config_path)
470        ),
471    );
472    Ok(())
473}
474
475fn shell_link_exists(link: &Path) -> bool {
476    link.exists()
477        || std::fs::symlink_metadata(link)
478            .map(|meta| meta.file_type().is_symlink())
479            .unwrap_or(false)
480}
481
482/// For each script that declares `# shine-template: true`, read the template from
483/// `source_path` (presets_dir — never modified), substitute env variables from
484fn build_script_pairs(
485    config: &Config,
486    categories: &[metadata::ShellCategory],
487) -> Vec<ScriptTemplate> {
488    categories
489        .iter()
490        .flat_map(|cat| {
491            cat.files.iter().map(|file| {
492                let source =
493                    super::deployment::deployment_source_path(config, &cat.name, &file.source_rel);
494                let rendered =
495                    super::deployment::rendered_path(config, &cat.name, &file.source_rel);
496                ScriptTemplate {
497                    source_path: source,
498                    rendered_path: rendered,
499                    display_name: format!("{}/{}", cat.name, file.command_name),
500                    transforms: file.transforms.clone(),
501                }
502            })
503        })
504        .collect()
505}
506
507pub(super) async fn installed_source_commands(config: &Config) -> Result<Vec<String>> {
508    let categories = metadata::load_installed_categories(config, None).await?;
509    installed_source_commands_for_categories(config, &categories).await
510}
511
512async fn installed_source_commands_for_categories(
513    config: &Config,
514    categories: &[metadata::ShellCategory],
515) -> Result<Vec<String>> {
516    let mut commands = categories
517        .iter()
518        .flat_map(|cat| cat.files.iter())
519        .filter(|file| file.needs_source)
520        .filter(|file| {
521            let link = crate::bin_links::command_path_for_name(
522                config.bin_dir(),
523                std::ffi::OsStr::new(&file.command_name),
524            );
525            shell_link_exists(&link)
526        })
527        .map(|file| file.command_name.clone())
528        .collect::<Vec<_>>();
529    commands.sort();
530    commands.dedup();
531    Ok(commands)
532}
533
534#[cfg(test)]
535mod tests {
536    use super::super::ShellType;
537    #[cfg(unix)]
538    use super::super::uninstall::handle_uninstall;
539    use super::*;
540    use crate::config::Config;
541    use std::path::PathBuf;
542    use tokio::fs;
543
544    #[test]
545    fn upgrade_section_hides_no_op_by_default_and_shows_verbose_or_changes() {
546        assert!(!should_print_upgrade_section(false, false, false, false));
547        assert!(should_print_upgrade_section(true, false, false, false));
548        assert!(should_print_upgrade_section(false, true, false, false));
549        assert!(should_print_upgrade_section(false, false, true, false));
550        assert!(should_print_upgrade_section(false, false, false, true));
551    }
552
553    #[test]
554    fn bin_link_summary_is_verbose_only_unless_there_is_a_conflict() {
555        assert!(!should_print_link_summary(false, 0));
556        assert!(should_print_link_summary(true, 0));
557        assert!(should_print_link_summary(false, 1));
558    }
559
560    #[test]
561    fn lifecycle_categories_count_each_shell_category_once() {
562        let targets = vec![
563            "proxy/setproxy".to_string(),
564            "proxy/usetproxy".to_string(),
565            "utils/copyfile".to_string(),
566        ];
567
568        assert_eq!(lifecycle_categories(&targets), vec!["proxy", "utils"]);
569    }
570
571    async fn make_temp_dir() -> PathBuf {
572        crate::test_support::make_temp_dir("shine-shell").await
573    }
574
575    #[tokio::test]
576    async fn install_dry_run_does_not_materialize_shell_state() {
577        let dir = make_temp_dir().await;
578        let category = dir.join("presets/shell/custom");
579        fs::create_dir_all(&category).await.unwrap();
580        fs::write(
581            category.join("shine.toml"),
582            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"tool\"\n",
583        )
584        .await
585        .unwrap();
586        fs::write(category.join("tool.sh"), b"#!/bin/sh\necho tool\n")
587            .await
588            .unwrap();
589        let mut config = Config::new_for_test(&dir);
590        config.is_external_presets = true;
591
592        handle_install_dry_run(&config, Some("custom"))
593            .await
594            .unwrap();
595
596        assert!(!config.bin_dir().exists());
597        assert!(!config.shine_dir().join("installed/shell").exists());
598        assert!(!config.shine_dir().join("shell-manifest.toml").exists());
599        assert!(!config.home_dir.join(".zshrc").exists());
600        fs::remove_dir_all(&dir).await.unwrap();
601    }
602
603    #[tokio::test]
604    async fn command_scoped_install_activates_only_selected_command() {
605        let dir = make_temp_dir().await;
606        let config = Config::new_for_test(&dir);
607        fs::create_dir_all(config.bin_dir()).await.unwrap();
608
609        handle_install(&config, Some("utils/shine-env-export"), false)
610            .await
611            .unwrap();
612
613        let selected = crate::bin_links::command_path_for_name(
614            config.bin_dir(),
615            std::ffi::OsStr::new("shine-env-export"),
616        );
617        let sibling = crate::bin_links::command_path_for_name(
618            config.bin_dir(),
619            std::ffi::OsStr::new("shine-theme-sync"),
620        );
621        assert!(selected.exists());
622        assert!(!sibling.exists());
623
624        let manifest = crate::shells::deployment::ShellManifest::load(&config)
625            .await
626            .unwrap();
627        assert!(manifest.find("shell/utils/shine-env-export").is_some());
628        assert!(manifest.find("shell/utils/shine-theme-sync").is_none());
629
630        let rows = crate::status::build_shell_rows(&config).await.unwrap();
631        let selected_row = rows
632            .iter()
633            .find(|row| row.label == "utils/shine-env-export")
634            .unwrap();
635        let sibling_row = rows
636            .iter()
637            .find(|row| row.label == "utils/shine-theme-sync")
638            .unwrap();
639        assert!(selected_row.is_installed);
640        assert!(!sibling_row.is_installed);
641        assert_eq!(sibling_row.status_text, "not installed");
642
643        fs::remove_dir_all(&dir).await.unwrap();
644    }
645
646    #[tokio::test]
647    async fn command_scoped_install_preserves_sibling_manifest_entries() {
648        let dir = make_temp_dir().await;
649        let config = Config::new_for_test(&dir);
650        fs::create_dir_all(config.bin_dir()).await.unwrap();
651
652        handle_install(&config, Some("utils/shine-env-export"), false)
653            .await
654            .unwrap();
655        handle_install(&config, Some("utils/shine-theme-sync"), false)
656            .await
657            .unwrap();
658
659        let manifest = crate::shells::deployment::ShellManifest::load(&config)
660            .await
661            .unwrap();
662        assert!(manifest.find("shell/utils/shine-env-export").is_some());
663        assert!(manifest.find("shell/utils/shine-theme-sync").is_some());
664
665        fs::remove_dir_all(&dir).await.unwrap();
666    }
667
668    #[tokio::test]
669    async fn command_scoped_install_rejects_unknown_targets_before_writing() {
670        let dir = make_temp_dir().await;
671        let config = Config::new_for_test(&dir);
672
673        let error = handle_install(&config, Some("utils/not-a-command"), false)
674            .await
675            .unwrap_err()
676            .to_string();
677
678        assert!(error.contains("shell preset command not found: utils/not-a-command"));
679        assert!(!config.bin_dir().exists());
680        assert!(!config.presets_dir().join("shell/utils").exists());
681
682        fs::remove_dir_all(&dir).await.unwrap();
683    }
684
685    #[tokio::test]
686    async fn category_upgrade_repairs_only_installed_commands() {
687        let dir = make_temp_dir().await;
688        let config = Config::new_for_test(&dir);
689        fs::create_dir_all(config.bin_dir()).await.unwrap();
690        handle_install(&config, Some("utils/shine-env-export"), false)
691            .await
692            .unwrap();
693        let selected = crate::bin_links::command_path_for_name(
694            config.bin_dir(),
695            std::ffi::OsStr::new("shine-env-export"),
696        );
697        let sibling = crate::bin_links::command_path_for_name(
698            config.bin_dir(),
699            std::ffi::OsStr::new("shine-theme-sync"),
700        );
701        crate::bin_links::unlink_managed_command(
702            config.bin_dir(),
703            std::ffi::OsStr::new("shine-env-export"),
704            &[config.presets_dir().join("shell/utils")],
705            false,
706        )
707        .await
708        .unwrap();
709
710        let mut separator = crate::output::SectionSeparator::new();
711        handle_upgrade_installed_target(&config, Some("utils"), false, &mut separator)
712            .await
713            .unwrap();
714
715        assert!(selected.exists());
716        assert!(!sibling.exists());
717
718        fs::remove_dir_all(&dir).await.unwrap();
719    }
720
721    #[tokio::test]
722    async fn external_snapshot_is_shared_but_only_selected_command_is_installed() {
723        let dir = make_temp_dir().await;
724        let category = dir.join("presets/shell/custom");
725        fs::create_dir_all(&category).await.unwrap();
726        fs::write(
727            category.join("shine.toml"),
728            b"[[files]]\nsource = \"one.sh\"\ntarget = \"one\"\n\n[[files]]\nsource = \"two.sh\"\ntarget = \"two\"\n",
729        )
730        .await
731        .unwrap();
732        fs::write(category.join("one.sh"), b"#!/bin/sh\necho one\n")
733            .await
734            .unwrap();
735        fs::write(category.join("two.sh"), b"#!/bin/sh\necho two\n")
736            .await
737            .unwrap();
738        let mut config = Config::new_for_test(&dir);
739        config.is_external_presets = true;
740        fs::create_dir_all(config.bin_dir()).await.unwrap();
741
742        handle_install(&config, Some("custom/one"), false)
743            .await
744            .unwrap();
745
746        assert!(config.installed_shell_dir().join("custom/one.sh").exists());
747        assert!(config.installed_shell_dir().join("custom/two.sh").exists());
748        assert!(
749            crate::bin_links::command_path_for_name(config.bin_dir(), std::ffi::OsStr::new("one"),)
750                .exists()
751        );
752        assert!(
753            !crate::bin_links::command_path_for_name(
754                config.bin_dir(),
755                std::ffi::OsStr::new("two"),
756            )
757            .exists()
758        );
759        let rows = crate::status::build_shell_rows(&config).await.unwrap();
760        let sibling = rows.iter().find(|row| row.label == "custom/two").unwrap();
761        assert!(!sibling.is_installed);
762        assert_eq!(sibling.status_text, "not installed");
763        assert!(sibling.changes.is_empty());
764
765        fs::remove_dir_all(&dir).await.unwrap();
766    }
767
768    #[cfg(unix)]
769    async fn make_executable(path: &Path) {
770        use std::os::unix::fs::PermissionsExt;
771        let mut perms = fs::metadata(path).await.unwrap().permissions();
772        perms.set_mode(perms.mode() | 0o111);
773        fs::set_permissions(path, perms).await.unwrap();
774    }
775
776    fn wrapper_marker(command: &str, shell: &ShellType) -> String {
777        match shell {
778            ShellType::PowerShell => format!("\nfunction {command} {{ . (Join-Path $shineBin"),
779            ShellType::Fish => format!("\nfunction {command}"),
780            _ => format!("\n{command}() {{ source"),
781        }
782    }
783
784    #[cfg(unix)]
785    fn managed_profile_source_marker(shell: &ShellType) -> &'static str {
786        match shell {
787            ShellType::PowerShell => ". (Join-Path $HOME 'shell/profile.ps1')",
788            ShellType::Fish => "source \"$HOME/shell/config.fish\"",
789            ShellType::Bash | ShellType::Zsh | ShellType::Elvish => {
790                "source \"$HOME/shell/profile.sh\""
791            }
792        }
793    }
794
795    #[cfg(unix)]
796    fn managed_profile_path_marker(shell: &ShellType) -> &'static str {
797        match shell {
798            ShellType::PowerShell => "$shinePathEntries",
799            ShellType::Fish => "fish_add_path",
800            ShellType::Bash | ShellType::Zsh | ShellType::Elvish => "export PATH",
801        }
802    }
803
804    #[cfg(unix)]
805    #[tokio::test]
806    async fn install_then_uninstall_roundtrip() {
807        let dir = make_temp_dir().await;
808        let config = Config::new_for_test(&dir);
809        fs::create_dir_all(config.presets_dir()).await.unwrap();
810        fs::create_dir_all(config.bin_dir()).await.unwrap();
811
812        handle_install(&config, None, false).await.unwrap();
813        assert!(
814            config
815                .presets_dir()
816                .join("shell/proxy/set_proxy.sh")
817                .exists(),
818            "preset should exist after install"
819        );
820        let first_bin_entry = fs::read_dir(config.bin_dir())
821            .await
822            .unwrap()
823            .next_entry()
824            .await
825            .unwrap();
826        assert!(
827            first_bin_entry.is_some(),
828            "bin dir should have symlinks after install"
829        );
830        // symlinks use stem names (no .sh suffix)
831        assert!(
832            config.bin_dir().join("setproxy").exists(),
833            "bin link should use configured rename"
834        );
835        assert!(!config.bin_dir().join("set_proxy").exists());
836        assert!(
837            managed_shell_profile_path(&config).exists(),
838            "managed shell profile should exist after install"
839        );
840
841        handle_uninstall(&config, None, false, false).await.unwrap();
842        assert!(
843            !config
844                .presets_dir()
845                .join("shell/proxy/set_proxy.sh")
846                .exists(),
847            "preset should be gone after uninstall"
848        );
849        let mut rd = fs::read_dir(config.bin_dir()).await.unwrap();
850        assert!(
851            rd.next_entry().await.unwrap().is_none(),
852            "bin dir should be empty after uninstall"
853        );
854        assert!(
855            !managed_shell_profile_path(&config).exists(),
856            "managed shell profile should be removed after full uninstall"
857        );
858
859        // Idempotency: second uninstall must not error
860        handle_uninstall(&config, None, false, false).await.unwrap();
861
862        fs::remove_dir_all(&dir).await.unwrap();
863    }
864
865    #[tokio::test]
866    async fn append_writes_snippet_to_shell_config() {
867        let dir = make_temp_dir().await;
868        let config = Config::new_for_test(&dir);
869
870        append_path_to_shell_config(&config, false, &[])
871            .await
872            .unwrap();
873
874        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
875        let content = fs::read_to_string(&config_path).await.unwrap();
876        assert!(
877            content.contains(super::super::SENTINEL_START),
878            "sentinel should be present"
879        );
880    }
881
882    #[tokio::test]
883    async fn completion_install_updates_profile_without_installing_presets() {
884        let dir = make_temp_dir().await;
885        let config = Config::new_for_test(&dir);
886
887        handle_completion_install(&config).await.unwrap();
888
889        let profile = fs::read_to_string(managed_shell_profile_path(&config))
890            .await
891            .unwrap();
892        let completion_marker = match config.shell_type {
893            ShellType::Bash => "COMPLETE=bash shine",
894            ShellType::Zsh => "COMPLETE=zsh shine",
895            ShellType::PowerShell => "$env:COMPLETE = 'powershell'",
896            ShellType::Fish | ShellType::Elvish => {
897                panic!("native default shell should support completion registration")
898            }
899        };
900        assert!(
901            profile.contains(completion_marker),
902            "profile should register shine completion: {profile}"
903        );
904        assert!(
905            !config.presets_dir().join("shell/proxy").exists(),
906            "completion install must not extract or install shell presets"
907        );
908    }
909
910    #[tokio::test]
911    async fn append_is_idempotent() {
912        let dir = make_temp_dir().await;
913        let config = Config::new_for_test(&dir);
914
915        append_path_to_shell_config(&config, false, &[])
916            .await
917            .unwrap();
918        append_path_to_shell_config(&config, false, &[])
919            .await
920            .unwrap();
921
922        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
923        let content = fs::read_to_string(&config_path).await.unwrap();
924        let count = content.matches(super::super::SENTINEL_START).count();
925        assert_eq!(count, 1, "sentinel should appear exactly once");
926    }
927
928    #[tokio::test]
929    async fn append_is_idempotent_with_source_wrappers() {
930        let dir = make_temp_dir().await;
931        let config = Config::new_for_test(&dir);
932        let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
933
934        append_path_to_shell_config(&config, false, &source_commands)
935            .await
936            .unwrap();
937        append_path_to_shell_config(&config, false, &source_commands)
938            .await
939            .unwrap();
940
941        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
942        let content = fs::read_to_string(&config_path).await.unwrap();
943        assert_eq!(
944            content.matches(super::super::SENTINEL_START).count(),
945            1,
946            "sentinel should appear exactly once"
947        );
948        assert!(
949            !content.contains("setproxy()"),
950            "source wrappers should live in the managed profile: {content}"
951        );
952
953        let profile_path = managed_shell_profile_path(&config);
954        let profile = fs::read_to_string(&profile_path).await.unwrap();
955        let setproxy_marker = wrapper_marker("setproxy", &config.shell_type);
956        let usetproxy_marker = wrapper_marker("usetproxy", &config.shell_type);
957        assert_eq!(
958            profile.matches(&setproxy_marker).count(),
959            1,
960            "setproxy wrapper should not be duplicated: {content}"
961        );
962        assert_eq!(
963            profile.matches(&usetproxy_marker).count(),
964            1,
965            "usetproxy wrapper should not be duplicated: {content}"
966        );
967
968        fs::remove_dir_all(&dir).await.unwrap();
969    }
970
971    #[cfg(unix)]
972    #[tokio::test]
973    async fn append_writes_source_entry_and_managed_profile() {
974        let dir = make_temp_dir().await;
975        let config = Config::new_for_test(&dir);
976        let source_commands = vec!["setproxy".to_string()];
977
978        append_path_to_shell_config(&config, false, &source_commands)
979            .await
980            .unwrap();
981
982        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
983        let content = fs::read_to_string(&config_path).await.unwrap();
984        assert!(
985            content.contains(managed_profile_source_marker(&config.shell_type)),
986            "shell config should only source managed profile: {content}"
987        );
988        assert!(
989            !content.contains("export PATH"),
990            "shell config should not contain direct PATH setup: {content}"
991        );
992        assert!(
993            !content.contains("setproxy()"),
994            "shell config should not contain direct wrapper functions: {content}"
995        );
996
997        let profile = fs::read_to_string(managed_shell_profile_path(&config))
998            .await
999            .unwrap();
1000        assert!(
1001            profile.contains(managed_profile_path_marker(&config.shell_type)),
1002            "managed profile should contain PATH setup: {profile}"
1003        );
1004        assert!(
1005            profile.contains(&wrapper_marker("setproxy", &config.shell_type)),
1006            "managed profile should contain source wrapper: {profile}"
1007        );
1008
1009        fs::remove_dir_all(&dir).await.unwrap();
1010    }
1011
1012    #[cfg(windows)]
1013    #[tokio::test]
1014    async fn append_writes_both_windows_powershell_profiles() {
1015        let dir = make_temp_dir().await;
1016        let mut config = Config::new_for_test(&dir);
1017        config.shell_type = ShellType::PowerShell;
1018        let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
1019
1020        append_path_to_shell_config(&config, false, &source_commands)
1021            .await
1022            .unwrap();
1023
1024        let profile = fs::read_to_string(managed_shell_profile_path(&config))
1025            .await
1026            .unwrap();
1027        for config_path in
1028            super::super::get_shell_config_paths(&config.shell_type, &config.home_dir).unwrap()
1029        {
1030            let content = fs::read_to_string(&config_path).await.unwrap();
1031            assert!(
1032                content.contains(". (Join-Path $HOME 'shell/profile.ps1')"),
1033                "PowerShell profile should source managed shine profile from {}: {content}",
1034                config_path.display()
1035            );
1036        }
1037        assert!(
1038            profile.contains("function setproxy"),
1039            "managed PowerShell profile should contain setproxy wrapper: {profile}"
1040        );
1041        assert!(
1042            profile.contains("function usetproxy"),
1043            "managed PowerShell profile should contain usetproxy wrapper: {profile}"
1044        );
1045
1046        fs::remove_dir_all(&dir).await.unwrap();
1047    }
1048
1049    #[cfg(unix)]
1050    #[tokio::test]
1051    async fn append_refreshes_stale_sentinel_with_managed_profile_source() {
1052        let dir = make_temp_dir().await;
1053        let config = Config::new_for_test(&dir);
1054        let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
1055        if let Some(parent) = config_path.parent() {
1056            fs::create_dir_all(parent).await.unwrap();
1057        }
1058        let sentinel_start = super::super::SENTINEL_START;
1059        let sentinel_end = "# <<< shine <<<";
1060        fs::write(
1061            &config_path,
1062            format!(
1063                "before\n\n{sentinel_start}\nif [[ \":$PATH:\" != *\":$HOME/.shine/bin:\"* ]]; then\n  export PATH=\"$HOME/.shine/bin:$PATH\"\nfi\n{sentinel_end}\nafter\n"
1064            ),
1065        )
1066        .await
1067        .unwrap();
1068
1069        let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
1070        let update = append_path_to_shell_config(&config, false, &source_commands)
1071            .await
1072            .unwrap();
1073
1074        assert!(
1075            matches!(update.config_status, PathUpdateStatus::Updated(_)),
1076            "stale sentinel should be refreshed"
1077        );
1078        let content = fs::read_to_string(&config_path).await.unwrap();
1079        assert!(
1080            content.contains(managed_profile_source_marker(&config.shell_type)),
1081            "shell config should source managed profile: {content}"
1082        );
1083        assert!(
1084            !content.contains("export PATH"),
1085            "stale PATH setup should be removed from shell config: {content}"
1086        );
1087        assert!(
1088            !content.contains("setproxy()"),
1089            "source wrappers should not be added directly to shell config: {content}"
1090        );
1091        assert!(
1092            content.contains("before"),
1093            "non-managed content should be preserved"
1094        );
1095        assert!(
1096            content.contains("after"),
1097            "non-managed content should be preserved"
1098        );
1099        let profile = fs::read_to_string(managed_shell_profile_path(&config))
1100            .await
1101            .unwrap();
1102        let setproxy_marker = wrapper_marker("setproxy", &config.shell_type);
1103        let usetproxy_marker = wrapper_marker("usetproxy", &config.shell_type);
1104        assert!(
1105            profile.contains(&setproxy_marker),
1106            "setproxy wrapper should be added to managed profile: {profile}"
1107        );
1108        assert!(
1109            profile.contains(&usetproxy_marker),
1110            "usetproxy wrapper should be added to managed profile: {profile}"
1111        );
1112
1113        fs::remove_dir_all(&dir).await.unwrap();
1114    }
1115
1116    #[tokio::test]
1117    async fn installed_source_commands_for_categories_are_scoped() {
1118        let dir = make_temp_dir().await;
1119        let config = Config::new_for_test(&dir);
1120        fs::create_dir_all(config.presets_dir()).await.unwrap();
1121        fs::create_dir_all(config.bin_dir()).await.unwrap();
1122
1123        handle_install(&config, Some("agent"), false).await.unwrap();
1124        handle_install(&config, Some("proxy"), false).await.unwrap();
1125
1126        let proxy_only = metadata::load_installed_categories(&config, Some("proxy"))
1127            .await
1128            .unwrap();
1129        let commands = installed_source_commands_for_categories(&config, &proxy_only)
1130            .await
1131            .unwrap();
1132
1133        assert_eq!(
1134            commands,
1135            vec!["setproxy".to_string(), "usetproxy".to_string()]
1136        );
1137        assert!(!commands.contains(&"ccenv".to_string()));
1138
1139        fs::remove_dir_all(&dir).await.unwrap();
1140    }
1141
1142    #[cfg(unix)]
1143    #[tokio::test]
1144    async fn external_presets_install_links_disk_scripts_without_extraction() {
1145        let dir = make_temp_dir().await;
1146        // new_for_test sets presets_dir = dir/presets, bin_dir = dir/bin
1147        // Create a script in presets_dir/shell/custom/ to simulate user-managed presets.
1148        let cat_dir = dir.join("presets/shell/custom");
1149        fs::create_dir_all(&cat_dir).await.unwrap();
1150        let script = cat_dir.join("my_tool.sh");
1151        fs::write(&script, b"#!/bin/bash\n# My tool.\necho hi\n")
1152            .await
1153            .unwrap();
1154        use std::os::unix::fs::PermissionsExt;
1155        let mut perms = fs::metadata(&script).await.unwrap().permissions();
1156        perms.set_mode(perms.mode() | 0o111);
1157        fs::set_permissions(&script, perms).await.unwrap();
1158
1159        let mut config = Config::new_for_test(&dir);
1160        config.is_external_presets = true;
1161        fs::create_dir_all(config.bin_dir()).await.unwrap();
1162
1163        handle_install(&config, Some("custom"), false)
1164            .await
1165            .unwrap();
1166
1167        // The script must NOT have been extracted from embedded assets into
1168        // presets_dir — the only file there is the one we created above.
1169        let count = {
1170            let mut rd = fs::read_dir(&cat_dir).await.unwrap();
1171            let mut n = 0u32;
1172            while rd.next_entry().await.unwrap().is_some() {
1173                n += 1;
1174            }
1175            n
1176        };
1177        assert_eq!(count, 1, "no embedded assets should have been extracted");
1178
1179        // A bin symlink for the script should have been created.
1180        let link = config.bin_dir().join("my_tool");
1181        assert!(link.exists(), "bin symlink should point at disk script");
1182
1183        fs::remove_dir_all(&dir).await.unwrap();
1184    }
1185
1186    #[cfg(unix)]
1187    #[tokio::test]
1188    async fn external_presets_install_applies_metadata_rename() {
1189        let dir = make_temp_dir().await;
1190        let cat_dir = dir.join("presets/shell/custom");
1191        fs::create_dir_all(&cat_dir).await.unwrap();
1192        fs::write(
1193            cat_dir.join("shine.toml"),
1194            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\n",
1195        )
1196        .await
1197        .unwrap();
1198        let script = cat_dir.join("set_proxy.sh");
1199        fs::write(&script, b"#!/bin/bash\n# Set proxy.\necho hi\n")
1200            .await
1201            .unwrap();
1202        use std::os::unix::fs::PermissionsExt;
1203        let mut perms = fs::metadata(&script).await.unwrap().permissions();
1204        perms.set_mode(perms.mode() | 0o111);
1205        fs::set_permissions(&script, perms).await.unwrap();
1206
1207        let mut config = Config::new_for_test(&dir);
1208        config.is_external_presets = true;
1209        fs::create_dir_all(config.bin_dir()).await.unwrap();
1210
1211        handle_install(&config, Some("custom"), false)
1212            .await
1213            .unwrap();
1214
1215        assert!(config.bin_dir().join("setproxy").exists());
1216        assert!(!config.bin_dir().join("set_proxy").exists());
1217
1218        fs::remove_dir_all(&dir).await.unwrap();
1219    }
1220
1221    #[cfg(unix)]
1222    #[tokio::test]
1223    async fn external_presets_install_links_non_executable_source_scripts() {
1224        let dir = make_temp_dir().await;
1225        let cat_dir = dir.join("presets/shell/proxy");
1226        fs::create_dir_all(&cat_dir).await.unwrap();
1227        fs::write(
1228            cat_dir.join("shine.toml"),
1229            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n[[files]]\nsource = \"uset_proxy.sh\"\ntarget = \"usetproxy\"\nneeds_source = true\n",
1230        )
1231        .await
1232        .unwrap();
1233        fs::write(
1234            &cat_dir.join("set_proxy.sh"),
1235            b"#!/bin/bash\n# Set proxy.\n",
1236        )
1237        .await
1238        .unwrap();
1239        fs::write(
1240            &cat_dir.join("uset_proxy.sh"),
1241            b"#!/bin/bash\n# Unset proxy.\n",
1242        )
1243        .await
1244        .unwrap();
1245
1246        let mut config = Config::new_for_test(&dir);
1247        config.is_external_presets = true;
1248        fs::create_dir_all(config.bin_dir()).await.unwrap();
1249
1250        handle_install(&config, Some("proxy"), false).await.unwrap();
1251
1252        assert!(config.bin_dir().join("setproxy").exists());
1253        assert!(config.bin_dir().join("usetproxy").exists());
1254
1255        fs::remove_dir_all(&dir).await.unwrap();
1256    }
1257
1258    #[tokio::test]
1259    async fn init_template_creates_parseable_shell_metadata() {
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
1264        let (path, overwritten) =
1265            utils::init_template::write_shine_toml_template(&cat_dir, false, SHELL_TEMPLATE)
1266                .unwrap();
1267        fs::write(
1268            cat_dir.join("my_tool.sh"),
1269            b"#!/bin/bash\n# My tool.\necho hi\n",
1270        )
1271        .await
1272        .unwrap();
1273
1274        let config = Config::new_for_test(&dir);
1275        let categories = metadata::load_installed_categories(&config, Some("custom"))
1276            .await
1277            .unwrap();
1278
1279        assert_eq!(path, cat_dir.join("shine.toml"));
1280        assert!(!overwritten);
1281        assert_eq!(categories.len(), 1);
1282        assert_eq!(
1283            categories[0].description.as_deref(),
1284            Some("My shell helper commands.")
1285        );
1286        assert_eq!(
1287            categories[0].files[0].source_rel,
1288            PathBuf::from("my_tool.sh")
1289        );
1290        assert_eq!(categories[0].files[0].command_name, "mytool");
1291        assert!(!categories[0].files[0].needs_source);
1292
1293        fs::remove_dir_all(&dir).await.unwrap();
1294    }
1295
1296    #[tokio::test]
1297    async fn init_template_refuses_existing_file_unless_forced() {
1298        let dir = make_temp_dir().await;
1299        fs::write(dir.join("shine.toml"), b"old").await.unwrap();
1300
1301        let err = utils::init_template::write_shine_toml_template(&dir, false, SHELL_TEMPLATE)
1302            .unwrap_err();
1303        assert!(
1304            err.to_string().contains("use --force to overwrite"),
1305            "unexpected error: {err:#}"
1306        );
1307        assert_eq!(fs::read(dir.join("shine.toml")).await.unwrap(), b"old");
1308
1309        let (_path, overwritten) =
1310            utils::init_template::write_shine_toml_template(&dir, true, SHELL_TEMPLATE).unwrap();
1311        assert!(overwritten);
1312        let content = fs::read_to_string(dir.join("shine.toml")).await.unwrap();
1313        assert!(content.contains("target = \"mytool\""));
1314
1315        fs::remove_dir_all(&dir).await.unwrap();
1316    }
1317
1318    #[cfg(unix)]
1319    #[tokio::test]
1320    async fn template_render_error_does_not_link_raw_script() {
1321        let dir = make_temp_dir().await;
1322        let cat_dir = dir.join("presets/shell/proxy");
1323        fs::create_dir_all(&cat_dir).await.unwrap();
1324        fs::write(
1325            cat_dir.join("shine.toml"),
1326            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1327        )
1328        .await
1329        .unwrap();
1330        let script = cat_dir.join("set_proxy.sh");
1331        fs::write(
1332            &script,
1333            b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
1334        )
1335        .await
1336        .unwrap();
1337        make_executable(&script).await;
1338
1339        let mut config = Config::new_for_test(&dir);
1340        config.is_external_presets = true;
1341        fs::create_dir_all(config.bin_dir()).await.unwrap();
1342        fs::write(config.rendered_dir(), b"not a directory")
1343            .await
1344            .unwrap();
1345
1346        let err = handle_install(&config, Some("proxy"), false)
1347            .await
1348            .expect_err("install should fail when rendered_dir cannot be created");
1349
1350        assert!(
1351            err.to_string()
1352                .contains("creating rendered script directory"),
1353            "unexpected error: {err:#}"
1354        );
1355        assert!(
1356            !config.bin_dir().join("setproxy").exists(),
1357            "failed render must not link the raw template script"
1358        );
1359
1360        fs::remove_dir_all(&dir).await.unwrap();
1361    }
1362
1363    #[tokio::test]
1364    async fn embedded_agent_installs_bun_launcher_without_rendering_credentials() {
1365        let dir = make_temp_dir().await;
1366        let config = Config::new_for_test(&dir);
1367        fs::create_dir_all(config.presets_dir()).await.unwrap();
1368        fs::create_dir_all(config.bin_dir()).await.unwrap();
1369
1370        handle_install(&config, Some("agent"), false).await.unwrap();
1371
1372        let source = config.presets_dir().join("shell/agent/cc.ts");
1373        assert!(source.exists());
1374        assert!(!config.rendered_dir().join("shell/agent/cc.ts").exists());
1375        let launcher = crate::bin_links::command_path_for_name(
1376            config.bin_dir(),
1377            std::ffi::OsStr::new("ccenv"),
1378        );
1379        let launcher_content = fs::read_to_string(&launcher).await.unwrap();
1380        assert!(launcher_content.contains("shine-managed"));
1381        let recorded_target = launcher_content
1382            .lines()
1383            .find_map(|line| line.strip_prefix("# shine-target: "))
1384            .expect("launcher should record its source target");
1385        assert_eq!(
1386            fs::canonicalize(recorded_target).await.unwrap(),
1387            fs::canonicalize(&source).await.unwrap()
1388        );
1389        assert!(launcher_content.contains("bun"));
1390
1391        let source_commands = installed_source_commands(&config).await.unwrap();
1392        assert!(!source_commands.contains(&"ccenv".to_string()));
1393
1394        fs::remove_dir_all(&dir).await.unwrap();
1395    }
1396
1397    #[cfg(unix)]
1398    #[tokio::test]
1399    async fn embedded_source_and_link_upgrade_report_target_once() {
1400        let dir = make_temp_dir().await;
1401        let config = Config::new_for_test(&dir);
1402        fs::create_dir_all(config.presets_dir()).await.unwrap();
1403        fs::create_dir_all(config.bin_dir()).await.unwrap();
1404        handle_install(&config, Some("utils"), false).await.unwrap();
1405
1406        let source = config.presets_dir().join("shell/utils/copyfile.sh");
1407        fs::write(&source, b"#!/bin/sh\necho stale\n")
1408            .await
1409            .unwrap();
1410        make_executable(&source).await;
1411
1412        let stale_source = dir.join("stale-copyfile.sh");
1413        fs::write(&stale_source, b"#!/bin/sh\necho stale link\n")
1414            .await
1415            .unwrap();
1416        make_executable(&stale_source).await;
1417        let link = config.bin_dir().join("copyfile");
1418        fs::remove_file(&link).await.unwrap();
1419        fs::symlink(&stale_source, &link).await.unwrap();
1420
1421        let mut separator = crate::output::SectionSeparator::new();
1422        let report = handle_upgrade_installed(&config, false, &mut separator)
1423            .await
1424            .unwrap();
1425
1426        assert_eq!(report.updated_targets, vec!["utils/copyfile"]);
1427        assert_eq!(report.updated_categories, vec!["utils"]);
1428        assert_eq!(report.links_updated, 1);
1429        assert_eq!(fs::read_link(&link).await.unwrap(), source);
1430        fs::remove_dir_all(&dir).await.unwrap();
1431    }
1432
1433    #[cfg(unix)]
1434    #[tokio::test]
1435    async fn external_presets_upgrade_does_not_install_preset_only_scripts() {
1436        let dir = make_temp_dir().await;
1437        let proxy_dir = dir.join("presets/shell/proxy");
1438        let extra_dir = dir.join("presets/shell/extra");
1439        fs::create_dir_all(&proxy_dir).await.unwrap();
1440        fs::create_dir_all(&extra_dir).await.unwrap();
1441
1442        fs::write(
1443            proxy_dir.join("shine.toml"),
1444            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1445        )
1446        .await
1447        .unwrap();
1448        let setproxy = proxy_dir.join("set_proxy.sh");
1449        fs::write(
1450            &setproxy,
1451            b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
1452        )
1453        .await
1454        .unwrap();
1455        make_executable(&setproxy).await;
1456
1457        let extra_tool = extra_dir.join("extra_tool.sh");
1458        fs::write(&extra_tool, b"#!/bin/bash\n# Extra tool.\necho extra\n")
1459            .await
1460            .unwrap();
1461        make_executable(&extra_tool).await;
1462
1463        let mut config = Config::new_for_test(&dir);
1464        config.is_external_presets = true;
1465        fs::create_dir_all(config.bin_dir()).await.unwrap();
1466
1467        handle_install(&config, Some("proxy"), false).await.unwrap();
1468        assert!(config.bin_dir().join("setproxy").exists());
1469        assert!(
1470            !config.bin_dir().join("extra_tool").exists(),
1471            "extra preset should start as present but not installed"
1472        );
1473
1474        fs::write(
1475            &setproxy,
1476            b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
1477        )
1478        .await
1479        .unwrap();
1480        make_executable(&setproxy).await;
1481
1482        let mut sep = crate::output::SectionSeparator::new();
1483        let report = handle_upgrade_installed(&config, false, &mut sep)
1484            .await
1485            .unwrap();
1486
1487        assert_eq!(
1488            report.templates_updated, 1,
1489            "changed shell template should be reported under shell presets"
1490        );
1491        assert_eq!(report.updated_targets, vec!["proxy/setproxy"]);
1492        assert_eq!(report.updated_categories, vec!["proxy"]);
1493        assert!(config.bin_dir().join("setproxy").exists());
1494        assert!(
1495            !config.bin_dir().join("extra_tool").exists(),
1496            "upgrade must not install preset-only scripts"
1497        );
1498
1499        fs::remove_dir_all(&dir).await.unwrap();
1500    }
1501
1502    #[cfg(unix)]
1503    #[tokio::test]
1504    async fn external_bun_preset_installs_launcher_and_uninstall_removes_it() {
1505        let dir = make_temp_dir().await;
1506        let cat_dir = dir.join("presets/shell/custom");
1507        fs::create_dir_all(&cat_dir).await.unwrap();
1508        fs::write(
1509            cat_dir.join("shine.toml"),
1510            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n",
1511        )
1512        .await
1513        .unwrap();
1514        // A non-executable .ts source: bun launchers do not require the exec bit.
1515        fs::write(cat_dir.join("tool.ts"), b"console.log('hi')\n")
1516            .await
1517            .unwrap();
1518
1519        let mut config = Config::new_for_test(&dir);
1520        config.is_external_presets = true;
1521        fs::create_dir_all(config.bin_dir()).await.unwrap();
1522
1523        handle_install(&config, Some("custom"), false)
1524            .await
1525            .unwrap();
1526
1527        let launcher = config.bin_dir().join("mytool");
1528        assert!(launcher.exists(), "bun launcher should be installed");
1529        assert!(!launcher.is_symlink(), "bun launcher is a regular file");
1530        let content = fs::read_to_string(&launcher).await.unwrap();
1531        assert!(content.contains("exec bun --no-install"));
1532        assert!(
1533            content.contains(
1534                &config
1535                    .installed_shell_dir()
1536                    .join("custom/tool.ts")
1537                    .display()
1538                    .to_string()
1539            )
1540        );
1541        assert!(
1542            !config.bin_dir().join("tool").exists(),
1543            "command should use the target rename, not the .ts stem"
1544        );
1545
1546        handle_uninstall(&config, Some("custom"), false, false)
1547            .await
1548            .unwrap();
1549        assert!(
1550            !launcher.exists(),
1551            "managed bun launcher must be removed on uninstall"
1552        );
1553        assert!(
1554            cat_dir.join("tool.ts").exists(),
1555            "external source must be preserved"
1556        );
1557
1558        fs::remove_dir_all(&dir).await.unwrap();
1559    }
1560
1561    #[cfg(unix)]
1562    #[tokio::test]
1563    async fn external_bun_preset_with_env_wraps_launcher_in_shine_env_run() {
1564        let dir = make_temp_dir().await;
1565        let cat_dir = dir.join("presets/shell/custom");
1566        fs::create_dir_all(&cat_dir).await.unwrap();
1567        fs::write(
1568            cat_dir.join("shine.toml"),
1569            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\nenv = [\"API_URL\", \"SERVICE_TOKEN=API_TOKEN\"]\n",
1570        )
1571        .await
1572        .unwrap();
1573        fs::write(cat_dir.join("tool.ts"), b"console.log(Bun.env.API_URL)\n")
1574            .await
1575            .unwrap();
1576
1577        let mut config = Config::new_for_test(&dir);
1578        config.is_external_presets = true;
1579        fs::create_dir_all(config.bin_dir()).await.unwrap();
1580
1581        handle_install(&config, Some("custom"), false)
1582            .await
1583            .unwrap();
1584
1585        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1586            .await
1587            .unwrap();
1588        assert!(launcher.contains("command -v shine"));
1589        assert!(launcher.contains(
1590            "exec shine env run --no-workspace --with 'API_URL' --with 'SERVICE_TOKEN=API_TOKEN' -- bun --no-install "
1591        ));
1592
1593        fs::remove_dir_all(&dir).await.unwrap();
1594    }
1595
1596    #[cfg(unix)]
1597    #[tokio::test]
1598    async fn external_bun_preset_with_locked_package_uses_fallback_and_records_hash() {
1599        let dir = make_temp_dir().await;
1600        let cat_dir = dir.join("presets/shell/custom");
1601        fs::create_dir_all(&cat_dir).await.unwrap();
1602        fs::write(
1603            cat_dir.join("shine.toml"),
1604            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n",
1605        )
1606        .await
1607        .unwrap();
1608        fs::write(cat_dir.join("tool.ts"), b"import 'zod'\n")
1609            .await
1610            .unwrap();
1611        fs::write(
1612            cat_dir.join("package.json"),
1613            b"{\"dependencies\":{\"zod\":\"4.0.0\"}}",
1614        )
1615        .await
1616        .unwrap();
1617        fs::write(cat_dir.join("bun.lock"), b"lockfileVersion = 1\n")
1618            .await
1619            .unwrap();
1620        fs::create_dir_all(cat_dir.join("node_modules/zod"))
1621            .await
1622            .unwrap();
1623        fs::write(cat_dir.join("node_modules/zod/index.js"), b"export {}")
1624            .await
1625            .unwrap();
1626
1627        let mut config = Config::new_for_test(&dir);
1628        config.is_external_presets = true;
1629        fs::create_dir_all(config.bin_dir()).await.unwrap();
1630        handle_install(&config, Some("custom"), false)
1631            .await
1632            .unwrap();
1633
1634        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1635            .await
1636            .unwrap();
1637        assert!(launcher.contains("exec bun --install=fallback"));
1638        assert!(
1639            !config
1640                .installed_shell_dir()
1641                .join("custom/node_modules")
1642                .exists()
1643        );
1644        let manifest = crate::shells::deployment::ShellManifest::load(&config)
1645            .await
1646            .unwrap();
1647        let entry = manifest.find("shell/custom/mytool").unwrap();
1648        assert_eq!(entry.bun_dependencies.as_deref(), Some("locked"));
1649        assert!(entry.dependency_hash.is_some());
1650
1651        fs::remove_dir_all(&dir).await.unwrap();
1652    }
1653
1654    #[cfg(unix)]
1655    #[tokio::test]
1656    async fn external_bun_preset_with_template_transform_targets_rendered_copy() {
1657        let dir = make_temp_dir().await;
1658        let cat_dir = dir.join("presets/shell/custom");
1659        fs::create_dir_all(&cat_dir).await.unwrap();
1660        fs::write(
1661            cat_dir.join("shine.toml"),
1662            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n",
1663        )
1664        .await
1665        .unwrap();
1666        fs::write(cat_dir.join("tool.ts"), b"const host = '@@PROXY_HOST@@'\n")
1667            .await
1668            .unwrap();
1669
1670        let mut config = Config::new_for_test(&dir);
1671        config.is_external_presets = true;
1672        config
1673            .env
1674            .insert("PROXY_HOST".into(), "proxy.example".into());
1675        fs::create_dir_all(config.bin_dir()).await.unwrap();
1676
1677        handle_install(&config, Some("custom"), false)
1678            .await
1679            .unwrap();
1680
1681        let rendered = config.rendered_dir().join("shell/custom/tool.ts");
1682        assert!(
1683            rendered.exists(),
1684            "template transform should render the .ts"
1685        );
1686        assert!(
1687            fs::read_to_string(&rendered)
1688                .await
1689                .unwrap()
1690                .contains("proxy.example"),
1691            "rendered bun script should have @@PROXY_HOST@@ substituted"
1692        );
1693        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1694            .await
1695            .unwrap();
1696        assert!(
1697            launcher.contains(&rendered.display().to_string()),
1698            "launcher must target the rendered copy: {launcher}"
1699        );
1700
1701        fs::remove_dir_all(&dir).await.unwrap();
1702    }
1703
1704    #[cfg(unix)]
1705    #[tokio::test]
1706    async fn live_transformed_bun_renders_again_on_demand() {
1707        let dir = make_temp_dir().await;
1708        let cat_dir = dir.join("presets/shell/custom");
1709        fs::create_dir_all(&cat_dir).await.unwrap();
1710        fs::write(
1711            cat_dir.join("shine.toml"),
1712            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n",
1713        )
1714        .await
1715        .unwrap();
1716        let source = cat_dir.join("tool.ts");
1717        fs::write(&source, b"console.log('@@PROXY_HOST@@')\n")
1718            .await
1719            .unwrap();
1720
1721        let mut config = Config::new_for_test(&dir);
1722        config.is_external_presets = true;
1723        config.external_shell_mode = crate::config::ExternalShellMode::Live;
1724        config
1725            .env
1726            .insert("PROXY_HOST".into(), "first.example".into());
1727        fs::create_dir_all(config.bin_dir()).await.unwrap();
1728        handle_install(&config, Some("custom"), false)
1729            .await
1730            .unwrap();
1731
1732        let rendered = config.rendered_dir().join("shell/custom/tool.ts");
1733        assert!(
1734            fs::read_to_string(&rendered)
1735                .await
1736                .unwrap()
1737                .contains("first.example")
1738        );
1739        config
1740            .env
1741            .insert("PROXY_HOST".into(), "second.example".into());
1742        crate::shells::deployment::handle_render_live(&config, "shell/custom/mytool")
1743            .await
1744            .unwrap();
1745        assert!(
1746            fs::read_to_string(&rendered)
1747                .await
1748                .unwrap()
1749                .contains("second.example")
1750        );
1751        let last_good = fs::read(&rendered).await.unwrap();
1752        fs::write(&source, b"console.log('@@MISSING_LIVE_VALUE@@')\n")
1753            .await
1754            .unwrap();
1755        assert!(
1756            crate::shells::deployment::handle_render_live(&config, "shell/custom/mytool")
1757                .await
1758                .is_err()
1759        );
1760        assert_eq!(
1761            fs::read(&rendered).await.unwrap(),
1762            last_good,
1763            "failed live transform must preserve the last-known-good output"
1764        );
1765
1766        let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1767            .await
1768            .unwrap();
1769        assert!(launcher.contains("__shell-render"));
1770        assert!(launcher.contains("--config-dir"));
1771        fs::remove_dir_all(&dir).await.unwrap();
1772    }
1773
1774    #[cfg(unix)]
1775    #[tokio::test]
1776    async fn snapshot_upgrade_applies_external_raw_source_change() {
1777        let dir = make_temp_dir().await;
1778        let cat_dir = dir.join("presets/shell/custom");
1779        fs::create_dir_all(&cat_dir).await.unwrap();
1780        fs::write(
1781            cat_dir.join("shine.toml"),
1782            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1783        )
1784        .await
1785        .unwrap();
1786        let source = cat_dir.join("tool.sh");
1787        fs::write(&source, b"#!/bin/sh\necho first\n")
1788            .await
1789            .unwrap();
1790
1791        let mut config = Config::new_for_test(&dir);
1792        config.is_external_presets = true;
1793        fs::create_dir_all(config.bin_dir()).await.unwrap();
1794        handle_install(&config, Some("custom"), false)
1795            .await
1796            .unwrap();
1797        let installed = config.installed_shell_dir().join("custom/tool.sh");
1798        assert!(
1799            fs::read_to_string(&installed)
1800                .await
1801                .unwrap()
1802                .contains("first")
1803        );
1804
1805        fs::write(&source, b"#!/bin/sh\necho second\n")
1806            .await
1807            .unwrap();
1808        let mut separator = crate::output::SectionSeparator::new();
1809        let report = handle_upgrade_installed(&config, false, &mut separator)
1810            .await
1811            .unwrap();
1812        assert_eq!(report.snapshots_updated, 1);
1813        assert_eq!(report.updated_targets, vec!["custom/mytool"]);
1814        assert_eq!(report.updated_categories, vec!["custom"]);
1815        assert!(
1816            fs::read_to_string(&installed)
1817                .await
1818                .unwrap()
1819                .contains("second")
1820        );
1821        assert_eq!(
1822            fs::read_link(config.bin_dir().join("mytool"))
1823                .await
1824                .unwrap(),
1825            installed
1826        );
1827        fs::remove_dir_all(&dir).await.unwrap();
1828    }
1829
1830    #[cfg(unix)]
1831    #[tokio::test]
1832    async fn upgrade_migrates_legacy_external_link_to_snapshot() {
1833        let dir = make_temp_dir().await;
1834        let cat_dir = dir.join("presets/shell/custom");
1835        fs::create_dir_all(&cat_dir).await.unwrap();
1836        fs::write(
1837            cat_dir.join("shine.toml"),
1838            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1839        )
1840        .await
1841        .unwrap();
1842        let source = cat_dir.join("tool.sh");
1843        fs::write(&source, b"#!/bin/sh\necho legacy\n")
1844            .await
1845            .unwrap();
1846        let mut config = Config::new_for_test(&dir);
1847        config.is_external_presets = true;
1848        fs::create_dir_all(config.bin_dir()).await.unwrap();
1849        fs::symlink(&source, config.bin_dir().join("mytool"))
1850            .await
1851            .unwrap();
1852
1853        let mut separator = crate::output::SectionSeparator::new();
1854        let report = handle_upgrade_installed(&config, false, &mut separator)
1855            .await
1856            .unwrap();
1857        assert_eq!(report.snapshots_updated, 1);
1858        assert_eq!(
1859            fs::read_link(config.bin_dir().join("mytool"))
1860                .await
1861                .unwrap(),
1862            config.installed_shell_dir().join("custom/tool.sh")
1863        );
1864        assert!(
1865            crate::shells::deployment::ShellManifest::load(&config)
1866                .await
1867                .unwrap()
1868                .find("shell/custom/mytool")
1869                .is_some()
1870        );
1871        fs::remove_dir_all(&dir).await.unwrap();
1872    }
1873
1874    #[cfg(unix)]
1875    #[tokio::test]
1876    async fn upgrade_switches_snapshot_raw_link_to_explicit_live_source() {
1877        let dir = make_temp_dir().await;
1878        let cat_dir = dir.join("presets/shell/custom");
1879        fs::create_dir_all(&cat_dir).await.unwrap();
1880        fs::write(
1881            cat_dir.join("shine.toml"),
1882            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1883        )
1884        .await
1885        .unwrap();
1886        let source = cat_dir.join("tool.sh");
1887        fs::write(&source, b"#!/bin/sh\necho live\n").await.unwrap();
1888        let mut config = Config::new_for_test(&dir);
1889        config.is_external_presets = true;
1890        fs::create_dir_all(config.bin_dir()).await.unwrap();
1891        handle_install(&config, Some("custom"), false)
1892            .await
1893            .unwrap();
1894
1895        config.external_shell_mode = crate::config::ExternalShellMode::Live;
1896        let mut separator = crate::output::SectionSeparator::new();
1897        handle_upgrade_installed(&config, false, &mut separator)
1898            .await
1899            .unwrap();
1900        assert_eq!(
1901            fs::read_link(config.bin_dir().join("mytool"))
1902                .await
1903                .unwrap(),
1904            source
1905        );
1906        let manifest = crate::shells::deployment::ShellManifest::load(&config)
1907            .await
1908            .unwrap();
1909        assert_eq!(
1910            manifest.find("shell/custom/mytool").unwrap().mode,
1911            crate::config::ExternalShellMode::Live
1912        );
1913        fs::remove_dir_all(&dir).await.unwrap();
1914    }
1915}