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