@ran-sh/dsh-crew 0.3.6 → 0.3.7

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 (78) hide show
  1. package/.claude-plugin/plugin.json +8 -8
  2. package/.mcp.json +8 -8
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +125 -180
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +125 -180
  19. package/agents/ds-flash.md +26 -26
  20. package/agents/ds-pro.md +32 -32
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +22 -22
  23. package/codex/agents/ds-flash.toml +30 -30
  24. package/codex/agents/ds-pro.toml +31 -31
  25. package/codex/agents/ds-reviewer.toml +28 -28
  26. package/codex/agents/ds-worker.toml +28 -28
  27. package/codex/prompts/dsh-config.md +3 -3
  28. package/codex/prompts/dsh-status.md +1 -1
  29. package/commands/config.md +11 -11
  30. package/commands/off.md +5 -5
  31. package/commands/on.md +5 -5
  32. package/commands/status.md +5 -5
  33. package/cordis.patch.yml +4 -4
  34. package/lib/client.js +3446 -2765
  35. package/package.json +131 -131
  36. package/scripts/build-client.mjs +28 -28
  37. package/scripts/live-crew-smoke.mjs +39 -39
  38. package/scripts/live-policy-matrix.mjs +177 -177
  39. package/scripts/policy-probe.mjs +101 -101
  40. package/scripts/setup.mjs +284 -284
  41. package/scripts/smoke-real.mjs +110 -110
  42. package/scripts/smoke.mjs +78 -78
  43. package/scripts/verify-installer-fix.mjs +26 -26
  44. package/src/adaptive-routing.mjs +260 -260
  45. package/src/client/activation-summary.tsx +64 -64
  46. package/src/client/collapsible-sections.mjs +55 -0
  47. package/src/client/entry.tsx +236 -236
  48. package/src/client/index.tsx +1213 -1120
  49. package/src/config-readiness.mjs +59 -59
  50. package/src/delivery.mjs +205 -205
  51. package/src/dsh-cli-runtime.mjs +239 -239
  52. package/src/failure-classification.mjs +172 -172
  53. package/src/hub/entry.mjs +98 -98
  54. package/src/hub-client.mjs +132 -132
  55. package/src/hub-compatibility.mjs +49 -49
  56. package/src/i18n.mjs +19 -19
  57. package/src/install/cli.mjs +28 -28
  58. package/src/install/install-legacy.mjs +462 -462
  59. package/src/install/install.mjs +451 -451
  60. package/src/install/npx-lifecycle.mjs +1055 -1055
  61. package/src/mcp-runtime.mjs +257 -257
  62. package/src/model-catalog.mjs +173 -173
  63. package/src/model-routing.mjs +391 -391
  64. package/src/policy.mjs +197 -197
  65. package/src/readiness-matrix.mjs +169 -169
  66. package/src/runtime-controls.mjs +90 -90
  67. package/src/runtime-identity.mjs +108 -108
  68. package/src/server.mjs +477 -477
  69. package/src/status-shard.mjs +52 -52
  70. package/src/structured-error-code.mjs +38 -38
  71. package/src/vision-route.mjs +138 -138
  72. package/src/workflow-runtime.mjs +573 -573
  73. package/src/workflow.mjs +160 -160
  74. package/src/workspace-audit.mjs +231 -231
  75. package/src/workspace-isolation.mjs +365 -365
  76. package/statusline/statusline.sh +14 -14
  77. package/statusline/worker-segment.sh +35 -35
  78. package/worker.cordis.yml +77 -77
@@ -1,1055 +1,1055 @@
1
- // Packaged global-launcher lifecycle: install / status / update / uninstall.
2
- //
3
- // Public UX:
4
- // npm install -g @ran-sh/dsh-crew@latest
5
- // dsh-crew install|status|update|uninstall
6
- //
7
- // A package-manager launcher may run from a replaceable global/cache path.
8
- // This module therefore persists the already-built package payload into
9
- // Crew-owned state BEFORE registering it with the Harness profile, so the
10
- // registration never depends on the cache, tarball, or temp extraction dir:
11
- //
12
- // <home>/.config/dsh-crew/app/current.json active-release pointer
13
- // <home>/.config/dsh-crew/app/releases/<stamp>/ durable installed payloads
14
- //
15
- // Everything else (config.json, credentials, backups, the isolated Harness
16
- // home) is never touched by install/update; uninstall removes only the
17
- // Crew-managed payload above plus registrations/integrations, and keeps the
18
- // normal Crew config unless --purge is explicitly requested.
19
- //
20
- // Source checkouts keep using scripts/setup.mjs; this module is for the
21
- // packaged npm payload and never requires pnpm lockfiles, devDependencies,
22
- // or a client rebuild.
23
-
24
- import { spawnSync } from 'node:child_process';
25
- import {
26
- cpSync,
27
- existsSync,
28
- lstatSync,
29
- mkdirSync,
30
- readdirSync,
31
- readFileSync,
32
- realpathSync,
33
- rmSync,
34
- writeFileSync,
35
- } from 'node:fs';
36
- import { createRequire } from 'node:module';
37
- import { dirname, isAbsolute, join, resolve } from 'node:path';
38
- import { fileURLToPath } from 'node:url';
39
- import { homedir } from 'node:os';
40
- import * as realInstaller from './install.mjs';
41
- import { crewProfileDir } from './install.mjs';
42
- import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration } from '../dsh-cli-runtime.mjs';
43
-
44
- export const CREW_APP_DIRNAME = 'app';
45
- export const RELEASES_DIRNAME = 'releases';
46
- export const CURRENT_POINTER_FILENAME = 'current.json';
47
- export const KEEP_RELEASES = 2;
48
- export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
49
-
50
- const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
51
-
52
- export function crewAppRoot({ home = homedir() } = {}) {
53
- return join(home, '.config', 'dsh-crew', CREW_APP_DIRNAME);
54
- }
55
-
56
- export function crewReleasesDir({ home = homedir() } = {}) {
57
- return join(crewAppRoot({ home }), RELEASES_DIRNAME);
58
- }
59
-
60
- export function currentPointerFile({ home = homedir() } = {}) {
61
- return join(crewAppRoot({ home }), CURRENT_POINTER_FILENAME);
62
- }
63
-
64
- /** Package root of the currently executing (candidate) instance. */
65
- export function runningPackageRoot() {
66
- return PACKAGE_ROOT;
67
- }
68
-
69
- function readManifest(root) {
70
- try {
71
- return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
72
- } catch {
73
- return null;
74
- }
75
- }
76
-
77
- function timestampStamp(now = new Date()) {
78
- const pad = (n, w = 2) => String(n).padStart(w, '0');
79
- return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}T${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}Z`;
80
- }
81
-
82
- let releaseSalt = 0;
83
- function uniqueReleaseName(version) {
84
- // Second-resolution timestamps can collide across rapid sequential runs in
85
- // the same process; a process-local counter keeps release names unique so
86
- // an update never reuses (and thus never clobbers) an existing release.
87
- releaseSalt += 1;
88
- return `${timestampStamp()}-${process.pid}-${releaseSalt.toString(36)}-${version}`;
89
- }
90
-
91
- function isoNow() {
92
- const offsetMinutes = -new Date().getTimezoneOffset();
93
- const sign = offsetMinutes >= 0 ? '+' : '-';
94
- const abs = Math.abs(offsetMinutes);
95
- const pad = (n, w = 2) => String(n).padStart(w, '0');
96
- return `${new Date().getFullYear()}-${pad(new Date().getMonth() + 1)}-${pad(new Date().getDate())}T${pad(new Date().getHours())}:${pad(new Date().getMinutes())}:${pad(new Date().getSeconds())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
97
- }
98
-
99
- // ---- installed-payload pointer ----------------------------------------------
100
-
101
- /**
102
- * Read the active installed-release pointer. Returns null when no valid
103
- * pointer exists. The pointed-at directory must still exist with a matching
104
- * manifest to count as installed.
105
- */
106
- export function readCurrentPointer({ home = homedir() } = {}) {
107
- const file = currentPointerFile({ home });
108
- if (!existsSync(file)) return null;
109
- let raw;
110
- try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
111
- if (!raw || typeof raw !== 'object') return null;
112
- if (typeof raw.name !== 'string' || typeof raw.version !== 'string' || typeof raw.path !== 'string') return null;
113
- if (!isAbsolute(raw.path)) return null;
114
- return raw;
115
- }
116
-
117
- function writeCurrentPointer({ home, name, version, path }) {
118
- mkdirSync(dirname(currentPointerFile({ home })), { recursive: true });
119
- const pointer = { name, version, path, installed_at: isoNow(), managed_by: 'npx' };
120
- writeFileSync(currentPointerFile({ home }), JSON.stringify(pointer, null, 2) + '\n');
121
- return pointer;
122
- }
123
-
124
- // ---- dependency tree materialization ----------------------------------------
125
-
126
- function listPackageEdges(manifest) {
127
- // Returns [{name, optional}] covering dependencies and
128
- // required peerDependencies. Packages in the DSH cohort use peers for
129
- // shared protocol/runtime modules, but a persisted Crew payload has no host
130
- // node_modules to supply them, so their transitive peers are runtime edges.
131
- // Platform-specific optional bits and optional peers stay non-fatal.
132
- const edges = [];
133
- for (const [name] of Object.entries(manifest?.dependencies ?? {})) {
134
- edges.push({ name, optional: false });
135
- }
136
- for (const [name] of Object.entries(manifest?.optionalDependencies ?? {})) {
137
- if (!edges.some((edge) => edge.name === name)) edges.push({ name, optional: true });
138
- }
139
- for (const [name] of Object.entries(manifest?.peerDependencies ?? {})) {
140
- if (edges.some((edge) => edge.name === name)) continue;
141
- edges.push({ name, optional: manifest?.peerDependenciesMeta?.[name]?.optional === true });
142
- }
143
- return edges.filter((edge) => typeof edge.name === 'string' && edge.name.trim());
144
- }
145
-
146
- function findPackageDir(fromRoot, name, exists = existsSync) {
147
- // Node-style upward resolution: for each ancestor directory of fromRoot,
148
- // probe <ancestor>/node_modules/<name>. Covers npm's hoisted npx cache and
149
- // pnpm's virtual store (once a package is realpathed into .pnpm/<pkg>/
150
- // node_modules, walking up finds its exact sibling closure).
151
- const parts = name.split('/');
152
- const seen = new Set();
153
- let cursor = fromRoot;
154
- while (true) {
155
- const candidate = join(cursor, 'node_modules', ...parts);
156
- if (exists(candidate)) {
157
- try {
158
- const real = realpathSync(candidate);
159
- if (!seen.has(real)) { seen.add(real); return real; }
160
- } catch { /* skip unreadable entry */ }
161
- }
162
- const parent = dirname(cursor);
163
- if (parent === cursor) return null;
164
- cursor = parent;
165
- }
166
- }
167
-
168
- /**
169
- * Copy the dependency closure of `names` out of the running instance's
170
- * dependency tree into `<toRoot>/node_modules`. Offline-safe: it replicates
171
- * exactly the dependency bits the candidate just executed with. Optional
172
- * edges (e.g. cross-platform native binaries) are copied when present but
173
- * reported separately when absent, so npm can reconcile them per-platform.
174
- * Returns { copied: Map<name, realSource>, missing: string[], missingOptional: string[] }.
175
- */
176
- export function copyProductionDependencyTree({
177
- fromRoot,
178
- toRoot,
179
- names,
180
- exists = existsSync,
181
- copyDir = (src, dest) => cpSync(src, dest, { recursive: true, force: true, dereference: true }),
182
- } = {}) {
183
- const toNodeModules = join(toRoot, 'node_modules');
184
- mkdirSync(toNodeModules, { recursive: true });
185
- const copied = new Map();
186
- const missing = [];
187
- const missingOptional = [];
188
- const queue = names.map((entry) => (typeof entry === 'string'
189
- ? { name: entry, fromRoot, optional: false }
190
- : { ...entry, fromRoot }));
191
- while (queue.length > 0) {
192
- const { name, fromRoot: searchRoot, optional } = queue.shift();
193
- if (copied.has(name) || missing.includes(name) || missingOptional.includes(name)) continue;
194
- const source = findPackageDir(searchRoot ?? fromRoot, name, exists);
195
- if (!source) {
196
- if (optional) missingOptional.push(name);
197
- else missing.push(name);
198
- continue;
199
- }
200
- const dest = join(toNodeModules, ...name.split('/'));
201
- copyDir(source, dest);
202
- copied.set(name, source);
203
- const depManifest = readManifest(source);
204
- for (const edge of listPackageEdges(depManifest)) {
205
- if (!copied.has(edge.name) && !missing.includes(edge.name) && !missingOptional.includes(edge.name)) {
206
- // Resolve children relative to the copied package's own physical
207
- // location so nested/virtual-store layouts resolve correctly.
208
- queue.push({ name: edge.name, fromRoot: dirname(source), optional: edge.optional || optional });
209
- }
210
- }
211
- }
212
- return { copied, missing, missingOptional };
213
- }
214
-
215
- function defaultNpmInstaller(stageRoot, log) {
216
- const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
217
- const result = spawnSync(npm, [
218
- 'install', '--prefix', stageRoot,
219
- '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund',
220
- '--no-package-lock', '--loglevel=error',
221
- ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', timeout: 600_000, windowsHide: true, env: sanitizedPackageManagerEnv() });
222
- if (result.status !== 0) {
223
- log(`- npm fallback install failed (${result.status}): ${(result.stderr || result.stdout || '').trim().slice(-300)}`);
224
- return false;
225
- }
226
- return true;
227
- }
228
-
229
- // ---- payload staging ---------------------------------------------------------
230
-
231
- /**
232
- * Stage the candidate into its FINAL release directory, guarded by an
233
- * incompleteness marker. Committing = removing the marker (a single-file
234
- * operation that is reliable on all platforms); activation = writing the
235
- * pointer last. This avoids directory renames, which Windows can refuse with
236
- * transient EPERM while a freshly written tree is being scanned.
237
- *
238
- * The persisted manifest merges the exact-pinned DSH peer cohort into
239
- * `dependencies` (peer declarations are dropped): the installed payload runs
240
- * standalone — `src/server.mjs` statically imports DSH peers — so those
241
- * packages must exist inside the release, not be assumed from a host.
242
- */
243
- export function stageCandidatePayload({
244
- sourceRoot = runningPackageRoot(),
245
- home = homedir(),
246
- log = () => {},
247
- now = new Date(),
248
- npmInstaller = defaultNpmInstaller,
249
- copyTree = copyProductionDependencyTree,
250
- smoke = defaultPayloadSmoke,
251
- } = {}) {
252
- const manifest = readManifest(sourceRoot);
253
- if (!manifest?.name || !manifest?.version) return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID' };
254
- if (!existsSync(join(sourceRoot, 'src', 'server.mjs')) || !existsSync(join(sourceRoot, 'cordis.patch.yml'))) {
255
- return { ok: false, code: 'CANDIDATE_NOT_RUNNABLE' };
256
- }
257
-
258
- const releasesDir = crewReleasesDir({ home });
259
- mkdirSync(releasesDir, { recursive: true });
260
- const stageDir = join(releasesDir, uniqueReleaseName(manifest.version));
261
- rmSync(stageDir, { recursive: true, force: true });
262
- mkdirSync(stageDir, { recursive: true });
263
- writeFileSync(join(stageDir, INCOMPLETE_MARKER), String(Date.now()) + '\n');
264
-
265
- // Copy every top-level entry referenced by manifest.files (directories are
266
- // copied whole; single-segment wildcards match within their directory).
267
- const entries = new Set(['package.json']);
268
- for (const pattern of manifest.files ?? []) {
269
- if (typeof pattern !== 'string' || !pattern.trim()) continue;
270
- if (!pattern.includes('*')) { entries.add(pattern); continue; }
271
- const base = dirname(pattern);
272
- const regex = new RegExp(`^${pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')}$`);
273
- const scanDir = join(sourceRoot, base);
274
- if (!existsSync(scanDir)) continue;
275
- for (const item of readdirSync(scanDir)) {
276
- const candidatePath = base === '.' ? item : `${base.replace(/\\/g, '/')}/${item}`;
277
- if (regex.test(candidatePath)) entries.add(candidatePath);
278
- }
279
- }
280
-
281
- for (const entry of entries) {
282
- const source = join(sourceRoot, entry);
283
- if (!existsSync(source)) continue;
284
- cpSync(source, join(stageDir, entry), { recursive: true, force: true, dereference: true });
285
- }
286
-
287
- // Persist an adjusted manifest: identity/runtime fields stay; production
288
- // dependencies gain the exact-pinned peer cohort so the payload runs
289
- // standalone (src/server.mjs statically imports DSH peers). Optional
290
- // dependencies keep their own section so npm reconciles them per-platform.
291
- const stagedManifest = { ...manifest };
292
- delete stagedManifest.devDependencies;
293
- delete stagedManifest.peerDependencies;
294
- delete stagedManifest.peerDependenciesMeta;
295
- const optionalNames = new Set(Object.keys(manifest.optionalDependencies ?? {}));
296
- stagedManifest.dependencies = {
297
- ...(manifest.peerDependencies ?? {}),
298
- ...(manifest.dependencies ?? {}),
299
- };
300
- writeFileSync(join(stageDir, 'package.json'), JSON.stringify(stagedManifest, null, 2) + '\n');
301
-
302
- // Materialize dependencies: prefer replicating the exact bits the candidate
303
- // ran with (offline-safe); fall back to npm for the rest (under npx this is
304
- // normally the exact-pinned DSH peer cohort).
305
- const depEdges = [
306
- ...Object.keys(stagedManifest.dependencies).map((name) => ({ name, optional: false })),
307
- ...Object.keys(manifest.optionalDependencies ?? {}).map((name) => ({ name, optional: true })),
308
- ];
309
- const { missing, missingOptional } = copyTree({ fromRoot: sourceRoot, toRoot: stageDir, names: depEdges });
310
- if (missingOptional.length > 0) {
311
- log(`- platform-optional dependencies left to npm (${missingOptional.slice(0, 4).join(', ')}${missingOptional.length > 4 ? ', …' : ''})`);
312
- }
313
- if (missing.length > 0) {
314
- log(`- local dependency replication incomplete (${missing.join(', ')}); falling back to npm`);
315
- if (!npmInstaller(stageDir, log)) {
316
- rmSync(stageDir, { recursive: true, force: true });
317
- return { ok: false, code: 'DEPENDENCY_MATERIALIZE_FAILED', missing };
318
- }
319
- }
320
-
321
- const validated = validateInstalledPayload(stageDir, { expectedName: manifest.name, expectedVersion: manifest.version, allowIncomplete: true });
322
- if (!validated.ok) {
323
- rmSync(stageDir, { recursive: true, force: true });
324
- return { ok: false, code: 'STAGE_VALIDATION_FAILED', detail: validated.errors };
325
- }
326
- const smoked = smoke(stageDir);
327
- if (!smoked.ok) {
328
- rmSync(stageDir, { recursive: true, force: true });
329
- return { ok: false, code: 'STAGE_SMOKE_FAILED', detail: smoked.detail };
330
- }
331
- return { ok: true, stageDir, manifest, releasesDir };
332
- }
333
-
334
- /**
335
- * Boot-smoke a staged payload with the real Node binary: the CLI entry must
336
- * start and answer --help from inside the staged tree alone.
337
- */
338
- export function defaultPayloadSmoke(dir, { nodePath = process.execPath, runner = spawnSync } = {}) {
339
- const cli = runner(nodePath, [join(dir, 'bin', 'dsh-crew.mjs'), '--help'], {
340
- encoding: 'utf8', timeout: 120_000, windowsHide: true,
341
- env: { ...process.env },
342
- });
343
- if (cli.status !== 0) {
344
- return { ok: false, detail: `bin --help exited ${cli.status}: ${(cli.stderr || cli.stdout || '').trim().slice(-300)}` };
345
- }
346
-
347
- const initialize = {
348
- jsonrpc: '2.0',
349
- id: 1,
350
- method: 'initialize',
351
- params: {
352
- protocolVersion: '2024-11-05',
353
- capabilities: {},
354
- clientInfo: { name: 'dsh-crew-payload-smoke', version: '1.0.0' },
355
- },
356
- };
357
- const mcp = runner(nodePath, [join(dir, 'src', 'server.mjs')], {
358
- encoding: 'utf8', timeout: 120_000, windowsHide: true,
359
- input: JSON.stringify(initialize) + '\n',
360
- env: { ...process.env },
361
- });
362
- if (mcp.status !== 0) {
363
- return { ok: false, detail: `MCP initialize exited ${mcp.status}: ${(mcp.stderr || mcp.stdout || '').trim().slice(-300)}` };
364
- }
365
- let response;
366
- try {
367
- response = String(mcp.stdout ?? '').split(/\r?\n/).filter(Boolean)
368
- .map((line) => JSON.parse(line)).find((entry) => entry?.id === initialize.id);
369
- } catch { /* handled by the fail-closed response check below */ }
370
- if (response?.result?.serverInfo?.name !== 'dsh-crew') {
371
- return { ok: false, detail: `MCP initialize returned no valid dsh-crew response: ${String(mcp.stdout ?? '').trim().slice(-300)}` };
372
- }
373
- return { ok: true };
374
- }
375
-
376
- // Keyword boundaries reject identifiers containing the keywords
377
- // ('legacy-import') and member calls (.from); captures stay on one line and
378
- // bounded — real specifiers never span lines.
379
- const IMPORT_SPECIFIER_RE = /(?<![\w.\-])(?:from|import|require)\b\s*\(?\s*["']([^"'\n]{1,200})["']/g;
380
-
381
- function collectExternalSpecifiers(dir) {
382
- const specifiers = new Set();
383
- const walk = (root) => {
384
- if (!existsSync(root)) return;
385
- for (const item of readdirSync(root, { withFileTypes: true })) {
386
- const full = join(root, item.name);
387
- if (item.isDirectory()) walk(full);
388
- else if (/\.(?:mjs|js)$/.test(item.name)) {
389
- let content = '';
390
- try { content = readFileSync(full, 'utf8'); } catch { continue; }
391
- for (const match of content.matchAll(IMPORT_SPECIFIER_RE)) {
392
- specifiers.add(match[1]);
393
- }
394
- }
395
- }
396
- };
397
- walk(join(dir, 'src'));
398
- walk(join(dir, 'bin'));
399
- return [...specifiers].filter((spec) =>
400
- !spec.startsWith('.') && !spec.startsWith('/') && !spec.startsWith('node:') && !isAbsolute(spec));
401
- }
402
-
403
- /**
404
- * Prove a payload directory is runnable on its own: correct identity, all
405
- * shipped runtime artifacts present, every declared dependency vendored, and
406
- * every external module specifier the payload's source actually imports
407
- * resolvable FROM THE PERSISTED LOCATION (never from a cache or checkout).
408
- */
409
- export function validateInstalledPayload(dir, { expectedName, expectedVersion, allowIncomplete = false } = {}) {
410
- const errors = [];
411
- const manifest = readManifest(dir);
412
- if (!manifest) errors.push('package.json missing or invalid');
413
- else {
414
- if (expectedName && manifest.name !== expectedName) errors.push(`package name mismatch (${manifest.name})`);
415
- if (expectedVersion && manifest.version !== expectedVersion) errors.push(`package version mismatch (${manifest.version})`);
416
- for (const dep of Object.keys(manifest.dependencies ?? {})) {
417
- if (!existsSync(join(dir, 'node_modules', ...dep.split('/'), 'package.json'))) {
418
- errors.push(`vendored dependency missing from payload: ${dep}`);
419
- }
420
- }
421
- }
422
- if (manifest) {
423
- const requireFromPayload = createRequire(join(dir, 'package.json'));
424
- for (const spec of collectExternalSpecifiers(dir)) {
425
- try {
426
- requireFromPayload.resolve(spec);
427
- } catch {
428
- errors.push(`import target not resolvable from payload: ${spec}`);
429
- }
430
- }
431
- }
432
- for (const rel of ['cordis.patch.yml', 'src/server.mjs', 'src/hub/entry.mjs', 'lib/client.js', 'bin/dsh-crew.mjs']) {
433
- if (!existsSync(join(dir, rel))) errors.push(`payload artifact missing: ${rel}`);
434
- }
435
- if (!allowIncomplete && existsSync(join(dir, INCOMPLETE_MARKER))) errors.push('release is still marked incomplete');
436
- return { ok: errors.length === 0, errors, manifest };
437
- }
438
-
439
- // ---- release commit / activation ---------------------------------------------
440
-
441
- function commitStagedRelease({ stageDir, manifest, home }) {
442
- rmSync(join(stageDir, INCOMPLETE_MARKER));
443
- writeCurrentPointer({ home, name: manifest.name, version: manifest.version, path: stageDir });
444
- gcOldReleases({ home });
445
- return stageDir;
446
- }
447
-
448
- const STALE_INCOMPLETE_MS = 24 * 60 * 60 * 1000;
449
-
450
- function gcOldReleases({ home, keep = KEEP_RELEASES }) {
451
- const pointer = readCurrentPointer({ home });
452
- const releasesDir = crewReleasesDir({ home });
453
- if (!existsSync(releasesDir)) return;
454
- const removed = [];
455
- const dirs = readdirSync(releasesDir)
456
- .map((name) => join(releasesDir, name))
457
- .filter((dir) => !pointer || dir !== pointer.path);
458
- const incomplete = [];
459
- const complete = [];
460
- for (const dir of dirs) {
461
- if (existsSync(join(dir, INCOMPLETE_MARKER))) {
462
- // Never race a concurrent staging; only reap clearly abandoned ones.
463
- try {
464
- if (Date.now() - lstatSync(dir).mtimeMs > STALE_INCOMPLETE_MS) incomplete.push(dir);
465
- } catch { /* ignore */ }
466
- continue;
467
- }
468
- complete.push(dir);
469
- }
470
- complete.sort();
471
- while (complete.length > Math.max(0, keep - 1)) {
472
- const victim = complete.shift();
473
- try { rmSync(victim, { recursive: true, force: true }); removed.push(victim); } catch { /* best effort */ }
474
- }
475
- for (const victim of incomplete) {
476
- try { rmSync(victim, { recursive: true, force: true }); removed.push(victim); } catch { /* best effort */ }
477
- }
478
- return removed;
479
- }
480
-
481
- function registrationLinkPath({ home, name }) {
482
- return join(crewProfileDir({ home }), 'node_modules', ...name.split('/'));
483
- }
484
-
485
- function registrationHealthy({ home, name, releaseDir }) {
486
- const link = registrationLinkPath({ home, name });
487
- if (!existsSync(link)) return false;
488
- try { return realpathSync(link) === realpathSync(releaseDir); } catch { return false; }
489
- }
490
-
491
- /**
492
- * Environment for child package managers. Under `npm exec`/npx the process
493
- * inherits dozens of npm_config_* variables describing the transient exec
494
- * context; leaking them into pnpm/npm children can misdirect their stores and
495
- * config resolution. Package-manager children must see the user's real
496
- * environment, not our execution context.
497
- */
498
- export function sanitizedPackageManagerEnv(baseEnv = process.env) {
499
- const env = {};
500
- for (const [key, value] of Object.entries(baseEnv)) {
501
- if (/^npm_(config_|lifecycle|package_|execpath$|node_execpath$)/i.test(key)) continue;
502
- env[key] = value;
503
- }
504
- return env;
505
- }
506
-
507
- async function ensureRuntimeStep({ home, log, ensureRuntime }) {
508
- const ensure = ensureRuntime ?? ((opts) => {
509
- const r = ensureCrewDshRuntime({ ...opts, env: sanitizedPackageManagerEnv() });
510
- if (!r.ok && r.stderrTail) {
511
- log(` (runtime installer said: ${r.stderrTail})`);
512
- }
513
- return r.ok ? { ok: true, version: r.cli?.version ?? null } : { ok: false, error: r.error ?? r.code ?? 'runtime bootstrap failed' };
514
- });
515
- const r = await ensure({ home });
516
- if (!r?.ok) {
517
- log(`✗ reusable Crew DSH runtime unavailable: ${r?.error ?? 'unknown error'}`);
518
- return false;
519
- }
520
- log(`✓ reusable Crew DSH runtime${r.version ? ` (@${r.version})` : ''}`);
521
- return true;
522
- }
523
-
524
- // ---- commands -----------------------------------------------------------------
525
-
526
- function currentInstallationHealth({ home }) {
527
- const pointer = readCurrentPointer({ home });
528
- if (!pointer) return { installed: false, healthy: false };
529
- if (!existsSync(pointer.path)) return { installed: false, healthy: false, pointer };
530
- const validated = validateInstalledPayload(pointer.path, { expectedName: pointer.name, expectedVersion: pointer.version });
531
- const registered = registrationHealthy({ home, name: pointer.name, releaseDir: pointer.path });
532
- return { installed: true, healthy: validated.ok && registered, validated, registered, pointer };
533
- }
534
-
535
- async function activateRelease({ home, releaseDir, manifest, log, installer }) {
536
- const registration = ensureCrewPluginRegistration({ home, root: releaseDir, name: manifest.name });
537
- if (!registration.ok) {
538
- log(`✗ Harness registration failed (${registration.code ?? 'unknown'})`);
539
- return false;
540
- }
541
- log(`✓ Harness plugin registered (dedicated dsh-crew profile → ${releaseDir})`);
542
-
543
- const codex = installer.installCodex({ home, root: releaseDir });
544
- if (codex.ok === false) {
545
- log(`✗ Codex Desktop integration failed: ${(codex.actions ?? []).join('; ')}`);
546
- return false;
547
- }
548
- log('✓ Codex Desktop integration');
549
-
550
- const claude = await installer.installClaudeCode({ home, root: releaseDir });
551
- if (claude.ok === false) {
552
- log(`✗ Claude Code integration failed`);
553
- return false;
554
- }
555
- log('✓ Claude Code integration');
556
- return true;
557
- }
558
-
559
- export async function npxInstall({
560
- home = homedir(),
561
- log = console.log,
562
- sourceRoot,
563
- installer = realInstaller,
564
- ensureRuntime,
565
- npmInstaller,
566
- } = {}) {
567
- log('DSH Crew installer (npx-managed)');
568
- const candidateRoot = sourceRoot ?? runningPackageRoot();
569
- const manifest = readManifest(candidateRoot);
570
- if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
571
-
572
- const health = currentInstallationHealth({ home });
573
- if (health.installed && health.pointer.version === manifest.version && health.validated.ok) {
574
- // Same-version reinstall: repair activation surfaces without restaging.
575
- log(`- installed payload ${manifest.version} already present and valid; repairing activation`);
576
- const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest, log, installer });
577
- if (!activated) return { ok: false, error: 'activation failed' };
578
- if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
579
- log('');
580
- log('Done.');
581
- return { ok: true, repaired: true, version: manifest.version, path: health.pointer.path };
582
- }
583
-
584
- const staged = stageCandidatePayload({ sourceRoot: candidateRoot, home, log, npmInstaller });
585
- if (!staged.ok) {
586
- log(`✗ staging failed (${staged.code})${staged.detail ? `: ${staged.detail.join('; ')}` : ''}`);
587
- return { ok: false, error: `staging failed (${staged.code})` };
588
- }
589
- log(`✓ candidate payload staged (${manifest.version})`);
590
-
591
- const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
592
- log(`✓ durable release committed under Crew-owned state`);
593
-
594
- const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
595
- if (!activated) return { ok: false, error: 'activation failed' };
596
-
597
- if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
598
-
599
- log('');
600
- log('Done.');
601
- log('Restart DeepSeek Harness and Codex Desktop.');
602
- return { ok: true, version: manifest.version, path: releaseDir };
603
- }
604
-
605
- export const UPDATE_PACKAGE_NAME = '@ran-sh/dsh-crew';
606
- export const UPDATE_DEFAULT_SPEC = `${UPDATE_PACKAGE_NAME}@latest`;
607
-
608
- /**
609
- * Extract a packed npm tarball with the platform `tar` binary (bsdtar ships
610
- * with Windows 10+, macOS, and Linux) into destDir; npm tarballs always root
611
- * at `package/`.
612
- */
613
- export function extractPackageTarball(tgzPath, destDir, { runner = spawnSync } = {}) {
614
- mkdirSync(destDir, { recursive: true });
615
- const result = runner('tar', ['-xzf', String(tgzPath), '-C', destDir], {
616
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
617
- });
618
- const root = join(destDir, 'package');
619
- if (result.status !== 0 || !existsSync(join(root, 'package.json'))) {
620
- return { ok: false, detail: `tar extract failed (${result.status}): ${(result.stderr || result.stdout || '').trim().slice(-200)}` };
621
- }
622
- return { ok: true, sourceRoot: root };
623
- }
624
-
625
- /**
626
- * The globally installed launcher intentionally does not self-replace. When
627
- * the payload is newer, keep it authoritative and give the exact launcher
628
- * refresh command. A newer launcher is handled by npxUpdate before this point.
629
- */
630
- function noteLauncherDivergence({ log, home = homedir() }) {
631
- const launcherVersion = readManifest(runningPackageRoot())?.version ?? null;
632
- let installedVersion = null;
633
- try { installedVersion = readCurrentPointer({ home }).version ?? null; } catch { installedVersion = null; }
634
- if (launcherVersion && installedVersion && compareVersions(installedVersion, launcherVersion) > 0) {
635
- log('');
636
- log(`- note: managed payload ${installedVersion} is newer than the global launcher ${launcherVersion}; the payload remains authoritative.`);
637
- log(` Refresh the launcher when convenient: npm install -g ${UPDATE_PACKAGE_NAME}@${installedVersion}`);
638
- }
639
- }
640
-
641
- /**
642
- * Resolve an update candidate WITHOUT a source checkout:
643
- * - explicit candidate path: a payload directory or a packed .tgz file;
644
- * - otherwise npm registry mode: `npm pack <spec>` using the user's own
645
- * configured registry/auth (no policy or config mutation), extracted to a
646
- * disposable directory the caller cleans up via returned cleanup().
647
- */
648
- export function resolveUpdateCandidate({
649
- candidate,
650
- spec = UPDATE_DEFAULT_SPEC,
651
- home = homedir(),
652
- log = () => {},
653
- runner = spawnSync,
654
- } = {}) {
655
- const candidateValue = candidate ?? process.env.DSH_CREW_CANDIDATE ?? undefined;
656
- if (candidateValue !== undefined && typeof candidateValue !== 'string') {
657
- return { ok: false, code: 'INVALID_CANDIDATE', detail: 'candidate must be a directory or .tgz path' };
658
- }
659
- if (typeof candidateValue === 'string' && candidateValue.trim()) {
660
- const value = resolve(candidateValue.trim());
661
- if (!existsSync(value)) return { ok: false, code: 'CANDIDATE_NOT_FOUND', detail: value };
662
- if (existsSync(join(value, 'package.json'))) {
663
- const manifest = readManifest(value);
664
- if (!manifest?.name || !manifest?.version) return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: value };
665
- return { ok: true, sourceRoot: value, version: manifest.version, cleanup: null };
666
- }
667
- if (/\.tgz$/i.test(value)) {
668
- const tmpRoot = join(crewReleasesDir({ home }), `.candidate-${timestampStamp()}-${process.pid}`);
669
- const extracted = extractPackageTarball(value, tmpRoot, { runner });
670
- if (!extracted.ok) {
671
- rmSync(tmpRoot, { recursive: true, force: true });
672
- return { ok: false, code: 'CANDIDATE_EXTRACT_FAILED', detail: extracted.detail };
673
- }
674
- const manifest = readManifest(extracted.sourceRoot);
675
- if (!manifest?.name || !manifest?.version) {
676
- rmSync(tmpRoot, { recursive: true, force: true });
677
- return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: value };
678
- }
679
- return {
680
- ok: true,
681
- sourceRoot: extracted.sourceRoot,
682
- version: manifest.version,
683
- cleanup: () => rmSync(tmpRoot, { recursive: true, force: true }),
684
- };
685
- }
686
- return { ok: false, code: 'UNSUPPORTED_CANDIDATE', detail: value };
687
- }
688
-
689
- // Registry mode.
690
- const tmpDir = join(crewReleasesDir({ home }), `.candidate-${timestampStamp()}-${process.pid}`);
691
- mkdirSync(tmpDir, { recursive: true });
692
- try {
693
- const effectiveSpec = process.env.DSH_CREW_UPDATE_SPEC ?? spec;
694
- const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
695
- const packArgs = ['pack', effectiveSpec, '--pack-destination', tmpDir, '--json', '--loglevel=error'];
696
- log(`- resolving update candidate from the configured npm registry (${effectiveSpec})`);
697
- const packed = runner(npm, packArgs, {
698
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32',
699
- timeout: 600_000, windowsHide: true, env: sanitizedPackageManagerEnv(),
700
- });
701
- if (packed.status !== 0) {
702
- return { ok: false, code: 'REGISTRY_PACK_FAILED', detail: `${effectiveSpec}: ${(packed.stderr || packed.stdout || '').trim().slice(-300)}` };
703
- }
704
- let info;
705
- try { info = JSON.parse(packed.stdout)[0]; } catch {
706
- return { ok: false, code: 'REGISTRY_PACK_UNPARSEABLE', detail: String(packed.stdout).slice(-200) };
707
- }
708
- if (!info?.filename) return { ok: false, code: 'REGISTRY_PACK_UNPARSEABLE', detail: 'pack output missing filename' };
709
- const tgzPath = join(tmpDir, info.filename);
710
- const extracted = extractPackageTarball(tgzPath, join(tmpDir, 'x'), { runner });
711
- if (!extracted.ok) {
712
- return { ok: false, code: 'CANDIDATE_EXTRACT_FAILED', detail: extracted.detail };
713
- }
714
- const manifest = readManifest(extracted.sourceRoot);
715
- if (!manifest?.name || !manifest?.version) {
716
- return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: info.filename };
717
- }
718
- if (info.name !== manifest.name || info.version !== manifest.version) {
719
- return { ok: false, code: 'CANDIDATE_IDENTITY_MISMATCH', detail: `pack ${info.name}@${info.version} vs manifest ${manifest.name}@${manifest.version}` };
720
- }
721
- return {
722
- ok: true,
723
- sourceRoot: extracted.sourceRoot,
724
- version: manifest.version,
725
- cleanup: () => rmSync(tmpDir, { recursive: true, force: true }),
726
- };
727
- } catch (error) {
728
- rmSync(tmpDir, { recursive: true, force: true });
729
- return { ok: false, code: 'REGISTRY_PACK_FAILED', detail: String(error?.message ?? error).slice(-300) };
730
- }
731
- }
732
-
733
- export async function npxUpdate({
734
- home = homedir(),
735
- log = console.log,
736
- sourceRoot,
737
- candidate,
738
- spec,
739
- installer = realInstaller,
740
- ensureRuntime,
741
- npmInstaller,
742
- runner = spawnSync,
743
- } = {}) {
744
- log('DSH Crew updater');
745
- // Candidate resolution: explicit path/dir override > a newer validated
746
- // running launcher > configured npm registry (@latest). This makes the
747
- // supported legacy bootstrap (`npm install -g ...@latest`, then `update`)
748
- // independent of registry propagation after the launcher refresh.
749
- const explicitCandidate = candidate ?? sourceRoot;
750
- const initialHealth = currentInstallationHealth({ home });
751
- const launcherRoot = runningPackageRoot();
752
- const launcherManifest = readManifest(launcherRoot);
753
- const launcherCanConverge = explicitCandidate === undefined
754
- && initialHealth.pointer?.version
755
- && launcherManifest?.name === UPDATE_PACKAGE_NAME
756
- && launcherManifest?.version
757
- && compareVersions(launcherManifest.version, initialHealth.pointer.version) > 0;
758
- let resolved;
759
- if (launcherCanConverge) {
760
- log(`- newer launcher ${launcherManifest.version}; converging managed payload ${initialHealth.pointer.version} before registry resolution`);
761
- resolved = { ok: true, sourceRoot: launcherRoot, version: launcherManifest.version, cleanup: null };
762
- } else {
763
- resolved = resolveUpdateCandidate({ candidate: explicitCandidate, spec, home, log, runner });
764
- }
765
- if (!resolved.ok) {
766
- log(`✗ candidate resolution failed (${resolved.code})${resolved.detail ? `: ${resolved.detail}` : ''}`);
767
- return { ok: false, error: `candidate resolution failed (${resolved.code})` };
768
- }
769
- try {
770
- const manifest = readManifest(resolved.sourceRoot);
771
- if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
772
-
773
- const health = currentInstallationHealth({ home });
774
-
775
- if (health.installed && health.healthy && health.pointer.version === manifest.version) {
776
- log(`- already current (${manifest.version}); repairing registration/integrations idempotently`);
777
- const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest, log, installer });
778
- if (!activated) return { ok: false, error: 'activation failed' };
779
- if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
780
- log('');
781
- log('Done.');
782
- return { ok: true, idempotent: true, version: manifest.version, path: health.pointer.path };
783
- }
784
-
785
- if (health.installed && health.healthy && compareVersions(manifest.version, health.pointer.version) < 0) {
786
- const source = explicitCandidate === undefined && !launcherCanConverge ? 'registry latest' : 'candidate';
787
- log(`- ${source} (${manifest.version}) is not newer than the installed payload (${health.pointer.version}); nothing to update`);
788
- const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest: readManifest(health.pointer.path), log, installer });
789
- if (!activated) return { ok: false, error: 'activation failed' };
790
- return { ok: true, idempotent: true, version: health.pointer.version, path: health.pointer.path };
791
- }
792
-
793
- // Stale, unhealthy, older, or missing installation: stage the candidate
794
- // fully and validate it before switching. The previous usable release is
795
- // left in place until the replacement has been committed and activated.
796
- if (health.installed && !health.healthy) {
797
- log('- existing installation is stale or incomplete; repairing via fresh candidate staging');
798
- } else if (health.installed) {
799
- log(`- updating managed payload ${health.pointer.version} -> ${manifest.version}`);
800
- }
801
-
802
- const staged = stageCandidatePayload({ sourceRoot: resolved.sourceRoot, home, log, npmInstaller });
803
- if (!staged.ok) {
804
- log(`✗ staging failed (${staged.code})${staged.detail ? `: ${staged.detail.join('; ')}` : ''}`);
805
- return { ok: false, error: `staging failed (${staged.code})` };
806
- }
807
- log(`✓ candidate payload staged and validated (${manifest.version})`);
808
-
809
- const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
810
- log('✓ durable release committed under Crew-owned state');
811
-
812
- const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
813
- if (!activated) return { ok: false, error: 'activation failed' };
814
-
815
- if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
816
-
817
- noteLauncherDivergence({ log, home });
818
- log('');
819
- log('Done.');
820
- log('Restart DeepSeek Harness and Codex Desktop.');
821
- return { ok: true, updated: true, version: manifest.version, path: releaseDir };
822
- } finally {
823
- resolved.cleanup?.();
824
- }
825
- }
826
-
827
- /**
828
- * Compare dotted numeric versions; returns -1/0/1. Non-numeric segments fall
829
- * back to string comparison so prerelease identifiers stay deterministic.
830
- */
831
- export function compareVersions(left, right) {
832
- const asParts = (value) => String(value ?? '').split('.');
833
- const a = asParts(left);
834
- const b = asParts(right);
835
- for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
836
- const x = a[index] ?? '0';
837
- const y = b[index] ?? '0';
838
- const xn = /^\d+$/.test(x);
839
- const yn = /^\d+$/.test(y);
840
- if (xn && yn) {
841
- const diff = Number(x) - Number(y);
842
- if (diff !== 0) return diff < 0 ? -1 : 1;
843
- } else if (xn !== yn) {
844
- // npm semantics: a numeric release segment outranks a prerelease segment.
845
- return xn ? 1 : -1;
846
- } else if (x !== y) {
847
- return x < y ? -1 : 1;
848
- }
849
- }
850
- return 0;
851
- }
852
-
853
- export function npxStatus({
854
- home = homedir(),
855
- log = console.log,
856
- sourceRoot,
857
- installer = realInstaller,
858
- } = {}) {
859
- const candidateRoot = sourceRoot ?? runningPackageRoot();
860
- const manifest = readManifest(candidateRoot);
861
- const candidateVersion = manifest?.version ?? null;
862
-
863
- const pointer = readCurrentPointer({ home });
864
- let installedLine = 'not installed';
865
- let installedVersion = null;
866
- if (pointer) {
867
- if (existsSync(pointer.path)) {
868
- const validated = validateInstalledPayload(pointer.path, { expectedName: pointer.name, expectedVersion: pointer.version });
869
- if (validated.ok) {
870
- installedVersion = pointer.version;
871
- installedLine = `${pointer.version} (${pointer.path})`;
872
- } else {
873
- installedVersion = pointer.version;
874
- installedLine = `${pointer.version} at ${pointer.path} (unverifiable/damaged)`;
875
- }
876
- } else {
877
- installedLine = `pointer references missing payload (${pointer.path})`;
878
- }
879
- }
880
-
881
- // Read-only DSH plugin state from the dedicated Crew profile only; the
882
- // official web profile layout is intentionally never inspected.
883
- let dshPlugin = 'not installed';
884
- const profilePkgFile = join(crewProfileDir({ home }), 'package.json');
885
- const packageName = manifest?.name ?? pointer?.name ?? null;
886
- if (packageName && existsSync(profilePkgFile)) {
887
- try {
888
- const pkg = JSON.parse(readFileSync(profilePkgFile, 'utf8'));
889
- const listed = Boolean(pkg.dependencies?.[packageName]) || Boolean(pkg.dsh?.profile?.bundles?.includes?.(packageName));
890
- const linkOk = packageName && existsSync(registrationLinkPath({ home, name: packageName }));
891
- dshPlugin = listed && linkOk ? 'installed' : listed ? 'registered but payload link missing' : 'not installed';
892
- } catch { dshPlugin = 'unknown'; }
893
- }
894
-
895
- const st = installer.installStatus ? installer.installStatus({ home }) : realInstaller.installStatus({ home });
896
- const codex = st?.codex?.installed ? 'installed' : 'not installed';
897
- const claude = st?.claude?.installed ? 'installed' : 'not installed';
898
-
899
- log(`DSH Crew launcher/candidate: ${candidateVersion ?? 'unknown'}`);
900
- log(`Installed DSH Crew payload: ${installedLine}`);
901
- if (candidateVersion && installedVersion && candidateVersion !== installedVersion) {
902
- const direction = compareVersions(candidateVersion, installedVersion);
903
- if (direction > 0) {
904
- log(`- launcher ${candidateVersion} is newer than the managed payload ${installedVersion}.`);
905
- log(' Run: dsh-crew update');
906
- } else {
907
- log(`- managed payload ${installedVersion} is newer than the launcher ${candidateVersion}; the payload remains authoritative.`);
908
- log(` Refresh the launcher with: npm install -g ${UPDATE_PACKAGE_NAME}@${installedVersion}`);
909
- }
910
- }
911
- log(`DSH plugin: ${dshPlugin} (dedicated dsh-crew profile; official web profile ignored)`);
912
- log(`Codex Desktop integration: ${codex}`);
913
- log(`Claude Code integration: ${claude}`);
914
-
915
- return {
916
- ok: true,
917
- candidateVersion,
918
- installedVersion,
919
- installedPath: pointer?.path ?? null,
920
- dshPlugin,
921
- codex,
922
- claude,
923
- };
924
- }
925
-
926
- export async function npxUninstall({
927
- home = homedir(),
928
- purge = false,
929
- log = console.log,
930
- installer = realInstaller,
931
- } = {}) {
932
- log('DSH Crew uninstaller (npx-managed)');
933
- const failures = [];
934
- const fail = (text) => { log(`✗ ${text}`); failures.push(text); };
935
-
936
- const pointer = readCurrentPointer({ home });
937
- const name = pointer?.name ?? readManifest(runningPackageRoot())?.name;
938
-
939
- const cx = installer.uninstallCodex({ home });
940
- if (cx.ok !== false) log('✓ Codex Desktop integration removed');
941
- else fail('Codex Desktop integration removal failed');
942
-
943
- const cl = installer.uninstallClaudeCode ? await installer.uninstallClaudeCode({ home }) : realInstaller.uninstallClaudeCode({ home });
944
- if (cl.ok !== false) log('✓ Claude Code integration removed');
945
- else fail('Claude Code integration removal failed');
946
-
947
- if (name) {
948
- const removed = removeCrewPluginRegistration({ home, name });
949
- if (!removed.ok) fail(`Harness registration removal failed (${removed.code ?? 'unknown'})`);
950
- else log(removed.removed ? '✓ Harness plugin registration removed (offline, Crew-owned state)' : '- Harness plugin registration already absent');
951
- } else {
952
- fail('installed package name unknown; registration not removed');
953
- }
954
-
955
- if (purge) {
956
- try {
957
- rmSync(join(home, '.config', 'dsh-crew'), { recursive: true, force: true });
958
- log('✓ ~/.config/dsh-crew purged');
959
- } catch { fail('could not purge ~/.config/dsh-crew'); }
960
- } else {
961
- // Remove ONLY the Crew-managed installed payload; config, credentials,
962
- // backups, and the isolated Harness home stay untouched.
963
- try {
964
- rmSync(crewAppRoot({ home }), { recursive: true, force: true });
965
- log('✓ Crew-managed installed payload removed (config/backups kept)');
966
- } catch { fail('could not remove the Crew-managed installed payload'); }
967
- }
968
-
969
- if (failures.length > 0) {
970
- log('');
971
- log('FAILED: uninstall incomplete');
972
- return { ok: false, failures };
973
- }
974
- log('');
975
- log('Done.');
976
- return { ok: true, failures: [] };
977
- }
978
-
979
- // ---- CLI dispatch --------------------------------------------------------------
980
-
981
- export const USAGE = `usage: dsh-crew <command> [--purge] [--candidate <path>]
982
-
983
- Commands:
984
- install persist the candidate package into Crew-owned state and register it
985
- status read-only report of launcher/installed versions and integrations
986
- update resolve the newest permitted package from the configured npm registry (or
987
- --candidate), stage and validate it, then activate; idempotent when current
988
- uninstall remove the Crew-managed payload, registration, and integrations (config kept)
989
-
990
- Options:
991
- --candidate <path> update from a local payload directory or packed .tgz instead of the registry
992
- --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
993
- --help show this help
994
-
995
- Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
996
- Source checkouts use scripts/setup.mjs instead.`;
997
-
998
- function normalizeCommand(argv) {
999
- const flags = argv.slice(1);
1000
- let candidate;
1001
- for (let index = 0; index < flags.length; index += 1) {
1002
- if (flags[index] === '--candidate') {
1003
- candidate = flags[index + 1];
1004
- flags.splice(index, 2);
1005
- index -= 1;
1006
- } else if (flags[index]?.startsWith('--candidate=')) {
1007
- candidate = flags[index].slice('--candidate='.length);
1008
- flags.splice(index, 1);
1009
- index -= 1;
1010
- }
1011
- }
1012
- const knownFlags = new Set(['--purge']);
1013
- const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
1014
- return { command: argv[0], purge: flags.includes('--purge'), candidate, unknown };
1015
- }
1016
-
1017
- /**
1018
- * CLI dispatcher used by bin/dsh-crew.mjs. Returns a process exit code.
1019
- */
1020
- export async function runNpxCli({
1021
- argv = process.argv.slice(2),
1022
- log = console.log,
1023
- error = console.error,
1024
- commands = {},
1025
- } = {}) {
1026
- const { command, purge, candidate, unknown } = normalizeCommand(argv);
1027
- if (command === '--help' || command === '-h' || command === 'help') {
1028
- log(USAGE);
1029
- return 0;
1030
- }
1031
- if (!command) {
1032
- error(USAGE);
1033
- return 1;
1034
- }
1035
- if (unknown.length > 0 || !['install', 'status', 'update', 'uninstall'].includes(command)) {
1036
- error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
1037
- return 1;
1038
- }
1039
- try {
1040
- const actions = {
1041
- install: commands.install ?? npxInstall,
1042
- status: commands.status ?? npxStatus,
1043
- update: commands.update ?? npxUpdate,
1044
- uninstall: commands.uninstall ?? npxUninstall,
1045
- };
1046
- let result;
1047
- if (command === 'uninstall') result = await actions.uninstall({ purge, log });
1048
- else if (command === 'update') result = await actions.update({ candidate, log });
1049
- else result = await actions[command]({ log });
1050
- return result?.ok === false ? 1 : 0;
1051
- } catch (err) {
1052
- error(`dsh-crew ${command} failed: ${err?.message ?? err}`);
1053
- return 1;
1054
- }
1055
- }
1
+ // Packaged global-launcher lifecycle: install / status / update / uninstall.
2
+ //
3
+ // Public UX:
4
+ // npm install -g @ran-sh/dsh-crew@latest
5
+ // dsh-crew install|status|update|uninstall
6
+ //
7
+ // A package-manager launcher may run from a replaceable global/cache path.
8
+ // This module therefore persists the already-built package payload into
9
+ // Crew-owned state BEFORE registering it with the Harness profile, so the
10
+ // registration never depends on the cache, tarball, or temp extraction dir:
11
+ //
12
+ // <home>/.config/dsh-crew/app/current.json active-release pointer
13
+ // <home>/.config/dsh-crew/app/releases/<stamp>/ durable installed payloads
14
+ //
15
+ // Everything else (config.json, credentials, backups, the isolated Harness
16
+ // home) is never touched by install/update; uninstall removes only the
17
+ // Crew-managed payload above plus registrations/integrations, and keeps the
18
+ // normal Crew config unless --purge is explicitly requested.
19
+ //
20
+ // Source checkouts keep using scripts/setup.mjs; this module is for the
21
+ // packaged npm payload and never requires pnpm lockfiles, devDependencies,
22
+ // or a client rebuild.
23
+
24
+ import { spawnSync } from 'node:child_process';
25
+ import {
26
+ cpSync,
27
+ existsSync,
28
+ lstatSync,
29
+ mkdirSync,
30
+ readdirSync,
31
+ readFileSync,
32
+ realpathSync,
33
+ rmSync,
34
+ writeFileSync,
35
+ } from 'node:fs';
36
+ import { createRequire } from 'node:module';
37
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+ import { homedir } from 'node:os';
40
+ import * as realInstaller from './install.mjs';
41
+ import { crewProfileDir } from './install.mjs';
42
+ import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration } from '../dsh-cli-runtime.mjs';
43
+
44
+ export const CREW_APP_DIRNAME = 'app';
45
+ export const RELEASES_DIRNAME = 'releases';
46
+ export const CURRENT_POINTER_FILENAME = 'current.json';
47
+ export const KEEP_RELEASES = 2;
48
+ export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
49
+
50
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
51
+
52
+ export function crewAppRoot({ home = homedir() } = {}) {
53
+ return join(home, '.config', 'dsh-crew', CREW_APP_DIRNAME);
54
+ }
55
+
56
+ export function crewReleasesDir({ home = homedir() } = {}) {
57
+ return join(crewAppRoot({ home }), RELEASES_DIRNAME);
58
+ }
59
+
60
+ export function currentPointerFile({ home = homedir() } = {}) {
61
+ return join(crewAppRoot({ home }), CURRENT_POINTER_FILENAME);
62
+ }
63
+
64
+ /** Package root of the currently executing (candidate) instance. */
65
+ export function runningPackageRoot() {
66
+ return PACKAGE_ROOT;
67
+ }
68
+
69
+ function readManifest(root) {
70
+ try {
71
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function timestampStamp(now = new Date()) {
78
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
79
+ return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}T${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}Z`;
80
+ }
81
+
82
+ let releaseSalt = 0;
83
+ function uniqueReleaseName(version) {
84
+ // Second-resolution timestamps can collide across rapid sequential runs in
85
+ // the same process; a process-local counter keeps release names unique so
86
+ // an update never reuses (and thus never clobbers) an existing release.
87
+ releaseSalt += 1;
88
+ return `${timestampStamp()}-${process.pid}-${releaseSalt.toString(36)}-${version}`;
89
+ }
90
+
91
+ function isoNow() {
92
+ const offsetMinutes = -new Date().getTimezoneOffset();
93
+ const sign = offsetMinutes >= 0 ? '+' : '-';
94
+ const abs = Math.abs(offsetMinutes);
95
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
96
+ return `${new Date().getFullYear()}-${pad(new Date().getMonth() + 1)}-${pad(new Date().getDate())}T${pad(new Date().getHours())}:${pad(new Date().getMinutes())}:${pad(new Date().getSeconds())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
97
+ }
98
+
99
+ // ---- installed-payload pointer ----------------------------------------------
100
+
101
+ /**
102
+ * Read the active installed-release pointer. Returns null when no valid
103
+ * pointer exists. The pointed-at directory must still exist with a matching
104
+ * manifest to count as installed.
105
+ */
106
+ export function readCurrentPointer({ home = homedir() } = {}) {
107
+ const file = currentPointerFile({ home });
108
+ if (!existsSync(file)) return null;
109
+ let raw;
110
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
111
+ if (!raw || typeof raw !== 'object') return null;
112
+ if (typeof raw.name !== 'string' || typeof raw.version !== 'string' || typeof raw.path !== 'string') return null;
113
+ if (!isAbsolute(raw.path)) return null;
114
+ return raw;
115
+ }
116
+
117
+ function writeCurrentPointer({ home, name, version, path }) {
118
+ mkdirSync(dirname(currentPointerFile({ home })), { recursive: true });
119
+ const pointer = { name, version, path, installed_at: isoNow(), managed_by: 'npx' };
120
+ writeFileSync(currentPointerFile({ home }), JSON.stringify(pointer, null, 2) + '\n');
121
+ return pointer;
122
+ }
123
+
124
+ // ---- dependency tree materialization ----------------------------------------
125
+
126
+ function listPackageEdges(manifest) {
127
+ // Returns [{name, optional}] covering dependencies and
128
+ // required peerDependencies. Packages in the DSH cohort use peers for
129
+ // shared protocol/runtime modules, but a persisted Crew payload has no host
130
+ // node_modules to supply them, so their transitive peers are runtime edges.
131
+ // Platform-specific optional bits and optional peers stay non-fatal.
132
+ const edges = [];
133
+ for (const [name] of Object.entries(manifest?.dependencies ?? {})) {
134
+ edges.push({ name, optional: false });
135
+ }
136
+ for (const [name] of Object.entries(manifest?.optionalDependencies ?? {})) {
137
+ if (!edges.some((edge) => edge.name === name)) edges.push({ name, optional: true });
138
+ }
139
+ for (const [name] of Object.entries(manifest?.peerDependencies ?? {})) {
140
+ if (edges.some((edge) => edge.name === name)) continue;
141
+ edges.push({ name, optional: manifest?.peerDependenciesMeta?.[name]?.optional === true });
142
+ }
143
+ return edges.filter((edge) => typeof edge.name === 'string' && edge.name.trim());
144
+ }
145
+
146
+ function findPackageDir(fromRoot, name, exists = existsSync) {
147
+ // Node-style upward resolution: for each ancestor directory of fromRoot,
148
+ // probe <ancestor>/node_modules/<name>. Covers npm's hoisted npx cache and
149
+ // pnpm's virtual store (once a package is realpathed into .pnpm/<pkg>/
150
+ // node_modules, walking up finds its exact sibling closure).
151
+ const parts = name.split('/');
152
+ const seen = new Set();
153
+ let cursor = fromRoot;
154
+ while (true) {
155
+ const candidate = join(cursor, 'node_modules', ...parts);
156
+ if (exists(candidate)) {
157
+ try {
158
+ const real = realpathSync(candidate);
159
+ if (!seen.has(real)) { seen.add(real); return real; }
160
+ } catch { /* skip unreadable entry */ }
161
+ }
162
+ const parent = dirname(cursor);
163
+ if (parent === cursor) return null;
164
+ cursor = parent;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Copy the dependency closure of `names` out of the running instance's
170
+ * dependency tree into `<toRoot>/node_modules`. Offline-safe: it replicates
171
+ * exactly the dependency bits the candidate just executed with. Optional
172
+ * edges (e.g. cross-platform native binaries) are copied when present but
173
+ * reported separately when absent, so npm can reconcile them per-platform.
174
+ * Returns { copied: Map<name, realSource>, missing: string[], missingOptional: string[] }.
175
+ */
176
+ export function copyProductionDependencyTree({
177
+ fromRoot,
178
+ toRoot,
179
+ names,
180
+ exists = existsSync,
181
+ copyDir = (src, dest) => cpSync(src, dest, { recursive: true, force: true, dereference: true }),
182
+ } = {}) {
183
+ const toNodeModules = join(toRoot, 'node_modules');
184
+ mkdirSync(toNodeModules, { recursive: true });
185
+ const copied = new Map();
186
+ const missing = [];
187
+ const missingOptional = [];
188
+ const queue = names.map((entry) => (typeof entry === 'string'
189
+ ? { name: entry, fromRoot, optional: false }
190
+ : { ...entry, fromRoot }));
191
+ while (queue.length > 0) {
192
+ const { name, fromRoot: searchRoot, optional } = queue.shift();
193
+ if (copied.has(name) || missing.includes(name) || missingOptional.includes(name)) continue;
194
+ const source = findPackageDir(searchRoot ?? fromRoot, name, exists);
195
+ if (!source) {
196
+ if (optional) missingOptional.push(name);
197
+ else missing.push(name);
198
+ continue;
199
+ }
200
+ const dest = join(toNodeModules, ...name.split('/'));
201
+ copyDir(source, dest);
202
+ copied.set(name, source);
203
+ const depManifest = readManifest(source);
204
+ for (const edge of listPackageEdges(depManifest)) {
205
+ if (!copied.has(edge.name) && !missing.includes(edge.name) && !missingOptional.includes(edge.name)) {
206
+ // Resolve children relative to the copied package's own physical
207
+ // location so nested/virtual-store layouts resolve correctly.
208
+ queue.push({ name: edge.name, fromRoot: dirname(source), optional: edge.optional || optional });
209
+ }
210
+ }
211
+ }
212
+ return { copied, missing, missingOptional };
213
+ }
214
+
215
+ function defaultNpmInstaller(stageRoot, log) {
216
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
217
+ const result = spawnSync(npm, [
218
+ 'install', '--prefix', stageRoot,
219
+ '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund',
220
+ '--no-package-lock', '--loglevel=error',
221
+ ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', timeout: 600_000, windowsHide: true, env: sanitizedPackageManagerEnv() });
222
+ if (result.status !== 0) {
223
+ log(`- npm fallback install failed (${result.status}): ${(result.stderr || result.stdout || '').trim().slice(-300)}`);
224
+ return false;
225
+ }
226
+ return true;
227
+ }
228
+
229
+ // ---- payload staging ---------------------------------------------------------
230
+
231
+ /**
232
+ * Stage the candidate into its FINAL release directory, guarded by an
233
+ * incompleteness marker. Committing = removing the marker (a single-file
234
+ * operation that is reliable on all platforms); activation = writing the
235
+ * pointer last. This avoids directory renames, which Windows can refuse with
236
+ * transient EPERM while a freshly written tree is being scanned.
237
+ *
238
+ * The persisted manifest merges the exact-pinned DSH peer cohort into
239
+ * `dependencies` (peer declarations are dropped): the installed payload runs
240
+ * standalone — `src/server.mjs` statically imports DSH peers — so those
241
+ * packages must exist inside the release, not be assumed from a host.
242
+ */
243
+ export function stageCandidatePayload({
244
+ sourceRoot = runningPackageRoot(),
245
+ home = homedir(),
246
+ log = () => {},
247
+ now = new Date(),
248
+ npmInstaller = defaultNpmInstaller,
249
+ copyTree = copyProductionDependencyTree,
250
+ smoke = defaultPayloadSmoke,
251
+ } = {}) {
252
+ const manifest = readManifest(sourceRoot);
253
+ if (!manifest?.name || !manifest?.version) return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID' };
254
+ if (!existsSync(join(sourceRoot, 'src', 'server.mjs')) || !existsSync(join(sourceRoot, 'cordis.patch.yml'))) {
255
+ return { ok: false, code: 'CANDIDATE_NOT_RUNNABLE' };
256
+ }
257
+
258
+ const releasesDir = crewReleasesDir({ home });
259
+ mkdirSync(releasesDir, { recursive: true });
260
+ const stageDir = join(releasesDir, uniqueReleaseName(manifest.version));
261
+ rmSync(stageDir, { recursive: true, force: true });
262
+ mkdirSync(stageDir, { recursive: true });
263
+ writeFileSync(join(stageDir, INCOMPLETE_MARKER), String(Date.now()) + '\n');
264
+
265
+ // Copy every top-level entry referenced by manifest.files (directories are
266
+ // copied whole; single-segment wildcards match within their directory).
267
+ const entries = new Set(['package.json']);
268
+ for (const pattern of manifest.files ?? []) {
269
+ if (typeof pattern !== 'string' || !pattern.trim()) continue;
270
+ if (!pattern.includes('*')) { entries.add(pattern); continue; }
271
+ const base = dirname(pattern);
272
+ const regex = new RegExp(`^${pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')}$`);
273
+ const scanDir = join(sourceRoot, base);
274
+ if (!existsSync(scanDir)) continue;
275
+ for (const item of readdirSync(scanDir)) {
276
+ const candidatePath = base === '.' ? item : `${base.replace(/\\/g, '/')}/${item}`;
277
+ if (regex.test(candidatePath)) entries.add(candidatePath);
278
+ }
279
+ }
280
+
281
+ for (const entry of entries) {
282
+ const source = join(sourceRoot, entry);
283
+ if (!existsSync(source)) continue;
284
+ cpSync(source, join(stageDir, entry), { recursive: true, force: true, dereference: true });
285
+ }
286
+
287
+ // Persist an adjusted manifest: identity/runtime fields stay; production
288
+ // dependencies gain the exact-pinned peer cohort so the payload runs
289
+ // standalone (src/server.mjs statically imports DSH peers). Optional
290
+ // dependencies keep their own section so npm reconciles them per-platform.
291
+ const stagedManifest = { ...manifest };
292
+ delete stagedManifest.devDependencies;
293
+ delete stagedManifest.peerDependencies;
294
+ delete stagedManifest.peerDependenciesMeta;
295
+ const optionalNames = new Set(Object.keys(manifest.optionalDependencies ?? {}));
296
+ stagedManifest.dependencies = {
297
+ ...(manifest.peerDependencies ?? {}),
298
+ ...(manifest.dependencies ?? {}),
299
+ };
300
+ writeFileSync(join(stageDir, 'package.json'), JSON.stringify(stagedManifest, null, 2) + '\n');
301
+
302
+ // Materialize dependencies: prefer replicating the exact bits the candidate
303
+ // ran with (offline-safe); fall back to npm for the rest (under npx this is
304
+ // normally the exact-pinned DSH peer cohort).
305
+ const depEdges = [
306
+ ...Object.keys(stagedManifest.dependencies).map((name) => ({ name, optional: false })),
307
+ ...Object.keys(manifest.optionalDependencies ?? {}).map((name) => ({ name, optional: true })),
308
+ ];
309
+ const { missing, missingOptional } = copyTree({ fromRoot: sourceRoot, toRoot: stageDir, names: depEdges });
310
+ if (missingOptional.length > 0) {
311
+ log(`- platform-optional dependencies left to npm (${missingOptional.slice(0, 4).join(', ')}${missingOptional.length > 4 ? ', …' : ''})`);
312
+ }
313
+ if (missing.length > 0) {
314
+ log(`- local dependency replication incomplete (${missing.join(', ')}); falling back to npm`);
315
+ if (!npmInstaller(stageDir, log)) {
316
+ rmSync(stageDir, { recursive: true, force: true });
317
+ return { ok: false, code: 'DEPENDENCY_MATERIALIZE_FAILED', missing };
318
+ }
319
+ }
320
+
321
+ const validated = validateInstalledPayload(stageDir, { expectedName: manifest.name, expectedVersion: manifest.version, allowIncomplete: true });
322
+ if (!validated.ok) {
323
+ rmSync(stageDir, { recursive: true, force: true });
324
+ return { ok: false, code: 'STAGE_VALIDATION_FAILED', detail: validated.errors };
325
+ }
326
+ const smoked = smoke(stageDir);
327
+ if (!smoked.ok) {
328
+ rmSync(stageDir, { recursive: true, force: true });
329
+ return { ok: false, code: 'STAGE_SMOKE_FAILED', detail: smoked.detail };
330
+ }
331
+ return { ok: true, stageDir, manifest, releasesDir };
332
+ }
333
+
334
+ /**
335
+ * Boot-smoke a staged payload with the real Node binary: the CLI entry must
336
+ * start and answer --help from inside the staged tree alone.
337
+ */
338
+ export function defaultPayloadSmoke(dir, { nodePath = process.execPath, runner = spawnSync } = {}) {
339
+ const cli = runner(nodePath, [join(dir, 'bin', 'dsh-crew.mjs'), '--help'], {
340
+ encoding: 'utf8', timeout: 120_000, windowsHide: true,
341
+ env: { ...process.env },
342
+ });
343
+ if (cli.status !== 0) {
344
+ return { ok: false, detail: `bin --help exited ${cli.status}: ${(cli.stderr || cli.stdout || '').trim().slice(-300)}` };
345
+ }
346
+
347
+ const initialize = {
348
+ jsonrpc: '2.0',
349
+ id: 1,
350
+ method: 'initialize',
351
+ params: {
352
+ protocolVersion: '2024-11-05',
353
+ capabilities: {},
354
+ clientInfo: { name: 'dsh-crew-payload-smoke', version: '1.0.0' },
355
+ },
356
+ };
357
+ const mcp = runner(nodePath, [join(dir, 'src', 'server.mjs')], {
358
+ encoding: 'utf8', timeout: 120_000, windowsHide: true,
359
+ input: JSON.stringify(initialize) + '\n',
360
+ env: { ...process.env },
361
+ });
362
+ if (mcp.status !== 0) {
363
+ return { ok: false, detail: `MCP initialize exited ${mcp.status}: ${(mcp.stderr || mcp.stdout || '').trim().slice(-300)}` };
364
+ }
365
+ let response;
366
+ try {
367
+ response = String(mcp.stdout ?? '').split(/\r?\n/).filter(Boolean)
368
+ .map((line) => JSON.parse(line)).find((entry) => entry?.id === initialize.id);
369
+ } catch { /* handled by the fail-closed response check below */ }
370
+ if (response?.result?.serverInfo?.name !== 'dsh-crew') {
371
+ return { ok: false, detail: `MCP initialize returned no valid dsh-crew response: ${String(mcp.stdout ?? '').trim().slice(-300)}` };
372
+ }
373
+ return { ok: true };
374
+ }
375
+
376
+ // Keyword boundaries reject identifiers containing the keywords
377
+ // ('legacy-import') and member calls (.from); captures stay on one line and
378
+ // bounded — real specifiers never span lines.
379
+ const IMPORT_SPECIFIER_RE = /(?<![\w.\-])(?:from|import|require)\b\s*\(?\s*["']([^"'\n]{1,200})["']/g;
380
+
381
+ function collectExternalSpecifiers(dir) {
382
+ const specifiers = new Set();
383
+ const walk = (root) => {
384
+ if (!existsSync(root)) return;
385
+ for (const item of readdirSync(root, { withFileTypes: true })) {
386
+ const full = join(root, item.name);
387
+ if (item.isDirectory()) walk(full);
388
+ else if (/\.(?:mjs|js)$/.test(item.name)) {
389
+ let content = '';
390
+ try { content = readFileSync(full, 'utf8'); } catch { continue; }
391
+ for (const match of content.matchAll(IMPORT_SPECIFIER_RE)) {
392
+ specifiers.add(match[1]);
393
+ }
394
+ }
395
+ }
396
+ };
397
+ walk(join(dir, 'src'));
398
+ walk(join(dir, 'bin'));
399
+ return [...specifiers].filter((spec) =>
400
+ !spec.startsWith('.') && !spec.startsWith('/') && !spec.startsWith('node:') && !isAbsolute(spec));
401
+ }
402
+
403
+ /**
404
+ * Prove a payload directory is runnable on its own: correct identity, all
405
+ * shipped runtime artifacts present, every declared dependency vendored, and
406
+ * every external module specifier the payload's source actually imports
407
+ * resolvable FROM THE PERSISTED LOCATION (never from a cache or checkout).
408
+ */
409
+ export function validateInstalledPayload(dir, { expectedName, expectedVersion, allowIncomplete = false } = {}) {
410
+ const errors = [];
411
+ const manifest = readManifest(dir);
412
+ if (!manifest) errors.push('package.json missing or invalid');
413
+ else {
414
+ if (expectedName && manifest.name !== expectedName) errors.push(`package name mismatch (${manifest.name})`);
415
+ if (expectedVersion && manifest.version !== expectedVersion) errors.push(`package version mismatch (${manifest.version})`);
416
+ for (const dep of Object.keys(manifest.dependencies ?? {})) {
417
+ if (!existsSync(join(dir, 'node_modules', ...dep.split('/'), 'package.json'))) {
418
+ errors.push(`vendored dependency missing from payload: ${dep}`);
419
+ }
420
+ }
421
+ }
422
+ if (manifest) {
423
+ const requireFromPayload = createRequire(join(dir, 'package.json'));
424
+ for (const spec of collectExternalSpecifiers(dir)) {
425
+ try {
426
+ requireFromPayload.resolve(spec);
427
+ } catch {
428
+ errors.push(`import target not resolvable from payload: ${spec}`);
429
+ }
430
+ }
431
+ }
432
+ for (const rel of ['cordis.patch.yml', 'src/server.mjs', 'src/hub/entry.mjs', 'lib/client.js', 'bin/dsh-crew.mjs']) {
433
+ if (!existsSync(join(dir, rel))) errors.push(`payload artifact missing: ${rel}`);
434
+ }
435
+ if (!allowIncomplete && existsSync(join(dir, INCOMPLETE_MARKER))) errors.push('release is still marked incomplete');
436
+ return { ok: errors.length === 0, errors, manifest };
437
+ }
438
+
439
+ // ---- release commit / activation ---------------------------------------------
440
+
441
+ function commitStagedRelease({ stageDir, manifest, home }) {
442
+ rmSync(join(stageDir, INCOMPLETE_MARKER));
443
+ writeCurrentPointer({ home, name: manifest.name, version: manifest.version, path: stageDir });
444
+ gcOldReleases({ home });
445
+ return stageDir;
446
+ }
447
+
448
+ const STALE_INCOMPLETE_MS = 24 * 60 * 60 * 1000;
449
+
450
+ function gcOldReleases({ home, keep = KEEP_RELEASES }) {
451
+ const pointer = readCurrentPointer({ home });
452
+ const releasesDir = crewReleasesDir({ home });
453
+ if (!existsSync(releasesDir)) return;
454
+ const removed = [];
455
+ const dirs = readdirSync(releasesDir)
456
+ .map((name) => join(releasesDir, name))
457
+ .filter((dir) => !pointer || dir !== pointer.path);
458
+ const incomplete = [];
459
+ const complete = [];
460
+ for (const dir of dirs) {
461
+ if (existsSync(join(dir, INCOMPLETE_MARKER))) {
462
+ // Never race a concurrent staging; only reap clearly abandoned ones.
463
+ try {
464
+ if (Date.now() - lstatSync(dir).mtimeMs > STALE_INCOMPLETE_MS) incomplete.push(dir);
465
+ } catch { /* ignore */ }
466
+ continue;
467
+ }
468
+ complete.push(dir);
469
+ }
470
+ complete.sort();
471
+ while (complete.length > Math.max(0, keep - 1)) {
472
+ const victim = complete.shift();
473
+ try { rmSync(victim, { recursive: true, force: true }); removed.push(victim); } catch { /* best effort */ }
474
+ }
475
+ for (const victim of incomplete) {
476
+ try { rmSync(victim, { recursive: true, force: true }); removed.push(victim); } catch { /* best effort */ }
477
+ }
478
+ return removed;
479
+ }
480
+
481
+ function registrationLinkPath({ home, name }) {
482
+ return join(crewProfileDir({ home }), 'node_modules', ...name.split('/'));
483
+ }
484
+
485
+ function registrationHealthy({ home, name, releaseDir }) {
486
+ const link = registrationLinkPath({ home, name });
487
+ if (!existsSync(link)) return false;
488
+ try { return realpathSync(link) === realpathSync(releaseDir); } catch { return false; }
489
+ }
490
+
491
+ /**
492
+ * Environment for child package managers. Under `npm exec`/npx the process
493
+ * inherits dozens of npm_config_* variables describing the transient exec
494
+ * context; leaking them into pnpm/npm children can misdirect their stores and
495
+ * config resolution. Package-manager children must see the user's real
496
+ * environment, not our execution context.
497
+ */
498
+ export function sanitizedPackageManagerEnv(baseEnv = process.env) {
499
+ const env = {};
500
+ for (const [key, value] of Object.entries(baseEnv)) {
501
+ if (/^npm_(config_|lifecycle|package_|execpath$|node_execpath$)/i.test(key)) continue;
502
+ env[key] = value;
503
+ }
504
+ return env;
505
+ }
506
+
507
+ async function ensureRuntimeStep({ home, log, ensureRuntime }) {
508
+ const ensure = ensureRuntime ?? ((opts) => {
509
+ const r = ensureCrewDshRuntime({ ...opts, env: sanitizedPackageManagerEnv() });
510
+ if (!r.ok && r.stderrTail) {
511
+ log(` (runtime installer said: ${r.stderrTail})`);
512
+ }
513
+ return r.ok ? { ok: true, version: r.cli?.version ?? null } : { ok: false, error: r.error ?? r.code ?? 'runtime bootstrap failed' };
514
+ });
515
+ const r = await ensure({ home });
516
+ if (!r?.ok) {
517
+ log(`✗ reusable Crew DSH runtime unavailable: ${r?.error ?? 'unknown error'}`);
518
+ return false;
519
+ }
520
+ log(`✓ reusable Crew DSH runtime${r.version ? ` (@${r.version})` : ''}`);
521
+ return true;
522
+ }
523
+
524
+ // ---- commands -----------------------------------------------------------------
525
+
526
+ function currentInstallationHealth({ home }) {
527
+ const pointer = readCurrentPointer({ home });
528
+ if (!pointer) return { installed: false, healthy: false };
529
+ if (!existsSync(pointer.path)) return { installed: false, healthy: false, pointer };
530
+ const validated = validateInstalledPayload(pointer.path, { expectedName: pointer.name, expectedVersion: pointer.version });
531
+ const registered = registrationHealthy({ home, name: pointer.name, releaseDir: pointer.path });
532
+ return { installed: true, healthy: validated.ok && registered, validated, registered, pointer };
533
+ }
534
+
535
+ async function activateRelease({ home, releaseDir, manifest, log, installer }) {
536
+ const registration = ensureCrewPluginRegistration({ home, root: releaseDir, name: manifest.name });
537
+ if (!registration.ok) {
538
+ log(`✗ Harness registration failed (${registration.code ?? 'unknown'})`);
539
+ return false;
540
+ }
541
+ log(`✓ Harness plugin registered (dedicated dsh-crew profile → ${releaseDir})`);
542
+
543
+ const codex = installer.installCodex({ home, root: releaseDir });
544
+ if (codex.ok === false) {
545
+ log(`✗ Codex Desktop integration failed: ${(codex.actions ?? []).join('; ')}`);
546
+ return false;
547
+ }
548
+ log('✓ Codex Desktop integration');
549
+
550
+ const claude = await installer.installClaudeCode({ home, root: releaseDir });
551
+ if (claude.ok === false) {
552
+ log(`✗ Claude Code integration failed`);
553
+ return false;
554
+ }
555
+ log('✓ Claude Code integration');
556
+ return true;
557
+ }
558
+
559
+ export async function npxInstall({
560
+ home = homedir(),
561
+ log = console.log,
562
+ sourceRoot,
563
+ installer = realInstaller,
564
+ ensureRuntime,
565
+ npmInstaller,
566
+ } = {}) {
567
+ log('DSH Crew installer (npx-managed)');
568
+ const candidateRoot = sourceRoot ?? runningPackageRoot();
569
+ const manifest = readManifest(candidateRoot);
570
+ if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
571
+
572
+ const health = currentInstallationHealth({ home });
573
+ if (health.installed && health.pointer.version === manifest.version && health.validated.ok) {
574
+ // Same-version reinstall: repair activation surfaces without restaging.
575
+ log(`- installed payload ${manifest.version} already present and valid; repairing activation`);
576
+ const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest, log, installer });
577
+ if (!activated) return { ok: false, error: 'activation failed' };
578
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
579
+ log('');
580
+ log('Done.');
581
+ return { ok: true, repaired: true, version: manifest.version, path: health.pointer.path };
582
+ }
583
+
584
+ const staged = stageCandidatePayload({ sourceRoot: candidateRoot, home, log, npmInstaller });
585
+ if (!staged.ok) {
586
+ log(`✗ staging failed (${staged.code})${staged.detail ? `: ${staged.detail.join('; ')}` : ''}`);
587
+ return { ok: false, error: `staging failed (${staged.code})` };
588
+ }
589
+ log(`✓ candidate payload staged (${manifest.version})`);
590
+
591
+ const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
592
+ log(`✓ durable release committed under Crew-owned state`);
593
+
594
+ const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
595
+ if (!activated) return { ok: false, error: 'activation failed' };
596
+
597
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
598
+
599
+ log('');
600
+ log('Done.');
601
+ log('Restart DeepSeek Harness and Codex Desktop.');
602
+ return { ok: true, version: manifest.version, path: releaseDir };
603
+ }
604
+
605
+ export const UPDATE_PACKAGE_NAME = '@ran-sh/dsh-crew';
606
+ export const UPDATE_DEFAULT_SPEC = `${UPDATE_PACKAGE_NAME}@latest`;
607
+
608
+ /**
609
+ * Extract a packed npm tarball with the platform `tar` binary (bsdtar ships
610
+ * with Windows 10+, macOS, and Linux) into destDir; npm tarballs always root
611
+ * at `package/`.
612
+ */
613
+ export function extractPackageTarball(tgzPath, destDir, { runner = spawnSync } = {}) {
614
+ mkdirSync(destDir, { recursive: true });
615
+ const result = runner('tar', ['-xzf', String(tgzPath), '-C', destDir], {
616
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
617
+ });
618
+ const root = join(destDir, 'package');
619
+ if (result.status !== 0 || !existsSync(join(root, 'package.json'))) {
620
+ return { ok: false, detail: `tar extract failed (${result.status}): ${(result.stderr || result.stdout || '').trim().slice(-200)}` };
621
+ }
622
+ return { ok: true, sourceRoot: root };
623
+ }
624
+
625
+ /**
626
+ * The globally installed launcher intentionally does not self-replace. When
627
+ * the payload is newer, keep it authoritative and give the exact launcher
628
+ * refresh command. A newer launcher is handled by npxUpdate before this point.
629
+ */
630
+ function noteLauncherDivergence({ log, home = homedir() }) {
631
+ const launcherVersion = readManifest(runningPackageRoot())?.version ?? null;
632
+ let installedVersion = null;
633
+ try { installedVersion = readCurrentPointer({ home }).version ?? null; } catch { installedVersion = null; }
634
+ if (launcherVersion && installedVersion && compareVersions(installedVersion, launcherVersion) > 0) {
635
+ log('');
636
+ log(`- note: managed payload ${installedVersion} is newer than the global launcher ${launcherVersion}; the payload remains authoritative.`);
637
+ log(` Refresh the launcher when convenient: npm install -g ${UPDATE_PACKAGE_NAME}@${installedVersion}`);
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Resolve an update candidate WITHOUT a source checkout:
643
+ * - explicit candidate path: a payload directory or a packed .tgz file;
644
+ * - otherwise npm registry mode: `npm pack <spec>` using the user's own
645
+ * configured registry/auth (no policy or config mutation), extracted to a
646
+ * disposable directory the caller cleans up via returned cleanup().
647
+ */
648
+ export function resolveUpdateCandidate({
649
+ candidate,
650
+ spec = UPDATE_DEFAULT_SPEC,
651
+ home = homedir(),
652
+ log = () => {},
653
+ runner = spawnSync,
654
+ } = {}) {
655
+ const candidateValue = candidate ?? process.env.DSH_CREW_CANDIDATE ?? undefined;
656
+ if (candidateValue !== undefined && typeof candidateValue !== 'string') {
657
+ return { ok: false, code: 'INVALID_CANDIDATE', detail: 'candidate must be a directory or .tgz path' };
658
+ }
659
+ if (typeof candidateValue === 'string' && candidateValue.trim()) {
660
+ const value = resolve(candidateValue.trim());
661
+ if (!existsSync(value)) return { ok: false, code: 'CANDIDATE_NOT_FOUND', detail: value };
662
+ if (existsSync(join(value, 'package.json'))) {
663
+ const manifest = readManifest(value);
664
+ if (!manifest?.name || !manifest?.version) return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: value };
665
+ return { ok: true, sourceRoot: value, version: manifest.version, cleanup: null };
666
+ }
667
+ if (/\.tgz$/i.test(value)) {
668
+ const tmpRoot = join(crewReleasesDir({ home }), `.candidate-${timestampStamp()}-${process.pid}`);
669
+ const extracted = extractPackageTarball(value, tmpRoot, { runner });
670
+ if (!extracted.ok) {
671
+ rmSync(tmpRoot, { recursive: true, force: true });
672
+ return { ok: false, code: 'CANDIDATE_EXTRACT_FAILED', detail: extracted.detail };
673
+ }
674
+ const manifest = readManifest(extracted.sourceRoot);
675
+ if (!manifest?.name || !manifest?.version) {
676
+ rmSync(tmpRoot, { recursive: true, force: true });
677
+ return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: value };
678
+ }
679
+ return {
680
+ ok: true,
681
+ sourceRoot: extracted.sourceRoot,
682
+ version: manifest.version,
683
+ cleanup: () => rmSync(tmpRoot, { recursive: true, force: true }),
684
+ };
685
+ }
686
+ return { ok: false, code: 'UNSUPPORTED_CANDIDATE', detail: value };
687
+ }
688
+
689
+ // Registry mode.
690
+ const tmpDir = join(crewReleasesDir({ home }), `.candidate-${timestampStamp()}-${process.pid}`);
691
+ mkdirSync(tmpDir, { recursive: true });
692
+ try {
693
+ const effectiveSpec = process.env.DSH_CREW_UPDATE_SPEC ?? spec;
694
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
695
+ const packArgs = ['pack', effectiveSpec, '--pack-destination', tmpDir, '--json', '--loglevel=error'];
696
+ log(`- resolving update candidate from the configured npm registry (${effectiveSpec})`);
697
+ const packed = runner(npm, packArgs, {
698
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32',
699
+ timeout: 600_000, windowsHide: true, env: sanitizedPackageManagerEnv(),
700
+ });
701
+ if (packed.status !== 0) {
702
+ return { ok: false, code: 'REGISTRY_PACK_FAILED', detail: `${effectiveSpec}: ${(packed.stderr || packed.stdout || '').trim().slice(-300)}` };
703
+ }
704
+ let info;
705
+ try { info = JSON.parse(packed.stdout)[0]; } catch {
706
+ return { ok: false, code: 'REGISTRY_PACK_UNPARSEABLE', detail: String(packed.stdout).slice(-200) };
707
+ }
708
+ if (!info?.filename) return { ok: false, code: 'REGISTRY_PACK_UNPARSEABLE', detail: 'pack output missing filename' };
709
+ const tgzPath = join(tmpDir, info.filename);
710
+ const extracted = extractPackageTarball(tgzPath, join(tmpDir, 'x'), { runner });
711
+ if (!extracted.ok) {
712
+ return { ok: false, code: 'CANDIDATE_EXTRACT_FAILED', detail: extracted.detail };
713
+ }
714
+ const manifest = readManifest(extracted.sourceRoot);
715
+ if (!manifest?.name || !manifest?.version) {
716
+ return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID', detail: info.filename };
717
+ }
718
+ if (info.name !== manifest.name || info.version !== manifest.version) {
719
+ return { ok: false, code: 'CANDIDATE_IDENTITY_MISMATCH', detail: `pack ${info.name}@${info.version} vs manifest ${manifest.name}@${manifest.version}` };
720
+ }
721
+ return {
722
+ ok: true,
723
+ sourceRoot: extracted.sourceRoot,
724
+ version: manifest.version,
725
+ cleanup: () => rmSync(tmpDir, { recursive: true, force: true }),
726
+ };
727
+ } catch (error) {
728
+ rmSync(tmpDir, { recursive: true, force: true });
729
+ return { ok: false, code: 'REGISTRY_PACK_FAILED', detail: String(error?.message ?? error).slice(-300) };
730
+ }
731
+ }
732
+
733
+ export async function npxUpdate({
734
+ home = homedir(),
735
+ log = console.log,
736
+ sourceRoot,
737
+ candidate,
738
+ spec,
739
+ installer = realInstaller,
740
+ ensureRuntime,
741
+ npmInstaller,
742
+ runner = spawnSync,
743
+ } = {}) {
744
+ log('DSH Crew updater');
745
+ // Candidate resolution: explicit path/dir override > a newer validated
746
+ // running launcher > configured npm registry (@latest). This makes the
747
+ // supported legacy bootstrap (`npm install -g ...@latest`, then `update`)
748
+ // independent of registry propagation after the launcher refresh.
749
+ const explicitCandidate = candidate ?? sourceRoot;
750
+ const initialHealth = currentInstallationHealth({ home });
751
+ const launcherRoot = runningPackageRoot();
752
+ const launcherManifest = readManifest(launcherRoot);
753
+ const launcherCanConverge = explicitCandidate === undefined
754
+ && initialHealth.pointer?.version
755
+ && launcherManifest?.name === UPDATE_PACKAGE_NAME
756
+ && launcherManifest?.version
757
+ && compareVersions(launcherManifest.version, initialHealth.pointer.version) > 0;
758
+ let resolved;
759
+ if (launcherCanConverge) {
760
+ log(`- newer launcher ${launcherManifest.version}; converging managed payload ${initialHealth.pointer.version} before registry resolution`);
761
+ resolved = { ok: true, sourceRoot: launcherRoot, version: launcherManifest.version, cleanup: null };
762
+ } else {
763
+ resolved = resolveUpdateCandidate({ candidate: explicitCandidate, spec, home, log, runner });
764
+ }
765
+ if (!resolved.ok) {
766
+ log(`✗ candidate resolution failed (${resolved.code})${resolved.detail ? `: ${resolved.detail}` : ''}`);
767
+ return { ok: false, error: `candidate resolution failed (${resolved.code})` };
768
+ }
769
+ try {
770
+ const manifest = readManifest(resolved.sourceRoot);
771
+ if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
772
+
773
+ const health = currentInstallationHealth({ home });
774
+
775
+ if (health.installed && health.healthy && health.pointer.version === manifest.version) {
776
+ log(`- already current (${manifest.version}); repairing registration/integrations idempotently`);
777
+ const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest, log, installer });
778
+ if (!activated) return { ok: false, error: 'activation failed' };
779
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
780
+ log('');
781
+ log('Done.');
782
+ return { ok: true, idempotent: true, version: manifest.version, path: health.pointer.path };
783
+ }
784
+
785
+ if (health.installed && health.healthy && compareVersions(manifest.version, health.pointer.version) < 0) {
786
+ const source = explicitCandidate === undefined && !launcherCanConverge ? 'registry latest' : 'candidate';
787
+ log(`- ${source} (${manifest.version}) is not newer than the installed payload (${health.pointer.version}); nothing to update`);
788
+ const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest: readManifest(health.pointer.path), log, installer });
789
+ if (!activated) return { ok: false, error: 'activation failed' };
790
+ return { ok: true, idempotent: true, version: health.pointer.version, path: health.pointer.path };
791
+ }
792
+
793
+ // Stale, unhealthy, older, or missing installation: stage the candidate
794
+ // fully and validate it before switching. The previous usable release is
795
+ // left in place until the replacement has been committed and activated.
796
+ if (health.installed && !health.healthy) {
797
+ log('- existing installation is stale or incomplete; repairing via fresh candidate staging');
798
+ } else if (health.installed) {
799
+ log(`- updating managed payload ${health.pointer.version} -> ${manifest.version}`);
800
+ }
801
+
802
+ const staged = stageCandidatePayload({ sourceRoot: resolved.sourceRoot, home, log, npmInstaller });
803
+ if (!staged.ok) {
804
+ log(`✗ staging failed (${staged.code})${staged.detail ? `: ${staged.detail.join('; ')}` : ''}`);
805
+ return { ok: false, error: `staging failed (${staged.code})` };
806
+ }
807
+ log(`✓ candidate payload staged and validated (${manifest.version})`);
808
+
809
+ const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
810
+ log('✓ durable release committed under Crew-owned state');
811
+
812
+ const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
813
+ if (!activated) return { ok: false, error: 'activation failed' };
814
+
815
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
816
+
817
+ noteLauncherDivergence({ log, home });
818
+ log('');
819
+ log('Done.');
820
+ log('Restart DeepSeek Harness and Codex Desktop.');
821
+ return { ok: true, updated: true, version: manifest.version, path: releaseDir };
822
+ } finally {
823
+ resolved.cleanup?.();
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Compare dotted numeric versions; returns -1/0/1. Non-numeric segments fall
829
+ * back to string comparison so prerelease identifiers stay deterministic.
830
+ */
831
+ export function compareVersions(left, right) {
832
+ const asParts = (value) => String(value ?? '').split('.');
833
+ const a = asParts(left);
834
+ const b = asParts(right);
835
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
836
+ const x = a[index] ?? '0';
837
+ const y = b[index] ?? '0';
838
+ const xn = /^\d+$/.test(x);
839
+ const yn = /^\d+$/.test(y);
840
+ if (xn && yn) {
841
+ const diff = Number(x) - Number(y);
842
+ if (diff !== 0) return diff < 0 ? -1 : 1;
843
+ } else if (xn !== yn) {
844
+ // npm semantics: a numeric release segment outranks a prerelease segment.
845
+ return xn ? 1 : -1;
846
+ } else if (x !== y) {
847
+ return x < y ? -1 : 1;
848
+ }
849
+ }
850
+ return 0;
851
+ }
852
+
853
+ export function npxStatus({
854
+ home = homedir(),
855
+ log = console.log,
856
+ sourceRoot,
857
+ installer = realInstaller,
858
+ } = {}) {
859
+ const candidateRoot = sourceRoot ?? runningPackageRoot();
860
+ const manifest = readManifest(candidateRoot);
861
+ const candidateVersion = manifest?.version ?? null;
862
+
863
+ const pointer = readCurrentPointer({ home });
864
+ let installedLine = 'not installed';
865
+ let installedVersion = null;
866
+ if (pointer) {
867
+ if (existsSync(pointer.path)) {
868
+ const validated = validateInstalledPayload(pointer.path, { expectedName: pointer.name, expectedVersion: pointer.version });
869
+ if (validated.ok) {
870
+ installedVersion = pointer.version;
871
+ installedLine = `${pointer.version} (${pointer.path})`;
872
+ } else {
873
+ installedVersion = pointer.version;
874
+ installedLine = `${pointer.version} at ${pointer.path} (unverifiable/damaged)`;
875
+ }
876
+ } else {
877
+ installedLine = `pointer references missing payload (${pointer.path})`;
878
+ }
879
+ }
880
+
881
+ // Read-only DSH plugin state from the dedicated Crew profile only; the
882
+ // official web profile layout is intentionally never inspected.
883
+ let dshPlugin = 'not installed';
884
+ const profilePkgFile = join(crewProfileDir({ home }), 'package.json');
885
+ const packageName = manifest?.name ?? pointer?.name ?? null;
886
+ if (packageName && existsSync(profilePkgFile)) {
887
+ try {
888
+ const pkg = JSON.parse(readFileSync(profilePkgFile, 'utf8'));
889
+ const listed = Boolean(pkg.dependencies?.[packageName]) || Boolean(pkg.dsh?.profile?.bundles?.includes?.(packageName));
890
+ const linkOk = packageName && existsSync(registrationLinkPath({ home, name: packageName }));
891
+ dshPlugin = listed && linkOk ? 'installed' : listed ? 'registered but payload link missing' : 'not installed';
892
+ } catch { dshPlugin = 'unknown'; }
893
+ }
894
+
895
+ const st = installer.installStatus ? installer.installStatus({ home }) : realInstaller.installStatus({ home });
896
+ const codex = st?.codex?.installed ? 'installed' : 'not installed';
897
+ const claude = st?.claude?.installed ? 'installed' : 'not installed';
898
+
899
+ log(`DSH Crew launcher/candidate: ${candidateVersion ?? 'unknown'}`);
900
+ log(`Installed DSH Crew payload: ${installedLine}`);
901
+ if (candidateVersion && installedVersion && candidateVersion !== installedVersion) {
902
+ const direction = compareVersions(candidateVersion, installedVersion);
903
+ if (direction > 0) {
904
+ log(`- launcher ${candidateVersion} is newer than the managed payload ${installedVersion}.`);
905
+ log(' Run: dsh-crew update');
906
+ } else {
907
+ log(`- managed payload ${installedVersion} is newer than the launcher ${candidateVersion}; the payload remains authoritative.`);
908
+ log(` Refresh the launcher with: npm install -g ${UPDATE_PACKAGE_NAME}@${installedVersion}`);
909
+ }
910
+ }
911
+ log(`DSH plugin: ${dshPlugin} (dedicated dsh-crew profile; official web profile ignored)`);
912
+ log(`Codex Desktop integration: ${codex}`);
913
+ log(`Claude Code integration: ${claude}`);
914
+
915
+ return {
916
+ ok: true,
917
+ candidateVersion,
918
+ installedVersion,
919
+ installedPath: pointer?.path ?? null,
920
+ dshPlugin,
921
+ codex,
922
+ claude,
923
+ };
924
+ }
925
+
926
+ export async function npxUninstall({
927
+ home = homedir(),
928
+ purge = false,
929
+ log = console.log,
930
+ installer = realInstaller,
931
+ } = {}) {
932
+ log('DSH Crew uninstaller (npx-managed)');
933
+ const failures = [];
934
+ const fail = (text) => { log(`✗ ${text}`); failures.push(text); };
935
+
936
+ const pointer = readCurrentPointer({ home });
937
+ const name = pointer?.name ?? readManifest(runningPackageRoot())?.name;
938
+
939
+ const cx = installer.uninstallCodex({ home });
940
+ if (cx.ok !== false) log('✓ Codex Desktop integration removed');
941
+ else fail('Codex Desktop integration removal failed');
942
+
943
+ const cl = installer.uninstallClaudeCode ? await installer.uninstallClaudeCode({ home }) : realInstaller.uninstallClaudeCode({ home });
944
+ if (cl.ok !== false) log('✓ Claude Code integration removed');
945
+ else fail('Claude Code integration removal failed');
946
+
947
+ if (name) {
948
+ const removed = removeCrewPluginRegistration({ home, name });
949
+ if (!removed.ok) fail(`Harness registration removal failed (${removed.code ?? 'unknown'})`);
950
+ else log(removed.removed ? '✓ Harness plugin registration removed (offline, Crew-owned state)' : '- Harness plugin registration already absent');
951
+ } else {
952
+ fail('installed package name unknown; registration not removed');
953
+ }
954
+
955
+ if (purge) {
956
+ try {
957
+ rmSync(join(home, '.config', 'dsh-crew'), { recursive: true, force: true });
958
+ log('✓ ~/.config/dsh-crew purged');
959
+ } catch { fail('could not purge ~/.config/dsh-crew'); }
960
+ } else {
961
+ // Remove ONLY the Crew-managed installed payload; config, credentials,
962
+ // backups, and the isolated Harness home stay untouched.
963
+ try {
964
+ rmSync(crewAppRoot({ home }), { recursive: true, force: true });
965
+ log('✓ Crew-managed installed payload removed (config/backups kept)');
966
+ } catch { fail('could not remove the Crew-managed installed payload'); }
967
+ }
968
+
969
+ if (failures.length > 0) {
970
+ log('');
971
+ log('FAILED: uninstall incomplete');
972
+ return { ok: false, failures };
973
+ }
974
+ log('');
975
+ log('Done.');
976
+ return { ok: true, failures: [] };
977
+ }
978
+
979
+ // ---- CLI dispatch --------------------------------------------------------------
980
+
981
+ export const USAGE = `usage: dsh-crew <command> [--purge] [--candidate <path>]
982
+
983
+ Commands:
984
+ install persist the candidate package into Crew-owned state and register it
985
+ status read-only report of launcher/installed versions and integrations
986
+ update resolve the newest permitted package from the configured npm registry (or
987
+ --candidate), stage and validate it, then activate; idempotent when current
988
+ uninstall remove the Crew-managed payload, registration, and integrations (config kept)
989
+
990
+ Options:
991
+ --candidate <path> update from a local payload directory or packed .tgz instead of the registry
992
+ --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
993
+ --help show this help
994
+
995
+ Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
996
+ Source checkouts use scripts/setup.mjs instead.`;
997
+
998
+ function normalizeCommand(argv) {
999
+ const flags = argv.slice(1);
1000
+ let candidate;
1001
+ for (let index = 0; index < flags.length; index += 1) {
1002
+ if (flags[index] === '--candidate') {
1003
+ candidate = flags[index + 1];
1004
+ flags.splice(index, 2);
1005
+ index -= 1;
1006
+ } else if (flags[index]?.startsWith('--candidate=')) {
1007
+ candidate = flags[index].slice('--candidate='.length);
1008
+ flags.splice(index, 1);
1009
+ index -= 1;
1010
+ }
1011
+ }
1012
+ const knownFlags = new Set(['--purge']);
1013
+ const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
1014
+ return { command: argv[0], purge: flags.includes('--purge'), candidate, unknown };
1015
+ }
1016
+
1017
+ /**
1018
+ * CLI dispatcher used by bin/dsh-crew.mjs. Returns a process exit code.
1019
+ */
1020
+ export async function runNpxCli({
1021
+ argv = process.argv.slice(2),
1022
+ log = console.log,
1023
+ error = console.error,
1024
+ commands = {},
1025
+ } = {}) {
1026
+ const { command, purge, candidate, unknown } = normalizeCommand(argv);
1027
+ if (command === '--help' || command === '-h' || command === 'help') {
1028
+ log(USAGE);
1029
+ return 0;
1030
+ }
1031
+ if (!command) {
1032
+ error(USAGE);
1033
+ return 1;
1034
+ }
1035
+ if (unknown.length > 0 || !['install', 'status', 'update', 'uninstall'].includes(command)) {
1036
+ error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
1037
+ return 1;
1038
+ }
1039
+ try {
1040
+ const actions = {
1041
+ install: commands.install ?? npxInstall,
1042
+ status: commands.status ?? npxStatus,
1043
+ update: commands.update ?? npxUpdate,
1044
+ uninstall: commands.uninstall ?? npxUninstall,
1045
+ };
1046
+ let result;
1047
+ if (command === 'uninstall') result = await actions.uninstall({ purge, log });
1048
+ else if (command === 'update') result = await actions.update({ candidate, log });
1049
+ else result = await actions[command]({ log });
1050
+ return result?.ok === false ? 1 : 0;
1051
+ } catch (err) {
1052
+ error(`dsh-crew ${command} failed: ${err?.message ?? err}`);
1053
+ return 1;
1054
+ }
1055
+ }