Skip to main content

cli/
bin_links.rs

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