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