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. The folder
522/// is single-quoted with [`sh_quote`], so `$`, backquotes and quotes in it are taken literally
523/// when the line is pasted into a shell.
524fn path_hint(dir: &Path) -> String {
525    let home = std::env::var_os("HOME").map(PathBuf::from);
526    let shown = match home.as_deref().and_then(|h| dir.strip_prefix(h).ok()) {
527        Some(rest) if rest.as_os_str().is_empty() => Ok("\"$HOME\"".to_string()),
528        Some(rest) => sh_quote(rest).map(|q| format!("\"$HOME\"/{q}")),
529        None => sh_quote(dir),
530    };
531    // A folder `sh_quote` refuses (control characters, not UTF-8): show it with the shell's
532    // special characters removed, as a hint only.
533    let shown = shown.unwrap_or_else(|_| format!("'{}'", dir.display().to_string().replace(|c: char| c == '\'' || c.is_control(), "")));
534    format!("export PATH={shown}:\"$PATH\"")
535}
536
537/// `p` as one single-quoted `/bin/sh` word. Refuses non-UTF-8 and control characters.
538pub fn sh_quote(p: &Path) -> Result<String> {
539    let s = p.to_str().ok_or_else(|| CliToolError::Invalid(format!("{} is not valid UTF-8", p.display())))?;
540    if s.is_empty() || s.chars().any(char::is_control) {
541        return Err(CliToolError::Invalid(format!("{} can't be passed to the shell", p.display())));
542    }
543    Ok(format!("'{}'", s.replace('\'', r"'\''")))
544}
545
546/// Create `dir`, then point `link` at `target` by renaming a fresh link over it, so the name
547/// never disappears and a crash leaves either the old entry or the new one.
548#[cfg(unix)]
549fn link_atomically(target: &Path, link: &Path) -> io::Result<()> {
550    let dir = link.parent().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the link has no folder"))?;
551    std::fs::create_dir_all(dir)?;
552    let name = link.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
553    let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
554    let _ = std::fs::remove_file(&tmp);
555    std::os::unix::fs::symlink(target, &tmp)?;
556    std::fs::rename(&tmp, link).inspect_err(|_| {
557        let _ = std::fs::remove_file(&tmp);
558    })
559}
560
561#[cfg(not(unix))]
562fn link_atomically(_: &Path, _: &Path) -> io::Result<()> {
563    Err(io::Error::new(io::ErrorKind::Unsupported, "command-line tool links need a Unix system"))
564}
565
566/// The macOS administrator prompt. The script and the prompt text travel as `osascript`
567/// arguments (`item n of argv`), so neither is ever spliced into AppleScript source.
568#[cfg(target_os = "macos")]
569fn admin_prompt(script: &str, prompt: &str) -> Elevated {
570    let out = std::process::Command::new("/usr/bin/osascript")
571        .args([
572            "-e",
573            "on run argv",
574            "-e",
575            "do shell script (item 1 of argv) with prompt (item 2 of argv) with administrator privileges",
576            "-e",
577            "end run",
578            script,
579            prompt,
580        ])
581        .output();
582    match out {
583        Ok(o) if o.status.success() => Elevated::Done,
584        Ok(o) => {
585            let err = String::from_utf8_lossy(&o.stderr);
586            if err.contains("-128") { Elevated::Declined } else { Elevated::Failed(crate::os_text(err)) }
587        }
588        Err(e) => Elevated::Failed(crate::os_text(e)),
589    }
590}
591
592#[cfg(not(target_os = "macos"))]
593fn admin_prompt(_: &str, _: &str) -> Elevated {
594    Elevated::Declined
595}
596
597// ---------------------------------------------------------------------------------------------
598// Commands
599// ---------------------------------------------------------------------------------------------
600
601/// A command host that can hand out its app's [`CliTool`] (`None` in a development build with no
602/// bundled tool: the commands are then disabled with a reason).
603pub trait CliToolHost: Host<Error: From<CommandError>> {
604    fn cli_tool(&self) -> Option<&CliTool>;
605}
606
607const INSTALL_PARAMS: &[Param] = &[Param::bool("replace").about("replace a file or link that isn't this app's tool")];
608
609/// `app.installCommandLineTool` and `app.uninstallCommandLineTool` in `menu` (macOS: the app
610/// menu, `&["<App name>"]`), and the menu-less query `app.commandLineToolStatus`. Each returns
611/// the outcome as JSON with a `message` for a toast.
612pub fn commands<S: CliToolHost>(menu: &'static [&'static str]) -> Vec<CommandSpec<S>> {
613    vec![
614        CommandSpec::new(INSTALL, "Install Command Line Tool…", run_install::<S>)
615            .menu(menu)
616            .params(Params::Static(INSTALL_PARAMS))
617            .enabled(can_install::<S>)
618            .query(),
619        CommandSpec::new(UNINSTALL, "Uninstall Command Line Tool", run_uninstall::<S>).menu(menu).enabled(has_tool::<S>).query(),
620        CommandSpec::new(STATUS, "Command Line Tool Status", run_status::<S>).enabled(has_tool::<S>).query(),
621    ]
622}
623
624fn tool<S: CliToolHost>(s: &S) -> std::result::Result<&CliTool, S::Error> {
625    s.cli_tool().ok_or_else(|| CommandError::failed("", "this build of the app has no command-line tool").into())
626}
627
628fn has_tool<S: CliToolHost>(s: &S) -> std::result::Result<(), String> {
629    match s.cli_tool() {
630        Some(_) => Ok(()),
631        None => Err("this build of the app has no command-line tool".into()),
632    }
633}
634
635fn can_install<S: CliToolHost>(s: &S) -> std::result::Result<(), String> {
636    has_tool(s)?;
637    match s.cli_tool() {
638        Some(t) if t.helper_present() => Ok(()),
639        _ => Err("the command-line tool is missing from this copy of the app".into()),
640    }
641}
642
643fn to_value<T: Serialize>(v: &T) -> Value {
644    serde_json::to_value(v).unwrap_or(Value::Null)
645}
646
647fn with_message(mut v: Value, message: String) -> Value {
648    if let Some(m) = v.as_object_mut() {
649        m.insert("message".into(), Value::String(message));
650    }
651    v
652}
653
654/// Run [`INSTALL`] (param `replace`), [`UNINSTALL`] or [`STATUS`] on `tool`, for apps that route
655/// commands themselves instead of through a [`ashlar_command::Registry`]. The result is the
656/// outcome as JSON with a `message` for a toast; `None` when `id` is none of the three.
657pub fn run(tool: &CliTool, id: &str, params: &Value) -> Option<Result<Value>> {
658    Some(match id {
659        INSTALL => {
660            let replace = params.get("replace").and_then(Value::as_bool).unwrap_or(false);
661            tool.install(replace).map(|out| with_message(to_value(&out), out.message(tool.name())))
662        }
663        UNINSTALL => tool.uninstall().map(|out| {
664            let kept: Vec<Value> = out.kept.iter().map(|(p, why)| json!({"path": p, "reason": why})).collect();
665            json!({"removed": out.removed, "kept": kept, "message": out.message(tool.name())})
666        }),
667        STATUS => {
668            let st = tool.status();
669            Ok(with_message(to_value(&st), st.message(tool.name())))
670        }
671        _ => return None,
672    })
673}
674
675fn run_command<S: CliToolHost>(s: &mut S, id: &str, p: &Value) -> std::result::Result<Value, S::Error> {
676    let t = tool(s)?;
677    match run(t, id, p) {
678        Some(r) => r.map_err(|e| S::Error::from(CommandError::from(e))),
679        None => Err(CommandError::failed(id, "not a command-line tool command").into()),
680    }
681}
682
683fn run_install<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
684    run_command(s, INSTALL, p)
685}
686
687fn run_uninstall<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
688    run_command(s, UNINSTALL, p)
689}
690
691fn run_status<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
692    run_command(s, STATUS, p)
693}
694
695#[cfg(test)]
696#[cfg(unix)]
697#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
698mod tests {
699    use std::os::unix::fs::{PermissionsExt, symlink};
700    use std::sync::Mutex;
701
702    use ashlar_command::{Journal, Registry};
703
704    use super::*;
705
706    struct Fixture {
707        _root: tempfile::TempDir,
708        app: PathBuf,
709        helper: PathBuf,
710        bin: PathBuf,
711        fallback: PathBuf,
712    }
713
714    fn fixture() -> Fixture {
715        let root = tempfile::tempdir().unwrap();
716        let app = root.path().join("Some App.app");
717        let helper = app.join("Contents/Helpers/tool");
718        std::fs::create_dir_all(helper.parent().unwrap()).unwrap();
719        std::fs::write(&helper, "#!/bin/sh\necho tool 1.0\n").unwrap();
720        std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap();
721        let bin = root.path().join("usr local/bin");
722        let fallback = root.path().join("home/.local/bin");
723        Fixture { app, helper, bin, fallback, _root: root }
724    }
725
726    fn tool_at(f: &Fixture, elevation: Elevation) -> CliTool {
727        CliTool::new("tool", &f.helper).unwrap().bin_dir(&f.bin).fallback_dir(Some(f.fallback.clone())).elevation(elevation)
728    }
729
730    fn read_only(dir: &Path) {
731        std::fs::create_dir_all(dir).unwrap();
732        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o555)).unwrap();
733    }
734
735    fn writable(dir: &Path) {
736        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755)).unwrap();
737    }
738
739    /// Root ignores permissions, so the "not writable" cases can't be staged.
740    fn running_as_root() -> bool {
741        let d = tempfile::tempdir().unwrap();
742        read_only(d.path());
743        let can = std::fs::write(d.path().join("probe"), "").is_ok();
744        writable(d.path());
745        can
746    }
747
748    #[test]
749    fn names_are_checked() {
750        for ok in ["tool", "my-tool", "a.b_c2"] {
751            assert!(check_name(ok).is_ok(), "{ok}");
752        }
753        for bad in ["", "-x", ".x", "a/b", "A", "a b", "a;b", &"x".repeat(65)] {
754            assert!(check_name(bad).is_err(), "{bad}");
755        }
756    }
757
758    #[test]
759    fn quoting_survives_quotes_and_spaces_and_refuses_control_characters() {
760        let p = Path::new("/tmp/it's a \"dir\"/$(x)`y`");
761        let q = sh_quote(p).unwrap();
762        let out = std::process::Command::new("/bin/sh").arg("-c").arg(format!("printf %s {q}")).output().unwrap();
763        assert_eq!(String::from_utf8(out.stdout).unwrap(), p.to_str().unwrap());
764        assert!(sh_quote(Path::new("/tmp/a\nb")).is_err());
765        assert!(sh_quote(Path::new("")).is_err());
766    }
767
768    #[test]
769    fn installs_into_a_missing_folder_then_reports_installed_and_is_idempotent() {
770        let f = fixture();
771        let t = tool_at(&f, Elevation::Never);
772        assert_eq!(t.status().state, LinkState::Missing);
773        let out = t.install(false).unwrap();
774        assert_eq!(out.link, f.bin.join("tool"));
775        assert!(!out.fallback && !out.unchanged && !out.elevated && out.path_hint.is_none());
776        assert_eq!(std::fs::read_link(f.bin.join("tool")).unwrap(), f.helper);
777        assert!(t.status().is_installed());
778        let again = t.install(false).unwrap();
779        assert!(again.unchanged);
780        let leftovers: Vec<_> = std::fs::read_dir(&f.bin).unwrap().map(|e| e.unwrap().file_name()).collect();
781        assert_eq!(leftovers, vec![std::ffi::OsString::from("tool")], "no temporary links left behind");
782    }
783
784    #[test]
785    fn a_missing_helper_is_an_error_and_nothing_is_created() {
786        let f = fixture();
787        std::fs::remove_file(&f.helper).unwrap();
788        let t = tool_at(&f, Elevation::Never);
789        assert!(matches!(t.install(false), Err(CliToolError::HelperMissing(_))));
790        assert!(!f.bin.exists());
791    }
792
793    #[test]
794    fn never_replaces_what_isnt_ours_without_replace() {
795        let f = fixture();
796        std::fs::create_dir_all(&f.bin).unwrap();
797        let link = f.bin.join("tool");
798        std::fs::write(&link, "someone else's").unwrap();
799        let t = tool_at(&f, Elevation::Never);
800        assert_eq!(t.status().state, LinkState::File);
801        let err = t.install(false).unwrap_err();
802        assert!(err.is_conflict(), "{err}");
803        assert!(err.to_string().contains("replace"), "{err}");
804        assert_eq!(std::fs::read_to_string(&link).unwrap(), "someone else's");
805        let out = t.install(true).unwrap();
806        assert_eq!(out.replaced.as_deref(), Some("a file"));
807        assert!(out.message("tool").contains("replaced a file"));
808        assert!(t.status().is_installed());
809    }
810
811    #[test]
812    fn a_link_to_something_else_is_foreign_and_is_kept_on_uninstall() {
813        let f = fixture();
814        std::fs::create_dir_all(&f.bin).unwrap();
815        let link = f.bin.join("tool");
816        symlink("/bin/echo", &link).unwrap();
817        let t = tool_at(&f, Elevation::Never);
818        assert_eq!(t.status().state, LinkState::ForeignLink { target: "/bin/echo".into() });
819        assert!(t.install(false).unwrap_err().is_conflict());
820        let r = t.uninstall().unwrap();
821        assert!(r.removed.is_empty());
822        assert_eq!(r.kept.len(), 1);
823        assert_eq!(std::fs::read_link(&link).unwrap(), Path::new("/bin/echo"));
824    }
825
826    #[test]
827    fn a_folder_is_never_replaced() {
828        let f = fixture();
829        std::fs::create_dir_all(f.bin.join("tool")).unwrap();
830        let t = tool_at(&f, Elevation::Never);
831        let err = t.install(true).unwrap_err();
832        assert!(matches!(err, CliToolError::Conflict { replaceable: false, .. }), "{err}");
833    }
834
835    #[test]
836    fn a_link_left_by_a_moved_app_is_broken_and_repaired_without_asking() {
837        let f = fixture();
838        std::fs::create_dir_all(&f.bin).unwrap();
839        let old = f.app.parent().unwrap().join("Old Place/Some App.app/Contents/Helpers/tool");
840        symlink(&old, f.bin.join("tool")).unwrap();
841        let t = tool_at(&f, Elevation::Never);
842        let st = t.status();
843        assert!(st.needs_repair(), "{st:?}");
844        assert!(st.message("tool").contains("repair"));
845        let out = t.repair().unwrap().unwrap();
846        assert!(out.repaired && out.replaced.is_none());
847        assert!(t.status().is_installed());
848        assert!(t.repair().unwrap().is_none(), "nothing left to repair");
849    }
850
851    #[test]
852    fn a_link_to_another_copy_needs_replace() {
853        let f = fixture();
854        let other = f.app.parent().unwrap().join("Copy.app/Contents/Helpers/tool");
855        std::fs::create_dir_all(other.parent().unwrap()).unwrap();
856        std::fs::write(&other, "").unwrap();
857        std::fs::create_dir_all(&f.bin).unwrap();
858        symlink(&other, f.bin.join("tool")).unwrap();
859        let t = tool_at(&f, Elevation::Never);
860        assert_eq!(t.status().state, LinkState::OtherCopy { target: other.clone() });
861        assert!(t.install(false).unwrap_err().is_conflict());
862        let r = t.uninstall().unwrap();
863        assert!(r.removed.is_empty() && r.kept.len() == 1);
864        assert!(t.install(true).unwrap().replaced.unwrap().contains("Copy.app"));
865    }
866
867    #[test]
868    fn a_read_only_folder_asks_for_admin_and_uses_the_answer() {
869        if running_as_root() {
870            return;
871        }
872        let f = fixture();
873        read_only(&f.bin);
874        let scripts: Arc<Mutex<Vec<String>>> = Arc::default();
875        let seen = scripts.clone();
876        let bin = f.bin.clone();
877        // "Administrator": make the folder writable, then run the script as given.
878        let accept: Elevator = Arc::new(move |script: &str| {
879            seen.lock().unwrap().push(script.to_string());
880            writable(&bin);
881            let ok = std::process::Command::new("/bin/sh").arg("-c").arg(script).status().unwrap().success();
882            if ok { Elevated::Done } else { Elevated::Failed("script failed".into()) }
883        });
884        let t = tool_at(&f, Elevation::Custom(accept));
885        let out = t.install(false).unwrap();
886        assert!(out.elevated && !out.fallback);
887        assert!(t.status().is_installed());
888        let script = scripts.lock().unwrap().first().cloned().unwrap();
889        assert!(script.starts_with("/bin/mkdir -p '") && script.contains("/bin/ln -sfn '"), "{script}");
890        assert!(script.contains("'") && script.contains("usr local/bin"), "paths are quoted: {script}");
891        // Uninstall from a read-only folder asks again.
892        read_only(&f.bin);
893        let r = t.uninstall().unwrap();
894        assert_eq!(r.removed, vec![f.bin.join("tool")]);
895        assert!(scripts.lock().unwrap().last().unwrap().starts_with("/bin/rm -f '"));
896        writable(&f.bin);
897    }
898
899    #[test]
900    fn a_declined_prompt_falls_back_to_the_user_folder_with_a_path_hint() {
901        if running_as_root() {
902            return;
903        }
904        let f = fixture();
905        read_only(&f.bin);
906        let asked = Arc::new(Mutex::new(0));
907        let n = asked.clone();
908        let decline: Elevator = Arc::new(move |_: &str| {
909            *n.lock().unwrap() += 1;
910            Elevated::Declined
911        });
912        let t = tool_at(&f, Elevation::Custom(decline));
913        let out = t.install(false).unwrap();
914        assert_eq!(*asked.lock().unwrap(), 1);
915        assert!(out.fallback && !out.elevated);
916        assert_eq!(out.link, f.fallback.join("tool"));
917        let hint = out.path_hint.clone().unwrap();
918        assert!(hint.starts_with("export PATH=") && hint.ends_with(":\"$PATH\""), "{hint}");
919        assert!(out.message("tool").contains("~/.zshrc"));
920        let st = t.status();
921        assert!(st.fallback && st.is_installed(), "{st:?}");
922        // Uninstall removes the fallback link without asking (the system folder is empty).
923        let r = t.uninstall().unwrap();
924        assert_eq!(r.removed, vec![f.fallback.join("tool")]);
925        assert_eq!(*asked.lock().unwrap(), 1);
926        writable(&f.bin);
927    }
928
929    #[test]
930    fn path_hint_quotes_shell_characters() {
931        let h = path_hint(Path::new("/opt/a $b `c` 'd\"/bin"));
932        assert_eq!(h, r#"export PATH='/opt/a $b `c` '\''d"/bin':"$PATH""#);
933        if let Some(home) = std::env::var_os("HOME").map(PathBuf::from).filter(|h| h.is_absolute()) {
934            assert_eq!(path_hint(&home.join("x$y/bin")), r#"export PATH="$HOME"/'x$y/bin':"$PATH""#);
935        }
936    }
937
938    #[test]
939    fn without_a_fallback_a_declined_prompt_is_an_error() {
940        if running_as_root() {
941            return;
942        }
943        let f = fixture();
944        read_only(&f.bin);
945        let t = tool_at(&f, Elevation::Never).fallback_dir(None);
946        assert!(matches!(t.install(false), Err(CliToolError::Os(_))));
947        writable(&f.bin);
948    }
949
950    #[test]
951    fn a_failed_admin_script_is_reported_not_hidden() {
952        if running_as_root() {
953            return;
954        }
955        let f = fixture();
956        read_only(&f.bin);
957        let fail: Elevator = Arc::new(|_: &str| Elevated::Failed("the disk said no".into()));
958        let t = tool_at(&f, Elevation::Custom(fail));
959        assert_eq!(t.install(false).unwrap_err(), CliToolError::Os("the disk said no".into()));
960        assert!(!f.fallback.join("tool").exists());
961        writable(&f.bin);
962    }
963
964    #[test]
965    fn without_env_overrides_the_system_folder_and_the_prompt_are_used() {
966        let f = fixture();
967        let t = CliTool::new("tool", &f.helper).unwrap();
968        if std::env::var_os(ENV_BIN_DIR).is_none() && std::env::var_os(ENV_FALLBACK_DIR).is_none() {
969            assert_eq!(t.link(), Path::new(SYSTEM_BIN_DIR).join("tool"));
970            assert!(matches!(t.elevation, Elevation::Prompt));
971        } else {
972            assert!(matches!(t.elevation, Elevation::Never));
973        }
974    }
975
976    #[test]
977    fn bundled_helper_is_next_to_the_executables_folder() {
978        let p = bundled_helper("tool").unwrap();
979        assert!(p.ends_with("Helpers/tool"), "{}", p.display());
980        assert!(bundled_helper("../x").is_err());
981    }
982
983    // --- commands ---
984
985    struct App {
986        journal: Journal,
987        tool: Option<CliTool>,
988    }
989
990    impl Host for App {
991        type Error = CommandError;
992        fn journal(&mut self) -> &mut Journal {
993            &mut self.journal
994        }
995    }
996
997    impl CliToolHost for App {
998        fn cli_tool(&self) -> Option<&CliTool> {
999            self.tool.as_ref()
1000        }
1001    }
1002
1003    #[test]
1004    fn commands_install_report_and_uninstall() {
1005        let f = fixture();
1006        let reg = Registry::new(commands::<App>(&["App"]));
1007        let mut app = App { journal: Journal::default(), tool: Some(tool_at(&f, Elevation::Never)) };
1008        let ids: Vec<_> = reg.specs().iter().map(|s| s.id).collect();
1009        assert_eq!(ids, [INSTALL, UNINSTALL, STATUS]);
1010        assert_eq!(reg.find(INSTALL).unwrap().menu, &["App"]);
1011        assert!(reg.find(STATUS).unwrap().menu.is_empty());
1012
1013        let st = reg.execute(&mut app, STATUS, json!({})).unwrap();
1014        assert_eq!(st["state"], "missing");
1015        let out = reg.execute(&mut app, INSTALL, json!({})).unwrap();
1016        assert!(out["message"].as_str().unwrap().starts_with("Installed `tool`"), "{out}");
1017        assert_eq!(reg.execute(&mut app, STATUS, json!({})).unwrap()["state"], "ours");
1018        let out = reg.execute(&mut app, UNINSTALL, json!({})).unwrap();
1019        assert_eq!(out["removed"].as_array().unwrap().len(), 1, "{out}");
1020        assert!(!f.bin.join("tool").exists());
1021    }
1022
1023    #[test]
1024    fn commands_refuse_a_conflict_with_a_reason_and_accept_replace() {
1025        let f = fixture();
1026        std::fs::create_dir_all(&f.bin).unwrap();
1027        std::fs::write(f.bin.join("tool"), "x").unwrap();
1028        let reg = Registry::new(commands::<App>(&["App"]));
1029        let mut app = App { journal: Journal::default(), tool: Some(tool_at(&f, Elevation::Never)) };
1030        let err = reg.execute(&mut app, INSTALL, json!({})).unwrap_err();
1031        assert!(err.to_string().contains("nothing was changed"), "{err}");
1032        let out = reg.execute(&mut app, INSTALL, json!({"replace": true})).unwrap();
1033        assert_eq!(out["replaced"], "a file");
1034    }
1035
1036    #[test]
1037    fn run_routes_the_three_ids_and_nothing_else() {
1038        let f = fixture();
1039        let t = tool_at(&f, Elevation::Never);
1040        assert!(run(&t, "app.quit", &json!({})).is_none());
1041        assert_eq!(run(&t, STATUS, &json!({})).unwrap().unwrap()["state"], "missing");
1042        let out = run(&t, INSTALL, &json!({})).unwrap().unwrap();
1043        assert_eq!(out["link"], json!(f.bin.join("tool")));
1044        assert!(out["message"].as_str().unwrap().contains("Installed"));
1045        let out = run(&t, UNINSTALL, &json!({})).unwrap().unwrap();
1046        assert!(out["message"].as_str().unwrap().starts_with("Removed"), "{out}");
1047    }
1048
1049    #[test]
1050    fn commands_are_disabled_without_a_tool_or_helper() {
1051        let f = fixture();
1052        let reg = Registry::new(commands::<App>(&["App"]));
1053        let mut app = App { journal: Journal::default(), tool: None };
1054        assert!(reg.execute(&mut app, INSTALL, json!({})).is_err());
1055        let info = reg.info(reg.find(INSTALL).unwrap(), &app);
1056        assert!(!info.enabled);
1057        std::fs::remove_file(&f.helper).unwrap();
1058        app.tool = Some(tool_at(&f, Elevation::Never));
1059        let info = reg.info(reg.find(INSTALL).unwrap(), &app);
1060        assert!(!info.enabled && info.disabled_reason.unwrap().contains("missing"));
1061        assert!(reg.info(reg.find(UNINSTALL).unwrap(), &app).enabled);
1062    }
1063}