Skip to main content

ashlar_native/
cli_tool.rs

1//! Put an app's bundled command-line tool on the user's `PATH`, the way editors offer an
2//! "Install command" menu item.
3//!
4//! The tool ships inside the app (on macOS `<App>.app/Contents/Helpers/<name>`). Installing makes
5//! a symbolic link `<bin dir>/<name>` → that file, so updating the app updates the tool:
6//!
7//! 1. The link goes in `/usr/local/bin` (created when missing).
8//! 2. When that folder isn't writable, the OS asks for an administrator password (macOS: the
9//!    standard `do shell script … with administrator privileges` prompt).
10//! 3. When that is declined (or no prompt is available), the link goes in `~/.local/bin` and the
11//!    result carries the line to add to the shell profile.
12//!
13//! A file or link that isn't this app's tool is never replaced unless the caller passes
14//! `replace` (and the result names what was replaced). A link left behind after the app moved
15//! (it points at a missing `…/Contents/Helpers/<name>`) is repaired without asking. A folder is
16//! never replaced.
17//!
18//! [`ENV_BIN_DIR`] and [`ENV_FALLBACK_DIR`] point both folders elsewhere for tests; while
19//! either is set the administrator prompt is never shown.
20//!
21//! [`commands`] are the matching command specs (`app.installCommandLineTool`,
22//! `app.uninstallCommandLineTool`, `app.commandLineToolStatus`) for any host that implements
23//! [`CliToolHost`]; [`run`] runs the same three for apps with their own command router.
24
25use std::io;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29use ashlar_command::{CommandError, CommandSpec, Host, Param, Params};
30use serde::Serialize;
31use serde_json::{Value, json};
32
33/// Where the link goes by default: on every macOS `PATH` (`/etc/paths`).
34pub const SYSTEM_BIN_DIR: &str = "/usr/local/bin";
35/// Install into this folder instead of [`SYSTEM_BIN_DIR`] (tests); disables the admin prompt.
36pub const ENV_BIN_DIR: &str = "ASHLAR_CLI_BIN_DIR";
37/// Fall back to this folder instead of `~/.local/bin` (tests); disables the admin prompt.
38pub const ENV_FALLBACK_DIR: &str = "ASHLAR_CLI_FALLBACK_DIR";
39
40/// Command ids registered by [`commands`].
41pub const INSTALL: &str = "app.installCommandLineTool";
42pub const UNINSTALL: &str = "app.uninstallCommandLineTool";
43pub const STATUS: &str = "app.commandLineToolStatus";
44
45/// Why installing or removing the tool did not happen.
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
47pub enum CliToolError {
48    /// The tool is not where the app expects it (a development build, a damaged copy).
49    #[error("the command-line tool isn't in this copy of the app (expected at {0})")]
50    HelperMissing(PathBuf),
51    /// Something that isn't this app's tool is in the way; nothing was changed.
52    #[error("{} already exists and {}; nothing was changed{}", link.display(), what, if *replaceable { " (pass `replace: true` to replace it)" } else { "" })]
53    Conflict { link: PathBuf, what: String, replaceable: bool },
54    /// The name or a path can't be used.
55    #[error("invalid: {0}")]
56    Invalid(String),
57    /// Not available on this platform.
58    #[error("not supported on this platform")]
59    Unsupported,
60    /// The OS refused; one line, safe to show.
61    #[error("{0}")]
62    Os(String),
63}
64
65impl CliToolError {
66    pub fn is_conflict(&self) -> bool {
67        matches!(self, Self::Conflict { .. })
68    }
69}
70
71impl From<CliToolError> for CommandError {
72    fn from(e: CliToolError) -> Self {
73        match e {
74            CliToolError::Invalid(_) => CommandError::bad_params("", e.to_string()),
75            _ => CommandError::failed("", e.to_string()),
76        }
77    }
78}
79
80type Result<T> = std::result::Result<T, CliToolError>;
81
82/// What one location holds.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84#[serde(tag = "state", rename_all = "camelCase")]
85pub enum LinkState {
86    /// Nothing there.
87    Missing,
88    /// A link to this app's tool.
89    Ours,
90    /// A link to a missing `…/Contents/Helpers/<name>`: the app moved or was deleted. Repaired
91    /// by installing.
92    Broken { target: PathBuf },
93    /// A link to the same tool in another copy of the app.
94    OtherCopy { target: PathBuf },
95    /// A link to something else, or a broken link that was never ours.
96    ForeignLink { target: PathBuf },
97    /// A regular file.
98    File,
99    /// A folder (never replaced).
100    Folder,
101}
102
103impl LinkState {
104    fn is_ours_or_stale(&self) -> bool {
105        matches!(self, Self::Ours | Self::Broken { .. })
106    }
107}
108
109/// Where the tool is installed, if anywhere.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111#[serde(rename_all = "camelCase")]
112pub struct Status {
113    /// The location that was looked at: the system folder, or the fallback when the system one
114    /// is empty and the fallback isn't.
115    pub link: PathBuf,
116    #[serde(flatten)]
117    pub state: LinkState,
118    /// `link` is in the fallback folder.
119    pub fallback: bool,
120    pub helper: PathBuf,
121}
122
123impl Status {
124    pub fn is_installed(&self) -> bool {
125        self.state == LinkState::Ours
126    }
127    /// The link is stale (the app moved): installing repairs it.
128    pub fn needs_repair(&self) -> bool {
129        matches!(self.state, LinkState::Broken { .. })
130    }
131    /// One sentence for a status line.
132    pub fn message(&self, name: &str) -> String {
133        let link = self.link.display();
134        match &self.state {
135            LinkState::Missing => format!("`{name}` isn't installed."),
136            LinkState::Ours => format!("`{name}` is installed at {link}."),
137            LinkState::Broken { target } => format!("{link} points to {}, which no longer exists. Install again to repair it.", target.display()),
138            LinkState::OtherCopy { target } => format!("{link} points to another copy of the app ({}).", target.display()),
139            LinkState::ForeignLink { target } => format!("{link} is a link to {}, which isn't this app's tool.", target.display()),
140            LinkState::File => format!("{link} is a file that isn't this app's tool."),
141            LinkState::Folder => format!("{link} is a folder."),
142        }
143    }
144}
145
146/// What [`CliTool::install`] did.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct Installed {
150    pub link: PathBuf,
151    /// Installed in the fallback folder (the admin prompt was declined or unavailable).
152    pub fallback: bool,
153    /// Already installed; nothing changed.
154    pub unchanged: bool,
155    /// A stale link was repaired.
156    pub repaired: bool,
157    /// What was replaced because the caller passed `replace` (a file, or a link's target).
158    pub replaced: Option<String>,
159    /// The administrator prompt was used.
160    pub elevated: bool,
161    /// For a fallback install: the line to add to the shell profile.
162    pub path_hint: Option<String>,
163}
164
165impl Installed {
166    pub fn message(&self, name: &str) -> String {
167        let link = self.link.display();
168        let mut text = if self.unchanged {
169            format!("`{name}` is already installed at {link}.")
170        } else if self.repaired {
171            format!("Repaired `{name}` at {link}.")
172        } else {
173            format!("Installed `{name}` at {link}.")
174        };
175        if let Some(r) = &self.replaced {
176            text.push_str(&format!(" It replaced {r}."));
177        }
178        if let Some(hint) = &self.path_hint {
179            text.push_str(&format!(" If your shell can't find `{name}`, add this line to ~/.zshrc: {hint}"));
180        }
181        text
182    }
183}
184
185/// What [`CliTool::uninstall`] did.
186#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
187#[serde(rename_all = "camelCase")]
188pub struct Removed {
189    pub removed: Vec<PathBuf>,
190    /// Left in place, with the reason (not ours, or the admin prompt was declined).
191    pub kept: Vec<(PathBuf, String)>,
192}
193
194impl Removed {
195    pub fn message(&self, name: &str) -> String {
196        let mut parts = vec![];
197        if self.removed.is_empty() && self.kept.is_empty() {
198            parts.push(format!("`{name}` wasn't installed."));
199        }
200        for p in &self.removed {
201            parts.push(format!("Removed {}.", p.display()));
202        }
203        for (p, why) in &self.kept {
204            parts.push(format!("Left {} in place: {why}.", p.display()));
205        }
206        parts.join(" ")
207    }
208}
209
210/// The answer to an administrator prompt.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum Elevated {
213    /// The script ran as administrator and exited successfully.
214    Done,
215    /// The user cancelled, or no prompt is available.
216    Declined,
217    /// The script ran and failed, or the prompt couldn't be shown; one line.
218    Failed(String),
219}
220
221/// Runs a `/bin/sh` script with administrator rights. The script is built by this module from
222/// fixed commands and single-quoted paths.
223pub type Elevator = Arc<dyn Fn(&str) -> Elevated + Send + Sync>;
224
225/// How to get administrator rights when the system folder isn't writable.
226#[derive(Clone)]
227pub enum Elevation {
228    /// The OS's own password prompt (macOS); elsewhere the same as `Never`.
229    Prompt,
230    /// Never ask: fall back straight away.
231    Never,
232    /// A caller-supplied runner (tests, or an app with its own privileged helper).
233    Custom(Elevator),
234}
235
236impl std::fmt::Debug for Elevation {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        f.write_str(match self {
239            Self::Prompt => "Prompt",
240            Self::Never => "Never",
241            Self::Custom(_) => "Custom",
242        })
243    }
244}
245
246/// One app's bundled command-line tool and where to install it.
247#[derive(Debug, Clone)]
248pub struct CliTool {
249    name: String,
250    helper: PathBuf,
251    bin_dir: PathBuf,
252    fallback_dir: Option<PathBuf>,
253    elevation: Elevation,
254    prompt: String,
255}
256
257/// Longest tool name accepted.
258const MAX_NAME: usize = 64;
259
260/// A tool name: lowercase ASCII letters, digits, `-`, `_`, `.`; not starting with `-` or `.`.
261pub fn check_name(name: &str) -> Result<()> {
262    let ok = !name.is_empty()
263        && name.len() <= MAX_NAME
264        && !name.starts_with(['-', '.'])
265        && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
266    if ok {
267        Ok(())
268    } else {
269        Err(CliToolError::Invalid(format!("`{}` is not a usable command name", name.chars().take(MAX_NAME).collect::<String>())))
270    }
271}
272
273/// The bundled tool's path for the running app: `<exe dir>/../Helpers/<name>`, which on macOS is
274/// `<App>.app/Contents/Helpers/<name>`.
275pub fn bundled_helper(name: &str) -> Result<PathBuf> {
276    check_name(name)?;
277    let exe = std::env::current_exe().map_err(|e| CliToolError::Os(crate::os_text(e)))?;
278    let contents = exe.parent().and_then(Path::parent).ok_or_else(|| CliToolError::Os("the app's own path has no parent folder".into()))?;
279    Ok(contents.join("Helpers").join(name))
280}
281
282impl CliTool {
283    /// The tool `name` at `helper`, installed into [`SYSTEM_BIN_DIR`] with `~/.local/bin` as the
284    /// fallback and the OS's admin prompt; [`ENV_BIN_DIR`] / [`ENV_FALLBACK_DIR`] override the
285    /// folders (and turn the prompt off).
286    pub fn new(name: &str, helper: impl Into<PathBuf>) -> Result<Self> {
287        check_name(name)?;
288        let env_dir = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from);
289        let (bin, fallback) = (env_dir(ENV_BIN_DIR), env_dir(ENV_FALLBACK_DIR));
290        let overridden = bin.is_some() || fallback.is_some();
291        let home_fallback = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local/bin"));
292        Ok(Self {
293            name: name.to_string(),
294            helper: helper.into(),
295            bin_dir: bin.unwrap_or_else(|| PathBuf::from(SYSTEM_BIN_DIR)),
296            fallback_dir: fallback.or(home_fallback),
297            elevation: if overridden { Elevation::Never } else { Elevation::Prompt },
298            prompt: format!("Install the `{name}` command."),
299        })
300    }
301
302    /// [`CliTool::new`] with the running app's [`bundled_helper`].
303    pub fn for_current_app(name: &str) -> Result<Self> {
304        Self::new(name, bundled_helper(name)?)
305    }
306
307    /// Install into `dir` instead of the system folder.
308    pub fn bin_dir(mut self, dir: impl Into<PathBuf>) -> Self {
309        self.bin_dir = dir.into();
310        self
311    }
312
313    /// Fall back to `dir` (`None`: no fallback, a declined prompt is an error).
314    pub fn fallback_dir(mut self, dir: Option<PathBuf>) -> Self {
315        self.fallback_dir = dir;
316        self
317    }
318
319    pub fn elevation(mut self, elevation: Elevation) -> Self {
320        self.elevation = elevation;
321        self
322    }
323
324    /// The text shown in the administrator prompt.
325    pub fn prompt(mut self, text: impl Into<String>) -> Self {
326        self.prompt = text.into();
327        self
328    }
329
330    pub fn name(&self) -> &str {
331        &self.name
332    }
333
334    pub fn helper(&self) -> &Path {
335        &self.helper
336    }
337
338    /// The link in the system folder.
339    pub fn link(&self) -> PathBuf {
340        self.bin_dir.join(&self.name)
341    }
342
343    fn fallback_link(&self) -> Option<PathBuf> {
344        self.fallback_dir.as_ref().map(|d| d.join(&self.name))
345    }
346
347    /// The tool exists in the app.
348    pub fn helper_present(&self) -> bool {
349        self.helper.is_file()
350    }
351
352    /// What `link` holds.
353    pub fn state_of(&self, link: &Path) -> LinkState {
354        let Ok(meta) = std::fs::symlink_metadata(link) else { return LinkState::Missing };
355        if meta.is_dir() {
356            return LinkState::Folder;
357        }
358        if !meta.file_type().is_symlink() {
359            return LinkState::File;
360        }
361        let Ok(raw) = std::fs::read_link(link) else { return LinkState::File };
362        let target = if raw.is_absolute() { raw } else { link.parent().map_or(raw.clone(), |p| p.join(&raw)) };
363        if same_file(&target, &self.helper) {
364            return LinkState::Ours;
365        }
366        let ends_like_ours = target.file_name().is_some_and(|n| n == self.name.as_str())
367            && target.parent().and_then(Path::file_name).is_some_and(|n| n == "Helpers")
368            && target.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|n| n == "Contents");
369        match (ends_like_ours, target.exists()) {
370            (true, false) => LinkState::Broken { target },
371            (true, true) => LinkState::OtherCopy { target },
372            (false, _) => LinkState::ForeignLink { target },
373        }
374    }
375
376    /// Where the tool is installed: the system link, or the fallback one when the system folder
377    /// holds nothing and the fallback does.
378    pub fn status(&self) -> Status {
379        let link = self.link();
380        let state = self.state_of(&link);
381        if state == LinkState::Missing
382            && let Some(fb) = self.fallback_link()
383        {
384            let fb_state = self.state_of(&fb);
385            if fb_state != LinkState::Missing {
386                return Status { link: fb, state: fb_state, fallback: true, helper: self.helper.clone() };
387            }
388        }
389        Status { link, state, fallback: false, helper: self.helper.clone() }
390    }
391
392    /// Link the tool into the system folder (asking for admin rights when needed), or into the
393    /// fallback when that is declined. Repairs a stale link. `replace` allows replacing a file or
394    /// a link that isn't ours (never a folder).
395    pub fn install(&self, replace: bool) -> Result<Installed> {
396        if !self.helper_present() {
397            return Err(CliToolError::HelperMissing(self.helper.clone()));
398        }
399        let link = self.link();
400        let state = self.state_of(&link);
401        let done = |link: PathBuf, fallback: bool, elevated: bool, state: &LinkState| Installed {
402            link,
403            fallback,
404            unchanged: false,
405            repaired: matches!(state, LinkState::Broken { .. }),
406            replaced: replaced_text(state),
407            elevated,
408            path_hint: None,
409        };
410        if state == LinkState::Ours {
411            let mut out = done(link, false, false, &state);
412            out.unchanged = true;
413            return Ok(out);
414        }
415        self.check_replaceable(&link, &state, replace)?;
416        match link_atomically(&self.helper, &link) {
417            Ok(()) => return Ok(done(link, false, false, &state)),
418            Err(e) if e.kind() == io::ErrorKind::Unsupported => return Err(CliToolError::Unsupported),
419            Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(CliToolError::Os(crate::os_text(e))),
420            Err(_) => {}
421        }
422        let script = format!("/bin/mkdir -p {} && /bin/ln -sfn {} {}", sh_quote(&self.bin_dir)?, sh_quote(&self.helper)?, sh_quote(&link)?);
423        match self.elevate(&script) {
424            Elevated::Done if self.state_of(&link) == LinkState::Ours => return Ok(done(link, false, true, &state)),
425            Elevated::Done => return Err(CliToolError::Os(format!("{} wasn't created as administrator", link.display()))),
426            Elevated::Failed(why) => return Err(CliToolError::Os(why)),
427            Elevated::Declined => {}
428        }
429        let Some(fb) = self.fallback_link() else {
430            return Err(CliToolError::Os(format!("{} isn't writable and administrator rights weren't given", self.bin_dir.display())));
431        };
432        let fb_state = self.state_of(&fb);
433        let hint = Some(path_hint(fb.parent().unwrap_or(&fb)));
434        if fb_state == LinkState::Ours {
435            let mut out = done(fb, true, false, &fb_state);
436            out.unchanged = true;
437            out.path_hint = hint;
438            return Ok(out);
439        }
440        self.check_replaceable(&fb, &fb_state, replace)?;
441        link_atomically(&self.helper, &fb).map_err(|e| CliToolError::Os(crate::os_text(e)))?;
442        let mut out = done(fb, true, false, &fb_state);
443        out.path_hint = hint;
444        Ok(out)
445    }
446
447    /// Install only when the link is stale (the app moved); otherwise report the status.
448    pub fn repair(&self) -> Result<Option<Installed>> {
449        if self.status().needs_repair() { self.install(false).map(Some) } else { Ok(None) }
450    }
451
452    /// Remove this app's links (and stale ones) from both folders. Anything else stays.
453    pub fn uninstall(&self) -> Result<Removed> {
454        let mut out = Removed::default();
455        let links = std::iter::once(self.link()).chain(self.fallback_link());
456        for link in links {
457            let state = self.state_of(&link);
458            if state == LinkState::Missing {
459                continue;
460            }
461            if !state.is_ours_or_stale() {
462                let why = match &state {
463                    LinkState::OtherCopy { .. } => "it belongs to another copy of the app",
464                    _ => "it isn't this app's tool",
465                };
466                out.kept.push((link, why.into()));
467                continue;
468            }
469            match std::fs::remove_file(&link) {
470                Ok(()) => out.removed.push(link),
471                Err(e) if e.kind() == io::ErrorKind::NotFound => {}
472                Err(e) if e.kind() == io::ErrorKind::PermissionDenied => match self.elevate(&format!("/bin/rm -f {}", sh_quote(&link)?)) {
473                    Elevated::Done if self.state_of(&link) == LinkState::Missing => out.removed.push(link),
474                    Elevated::Done => out.kept.push((link, "it couldn't be removed as administrator".into())),
475                    Elevated::Declined => out.kept.push((link, "removing it needs administrator rights".into())),
476                    Elevated::Failed(why) => out.kept.push((link, why)),
477                },
478                Err(e) => out.kept.push((link, crate::os_text(e))),
479            }
480        }
481        Ok(out)
482    }
483
484    fn check_replaceable(&self, link: &Path, state: &LinkState, replace: bool) -> Result<()> {
485        let what = match state {
486            LinkState::Missing | LinkState::Ours | LinkState::Broken { .. } => return Ok(()),
487            LinkState::Folder => return Err(CliToolError::Conflict { link: link.into(), what: "is a folder".into(), replaceable: false }),
488            _ if replace => return Ok(()),
489            LinkState::OtherCopy { target } => format!("points to another copy of the app ({})", target.display()),
490            LinkState::ForeignLink { target } => format!("is a link to {}", target.display()),
491            LinkState::File => "is a file that isn't this app's tool".into(),
492        };
493        Err(CliToolError::Conflict { link: link.into(), what, replaceable: true })
494    }
495
496    fn elevate(&self, script: &str) -> Elevated {
497        match &self.elevation {
498            Elevation::Never => Elevated::Declined,
499            Elevation::Custom(f) => f(script),
500            Elevation::Prompt => crate::modal::around(|| admin_prompt(script, &self.prompt)),
501        }
502    }
503}
504
505fn replaced_text(state: &LinkState) -> Option<String> {
506    match state {
507        LinkState::OtherCopy { target } | LinkState::ForeignLink { target } => Some(format!("a link to {}", target.display())),
508        LinkState::File => Some("a file".into()),
509        _ => None,
510    }
511}
512
513/// Both paths name the same file (after resolving links), or are equal when neither resolves.
514fn same_file(a: &Path, b: &Path) -> bool {
515    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
516        (Ok(x), Ok(y)) => x == y,
517        _ => a == b,
518    }
519}
520
521/// The shell line that puts `dir` on `PATH`, using `$HOME` when `dir` is inside it.
522fn path_hint(dir: &Path) -> String {
523    let home = std::env::var_os("HOME").map(PathBuf::from);
524    let shown = match home.as_deref().and_then(|h| dir.strip_prefix(h).ok()) {
525        Some(rest) => format!("$HOME/{}", rest.display()),
526        None => dir.display().to_string(),
527    };
528    format!("export PATH=\"{}:$PATH\"", shown.replace(['"', '\\', '`'], ""))
529}
530
531/// `p` as one single-quoted `/bin/sh` word. Refuses non-UTF-8 and control characters.
532pub fn sh_quote(p: &Path) -> Result<String> {
533    let s = p.to_str().ok_or_else(|| CliToolError::Invalid(format!("{} is not valid UTF-8", p.display())))?;
534    if s.is_empty() || s.chars().any(char::is_control) {
535        return Err(CliToolError::Invalid(format!("{} can't be passed to the shell", p.display())));
536    }
537    Ok(format!("'{}'", s.replace('\'', r"'\''")))
538}
539
540/// Create `dir`, then point `link` at `target` by renaming a fresh link over it, so the name
541/// never disappears and a crash leaves either the old entry or the new one.
542#[cfg(unix)]
543fn link_atomically(target: &Path, link: &Path) -> io::Result<()> {
544    let dir = link.parent().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the link has no folder"))?;
545    std::fs::create_dir_all(dir)?;
546    let name = link.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
547    let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
548    let _ = std::fs::remove_file(&tmp);
549    std::os::unix::fs::symlink(target, &tmp)?;
550    std::fs::rename(&tmp, link).inspect_err(|_| {
551        let _ = std::fs::remove_file(&tmp);
552    })
553}
554
555#[cfg(not(unix))]
556fn link_atomically(_: &Path, _: &Path) -> io::Result<()> {
557    Err(io::Error::new(io::ErrorKind::Unsupported, "command-line tool links need a Unix system"))
558}
559
560/// The macOS administrator prompt. The script and the prompt text travel as `osascript`
561/// arguments (`item n of argv`), so neither is ever spliced into AppleScript source.
562#[cfg(target_os = "macos")]
563fn admin_prompt(script: &str, prompt: &str) -> Elevated {
564    let out = std::process::Command::new("/usr/bin/osascript")
565        .args([
566            "-e",
567            "on run argv",
568            "-e",
569            "do shell script (item 1 of argv) with prompt (item 2 of argv) with administrator privileges",
570            "-e",
571            "end run",
572            script,
573            prompt,
574        ])
575        .output();
576    match out {
577        Ok(o) if o.status.success() => Elevated::Done,
578        Ok(o) => {
579            let err = String::from_utf8_lossy(&o.stderr);
580            if err.contains("-128") { Elevated::Declined } else { Elevated::Failed(crate::os_text(err)) }
581        }
582        Err(e) => Elevated::Failed(crate::os_text(e)),
583    }
584}
585
586#[cfg(not(target_os = "macos"))]
587fn admin_prompt(_: &str, _: &str) -> Elevated {
588    Elevated::Declined
589}
590
591// ---------------------------------------------------------------------------------------------
592// Commands
593// ---------------------------------------------------------------------------------------------
594
595/// A command host that can hand out its app's [`CliTool`] (`None` in a development build with no
596/// bundled tool: the commands are then disabled with a reason).
597pub trait CliToolHost: Host<Error: From<CommandError>> {
598    fn cli_tool(&self) -> Option<&CliTool>;
599}
600
601const INSTALL_PARAMS: &[Param] = &[Param::bool("replace").about("replace a file or link that isn't this app's tool")];
602
603/// `app.installCommandLineTool` and `app.uninstallCommandLineTool` in `menu` (macOS: the app
604/// menu, `&["<App name>"]`), and the menu-less query `app.commandLineToolStatus`. Each returns
605/// the outcome as JSON with a `message` for a toast.
606pub fn commands<S: CliToolHost>(menu: &'static [&'static str]) -> Vec<CommandSpec<S>> {
607    vec![
608        CommandSpec::new(INSTALL, "Install Command Line Tool…", run_install::<S>)
609            .menu(menu)
610            .params(Params::Static(INSTALL_PARAMS))
611            .enabled(can_install::<S>)
612            .query(),
613        CommandSpec::new(UNINSTALL, "Uninstall Command Line Tool", run_uninstall::<S>).menu(menu).enabled(has_tool::<S>).query(),
614        CommandSpec::new(STATUS, "Command Line Tool Status", run_status::<S>).enabled(has_tool::<S>).query(),
615    ]
616}
617
618fn tool<S: CliToolHost>(s: &S) -> std::result::Result<&CliTool, S::Error> {
619    s.cli_tool().ok_or_else(|| CommandError::failed("", "this build of the app has no command-line tool").into())
620}
621
622fn has_tool<S: CliToolHost>(s: &S) -> std::result::Result<(), String> {
623    match s.cli_tool() {
624        Some(_) => Ok(()),
625        None => Err("this build of the app has no command-line tool".into()),
626    }
627}
628
629fn can_install<S: CliToolHost>(s: &S) -> std::result::Result<(), String> {
630    has_tool(s)?;
631    match s.cli_tool() {
632        Some(t) if t.helper_present() => Ok(()),
633        _ => Err("the command-line tool is missing from this copy of the app".into()),
634    }
635}
636
637fn to_value<T: Serialize>(v: &T) -> Value {
638    serde_json::to_value(v).unwrap_or(Value::Null)
639}
640
641fn with_message(mut v: Value, message: String) -> Value {
642    if let Some(m) = v.as_object_mut() {
643        m.insert("message".into(), Value::String(message));
644    }
645    v
646}
647
648/// Run [`INSTALL`] (param `replace`), [`UNINSTALL`] or [`STATUS`] on `tool`, for apps that route
649/// commands themselves instead of through a [`ashlar_command::Registry`]. The result is the
650/// outcome as JSON with a `message` for a toast; `None` when `id` is none of the three.
651pub fn run(tool: &CliTool, id: &str, params: &Value) -> Option<Result<Value>> {
652    Some(match id {
653        INSTALL => {
654            let replace = params.get("replace").and_then(Value::as_bool).unwrap_or(false);
655            tool.install(replace).map(|out| with_message(to_value(&out), out.message(tool.name())))
656        }
657        UNINSTALL => tool.uninstall().map(|out| {
658            let kept: Vec<Value> = out.kept.iter().map(|(p, why)| json!({"path": p, "reason": why})).collect();
659            json!({"removed": out.removed, "kept": kept, "message": out.message(tool.name())})
660        }),
661        STATUS => {
662            let st = tool.status();
663            Ok(with_message(to_value(&st), st.message(tool.name())))
664        }
665        _ => return None,
666    })
667}
668
669fn run_command<S: CliToolHost>(s: &mut S, id: &str, p: &Value) -> std::result::Result<Value, S::Error> {
670    let t = tool(s)?;
671    match run(t, id, p) {
672        Some(r) => r.map_err(|e| S::Error::from(CommandError::from(e))),
673        None => Err(CommandError::failed(id, "not a command-line tool command").into()),
674    }
675}
676
677fn run_install<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
678    run_command(s, INSTALL, p)
679}
680
681fn run_uninstall<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
682    run_command(s, UNINSTALL, p)
683}
684
685fn run_status<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
686    run_command(s, STATUS, p)
687}
688
689#[cfg(test)]
690#[cfg(unix)]
691#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
692mod tests {
693    use std::os::unix::fs::{PermissionsExt, symlink};
694    use std::sync::Mutex;
695
696    use ashlar_command::{Journal, Registry};
697
698    use super::*;
699
700    struct Fixture {
701        _root: tempfile::TempDir,
702        app: PathBuf,
703        helper: PathBuf,
704        bin: PathBuf,
705        fallback: PathBuf,
706    }
707
708    fn fixture() -> Fixture {
709        let root = tempfile::tempdir().unwrap();
710        let app = root.path().join("Some App.app");
711        let helper = app.join("Contents/Helpers/tool");
712        std::fs::create_dir_all(helper.parent().unwrap()).unwrap();
713        std::fs::write(&helper, "#!/bin/sh\necho tool 1.0\n").unwrap();
714        std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap();
715        let bin = root.path().join("usr local/bin");
716        let fallback = root.path().join("home/.local/bin");
717        Fixture { app, helper, bin, fallback, _root: root }
718    }
719
720    fn tool_at(f: &Fixture, elevation: Elevation) -> CliTool {
721        CliTool::new("tool", &f.helper).unwrap().bin_dir(&f.bin).fallback_dir(Some(f.fallback.clone())).elevation(elevation)
722    }
723
724    fn read_only(dir: &Path) {
725        std::fs::create_dir_all(dir).unwrap();
726        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o555)).unwrap();
727    }
728
729    fn writable(dir: &Path) {
730        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755)).unwrap();
731    }
732
733    /// Root ignores permissions, so the "not writable" cases can't be staged.
734    fn running_as_root() -> bool {
735        let d = tempfile::tempdir().unwrap();
736        read_only(d.path());
737        let can = std::fs::write(d.path().join("probe"), "").is_ok();
738        writable(d.path());
739        can
740    }
741
742    #[test]
743    fn names_are_checked() {
744        for ok in ["tool", "my-tool", "a.b_c2"] {
745            assert!(check_name(ok).is_ok(), "{ok}");
746        }
747        for bad in ["", "-x", ".x", "a/b", "A", "a b", "a;b", &"x".repeat(65)] {
748            assert!(check_name(bad).is_err(), "{bad}");
749        }
750    }
751
752    #[test]
753    fn quoting_survives_quotes_and_spaces_and_refuses_control_characters() {
754        let p = Path::new("/tmp/it's a \"dir\"/$(x)`y`");
755        let q = sh_quote(p).unwrap();
756        let out = std::process::Command::new("/bin/sh").arg("-c").arg(format!("printf %s {q}")).output().unwrap();
757        assert_eq!(String::from_utf8(out.stdout).unwrap(), p.to_str().unwrap());
758        assert!(sh_quote(Path::new("/tmp/a\nb")).is_err());
759        assert!(sh_quote(Path::new("")).is_err());
760    }
761
762    #[test]
763    fn installs_into_a_missing_folder_then_reports_installed_and_is_idempotent() {
764        let f = fixture();
765        let t = tool_at(&f, Elevation::Never);
766        assert_eq!(t.status().state, LinkState::Missing);
767        let out = t.install(false).unwrap();
768        assert_eq!(out.link, f.bin.join("tool"));
769        assert!(!out.fallback && !out.unchanged && !out.elevated && out.path_hint.is_none());
770        assert_eq!(std::fs::read_link(f.bin.join("tool")).unwrap(), f.helper);
771        assert!(t.status().is_installed());
772        let again = t.install(false).unwrap();
773        assert!(again.unchanged);
774        let leftovers: Vec<_> = std::fs::read_dir(&f.bin).unwrap().map(|e| e.unwrap().file_name()).collect();
775        assert_eq!(leftovers, vec![std::ffi::OsString::from("tool")], "no temporary links left behind");
776    }
777
778    #[test]
779    fn a_missing_helper_is_an_error_and_nothing_is_created() {
780        let f = fixture();
781        std::fs::remove_file(&f.helper).unwrap();
782        let t = tool_at(&f, Elevation::Never);
783        assert!(matches!(t.install(false), Err(CliToolError::HelperMissing(_))));
784        assert!(!f.bin.exists());
785    }
786
787    #[test]
788    fn never_replaces_what_isnt_ours_without_replace() {
789        let f = fixture();
790        std::fs::create_dir_all(&f.bin).unwrap();
791        let link = f.bin.join("tool");
792        std::fs::write(&link, "someone else's").unwrap();
793        let t = tool_at(&f, Elevation::Never);
794        assert_eq!(t.status().state, LinkState::File);
795        let err = t.install(false).unwrap_err();
796        assert!(err.is_conflict(), "{err}");
797        assert!(err.to_string().contains("replace"), "{err}");
798        assert_eq!(std::fs::read_to_string(&link).unwrap(), "someone else's");
799        let out = t.install(true).unwrap();
800        assert_eq!(out.replaced.as_deref(), Some("a file"));
801        assert!(out.message("tool").contains("replaced a file"));
802        assert!(t.status().is_installed());
803    }
804
805    #[test]
806    fn a_link_to_something_else_is_foreign_and_is_kept_on_uninstall() {
807        let f = fixture();
808        std::fs::create_dir_all(&f.bin).unwrap();
809        let link = f.bin.join("tool");
810        symlink("/bin/echo", &link).unwrap();
811        let t = tool_at(&f, Elevation::Never);
812        assert_eq!(t.status().state, LinkState::ForeignLink { target: "/bin/echo".into() });
813        assert!(t.install(false).unwrap_err().is_conflict());
814        let r = t.uninstall().unwrap();
815        assert!(r.removed.is_empty());
816        assert_eq!(r.kept.len(), 1);
817        assert_eq!(std::fs::read_link(&link).unwrap(), Path::new("/bin/echo"));
818    }
819
820    #[test]
821    fn a_folder_is_never_replaced() {
822        let f = fixture();
823        std::fs::create_dir_all(f.bin.join("tool")).unwrap();
824        let t = tool_at(&f, Elevation::Never);
825        let err = t.install(true).unwrap_err();
826        assert!(matches!(err, CliToolError::Conflict { replaceable: false, .. }), "{err}");
827    }
828
829    #[test]
830    fn a_link_left_by_a_moved_app_is_broken_and_repaired_without_asking() {
831        let f = fixture();
832        std::fs::create_dir_all(&f.bin).unwrap();
833        let old = f.app.parent().unwrap().join("Old Place/Some App.app/Contents/Helpers/tool");
834        symlink(&old, f.bin.join("tool")).unwrap();
835        let t = tool_at(&f, Elevation::Never);
836        let st = t.status();
837        assert!(st.needs_repair(), "{st:?}");
838        assert!(st.message("tool").contains("repair"));
839        let out = t.repair().unwrap().unwrap();
840        assert!(out.repaired && out.replaced.is_none());
841        assert!(t.status().is_installed());
842        assert!(t.repair().unwrap().is_none(), "nothing left to repair");
843    }
844
845    #[test]
846    fn a_link_to_another_copy_needs_replace() {
847        let f = fixture();
848        let other = f.app.parent().unwrap().join("Copy.app/Contents/Helpers/tool");
849        std::fs::create_dir_all(other.parent().unwrap()).unwrap();
850        std::fs::write(&other, "").unwrap();
851        std::fs::create_dir_all(&f.bin).unwrap();
852        symlink(&other, f.bin.join("tool")).unwrap();
853        let t = tool_at(&f, Elevation::Never);
854        assert_eq!(t.status().state, LinkState::OtherCopy { target: other.clone() });
855        assert!(t.install(false).unwrap_err().is_conflict());
856        let r = t.uninstall().unwrap();
857        assert!(r.removed.is_empty() && r.kept.len() == 1);
858        assert!(t.install(true).unwrap().replaced.unwrap().contains("Copy.app"));
859    }
860
861    #[test]
862    fn a_read_only_folder_asks_for_admin_and_uses_the_answer() {
863        if running_as_root() {
864            return;
865        }
866        let f = fixture();
867        read_only(&f.bin);
868        let scripts: Arc<Mutex<Vec<String>>> = Arc::default();
869        let seen = scripts.clone();
870        let bin = f.bin.clone();
871        // "Administrator": make the folder writable, then run the script as given.
872        let accept: Elevator = Arc::new(move |script: &str| {
873            seen.lock().unwrap().push(script.to_string());
874            writable(&bin);
875            let ok = std::process::Command::new("/bin/sh").arg("-c").arg(script).status().unwrap().success();
876            if ok { Elevated::Done } else { Elevated::Failed("script failed".into()) }
877        });
878        let t = tool_at(&f, Elevation::Custom(accept));
879        let out = t.install(false).unwrap();
880        assert!(out.elevated && !out.fallback);
881        assert!(t.status().is_installed());
882        let script = scripts.lock().unwrap().first().cloned().unwrap();
883        assert!(script.starts_with("/bin/mkdir -p '") && script.contains("/bin/ln -sfn '"), "{script}");
884        assert!(script.contains("'") && script.contains("usr local/bin"), "paths are quoted: {script}");
885        // Uninstall from a read-only folder asks again.
886        read_only(&f.bin);
887        let r = t.uninstall().unwrap();
888        assert_eq!(r.removed, vec![f.bin.join("tool")]);
889        assert!(scripts.lock().unwrap().last().unwrap().starts_with("/bin/rm -f '"));
890        writable(&f.bin);
891    }
892
893    #[test]
894    fn a_declined_prompt_falls_back_to_the_user_folder_with_a_path_hint() {
895        if running_as_root() {
896            return;
897        }
898        let f = fixture();
899        read_only(&f.bin);
900        let asked = Arc::new(Mutex::new(0));
901        let n = asked.clone();
902        let decline: Elevator = Arc::new(move |_: &str| {
903            *n.lock().unwrap() += 1;
904            Elevated::Declined
905        });
906        let t = tool_at(&f, Elevation::Custom(decline));
907        let out = t.install(false).unwrap();
908        assert_eq!(*asked.lock().unwrap(), 1);
909        assert!(out.fallback && !out.elevated);
910        assert_eq!(out.link, f.fallback.join("tool"));
911        let hint = out.path_hint.clone().unwrap();
912        assert!(hint.starts_with("export PATH=\"") && hint.ends_with(":$PATH\""), "{hint}");
913        assert!(out.message("tool").contains("~/.zshrc"));
914        let st = t.status();
915        assert!(st.fallback && st.is_installed(), "{st:?}");
916        // Uninstall removes the fallback link without asking (the system folder is empty).
917        let r = t.uninstall().unwrap();
918        assert_eq!(r.removed, vec![f.fallback.join("tool")]);
919        assert_eq!(*asked.lock().unwrap(), 1);
920        writable(&f.bin);
921    }
922
923    #[test]
924    fn without_a_fallback_a_declined_prompt_is_an_error() {
925        if running_as_root() {
926            return;
927        }
928        let f = fixture();
929        read_only(&f.bin);
930        let t = tool_at(&f, Elevation::Never).fallback_dir(None);
931        assert!(matches!(t.install(false), Err(CliToolError::Os(_))));
932        writable(&f.bin);
933    }
934
935    #[test]
936    fn a_failed_admin_script_is_reported_not_hidden() {
937        if running_as_root() {
938            return;
939        }
940        let f = fixture();
941        read_only(&f.bin);
942        let fail: Elevator = Arc::new(|_: &str| Elevated::Failed("the disk said no".into()));
943        let t = tool_at(&f, Elevation::Custom(fail));
944        assert_eq!(t.install(false).unwrap_err(), CliToolError::Os("the disk said no".into()));
945        assert!(!f.fallback.join("tool").exists());
946        writable(&f.bin);
947    }
948
949    #[test]
950    fn without_env_overrides_the_system_folder_and_the_prompt_are_used() {
951        let f = fixture();
952        let t = CliTool::new("tool", &f.helper).unwrap();
953        if std::env::var_os(ENV_BIN_DIR).is_none() && std::env::var_os(ENV_FALLBACK_DIR).is_none() {
954            assert_eq!(t.link(), Path::new(SYSTEM_BIN_DIR).join("tool"));
955            assert!(matches!(t.elevation, Elevation::Prompt));
956        } else {
957            assert!(matches!(t.elevation, Elevation::Never));
958        }
959    }
960
961    #[test]
962    fn bundled_helper_is_next_to_the_executables_folder() {
963        let p = bundled_helper("tool").unwrap();
964        assert!(p.ends_with("Helpers/tool"), "{}", p.display());
965        assert!(bundled_helper("../x").is_err());
966    }
967
968    // --- commands ---
969
970    struct App {
971        journal: Journal,
972        tool: Option<CliTool>,
973    }
974
975    impl Host for App {
976        type Error = CommandError;
977        fn journal(&mut self) -> &mut Journal {
978            &mut self.journal
979        }
980    }
981
982    impl CliToolHost for App {
983        fn cli_tool(&self) -> Option<&CliTool> {
984            self.tool.as_ref()
985        }
986    }
987
988    #[test]
989    fn commands_install_report_and_uninstall() {
990        let f = fixture();
991        let reg = Registry::new(commands::<App>(&["App"]));
992        let mut app = App { journal: Journal::default(), tool: Some(tool_at(&f, Elevation::Never)) };
993        let ids: Vec<_> = reg.specs().iter().map(|s| s.id).collect();
994        assert_eq!(ids, [INSTALL, UNINSTALL, STATUS]);
995        assert_eq!(reg.find(INSTALL).unwrap().menu, &["App"]);
996        assert!(reg.find(STATUS).unwrap().menu.is_empty());
997
998        let st = reg.execute(&mut app, STATUS, json!({})).unwrap();
999        assert_eq!(st["state"], "missing");
1000        let out = reg.execute(&mut app, INSTALL, json!({})).unwrap();
1001        assert!(out["message"].as_str().unwrap().starts_with("Installed `tool`"), "{out}");
1002        assert_eq!(reg.execute(&mut app, STATUS, json!({})).unwrap()["state"], "ours");
1003        let out = reg.execute(&mut app, UNINSTALL, json!({})).unwrap();
1004        assert_eq!(out["removed"].as_array().unwrap().len(), 1, "{out}");
1005        assert!(!f.bin.join("tool").exists());
1006    }
1007
1008    #[test]
1009    fn commands_refuse_a_conflict_with_a_reason_and_accept_replace() {
1010        let f = fixture();
1011        std::fs::create_dir_all(&f.bin).unwrap();
1012        std::fs::write(f.bin.join("tool"), "x").unwrap();
1013        let reg = Registry::new(commands::<App>(&["App"]));
1014        let mut app = App { journal: Journal::default(), tool: Some(tool_at(&f, Elevation::Never)) };
1015        let err = reg.execute(&mut app, INSTALL, json!({})).unwrap_err();
1016        assert!(err.to_string().contains("nothing was changed"), "{err}");
1017        let out = reg.execute(&mut app, INSTALL, json!({"replace": true})).unwrap();
1018        assert_eq!(out["replaced"], "a file");
1019    }
1020
1021    #[test]
1022    fn run_routes_the_three_ids_and_nothing_else() {
1023        let f = fixture();
1024        let t = tool_at(&f, Elevation::Never);
1025        assert!(run(&t, "app.quit", &json!({})).is_none());
1026        assert_eq!(run(&t, STATUS, &json!({})).unwrap().unwrap()["state"], "missing");
1027        let out = run(&t, INSTALL, &json!({})).unwrap().unwrap();
1028        assert_eq!(out["link"], json!(f.bin.join("tool")));
1029        assert!(out["message"].as_str().unwrap().contains("Installed"));
1030        let out = run(&t, UNINSTALL, &json!({})).unwrap().unwrap();
1031        assert!(out["message"].as_str().unwrap().starts_with("Removed"), "{out}");
1032    }
1033
1034    #[test]
1035    fn commands_are_disabled_without_a_tool_or_helper() {
1036        let f = fixture();
1037        let reg = Registry::new(commands::<App>(&["App"]));
1038        let mut app = App { journal: Journal::default(), tool: None };
1039        assert!(reg.execute(&mut app, INSTALL, json!({})).is_err());
1040        let info = reg.info(reg.find(INSTALL).unwrap(), &app);
1041        assert!(!info.enabled);
1042        std::fs::remove_file(&f.helper).unwrap();
1043        app.tool = Some(tool_at(&f, Elevation::Never));
1044        let info = reg.info(reg.find(INSTALL).unwrap(), &app);
1045        assert!(!info.enabled && info.disabled_reason.unwrap().contains("missing"));
1046        assert!(reg.info(reg.find(UNINSTALL).unwrap(), &app).enabled);
1047    }
1048}