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