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,