external-subagent 0.4.1 → 0.5.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/README.md +37 -19
- package/bin/external-subagent-debug-mcp.mjs +2 -1
- package/bin/external-subagent-mcp.mjs +36 -11
- package/cli/commands/config.mjs +6 -1
- package/cli/commands/daemon.mjs +12 -2
- package/cli/config/write.mjs +21 -3
- package/cli/constants.mjs +35 -1
- package/cli/install/init.mjs +30 -11
- package/cli/install/layout.mjs +11 -4
- package/cli/install/path.mjs +35 -10
- package/cli/install/payload.mjs +86 -6
- package/cli/install/service-activation.mjs +133 -1
- package/cli/install/service-linux.mjs +327 -0
- package/cli/install/service-macos.mjs +59 -18
- package/cli/install/update.mjs +7 -1
- package/cli/main.mjs +25 -11
- package/cli/maintenance.mjs +16 -11
- package/cli/paths.mjs +57 -8
- package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
- package/npm/native/darwin-arm64/external-subagentd +0 -0
- package/npm/native/darwin-arm64/payload.json +4 -4
- package/npm/native/linux-x64/external-subagent-mcp +0 -0
- package/npm/native/linux-x64/external-subagentd +0 -0
- package/npm/native/linux-x64/payload.json +20 -0
- package/package.json +6 -3
- package/plugins/codex/external-subagent/.codex-plugin/plugin.json +1 -1
- package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +16 -2
- package/scripts/release/build-native-payload.mjs +18 -6
- package/scripts/release/check-native-tarball.mjs +99 -31
- package/scripts/release/test-installed-tarball.mjs +6 -1
|
@@ -4,7 +4,9 @@ import { atomicWrite } from '../fs-atomic.mjs';
|
|
|
4
4
|
import { CliError } from '../errors.mjs';
|
|
5
5
|
import { callDaemon } from '../rpc.mjs';
|
|
6
6
|
import { LAUNCH_AGENT_LABEL } from '../constants.mjs';
|
|
7
|
+
import { platform } from '../paths.mjs';
|
|
7
8
|
import { launchAgentPlist, launchctl } from './service-macos.mjs';
|
|
9
|
+
import { SYSTEMD_UNIT_NAME, execStartProgram, replaceExecStartProgram, systemctl, systemdUnit, unitServiceStatus } from './service-linux.mjs';
|
|
8
10
|
|
|
9
11
|
const xml = (s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
10
12
|
const unxml = (s) => s.replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&');
|
|
@@ -15,12 +17,18 @@ export function hasInstalledService(paths) {
|
|
|
15
17
|
return Boolean(paths.launchAgent && fs.existsSync(paths.launchAgent));
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
// Platform dispatch (S03): the same activation health/rollback contract runs
|
|
21
|
+
// over launchd on macOS (verbatim) and the systemd user manager on Linux.
|
|
22
|
+
export async function activateService(paths, candidate, options = {}) {
|
|
23
|
+
return platform() === 'darwin' ? activateLaunchdService(paths, candidate, options) : activateSystemdService(paths, candidate, options);
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
// The service definition is derived from the CURRENT config: activation
|
|
19
27
|
// regenerates the whole plist (environment, revision, runtime forwarding) so a
|
|
20
28
|
// reconfigured installation can never keep serving stale launchd environment.
|
|
21
29
|
// Only rollback restores the previous definition bytes — the exact known state
|
|
22
30
|
// the service ran before — with its executable repointed.
|
|
23
|
-
|
|
31
|
+
async function activateLaunchdService(paths, candidate, options = {}) {
|
|
24
32
|
const control = options.launchctl || launchctl;
|
|
25
33
|
const rpc = options.callDaemon || callDaemon;
|
|
26
34
|
const target = `gui/${process.getuid()}/${LAUNCH_AGENT_LABEL}`;
|
|
@@ -138,3 +146,127 @@ export async function activateService(paths, candidate, options = {}) {
|
|
|
138
146
|
throw error;
|
|
139
147
|
}
|
|
140
148
|
}
|
|
149
|
+
|
|
150
|
+
// The systemd twin of the launchd activation above: same drain/claim prelude,
|
|
151
|
+
// same health verification (a NEW process running the selected artifact's
|
|
152
|
+
// exact bytes and self-reporting its identity), same rollback-to-retained-bytes
|
|
153
|
+
// contract. unload = disable --now + the bounded inactive confirmation;
|
|
154
|
+
// load = daemon-reload + enable --now (the same bootstrap init uses).
|
|
155
|
+
async function activateSystemdService(paths, candidate, options = {}) {
|
|
156
|
+
const control = options.systemctl || systemctl;
|
|
157
|
+
const rpc = options.callDaemon || callDaemon;
|
|
158
|
+
const oldUnit = fs.readFileSync(paths.launchAgent);
|
|
159
|
+
const oldPath = execStartProgram(oldUnit);
|
|
160
|
+
if (!oldPath) throw new CliError('SERVICE_DEFINITION_INVALID', 'installed service has no executable entry');
|
|
161
|
+
const servicePid = async () => {
|
|
162
|
+
const view = unitServiceStatus(control);
|
|
163
|
+
if (view.skipped) throw new CliError('SERVICE_NOT_VERIFIED', 'service control was skipped');
|
|
164
|
+
if (!view.registered) return null;
|
|
165
|
+
return view.pid;
|
|
166
|
+
};
|
|
167
|
+
const oldPid = await servicePid();
|
|
168
|
+
const oldStatus = oldPid ? await rpc(paths.socket, 'status', {}) : null;
|
|
169
|
+
// The running daemon's self-reported digest is the truth about which bytes
|
|
170
|
+
// the service was executing; the file at oldPath may already carry the NEXT
|
|
171
|
+
// version after an npm install replaced the package directory in place.
|
|
172
|
+
const runningArtifact = oldStatus?.identity?.daemon?.artifact;
|
|
173
|
+
const oldSha = (typeof runningArtifact?.sha256 === 'string' && runningArtifact.sha256) || digest(oldPath);
|
|
174
|
+
if (oldPid) {
|
|
175
|
+
const drain = await rpc(paths.socket, 'drain-status', {});
|
|
176
|
+
if (!drain.ready_for_activation) throw new CliError('DAEMON_NOT_DRAINED', 'daemon still owns active or unreaped work');
|
|
177
|
+
const claim = await rpc(paths.socket, 'activate-ready', {});
|
|
178
|
+
if (!claim.activation_claim) throw new CliError('ACTIVATION_NOT_CLAIMED', 'daemon did not grant activation');
|
|
179
|
+
}
|
|
180
|
+
// npm may have replaced the old program path with the new payload: rolling
|
|
181
|
+
// the service back must run the OLD bytes, from the original path when they
|
|
182
|
+
// are still intact or from the retained payload copy the updater staged
|
|
183
|
+
// for exactly this recovery.
|
|
184
|
+
const rollbackExecutable = () => {
|
|
185
|
+
if (fs.existsSync(oldPath) && digest(oldPath) === oldSha) return { path: oldPath, sha256: oldSha };
|
|
186
|
+
const retained = options.rollbackPayload;
|
|
187
|
+
if (retained && typeof retained.path === 'string' && typeof retained.sha256 === 'string'
|
|
188
|
+
&& fs.existsSync(retained.path) && digest(retained.path) === retained.sha256) {
|
|
189
|
+
return { path: retained.path, sha256: retained.sha256 };
|
|
190
|
+
}
|
|
191
|
+
throw new CliError('ROLLBACK_PAYLOAD_UNAVAILABLE', 'previous payload bytes are unavailable; automatic rollback refused');
|
|
192
|
+
};
|
|
193
|
+
const healthy = async (expected, previousPid, previousGeneration) => {
|
|
194
|
+
const deadline = Date.now() + (options.healthTimeoutMs ?? 10_000);
|
|
195
|
+
let last;
|
|
196
|
+
do {
|
|
197
|
+
try {
|
|
198
|
+
const pid = await servicePid();
|
|
199
|
+
if (!pid || pid === previousPid) throw new Error('service has no new process');
|
|
200
|
+
const status = await rpc(paths.socket, 'status', {});
|
|
201
|
+
const identity = status.identity?.daemon;
|
|
202
|
+
// Health verification uses the service generation and the running
|
|
203
|
+
// artifact identity. The diagnostic-only mcp_version never gates
|
|
204
|
+
// admission.
|
|
205
|
+
if (!status.service_generation || status.service_generation === previousGeneration
|
|
206
|
+
|| identity?.artifact?.path !== expected.path || identity?.artifact?.sha256 !== expected.sha256
|
|
207
|
+
|| (expected.version && identity?.version !== expected.version)) throw new Error('running daemon identity does not match selected payload');
|
|
208
|
+
if (await servicePid() !== pid) throw new Error('service process changed during health verification');
|
|
209
|
+
return { pid, version: identity.version, artifact: identity.artifact, service_generation: status.service_generation };
|
|
210
|
+
} catch (error) { last = error; }
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
212
|
+
} while (Date.now() < deadline);
|
|
213
|
+
throw new CliError('SERVICE_HEALTH_FAILED', last?.message || 'service did not become healthy');
|
|
214
|
+
};
|
|
215
|
+
const unload = async () => {
|
|
216
|
+
const view = unitServiceStatus(control);
|
|
217
|
+
if (view.skipped || !view.registered) return;
|
|
218
|
+
await control(['disable', '--now', SYSTEMD_UNIT_NAME]);
|
|
219
|
+
const deadline = Date.now() + (options.unloadTimeoutMs ?? 10_000);
|
|
220
|
+
do {
|
|
221
|
+
const probe = unitServiceStatus(control);
|
|
222
|
+
if (probe.skipped || !probe.registered || (probe.state === 'inactive' && probe.pid === null)) return;
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
224
|
+
} while (Date.now() < deadline);
|
|
225
|
+
throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'systemd service remained active after disable --now');
|
|
226
|
+
};
|
|
227
|
+
const writeDefinition = (entryPath) => atomicWrite(paths.launchAgent, systemdUnit(paths, {
|
|
228
|
+
daemonPath: entryPath,
|
|
229
|
+
...(options.zcodeRuntime !== undefined ? { zcodeRuntime: options.zcodeRuntime } : {}),
|
|
230
|
+
}), 0o600);
|
|
231
|
+
const restoreDefinition = (entryPath) => atomicWrite(paths.launchAgent, replaceExecStartProgram(oldUnit, entryPath), 0o600);
|
|
232
|
+
const load = async () => {
|
|
233
|
+
await control(['daemon-reload']);
|
|
234
|
+
await control(['enable', '--now', SYSTEMD_UNIT_NAME]);
|
|
235
|
+
};
|
|
236
|
+
const rollbackService = async () => {
|
|
237
|
+
await unload();
|
|
238
|
+
const previous = rollbackExecutable();
|
|
239
|
+
restoreDefinition(previous.path);
|
|
240
|
+
await load();
|
|
241
|
+
return healthy({ path: previous.path, sha256: previous.sha256, version: oldStatus?.identity?.daemon?.version }, oldPid, oldStatus?.service_generation);
|
|
242
|
+
};
|
|
243
|
+
try {
|
|
244
|
+
await unload();
|
|
245
|
+
writeDefinition(candidate.path);
|
|
246
|
+
await load();
|
|
247
|
+
const health = await healthy(candidate, oldPid, oldStatus?.service_generation);
|
|
248
|
+
return {
|
|
249
|
+
...health,
|
|
250
|
+
rollback: async () => {
|
|
251
|
+
const restored = await rollbackService();
|
|
252
|
+
if (digest(oldPath) !== oldSha) return { ...restored, restored_from: 'retained_payload' };
|
|
253
|
+
return restored;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (!oldPid) {
|
|
258
|
+
try {
|
|
259
|
+
await unload();
|
|
260
|
+
atomicWrite(paths.launchAgent, oldUnit, 0o600);
|
|
261
|
+
error.rollback = { attempted: true, restored: false, unit_restored: true, error: 'no previous service identity to verify' };
|
|
262
|
+
} catch (rollbackError) {
|
|
263
|
+
error.rollback = { attempted: true, restored: false, unit_restored: false, error: rollbackError.message };
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
error.rollback = await rollbackService();
|
|
269
|
+
} catch (rollbackError) { error.rollbackError = rollbackError.message; }
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { DAEMON_BIN_NAME, PRODUCT_NAME, zcodeRuntimePath } from '../constants.mjs';
|
|
5
|
+
import { CliError } from '../errors.mjs';
|
|
6
|
+
import { atomicWrite } from '../fs-atomic.mjs';
|
|
7
|
+
import { nativeBinary } from './layout.mjs';
|
|
8
|
+
import { systemdServicePath } from './path.mjs';
|
|
9
|
+
import { readConfig } from '../config/read.mjs';
|
|
10
|
+
|
|
11
|
+
// The one Linux service backend for the product daemon: a systemd USER unit,
|
|
12
|
+
// never the system manager. The unit pins absolute payload paths and a fixed
|
|
13
|
+
// PATH so the user-manager environment never depends on the interactive shell,
|
|
14
|
+
// exactly the contract the launchd plist owns on macOS (service-macos.mjs is
|
|
15
|
+
// the structural twin; service-macos.mjs also performs the platform dispatch
|
|
16
|
+
// between the two backends). Bootstrap = daemon-reload + enable --now; bootout
|
|
17
|
+
// = disable --now + a bounded inactive confirmation — the same idempotence and
|
|
18
|
+
// removal-confirmation semantics, carried by `systemctl --user` and honoring
|
|
19
|
+
// the EXTERNAL_SUBAGENT_TEST_NO_SYSTEMCTL seam (the structural twin of
|
|
20
|
+
// EXTERNAL_SUBAGENT_TEST_NO_LAUNCHCTL) so fixture runs never touch the real
|
|
21
|
+
// user manager.
|
|
22
|
+
|
|
23
|
+
export const SYSTEMD_UNIT_NAME = `${PRODUCT_NAME}.service`;
|
|
24
|
+
export const SYSTEMCTL_PATH = '/usr/bin/systemctl';
|
|
25
|
+
|
|
26
|
+
// D-S04: a host without a reachable user manager gets an actionable
|
|
27
|
+
// explanation, never an automatic `loginctl enable-linger` and never a detour
|
|
28
|
+
// through the system manager.
|
|
29
|
+
const NO_SESSION_HINT = 'log in through a real session (which provides $XDG_RUNTIME_DIR), or run \'loginctl enable-linger <user>\' so the user manager stays available without one; this product never enables linger, never modifies unrelated units, and never contacts the system (PID 1) manager';
|
|
30
|
+
|
|
31
|
+
function noSessionError(detail = null) {
|
|
32
|
+
const cause = detail ?? 'neither $XDG_RUNTIME_DIR nor $DBUS_SESSION_BUS_ADDRESS is set';
|
|
33
|
+
return new CliError('NO_USER_SYSTEMD_SESSION', `systemctl --user cannot reach the systemd user manager: ${cause}. ${NO_SESSION_HINT}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hasUserSystemdSession(env = process.env) {
|
|
37
|
+
return Boolean(
|
|
38
|
+
(typeof env.XDG_RUNTIME_DIR === 'string' && env.XDG_RUNTIME_DIR !== '')
|
|
39
|
+
|| (typeof env.DBUS_SESSION_BUS_ADDRESS === 'string' && env.DBUS_SESSION_BUS_ADDRESS !== ''),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// systemd's two expansion grammars differ, and both were verified against the
|
|
44
|
+
// running user manager (systemd 259):
|
|
45
|
+
// Environment= values — NO variable expansion ("the '$' character has no
|
|
46
|
+
// special meaning", man systemd.exec; a literal `$HOME` is passed through
|
|
47
|
+
// verbatim), but %-specifier expansion IS performed (`%h` becomes the
|
|
48
|
+
// home directory; `%%` is a literal `%`).
|
|
49
|
+
// ExecStart arguments — BOTH expansions apply: `$`/`${}` at spawn time
|
|
50
|
+
// (`$$` passes a literal dollar, man systemd.service) and %-specifiers at
|
|
51
|
+
// load time (`%%` literal).
|
|
52
|
+
// Each side therefore escapes only what its grammar consumes; both quote the
|
|
53
|
+
// value with `\"`/`\\` per systemd.syntax (an unrecognized `\x` escape makes
|
|
54
|
+
// systemd DROP the whole Environment assignment, so every backslash is
|
|
55
|
+
// always doubled). The `$`/`%` doublings use replacer functions because a
|
|
56
|
+
// plain `'$$'` replacement string is itself a $-pattern ("insert one $") and
|
|
57
|
+
// would silently cancel the escape.
|
|
58
|
+
function escapeQuotedCharacters(value) {
|
|
59
|
+
return String(value)
|
|
60
|
+
.replaceAll('\\', () => '\\\\')
|
|
61
|
+
.replaceAll('"', () => '\\"');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function escapeEnvironmentValue(value) {
|
|
65
|
+
return escapeQuotedCharacters(value).replaceAll('%', () => '%%');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function escapeExecStartArgument(value) {
|
|
69
|
+
return escapeQuotedCharacters(value).replaceAll('$', () => '$$').replaceAll('%', () => '%%');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A newline inside a unit directive cannot be escaped away: systemd ends the
|
|
73
|
+
// assignment there and parses the remainder as NEW directive lines, so a
|
|
74
|
+
// config value like "acp\nRestart=no\n#" silently overrides the product-fixed
|
|
75
|
+
// Restart=always — and systemd-analyze verify accepts the corrupted unit
|
|
76
|
+
// without a warning (reproduced with the real generator). Both forwarding
|
|
77
|
+
// paths therefore refuse such values loudly instead of writing a corrupted
|
|
78
|
+
// unit; the macOS plist carries the same value as inert XML data and keeps
|
|
79
|
+
// forwarding it (its generator is deliberately untouched).
|
|
80
|
+
function assertUnitLineSafe(value, label) {
|
|
81
|
+
if (/[\n\r]/u.test(String(value))) {
|
|
82
|
+
throw new CliError('SERVICE_DEFINITION_INVALID',
|
|
83
|
+
`the ${label} contains a newline (line feed or carriage return); systemd unit directive lines cannot represent it, and the service definition is refused rather than written corrupted — remove the newline from the configuration value`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function quotedArgument(value) {
|
|
88
|
+
assertUnitLineSafe(value, 'ExecStart argument');
|
|
89
|
+
return `"${escapeExecStartArgument(value)}"`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The assignment is quoted as a whole (`Environment="NAME=value"`): systemd
|
|
93
|
+
// parses one assignment per line, and quotes around only the value would be
|
|
94
|
+
// taken literally.
|
|
95
|
+
function environmentLine(name, value) {
|
|
96
|
+
assertUnitLineSafe(value, `${name} environment value`);
|
|
97
|
+
return `Environment="${name}=${escapeEnvironmentValue(value)}"`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function unescapeUnitValue(text) {
|
|
101
|
+
let out = '';
|
|
102
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
103
|
+
const char = text[index];
|
|
104
|
+
if (char === '\\' && index + 1 < text.length) { out += text[index + 1]; index += 1; continue; }
|
|
105
|
+
if (char === '$' && text[index + 1] === '$') { out += '$'; index += 1; continue; }
|
|
106
|
+
if (char === '%' && text[index + 1] === '%') { out += '%'; index += 1; continue; }
|
|
107
|
+
out += char;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function firstExecStartToken(line) {
|
|
113
|
+
const rest = line.replace(/^[+!:-]+/u, ''); // systemd command prefixes (+, -, :, !, !!)
|
|
114
|
+
if (rest.startsWith('"')) {
|
|
115
|
+
for (let index = 1; index < rest.length; index += 1) {
|
|
116
|
+
if (rest[index] === '\\') { index += 1; continue; }
|
|
117
|
+
if (rest[index] === '"') return { token: rest.slice(1, index), after: rest.slice(index + 1) };
|
|
118
|
+
}
|
|
119
|
+
return null; // unterminated quote
|
|
120
|
+
}
|
|
121
|
+
const end = rest.search(/\s/u);
|
|
122
|
+
return { token: end === -1 ? rest : rest.slice(0, end), after: end === -1 ? '' : rest.slice(end) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The executable the unit pins — the systemd twin of the plist's first
|
|
126
|
+
// ProgramArguments entry — used by activation to learn the old program and to
|
|
127
|
+
// restore it byte-for-byte on rollback.
|
|
128
|
+
export function execStartProgram(unitBytes) {
|
|
129
|
+
const match = unitBytes.toString('utf8').match(/^ExecStart=(.*)$/m);
|
|
130
|
+
if (!match) return null;
|
|
131
|
+
const token = firstExecStartToken(match[1]);
|
|
132
|
+
return token ? unescapeUnitValue(token.token) : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function replaceExecStartProgram(unitBytes, program) {
|
|
136
|
+
const text = unitBytes.toString('utf8');
|
|
137
|
+
const match = text.match(/^ExecStart=(.*)$/m);
|
|
138
|
+
if (!match) return null;
|
|
139
|
+
const token = firstExecStartToken(match[1]);
|
|
140
|
+
if (!token) return null;
|
|
141
|
+
const prefix = match[1].match(/^[+!:-]*/u)[0];
|
|
142
|
+
const line = `ExecStart=${prefix}${quotedArgument(program)}${token.after}`;
|
|
143
|
+
// Replacer function: a raw replacement string would reinterpret $-patterns
|
|
144
|
+
// inside the restored unit text.
|
|
145
|
+
return Buffer.from(text.replace(/^ExecStart=.*$/m, () => line), 'utf8');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The same parameterization the plist generator carries (one-to-one): the
|
|
149
|
+
// verified daemon artifact, the data paths the daemon takes as arguments, the
|
|
150
|
+
// pinned ZCode runtime forwarded exactly when that installation exists, and
|
|
151
|
+
// the persisted subagent environment. Restart=always mirrors KeepAlive;
|
|
152
|
+
// WantedBy=default.target mirrors RunAtLoad. The daemon's own
|
|
153
|
+
// --diagnostic-log writer puts daemon-error.log under the XDG state log
|
|
154
|
+
// directory; stdout/stderr stay with the user journal, the systemd-native
|
|
155
|
+
// sink (StandardOutput=append: cannot represent paths containing spaces).
|
|
156
|
+
export function systemdUnit(paths, options = {}) {
|
|
157
|
+
const daemon = options.daemonPath || nativeBinary(DAEMON_BIN_NAME);
|
|
158
|
+
const config = readConfig(paths.config);
|
|
159
|
+
const { runtime_path: dshRuntime, home: dshHome, profile: dshProfile, version: dshVersion } = config.subagents.dsh;
|
|
160
|
+
const { runtime_path: codexRuntime, home: codexHome } = config.subagents.codex;
|
|
161
|
+
const { runtime_path: agyRuntime } = config.subagents.agy;
|
|
162
|
+
const configRevision = config.revision;
|
|
163
|
+
const zcodeRuntime = options.zcodeRuntime ?? zcodeRuntimePath(paths.home);
|
|
164
|
+
const execStart = [
|
|
165
|
+
quotedArgument(daemon),
|
|
166
|
+
quotedArgument('--database'),
|
|
167
|
+
quotedArgument(paths.database),
|
|
168
|
+
quotedArgument('--socket'),
|
|
169
|
+
quotedArgument(paths.socket),
|
|
170
|
+
...(fs.existsSync(zcodeRuntime) ? [quotedArgument('--runtime'), quotedArgument(zcodeRuntime)] : []),
|
|
171
|
+
quotedArgument('--diagnostic-log'),
|
|
172
|
+
quotedArgument(path.join(paths.logs, 'daemon-error.log')),
|
|
173
|
+
].join(' ');
|
|
174
|
+
const environment = [
|
|
175
|
+
environmentLine('PATH', systemdServicePath()),
|
|
176
|
+
...(configRevision === null ? [] : [environmentLine('EXTERNAL_SUBAGENT_CONFIG_REVISION', configRevision)]),
|
|
177
|
+
...(dshRuntime ? [environmentLine('DSH_RUNTIME_PATH', dshRuntime)] : []),
|
|
178
|
+
...(dshHome ? [environmentLine('DSH_HOME', dshHome)] : []),
|
|
179
|
+
...(dshProfile ? [environmentLine('DSH_PROFILE', dshProfile)] : []),
|
|
180
|
+
...(dshVersion ? [environmentLine('DSH_VERSION', dshVersion)] : []),
|
|
181
|
+
...(codexRuntime ? [environmentLine('CODEX_RUNTIME_PATH', codexRuntime)] : []),
|
|
182
|
+
...(codexHome ? [environmentLine('CODEX_HOME', codexHome)] : []),
|
|
183
|
+
...(agyRuntime ? [environmentLine('AGY_RUNTIME_PATH', agyRuntime)] : []),
|
|
184
|
+
];
|
|
185
|
+
return Buffer.from([
|
|
186
|
+
`# Managed by ${PRODUCT_NAME}; regenerate with 'external-subagent init'.`,
|
|
187
|
+
'[Unit]',
|
|
188
|
+
`Description=${PRODUCT_NAME} daemon`,
|
|
189
|
+
'',
|
|
190
|
+
'[Service]',
|
|
191
|
+
'Type=simple',
|
|
192
|
+
`ExecStart=${execStart}`,
|
|
193
|
+
...environment,
|
|
194
|
+
'Restart=always',
|
|
195
|
+
'',
|
|
196
|
+
'[Install]',
|
|
197
|
+
'WantedBy=default.target',
|
|
198
|
+
'',
|
|
199
|
+
].join('\n'), 'utf8');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function installServiceUnit(paths, options = {}) {
|
|
203
|
+
const unit = options.unit || systemdUnit(paths);
|
|
204
|
+
atomicWrite(paths.launchAgent, unit, 0o600);
|
|
205
|
+
return { installed: true, path: paths.launchAgent, unit: SYSTEMD_UNIT_NAME };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function systemctl(args) {
|
|
209
|
+
if (process.env.EXTERNAL_SUBAGENT_TEST_NO_SYSTEMCTL === '1') {
|
|
210
|
+
return { action: args[0], skipped: true, reason: 'systemd neutralized by test seam' };
|
|
211
|
+
}
|
|
212
|
+
if (!hasUserSystemdSession()) throw noSessionError();
|
|
213
|
+
const result = spawnSync(SYSTEMCTL_PATH, ['--user', ...args], { encoding: 'utf8' });
|
|
214
|
+
if (result.error) throw new CliError('DAEMON_CONTROL_FAILED', `systemctl is unavailable: ${result.error.message}`);
|
|
215
|
+
if (result.status !== 0 && /Failed to connect to (?:user scope )?bus/iu.test(result.stderr || '')) {
|
|
216
|
+
throw noSessionError((result.stderr || '').trim());
|
|
217
|
+
}
|
|
218
|
+
if (result.status !== 0) {
|
|
219
|
+
throw new CliError('DAEMON_CONTROL_FAILED', (result.stderr || result.stdout || `systemctl ${args[0]} failed`).trim());
|
|
220
|
+
}
|
|
221
|
+
return { action: args[0], status: result.status, stdout: result.stdout };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The single `show`-shaped probe every decision below reuses — the structural
|
|
225
|
+
// twin of the launchctl print/absent discriminator. A unit the user manager
|
|
226
|
+
// has loaded reports its real state (`failed` included), so a status view built
|
|
227
|
+
// on it can never turn a crashed unit into "healthy".
|
|
228
|
+
export function unitServiceStatus(control, unit = SYSTEMD_UNIT_NAME) {
|
|
229
|
+
const result = control(['show', unit]);
|
|
230
|
+
if (result.skipped) return { skipped: true, reason: result.reason };
|
|
231
|
+
const properties = Object.fromEntries(
|
|
232
|
+
(result.stdout || '').split('\n').filter((line) => /^[A-Za-z]+=/.test(line)).map((line) => {
|
|
233
|
+
const separator = line.indexOf('=');
|
|
234
|
+
return [line.slice(0, separator), line.slice(separator + 1)];
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
const pid = Number(properties.MainPID);
|
|
238
|
+
return {
|
|
239
|
+
registered: properties.LoadState === 'loaded',
|
|
240
|
+
load_state: properties.LoadState ?? null,
|
|
241
|
+
state: properties.ActiveState ?? null,
|
|
242
|
+
sub_state: properties.SubState ?? null,
|
|
243
|
+
pid: Number.isInteger(pid) && pid > 0 ? pid : null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ActiveState values that mean "the service has a live process right now".
|
|
248
|
+
const LIVE_STATES = new Set(['active', 'activating', 'reloading']);
|
|
249
|
+
|
|
250
|
+
// Read-only registration view for `status`. A missing user session degrades
|
|
251
|
+
// into an explicit unavailable view (with the linger hint) instead of failing
|
|
252
|
+
// the whole read-only command; every other control failure still surfaces.
|
|
253
|
+
export function systemdServiceRegistrationStatus(options = {}) {
|
|
254
|
+
const control = options.systemctl || systemctl;
|
|
255
|
+
try {
|
|
256
|
+
const view = unitServiceStatus(control);
|
|
257
|
+
if (view.skipped) return { query: 'skipped', registered: null, reason: view.reason };
|
|
258
|
+
const { registered, state, sub_state: subState, pid } = view;
|
|
259
|
+
return registered ? { registered: true, state, sub_state: subState, pid } : { registered: false };
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error.code === 'NO_USER_SYSTEMD_SESSION') {
|
|
262
|
+
return { query: 'unavailable', registered: null, reason: error.message };
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// systemd natively answers a repeat `enable --now` with success even on a
|
|
269
|
+
// running unit, but the product keeps the launchd contract: an already-live
|
|
270
|
+
// service is reported as already_loaded with its real state/pid and is never
|
|
271
|
+
// disturbed, and an enable that loses a race to another starter is settled
|
|
272
|
+
// through the same probe instead of surfacing a phantom failure.
|
|
273
|
+
export function bootstrapServiceSystemd(paths, _uid, options = {}) {
|
|
274
|
+
const control = options.systemctl || systemctl;
|
|
275
|
+
const alreadyLoaded = (view) => ({
|
|
276
|
+
action: 'bootstrap', unit: SYSTEMD_UNIT_NAME, already_loaded: true,
|
|
277
|
+
state: view.state ?? null, pid: view.pid ?? null,
|
|
278
|
+
});
|
|
279
|
+
const isLive = (view) => !view.skipped && view.registered === true && LIVE_STATES.has(view.state);
|
|
280
|
+
const existing = unitServiceStatus(control);
|
|
281
|
+
if (isLive(existing)) return alreadyLoaded(existing);
|
|
282
|
+
try {
|
|
283
|
+
control(['daemon-reload']);
|
|
284
|
+
return { ...control(['enable', '--now', SYSTEMD_UNIT_NAME]), unit: SYSTEMD_UNIT_NAME };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
const settled = unitServiceStatus(control);
|
|
287
|
+
if (isLive(settled)) return alreadyLoaded(settled);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A disable --now returns before the unit has necessarily finished its
|
|
293
|
+
// teardown, and a start issued inside that window can race the still-running
|
|
294
|
+
// stop job — the same observed hazard the launchd bootout confirmation loop
|
|
295
|
+
// guards. stop therefore confirms the unit is actually inactive (and its
|
|
296
|
+
// process gone or the socket removed) before reporting success, bounded, and
|
|
297
|
+
// a unit that was never loaded is an idempotent already_stopped.
|
|
298
|
+
export function bootoutServiceSystemd(paths, _uid, options = {}) {
|
|
299
|
+
const control = options.systemctl || systemctl;
|
|
300
|
+
// A skipped probe (test seam) reads as not-registered, exactly like the
|
|
301
|
+
// launchd bootout owner treats a neutralized print: fixture stops are
|
|
302
|
+
// already_stopped, never a real disable.
|
|
303
|
+
const stopped = (view) => view.skipped || view.registered !== true
|
|
304
|
+
|| (view.state === 'inactive' && view.pid === null);
|
|
305
|
+
const existing = unitServiceStatus(control);
|
|
306
|
+
if (stopped(existing)) return { action: 'bootout', unit: SYSTEMD_UNIT_NAME, already_stopped: true };
|
|
307
|
+
let result;
|
|
308
|
+
try {
|
|
309
|
+
result = control(['disable', '--now', SYSTEMD_UNIT_NAME]);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
// A racing removal (another stop, or the user manager itself) may have
|
|
312
|
+
// taken the unit down between the probe and the disable; confirm before
|
|
313
|
+
// reporting failure, so a repeated stop stays idempotent.
|
|
314
|
+
const settled = unitServiceStatus(control);
|
|
315
|
+
if (stopped(settled)) return { action: 'bootout', unit: SYSTEMD_UNIT_NAME, already_stopped: true };
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
const deadline = Date.now() + (options.unloadTimeoutMs ?? 10_000);
|
|
319
|
+
for (;;) {
|
|
320
|
+
const probe = unitServiceStatus(control);
|
|
321
|
+
if (stopped(probe)) {
|
|
322
|
+
return { ...result, unit: SYSTEMD_UNIT_NAME, removed: true, socket_removed: !fs.existsSync(paths.socket) };
|
|
323
|
+
}
|
|
324
|
+
if (Date.now() >= deadline) throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'systemd service remained active after disable --now');
|
|
325
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { DAEMON_BIN_NAME, LAUNCH_AGENT_LABEL,
|
|
4
|
+
import { DAEMON_BIN_NAME, LAUNCH_AGENT_LABEL, zcodeRuntimePath } from '../constants.mjs';
|
|
5
5
|
import { CliError } from '../errors.mjs';
|
|
6
6
|
import { atomicWrite } from '../fs-atomic.mjs';
|
|
7
7
|
import { nativeBinary } from './layout.mjs';
|
|
8
8
|
import { LAUNCHD_FIXED_PATH } from './path.mjs';
|
|
9
9
|
import { readConfig } from '../config/read.mjs';
|
|
10
|
+
import { platform } from '../paths.mjs';
|
|
11
|
+
import {
|
|
12
|
+
SYSTEMD_UNIT_NAME, bootstrapServiceSystemd, bootoutServiceSystemd, installServiceUnit,
|
|
13
|
+
systemdServiceRegistrationStatus,
|
|
14
|
+
} from './service-linux.mjs';
|
|
10
15
|
|
|
11
|
-
// The
|
|
16
|
+
// The macOS service backend for the product daemon, plus the platform
|
|
17
|
+
// dispatch into the systemd user backend (service-linux.mjs). The plist pins
|
|
12
18
|
// absolute payload paths and a fixed PATH so the GUI/launchd environment can
|
|
13
19
|
// never depend on the interactive shell. Launchctl activation honors the
|
|
14
20
|
// EXTERNAL_SUBAGENT_TEST_NO_LAUNCHCTL seam so fixture runs never load real
|
|
@@ -22,7 +28,7 @@ function escapeXml(value) {
|
|
|
22
28
|
export function runtimeObservations(paths, options = {}) {
|
|
23
29
|
const config = readConfig(paths.config);
|
|
24
30
|
return Object.fromEntries(['zcode', 'dsh', 'codex', 'agy'].map((agent) => {
|
|
25
|
-
const runtime = agent === 'zcode' ? (options.zcodeRuntime ??
|
|
31
|
+
const runtime = agent === 'zcode' ? (options.zcodeRuntime ?? zcodeRuntimePath(paths.home)) : config.subagents[agent].runtime_path;
|
|
26
32
|
return [agent, { path: runtime, present: Boolean(runtime && fs.existsSync(runtime)), enabled: config.subagents[agent].enabled }];
|
|
27
33
|
}));
|
|
28
34
|
}
|
|
@@ -38,13 +44,14 @@ export function launchAgentPlist(paths, options = {}) {
|
|
|
38
44
|
const { runtime_path: agyRuntime } = config.subagents.agy;
|
|
39
45
|
const configRevision = config.revision;
|
|
40
46
|
// AUD-005/D1: the standalone service never depends on an unrelated runtime.
|
|
41
|
-
// The pinned ZCode runtime is forwarded
|
|
42
|
-
// without it the daemon still starts and
|
|
43
|
-
//
|
|
44
|
-
// dying on launchd because
|
|
45
|
-
// removing) ZCode later is
|
|
46
|
-
// plist. The seam exists so
|
|
47
|
-
|
|
47
|
+
// The pinned ZCode runtime (platform-discovered, constants.mjs) is forwarded
|
|
48
|
+
// only when that installation exists; without it the daemon still starts and
|
|
49
|
+
// the zcode adapter fails closed at spawn ("ZCODE_RUNTIME_PATH is
|
|
50
|
+
// unavailable") instead of the whole service dying on launchd because
|
|
51
|
+
// --runtime cannot canonicalize. Installing (or removing) ZCode later is
|
|
52
|
+
// picked up by the next init, which rewrites the plist. The seam exists so
|
|
53
|
+
// tests can pin both branches deterministically.
|
|
54
|
+
const zcodeRuntime = options.zcodeRuntime ?? zcodeRuntimePath(paths.home);
|
|
48
55
|
const programArguments = [
|
|
49
56
|
`<string>${escapeXml(daemon)}</string>`,
|
|
50
57
|
'<string>--database</string>',
|
|
@@ -103,7 +110,7 @@ export function launchctl(args) {
|
|
|
103
110
|
// A repeat init/start must stay idempotent instead of failing the whole run,
|
|
104
111
|
// so the registration lookup below — the same print/absent discriminator the
|
|
105
112
|
// update owner uses — decides between "already loaded" and a real failure.
|
|
106
|
-
export function
|
|
113
|
+
export function launchdServiceRegistrationStatus(uid = process.getuid(), options = {}) {
|
|
107
114
|
const control = options.launchctl || launchctl;
|
|
108
115
|
const result = control(['print', `gui/${uid}/${LAUNCH_AGENT_LABEL}`]);
|
|
109
116
|
if (result.skipped) return { query: 'skipped', registered: null, reason: result.reason };
|
|
@@ -113,20 +120,20 @@ export function serviceRegistrationStatus(uid = process.getuid(), options = {})
|
|
|
113
120
|
return { registered: true, state, pid: Number.isInteger(pid) && pid > 0 ? pid : null };
|
|
114
121
|
}
|
|
115
122
|
|
|
116
|
-
export function
|
|
123
|
+
export function bootstrapLaunchdService(paths, uid = process.getuid(), options = {}) {
|
|
117
124
|
const control = options.launchctl || launchctl;
|
|
118
125
|
const alreadyLoaded = (lookup) => ({
|
|
119
126
|
action: 'bootstrap', label: LAUNCH_AGENT_LABEL, already_loaded: true,
|
|
120
127
|
state: lookup.state ?? null, pid: lookup.pid ?? null,
|
|
121
128
|
});
|
|
122
|
-
const existing =
|
|
129
|
+
const existing = launchdServiceRegistrationStatus(uid, { launchctl: control });
|
|
123
130
|
if (existing.registered) return alreadyLoaded(existing);
|
|
124
131
|
try {
|
|
125
132
|
return control(['bootstrap', `gui/${uid}`, paths.launchAgent]);
|
|
126
133
|
} catch (error) {
|
|
127
134
|
// Another loader (a racing init, or launchd itself re-reading the plist)
|
|
128
135
|
// may have won after the check above; confirm before reporting failure.
|
|
129
|
-
const loaded =
|
|
136
|
+
const loaded = launchdServiceRegistrationStatus(uid, { launchctl: control });
|
|
130
137
|
if (loaded.registered) return alreadyLoaded(loaded);
|
|
131
138
|
throw error;
|
|
132
139
|
}
|
|
@@ -138,9 +145,9 @@ export function bootstrapService(paths, uid = process.getuid(), options = {}) {
|
|
|
138
145
|
// service unregistered roughly one time in three). The stop owner therefore
|
|
139
146
|
// confirms the job is actually gone — bounded, with the same print/absent
|
|
140
147
|
// discriminator the update owner's unload uses — before reporting success.
|
|
141
|
-
export function
|
|
148
|
+
export function bootoutLaunchdService(paths, uid = process.getuid(), options = {}) {
|
|
142
149
|
const control = options.launchctl || launchctl;
|
|
143
|
-
const existing =
|
|
150
|
+
const existing = launchdServiceRegistrationStatus(uid, { launchctl: control });
|
|
144
151
|
if (!existing.registered) return { action: 'bootout', label: LAUNCH_AGENT_LABEL, already_stopped: true };
|
|
145
152
|
let result;
|
|
146
153
|
try {
|
|
@@ -149,15 +156,49 @@ export function bootoutService(paths, uid = process.getuid(), options = {}) {
|
|
|
149
156
|
// A racing removal (another stop, or launchd itself) may have taken the
|
|
150
157
|
// job out between the registration probe and the bootout; confirm before
|
|
151
158
|
// reporting failure, so a repeated stop stays idempotent.
|
|
152
|
-
const settled =
|
|
159
|
+
const settled = launchdServiceRegistrationStatus(uid, { launchctl: control });
|
|
153
160
|
if (!settled.registered) return { action: 'bootout', label: LAUNCH_AGENT_LABEL, already_stopped: true };
|
|
154
161
|
throw error;
|
|
155
162
|
}
|
|
156
163
|
const deadline = Date.now() + (options.unloadTimeoutMs ?? 10_000);
|
|
157
164
|
for (;;) {
|
|
158
|
-
const probe =
|
|
165
|
+
const probe = launchdServiceRegistrationStatus(uid, { launchctl: control });
|
|
159
166
|
if (!probe.registered) return { ...result, label: LAUNCH_AGENT_LABEL, removed: true };
|
|
160
167
|
if (Date.now() >= deadline) throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'launchd service remained registered after bootout');
|
|
161
168
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
162
169
|
}
|
|
163
170
|
}
|
|
171
|
+
|
|
172
|
+
// Platform dispatch (S03): the service backend is selected by the process
|
|
173
|
+
// platform — launchd on macOS, the systemd user manager on Linux — behind the
|
|
174
|
+
// same call surface every caller (init, start/stop, uninstall, status) already
|
|
175
|
+
// uses. The macOS arms below are the verbatim launchd implementations; the
|
|
176
|
+
// Linux arms carry the same idempotence/removal-confirmation contract through
|
|
177
|
+
// `systemctl --user` (options.systemctl is the injectable seam, the twin of
|
|
178
|
+
// options.launchctl).
|
|
179
|
+
export function serviceRegistrationStatus(uid = process.getuid(), options = {}) {
|
|
180
|
+
return platform() === 'darwin'
|
|
181
|
+
? launchdServiceRegistrationStatus(uid, options)
|
|
182
|
+
: systemdServiceRegistrationStatus(options);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function bootstrapService(paths, uid = process.getuid(), options = {}) {
|
|
186
|
+
return platform() === 'darwin'
|
|
187
|
+
? bootstrapLaunchdService(paths, uid, options)
|
|
188
|
+
: bootstrapServiceSystemd(paths, uid, options);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function bootoutService(paths, uid = process.getuid(), options = {}) {
|
|
192
|
+
return platform() === 'darwin'
|
|
193
|
+
? bootoutLaunchdService(paths, uid, options)
|
|
194
|
+
: bootoutServiceSystemd(paths, uid, options);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The service-definition installer behind init's install-launch-agent step:
|
|
198
|
+
// the launchd plist on macOS, the systemd user unit (0600, absolute verified
|
|
199
|
+
// payload, Restart=always) on Linux, both at paths.launchAgent.
|
|
200
|
+
export function installServiceDefinition(paths, options = {}) {
|
|
201
|
+
return platform() === 'darwin' ? installLaunchAgent(paths, options) : installServiceUnit(paths, options);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export { SYSTEMD_UNIT_NAME };
|
package/cli/install/update.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { CLI_ENTRY_NAME, DAEMON_BIN_NAME, NATIVE_DIR_NAME } from '../constants.m
|
|
|
7
7
|
import { reconcileCodexHomes } from './reconcile.mjs';
|
|
8
8
|
import { packageVersion, packageRoot } from './layout.mjs';
|
|
9
9
|
import { loadUpdateState } from './recovery.mjs';
|
|
10
|
-
import { verifyPayload } from './payload.mjs';
|
|
10
|
+
import { normalizePayloadFiles, verifyPayload } from './payload.mjs';
|
|
11
11
|
|
|
12
12
|
const SCHEMA_VERSION = 2;
|
|
13
13
|
const lockPath = (paths) => path.join(paths.data, 'install.lock');
|
|
@@ -113,6 +113,12 @@ export function preflightUpdate(options = {}) {
|
|
|
113
113
|
if (typeof version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(version) || !available.includes(version)) {
|
|
114
114
|
throw new CliError('PAYLOAD_VERSION_UNAVAILABLE', `requested payload version is unavailable: ${version}`);
|
|
115
115
|
}
|
|
116
|
+
// Repair a candidate payload mode masked by the installing umask before the
|
|
117
|
+
// strict verifier runs; the bytes are untouched, so the digest/arch checks
|
|
118
|
+
// below still bind the candidate to the release manifest.
|
|
119
|
+
for (const entry of normalizePayloadFiles({ root: candidateRoot, platform: options.platform })) {
|
|
120
|
+
process.stderr.write(`normalized payload mode ${entry.from} -> ${entry.to} (${entry.name})\n`);
|
|
121
|
+
}
|
|
116
122
|
const payload = verifyPayload({ root: candidateRoot, platform: options.platform });
|
|
117
123
|
if (version !== payload.version) throw new CliError('PAYLOAD_VERSION_MISMATCH', `requested version ${version} differs from candidate ${payload.version}`);
|
|
118
124
|
// Reject a doomed candidate up front: an unusable stable entry or daemon
|