Skip to main content

cli/shells/
install.rs

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