@love-moon/conductor-cli 0.9.0 → 0.11.0

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,240 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+
4
+ // Shared reader/writer for `${WORKSPACE_ROOT}/daemon.pid`.
5
+ //
6
+ // The lock file is read by two independent code paths that must agree:
7
+ // - `src/daemon.js` (in-process lock acquisition, including `--force`)
8
+ // - `bin/conductor-daemon.js` (the `--nohup` preflight)
9
+ // Keeping the parse/serialize/compare logic here is what stops them drifting.
10
+ export const DAEMON_LOCK_FILE_NAME = "daemon.pid";
11
+
12
+ // Opt-in escape hatch for the legacy-lock case documented on
13
+ // `compareDaemonLockIdentity` below.
14
+ export const FORCE_KILL_UNKNOWN_OWNER_ENV_VAR = "CONDUCTOR_DAEMON_FORCE_KILL_UNKNOWN";
15
+
16
+ function normalizeOptionalString(value) {
17
+ return typeof value === "string" && value.trim() ? value.trim() : "";
18
+ }
19
+
20
+ function normalizePositiveInt(value, fallback = null) {
21
+ const parsed = Number.parseInt(value, 10);
22
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
23
+ }
24
+
25
+ function normalizePathField(value) {
26
+ const normalized = normalizeOptionalString(value);
27
+ return normalized ? path.resolve(normalized) : "";
28
+ }
29
+
30
+ /**
31
+ * The instance fingerprint deliberately covers only the three *path* axes that
32
+ * define a Conductor instance (see RFC 0036): where its config lives, where its
33
+ * state lives, and where its workspace lives. Two daemons that differ on any of
34
+ * these are separate instances and must never force-kill each other.
35
+ *
36
+ * `daemon_name` and `backend_url` are recorded alongside it but intentionally
37
+ * left OUT of the hash: both are mutable config *contents* that a user
38
+ * legitimately edits between restarts (rename the daemon, point it at a
39
+ * different backend), and hashing them would turn "restart my own daemon after
40
+ * a config edit" into a refusal.
41
+ */
42
+ export function computeDaemonInstanceId({ conductorHome, configPath, workspaceRoot }) {
43
+ const parts = [
44
+ normalizePathField(conductorHome),
45
+ normalizePathField(configPath),
46
+ normalizePathField(workspaceRoot),
47
+ ];
48
+ if (!parts.some(Boolean)) {
49
+ return "";
50
+ }
51
+ return crypto.createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 32);
52
+ }
53
+
54
+ export function buildDaemonInstanceIdentity({
55
+ conductorHome,
56
+ configPath,
57
+ workspaceRoot,
58
+ daemonName,
59
+ backendUrl,
60
+ } = {}) {
61
+ const normalized = {
62
+ conductorHome: normalizePathField(conductorHome),
63
+ configPath: normalizePathField(configPath),
64
+ workspaceRoot: normalizePathField(workspaceRoot),
65
+ };
66
+ return {
67
+ ...normalized,
68
+ instanceId: computeDaemonInstanceId(normalized),
69
+ daemonName: normalizeOptionalString(daemonName),
70
+ backendUrl: normalizeOptionalString(backendUrl),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Accepts both the legacy bare-pid file written by CLIs older than the identity
76
+ * change and the JSON payload written by `serializeDaemonLock`.
77
+ * Returns `null` for empty/malformed content.
78
+ */
79
+ export function parseDaemonLockState(raw) {
80
+ const text = String(raw ?? "").trim();
81
+ if (!text) {
82
+ return null;
83
+ }
84
+
85
+ const barePid = normalizePositiveInt(text);
86
+ if (barePid !== null && String(barePid) === text) {
87
+ return {
88
+ pid: barePid,
89
+ instanceId: "",
90
+ conductorHome: "",
91
+ configPath: "",
92
+ workspaceRoot: "",
93
+ daemonName: "",
94
+ backendUrl: "",
95
+ handoffFromPid: null,
96
+ handoffToken: null,
97
+ handoffExpiresAt: null,
98
+ };
99
+ }
100
+
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(text);
104
+ } catch {
105
+ return null;
106
+ }
107
+ if (!parsed || typeof parsed !== "object") {
108
+ return null;
109
+ }
110
+
111
+ const handoffFromPid = normalizePositiveInt(parsed.handoff_from_pid);
112
+ return {
113
+ pid: normalizePositiveInt(parsed.pid) ?? handoffFromPid,
114
+ instanceId: normalizeOptionalString(parsed.instance_id),
115
+ conductorHome: normalizeOptionalString(parsed.conductor_home),
116
+ configPath: normalizeOptionalString(parsed.config_path),
117
+ workspaceRoot: normalizeOptionalString(parsed.workspace_root),
118
+ daemonName: normalizeOptionalString(parsed.daemon_name),
119
+ backendUrl: normalizeOptionalString(parsed.backend_url),
120
+ handoffFromPid,
121
+ handoffToken: normalizeOptionalString(parsed.handoff_token) || null,
122
+ handoffExpiresAt: normalizePositiveInt(parsed.handoff_expires_at),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Single writer for the lock file. `handoff` is the auto-update takeover
128
+ * payload; when present the recorded pid is the *outgoing* daemon's pid, which
129
+ * is exactly what the pre-existing handoff protocol expects.
130
+ */
131
+ export function serializeDaemonLock({ pid, identity, handoff } = {}) {
132
+ const payload = {
133
+ pid: normalizePositiveInt(pid),
134
+ instance_id: identity?.instanceId || "",
135
+ daemon_name: identity?.daemonName || "",
136
+ config_path: identity?.configPath || "",
137
+ conductor_home: identity?.conductorHome || "",
138
+ workspace_root: identity?.workspaceRoot || "",
139
+ backend_url: identity?.backendUrl || "",
140
+ };
141
+ if (handoff) {
142
+ payload.handoff_from_pid = normalizePositiveInt(handoff.handoffFromPid);
143
+ payload.handoff_token = handoff.handoffToken || null;
144
+ payload.handoff_expires_at = normalizePositiveInt(handoff.handoffExpiresAt);
145
+ }
146
+ return JSON.stringify(payload);
147
+ }
148
+
149
+ /**
150
+ * Ownership verdict for a lock file relative to the instance about to act on it.
151
+ *
152
+ * "self" — the lock records our instance fingerprint; force-restart is ours to do.
153
+ * "other" — the lock records a *different* instance; never kill it.
154
+ * "unknown" — legacy lock with no identity field (written by an older CLI), or
155
+ * we could not fingerprint ourselves.
156
+ *
157
+ * On "unknown" callers must refuse by default. There is no way to interrogate a
158
+ * running process for its Conductor identity from the outside: macOS does not
159
+ * expose another process's environment (`ps -E`/`ps eww` print argv only, even
160
+ * for processes you own), and the daemon's instance is selected mostly by env
161
+ * vars, so argv tells us nothing either. Since we cannot obtain a positive
162
+ * same-instance signal, guessing "it's probably mine" is exactly the
163
+ * cross-account kill primitive this check exists to remove.
164
+ *
165
+ * The legitimate "restart my own daemon after upgrading the CLI" flow is not
166
+ * hard-broken by that refusal:
167
+ * - Auto-update restarts go through the handoff token, which *is* a verifiable
168
+ * positive signal (a secret the outgoing daemon hands to its own successor)
169
+ * and is checked before ownership; legacy handoff locks keep working.
170
+ * - A stale legacy lock (dead pid) is still cleaned up silently.
171
+ * - A live legacy lock only needs a one-time manual `kill <pid>` or an explicit
172
+ * `CONDUCTOR_DAEMON_FORCE_KILL_UNKNOWN=1`, after which every subsequent lock
173
+ * carries an identity and `--force` works normally again.
174
+ */
175
+ export function compareDaemonLockIdentity(lockState, identity) {
176
+ const lockInstanceId = normalizeOptionalString(lockState?.instanceId);
177
+ const selfInstanceId = normalizeOptionalString(identity?.instanceId);
178
+ if (!lockInstanceId || !selfInstanceId) {
179
+ return "unknown";
180
+ }
181
+ return lockInstanceId === selfInstanceId ? "self" : "other";
182
+ }
183
+
184
+ export function isForceKillUnknownOwnerEnabled(env = process.env) {
185
+ const value = normalizeOptionalString(env?.[FORCE_KILL_UNKNOWN_OWNER_ENV_VAR]).toLowerCase();
186
+ return value === "1" || value === "true" || value === "yes" || value === "on";
187
+ }
188
+
189
+ function describeIdentityFields({ daemonName, configPath, workspaceRoot, backendUrl }) {
190
+ const details = [];
191
+ if (configPath) details.push(`config ${configPath}`);
192
+ if (workspaceRoot) details.push(`workspace ${workspaceRoot}`);
193
+ if (backendUrl) details.push(`backend ${backendUrl}`);
194
+ const name = daemonName ? `daemon_name "${daemonName}"` : "an unnamed daemon";
195
+ return details.length > 0 ? `${name} (${details.join(", ")})` : name;
196
+ }
197
+
198
+ export function describeDaemonLockOwner(lockState) {
199
+ if (!lockState?.instanceId) {
200
+ return "an unidentified daemon (lock file written by an older Conductor CLI)";
201
+ }
202
+ return describeIdentityFields(lockState);
203
+ }
204
+
205
+ export function describeDaemonInstance(identity) {
206
+ return describeIdentityFields(identity || {});
207
+ }
208
+
209
+ /**
210
+ * Single source of truth for whether `--force` may stop the process recorded in
211
+ * the lock file. Returns null when the force restart may proceed, otherwise the
212
+ * user-facing refusal reason.
213
+ *
214
+ * Both `src/daemon.js` and the `bin/conductor-daemon.js --nohup` preflight call
215
+ * this so the two paths cannot disagree about who owns the lock.
216
+ */
217
+ export function describeForceRestartRefusal({ lockState, identity, lockFile, env = process.env } = {}) {
218
+ const ownership = compareDaemonLockIdentity(lockState, identity);
219
+ if (ownership === "self") {
220
+ return null;
221
+ }
222
+ const pid = lockState?.pid;
223
+ const held = lockFile ? `${lockFile} is held by PID ${pid}` : `PID ${pid} holds the daemon lock`;
224
+ if (ownership === "other") {
225
+ return (
226
+ `refusing --force: ${held}, which belongs to a different Conductor instance — ${describeDaemonLockOwner(lockState)}. ` +
227
+ `This instance is ${describeDaemonInstance(identity)}. ` +
228
+ `Give each instance its own CONDUCTOR_WS (and CONDUCTOR_HOME), or stop PID ${pid} yourself.`
229
+ );
230
+ }
231
+ if (isForceKillUnknownOwnerEnabled(env)) {
232
+ return null;
233
+ }
234
+ return (
235
+ `refusing --force: ${held} but records no instance identity, so it cannot be confirmed to be this daemon. ` +
236
+ `This instance is ${describeDaemonInstance(identity)}. ` +
237
+ `If PID ${pid} is your own daemon from an older CLI, stop it with \`kill ${pid}\` or re-run with ` +
238
+ `${FORCE_KILL_UNKNOWN_OWNER_ENV_VAR}=1 (one time only; the next lock file will carry an identity).`
239
+ );
240
+ }