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