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.
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):
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 aSHASUMS256.txtlisting many assets (platforms, plus-baseline/-musl/-profiledecoys). 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 acontains/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)— mirrorsphp_install::install: unpack into a fresh.staging-<id>-<pid>dir, write the.versionmarker, remove any existing{data}/tools/<id>, then atomicrename. So a reinstall/update replaces in place and always leaves exactly one versioned child (keepingextract_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 internalbin/npm → ../lib/node_modules/npm/bin/npm-cli.jslinks); 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
| Module | Version source | Artifact | Integrity | Layout |
|---|---|---|---|---|
composer.rs | getcomposer.org/versions → newest stable the installed PHP can run (honours each entry's min-php PHP_VERSION_ID) | …/download/<ver>/composer.phar | composer.phar.sha256sum (single line) | {data}/tools/composer/composer.phar |
node.rs | nodejs.org/dist/index.json → first entry with a string lts (newest LTS) | node-<ver>-<os>-<arch>.tar.gz | per-release SHASUMS256.txt | unpacked tree under a versioned dir |
bun.rs | GitHub releases/latest → tag_name (bun-v…) | bun-<os>-<arch>.zip (plain, non-baseline) | per-release SHASUMS256.txt | bun-<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:
| Tool | Link → target |
|---|---|
| Composer | composer → the yerd binary (a multi-call shim, like phpcover) |
| Node | node/npm/npx → absolute node_root/bin/{…} |
| Bun | bun → bun_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):
| Request | Response |
|---|---|
ListTools | Tools { 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)— eachfinish(exchange(&Request::…)), registered inmain.rs'sgenerate_handler!. No capabilities edit (our#[tauri::command]s are permitted by registration). - Frontend:
ipc/types.ts(ToolStatus+ thetoolsresponse),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 theSideNav.vue+router.tsentries that place Tooling between Sites and Services. - CLI:
yerd tools,yerd install tool <id>,yerd uninstall tool <id>— aToolvariant on theInstallTarget/UninstallTargetenums and aToolscommand, mapped inmap::to_request(exhaustive, compile-checked) with an explicitResponse::Toolsarm inmap::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_assetexact-match against a multi-asset fixture with-baseline/-musl/-profiledecoys and a CRLF line;extract_root_dir's single-child invariant; Node's LTS pick from anindex.jsonfixture; the host→asset mappers; Composer'smin-phpselection; Bun's zip unpack preserving the executable bit. - Subsystem:
statusreflects the marker;stage_and_swapplaces then replaces in place;reconcile_tool_shimscreates an installed tool's links, prunes an uninstalled tool's, and leaves PHP shims untouched. - Wire stability: byte-shape goldens for
ListTools/InstallTool/UninstallToolandToolsincrates/yerd-ipc/tests/wire_stability.rs.
See also
- Tooling guide — the user-facing view.
- yerdd (daemon) — the host process and its cover-shim/pcov sibling.
- yerd (CLI) — the multi-call binary that backs
composer. - IPC Protocol — the additive wire contract.