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
202pub 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 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 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 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 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 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 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}