@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.
- package/CHANGELOG.md +129 -0
- package/bin/conductor-config.js +133 -8
- package/bin/conductor-daemon.js +53 -29
- package/bin/conductor-diagnose.js +3 -1
- package/bin/conductor-fire.js +11 -0
- package/bin/conductor-project.js +3 -1
- package/bin/conductor-update.js +13 -0
- package/package.json +5 -5
- package/src/daemon-lock.js +240 -0
- package/src/daemon.js +717 -123
- package/src/guest-daemon.js +268 -0
- package/src/runtime-backends.js +63 -2
- package/src/version-check.js +23 -0
|
@@ -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
|
+
}
|