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