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 use tokio::fs;
1073
1074 async fn make_dirs() -> (PathBuf, PathBuf) {
1075 let id = uuid::Uuid::new_v4();
1076 let src_dir = std::env::temp_dir().join(format!("shine-bl-src-{id}"));
1077 let bin_dir = std::env::temp_dir().join(format!("shine-bl-bin-{id}"));
1078 fs::create_dir_all(&src_dir).await.unwrap();
1079 fs::create_dir_all(&bin_dir).await.unwrap();
1080 (src_dir, bin_dir)
1081 }
1082
1083 #[cfg(unix)]
1085 async fn make_executable(dir: &Path, name: &str) -> PathBuf {
1086 use std::os::unix::fs::PermissionsExt;
1087 let path = dir.join(name);
1088 fs::write(&path, b"#!/bin/sh\n").await.unwrap();
1089 let mut perms = fs::metadata(&path).await.unwrap().permissions();
1090 perms.set_mode(0o755);
1091 fs::set_permissions(&path, perms).await.unwrap();
1092 path
1093 }
1094
1095 async fn make_plain(dir: &Path, name: &str) -> PathBuf {
1096 let path = dir.join(name);
1097 fs::write(&path, b"data").await.unwrap();
1098 path
1099 }
1100
1101 #[cfg(unix)]
1102 #[tokio::test]
1103 async fn creates_symlink_for_executable_source() {
1104 let (src, bin) = make_dirs().await;
1105 let exe = make_executable(&src, "run.sh").await;
1106
1107 let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1108 .await
1109 .unwrap();
1110
1111 assert_eq!(report.created.len(), 1);
1112 let link = &report.created[0];
1113 assert!(link.is_symlink());
1114 assert_eq!(fs::read_link(link).await.unwrap(), exe);
1115 assert_eq!(link.file_name().unwrap(), "run");
1117
1118 fs::remove_dir_all(&src).await.unwrap();
1119 fs::remove_dir_all(&bin).await.unwrap();
1120 }
1121
1122 #[cfg(unix)]
1123 #[tokio::test]
1124 async fn skips_non_executable_source() {
1125 let (src, bin) = make_dirs().await;
1126 let plain = make_plain(&src, "readme.txt").await;
1127
1128 let report = link_executables(&bin, &[plain], false).await.unwrap();
1129
1130 assert!(report.created.is_empty());
1131 assert!(report.skipped.is_empty());
1132
1133 fs::remove_dir_all(&src).await.unwrap();
1134 fs::remove_dir_all(&bin).await.unwrap();
1135 }
1136
1137 #[cfg(unix)]
1138 #[tokio::test]
1139 async fn skips_when_correct_symlink_already_exists() {
1140 let (src, bin) = make_dirs().await;
1141 let exe = make_executable(&src, "run.sh").await;
1142 tokio::fs::symlink(&exe, bin.join("run")).await.unwrap();
1143
1144 let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1145 .await
1146 .unwrap();
1147
1148 assert!(report.created.is_empty());
1149 assert_eq!(report.skipped.len(), 1);
1150
1151 fs::remove_dir_all(&src).await.unwrap();
1152 fs::remove_dir_all(&bin).await.unwrap();
1153 }
1154
1155 #[cfg(unix)]
1156 #[tokio::test]
1157 async fn reports_conflict_when_regular_file_exists() {
1158 let (src, bin) = make_dirs().await;
1159 let exe = make_executable(&src, "run.sh").await;
1160 make_plain(&bin, "run").await;
1161
1162 let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1163 .await
1164 .unwrap();
1165
1166 assert!(report.created.is_empty());
1167 assert_eq!(report.conflicts.len(), 1);
1168 assert_eq!(report.conflicts[0].link_path, bin.join("run"));
1169 assert_eq!(report.conflicts[0].source, exe);
1170 assert_eq!(report.conflicts[0].kind, LinkConflictKind::ExistingEntry);
1171
1172 fs::remove_dir_all(&src).await.unwrap();
1173 fs::remove_dir_all(&bin).await.unwrap();
1174 }
1175
1176 #[cfg(unix)]
1177 #[tokio::test]
1178 async fn overwrites_stale_symlink_when_overwrite_true() {
1179 let (src, bin) = make_dirs().await;
1180 let exe = make_executable(&src, "run.sh").await;
1181 let other = make_executable(&src, "other.sh").await;
1182 tokio::fs::symlink(&other, bin.join("run")).await.unwrap();
1183
1184 let report = link_executables(&bin, std::slice::from_ref(&exe), true)
1185 .await
1186 .unwrap();
1187
1188 assert_eq!(report.overwritten.len(), 1);
1189 assert_eq!(fs::read_link(bin.join("run")).await.unwrap(), exe);
1190
1191 fs::remove_dir_all(&src).await.unwrap();
1192 fs::remove_dir_all(&bin).await.unwrap();
1193 }
1194
1195 #[cfg(unix)]
1196 #[tokio::test]
1197 async fn flattens_nested_preset_path_into_bin_dir() {
1198 let (src, bin) = make_dirs().await;
1199 let sub = src.join("shell").join("proxy");
1200 fs::create_dir_all(&sub).await.unwrap();
1201 let exe = {
1202 use std::os::unix::fs::PermissionsExt;
1203 let path = sub.join("set_proxy.sh");
1204 fs::write(&path, b"#!/bin/sh\n").await.unwrap();
1205 let mut perms = fs::metadata(&path).await.unwrap().permissions();
1206 perms.set_mode(0o755);
1207 fs::set_permissions(&path, perms).await.unwrap();
1208 path
1209 };
1210
1211 let report = link_executables(&bin, &[exe], false).await.unwrap();
1212
1213 assert_eq!(report.created.len(), 1);
1214 assert!(bin.join("set_proxy").exists());
1215
1216 fs::remove_dir_all(&src).await.unwrap();
1217 fs::remove_dir_all(&bin).await.unwrap();
1218 }
1219
1220 #[cfg(unix)]
1221 #[tokio::test]
1222 async fn reports_collision_when_two_sources_share_basename() {
1223 let (src, bin) = make_dirs().await;
1224 let sub1 = src.join("a");
1225 let sub2 = src.join("b");
1226 fs::create_dir_all(&sub1).await.unwrap();
1227 fs::create_dir_all(&sub2).await.unwrap();
1228 let exe1 = make_executable(&sub1, "run.sh").await;
1229 let exe2 = make_executable(&sub2, "run.sh").await;
1230
1231 let report = link_executables(&bin, &[exe1, exe2.clone()], false)
1232 .await
1233 .unwrap();
1234
1235 assert_eq!(report.created.len(), 1);
1236 assert_eq!(report.conflicts.len(), 1);
1237 assert_eq!(report.conflicts[0].link_path, bin.join("run"));
1238 assert_eq!(report.conflicts[0].source, exe2);
1239 assert_eq!(report.conflicts[0].kind, LinkConflictKind::DuplicateName);
1240
1241 fs::remove_dir_all(&src).await.unwrap();
1242 fs::remove_dir_all(&bin).await.unwrap();
1243 }
1244
1245 #[cfg(unix)]
1246 #[tokio::test]
1247 async fn creates_symlink_with_explicit_link_name() {
1248 let (src, bin) = make_dirs().await;
1249 let exe = make_executable(&src, "set_proxy.sh").await;
1250 let specs = [LinkSpec {
1251 source: exe.clone(),
1252 link_name: OsString::from("setproxy"),
1253 runtime: LinkRuntime::Native,
1254 bun_dependencies: BunDependencyMode::Disabled,
1255 env: Vec::new(),
1256 render_target: None,
1257 }];
1258
1259 let report = link_executables_with_names(&bin, &specs, false)
1260 .await
1261 .unwrap();
1262
1263 assert_eq!(report.created.len(), 1);
1264 assert!(bin.join("setproxy").exists());
1265 assert!(!bin.join("set_proxy").exists());
1266 assert_eq!(fs::read_link(bin.join("setproxy")).await.unwrap(), exe);
1267
1268 fs::remove_dir_all(&src).await.unwrap();
1269 fs::remove_dir_all(&bin).await.unwrap();
1270 }
1271
1272 #[cfg(unix)]
1273 #[tokio::test]
1274 async fn links_non_executable_shell_script_source() {
1275 let (src, bin) = make_dirs().await;
1276 let script = src.join("set_proxy.sh");
1277 fs::write(&script, b"#!/bin/sh\n").await.unwrap();
1278 let specs = [LinkSpec {
1279 source: script.clone(),
1280 link_name: OsString::from("setproxy"),
1281 runtime: LinkRuntime::Native,
1282 bun_dependencies: BunDependencyMode::Disabled,
1283 env: Vec::new(),
1284 render_target: None,
1285 }];
1286
1287 let report = link_executables_with_names(&bin, &specs, false)
1288 .await
1289 .unwrap();
1290
1291 assert_eq!(report.created.len(), 1);
1292 assert!(bin.join("setproxy").exists());
1293 assert_eq!(fs::read_link(bin.join("setproxy")).await.unwrap(), script);
1294
1295 fs::remove_dir_all(&src).await.unwrap();
1296 fs::remove_dir_all(&bin).await.unwrap();
1297 }
1298
1299 #[cfg(unix)]
1300 #[tokio::test]
1301 async fn skips_non_executable_non_script_source_with_custom_name() {
1302 let (src, bin) = make_dirs().await;
1303 let plain = make_plain(&src, "proxy.txt").await;
1304 let specs = [LinkSpec {
1305 source: plain,
1306 link_name: OsString::from("setproxy"),
1307 runtime: LinkRuntime::Native,
1308 bun_dependencies: BunDependencyMode::Disabled,
1309 env: Vec::new(),
1310 render_target: None,
1311 }];
1312
1313 let report = link_executables_with_names(&bin, &specs, false)
1314 .await
1315 .unwrap();
1316
1317 assert!(report.created.is_empty());
1318 assert!(!bin.join("setproxy").exists());
1319
1320 fs::remove_dir_all(&src).await.unwrap();
1321 fs::remove_dir_all(&bin).await.unwrap();
1322 }
1323
1324 #[cfg(unix)]
1327 #[tokio::test]
1328 async fn unlink_removes_symlink_pointing_into_managed_root() {
1329 let (src, bin) = make_dirs().await;
1330 let exe = make_executable(&src, "run.sh").await;
1331 tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1332
1333 let report = unlink_managed(&bin, &src, false).await.unwrap();
1334
1335 assert_eq!(report.removed.len(), 1);
1336 assert!(!bin.join("run.sh").exists());
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 unlink_skips_symlink_outside_managed_root() {
1345 let (src, bin) = make_dirs().await;
1346 let outside = std::env::temp_dir().join(format!("shine-bl-out-{}", uuid::Uuid::new_v4()));
1347 fs::create_dir_all(&outside).await.unwrap();
1348 let exe = make_executable(&outside, "run.sh").await;
1349 tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1350
1351 let report = unlink_managed(&bin, &src, false).await.unwrap();
1352
1353 assert_eq!(report.skipped.len(), 1);
1354 assert!(bin.join("run.sh").is_symlink());
1355
1356 fs::remove_dir_all(&src).await.unwrap();
1357 fs::remove_dir_all(&bin).await.unwrap();
1358 fs::remove_dir_all(&outside).await.unwrap();
1359 }
1360
1361 #[cfg(unix)]
1362 #[tokio::test]
1363 async fn unlink_skips_regular_files_in_bin_dir() {
1364 let (src, bin) = make_dirs().await;
1365 make_plain(&bin, "user_script.sh").await;
1366
1367 let report = unlink_managed(&bin, &src, false).await.unwrap();
1368
1369 assert!(report.removed.is_empty());
1370 assert_eq!(report.skipped.len(), 1);
1371 assert!(bin.join("user_script.sh").exists());
1372
1373 fs::remove_dir_all(&src).await.unwrap();
1374 fs::remove_dir_all(&bin).await.unwrap();
1375 }
1376
1377 #[cfg(unix)]
1378 #[tokio::test]
1379 async fn unlink_dry_run_reports_but_does_not_remove() {
1380 let (src, bin) = make_dirs().await;
1381 let exe = make_executable(&src, "run.sh").await;
1382 tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1383
1384 let report = unlink_managed(&bin, &src, true).await.unwrap();
1385
1386 assert_eq!(report.removed.len(), 1);
1387 assert!(bin.join("run.sh").is_symlink(), "dry-run must not remove");
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 unlink_is_idempotent_on_empty_bin_dir() {
1396 let (src, bin) = make_dirs().await;
1397
1398 let r1 = unlink_managed(&bin, &src, false).await.unwrap();
1399 let r2 = unlink_managed(&bin, &src, false).await.unwrap();
1400
1401 assert!(r1.removed.is_empty());
1402 assert!(r2.removed.is_empty());
1403
1404 fs::remove_dir_all(&src).await.unwrap();
1405 fs::remove_dir_all(&bin).await.unwrap();
1406 }
1407
1408 #[tokio::test]
1409 async fn unlink_returns_empty_report_when_bin_dir_missing() {
1410 let missing = std::env::temp_dir().join(format!("shine-bl-miss-{}", uuid::Uuid::new_v4()));
1411 let managed = std::env::temp_dir().join(format!("shine-bl-mgd-{}", uuid::Uuid::new_v4()));
1412
1413 let report = unlink_managed(&missing, &managed, false).await.unwrap();
1414
1415 assert!(report.removed.is_empty());
1416 assert!(report.skipped.is_empty());
1417 }
1418
1419 #[test]
1420 fn link_stem_strips_bun_extensions() {
1421 assert_eq!(link_stem(Path::new("tool.ts")), OsString::from("tool"));
1422 assert_eq!(link_stem(Path::new("tool.js")), OsString::from("tool"));
1423 assert_eq!(link_stem(Path::new("tool.mts")), OsString::from("tool"));
1424 assert_eq!(link_stem(Path::new("tool.mjs")), OsString::from("tool"));
1425 }
1426
1427 #[cfg(unix)]
1428 fn bun_spec(source: &Path, name: &str) -> LinkSpec {
1429 bun_spec_with_env(source, name, Vec::new())
1430 }
1431
1432 #[cfg(unix)]
1433 fn locked_bun_spec(source: &Path, name: &str) -> LinkSpec {
1434 LinkSpec {
1435 source: source.to_path_buf(),
1436 link_name: OsString::from(name),
1437 runtime: LinkRuntime::Bun,
1438 bun_dependencies: BunDependencyMode::Locked,
1439 env: Vec::new(),
1440 render_target: None,
1441 }
1442 }
1443
1444 #[cfg(unix)]
1445 fn bun_spec_with_env(source: &Path, name: &str, env: Vec<String>) -> LinkSpec {
1446 LinkSpec {
1447 source: source.to_path_buf(),
1448 link_name: OsString::from(name),
1449 runtime: LinkRuntime::Bun,
1450 bun_dependencies: BunDependencyMode::Disabled,
1451 env,
1452 render_target: None,
1453 }
1454 }
1455
1456 #[cfg(unix)]
1457 #[tokio::test]
1458 async fn creates_bun_launcher_as_marked_executable_regular_file() {
1459 use std::os::unix::fs::PermissionsExt;
1460 let (src, bin) = make_dirs().await;
1461 let script = src.join("tool.ts");
1462 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1463
1464 let report = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1465 .await
1466 .unwrap();
1467
1468 assert_eq!(report.created.len(), 1);
1469 let launcher = bin.join("tool");
1470 assert!(launcher.exists());
1471 assert!(
1472 !launcher.is_symlink(),
1473 "bun launcher must be a regular file, not a symlink"
1474 );
1475 let content = fs::read_to_string(&launcher).await.unwrap();
1476 assert!(content.contains("# shine-managed"));
1477 assert!(content.contains(&format!("# shine-target: {}", script.display())));
1478 assert!(content.contains("command -v bun"));
1479 assert!(content.contains("exit 127"));
1480 assert!(content.contains(&format!(
1481 "exec bun --no-install '{}' \"$@\"",
1482 script.display()
1483 )));
1484 let mode = fs::metadata(&launcher).await.unwrap().permissions().mode();
1485 assert!(mode & 0o111 != 0, "launcher must be executable");
1486
1487 fs::remove_dir_all(&src).await.unwrap();
1488 fs::remove_dir_all(&bin).await.unwrap();
1489 }
1490
1491 #[cfg(unix)]
1492 #[tokio::test]
1493 async fn bun_launcher_is_idempotent_and_refreshes_when_stale() {
1494 let (src, bin) = make_dirs().await;
1495 let script = src.join("tool.ts");
1496 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1497
1498 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1499 .await
1500 .unwrap();
1501 let again = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1502 .await
1503 .unwrap();
1504 assert_eq!(
1505 again.skipped.len(),
1506 1,
1507 "identical launcher should be skipped"
1508 );
1509 assert!(again.created.is_empty());
1510 assert!(again.overwritten.is_empty());
1511
1512 let launcher = bin.join("tool");
1514 fs::write(
1515 &launcher,
1516 format!(
1517 "#!/usr/bin/env bash\n# shine-managed\n# shine-target: {}\necho stale\n",
1518 script.display()
1519 ),
1520 )
1521 .await
1522 .unwrap();
1523 let refreshed = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1524 .await
1525 .unwrap();
1526 assert_eq!(
1527 refreshed.overwritten.len(),
1528 1,
1529 "stale launcher should refresh"
1530 );
1531 assert!(
1532 fs::read_to_string(&launcher)
1533 .await
1534 .unwrap()
1535 .contains("exec bun")
1536 );
1537
1538 fs::remove_dir_all(&src).await.unwrap();
1539 fs::remove_dir_all(&bin).await.unwrap();
1540 }
1541
1542 #[cfg(unix)]
1543 #[tokio::test]
1544 async fn bun_launcher_conflicts_with_user_file_unless_forced() {
1545 let (src, bin) = make_dirs().await;
1546 let script = src.join("tool.ts");
1547 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1548 make_plain(&bin, "tool").await;
1550
1551 let report = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1552 .await
1553 .unwrap();
1554 assert_eq!(report.conflicts.len(), 1);
1555 assert_eq!(report.conflicts[0].kind, LinkConflictKind::ExistingEntry);
1556 assert_eq!(fs::read_to_string(bin.join("tool")).await.unwrap(), "data");
1557
1558 let forced = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], true)
1559 .await
1560 .unwrap();
1561 assert_eq!(forced.overwritten.len(), 1);
1562 assert!(
1563 fs::read_to_string(bin.join("tool"))
1564 .await
1565 .unwrap()
1566 .contains("exec bun")
1567 );
1568
1569 fs::remove_dir_all(&src).await.unwrap();
1570 fs::remove_dir_all(&bin).await.unwrap();
1571 }
1572
1573 #[cfg(unix)]
1574 #[tokio::test]
1575 async fn unlink_removes_managed_bun_launcher_but_skips_user_file() {
1576 let (src, bin) = make_dirs().await;
1577 let script = src.join("tool.ts");
1578 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1579 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1580 .await
1581 .unwrap();
1582 make_plain(&bin, "user_tool").await;
1584
1585 let report = unlink_managed(&bin, &src, false).await.unwrap();
1586
1587 assert!(report.removed.iter().any(|p| p.ends_with("tool")));
1588 assert!(
1589 !bin.join("tool").exists(),
1590 "managed launcher should be removed"
1591 );
1592 assert!(
1593 bin.join("user_tool").exists(),
1594 "user file must be preserved"
1595 );
1596 assert!(report.skipped.iter().any(|p| p.ends_with("user_tool")));
1597
1598 fs::remove_dir_all(&src).await.unwrap();
1599 fs::remove_dir_all(&bin).await.unwrap();
1600 }
1601
1602 #[cfg(unix)]
1603 #[tokio::test]
1604 async fn bun_launcher_without_env_has_no_shine_dependency() {
1605 let (src, bin) = make_dirs().await;
1606 let script = src.join("tool.ts");
1607 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1608
1609 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1610 .await
1611 .unwrap();
1612
1613 let content = fs::read_to_string(bin.join("tool")).await.unwrap();
1614 assert!(
1615 content.contains(&format!(
1616 "exec bun --no-install '{}' \"$@\"",
1617 script.display()
1618 )),
1619 "no-env launcher must run bun directly: {content}"
1620 );
1621 assert!(
1622 !content.contains("shine env run"),
1623 "no-env launcher must not depend on shine: {content}"
1624 );
1625
1626 fs::remove_dir_all(&src).await.unwrap();
1627 fs::remove_dir_all(&bin).await.unwrap();
1628 }
1629
1630 #[cfg(unix)]
1631 #[tokio::test]
1632 async fn bun_launcher_with_env_wraps_shine_env_run() {
1633 let (src, bin) = make_dirs().await;
1634 let script = src.join("tool.ts");
1635 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1636
1637 let env = vec!["API_URL".to_string(), "SERVICE_TOKEN=API_TOKEN".to_string()];
1638 link_executables_with_names(&bin, &[bun_spec_with_env(&script, "tool", env)], false)
1639 .await
1640 .unwrap();
1641
1642 let content = fs::read_to_string(bin.join("tool")).await.unwrap();
1643 assert!(content.contains("command -v bun"));
1645 assert!(content.contains("command -v shine"));
1646 assert_eq!(content.matches("exit 127").count(), 2);
1647 assert!(content.contains(&format!(
1649 "exec shine env run --no-workspace --with 'API_URL' --with 'SERVICE_TOKEN=API_TOKEN' -- bun --no-install '{}' \"$@\"",
1650 script.display()
1651 )));
1652
1653 fs::remove_dir_all(&src).await.unwrap();
1654 fs::remove_dir_all(&bin).await.unwrap();
1655 }
1656
1657 #[cfg(unix)]
1658 #[tokio::test]
1659 async fn locked_bun_launcher_uses_fallback_and_refreshes_disabled_launcher() {
1660 let (src, bin) = make_dirs().await;
1661 let script = src.join("tool.ts");
1662 fs::write(&script, b"import 'zod'\n").await.unwrap();
1663
1664 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1665 .await
1666 .unwrap();
1667 let refreshed =
1668 link_executables_with_names(&bin, &[locked_bun_spec(&script, "tool")], false)
1669 .await
1670 .unwrap();
1671 assert_eq!(refreshed.overwritten.len(), 1);
1672 let content = fs::read_to_string(bin.join("tool")).await.unwrap();
1673 assert!(content.contains("exec bun --install=fallback"));
1674
1675 fs::remove_dir_all(&src).await.unwrap();
1676 fs::remove_dir_all(&bin).await.unwrap();
1677 }
1678
1679 #[cfg(unix)]
1680 #[tokio::test]
1681 async fn bun_launcher_refreshes_when_env_declaration_changes() {
1682 let (src, bin) = make_dirs().await;
1683 let script = src.join("tool.ts");
1684 fs::write(&script, b"console.log('hi')\n").await.unwrap();
1685
1686 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
1688 .await
1689 .unwrap();
1690 let changed = link_executables_with_names(
1691 &bin,
1692 &[bun_spec_with_env(
1693 &script,
1694 "tool",
1695 vec!["API_URL".to_string()],
1696 )],
1697 false,
1698 )
1699 .await
1700 .unwrap();
1701 assert_eq!(
1702 changed.overwritten.len(),
1703 1,
1704 "adding an env declaration must refresh the launcher without --force"
1705 );
1706
1707 let again = link_executables_with_names(
1709 &bin,
1710 &[bun_spec_with_env(
1711 &script,
1712 "tool",
1713 vec!["API_URL".to_string()],
1714 )],
1715 false,
1716 )
1717 .await
1718 .unwrap();
1719 assert_eq!(again.skipped.len(), 1);
1720 assert!(again.overwritten.is_empty());
1721
1722 fs::remove_dir_all(&src).await.unwrap();
1723 fs::remove_dir_all(&bin).await.unwrap();
1724 }
1725
1726 #[cfg(not(unix))]
1727 #[test]
1728 fn shell_shims_pass_bash_compatible_paths_on_windows() {
1729 let source = PathBuf::from(r"C:\Users\me\.shine\rendered\shell\utils\copyfile.sh");
1730
1731 let ps1 = powershell_shim_content(&source, LinkRuntime::Native, "copyfile", &[], None);
1732 let cmd = cmd_shim_content(&source, LinkRuntime::Native, "copyfile", &[], None);
1733
1734 assert!(ps1.contains("C:/Users/me/.shine/rendered/shell/utils/copyfile.sh"));
1735 assert!(cmd.contains("C:/Users/me/.shine/rendered/shell/utils/copyfile.sh"));
1736 assert!(!ps1.contains(r"& bash 'C:\Users\me"));
1737 }
1738}