From 052f65149d39a8f522852eace53fea080692f5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 4 May 2026 13:31:27 +0100 Subject: [PATCH] =?UTF-8?q?fix(cli):=201.30.1=20=E2=80=94=20daemon=20insta?= =?UTF-8?q?ll=20upgrade-safe=20+=20node-pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two install-path fixes that bit on first 1.30.0 upgrade: - pin node by absolute path in launchd plist / systemd unit. shebang's /usr/bin/env node resolved against the service environment PATH and picked up system Node 22.x, which lacks node:sqlite (experimental) → daemon died with ERR_UNKNOWN_BUILTIN_MODULE. process.execPath now goes first, so the daemon always runs under the same Node that ran claudemesh install. - tear down the old daemon before bootstrapping. claudemesh install on a machine with an already-running daemon hit Bootstrap failed: 5: Input/output error (launchctl refuses to re-bootstrap a loaded unit + old daemon held the singleton lock). Now we run launchctl bootout (systemd: systemctl --user stop) first, plus SIGTERM to any orphan pid in daemon.pid, so subsequent installs replace cleanly. both fixes apply to darwin and linux paths. windows path is unchanged — it doesn't have a service-install today (daemon-install-service errors with "unsupported platform" on win32). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/CHANGELOG.md | 23 ++++++++++++ apps/cli/package.json | 2 +- apps/cli/src/daemon/service-install.ts | 50 ++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index f95d91c..1df47cf 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 1.30.1 (2026-05-04) — daemon install upgrade-safe + node-pinned + +Two install-path fixes that bit on first user upgrade: + +- **Pin `node` by absolute path in the launchd plist / systemd unit.** + The bin script's `#!/usr/bin/env node` shebang resolves against the + service environment's PATH, which on macOS launchd defaults to + `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin`. + That picks up whatever Node is installed system-wide instead of the + Node that ran `claudemesh install` — and Node 22.x doesn't expose + `node:sqlite` without the experimental flag, so the daemon crashed + with `db open failed: ERR_UNKNOWN_BUILTIN_MODULE`. Now we write + `process.execPath` as the first ProgramArgument so the daemon + always runs under the same Node that installed it. +- **Tear down the old daemon before re-bootstrapping.** `claudemesh + install` on a machine that already has a running daemon was hitting + `Bootstrap failed: 5: Input/output error` because launchctl refuses + to bootstrap a unit that's already loaded, and the old daemon + process held the singleton lock. The install path now runs + `launchctl bootout` (or `systemctl --user stop`) first, plus a + `SIGTERM` to any orphaned daemon pid in `~/.claudemesh/daemon/ + daemon.pid`, so subsequent installs replace cleanly. + ## 1.30.0 (2026-05-04) — per-session broker presence Sprint A Phase 3. Two `claudemesh launch` sessions in the same cwd now diff --git a/apps/cli/package.json b/apps/cli/package.json index 38aa962..e8900cd 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.30.0", + "version": "1.30.1", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/daemon/service-install.ts b/apps/cli/src/daemon/service-install.ts index b74a16c..2c7cff3 100644 --- a/apps/cli/src/daemon/service-install.ts +++ b/apps/cli/src/daemon/service-install.ts @@ -87,7 +87,14 @@ function installDarwin(args: InstallArgs): InstallResult { const plist = darwinPlistPath(); mkdirSync(dirname(plist), { recursive: true }); const log = DAEMON_PATHS.LOG_FILE; + // Resolve `node` explicitly. The bin script in node_modules/.bin starts + // with `#!/usr/bin/env node`; under launchd's restricted PATH that would + // resolve `node` to a system Node (often the wrong major) instead of the + // one that installed claudemesh-cli. Pinning process.execPath here means + // the daemon always runs under the same Node that ran `claudemesh install`. + const nodeBin = process.execPath; const meshArgs = [ + `${escapeXml(args.binaryPath)}`, "daemon", "up", "--mesh", @@ -103,7 +110,7 @@ function installDarwin(args: InstallArgs): InstallResult { ${SERVICE_LABEL} ProgramArguments - ${escapeXml(args.binaryPath)} + ${escapeXml(nodeBin)} ${meshArgs} RunAtLoad @@ -128,6 +135,26 @@ function installDarwin(args: InstallArgs): InstallResult { `; writeFileSync(plist, xml, { mode: 0o644 }); + // Stop any prior incarnation BEFORE bootstrapping so an upgrade run + // doesn't hit "service already loaded" → bootstrap exit-5 IO_ERROR. + // Both calls are best-effort: launchctl prints to stderr if the unit + // isn't loaded, and we don't want to fail install for that. + try { + execSync(`launchctl bootout gui/$(id -u)/${SERVICE_LABEL}`, { stdio: "ignore" }); + } catch { /* unit not loaded — fine */ } + // Also kill any orphaned daemon process (started manually or by an + // older script) so the new launchd-managed one can claim the singleton + // lock on first start. + try { + const pidPath = DAEMON_PATHS.PID_FILE; + if (existsSync(pidPath)) { + const pid = parseInt(readFileSync(pidPath, "utf8").trim(), 10); + if (Number.isFinite(pid) && pid > 0) { + try { process.kill(pid, "SIGTERM"); } catch { /* already dead */ } + } + } + } catch { /* pid file missing — fine */ } + return { platform: "darwin", unitPath: plist, @@ -144,6 +171,9 @@ function linuxUnitPath(): string { function installLinux(args: InstallArgs): InstallResult { const unit = linuxUnitPath(); mkdirSync(dirname(unit), { recursive: true }); + // Same node-pinning rationale as macOS — systemd's User= environment is + // similarly minimal; resolve node by absolute path. + const nodeBin = process.execPath; const execArgs = [ "daemon", "up", "--mesh", args.meshSlug, @@ -157,7 +187,7 @@ Wants=network-online.target [Service] Type=simple -ExecStart=${shellQuote(args.binaryPath)} ${execArgs} +ExecStart=${shellQuote(nodeBin)} ${shellQuote(args.binaryPath)} ${execArgs} Restart=always RestartSec=3 StandardOutput=append:${DAEMON_PATHS.LOG_FILE} @@ -169,6 +199,22 @@ WantedBy=default.target `; writeFileSync(unit, content, { mode: 0o644 }); + // Mirror the darwin path: stop the previous unit (if any) so an + // upgrade run replaces it cleanly, plus kill any orphaned manual + // daemon process holding the singleton lock. + try { + execSync(`systemctl --user stop ${SYSTEMD_UNIT}`, { stdio: "ignore" }); + } catch { /* not loaded — fine */ } + try { + const pidPath = DAEMON_PATHS.PID_FILE; + if (existsSync(pidPath)) { + const pid = parseInt(readFileSync(pidPath, "utf8").trim(), 10); + if (Number.isFinite(pid) && pid > 0) { + try { process.kill(pid, "SIGTERM"); } catch { /* already dead */ } + } + } + } catch { /* pid file missing — fine */ } + return { platform: "linux", unitPath: unit,