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