1use crate::runtime::{
2 BunDependencyMode, FileKind, FileSystemHost, FileSystemObservationHost, LinkRuntime,
3};
4#[cfg(test)]
5use anyhow::Context;
6use anyhow::Result;
7use std::collections::HashSet;
8use std::ffi::{OsStr, OsString};
9use std::path::{Path, PathBuf};
10
11const LINKABLE_SCRIPT_EXTENSIONS: &[&str] = &["sh", "bash", "zsh", "fish", "ps1"];
12
13const BUN_SCRIPT_EXTENSIONS: &[&str] = &["ts", "js", "mts", "mjs"];
15
16#[cfg(not(unix))]
17const EXECUTABLE_EXTENSIONS: &[&str] = &["sh", "ps1"];
18
19const SHIM_MANAGED_MARKER: &str = "# shine-managed";
22const CMD_SHIM_MANAGED_MARKER: &str = "REM shine-managed";
23const SHIM_TARGET_PREFIX: &str = "# shine-target: ";
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37enum LauncherStatus {
38 Current,
39 Stale,
40 NotManaged,
41}
42
43pub struct LinkReport {
44 pub created: Vec<PathBuf>,
45 pub skipped: Vec<PathBuf>,
46 pub conflicts: Vec<LinkConflict>,
47 pub overwritten: Vec<PathBuf>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum LinkConflictKind {
52 ExistingEntry,
53 DuplicateName,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct LinkConflict {
58 pub link_path: PathBuf,
59 pub source: PathBuf,
60 pub kind: LinkConflictKind,
61}
62
63pub struct UnlinkReport {
64 pub removed: Vec<PathBuf>,
65 pub skipped: Vec<PathBuf>,
66}
67
68pub(crate) struct ManagedLauncherProbe {
69 pub resources: Vec<PreparedLauncherResource>,
70 pub conflicts: Vec<PathBuf>,
71}
72
73#[derive(Clone)]
74pub struct LinkSpec {
75 pub source: PathBuf,
76 pub link_name: OsString,
77 pub runtime: LinkRuntime,
78 pub bun_dependencies: BunDependencyMode,
80 pub env: Vec<String>,
84 pub render_target: Option<String>,
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub(crate) enum PreparedLauncherResource {
90 Symlink {
91 destination: PathBuf,
92 target: PathBuf,
93 },
94 File {
95 destination: PathBuf,
96 bytes: Vec<u8>,
97 unix_mode: Option<u32>,
98 },
99}
100
101impl PreparedLauncherResource {
102 pub(crate) fn destination(&self) -> &Path {
103 match self {
104 Self::Symlink { destination, .. } | Self::File { destination, .. } => destination,
105 }
106 }
107}
108
109pub(crate) fn prepare_launcher_resources(
110 bin_dir: &Path,
111 spec: &LinkSpec,
112) -> Vec<PreparedLauncherResource> {
113 let link_path = command_path_for_name(bin_dir, &spec.link_name);
114 #[cfg(unix)]
115 {
116 let content = if let Some(target) = spec.render_target.as_deref() {
117 Some(unix_live_launcher_content(
118 &spec.source,
119 &launcher_command_name(&link_path),
120 spec.runtime,
121 spec.bun_dependencies,
122 &spec.env,
123 target,
124 ))
125 } else if spec.runtime == LinkRuntime::Bun {
126 Some(unix_bun_launcher_content(
127 &spec.source,
128 &launcher_command_name(&link_path),
129 spec.bun_dependencies,
130 &spec.env,
131 ))
132 } else {
133 None
134 };
135 match content {
136 Some(content) => vec![PreparedLauncherResource::File {
137 destination: link_path,
138 bytes: content.into_bytes(),
139 unix_mode: Some(0o755),
140 }],
141 None => vec![PreparedLauncherResource::Symlink {
142 destination: link_path,
143 target: spec.source.clone(),
144 }],
145 }
146 }
147 #[cfg(not(unix))]
148 {
149 let name = launcher_command_name(&link_path);
150 let ps1 = powershell_shim_content(
151 &spec.source,
152 spec.runtime,
153 &name,
154 spec.bun_dependencies,
155 &spec.env,
156 spec.render_target.as_deref(),
157 );
158 let cmd = cmd_shim_content(
159 &spec.source,
160 spec.runtime,
161 &name,
162 spec.bun_dependencies,
163 &spec.env,
164 spec.render_target.as_deref(),
165 );
166 vec![
167 PreparedLauncherResource::File {
168 destination: link_path.clone(),
169 bytes: ps1.into_bytes(),
170 unix_mode: None,
171 },
172 PreparedLauncherResource::File {
173 destination: link_path.with_extension("cmd"),
174 bytes: cmd.into_bytes(),
175 unix_mode: None,
176 },
177 ]
178 }
179}
180
181pub(crate) async fn apply_prepared_launcher_resource(
182 host: &impl FileSystemHost,
183 resource: &PreparedLauncherResource,
184) -> Result<()> {
185 let destination = resource.destination();
186 if let Some(parent) = destination.parent() {
187 host.create_dir_all(parent)
188 .await
189 .map_err(|error| error.into_anyhow("creating shell launcher directory"))?;
190 }
191 match resource {
192 PreparedLauncherResource::Symlink {
193 destination,
194 target,
195 } => host
196 .symlink(target, destination)
197 .await
198 .map_err(|error| error.into_anyhow("creating shell symlink")),
199 PreparedLauncherResource::File {
200 destination,
201 bytes,
202 unix_mode,
203 } => {
204 host.write_atomic(destination, bytes)
205 .await
206 .map_err(|error| error.into_anyhow("writing shell launcher"))?;
207 if let Some(mode) = unix_mode {
208 host.set_mode(destination, *mode)
209 .await
210 .map_err(|error| error.into_anyhow("setting shell launcher permissions"))?;
211 }
212 Ok(())
213 }
214 }
215}
216
217pub(crate) async fn prepared_launcher_resource_is_exact(
218 host: &impl FileSystemObservationHost,
219 resource: &PreparedLauncherResource,
220) -> Result<bool> {
221 let metadata = match host.metadata(resource.destination()).await {
222 Ok(metadata) => metadata,
223 Err(error) if error.is_not_found() => return Ok(false),
224 Err(error) => return Err(error.into_anyhow("observing prepared Shell launcher")),
225 };
226 Ok(match resource {
227 PreparedLauncherResource::Symlink { target, .. } => {
228 metadata.kind == FileKind::Symlink
229 && host
230 .read_link(resource.destination())
231 .await
232 .is_ok_and(|current| current == *target)
233 }
234 PreparedLauncherResource::File {
235 bytes, unix_mode, ..
236 } => {
237 metadata.kind == FileKind::File
238 && host
239 .read(resource.destination())
240 .await
241 .is_ok_and(|current| current == *bytes)
242 && unix_mode.is_none_or(|expected| {
243 metadata
244 .unix_mode
245 .is_some_and(|mode| mode & 0o777 == expected)
246 })
247 }
248 })
249}
250
251async fn host_remove_link(host: &impl FileSystemHost, link_path: &Path) -> Result<()> {
252 #[cfg(unix)]
253 {
254 host.remove_file(link_path)
255 .await
256 .map_err(|error| error.into_anyhow("removing existing shell launcher"))
257 }
258 #[cfg(not(unix))]
259 {
260 for path in [link_path.to_path_buf(), link_path.with_extension("cmd")] {
261 match host.remove_file(&path).await {
262 Ok(()) => {}
263 Err(error) if error.is_not_found() => {}
264 Err(error) => {
265 return Err(error.into_anyhow("removing existing shell shim"));
266 }
267 }
268 }
269 Ok(())
270 }
271}
272
273async fn host_create_link(
274 host: &impl FileSystemHost,
275 source: &Path,
276 link_path: &Path,
277 runtime: LinkRuntime,
278 bun_dependencies: BunDependencyMode,
279 env: &[String],
280 render_target: Option<&str>,
281) -> Result<()> {
282 let spec = LinkSpec {
283 source: source.to_path_buf(),
284 link_name: {
285 #[cfg(unix)]
286 let name = link_path.file_name();
287 #[cfg(not(unix))]
288 let name = link_path.file_stem();
289 name.unwrap_or_default().to_os_string()
290 },
291 runtime,
292 bun_dependencies,
293 env: env.to_vec(),
294 render_target: render_target.map(str::to_string),
295 };
296 for resource in
297 prepare_launcher_resources(link_path.parent().unwrap_or_else(|| Path::new("")), &spec)
298 {
299 apply_prepared_launcher_resource(host, &resource).await?;
300 }
301 Ok(())
302}
303
304async fn host_launcher_status(
305 host: &impl FileSystemObservationHost,
306 link_path: &Path,
307 source: &Path,
308 runtime: LinkRuntime,
309 bun_dependencies: BunDependencyMode,
310 env: &[String],
311 render_target: Option<&str>,
312) -> Result<LauncherStatus> {
313 let content = match host.read(link_path).await {
314 Ok(bytes) => match String::from_utf8(bytes) {
315 Ok(content) => content,
316 Err(_) => return Ok(LauncherStatus::NotManaged),
317 },
318 Err(_) => return Ok(LauncherStatus::NotManaged),
319 };
320 if !has_shim_managed_marker(&content) {
321 return Ok(LauncherStatus::NotManaged);
322 }
323 let Some(target) = shim_target_from_content(&content) else {
324 return Ok(LauncherStatus::Stale);
325 };
326 #[cfg(unix)]
327 if target.as_os_str() != source.as_os_str() {
328 return Ok(LauncherStatus::NotManaged);
329 }
330 #[cfg(not(unix))]
331 if windows_path_key(&target) != windows_path_key(source) {
332 return Ok(LauncherStatus::NotManaged);
333 }
334
335 let name = launcher_command_name(link_path);
336 #[cfg(unix)]
337 let current = if let Some(target) = render_target {
338 content == unix_live_launcher_content(source, &name, runtime, bun_dependencies, env, target)
339 } else {
340 runtime == LinkRuntime::Bun
341 && content == unix_bun_launcher_content(source, &name, bun_dependencies, env)
342 };
343 #[cfg(not(unix))]
344 let current = {
345 let expected_ps1 =
346 powershell_shim_content(source, runtime, &name, bun_dependencies, env, render_target);
347 let expected_cmd =
348 cmd_shim_content(source, runtime, &name, bun_dependencies, env, render_target);
349 let cmd_current = host
350 .read(&link_path.with_extension("cmd"))
351 .await
352 .ok()
353 .is_some_and(|bytes| bytes == expected_cmd.as_bytes());
354 content == expected_ps1 && cmd_current
355 };
356 Ok(if current {
357 LauncherStatus::Current
358 } else {
359 LauncherStatus::Stale
360 })
361}
362
363async fn host_source_is_linkable(host: &impl FileSystemHost, path: &Path) -> bool {
364 if has_linkable_script_extension(path) {
365 return true;
366 }
367 #[cfg(unix)]
368 return host
369 .metadata(path)
370 .await
371 .ok()
372 .and_then(|metadata| metadata.unix_mode)
373 .is_some_and(|mode| mode & 0o111 != 0);
374 #[cfg(not(unix))]
375 return path
376 .extension()
377 .and_then(|extension| extension.to_str())
378 .is_some_and(|extension| EXECUTABLE_EXTENSIONS.contains(&extension));
379}
380
381pub async fn link_executables_with_host(
382 host: &impl FileSystemHost,
383 bin_dir: &Path,
384 specs: &[LinkSpec],
385 overwrite: bool,
386) -> Result<LinkReport> {
387 let mut report = LinkReport {
388 created: Vec::new(),
389 skipped: Vec::new(),
390 conflicts: Vec::new(),
391 overwritten: Vec::new(),
392 };
393 let mut seen = HashSet::new();
394 for spec in specs {
395 if spec.runtime == LinkRuntime::Native && !host_source_is_linkable(host, &spec.source).await
396 {
397 continue;
398 }
399 if spec.source.file_name().is_none() {
400 continue;
401 }
402 if !seen.insert(spec.link_name.clone()) {
403 report.conflicts.push(LinkConflict {
404 link_path: command_path_for_name(bin_dir, &spec.link_name),
405 source: spec.source.clone(),
406 kind: LinkConflictKind::DuplicateName,
407 });
408 continue;
409 }
410 let link_path = command_path_for_name(bin_dir, &spec.link_name);
411 match host.metadata(&link_path).await {
412 Ok(metadata) if metadata.kind == FileKind::Symlink => {
413 let current = host
414 .read_link(&link_path)
415 .await
416 .is_ok_and(|target| target == spec.source && spec.render_target.is_none());
417 if current {
418 report.skipped.push(link_path);
419 } else if overwrite {
420 host_remove_link(host, &link_path).await?;
421 host_create_link(
422 host,
423 &spec.source,
424 &link_path,
425 spec.runtime,
426 spec.bun_dependencies,
427 &spec.env,
428 spec.render_target.as_deref(),
429 )
430 .await?;
431 report.overwritten.push(link_path);
432 } else {
433 report.conflicts.push(LinkConflict {
434 link_path,
435 source: spec.source.clone(),
436 kind: LinkConflictKind::ExistingEntry,
437 });
438 }
439 }
440 Ok(_) => match host_launcher_status(
441 host,
442 &link_path,
443 &spec.source,
444 spec.runtime,
445 spec.bun_dependencies,
446 &spec.env,
447 spec.render_target.as_deref(),
448 )
449 .await?
450 {
451 LauncherStatus::Current => report.skipped.push(link_path),
452 LauncherStatus::Stale => {
453 host_remove_link(host, &link_path).await?;
454 host_create_link(
455 host,
456 &spec.source,
457 &link_path,
458 spec.runtime,
459 spec.bun_dependencies,
460 &spec.env,
461 spec.render_target.as_deref(),
462 )
463 .await?;
464 report.overwritten.push(link_path);
465 }
466 LauncherStatus::NotManaged if overwrite => {
467 host_remove_link(host, &link_path).await?;
468 host_create_link(
469 host,
470 &spec.source,
471 &link_path,
472 spec.runtime,
473 spec.bun_dependencies,
474 &spec.env,
475 spec.render_target.as_deref(),
476 )
477 .await?;
478 report.overwritten.push(link_path);
479 }
480 LauncherStatus::NotManaged => report.conflicts.push(LinkConflict {
481 link_path,
482 source: spec.source.clone(),
483 kind: LinkConflictKind::ExistingEntry,
484 }),
485 },
486 Err(error) if error.is_not_found() => {
487 host_create_link(
488 host,
489 &spec.source,
490 &link_path,
491 spec.runtime,
492 spec.bun_dependencies,
493 &spec.env,
494 spec.render_target.as_deref(),
495 )
496 .await?;
497 report.created.push(link_path);
498 }
499 Err(error) => return Err(error.into_anyhow("inspecting shell launcher")),
500 }
501 }
502 Ok(report)
503}
504
505pub async fn link_is_current_with_host(
506 host: &impl FileSystemObservationHost,
507 link_path: &Path,
508 source: &Path,
509 runtime: LinkRuntime,
510 bun_dependencies: BunDependencyMode,
511 env: &[String],
512 render_target: Option<&str>,
513) -> Result<bool> {
514 match host.metadata(link_path).await {
515 Ok(metadata) if metadata.kind == FileKind::Symlink => {
516 if runtime != LinkRuntime::Native || render_target.is_some() {
517 return Ok(false);
518 }
519 Ok(host
520 .read_link(link_path)
521 .await
522 .is_ok_and(|target| target == source))
523 }
524 Ok(_) => Ok(matches!(
525 host_launcher_status(
526 host,
527 link_path,
528 source,
529 runtime,
530 bun_dependencies,
531 env,
532 render_target,
533 )
534 .await?,
535 LauncherStatus::Current
536 )),
537 Err(error) if error.is_not_found() => Ok(false),
538 Err(error) => Err(error.into_anyhow("inspecting shell launcher")),
539 }
540}
541
542pub async fn unlink_managed_command_with_host(
543 host: &impl FileSystemHost,
544 bin_dir: &Path,
545 command: &OsStr,
546 managed_roots: &[PathBuf],
547 dry_run: bool,
548) -> Result<UnlinkReport> {
549 let probe = probe_managed_command_with_host(host, bin_dir, command, managed_roots).await?;
550 if !probe.conflicts.is_empty() {
551 return Ok(UnlinkReport {
552 removed: Vec::new(),
553 skipped: probe.conflicts,
554 });
555 }
556 let removed = probe
557 .resources
558 .iter()
559 .map(|resource| resource.destination().to_path_buf())
560 .collect::<Vec<_>>();
561 if !dry_run {
562 for path in &removed {
563 host.remove_file(path)
564 .await
565 .map_err(|error| error.into_anyhow("removing managed shell launcher"))?;
566 }
567 }
568 Ok(UnlinkReport {
569 removed,
570 skipped: Vec::new(),
571 })
572}
573
574pub(crate) async fn probe_managed_command_with_host(
575 host: &impl FileSystemObservationHost,
576 bin_dir: &Path,
577 command: &OsStr,
578 managed_roots: &[PathBuf],
579) -> Result<ManagedLauncherProbe> {
580 let primary = command_path_for_name(bin_dir, command);
581 #[cfg(unix)]
582 let candidates = vec![primary];
583 #[cfg(not(unix))]
584 let candidates = vec![primary.clone(), primary.with_extension("cmd")];
585
586 let mut resources = Vec::new();
587 let mut conflicts = Vec::new();
588 for path in candidates {
589 let metadata = match host.metadata(&path).await {
590 Ok(metadata) => metadata,
591 Err(error) if error.is_not_found() => continue,
592 Err(error) => return Err(error.into_anyhow("inspecting shell launcher")),
593 };
594 let (target, resource) = if metadata.kind == FileKind::Symlink {
595 let target = host
596 .read_link(&path)
597 .await
598 .map_err(|error| error.into_anyhow("reading shell launcher target"))?;
599 (
600 target.clone(),
601 PreparedLauncherResource::Symlink {
602 destination: path.clone(),
603 target,
604 },
605 )
606 } else if metadata.kind == FileKind::File {
607 let bytes = host
608 .read(&path)
609 .await
610 .map_err(|error| error.into_anyhow("reading shell launcher"))?;
611 let target = String::from_utf8(bytes.clone())
612 .ok()
613 .filter(|content| has_shim_managed_marker(content))
614 .and_then(|content| shim_target_from_content(&content));
615 let Some(target) = target else {
616 conflicts.push(path);
617 continue;
618 };
619 (
620 target,
621 PreparedLauncherResource::File {
622 destination: path.clone(),
623 bytes,
624 unix_mode: metadata.unix_mode.map(|mode| mode & 0o777),
625 },
626 )
627 } else {
628 conflicts.push(path);
629 continue;
630 };
631 if managed_roots
632 .iter()
633 .any(|root| target_is_managed(&target, root, bin_dir))
634 {
635 resources.push(resource);
636 } else {
637 conflicts.push(path);
638 }
639 }
640 if !conflicts.is_empty() {
641 resources.clear();
642 }
643 Ok(ManagedLauncherProbe {
644 resources,
645 conflicts,
646 })
647}
648
649#[cfg(test)]
655pub async fn unlink_managed(
656 bin_dir: &Path,
657 managed_root: &Path,
658 dry_run: bool,
659) -> Result<UnlinkReport> {
660 let mut report = UnlinkReport {
661 removed: Vec::new(),
662 skipped: Vec::new(),
663 };
664
665 let mut read_dir = match tokio::fs::read_dir(bin_dir).await {
666 Ok(rd) => rd,
667 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(report),
668 Err(e) => return Err(e).with_context(|| format!("reading bin dir: {bin_dir:?}")),
669 };
670
671 while let Some(entry) = read_dir
672 .next_entry()
673 .await
674 .with_context(|| format!("iterating bin dir: {bin_dir:?}"))?
675 {
676 let path = entry.path();
677 let meta = match tokio::fs::symlink_metadata(&path).await {
678 Ok(m) => m,
679 Err(_) => continue,
680 };
681
682 if !meta.file_type().is_symlink() {
688 match launcher_target(&path).await {
689 Ok(Some(target)) if target_is_managed(&target, managed_root, bin_dir) => {
690 if !dry_run {
691 remove_link(&path).await?;
692 }
693 report.removed.push(path);
694 }
695 _ => report.skipped.push(path),
696 }
697 continue;
698 }
699
700 let target = match tokio::fs::read_link(&path).await {
701 Ok(t) => t,
702 Err(_) => {
703 report.skipped.push(path);
704 continue;
705 }
706 };
707
708 if target_is_managed(&target, managed_root, bin_dir) {
710 if !dry_run {
711 tokio::fs::remove_file(&path)
712 .await
713 .with_context(|| format!("removing symlink: {path:?}"))?;
714 }
715 report.removed.push(path);
716 } else {
717 report.skipped.push(path);
718 }
719 }
720
721 Ok(report)
722}
723
724#[cfg(test)]
728#[expect(dead_code, reason = "retained as a no-host launcher regression helper")]
729pub async fn unlink_managed_command(
730 bin_dir: &Path,
731 command: &OsStr,
732 managed_roots: &[PathBuf],
733 dry_run: bool,
734) -> Result<UnlinkReport> {
735 let path = command_path_for_name(bin_dir, command);
736 let mut report = UnlinkReport {
737 removed: Vec::new(),
738 skipped: Vec::new(),
739 };
740 let meta = match tokio::fs::symlink_metadata(&path).await {
741 Ok(meta) => meta,
742 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(report),
743 Err(error) => return Err(error).with_context(|| format!("stat failed: {path:?}")),
744 };
745
746 let target = if meta.file_type().is_symlink() {
747 tokio::fs::read_link(&path).await.ok()
748 } else {
749 launcher_target(&path).await?
750 };
751 let managed = target.is_some_and(|target| {
752 managed_roots
753 .iter()
754 .any(|root| target_is_managed(&target, root, bin_dir))
755 });
756 if !managed {
757 report.skipped.push(path);
758 return Ok(report);
759 }
760
761 if !dry_run {
762 remove_link(&path).await?;
763 }
764 report.removed.push(path);
765 Ok(report)
766}
767
768#[cfg(test)]
775pub async fn link_executables(
776 bin_dir: &Path,
777 sources: &[PathBuf],
778 overwrite: bool,
779) -> Result<LinkReport> {
780 let specs: Vec<_> = sources
781 .iter()
782 .map(|source| LinkSpec {
783 source: source.clone(),
784 link_name: link_stem(source),
785 runtime: LinkRuntime::Native,
786 bun_dependencies: BunDependencyMode::Disabled,
787 env: Vec::new(),
788 render_target: None,
789 })
790 .collect();
791 link_executables_with_names(bin_dir, &specs, overwrite).await
792}
793
794#[cfg(test)]
795pub async fn link_executables_with_names(
796 bin_dir: &Path,
797 specs: &[LinkSpec],
798 overwrite: bool,
799) -> Result<LinkReport> {
800 let mut report = LinkReport {
801 created: Vec::new(),
802 skipped: Vec::new(),
803 conflicts: Vec::new(),
804 overwritten: Vec::new(),
805 };
806
807 let mut seen: HashSet<OsString> = HashSet::new();
808
809 for spec in specs {
810 if spec.runtime == LinkRuntime::Native && !is_linkable_source(&spec.source) {
813 continue;
814 }
815
816 if spec.source.file_name().is_none() {
817 continue;
818 }
819 let stem = spec.link_name.clone();
820
821 if !seen.insert(stem.clone()) {
822 report.conflicts.push(LinkConflict {
823 link_path: command_path_for_name(bin_dir, &stem),
824 source: spec.source.clone(),
825 kind: LinkConflictKind::DuplicateName,
826 });
827 continue;
828 }
829
830 let link_path = command_path_for_name(bin_dir, &stem);
831
832 match tokio::fs::symlink_metadata(&link_path).await {
833 Ok(meta) if meta.file_type().is_symlink() => {
834 match tokio::fs::read_link(&link_path).await {
835 Ok(existing) if existing == spec.source && spec.render_target.is_none() => {
836 report.skipped.push(link_path);
837 }
838 _ => {
839 if overwrite {
840 tokio::fs::remove_file(&link_path).await.with_context(|| {
841 format!("removing stale symlink: {link_path:?}")
842 })?;
843 create_link(
844 &spec.source,
845 &link_path,
846 spec.runtime,
847 spec.bun_dependencies,
848 &spec.env,
849 spec.render_target.as_deref(),
850 )
851 .await?;
852 report.overwritten.push(link_path);
853 } else {
854 report.conflicts.push(LinkConflict {
855 link_path,
856 source: spec.source.clone(),
857 kind: LinkConflictKind::ExistingEntry,
858 });
859 }
860 }
861 }
862 }
863 Ok(_) => {
864 match launcher_status(
865 &link_path,
866 &spec.source,
867 spec.runtime,
868 spec.bun_dependencies,
869 &spec.env,
870 spec.render_target.as_deref(),
871 )
872 .await?
873 {
874 LauncherStatus::Current => {
875 report.skipped.push(link_path);
876 continue;
877 }
878 LauncherStatus::Stale => {
879 remove_link(&link_path).await?;
880 create_link(
881 &spec.source,
882 &link_path,
883 spec.runtime,
884 spec.bun_dependencies,
885 &spec.env,
886 spec.render_target.as_deref(),
887 )
888 .await?;
889 report.overwritten.push(link_path);
890 continue;
891 }
892 LauncherStatus::NotManaged => {}
893 }
894
895 if overwrite {
896 remove_link(&link_path).await?;
897 create_link(
898 &spec.source,
899 &link_path,
900 spec.runtime,
901 spec.bun_dependencies,
902 &spec.env,
903 spec.render_target.as_deref(),
904 )
905 .await?;
906 report.overwritten.push(link_path);
907 } else {
908 report.conflicts.push(LinkConflict {
909 link_path,
910 source: spec.source.clone(),
911 kind: LinkConflictKind::ExistingEntry,
912 });
913 }
914 }
915 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
916 create_link(
917 &spec.source,
918 &link_path,
919 spec.runtime,
920 spec.bun_dependencies,
921 &spec.env,
922 spec.render_target.as_deref(),
923 )
924 .await?;
925 report.created.push(link_path);
926 }
927 Err(e) => {
928 return Err(e).with_context(|| format!("stat failed: {link_path:?}"));
929 }
930 }
931 }
932
933 Ok(report)
934}
935
936#[cfg(test)]
942#[expect(dead_code, reason = "retained as a no-host launcher regression helper")]
943pub async fn link_is_current(
944 link_path: &Path,
945 source: &Path,
946 runtime: LinkRuntime,
947 bun_dependencies: BunDependencyMode,
948 env: &[String],
949 render_target: Option<&str>,
950) -> Result<bool> {
951 match tokio::fs::symlink_metadata(link_path).await {
952 Ok(meta) if meta.file_type().is_symlink() => {
953 if runtime != LinkRuntime::Native || render_target.is_some() {
954 return Ok(false);
955 }
956 Ok(tokio::fs::read_link(link_path)
957 .await
958 .is_ok_and(|target| target == source))
959 }
960 Ok(_) => Ok(matches!(
961 launcher_status(
962 link_path,
963 source,
964 runtime,
965 bun_dependencies,
966 env,
967 render_target,
968 )
969 .await?,
970 LauncherStatus::Current
971 )),
972 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
973 Err(error) => Err(error).with_context(|| format!("stat failed: {link_path:?}")),
974 }
975}
976
977pub fn command_path_for_name(bin_dir: &Path, stem: &OsStr) -> PathBuf {
978 #[cfg(unix)]
979 {
980 bin_dir.join(stem)
981 }
982 #[cfg(not(unix))]
983 {
984 let mut name = stem.to_os_string();
985 name.push(".ps1");
986 bin_dir.join(name)
987 }
988}
989
990pub fn link_stem(path: &Path) -> std::ffi::OsString {
991 if has_linkable_script_extension(path) || has_bun_script_extension(path) {
992 path.file_stem().map(|s| s.to_owned()).unwrap_or_default()
993 } else {
994 path.file_name().map(|n| n.to_owned()).unwrap_or_default()
995 }
996}
997
998#[cfg(test)]
999fn is_executable(path: &Path) -> bool {
1000 #[cfg(unix)]
1001 {
1002 use std::os::unix::fs::PermissionsExt;
1003 std::fs::metadata(path)
1004 .map(|m| m.permissions().mode() & 0o111 != 0)
1005 .unwrap_or(false)
1006 }
1007 #[cfg(not(unix))]
1008 {
1009 path.extension()
1010 .and_then(|e| e.to_str())
1011 .map(|ext| EXECUTABLE_EXTENSIONS.contains(&ext))
1012 .unwrap_or(false)
1013 }
1014}
1015
1016#[cfg(test)]
1017fn is_linkable_source(path: &Path) -> bool {
1018 is_executable(path) || has_linkable_script_extension(path)
1019}
1020
1021fn has_linkable_script_extension(path: &Path) -> bool {
1022 path.extension()
1023 .and_then(|e| e.to_str())
1024 .map(|ext| LINKABLE_SCRIPT_EXTENSIONS.contains(&ext))
1025 .unwrap_or(false)
1026}
1027
1028fn has_bun_script_extension(path: &Path) -> bool {
1029 path.extension()
1030 .and_then(|e| e.to_str())
1031 .map(|ext| BUN_SCRIPT_EXTENSIONS.contains(&ext))
1032 .unwrap_or(false)
1033}
1034
1035fn target_is_managed(target: &Path, managed_root: &Path, bin_dir: &Path) -> bool {
1039 if target.is_absolute() {
1040 target.starts_with(managed_root)
1041 } else {
1042 bin_dir.join(target).starts_with(managed_root)
1043 }
1044}
1045
1046fn has_shim_managed_marker(content: &str) -> bool {
1047 content.contains(SHIM_MANAGED_MARKER) || content.contains(CMD_SHIM_MANAGED_MARKER)
1048}
1049
1050fn launcher_command_name(link_path: &Path) -> String {
1052 link_path
1053 .file_stem()
1054 .map(|s| s.to_string_lossy().into_owned())
1055 .unwrap_or_default()
1056}
1057
1058#[cfg(test)]
1062async fn launcher_target(path: &Path) -> Result<Option<PathBuf>> {
1063 let content = match tokio::fs::read_to_string(path).await {
1064 Ok(content) => content,
1065 Err(_) => return Ok(None),
1066 };
1067 if !has_shim_managed_marker(&content) {
1068 return Ok(None);
1069 }
1070 Ok(shim_target_from_content(&content))
1071}
1072
1073fn shim_target_from_content(content: &str) -> Option<PathBuf> {
1074 content.lines().find_map(|line| {
1075 line.strip_prefix(SHIM_TARGET_PREFIX)
1076 .or_else(|| line.strip_prefix("REM shine-target: "))
1077 .map(PathBuf::from)
1078 })
1079}
1080
1081#[cfg(test)]
1082async fn create_link(
1083 source: &Path,
1084 link_path: &Path,
1085 runtime: LinkRuntime,
1086 bun_dependencies: BunDependencyMode,
1087 env: &[String],
1088 render_target: Option<&str>,
1089) -> Result<()> {
1090 #[cfg(unix)]
1091 {
1092 if let Some(target) = render_target {
1093 return write_unix_live_launcher(
1094 source,
1095 link_path,
1096 runtime,
1097 bun_dependencies,
1098 env,
1099 target,
1100 )
1101 .await;
1102 }
1103 match runtime {
1104 LinkRuntime::Native => tokio::fs::symlink(source, link_path)
1105 .await
1106 .with_context(|| format!("creating symlink {link_path:?} -> {source:?}")),
1107 LinkRuntime::Bun => {
1108 write_unix_bun_launcher(source, link_path, bun_dependencies, env).await
1109 }
1110 }
1111 }
1112 #[cfg(not(unix))]
1113 {
1114 create_windows_shims(
1115 source,
1116 link_path,
1117 runtime,
1118 bun_dependencies,
1119 env,
1120 render_target,
1121 )
1122 .await
1123 }
1124}
1125
1126#[cfg(test)]
1127async fn remove_link(link_path: &Path) -> Result<()> {
1128 #[cfg(unix)]
1129 {
1130 tokio::fs::remove_file(link_path)
1131 .await
1132 .with_context(|| format!("removing existing file: {link_path:?}"))
1133 }
1134 #[cfg(not(unix))]
1135 {
1136 remove_windows_shims(link_path).await
1137 }
1138}
1139
1140#[cfg(test)]
1145async fn launcher_status(
1146 link_path: &Path,
1147 source: &Path,
1148 runtime: LinkRuntime,
1149 bun_dependencies: BunDependencyMode,
1150 env: &[String],
1151 render_target: Option<&str>,
1152) -> Result<LauncherStatus> {
1153 #[cfg(unix)]
1154 {
1155 if let Some(target) = render_target {
1156 return unix_live_launcher_status(
1157 link_path,
1158 source,
1159 runtime,
1160 bun_dependencies,
1161 env,
1162 target,
1163 )
1164 .await;
1165 }
1166 match runtime {
1167 LinkRuntime::Bun => {
1168 unix_launcher_status(link_path, source, bun_dependencies, env).await
1169 }
1170 LinkRuntime::Native => Ok(LauncherStatus::NotManaged),
1171 }
1172 }
1173 #[cfg(not(unix))]
1174 {
1175 windows_shim_status(
1176 link_path,
1177 source,
1178 runtime,
1179 bun_dependencies,
1180 env,
1181 render_target,
1182 )
1183 .await
1184 }
1185}
1186
1187#[cfg(unix)]
1188fn shell_single_quote(value: &str) -> String {
1189 format!("'{}'", value.replace('\'', "'\\''"))
1190}
1191
1192#[cfg(unix)]
1196fn unix_bun_launcher_content(
1197 source: &Path,
1198 name: &str,
1199 bun_dependencies: BunDependencyMode,
1200 env: &[String],
1201) -> String {
1202 let target = source.display().to_string();
1203 let quoted_target = shell_single_quote(&target);
1204 let quoted_name = shell_single_quote(name);
1205 let install_arg = bun_dependencies.install_arg();
1206 let (shine_check, runner) = if env.is_empty() {
1210 (
1211 String::new(),
1212 format!("exec bun {install_arg} {quoted_target} \"$@\"\n"),
1213 )
1214 } else {
1215 let with_args = env
1216 .iter()
1217 .map(|token| format!("--with {}", shell_single_quote(token)))
1218 .collect::<Vec<_>>()
1219 .join(" ");
1220 (
1221 format!(
1222 "if ! command -v shine >/dev/null 2>&1; then\n \
1223 printf 'shine: %s requires the shine command, which was not found on PATH.\\n' {quoted_name} >&2\n \
1224 exit 127\nfi\n"
1225 ),
1226 format!(
1227 "exec shine env run --no-workspace {with_args} -- bun {install_arg} {quoted_target} \"$@\"\n"
1228 ),
1229 )
1230 };
1231 format!(
1232 "#!/usr/bin/env bash\n\
1233 {SHIM_MANAGED_MARKER}\n\
1234 {SHIM_TARGET_PREFIX}{target}\n\
1235 if ! command -v bun >/dev/null 2>&1; then\n \
1236 printf 'shine: %s requires Bun, which was not found on PATH.\\n' {quoted_name} >&2\n \
1237 printf 'shine: install Bun from https://bun.sh, then re-run %s.\\n' {quoted_name} >&2\n \
1238 exit 127\nfi\n\
1239 {shine_check}{runner}"
1240 )
1241}
1242
1243#[cfg(unix)]
1244fn unix_live_launcher_content(
1245 source: &Path,
1246 name: &str,
1247 runtime: LinkRuntime,
1248 bun_dependencies: BunDependencyMode,
1249 env: &[String],
1250 render_target: &str,
1251) -> String {
1252 let target = source.display().to_string();
1253 let quoted_source = shell_single_quote(&target);
1254 let quoted_name = shell_single_quote(name);
1255 let quoted_render_target = shell_single_quote(render_target);
1256 let config_dir = live_config_dir(source);
1257 let config_arg = if config_dir.file_name() == Some(OsStr::new(".shine")) {
1258 String::new()
1259 } else {
1260 format!(
1261 "--config-dir {} ",
1262 shell_single_quote(&config_dir.display().to_string())
1263 )
1264 };
1265 let render = format!(
1266 "if ! command -v shine >/dev/null 2>&1; then\n \
1267 printf 'shine: %s requires the shine command, which was not found on PATH.\\n' {quoted_name} >&2\n \
1268 return 127 2>/dev/null || exit 127\nfi\n\
1269 shine {config_arg}__shell-render {quoted_render_target} || {{ _shine_code=$?; return $_shine_code 2>/dev/null || exit $_shine_code; }}\n"
1270 );
1271 let runner = match runtime {
1272 LinkRuntime::Native => format!(
1273 "_shine_sourced=false\n\
1274 case \"$ZSH_EVAL_CONTEXT\" in *:file|*:file:*) _shine_sourced=true ;; esac\n\
1275 if [ -n \"$BASH_VERSION\" ] && [ \"$BASH_SOURCE\" != \"$0\" ]; then _shine_sourced=true; fi\n\
1276 if [ \"$_shine_sourced\" = true ]; then\n . {quoted_source} \"$@\"\n return $?\nfi\n\
1277 exec {quoted_source} \"$@\"\n"
1278 ),
1279 LinkRuntime::Bun => {
1280 let install_arg = bun_dependencies.install_arg();
1281 let bun_check = format!(
1282 "if ! command -v bun >/dev/null 2>&1; then\n \
1283 printf 'shine: %s requires Bun, which was not found on PATH.\\n' {quoted_name} >&2\n \
1284 exit 127\nfi\n"
1285 );
1286 if env.is_empty() {
1287 format!("{bun_check}exec bun {install_arg} {quoted_source} \"$@\"\n")
1288 } else {
1289 let with_args = env
1290 .iter()
1291 .map(|token| format!("--with {}", shell_single_quote(token)))
1292 .collect::<Vec<_>>()
1293 .join(" ");
1294 format!(
1295 "{bun_check}exec shine env run --no-workspace {with_args} -- bun {install_arg} {quoted_source} \"$@\"\n"
1296 )
1297 }
1298 }
1299 };
1300 format!(
1301 "#!/usr/bin/env bash\n{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}{runner}"
1302 )
1303}
1304
1305fn live_config_dir(rendered_source: &Path) -> PathBuf {
1306 rendered_source
1307 .ancestors()
1308 .find(|path| path.file_name() == Some(OsStr::new("rendered")))
1309 .and_then(Path::parent)
1310 .map(Path::to_path_buf)
1311 .unwrap_or_else(|| {
1312 rendered_source
1313 .parent()
1314 .unwrap_or_else(|| Path::new("."))
1315 .to_path_buf()
1316 })
1317}
1318
1319#[cfg(unix)]
1320#[cfg(test)]
1321async fn write_unix_live_launcher(
1322 source: &Path,
1323 link_path: &Path,
1324 runtime: LinkRuntime,
1325 bun_dependencies: BunDependencyMode,
1326 env: &[String],
1327 render_target: &str,
1328) -> Result<()> {
1329 use std::os::unix::fs::PermissionsExt;
1330 if let Some(parent) = link_path.parent() {
1331 tokio::fs::create_dir_all(parent).await?;
1332 }
1333 let name = launcher_command_name(link_path);
1334 let content =
1335 unix_live_launcher_content(source, &name, runtime, bun_dependencies, env, render_target);
1336 crate::persist::atomic_write(link_path, content.as_bytes()).await?;
1337 tokio::fs::set_permissions(link_path, std::fs::Permissions::from_mode(0o755)).await?;
1338 Ok(())
1339}
1340
1341#[cfg(unix)]
1342#[cfg(test)]
1343async fn unix_live_launcher_status(
1344 link_path: &Path,
1345 source: &Path,
1346 runtime: LinkRuntime,
1347 bun_dependencies: BunDependencyMode,
1348 env: &[String],
1349 render_target: &str,
1350) -> Result<LauncherStatus> {
1351 let content = match tokio::fs::read_to_string(link_path).await {
1352 Ok(content) => content,
1353 Err(_) => return Ok(LauncherStatus::NotManaged),
1354 };
1355 if !has_shim_managed_marker(&content) {
1356 return Ok(LauncherStatus::NotManaged);
1357 }
1358 let Some(target) = shim_target_from_content(&content) else {
1359 return Ok(LauncherStatus::Stale);
1360 };
1361 if target.as_os_str() != source.as_os_str() {
1362 return Ok(LauncherStatus::NotManaged);
1363 }
1364 let name = launcher_command_name(link_path);
1365 if content
1366 == unix_live_launcher_content(source, &name, runtime, bun_dependencies, env, render_target)
1367 {
1368 Ok(LauncherStatus::Current)
1369 } else {
1370 Ok(LauncherStatus::Stale)
1371 }
1372}
1373
1374#[cfg(unix)]
1375#[cfg(test)]
1376async fn write_unix_bun_launcher(
1377 source: &Path,
1378 link_path: &Path,
1379 bun_dependencies: BunDependencyMode,
1380 env: &[String],
1381) -> Result<()> {
1382 use std::os::unix::fs::PermissionsExt;
1383 if let Some(parent) = link_path.parent() {
1384 tokio::fs::create_dir_all(parent)
1385 .await
1386 .with_context(|| format!("creating bin dir: {parent:?}"))?;
1387 }
1388 let name = launcher_command_name(link_path);
1389 tokio::fs::write(
1390 link_path,
1391 unix_bun_launcher_content(source, &name, bun_dependencies, env),
1392 )
1393 .await
1394 .with_context(|| format!("writing bun launcher: {link_path:?}"))?;
1395 tokio::fs::set_permissions(link_path, std::fs::Permissions::from_mode(0o755))
1396 .await
1397 .with_context(|| format!("setting bun launcher permissions: {link_path:?}"))?;
1398 Ok(())
1399}
1400
1401#[cfg(unix)]
1402#[cfg(test)]
1403async fn unix_launcher_status(
1404 link_path: &Path,
1405 source: &Path,
1406 bun_dependencies: BunDependencyMode,
1407 env: &[String],
1408) -> Result<LauncherStatus> {
1409 let content = match tokio::fs::read_to_string(link_path).await {
1410 Ok(content) => content,
1411 Err(_) => return Ok(LauncherStatus::NotManaged),
1413 };
1414 if !has_shim_managed_marker(&content) {
1415 return Ok(LauncherStatus::NotManaged);
1416 }
1417 let Some(target) = shim_target_from_content(&content) else {
1418 return Ok(LauncherStatus::Stale);
1419 };
1420 if target.as_os_str() != source.as_os_str() {
1421 return Ok(LauncherStatus::NotManaged);
1422 }
1423 let name = launcher_command_name(link_path);
1424 if content == unix_bun_launcher_content(source, &name, bun_dependencies, env) {
1427 Ok(LauncherStatus::Current)
1428 } else {
1429 Ok(LauncherStatus::Stale)
1430 }
1431}
1432
1433#[cfg(all(test, not(unix)))]
1434async fn create_windows_shims(
1435 source: &Path,
1436 ps1_path: &Path,
1437 runtime: LinkRuntime,
1438 bun_dependencies: BunDependencyMode,
1439 env: &[String],
1440 render_target: Option<&str>,
1441) -> Result<()> {
1442 let cmd_path = ps1_path.with_extension("cmd");
1443 if let Some(parent) = ps1_path.parent() {
1444 tokio::fs::create_dir_all(parent)
1445 .await
1446 .with_context(|| format!("creating bin dir: {parent:?}"))?;
1447 }
1448 let name = launcher_command_name(ps1_path);
1449 tokio::fs::write(
1450 ps1_path,
1451 powershell_shim_content(source, runtime, &name, bun_dependencies, env, render_target),
1452 )
1453 .await
1454 .with_context(|| format!("writing PowerShell shim: {ps1_path:?}"))?;
1455 tokio::fs::write(
1456 &cmd_path,
1457 cmd_shim_content(source, runtime, &name, bun_dependencies, env, render_target),
1458 )
1459 .await
1460 .with_context(|| format!("writing cmd shim: {cmd_path:?}"))?;
1461 Ok(())
1462}
1463
1464#[cfg(not(unix))]
1465fn powershell_shim_content(
1466 source: &Path,
1467 runtime: LinkRuntime,
1468 name: &str,
1469 bun_dependencies: BunDependencyMode,
1470 env: &[String],
1471 render_target: Option<&str>,
1472) -> String {
1473 let target = windows_native_path(source);
1474 let escaped = target.replace('\'', "''");
1475 let render = render_target.map_or_else(String::new, |render_target| {
1476 let render_target = render_target.replace('\'', "''");
1477 let config_dir = windows_native_path(&live_config_dir(source)).replace('\'', "''");
1478 let config_arg = if Path::new(&config_dir).file_name() == Some(OsStr::new(".shine")) {
1479 String::new()
1480 } else {
1481 format!("--config-dir '{config_dir}' ")
1482 };
1483 format!(
1484 "$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"
1485 )
1486 });
1487 match runtime {
1488 LinkRuntime::Bun => {
1489 let install_arg = bun_dependencies.install_arg();
1490 let name_escaped = name.replace('\'', "''");
1491 let bun_check = format!(
1492 "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"
1493 );
1494 if env.is_empty() {
1495 format!(
1496 "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}{bun_check}& bun {install_arg} '{escaped}' @args\nexit $LASTEXITCODE\n"
1497 )
1498 } else {
1499 let with_args = env
1500 .iter()
1501 .map(|token| format!("--with '{}'", token.replace('\'', "''")))
1502 .collect::<Vec<_>>()
1503 .join(" ");
1504 format!(
1505 "{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"
1506 )
1507 }
1508 }
1509 LinkRuntime::Native => {
1510 let bash_target = bash_compatible_path(source);
1511 let bash_escaped = bash_target.replace('\'', "''");
1512 match source.extension().and_then(|e| e.to_str()) {
1513 Some("ps1") => format!(
1514 "{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"
1515 ),
1516 _ => format!(
1517 "{SHIM_MANAGED_MARKER}\n{SHIM_TARGET_PREFIX}{target}\n{render}& bash '{bash_escaped}' @args\nexit $LASTEXITCODE\n"
1518 ),
1519 }
1520 }
1521 }
1522}
1523
1524#[cfg(not(unix))]
1525fn cmd_shim_content(
1526 source: &Path,
1527 runtime: LinkRuntime,
1528 name: &str,
1529 bun_dependencies: BunDependencyMode,
1530 env: &[String],
1531 render_target: Option<&str>,
1532) -> String {
1533 let target = windows_native_path(source);
1534 let render = render_target.map_or_else(String::new, |render_target| {
1535 let config_dir = windows_native_path(&live_config_dir(source));
1536 let config_arg = if Path::new(&config_dir).file_name() == Some(OsStr::new(".shine")) {
1537 String::new()
1538 } else {
1539 format!("--config-dir \"{config_dir}\" ")
1540 };
1541 format!(
1542 "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"
1543 )
1544 });
1545 match runtime {
1546 LinkRuntime::Bun => {
1547 let install_arg = bun_dependencies.install_arg();
1548 let bun_check = format!(
1549 "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"
1550 );
1551 if env.is_empty() {
1552 format!(
1553 "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}{bun_check}bun {install_arg} \"{target}\" %*\r\n"
1554 )
1555 } else {
1556 let with_args = env
1557 .iter()
1558 .map(|token| format!("--with {token}"))
1559 .collect::<Vec<_>>()
1560 .join(" ");
1561 format!(
1562 "@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"
1563 )
1564 }
1565 }
1566 LinkRuntime::Native => {
1567 let escaped = target.replace('\'', "''");
1568 let bash_target = bash_compatible_path(source);
1569 match source.extension().and_then(|e| e.to_str()) {
1570 Some("ps1") => format!(
1571 "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"{escaped}\" %*\r\n"
1572 ),
1573 _ => format!(
1574 "@echo off\r\nREM shine-managed\r\nREM shine-target: {target}\r\n{render}bash \"{bash_target}\" %*\r\n"
1575 ),
1576 }
1577 }
1578 }
1579}
1580
1581#[cfg(not(unix))]
1582fn bash_compatible_path(path: &Path) -> String {
1583 windows_native_path(path).replace('\\', "/")
1584}
1585
1586#[cfg(not(unix))]
1587fn windows_native_path(path: &Path) -> String {
1588 strip_windows_verbatim_prefix(&path.display().to_string())
1589}
1590
1591#[cfg(not(unix))]
1592fn strip_windows_verbatim_prefix(value: &str) -> String {
1593 value
1594 .strip_prefix(r"\\?\UNC\")
1595 .map(|rest| format!(r"\\{rest}"))
1596 .or_else(|| value.strip_prefix(r"\\?\").map(str::to_string))
1597 .unwrap_or_else(|| value.to_string())
1598}
1599
1600#[cfg(all(test, not(unix)))]
1601async fn windows_shim_status(
1602 link_path: &Path,
1603 source: &Path,
1604 runtime: LinkRuntime,
1605 bun_dependencies: BunDependencyMode,
1606 env: &[String],
1607 render_target: Option<&str>,
1608) -> Result<LauncherStatus> {
1609 let content = match tokio::fs::read_to_string(link_path).await {
1610 Ok(content) => content,
1611 Err(_) => return Ok(LauncherStatus::NotManaged),
1613 };
1614 if !has_shim_managed_marker(&content) {
1615 return Ok(LauncherStatus::NotManaged);
1616 }
1617
1618 let Some(target) = shim_target_from_content(&content) else {
1619 return Ok(LauncherStatus::Stale);
1620 };
1621 if windows_path_key(&target) != windows_path_key(source) {
1622 return Ok(LauncherStatus::NotManaged);
1623 }
1624
1625 let name = launcher_command_name(link_path);
1626 let expected_ps1 =
1627 powershell_shim_content(source, runtime, &name, bun_dependencies, env, render_target);
1628 let expected_cmd =
1629 cmd_shim_content(source, runtime, &name, bun_dependencies, env, render_target);
1630 let cmd_path = link_path.with_extension("cmd");
1631 let cmd_content = tokio::fs::read_to_string(&cmd_path).await.ok();
1632 if content == expected_ps1 && cmd_content.as_deref() == Some(expected_cmd.as_str()) {
1633 Ok(LauncherStatus::Current)
1634 } else {
1635 Ok(LauncherStatus::Stale)
1636 }
1637}
1638
1639#[cfg(not(unix))]
1640fn windows_path_key(path: &Path) -> String {
1641 windows_native_path(path)
1642 .replace('\\', "/")
1643 .to_ascii_lowercase()
1644}
1645
1646#[cfg(all(test, not(unix)))]
1647async fn remove_windows_shims(ps1_path: &Path) -> Result<()> {
1648 let cmd_path = ps1_path.with_extension("cmd");
1649 match tokio::fs::remove_file(ps1_path).await {
1650 Ok(()) => {}
1651 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1652 Err(err) => return Err(err).with_context(|| format!("removing shim: {ps1_path:?}")),
1653 }
1654 match tokio::fs::remove_file(&cmd_path).await {
1655 Ok(()) => {}
1656 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1657 Err(err) => return Err(err).with_context(|| format!("removing shim: {cmd_path:?}")),
1658 }
1659 Ok(())
1660}
1661
1662#[cfg(test)]
1663mod tests {
1664 use super::*;
1665 #[cfg(unix)]
1666 use tokio::fs;
1667
1668 #[test]
1669 fn recognizes_powershell_unix_and_cmd_managed_markers() {
1670 assert!(has_shim_managed_marker("# shine-managed\n"));
1671 assert!(has_shim_managed_marker("REM shine-managed\r\n"));
1672 assert!(!has_shim_managed_marker("shine-managed\n"));
1673 }
1674
1675 #[cfg(unix)]
1676 async fn make_dirs() -> (PathBuf, PathBuf) {
1677 let id = uuid::Uuid::new_v4();
1678 let src_dir = std::env::temp_dir().join(format!("shine-bl-src-{id}"));
1679 let bin_dir = std::env::temp_dir().join(format!("shine-bl-bin-{id}"));
1680 fs::create_dir_all(&src_dir).await.unwrap();
1681 fs::create_dir_all(&bin_dir).await.unwrap();
1682 (src_dir, bin_dir)
1683 }
1684
1685 #[cfg(unix)]
1687 async fn make_executable(dir: &Path, name: &str) -> PathBuf {
1688 use std::os::unix::fs::PermissionsExt;
1689 let path = dir.join(name);
1690 fs::write(&path, b"#!/bin/sh\n").await.unwrap();
1691 let mut perms = fs::metadata(&path).await.unwrap().permissions();
1692 perms.set_mode(0o755);
1693 fs::set_permissions(&path, perms).await.unwrap();
1694 path
1695 }
1696
1697 #[cfg(unix)]
1698 async fn make_plain(dir: &Path, name: &str) -> PathBuf {
1699 let path = dir.join(name);
1700 fs::write(&path, b"data").await.unwrap();
1701 path
1702 }
1703
1704 #[cfg(unix)]
1705 #[tokio::test]
1706 async fn creates_symlink_for_executable_source() {
1707 let (src, bin) = make_dirs().await;
1708 let exe = make_executable(&src, "run.sh").await;
1709
1710 let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1711 .await
1712 .unwrap();
1713
1714 assert_eq!(report.created.len(), 1);
1715 let link = &report.created[0];
1716 assert!(link.is_symlink());
1717 assert_eq!(fs::read_link(link).await.unwrap(), exe);
1718 assert_eq!(link.file_name().unwrap(), "run");
1720
1721 fs::remove_dir_all(&src).await.unwrap();
1722 fs::remove_dir_all(&bin).await.unwrap();
1723 }
1724
1725 #[cfg(unix)]
1726 #[tokio::test]
1727 async fn skips_non_executable_source() {
1728 let (src, bin) = make_dirs().await;
1729 let plain = make_plain(&src, "readme.txt").await;
1730
1731 let report = link_executables(&bin, &[plain], false).await.unwrap();
1732
1733 assert!(report.created.is_empty());
1734 assert!(report.skipped.is_empty());
1735
1736 fs::remove_dir_all(&src).await.unwrap();
1737 fs::remove_dir_all(&bin).await.unwrap();
1738 }
1739
1740 #[cfg(unix)]
1741 #[tokio::test]
1742 async fn skips_when_correct_symlink_already_exists() {
1743 let (src, bin) = make_dirs().await;
1744 let exe = make_executable(&src, "run.sh").await;
1745 tokio::fs::symlink(&exe, bin.join("run")).await.unwrap();
1746
1747 let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1748 .await
1749 .unwrap();
1750
1751 assert!(report.created.is_empty());
1752 assert_eq!(report.skipped.len(), 1);
1753
1754 fs::remove_dir_all(&src).await.unwrap();
1755 fs::remove_dir_all(&bin).await.unwrap();
1756 }
1757
1758 #[cfg(unix)]
1759 #[tokio::test]
1760 async fn reports_conflict_when_regular_file_exists() {
1761 let (src, bin) = make_dirs().await;
1762 let exe = make_executable(&src, "run.sh").await;
1763 make_plain(&bin, "run").await;
1764
1765 let report = link_executables(&bin, std::slice::from_ref(&exe), false)
1766 .await
1767 .unwrap();
1768
1769 assert!(report.created.is_empty());
1770 assert_eq!(report.conflicts.len(), 1);
1771 assert_eq!(report.conflicts[0].link_path, bin.join("run"));
1772 assert_eq!(report.conflicts[0].source, exe);
1773 assert_eq!(report.conflicts[0].kind, LinkConflictKind::ExistingEntry);
1774
1775 fs::remove_dir_all(&src).await.unwrap();
1776 fs::remove_dir_all(&bin).await.unwrap();
1777 }
1778
1779 #[cfg(unix)]
1780 #[tokio::test]
1781 async fn overwrites_stale_symlink_when_overwrite_true() {
1782 let (src, bin) = make_dirs().await;
1783 let exe = make_executable(&src, "run.sh").await;
1784 let other = make_executable(&src, "other.sh").await;
1785 tokio::fs::symlink(&other, bin.join("run")).await.unwrap();
1786
1787 let report = link_executables(&bin, std::slice::from_ref(&exe), true)
1788 .await
1789 .unwrap();
1790
1791 assert_eq!(report.overwritten.len(), 1);
1792 assert_eq!(fs::read_link(bin.join("run")).await.unwrap(), exe);
1793
1794 fs::remove_dir_all(&src).await.unwrap();
1795 fs::remove_dir_all(&bin).await.unwrap();
1796 }
1797
1798 #[cfg(unix)]
1799 #[tokio::test]
1800 async fn flattens_nested_preset_path_into_bin_dir() {
1801 let (src, bin) = make_dirs().await;
1802 let sub = src.join("shell").join("proxy");
1803 fs::create_dir_all(&sub).await.unwrap();
1804 let exe = {
1805 use std::os::unix::fs::PermissionsExt;
1806 let path = sub.join("set_proxy.sh");
1807 fs::write(&path, b"#!/bin/sh\n").await.unwrap();
1808 let mut perms = fs::metadata(&path).await.unwrap().permissions();
1809 perms.set_mode(0o755);
1810 fs::set_permissions(&path, perms).await.unwrap();
1811 path
1812 };
1813
1814 let report = link_executables(&bin, &[exe], false).await.unwrap();
1815
1816 assert_eq!(report.created.len(), 1);
1817 assert!(bin.join("set_proxy").exists());
1818
1819 fs::remove_dir_all(&src).await.unwrap();
1820 fs::remove_dir_all(&bin).await.unwrap();
1821 }
1822
1823 #[cfg(unix)]
1824 #[tokio::test]
1825 async fn reports_collision_when_two_sources_share_basename() {
1826 let (src, bin) = make_dirs().await;
1827 let sub1 = src.join("a");
1828 let sub2 = src.join("b");
1829 fs::create_dir_all(&sub1).await.unwrap();
1830 fs::create_dir_all(&sub2).await.unwrap();
1831 let exe1 = make_executable(&sub1, "run.sh").await;
1832 let exe2 = make_executable(&sub2, "run.sh").await;
1833
1834 let report = link_executables(&bin, &[exe1, exe2.clone()], false)
1835 .await
1836 .unwrap();
1837
1838 assert_eq!(report.created.len(), 1);
1839 assert_eq!(report.conflicts.len(), 1);
1840 assert_eq!(report.conflicts[0].link_path, bin.join("run"));
1841 assert_eq!(report.conflicts[0].source, exe2);
1842 assert_eq!(report.conflicts[0].kind, LinkConflictKind::DuplicateName);
1843
1844 fs::remove_dir_all(&src).await.unwrap();
1845 fs::remove_dir_all(&bin).await.unwrap();
1846 }
1847
1848 #[cfg(unix)]
1849 #[tokio::test]
1850 async fn creates_symlink_with_explicit_link_name() {
1851 let (src, bin) = make_dirs().await;
1852 let exe = make_executable(&src, "set_proxy.sh").await;
1853 let specs = [LinkSpec {
1854 source: exe.clone(),
1855 link_name: OsString::from("setproxy"),
1856 runtime: LinkRuntime::Native,
1857 bun_dependencies: BunDependencyMode::Disabled,
1858 env: Vec::new(),
1859 render_target: None,
1860 }];
1861
1862 let report = link_executables_with_names(&bin, &specs, false)
1863 .await
1864 .unwrap();
1865
1866 assert_eq!(report.created.len(), 1);
1867 assert!(bin.join("setproxy").exists());
1868 assert!(!bin.join("set_proxy").exists());
1869 assert_eq!(fs::read_link(bin.join("setproxy")).await.unwrap(), exe);
1870
1871 fs::remove_dir_all(&src).await.unwrap();
1872 fs::remove_dir_all(&bin).await.unwrap();
1873 }
1874
1875 #[cfg(unix)]
1876 #[tokio::test]
1877 async fn links_non_executable_shell_script_source() {
1878 let (src, bin) = make_dirs().await;
1879 let script = src.join("set_proxy.sh");
1880 fs::write(&script, b"#!/bin/sh\n").await.unwrap();
1881 let specs = [LinkSpec {
1882 source: script.clone(),
1883 link_name: OsString::from("setproxy"),
1884 runtime: LinkRuntime::Native,
1885 bun_dependencies: BunDependencyMode::Disabled,
1886 env: Vec::new(),
1887 render_target: None,
1888 }];
1889
1890 let report = link_executables_with_names(&bin, &specs, false)
1891 .await
1892 .unwrap();
1893
1894 assert_eq!(report.created.len(), 1);
1895 assert!(bin.join("setproxy").exists());
1896 assert_eq!(fs::read_link(bin.join("setproxy")).await.unwrap(), script);
1897
1898 fs::remove_dir_all(&src).await.unwrap();
1899 fs::remove_dir_all(&bin).await.unwrap();
1900 }
1901
1902 #[cfg(unix)]
1903 #[tokio::test]
1904 async fn skips_non_executable_non_script_source_with_custom_name() {
1905 let (src, bin) = make_dirs().await;
1906 let plain = make_plain(&src, "proxy.txt").await;
1907 let specs = [LinkSpec {
1908 source: plain,
1909 link_name: OsString::from("setproxy"),
1910 runtime: LinkRuntime::Native,
1911 bun_dependencies: BunDependencyMode::Disabled,
1912 env: Vec::new(),
1913 render_target: None,
1914 }];
1915
1916 let report = link_executables_with_names(&bin, &specs, false)
1917 .await
1918 .unwrap();
1919
1920 assert!(report.created.is_empty());
1921 assert!(!bin.join("setproxy").exists());
1922
1923 fs::remove_dir_all(&src).await.unwrap();
1924 fs::remove_dir_all(&bin).await.unwrap();
1925 }
1926
1927 #[cfg(unix)]
1930 #[tokio::test]
1931 async fn unlink_removes_symlink_pointing_into_managed_root() {
1932 let (src, bin) = make_dirs().await;
1933 let exe = make_executable(&src, "run.sh").await;
1934 tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1935
1936 let report = unlink_managed(&bin, &src, false).await.unwrap();
1937
1938 assert_eq!(report.removed.len(), 1);
1939 assert!(!bin.join("run.sh").exists());
1940
1941 fs::remove_dir_all(&src).await.unwrap();
1942 fs::remove_dir_all(&bin).await.unwrap();
1943 }
1944
1945 #[cfg(unix)]
1946 #[tokio::test]
1947 async fn unlink_skips_symlink_outside_managed_root() {
1948 let (src, bin) = make_dirs().await;
1949 let outside = std::env::temp_dir().join(format!("shine-bl-out-{}", uuid::Uuid::new_v4()));
1950 fs::create_dir_all(&outside).await.unwrap();
1951 let exe = make_executable(&outside, "run.sh").await;
1952 tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1953
1954 let report = unlink_managed(&bin, &src, false).await.unwrap();
1955
1956 assert_eq!(report.skipped.len(), 1);
1957 assert!(bin.join("run.sh").is_symlink());
1958
1959 fs::remove_dir_all(&src).await.unwrap();
1960 fs::remove_dir_all(&bin).await.unwrap();
1961 fs::remove_dir_all(&outside).await.unwrap();
1962 }
1963
1964 #[cfg(unix)]
1965 #[tokio::test]
1966 async fn unlink_skips_regular_files_in_bin_dir() {
1967 let (src, bin) = make_dirs().await;
1968 make_plain(&bin, "user_script.sh").await;
1969
1970 let report = unlink_managed(&bin, &src, false).await.unwrap();
1971
1972 assert!(report.removed.is_empty());
1973 assert_eq!(report.skipped.len(), 1);
1974 assert!(bin.join("user_script.sh").exists());
1975
1976 fs::remove_dir_all(&src).await.unwrap();
1977 fs::remove_dir_all(&bin).await.unwrap();
1978 }
1979
1980 #[cfg(unix)]
1981 #[tokio::test]
1982 async fn unlink_dry_run_reports_but_does_not_remove() {
1983 let (src, bin) = make_dirs().await;
1984 let exe = make_executable(&src, "run.sh").await;
1985 tokio::fs::symlink(&exe, bin.join("run.sh")).await.unwrap();
1986
1987 let report = unlink_managed(&bin, &src, true).await.unwrap();
1988
1989 assert_eq!(report.removed.len(), 1);
1990 assert!(bin.join("run.sh").is_symlink(), "dry-run must not remove");
1991
1992 fs::remove_dir_all(&src).await.unwrap();
1993 fs::remove_dir_all(&bin).await.unwrap();
1994 }
1995
1996 #[cfg(unix)]
1997 #[tokio::test]
1998 async fn unlink_is_idempotent_on_empty_bin_dir() {
1999 let (src, bin) = make_dirs().await;
2000
2001 let r1 = unlink_managed(&bin, &src, false).await.unwrap();
2002 let r2 = unlink_managed(&bin, &src, false).await.unwrap();
2003
2004 assert!(r1.removed.is_empty());
2005 assert!(r2.removed.is_empty());
2006
2007 fs::remove_dir_all(&src).await.unwrap();
2008 fs::remove_dir_all(&bin).await.unwrap();
2009 }
2010
2011 #[tokio::test]
2012 async fn unlink_returns_empty_report_when_bin_dir_missing() {
2013 let missing = std::env::temp_dir().join(format!("shine-bl-miss-{}", uuid::Uuid::new_v4()));
2014 let managed = std::env::temp_dir().join(format!("shine-bl-mgd-{}", uuid::Uuid::new_v4()));
2015
2016 let report = unlink_managed(&missing, &managed, false).await.unwrap();
2017
2018 assert!(report.removed.is_empty());
2019 assert!(report.skipped.is_empty());
2020 }
2021
2022 #[test]
2023 fn link_stem_strips_bun_extensions() {
2024 assert_eq!(link_stem(Path::new("tool.ts")), OsString::from("tool"));
2025 assert_eq!(link_stem(Path::new("tool.js")), OsString::from("tool"));
2026 assert_eq!(link_stem(Path::new("tool.mts")), OsString::from("tool"));
2027 assert_eq!(link_stem(Path::new("tool.mjs")), OsString::from("tool"));
2028 }
2029
2030 #[cfg(unix)]
2031 fn bun_spec(source: &Path, name: &str) -> LinkSpec {
2032 bun_spec_with_env(source, name, Vec::new())
2033 }
2034
2035 #[cfg(unix)]
2036 fn locked_bun_spec(source: &Path, name: &str) -> LinkSpec {
2037 LinkSpec {
2038 source: source.to_path_buf(),
2039 link_name: OsString::from(name),
2040 runtime: LinkRuntime::Bun,
2041 bun_dependencies: BunDependencyMode::Locked,
2042 env: Vec::new(),
2043 render_target: None,
2044 }
2045 }
2046
2047 #[cfg(unix)]
2048 fn bun_spec_with_env(source: &Path, name: &str, env: Vec<String>) -> LinkSpec {
2049 LinkSpec {
2050 source: source.to_path_buf(),
2051 link_name: OsString::from(name),
2052 runtime: LinkRuntime::Bun,
2053 bun_dependencies: BunDependencyMode::Disabled,
2054 env,
2055 render_target: None,
2056 }
2057 }
2058
2059 #[cfg(unix)]
2060 #[tokio::test]
2061 async fn creates_bun_launcher_as_marked_executable_regular_file() {
2062 use std::os::unix::fs::PermissionsExt;
2063 let (src, bin) = make_dirs().await;
2064 let script = src.join("tool.ts");
2065 fs::write(&script, b"console.log('hi')\n").await.unwrap();
2066
2067 let report = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2068 .await
2069 .unwrap();
2070
2071 assert_eq!(report.created.len(), 1);
2072 let launcher = bin.join("tool");
2073 assert!(launcher.exists());
2074 assert!(
2075 !launcher.is_symlink(),
2076 "bun launcher must be a regular file, not a symlink"
2077 );
2078 let content = fs::read_to_string(&launcher).await.unwrap();
2079 assert!(content.contains("# shine-managed"));
2080 assert!(content.contains(&format!("# shine-target: {}", script.display())));
2081 assert!(content.contains("command -v bun"));
2082 assert!(content.contains("exit 127"));
2083 assert!(content.contains(&format!(
2084 "exec bun --no-install '{}' \"$@\"",
2085 script.display()
2086 )));
2087 let mode = fs::metadata(&launcher).await.unwrap().permissions().mode();
2088 assert!(mode & 0o111 != 0, "launcher must be executable");
2089
2090 fs::remove_dir_all(&src).await.unwrap();
2091 fs::remove_dir_all(&bin).await.unwrap();
2092 }
2093
2094 #[cfg(unix)]
2095 #[tokio::test]
2096 async fn bun_launcher_is_idempotent_and_refreshes_when_stale() {
2097 let (src, bin) = make_dirs().await;
2098 let script = src.join("tool.ts");
2099 fs::write(&script, b"console.log('hi')\n").await.unwrap();
2100
2101 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2102 .await
2103 .unwrap();
2104 let again = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2105 .await
2106 .unwrap();
2107 assert_eq!(
2108 again.skipped.len(),
2109 1,
2110 "identical launcher should be skipped"
2111 );
2112 assert!(again.created.is_empty());
2113 assert!(again.overwritten.is_empty());
2114
2115 let launcher = bin.join("tool");
2117 fs::write(
2118 &launcher,
2119 format!(
2120 "#!/usr/bin/env bash\n# shine-managed\n# shine-target: {}\necho stale\n",
2121 script.display()
2122 ),
2123 )
2124 .await
2125 .unwrap();
2126 let refreshed = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2127 .await
2128 .unwrap();
2129 assert_eq!(
2130 refreshed.overwritten.len(),
2131 1,
2132 "stale launcher should refresh"
2133 );
2134 assert!(
2135 fs::read_to_string(&launcher)
2136 .await
2137 .unwrap()
2138 .contains("exec bun")
2139 );
2140
2141 fs::remove_dir_all(&src).await.unwrap();
2142 fs::remove_dir_all(&bin).await.unwrap();
2143 }
2144
2145 #[cfg(unix)]
2146 #[tokio::test]
2147 async fn bun_launcher_conflicts_with_user_file_unless_forced() {
2148 let (src, bin) = make_dirs().await;
2149 let script = src.join("tool.ts");
2150 fs::write(&script, b"console.log('hi')\n").await.unwrap();
2151 make_plain(&bin, "tool").await;
2153
2154 let report = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2155 .await
2156 .unwrap();
2157 assert_eq!(report.conflicts.len(), 1);
2158 assert_eq!(report.conflicts[0].kind, LinkConflictKind::ExistingEntry);
2159 assert_eq!(fs::read_to_string(bin.join("tool")).await.unwrap(), "data");
2160
2161 let forced = link_executables_with_names(&bin, &[bun_spec(&script, "tool")], true)
2162 .await
2163 .unwrap();
2164 assert_eq!(forced.overwritten.len(), 1);
2165 assert!(
2166 fs::read_to_string(bin.join("tool"))
2167 .await
2168 .unwrap()
2169 .contains("exec bun")
2170 );
2171
2172 fs::remove_dir_all(&src).await.unwrap();
2173 fs::remove_dir_all(&bin).await.unwrap();
2174 }
2175
2176 #[cfg(unix)]
2177 #[tokio::test]
2178 async fn unlink_removes_managed_bun_launcher_but_skips_user_file() {
2179 let (src, bin) = make_dirs().await;
2180 let script = src.join("tool.ts");
2181 fs::write(&script, b"console.log('hi')\n").await.unwrap();
2182 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2183 .await
2184 .unwrap();
2185 make_plain(&bin, "user_tool").await;
2187
2188 let report = unlink_managed(&bin, &src, false).await.unwrap();
2189
2190 assert!(report.removed.iter().any(|p| p.ends_with("tool")));
2191 assert!(
2192 !bin.join("tool").exists(),
2193 "managed launcher should be removed"
2194 );
2195 assert!(
2196 bin.join("user_tool").exists(),
2197 "user file must be preserved"
2198 );
2199 assert!(report.skipped.iter().any(|p| p.ends_with("user_tool")));
2200
2201 fs::remove_dir_all(&src).await.unwrap();
2202 fs::remove_dir_all(&bin).await.unwrap();
2203 }
2204
2205 #[cfg(unix)]
2206 #[tokio::test]
2207 async fn bun_launcher_without_env_has_no_shine_dependency() {
2208 let (src, bin) = make_dirs().await;
2209 let script = src.join("tool.ts");
2210 fs::write(&script, b"console.log('hi')\n").await.unwrap();
2211
2212 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2213 .await
2214 .unwrap();
2215
2216 let content = fs::read_to_string(bin.join("tool")).await.unwrap();
2217 assert!(
2218 content.contains(&format!(
2219 "exec bun --no-install '{}' \"$@\"",
2220 script.display()
2221 )),
2222 "no-env launcher must run bun directly: {content}"
2223 );
2224 assert!(
2225 !content.contains("shine env run"),
2226 "no-env launcher must not depend on shine: {content}"
2227 );
2228
2229 fs::remove_dir_all(&src).await.unwrap();
2230 fs::remove_dir_all(&bin).await.unwrap();
2231 }
2232
2233 #[cfg(unix)]
2234 #[tokio::test]
2235 async fn bun_launcher_with_env_wraps_shine_env_run() {
2236 let (src, bin) = make_dirs().await;
2237 let script = src.join("tool.ts");
2238 fs::write(&script, b"console.log('hi')\n").await.unwrap();
2239
2240 let env = vec!["API_URL".to_string(), "SERVICE_TOKEN=API_TOKEN".to_string()];
2241 link_executables_with_names(&bin, &[bun_spec_with_env(&script, "tool", env)], false)
2242 .await
2243 .unwrap();
2244
2245 let content = fs::read_to_string(bin.join("tool")).await.unwrap();
2246 assert!(content.contains("command -v bun"));
2248 assert!(content.contains("command -v shine"));
2249 assert_eq!(content.matches("exit 127").count(), 2);
2250 assert!(content.contains(&format!(
2252 "exec shine env run --no-workspace --with 'API_URL' --with 'SERVICE_TOKEN=API_TOKEN' -- bun --no-install '{}' \"$@\"",
2253 script.display()
2254 )));
2255
2256 fs::remove_dir_all(&src).await.unwrap();
2257 fs::remove_dir_all(&bin).await.unwrap();
2258 }
2259
2260 #[cfg(unix)]
2261 #[tokio::test]
2262 async fn locked_bun_launcher_uses_fallback_and_refreshes_disabled_launcher() {
2263 let (src, bin) = make_dirs().await;
2264 let script = src.join("tool.ts");
2265 fs::write(&script, b"import 'zod'\n").await.unwrap();
2266
2267 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2268 .await
2269 .unwrap();
2270 let refreshed =
2271 link_executables_with_names(&bin, &[locked_bun_spec(&script, "tool")], false)
2272 .await
2273 .unwrap();
2274 assert_eq!(refreshed.overwritten.len(), 1);
2275 let content = fs::read_to_string(bin.join("tool")).await.unwrap();
2276 assert!(content.contains("exec bun --install=fallback"));
2277
2278 fs::remove_dir_all(&src).await.unwrap();
2279 fs::remove_dir_all(&bin).await.unwrap();
2280 }
2281
2282 #[cfg(unix)]
2283 #[tokio::test]
2284 async fn bun_launcher_refreshes_when_env_declaration_changes() {
2285 let (src, bin) = make_dirs().await;
2286 let script = src.join("tool.ts");
2287 fs::write(&script, b"console.log('hi')\n").await.unwrap();
2288
2289 link_executables_with_names(&bin, &[bun_spec(&script, "tool")], false)
2291 .await
2292 .unwrap();
2293 let changed = link_executables_with_names(
2294 &bin,
2295 &[bun_spec_with_env(
2296 &script,
2297 "tool",
2298 vec!["API_URL".to_string()],
2299 )],
2300 false,
2301 )
2302 .await
2303 .unwrap();
2304 assert_eq!(
2305 changed.overwritten.len(),
2306 1,
2307 "adding an env declaration must refresh the launcher without --force"
2308 );
2309
2310 let again = link_executables_with_names(
2312 &bin,
2313 &[bun_spec_with_env(
2314 &script,
2315 "tool",
2316 vec!["API_URL".to_string()],
2317 )],
2318 false,
2319 )
2320 .await
2321 .unwrap();
2322 assert_eq!(again.skipped.len(), 1);
2323 assert!(again.overwritten.is_empty());
2324
2325 fs::remove_dir_all(&src).await.unwrap();
2326 fs::remove_dir_all(&bin).await.unwrap();
2327 }
2328
2329 #[cfg(not(unix))]
2330 #[test]
2331 fn shell_shims_pass_bash_compatible_paths_on_windows() {
2332 let source = PathBuf::from(r"C:\Users\me\.shine\rendered\shell\utils\copyfile.sh");
2333
2334 let ps1 = powershell_shim_content(
2335 &source,
2336 LinkRuntime::Native,
2337 "copyfile",
2338 BunDependencyMode::Disabled,
2339 &[],
2340 None,
2341 );
2342 let cmd = cmd_shim_content(
2343 &source,
2344 LinkRuntime::Native,
2345 "copyfile",
2346 BunDependencyMode::Disabled,
2347 &[],
2348 None,
2349 );
2350
2351 assert!(ps1.contains("C:/Users/me/.shine/rendered/shell/utils/copyfile.sh"));
2352 assert!(cmd.contains("C:/Users/me/.shine/rendered/shell/utils/copyfile.sh"));
2353 assert!(!ps1.contains(r"& bash 'C:\Users\me"));
2354 }
2355}