1use super::links::{build_link_specs, print_link_conflicts};
2use super::profile::{
3 append_path_to_shell_config, managed_shell_profile_path, print_source_command_activation_hint,
4 shell_source_command,
5};
6use super::report::{
7 ShellUpgradeReport, link_report_summary_parts, preset_extract_summary_parts,
8 upgrade_link_report_summary_parts,
9};
10use super::template::{ScriptTemplate, apply_template_to_scripts};
11use super::{PathUpdateStatus, get_shell_config_path, metadata};
12use crate::colors;
13use crate::config::Config;
14use crate::output;
15use anyhow::{Context, Result};
16use std::collections::BTreeSet;
17use std::path::Path;
18
19const SHELL_TEMPLATE: &str = r#"# Shell preset metadata for shine.
20description = "My shell helper commands."
21
22[[files]]
23source = "my_tool.sh"
24target = "mytool"
25needs_source = false
26# Optional: limit a file to specific platforms.
27# platforms = ["unix"] # or ["windows"]
28
29# PowerShell scripts are also supported:
30# source = "my_tool.ps1"
31
32# Cross-platform Bun helpers (requires `bun` on PATH; shine never installs it):
33# [[files]]
34# source = "my_tool.ts" # .ts / .js / .mts / .mjs
35# target = "mytool"
36# runtime = "bun"
37# platforms = ["unix", "windows"]
38# description = "What mytool does." # or a `// ...` header at the top of my_tool.ts
39# transforms = ["template"] # opt into @@VAR@@ env substitution (static, needs `shine upgrade`)
40# env = ["API_URL", "SERVICE_TOKEN=API_TOKEN"] # inject shine values at launch; read via Bun.env
41"#;
42
43pub async fn handle_init_template(force: bool) -> Result<()> {
44 let dir = std::env::current_dir().context("reading current directory")?;
45 let (path, overwritten) =
46 utils::init_template::write_shine_toml_template(&dir, force, SHELL_TEMPLATE)?;
47 if overwritten {
48 println!("Updated shell preset template: {}", path.display());
49 } else {
50 println!("Created shell preset template: {}", path.display());
51 }
52 Ok(())
53}
54
55pub async fn handle_install(config: &Config, category: Option<&str>, force: bool) -> Result<()> {
56 crate::config::print_presets_note(config);
57 let prefix = match category {
58 Some(cat) => format!("shell/{cat}"),
59 None => "shell".to_string(),
60 };
61
62 if !config.is_external_presets {
64 let report = crate::presets::extract_prefix(&prefix, config.presets_dir(), force).await?;
65 output::summary_line("Shell Presets", &preset_extract_summary_parts(&report));
66 }
67
68 let categories = metadata::load_active_categories(config, category).await?;
69 super::deployment::validate_snapshot_categories(config, &categories).await?;
70 let snapshots_updated =
71 super::deployment::materialize_snapshot_categories(config, &categories).await?;
72 if config.is_external_presets
73 && config.external_shell_mode == crate::config::ExternalShellMode::Snapshot
74 {
75 let summary = if snapshots_updated > 0 {
76 colors::green(&format!("{snapshots_updated} updated"))
77 } else {
78 colors::dim("up to date")
79 };
80 output::summary_line("Shell Snapshots", &[summary]);
81 }
82 let script_pairs = build_script_pairs(config, &categories);
85
86 apply_template_to_scripts(config, &script_pairs).await?;
89
90 let link_specs = build_link_specs(config, &categories);
93 let link_report =
94 crate::bin_links::link_executables_with_names(config.bin_dir(), &link_specs, force).await?;
95 super::deployment::update_manifest(config, &categories).await?;
96
97 output::summary_line("Bin Links", &link_report_summary_parts(&link_report));
98 print_link_conflicts(config, &link_report.conflicts, category);
99
100 let source_commands = installed_source_commands(config).await?;
101 let installed_commands = installed_source_commands_for_categories(config, &categories).await?;
102
103 let shell_config_path = get_shell_config_path(&config.shell_type, &config.home_dir)?;
104 let shell_update = append_path_to_shell_config(config, force, &source_commands).await?;
105 let profile_path = managed_shell_profile_path(config);
106 if shell_update.profile_updated {
107 output::detail_line(
108 "Shell Profile",
109 &colors::green("updated"),
110 Some(profile_path.display().to_string()),
111 );
112 }
113 match shell_update.config_status {
114 PathUpdateStatus::AlreadyConfigured => {
115 output::detail_line(
116 "Shell Config",
117 &colors::dim("up to date"),
118 Some(shell_config_path.display().to_string()),
119 );
120 }
121 PathUpdateStatus::Updated(path) => {
122 output::detail_line(
123 "Shell Config",
124 &colors::green("updated"),
125 Some(path.display().to_string()),
126 );
127 }
128 }
129 print_source_command_activation_hint(config, &shell_config_path, &installed_commands);
130 Ok(())
131}
132
133pub async fn handle_upgrade_installed(
134 config: &Config,
135 verbose: bool,
136 sep: &mut crate::output::SectionSeparator,
137) -> Result<ShellUpgradeReport> {
138 handle_upgrade_installed_target(config, None, verbose, sep).await
139}
140
141pub async fn handle_upgrade_installed_target(
142 config: &Config,
143 category_filter: Option<&str>,
144 verbose: bool,
145 sep: &mut crate::output::SectionSeparator,
146) -> Result<ShellUpgradeReport> {
147 let all_categories = if config.is_external_presets {
148 metadata::load_installed_categories(config, None).await?
149 } else {
150 metadata::load_embedded_categories(None)?
151 };
152
153 let installed_commands: Vec<(String, String)> = all_categories
154 .iter()
155 .filter(|cat| category_filter.is_none_or(|filter| cat.name == filter))
156 .flat_map(|cat| {
157 cat.files.iter().filter_map(|file| {
158 let link = crate::bin_links::command_path_for_name(
159 config.bin_dir(),
160 std::ffi::OsStr::new(&file.command_name),
161 );
162 shell_link_exists(&link).then(|| (cat.name.clone(), file.command_name.clone()))
163 })
164 })
165 .collect();
166
167 if installed_commands.is_empty() {
168 if let Some(category) = category_filter {
169 anyhow::bail!("shell preset is not installed: {category}");
170 }
171 if verbose {
172 println!("{}", colors::dim("No installed shell presets found."));
173 }
174 return Ok(ShellUpgradeReport::default());
175 }
176
177 let installed_categories: std::collections::BTreeSet<String> = installed_commands
178 .iter()
179 .map(|(cat_name, _)| cat_name.clone())
180 .collect();
181
182 let pending_targets = pending_upgrade_targets(config, &installed_commands).await?;
183
184 if !config.is_external_presets {
185 for category in &installed_categories {
186 let prefix = format!("shell/{category}");
187 let _ = crate::presets::extract_prefix(&prefix, config.presets_dir(), true).await?;
188 }
189 }
190
191 let categories = metadata::load_installed_categories(config, None).await?;
192 let mut categories: Vec<_> = categories
193 .into_iter()
194 .filter(|cat| installed_categories.contains(&cat.name))
195 .collect();
196 for cat in &mut categories {
197 cat.files.retain(|file| {
198 installed_commands.contains(&(cat.name.clone(), file.command_name.clone()))
199 });
200 }
201
202 super::deployment::validate_snapshot_categories(config, &categories).await?;
203 let snapshots_updated =
204 super::deployment::materialize_snapshot_categories(config, &categories).await?;
205
206 let script_pairs = build_script_pairs(config, &categories);
207 let template_report = apply_template_to_scripts(config, &script_pairs).await?;
208
209 let link_specs = build_link_specs(config, &categories);
210 let link_report =
211 crate::bin_links::link_executables_with_names(config.bin_dir(), &link_specs, true).await?;
212 super::deployment::update_manifest(config, &categories).await?;
213
214 let link_parts = upgrade_link_report_summary_parts(&link_report, verbose);
215
216 let source_commands = installed_source_commands(config).await?;
217
218 let shell_update = append_path_to_shell_config(config, false, &source_commands).await?;
219 let updated_shell_config = match shell_update.config_status {
220 PathUpdateStatus::AlreadyConfigured => None,
221 PathUpdateStatus::Updated(path) => Some(path),
222 };
223
224 let remaining_targets = pending_upgrade_targets(config, &installed_commands).await?;
225 let mut updated_targets = pending_targets
226 .difference(&remaining_targets)
227 .cloned()
228 .collect::<BTreeSet<_>>();
229 updated_targets.extend(template_report.updated.iter().cloned());
230 for link in link_report.created.iter().chain(&link_report.overwritten) {
231 let Some(command) = link.file_name().and_then(|name| name.to_str()) else {
232 continue;
233 };
234 updated_targets.extend(
235 installed_commands
236 .iter()
237 .filter(|(_, installed_command)| installed_command == command)
238 .map(|(category, installed_command)| format!("{category}/{installed_command}")),
239 );
240 }
241 let updated_targets = updated_targets.into_iter().collect::<Vec<_>>();
242
243 let has_visible_result = should_print_upgrade_section(
244 verbose,
245 !updated_targets.is_empty(),
246 !link_report.conflicts.is_empty(),
247 updated_shell_config.is_some(),
248 );
249 if has_visible_result {
250 sep.begin();
251 if verbose {
252 output::summary_line(
253 "Shell Presets",
254 &[colors::dim(&format!(
255 "{} installed categories",
256 installed_categories.len()
257 ))],
258 );
259 } else {
260 println!("{}", colors::bold("Shell Presets"));
261 }
262
263 for target in &updated_targets {
264 println!(" {} {target}", colors::symbol("✓"));
265 }
266 if verbose && snapshots_updated > 0 {
267 println!(
268 " {} {}",
269 colors::symbol("✓"),
270 colors::green(&format!("{snapshots_updated} snapshot(s) updated"))
271 );
272 }
273 if verbose && !template_report.updated.is_empty() {
274 output::summary_line(
275 "Templates",
276 &[colors::green(&format!(
277 "{} rendered",
278 template_report.updated.len()
279 ))],
280 );
281 }
282 if should_print_link_summary(verbose, link_report.conflicts.len()) {
283 if verbose && !link_parts.is_empty() {
284 output::summary_line("Bin Links", &link_parts);
285 } else if !link_report.conflicts.is_empty() {
286 output::summary_line(
287 "Bin Links",
288 &[colors::yellow(&format!(
289 "{} conflicts",
290 link_report.conflicts.len()
291 ))],
292 );
293 }
294 }
295 print_link_conflicts(config, &link_report.conflicts, None);
296 if let Some(path) = &updated_shell_config {
297 output::detail_line(
298 "Shell Config",
299 &colors::green("updated"),
300 Some(path.display().to_string()),
301 );
302 }
303 }
304
305 Ok(ShellUpgradeReport {
306 updated_targets,
307 snapshots_updated,
308 templates_updated: template_report.updated.len(),
309 links_created: link_report.created.len(),
310 links_updated: link_report.overwritten.len(),
311 link_conflicts: link_report.conflicts.len(),
312 path_changed: updated_shell_config.is_some(),
313 })
314}
315
316fn should_print_upgrade_section(
317 verbose: bool,
318 targets_updated: bool,
319 has_link_conflict: bool,
320 path_changed: bool,
321) -> bool {
322 verbose || targets_updated || has_link_conflict || path_changed
323}
324
325fn should_print_link_summary(verbose: bool, conflict_count: usize) -> bool {
326 verbose || conflict_count > 0
327}
328
329async fn pending_upgrade_targets(
330 config: &Config,
331 installed_commands: &[(String, String)],
332) -> Result<BTreeSet<String>> {
333 let installed_targets = installed_commands
334 .iter()
335 .map(|(category, command)| format!("{category}/{command}"))
336 .collect::<BTreeSet<_>>();
337 Ok(crate::status::build_shell_rows(config)
338 .await?
339 .into_iter()
340 .filter(|row| row.status_sym == "↑" && installed_targets.contains(&row.label))
341 .map(|row| row.label)
342 .collect())
343}
344
345pub async fn handle_completion_install(config: &Config) -> Result<()> {
346 let source_commands = installed_source_commands(config).await?;
347 let shell_config_path = get_shell_config_path(&config.shell_type, &config.home_dir)?;
348 let shell_update = append_path_to_shell_config(config, false, &source_commands).await?;
349 let profile_path = managed_shell_profile_path(config);
350
351 if shell_update.profile_updated {
352 output::detail_line(
353 "Shell Profile",
354 &colors::green("updated"),
355 Some(profile_path.display().to_string()),
356 );
357 } else {
358 output::detail_line(
359 "Shell Profile",
360 &colors::dim("up to date"),
361 Some(profile_path.display().to_string()),
362 );
363 }
364
365 match shell_update.config_status {
366 PathUpdateStatus::AlreadyConfigured => {
367 output::detail_line(
368 "Shell Config",
369 &colors::dim("up to date"),
370 Some(shell_config_path.display().to_string()),
371 );
372 }
373 PathUpdateStatus::Updated(path) => {
374 output::detail_line(
375 "Shell Config",
376 &colors::green("updated"),
377 Some(path.display().to_string()),
378 );
379 }
380 }
381
382 if !super::profile::supports_completion_registration(&config.shell_type) {
383 let shell: &'static str = config.shell_type.into();
384 output::detail_line(
385 "Completion",
386 &colors::yellow("unsupported"),
387 Some(format!("{shell}; PATH setup was installed")),
388 );
389 }
390
391 output::hint_line(
392 "Next Step",
393 &format!(
394 "run `{}` once, or open a new shell",
395 shell_source_command(&config.shell_type, &shell_config_path)
396 ),
397 );
398 Ok(())
399}
400
401fn shell_link_exists(link: &Path) -> bool {
402 link.exists()
403 || std::fs::symlink_metadata(link)
404 .map(|meta| meta.file_type().is_symlink())
405 .unwrap_or(false)
406}
407
408fn build_script_pairs(
411 config: &Config,
412 categories: &[metadata::ShellCategory],
413) -> Vec<ScriptTemplate> {
414 categories
415 .iter()
416 .flat_map(|cat| {
417 cat.files.iter().map(|file| {
418 let source =
419 super::deployment::deployment_source_path(config, &cat.name, &file.source_rel);
420 let rendered =
421 super::deployment::rendered_path(config, &cat.name, &file.source_rel);
422 ScriptTemplate {
423 source_path: source,
424 rendered_path: rendered,
425 display_name: format!("{}/{}", cat.name, file.command_name),
426 transforms: file.transforms.clone(),
427 }
428 })
429 })
430 .collect()
431}
432
433pub(super) async fn installed_source_commands(config: &Config) -> Result<Vec<String>> {
434 let categories = metadata::load_installed_categories(config, None).await?;
435 installed_source_commands_for_categories(config, &categories).await
436}
437
438async fn installed_source_commands_for_categories(
439 config: &Config,
440 categories: &[metadata::ShellCategory],
441) -> Result<Vec<String>> {
442 let mut commands = categories
443 .iter()
444 .flat_map(|cat| cat.files.iter())
445 .filter(|file| file.needs_source)
446 .filter(|file| {
447 let link = crate::bin_links::command_path_for_name(
448 config.bin_dir(),
449 std::ffi::OsStr::new(&file.command_name),
450 );
451 shell_link_exists(&link)
452 })
453 .map(|file| file.command_name.clone())
454 .collect::<Vec<_>>();
455 commands.sort();
456 commands.dedup();
457 Ok(commands)
458}
459
460#[cfg(test)]
461mod tests {
462 use super::super::ShellType;
463 use super::super::uninstall::handle_uninstall;
464 use super::*;
465 use crate::config::Config;
466 use std::path::PathBuf;
467 use tokio::fs;
468
469 #[test]
470 fn upgrade_section_hides_no_op_by_default_and_shows_verbose_or_changes() {
471 assert!(!should_print_upgrade_section(false, false, false, false));
472 assert!(should_print_upgrade_section(true, false, false, false));
473 assert!(should_print_upgrade_section(false, true, false, false));
474 assert!(should_print_upgrade_section(false, false, true, false));
475 assert!(should_print_upgrade_section(false, false, false, true));
476 }
477
478 #[test]
479 fn bin_link_summary_is_verbose_only_unless_there_is_a_conflict() {
480 assert!(!should_print_link_summary(false, 0));
481 assert!(should_print_link_summary(true, 0));
482 assert!(should_print_link_summary(false, 1));
483 }
484
485 async fn make_temp_dir() -> PathBuf {
486 crate::test_support::make_temp_dir("shine-shell").await
487 }
488
489 #[cfg(unix)]
490 async fn make_executable(path: &Path) {
491 use std::os::unix::fs::PermissionsExt;
492 let mut perms = fs::metadata(path).await.unwrap().permissions();
493 perms.set_mode(perms.mode() | 0o111);
494 fs::set_permissions(path, perms).await.unwrap();
495 }
496
497 fn wrapper_marker(command: &str, shell: &ShellType) -> String {
498 match shell {
499 ShellType::PowerShell => format!("\nfunction {command} {{ . (Join-Path $shineBin"),
500 ShellType::Fish => format!("\nfunction {command}"),
501 _ => format!("\n{command}() {{ source"),
502 }
503 }
504
505 fn managed_profile_source_marker(shell: &ShellType) -> &'static str {
506 match shell {
507 ShellType::PowerShell => ". (Join-Path $HOME 'shell/profile.ps1')",
508 ShellType::Fish => "source \"$HOME/shell/config.fish\"",
509 ShellType::Bash | ShellType::Zsh | ShellType::Elvish => {
510 "source \"$HOME/shell/profile.sh\""
511 }
512 }
513 }
514
515 fn managed_profile_path_marker(shell: &ShellType) -> &'static str {
516 match shell {
517 ShellType::PowerShell => "$shinePathEntries",
518 ShellType::Fish => "fish_add_path",
519 ShellType::Bash | ShellType::Zsh | ShellType::Elvish => "export PATH",
520 }
521 }
522
523 #[cfg(unix)]
524 #[tokio::test]
525 async fn install_then_uninstall_roundtrip() {
526 let dir = make_temp_dir().await;
527 let config = Config::new_for_test(&dir);
528 fs::create_dir_all(config.presets_dir()).await.unwrap();
529 fs::create_dir_all(config.bin_dir()).await.unwrap();
530
531 handle_install(&config, None, false).await.unwrap();
532 assert!(
533 config
534 .presets_dir()
535 .join("shell/proxy/set_proxy.sh")
536 .exists(),
537 "preset should exist after install"
538 );
539 let first_bin_entry = fs::read_dir(config.bin_dir())
540 .await
541 .unwrap()
542 .next_entry()
543 .await
544 .unwrap();
545 assert!(
546 first_bin_entry.is_some(),
547 "bin dir should have symlinks after install"
548 );
549 assert!(
551 config.bin_dir().join("setproxy").exists(),
552 "bin link should use configured rename"
553 );
554 assert!(!config.bin_dir().join("set_proxy").exists());
555 assert!(
556 managed_shell_profile_path(&config).exists(),
557 "managed shell profile should exist after install"
558 );
559
560 handle_uninstall(&config, None, false, false).await.unwrap();
561 assert!(
562 !config
563 .presets_dir()
564 .join("shell/proxy/set_proxy.sh")
565 .exists(),
566 "preset should be gone after uninstall"
567 );
568 let mut rd = fs::read_dir(config.bin_dir()).await.unwrap();
569 assert!(
570 rd.next_entry().await.unwrap().is_none(),
571 "bin dir should be empty after uninstall"
572 );
573 assert!(
574 !managed_shell_profile_path(&config).exists(),
575 "managed shell profile should be removed after full uninstall"
576 );
577
578 handle_uninstall(&config, None, false, false).await.unwrap();
580
581 fs::remove_dir_all(&dir).await.unwrap();
582 }
583
584 #[tokio::test]
585 async fn append_writes_snippet_to_shell_config() {
586 let dir = make_temp_dir().await;
587 let config = Config::new_for_test(&dir);
588
589 append_path_to_shell_config(&config, false, &[])
590 .await
591 .unwrap();
592
593 let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
594 let content = fs::read_to_string(&config_path).await.unwrap();
595 assert!(
596 content.contains(super::super::SENTINEL_START),
597 "sentinel should be present"
598 );
599 }
600
601 #[tokio::test]
602 async fn completion_install_updates_profile_without_installing_presets() {
603 let dir = make_temp_dir().await;
604 let config = Config::new_for_test(&dir);
605
606 handle_completion_install(&config).await.unwrap();
607
608 let profile = fs::read_to_string(managed_shell_profile_path(&config))
609 .await
610 .unwrap();
611 let shell_name: &'static str = config.shell_type.into();
612 assert!(
613 profile.contains(&format!("COMPLETE={shell_name} shine")),
614 "profile should register shine completion: {profile}"
615 );
616 assert!(
617 !config.presets_dir().join("shell/proxy").exists(),
618 "completion install must not extract or install shell presets"
619 );
620 }
621
622 #[tokio::test]
623 async fn append_is_idempotent() {
624 let dir = make_temp_dir().await;
625 let config = Config::new_for_test(&dir);
626
627 append_path_to_shell_config(&config, false, &[])
628 .await
629 .unwrap();
630 append_path_to_shell_config(&config, false, &[])
631 .await
632 .unwrap();
633
634 let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
635 let content = fs::read_to_string(&config_path).await.unwrap();
636 let count = content.matches(super::super::SENTINEL_START).count();
637 assert_eq!(count, 1, "sentinel should appear exactly once");
638 }
639
640 #[tokio::test]
641 async fn append_is_idempotent_with_source_wrappers() {
642 let dir = make_temp_dir().await;
643 let config = Config::new_for_test(&dir);
644 let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
645
646 append_path_to_shell_config(&config, false, &source_commands)
647 .await
648 .unwrap();
649 append_path_to_shell_config(&config, false, &source_commands)
650 .await
651 .unwrap();
652
653 let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
654 let content = fs::read_to_string(&config_path).await.unwrap();
655 assert_eq!(
656 content.matches(super::super::SENTINEL_START).count(),
657 1,
658 "sentinel should appear exactly once"
659 );
660 assert!(
661 !content.contains("setproxy()"),
662 "source wrappers should live in the managed profile: {content}"
663 );
664
665 let profile_path = managed_shell_profile_path(&config);
666 let profile = fs::read_to_string(&profile_path).await.unwrap();
667 let setproxy_marker = wrapper_marker("setproxy", &config.shell_type);
668 let usetproxy_marker = wrapper_marker("usetproxy", &config.shell_type);
669 assert_eq!(
670 profile.matches(&setproxy_marker).count(),
671 1,
672 "setproxy wrapper should not be duplicated: {content}"
673 );
674 assert_eq!(
675 profile.matches(&usetproxy_marker).count(),
676 1,
677 "usetproxy wrapper should not be duplicated: {content}"
678 );
679
680 fs::remove_dir_all(&dir).await.unwrap();
681 }
682
683 #[tokio::test]
684 async fn append_writes_source_entry_and_managed_profile() {
685 let dir = make_temp_dir().await;
686 let config = Config::new_for_test(&dir);
687 let source_commands = vec!["setproxy".to_string()];
688
689 append_path_to_shell_config(&config, false, &source_commands)
690 .await
691 .unwrap();
692
693 let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
694 let content = fs::read_to_string(&config_path).await.unwrap();
695 assert!(
696 content.contains(managed_profile_source_marker(&config.shell_type)),
697 "shell config should only source managed profile: {content}"
698 );
699 assert!(
700 !content.contains("export PATH"),
701 "shell config should not contain direct PATH setup: {content}"
702 );
703 assert!(
704 !content.contains("setproxy()"),
705 "shell config should not contain direct wrapper functions: {content}"
706 );
707
708 let profile = fs::read_to_string(managed_shell_profile_path(&config))
709 .await
710 .unwrap();
711 assert!(
712 profile.contains(managed_profile_path_marker(&config.shell_type)),
713 "managed profile should contain PATH setup: {profile}"
714 );
715 assert!(
716 profile.contains(&wrapper_marker("setproxy", &config.shell_type)),
717 "managed profile should contain source wrapper: {profile}"
718 );
719
720 fs::remove_dir_all(&dir).await.unwrap();
721 }
722
723 #[cfg(windows)]
724 #[tokio::test]
725 async fn append_writes_both_windows_powershell_profiles() {
726 let dir = make_temp_dir().await;
727 let mut config = Config::new_for_test(&dir);
728 config.shell_type = ShellType::PowerShell;
729 let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
730
731 append_path_to_shell_config(&config, false, &source_commands)
732 .await
733 .unwrap();
734
735 let profile = fs::read_to_string(managed_shell_profile_path(&config))
736 .await
737 .unwrap();
738 for config_path in
739 super::super::get_shell_config_paths(&config.shell_type, &config.home_dir).unwrap()
740 {
741 let content = fs::read_to_string(&config_path).await.unwrap();
742 assert!(
743 content.contains(". (Join-Path $HOME 'shell/profile.ps1')"),
744 "PowerShell profile should source managed shine profile from {}: {content}",
745 config_path.display()
746 );
747 }
748 assert!(
749 profile.contains("function setproxy"),
750 "managed PowerShell profile should contain setproxy wrapper: {profile}"
751 );
752 assert!(
753 profile.contains("function usetproxy"),
754 "managed PowerShell profile should contain usetproxy wrapper: {profile}"
755 );
756
757 fs::remove_dir_all(&dir).await.unwrap();
758 }
759
760 #[tokio::test]
761 async fn append_refreshes_stale_sentinel_with_managed_profile_source() {
762 let dir = make_temp_dir().await;
763 let config = Config::new_for_test(&dir);
764 let config_path = get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
765 if let Some(parent) = config_path.parent() {
766 fs::create_dir_all(parent).await.unwrap();
767 }
768 let sentinel_start = super::super::SENTINEL_START;
769 let sentinel_end = "# <<< shine <<<";
770 fs::write(
771 &config_path,
772 format!(
773 "before\n\n{sentinel_start}\nif [[ \":$PATH:\" != *\":$HOME/.shine/bin:\"* ]]; then\n export PATH=\"$HOME/.shine/bin:$PATH\"\nfi\n{sentinel_end}\nafter\n"
774 ),
775 )
776 .await
777 .unwrap();
778
779 let source_commands = vec!["setproxy".to_string(), "usetproxy".to_string()];
780 let update = append_path_to_shell_config(&config, false, &source_commands)
781 .await
782 .unwrap();
783
784 assert!(
785 matches!(update.config_status, PathUpdateStatus::Updated(_)),
786 "stale sentinel should be refreshed"
787 );
788 let content = fs::read_to_string(&config_path).await.unwrap();
789 assert!(
790 content.contains(managed_profile_source_marker(&config.shell_type)),
791 "shell config should source managed profile: {content}"
792 );
793 assert!(
794 !content.contains("export PATH"),
795 "stale PATH setup should be removed from shell config: {content}"
796 );
797 assert!(
798 !content.contains("setproxy()"),
799 "source wrappers should not be added directly to shell config: {content}"
800 );
801 assert!(
802 content.contains("before"),
803 "non-managed content should be preserved"
804 );
805 assert!(
806 content.contains("after"),
807 "non-managed content should be preserved"
808 );
809 let profile = fs::read_to_string(managed_shell_profile_path(&config))
810 .await
811 .unwrap();
812 let setproxy_marker = wrapper_marker("setproxy", &config.shell_type);
813 let usetproxy_marker = wrapper_marker("usetproxy", &config.shell_type);
814 assert!(
815 profile.contains(&setproxy_marker),
816 "setproxy wrapper should be added to managed profile: {profile}"
817 );
818 assert!(
819 profile.contains(&usetproxy_marker),
820 "usetproxy wrapper should be added to managed profile: {profile}"
821 );
822
823 fs::remove_dir_all(&dir).await.unwrap();
824 }
825
826 #[tokio::test]
827 async fn installed_source_commands_for_categories_are_scoped() {
828 let dir = make_temp_dir().await;
829 let config = Config::new_for_test(&dir);
830 fs::create_dir_all(config.presets_dir()).await.unwrap();
831 fs::create_dir_all(config.bin_dir()).await.unwrap();
832
833 handle_install(&config, Some("agent"), false).await.unwrap();
834 handle_install(&config, Some("proxy"), false).await.unwrap();
835
836 let proxy_only = metadata::load_installed_categories(&config, Some("proxy"))
837 .await
838 .unwrap();
839 let commands = installed_source_commands_for_categories(&config, &proxy_only)
840 .await
841 .unwrap();
842
843 assert_eq!(
844 commands,
845 vec!["setproxy".to_string(), "usetproxy".to_string()]
846 );
847 assert!(!commands.contains(&"ccenv".to_string()));
848
849 fs::remove_dir_all(&dir).await.unwrap();
850 }
851
852 #[cfg(unix)]
853 #[tokio::test]
854 async fn external_presets_install_links_disk_scripts_without_extraction() {
855 let dir = make_temp_dir().await;
856 let cat_dir = dir.join("presets/shell/custom");
859 fs::create_dir_all(&cat_dir).await.unwrap();
860 let script = cat_dir.join("my_tool.sh");
861 fs::write(&script, b"#!/bin/bash\n# My tool.\necho hi\n")
862 .await
863 .unwrap();
864 use std::os::unix::fs::PermissionsExt;
865 let mut perms = fs::metadata(&script).await.unwrap().permissions();
866 perms.set_mode(perms.mode() | 0o111);
867 fs::set_permissions(&script, perms).await.unwrap();
868
869 let mut config = Config::new_for_test(&dir);
870 config.is_external_presets = true;
871 fs::create_dir_all(config.bin_dir()).await.unwrap();
872
873 handle_install(&config, Some("custom"), false)
874 .await
875 .unwrap();
876
877 let count = {
880 let mut rd = fs::read_dir(&cat_dir).await.unwrap();
881 let mut n = 0u32;
882 while rd.next_entry().await.unwrap().is_some() {
883 n += 1;
884 }
885 n
886 };
887 assert_eq!(count, 1, "no embedded assets should have been extracted");
888
889 let link = config.bin_dir().join("my_tool");
891 assert!(link.exists(), "bin symlink should point at disk script");
892
893 fs::remove_dir_all(&dir).await.unwrap();
894 }
895
896 #[cfg(unix)]
897 #[tokio::test]
898 async fn external_presets_install_applies_metadata_rename() {
899 let dir = make_temp_dir().await;
900 let cat_dir = dir.join("presets/shell/custom");
901 fs::create_dir_all(&cat_dir).await.unwrap();
902 fs::write(
903 cat_dir.join("shine.toml"),
904 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\n",
905 )
906 .await
907 .unwrap();
908 let script = cat_dir.join("set_proxy.sh");
909 fs::write(&script, b"#!/bin/bash\n# Set proxy.\necho hi\n")
910 .await
911 .unwrap();
912 use std::os::unix::fs::PermissionsExt;
913 let mut perms = fs::metadata(&script).await.unwrap().permissions();
914 perms.set_mode(perms.mode() | 0o111);
915 fs::set_permissions(&script, perms).await.unwrap();
916
917 let mut config = Config::new_for_test(&dir);
918 config.is_external_presets = true;
919 fs::create_dir_all(config.bin_dir()).await.unwrap();
920
921 handle_install(&config, Some("custom"), false)
922 .await
923 .unwrap();
924
925 assert!(config.bin_dir().join("setproxy").exists());
926 assert!(!config.bin_dir().join("set_proxy").exists());
927
928 fs::remove_dir_all(&dir).await.unwrap();
929 }
930
931 #[cfg(unix)]
932 #[tokio::test]
933 async fn external_presets_install_links_non_executable_source_scripts() {
934 let dir = make_temp_dir().await;
935 let cat_dir = dir.join("presets/shell/proxy");
936 fs::create_dir_all(&cat_dir).await.unwrap();
937 fs::write(
938 cat_dir.join("shine.toml"),
939 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n[[files]]\nsource = \"uset_proxy.sh\"\ntarget = \"usetproxy\"\nneeds_source = true\n",
940 )
941 .await
942 .unwrap();
943 fs::write(
944 &cat_dir.join("set_proxy.sh"),
945 b"#!/bin/bash\n# Set proxy.\n",
946 )
947 .await
948 .unwrap();
949 fs::write(
950 &cat_dir.join("uset_proxy.sh"),
951 b"#!/bin/bash\n# Unset proxy.\n",
952 )
953 .await
954 .unwrap();
955
956 let mut config = Config::new_for_test(&dir);
957 config.is_external_presets = true;
958 fs::create_dir_all(config.bin_dir()).await.unwrap();
959
960 handle_install(&config, Some("proxy"), false).await.unwrap();
961
962 assert!(config.bin_dir().join("setproxy").exists());
963 assert!(config.bin_dir().join("usetproxy").exists());
964
965 fs::remove_dir_all(&dir).await.unwrap();
966 }
967
968 #[tokio::test]
969 async fn init_template_creates_parseable_shell_metadata() {
970 let dir = make_temp_dir().await;
971 let cat_dir = dir.join("presets/shell/custom");
972 fs::create_dir_all(&cat_dir).await.unwrap();
973
974 let (path, overwritten) =
975 utils::init_template::write_shine_toml_template(&cat_dir, false, SHELL_TEMPLATE)
976 .unwrap();
977 fs::write(
978 cat_dir.join("my_tool.sh"),
979 b"#!/bin/bash\n# My tool.\necho hi\n",
980 )
981 .await
982 .unwrap();
983
984 let config = Config::new_for_test(&dir);
985 let categories = metadata::load_installed_categories(&config, Some("custom"))
986 .await
987 .unwrap();
988
989 assert_eq!(path, cat_dir.join("shine.toml"));
990 assert!(!overwritten);
991 assert_eq!(categories.len(), 1);
992 assert_eq!(
993 categories[0].description.as_deref(),
994 Some("My shell helper commands.")
995 );
996 assert_eq!(
997 categories[0].files[0].source_rel,
998 PathBuf::from("my_tool.sh")
999 );
1000 assert_eq!(categories[0].files[0].command_name, "mytool");
1001 assert!(!categories[0].files[0].needs_source);
1002
1003 fs::remove_dir_all(&dir).await.unwrap();
1004 }
1005
1006 #[tokio::test]
1007 async fn init_template_refuses_existing_file_unless_forced() {
1008 let dir = make_temp_dir().await;
1009 fs::write(dir.join("shine.toml"), b"old").await.unwrap();
1010
1011 let err = utils::init_template::write_shine_toml_template(&dir, false, SHELL_TEMPLATE)
1012 .unwrap_err();
1013 assert!(
1014 err.to_string().contains("use --force to overwrite"),
1015 "unexpected error: {err:#}"
1016 );
1017 assert_eq!(fs::read(dir.join("shine.toml")).await.unwrap(), b"old");
1018
1019 let (_path, overwritten) =
1020 utils::init_template::write_shine_toml_template(&dir, true, SHELL_TEMPLATE).unwrap();
1021 assert!(overwritten);
1022 let content = fs::read_to_string(dir.join("shine.toml")).await.unwrap();
1023 assert!(content.contains("target = \"mytool\""));
1024
1025 fs::remove_dir_all(&dir).await.unwrap();
1026 }
1027
1028 #[cfg(unix)]
1029 #[tokio::test]
1030 async fn template_render_error_does_not_link_raw_script() {
1031 let dir = make_temp_dir().await;
1032 let cat_dir = dir.join("presets/shell/proxy");
1033 fs::create_dir_all(&cat_dir).await.unwrap();
1034 fs::write(
1035 cat_dir.join("shine.toml"),
1036 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1037 )
1038 .await
1039 .unwrap();
1040 let script = cat_dir.join("set_proxy.sh");
1041 fs::write(
1042 &script,
1043 b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
1044 )
1045 .await
1046 .unwrap();
1047 make_executable(&script).await;
1048
1049 let mut config = Config::new_for_test(&dir);
1050 config.is_external_presets = true;
1051 fs::create_dir_all(config.bin_dir()).await.unwrap();
1052 fs::write(config.rendered_dir(), b"not a directory")
1053 .await
1054 .unwrap();
1055
1056 let err = handle_install(&config, Some("proxy"), false)
1057 .await
1058 .expect_err("install should fail when rendered_dir cannot be created");
1059
1060 assert!(
1061 err.to_string()
1062 .contains("creating rendered script directory"),
1063 "unexpected error: {err:#}"
1064 );
1065 assert!(
1066 !config.bin_dir().join("setproxy").exists(),
1067 "failed render must not link the raw template script"
1068 );
1069
1070 fs::remove_dir_all(&dir).await.unwrap();
1071 }
1072
1073 #[tokio::test]
1074 async fn embedded_agent_installs_bun_launcher_without_rendering_credentials() {
1075 let dir = make_temp_dir().await;
1076 let config = Config::new_for_test(&dir);
1077 fs::create_dir_all(config.presets_dir()).await.unwrap();
1078 fs::create_dir_all(config.bin_dir()).await.unwrap();
1079
1080 handle_install(&config, Some("agent"), false).await.unwrap();
1081
1082 let source = config.presets_dir().join("shell/agent/cc.ts");
1083 assert!(source.exists());
1084 assert!(!config.rendered_dir().join("shell/agent/cc.ts").exists());
1085 let launcher = crate::bin_links::command_path_for_name(
1086 config.bin_dir(),
1087 std::ffi::OsStr::new("ccenv"),
1088 );
1089 let launcher_content = fs::read_to_string(&launcher).await.unwrap();
1090 assert!(launcher_content.contains("shine-managed"));
1091 assert!(launcher_content.contains(&source.display().to_string()));
1092 assert!(launcher_content.contains("bun"));
1093
1094 let source_commands = installed_source_commands(&config).await.unwrap();
1095 assert!(!source_commands.contains(&"ccenv".to_string()));
1096
1097 fs::remove_dir_all(&dir).await.unwrap();
1098 }
1099
1100 #[cfg(unix)]
1101 #[tokio::test]
1102 async fn embedded_source_and_link_upgrade_report_target_once() {
1103 let dir = make_temp_dir().await;
1104 let config = Config::new_for_test(&dir);
1105 fs::create_dir_all(config.presets_dir()).await.unwrap();
1106 fs::create_dir_all(config.bin_dir()).await.unwrap();
1107 handle_install(&config, Some("utils"), false).await.unwrap();
1108
1109 let source = config.presets_dir().join("shell/utils/copyfile.sh");
1110 fs::write(&source, b"#!/bin/sh\necho stale\n")
1111 .await
1112 .unwrap();
1113 make_executable(&source).await;
1114
1115 let stale_source = dir.join("stale-copyfile.sh");
1116 fs::write(&stale_source, b"#!/bin/sh\necho stale link\n")
1117 .await
1118 .unwrap();
1119 make_executable(&stale_source).await;
1120 let link = config.bin_dir().join("copyfile");
1121 fs::remove_file(&link).await.unwrap();
1122 fs::symlink(&stale_source, &link).await.unwrap();
1123
1124 let mut separator = crate::output::SectionSeparator::new();
1125 let report = handle_upgrade_installed(&config, false, &mut separator)
1126 .await
1127 .unwrap();
1128
1129 assert_eq!(report.updated_targets, vec!["utils/copyfile"]);
1130 assert_eq!(report.links_updated, 1);
1131 assert_eq!(fs::read_link(&link).await.unwrap(), source);
1132 fs::remove_dir_all(&dir).await.unwrap();
1133 }
1134
1135 #[cfg(unix)]
1136 #[tokio::test]
1137 async fn external_presets_upgrade_does_not_install_preset_only_scripts() {
1138 let dir = make_temp_dir().await;
1139 let proxy_dir = dir.join("presets/shell/proxy");
1140 let extra_dir = dir.join("presets/shell/extra");
1141 fs::create_dir_all(&proxy_dir).await.unwrap();
1142 fs::create_dir_all(&extra_dir).await.unwrap();
1143
1144 fs::write(
1145 proxy_dir.join("shine.toml"),
1146 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1147 )
1148 .await
1149 .unwrap();
1150 let setproxy = proxy_dir.join("set_proxy.sh");
1151 fs::write(
1152 &setproxy,
1153 b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
1154 )
1155 .await
1156 .unwrap();
1157 make_executable(&setproxy).await;
1158
1159 let extra_tool = extra_dir.join("extra_tool.sh");
1160 fs::write(&extra_tool, b"#!/bin/bash\n# Extra tool.\necho extra\n")
1161 .await
1162 .unwrap();
1163 make_executable(&extra_tool).await;
1164
1165 let mut config = Config::new_for_test(&dir);
1166 config.is_external_presets = true;
1167 fs::create_dir_all(config.bin_dir()).await.unwrap();
1168
1169 handle_install(&config, Some("proxy"), false).await.unwrap();
1170 assert!(config.bin_dir().join("setproxy").exists());
1171 assert!(
1172 !config.bin_dir().join("extra_tool").exists(),
1173 "extra preset should start as present but not installed"
1174 );
1175
1176 fs::write(
1177 &setproxy,
1178 b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
1179 )
1180 .await
1181 .unwrap();
1182 make_executable(&setproxy).await;
1183
1184 let mut sep = crate::output::SectionSeparator::new();
1185 let report = handle_upgrade_installed(&config, false, &mut sep)
1186 .await
1187 .unwrap();
1188
1189 assert_eq!(
1190 report.templates_updated, 1,
1191 "changed shell template should be reported under shell presets"
1192 );
1193 assert_eq!(report.updated_targets, vec!["proxy/setproxy"]);
1194 assert!(config.bin_dir().join("setproxy").exists());
1195 assert!(
1196 !config.bin_dir().join("extra_tool").exists(),
1197 "upgrade must not install preset-only scripts"
1198 );
1199
1200 fs::remove_dir_all(&dir).await.unwrap();
1201 }
1202
1203 #[cfg(unix)]
1204 #[tokio::test]
1205 async fn external_bun_preset_installs_launcher_and_uninstall_removes_it() {
1206 let dir = make_temp_dir().await;
1207 let cat_dir = dir.join("presets/shell/custom");
1208 fs::create_dir_all(&cat_dir).await.unwrap();
1209 fs::write(
1210 cat_dir.join("shine.toml"),
1211 b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n",
1212 )
1213 .await
1214 .unwrap();
1215 fs::write(cat_dir.join("tool.ts"), b"console.log('hi')\n")
1217 .await
1218 .unwrap();
1219
1220 let mut config = Config::new_for_test(&dir);
1221 config.is_external_presets = true;
1222 fs::create_dir_all(config.bin_dir()).await.unwrap();
1223
1224 handle_install(&config, Some("custom"), false)
1225 .await
1226 .unwrap();
1227
1228 let launcher = config.bin_dir().join("mytool");
1229 assert!(launcher.exists(), "bun launcher should be installed");
1230 assert!(!launcher.is_symlink(), "bun launcher is a regular file");
1231 let content = fs::read_to_string(&launcher).await.unwrap();
1232 assert!(content.contains("exec bun"));
1233 assert!(
1234 content.contains(
1235 &config
1236 .installed_shell_dir()
1237 .join("custom/tool.ts")
1238 .display()
1239 .to_string()
1240 )
1241 );
1242 assert!(
1243 !config.bin_dir().join("tool").exists(),
1244 "command should use the target rename, not the .ts stem"
1245 );
1246
1247 handle_uninstall(&config, Some("custom"), false, false)
1248 .await
1249 .unwrap();
1250 assert!(
1251 !launcher.exists(),
1252 "managed bun launcher must be removed on uninstall"
1253 );
1254 assert!(
1255 cat_dir.join("tool.ts").exists(),
1256 "external source must be preserved"
1257 );
1258
1259 fs::remove_dir_all(&dir).await.unwrap();
1260 }
1261
1262 #[cfg(unix)]
1263 #[tokio::test]
1264 async fn external_bun_preset_with_env_wraps_launcher_in_shine_env_run() {
1265 let dir = make_temp_dir().await;
1266 let cat_dir = dir.join("presets/shell/custom");
1267 fs::create_dir_all(&cat_dir).await.unwrap();
1268 fs::write(
1269 cat_dir.join("shine.toml"),
1270 b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\nenv = [\"API_URL\", \"SERVICE_TOKEN=API_TOKEN\"]\n",
1271 )
1272 .await
1273 .unwrap();
1274 fs::write(cat_dir.join("tool.ts"), b"console.log(Bun.env.API_URL)\n")
1275 .await
1276 .unwrap();
1277
1278 let mut config = Config::new_for_test(&dir);
1279 config.is_external_presets = true;
1280 fs::create_dir_all(config.bin_dir()).await.unwrap();
1281
1282 handle_install(&config, Some("custom"), false)
1283 .await
1284 .unwrap();
1285
1286 let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1287 .await
1288 .unwrap();
1289 assert!(launcher.contains("command -v shine"));
1290 assert!(launcher.contains(
1291 "exec shine env run --no-workspace --with 'API_URL' --with 'SERVICE_TOKEN=API_TOKEN' -- bun "
1292 ));
1293
1294 fs::remove_dir_all(&dir).await.unwrap();
1295 }
1296
1297 #[cfg(unix)]
1298 #[tokio::test]
1299 async fn external_bun_preset_with_template_transform_targets_rendered_copy() {
1300 let dir = make_temp_dir().await;
1301 let cat_dir = dir.join("presets/shell/custom");
1302 fs::create_dir_all(&cat_dir).await.unwrap();
1303 fs::write(
1304 cat_dir.join("shine.toml"),
1305 b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n",
1306 )
1307 .await
1308 .unwrap();
1309 fs::write(cat_dir.join("tool.ts"), b"const host = '@@PROXY_HOST@@'\n")
1310 .await
1311 .unwrap();
1312
1313 let mut config = Config::new_for_test(&dir);
1314 config.is_external_presets = true;
1315 config
1316 .env
1317 .insert("PROXY_HOST".into(), "proxy.example".into());
1318 fs::create_dir_all(config.bin_dir()).await.unwrap();
1319
1320 handle_install(&config, Some("custom"), false)
1321 .await
1322 .unwrap();
1323
1324 let rendered = config.rendered_dir().join("shell/custom/tool.ts");
1325 assert!(
1326 rendered.exists(),
1327 "template transform should render the .ts"
1328 );
1329 assert!(
1330 fs::read_to_string(&rendered)
1331 .await
1332 .unwrap()
1333 .contains("proxy.example"),
1334 "rendered bun script should have @@PROXY_HOST@@ substituted"
1335 );
1336 let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1337 .await
1338 .unwrap();
1339 assert!(
1340 launcher.contains(&rendered.display().to_string()),
1341 "launcher must target the rendered copy: {launcher}"
1342 );
1343
1344 fs::remove_dir_all(&dir).await.unwrap();
1345 }
1346
1347 #[cfg(unix)]
1348 #[tokio::test]
1349 async fn live_transformed_bun_renders_again_on_demand() {
1350 let dir = make_temp_dir().await;
1351 let cat_dir = dir.join("presets/shell/custom");
1352 fs::create_dir_all(&cat_dir).await.unwrap();
1353 fs::write(
1354 cat_dir.join("shine.toml"),
1355 b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n",
1356 )
1357 .await
1358 .unwrap();
1359 let source = cat_dir.join("tool.ts");
1360 fs::write(&source, b"console.log('@@PROXY_HOST@@')\n")
1361 .await
1362 .unwrap();
1363
1364 let mut config = Config::new_for_test(&dir);
1365 config.is_external_presets = true;
1366 config.external_shell_mode = crate::config::ExternalShellMode::Live;
1367 config
1368 .env
1369 .insert("PROXY_HOST".into(), "first.example".into());
1370 fs::create_dir_all(config.bin_dir()).await.unwrap();
1371 handle_install(&config, Some("custom"), false)
1372 .await
1373 .unwrap();
1374
1375 let rendered = config.rendered_dir().join("shell/custom/tool.ts");
1376 assert!(
1377 fs::read_to_string(&rendered)
1378 .await
1379 .unwrap()
1380 .contains("first.example")
1381 );
1382 config
1383 .env
1384 .insert("PROXY_HOST".into(), "second.example".into());
1385 crate::shells::deployment::handle_render_live(&config, "shell/custom/mytool")
1386 .await
1387 .unwrap();
1388 assert!(
1389 fs::read_to_string(&rendered)
1390 .await
1391 .unwrap()
1392 .contains("second.example")
1393 );
1394 let last_good = fs::read(&rendered).await.unwrap();
1395 fs::write(&source, b"console.log('@@MISSING_LIVE_VALUE@@')\n")
1396 .await
1397 .unwrap();
1398 assert!(
1399 crate::shells::deployment::handle_render_live(&config, "shell/custom/mytool")
1400 .await
1401 .is_err()
1402 );
1403 assert_eq!(
1404 fs::read(&rendered).await.unwrap(),
1405 last_good,
1406 "failed live transform must preserve the last-known-good output"
1407 );
1408
1409 let launcher = fs::read_to_string(config.bin_dir().join("mytool"))
1410 .await
1411 .unwrap();
1412 assert!(launcher.contains("__shell-render"));
1413 assert!(launcher.contains("--config-dir"));
1414 fs::remove_dir_all(&dir).await.unwrap();
1415 }
1416
1417 #[cfg(unix)]
1418 #[tokio::test]
1419 async fn snapshot_upgrade_applies_external_raw_source_change() {
1420 let dir = make_temp_dir().await;
1421 let cat_dir = dir.join("presets/shell/custom");
1422 fs::create_dir_all(&cat_dir).await.unwrap();
1423 fs::write(
1424 cat_dir.join("shine.toml"),
1425 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1426 )
1427 .await
1428 .unwrap();
1429 let source = cat_dir.join("tool.sh");
1430 fs::write(&source, b"#!/bin/sh\necho first\n")
1431 .await
1432 .unwrap();
1433
1434 let mut config = Config::new_for_test(&dir);
1435 config.is_external_presets = true;
1436 fs::create_dir_all(config.bin_dir()).await.unwrap();
1437 handle_install(&config, Some("custom"), false)
1438 .await
1439 .unwrap();
1440 let installed = config.installed_shell_dir().join("custom/tool.sh");
1441 assert!(
1442 fs::read_to_string(&installed)
1443 .await
1444 .unwrap()
1445 .contains("first")
1446 );
1447
1448 fs::write(&source, b"#!/bin/sh\necho second\n")
1449 .await
1450 .unwrap();
1451 let mut separator = crate::output::SectionSeparator::new();
1452 let report = handle_upgrade_installed(&config, false, &mut separator)
1453 .await
1454 .unwrap();
1455 assert_eq!(report.snapshots_updated, 1);
1456 assert_eq!(report.updated_targets, vec!["custom/mytool"]);
1457 assert!(
1458 fs::read_to_string(&installed)
1459 .await
1460 .unwrap()
1461 .contains("second")
1462 );
1463 assert_eq!(
1464 fs::read_link(config.bin_dir().join("mytool"))
1465 .await
1466 .unwrap(),
1467 installed
1468 );
1469 fs::remove_dir_all(&dir).await.unwrap();
1470 }
1471
1472 #[cfg(unix)]
1473 #[tokio::test]
1474 async fn upgrade_migrates_legacy_external_link_to_snapshot() {
1475 let dir = make_temp_dir().await;
1476 let cat_dir = dir.join("presets/shell/custom");
1477 fs::create_dir_all(&cat_dir).await.unwrap();
1478 fs::write(
1479 cat_dir.join("shine.toml"),
1480 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1481 )
1482 .await
1483 .unwrap();
1484 let source = cat_dir.join("tool.sh");
1485 fs::write(&source, b"#!/bin/sh\necho legacy\n")
1486 .await
1487 .unwrap();
1488 let mut config = Config::new_for_test(&dir);
1489 config.is_external_presets = true;
1490 fs::create_dir_all(config.bin_dir()).await.unwrap();
1491 fs::symlink(&source, config.bin_dir().join("mytool"))
1492 .await
1493 .unwrap();
1494
1495 let mut separator = crate::output::SectionSeparator::new();
1496 let report = handle_upgrade_installed(&config, false, &mut separator)
1497 .await
1498 .unwrap();
1499 assert_eq!(report.snapshots_updated, 1);
1500 assert_eq!(
1501 fs::read_link(config.bin_dir().join("mytool"))
1502 .await
1503 .unwrap(),
1504 config.installed_shell_dir().join("custom/tool.sh")
1505 );
1506 assert!(
1507 crate::shells::deployment::ShellManifest::load(&config)
1508 .await
1509 .unwrap()
1510 .find("shell/custom/mytool")
1511 .is_some()
1512 );
1513 fs::remove_dir_all(&dir).await.unwrap();
1514 }
1515
1516 #[cfg(unix)]
1517 #[tokio::test]
1518 async fn upgrade_switches_snapshot_raw_link_to_explicit_live_source() {
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 fs::write(
1523 cat_dir.join("shine.toml"),
1524 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1525 )
1526 .await
1527 .unwrap();
1528 let source = cat_dir.join("tool.sh");
1529 fs::write(&source, b"#!/bin/sh\necho live\n").await.unwrap();
1530 let mut config = Config::new_for_test(&dir);
1531 config.is_external_presets = true;
1532 fs::create_dir_all(config.bin_dir()).await.unwrap();
1533 handle_install(&config, Some("custom"), false)
1534 .await
1535 .unwrap();
1536
1537 config.external_shell_mode = crate::config::ExternalShellMode::Live;
1538 let mut separator = crate::output::SectionSeparator::new();
1539 handle_upgrade_installed(&config, false, &mut separator)
1540 .await
1541 .unwrap();
1542 assert_eq!(
1543 fs::read_link(config.bin_dir().join("mytool"))
1544 .await
1545 .unwrap(),
1546 source
1547 );
1548 let manifest = crate::shells::deployment::ShellManifest::load(&config)
1549 .await
1550 .unwrap();
1551 assert_eq!(
1552 manifest.find("shell/custom/mytool").unwrap().mode,
1553 crate::config::ExternalShellMode::Live
1554 );
1555 fs::remove_dir_all(&dir).await.unwrap();
1556 }
1557}