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