@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.
Files changed (40) hide show
  1. package/README.md +4 -0
  2. package/README.zh.md +4 -0
  3. package/codex/AGENTS.md +101 -92
  4. package/docs/ui-surfaces.md +48 -21
  5. package/lib/client.js +235 -34
  6. package/official-web-bridge/lib/client.js +368 -4532
  7. package/official-web-bridge/package.json +1 -2
  8. package/package.json +130 -67
  9. package/scripts/build-client.mjs +30 -15
  10. package/scripts/remove-legacy-official-bridge.ps1 +89 -0
  11. package/scripts/setup.mjs +152 -140
  12. package/scripts/verify-npm-install.mjs +311 -310
  13. package/scripts/verify-official-bridge-e2e.mjs +12 -1
  14. package/src/client/host-readiness.mjs +62 -59
  15. package/src/client/index.tsx +117 -24
  16. package/src/client/quick-entry.tsx +10 -0
  17. package/src/client/quick-panel.tsx +295 -0
  18. package/src/client/surface-detection.mjs +43 -30
  19. package/src/credential-reference.mjs +38 -0
  20. package/src/dsh-cli-runtime.mjs +500 -40
  21. package/src/dsh-cohort.mjs +20 -0
  22. package/src/hub/index.mjs +1602 -1016
  23. package/src/install/npx-lifecycle.mjs +1983 -468
  24. package/src/install/official-web.mjs +36 -73
  25. package/src/jobs.mjs +20 -24
  26. package/src/model-catalog.mjs +8 -1
  27. package/src/official-web-bridge.mjs +329 -249
  28. package/src/provider-delete-adapters.mjs +165 -17
  29. package/src/provider-inventory.mjs +17 -3
  30. package/src/provider-layer-migration-adapters.mjs +759 -0
  31. package/src/provider-layer-migration.mjs +198 -0
  32. package/src/provider-lifecycle-state.mjs +4 -1
  33. package/src/provider-profile-store.mjs +193 -4
  34. package/src/provider-settings-store.mjs +82 -6
  35. package/src/provider-store-lock.mjs +67 -0
  36. package/src/runtime-identity.mjs +24 -1
  37. package/src/supervisor/restart-request.mjs +202 -0
  38. package/windows/start-dsh-crew.ps1 +783 -370
  39. package/worker.cordis.yml +32 -46
  40. 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 null;
143
+ if (!existsSync(file)) return { status: 'absent', file };
131
144
  let raw;
132
- try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
133
- if (!raw || typeof raw !== 'object') return null;
134
- if (typeof raw.name !== 'string' || typeof raw.version !== 'string' || typeof raw.path !== 'string') return null;
135
- if (!isAbsolute(raw.path)) return null;
136
- return raw;
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
- writeFileSync(currentPointerFile({ home }), JSON.stringify(pointer, null, 2) + '\n');
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 commitStagedRelease({ stageDir, manifest, home }) {
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
- gcOldReleases({ home });
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
- async function restartOwnedRuntime(fetchImpl = globalThis.fetch) {
536
- const response = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
537
- method: 'POST',
538
- headers: { accept: 'application/json', 'content-type': 'application/json' },
539
- body: JSON.stringify({ confirm: true }),
540
- });
541
- const body = await response.json();
542
- return response.ok && body?.ok === true ? body : { ok: false, code: body?.code ?? 'CREW_3210_RESTART_FAILED' };
543
- }
544
-
545
- async function verifyRuntimeVersion(version, fetchImpl = globalThis.fetch) {
546
- const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/runtime', { headers: { accept: 'application/json' } });
547
- const body = await response.json();
548
- return response.ok && body?.ok === true && body.runtime_version === version
549
- ? { ok: true, runtime_version: body.runtime_version }
550
- : { ok: false, code: 'RUNTIME_VERSION_MISMATCH' };
551
- }
552
-
553
- export async function npxReleases({ home = homedir(), log = console.log } = {}) {
554
- const releases = listManagedReleases({ home });
555
- log(JSON.stringify(releases, null, 2));
556
- return { ok: true, releases };
557
- }
558
-
559
- export async function npxRollback({
560
- home = homedir(),
561
- version,
562
- log = console.log,
563
- installer = realInstaller,
564
- ensureRuntime,
565
- validatePayload = validateInstalledPayload,
566
- activate,
567
- restart = () => restartOwnedRuntime(),
568
- verifyRuntime = (targetVersion) => verifyRuntimeVersion(targetVersion),
569
- } = {}) {
570
- const targetVersion = typeof version === 'string' ? version.trim() : '';
571
- if (!targetVersion) return { ok: false, error: 'rollback requires a target version' };
572
- const current = readCurrentPointer({ home });
573
- if (!current?.path || !existsSync(current.path)) return { ok: false, error: 'no active Crew payload to roll back' };
574
- const target = listManagedReleases({ home }).find((release) => release.version === targetVersion);
575
- if (!target) return { ok: false, error: `retained release ${targetVersion} was not found` };
576
- if (target.path === current.path) return { ok: true, idempotent: true, version: target.version, path: target.path };
577
- const targetManifest = readManifest(target.path);
578
- const validation = validatePayload(target.path, { expectedName: targetManifest?.name, expectedVersion: targetManifest?.version });
579
- if (!validation.ok) return { ok: false, error: 'target release failed payload validation' };
580
- const previousManifest = readManifest(current.path);
581
- const activateReleaseFn = activate ?? (({ releaseDir, manifest }) => activateRelease({ home, releaseDir, manifest, log, installer, ensureRuntime }));
582
- const switchPointer = (release) => writeCurrentPointer({ home, name: release.name, version: release.version, path: release.path });
583
- try {
584
- switchPointer(target);
585
- if (!await activateReleaseFn({ releaseDir: target.path, manifest: targetManifest })) throw new Error('target release activation failed');
586
- const restarted = await restart(target.version);
587
- if (restarted?.ok === false) throw Object.assign(new Error('target runtime restart failed'), { code: restarted.code });
588
- const runtime = await verifyRuntime(target.version);
589
- if (runtime?.ok !== true) throw Object.assign(new Error('target runtime verification failed'), { code: runtime?.code ?? 'RUNTIME_VERSION_MISMATCH' });
590
- log(`✓ rolled back Crew payload to ${target.version}`);
591
- return { ok: true, rolled_back: true, version: target.version, path: target.path, restart: restarted, runtime };
592
- } catch (error) {
593
- const prior = { name: current.name, version: current.version, path: current.path };
594
- let recovery;
595
- try {
596
- switchPointer(prior);
597
- if (!previousManifest) throw Object.assign(new Error('previous release manifest unavailable'), { stage: 'activation' });
598
- const activated = await activateReleaseFn({ releaseDir: prior.path, manifest: previousManifest });
599
- if (activated !== true) throw Object.assign(new Error('previous release activation failed'), { stage: 'activation' });
600
- const restarted = await restart(prior.version);
601
- if (restarted?.ok !== true) throw Object.assign(new Error('previous runtime restart failed'), { stage: 'restart' });
602
- const runtime = await verifyRuntime(prior.version);
603
- if (runtime?.ok !== true) throw Object.assign(new Error('previous runtime verification failed'), { stage: 'verification' });
604
- const restoredPointer = readCurrentPointer({ home });
605
- if (restoredPointer?.path !== prior.path || restoredPointer.version !== prior.version) {
606
- throw Object.assign(new Error('previous release pointer was not restored'), { stage: 'pointer' });
607
- }
608
- recovery = { ok: true, version: prior.version, path: prior.path };
609
- } catch (recoveryError) {
610
- recovery = {
611
- ok: false,
612
- code: 'RELEASE_ROLLBACK_RECOVERY_FAILED',
613
- stage: ['activation', 'restart', 'verification', 'pointer'].includes(recoveryError?.stage) ? recoveryError.stage : 'unknown',
614
- };
615
- }
616
- return {
617
- ok: false,
618
- error: error?.message ?? 'release rollback failed',
619
- code: error?.code ?? 'RELEASE_ROLLBACK_FAILED',
620
- restored: recovery.ok === true,
621
- recovery,
622
- };
623
- }
624
- }
625
-
626
- function registrationLinkPath({ home, name }) {
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
- async function ensureRuntimeStep({ home, log, ensureRuntime }) {
653
- const ensure = ensureRuntime ?? ((opts) => {
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 (!r?.ok) {
662
- log(`✗ reusable Crew DSH runtime unavailable: ${r?.error ?? 'unknown error'}`);
663
- return false;
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.enabled) {
720
- const repaired = ensureOfficialWebIntegration({ home, releaseDir });
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
- const pointer = readCurrentPointer({ home });
732
- if (!pointer || !existsSync(pointer.path)) {
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
- const result = removeOfficialWebIntegration({ home });
755
- if (!result.ok) {
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
- const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
796
- log(`✓ durable release committed under Crew-owned state`);
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
- const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
799
- if (!activated) return { ok: false, error: 'activation failed' };
2168
+ beginReleaseActivation({ stageDir: staged.stageDir, manifest, home, prior });
2169
+ log(`✓ candidate payload staged (${manifest.version})`);
800
2170
 
801
- if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
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
- const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
1022
- log('✓ durable release committed under Crew-owned state');
1023
-
1024
- const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
1025
- if (!activated) return { ok: false, error: 'activation failed' };
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.enabled ? 'disabled' : official.healthy ? 'installed' : 'needs repair';
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 show Crew inside the official 3080 UI; backend stays isolated on 3210
1226
- detach remove only the official 3080 bridge; isolated 3210 mode remains available
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
- 'delete-plan': Object.freeze(['provider-inventory', 'provider-lifecycle-v1']),
1310
- delete: Object.freeze(['provider-inventory', 'provider-lifecycle-v1']),
1311
- rollback: Object.freeze(['provider-inventory', 'provider-lifecycle-v1']),
1312
- probe: Object.freeze(['provider-inventory', 'provider-health-v1', 'provider-probe-stream-v1']),
1313
- });
1314
- const JOB_CAPABILITY_REQUIREMENTS = Object.freeze({
1315
- list: Object.freeze(['jobs']),
1316
- get: Object.freeze(['jobs', 'jobs-wait']),
1317
- watch: Object.freeze(['jobs', 'jobs-wait']),
1318
- cancel: Object.freeze(['jobs', 'jobs-cancel']),
1319
- submit: Object.freeze(['jobs', 'roles', 'attempt-index', 'model-policy']),
1320
- });
1321
- const INSPECT_CAPABILITIES = Object.freeze(['extension-contract', 'evidence', 'runtime-provenance-v1']);
1322
- const CREDENTIAL_CAPABILITY_REQUIREMENTS = Object.freeze({
1323
- list: Object.freeze(['credential-reference-inventory-v1']),
1324
- 'purge-plan': Object.freeze(['credential-reference-inventory-v1', 'credential-purge-v1']),
1325
- purge: Object.freeze(['credential-reference-inventory-v1', 'credential-purge-v1']),
1326
- });
1327
- export const PRODUCTION_HUB_URL = 'http://127.0.0.1:3210';
1328
-
1329
- export async function assertProductionHub({ hubUrl, requiredCapabilities = [], purpose = 'command', fetchImpl = globalThis.fetch } = {}) {
1330
- if (hubUrl !== PRODUCTION_HUB_URL) throw new Error(`${purpose} requires the isolated 3210 Crew Hub`);
1331
- const response = await fetchImpl(`${hubUrl}${CREW_ROUTE_BASE}/runtime`, { headers: { accept: 'application/json' } });
1332
- let body;
1333
- try { body = await response.json(); } catch { body = null; }
1334
- const validIdentity = response.ok && body?.ok === true
1335
- && body?.service === 'dsh-crew-hub'
1336
- && body?.execution_plane === 'hub-3210'
1337
- && body?.profile === 'dsh-crew'
1338
- && Number(body?.listen_port) === 3210
1339
- && typeof body?.runtime_id === 'string' && body.runtime_id.trim().length > 0;
1340
- if (!validIdentity) throw new Error(`${purpose} requires a compatible isolated 3210 Crew Hub`);
1341
- const advertised = new Set(Array.isArray(body.capabilities) ? body.capabilities.filter((value) => typeof value === 'string') : []);
1342
- const missing = requiredCapabilities.filter((capability) => !advertised.has(capability));
1343
- if (missing.length > 0) throw new Error(`missing ${purpose} capability: ${missing.join(', ')}`);
1344
- return { ok: true, runtime: body, capabilities: [...advertised] };
1345
- }
1346
-
1347
- /**
1348
- * Confirm the target is the live isolated Hub and advertises the lifecycle
1349
- * surface before sending a provider request. This prevents a same-port stale
1350
- * Hub from turning a later 404 into an ambiguous destructive failure.
1351
- */
1352
- export async function assertProviderHubCapabilities({ hubUrl, action, fetchImpl = globalThis.fetch } = {}) {
1353
- return assertProductionHub({
1354
- hubUrl,
1355
- requiredCapabilities: PROVIDER_CAPABILITY_REQUIREMENTS[action] ?? PROVIDER_CAPABILITY_REQUIREMENTS.list,
1356
- purpose: 'provider lifecycle',
1357
- fetchImpl,
1358
- });
1359
- }
1360
-
1361
- /**
1362
- * Call the isolated 3210 provider lifecycle API. The CLI never parses or
1363
- * edits Harness YAML itself; the Hub remains the sole mutation authority.
1364
- */
1365
- export async function npxProviders({
1366
- args = [],
1367
- planId,
1368
- expectedRevision,
1369
- replacementDefault,
1370
- confirm = false,
1371
- purgeOrphanCredentials = false,
1372
- log = console.log,
1373
- fetchImpl = globalThis.fetch,
1374
- readConfig = realInstaller.readGlobalConfig,
1375
- } = {}) {
1376
- const configuredHubUrl = String(readConfig()?.hub_url ?? PRODUCTION_HUB_URL).replace(/\/$/, '');
1377
- const hubUrl = configuredHubUrl;
1378
- const base = `${hubUrl}/_dsh/dsh-crew/providers`;
1379
- const action = args[0] ?? 'list';
1380
- const id = args[1];
1381
- const options = new Map();
1382
- for (let index = 2; index < args.length; index += 1) {
1383
- const value = args[index];
1384
- if (!value?.startsWith('--')) continue;
1385
- const [name, inline] = value.split('=', 2);
1386
- if (name === '--confirm' || name === '--purge-orphan-credentials') options.set(name === '--confirm' ? 'confirm' : name, true);
1387
- else if (inline !== undefined) options.set(name, inline);
1388
- else if (args[index + 1] !== undefined) options.set(name, args[++index]);
1389
- }
1390
- const resolvedPlan = planId ?? options.get('--plan');
1391
- const resolvedRevision = expectedRevision ?? options.get('--expected-revision');
1392
- const resolvedReplacement = replacementDefault ?? options.get('--replacement-default');
1393
- const resolvedConfirm = confirm === true || options.get('confirm') === true;
1394
- if (purgeOrphanCredentials === true || options.get('--purge-orphan-credentials') === true) {
1395
- throw new Error('credential purge requires a separate explicit confirmation flow');
1396
- }
1397
- await assertProviderHubCapabilities({ hubUrl, action, fetchImpl });
1398
- let url = base;
1399
- let init = { headers: { accept: 'application/json' } };
1400
- if (action === 'list') {
1401
- // keep defaults
1402
- } else if (action === 'delete-plan') {
1403
- if (!id) throw new Error('providers delete-plan requires a provider id');
1404
- url = `${base}/${encodeURIComponent(id)}/delete-plan`;
1405
- init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ ...(resolvedReplacement ? { replacement_default: resolvedReplacement } : {}) }) };
1406
- } else if (action === 'delete') {
1407
- if (!id) throw new Error('providers delete requires a provider id');
1408
- if (!resolvedPlan || !resolvedRevision || !resolvedConfirm) throw new Error('providers delete requires --plan, --expected-revision and --confirm');
1409
- url = `${base}/${encodeURIComponent(id)}`;
1410
- init = {
1411
- method: 'DELETE',
1412
- headers: { accept: 'application/json', 'content-type': 'application/json' },
1413
- body: JSON.stringify({ plan_id: resolvedPlan, expected_revision: resolvedRevision, confirm: true }),
1414
- };
1415
- } else if (action === 'probe') {
1416
- if (!id) throw new Error('providers probe requires a provider id');
1417
- url = `${base}/${encodeURIComponent(id)}/probe`;
1418
- init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' };
1419
- } else if (action === 'rollback') {
1420
- if (!id) throw new Error('providers rollback requires a provider id');
1421
- if (!resolvedPlan || !resolvedConfirm) throw new Error('providers rollback requires --plan and --confirm');
1422
- url = `${base}/${encodeURIComponent(id)}/rollback`;
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(`unknown providers action: ${action}`);
1426
- }
1427
- const response = await fetchImpl(url, init);
1428
- let body = await response.json();
1429
- if (!response.ok || body?.ok === false) throw new Error(body?.code ?? body?.error ?? 'Crew provider API unavailable');
1430
- if (action === 'delete' && body?.restart_required === true && body?.result?.state === 'RESTART_PENDING') {
1431
- const restartResponse = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
1432
- method: 'POST',
1433
- headers: { accept: 'application/json', 'content-type': 'application/json' },
1434
- body: JSON.stringify({ confirm: true }),
1435
- });
1436
- const restartBody = await restartResponse.json();
1437
- if (!restartResponse.ok || restartBody?.ok !== true) throw new Error(restartBody?.code ?? restartBody?.error ?? 'Crew 3210 restart failed');
1438
- const verifyUrl = `${base}/${encodeURIComponent(id)}/verify-delete`;
1439
- const verifyResponse = await fetchImpl(verifyUrl, {
1440
- method: 'POST',
1441
- headers: { accept: 'application/json', 'content-type': 'application/json' },
1442
- body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }),
1443
- });
1444
- const verifyBody = await verifyResponse.json();
1445
- if (!verifyResponse.ok || verifyBody?.ok !== true) throw new Error(verifyBody?.code ?? verifyBody?.error ?? 'Crew provider deletion verification failed');
1446
- body = { ...body, restart: restartBody, verification: verifyBody };
1447
- }
1448
- if (action === 'rollback' && body?.restart_required === true && body?.state === 'ROLLBACK_PENDING') {
1449
- const restartResponse = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
1450
- method: 'POST',
1451
- headers: { accept: 'application/json', 'content-type': 'application/json' },
1452
- body: JSON.stringify({ confirm: true }),
1453
- });
1454
- const restartBody = await restartResponse.json();
1455
- if (!restartResponse.ok || restartBody?.ok !== true) throw new Error(restartBody?.code ?? restartBody?.error ?? 'Crew 3210 restart failed');
1456
- const verifyUrl = `${base}/${encodeURIComponent(id)}/verify-rollback`;
1457
- const verifyResponse = await fetchImpl(verifyUrl, {
1458
- method: 'POST',
1459
- headers: { accept: 'application/json', 'content-type': 'application/json' },
1460
- body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }),
1461
- });
1462
- const verifyBody = await verifyResponse.json();
1463
- if (!verifyResponse.ok || verifyBody?.ok !== true) throw new Error(verifyBody?.code ?? verifyBody?.error ?? 'Crew provider rollback verification failed');
1464
- body = { ...body, restart: restartBody, verification: verifyBody };
1465
- }
1466
- log(JSON.stringify(body, null, 2));
1467
- return { ok: true, body };
1468
- }
1469
-
1470
- /** Independent credential-reference inventory and irreversible purge CLI. */
1471
- export async function npxCredentials({
1472
- args = [],
1473
- planId,
1474
- expectedRevision,
1475
- confirm = false,
1476
- log = console.log,
1477
- fetchImpl = globalThis.fetch,
1478
- readConfig = realInstaller.readGlobalConfig,
1479
- } = {}) {
1480
- const hubUrl = String(readConfig()?.hub_url ?? PRODUCTION_HUB_URL).replace(/\/$/, '');
1481
- const action = args[0] ?? 'list';
1482
- const id = args[1];
1483
- const requiredCapabilities = CREDENTIAL_CAPABILITY_REQUIREMENTS[action];
1484
- if (!requiredCapabilities) throw new Error(`unknown credentials action: ${action}`);
1485
- await assertProductionHub({ hubUrl, requiredCapabilities, purpose: 'credential', fetchImpl });
1486
- const base = `${hubUrl}${CREW_ROUTE_BASE}/credential-references`;
1487
- let url = base;
1488
- let init = { headers: { accept: 'application/json' } };
1489
- if (action === 'purge-plan') {
1490
- if (!id) throw new Error('credentials purge-plan requires a reference id');
1491
- url = `${base}/${encodeURIComponent(id)}/purge-plan`;
1492
- init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ ...(expectedRevision ? { expected_revision: expectedRevision } : {}) }) };
1493
- } else if (action === 'purge') {
1494
- if (!id) throw new Error('credentials purge requires a reference id');
1495
- if (!planId || !expectedRevision || confirm !== true) throw new Error('credentials purge requires --plan, --expected-revision and --confirm');
1496
- url = `${base}/${encodeURIComponent(id)}`;
1497
- init = { method: 'DELETE', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ plan_id: planId, expected_revision: expectedRevision, confirm: true }) };
1498
- }
1499
- const response = await fetchImpl(url, init);
1500
- const body = await response.json();
1501
- if (!response.ok || body?.ok === false) throw new Error(body?.code ?? body?.error ?? 'Crew credential API unavailable');
1502
- log(JSON.stringify(body, null, 2));
1503
- return { ok: true, body };
1504
- }
1505
-
1506
- function normalizeCommand(argv) {
1507
- const flags = argv.slice(1);
1508
- let candidate;
1509
- let after = 0;
1510
- let detail = 'compact';
1511
- let request;
1512
- let planId;
1513
- let expectedRevision;
1514
- let replacementDefault;
1515
- let confirm = false;
1516
- let purgeOrphanCredentials = false;
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) {