@love-moon/conductor-cli 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,614 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { spawn } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ import { resolveConductorHome } from "./conductor-paths.js";
8
+ import { DAEMON_LOCK_FILE_NAME, parseDaemonLockState } from "./daemon-lock.js";
9
+ import {
10
+ PACKAGE_NAME,
11
+ detectPackageManager,
12
+ fetchLatestVersion,
13
+ isNewerVersion,
14
+ resolveGlobalInstallPrefix,
15
+ } from "./version-check.js";
16
+ import {
17
+ buildPnpmAllowBuildArgs,
18
+ ensurePnpmOnlyBuiltDependencies,
19
+ repairAndVerifyGlobalNodePty,
20
+ resolveGlobalPackageDirectory,
21
+ } from "./native-deps.js";
22
+
23
+ /**
24
+ * Built-in "Update Daemon": upgrade the globally installed CLI and restart the
25
+ * daemon onto it.
26
+ *
27
+ * The one invariant this module exists to uphold is that a *failed* update
28
+ * never leaves the machine without a daemon:
29
+ *
30
+ * 1. The work runs in a DETACHED updater process, not inside the daemon. The
31
+ * daemon it is about to replace can therefore die (or be killed) without
32
+ * taking the update with it, and a crashing update cannot take the daemon
33
+ * down with it either.
34
+ * 2. Install and verification happen FIRST. The running daemon is only
35
+ * stopped once the new version is on disk, reports the expected version,
36
+ * and passes the node-pty native check. Any failure before that point
37
+ * aborts with the old daemon still running and still serving tasks.
38
+ * 3. Progress is journaled to a status file, so the answer survives the
39
+ * restart and can be read back by whichever daemon is alive afterwards.
40
+ */
41
+ export const UPDATE_DAEMON_CAPABILITY = "update_daemon";
42
+
43
+ const STATUS_FILE_NAME = "daemon-update.json";
44
+ const UPDATE_LOG_FILE_NAME = "daemon-update.log";
45
+ const MAX_LOG_TAIL_CHARS = 12_000;
46
+ const INSTALL_TIMEOUT_MS = 15 * 60_000;
47
+ const VERSION_CHECK_TIMEOUT_MS = 30_000;
48
+ const STOP_DAEMON_TIMEOUT_MS = 60_000;
49
+ const START_DAEMON_TIMEOUT_MS = 90_000;
50
+ const POLL_INTERVAL_MS = 300;
51
+
52
+ export function resolveDaemonUpdatePaths(env = process.env) {
53
+ const home = resolveConductorHome(env);
54
+ return {
55
+ statusPath: path.join(home, "state", STATUS_FILE_NAME),
56
+ logPath: path.join(home, "logs", UPDATE_LOG_FILE_NAME),
57
+ };
58
+ }
59
+
60
+ export function readDaemonUpdateStatus(statusPath, { readFileSync = fs.readFileSync } = {}) {
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(statusPath, "utf8"));
63
+ return parsed && typeof parsed === "object" ? parsed : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export function writeDaemonUpdateStatus(
70
+ statusPath,
71
+ status,
72
+ { mkdirSync = fs.mkdirSync, writeFileSync = fs.writeFileSync } = {},
73
+ ) {
74
+ try {
75
+ mkdirSync(path.dirname(statusPath), { recursive: true });
76
+ writeFileSync(statusPath, JSON.stringify(status));
77
+ } catch {
78
+ // A journal we cannot write must not abort the update itself.
79
+ }
80
+ }
81
+
82
+ function isProcessAlive(pid, killFn = process.kill) {
83
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
84
+ try {
85
+ killFn(pid, 0);
86
+ return true;
87
+ } catch (error) {
88
+ // EPERM means the process exists but belongs to another OS user.
89
+ return error?.code !== "ESRCH";
90
+ }
91
+ }
92
+
93
+ /**
94
+ * A status file left behind by an updater that died (machine reboot, SIGKILL)
95
+ * would otherwise wedge the feature at "running" forever.
96
+ */
97
+ function isUpdateStillRunning(status, killFn = process.kill) {
98
+ if (status?.status !== "running") return false;
99
+ if (!status.updaterPid) return true;
100
+ return isProcessAlive(status.updaterPid, killFn);
101
+ }
102
+
103
+ export function createDaemonUpdateHandlers({
104
+ statusPath,
105
+ logPath,
106
+ updaterScript,
107
+ updaterParams,
108
+ spawnFn = spawn,
109
+ killFn = process.kill.bind(process),
110
+ readStatus = readDaemonUpdateStatus,
111
+ writeStatus = writeDaemonUpdateStatus,
112
+ refuseReason = null,
113
+ env = process.env,
114
+ } = {}) {
115
+ function status() {
116
+ const current = readStatus(statusPath);
117
+ if (!current) {
118
+ return { status: "idle", runId: null, logPath };
119
+ }
120
+ if (current.status === "running" && !isUpdateStillRunning(current, killFn)) {
121
+ return {
122
+ ...current,
123
+ status: "failed",
124
+ error: current.error || "updater process exited without reporting a result",
125
+ };
126
+ }
127
+ return current;
128
+ }
129
+
130
+ function start() {
131
+ if (refuseReason) {
132
+ throw new Error(refuseReason);
133
+ }
134
+ const current = readStatus(statusPath);
135
+ if (isUpdateStillRunning(current, killFn)) {
136
+ throw new Error(`daemon update already running (run ${current.runId})`);
137
+ }
138
+
139
+ const runId = randomUUID();
140
+ const startedAt = new Date().toISOString();
141
+ const params = { ...updaterParams, runId, statusPath, logPath };
142
+
143
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
144
+ const logFd = fs.openSync(logPath, "a");
145
+ let child;
146
+ try {
147
+ // `detached` is the whole point: the updater gets its own process group,
148
+ // so stopping the daemon (step 3 of the update) cannot kill the process
149
+ // that is performing the update.
150
+ child = spawnFn(process.execPath, [updaterScript, JSON.stringify(params)], {
151
+ detached: true,
152
+ stdio: ["ignore", logFd, logFd],
153
+ // The package directory is about to be replaced by the installer; a
154
+ // cwd inside it would be pulled out from under the updater.
155
+ cwd: os.tmpdir(),
156
+ env: { ...env },
157
+ });
158
+ child.unref?.();
159
+ } finally {
160
+ try {
161
+ fs.closeSync(logFd);
162
+ } catch {
163
+ // ignore
164
+ }
165
+ }
166
+
167
+ const pending = {
168
+ runId,
169
+ status: "running",
170
+ phase: "starting",
171
+ message: "Update starting",
172
+ fromVersion: updaterParams?.currentVersion ?? null,
173
+ toVersion: null,
174
+ error: null,
175
+ logPath,
176
+ updaterPid: typeof child?.pid === "number" ? child.pid : null,
177
+ daemonRestarted: false,
178
+ startedAt,
179
+ updatedAt: startedAt,
180
+ finishedAt: null,
181
+ log: "",
182
+ };
183
+ writeStatus(statusPath, pending);
184
+ return pending;
185
+ }
186
+
187
+ async function dispatch(payload) {
188
+ const action = payload?.action;
189
+ try {
190
+ switch (action) {
191
+ case "start":
192
+ return { result: start() };
193
+ case "status":
194
+ return { result: status() };
195
+ default:
196
+ return { error: `unknown action: ${action}` };
197
+ }
198
+ } catch (error) {
199
+ return { error: error instanceof Error ? error.message : String(error) };
200
+ }
201
+ }
202
+
203
+ return { dispatch, statusPath, logPath };
204
+ }
205
+
206
+ export async function handleUpdateDaemonRequest(client, handlers, payload) {
207
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
208
+ const action = payload?.action ? String(payload.action) : "";
209
+ if (!requestId) {
210
+ return { error: "missing request_id" };
211
+ }
212
+
213
+ const response = await handlers.dispatch({ action });
214
+ await client
215
+ .sendJson({
216
+ type: "update_daemon_response",
217
+ payload: {
218
+ request_id: requestId,
219
+ action,
220
+ ...(response?.error ? { error: response.error } : { result: response?.result }),
221
+ },
222
+ })
223
+ .catch(() => {});
224
+ return response;
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Updater process
229
+ // ---------------------------------------------------------------------------
230
+
231
+ function defaultRunCommand(command, args, options = {}) {
232
+ return new Promise((resolve) => {
233
+ let stdout = "";
234
+ let stderr = "";
235
+ const child = spawn(command, args, {
236
+ stdio: ["ignore", "pipe", "pipe"],
237
+ env: options.env || process.env,
238
+ cwd: options.cwd || os.tmpdir(),
239
+ });
240
+ const timer = setTimeout(() => {
241
+ try {
242
+ child.kill("SIGTERM");
243
+ } catch {
244
+ // ignore
245
+ }
246
+ }, options.timeoutMs ?? 120_000);
247
+ child.stdout?.on("data", (chunk) => {
248
+ if (stdout.length < 16_000) stdout += chunk.toString();
249
+ });
250
+ child.stderr?.on("data", (chunk) => {
251
+ if (stderr.length < 16_000) stderr += chunk.toString();
252
+ });
253
+ child.on("close", (code) => {
254
+ clearTimeout(timer);
255
+ resolve({ success: code === 0, code, stdout, stderr });
256
+ });
257
+ child.on("error", (error) => {
258
+ clearTimeout(timer);
259
+ resolve({ success: false, code: -1, stdout, stderr: error?.message || String(error) });
260
+ });
261
+ });
262
+ }
263
+
264
+ function buildInstallCommand(packageManager, pkgSpec) {
265
+ switch (packageManager) {
266
+ case "pnpm":
267
+ return { command: "pnpm", args: ["add", "-g", ...buildPnpmAllowBuildArgs(["node-pty"]), pkgSpec] };
268
+ case "yarn":
269
+ return { command: "yarn", args: ["global", "add", pkgSpec] };
270
+ default:
271
+ return { command: "npm", args: ["install", "-g", pkgSpec] };
272
+ }
273
+ }
274
+
275
+ function buildUninstallCommand(packageManager, packageName) {
276
+ switch (packageManager) {
277
+ case "pnpm":
278
+ return { command: "pnpm", args: ["remove", "-g", packageName] };
279
+ case "yarn":
280
+ return { command: "yarn", args: ["global", "remove", packageName] };
281
+ default:
282
+ return { command: "npm", args: ["uninstall", "-g", packageName] };
283
+ }
284
+ }
285
+
286
+ /**
287
+ * `npm install -g` targets whichever npm wins the PATH lookup, which is not
288
+ * necessarily the one that installed us. Pin it to the prefix the running
289
+ * package actually lives in, exactly like `conductor update` does.
290
+ */
291
+ function buildInstallEnv(packageManager, packageRoot, env) {
292
+ const installEnv = { ...env };
293
+ if (packageManager === "npm") {
294
+ const prefix = resolveGlobalInstallPrefix(packageRoot);
295
+ if (prefix) installEnv.npm_config_prefix = prefix;
296
+ }
297
+ return installEnv;
298
+ }
299
+
300
+ export async function runDaemonUpdate(params = {}, deps = {}) {
301
+ const {
302
+ runId = randomUUID(),
303
+ statusPath,
304
+ logPath = null,
305
+ packageRoot,
306
+ packageName = PACKAGE_NAME,
307
+ launcherScript,
308
+ launcherArgs = [],
309
+ versionCheckScript = null,
310
+ versionCheckArgs = ["--version"],
311
+ daemonPid = null,
312
+ lockFile,
313
+ daemonLogPath = null,
314
+ currentVersion = "unknown",
315
+ env = process.env,
316
+ } = params;
317
+
318
+ const runCommand = deps.runCommand || defaultRunCommand;
319
+ const spawnFn = deps.spawn || spawn;
320
+ const killFn = deps.kill || process.kill.bind(process);
321
+ const readFileSync = deps.readFileSync || fs.readFileSync;
322
+ const rmSync = deps.rmSync || fs.rmSync;
323
+ const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
324
+ const now = deps.now || (() => Date.now());
325
+ const writeStatus = deps.writeStatus || writeDaemonUpdateStatus;
326
+ const fetchLatestVersionFn = deps.fetchLatestVersion || fetchLatestVersion;
327
+ const detectPackageManagerFn = deps.detectPackageManager || detectPackageManager;
328
+ const repairAndVerifyNodePty = deps.repairAndVerifyGlobalNodePty || repairAndVerifyGlobalNodePty;
329
+ const ensurePnpmAllowlist = deps.ensurePnpmOnlyBuiltDependencies || ensurePnpmOnlyBuiltDependencies;
330
+ const writeLine = deps.writeLine || ((line) => process.stdout.write(`${line}\n`));
331
+
332
+ const startedAt = new Date().toISOString();
333
+ const state = {
334
+ runId,
335
+ status: "running",
336
+ phase: "resolving",
337
+ message: "Resolving latest version",
338
+ fromVersion: currentVersion,
339
+ toVersion: null,
340
+ error: null,
341
+ logPath,
342
+ updaterPid: process.pid,
343
+ daemonRestarted: false,
344
+ startedAt,
345
+ updatedAt: startedAt,
346
+ finishedAt: null,
347
+ log: "",
348
+ };
349
+
350
+ const log = (line) => {
351
+ const text = `==> ${line}`;
352
+ writeLine(text);
353
+ state.log = `${state.log}${text}\n`.slice(-MAX_LOG_TAIL_CHARS);
354
+ };
355
+ const publish = (updates = {}) => {
356
+ Object.assign(state, updates, { updatedAt: new Date().toISOString() });
357
+ if (statusPath) writeStatus(statusPath, state);
358
+ };
359
+ const finish = (updates) => {
360
+ publish({ ...updates, finishedAt: new Date().toISOString() });
361
+ return { ...state };
362
+ };
363
+ let daemonStopped = false;
364
+ const fail = (message) => {
365
+ log(`FAILED: ${message}`);
366
+ log(
367
+ daemonStopped
368
+ ? "the daemon was already stopped for the restart; start it again on this machine"
369
+ : "daemon was left running on the current version",
370
+ );
371
+ return finish({ status: "failed", phase: "done", message: "Update failed", error: message });
372
+ };
373
+
374
+ log(`current version: ${currentVersion}`);
375
+ publish({});
376
+
377
+ // --- 1. Resolve the target version -------------------------------------
378
+ let latestVersion;
379
+ try {
380
+ latestVersion = await fetchLatestVersionFn(packageName);
381
+ } catch (error) {
382
+ return fail(`could not reach the npm registry: ${error?.message || error}`);
383
+ }
384
+ if (!latestVersion) {
385
+ return fail("could not resolve the latest version from the npm registry");
386
+ }
387
+ log(`latest version: ${latestVersion}`);
388
+ if (!isNewerVersion(latestVersion, currentVersion)) {
389
+ log("already up to date; daemon left untouched");
390
+ return finish({
391
+ status: "completed",
392
+ phase: "done",
393
+ toVersion: currentVersion,
394
+ message: `Already on the latest version (${currentVersion})`,
395
+ });
396
+ }
397
+
398
+ // --- 2. Install ---------------------------------------------------------
399
+ const packageManager = detectPackageManagerFn({
400
+ launcherPath: versionCheckScript || launcherScript,
401
+ packageRoot,
402
+ });
403
+ const pkgSpec = `${packageName}@${latestVersion}`;
404
+ const installEnv = buildInstallEnv(packageManager, packageRoot, env);
405
+ publish({
406
+ phase: "installing",
407
+ toVersion: latestVersion,
408
+ message: `Installing ${pkgSpec} via ${packageManager}`,
409
+ });
410
+
411
+ if (packageManager === "pnpm") {
412
+ try {
413
+ await ensurePnpmAllowlist({ runCommand, dependencies: ["node-pty"], global: true });
414
+ } catch (error) {
415
+ log(`could not prepare the pnpm build allowlist: ${error?.message || error}`);
416
+ }
417
+ }
418
+
419
+ const install = buildInstallCommand(packageManager, pkgSpec);
420
+ const runInstall = () =>
421
+ runCommand(install.command, install.args, { env: installEnv, timeoutMs: INSTALL_TIMEOUT_MS });
422
+
423
+ log(`${install.command} ${install.args.join(" ")}`);
424
+ let installResult = await runInstall();
425
+ if (!installResult.success) {
426
+ // A half-removed global install makes npm fail with ENOTEMPTY forever.
427
+ // Clear it out and try once more before giving up.
428
+ log(`install failed (exit ${installResult.code}); removing the broken install and retrying`);
429
+ log(String(installResult.stderr || installResult.stdout || "").trim().slice(-800));
430
+ const uninstall = buildUninstallCommand(packageManager, packageName);
431
+ await runCommand(uninstall.command, uninstall.args, { env: installEnv, timeoutMs: INSTALL_TIMEOUT_MS });
432
+ try {
433
+ const packageDirectory = await resolveGlobalPackageDirectory({
434
+ packageManager,
435
+ packageName,
436
+ runCommand: (command, args, options) => runCommand(command, args, { env: installEnv, ...options }),
437
+ });
438
+ rmSync(packageDirectory, { recursive: true, force: true });
439
+ log(`removed ${packageDirectory}`);
440
+ } catch (error) {
441
+ log(`could not remove the global package directory: ${error?.message || error}`);
442
+ }
443
+ installResult = await runInstall();
444
+ }
445
+ if (!installResult.success) {
446
+ return fail(
447
+ `install failed (exit ${installResult.code}): ${String(installResult.stderr || installResult.stdout || "")
448
+ .trim()
449
+ .slice(-500)}`,
450
+ );
451
+ }
452
+
453
+ // --- 3. Verify BEFORE touching the running daemon -----------------------
454
+ publish({ phase: "verifying", message: `Verifying ${latestVersion}` });
455
+
456
+ const installedVersion = await readInstalledVersion({
457
+ runCommand,
458
+ readFileSync,
459
+ versionCheckScript,
460
+ versionCheckArgs,
461
+ launcherScript,
462
+ packageRoot,
463
+ env: installEnv,
464
+ });
465
+ if (installedVersion !== latestVersion) {
466
+ return fail(`version mismatch after install: expected ${latestVersion}, got ${installedVersion || "unknown"}`);
467
+ }
468
+
469
+ try {
470
+ await repairAndVerifyNodePty({
471
+ packageManager,
472
+ packageName,
473
+ runCommand: (command, args, options) => runCommand(command, args, { env: installEnv, ...options }),
474
+ nodeExecutable: process.execPath,
475
+ });
476
+ } catch (error) {
477
+ return fail(`native dependency verification failed: ${error?.message || error}`);
478
+ }
479
+ log(`installed and verified ${latestVersion}`);
480
+
481
+ // --- 4. Restart the daemon onto the new version -------------------------
482
+ if (!launcherScript) {
483
+ return fail("installed the new version but no daemon launcher is known, so the daemon was not restarted");
484
+ }
485
+ publish({ phase: "restarting", message: `Restarting daemon on ${latestVersion}` });
486
+
487
+ if (daemonPid && isProcessAlive(daemonPid, killFn)) {
488
+ log(`stopping daemon (PID ${daemonPid})`);
489
+ try {
490
+ killFn(daemonPid, "SIGTERM");
491
+ } catch (error) {
492
+ log(`SIGTERM failed: ${error?.message || error}`);
493
+ }
494
+ const stopped = await waitFor(
495
+ () => !isProcessAlive(daemonPid, killFn),
496
+ STOP_DAEMON_TIMEOUT_MS,
497
+ { sleep, now },
498
+ );
499
+ if (!stopped) {
500
+ log(`daemon did not exit within ${STOP_DAEMON_TIMEOUT_MS}ms; sending SIGKILL`);
501
+ try {
502
+ killFn(daemonPid, "SIGKILL");
503
+ } catch {
504
+ // ignore
505
+ }
506
+ await waitFor(() => !isProcessAlive(daemonPid, killFn), 5_000, { sleep, now });
507
+ }
508
+ daemonStopped = true;
509
+ }
510
+
511
+ // `--force` lets the fresh daemon take over the lock even if the outgoing one
512
+ // had to be SIGKILLed and never cleaned it up.
513
+ const args = launcherArgs.includes("--force") ? launcherArgs : [...launcherArgs, "--force"];
514
+ let logFd = null;
515
+ try {
516
+ if (daemonLogPath) {
517
+ fs.mkdirSync(path.dirname(daemonLogPath), { recursive: true });
518
+ logFd = fs.openSync(daemonLogPath, "a");
519
+ }
520
+ const child = spawnFn(process.execPath, [launcherScript, ...args], {
521
+ detached: true,
522
+ stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore"],
523
+ cwd: os.tmpdir(),
524
+ env: { ...env },
525
+ });
526
+ child.unref?.();
527
+ log(`started daemon (PID ${child?.pid ?? "unknown"}): ${launcherScript} ${args.join(" ")}`);
528
+ } catch (error) {
529
+ return fail(`installed ${latestVersion} but failed to start the new daemon: ${error?.message || error}`);
530
+ } finally {
531
+ if (typeof logFd === "number") {
532
+ try {
533
+ fs.closeSync(logFd);
534
+ } catch {
535
+ // ignore
536
+ }
537
+ }
538
+ }
539
+
540
+ const cameUp = await waitFor(
541
+ () => {
542
+ const lockState = parseDaemonLockState(safeRead(readFileSync, lockFile));
543
+ const pid = lockState?.pid;
544
+ return Boolean(pid) && pid !== daemonPid && isProcessAlive(pid, killFn);
545
+ },
546
+ START_DAEMON_TIMEOUT_MS,
547
+ { sleep, now },
548
+ );
549
+ if (!cameUp) {
550
+ return fail(
551
+ `installed ${latestVersion} but the new daemon did not come up within ${Math.round(
552
+ START_DAEMON_TIMEOUT_MS / 1000,
553
+ )}s — check ${daemonLogPath || "the daemon log"}`,
554
+ );
555
+ }
556
+
557
+ log(`daemon is back up on ${latestVersion}`);
558
+ return finish({
559
+ status: "completed",
560
+ phase: "done",
561
+ daemonRestarted: true,
562
+ message: `Updated ${currentVersion} → ${latestVersion} and restarted the daemon`,
563
+ });
564
+ }
565
+
566
+ function safeRead(readFileSync, filePath) {
567
+ if (!filePath) return "";
568
+ try {
569
+ return readFileSync(filePath, "utf8");
570
+ } catch {
571
+ return "";
572
+ }
573
+ }
574
+
575
+ async function waitFor(predicate, timeoutMs, { sleep, now }) {
576
+ const deadline = now() + timeoutMs;
577
+ for (;;) {
578
+ if (predicate()) return true;
579
+ if (now() >= deadline) return false;
580
+ await sleep(POLL_INTERVAL_MS);
581
+ }
582
+ }
583
+
584
+ async function readInstalledVersion({
585
+ runCommand,
586
+ readFileSync,
587
+ versionCheckScript,
588
+ versionCheckArgs,
589
+ launcherScript,
590
+ packageRoot,
591
+ env,
592
+ }) {
593
+ const script = versionCheckScript || launcherScript;
594
+ const attempts = script
595
+ ? [{ command: process.execPath, args: [script, ...versionCheckArgs] }]
596
+ : [{ command: "conductor", args: ["--version"] }];
597
+
598
+ for (const attempt of attempts) {
599
+ const result = await runCommand(attempt.command, attempt.args, {
600
+ env,
601
+ timeoutMs: VERSION_CHECK_TIMEOUT_MS,
602
+ });
603
+ const combined = `${result.stdout}\n${result.stderr}`;
604
+ const match = combined.match(/conductor version ([^\s]+)/);
605
+ if (match?.[1]) return match[1];
606
+ }
607
+
608
+ // Fall back to the package.json the installer just rewrote on disk.
609
+ try {
610
+ return JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")).version || null;
611
+ } catch {
612
+ return null;
613
+ }
614
+ }