@ran-sh/dsh-crew 1.0.2 → 1.1.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 +4 -0
- package/README.zh.md +4 -0
- package/codex/AGENTS.md +101 -92
- package/docs/ui-surfaces.md +48 -21
- package/lib/client.js +235 -34
- package/official-web-bridge/lib/client.js +368 -4532
- package/official-web-bridge/package.json +1 -2
- package/package.json +130 -67
- package/scripts/build-client.mjs +30 -15
- package/scripts/remove-legacy-official-bridge.ps1 +89 -0
- package/scripts/setup.mjs +152 -140
- package/scripts/verify-npm-install.mjs +311 -310
- package/scripts/verify-official-bridge-e2e.mjs +12 -1
- package/src/client/host-readiness.mjs +62 -59
- package/src/client/index.tsx +117 -24
- package/src/client/quick-entry.tsx +10 -0
- package/src/client/quick-panel.tsx +295 -0
- package/src/client/surface-detection.mjs +43 -30
- package/src/credential-reference.mjs +38 -0
- package/src/dsh-cli-runtime.mjs +500 -40
- package/src/dsh-cohort.mjs +20 -0
- package/src/hub/index.mjs +1602 -1016
- package/src/install/npx-lifecycle.mjs +1983 -468
- package/src/install/official-web.mjs +36 -73
- package/src/jobs.mjs +20 -24
- package/src/model-catalog.mjs +8 -1
- package/src/official-web-bridge.mjs +329 -249
- package/src/provider-delete-adapters.mjs +165 -17
- package/src/provider-inventory.mjs +17 -3
- package/src/provider-layer-migration-adapters.mjs +759 -0
- package/src/provider-layer-migration.mjs +198 -0
- package/src/provider-lifecycle-state.mjs +4 -1
- package/src/provider-profile-store.mjs +193 -4
- package/src/provider-settings-store.mjs +82 -6
- package/src/provider-store-lock.mjs +67 -0
- package/src/runtime-identity.mjs +24 -1
- package/src/supervisor/restart-request.mjs +202 -0
- package/windows/start-dsh-crew.ps1 +783 -370
- package/worker.cordis.yml +32 -46
- package/zcode/AGENTS.md +35 -26
|
@@ -30,17 +30,18 @@ import {
|
|
|
30
30
|
readdirSync,
|
|
31
31
|
readFileSync,
|
|
32
32
|
realpathSync,
|
|
33
|
+
renameSync,
|
|
33
34
|
rmSync,
|
|
34
35
|
copyFileSync,
|
|
35
36
|
writeFileSync,
|
|
36
37
|
} from 'node:fs';
|
|
37
38
|
import { createRequire } from 'node:module';
|
|
38
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
39
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
39
40
|
import { fileURLToPath } from 'node:url';
|
|
40
41
|
import { homedir } from 'node:os';
|
|
41
42
|
import * as realInstaller from './install.mjs';
|
|
42
|
-
import { crewProfileDir } from './install.mjs';
|
|
43
|
-
import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration } from '../dsh-cli-runtime.mjs';
|
|
43
|
+
import { crewDshHome, crewProfileDir } from './install.mjs';
|
|
44
|
+
import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
|
|
44
45
|
import {
|
|
45
46
|
ensureOfficialWebIntegration,
|
|
46
47
|
officialWebIntegrationStatus,
|
|
@@ -49,10 +50,14 @@ import {
|
|
|
49
50
|
|
|
50
51
|
export const CREW_APP_DIRNAME = 'app';
|
|
51
52
|
export const RELEASES_DIRNAME = 'releases';
|
|
52
|
-
export const CURRENT_POINTER_FILENAME = 'current.json';
|
|
53
|
-
export const KEEP_RELEASES = 2;
|
|
54
|
-
export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
|
|
55
|
-
const CREW_ROUTE_BASE = '/_dsh/dsh-crew';
|
|
53
|
+
export const CURRENT_POINTER_FILENAME = 'current.json';
|
|
54
|
+
export const KEEP_RELEASES = 2;
|
|
55
|
+
export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
|
|
56
|
+
const CREW_ROUTE_BASE = '/_dsh/dsh-crew';
|
|
57
|
+
// Exact DSH cohort version: dotted numeric with optional -prerelease suffix.
|
|
58
|
+
// Anything else (ranges, "../..", paths) is NOT an authorized cohort value
|
|
59
|
+
// and must never reach rename/rmSync authority via retained-runtimes keys.
|
|
60
|
+
export const EXACT_DSH_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
56
61
|
|
|
57
62
|
export function npmCliInvocation(args, {
|
|
58
63
|
platform = process.platform,
|
|
@@ -126,23 +131,585 @@ function isoNow() {
|
|
|
126
131
|
* manifest to count as installed.
|
|
127
132
|
*/
|
|
128
133
|
export function readCurrentPointer({ home = homedir() } = {}) {
|
|
134
|
+
const state = readCurrentPointerState({ home });
|
|
135
|
+
return state.status === 'valid' ? state.pointer : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Tri-state pointer read: ABSENT (no file) / VALID / MALFORMED (present
|
|
139
|
+
// but unparseable or schema-invalid). Recovery must never merge ABSENT
|
|
140
|
+
// and MALFORMED: a corrupt pointer fails closed with the journal retained.
|
|
141
|
+
export function readCurrentPointerState({ home = homedir() } = {}) {
|
|
129
142
|
const file = currentPointerFile({ home });
|
|
130
|
-
if (!existsSync(file)) return
|
|
143
|
+
if (!existsSync(file)) return { status: 'absent', file };
|
|
131
144
|
let raw;
|
|
132
|
-
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!
|
|
136
|
-
|
|
145
|
+
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
|
|
146
|
+
return { status: 'malformed', file, code: 'POINTER_UNPARSEABLE' };
|
|
147
|
+
}
|
|
148
|
+
if (!raw || typeof raw !== 'object') return { status: 'malformed', file, code: 'POINTER_NOT_OBJECT' };
|
|
149
|
+
if (typeof raw.name !== 'string' || raw.name.length === 0
|
|
150
|
+
|| typeof raw.version !== 'string' || raw.version.length === 0
|
|
151
|
+
|| typeof raw.path !== 'string' || raw.path.length === 0) {
|
|
152
|
+
return { status: 'malformed', file, code: 'POINTER_FIELDS_INVALID' };
|
|
153
|
+
}
|
|
154
|
+
if (!isAbsolute(raw.path)) return { status: 'malformed', file, code: 'POINTER_PATH_NOT_ABSOLUTE' };
|
|
155
|
+
return { status: 'valid', file, pointer: raw };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validJournalRelease(value) {
|
|
159
|
+
return !!value && typeof value === 'object'
|
|
160
|
+
&& typeof value.name === 'string' && value.name.length > 0
|
|
161
|
+
&& typeof value.version === 'string' && value.version.length > 0
|
|
162
|
+
&& typeof value.path === 'string' && isAbsolute(value.path);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function validJournalCandidate(value) {
|
|
166
|
+
return !!value && typeof value === 'object'
|
|
167
|
+
&& typeof value.name === 'string' && value.name.length > 0
|
|
168
|
+
&& typeof value.version === 'string' && value.version.length > 0
|
|
169
|
+
&& typeof value.stageDir === 'string' && isAbsolute(value.stageDir);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function writeFileAtomic(file, content) {
|
|
173
|
+
// Same-directory temp + rename: a crash can never leave a truncated
|
|
174
|
+
// pointer or journal behind to be misread as valid state.
|
|
175
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
176
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
177
|
+
writeFileSync(temp, content);
|
|
178
|
+
try {
|
|
179
|
+
renameSync(temp, file);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
try { rmSync(temp, { force: true }); } catch {}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
137
184
|
}
|
|
138
185
|
|
|
139
186
|
function writeCurrentPointer({ home, name, version, path }) {
|
|
140
|
-
mkdirSync(dirname(currentPointerFile({ home })), { recursive: true });
|
|
141
187
|
const pointer = { name, version, path, installed_at: isoNow(), managed_by: 'npx' };
|
|
142
|
-
|
|
188
|
+
writeFileAtomic(currentPointerFile({ home }), JSON.stringify(pointer, null, 2) + '\n');
|
|
143
189
|
return pointer;
|
|
144
190
|
}
|
|
145
191
|
|
|
192
|
+
export const UPDATE_JOURNAL_FILENAME = 'update-journal.json';
|
|
193
|
+
export const UPDATE_LOCK_FILENAME = 'update-in-progress.lock';
|
|
194
|
+
|
|
195
|
+
export function updateJournalFile({ home = homedir() } = {}) {
|
|
196
|
+
return join(crewAppRoot({ home }), UPDATE_JOURNAL_FILENAME);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function updateLockFile({ home = homedir() } = {}) {
|
|
200
|
+
return join(crewAppRoot({ home }), UPDATE_LOCK_FILENAME);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function acquireUpdateLock({ home = homedir() } = {}) {
|
|
204
|
+
const file = updateLockFile({ home });
|
|
205
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
206
|
+
const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
207
|
+
const record = { pid: process.pid, started_at: isoNow(), nonce, hostname: process.env.COMPUTERNAME ?? null };
|
|
208
|
+
try {
|
|
209
|
+
writeFileSync(file, JSON.stringify(record) + '\n', { flag: 'wx' });
|
|
210
|
+
return { ok: true, owner: true, nonce };
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error?.code !== 'EEXIST') {
|
|
213
|
+
return { ok: false, code: 'UPDATE_LOCK_FAILED', error: String(error?.message ?? error) };
|
|
214
|
+
}
|
|
215
|
+
return tryReclaimUpdateLock({ home, record });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function lockOwnerAlive(record) {
|
|
220
|
+
const pid = Number(record?.pid);
|
|
221
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
222
|
+
try {
|
|
223
|
+
process.kill(pid, 0);
|
|
224
|
+
return true;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
// ESRCH = no such process (dead owner, safe to reclaim).
|
|
227
|
+
// EPERM = process exists but we cannot signal it (live owner, keep).
|
|
228
|
+
return error?.code !== 'ESRCH';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Stale-lock reclaim with a single atomic claim: each contender writes its
|
|
233
|
+
// full claim into a UNIQUE temp dir, then renames that dir onto the fixed
|
|
234
|
+
// arbitration path. Rename-onto-existing is atomic on the same filesystem:
|
|
235
|
+
// exactly one contender wins; the loser gets EEXIST and backs off. No
|
|
236
|
+
// shared claim.json is ever overwritten, so two contenders can never both
|
|
237
|
+
// believe they own the reclaim. A crashed winner leaves a stale arbitration
|
|
238
|
+
// dir: a later contender quarantines the whole dir (atomic rename away)
|
|
239
|
+
// only after proving the recorded owner dead, then restarts contention
|
|
240
|
+
// from the top. The winner's finally removes the arbitration dir only when
|
|
241
|
+
// its claim nonce still matches (CAS), never unconditionally.
|
|
242
|
+
function tryReclaimUpdateLock({ home, record }) {
|
|
243
|
+
const file = updateLockFile({ home });
|
|
244
|
+
const arbitrationPath = `${file}.arbitration`;
|
|
245
|
+
const myClaim = { pid: process.pid, nonce: record.nonce, started_at: isoNow() };
|
|
246
|
+
for (let round = 0; round < 3; round += 1) {
|
|
247
|
+
let current = null;
|
|
248
|
+
try { current = JSON.parse(readFileSync(file, 'utf8')); } catch { current = null; }
|
|
249
|
+
if (!current || typeof current !== 'object') {
|
|
250
|
+
// Unparseable lock: fail closed, keep for operator inspection.
|
|
251
|
+
return { ok: false, code: 'UPDATE_LOCK_CORRUPT' };
|
|
252
|
+
}
|
|
253
|
+
if (lockOwnerAlive(current)) {
|
|
254
|
+
return { ok: false, code: 'UPDATE_IN_PROGRESS' };
|
|
255
|
+
}
|
|
256
|
+
// Single atomic claim: unique temp dir -> rename onto fixed path.
|
|
257
|
+
const tmpDir = `${arbitrationPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
258
|
+
try {
|
|
259
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
260
|
+
writeFileSync(join(tmpDir, 'claim.json'), JSON.stringify(myClaim) + '\n', { flag: 'wx' });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
263
|
+
return { ok: false, code: 'UPDATE_LOCK_FAILED', error: String(error?.message ?? error) };
|
|
264
|
+
}
|
|
265
|
+
let claimed = false;
|
|
266
|
+
try {
|
|
267
|
+
renameSync(tmpDir, arbitrationPath);
|
|
268
|
+
claimed = true;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
271
|
+
if (error?.code !== 'EEXIST' && error?.code !== 'EPERM' && error?.code !== 'ENOTEMPTY') {
|
|
272
|
+
return { ok: false, code: 'UPDATE_LOCK_FAILED', error: String(error?.message ?? error) };
|
|
273
|
+
}
|
|
274
|
+
// Arbitration path occupied: quarantine it only when its recorded
|
|
275
|
+
// owner is provably dead; a live contender's claim is never touched.
|
|
276
|
+
let guard = null;
|
|
277
|
+
try { guard = JSON.parse(readFileSync(join(arbitrationPath, 'claim.json'), 'utf8')); } catch { guard = null; }
|
|
278
|
+
if (!guard || typeof guard !== 'object' || lockOwnerAlive(guard)) {
|
|
279
|
+
return { ok: false, code: 'UPDATE_IN_PROGRESS' };
|
|
280
|
+
}
|
|
281
|
+
const quarantine = `${arbitrationPath}.quarantine.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
282
|
+
try {
|
|
283
|
+
renameSync(arbitrationPath, quarantine);
|
|
284
|
+
try { rmSync(quarantine, { recursive: true, force: true }); } catch {}
|
|
285
|
+
} catch {
|
|
286
|
+
return { ok: false, code: 'UPDATE_IN_PROGRESS' };
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
// Winner: re-verify the main lock is still the same dead record.
|
|
292
|
+
let reread = null;
|
|
293
|
+
try { reread = JSON.parse(readFileSync(file, 'utf8')); } catch { reread = null; }
|
|
294
|
+
if (!reread || reread.nonce !== current.nonce || reread.pid !== current.pid) {
|
|
295
|
+
return { ok: false, code: 'UPDATE_IN_PROGRESS' };
|
|
296
|
+
}
|
|
297
|
+
if (lockOwnerAlive(reread)) {
|
|
298
|
+
return { ok: false, code: 'UPDATE_IN_PROGRESS' };
|
|
299
|
+
}
|
|
300
|
+
rmSync(file, { force: true });
|
|
301
|
+
try {
|
|
302
|
+
writeFileSync(file, JSON.stringify(record) + '\n', { flag: 'wx' });
|
|
303
|
+
return { ok: true, owner: true, nonce: record.nonce, reclaimed: true };
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error?.code === 'EEXIST') return { ok: false, code: 'UPDATE_IN_PROGRESS' };
|
|
306
|
+
return { ok: false, code: 'UPDATE_LOCK_FAILED', error: String(error?.message ?? error) };
|
|
307
|
+
}
|
|
308
|
+
} finally {
|
|
309
|
+
// CAS cleanup: remove the arbitration dir only when its claim is
|
|
310
|
+
// still ours; never delete another contender's claim.
|
|
311
|
+
try {
|
|
312
|
+
const mine = JSON.parse(readFileSync(join(arbitrationPath, 'claim.json'), 'utf8'));
|
|
313
|
+
if (mine?.nonce === myClaim.nonce && mine?.pid === process.pid) {
|
|
314
|
+
rmSync(arbitrationPath, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
} catch {}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { ok: false, code: 'UPDATE_IN_PROGRESS' };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function releaseUpdateLock({ home = homedir(), nonce = null } = {}) {
|
|
323
|
+
// Nonce-checked release: a stale owner finalizer must never delete a
|
|
324
|
+
// replacement owner's lock. Without an expected nonce this is best-effort
|
|
325
|
+
// legacy behavior; with one it fails closed on mismatch.
|
|
326
|
+
if (nonce !== null && nonce !== undefined) {
|
|
327
|
+
let current = null;
|
|
328
|
+
try { current = JSON.parse(readFileSync(updateLockFile({ home }), 'utf8')); } catch { current = null; }
|
|
329
|
+
if (!current || current.nonce !== nonce) {
|
|
330
|
+
return { ok: false, code: 'NOT_OWNER' };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
try { rmSync(updateLockFile({ home }), { force: true }); } catch {}
|
|
334
|
+
return { ok: true };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function readUpdateJournal({ home = homedir() } = {}) {
|
|
338
|
+
const file = updateJournalFile({ home });
|
|
339
|
+
if (!existsSync(file)) return null;
|
|
340
|
+
let raw;
|
|
341
|
+
try {
|
|
342
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
343
|
+
} catch {
|
|
344
|
+
// A truncated journal is a crash artifact, NOT absence: fail closed and
|
|
345
|
+
// keep the file so an operator can inspect it instead of silently
|
|
346
|
+
// treating an interrupted transaction as clean.
|
|
347
|
+
return { malformed: true, file };
|
|
348
|
+
}
|
|
349
|
+
if (!raw || typeof raw !== 'object' || typeof raw.stage !== 'string') {
|
|
350
|
+
return { malformed: true, file };
|
|
351
|
+
}
|
|
352
|
+
// Full schema check: a semantically broken journal (null candidate,
|
|
353
|
+
// incomplete prior) must fail closed, never enter recovery.
|
|
354
|
+
if (!validJournalCandidate(raw.candidate)) {
|
|
355
|
+
return { malformed: true, file, code: 'JOURNAL_CANDIDATE_SCHEMA_INVALID' };
|
|
356
|
+
}
|
|
357
|
+
if (raw.prior !== null && raw.prior !== undefined && !validJournalRelease(raw.prior)) {
|
|
358
|
+
return { malformed: true, file, code: 'JOURNAL_PRIOR_SCHEMA_INVALID' };
|
|
359
|
+
}
|
|
360
|
+
return raw;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Synchronously read the @deepseek-ai/dsh version a runtime tree ships.
|
|
364
|
+
function readRuntimeTreeVersionSync(root) {
|
|
365
|
+
if (!root || typeof root !== 'string') return null;
|
|
366
|
+
try {
|
|
367
|
+
const parsed = JSON.parse(readFileSync(join(root, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'), 'utf8'));
|
|
368
|
+
return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null;
|
|
369
|
+
} catch { return null; }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function writeUpdateJournal({ home, stage, prior = null, candidate = null, verified = false, runtime = null }) {
|
|
373
|
+
const record = { stage, prior, candidate, verified: verified === true, updated_at: isoNow() };
|
|
374
|
+
if (runtime && typeof runtime === 'object') record.runtime = runtime;
|
|
375
|
+
writeFileAtomic(updateJournalFile({ home }), JSON.stringify(record, null, 2) + '\n');
|
|
376
|
+
return record;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Atomically mark the current journal verified AFTER re-checking its
|
|
380
|
+
// identity matches the expected stage/prior/candidate. A crash between
|
|
381
|
+
// verification success and this write leaves the journal unverified, so
|
|
382
|
+
// reconcile refuses to finalize it.
|
|
383
|
+
export function markJournalVerified({ home = homedir(), stage, prior = null, candidate = null, runtime = null } = {}) {
|
|
384
|
+
const current = readUpdateJournal({ home });
|
|
385
|
+
if (!current || current.malformed) return { ok: false, code: 'JOURNAL_NOT_FOUND' };
|
|
386
|
+
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
387
|
+
if (current.stage !== stage || !same(current.prior, prior) || !same(current.candidate, candidate)) {
|
|
388
|
+
return { ok: false, code: 'JOURNAL_IDENTITY_MISMATCH' };
|
|
389
|
+
}
|
|
390
|
+
writeUpdateJournal({ home, stage, prior, candidate, verified: true, runtime });
|
|
391
|
+
return { ok: true };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function clearUpdateJournal({ home = homedir() } = {}) {
|
|
395
|
+
try { rmSync(updateJournalFile({ home }), { force: true }); } catch {}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Synchronous profile-registration compensation for crash recovery:
|
|
399
|
+
// re-point the dedicated Crew profile at the prior release. Host
|
|
400
|
+
// integrations (Codex/ZCode/startup/Claude) are repaired by the next
|
|
401
|
+
// successful install/update activation; the profile link is the surface
|
|
402
|
+
// that must never dangle at a deleted candidate.
|
|
403
|
+
function compensateActivationSync({ home, prior, manifest, log, installer }) {
|
|
404
|
+
if (!prior?.path || !existsSync(prior.path)) return { ok: false, code: 'PRIOR_RELEASE_MISSING' };
|
|
405
|
+
if (!manifest?.name) return { ok: false, code: 'PRIOR_MANIFEST_INVALID' };
|
|
406
|
+
const registration = ensureCrewPluginRegistration({ home, root: prior.path, name: manifest.name });
|
|
407
|
+
if (!registration.ok) return { ok: false, code: registration.code ?? 'PRIOR_REGISTRATION_FAILED' };
|
|
408
|
+
return { ok: true, version: manifest.version, path: prior.path };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Undo a first-install candidate's activation surfaces: remove its profile
|
|
412
|
+
// registration (dependency + bundle + junction) ONLY when each surface
|
|
413
|
+
// still references THIS journal's candidate. The dependency must resolve
|
|
414
|
+
// to exactly link:<candidateRealPath> (normalized slashes); a later
|
|
415
|
+
// legitimate registration pointing elsewhere is never removed. Mixed or
|
|
416
|
+
// unjudgeable state fails closed with the journal retained. When the
|
|
417
|
+
// profile manifest is missing/unreadable the junction is still handled
|
|
418
|
+
// independently so a "junction created, manifest never written" crash
|
|
419
|
+
// cannot leave a dangling junction.
|
|
420
|
+
function undoCandidateActivationSync({ home, candidateDir, candidateName }) {
|
|
421
|
+
if (!candidateDir || !candidateName) return { ok: true, undone: false };
|
|
422
|
+
const profileRoot = crewProfileDir({ home });
|
|
423
|
+
const profileFile = join(profileRoot, 'package.json');
|
|
424
|
+
let manifest = null;
|
|
425
|
+
let manifestReadable = true;
|
|
426
|
+
try { manifest = JSON.parse(readFileSync(profileFile, 'utf8')); } catch { manifest = null; manifestReadable = false; }
|
|
427
|
+
const linkPath = join(profileRoot, 'node_modules', ...candidateName.split('/'));
|
|
428
|
+
let linked = null;
|
|
429
|
+
try {
|
|
430
|
+
if (lstatSync(linkPath).isSymbolicLink()) linked = realpathSync(linkPath);
|
|
431
|
+
} catch { linked = null; }
|
|
432
|
+
let candidateReal = null;
|
|
433
|
+
try { candidateReal = realpathSync(candidateDir); } catch { candidateReal = candidateDir; }
|
|
434
|
+
const expectedDep = `link:${String(candidateReal).replace(/\\/g, '/')}`;
|
|
435
|
+
const rawDep = manifest?.dependencies?.[candidateName];
|
|
436
|
+
const depPointsAtCandidate = typeof rawDep === 'string'
|
|
437
|
+
&& rawDep.replace(/\\/g, '/') === expectedDep;
|
|
438
|
+
const bundleNamesCandidate = Array.isArray(manifest?.dsh?.profile?.bundles) && manifest.dsh.profile.bundles.includes(candidateName);
|
|
439
|
+
const linkPointsAtCandidate = linked !== null && linked === candidateReal;
|
|
440
|
+
// Bundle carries no path: it NEVER grants deletion authority by itself.
|
|
441
|
+
// It is removed only when dependency or junction proves THIS journal's
|
|
442
|
+
// candidate still owns the registration. A re-pointed dep/junction with
|
|
443
|
+
// a leftover same-name bundle fails closed (journal retained).
|
|
444
|
+
const identityEvidence = depPointsAtCandidate || linkPointsAtCandidate;
|
|
445
|
+
const bundlePointsAtCandidate = bundleNamesCandidate && identityEvidence;
|
|
446
|
+
// Manifest unreadable but junction dangles at candidate: remove junction.
|
|
447
|
+
if (!manifestReadable) {
|
|
448
|
+
if (!linkPointsAtCandidate) return { ok: true, undone: false };
|
|
449
|
+
try { rmSync(linkPath, { force: true }); } catch (error) {
|
|
450
|
+
return { ok: false, code: 'CANDIDATE_UNDO_FAILED', error: String(error?.message ?? error) };
|
|
451
|
+
}
|
|
452
|
+
return { ok: true, undone: true };
|
|
453
|
+
}
|
|
454
|
+
if (!manifest) return { ok: true, undone: false };
|
|
455
|
+
// Same-name package re-pointed elsewhere (dep or junction references a
|
|
456
|
+
// different target) while the bundle still names it: mixed state that
|
|
457
|
+
// cannot prove THIS candidate owns the registration. Fail closed,
|
|
458
|
+
// retain the journal for operator inspection.
|
|
459
|
+
const depPointsElsewhere = typeof rawDep === 'string' && !depPointsAtCandidate;
|
|
460
|
+
const linkPointsElsewhere = linked !== null && !linkPointsAtCandidate;
|
|
461
|
+
if ((depPointsElsewhere || linkPointsElsewhere) && bundleNamesCandidate) {
|
|
462
|
+
return { ok: false, code: 'CANDIDATE_UNDO_AMBIGUOUS', error: 'same-name package re-pointed elsewhere; refusing bundle removal' };
|
|
463
|
+
}
|
|
464
|
+
if (!depPointsAtCandidate && !bundlePointsAtCandidate && !linkPointsAtCandidate) {
|
|
465
|
+
return { ok: true, undone: false };
|
|
466
|
+
}
|
|
467
|
+
// Partial registration: only undo the surfaces that reference THIS
|
|
468
|
+
// candidate; leave anything already re-pointed elsewhere untouched.
|
|
469
|
+
try {
|
|
470
|
+
const next = { ...manifest };
|
|
471
|
+
if (depPointsAtCandidate) {
|
|
472
|
+
next.dependencies = { ...manifest.dependencies };
|
|
473
|
+
delete next.dependencies[candidateName];
|
|
474
|
+
if (Object.keys(next.dependencies).length === 0) delete next.dependencies;
|
|
475
|
+
}
|
|
476
|
+
if (bundlePointsAtCandidate) {
|
|
477
|
+
next.dsh = { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: manifest.dsh.profile.bundles.filter((b) => b !== candidateName) } };
|
|
478
|
+
}
|
|
479
|
+
writeFileAtomic(profileFile, JSON.stringify(next, null, 2) + '\n');
|
|
480
|
+
if (linkPointsAtCandidate) rmSync(linkPath, { force: true });
|
|
481
|
+
} catch (error) {
|
|
482
|
+
return { ok: false, code: 'CANDIDATE_UNDO_FAILED', error: String(error?.message ?? error) };
|
|
483
|
+
}
|
|
484
|
+
return { ok: true, undone: true };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Canonical containment validation for a journal's runtime segment. Recovery
|
|
488
|
+
// may rename/recursively-delete these roots, so liveRoot and retainedRoot
|
|
489
|
+
// must be EXACTLY the canonical Crew-owned paths (resolved, never a suffix
|
|
490
|
+
// match), and a parked prior root must be a DIRECT child of the harness home
|
|
491
|
+
// whose basename matches the lifecycle's own runtime-prev-* naming.
|
|
492
|
+
function validateJournalRuntimeSegment({ home, rt }) {
|
|
493
|
+
const harnessRoot = resolve(crewDshHome({ home }));
|
|
494
|
+
const canonicalLive = resolve(crewDshRuntimeRoot({ home }));
|
|
495
|
+
const canonicalRetained = resolve(join(harnessRoot, 'retained-runtimes'));
|
|
496
|
+
// A coordinated-update journal MUST carry a runtime segment: without it
|
|
497
|
+
// recovery could restore the payload but leave a candidate-cohort runtime
|
|
498
|
+
// behind (an unsupported pair) and clear the journal.
|
|
499
|
+
if (!rt || typeof rt !== 'object') return { ok: false, error: 'runtime segment missing from coordinated-update journal' };
|
|
500
|
+
if (typeof rt.liveRoot !== 'string' || resolve(rt.liveRoot) !== canonicalLive) {
|
|
501
|
+
return { ok: false, error: `liveRoot is not the canonical Crew runtime root (${rt.liveRoot})` };
|
|
502
|
+
}
|
|
503
|
+
if (typeof rt.candidateVersion !== 'string' || !EXACT_DSH_VERSION_RE.test(rt.candidateVersion)) {
|
|
504
|
+
return { ok: false, error: 'candidateVersion is not an exact version' };
|
|
505
|
+
}
|
|
506
|
+
if (rt.priorVersion !== undefined && rt.priorVersion !== null
|
|
507
|
+
&& (typeof rt.priorVersion !== 'string' || !EXACT_DSH_VERSION_RE.test(rt.priorVersion))) {
|
|
508
|
+
return { ok: false, error: 'priorVersion is not an exact version' };
|
|
509
|
+
}
|
|
510
|
+
if (rt.priorRoot !== undefined && rt.priorRoot !== null) {
|
|
511
|
+
if (typeof rt.priorRoot !== 'string') return { ok: false, error: 'priorRoot is not a path' };
|
|
512
|
+
const priorResolved = resolve(rt.priorRoot);
|
|
513
|
+
// Direct child of the harness home only.
|
|
514
|
+
if (dirname(priorResolved) !== harnessRoot) {
|
|
515
|
+
return { ok: false, error: `priorRoot is not a direct child of the Crew harness home (${rt.priorRoot})` };
|
|
516
|
+
}
|
|
517
|
+
const base = priorResolved.split(/[\\/]/).filter(Boolean).at(-1) ?? '';
|
|
518
|
+
if (!/^runtime-prev-[0-9a-z-]+$/i.test(base)) {
|
|
519
|
+
return { ok: false, error: `priorRoot basename is not a lifecycle runtime-prev-* dir (${base})` };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (rt.retainedRoot !== undefined && rt.retainedRoot !== null) {
|
|
523
|
+
if (typeof rt.retainedRoot !== 'string' || resolve(rt.retainedRoot) !== canonicalRetained) {
|
|
524
|
+
return { ok: false, error: `retainedRoot is not the canonical retained-runtimes dir (${rt.retainedRoot})` };
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return { ok: true };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Reconcile a leftover journal from a crashed update/install. The single
|
|
531
|
+
// commit point is the pointer write: pointer == candidate means committed
|
|
532
|
+
// (finalize, do NOT roll back); pointer == prior/absent means pre-commit
|
|
533
|
+
// (restore activation surfaces, drop candidate). A malformed journal fails
|
|
534
|
+
// closed and is retained for operator inspection.
|
|
535
|
+
export function reconcileUpdateJournal({ home = homedir(), log = () => {}, installer = realInstaller } = {}) {
|
|
536
|
+
const journal = readUpdateJournal({ home });
|
|
537
|
+
if (!journal) return { ok: true, reconciled: false };
|
|
538
|
+
if (journal.malformed) {
|
|
539
|
+
return { ok: false, code: journal.code ?? 'JOURNAL_MALFORMED', file: journal.file };
|
|
540
|
+
}
|
|
541
|
+
// Tri-state pointer: a corrupt pointer is NOT absence. Recovery with a
|
|
542
|
+
// malformed pointer fails closed with the journal retained.
|
|
543
|
+
const pointerState = readCurrentPointerState({ home });
|
|
544
|
+
if (pointerState.status === 'malformed') {
|
|
545
|
+
return { ok: false, code: 'POINTER_MALFORMED', file: pointerState.file, error: pointerState.code ?? 'pointer unreadable; refusing recovery' };
|
|
546
|
+
}
|
|
547
|
+
const pointer = pointerState.status === 'valid' ? pointerState.pointer : null;
|
|
548
|
+
const candidateDir = journal.candidate?.stageDir ?? null;
|
|
549
|
+
const candidateManifest = candidateDir && existsSync(candidateDir) ? readManifest(candidateDir) : null;
|
|
550
|
+
|
|
551
|
+
// A coordinated-update journal MUST carry a runtime segment; recovery may
|
|
552
|
+
// rename or recursively delete its roots, so validate them canonically
|
|
553
|
+
// BEFORE granting any destructive authority: liveRoot/retainedRoot must be
|
|
554
|
+
// the exact canonical Crew-owned paths (resolved, no suffix games) and a
|
|
555
|
+
// parked prior root must be a direct child of the harness home matching
|
|
556
|
+
// the lifecycle's own runtime-prev-* naming. A missing or invalid segment
|
|
557
|
+
// fails closed and touches nothing.
|
|
558
|
+
if (journal.stage === 'coordinated-update') {
|
|
559
|
+
const validation = validateJournalRuntimeSegment({ home, rt: journal.runtime });
|
|
560
|
+
if (!validation.ok) {
|
|
561
|
+
return { ok: false, code: 'JOURNAL_RUNTIME_INVALID', stage: journal.stage, error: validation.error, file: updateJournalFile({ home }) };
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Committed side: pointer must match the FULL journal candidate identity
|
|
566
|
+
// (name + version + path), and the candidate manifest is verified against
|
|
567
|
+
// the JOURNAL candidate name/version (not its own). A rollback journal
|
|
568
|
+
// additionally requires explicit verification: pointer==target alone
|
|
569
|
+
// never means a verified rollback commit (activation + restart +
|
|
570
|
+
// dual-domain verification may never have completed).
|
|
571
|
+
const candidateIdent = journal.candidate ?? null;
|
|
572
|
+
const pointerMatchesCandidate = candidateDir !== null
|
|
573
|
+
&& pointer?.path === candidateDir
|
|
574
|
+
&& typeof pointer?.name === 'string' && pointer.name.length > 0
|
|
575
|
+
&& typeof pointer?.version === 'string' && pointer.version.length > 0
|
|
576
|
+
&& pointer.name === candidateIdent?.name
|
|
577
|
+
&& pointer.version === candidateIdent?.version;
|
|
578
|
+
if (candidateDir && pointer?.path === candidateDir && !pointerMatchesCandidate) {
|
|
579
|
+
return { ok: false, code: 'JOURNAL_POINTER_DIVERGED', stage: journal.stage, error: 'pointer path matches candidate but name/version do not; refusing recovery' };
|
|
580
|
+
}
|
|
581
|
+
if (journal.stage === 'rollback' && pointerMatchesCandidate && journal.verified !== true) {
|
|
582
|
+
return { ok: false, code: 'JOURNAL_ROLLBACK_UNVERIFIED', stage: journal.stage, error: 'rollback journal not marked verified; refusing finalize' };
|
|
583
|
+
}
|
|
584
|
+
if (journal.stage === 'coordinated-update' && pointerMatchesCandidate && journal.verified !== true) {
|
|
585
|
+
return { ok: false, code: 'JOURNAL_COORDINATED_UNVERIFIED', stage: journal.stage, error: 'coordinated-update journal not marked verified; refusing finalize' };
|
|
586
|
+
}
|
|
587
|
+
if (journal.stage === 'coordinated-update' && pointerMatchesCandidate) {
|
|
588
|
+
// Coordinated commit also requires the live runtime to report the
|
|
589
|
+
// candidate cohort. We cannot reach the network synchronously here, so
|
|
590
|
+
// verify against the journal's recorded runtime roots: the live tree must
|
|
591
|
+
// BE the candidate runtime (candidateRoot moved onto liveRoot).
|
|
592
|
+
const rt = journal.runtime ?? null;
|
|
593
|
+
if (rt?.candidateVersion && rt?.liveRoot) {
|
|
594
|
+
const liveVersion = readRuntimeTreeVersionSync(rt.liveRoot);
|
|
595
|
+
if (liveVersion !== rt.candidateVersion) {
|
|
596
|
+
return { ok: false, code: 'JOURNAL_COORDINATED_RUNTIME_MISMATCH', stage: journal.stage, error: `live runtime is ${liveVersion ?? 'unknown'} but coordinated journal expects ${rt.candidateVersion}; refusing finalize` };
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (pointerMatchesCandidate) {
|
|
601
|
+
if (!candidateManifest?.name || !candidateManifest?.version) {
|
|
602
|
+
return { ok: false, code: 'JOURNAL_CANDIDATE_INVALID', stage: journal.stage };
|
|
603
|
+
}
|
|
604
|
+
const validated = validateInstalledPayload(candidateDir, { expectedName: candidateIdent.name, expectedVersion: candidateIdent.version });
|
|
605
|
+
if (!validated.ok) {
|
|
606
|
+
return { ok: false, code: 'JOURNAL_CANDIDATE_INVALID', stage: journal.stage, error: (validated.errors ?? []).join('; ') };
|
|
607
|
+
}
|
|
608
|
+
clearUpdateJournal({ home });
|
|
609
|
+
gcOldReleases({ home, protect: journal.prior?.path ?? null });
|
|
610
|
+
log(`- recovered update journal at stage ${journal.stage}: candidate ${candidateIdent.version} already committed, finalized`);
|
|
611
|
+
return { ok: true, reconciled: true, stage: journal.stage, committed: true };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Diverged side: pointer references neither candidate nor prior. No
|
|
615
|
+
// authority to decide which release should win: fail closed, touch
|
|
616
|
+
// nothing (no registration change, no release delete, no journal clear).
|
|
617
|
+
const priorPath = journal.prior?.path ?? null;
|
|
618
|
+
const priorMatches = priorPath !== null
|
|
619
|
+
&& pointer?.path === priorPath
|
|
620
|
+
&& pointer?.name === journal.prior?.name
|
|
621
|
+
&& pointer?.version === journal.prior?.version;
|
|
622
|
+
const isAbsent = !pointer;
|
|
623
|
+
if (!priorMatches && !isAbsent) {
|
|
624
|
+
return { ok: false, code: 'JOURNAL_POINTER_DIVERGED', stage: journal.stage, error: `pointer references unexpected release ${pointer?.path ?? 'unknown'}; refusing recovery` };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Pre-commit side: prior stays authoritative. Re-point live activation
|
|
628
|
+
// surfaces back at prior (a crash between activation and pointer write
|
|
629
|
+
// leaves them on the candidate), then drop the candidate. A rollback
|
|
630
|
+
// journal's candidate is a RETAINED release, never an orphan stage: it
|
|
631
|
+
// must be preserved even when the pointer still references prior. A
|
|
632
|
+
// coordinated-update journal additionally carries a runtime segment: a
|
|
633
|
+
// crash mid-swap may have left the live runtime on the CANDIDATE cohort
|
|
634
|
+
// while the pointer (and payload) still reference prior — that unsupported
|
|
635
|
+
// combination must be restored to the prior cohort synchronously here.
|
|
636
|
+
if (priorPath) {
|
|
637
|
+
if (!existsSync(journal.prior.path)) {
|
|
638
|
+
return { ok: false, code: 'JOURNAL_PRIOR_MISSING', stage: journal.stage, error: 'prior release disappeared; refusing silent recovery' };
|
|
639
|
+
}
|
|
640
|
+
const rt = journal.stage === 'coordinated-update' ? journal.runtime ?? null : null;
|
|
641
|
+
if (rt?.priorVersion && rt?.liveRoot) {
|
|
642
|
+
const liveVersion = readRuntimeTreeVersionSync(rt.liveRoot);
|
|
643
|
+
if (liveVersion !== rt.priorVersion) {
|
|
644
|
+
// Move the parked prior tree back onto liveRoot. This is a pure
|
|
645
|
+
// directory swap: no network, no registry, safe under a stale lock.
|
|
646
|
+
const parked = rt.priorRoot && existsSync(rt.priorRoot) ? rt.priorRoot : null;
|
|
647
|
+
if (!parked && rt.candidateRoot && existsSync(rt.candidateRoot)) {
|
|
648
|
+
// Fall back: liveRoot may hold the candidate tree and the prior
|
|
649
|
+
// tree may have been consumed into retained-runtimes. Attempt the
|
|
650
|
+
// retained path next.
|
|
651
|
+
}
|
|
652
|
+
if (parked) {
|
|
653
|
+
try {
|
|
654
|
+
rmSync(rt.liveRoot, { recursive: true, force: true });
|
|
655
|
+
renameSync(parked, rt.liveRoot);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
return { ok: false, code: 'JOURNAL_COORDINATED_RUNTIME_RESTORE_FAILED', stage: journal.stage, error: `prior runtime restore failed: ${error?.message ?? error}` };
|
|
658
|
+
}
|
|
659
|
+
} else {
|
|
660
|
+
// No parked tree: try the retained-runtimes/<priorVersion> copy.
|
|
661
|
+
const retainedDir = rt.retainedRoot ? join(rt.retainedRoot, rt.priorVersion) : null;
|
|
662
|
+
let restored = false;
|
|
663
|
+
if (retainedDir && existsSync(retainedDir)) {
|
|
664
|
+
try {
|
|
665
|
+
rmSync(rt.liveRoot, { recursive: true, force: true });
|
|
666
|
+
renameSync(retainedDir, rt.liveRoot);
|
|
667
|
+
restored = true;
|
|
668
|
+
} catch { /* fall through to fail closed */ }
|
|
669
|
+
}
|
|
670
|
+
if (!restored) {
|
|
671
|
+
return { ok: false, code: 'JOURNAL_COORDINATED_RUNTIME_RESTORE_FAILED', stage: journal.stage, error: 'prior runtime tree unavailable for synchronous recovery; run update --candidate to re-migrate' };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
log(`- coordinated recovery: restored prior DSH runtime (@${rt.priorVersion})`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const priorManifest = readManifest(journal.prior.path);
|
|
678
|
+
const compensated = compensateActivationSync({ home, prior: journal.prior, manifest: priorManifest, log, installer });
|
|
679
|
+
if (!compensated.ok) {
|
|
680
|
+
return { ok: false, code: 'JOURNAL_COMPENSATE_FAILED', stage: journal.stage, error: compensated.error ?? compensated.code };
|
|
681
|
+
}
|
|
682
|
+
if (journal.stage !== 'rollback' && journal.stage !== 'coordinated-update' && candidateDir && existsSync(candidateDir)) {
|
|
683
|
+
// A coordinated-update candidate stage is consumed into the runtime
|
|
684
|
+
// swap already; if it still exists it is an orphan of the failed swap
|
|
685
|
+
// and may be removed. (rollback candidates are retained releases.)
|
|
686
|
+
try { rmSync(candidateDir, { recursive: true, force: true }); } catch {}
|
|
687
|
+
}
|
|
688
|
+
clearUpdateJournal({ home });
|
|
689
|
+
log(`- recovered update journal at stage ${journal.stage}: restored prior release ${journal.prior.version}`);
|
|
690
|
+
return { ok: true, reconciled: true, stage: journal.stage, committed: false };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// First-install pre-commit: no prior exists. Undo the candidate's
|
|
694
|
+
// activation surfaces FIRST (a crash between activation and pointer
|
|
695
|
+
// write leaves the profile link on the candidate), then remove the
|
|
696
|
+
// orphan candidate pointer + dir. Journal clears only after successful
|
|
697
|
+
// compensation.
|
|
698
|
+
const undone = undoCandidateActivationSync({ home, candidateDir, candidateName: journal.candidate?.name ?? null });
|
|
699
|
+
if (!undone.ok) {
|
|
700
|
+
return { ok: false, code: 'JOURNAL_UNDO_FAILED', stage: journal.stage, error: undone.error ?? undone.code };
|
|
701
|
+
}
|
|
702
|
+
if (candidateDir && existsSync(candidateDir)) {
|
|
703
|
+
try { rmSync(candidateDir, { recursive: true, force: true }); } catch {}
|
|
704
|
+
}
|
|
705
|
+
if (pointer && candidateDir && pointer.path === candidateDir) {
|
|
706
|
+
try { rmSync(currentPointerFile({ home }), { force: true }); } catch {}
|
|
707
|
+
}
|
|
708
|
+
clearUpdateJournal({ home });
|
|
709
|
+
log(`- recovered first-install journal at stage ${journal.stage}: removed orphan candidate`);
|
|
710
|
+
return { ok: true, reconciled: true, stage: journal.stage, committed: false };
|
|
711
|
+
}
|
|
712
|
+
|
|
146
713
|
// ---- dependency tree materialization ----------------------------------------
|
|
147
714
|
|
|
148
715
|
function listPackageEdges(manifest) {
|
|
@@ -468,24 +1035,53 @@ export function validateInstalledPayload(dir, { expectedName, expectedVersion, a
|
|
|
468
1035
|
}
|
|
469
1036
|
|
|
470
1037
|
// ---- release commit / activation ---------------------------------------------
|
|
1038
|
+
// Transactional commit: stage -> journal(activating) -> activation ->
|
|
1039
|
+
// pointer write LAST -> clear journal -> GC (prior protected until commit).
|
|
1040
|
+
// A crash at any point before the pointer write leaves the prior release
|
|
1041
|
+
// authoritative and the journal behind for reconcileUpdateJournal.
|
|
1042
|
+
export function beginReleaseActivation({ stageDir, manifest, home, prior = null }) {
|
|
1043
|
+
// Journal FIRST, marker removal second: a crash between the two leaves a
|
|
1044
|
+
// journaled (recoverable) candidate, never a marker-free orphan that
|
|
1045
|
+
// looks complete but was never in a transaction. Marker removal failure
|
|
1046
|
+
// aborts the transaction instead of proceeding with a half-marked stage.
|
|
1047
|
+
writeUpdateJournal({
|
|
1048
|
+
home,
|
|
1049
|
+
stage: 'activating',
|
|
1050
|
+
prior: prior ? { name: prior.name, version: prior.version, path: prior.path } : null,
|
|
1051
|
+
candidate: { name: manifest.name, version: manifest.version, stageDir },
|
|
1052
|
+
});
|
|
1053
|
+
try {
|
|
1054
|
+
rmSync(join(stageDir, INCOMPLETE_MARKER), { force: true });
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
throw Object.assign(new Error(`cannot clear stage marker: ${error?.message ?? error}`), { code: 'STAGE_MARKER_REMOVE_FAILED' });
|
|
1057
|
+
}
|
|
1058
|
+
if (existsSync(join(stageDir, INCOMPLETE_MARKER))) {
|
|
1059
|
+
throw Object.assign(new Error('stage marker still present after removal'), { code: 'STAGE_MARKER_REMOVE_FAILED' });
|
|
1060
|
+
}
|
|
1061
|
+
return stageDir;
|
|
1062
|
+
}
|
|
471
1063
|
|
|
472
|
-
function
|
|
473
|
-
rmSync(join(stageDir, INCOMPLETE_MARKER));
|
|
1064
|
+
export function commitActivatedRelease({ stageDir, manifest, home, prior = null }) {
|
|
474
1065
|
writeCurrentPointer({ home, name: manifest.name, version: manifest.version, path: stageDir });
|
|
475
|
-
|
|
1066
|
+
clearUpdateJournal({ home });
|
|
1067
|
+
gcOldReleases({ home, protect: prior?.path ?? null });
|
|
476
1068
|
return stageDir;
|
|
477
1069
|
}
|
|
478
1070
|
|
|
1071
|
+
function commitStagedRelease({ stageDir, manifest, home, prior = null }) {
|
|
1072
|
+
return commitActivatedRelease({ stageDir, manifest, home, prior });
|
|
1073
|
+
}
|
|
1074
|
+
|
|
479
1075
|
const STALE_INCOMPLETE_MS = 24 * 60 * 60 * 1000;
|
|
480
1076
|
|
|
481
|
-
function gcOldReleases({ home, keep = KEEP_RELEASES }) {
|
|
1077
|
+
function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
|
|
482
1078
|
const pointer = readCurrentPointer({ home });
|
|
483
1079
|
const releasesDir = crewReleasesDir({ home });
|
|
484
1080
|
if (!existsSync(releasesDir)) return;
|
|
485
1081
|
const removed = [];
|
|
486
1082
|
const dirs = readdirSync(releasesDir)
|
|
487
1083
|
.map((name) => join(releasesDir, name))
|
|
488
|
-
.filter((dir) => !pointer || dir !== pointer.path);
|
|
1084
|
+
.filter((dir) => (!pointer || dir !== pointer.path) && (!protect || dir !== protect));
|
|
489
1085
|
const incomplete = [];
|
|
490
1086
|
const complete = [];
|
|
491
1087
|
for (const dir of dirs) {
|
|
@@ -507,123 +1103,585 @@ function gcOldReleases({ home, keep = KEEP_RELEASES }) {
|
|
|
507
1103
|
try { rmSync(victim, { recursive: true, force: true }); removed.push(victim); } catch { /* best effort */ }
|
|
508
1104
|
}
|
|
509
1105
|
return removed;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
export function listManagedReleases({ home = homedir() } = {}) {
|
|
513
|
-
const pointer = readCurrentPointer({ home });
|
|
514
|
-
const root = crewReleasesDir({ home });
|
|
515
|
-
if (!existsSync(root)) return [];
|
|
516
|
-
return readdirSync(root, { withFileTypes: true })
|
|
517
|
-
.filter((entry) => entry.isDirectory())
|
|
518
|
-
.map((entry) => {
|
|
519
|
-
const path = join(root, entry.name);
|
|
520
|
-
const manifest = readManifest(path);
|
|
521
|
-
if (!manifest?.name || !manifest?.version) return null;
|
|
522
|
-
const validation = validateInstalledPayload(path, { expectedName: manifest.name, expectedVersion: manifest.version });
|
|
523
|
-
return {
|
|
524
|
-
name: manifest.name,
|
|
525
|
-
version: manifest.version,
|
|
526
|
-
path,
|
|
527
|
-
current: pointer?.path === path,
|
|
528
|
-
healthy: validation.ok,
|
|
529
|
-
};
|
|
530
|
-
})
|
|
531
|
-
.filter(Boolean)
|
|
532
|
-
.sort((a, b) => compareVersions(b.version, a.version) || b.path.localeCompare(a.path));
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export function listManagedReleases({ home = homedir() } = {}) {
|
|
1109
|
+
const pointer = readCurrentPointer({ home });
|
|
1110
|
+
const root = crewReleasesDir({ home });
|
|
1111
|
+
if (!existsSync(root)) return [];
|
|
1112
|
+
return readdirSync(root, { withFileTypes: true })
|
|
1113
|
+
.filter((entry) => entry.isDirectory())
|
|
1114
|
+
.map((entry) => {
|
|
1115
|
+
const path = join(root, entry.name);
|
|
1116
|
+
const manifest = readManifest(path);
|
|
1117
|
+
if (!manifest?.name || !manifest?.version) return null;
|
|
1118
|
+
const validation = validateInstalledPayload(path, { expectedName: manifest.name, expectedVersion: manifest.version });
|
|
1119
|
+
return {
|
|
1120
|
+
name: manifest.name,
|
|
1121
|
+
version: manifest.version,
|
|
1122
|
+
path,
|
|
1123
|
+
current: pointer?.path === path,
|
|
1124
|
+
healthy: validation.ok,
|
|
1125
|
+
};
|
|
1126
|
+
})
|
|
1127
|
+
.filter(Boolean)
|
|
1128
|
+
.sort((a, b) => compareVersions(b.version, a.version) || b.path.localeCompare(a.path));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Unified restart client: request a Crew 3210 restart through the durable
|
|
1132
|
+
// supervisor control channel. The hub (3210) writes a restart request; the
|
|
1133
|
+
// Windows supervisor executes it and writes a VERIFIED result. This polls
|
|
1134
|
+
// until the result arrives (or the timeout elapses). NEVER talks to the
|
|
1135
|
+
// legacy 3080 supervisor endpoint.
|
|
1136
|
+
export async function requestCrewRuntimeRestart({
|
|
1137
|
+
reason = null,
|
|
1138
|
+
fetchImpl = globalThis.fetch,
|
|
1139
|
+
pollIntervalMs = 1_000,
|
|
1140
|
+
timeoutMs = 90_000,
|
|
1141
|
+
log = () => {},
|
|
1142
|
+
} = {}) {
|
|
1143
|
+
const base = 'http://127.0.0.1:3210/_dsh/dsh-crew/runtime';
|
|
1144
|
+
let created;
|
|
1145
|
+
try {
|
|
1146
|
+
const response = await fetchImpl(`${base}/restart-request`, {
|
|
1147
|
+
method: 'POST',
|
|
1148
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
1149
|
+
body: JSON.stringify({ confirm: true, reason }),
|
|
1150
|
+
});
|
|
1151
|
+
created = await response.json();
|
|
1152
|
+
if (!response.ok || created?.ok !== true) {
|
|
1153
|
+
return { ok: false, code: created?.code ?? 'CREW_3210_RESTART_REQUEST_FAILED', error: created?.error ?? `restart request failed (HTTP ${response.status})` };
|
|
1154
|
+
}
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
return { ok: false, code: 'CREW_3210_RESTART_REQUEST_UNREACHABLE', error: String(error?.message ?? error) };
|
|
1157
|
+
}
|
|
1158
|
+
const requestId = created.request_id;
|
|
1159
|
+
const deadline = Date.now() + timeoutMs;
|
|
1160
|
+
for (;;) {
|
|
1161
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
1162
|
+
if (Date.now() > deadline) {
|
|
1163
|
+
return { ok: false, code: 'CREW_3210_RESTART_TIMEOUT', error: `restart request ${requestId} not verified within ${timeoutMs}ms`, request_id: requestId };
|
|
1164
|
+
}
|
|
1165
|
+
try {
|
|
1166
|
+
const statusResponse = await fetchImpl(`${base}/restart-status?id=${encodeURIComponent(requestId)}`, { headers: { accept: 'application/json' } });
|
|
1167
|
+
const status = await statusResponse.json();
|
|
1168
|
+
if (!statusResponse.ok || status?.ok !== true) {
|
|
1169
|
+
// 404 while the watcher has not yet picked the request up is normal.
|
|
1170
|
+
if (statusResponse.status === 404) continue;
|
|
1171
|
+
return { ok: false, code: status?.code ?? 'CREW_3210_RESTART_STATUS_FAILED', error: status?.error ?? 'restart status query failed', request_id: requestId };
|
|
1172
|
+
}
|
|
1173
|
+
if (status.state === 'VERIFIED') {
|
|
1174
|
+
log(`- Crew 3210 restart verified (request ${requestId})`);
|
|
1175
|
+
return { ok: true, state: 'VERIFIED', request_id: requestId, detail: status.detail ?? null };
|
|
1176
|
+
}
|
|
1177
|
+
if (status.state === 'RESTART_REQUEST_EXPIRED' || status.state === 'SUPERVISOR_OWNERSHIP_CONFLICT' || status.state === 'SUPERVISOR_STOP_FAILED' || status.state === 'VERIFY_FAILED') {
|
|
1178
|
+
return { ok: false, code: `CREW_3210_RESTART_${status.state}`, error: `restart ended in state ${status.state}`, request_id: requestId, detail: status.detail ?? null };
|
|
1179
|
+
}
|
|
1180
|
+
// RESTART_REQUESTED: still pending; keep polling.
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
// Hub may be briefly down mid-restart; keep polling until the deadline.
|
|
1183
|
+
log(`- restart poll transient error: ${String(error?.message ?? error)}`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function restartOwnedRuntime(fetchImpl = globalThis.fetch) {
|
|
1189
|
+
return requestCrewRuntimeRestart({ fetchImpl, reason: 'lifecycle restart' });
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
async function verifyRuntimeVersion(version, fetchImpl = globalThis.fetch) {
|
|
1193
|
+
const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/runtime', { headers: { accept: 'application/json' } });
|
|
1194
|
+
const body = await response.json();
|
|
1195
|
+
return response.ok && body?.ok === true && body.runtime_version === version
|
|
1196
|
+
? { ok: true, runtime_version: body.runtime_version }
|
|
1197
|
+
: { ok: false, code: 'RUNTIME_VERSION_MISMATCH' };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Cohort verifier: compares the TARGET DSH cohort against the Hub's
|
|
1201
|
+
// dsh_version domain (the installed @deepseek-ai/dsh package), NEVER
|
|
1202
|
+
// against Crew's own runtime_version (the dsh-crew release). A null
|
|
1203
|
+
// dsh_version means unknown cohort and fails closed.
|
|
1204
|
+
export async function verifyCrewDshCohort(version, fetchImpl = globalThis.fetch) {
|
|
1205
|
+
let body = null;
|
|
1206
|
+
try {
|
|
1207
|
+
const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/extension', { headers: { accept: 'application/json' } });
|
|
1208
|
+
body = await response.json();
|
|
1209
|
+
if (!response.ok) return { ok: false, code: 'DSH_COHORT_UNREACHABLE' };
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
return { ok: false, code: 'DSH_COHORT_UNREACHABLE', error: String(error?.message ?? error) };
|
|
1212
|
+
}
|
|
1213
|
+
const dshVersion = body?.extension?.runtime?.dsh_version ?? body?.runtime?.dsh_version ?? null;
|
|
1214
|
+
if (typeof dshVersion !== 'string' || dshVersion.length === 0) {
|
|
1215
|
+
return { ok: false, code: 'DSH_COHORT_UNKNOWN' };
|
|
1216
|
+
}
|
|
1217
|
+
return dshVersion === version
|
|
1218
|
+
? { ok: true, dsh_version: dshVersion }
|
|
1219
|
+
: { ok: false, code: 'DSH_COHORT_MISMATCH', installed: dshVersion, target: version };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Full Crew-owned 3210 identity check: service + execution plane + profile
|
|
1223
|
+
// + port + non-empty runtime_id, plus the exact target COHORT version read
|
|
1224
|
+
// from the dsh_version domain (the installed @deepseek-ai/dsh package).
|
|
1225
|
+
// Never compares the cohort against Crew's own runtime_version.
|
|
1226
|
+
export async function verifyCrewRuntimeIdentity(version, fetchImpl = globalThis.fetch) {
|
|
1227
|
+
let body = null;
|
|
1228
|
+
try {
|
|
1229
|
+
const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/extension', { headers: { accept: 'application/json' } });
|
|
1230
|
+
body = await response.json();
|
|
1231
|
+
if (!response.ok) return { ok: false, code: 'RUNTIME_IDENTITY_UNREACHABLE' };
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
return { ok: false, code: 'RUNTIME_IDENTITY_UNREACHABLE', error: String(error?.message ?? error) };
|
|
1234
|
+
}
|
|
1235
|
+
const runtime = body?.extension?.runtime ?? null;
|
|
1236
|
+
const checks = [
|
|
1237
|
+
body?.ok === true,
|
|
1238
|
+
runtime?.service === 'dsh-crew-hub',
|
|
1239
|
+
runtime?.execution_plane === 'hub-3210',
|
|
1240
|
+
runtime?.profile === 'dsh-crew',
|
|
1241
|
+
Number(runtime?.listen_port) === 3210,
|
|
1242
|
+
typeof runtime?.runtime_id === 'string' && runtime.runtime_id.trim().length > 0,
|
|
1243
|
+
];
|
|
1244
|
+
if (!checks.every(Boolean)) return { ok: false, code: 'RUNTIME_IDENTITY_MISMATCH' };
|
|
1245
|
+
const cohort = await verifyCrewDshCohort(version, fetchImpl);
|
|
1246
|
+
if (!cohort.ok) return cohort;
|
|
1247
|
+
return { ok: true, runtime_id: runtime.runtime_id, dsh_version: cohort.dsh_version };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Dual-domain post-restart verifier for rollback: the restarted 3210 must
|
|
1251
|
+
// report BOTH the expected Crew release version (runtime_version domain)
|
|
1252
|
+
// AND the expected DSH cohort (dsh_version domain), alongside full 3210
|
|
1253
|
+
// identity. A stale old-Crew process on the right cohort (or vice versa)
|
|
1254
|
+
// fails closed instead of reporting rollback success.
|
|
1255
|
+
export async function verifyRollbackTarget({ crewVersion, dshVersion, fetchImpl = globalThis.fetch } = {}) {
|
|
1256
|
+
let body = null;
|
|
1257
|
+
try {
|
|
1258
|
+
const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/extension', { headers: { accept: 'application/json' } });
|
|
1259
|
+
body = await response.json();
|
|
1260
|
+
if (!response.ok) return { ok: false, code: 'RUNTIME_IDENTITY_UNREACHABLE' };
|
|
1261
|
+
} catch (error) {
|
|
1262
|
+
return { ok: false, code: 'RUNTIME_IDENTITY_UNREACHABLE', error: String(error?.message ?? error) };
|
|
1263
|
+
}
|
|
1264
|
+
const runtime = body?.extension?.runtime ?? null;
|
|
1265
|
+
const checks = [
|
|
1266
|
+
body?.ok === true,
|
|
1267
|
+
runtime?.service === 'dsh-crew-hub',
|
|
1268
|
+
runtime?.execution_plane === 'hub-3210',
|
|
1269
|
+
runtime?.profile === 'dsh-crew',
|
|
1270
|
+
Number(runtime?.listen_port) === 3210,
|
|
1271
|
+
typeof runtime?.runtime_id === 'string' && runtime.runtime_id.trim().length > 0,
|
|
1272
|
+
];
|
|
1273
|
+
if (!checks.every(Boolean)) return { ok: false, code: 'RUNTIME_IDENTITY_MISMATCH' };
|
|
1274
|
+
if (typeof crewVersion === 'string' && runtime?.runtime_version !== crewVersion) {
|
|
1275
|
+
return { ok: false, code: 'CREW_VERSION_MISMATCH', installed: runtime?.runtime_version ?? null, target: crewVersion };
|
|
1276
|
+
}
|
|
1277
|
+
if (typeof dshVersion === 'string') {
|
|
1278
|
+
const cohort = await verifyCrewDshCohort(dshVersion, fetchImpl);
|
|
1279
|
+
if (!cohort.ok) return cohort;
|
|
1280
|
+
return { ok: true, runtime_id: runtime.runtime_id, runtime_version: runtime?.runtime_version ?? null, dsh_version: cohort.dsh_version };
|
|
1281
|
+
}
|
|
1282
|
+
return { ok: true, runtime_id: runtime.runtime_id, runtime_version: runtime?.runtime_version ?? null };
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Crew-owned stop/start for cohort migration go through the Windows
|
|
1286
|
+
// launcher supervisor (the ONLY process authority) via durable maintenance
|
|
1287
|
+
// requests. The npx process owns the runtime TREE swap; the supervisor owns
|
|
1288
|
+
// the PROCESS stop/start. The Node sidecar supervisor is deliberately not
|
|
1289
|
+
// used here: two authorities must never share the same kill rights.
|
|
1290
|
+
function crewSupervisor({ home = homedir() } = {}) {
|
|
1291
|
+
// The ONLY Crew app root both the hub and the Windows launcher agree on.
|
|
1292
|
+
// Requests written anywhere else are invisible to the supervisor.
|
|
1293
|
+
const appRoot = join(home, '.config', 'dsh-crew');
|
|
1294
|
+
const pollMs = 1_000;
|
|
1295
|
+
const timeoutMs = 90_000;
|
|
1296
|
+
// One maintenance transaction = one lease + the pre-stop identity proven
|
|
1297
|
+
// live. stop() fetches the CURRENT live runtime_id (the process exists);
|
|
1298
|
+
// start() reuses that proven identity + lease (the process is stopped by
|
|
1299
|
+
// design and cannot re-prove itself). This pairs stop and start into a
|
|
1300
|
+
// single one-shot transaction instead of two independent requests.
|
|
1301
|
+
let transaction = null;
|
|
1302
|
+
async function fetchCrewIdentity() {
|
|
1303
|
+
try {
|
|
1304
|
+
const response = await fetch('http://127.0.0.1:3210/_dsh/dsh-crew/extension', { headers: { accept: 'application/json' } });
|
|
1305
|
+
if (!response.ok) return null;
|
|
1306
|
+
const body = await response.json();
|
|
1307
|
+
const runtime = body?.extension?.runtime ?? null;
|
|
1308
|
+
return runtime?.runtime_id ? { execution_plane: 'hub-3210', profile: 'dsh-crew', listen_port: 3210, runtime_id: runtime.runtime_id } : null;
|
|
1309
|
+
} catch { return null; }
|
|
1310
|
+
}
|
|
1311
|
+
async function writeMaintenance({ operation, lease, identity, extra = null }) {
|
|
1312
|
+
const { createSupervisorRequest, maintenanceResultsDir } = await import('../supervisor/restart-request.mjs');
|
|
1313
|
+
const created = createSupervisorRequest({
|
|
1314
|
+
appRoot,
|
|
1315
|
+
operation,
|
|
1316
|
+
runtimeIdentity: identity,
|
|
1317
|
+
lease,
|
|
1318
|
+
extra,
|
|
1319
|
+
});
|
|
1320
|
+
if (!created.ok) return created;
|
|
1321
|
+
const deadline = Date.now() + timeoutMs;
|
|
1322
|
+
for (;;) {
|
|
1323
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
1324
|
+
if (Date.now() > deadline) {
|
|
1325
|
+
return { ok: false, code: 'MAINTENANCE_TIMEOUT', error: `${operation} ${created.request.request_id} not completed within ${timeoutMs}ms` };
|
|
1326
|
+
}
|
|
1327
|
+
const resultFile = join(maintenanceResultsDir(appRoot), `${created.request.request_id}.json`);
|
|
1328
|
+
let result = null;
|
|
1329
|
+
try {
|
|
1330
|
+
if (existsSync(resultFile)) {
|
|
1331
|
+
result = JSON.parse(readFileSync(resultFile, 'utf8'));
|
|
1332
|
+
}
|
|
1333
|
+
} catch { /* not yet */ }
|
|
1334
|
+
if (!result || result.request_id !== created.request.request_id) continue;
|
|
1335
|
+
if (result.state === 'STOPPED' || result.state === 'VERIFIED') {
|
|
1336
|
+
return { ok: true, state: result.state, request_id: result.request_id, detail: result.detail ?? null };
|
|
1337
|
+
}
|
|
1338
|
+
return { ok: false, code: `MAINTENANCE_${result.state ?? 'FAILED'}`, error: `maintenance ended in state ${result.state}`, detail: result.detail ?? null };
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return {
|
|
1342
|
+
stopOwnedBackend: async () => {
|
|
1343
|
+
const identity = await fetchCrewIdentity();
|
|
1344
|
+
if (!identity?.runtime_id) {
|
|
1345
|
+
transaction = null;
|
|
1346
|
+
return { ok: false, code: 'MAINTENANCE_IDENTITY_UNAVAILABLE', error: 'live 3210 runtime_id unavailable' };
|
|
1347
|
+
}
|
|
1348
|
+
transaction = { lease: `txn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, runtime_id: identity.runtime_id };
|
|
1349
|
+
const result = await writeMaintenance({ operation: 'maintenance-stop', lease: transaction.lease, identity });
|
|
1350
|
+
if (!result.ok) {
|
|
1351
|
+
// A failed stop must not leave a usable lease behind: clear it so a
|
|
1352
|
+
// later startOwnedBackend() fails closed instead of presenting a
|
|
1353
|
+
// stale transaction.
|
|
1354
|
+
transaction = null;
|
|
1355
|
+
}
|
|
1356
|
+
return result;
|
|
1357
|
+
},
|
|
1358
|
+
startOwnedBackend: async () => {
|
|
1359
|
+
// Reuse the proven stop-phase identity + lease: the process is stopped
|
|
1360
|
+
// by design and fetching identity now would always fail.
|
|
1361
|
+
if (!transaction?.lease || !transaction?.runtime_id) {
|
|
1362
|
+
return { ok: false, code: 'MAINTENANCE_NO_TRANSACTION', error: 'maintenance-start requires a completed maintenance-stop in the same supervisor' };
|
|
1363
|
+
}
|
|
1364
|
+
const identity = { execution_plane: 'hub-3210', profile: 'dsh-crew', listen_port: 3210, runtime_id: transaction.runtime_id };
|
|
1365
|
+
const result = await writeMaintenance({ operation: 'maintenance-start', lease: transaction.lease, identity });
|
|
1366
|
+
transaction = null;
|
|
1367
|
+
return result;
|
|
1368
|
+
},
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// ---- release cohort resolution -------------------------------------------------
|
|
1373
|
+
|
|
1374
|
+
// Lifecycle-owned cohort sidecar for a managed release directory. Historical
|
|
1375
|
+
// releases (pre-1.0.4) do not pin @deepseek-ai/dsh in their immutable
|
|
1376
|
+
// manifest; this records the cohort fact the lifecycle observed for them.
|
|
1377
|
+
const cohortFile = (releaseDir) => join(releaseDir, 'release-cohort.json');
|
|
1378
|
+
|
|
1379
|
+
// Exact DSH cohort version helper: delegates to the module-level regex.
|
|
1380
|
+
export function isExactDshVersion(value) {
|
|
1381
|
+
return typeof value === 'string' && EXACT_DSH_VERSION_RE.test(value);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
export function writeReleaseCohort({ releaseDir, dshVersion, source }) {
|
|
1385
|
+
if (!releaseDir || !existsSync(releaseDir) || !isExactDshVersion(dshVersion)) return null;
|
|
1386
|
+
try {
|
|
1387
|
+
const record = {
|
|
1388
|
+
schema_version: 1,
|
|
1389
|
+
release: readManifest(releaseDir)?.version ?? null,
|
|
1390
|
+
dsh_version: dshVersion,
|
|
1391
|
+
source: source ?? 'observed',
|
|
1392
|
+
recorded_at: isoNow(),
|
|
1393
|
+
};
|
|
1394
|
+
writeFileAtomic(cohortFile(releaseDir), JSON.stringify(record, null, 2) + '\n');
|
|
1395
|
+
return record;
|
|
1396
|
+
} catch { return null; }
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Read + validate the cohort sidecar. Returns:
|
|
1400
|
+
// { ok: true, dshVersion } valid sidecar
|
|
1401
|
+
// { ok: false, present: false } no sidecar file
|
|
1402
|
+
// { ok: false, present: true, reason } sidecar EXISTS but is invalid —
|
|
1403
|
+
// callers MUST fail closed on this (never silently fall back to live
|
|
1404
|
+
// discovery, because the recorded value may be corrupt and would feed
|
|
1405
|
+
// rmSync/rename authority).
|
|
1406
|
+
function readReleaseCohortSidecar(releaseDir) {
|
|
1407
|
+
const file = cohortFile(releaseDir);
|
|
1408
|
+
if (!existsSync(file)) return { ok: false, present: false };
|
|
1409
|
+
let parsed;
|
|
1410
|
+
try {
|
|
1411
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
1412
|
+
} catch {
|
|
1413
|
+
return { ok: false, present: true, reason: 'sidecar unparseable' };
|
|
1414
|
+
}
|
|
1415
|
+
if (parsed?.schema_version !== 1) {
|
|
1416
|
+
return { ok: false, present: true, reason: 'sidecar schema_version invalid' };
|
|
1417
|
+
}
|
|
1418
|
+
if (!isExactDshVersion(parsed.dsh_version)) {
|
|
1419
|
+
return { ok: false, present: true, reason: `sidecar dsh_version is not an exact version (${String(parsed.dsh_version)})` };
|
|
1420
|
+
}
|
|
1421
|
+
const manifest = readManifest(releaseDir);
|
|
1422
|
+
if (typeof parsed.release !== 'string' || parsed.release.length === 0 || parsed.release !== manifest?.version) {
|
|
1423
|
+
return { ok: false, present: true, reason: 'sidecar release identity does not match the release manifest' };
|
|
1424
|
+
}
|
|
1425
|
+
return { ok: true, dshVersion: parsed.dsh_version };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Resolve the DSH cohort a managed release corresponds to. Priority:
|
|
1429
|
+
// 1. exact @deepseek-ai/dsh pin in the release manifest (dependencies,
|
|
1430
|
+
// then peerDependencies)
|
|
1431
|
+
// 2. lifecycle-owned sidecar (release-cohort.json) — if the sidecar EXISTS
|
|
1432
|
+
// but is invalid, FAIL CLOSED (return {invalid:true}) instead of falling
|
|
1433
|
+
// back, because the corrupt value must never be silently ignored.
|
|
1434
|
+
// 3. legacy discovery: the release predates cohort awareness; fall back to
|
|
1435
|
+
// the current live runtime tree's cohort ONLY when explicitly allowed
|
|
1436
|
+
// (the caller is the forward-upgrade path that will immediately migrate
|
|
1437
|
+
// to the target). Never used for rollback targeting.
|
|
1438
|
+
// Returns { ok:true, dshVersion } | { ok:false, code } | { ok:false, code,
|
|
1439
|
+
// invalid:true } when a present-but-invalid sidecar blocks resolution.
|
|
1440
|
+
export function resolveReleaseCohort({ releaseDir, allowLegacyLiveFallback = false, readRuntimeVersion = () => null } = {}) {
|
|
1441
|
+
const manifest = readManifest(releaseDir);
|
|
1442
|
+
const pinned = payloadDshVersion(manifest);
|
|
1443
|
+
if (pinned) return { ok: true, dshVersion: pinned };
|
|
1444
|
+
const sidecar = readReleaseCohortSidecar(releaseDir);
|
|
1445
|
+
if (sidecar.ok) return { ok: true, dshVersion: sidecar.dshVersion };
|
|
1446
|
+
if (sidecar.present) {
|
|
1447
|
+
return { ok: false, code: 'RELEASE_COHORT_SIDECAR_INVALID', invalid: true, error: sidecar.reason ?? 'sidecar invalid' };
|
|
1448
|
+
}
|
|
1449
|
+
if (allowLegacyLiveFallback) {
|
|
1450
|
+
const live = readRuntimeVersion();
|
|
1451
|
+
if (isExactDshVersion(live)) return { ok: true, dshVersion: live };
|
|
1452
|
+
}
|
|
1453
|
+
return { ok: false, code: 'RELEASE_COHORT_UNKNOWN' };
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
export async function npxReleases({ home = homedir(), log = console.log } = {}) {
|
|
1457
|
+
const releases = listManagedReleases({ home });
|
|
1458
|
+
log(JSON.stringify(releases, null, 2));
|
|
1459
|
+
return { ok: true, releases };
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
export async function npxRollback({
|
|
1463
|
+
home = homedir(),
|
|
1464
|
+
version,
|
|
1465
|
+
log = console.log,
|
|
1466
|
+
installer = realInstaller,
|
|
1467
|
+
ensureRuntime,
|
|
1468
|
+
validatePayload = validateInstalledPayload,
|
|
1469
|
+
activate,
|
|
1470
|
+
restart,
|
|
1471
|
+
verifyRuntime,
|
|
1472
|
+
supervisorFactory = crewSupervisor,
|
|
1473
|
+
} = {}) {
|
|
1474
|
+
const targetVersion = typeof version === 'string' ? version.trim() : '';
|
|
1475
|
+
if (!targetVersion) return { ok: false, error: 'rollback requires a target version' };
|
|
1476
|
+
// Rollback shares the update mutual-exclusion lock: it mutates the same
|
|
1477
|
+
// pointer/registration/runtime surfaces as install/update, and the watch
|
|
1478
|
+
// supervisor must observe-only while it runs.
|
|
1479
|
+
const updateLock = acquireUpdateLock({ home });
|
|
1480
|
+
if (!updateLock.ok) return { ok: false, error: `another update is in progress (${updateLock.code})` };
|
|
1481
|
+
try {
|
|
1482
|
+
return await npxRollbackInner({ home, version: targetVersion, log, installer, ensureRuntime, validatePayload, activate, restart, verifyRuntime, supervisorFactory });
|
|
1483
|
+
} finally {
|
|
1484
|
+
releaseUpdateLock({ home, nonce: updateLock.nonce ?? null });
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
async function npxRollbackInner({ home, version: targetVersion, log, installer, ensureRuntime, validatePayload, activate, restart, verifyRuntime, supervisorFactory }) {
|
|
1489
|
+
// Journal-aware entry: refuse to overwrite a journal left by a crashed
|
|
1490
|
+
// transaction. Reconcile it first (or fail closed) instead of silently
|
|
1491
|
+
// replacing it with the rollback intent.
|
|
1492
|
+
const pending = reconcileUpdateJournal({ home, log });
|
|
1493
|
+
if (!pending.ok) return { ok: false, error: `refusing rollback with unreconciled journal (${pending.code ?? 'unknown'})` };
|
|
1494
|
+
const current = readCurrentPointer({ home });
|
|
1495
|
+
if (!current?.path || !existsSync(current.path)) return { ok: false, error: 'no active Crew payload to roll back' };
|
|
1496
|
+
const target = listManagedReleases({ home }).find((release) => release.version === targetVersion);
|
|
1497
|
+
if (!target) return { ok: false, error: `retained release ${targetVersion} was not found` };
|
|
1498
|
+
if (target.path === current.path) return { ok: true, idempotent: true, version: target.version, path: target.path };
|
|
1499
|
+
const targetManifest = readManifest(target.path);
|
|
1500
|
+
const validation = validatePayload(target.path, { expectedName: targetManifest?.name, expectedVersion: targetManifest?.version });
|
|
1501
|
+
if (!validation.ok) return { ok: false, error: 'target release failed payload validation' };
|
|
1502
|
+
const previousManifest = readManifest(current.path);
|
|
1503
|
+
// Cross-cohort rollback: derive the DSH cohort each payload corresponds to
|
|
1504
|
+
// through the FULL resolution chain (manifest exact pin -> lifecycle
|
|
1505
|
+
// release-cohort.json sidecar). Never assume the running code's TARGET:
|
|
1506
|
+
// 1.0.4/rc.1 rolling back to 1.0.3 must restore alpha.5, not verify
|
|
1507
|
+
// against rc.1. A present-but-invalid sidecar fails closed (its recorded
|
|
1508
|
+
// value would otherwise feed rmSync/rename authority). The production path
|
|
1509
|
+
// (no injected verifyRuntime) fails closed when the cohort is unknown;
|
|
1510
|
+
// callers that inject their own verifier/restart mocks (tests, custom
|
|
1511
|
+
// drivers) own the cohort contract and may leave it unset, in which case
|
|
1512
|
+
// no runtime swap is attempted.
|
|
1513
|
+
const productionVerify = typeof verifyRuntime !== 'function';
|
|
1514
|
+
const targetCohort = resolveReleaseCohort({ releaseDir: target.path, allowLegacyLiveFallback: false });
|
|
1515
|
+
const priorCohort = resolveReleaseCohort({ releaseDir: current.path, allowLegacyLiveFallback: false });
|
|
1516
|
+
if (targetCohort.invalid || priorCohort.invalid) {
|
|
1517
|
+
return { ok: false, code: 'RELEASE_COHORT_SIDECAR_INVALID', error: targetCohort.invalid ? targetCohort.error : priorCohort.error };
|
|
1518
|
+
}
|
|
1519
|
+
const targetDshVersion = targetCohort.ok ? targetCohort.dshVersion : null;
|
|
1520
|
+
const priorDshVersion = priorCohort.ok ? priorCohort.dshVersion : null;
|
|
1521
|
+
const cohortKnown = targetDshVersion !== null && priorDshVersion !== null;
|
|
1522
|
+
if (productionVerify && !cohortKnown) {
|
|
1523
|
+
return { ok: false, code: 'RELEASE_DSH_COHORT_UNKNOWN', error: 'release cohort cannot be resolved (no manifest pin and no valid sidecar)' };
|
|
1524
|
+
}
|
|
1525
|
+
const activateReleaseFn = activate ?? (({ releaseDir, manifest }) => activateRelease({ home, releaseDir, manifest, log, installer, ensureRuntime }));
|
|
1526
|
+
// Direct-owned restart/verify through ONE supervisor instance. No legacy
|
|
1527
|
+
// 3080 bridge: rollback must work with the bridge fully absent.
|
|
1528
|
+
const supervisor = supervisorFactory({ home });
|
|
1529
|
+
const stopFn = () => supervisor.stopOwnedBackend();
|
|
1530
|
+
const restartFn = restart ?? (async () => {
|
|
1531
|
+
const stopped = await supervisor.stopOwnedBackend();
|
|
1532
|
+
if (!stopped.ok) return stopped;
|
|
1533
|
+
return supervisor.startOwnedBackend();
|
|
1534
|
+
});
|
|
1535
|
+
// Dual-domain post-restart verification: the restarted 3210 must report
|
|
1536
|
+
// BOTH the target Crew release (runtime_version) AND the cohort that
|
|
1537
|
+
// release pins (dsh_version). A stale old-Crew process on the wrong
|
|
1538
|
+
// cohort (or vice versa) fails closed. The verifier is version-parametric
|
|
1539
|
+
// in BOTH domains: target verification checks target crew+cohort,
|
|
1540
|
+
// compensation verification checks prior crew+cohort.
|
|
1541
|
+
const verifyFn = verifyRuntime ?? ((crewVersion, dshVersion) => verifyRollbackTarget({ crewVersion, dshVersion }));
|
|
1542
|
+
// Journal the rollback intent BEFORE switching anything. The rollback
|
|
1543
|
+
// journal carries an explicit verified flag: pointer==target alone does
|
|
1544
|
+
// NOT mean committed until activation + restart + dual verification all
|
|
1545
|
+
// succeed and the journal is marked verified.
|
|
1546
|
+
writeUpdateJournal({
|
|
1547
|
+
home,
|
|
1548
|
+
stage: 'rollback',
|
|
1549
|
+
prior: { name: current.name, version: current.version, path: current.path },
|
|
1550
|
+
candidate: { name: target.name, version: target.version, stageDir: target.path },
|
|
1551
|
+
});
|
|
1552
|
+
const switchPointer = (release) => writeCurrentPointer({ home, name: release.name, version: release.version, path: release.path });
|
|
1553
|
+
// Cohort switch for cross-cohort rollback: when the payload we are rolling
|
|
1554
|
+
// back to pins a different DSH cohort than the one currently live, prepare
|
|
1555
|
+
// the target runtime tree (disk-only, process NOT started) BEFORE
|
|
1556
|
+
// activating the older payload. The 3210 is started exactly ONCE after
|
|
1557
|
+
// both the tree and the payload are in place, so an unsupported
|
|
1558
|
+
// payload/cohort pair is never booted. Prefer the retained offline tree;
|
|
1559
|
+
// a registry re-stage is the last resort (migrateCrewDshRuntime prepares
|
|
1560
|
+
// the live tree in place and restarts it — used only when no retained copy
|
|
1561
|
+
// exists and the caller accepts the network dependency).
|
|
1562
|
+
async function ensureTargetCohort() {
|
|
1563
|
+
if (!cohortKnown) return { ok: true, changed: false, unknown: true, prepared: false };
|
|
1564
|
+
// Decide from the DISK runtime tree, never a live 3210 fetch: the hub
|
|
1565
|
+
// may be down, mid-restart, or a stale process (the very condition the
|
|
1566
|
+
// supervisor cohort check exists to catch). The tree that the next boot
|
|
1567
|
+
// will load is the source of truth for whether a swap is needed.
|
|
1568
|
+
const liveDsh = readRuntimeTreeVersionSync(crewDshRuntimeRoot({ home }));
|
|
1569
|
+
if (liveDsh === targetDshVersion) return { ok: true, changed: false, prepared: false };
|
|
1570
|
+
// Prepare the retained tree WITHOUT starting the 3210 (pair ordering).
|
|
1571
|
+
const prepared = await restoreRetainedRuntime({
|
|
1572
|
+
home,
|
|
1573
|
+
version: targetDshVersion,
|
|
1574
|
+
stopOwned: stopFn,
|
|
1575
|
+
log,
|
|
1576
|
+
prepareOnly: true,
|
|
1577
|
+
});
|
|
1578
|
+
if (prepared.ok) return { ok: true, changed: true, prepared: true, version: targetDshVersion };
|
|
1579
|
+
return { ok: false, code: 'ROLLBACK_RUNTIME_RESTORE_FAILED', error: `cohort ${targetDshVersion} is not retained and cannot be restored offline`, detail: prepared };
|
|
1580
|
+
}
|
|
1581
|
+
try {
|
|
1582
|
+
const cohort = await ensureTargetCohort();
|
|
1583
|
+
if (!cohort.ok) throw Object.assign(new Error(cohort.error), { code: cohort.code });
|
|
1584
|
+
// Activate the target payload BEFORE any start: with the target tree
|
|
1585
|
+
// already on disk (or the live tree already matching), the 3210 must
|
|
1586
|
+
// never boot while the profile still points at the prior payload.
|
|
1587
|
+
switchPointer(target);
|
|
1588
|
+
if (!await activateReleaseFn({ releaseDir: target.path, manifest: targetManifest })) throw new Error('target release activation failed');
|
|
1589
|
+
// Start ONCE after tree + payload both target the rollback release.
|
|
1590
|
+
let restarted = null;
|
|
1591
|
+
if (cohort.changed) {
|
|
1592
|
+
// Tree was swapped by the prepare step; the process is stopped.
|
|
1593
|
+
restarted = await supervisor.startOwnedBackend();
|
|
1594
|
+
if (restarted?.ok === false) throw Object.assign(new Error('target runtime restart failed'), { code: restarted.code });
|
|
1595
|
+
} else {
|
|
1596
|
+
restarted = await restartFn(target.version);
|
|
1597
|
+
if (restarted?.ok === false) throw Object.assign(new Error('target runtime restart failed'), { code: restarted.code });
|
|
1598
|
+
}
|
|
1599
|
+
const runtime = await verifyFn(target.version, cohortKnown ? targetDshVersion : undefined);
|
|
1600
|
+
if (runtime?.ok !== true) throw Object.assign(new Error('target runtime verification failed'), { code: runtime?.code ?? 'RUNTIME_IDENTITY_MISMATCH' });
|
|
1601
|
+
log(`✓ rolled back Crew payload to ${target.version}`);
|
|
1602
|
+
// Persist verification BEFORE clearing: a crash between verify
|
|
1603
|
+
// success and journal clear must still finalize on recovery.
|
|
1604
|
+
const marked = markJournalVerified({
|
|
1605
|
+
home,
|
|
1606
|
+
stage: 'rollback',
|
|
1607
|
+
prior: { name: current.name, version: current.version, path: current.path },
|
|
1608
|
+
candidate: { name: target.name, version: target.version, stageDir: target.path },
|
|
1609
|
+
});
|
|
1610
|
+
if (!marked.ok) throw Object.assign(new Error('rollback journal mark-verified failed'), { code: marked.code ?? 'JOURNAL_MARK_FAILED' });
|
|
1611
|
+
clearUpdateJournal({ home });
|
|
1612
|
+
return { ok: true, rolled_back: true, version: target.version, path: target.path, restart: restarted, runtime };
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
const prior = { name: current.name, version: current.version, path: current.path };
|
|
1615
|
+
let recovery;
|
|
1616
|
+
try {
|
|
1617
|
+
// Pair-ordered compensation: prepare the PRIOR runtime tree (disk only,
|
|
1618
|
+
// process stopped), activate the prior payload, THEN start once and
|
|
1619
|
+
// dual-verify — the 3210 never boots a mismatched pair. When the
|
|
1620
|
+
// cohort contract is unknown (injected mocks own it), skip the runtime
|
|
1621
|
+
// swap and restore the payload only.
|
|
1622
|
+
let processStopped = false;
|
|
1623
|
+
if (cohortKnown) {
|
|
1624
|
+
const priorCohortOk = await restoreRetainedRuntime({
|
|
1625
|
+
home,
|
|
1626
|
+
version: priorDshVersion,
|
|
1627
|
+
stopOwned: stopFn,
|
|
1628
|
+
log,
|
|
1629
|
+
prepareOnly: true,
|
|
1630
|
+
});
|
|
1631
|
+
if (priorCohortOk.ok) {
|
|
1632
|
+
// Retained tree swapped in; the 3210 is stopped and must be
|
|
1633
|
+
// started after the payload activation below.
|
|
1634
|
+
processStopped = true;
|
|
1635
|
+
} else {
|
|
1636
|
+
// Fall back to a registry install at the live root — PREPARE ONLY:
|
|
1637
|
+
// the process stays stopped until the prior payload is activated,
|
|
1638
|
+
// so an unsupported payload/cohort pair is never booted.
|
|
1639
|
+
const m = await migrateCrewDshRuntime({
|
|
1640
|
+
home,
|
|
1641
|
+
version: priorDshVersion,
|
|
1642
|
+
stopOwned: stopFn,
|
|
1643
|
+
log,
|
|
1644
|
+
prepareOnly: true,
|
|
1645
|
+
});
|
|
1646
|
+
if (!m.ok) throw Object.assign(new Error('prior cohort could not be restored'), { stage: 'cohort' });
|
|
1647
|
+
processStopped = true;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
switchPointer(prior);
|
|
1651
|
+
if (!previousManifest) throw Object.assign(new Error('previous release manifest unavailable'), { stage: 'activation' });
|
|
1652
|
+
const activated = await activateReleaseFn({ releaseDir: prior.path, manifest: previousManifest });
|
|
1653
|
+
if (activated !== true) throw Object.assign(new Error('previous release activation failed'), { stage: 'activation' });
|
|
1654
|
+
// Start ONCE: tree + payload both point at the prior release now.
|
|
1655
|
+
const restarted = processStopped
|
|
1656
|
+
? await supervisor.startOwnedBackend()
|
|
1657
|
+
: await restartFn(prior.version);
|
|
1658
|
+
if (restarted?.ok !== true) throw Object.assign(new Error('previous runtime restart failed'), { stage: 'restart' });
|
|
1659
|
+
const runtime = await verifyFn(prior.version, cohortKnown ? priorDshVersion : undefined);
|
|
1660
|
+
if (runtime?.ok !== true) throw Object.assign(new Error('previous runtime verification failed'), { stage: 'verification' });
|
|
1661
|
+
const restoredPointer = readCurrentPointer({ home });
|
|
1662
|
+
if (restoredPointer?.path !== prior.path || restoredPointer.version !== prior.version) {
|
|
1663
|
+
throw Object.assign(new Error('previous release pointer was not restored'), { stage: 'pointer' });
|
|
1664
|
+
}
|
|
1665
|
+
recovery = { ok: true, version: prior.version, path: prior.path };
|
|
1666
|
+
clearUpdateJournal({ home });
|
|
1667
|
+
} catch (recoveryError) {
|
|
1668
|
+
recovery = {
|
|
1669
|
+
ok: false,
|
|
1670
|
+
code: 'RELEASE_ROLLBACK_RECOVERY_FAILED',
|
|
1671
|
+
stage: ['cohort', 'activation', 'restart', 'verification', 'pointer'].includes(recoveryError?.stage) ? recoveryError.stage : 'unknown',
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
return {
|
|
1675
|
+
ok: false,
|
|
1676
|
+
error: error?.message ?? 'release rollback failed',
|
|
1677
|
+
code: error?.code ?? 'RELEASE_ROLLBACK_FAILED',
|
|
1678
|
+
restored: recovery.ok === true,
|
|
1679
|
+
recovery,
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function registrationLinkPath({ home, name }) {
|
|
627
1685
|
return join(crewProfileDir({ home }), 'node_modules', ...name.split('/'));
|
|
628
1686
|
}
|
|
629
1687
|
|
|
@@ -649,21 +1707,330 @@ export function sanitizedPackageManagerEnv(baseEnv = process.env) {
|
|
|
649
1707
|
return env;
|
|
650
1708
|
}
|
|
651
1709
|
|
|
652
|
-
|
|
653
|
-
|
|
1710
|
+
// Production migration adapter: builds the migrate callback used by the
|
|
1711
|
+
// default ensureRuntimeStep path. Exactly ONE supervisor instance is
|
|
1712
|
+
// created per migration and reused by stop/start/verify/rollback so the
|
|
1713
|
+
// child identity stays coherent. Exported so tests can prove the
|
|
1714
|
+
// production default wiring (single instance, full identity verify)
|
|
1715
|
+
// instead of hand-injecting three mock callbacks.
|
|
1716
|
+
export function buildProductionMigration({ home, log = () => {}, stopOwned, startOwned, verifyOwned, supervisorFactory = crewSupervisor } = {}) {
|
|
1717
|
+
const supervisor = supervisorFactory({ home });
|
|
1718
|
+
return (opts) => migrateCrewDshRuntime({
|
|
1719
|
+
home,
|
|
1720
|
+
version: TARGET_DSH_VERSION,
|
|
1721
|
+
stopOwned: stopOwned ?? (() => supervisor.stopOwnedBackend()),
|
|
1722
|
+
startOwned: startOwned ?? (() => supervisor.startOwnedBackend()),
|
|
1723
|
+
verifyOwned: verifyOwned ?? (() => verifyCrewRuntimeIdentity(TARGET_DSH_VERSION)),
|
|
1724
|
+
...opts,
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
async function ensureRuntimeStep({ home, log, ensureRuntime, migrateRuntime, stopOwned, startOwned, verifyOwned, supervisorFactory = crewSupervisor }) { const ensure = ensureRuntime ?? ((opts) => {
|
|
654
1729
|
const r = ensureCrewDshRuntime({ ...opts, env: sanitizedPackageManagerEnv() });
|
|
655
1730
|
if (!r.ok && r.stderrTail) {
|
|
656
1731
|
log(` (runtime installer said: ${r.stderrTail})`);
|
|
657
1732
|
}
|
|
1733
|
+
if (!r.ok && r.code === 'DSH_RUNTIME_COHORT_MISMATCH') {
|
|
1734
|
+
return { ok: false, code: r.code, error: r.error, installed: r.installed, target: r.target, needsMigration: true };
|
|
1735
|
+
}
|
|
658
1736
|
return r.ok ? { ok: true, version: r.cli?.version ?? null } : { ok: false, error: r.error ?? r.code ?? 'runtime bootstrap failed' };
|
|
659
1737
|
});
|
|
1738
|
+
// Production migration wiring: ONE supervisor instance per migration,
|
|
1739
|
+
// reused by stop/start/verify/rollback so child identity stays coherent.
|
|
1740
|
+
// No legacy bridge involved. Missing callbacks fail closed inside
|
|
1741
|
+
// migrateCrewDshRuntime.
|
|
1742
|
+
const migrate = migrateRuntime ?? buildProductionMigration({ home, log, stopOwned, startOwned, verifyOwned, supervisorFactory });
|
|
660
1743
|
const r = await ensure({ home });
|
|
661
|
-
if (
|
|
662
|
-
log(
|
|
663
|
-
return
|
|
1744
|
+
if (r?.ok) {
|
|
1745
|
+
log(`✓ reusable Crew DSH runtime${r.version ? ` (@${r.version})` : ''}`);
|
|
1746
|
+
return true;
|
|
1747
|
+
}
|
|
1748
|
+
if (r?.needsMigration === true) {
|
|
1749
|
+
log(`- runtime cohort stale (${r.installed} -> ${r.target}); migrating via staged transaction`);
|
|
1750
|
+
const m = await migrate({ log });
|
|
1751
|
+
if (!m?.ok) {
|
|
1752
|
+
log(`✗ runtime cohort migration failed: ${m?.error ?? m?.code ?? 'unknown error'}`);
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
log(`✓ runtime cohort migrated (@${m.version})`);
|
|
1756
|
+
return true;
|
|
1757
|
+
}
|
|
1758
|
+
log(`✗ reusable Crew DSH runtime unavailable: ${r?.error ?? 'unknown error'}`);
|
|
1759
|
+
return false;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// ---- coordinated payload + cohort transaction ---------------------------------
|
|
1763
|
+
|
|
1764
|
+
// Perform an update whose candidate payload pins a DIFFERENT DSH cohort than
|
|
1765
|
+
// the currently live one (e.g. 1.0.3/alpha.5 -> 1.0.4/rc.1). The payload
|
|
1766
|
+
// activation and the runtime swap are one transaction so the 3210 never runs
|
|
1767
|
+
// an unsupported payload/cohort combination:
|
|
1768
|
+
//
|
|
1769
|
+
// 1. stage candidate payload (caller did)
|
|
1770
|
+
// 2. stage target DSH cohort (no live touch)
|
|
1771
|
+
// 3. write durable journal (runtime segment included)
|
|
1772
|
+
// 4. stop owned 3210 ONCE
|
|
1773
|
+
// 5. swap runtime live -> retained-prior, staged -> live
|
|
1774
|
+
// 6. activate candidate Crew payload
|
|
1775
|
+
// 7. start owned 3210 ONCE
|
|
1776
|
+
// 8. dual verify: runtime_version == candidate version AND
|
|
1777
|
+
// dsh_version == candidate cohort
|
|
1778
|
+
// 9. mark journal verified
|
|
1779
|
+
// 10. write current.json LAST (commit point)
|
|
1780
|
+
// 11. clear journal
|
|
1781
|
+
//
|
|
1782
|
+
// Any failure before step 10 compensates: restore prior payload + prior
|
|
1783
|
+
// cohort (retained offline tree first, registry re-stage as fallback) and
|
|
1784
|
+
// restart, verifying the prior dual identity before clearing the journal.
|
|
1785
|
+
export async function performCoordinatedCohortUpdate({
|
|
1786
|
+
home = homedir(),
|
|
1787
|
+
log = console.log,
|
|
1788
|
+
prior, // current pointer record
|
|
1789
|
+
priorDshVersion, // cohort pinned by the prior payload
|
|
1790
|
+
candidateManifest, // candidate payload manifest
|
|
1791
|
+
candidateDshVersion, // cohort pinned by the candidate payload
|
|
1792
|
+
stageDir, // validated candidate payload stage dir
|
|
1793
|
+
installer = realInstaller,
|
|
1794
|
+
activate = null, // injectable activation (tests)
|
|
1795
|
+
stopOwned,
|
|
1796
|
+
startOwned,
|
|
1797
|
+
verifyOwned,
|
|
1798
|
+
supervisorFactory = crewSupervisor,
|
|
1799
|
+
stageOptions = {},
|
|
1800
|
+
} = {}) {
|
|
1801
|
+
if (!candidateManifest?.name || !candidateManifest?.version) return { ok: false, error: 'candidate manifest invalid' };
|
|
1802
|
+
if (!prior?.path || !existsSync(prior.path)) return { ok: false, error: 'prior payload missing for coordinated update' };
|
|
1803
|
+
const supervisor = supervisorFactory({ home });
|
|
1804
|
+
const stopFn = stopOwned ?? (() => supervisor.stopOwnedBackend());
|
|
1805
|
+
const startFn = startOwned ?? (() => supervisor.startOwnedBackend());
|
|
1806
|
+
const verifyFn = verifyOwned ?? ((crewVersion, dshVersion) => verifyRollbackTarget({ crewVersion, dshVersion }));
|
|
1807
|
+
const activateFn = activate ?? (({ releaseDir, manifest }) => activateRelease({ home, releaseDir, manifest, log, installer }));
|
|
1808
|
+
|
|
1809
|
+
const liveRoot = crewDshRuntimeRoot({ home });
|
|
1810
|
+
const retainedRoot = join(crewDshHome({ home }), 'retained-runtimes');
|
|
1811
|
+
// Plan the prior-tree parking path BEFORE any destructive step and persist
|
|
1812
|
+
// it in the journal so a crash mid-swap is synchronously recoverable by
|
|
1813
|
+
// reconcileUpdateJournal (it can move priorRoot back onto liveRoot without
|
|
1814
|
+
// needing to know any in-memory nonce).
|
|
1815
|
+
const prevPath = join(crewDshHome({ home }), `runtime-prev-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`);
|
|
1816
|
+
|
|
1817
|
+
// Journal the full coordinated intent BEFORE any destructive step. The
|
|
1818
|
+
// journal carries prior+candidate dshVersion plus runtime roots so a crash
|
|
1819
|
+
// at any point is recoverable by reconcileUpdateJournal.
|
|
1820
|
+
writeUpdateJournal({
|
|
1821
|
+
home,
|
|
1822
|
+
stage: 'coordinated-update',
|
|
1823
|
+
prior: { name: prior.name, version: prior.version, path: prior.path, dshVersion: priorDshVersion },
|
|
1824
|
+
candidate: { name: candidateManifest.name, version: candidateManifest.version, stageDir, dshVersion: candidateDshVersion },
|
|
1825
|
+
runtime: {
|
|
1826
|
+
state: 'staged',
|
|
1827
|
+
liveRoot,
|
|
1828
|
+
priorRoot: prevPath,
|
|
1829
|
+
priorVersion: priorDshVersion,
|
|
1830
|
+
candidateVersion: candidateDshVersion,
|
|
1831
|
+
retainedRoot,
|
|
1832
|
+
},
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
const switchPointer = (release) => writeCurrentPointer({ home, name: release.name, version: release.version, path: release.path });
|
|
1836
|
+
const priorRelease = { name: prior.name, version: prior.version, path: prior.path };
|
|
1837
|
+
const candidateRelease = { name: candidateManifest.name, version: candidateManifest.version, path: stageDir };
|
|
1838
|
+
|
|
1839
|
+
async function compensate() {
|
|
1840
|
+
// Pair-ordered compensation: prepare the PRIOR runtime tree (disk only,
|
|
1841
|
+
// process left stopped — whether from the retained tree or a registry
|
|
1842
|
+
// install), activate the prior payload, then start ONCE and dual-verify
|
|
1843
|
+
// prior identity. The 3210 is never started while tree and payload
|
|
1844
|
+
// disagree, and never started twice.
|
|
1845
|
+
// Retained offline tree first (prepare-only)...
|
|
1846
|
+
const priorCohortOk = await restoreRetainedRuntime({
|
|
1847
|
+
home,
|
|
1848
|
+
version: priorDshVersion,
|
|
1849
|
+
stopOwned: stopFn,
|
|
1850
|
+
log,
|
|
1851
|
+
prepareOnly: true,
|
|
1852
|
+
});
|
|
1853
|
+
if (!priorCohortOk.ok) {
|
|
1854
|
+
// ...then registry install at the live root, prepare-only: the process
|
|
1855
|
+
// stays stopped until the matching payload is activated below.
|
|
1856
|
+
const migrated = await migrateCrewDshRuntime({
|
|
1857
|
+
home,
|
|
1858
|
+
version: priorDshVersion,
|
|
1859
|
+
stopOwned: stopFn,
|
|
1860
|
+
log,
|
|
1861
|
+
prepareOnly: true,
|
|
1862
|
+
});
|
|
1863
|
+
if (!migrated.ok) {
|
|
1864
|
+
return { ok: false, code: 'COORDINATED_COMPENSATE_COHORT_FAILED', error: migrated.error ?? 'prior cohort restore failed' };
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
const activated = await activateFn({ releaseDir: prior.path, manifest: readManifest(prior.path) });
|
|
1868
|
+
if (activated !== true) return { ok: false, code: 'COORDINATED_COMPENSATE_ACTIVATION_FAILED' };
|
|
1869
|
+
// Start ONCE: tree + payload both point at the prior release now.
|
|
1870
|
+
const restarted = await startFn();
|
|
1871
|
+
if (restarted?.ok !== true) return { ok: false, code: restarted?.code ?? 'COORDINATED_COMPENSATE_RESTART_FAILED' };
|
|
1872
|
+
const verified = await verifyFn(prior.version, priorDshVersion);
|
|
1873
|
+
if (verified?.ok !== true) return { ok: false, code: verified?.code ?? 'COORDINATED_COMPENSATE_VERIFY_FAILED' };
|
|
1874
|
+
switchPointer(priorRelease);
|
|
1875
|
+
return { ok: true };
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
try {
|
|
1879
|
+
// Stop ONCE, install the target cohort AT the live root (pnpm shims are
|
|
1880
|
+
// absolute-path; the tree must be born at its final resting path),
|
|
1881
|
+
// activate payload, start ONCE.
|
|
1882
|
+
const stopped = await stopFn();
|
|
1883
|
+
if (!stopped.ok) {
|
|
1884
|
+
// Nothing was swapped yet; just clear the journal (no activation ran).
|
|
1885
|
+
clearUpdateJournal({ home });
|
|
1886
|
+
return { ok: false, code: stopped.code ?? 'COORDINATED_STOP_FAILED', error: stopped.error ?? 'could not stop owned 3210' };
|
|
1887
|
+
}
|
|
1888
|
+
let liveMoved = false;
|
|
1889
|
+
try {
|
|
1890
|
+
// Move live runtime aside, retaining its tree for offline rollback.
|
|
1891
|
+
if (existsSync(liveRoot)) {
|
|
1892
|
+
// prevPath is unique per attempt and already recorded in the journal.
|
|
1893
|
+
try { rmSync(prevPath, { recursive: true, force: true }); } catch {}
|
|
1894
|
+
renameSync(liveRoot, prevPath);
|
|
1895
|
+
liveMoved = true;
|
|
1896
|
+
}
|
|
1897
|
+
mkdirSync(liveRoot, { recursive: true });
|
|
1898
|
+
} catch (error) {
|
|
1899
|
+
// Park failed: restore live tree before anything else.
|
|
1900
|
+
try {
|
|
1901
|
+
if (liveMoved && !existsSync(liveRoot) && prevPath && existsSync(prevPath)) renameSync(prevPath, liveRoot);
|
|
1902
|
+
} catch { /* best effort */ }
|
|
1903
|
+
const comp = await compensate();
|
|
1904
|
+
return finalizeCompensationFailure({ home, code: 'COORDINATED_RUNTIME_PARK_FAILED', error: String(error?.message ?? error), comp });
|
|
1905
|
+
}
|
|
1906
|
+
// Install the target cohort at the live root. On failure, roll back the
|
|
1907
|
+
// parked prior tree and compensate the payload.
|
|
1908
|
+
const installed = installDshInto({ root: liveRoot, version: candidateDshVersion, ...stageOptions });
|
|
1909
|
+
if (!installed.ok) {
|
|
1910
|
+
let comp = { ok: false };
|
|
1911
|
+
try {
|
|
1912
|
+
rmSync(liveRoot, { recursive: true, force: true });
|
|
1913
|
+
if (liveMoved && existsSync(prevPath)) renameSync(prevPath, liveRoot);
|
|
1914
|
+
comp = await compensate();
|
|
1915
|
+
} catch { /* compensate below already reports */ }
|
|
1916
|
+
return finalizeCompensationFailure({ home, code: installed.code ?? 'COORDINATED_RUNTIME_INSTALL_FAILED', error: installed.error ?? 'runtime install at live root failed', comp });
|
|
1917
|
+
}
|
|
1918
|
+
// Runtime now on candidate cohort; live tree preserved at prevPath.
|
|
1919
|
+
const activated = await activateFn({ releaseDir: stageDir, manifest: candidateManifest });
|
|
1920
|
+
if (activated !== true) {
|
|
1921
|
+
const comp = await compensate();
|
|
1922
|
+
return finalizeCompensationFailure({ home, code: null, error: 'candidate activation failed', comp });
|
|
1923
|
+
}
|
|
1924
|
+
const started = await startFn();
|
|
1925
|
+
if (started?.ok !== true) {
|
|
1926
|
+
const comp = await compensate();
|
|
1927
|
+
return finalizeCompensationFailure({ home, code: started?.code ?? 'COORDINATED_START_FAILED', error: started?.error ?? 'restart after coordinated update failed', comp });
|
|
1928
|
+
}
|
|
1929
|
+
const verified = await verifyFn(candidateManifest.version, candidateDshVersion);
|
|
1930
|
+
if (verified?.ok !== true) {
|
|
1931
|
+
const comp = await compensate();
|
|
1932
|
+
return finalizeCompensationFailure({ home, code: verified?.code ?? 'COORDINATED_VERIFY_FAILED', error: verified?.error ?? 'dual identity verification failed', comp });
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// Durable retention of the parked prior tree is a COMMIT PREREQUISITE:
|
|
1936
|
+
// "update complete" must imply the offline rollback source for the
|
|
1937
|
+
// previous cohort is durable. A failed retain never grants delete
|
|
1938
|
+
// authority over the parked tree; it aborts the commit and compensates.
|
|
1939
|
+
if (prevPath && existsSync(prevPath)) {
|
|
1940
|
+
const retained = retainParkedRuntimeTree({ home, prevPath, priorDshVersion });
|
|
1941
|
+
if (!retained.ok) {
|
|
1942
|
+
// Leave the parked tree in place (it is the only prior copy); do NOT
|
|
1943
|
+
// commit. Compensate so the live pair returns to prior.
|
|
1944
|
+
const comp = await compensate().catch(() => ({ ok: false }));
|
|
1945
|
+
return finalizeCompensationFailure({ home, code: 'COORDINATED_RETAIN_FAILED', error: retained.error ?? 'could not durably retain prior runtime', comp, parked: prevPath });
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// Mark the journal verified BEFORE the pointer write. Crash recovery
|
|
1950
|
+
// then has a clean WAL relation: pointer==prior means not committed,
|
|
1951
|
+
// pointer==candidate with verified journal means committed.
|
|
1952
|
+
const marked = markJournalVerified({
|
|
1953
|
+
home,
|
|
1954
|
+
stage: 'coordinated-update',
|
|
1955
|
+
prior: { name: prior.name, version: prior.version, path: prior.path, dshVersion: priorDshVersion },
|
|
1956
|
+
candidate: { name: candidateManifest.name, version: candidateManifest.version, stageDir, dshVersion: candidateDshVersion },
|
|
1957
|
+
runtime: {
|
|
1958
|
+
state: 'verified',
|
|
1959
|
+
liveRoot,
|
|
1960
|
+
candidateRoot: liveRoot,
|
|
1961
|
+
priorVersion: priorDshVersion,
|
|
1962
|
+
candidateVersion: candidateDshVersion,
|
|
1963
|
+
retainedRoot,
|
|
1964
|
+
},
|
|
1965
|
+
});
|
|
1966
|
+
if (!marked.ok) {
|
|
1967
|
+
const comp = await compensate().catch(() => ({ ok: false }));
|
|
1968
|
+
return finalizeCompensationFailure({ home, code: marked.code ?? 'JOURNAL_MARK_FAILED', error: 'coordinated journal mark-verified failed', comp });
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// COMMIT POINT / LAST: pointer write after the verified journal.
|
|
1972
|
+
switchPointer(candidateRelease);
|
|
1973
|
+
|
|
1974
|
+
clearUpdateJournal({ home });
|
|
1975
|
+
log(`✓ coordinated update committed: Crew ${candidateManifest.version} + DSH ${candidateDshVersion}`);
|
|
1976
|
+
return { ok: true, version: candidateManifest.version, path: stageDir, dsh_version: candidateDshVersion, restarted: started };
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
const comp = await compensate().catch(() => ({ ok: false }));
|
|
1979
|
+
return finalizeCompensationFailure({ home, code: null, error: error?.message ?? 'coordinated update failed', comp });
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// Shared failure exit for a compensated coordinated transaction. The journal
|
|
1984
|
+
// is cleared ONLY when compensation fully succeeded (prior payload
|
|
1985
|
+
// activated, prior runtime restored, restart + dual verify passed). When
|
|
1986
|
+
// compensation itself failed, the journal is the ONLY durable recovery
|
|
1987
|
+
// witness describing priorRoot/liveRoot/versions — it must survive so the
|
|
1988
|
+
// next reconcile can finish the recovery. A compensation-failed marker is
|
|
1989
|
+
// appended so operators can see why the journal is still present.
|
|
1990
|
+
function finalizeCompensationFailure({ home, code, error, comp, parked = null }) {
|
|
1991
|
+
if (comp.ok === true) {
|
|
1992
|
+
clearUpdateJournal({ home });
|
|
1993
|
+
} else {
|
|
1994
|
+
// Compensation failed: RETAIN the journal (never delete the recovery
|
|
1995
|
+
// witness) and annotate it with the failure for the next reconcile pass.
|
|
1996
|
+
try {
|
|
1997
|
+
const file = updateJournalFile({ home });
|
|
1998
|
+
if (existsSync(file)) {
|
|
1999
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
2000
|
+
raw.compensation_failed = true;
|
|
2001
|
+
raw.compensation_error = String(error ?? comp.error ?? 'compensation failed');
|
|
2002
|
+
writeFileAtomic(file, JSON.stringify(raw, null, 2) + '\n');
|
|
2003
|
+
}
|
|
2004
|
+
} catch { /* best effort annotation; journal itself stays untouched */ }
|
|
2005
|
+
}
|
|
2006
|
+
return { ok: false, code: code ?? undefined, error: error ?? 'coordinated update failed', compensated: comp.ok === true, compensation_failed: comp.ok !== true, parked };
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// Unified durable retention of a parked prior runtime tree. NEVER deletes
|
|
2010
|
+
// the parked tree on failure: the caller decides whether to compensate
|
|
2011
|
+
// (abort the commit) or surface the parked path as a durable rollback
|
|
2012
|
+
// source. Bounded retry absorbs transient Windows file-handle contention
|
|
2013
|
+
// right after the new 3210 starts.
|
|
2014
|
+
function retainParkedRuntimeTree({ home, prevPath, priorDshVersion, rename = renameSync }) {
|
|
2015
|
+
try {
|
|
2016
|
+
if (!existsSync(prevPath)) return { ok: true, retained: false };
|
|
2017
|
+
const retainedRoot = join(crewDshHome({ home }), 'retained-runtimes');
|
|
2018
|
+
const target = join(retainedRoot, priorDshVersion);
|
|
2019
|
+
mkdirSync(retainedRoot, { recursive: true });
|
|
2020
|
+
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
|
|
2021
|
+
const delays = [0, 50, 150, 400];
|
|
2022
|
+
let lastError = null;
|
|
2023
|
+
for (const delay of delays) {
|
|
2024
|
+
if (delay > 0) { const until = Date.now() + delay; while (Date.now() < until) { /* busy-wait bounded */ } }
|
|
2025
|
+
try {
|
|
2026
|
+
rename(prevPath, target);
|
|
2027
|
+
return { ok: true, retained: true, version: priorDshVersion, path: target };
|
|
2028
|
+
} catch (error) { lastError = error; }
|
|
2029
|
+
}
|
|
2030
|
+
return { ok: false, retained: false, prevPath, error: String(lastError?.message ?? lastError) };
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
return { ok: false, retained: false, prevPath, error: String(error?.message ?? error) };
|
|
664
2033
|
}
|
|
665
|
-
log(`✓ reusable Crew DSH runtime${r.version ? ` (@${r.version})` : ''}`);
|
|
666
|
-
return true;
|
|
667
2034
|
}
|
|
668
2035
|
|
|
669
2036
|
// ---- commands -----------------------------------------------------------------
|
|
@@ -715,49 +2082,36 @@ async function activateRelease({ home, releaseDir, manifest, log, installer }) {
|
|
|
715
2082
|
}
|
|
716
2083
|
log('✓ Claude Code integration');
|
|
717
2084
|
|
|
2085
|
+
// The official ~/.dsh/profiles/web tree is read-only: activation never
|
|
2086
|
+
// repairs or mutates the official bridge. The isolated 3210 Crew backend
|
|
2087
|
+
// is the only runtime Crew owns.
|
|
718
2088
|
const official = officialWebIntegrationStatus({ home });
|
|
719
|
-
if (official.
|
|
720
|
-
|
|
721
|
-
if (!repaired.ok) {
|
|
722
|
-
log(`✗ official 3080 bridge repair failed (${repaired.code ?? 'unknown'})`);
|
|
723
|
-
return false;
|
|
724
|
-
}
|
|
725
|
-
log('✓ official 3080 UI bridge → isolated Crew backend on 3210');
|
|
2089
|
+
if (official.legacy_present) {
|
|
2090
|
+
log('- legacy official 3080 bridge record present but deprecated; official web profile is read-only and untouched');
|
|
726
2091
|
}
|
|
727
2092
|
return true;
|
|
728
2093
|
}
|
|
729
2094
|
|
|
2095
|
+
// Compensate a failed candidate activation by re-pointing live activation
|
|
2096
|
+
// surfaces (profile registration + host integrations) back at the prior
|
|
2097
|
+
// release. Rewriting current.json alone is NOT enough: a crash between
|
|
2098
|
+
// activation and pointer commit leaves live surfaces on the candidate.
|
|
2099
|
+
async function compensateActivation({ home, prior, log, installer }) {
|
|
2100
|
+
if (!prior?.path || !existsSync(prior.path)) return { ok: false, code: 'PRIOR_RELEASE_MISSING' };
|
|
2101
|
+
const manifest = readManifest(prior.path);
|
|
2102
|
+
if (!manifest?.name || !manifest?.version) return { ok: false, code: 'PRIOR_MANIFEST_INVALID' };
|
|
2103
|
+
const ok = await activateRelease({ home, releaseDir: prior.path, manifest, log, installer });
|
|
2104
|
+
return ok ? { ok: true, version: manifest.version, path: prior.path } : { ok: false, code: 'PRIOR_ACTIVATION_FAILED' };
|
|
2105
|
+
}
|
|
2106
|
+
|
|
730
2107
|
export async function npxIntegrate({ home = homedir(), log = console.log } = {}) {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
log('✗ install DSH Crew before enabling the official 3080 integration');
|
|
734
|
-
return { ok: false, error: 'DSH Crew is not installed' };
|
|
735
|
-
}
|
|
736
|
-
const validated = validateInstalledPayload(pointer.path, { expectedName: pointer.name, expectedVersion: pointer.version });
|
|
737
|
-
if (!validated.ok) {
|
|
738
|
-
log('✗ installed DSH Crew payload is damaged; run dsh-crew update first');
|
|
739
|
-
return { ok: false, error: 'installed payload invalid' };
|
|
740
|
-
}
|
|
741
|
-
const result = ensureOfficialWebIntegration({ home, releaseDir: pointer.path });
|
|
742
|
-
if (!result.ok) {
|
|
743
|
-
log(`✗ official 3080 integration failed (${result.code ?? 'unknown'})`);
|
|
744
|
-
return { ok: false, error: result.code ?? 'integration failed' };
|
|
745
|
-
}
|
|
746
|
-
log(result.changed
|
|
747
|
-
? '✓ official 3080 UI connected to the isolated Crew backend on 3210'
|
|
748
|
-
: '- official 3080 UI integration already healthy');
|
|
749
|
-
log(` backup: ${result.backupFile}`);
|
|
750
|
-
return { ok: true, changed: result.changed, backupFile: result.backupFile };
|
|
2108
|
+
log('✗ official 3080 integration is disabled: the official web profile is read-only');
|
|
2109
|
+
return { ok: false, error: 'OFFICIAL_WEB_PROFILE_READ_ONLY' };
|
|
751
2110
|
}
|
|
752
2111
|
|
|
753
2112
|
export async function npxDetach({ home = homedir(), log = console.log } = {}) {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
log(`✗ official 3080 integration removal failed (${result.code ?? 'unknown'})`);
|
|
757
|
-
return { ok: false, error: result.code ?? 'detach failed' };
|
|
758
|
-
}
|
|
759
|
-
log(result.removed ? '✓ official 3080 bridge removed; isolated 3210 mode remains available' : '- official 3080 bridge already absent');
|
|
760
|
-
return { ok: true, removed: result.removed };
|
|
2113
|
+
log('✗ official 3080 detach is disabled: the official web profile is read-only');
|
|
2114
|
+
return { ok: false, error: 'OFFICIAL_WEB_PROFILE_READ_ONLY' };
|
|
761
2115
|
}
|
|
762
2116
|
|
|
763
2117
|
export async function npxInstall({
|
|
@@ -769,6 +2123,18 @@ export async function npxInstall({
|
|
|
769
2123
|
npmInstaller,
|
|
770
2124
|
} = {}) {
|
|
771
2125
|
log('DSH Crew installer (npx-managed)');
|
|
2126
|
+
const updateLock = acquireUpdateLock({ home });
|
|
2127
|
+
if (!updateLock.ok) return { ok: false, error: `another update is in progress (${updateLock.code})` };
|
|
2128
|
+
try {
|
|
2129
|
+
return await npxInstallInner({ home, log, sourceRoot, installer, ensureRuntime, npmInstaller });
|
|
2130
|
+
} finally {
|
|
2131
|
+
releaseUpdateLock({ home, nonce: updateLock.nonce ?? null });
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
async function npxInstallInner({ home, log, sourceRoot, installer, ensureRuntime, npmInstaller }) {
|
|
2136
|
+
const reconciled = reconcileUpdateJournal({ home, log });
|
|
2137
|
+
if (!reconciled.ok) return { ok: false, error: `update journal recovery failed (${reconciled.code ?? 'unknown'})` };
|
|
772
2138
|
const candidateRoot = sourceRoot ?? runningPackageRoot();
|
|
773
2139
|
const manifest = readManifest(candidateRoot);
|
|
774
2140
|
if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
|
|
@@ -792,13 +2158,24 @@ export async function npxInstall({
|
|
|
792
2158
|
}
|
|
793
2159
|
log(`✓ candidate payload staged (${manifest.version})`);
|
|
794
2160
|
|
|
795
|
-
|
|
796
|
-
|
|
2161
|
+
// Transaction order: stage -> runtime gate -> journal(begin) ->
|
|
2162
|
+
// activation -> pointer commit LAST -> clear journal -> GC. A crash
|
|
2163
|
+
// anywhere before the pointer write leaves the prior release
|
|
2164
|
+
// authoritative for reconcileUpdateJournal.
|
|
2165
|
+
const prior = readCurrentPointer({ home });
|
|
2166
|
+
if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
|
|
797
2167
|
|
|
798
|
-
|
|
799
|
-
|
|
2168
|
+
beginReleaseActivation({ stageDir: staged.stageDir, manifest, home, prior });
|
|
2169
|
+
log(`✓ candidate payload staged (${manifest.version})`);
|
|
800
2170
|
|
|
801
|
-
|
|
2171
|
+
const activated = await activateRelease({ home, releaseDir: staged.stageDir, manifest, log, installer });
|
|
2172
|
+
if (!activated) {
|
|
2173
|
+
const compensated = prior?.path ? await compensateActivation({ home, prior, log, installer }) : null;
|
|
2174
|
+
return { ok: false, error: 'activation failed', compensated: compensated?.ok === true };
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
const releaseDir = commitActivatedRelease({ stageDir: staged.stageDir, manifest, home, prior });
|
|
2178
|
+
log(`✓ durable release committed under Crew-owned state`);
|
|
802
2179
|
|
|
803
2180
|
log('');
|
|
804
2181
|
log('Done.');
|
|
@@ -954,6 +2331,18 @@ export async function npxUpdate({
|
|
|
954
2331
|
runner = spawnSync,
|
|
955
2332
|
} = {}) {
|
|
956
2333
|
log('DSH Crew updater');
|
|
2334
|
+
const updateLock = acquireUpdateLock({ home });
|
|
2335
|
+
if (!updateLock.ok) return { ok: false, error: `another update is in progress (${updateLock.code})` };
|
|
2336
|
+
try {
|
|
2337
|
+
return await npxUpdateInner({ home, log, sourceRoot, candidate, spec, installer, ensureRuntime, npmInstaller, runner });
|
|
2338
|
+
} finally {
|
|
2339
|
+
releaseUpdateLock({ home, nonce: updateLock.nonce ?? null });
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
async function npxUpdateInner({ home, log, sourceRoot, candidate, spec, installer, ensureRuntime, npmInstaller, runner = spawnSync }) {
|
|
2344
|
+
const journalRecovery = reconcileUpdateJournal({ home, log });
|
|
2345
|
+
if (!journalRecovery.ok) return { ok: false, error: `update journal recovery failed (${journalRecovery.code ?? 'unknown'})` };
|
|
957
2346
|
// Candidate resolution: explicit path/dir override > a newer validated
|
|
958
2347
|
// running launcher > configured npm registry (@latest). This makes the
|
|
959
2348
|
// supported legacy bootstrap (`npm install -g ...@latest`, then `update`)
|
|
@@ -1018,14 +2407,97 @@ export async function npxUpdate({
|
|
|
1018
2407
|
}
|
|
1019
2408
|
log(`✓ candidate payload staged and validated (${manifest.version})`);
|
|
1020
2409
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
2410
|
+
// Transaction order: stage -> runtime gate -> journal(begin) ->
|
|
2411
|
+
// activation -> pointer commit LAST -> clear journal -> GC. A crash
|
|
2412
|
+
// anywhere before the pointer write leaves the prior release
|
|
2413
|
+
// authoritative for reconcileUpdateJournal.
|
|
2414
|
+
const prior = readCurrentPointer({ home });
|
|
2415
|
+
const priorManifest = prior?.path ? readManifest(prior.path) : null;
|
|
2416
|
+
const candidateDsh = payloadDshVersion(manifest);
|
|
2417
|
+
// Resolve the PRIOR cohort through the full resolution chain. Historical
|
|
2418
|
+
// releases (pre-1.0.4) carry no manifest pin, so for the FORWARD upgrade
|
|
2419
|
+
// path the live runtime tree's current cohort is the authoritative fact
|
|
2420
|
+
// (it is exactly the cohort this upgrade will migrate away from). This
|
|
2421
|
+
// guarantees even a legacy unpinned 1.0.3 prior routes through the
|
|
2422
|
+
// coordinated payload+runtime transaction instead of the standalone
|
|
2423
|
+
// migrate path. A present-but-invalid sidecar fails closed.
|
|
2424
|
+
let priorDsh = null;
|
|
2425
|
+
if (prior?.path) {
|
|
2426
|
+
const priorCohort = resolveReleaseCohort({
|
|
2427
|
+
releaseDir: prior.path,
|
|
2428
|
+
allowLegacyLiveFallback: true,
|
|
2429
|
+
readRuntimeVersion: () => readRuntimeTreeVersionSync(crewDshRuntimeRoot({ home })),
|
|
2430
|
+
});
|
|
2431
|
+
if (priorCohort.invalid) {
|
|
2432
|
+
return { ok: false, error: `prior release cohort sidecar invalid (${priorCohort.error ?? 'unknown'})` };
|
|
2433
|
+
}
|
|
2434
|
+
priorDsh = priorCohort.ok ? priorCohort.dshVersion : null;
|
|
2435
|
+
}
|
|
2436
|
+
const needsCohortSwap = candidateDsh !== null && priorDsh !== null && candidateDsh !== priorDsh;
|
|
2437
|
+
|
|
2438
|
+
if (needsCohortSwap) {
|
|
2439
|
+
// Cross-cohort update: payload activation and runtime swap are ONE
|
|
2440
|
+
// coordinated transaction so the 3210 never runs an unsupported
|
|
2441
|
+
// payload/cohort combination.
|
|
2442
|
+
log(`- cross-cohort update: DSH ${priorDsh} -> ${candidateDsh}; running coordinated payload+runtime transaction`);
|
|
2443
|
+
// The discovered prior cohort must be DURABLY recorded as a sidecar
|
|
2444
|
+
// BEFORE the transaction: "update complete" must imply the legacy
|
|
2445
|
+
// release can later be rolled back offline. Write then READ BACK
|
|
2446
|
+
// (without live fallback) — a writer that returned null OR a sidecar
|
|
2447
|
+
// that does not round-trip to the discovered cohort aborts before any
|
|
2448
|
+
// runtime park / payload activation / pointer mutation. A pre-existing
|
|
2449
|
+
// valid sidecar for the same cohort also satisfies the read-back.
|
|
2450
|
+
const persistResult = writeReleaseCohort({ releaseDir: prior.path, dshVersion: priorDsh, source: 'discovered-live-runtime' });
|
|
2451
|
+
const readBack = resolveReleaseCohort({ releaseDir: prior.path, allowLegacyLiveFallback: false });
|
|
2452
|
+
const sidecarDurable = persistResult !== null
|
|
2453
|
+
&& readBack.ok === true
|
|
2454
|
+
&& readBack.dshVersion === priorDsh;
|
|
2455
|
+
if (!sidecarDurable) {
|
|
2456
|
+
return {
|
|
2457
|
+
ok: false,
|
|
2458
|
+
code: 'RELEASE_COHORT_SIDECAR_PERSIST_FAILED',
|
|
2459
|
+
error: `prior release cohort sidecar could not be durably persisted (${readBack.ok ? 'read-back mismatch' : readBack.error ?? 'write failed'}); refusing cross-cohort update that could not be rolled back offline`,
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
const coordinated = await performCoordinatedCohortUpdate({
|
|
2463
|
+
home,
|
|
2464
|
+
log,
|
|
2465
|
+
prior,
|
|
2466
|
+
priorDshVersion: priorDsh,
|
|
2467
|
+
candidateManifest: manifest,
|
|
2468
|
+
candidateDshVersion: candidateDsh,
|
|
2469
|
+
stageDir: staged.stageDir,
|
|
2470
|
+
installer,
|
|
2471
|
+
});
|
|
2472
|
+
if (!coordinated.ok) {
|
|
2473
|
+
return { ok: false, error: `coordinated cohort update failed${coordinated.code ? ` (${coordinated.code})` : ''}`, compensated: coordinated.compensated === true };
|
|
2474
|
+
}
|
|
2475
|
+
// Record the candidate cohort sidecar for the newly committed release.
|
|
2476
|
+
const committedRelease = readCurrentPointer({ home });
|
|
2477
|
+
if (committedRelease?.path) writeReleaseCohort({ releaseDir: committedRelease.path, dshVersion: candidateDsh, source: 'manifest-pin' });
|
|
2478
|
+
noteLauncherDivergence({ log, home });
|
|
2479
|
+
log('');
|
|
2480
|
+
log('Done.');
|
|
2481
|
+
log('Restart DeepSeek Harness and Codex Desktop.');
|
|
2482
|
+
return { ok: true, updated: true, version: manifest.version, path: staged.stageDir, coordinated: true, dsh_version: candidateDsh };
|
|
2483
|
+
}
|
|
1026
2484
|
|
|
1027
2485
|
if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
|
|
1028
2486
|
|
|
2487
|
+
beginReleaseActivation({ stageDir: staged.stageDir, manifest, home, prior });
|
|
2488
|
+
|
|
2489
|
+
const activated = await activateRelease({ home, releaseDir: staged.stageDir, manifest, log, installer });
|
|
2490
|
+
if (!activated) {
|
|
2491
|
+
const compensated = prior?.path ? await compensateActivation({ home, prior, log, installer }) : null;
|
|
2492
|
+
return { ok: false, error: 'activation failed', compensated: compensated?.ok === true };
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
const releaseDir = commitActivatedRelease({ stageDir: staged.stageDir, manifest, home, prior });
|
|
2496
|
+
log('✓ durable release committed under Crew-owned state');
|
|
2497
|
+
// Record the cohort sidecar for the committed release (exact pin when
|
|
2498
|
+
// available, else the live cohort the activation just ran on).
|
|
2499
|
+
if (candidateDsh) writeReleaseCohort({ releaseDir, dshVersion: candidateDsh, source: 'manifest-pin' });
|
|
2500
|
+
|
|
1029
2501
|
noteLauncherDivergence({ log, home });
|
|
1030
2502
|
log('');
|
|
1031
2503
|
log('Done.');
|
|
@@ -1111,7 +2583,7 @@ export function npxStatus({
|
|
|
1111
2583
|
const zcode = st?.zcode?.installed ? 'installed' : 'not installed';
|
|
1112
2584
|
const claude = st?.claude?.installed ? 'installed' : 'not installed';
|
|
1113
2585
|
const official = officialWebIntegrationStatus({ home, releaseDir: pointer?.path });
|
|
1114
|
-
const officialWeb = !official.
|
|
2586
|
+
const officialWeb = !official.legacy_present ? 'not present (native 3210 control plane)' : official.healthy ? 'legacy full bridge present (deprecated; manual cleanup available)' : 'legacy full bridge record present but unhealthy (deprecated)';
|
|
1115
2587
|
const startupState = installer.windowsStartupStatus?.({ home });
|
|
1116
2588
|
const windowsStartup = !startupState?.supported ? 'not supported'
|
|
1117
2589
|
: startupState.ready ? 'installed' : startupState.installed ? 'needs repair' : 'not installed';
|
|
@@ -1181,7 +2653,8 @@ export async function npxUninstall({
|
|
|
1181
2653
|
else if (startup?.supported) log('✓ Windows login startup removed');
|
|
1182
2654
|
|
|
1183
2655
|
const official = removeOfficialWebIntegration({ home, preserveIntent: !purge, remember: !purge });
|
|
1184
|
-
if (!official.ok) fail(`official 3080 bridge removal failed (${official.code ?? 'unknown'})`);
|
|
2656
|
+
if (!official.ok && official.code !== 'OFFICIAL_WEB_PROFILE_READ_ONLY') fail(`official 3080 bridge removal failed (${official.code ?? 'unknown'})`);
|
|
2657
|
+
else if (official.code === 'OFFICIAL_WEB_PROFILE_READ_ONLY') log('- official 3080 bridge left untouched (official web profile is read-only)');
|
|
1185
2658
|
else log(official.removed ? '✓ official 3080 bridge removed' : '- official 3080 bridge already absent');
|
|
1186
2659
|
|
|
1187
2660
|
if (name) {
|
|
@@ -1222,15 +2695,15 @@ export const USAGE = `usage: dsh-crew <command> [--purge] [--candidate <path>]
|
|
|
1222
2695
|
|
|
1223
2696
|
Commands:
|
|
1224
2697
|
install persist the candidate package into Crew-owned state and register it
|
|
1225
|
-
integrate
|
|
1226
|
-
detach
|
|
2698
|
+
integrate disabled: the official web profile is read-only (legacy bridge retired)
|
|
2699
|
+
detach disabled: the official web profile is read-only (legacy bridge retired)
|
|
1227
2700
|
status read-only report of launcher/installed versions and integrations
|
|
1228
|
-
inspect print the machine-readable extension capability/readiness contract
|
|
1229
|
-
jobs machine-first job API: list|get|watch|cancel|submit
|
|
1230
|
-
providers 3210 provider lifecycle API: list|delete-plan|delete|rollback|probe
|
|
1231
|
-
credentials 3210 credential references: list|purge-plan|purge (separate confirmation)
|
|
1232
|
-
releases list retained, validated Crew payload releases
|
|
1233
|
-
rollback switch to a retained payload version and verify the 3210 runtime
|
|
2701
|
+
inspect print the machine-readable extension capability/readiness contract
|
|
2702
|
+
jobs machine-first job API: list|get|watch|cancel|submit
|
|
2703
|
+
providers 3210 provider lifecycle API: list|migration-status|migrate-plan|migrate|verify-migration|rollback-migration|verify-rollback-migration|delete-plan|delete|rollback|probe
|
|
2704
|
+
credentials 3210 credential references: list|purge-plan|purge (separate confirmation)
|
|
2705
|
+
releases list retained, validated Crew payload releases
|
|
2706
|
+
rollback switch to a retained payload version and verify the 3210 runtime
|
|
1234
2707
|
update resolve the newest permitted package from the configured npm registry (or
|
|
1235
2708
|
--candidate), stage and validate it, then activate; idempotent when current
|
|
1236
2709
|
uninstall remove the Crew-managed payload, registration, and integrations (config kept)
|
|
@@ -1239,25 +2712,25 @@ Options:
|
|
|
1239
2712
|
--candidate <path> update from a local payload directory or packed .tgz instead of the registry
|
|
1240
2713
|
--after <sequence> with jobs watch/get: return canonical events after this cursor
|
|
1241
2714
|
--detail <mode> with jobs get/watch: compact (default) or full
|
|
1242
|
-
--request <path> with jobs submit: JSON Job Request document
|
|
1243
|
-
--plan <id> with providers/credentials destructive actions: approved plan id
|
|
1244
|
-
--expected-revision <sha256> with providers/credentials actions: revision from plan
|
|
1245
|
-
--replacement-default <id> with providers delete-plan: replacement Harness Default provider
|
|
1246
|
-
--confirm with providers delete: confirm the destructive mutation
|
|
2715
|
+
--request <path> with jobs submit: JSON Job Request document
|
|
2716
|
+
--plan <id> with providers/credentials destructive actions: approved plan id
|
|
2717
|
+
--expected-revision <sha256> with providers/credentials actions: revision from plan
|
|
2718
|
+
--replacement-default <id> with providers delete-plan: replacement Harness Default provider
|
|
2719
|
+
--confirm with providers delete: confirm the destructive mutation
|
|
1247
2720
|
--purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
|
|
1248
2721
|
--help show this help
|
|
1249
2722
|
|
|
1250
2723
|
Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
|
|
1251
2724
|
Source checkouts use scripts/setup.mjs instead.`;
|
|
1252
2725
|
|
|
1253
|
-
export async function npxInspect({
|
|
2726
|
+
export async function npxInspect({
|
|
1254
2727
|
log = console.log,
|
|
1255
2728
|
fetchImpl = globalThis.fetch,
|
|
1256
2729
|
readConfig = realInstaller.readGlobalConfig,
|
|
1257
|
-
} = {}) {
|
|
1258
|
-
const hubUrl = String(readConfig()?.hub_url ?? PRODUCTION_HUB_URL).replace(/\/$/, '');
|
|
1259
|
-
await assertProductionHub({ hubUrl, requiredCapabilities: INSPECT_CAPABILITIES, purpose: 'inspect', fetchImpl });
|
|
1260
|
-
const url = `${String(hubUrl).replace(/\/$/, '')}/_dsh/dsh-crew/extension`;
|
|
2730
|
+
} = {}) {
|
|
2731
|
+
const hubUrl = String(readConfig()?.hub_url ?? PRODUCTION_HUB_URL).replace(/\/$/, '');
|
|
2732
|
+
await assertProductionHub({ hubUrl, requiredCapabilities: INSPECT_CAPABILITIES, purpose: 'inspect', fetchImpl });
|
|
2733
|
+
const url = `${String(hubUrl).replace(/\/$/, '')}/_dsh/dsh-crew/extension`;
|
|
1261
2734
|
const response = await fetchImpl(url, { headers: { accept: 'application/json' } });
|
|
1262
2735
|
const body = await response.json();
|
|
1263
2736
|
if (!response.ok || body?.ok !== true || !body.extension) {
|
|
@@ -1267,7 +2740,7 @@ export async function npxInspect({
|
|
|
1267
2740
|
return { ok: true, extension: body.extension };
|
|
1268
2741
|
}
|
|
1269
2742
|
|
|
1270
|
-
export async function npxJobs({
|
|
2743
|
+
export async function npxJobs({
|
|
1271
2744
|
args = [],
|
|
1272
2745
|
after = 0,
|
|
1273
2746
|
detail = 'compact',
|
|
@@ -1275,15 +2748,15 @@ export async function npxJobs({
|
|
|
1275
2748
|
log = console.log,
|
|
1276
2749
|
fetchImpl = globalThis.fetch,
|
|
1277
2750
|
readConfig = realInstaller.readGlobalConfig,
|
|
1278
|
-
} = {}) {
|
|
1279
|
-
const hubUrl = String(readConfig()?.hub_url ?? 'http://127.0.0.1:3210').replace(/\/$/, '');
|
|
1280
|
-
const base = `${hubUrl}/_dsh/dsh-crew/jobs`;
|
|
1281
|
-
const action = args[0] ?? 'list';
|
|
1282
|
-
const id = args[1];
|
|
1283
|
-
const requiredCapabilities = JOB_CAPABILITY_REQUIREMENTS[action];
|
|
1284
|
-
if (!requiredCapabilities) throw new Error(`unknown jobs action: ${action}`);
|
|
1285
|
-
await assertProductionHub({ hubUrl, requiredCapabilities, purpose: 'job', fetchImpl });
|
|
1286
|
-
let url = base;
|
|
2751
|
+
} = {}) {
|
|
2752
|
+
const hubUrl = String(readConfig()?.hub_url ?? 'http://127.0.0.1:3210').replace(/\/$/, '');
|
|
2753
|
+
const base = `${hubUrl}/_dsh/dsh-crew/jobs`;
|
|
2754
|
+
const action = args[0] ?? 'list';
|
|
2755
|
+
const id = args[1];
|
|
2756
|
+
const requiredCapabilities = JOB_CAPABILITY_REQUIREMENTS[action];
|
|
2757
|
+
if (!requiredCapabilities) throw new Error(`unknown jobs action: ${action}`);
|
|
2758
|
+
await assertProductionHub({ hubUrl, requiredCapabilities, purpose: 'job', fetchImpl });
|
|
2759
|
+
let url = base;
|
|
1287
2760
|
let init = { headers: { accept: 'application/json' } };
|
|
1288
2761
|
if (action === 'get' || action === 'watch') {
|
|
1289
2762
|
if (!id) throw new Error(`jobs ${action} requires a job id`);
|
|
@@ -1296,224 +2769,266 @@ export async function npxJobs({
|
|
|
1296
2769
|
if (!request) throw new Error('jobs submit requires --request <json-file>');
|
|
1297
2770
|
const document = JSON.parse(readFileSync(resolve(request), 'utf8'));
|
|
1298
2771
|
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(document) };
|
|
1299
|
-
}
|
|
2772
|
+
}
|
|
1300
2773
|
const response = await fetchImpl(url, init);
|
|
1301
2774
|
const body = await response.json();
|
|
1302
2775
|
if (!response.ok || body?.ok === false) throw new Error(body?.error ?? 'Crew jobs API unavailable');
|
|
1303
2776
|
log(JSON.stringify(body, null, 2));
|
|
1304
|
-
return { ok: true, body };
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
const PROVIDER_CAPABILITY_REQUIREMENTS = Object.freeze({
|
|
1308
|
-
list: Object.freeze(['provider-inventory']),
|
|
1309
|
-
'
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
});
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
if (
|
|
1401
|
-
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
url = `${base}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
if (!
|
|
1417
|
-
url = `${base}/${encodeURIComponent(id)}/
|
|
1418
|
-
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body:
|
|
1419
|
-
} else if (action === '
|
|
1420
|
-
if (!id) throw new Error('providers
|
|
1421
|
-
if (!resolvedPlan || !resolvedConfirm) throw new Error('providers
|
|
1422
|
-
url = `${base}/${encodeURIComponent(id)}/
|
|
1423
|
-
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }) };
|
|
1424
|
-
} else {
|
|
1425
|
-
throw new Error(
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
if (
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
2777
|
+
return { ok: true, body };
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
const PROVIDER_CAPABILITY_REQUIREMENTS = Object.freeze({
|
|
2781
|
+
list: Object.freeze(['provider-inventory']),
|
|
2782
|
+
'migration-status': Object.freeze(['provider-inventory']),
|
|
2783
|
+
'migrate-plan': Object.freeze(['provider-inventory', 'provider-layer-migration-v1']),
|
|
2784
|
+
migrate: Object.freeze(['provider-inventory', 'provider-layer-migration-v1']),
|
|
2785
|
+
'verify-migration': Object.freeze(['provider-inventory', 'provider-layer-migration-v1']),
|
|
2786
|
+
'rollback-migration': Object.freeze(['provider-inventory', 'provider-layer-migration-v1']),
|
|
2787
|
+
'verify-rollback-migration': Object.freeze(['provider-inventory', 'provider-layer-migration-v1']),
|
|
2788
|
+
'delete-plan': Object.freeze(['provider-inventory', 'provider-lifecycle-v1']),
|
|
2789
|
+
delete: Object.freeze(['provider-inventory', 'provider-lifecycle-v1']),
|
|
2790
|
+
rollback: Object.freeze(['provider-inventory', 'provider-lifecycle-v1']),
|
|
2791
|
+
probe: Object.freeze(['provider-inventory', 'provider-health-v1', 'provider-probe-stream-v1']),
|
|
2792
|
+
});
|
|
2793
|
+
const JOB_CAPABILITY_REQUIREMENTS = Object.freeze({
|
|
2794
|
+
list: Object.freeze(['jobs']),
|
|
2795
|
+
get: Object.freeze(['jobs', 'jobs-wait']),
|
|
2796
|
+
watch: Object.freeze(['jobs', 'jobs-wait']),
|
|
2797
|
+
cancel: Object.freeze(['jobs', 'jobs-cancel']),
|
|
2798
|
+
submit: Object.freeze(['jobs', 'roles', 'attempt-index', 'model-policy']),
|
|
2799
|
+
});
|
|
2800
|
+
const INSPECT_CAPABILITIES = Object.freeze(['extension-contract', 'evidence', 'runtime-provenance-v1']);
|
|
2801
|
+
const CREDENTIAL_CAPABILITY_REQUIREMENTS = Object.freeze({
|
|
2802
|
+
list: Object.freeze(['credential-reference-inventory-v1']),
|
|
2803
|
+
'purge-plan': Object.freeze(['credential-reference-inventory-v1', 'credential-purge-v1']),
|
|
2804
|
+
purge: Object.freeze(['credential-reference-inventory-v1', 'credential-purge-v1']),
|
|
2805
|
+
});
|
|
2806
|
+
export const PRODUCTION_HUB_URL = 'http://127.0.0.1:3210';
|
|
2807
|
+
|
|
2808
|
+
export async function assertProductionHub({ hubUrl, requiredCapabilities = [], purpose = 'command', fetchImpl = globalThis.fetch } = {}) {
|
|
2809
|
+
if (hubUrl !== PRODUCTION_HUB_URL) throw new Error(`${purpose} requires the isolated 3210 Crew Hub`);
|
|
2810
|
+
const response = await fetchImpl(`${hubUrl}${CREW_ROUTE_BASE}/runtime`, { headers: { accept: 'application/json' } });
|
|
2811
|
+
let body;
|
|
2812
|
+
try { body = await response.json(); } catch { body = null; }
|
|
2813
|
+
const validIdentity = response.ok && body?.ok === true
|
|
2814
|
+
&& body?.service === 'dsh-crew-hub'
|
|
2815
|
+
&& body?.execution_plane === 'hub-3210'
|
|
2816
|
+
&& body?.profile === 'dsh-crew'
|
|
2817
|
+
&& Number(body?.listen_port) === 3210
|
|
2818
|
+
&& typeof body?.runtime_id === 'string' && body.runtime_id.trim().length > 0;
|
|
2819
|
+
if (!validIdentity) throw new Error(`${purpose} requires a compatible isolated 3210 Crew Hub`);
|
|
2820
|
+
const advertised = new Set(Array.isArray(body.capabilities) ? body.capabilities.filter((value) => typeof value === 'string') : []);
|
|
2821
|
+
const missing = requiredCapabilities.filter((capability) => !advertised.has(capability));
|
|
2822
|
+
if (missing.length > 0) throw new Error(`missing ${purpose} capability: ${missing.join(', ')}`);
|
|
2823
|
+
return { ok: true, runtime: body, capabilities: [...advertised] };
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
/**
|
|
2827
|
+
* Confirm the target is the live isolated Hub and advertises the lifecycle
|
|
2828
|
+
* surface before sending a provider request. This prevents a same-port stale
|
|
2829
|
+
* Hub from turning a later 404 into an ambiguous destructive failure.
|
|
2830
|
+
*/
|
|
2831
|
+
export async function assertProviderHubCapabilities({ hubUrl, action, fetchImpl = globalThis.fetch } = {}) {
|
|
2832
|
+
return assertProductionHub({
|
|
2833
|
+
hubUrl,
|
|
2834
|
+
requiredCapabilities: PROVIDER_CAPABILITY_REQUIREMENTS[action] ?? PROVIDER_CAPABILITY_REQUIREMENTS.list,
|
|
2835
|
+
purpose: 'provider lifecycle',
|
|
2836
|
+
fetchImpl,
|
|
2837
|
+
});
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/**
|
|
2841
|
+
* Call the isolated 3210 provider lifecycle API. The CLI never parses or
|
|
2842
|
+
* edits Harness YAML itself; the Hub remains the sole mutation authority.
|
|
2843
|
+
*/
|
|
2844
|
+
export async function npxProviders({
|
|
2845
|
+
args = [],
|
|
2846
|
+
planId,
|
|
2847
|
+
expectedRevision,
|
|
2848
|
+
replacementDefault,
|
|
2849
|
+
confirm = false,
|
|
2850
|
+
purgeOrphanCredentials = false,
|
|
2851
|
+
log = console.log,
|
|
2852
|
+
fetchImpl = globalThis.fetch,
|
|
2853
|
+
readConfig = realInstaller.readGlobalConfig,
|
|
2854
|
+
} = {}) {
|
|
2855
|
+
const configuredHubUrl = String(readConfig()?.hub_url ?? PRODUCTION_HUB_URL).replace(/\/$/, '');
|
|
2856
|
+
const hubUrl = configuredHubUrl;
|
|
2857
|
+
const base = `${hubUrl}/_dsh/dsh-crew/providers`;
|
|
2858
|
+
const action = args[0] ?? 'list';
|
|
2859
|
+
const id = args[1];
|
|
2860
|
+
const options = new Map();
|
|
2861
|
+
for (let index = 2; index < args.length; index += 1) {
|
|
2862
|
+
const value = args[index];
|
|
2863
|
+
if (!value?.startsWith('--')) continue;
|
|
2864
|
+
const [name, inline] = value.split('=', 2);
|
|
2865
|
+
if (name === '--confirm' || name === '--purge-orphan-credentials') options.set(name === '--confirm' ? 'confirm' : name, true);
|
|
2866
|
+
else if (inline !== undefined) options.set(name, inline);
|
|
2867
|
+
else if (args[index + 1] !== undefined) options.set(name, args[++index]);
|
|
2868
|
+
}
|
|
2869
|
+
const resolvedPlan = planId ?? options.get('--plan');
|
|
2870
|
+
const resolvedRevision = expectedRevision ?? options.get('--expected-revision');
|
|
2871
|
+
const resolvedReplacement = replacementDefault ?? options.get('--replacement-default');
|
|
2872
|
+
const resolvedConfirm = confirm === true || options.get('confirm') === true;
|
|
2873
|
+
if (purgeOrphanCredentials === true || options.get('--purge-orphan-credentials') === true) {
|
|
2874
|
+
throw new Error('credential purge requires a separate explicit confirmation flow');
|
|
2875
|
+
}
|
|
2876
|
+
await assertProviderHubCapabilities({ hubUrl, action, fetchImpl });
|
|
2877
|
+
let url = base;
|
|
2878
|
+
let init = { headers: { accept: 'application/json' } };
|
|
2879
|
+
if (action === 'list') {
|
|
2880
|
+
// keep defaults
|
|
2881
|
+
} else if (action === 'migration-status') {
|
|
2882
|
+
url = `${base}/migration-status`;
|
|
2883
|
+
} else if (action === 'migrate-plan') {
|
|
2884
|
+
if (!id) throw new Error('providers migrate-plan requires a provider id');
|
|
2885
|
+
url = `${base}/${encodeURIComponent(id)}/migrate-plan`;
|
|
2886
|
+
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' };
|
|
2887
|
+
} else if (action === 'migrate') {
|
|
2888
|
+
if (!id) throw new Error('providers migrate requires a provider id');
|
|
2889
|
+
if (!resolvedPlan || !resolvedConfirm) throw new Error('providers migrate requires --plan and --confirm');
|
|
2890
|
+
url = `${base}/${encodeURIComponent(id)}/migrate`;
|
|
2891
|
+
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ plan_id: resolvedPlan, confirm: true }) };
|
|
2892
|
+
} else if (action === 'verify-migration') {
|
|
2893
|
+
if (!id) throw new Error('providers verify-migration requires a provider id');
|
|
2894
|
+
if (!resolvedPlan || !resolvedConfirm) throw new Error('providers verify-migration requires --plan and --confirm');
|
|
2895
|
+
url = `${base}/${encodeURIComponent(id)}/verify-migration`;
|
|
2896
|
+
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }) };
|
|
2897
|
+
} else if (action === 'rollback-migration') {
|
|
2898
|
+
if (!id) throw new Error('providers rollback-migration requires a provider id');
|
|
2899
|
+
if (!resolvedPlan || !resolvedConfirm) throw new Error('providers rollback-migration requires --plan and --confirm');
|
|
2900
|
+
url = `${base}/${encodeURIComponent(id)}/rollback-migration`;
|
|
2901
|
+
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }) };
|
|
2902
|
+
} else if (action === 'verify-rollback-migration') {
|
|
2903
|
+
if (!id) throw new Error('providers verify-rollback-migration requires a provider id');
|
|
2904
|
+
if (!resolvedPlan || !resolvedConfirm) throw new Error('providers verify-rollback-migration requires --plan and --confirm');
|
|
2905
|
+
url = `${base}/${encodeURIComponent(id)}/verify-rollback-migration`;
|
|
2906
|
+
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }) };
|
|
2907
|
+
} else if (action === 'delete-plan') {
|
|
2908
|
+
if (!id) throw new Error('providers delete-plan requires a provider id');
|
|
2909
|
+
url = `${base}/${encodeURIComponent(id)}/delete-plan`;
|
|
2910
|
+
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ ...(resolvedReplacement ? { replacement_default: resolvedReplacement } : {}) }) };
|
|
2911
|
+
} else if (action === 'delete') {
|
|
2912
|
+
if (!id) throw new Error('providers delete requires a provider id');
|
|
2913
|
+
if (!resolvedPlan || !resolvedRevision || !resolvedConfirm) throw new Error('providers delete requires --plan, --expected-revision and --confirm');
|
|
2914
|
+
url = `${base}/${encodeURIComponent(id)}`;
|
|
2915
|
+
init = {
|
|
2916
|
+
method: 'DELETE',
|
|
2917
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
2918
|
+
body: JSON.stringify({ plan_id: resolvedPlan, expected_revision: resolvedRevision, confirm: true }),
|
|
2919
|
+
};
|
|
2920
|
+
} else if (action === 'probe') {
|
|
2921
|
+
if (!id) throw new Error('providers probe requires a provider id');
|
|
2922
|
+
url = `${base}/${encodeURIComponent(id)}/probe`;
|
|
2923
|
+
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' };
|
|
2924
|
+
} else if (action === 'rollback') {
|
|
2925
|
+
if (!id) throw new Error('providers rollback requires a provider id');
|
|
2926
|
+
if (!resolvedPlan || !resolvedConfirm) throw new Error('providers rollback requires --plan and --confirm');
|
|
2927
|
+
url = `${base}/${encodeURIComponent(id)}/rollback`;
|
|
2928
|
+
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }) };
|
|
2929
|
+
} else {
|
|
2930
|
+
throw new Error(`unknown providers action: ${action}`);
|
|
2931
|
+
}
|
|
2932
|
+
const response = await fetchImpl(url, init);
|
|
2933
|
+
let body = await response.json();
|
|
2934
|
+
if (!response.ok || body?.ok === false) throw new Error(body?.code ?? body?.error ?? 'Crew provider API unavailable');
|
|
2935
|
+
if (action === 'delete' && body?.restart_required === true && body?.result?.state === 'RESTART_PENDING') {
|
|
2936
|
+
const restartBody = await requestCrewRuntimeRestart({ fetchImpl, reason: `providers ${action}` });
|
|
2937
|
+
if (restartBody?.ok !== true) throw new Error(restartBody?.error ?? restartBody?.code ?? 'Crew 3210 restart failed');
|
|
2938
|
+
const verifyUrl = `${base}/${encodeURIComponent(id)}/verify-delete`;
|
|
2939
|
+
const verifyResponse = await fetchImpl(verifyUrl, {
|
|
2940
|
+
method: 'POST',
|
|
2941
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
2942
|
+
body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }),
|
|
2943
|
+
});
|
|
2944
|
+
const verifyBody = await verifyResponse.json();
|
|
2945
|
+
if (!verifyResponse.ok || verifyBody?.ok !== true) throw new Error(verifyBody?.code ?? verifyBody?.error ?? 'Crew provider deletion verification failed');
|
|
2946
|
+
body = { ...body, restart: restartBody, verification: verifyBody };
|
|
2947
|
+
}
|
|
2948
|
+
if (action === 'migrate' && body?.restart_required === true && body?.result?.state === 'RESTART_PENDING') {
|
|
2949
|
+
const restartBody = await requestCrewRuntimeRestart({ fetchImpl, reason: `providers ${action}` });
|
|
2950
|
+
if (restartBody?.ok !== true) throw new Error(restartBody?.error ?? restartBody?.code ?? 'Crew 3210 restart failed');
|
|
2951
|
+
const verifyResponse = await fetchImpl(`${base}/${encodeURIComponent(id)}/verify-migration`, {
|
|
2952
|
+
method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }),
|
|
2953
|
+
});
|
|
2954
|
+
const verifyBody = await verifyResponse.json();
|
|
2955
|
+
if (!verifyResponse.ok || verifyBody?.ok !== true) throw new Error(verifyBody?.code ?? verifyBody?.error ?? 'Crew provider migration verification failed');
|
|
2956
|
+
body = { ...body, restart: restartBody, verification: verifyBody };
|
|
2957
|
+
}
|
|
2958
|
+
if (action === 'rollback-migration' && body?.restart_required === true && body?.state === 'ROLLBACK_RESTART_PENDING') {
|
|
2959
|
+
const restartBody = await requestCrewRuntimeRestart({ fetchImpl, reason: `providers ${action}` });
|
|
2960
|
+
if (restartBody?.ok !== true) throw new Error(restartBody?.error ?? restartBody?.code ?? 'Crew 3210 restart failed');
|
|
2961
|
+
const verifyResponse = await fetchImpl(`${base}/${encodeURIComponent(id)}/verify-rollback-migration`, {
|
|
2962
|
+
method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }),
|
|
2963
|
+
});
|
|
2964
|
+
const verifyBody = await verifyResponse.json();
|
|
2965
|
+
if (!verifyResponse.ok || verifyBody?.ok !== true) throw new Error(verifyBody?.code ?? verifyBody?.error ?? 'Crew provider migration rollback verification failed');
|
|
2966
|
+
body = { ...body, restart: restartBody, verification: verifyBody };
|
|
2967
|
+
}
|
|
2968
|
+
if (action === 'rollback' && body?.restart_required === true && body?.state === 'ROLLBACK_PENDING') {
|
|
2969
|
+
const restartBody = await requestCrewRuntimeRestart({ fetchImpl, reason: `providers ${action}` });
|
|
2970
|
+
if (restartBody?.ok !== true) throw new Error(restartBody?.error ?? restartBody?.code ?? 'Crew 3210 restart failed');
|
|
2971
|
+
const verifyUrl = `${base}/${encodeURIComponent(id)}/verify-rollback`;
|
|
2972
|
+
const verifyResponse = await fetchImpl(verifyUrl, {
|
|
2973
|
+
method: 'POST',
|
|
2974
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
2975
|
+
body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }),
|
|
2976
|
+
});
|
|
2977
|
+
const verifyBody = await verifyResponse.json();
|
|
2978
|
+
if (!verifyResponse.ok || verifyBody?.ok !== true) throw new Error(verifyBody?.code ?? verifyBody?.error ?? 'Crew provider rollback verification failed');
|
|
2979
|
+
body = { ...body, restart: restartBody, verification: verifyBody };
|
|
2980
|
+
}
|
|
2981
|
+
log(JSON.stringify(body, null, 2));
|
|
2982
|
+
return { ok: true, body };
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
/** Independent credential-reference inventory and irreversible purge CLI. */
|
|
2986
|
+
export async function npxCredentials({
|
|
2987
|
+
args = [],
|
|
2988
|
+
planId,
|
|
2989
|
+
expectedRevision,
|
|
2990
|
+
confirm = false,
|
|
2991
|
+
log = console.log,
|
|
2992
|
+
fetchImpl = globalThis.fetch,
|
|
2993
|
+
readConfig = realInstaller.readGlobalConfig,
|
|
2994
|
+
} = {}) {
|
|
2995
|
+
const hubUrl = String(readConfig()?.hub_url ?? PRODUCTION_HUB_URL).replace(/\/$/, '');
|
|
2996
|
+
const action = args[0] ?? 'list';
|
|
2997
|
+
const id = args[1];
|
|
2998
|
+
const requiredCapabilities = CREDENTIAL_CAPABILITY_REQUIREMENTS[action];
|
|
2999
|
+
if (!requiredCapabilities) throw new Error(`unknown credentials action: ${action}`);
|
|
3000
|
+
await assertProductionHub({ hubUrl, requiredCapabilities, purpose: 'credential', fetchImpl });
|
|
3001
|
+
const base = `${hubUrl}${CREW_ROUTE_BASE}/credential-references`;
|
|
3002
|
+
let url = base;
|
|
3003
|
+
let init = { headers: { accept: 'application/json' } };
|
|
3004
|
+
if (action === 'purge-plan') {
|
|
3005
|
+
if (!id) throw new Error('credentials purge-plan requires a reference id');
|
|
3006
|
+
url = `${base}/${encodeURIComponent(id)}/purge-plan`;
|
|
3007
|
+
init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ ...(expectedRevision ? { expected_revision: expectedRevision } : {}) }) };
|
|
3008
|
+
} else if (action === 'purge') {
|
|
3009
|
+
if (!id) throw new Error('credentials purge requires a reference id');
|
|
3010
|
+
if (!planId || !expectedRevision || confirm !== true) throw new Error('credentials purge requires --plan, --expected-revision and --confirm');
|
|
3011
|
+
url = `${base}/${encodeURIComponent(id)}`;
|
|
3012
|
+
init = { method: 'DELETE', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ plan_id: planId, expected_revision: expectedRevision, confirm: true }) };
|
|
3013
|
+
}
|
|
3014
|
+
const response = await fetchImpl(url, init);
|
|
3015
|
+
const body = await response.json();
|
|
3016
|
+
if (!response.ok || body?.ok === false) throw new Error(body?.code ?? body?.error ?? 'Crew credential API unavailable');
|
|
3017
|
+
log(JSON.stringify(body, null, 2));
|
|
3018
|
+
return { ok: true, body };
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
function normalizeCommand(argv) {
|
|
3022
|
+
const flags = argv.slice(1);
|
|
3023
|
+
let candidate;
|
|
3024
|
+
let after = 0;
|
|
3025
|
+
let detail = 'compact';
|
|
3026
|
+
let request;
|
|
3027
|
+
let planId;
|
|
3028
|
+
let expectedRevision;
|
|
3029
|
+
let replacementDefault;
|
|
3030
|
+
let confirm = false;
|
|
3031
|
+
let purgeOrphanCredentials = false;
|
|
1517
3032
|
for (let index = 0; index < flags.length; index += 1) {
|
|
1518
3033
|
if (flags[index] === '--candidate') {
|
|
1519
3034
|
candidate = flags[index + 1];
|
|
@@ -1535,34 +3050,34 @@ function normalizeCommand(argv) {
|
|
|
1535
3050
|
after = Number(flags[index].slice('--after='.length)); flags.splice(index, 1); index -= 1;
|
|
1536
3051
|
} else if (flags[index]?.startsWith('--detail=')) {
|
|
1537
3052
|
detail = flags[index].slice('--detail='.length); flags.splice(index, 1); index -= 1;
|
|
1538
|
-
} else if (flags[index]?.startsWith('--request=')) {
|
|
1539
|
-
request = flags[index].slice('--request='.length); flags.splice(index, 1); index -= 1;
|
|
1540
|
-
} else if (flags[index] === '--plan' || flags[index] === '--expected-revision' || flags[index] === '--replacement-default') {
|
|
1541
|
-
const name = flags[index];
|
|
1542
|
-
const value = flags[index + 1];
|
|
1543
|
-
if (name === '--plan') planId = value;
|
|
1544
|
-
if (name === '--expected-revision') expectedRevision = value;
|
|
1545
|
-
if (name === '--replacement-default') replacementDefault = value;
|
|
1546
|
-
flags.splice(index, 2); index -= 1;
|
|
1547
|
-
} else if (flags[index]?.startsWith('--plan=')) {
|
|
1548
|
-
planId = flags[index].slice('--plan='.length); flags.splice(index, 1); index -= 1;
|
|
1549
|
-
} else if (flags[index]?.startsWith('--expected-revision=')) {
|
|
1550
|
-
expectedRevision = flags[index].slice('--expected-revision='.length); flags.splice(index, 1); index -= 1;
|
|
1551
|
-
} else if (flags[index]?.startsWith('--replacement-default=')) {
|
|
1552
|
-
replacementDefault = flags[index].slice('--replacement-default='.length); flags.splice(index, 1); index -= 1;
|
|
1553
|
-
} else if (flags[index] === '--confirm') {
|
|
1554
|
-
confirm = true; flags.splice(index, 1); index -= 1;
|
|
1555
|
-
} else if (flags[index] === '--purge-orphan-credentials') {
|
|
1556
|
-
purgeOrphanCredentials = true; flags.splice(index, 1); index -= 1;
|
|
1557
|
-
}
|
|
3053
|
+
} else if (flags[index]?.startsWith('--request=')) {
|
|
3054
|
+
request = flags[index].slice('--request='.length); flags.splice(index, 1); index -= 1;
|
|
3055
|
+
} else if (flags[index] === '--plan' || flags[index] === '--expected-revision' || flags[index] === '--replacement-default') {
|
|
3056
|
+
const name = flags[index];
|
|
3057
|
+
const value = flags[index + 1];
|
|
3058
|
+
if (name === '--plan') planId = value;
|
|
3059
|
+
if (name === '--expected-revision') expectedRevision = value;
|
|
3060
|
+
if (name === '--replacement-default') replacementDefault = value;
|
|
3061
|
+
flags.splice(index, 2); index -= 1;
|
|
3062
|
+
} else if (flags[index]?.startsWith('--plan=')) {
|
|
3063
|
+
planId = flags[index].slice('--plan='.length); flags.splice(index, 1); index -= 1;
|
|
3064
|
+
} else if (flags[index]?.startsWith('--expected-revision=')) {
|
|
3065
|
+
expectedRevision = flags[index].slice('--expected-revision='.length); flags.splice(index, 1); index -= 1;
|
|
3066
|
+
} else if (flags[index]?.startsWith('--replacement-default=')) {
|
|
3067
|
+
replacementDefault = flags[index].slice('--replacement-default='.length); flags.splice(index, 1); index -= 1;
|
|
3068
|
+
} else if (flags[index] === '--confirm') {
|
|
3069
|
+
confirm = true; flags.splice(index, 1); index -= 1;
|
|
3070
|
+
} else if (flags[index] === '--purge-orphan-credentials') {
|
|
3071
|
+
purgeOrphanCredentials = true; flags.splice(index, 1); index -= 1;
|
|
3072
|
+
}
|
|
1558
3073
|
}
|
|
1559
3074
|
const knownFlags = new Set(['--purge']);
|
|
1560
3075
|
const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
|
|
1561
3076
|
const args = flags.filter((f) => !f.startsWith('--'));
|
|
1562
3077
|
if (!Number.isInteger(after) || after < 0) unknown.push('--after');
|
|
1563
3078
|
if (!['compact', 'full'].includes(detail)) unknown.push('--detail');
|
|
1564
|
-
return { command: argv[0], purge: flags.includes('--purge'), candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, args, unknown };
|
|
1565
|
-
}
|
|
3079
|
+
return { command: argv[0], purge: flags.includes('--purge'), candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, args, unknown };
|
|
3080
|
+
}
|
|
1566
3081
|
|
|
1567
3082
|
/**
|
|
1568
3083
|
* CLI dispatcher used by bin/dsh-crew.mjs. Returns a process exit code.
|
|
@@ -1573,7 +3088,7 @@ export async function runNpxCli({
|
|
|
1573
3088
|
error = console.error,
|
|
1574
3089
|
commands = {},
|
|
1575
3090
|
} = {}) {
|
|
1576
|
-
const { command, purge, candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, args, unknown } = normalizeCommand(argv);
|
|
3091
|
+
const { command, purge, candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, args, unknown } = normalizeCommand(argv);
|
|
1577
3092
|
if (command === '--help' || command === '-h' || command === 'help') {
|
|
1578
3093
|
log(USAGE);
|
|
1579
3094
|
return 0;
|
|
@@ -1582,7 +3097,7 @@ export async function runNpxCli({
|
|
|
1582
3097
|
error(USAGE);
|
|
1583
3098
|
return 1;
|
|
1584
3099
|
}
|
|
1585
|
-
if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'providers', 'credentials', 'releases', 'rollback', 'update', 'uninstall'].includes(command)) {
|
|
3100
|
+
if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'providers', 'credentials', 'releases', 'rollback', 'update', 'uninstall'].includes(command)) {
|
|
1586
3101
|
error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
|
|
1587
3102
|
return 1;
|
|
1588
3103
|
}
|
|
@@ -1592,23 +3107,23 @@ export async function runNpxCli({
|
|
|
1592
3107
|
integrate: commands.integrate ?? npxIntegrate,
|
|
1593
3108
|
detach: commands.detach ?? npxDetach,
|
|
1594
3109
|
status: commands.status ?? npxStatus,
|
|
1595
|
-
inspect: commands.inspect ?? npxInspect,
|
|
1596
|
-
jobs: commands.jobs ?? npxJobs,
|
|
1597
|
-
providers: commands.providers ?? npxProviders,
|
|
1598
|
-
credentials: commands.credentials ?? npxCredentials,
|
|
1599
|
-
releases: commands.releases ?? npxReleases,
|
|
1600
|
-
rollback: commands.rollback ?? npxRollback,
|
|
3110
|
+
inspect: commands.inspect ?? npxInspect,
|
|
3111
|
+
jobs: commands.jobs ?? npxJobs,
|
|
3112
|
+
providers: commands.providers ?? npxProviders,
|
|
3113
|
+
credentials: commands.credentials ?? npxCredentials,
|
|
3114
|
+
releases: commands.releases ?? npxReleases,
|
|
3115
|
+
rollback: commands.rollback ?? npxRollback,
|
|
1601
3116
|
update: commands.update ?? npxUpdate,
|
|
1602
3117
|
uninstall: commands.uninstall ?? npxUninstall,
|
|
1603
3118
|
};
|
|
1604
3119
|
let result;
|
|
1605
3120
|
if (command === 'uninstall') result = await actions.uninstall({ purge, log });
|
|
1606
|
-
else if (command === 'update') result = await actions.update({ candidate, log });
|
|
1607
|
-
else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
|
|
1608
|
-
else if (command === 'providers') result = await actions.providers({ args, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, log });
|
|
1609
|
-
else if (command === 'credentials') result = await actions.credentials({ args, planId, expectedRevision, confirm, log });
|
|
1610
|
-
else if (command === 'releases') result = await actions.releases({ args, log });
|
|
1611
|
-
else if (command === 'rollback') result = await actions.rollback({ version: args?.[0], args, log });
|
|
3121
|
+
else if (command === 'update') result = await actions.update({ candidate, log });
|
|
3122
|
+
else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
|
|
3123
|
+
else if (command === 'providers') result = await actions.providers({ args, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, log });
|
|
3124
|
+
else if (command === 'credentials') result = await actions.credentials({ args, planId, expectedRevision, confirm, log });
|
|
3125
|
+
else if (command === 'releases') result = await actions.releases({ args, log });
|
|
3126
|
+
else if (command === 'rollback') result = await actions.rollback({ version: args?.[0], args, log });
|
|
1612
3127
|
else result = await actions[command]({ log });
|
|
1613
3128
|
return result?.ok === false ? 1 : 0;
|
|
1614
3129
|
} catch (err) {
|