1use anyhow::{Context, Result};
2use std::collections::HashSet;
3use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5
6const LINKABLE_SCRIPT_EXTENSIONS: &[&str] = &["sh", "bash", "zsh", "fish", "ps1"];
7
8const BUN_SCRIPT_EXTENSIONS: &[&str] = &["ts", "js", "mts", "mjs"];
10
11#[cfg(not(unix))]
12const EXECUTABLE_EXTENSIONS: &[&str] = &["sh", "ps1"];
13
14const SHIM_MANAGED_MARKER: &str = "# shine-managed";
18const SHIM_TARGET_PREFIX: &str = "# shine-target: ";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum LinkRuntime {
28 #[default]
29 Native,
30 Bun,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39enum LauncherStatus {
40 Current,
41 Stale,
42 NotManaged,
43}
44
45pub struct LinkReport {
46 pub created: Vec<PathBuf>,
47 pub skipped: Vec<PathBuf>,
48 pub conflicts: Vec<LinkConflict>,
49 pub overwritten: Vec<PathBuf>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum LinkConflictKind {
54 ExistingEntry,
55 DuplicateName,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct LinkConflict {
60 pub link_path: PathBuf,
61 pub source: PathBuf,
62 pub kind: LinkConflictKind,
63}
64
65pub struct UnlinkReport {
66 pub removed: Vec<PathBuf>,
67 pub skipped: Vec<PathBuf>,
68}
69
70pub struct LinkSpec {
71 pub source: PathBuf,
72 pub link_name: OsString,
73 pub runtime: LinkRuntime,
74 pub env: Vec<String>,
79 pub render_target: Option<String>,
81}
82
83pub async fn unlink_managed(
89 bin_dir: &Path,
90 managed_root: &Path,
91 dry_run: bool,
92) -> Result<UnlinkReport> {
93 let mut report = UnlinkReport {
94 removed: Vec::new(),
95 skipped: Vec::new(),
96 };
97
98 let mut read_dir = match tokio::fs::read_dir(bin_dir).await {
99 Ok(rd) => rd,
100 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(report),
101 Err(e) => return Err(e).with_context(|| format!("reading bin dir: {bin_dir:?}")),
102 };
103
104 while let Some(entry) = read_dir
105 .next_entry()
106 .await
107 .with_context(|| format!("iterating bin dir: {bin_dir:?}"))?
108 {
109 let path = entry.path();
110 let meta = match tokio::fs::symlink_metadata(&path).await {
111 Ok(m) => m,
112 Err(_) => continue,
113 };
114
115 if !meta.file_type().is_symlink() {
121 match launcher_target(&path).await {
122 Ok(Some(target)) if target_is_managed(&target, managed_root, bin_dir) => {
123 if !dry_run {
124 remove_link(&path).await?;
125 }
126 report.removed.push(path);
127 }
128 _ => report.skipped.push(path),
129 }
130 continue;
131 }
132
133 let target = match tokio::fs::read_link(&path).await {
134 Ok(t) => t,
135 Err(_) => {
136 report.skipped.push(path);
137 continue;
138 }
139 };
140
141 if target_is_managed(&target, managed_root, bin_dir) {
143 if !dry_run {
144 tokio::fs::remove_file(&path)
145 .await
146 .with_context(|| format!("removing symlink: {path:?}"))?;
147 }
148 report.removed.push(path);
149 } else {
150 report.skipped.push(path);
151 }
152 }
153
154 Ok(report)
155}
156
157#[cfg(test)]
164pub async fn link_executables(
165 bin_dir: &Path,
166 sources: &[PathBuf],
167 overwrite: bool,
168) -> Result<LinkReport> {
169 let specs: Vec<_> = sources
170 .iter()
171 .map(|source| LinkSpec {
172 source: source.clone(),
173 link_name: link_stem(source),
174 runtime: LinkRuntime::Native,
175 env: Vec::new(),
176 render_target: None,
177 })
178 .collect();
179 link_executables_with_names(bin_dir, &specs, overwrite).await
180}
181
182pub async fn link_executables_with_names(
183 bin_dir: &Path,
184 specs: &[LinkSpec],
185 overwrite: bool,
186) -> Result<LinkReport> {
187 let mut report = LinkReport {
188 created: Vec::new(),
189 skipped: Vec::new(),
190 conflicts: Vec::new(),
191 overwritten: Vec::new(),
192 };
193
194 let mut seen: HashSet<OsString> = HashSet::new();
195
196 for spec in specs {
197 if spec.runtime == LinkRuntime::Native && !is_linkable_source(&spec.source) {
200 continue;
201 }
202
203 if spec.source.file_name().is_none() {
204 continue;
205 }
206 let stem = spec.link_name.clone();
207
208 if !seen.insert(stem.clone()) {
209 report.conflicts.push(LinkConflict {
210 link_path: command_path_for_name(bin_dir, &stem),
211 source: spec.source.clone(),
212 kind: LinkConflictKind::DuplicateName,
213 });
214 continue;
215 }
216
217 let link_path = command_path_for_name(bin_dir, &stem);
218
219 match tokio::fs::symlink_metadata(&link_path).await {
220 Ok(meta) if meta.file_type().is_symlink() => {
221 match tokio::fs::read_link(&link_path).await {
222 Ok(existing) if existing == spec.source && spec.render_target.is_none() => {
223 report.skipped.push(link_path);
224 }
225 _ => {
226 if overwrite {
227 tokio::fs::remove_file(&link_path).await.with_context(|| {
228 format!("removing stale symlink: {link_path:?}")
229 })?;
230 create_link(
231 &spec.source,
232 &link_path,
233 spec.runtime,
234 &spec.env,
235 spec.render_target.as_deref(),
236 )
237 .await?;
238 report.overwritten.push(link_path);
239 } else {
240 report.conflicts.push(LinkConflict {
241 link_path,
242 source: spec.source.clone(),
243 kind: LinkConflictKind::ExistingEntry,
244 });
245 }
246 }
247 }
248 }
249 Ok(_) => {
250 match launcher_status(
251 &link_path,
252 &spec.source,
253 spec.runtime,
254 &spec.env,
255 spec.render_target.as_deref(),
256 )
257 .await?
258 {
259 LauncherStatus::Current => {
260 report.skipped.push(link_path);
261 continue;
262 }
263 LauncherStatus::Stale => {
264 remove_link(&link_path).await?;
265 create_link(
266 &spec.source,
267 &link_path,
268 spec.runtime,
269 &spec.env,
270 spec.render_target.as_deref(),
271 )
272 .await?;
273 report.overwritten.push(link_path);
274 continue;
275 }
276 LauncherStatus::NotManaged => {}
277 }
278
279 if overwrite {
280 remove_link(&link_path).await?;
281 create_link(
282 &spec.source,
283 &link_path,
284 spec.runtime,
285 &spec.env,
286 spec.render_target.as_deref(),
287 )
288 .await?;
289 report.overwritten.push(link_path);
290 } else {
291 report.conflicts.push(LinkConflict {
292 link_path,
293 source: spec.source.clone(),
294 kind: LinkConflictKind::ExistingEntry,
295 });
296 }
297 }
298 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
299 create_link(
300 &spec.source,
301 &link_path,
302 spec.runtime,
303 &spec.env,
304 spec.render_target.as_deref(),
305 )
306 .await?;
307 report.created.push(link_path);
308 }
309 Err(e) => {
310 return Err(e).with_context(|| format!("stat failed: {link_path:?}"));
311 }
312 }
313 }
314
315 Ok(report)
316}
317
318pub(crate) async fn link_is_current(
324 link_path: &Path,
325 source: &Path,
326 runtime: LinkRuntime,
327 env: &[String],
328 render_target: Option<&str>,
329) -> Result<bool> {
330 match tokio::fs::symlink_metadata(link_path).await {
331 Ok(meta) if meta.file_type().is_symlink() => {
332 if runtime != LinkRuntime::Native || render_target.is_some() {
333 return Ok(false);
334 }
335 Ok(tokio::fs::read_link(link_path)
336 .await
337 .is_ok_and(|target| target == source))
338 }
339 Ok(_) => Ok(matches!(
340 launcher_status(link_path, source, runtime, env, render_target).await?,
341 LauncherStatus::Current
342 )),
343 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
344 Err(error) => Err(error).with_context(|| format!("stat failed: {link_path:?}")),
345 }
346}
347
348pub fn command_path_for_name(bin_dir: &Path, stem: &OsStr) -> PathBuf {
349 #[cfg(unix)]
350 {
351 bin_dir.join(stem)
352 }
353 #[cfg(not(unix))]
354 {
355 let mut name = stem.to_os_string();
356 name.push(".ps1");
357 bin_dir.join(name)
358 }
359}
360
361pub fn link_stem(path: &Path) -> std::ffi::OsString {
362 if has_linkable_script_extension(path) || has_bun_script_extension(path) {
363 path.file_stem().map(|s| s.to_owned()).unwrap_or_default()
364 } else {
365 path.file_name().map(|n| n.to_owned()).unwrap_or_default()
366 }
367}
368
369fn is_executable(path: &Path) -> bool {
370 #[cfg(unix)]
371 {
372 use std::os::unix::fs::PermissionsExt;
373 std::fs::metadata(path)
374 .map(|m| m.permissions().mode() & 0o111 != 0)
375 .unwrap_or(false)
376 }
377 #[cfg(not(unix))]
378 {
379 path.extension()
380 .and_then(|e| e.to_str())
381 .map(|ext| EXECUTABLE_EXTENSIONS.contains(&ext))
382 .unwrap_or(false)
383 }
384}
385
386fn is_linkable_source(path: &Path) -> bool {
387 is_executable(path) || has_linkable_script_extension(path)
388}
389
390fn has_linkable_script_extension(path: &Path) -> bool {
391 path.extension()
392 .and_then(|e| e.to_str())
393 .map(|ext| LINKABLE_SCRIPT_EXTENSIONS.contains(&ext))
394 .unwrap_or(false)
395}
396
397fn has_bun_script_extension(path: &Path) -> bool {
398 path.extension()
399 .and_then(|e| e.to_str())
400 .map(|ext| BUN_SCRIPT_EXTENSIONS.contains(&ext))
401 .unwrap_or(false)
402}
403
404fn target_is_managed(target: &Path, managed_root: &Path, bin_dir: &Path) -> bool {
408 if target.is_absolute() {
409 target.starts_with(managed_root)
410 } else {
411 bin_dir.join(target).starts_with(managed_root)
412 }
413}
414
415fn launcher_command_name(link_path: &Path) -> String {
417 link_path
418 .file_stem()
419 .map(|s| s.to_string_lossy().into_owned())
420 .unwrap_or_default()
421}
422
423async fn launcher_target(path: &Path) -> Result<Option<PathBuf>> {
427 let content = match tokio::fs::read_to_string(path).await {
428 Ok(content) => content,
429 Err(_) => return Ok(None),
430 };
431 if !content.contains(SHIM_MANAGED_MARKER) {
432 return Ok(None);
433 }
434 Ok(shim_target_from_content(&content))
435}
436
437fn shim_target_from_content(content: &str) -> Option<PathBuf> {
438 content.lines().find_map(|line| {
439 line.strip_prefix(SHIM_TARGET_PREFIX)
440 .or_else(|| line.strip_prefix("REM shine-target: "))
441 .map(PathBuf::from)
442 })
443}
444
445async fn create_link(
446 source: &Path,
447 link_path: &Path,
448 runtime: LinkRuntime,
449 env: &[String],
450 render_target: Option<&str>,
451) -> Result<()> {
452 #[cfg(unix)]
453 {
454 if let Some(target) = render_target {
455 return write_unix_live_launcher(source, link_path, runtime, env, target).await;
456 }
457 match runtime {
458 LinkRuntime::Native => tokio::fs::symlink(source, link_path)
459 .await
460 .with_context(|| format!("creating symlink {link_path:?} -> {source:?}")),
461 LinkRuntime::Bun => write_unix_bun_launcher(source, link_path, env).await,
462 }
463 }
464 #[cfg(not(unix))]
465 {
466 create_windows_shims(source, link_path, runtime, env, render_target).await
467 }
468}
469
470async fn remove_link(link_path: &Path) -> Result<()> {
471 #[cfg(unix)]
472 {
473 tokio::fs::remove_file(link_path)
474 .await
475 .with_context(|| format!("removing existing file: {link_path:?}"))
476 }
477 #[cfg(not(unix))]
478 {
479 remove_windows_shims(link_path).await
480 }
481}
482
483async fn launcher_status(
488 link_path: &Path,
489 source: &Path,
490 runtime: LinkRuntime,
491 env: &[String],
492 render_target: Option<&str>,
493) -> Result<LauncherStatus> {
494 #[cfg(unix)]
495 {
496 if let Some(target) = render_target {
497 return unix_live_launcher_status(link_path, source, runtime, env, target).await;
498 }
499 match runtime {
500 LinkRuntime::Bun => unix_launcher_status(link_path, source, env).await,
501 LinkRuntime::Native => Ok(LauncherStatus::NotManaged),
502 }
503 }
504 #[cfg(not(unix))]
505 {
506 windows_shim_status(link_path, source, runtime, env, render_target).await
507 }
508}
509
510#[cfg(unix)]
511fn shell_single_quote(value: &str) -> String {
512 format!("'{}'", value.replace('\'', "'\\''"))
513}
514
515#[cfg(unix)]
519fn unix_bun_launcher_content(source: &Path, name: &str, env: &[String]) -> String {
520 let target = source.display().to_string();
521 let quoted_target = shell_single_quote(&target);
522 let quoted_name = shell_single_quote(name);
523 let (shine_check, runner) = if env.is_empty() {
527 (String::new(), format!("exec bun {quoted_target} \"$@\"\n"))
528 } else {
529 let with_args = env
530 .iter()
531 .map(|token| format!("--with {}", shell_single_quote(token)))
532 .collect::<Vec<_>>()
533 .join(" ");
534 (
535 format!(
536 "if ! command -v shine >/dev/null 2>&1; then\n \
537 printf 'shine: %s requires the shine command, which was not found on PATH.\\n' {quoted_name} >&2\n \
538 exit 127\nfi\n"
539 ),
540 format!(
541 "exec shine env run --no-workspace {with_args} -- bun {quoted_target} \"$@\"\n"
542 ),
543 )
544 };
545 format!(
546 "#!/usr/bin/env bash\n\
547 {SHIM_MANAGED_MARKER}\n\
548 {SHIM_TARGET_PREFIX}{target}\n\
549 if ! command -v bun >/dev/null 2>&1; then\n \
550 printf 'shine: %s requires Bun, which was not found on PATH.\\n' {quoted_name} >&2\n \
551 printf 'shine: install Bun from https://bun.sh, then re-run %s.\\n' {quoted_name} >&2\n \
552 exit 127\nfi\n\
553 {shine_check}{runner}"
554 )
555}
556
557#[cfg(unix)]
558fn unix_live_launcher_content(
559 source: &Path,
560 name: &str,
561 runtime: LinkRuntime,
562 env: &[String],
563 render_target: &str,
564) -> String {
565 let target = source.display().to_string();
566 let quoted_source = shell_single_quote(&target);
567 let quoted_name = shell_single_quote(name);
568 let quoted_render_target = shell_single_quote(render_target);
569 let config_dir = live_config_dir(source);
570 let config_arg = if config_dir.file_name() == Some(OsStr::new(".shine")) {
571 String::new()
572 } else {
573 format!(
574 "--config-dir {} ",
575 shell_single_quote(&config_dir.display().to_string())
576 )
577 };
578 let render = format!(
579 "if ! command -v shine >/dev/null 2>&1; then\n \
580 printf 'shine: %s requires the shine command, which was not found on PATH.\\n' {quoted_name} >&2\n \
581 return 127 2>/dev/null || exit 127\nfi\n\
582 shine {config_arg}__shell-render {quoted_render_target} || {{ _shine_code=$?; return $_shine_code 2>/dev/null || exit $_shine_code; }}\n"
583 );
584 let runner = match runtime {
585 LinkRuntime::Native => format!(
586 "_shine_sourced=false\n\
587 case \"$ZSH_EVAL_CONTEXT\" in *:file|*:file:*) _shine_sourced=true ;; esac\n\
588 if [ -n \"$BASH_VERSION\" ] && [ \"$BASH_SOURCE\" != \"$0\" ]; then _shine_sourced=true; fi\n\
589 if [ \"$_shine_sourced\" = true ]; then\n . {quoted_source} \"$@\"\n return $?\nfi\n\
590 exec {quoted_source} \"$@\"\n"
591 ),
592 LinkRuntime::Bun => {
593 let bun_check = format!(
594 "if ! command -v bun >/dev/null 2>&1; then\n \
595 printf 'shine: %s requires Bun, which was not found on PATH.\\n' {quoted_name} >&2\n \
596 exit 127\nfi\n"
597 );
598 if env.is_empty() {
599 format!("{bun_check}exec bun {quoted_source} \"$@\"\n")
600 } else {
601 let with_args = env
602 .iter()
603 .map(|token| format!("--with {}", shell_single_quote(token)))
604 .collect::<Vec<_>>()
605 .join(" ");
606 format!(
607 "{bun_check}exec shine env run --no-workspace {with_args} -- bun {quoted_source} \"$@\"\n"
608 )
609 }
610 }
611 };
612 format!(
613 "#!/usr/bin/env bash\n{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}{runner}"
614 )
615}
616
617fn live_config_dir(rendered_source: &Path) -> PathBuf {
618 rendered_source
619 .ancestors()
620 .find(|path| path.file_name() == Some(OsStr::new("rendered")))
621 .and_then(Path::parent)
622 .map(Path::to_path_buf)
623 .unwrap_or_else(|| {
624 rendered_source
625 .parent()
626 .unwrap_or_else(|| Path::new("."))
627 .to_path_buf()
628 })
629}
630
631#[cfg(unix)]
632async fn write_unix_live_launcher(
633 source: &Path,
634 link_path: &Path,
635 runtime: LinkRuntime,
636 env: &[String],
637 render_target: &str,
638) -> Result<()> {
639 use std::os::unix::fs::PermissionsExt;
640 if let Some(parent) = link_path.parent() {
641 tokio::fs::create_dir_all(parent).await?;
642 }
643 let name = launcher_command_name(link_path);
644 let content = unix_live_launcher_content(source, &name, runtime, env, render_target);
645 crate::persist::atomic_write(link_path, content.as_bytes()).await?;
646 tokio::fs::set_permissions(link_path, std::fs::Permissions::from_mode(0o755)).await?;
647 Ok(())
648}
649
650#[cfg(unix)]
651async fn unix_live_launcher_status(
652 link_path: &Path,
653 source: &Path,
654 runtime: LinkRuntime,
655 env: &[String],
656 render_target: &str,
657) -> Result<LauncherStatus> {
658 let content = match tokio::fs::read_to_string(link_path).await {
659 Ok(content) => content,
660 Err(_) => return Ok(LauncherStatus::NotManaged),
661 };
662 if !content.contains(SHIM_MANAGED_MARKER) {
663 return Ok(LauncherStatus::NotManaged);
664 }
665 let Some(target) = shim_target_from_content(&content) else {
666 return Ok(LauncherStatus::Stale);
667 };
668 if target.as_os_str() != source.as_os_str() {
669 return Ok(LauncherStatus::NotManaged);
670 }
671 let name = launcher_command_name(link_path);
672 if content == unix_live_launcher_content(source, &name, runtime, env, render_target) {
673 Ok(LauncherStatus::Current)
674 } else {
675 Ok(LauncherStatus::Stale)
676 }
677}
678
679#[cfg(unix)]
680async fn write_unix_bun_launcher(source: &Path, link_path: &Path, env: &[String]) -> Result<()> {
681 use std::os::unix::fs::PermissionsExt;
682 if let Some(parent) = link_path.parent() {
683 tokio::fs::create_dir_all(parent)
684 .await
685 .with_context(|| format!("creating bin dir: {parent:?}"))?;
686 }
687 let name = launcher_command_name(link_path);
688 tokio::fs::write(link_path, unix_bun_launcher_content(source, &name, env))
689 .await
690 .with_context(|| format!("writing bun launcher: {link_path:?}"))?;
691 tokio::fs::set_permissions(link_path, std::fs::Permissions::from_mode(0o755))
692 .await
693 .with_context(|| format!("setting bun launcher permissions: {link_path:?}"))?;
694 Ok(())
695}
696
697#[cfg(unix)]
698async fn unix_launcher_status(
699 link_path: &Path,
700 source: &Path,
701 env: &[String],
702) -> Result<LauncherStatus> {
703 let content = match tokio::fs::read_to_string(link_path).await {
704 Ok(content) => content,
705 Err(_) => return Ok(LauncherStatus::NotManaged),
707 };
708 if !content.contains(SHIM_MANAGED_MARKER) {
709 return Ok(LauncherStatus::NotManaged);
710 }
711 let Some(target) = shim_target_from_content(&content) else {
712 return Ok(LauncherStatus::Stale);
713 };
714 if target.as_os_str() != source.as_os_str() {
715 return Ok(LauncherStatus::NotManaged);
716 }
717 let name = launcher_command_name(link_path);
718 if content == unix_bun_launcher_content(source, &name, env) {
721 Ok(LauncherStatus::Current)
722 } else {
723 Ok(LauncherStatus::Stale)
724 }
725}
726
727#[cfg(not(unix))]
728async fn create_windows_shims(
729 source: &Path,
730 ps1_path: &Path,
731 runtime: LinkRuntime,
732 env: &[String],
733 render_target: Option<&str>,
734) -> Result<()> {
735 let cmd_path = ps1_path.with_extension("cmd");
736 if let Some(parent) = ps1_path.parent() {
737 tokio::fs::create_dir_all(parent)
738 .await
739 .with_context(|| format!("creating bin dir: {parent:?}"))?;
740 }
741 let name = launcher_command_name(ps1_path);
742 tokio::fs::write(
743 ps1_path,
744 powershell_shim_content(source, runtime, &name, env, render_target),
745 )
746 .await
747 .with_context(|| format!("writing PowerShell shim: {ps1_path:?}"))?;
748 tokio::fs::write(
749 &cmd_path,
750 cmd_shim_content(source, runtime, &name, env, render_target),
751 )
752 .await
753 .with_context(|| format!("writing cmd shim: {cmd_path:?}"))?;
754 Ok(())
755}
756
757#[cfg(not(unix))]
758fn powershell_shim_content(
759 source: &Path,
760 runtime: LinkRuntime,
761 name: &str,
762 env: &[String],
763 render_target: Option<&str>,
764) -> String {
765 let target = windows_native_path(source);
766 let escaped = target.replace('\'', "''");
767 let render = render_target.map_or_else(String::new, |render_target| {
768 let render_target = render_target.replace('\'', "''");
769 let config_dir = windows_native_path(&live_config_dir(source)).replace('\'', "''");
770 let config_arg = if Path::new(&config_dir).file_name() == Some(OsStr::new(".shine")) {
771 String::new()
772 } else {
773 format!("--config-dir '{config_dir}' ")
774 };
775 format!(
776 "$shineDotSourced = $MyInvocation.InvocationName -eq '.'\nif (-not (Get-Command shine -ErrorAction SilentlyContinue)) {{\n [Console]::Error.WriteLine('shine: live transformed command requires shine on PATH.')\n if ($shineDotSourced) {{ return }} else {{ exit 127 }}\n}}\n& shine {config_arg}__shell-render '{render_target}'\nif ($LASTEXITCODE -ne 0) {{\n $shineRenderCode = $LASTEXITCODE\n if ($shineDotSourced) {{ return }} else {{ exit $shineRenderCode }}\n}}\n"
777 )
778 });
779 match runtime {
780 LinkRuntime::Bun => {
781 let name_escaped = name.replace('\'', "''");
782 let bun_check = format!(
783 "if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {{\n [Console]::Error.WriteLine('shine: {name_escaped} requires Bun, which was not found on PATH. Install from https://bun.sh')\n exit 127\n}}\n"
784 );
785 if env.is_empty() {
786 format!(
787 "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}{bun_check}& bun '{escaped}' @args\nexit $LASTEXITCODE\n"
788 )
789 } else {
790 let with_args = env
791 .iter()
792 .map(|token| format!("--with '{}'", token.replace('\'', "''")))
793 .collect::<Vec<_>>()
794 .join(" ");
795 format!(
796 "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}{bun_check}if (-not (Get-Command shine -ErrorAction SilentlyContinue)) {{\n [Console]::Error.WriteLine('shine: {name_escaped} requires the shine command, which was not found on PATH.')\n exit 127\n}}\n& shine env run --no-workspace {with_args} -- bun '{escaped}' @args\nexit $LASTEXITCODE\n"
797 )
798 }
799 }
800 LinkRuntime::Native => {
801 let bash_target = bash_compatible_path(source);
802 let bash_escaped = bash_target.replace('\'', "''");
803 match source.extension().and_then(|e| e.to_str()) {
804 Some("ps1") => format!(
805 "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}if ($MyInvocation.InvocationName -eq '.') {{\n . '{escaped}' @args\n}} else {{\n & '{escaped}' @args\n exit $LASTEXITCODE\n}}\n"
806 ),
807 _ => format!(
808 "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}& bash '{bash_escaped}' @args\nexit $LASTEXITCODE\n"
809 ),
810 }
811 }
812 }
813}
814
815#[cfg(not(unix))]
816fn cmd_shim_content(
817 source: &Path,
818 runtime: LinkRuntime,
819 name: &str,
820 env: &[String],
821 render_target: Option<&str>,
822) -> String {
823 let target = windows_native_path(source);
824 let render = render_target.map_or_else(String::new, |render_target| {
825 let config_dir = windows_native_path(&live_config_dir(source));
826 let config_arg = if Path::new(&config_dir).file_name() == Some(OsStr::new(".shine")) {
827 String::new()
828 } else {
829 format!("--config-dir \"{config_dir}\" ")
830 };
831 format!(
832 "where shine >nul 2>nul\r\nif errorlevel 1 exit /b 127\r\nshine {config_arg}__shell-render \"{render_target}\"\r\nif errorlevel 1 exit /b %errorlevel%\r\n"
833 )
834 });
835 match runtime {
836 LinkRuntime::Bun => {
837 let bun_check = format!(
838 "where bun >nul 2>nul\r\nif errorlevel 1 (\r\n echo shine: {name} requires Bun, which was not found on PATH. Install from https://bun.sh 1>&2\r\n exit /b 127\r\n)\r\n"
839 );
840 if env.is_empty() {
841 format!(
842 "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}{bun_check}bun \"{target}\" %*\r\n"
843 )
844 } else {
845 let with_args = env
846 .iter()
847 .map(|token| format!("--with {token}"))
848 .collect::<Vec<_>>()
849 .join(" ");
850 format!(
851 "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}{bun_check}where shine >nul 2>nul\r\nif errorlevel 1 (\r\n echo shine: {name} requires the shine command, which was not found on PATH. 1>&2\r\n exit /b 127\r\n)\r\nshine env run --no-workspace {with_args} -- bun \"{target}\" %*\r\n"
852 )
853 }
854 }
855 LinkRuntime::Native => {
856 let escaped = target.replace('\'', "''");
857 let bash_target = bash_compatible_path(source);
858 match source.extension().and_then(|e| e.to_str()) {
859 Some("ps1") => format!(
860 "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"{escaped}\" %*\r\n"
861 ),
862 _ => format!(
863 "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}bash \"{bash_target}\" %*\r\n"
864 ),
865 }
866 }
867 }
868}
869
870#[cfg(not(unix))]
871fn bash_compatible_path(path: &Path) -> String {
872 windows_native_path(path).replace('\\', "/")
873}
874
875#[cfg(not(unix))]
876fn windows_native_path(path: &Path) -> String {
877 crate::path_display::strip_windows_verbatim_prefix(&path.display().to_string())
878}
879
880#[cfg(not(unix))]
881async fn windows_shim_status(
882 link_path: &Path,
883 source: &Path,
884 runtime: LinkRuntime,
885 env: &[String],
886 render_target: Option<&str>,
887) -> Result<LauncherStatus> {
888 let content = match tokio::fs::read_to_string(link_path).await {
889 Ok(content) => content,
890 Err(_) => return Ok(LauncherStatus::NotManaged),
892 };
893 if !content.contains(SHIM_MANAGED_MARKER) {
894 return Ok(LauncherStatus::NotManaged);
895 }
896
897 let Some(target) = shim_target_from_content(&content) else {
898 return Ok(LauncherStatus::Stale);
899 };
900 if windows_path_key(&target) != windows_path_key(source) {
901 return Ok(LauncherStatus::NotManaged);
902 }
903
904 let name = launcher_command_name(link_path);
905 let expected_ps1 = powershell_shim_content(source, runtime, &name, env, render_target);
906 let expected_cmd = cmd_shim_content(source, runtime, &name, env, render_target);
907 let cmd_path = link_path.with_extension("cmd");
908 let cmd_content = tokio::fs::read_to_string(&cmd_path).await.ok();
909 if content == expected_ps1 && cmd_content.as_deref() == Some(expected_cmd.as_str()) {
910 Ok(LauncherStatus::Current)
911 } else {
912 Ok(LauncherStatus::Stale)
913 }
914}
915
916#[cfg(not(unix))]
917fn windows_path_key(path: &Path) -> String {
918 windows_native_path(path)
919 .replace('\\', "/")
920 .to_ascii_lowercase()
921}
922
923#[cfg(not(unix))]
924async fn remove_windows_shims(ps1_path: &Path) -> Result<()> {
925 let cmd_path = ps1_path.with_extension("cmd");
926 match tokio::fs::remove_file(ps1_path).await {
927 Ok(()) => {}
928 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
929 Err(err) => return Err(err).with_context(|| format!("removing shim: {ps1_path:?}")),
930 }
931 match tokio::fs::remove_file(&cmd_path).await {
932 Ok(()) => {}
933 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
934 Err(err) => return Err(err).with_context(|| format!("removing shim: {cmd_path:?}")),
935 }
936 Ok(())
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942 use tokio::fs;
943
944 async fn make_dirs() -> (PathBuf, PathBuf) {
945 let id = uuid::Uuid::new_v4();
946 let src_dir = std::env::temp_dir().join(format!("shine-bl-src-{id}"));
947 let bin_dir = std::env::temp_dir().join(format!("shine-bl-bin-{id}"));
948 fs::create_dir_all(&src_dir).await.unwrap();
949 fs::create_dir_all(&bin_dir).await.unwrap();
950 (src_dir, bin_dir)
951 }
952
953 #[cfg(unix)]
955 async fn make_executable(dir: &Path, name: &str) -> PathBuf {
956 use std::os::unix::fs::PermissionsExt;
957 let path = dir.join(name);
958 fs::write(&path, b"#!/bin/sh\n").await.unwrap();
959 let mut perms = fs::metadata(&path).await.unwrap().permissions();
960 perms.set_mode(0o755);
961 fs::set_permissions(&path, perms).await.unwrap();
962 path
963 }
964
965 async fn make_plain(dir: &Path, name: &str) -> PathBuf {
966 let path = dir.join(name);
967 fs::write(&path, b"data").await.unwrap();
968 path
969 }
970
971 #[cfg(unix)]
972 #[tokio::test]
973 async fn creates_symlink_for_executable_source() {
974 let (src, bin) = make_dirs().await;
975 let exe = make_executable(&src, "run.sh").await;
976
977 let report = link_executables(&bin, std::slice::from_ref(&exe), false)
978 .await
979 .unwrap();
980
981 assert_eq!(report.created.len(), 1);
982 let link = &report.created[0];
983 assert!(link.is_symlink());
984 assert_eq!(fs::read_link(link).await.unwrap(), exe);
985 assert_eq!(link.file_name().unwrap(), "run");
987
988 fs::remove_dir_all(&src).await.unwrap();
989 fs::remove_dir_all(&bin).await.unwrap();
990 }
991
992 #[cfg(unix)]
993 #[tokio::test]
994 async fn skips_non_executable_source() {
995 let (src, bin) = make_dirs().await;
996 let plain = make_plain(&src, "readme.txt").await;
997
998 let report = link_executables(&bin, &[plain], false).await.unwrap();
999
1000 assert!(report.created.is_empty());
1001 assert!(report.skipped.is_empty());
1002
1003 fs::remove_dir_all(&src).await.unwrap();
1004 fs::remove_dir_all(&bin).await.unwrap();
1005 }
1006
1007 #[cfg(unix)]
1008 #[tokio::test]
1009 async fn skips_when_correct_symlink_already_exists() {
1010 let (src, bin) = make_dirs().await;
1011 let exe = make_executable(&src, "run.sh").await;
1012 tokio::fs::symlink(&exe, bin.join("run")).await.unwrap();
1013
1014 let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1015 .await
1016 .unwrap();
1017
1018 assert!(report.created.is_empty());
1019 assert_eq!(report.skipped.len(), 1);
1020
1021 fs::remove_dir_all(&src).await.unwrap();
1022 fs::remove_dir_all(&bin).await.unwrap();
1023 }
1024
1025 #[cfg(unix)]
1026 #[tokio::test]
1027 async fn reports_conflict_when_regular_file_exists() {
1028 let (src, bin) = make_dirs().await;
1029 let exe = make_executable(&src, "run.sh").await;
1030 make_plain(&bin, "run").await;
1031
1032 let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1033 .await
1034 .unwrap();
1035
1036 assert!(report.created.is_empty());
1037 assert_eq!(report.conflicts.len(), 1);
1038 assert_eq!(report.conflicts[0].link_path, bin.join("run"));
1039 assert_eq!(report.conflicts[0].source, exe);
1040 assert_eq!(report.conflicts[0].kind, LinkConflictKind::ExistingEntry);
1041
1042 fs::remove_dir_all(&src).await.unwrap();
1043 fs::remove_dir_all(&bin).await.unwrap();
1044 }
1045
1046 #[cfg(unix)]
1047 #[tokio::test]
1048 async fn overwrites_stale_symlink_when_overwrite_true() {
1049 let (src, bin) = make_dirs().await;
1050 let exe = make_executable(&src, "run.sh").await;
1051 let other = make_executable(&src, "other.sh").await;
1052 tokio::fs::symlink(&other, bin.join("run")).await.unwrap();
1053
1054 let report = link_executables(&bin, std::slice::from_ref(&exe), true)
1055 .await
1056 .unwrap();
1057
1058 assert_eq!(report.overwritten.len(), 1);
1059 assert_eq!(fs::read_link(bin.join("run")).await.unwrap(), exe);
1060
1061 fs::remove_dir_all(&src).await.unwrap();
1062 fs::remove_dir_all(&bin).await.unwrap();
1063 }
1064
1065 #[cfg(unix)]
1066 #[tokio::test]
1067 async fn flattens_nested_preset_path_into_bin_dir() {
1068 let (src, bin) = make_dirs().await;
1069 let sub = src.join("shell").join("proxy");
1070 fs::create_dir_all(&sub).await.unwrap();
1071 let exe = {
1072 use std::os::unix::fs::PermissionsExt;
1073 let path = sub.join("set_proxy.sh");
1074 fs::write(&path, b"#!/bin/sh\n").await.unwrap();
1075 let mut perms = fs::metadata(&path).await.unwrap().permissions();
1076 perms.set_mode(0o755);
1077 fs::set_permissions(&path, perms).await.unwrap();
1078 path
1079 };
1080
1081 let report = link_executables(&bin, &[exe], false).await.unwrap();
1082
1083 assert_eq!(report.created.len(), 1);
1084 assert!(bin.join("set_proxy").exists());
1085
1086 fs::remove_dir_all(&src).await.unwrap();
1087 fs::remove_dir_all(&bin).await.unwrap();
1088 }
1089
1090 #[cfg(unix)]
1091 #[tokio::test]
1092 async fn reports_collision_when_two_sources_share_basename() {
1093 let (src, bin) = make_dirs().await;
1094 let sub1 = src.join("a");
1095 let sub2 = src.join("b");
1096 fs::create_dir_all(&sub1).await.unwrap();
1097 fs::create_dir_all(&sub2).await.unwrap();
1098 let exe1 = make_executable(&sub1, "run.sh").await;
1099 let exe2 = make_executable(&sub2, "run.sh").await;
1100
1101 let report = link_executables(&bin, &[exe1, exe2.clone()], false)
1102 .await
1103 .unwrap();
1104
1105 assert_eq!(report.created.len(), 1);
1106 assert_eq!(report.conflicts.len(), 1);
1107 assert_eq!(report.conflicts[0].link_path, bin.join("run"));
1108 assert_eq!(report.conflicts[0].source, exe2);
1109 assert_eq!(report.conflicts[0].kind, LinkConflictKind::DuplicateName);
1110
1111 fs::remove_dir_all(&src).await.unwrap();
1112 fs::remove_dir_all(&bin).await.unwrap();
1113 }
1114
1115 #[cfg(unix)]
1116 #[tokio::test]
1117 async fn creates_symlink_with_explicit_link_name() {
1118 let (src, bin) = make_dirs().await;
1119 let exe = make_executable(&src, "set_proxy.sh").await;
1120 let specs = [LinkSpec {
1121 source: exe.clone(),
1122 link_name: OsString::from("setproxy"),
1123 runtime: LinkRuntime::Native,
1124 env: Vec::new(),
1125 render_target: None,
1126 }];
1127
1128 let report = link_executables_with_names(&bin, &specs, false)
1129 .await
1130 .unwrap();
1131
1132 assert_eq!(report.created.len(), 1);
1133 assert!(bin.join("setproxy").exists());
1134 assert!(!bin.join("set_proxy").exists());
1135 assert_eq!(fs::read_link(bin.join("setproxy")).await.unwrap(), exe);
1136
1137 fs::remove_dir_all(&src).await.unwrap();
1138 fs::remove_dir_all(&bin).await.unwrap();
1139 }
1140
1141 #[cfg(unix)]
1142 #[tokio::test]
1143 async fn links_non_executable_shell_script_source() {
1144 let (src, bin) = make_dirs().await;
1145 let script = src.join("set_proxy.sh");
1146 fs::write(&script, b"#!/bin/sh\n").await.unwrap();
1147 let specs = [LinkSpec {
1148 source: script.clone(),
1149 link_name: OsString::from("setproxy"),
1150 runtime: LinkRuntime::Native,
1151 env: Vec::new(),
1152 render_target: None,
1153 }];
1154
1155 let report = link_executables_with_names(&bin, &specs, false)
1156 .await
1157 .unwrap();
1158
1159 assert_eq!(report.created.len(), 1);
1160 assert!(bin.join("setproxy").exists());
1161 assert_eq!(fs::read_link(bin.join("setproxy")).await.unwrap(), script);
1162
1163 fs::remove_dir_all(&src).await.unwrap();
1164 fs::remove_dir_all(&bin).await.unwrap();
1165 }
1166
1167 #[cfg(unix)]
1168 #[tokio::test]
1169 async fn skips_non_executable_non_script_source_with_custom_name() {
1170 let (src, bin) = make_dirs().await;
1171 let plain = make_plain(&src, "proxy.txt").await;
1172 let specs = [LinkSpec {
1173 source: plain,
1174 link_name: OsString::from("setproxy"),
1175 runtime: LinkRuntime::Native,
1176 env: Vec::new(),
1177 render_target: None,
1178 }];
1179
1180 let report = link_executables_with_names(&bin, &specs, false)
1181 .await
1182 .unwrap();
1183
1184 assert!(report.created.is_empty());
1185 assert!(!bin.join("setproxy").exists());
1186
1187 fs::remove_dir_all(&src).await.unwrap();
1188 fs::remove_dir_all(&bin).await.unwrap();
1189 }
1190
1191 #[cfg(unix)]
1194 #[tokio::test]
1195 async fn unlink_removes_symlink_pointing_into_managed_root() {
1196 let (src, bin) = make_dirs().await;
1197 let exe = make_executable(&src, "run.sh").await;
1198 tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1199
1200 let report = unlink_managed(&bin, &src, false).await.unwrap();
1201
1202 assert_eq!(report.removed.len(), 1);
1203 assert!(!bin.join("run.sh").exists());
1204
1205 fs::remove_dir_all(&src).await.unwrap();
1206 fs::remove_dir_all(&bin).await.unwrap();
1207 }
1208
1209 #[cfg(unix)]
1210 #[tokio::test]
1211 async fn unlink_skips_symlink_outside_managed_root() {
1212 let (src, bin) = make_dirs().await;
1213 let outside = std::env::temp_dir().join(format!("shine-bl-out-{}", uuid::Uuid::new_v4()));
1214 fs::create_dir_all(&outside).await.unwrap();
1215 let exe = make_executable(&outside, "run.sh").await;
1216 tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1217
1218 let report = unlink_managed(&bin, &src, false).await.unwrap();
1219
1220 assert_eq!(report.skipped.len(), 1);
1221 assert!(bin.join("run.sh").is_symlink());
1222
1223 fs::remove_dir_all(&src).await.unwrap();
1224 fs::remove_dir_all(&bin).await.unwrap();
1225 fs::remove_dir_all(&outside).await.unwrap();
1226 }
1227
1228 #[cfg(unix)]
1229 #[tokio::test]
1230 async fn unlink_skips_regular_files_in_bin_dir() {
1231 let (src, bin) = make_dirs().await;
1232 make_plain(&bin, "user_script.sh").await;
1233
1234 let report = unlink_managed(&bin, &src, false).await.unwrap();
1235
1236 assert!(report.removed.is_empty());
1237 assert_eq!(report.skipped.len(), 1);
1238 assert!(bin.join("user_script.sh").exists());
1239
1240 fs::remove_dir_all(&src).await.unwrap();
1241 fs::remove_dir_all(&bin).await.unwrap();
1242 }
1243
1244 #[cfg(unix)]
1245 #[tokio::test]
1246 async fn unlink_dry_run_reports_but_does_not_remove() {
1247 let (src, bin) = make_dirs().await;
1248 let exe = make_executable(&src, "run.sh").await;
1249 tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1250
1251 let report = unlink_managed(&bin, &src, true).await.unwrap();
1252
1253 assert_eq!(report.removed.len(), 1);
1254 assert!(bin.join("run.sh").is_symlink(), "dry-run must not remove");
1255
1256 fs::remove_dir_all(&src).await.unwrap();
1257 fs::remove_dir_all(&bin).await.unwrap();
1258 }
1259
1260 #[cfg(unix)]
1261 #[tokio::test]
1262 async fn unlink_is_idempotent_on_empty_bin_dir() {
1263 let (src, bin) = make_dirs().await;
1264
1265 let r1 = unlink_managed(&bin, &src, false).await.unwrap();
1266 let r2 = unlink_managed(&bin, &src, false).await.unwrap();
1267
1268 assert!(r1.removed.is_empty());
1269 assert!(r2.removed.is_empty());
1270
1271 fs::remove_dir_all(&src).await.unwrap();
1272 fs::remove_dir_all(&bin).await.unwrap();
1273 }
1274
1275 #[tokio::test]
1276 async fn unlink_returns_empty_report_when_bin_dir_missing() {
1277 let missing = std::env::temp_dir().join(format!("shine-bl-miss-{}", uuid::Uuid::new_v4()));
1278 let managed = std::env::temp_dir().join(format!("shine-bl-mgd-{}", uuid::Uuid::new_v4()));
1279
1280 let report = unlink_managed(&missing, &managed, false).await.unwrap();
1281
1282 assert!(report.removed.is_empty());
1283 assert!(report.skipped.is_empty());
1284 }
1285
1286 #[test]
1287 fn link_stem_strips_bun_extensions() {
1288 assert_eq!(link_stem(Path::new("tool.ts")), OsString::from("tool"));
1289 assert_eq!(link_stem(Path::new("tool.js")), OsString::from("tool"));
1290 assert_eq!(link_stem(Path::new("tool.mts")), OsString::from("tool"));
1291 assert_eq!(link_stem(Path::new("tool.mjs")), OsString::from("tool"));
1292 }
1293
1294 #[cfg(unix)]
1295 fn bun_spec(source: &Path, name: &str) -> LinkSpec {
1296 bun_spec_with_env(source, name, Vec::new())
1297 }
1298
1299 #[cfg(unix)]
1300 fn bun_spec_with_env(source: &Path, name: &str, env: Vec<String>) -> LinkSpec {
1301 LinkSpec {
1302 source: source.to_path_buf(),
1303 link_name: OsString::from(name),
1304 runtime: LinkRuntime::Bun,
1305 env,
1306 render_target: None,
1307 }
1308 }
1309
1310 #[cfg(unix)]
1311 #[tokio::test]
1312 async fn creates_bun_launcher_as_marked_executable_regular_file() {
1313 use std::os::unix::fs::PermissionsExt;
1314 let (src, bin) = make_dirs().await;
1315 let script = src.join("tool.ts");
1316 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1317
1318 let report = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1319 .await
1320 .unwrap();
1321
1322 assert_eq!(report.created.len(), 1);
1323 let launcher = bin.join("tool");
1324 assert!(launcher.exists());
1325 assert!(
1326 !launcher.is_symlink(),
1327 "bun launcher must be a regular file, not a symlink"
1328 );
1329 let content = fs::read_to_string(&launcher).await.unwrap();
1330 assert!(content.contains("# shine-managed"));
1331 assert!(content.contains(&format!("# shine-target: {}", script.display())));
1332 assert!(content.contains("command -v bun"));
1333 assert!(content.contains("exit 127"));
1334 assert!(content.contains(&format!("exec bun '{}' \"$@\"", script.display())));
1335 let mode = fs::metadata(&launcher).await.unwrap().permissions().mode();
1336 assert!(mode & 0o111 != 0, "launcher must be executable");
1337
1338 fs::remove_dir_all(&src).await.unwrap();
1339 fs::remove_dir_all(&bin).await.unwrap();
1340 }
1341
1342 #[cfg(unix)]
1343 #[tokio::test]
1344 async fn bun_launcher_is_idempotent_and_refreshes_when_stale() {
1345 let (src, bin) = make_dirs().await;
1346 let script = src.join("tool.ts");
1347 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1348
1349 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1350 .await
1351 .unwrap();
1352 let again = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1353 .await
1354 .unwrap();
1355 assert_eq!(
1356 again.skipped.len(),
1357 1,
1358 "identical launcher should be skipped"
1359 );
1360 assert!(again.created.is_empty());
1361 assert!(again.overwritten.is_empty());
1362
1363 let launcher = bin.join("tool");
1365 fs::write(
1366 &launcher,
1367 format!(
1368 "#!/usr/bin/env bash\n# shine-managed\n# shine-target: {}\necho stale\n",
1369 script.display()
1370 ),
1371 )
1372 .await
1373 .unwrap();
1374 let refreshed = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1375 .await
1376 .unwrap();
1377 assert_eq!(
1378 refreshed.overwritten.len(),
1379 1,
1380 "stale launcher should refresh"
1381 );
1382 assert!(
1383 fs::read_to_string(&launcher)
1384 .await
1385 .unwrap()
1386 .contains("exec bun")
1387 );
1388
1389 fs::remove_dir_all(&src).await.unwrap();
1390 fs::remove_dir_all(&bin).await.unwrap();
1391 }
1392
1393 #[cfg(unix)]
1394 #[tokio::test]
1395 async fn bun_launcher_conflicts_with_user_file_unless_forced() {
1396 let (src, bin) = make_dirs().await;
1397 let script = src.join("tool.ts");
1398 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1399 make_plain(&bin, "tool").await;
1401
1402 let report = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1403 .await
1404 .unwrap();
1405 assert_eq!(report.conflicts.len(), 1);
1406 assert_eq!(report.conflicts[0].kind, LinkConflictKind::ExistingEntry);
1407 assert_eq!(fs::read_to_string(bin.join("tool")).await.unwrap(), "data");
1408
1409 let forced = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], true)
1410 .await
1411 .unwrap();
1412 assert_eq!(forced.overwritten.len(), 1);
1413 assert!(
1414 fs::read_to_string(bin.join("tool"))
1415 .await
1416 .unwrap()
1417 .contains("exec bun")
1418 );
1419
1420 fs::remove_dir_all(&src).await.unwrap();
1421 fs::remove_dir_all(&bin).await.unwrap();
1422 }
1423
1424 #[cfg(unix)]
1425 #[tokio::test]
1426 async fn unlink_removes_managed_bun_launcher_but_skips_user_file() {
1427 let (src, bin) = make_dirs().await;
1428 let script = src.join("tool.ts");
1429 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1430 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1431 .await
1432 .unwrap();
1433 make_plain(&bin, "user_tool").await;
1435
1436 let report = unlink_managed(&bin, &src, false).await.unwrap();
1437
1438 assert!(report.removed.iter().any(|p| p.ends_with("tool")));
1439 assert!(
1440 !bin.join("tool").exists(),
1441 "managed launcher should be removed"
1442 );
1443 assert!(
1444 bin.join("user_tool").exists(),
1445 "user file must be preserved"
1446 );
1447 assert!(report.skipped.iter().any(|p| p.ends_with("user_tool")));
1448
1449 fs::remove_dir_all(&src).await.unwrap();
1450 fs::remove_dir_all(&bin).await.unwrap();
1451 }
1452
1453 #[cfg(unix)]
1454 #[tokio::test]
1455 async fn bun_launcher_without_env_has_no_shine_dependency() {
1456 let (src, bin) = make_dirs().await;
1457 let script = src.join("tool.ts");
1458 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1459
1460 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1461 .await
1462 .unwrap();
1463
1464 let content = fs::read_to_string(bin.join("tool")).await.unwrap();
1465 assert!(
1466 content.contains(&format!("exec bun '{}' \"$@\"", script.display())),
1467 "no-env launcher must run bun directly: {content}"
1468 );
1469 assert!(
1470 !content.contains("shine env run"),
1471 "no-env launcher must not depend on shine: {content}"
1472 );
1473
1474 fs::remove_dir_all(&src).await.unwrap();
1475 fs::remove_dir_all(&bin).await.unwrap();
1476 }
1477
1478 #[cfg(unix)]
1479 #[tokio::test]
1480 async fn bun_launcher_with_env_wraps_shine_env_run() {
1481 let (src, bin) = make_dirs().await;
1482 let script = src.join("tool.ts");
1483 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1484
1485 let env = vec!["API_URL".to_string(), "SERVICE_TOKEN=API_TOKEN".to_string()];
1486 link_executables_with_names(&bin, &[bun_spec_with_env(&script, "tool", env)], false)
1487 .await
1488 .unwrap();
1489
1490 let content = fs::read_to_string(bin.join("tool")).await.unwrap();
1491 assert!(content.contains("command -v bun"));
1493 assert!(content.contains("command -v shine"));
1494 assert_eq!(content.matches("exit 127").count(), 2);
1495 assert!(content.contains(&format!(
1497 "exec shine env run --no-workspace --with 'API_URL' --with 'SERVICE_TOKEN=API_TOKEN' -- bun '{}' \"$@\"",
1498 script.display()
1499 )));
1500
1501 fs::remove_dir_all(&src).await.unwrap();
1502 fs::remove_dir_all(&bin).await.unwrap();
1503 }
1504
1505 #[cfg(unix)]
1506 #[tokio::test]
1507 async fn bun_launcher_refreshes_when_env_declaration_changes() {
1508 let (src, bin) = make_dirs().await;
1509 let script = src.join("tool.ts");
1510 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1511
1512 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1514 .await
1515 .unwrap();
1516 let changed = link_executables_with_names(
1517 &bin,
1518 &[bun_spec_with_env(
1519 &script,
1520 "tool",
1521 vec!["API_URL".to_string()],
1522 )],
1523 false,
1524 )
1525 .await
1526 .unwrap();
1527 assert_eq!(
1528 changed.overwritten.len(),
1529 1,
1530 "adding an env declaration must refresh the launcher without --force"
1531 );
1532
1533 let again = link_executables_with_names(
1535 &bin,
1536 &[bun_spec_with_env(
1537 &script,
1538 "tool",
1539 vec!["API_URL".to_string()],
1540 )],
1541 false,
1542 )
1543 .await
1544 .unwrap();
1545 assert_eq!(again.skipped.len(), 1);
1546 assert!(again.overwritten.is_empty());
1547
1548 fs::remove_dir_all(&src).await.unwrap();
1549 fs::remove_dir_all(&bin).await.unwrap();
1550 }
1551
1552 #[cfg(not(unix))]
1553 #[test]
1554 fn shell_shims_pass_bash_compatible_paths_on_windows() {
1555 let source = PathBuf::from(r"C:\Users\me\.shine\rendered\shell\utils\copyfile.sh");
1556
1557 let ps1 = powershell_shim_content(&source, LinkRuntime::Native, "copyfile", &[], None);
1558 let cmd = cmd_shim_content(&source, LinkRuntime::Native, "copyfile", &[], None);
1559
1560 assert!(ps1.contains("C:/Users/me/.shine/rendered/shell/utils/copyfile.sh"));
1561 assert!(cmd.contains("C:/Users/me/.shine/rendered/shell/utils/copyfile.sh"));
1562 assert!(!ps1.contains(r"& bash 'C:\Users\me"));
1563 }
1564}