fix(cli): 1.30.1 — daemon install upgrade-safe + node-pinned
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-04 13:31:27 +01:00
parent 0b3014e7eb
commit 052f65149d
3 changed files with 72 additions and 3 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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 = [
`<string>${escapeXml(args.binaryPath)}</string>`,
"<string>daemon</string>",
"<string>up</string>",
"<string>--mesh</string>",
@@ -103,7 +110,7 @@ function installDarwin(args: InstallArgs): InstallResult {
<string>${SERVICE_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${escapeXml(args.binaryPath)}</string>
<string>${escapeXml(nodeBin)}</string>
${meshArgs}
</array>
<key>RunAtLoad</key>
@@ -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,