Skip to content

Dev-tool installer subsystem

The Tooling feature lets a user install Composer, Node (node/npm/npx), and Bun (bun/bunx) as self-contained binaries on their PATH, from the desktop app or the yerd CLI. This page documents its implementation end-to-end: the daemon subsystem that downloads/verifies/lays down the binaries, the {data}/bin shim reconciliation, the IPC contract, and the thin GUI/CLI wiring.

It follows the same I/O-edge pattern as PHP installs and the cover-shim/pcov work: all logic lives in the daemon and its libraries; the GUI is a thin IPC client. Everything is Unix-first — the shim symlinks and the composer multi-call exec are #[cfg(unix)].

Source

The subsystem is bin/yerdd/src/tools/ (mod.rs + composer.rs / node.rs / bun.rs). The composer exec shim is bin/yerd/src/composer_shim.rs.

At a glance

The Tool registry (tools/mod.rs)

A small Copy enum drives everything; there is no per-tool trait — the variants differ enough (phar vs multi-file tarball vs single-binary zip) that a match into per-tool modules is clearer than an abstraction.

rust
pub enum Tool { Composer, Node, Bun }

impl Tool {
    pub const ALL: [Tool; 3];
    pub const fn id(self) -> &'static str;            // "composer" | "node" | "bun"
    pub const fn display_name(self) -> &'static str;  // for the UI
    pub const fn exposed_bins(self) -> &'static [&'static str]; // commands on PATH
    pub fn parse(id: &str) -> Option<Tool>;           // wire id → Tool
}

exposed_bins is the single source of truth for which {data}/bin names a tool owns: composer; node/npm/npx; bun/bunx. It drives both shim creation and pruning.

Failures are a binary-local thiserror enum (the tools subsystem has no library crate of its own, so this mirrors MutateError/DaemonError rather than the library PhpError/ServiceError):

rust
pub enum ToolError {
    Download(String), Sha256Mismatch(String), Unpack(String),
    UnsupportedHost(&'static str), Io(String), Unknown(String),
}

ipc_server::tool_error_code maps it to a wire ErrorCode (mirrors php_error_code): Unknown → NotFound, UnsupportedHost → InvalidPath, else Internal.

Status: pure filesystem reads

status(dirs, tool) and list_status(dirs) read a tool's .version marker under {data}/tools/<id>/ and return a yerd_ipc::ToolStatus (id, display_name, installed, version, binaries). No marker → installed: false. There is no network or lock, so ListTools is cheap and can never block — unlike ListServices, which probes run-state.

Install: download → verify → stage → swap

install(tool, dirs, dl) returns Result<(), ToolError> — a real Ok/Error so the synchronous dispatch arm can report success or failure (it deliberately does not reuse Composer's old error-swallowing ensure_present wrapper). The shared helpers in mod.rs:

  • sha_for_asset(sums_text, exact_filename) — Node and Bun publish a SHASUMS256.txt listing many assets (platforms, plus -baseline/-musl/ -profile decoys). The parser tokenises each "<hex> <file>" line (tolerating CRLF, a UTF-8 BOM on line 1, and a * binary-mode marker) and matches the filename exactly — never a contains/ends_with, which would pick a decoy.
  • extract_root_dir(dir) — Node and Bun archives wrap their payload in one top-level, version-named directory (node-v24.17.0-darwin-arm64/…, bun-darwin-aarch64/bun). This resolves the actual single child dir (erroring unless exactly one), so the install never reconstructs the name from a version string that could drift upstream.
  • stage_and_swap(dirs, tool, version, unpack) — mirrors php_install::install: unpack into a fresh .staging-<id>-<pid> dir, write the .version marker, remove any existing {data}/tools/<id>, then atomic rename. So a reinstall/update replaces in place and always leaves exactly one versioned child (keeping extract_root_dir's invariant true across updates).
  • Trust boundary. The tar/zip unpackers validate member names against traversal (yerd_php::is_safe_member) and preserve symlinks (Node needs its internal bin/npm → ../lib/node_modules/npm/bin/npm-cli.js links); the sha256 verification before unpack is the integrity boundary.

The reqwest-backed ReqwestDownloader (from php_install) is reused; it sets a User-Agent (the GitHub API used for Bun rejects requests without one).

Per-tool specifics

ModuleVersion sourceArtifactIntegrityLayout
composer.rsgetcomposer.org/versions → newest stable the installed PHP can run (honours each entry's min-php PHP_VERSION_ID)…/download/<ver>/composer.pharcomposer.phar.sha256sum (single line){data}/tools/composer/composer.phar
node.rsnodejs.org/dist/index.json → first entry with a string lts (newest LTS)node-<ver>-<os>-<arch>.tar.gzper-release SHASUMS256.txtunpacked tree under a versioned dir
bun.rsGitHub releases/latesttag_name (bun-v…)bun-<os>-<arch>.zip (plain, non-baseline)per-release SHASUMS256.txtbun-<os>-<arch>/bun

Each module has a pure host→asset mapper (current_os_arch()darwin-arm64 / linux-x64 / bun-darwin-aarch64 / …) that returns a typed ToolError::UnsupportedHost for a platform the publisher doesn't ship, rather than building a URL that 404s. Bun's zip is unpacked with the zip crate (default-features = false, features = ["deflate"], pulling only the pure-Rust flate2 backend — pinned to 2.x to stay within the workspace's MSRV-1.77 discipline; see the Cargo.toml MSRV-pin block).

Shims: reconcile_tool_shims

The commands a tool provides are symlinks in {data}/bin — the same directory as the PHP php/php<ver>/phpcover shims. The two reconcilers partition the namespace cleanly: the PHP reconcile only prunes php<X.Y>* names, and reconcile_tool_shims owns composer/node/npm/npx/bun/bunx.

For each installed tool it creates (name → target) links via the shared php_install::place_symlink (atomic temp+rename); for each uninstalled tool it prunes that tool's owned names. Pruning keys on name-ownership gated on symlink_metadata().is_symlink() — never target.exists() — so a link left dangling by an uninstall is still removed. Per-tool targets:

ToolLink → target
Composercomposer → the yerd binary (a multi-call shim, like phpcover)
Nodenode/npm/npxabsolute node_root/bin/{…}
Bunbunbun_root/bun; bunx{data}/bin/bun (sibling; Bun dispatches on argv0)

Why npm points at an absolute path

Node's bin/npm and bin/npx are themselves relative symlinks into ../lib/node_modules/npm. Pointing {data}/bin/npm at the absolutenode_root/bin/npm lets the kernel resolve that inner relative link correctly (each link resolves relative to its own directory). The npm-cli shebang #!/usr/bin/env node then finds {data}/bin/node via PATH. Pointing {data}/bin/npm straight at npm-cli.js would lose npm's own arg handling.

Concurrency

{data}/bin is written by both the PHP reconcile and the tool reconcile, and the daemon serves IPC requests tokio::spawn-per-connection — so two clients could mutate the directory at once. reconcile_tool_shims_now takes the samestate.shim_reconcile mutex as the PHP reconcile, serialising all {data}/bin mutation. The slow download runs before the lock is taken; only the reconcile holds it.

The composer exec shim (bin/yerd/src/composer_shim.rs)

Composer is a phar, so its composer command can't be a direct symlink — it needs a PHP interpreter. Like the cover shims, {data}/bin/composer symlinks to the yerd binary, which detects argv[0] == "composer" before clap (in main.rs, ahead of cover_shim::dispatch) and execs the default managed PHP against {data}/tools/composer/composer.phar. If no PHP is installed it prints a clear "install a PHP version" message; if the phar is absent it points the user at the Tooling page.

IPC contract

Three additive variants (no PROTOCOL_VERSION bump — see IPC Protocol):

RequestResponse
ListToolsTools { tools: Vec<ToolStatus> }
InstallTool { tool: String }Ok / Error
UninstallTool { tool: String }Ok / Error

ToolStatus lives in crates/yerd-ipc/src/status.rs (alongside ServiceStatus/ DatabaseSummary); its field declaration order is the wire contract and is pinned by tests/wire_stability.rs. The install/uninstall arms run the slow work with no lock held, then call reconcile_tool_shims_now; the daemon also reconciles tool shims once at startup (self-healing the composer link if the yerd binary moved between runs).

GUI & CLI wiring

  • Tauri (apps/yerd-gui/src-tauri/src/commands.rs): list_tools, install_tool(tool), uninstall_tool(tool) — each finish(exchange(&Request::…)), registered in main.rs's generate_handler!. No capabilities edit (our #[tauri::command]s are permitted by registration).
  • Frontend: ipc/types.ts (ToolStatus + the tools response), ipc/client.ts (listTools/installTool/uninstallTool), views/ToolingView.vue (a table copied from the PHP view's pattern — install/update/uninstall with a busy spinner and toasts), plus the SideNav.vue + router.ts entries that place Tooling between Sites and Services.
  • CLI: yerd tools, yerd install tool <id>, yerd uninstall tool <id> — a Tool variant on the InstallTarget/UninstallTarget enums and a Tools command, mapped in map::to_request (exhaustive, compile-checked) with an explicit Response::Tools arm in map::render (which has a wildcard, so a missing arm would silently misrender).

Composer is opt-in

Composer was briefly auto-installed on daemon start; that was reverted when the Tooling page landed. The startup/12h auto-fetch and the unconditional composer shim were removed; Composer now installs only via InstallTool (or yerd install tool composer), and its phar moved from {data}/composer/ to {data}/tools/composer/. A fresh daemon start performs no Composer network I/O.

Tests

  • Pure helpers (inline): the sha_for_asset exact-match against a multi-asset fixture with -baseline/-musl/-profile decoys and a CRLF line; extract_root_dir's single-child invariant; Node's LTS pick from an index.json fixture; the host→asset mappers; Composer's min-php selection; Bun's zip unpack preserving the executable bit.
  • Subsystem: status reflects the marker; stage_and_swap places then replaces in place; reconcile_tool_shims creates an installed tool's links, prunes an uninstalled tool's, and leaves PHP shims untouched.
  • Wire stability: byte-shape goldens for ListTools/InstallTool/ UninstallTool and Tools in crates/yerd-ipc/tests/wire_stability.rs.

See also

A Forjed project. Released under the MIT License.