@ran-sh/dsh-crew 1.9.0 → 1.9.1

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 (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  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 +126 -126
  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 +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +1 -1
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -340
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +455 -455
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. package/zcode/commands/dsh-status.md +5 -5
@@ -1,1021 +1,1021 @@
1
- // DSH CLI/runtime resolution for Crew-owned installs.
2
- //
3
- // The resolver is deliberately independent from the official ~/.dsh state.
4
- // A reusable CLI may live under the Crew DSH_HOME, while global dsh/npx remain
5
- // compatibility fallbacks. Status and uninstall callers can resolve without
6
- // allowing a download.
7
-
8
- import { spawnSync } from 'node:child_process';
9
- import { createRequire } from 'node:module';
10
- import {
11
- existsSync,
12
- lstatSync,
13
- mkdirSync,
14
- readFileSync,
15
- realpathSync,
16
- renameSync,
17
- rmSync,
18
- symlinkSync,
19
- unlinkSync,
20
- writeFileSync,
21
- } from 'node:fs';
22
- import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
23
- import { homedir } from 'node:os';
24
- import { crewDshHome, crewProfileDir } from './install/install.mjs';
25
- import { reconcileProviderDesiredState } from './provider-desired-state.mjs';
26
- import {
27
- DSH_CLI_PACKAGE,
28
- TARGET_DSH_VERSION,
29
- TARGET_DSH_SPEC,
30
- RETAINED_RUNTIMES_DIRNAME,
31
- } from './dsh-cohort.mjs';
32
-
33
- // Re-exported so existing importers keep working while the cohort value now
34
- // lives in exactly one place (src/dsh-cohort.mjs).
35
- export { DSH_CLI_PACKAGE, TARGET_DSH_VERSION, TARGET_DSH_SPEC };
36
- export const CREW_DSH_RUNTIME_DIRNAME = 'runtime';
37
- const CREW_PROFILE_DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'];
38
- const PROFILE_PATCH_TEMPLATE = '[]\n';
39
- const PROFILE_PNPM_WORKSPACE = 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n';
40
-
41
- function defaultFindCommand(name) {
42
- const probe = process.platform === 'win32' ? 'where.exe' : 'which';
43
- const result = spawnSync(probe, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
44
- if (result.status !== 0) return null;
45
- const lines = String(result.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
46
- if (lines.length === 0) return null;
47
- if (process.platform !== 'win32') return lines[0];
48
- // where.exe may list extensionless npm/sh shims first; those are not
49
- // directly spawnable by Node on Windows, so prefer a real executable.
50
- const executable = lines.find((line) => /\.(?:exe|cmd|bat)$/iu.test(line));
51
- return executable ?? lines[0];
52
- }
53
-
54
- function defaultExists(path) {
55
- try { return existsSync(path); } catch { return false; }
56
- }
57
-
58
- function packageVersion(entry, read = readFileSync) {
59
- try {
60
- const packageFile = join(entry, '..', '..', 'package.json');
61
- const parsed = JSON.parse(read(packageFile, 'utf8'));
62
- return typeof parsed.version === 'string' ? parsed.version : null;
63
- } catch { return null; }
64
- }
65
-
66
- export function crewDshRuntimeRoot({ home = homedir() } = {}) {
67
- return join(crewDshHome({ home }), CREW_DSH_RUNTIME_DIRNAME);
68
- }
69
-
70
- export function crewDshRuntimeVersionDir({ home = homedir(), version = TARGET_DSH_VERSION } = {}) {
71
- return join(crewDshHome({ home }), `runtime-${version}`);
72
- }
73
-
74
- export function crewDshRuntimeEntry({ home = homedir(), platform = process.platform } = {}) {
75
- const suffix = platform === 'win32' ? '.cmd' : '';
76
- return join(crewDshRuntimeRoot({ home }), 'node_modules', '.bin', `dsh${suffix}`);
77
- }
78
-
79
- export function crewDshRuntimeModule({ home = homedir() } = {}) {
80
- return join(crewDshRuntimeRoot({ home }), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
81
- }
82
-
83
- function isPathLike(value) {
84
- return typeof value === 'string' && (value.includes('/') || value.includes('\\') || extname(value) === '.cmd' || extname(value) === '.exe');
85
- }
86
-
87
- function descriptor({ kind, command, args = [], source, version = null, reusable = false }) {
88
- return Object.freeze({ kind, command, args: [...args], source, version, reusable });
89
- }
90
-
91
- function explicitDescriptor(value, { exists = defaultExists, platform = process.platform } = {}) {
92
- if (typeof value !== 'string' || !value.trim()) return null;
93
- const trimmed = value.trim();
94
- if (isPathLike(trimmed) && !exists(trimmed)) return null;
95
- if (extname(trimmed).toLowerCase() === '.js') {
96
- return descriptor({ kind: 'explicit-node', command: process.execPath, args: [trimmed], source: 'explicit', reusable: true });
97
- }
98
- return descriptor({ kind: platform === 'win32' && extname(trimmed).toLowerCase() === '.cmd' ? 'explicit-cmd' : 'explicit', command: trimmed, source: 'explicit', reusable: true });
99
- }
100
-
101
- /**
102
- * Resolve a CLI without reading DSH credentials or profile state.
103
- * `allowDownload` only affects the final npx descriptor; resolution itself
104
- * never starts a network operation.
105
- */
106
- export function resolveDshCli({
107
- home = homedir(),
108
- env = process.env,
109
- platform = process.platform,
110
- exists = defaultExists,
111
- findCommand = defaultFindCommand,
112
- allowDownload = false,
113
- includeCompatibility = true,
114
- read = readFileSync,
115
- } = {}) {
116
- const explicit = explicitDescriptor(env.DSH_CREW_DSH_CLI ?? env.DSH_CLI, { exists, platform });
117
- if (explicit) return explicit;
118
-
119
- const moduleEntry = crewDshRuntimeModule({ home });
120
- if (exists(moduleEntry)) {
121
- return descriptor({
122
- kind: 'crew-runtime',
123
- command: process.execPath,
124
- args: [moduleEntry],
125
- source: 'crew-runtime',
126
- version: packageVersion(moduleEntry, read),
127
- reusable: true,
128
- });
129
- }
130
-
131
- if (!includeCompatibility) return null;
132
-
133
- const global = findCommand('dsh');
134
- if (global) return descriptor({ kind: 'global', command: global, source: 'global' });
135
-
136
- const npx = findCommand('npx');
137
- if (!npx) return null;
138
- return descriptor({
139
- kind: allowDownload ? 'npx-download' : 'npx-local',
140
- command: npx,
141
- args: allowDownload ? ['--yes', DSH_CLI_PACKAGE] : ['--no-install', DSH_CLI_PACKAGE],
142
- source: allowDownload ? 'npx-download' : 'npx-local',
143
- });
144
- }
145
-
146
- function quoteWindowsArg(value) {
147
- const input = String(value);
148
- if (input.length > 0 && !/[\s"]/u.test(input)) return input;
149
- let output = '"';
150
- let slashes = 0;
151
- for (const char of input) {
152
- if (char === '\\') { slashes += 1; continue; }
153
- if (char === '"') {
154
- output += '\\'.repeat(slashes * 2 + 1) + '"';
155
- } else {
156
- output += '\\'.repeat(slashes) + char;
157
- }
158
- slashes = 0;
159
- }
160
- output += '\\'.repeat(slashes * 2) + '"';
161
- return output;
162
- }
163
-
164
- export { quoteWindowsArg };
165
-
166
- /** Build a shell-free invocation, including deterministic Windows .cmd handling. */
167
- export function buildDshInvocation(cli, args = [], { platform = process.platform, comspec = process.env.ComSpec ?? 'cmd.exe' } = {}) {
168
- const allArgs = [...(cli?.args ?? []), ...args].map(String);
169
- const command = String(cli?.command ?? '');
170
- if (platform === 'win32' && /\.(?:cmd|bat)$/iu.test(command)) {
171
- return {
172
- command: comspec,
173
- args: ['/d', '/s', '/c', [quoteWindowsArg(command), ...allArgs.map(quoteWindowsArg)].join(' ')],
174
- shell: false,
175
- };
176
- }
177
- return { command, args: allArgs, shell: false };
178
- }
179
-
180
- export function runResolvedDsh(cli, args = [], {
181
- home = homedir(),
182
- env = process.env,
183
- runner = spawnSync,
184
- platform = process.platform,
185
- comspec = process.env.ComSpec ?? 'cmd.exe',
186
- } = {}) {
187
- if (!cli) return { ok: false, status: -1, stdout: '', stderr: 'DSH CLI unavailable' };
188
- const invocation = buildDshInvocation(cli, args, { platform, comspec });
189
- const result = runner(invocation.command, invocation.args, {
190
- encoding: 'utf8',
191
- stdio: ['ignore', 'pipe', 'pipe'],
192
- shell: invocation.shell,
193
- env: { ...env, DSH_HOME: crewDshHome({ home }) },
194
- });
195
- return {
196
- ok: result.status === 0,
197
- status: result.status ?? -1,
198
- stdout: result.stdout ?? '',
199
- stderr: result.stderr ?? '',
200
- invocation,
201
- };
202
- }
203
-
204
- /**
205
- * Install a reusable DSH CLI into Crew-owned state. This is the only helper
206
- * that may invoke a package manager, and callers must explicitly opt into it.
207
- *
208
- * The caller-supplied `version` defaults to the pinned TARGET. Reuse happens
209
- * only when the live runtime already matches `version`; a mismatch is
210
- * reported as DSH_RUNTIME_COHORT_MISMATCH and NEVER upgraded in place.
211
- * Callers that intend an upgrade must interpret that code as needsMigration
212
- * and run the staged transactional path — there is deliberately no `force`.
213
- */
214
- export function ensureCrewDshRuntime({
215
- home = homedir(),
216
- version = TARGET_DSH_VERSION,
217
- packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
218
- npmCommand = null,
219
- pnpmCommand = null,
220
- findCommand = defaultFindCommand,
221
- exists = defaultExists,
222
- runner = spawnSync,
223
- platform = process.platform,
224
- comspec = process.env.ComSpec ?? 'cmd.exe',
225
- env = process.env,
226
- } = {}) {
227
- const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
228
- // Reuse only when the installed runtime already matches the requested
229
- // cohort. A stale cohort (e.g. an online 0.1.1-rc.2 runtime) must never be
230
- // mistaken for the target, and it must never be upgraded in place under a
231
- // live hub.
232
- if (existing?.kind === 'crew-runtime' && existing?.version === version) {
233
- return { ok: true, cli: existing, reused: true };
234
- }
235
- if (existing?.kind === 'crew-runtime') {
236
- return {
237
- ok: false,
238
- code: 'DSH_RUNTIME_COHORT_MISMATCH',
239
- error: `Crew runtime is ${existing.version ?? 'unknown version'} but ${version} is required; refusing in-place upgrade of a live runtime`,
240
- installed: existing.version ?? null,
241
- target: version,
242
- };
243
- }
244
-
245
- const pnpm = pnpmCommand ?? findCommand('pnpm');
246
- const npm = npmCommand ?? findCommand('npm');
247
- if (!pnpm && !npm) return { ok: false, code: 'DSH_RUNTIME_INSTALLER_NOT_FOUND', error: 'pnpm/npm unavailable' };
248
- const runtimeRoot = crewDshRuntimeRoot({ home });
249
- mkdirSync(runtimeRoot, { recursive: true });
250
- const packageManager = pnpm
251
- ? descriptor({ kind: 'pnpm', command: pnpm, source: 'pnpm' })
252
- : descriptor({ kind: 'npm', command: npm, source: 'npm' });
253
- const packageArgs = pnpm
254
- ? ['add', '--dir', runtimeRoot, '--ignore-scripts', packageSpec]
255
- : ['install', '--prefix', runtimeRoot, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
256
- const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
257
- const result = runner(invocation.command, invocation.args, {
258
- encoding: 'utf8',
259
- stdio: ['ignore', 'pipe', 'pipe'],
260
- shell: invocation.shell,
261
- env: { ...env },
262
- });
263
- const cli = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
264
- if (result.status !== 0 || !cli || cli.kind !== 'crew-runtime') {
265
- return {
266
- ok: false,
267
- code: 'DSH_RUNTIME_INSTALL_FAILED',
268
- error: 'Crew-owned DSH runtime install failed',
269
- status: result.status ?? -1,
270
- stderrTail: String(result.stderr || result.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
271
- };
272
- }
273
- // Fail closed when the installed cohort drifts from the requested target
274
- // (registry race, hoisted pollution, partial install).
275
- if (cli.version !== version) {
276
- return {
277
- ok: false,
278
- code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
279
- error: `installed Crew runtime is ${cli.version ?? 'unknown version'} but ${version} is required`,
280
- installed: cli.version ?? null,
281
- target: version,
282
- };
283
- }
284
- return { ok: true, cli, reused: false, version: cli.version, runtimeRoot };
285
- }
286
-
287
- export function describeDshCli(cli) {
288
- if (!cli) return 'unavailable';
289
- const version = cli.version ? `@${cli.version}` : '';
290
- return `${cli.kind}${version}`;
291
- }
292
-
293
- // Shared pnpm/npm install of the pinned DSH cohort into a specific root
294
- // directory. pnpm materializes ABSOLUTE-path .cmd shims and junctions that
295
- // point at the install root's own .pnpm store, so the tree must be installed
296
- // at its FINAL resting path: renaming a pnpm tree breaks every shim. Callers
297
- // that need an atomic swap must install at the live root only after moving
298
- // the old tree aside.
299
- export function installDshInto({
300
- root,
301
- version,
302
- packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
303
- npmCommand = null,
304
- pnpmCommand = null,
305
- findCommand = defaultFindCommand,
306
- exists = defaultExists,
307
- runner = spawnSync,
308
- platform = process.platform,
309
- comspec = process.env.ComSpec ?? 'cmd.exe',
310
- env = process.env,
311
- }) {
312
- const pnpm = pnpmCommand ?? findCommand('pnpm');
313
- const npm = npmCommand ?? findCommand('npm');
314
- if (!pnpm && !npm) return { ok: false, code: 'DSH_RUNTIME_INSTALLER_NOT_FOUND', error: 'pnpm/npm unavailable' };
315
- mkdirSync(root, { recursive: true });
316
- const packageManager = pnpm
317
- ? descriptor({ kind: 'pnpm', command: pnpm, source: 'pnpm' })
318
- : descriptor({ kind: 'npm', command: npm, source: 'npm' });
319
- const packageArgs = pnpm
320
- ? ['add', '--dir', root, '--ignore-scripts', packageSpec]
321
- : ['install', '--prefix', root, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
322
- const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
323
- const result = runner(invocation.command, invocation.args, {
324
- encoding: 'utf8',
325
- stdio: ['ignore', 'pipe', 'pipe'],
326
- shell: invocation.shell,
327
- env: { ...env },
328
- });
329
- if (result.status !== 0) {
330
- return {
331
- ok: false,
332
- code: 'DSH_RUNTIME_INSTALL_FAILED',
333
- error: 'Crew runtime install failed',
334
- status: result.status ?? -1,
335
- stderrTail: String(result.stderr || result.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
336
- };
337
- }
338
- const moduleEntry = join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
339
- if (!exists(moduleEntry)) {
340
- return { ok: false, code: 'DSH_RUNTIME_INSTALL_INCOMPLETE', error: 'runtime entry missing after install' };
341
- }
342
- const installedVersion = packageVersion(moduleEntry);
343
- if (installedVersion !== version) {
344
- return {
345
- ok: false,
346
- code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
347
- error: `installed Crew runtime is ${installedVersion ?? 'unknown version'} but ${version} is required`,
348
- installed: installedVersion ?? null,
349
- target: version,
350
- };
351
- }
352
- return { ok: true, root, version: installedVersion };
353
- }
354
-
355
- // Staged cohort migration: install the target cohort into a versioned
356
- // directory WITHOUT touching the live runtime/, verify its manifest, then
357
- // report the staged root for an atomic switch by the caller (stop owned
358
- // 3210 -> swap directories -> clean restart -> identity check). Never
359
- // upgrades a live runtime in place.
360
- //
361
- // NOTE: pnpm trees embed absolute paths in .cmd shims and junctions, so a
362
- // staged tree CANNOT be renamed onto the live root afterwards. migrate
363
- //CrewDshRuntime therefore installs at the live root in place (after parking
364
- // the old tree); stageCrewDshRuntime remains for callers that consume the
365
- // stage dir at its fixed versioned path (tests, dry runs, audits).
366
- export function stageCrewDshRuntime({
367
- home = homedir(),
368
- version = TARGET_DSH_VERSION,
369
- packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
370
- npmCommand = null,
371
- pnpmCommand = null,
372
- findCommand = defaultFindCommand,
373
- exists = defaultExists,
374
- runner = spawnSync,
375
- platform = process.platform,
376
- comspec = process.env.ComSpec ?? 'cmd.exe',
377
- env = process.env,
378
- read = readFileSync,
379
- } = {}) {
380
- const stagedRoot = crewDshRuntimeVersionDir({ home, version });
381
- // Clean/recreate the versioned stage dir: a failed older attempt must
382
- // never contaminate the next staging.
383
- try { rmSync(stagedRoot, { recursive: true, force: true }); } catch {}
384
- const installed = installDshInto({
385
- root: stagedRoot,
386
- version,
387
- packageSpec,
388
- npmCommand,
389
- pnpmCommand,
390
- findCommand,
391
- exists,
392
- runner,
393
- platform,
394
- comspec,
395
- env,
396
- });
397
- if (!installed.ok) {
398
- const code = installed.code === 'DSH_RUNTIME_INSTALL_FAILED'
399
- ? 'DSH_RUNTIME_STAGE_FAILED'
400
- : installed.code === 'DSH_RUNTIME_INSTALL_INCOMPLETE'
401
- ? 'DSH_RUNTIME_STAGE_INCOMPLETE'
402
- : installed.code;
403
- return { ...installed, code, error: code === 'DSH_RUNTIME_STAGE_FAILED' ? 'staged Crew runtime install failed' : installed.error };
404
- }
405
- const stagedModule = join(stagedRoot, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
406
- if (!exists(stagedModule)) {
407
- return { ok: false, code: 'DSH_RUNTIME_STAGE_INCOMPLETE', error: 'staged runtime entry missing' };
408
- }
409
- const stagedVersion = packageVersion(stagedModule, read);
410
- if (stagedVersion !== version) {
411
- return {
412
- ok: false,
413
- code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
414
- error: `staged Crew runtime is ${stagedVersion ?? 'unknown version'} but ${version} is required`,
415
- installed: stagedVersion ?? null,
416
- target: version,
417
- };
418
- }
419
- return { ok: true, stagedRoot, version: stagedVersion };
420
- }
421
-
422
- // Full cohort migration transaction (caller holds the update lock):
423
- // stop owned 3210 -> park the live tree (rename to prev, its shims stay
424
- // valid because the tree returns to the same path on rollback) -> install
425
- // the target cohort AT THE LIVE ROOT (pnpm shims are absolute-path, so the
426
- // tree must be born at its final resting path; a renamed pnpm tree has
427
- // dangling shims) -> restart -> identity verify -> rollback on any failure.
428
- // stopOwned/startOwned/verifyOwned have NO defaults: a missing callback
429
- // fails closed instead of pretending an unverified step succeeded.
430
- //
431
- // prepareOnly=true stops + swaps the tree but NEVER starts the process:
432
- // the caller (a pair-ordered rollback/compensation) will activate the
433
- // matching Crew payload and start exactly once itself. The parked prior
434
- // tree is returned as prevRoot for durable retention by the caller.
435
- export async function migrateCrewDshRuntime({
436
- home = homedir(),
437
- version = TARGET_DSH_VERSION,
438
- stageOptions = {},
439
- stopOwned,
440
- startOwned,
441
- verifyOwned,
442
- rename = renameSync,
443
- log = () => {},
444
- prepareOnly = false,
445
- } = {}) {
446
- const liveRoot = crewDshRuntimeRoot({ home });
447
- if (typeof stopOwned !== 'function') {
448
- return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'stop callback is required' };
449
- }
450
- if (!prepareOnly && (typeof startOwned !== 'function' || typeof verifyOwned !== 'function')) {
451
- return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'start/verify callbacks are required unless prepareOnly' };
452
- }
453
- const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
454
- const prevRoot = join(crewDshHome({ home }), `runtime-prev-${nonce}`);
455
- const stop = await stopOwned();
456
- if (!stop.ok) {
457
- return { ok: false, code: stop.code ?? 'DSH_RUNTIME_STOP_FAILED', error: stop.error ?? 'could not stop owned 3210' };
458
- }
459
- let liveMoved = false;
460
- try {
461
- if (existsSync(liveRoot)) {
462
- rename(liveRoot, prevRoot);
463
- liveMoved = true;
464
- }
465
- mkdirSync(liveRoot, { recursive: true });
466
- } catch (error) {
467
- // Park failed: restore the live tree before reporting.
468
- let recovery = { ok: false };
469
- try {
470
- if (liveMoved && existsSync(prevRoot) && !existsSync(liveRoot)) rename(prevRoot, liveRoot);
471
- if (!prepareOnly) {
472
- const restarted = await startOwned();
473
- recovery = restarted?.ok ? { ok: true } : { ok: false, code: restarted?.code ?? 'DSH_RUNTIME_RESTART_FAILED' };
474
- } else {
475
- recovery = { ok: true };
476
- }
477
- } catch (recoveryError) {
478
- recovery = { ok: false, code: 'DSH_RUNTIME_SWAP_RECOVERY_FAILED', error: String(recoveryError?.message ?? recoveryError) };
479
- }
480
- return { ok: false, code: 'DSH_RUNTIME_PARK_FAILED', error: String(error?.message ?? error), recovery };
481
- }
482
- // Install the target cohort AT the live root so every pnpm shim/junction
483
- // carries the correct final absolute path.
484
- const installed = installDshInto({ root: liveRoot, version, ...stageOptions });
485
- if (!installed.ok) {
486
- const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename, prepareOnly });
487
- return { ok: false, code: installed.code ?? 'DSH_RUNTIME_INSTALL_FAILED', error: installed.error ?? 'runtime install at live root failed', recovery };
488
- }
489
- if (prepareOnly) {
490
- log(`- runtime tree prepared at live root (@${version}); process not started`);
491
- return { ok: true, version, liveRoot, prevRoot, prepared: true };
492
- }
493
- const start = await startOwned();
494
- if (!start.ok) {
495
- const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename });
496
- return { ok: false, code: start.code ?? 'DSH_RUNTIME_START_FAILED', error: start.error ?? 'restart after install failed', recovery };
497
- }
498
- const verified = await verifyOwned();
499
- if (!verified.ok) {
500
- const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename });
501
- return { ok: false, code: verified.code ?? 'DSH_RUNTIME_VERIFY_FAILED', error: verified.error ?? 'identity check failed', recovery };
502
- }
503
- // Retain the prior cohort under retained-runtimes/<version> instead of
504
- // deleting it: a later cross-cohort rollback can restore it offline with
505
- // no registry round-trip. Retention is best-effort (a failure here must
506
- // not fail the migration), and an existing retained copy of the same
507
- // version is replaced so the retained set always holds the newest tree.
508
- retainPriorRuntime({ home, prevRoot });
509
- log(`- runtime cohort migrated to ${version}`);
510
- return { ok: true, version, liveRoot };
511
- }
512
-
513
- async function rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename = renameSync, prepareOnly = false }) {
514
- const recovery = { stoppedCandidate: false, restore: false, restart: false };
515
- // The failed candidate 3210 may still be running against the tree we are
516
- // about to replace: stop it FIRST, otherwise the swap races a live
517
- // process (and on Windows the live handles can fail the rename/delete).
518
- if (typeof stopOwned === 'function') {
519
- try {
520
- const stopped = await stopOwned();
521
- recovery.stoppedCandidate = stopped?.ok === true;
522
- if (!recovery.stoppedCandidate) recovery.stopError = stopped?.code ?? stopped?.error ?? 'candidate stop failed';
523
- } catch (error) {
524
- recovery.stopError = String(error?.message ?? error);
525
- }
526
- if (!recovery.stoppedCandidate) {
527
- recovery.ok = false;
528
- return recovery;
529
- }
530
- }
531
- try { rmSync(liveRoot, { recursive: true, force: true }); } catch {}
532
- try {
533
- if (existsSync(prevRoot)) { rename(prevRoot, liveRoot); recovery.restore = true; }
534
- } catch (error) {
535
- recovery.restoreError = String(error?.message ?? error);
536
- }
537
- if (prepareOnly) {
538
- recovery.ok = recovery.restore === true;
539
- return recovery;
540
- }
541
- try {
542
- const restarted = await startOwned();
543
- recovery.restart = restarted?.ok === true;
544
- if (!recovery.restart) recovery.restartCode = restarted?.code ?? 'DSH_RUNTIME_RESTART_FAILED';
545
- } catch (error) {
546
- recovery.restartError = String(error?.message ?? error);
547
- }
548
- recovery.ok = recovery.restore === true && recovery.restart === true;
549
- return recovery;
550
- }
551
-
552
- // ---- retained runtime cohorts -------------------------------------------------
553
-
554
- function retainedRuntimesRoot({ home }) {
555
- return join(crewDshHome({ home }), RETAINED_RUNTIMES_DIRNAME);
556
- }
557
-
558
- function retainedRuntimeDir({ home, version }) {
559
- return join(retainedRuntimesRoot({ home }), version);
560
- }
561
-
562
- // Probe the dsh package version inside a runtime tree (live or prev/retained).
563
- function runtimeTreeVersion(root, read = readFileSync) {
564
- if (!root) return null;
565
- try {
566
- const file = join(root, 'node_modules', '@deepseek-ai', 'dsh', 'package.json');
567
- const parsed = JSON.parse(read(file, 'utf8'));
568
- return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null;
569
- } catch { return null; }
570
- }
571
-
572
- // Best-effort retention of the swapped-out prior runtime tree. Never throws
573
- // and never fails the caller: retention is an optimization for offline
574
- // rollback, not a correctness requirement of the migration itself.
575
- function retainPriorRuntime({ home, prevRoot, rename = renameSync }) {
576
- try {
577
- if (!existsSync(prevRoot)) return { ok: true, retained: false };
578
- const version = runtimeTreeVersion(prevRoot);
579
- if (!version) {
580
- // No version to key retention on; the tree is unreadable junk.
581
- try { rmSync(prevRoot, { recursive: true, force: true }); } catch {}
582
- return { ok: true, retained: false, reason: 'prior tree version unreadable; removed' };
583
- }
584
- const retainedRoot = retainedRuntimesRoot({ home });
585
- const target = retainedRuntimeDir({ home, version });
586
- mkdirSync(retainedRoot, { recursive: true });
587
- if (existsSync(target)) rmSync(target, { recursive: true, force: true });
588
- rename(prevRoot, target);
589
- return { ok: true, retained: true, version, path: target };
590
- } catch (error) {
591
- // A failed retain must not fail an otherwise-successful migration, and it
592
- // must NOT delete the parked prior tree: that tree is the only offline
593
- // rollback copy of the previous cohort. Leave it in place (a later
594
- // operator/GC pass can retry or reap it) and report the failure loudly.
595
- return { ok: false, retained: false, prevRoot, error: String(error?.message ?? error) };
596
- }
597
- }
598
-
599
- // Locate a usable retained cohort tree. Returns the retained path when one
600
- // exists and its manifest reports the requested version; otherwise null.
601
- export function findRetainedRuntime({ home = homedir(), version, exists = existsSync, read = readFileSync } = {}) {
602
- if (typeof version !== 'string' || version.length === 0) return null;
603
- const dir = retainedRuntimeDir({ home, version });
604
- if (!exists(dir)) return null;
605
- if (runtimeTreeVersion(dir, read) !== version) return null;
606
- return dir;
607
- }
608
-
609
- // Offline cohort restore for cross-cohort rollback: move the retained tree
610
- // back onto live runtime/. The caller must hold the update lock.
611
- //
612
- // Pair-ordering contract: with prepareOnly=false this helper ALSO starts and
613
- // verifies the 3210, which is only safe when the caller has already
614
- // activated the matching Crew payload. Callers that must activate the
615
- // payload FIRST (rollback/compensation pair transition) pass prepareOnly=true
616
- // to swap the tree WITHOUT starting the process, then activate the payload,
617
- // then start once and dual-verify. On success the retained copy is consumed
618
- // (moved), so a subsequent rollback to the same cohort re-stages from the
619
- // registry.
620
- export async function restoreRetainedRuntime({
621
- home = homedir(),
622
- version,
623
- stopOwned,
624
- startOwned,
625
- verifyOwned,
626
- rename = renameSync,
627
- log = () => {},
628
- prepareOnly = false,
629
- } = {}) {
630
- const retained = findRetainedRuntime({ home, version });
631
- if (!retained) {
632
- return { ok: false, code: 'DSH_RUNTIME_RETAINED_MISSING', error: `no retained runtime for cohort ${version}` };
633
- }
634
- if (typeof stopOwned !== 'function') {
635
- return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'stop callback is required' };
636
- }
637
- if (!prepareOnly && (typeof startOwned !== 'function' || typeof verifyOwned !== 'function')) {
638
- return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'start/verify callbacks are required unless prepareOnly' };
639
- }
640
- const liveRoot = crewDshRuntimeRoot({ home });
641
- const stop = await stopOwned();
642
- if (!stop.ok) {
643
- return { ok: false, code: stop.code ?? 'DSH_RUNTIME_STOP_FAILED', error: stop.error ?? 'could not stop owned 3210' };
644
- }
645
- try {
646
- if (existsSync(liveRoot)) rmSync(liveRoot, { recursive: true, force: true });
647
- rename(retained, liveRoot);
648
- } catch (error) {
649
- // Restore the pre-existing live tree is impossible (it was replaced only
650
- // on success above); report and let the caller reconcile.
651
- return { ok: false, code: 'DSH_RUNTIME_RESTORE_SWAP_FAILED', error: String(error?.message ?? error) };
652
- }
653
- if (prepareOnly) {
654
- log(`- runtime tree prepared offline from retained tree (@${version}); process not started`);
655
- return { ok: true, version, liveRoot, prepared: true };
656
- }
657
- const start = await startOwned();
658
- if (!start.ok) {
659
- return { ok: false, code: start.code ?? 'DSH_RUNTIME_START_FAILED', error: start.error ?? 'restart after restore failed', restored: true };
660
- }
661
- const verified = await verifyOwned();
662
- if (!verified.ok) {
663
- return { ok: false, code: verified.code ?? 'DSH_RUNTIME_VERIFY_FAILED', error: verified.error ?? 'identity check after restore failed', restored: true };
664
- }
665
- log(`- runtime cohort restored offline from retained tree (@${version})`);
666
- return { ok: true, version, liveRoot };
667
- }
668
-
669
- // GC retained runtimes that no release pins. A retained cohort is needed only
670
- // while some managed release (current or retained) declares it as its exact
671
- // @deepseek-ai/dsh dependency. Best-effort; never throws.
672
- export function gcRetainedRuntimes({ home = homedir(), releases = [], log = () => {} } = {}) {
673
- const root = retainedRuntimesRoot({ home });
674
- if (!existsSync(root)) return [];
675
- const needed = new Set();
676
- for (const release of releases) {
677
- const spec = payloadDshSpec(release);
678
- if (spec && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) needed.add(spec);
679
- }
680
- const removed = [];
681
- for (const name of readdirSync(root)) {
682
- const dir = join(root, name);
683
- if (!needed.has(name)) {
684
- try { rmSync(dir, { recursive: true, force: true }); removed.push(name); } catch { /* best effort */ }
685
- }
686
- }
687
- return removed;
688
- }
689
-
690
- // Read the exact @deepseek-ai/dsh pin from a payload manifest (dependencies
691
- // first, then peerDependencies). Exact pins only: a range or absence yields
692
- // null so callers fail closed instead of guessing a cohort.
693
- function payloadDshSpec(manifest) {
694
- if (!manifest || typeof manifest !== 'object') return null;
695
- const direct = manifest.dependencies?.['@deepseek-ai/dsh'];
696
- if (typeof direct === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(direct)) return direct;
697
- const peer = manifest.peerDependencies?.['@deepseek-ai/dsh'];
698
- if (typeof peer === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(peer)) return peer;
699
- return null;
700
- }
701
-
702
- export function payloadDshVersion(manifest) {
703
- return payloadDshSpec(manifest);
704
- }
705
-
706
- function isObject(value) {
707
- return value !== null && typeof value === 'object' && !Array.isArray(value);
708
- }
709
-
710
- function isSafePackageName(name) {
711
- return typeof name === 'string'
712
- && /^(?:@[^/\s]+\/)?[^/\s]+$/u.test(name)
713
- && !name.split('/').some((part) => part === '.' || part === '..');
714
- }
715
-
716
- function isWithin(root, candidate) {
717
- const rel = relative(root, candidate);
718
- return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
719
- }
720
-
721
- function fileSpec(target) {
722
- return `link:${target.replace(/\\/g, '/')}`;
723
- }
724
-
725
- function profileLinkPath(profileRoot, name) {
726
- return join(profileRoot, 'node_modules', ...name.split('/'));
727
- }
728
-
729
- function readPluginRoot(root, expectedName) {
730
- if (typeof root !== 'string' || !isAbsolute(root)) return { ok: false, code: 'INVALID_CREW_PLUGIN_ROOT' };
731
- let targetRoot;
732
- try { targetRoot = realpathSync(root); } catch { return { ok: false, code: 'CREW_PLUGIN_ROOT_NOT_FOUND' }; }
733
- const packageFile = join(targetRoot, 'package.json');
734
- let pkg;
735
- try { pkg = JSON.parse(readFileSync(packageFile, 'utf8')); } catch { return { ok: false, code: 'CREW_PLUGIN_MANIFEST_INVALID' }; }
736
- if (!isObject(pkg) || !isSafePackageName(pkg.name) || (expectedName !== undefined && pkg.name !== expectedName)) {
737
- return { ok: false, code: 'CREW_PLUGIN_MANIFEST_INVALID' };
738
- }
739
- const patchSpec = pkg.dsh?.bundle?.patch;
740
- if (typeof patchSpec !== 'string' || !patchSpec.trim()) return { ok: false, code: 'CREW_PLUGIN_BUNDLE_INVALID' };
741
- const patchPath = resolve(targetRoot, patchSpec);
742
- if (!isWithin(targetRoot, patchPath) || !existsSync(patchPath)) return { ok: false, code: 'CREW_PLUGIN_BUNDLE_INVALID' };
743
- return { ok: true, targetRoot, packageFile, packageName: pkg.name, packageVersion: pkg.version ?? null, patchPath, pkg };
744
- }
745
-
746
- function readProfileManifestForRegistration(profileRoot, { create = true, defaultBundles = CREW_PROFILE_DEFAULT_BUNDLES } = {}) {
747
- const profileManifest = join(profileRoot, 'package.json');
748
- if (!existsSync(profileManifest)) {
749
- if (!create) return { ok: false, code: 'PROFILE_NOT_FOUND', profileManifest };
750
- return {
751
- profileManifest,
752
- manifest: {
753
- name: `dsh-profile-${profileRoot.split(/[\\/]/u).at(-1)}`,
754
- private: true,
755
- dependencies: {},
756
- dsh: { profile: { bundles: [...defaultBundles] } },
757
- },
758
- created: true,
759
- };
760
- }
761
- let manifest;
762
- try { manifest = JSON.parse(readFileSync(profileManifest, 'utf8')); } catch { return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' }; }
763
- if (!isObject(manifest)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
764
- if (manifest.dependencies !== undefined && !isObject(manifest.dependencies)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
765
- if (manifest.dependencies && Object.values(manifest.dependencies).some((value) => typeof value !== 'string')) {
766
- return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
767
- }
768
- if (manifest.dsh !== undefined && !isObject(manifest.dsh)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
769
- if (manifest.dsh?.profile !== undefined && !isObject(manifest.dsh.profile)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
770
- const bundles = manifest.dsh?.profile?.bundles;
771
- if (bundles !== undefined && (!Array.isArray(bundles) || bundles.some((name) => !isSafePackageName(name)))) {
772
- return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
773
- }
774
- return { profileManifest, manifest, created: false };
775
- }
776
-
777
- function ensureProfileScaffold(profileRoot) {
778
- mkdirSync(profileRoot, { recursive: true });
779
- let changed = false;
780
- const patchFile = join(profileRoot, 'cordis.patch.yml');
781
- if (!existsSync(patchFile)) { writeFileSync(patchFile, PROFILE_PATCH_TEMPLATE); changed = true; }
782
- const workspaceFile = join(profileRoot, 'pnpm-workspace.yaml');
783
- if (!existsSync(workspaceFile)) { writeFileSync(workspaceFile, PROFILE_PNPM_WORKSPACE); changed = true; }
784
- return changed;
785
- }
786
-
787
- function readPackageVersionAt(dir, read = readFileSync) {
788
- try {
789
- const parsed = JSON.parse(read(join(dir, 'package.json'), 'utf8'));
790
- return typeof parsed.version === 'string' && parsed.version ? parsed.version : null;
791
- } catch { return null; }
792
- }
793
-
794
- // Nearest `<dir>/node_modules/<name>` walking up from `dir`, mirroring the
795
- // resolution a require() from that directory would take.
796
- export function resolveNearestBundleVersion(dir, name, { exists = defaultExists, read = readFileSync } = {}) {
797
- let cursor = dir;
798
- for (let depth = 0; depth < 12; depth += 1) {
799
- const candidate = join(cursor, 'node_modules', ...name.split('/'));
800
- if (exists(candidate)) return readPackageVersionAt(candidate, read);
801
- const parent = dirname(cursor);
802
- if (parent === cursor) break;
803
- cursor = parent;
804
- }
805
- return null;
806
- }
807
-
808
- /**
809
- * Stop a profile from resolving its bundle packages out of a foreign cohort.
810
- *
811
- * A profile first installed against a source checkout records `dsh-base` and
812
- * `dsh-web-app` as links into that checkout. Switching the CLI to another
813
- * cohort leaves those links behind, and because they sit in the profile's own
814
- * node_modules they shadow the matching packages installed beside the profile.
815
- * The host then composes the web app of one cohort while the plugin table comes
816
- * from another; the client module table is built per cohort, so the mismatched
817
- * plugin dies at require time with "missed the module table".
818
- *
819
- * Only an entry whose fallthrough provably resolves the target cohort is
820
- * removed. A profile that deliberately tracks a different cohort keeps its
821
- * links and is reported as skipped, never as an error.
822
- */
823
- export function reconcileProfileBundleCohort({
824
- home = homedir(),
825
- profileRoot,
826
- version = TARGET_DSH_VERSION,
827
- bundles = CREW_PROFILE_DEFAULT_BUNDLES,
828
- exists = defaultExists,
829
- read = readFileSync,
830
- remove = (path) => rmSync(path, { recursive: true, force: true }),
831
- } = {}) {
832
- const root = profileRoot ?? crewProfileDir({ home });
833
- if (typeof root !== 'string' || !isAbsolute(root)) return { ok: false, code: 'INVALID_PROFILE_ROOT' };
834
- const modulesDir = join(root, 'node_modules');
835
- const parentDir = dirname(root);
836
- const unshadowed = [];
837
- const skipped = [];
838
- const removeBundle = (path) => remove(path);
839
- for (const name of bundles) {
840
- const shadowPath = join(modulesDir, ...name.split('/'));
841
- if (!exists(shadowPath)) continue;
842
- const shadowed = readPackageVersionAt(shadowPath, read);
843
- if (shadowed === version) continue;
844
- const fallthrough = resolveNearestBundleVersion(parentDir, name, { exists, read });
845
- if (fallthrough !== version) {
846
- skipped.push({ bundle: name, shadowed: shadowed ?? null, fallthrough: fallthrough ?? null });
847
- continue;
848
- }
849
- try { removeBundle(shadowPath); } catch { return { ok: false, code: 'PROFILE_BUNDLE_UNSHADOW_FAILED', profileRoot: root, bundle: name }; }
850
- if (exists(shadowPath)) return { ok: false, code: 'PROFILE_BUNDLE_UNSHADOW_FAILED', profileRoot: root, bundle: name };
851
- unshadowed.push({ bundle: name, shadowed: shadowed ?? null });
852
- }
853
- return { ok: true, changed: unshadowed.length > 0, profileRoot: root, unshadowed, skipped };
854
- }
855
-
856
- function readCrewTombstones(home) {
857
- const lifecycleFile = join(dirname(crewDshHome({ home })), 'provider-lifecycle.json');
858
- if (!existsSync(lifecycleFile)) return { ok: true, tombstones: {} };
859
- try {
860
- const state = JSON.parse(readFileSync(lifecycleFile, 'utf8'));
861
- return { ok: true, tombstones: state?.tombstones && typeof state.tombstones === 'object' && !Array.isArray(state.tombstones) ? state.tombstones : {} };
862
- } catch { return { ok: false, code: 'PROVIDER_LIFECYCLE_STATE_INVALID' }; }
863
- }
864
-
865
- function reconcileCrewProfileProviders({ home, profileRoot }) {
866
- const patchFile = join(profileRoot, 'cordis.patch.yml');
867
- if (!existsSync(patchFile)) return { ok: true, changed: false, removed: [] };
868
- const lifecycle = readCrewTombstones(home);
869
- if (!lifecycle.ok) return lifecycle;
870
- const source = readFileSync(patchFile, 'utf8');
871
- if (source.trim() === '[]') return { ok: true, changed: false, removed: [] };
872
- const reconciled = reconcileProviderDesiredState(source, { tombstones: lifecycle.tombstones });
873
- if (!reconciled.ok) return reconciled;
874
- if (reconciled.changed) writeFileSync(patchFile, reconciled.text);
875
- return reconciled;
876
- }
877
-
878
- function ensureDirectoryLink(linkPath, targetRoot) {
879
- let stat;
880
- try { stat = lstatSync(linkPath); } catch { stat = null; }
881
- if (stat && !stat.isSymbolicLink()) return { ok: false, code: 'CREW_PLUGIN_LINK_CONFLICT' };
882
- if (stat) {
883
- try {
884
- if (realpathSync(linkPath) === targetRoot) return { ok: true, changed: false };
885
- } catch {}
886
- unlinkSync(linkPath);
887
- }
888
- mkdirSync(dirname(linkPath), { recursive: true });
889
- symlinkSync(targetRoot, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
890
- return { ok: true, changed: true };
891
- }
892
-
893
- /**
894
- * Register a local Crew plugin without invoking DSH's pnpm-forwarding plugin
895
- * command. The profile manifest and one loader-visible directory link are the
896
- * only state changed; no dependency resolution or policy bypass is attempted.
897
- */
898
- export function ensurePluginRegistration({
899
- profileRoot,
900
- root,
901
- name,
902
- createProfile = true,
903
- defaultBundles = CREW_PROFILE_DEFAULT_BUNDLES,
904
- } = {}) {
905
- const plugin = readPluginRoot(root, name);
906
- if (!plugin.ok) return plugin;
907
- if (typeof profileRoot !== 'string' || !isAbsolute(profileRoot)) return { ok: false, code: 'INVALID_PROFILE_ROOT' };
908
- const profile = readProfileManifestForRegistration(profileRoot, { create: createProfile, defaultBundles });
909
- if (profile.ok === false) return profile;
910
- const scaffoldChanged = createProfile ? ensureProfileScaffold(profileRoot) : false;
911
- const current = profile.manifest;
912
- const dependencies = { ...(current.dependencies ?? {}) };
913
- const currentBundles = current.dsh?.profile?.bundles ?? [...CREW_PROFILE_DEFAULT_BUNDLES];
914
- const nextBundles = [];
915
- let seen = false;
916
- for (const bundle of currentBundles) {
917
- if (bundle === plugin.packageName) {
918
- if (!seen) { nextBundles.push(bundle); seen = true; }
919
- } else nextBundles.push(bundle);
920
- }
921
- if (!seen) nextBundles.push(plugin.packageName);
922
- dependencies[plugin.packageName] = fileSpec(plugin.targetRoot);
923
- const next = {
924
- ...current,
925
- dependencies,
926
- dsh: {
927
- ...(current.dsh ?? {}),
928
- profile: { ...(current.dsh?.profile ?? {}), bundles: nextBundles },
929
- },
930
- };
931
- const manifestChanged = JSON.stringify(current) !== JSON.stringify(next) || profile.created;
932
- const linkPath = profileLinkPath(profileRoot, plugin.packageName);
933
- let link;
934
- try { link = ensureDirectoryLink(linkPath, plugin.targetRoot); } catch { return { ok: false, code: 'CREW_PLUGIN_LINK_FAILED' }; }
935
- if (!link.ok) return link;
936
- try {
937
- if (manifestChanged) writeFileSync(profile.profileManifest, JSON.stringify(next, null, 2) + '\n');
938
- // Resolve the intended package entry from the validated checkout. Node's
939
- // package-name resolution cache can retain the old target after a stale
940
- // junction is replaced in the same process, so loader visibility is proven
941
- // by the link target plus a normal package entry resolution from that
942
- // target rather than by trusting a cached package-name lookup.
943
- const resolvedEntry = createRequire(join(plugin.targetRoot, 'package.json')).resolve('.');
944
- const resolvedRoot = realpathSync(linkPath);
945
- if (resolvedRoot !== plugin.targetRoot || !isWithin(plugin.targetRoot, realpathSync(resolvedEntry))) {
946
- return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' };
947
- }
948
- return {
949
- ok: true,
950
- changed: scaffoldChanged || manifestChanged || link.changed,
951
- profileRoot,
952
- profileManifest: profile.profileManifest,
953
- linkPath,
954
- targetRoot: plugin.targetRoot,
955
- packageName: plugin.packageName,
956
- packageVersion: plugin.packageVersion,
957
- patchPath: plugin.patchPath,
958
- resolvedEntry,
959
- };
960
- } catch { return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' }; }
961
- }
962
-
963
- export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
964
- const result = ensurePluginRegistration({ profileRoot: crewProfileDir({ home }), root, name });
965
- if (!result.ok) return result;
966
- const reconciliation = reconcileProfileBundleCohort({ home, profileRoot: result.profileRoot });
967
- if (!reconciliation.ok) return reconciliation;
968
- const reconciled = reconcileCrewProfileProviders({ home, profileRoot: result.profileRoot });
969
- if (!reconciled.ok) return reconciled;
970
- return {
971
- ...result,
972
- changed: result.changed || reconciled.changed || reconciliation.changed,
973
- provider_reconciliation: reconciled.removed ?? [],
974
- bundle_reconciliation: reconciliation.unshadowed ?? [],
975
- };
976
- }
977
-
978
- /**
979
- * Remove one Crew plugin registration without invoking a package manager.
980
- * This is intentionally limited to the derived Crew profile directory so a
981
- * status/uninstall probe cannot touch the official web profile or download
982
- * anything merely to remove a stale registration.
983
- */
984
- export function removeCrewPluginRegistration({ home = homedir(), name, profileRoot = crewProfileDir({ home }) } = {}) {
985
- if (!isSafePackageName(name)) {
986
- return { ok: false, code: 'INVALID_CREW_PLUGIN_NAME' };
987
- }
988
- const packageFile = join(profileRoot, 'package.json');
989
- if (!existsSync(packageFile)) return { ok: true, removed: false };
990
- let pkg;
991
- try { pkg = JSON.parse(readFileSync(packageFile, 'utf8')); } catch { return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' }; }
992
- if (!isObject(pkg)
993
- || (pkg.dependencies !== undefined && !isObject(pkg.dependencies))
994
- || (pkg.dsh !== undefined && !isObject(pkg.dsh))
995
- || (pkg.dsh?.profile !== undefined && !isObject(pkg.dsh.profile))
996
- || (pkg.dsh?.profile?.bundles !== undefined
997
- && (!Array.isArray(pkg.dsh.profile.bundles)
998
- || pkg.dsh.profile.bundles.some((item) => !isSafePackageName(item))))) {
999
- return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
1000
- }
1001
- const hadDependency = Boolean(pkg.dependencies?.[name]);
1002
- const bundles = Array.isArray(pkg.dsh?.profile?.bundles) ? pkg.dsh.profile.bundles : [];
1003
- const hadBundle = bundles.includes(name);
1004
- if (!hadDependency && !hadBundle) return { ok: true, removed: false };
1005
- const linkPath = profileLinkPath(profileRoot, name);
1006
- let linkStat;
1007
- try { linkStat = lstatSync(linkPath); } catch { linkStat = null; }
1008
- if (linkStat && !linkStat.isSymbolicLink()) return { ok: false, code: 'CREW_PLUGIN_LINK_CONFLICT' };
1009
- const next = { ...pkg };
1010
- if (hadDependency) {
1011
- next.dependencies = { ...pkg.dependencies };
1012
- delete next.dependencies[name];
1013
- if (Object.keys(next.dependencies).length === 0) delete next.dependencies;
1014
- }
1015
- if (hadBundle) {
1016
- next.dsh = { ...pkg.dsh, profile: { ...pkg.dsh.profile, bundles: bundles.filter((item) => item !== name) } };
1017
- }
1018
- writeFileSync(packageFile, JSON.stringify(next, null, 2) + '\n');
1019
- if (linkStat) unlinkSync(linkPath);
1020
- return { ok: true, removed: true };
1021
- }
1
+ // DSH CLI/runtime resolution for Crew-owned installs.
2
+ //
3
+ // The resolver is deliberately independent from the official ~/.dsh state.
4
+ // A reusable CLI may live under the Crew DSH_HOME, while global dsh/npx remain
5
+ // compatibility fallbacks. Status and uninstall callers can resolve without
6
+ // allowing a download.
7
+
8
+ import { spawnSync } from 'node:child_process';
9
+ import { createRequire } from 'node:module';
10
+ import {
11
+ existsSync,
12
+ lstatSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ realpathSync,
16
+ renameSync,
17
+ rmSync,
18
+ symlinkSync,
19
+ unlinkSync,
20
+ writeFileSync,
21
+ } from 'node:fs';
22
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+ import { crewDshHome, crewProfileDir } from './install/install.mjs';
25
+ import { reconcileProviderDesiredState } from './provider-desired-state.mjs';
26
+ import {
27
+ DSH_CLI_PACKAGE,
28
+ TARGET_DSH_VERSION,
29
+ TARGET_DSH_SPEC,
30
+ RETAINED_RUNTIMES_DIRNAME,
31
+ } from './dsh-cohort.mjs';
32
+
33
+ // Re-exported so existing importers keep working while the cohort value now
34
+ // lives in exactly one place (src/dsh-cohort.mjs).
35
+ export { DSH_CLI_PACKAGE, TARGET_DSH_VERSION, TARGET_DSH_SPEC };
36
+ export const CREW_DSH_RUNTIME_DIRNAME = 'runtime';
37
+ const CREW_PROFILE_DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'];
38
+ const PROFILE_PATCH_TEMPLATE = '[]\n';
39
+ const PROFILE_PNPM_WORKSPACE = 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n';
40
+
41
+ function defaultFindCommand(name) {
42
+ const probe = process.platform === 'win32' ? 'where.exe' : 'which';
43
+ const result = spawnSync(probe, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
44
+ if (result.status !== 0) return null;
45
+ const lines = String(result.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
46
+ if (lines.length === 0) return null;
47
+ if (process.platform !== 'win32') return lines[0];
48
+ // where.exe may list extensionless npm/sh shims first; those are not
49
+ // directly spawnable by Node on Windows, so prefer a real executable.
50
+ const executable = lines.find((line) => /\.(?:exe|cmd|bat)$/iu.test(line));
51
+ return executable ?? lines[0];
52
+ }
53
+
54
+ function defaultExists(path) {
55
+ try { return existsSync(path); } catch { return false; }
56
+ }
57
+
58
+ function packageVersion(entry, read = readFileSync) {
59
+ try {
60
+ const packageFile = join(entry, '..', '..', 'package.json');
61
+ const parsed = JSON.parse(read(packageFile, 'utf8'));
62
+ return typeof parsed.version === 'string' ? parsed.version : null;
63
+ } catch { return null; }
64
+ }
65
+
66
+ export function crewDshRuntimeRoot({ home = homedir() } = {}) {
67
+ return join(crewDshHome({ home }), CREW_DSH_RUNTIME_DIRNAME);
68
+ }
69
+
70
+ export function crewDshRuntimeVersionDir({ home = homedir(), version = TARGET_DSH_VERSION } = {}) {
71
+ return join(crewDshHome({ home }), `runtime-${version}`);
72
+ }
73
+
74
+ export function crewDshRuntimeEntry({ home = homedir(), platform = process.platform } = {}) {
75
+ const suffix = platform === 'win32' ? '.cmd' : '';
76
+ return join(crewDshRuntimeRoot({ home }), 'node_modules', '.bin', `dsh${suffix}`);
77
+ }
78
+
79
+ export function crewDshRuntimeModule({ home = homedir() } = {}) {
80
+ return join(crewDshRuntimeRoot({ home }), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
81
+ }
82
+
83
+ function isPathLike(value) {
84
+ return typeof value === 'string' && (value.includes('/') || value.includes('\\') || extname(value) === '.cmd' || extname(value) === '.exe');
85
+ }
86
+
87
+ function descriptor({ kind, command, args = [], source, version = null, reusable = false }) {
88
+ return Object.freeze({ kind, command, args: [...args], source, version, reusable });
89
+ }
90
+
91
+ function explicitDescriptor(value, { exists = defaultExists, platform = process.platform } = {}) {
92
+ if (typeof value !== 'string' || !value.trim()) return null;
93
+ const trimmed = value.trim();
94
+ if (isPathLike(trimmed) && !exists(trimmed)) return null;
95
+ if (extname(trimmed).toLowerCase() === '.js') {
96
+ return descriptor({ kind: 'explicit-node', command: process.execPath, args: [trimmed], source: 'explicit', reusable: true });
97
+ }
98
+ return descriptor({ kind: platform === 'win32' && extname(trimmed).toLowerCase() === '.cmd' ? 'explicit-cmd' : 'explicit', command: trimmed, source: 'explicit', reusable: true });
99
+ }
100
+
101
+ /**
102
+ * Resolve a CLI without reading DSH credentials or profile state.
103
+ * `allowDownload` only affects the final npx descriptor; resolution itself
104
+ * never starts a network operation.
105
+ */
106
+ export function resolveDshCli({
107
+ home = homedir(),
108
+ env = process.env,
109
+ platform = process.platform,
110
+ exists = defaultExists,
111
+ findCommand = defaultFindCommand,
112
+ allowDownload = false,
113
+ includeCompatibility = true,
114
+ read = readFileSync,
115
+ } = {}) {
116
+ const explicit = explicitDescriptor(env.DSH_CREW_DSH_CLI ?? env.DSH_CLI, { exists, platform });
117
+ if (explicit) return explicit;
118
+
119
+ const moduleEntry = crewDshRuntimeModule({ home });
120
+ if (exists(moduleEntry)) {
121
+ return descriptor({
122
+ kind: 'crew-runtime',
123
+ command: process.execPath,
124
+ args: [moduleEntry],
125
+ source: 'crew-runtime',
126
+ version: packageVersion(moduleEntry, read),
127
+ reusable: true,
128
+ });
129
+ }
130
+
131
+ if (!includeCompatibility) return null;
132
+
133
+ const global = findCommand('dsh');
134
+ if (global) return descriptor({ kind: 'global', command: global, source: 'global' });
135
+
136
+ const npx = findCommand('npx');
137
+ if (!npx) return null;
138
+ return descriptor({
139
+ kind: allowDownload ? 'npx-download' : 'npx-local',
140
+ command: npx,
141
+ args: allowDownload ? ['--yes', DSH_CLI_PACKAGE] : ['--no-install', DSH_CLI_PACKAGE],
142
+ source: allowDownload ? 'npx-download' : 'npx-local',
143
+ });
144
+ }
145
+
146
+ function quoteWindowsArg(value) {
147
+ const input = String(value);
148
+ if (input.length > 0 && !/[\s"]/u.test(input)) return input;
149
+ let output = '"';
150
+ let slashes = 0;
151
+ for (const char of input) {
152
+ if (char === '\\') { slashes += 1; continue; }
153
+ if (char === '"') {
154
+ output += '\\'.repeat(slashes * 2 + 1) + '"';
155
+ } else {
156
+ output += '\\'.repeat(slashes) + char;
157
+ }
158
+ slashes = 0;
159
+ }
160
+ output += '\\'.repeat(slashes * 2) + '"';
161
+ return output;
162
+ }
163
+
164
+ export { quoteWindowsArg };
165
+
166
+ /** Build a shell-free invocation, including deterministic Windows .cmd handling. */
167
+ export function buildDshInvocation(cli, args = [], { platform = process.platform, comspec = process.env.ComSpec ?? 'cmd.exe' } = {}) {
168
+ const allArgs = [...(cli?.args ?? []), ...args].map(String);
169
+ const command = String(cli?.command ?? '');
170
+ if (platform === 'win32' && /\.(?:cmd|bat)$/iu.test(command)) {
171
+ return {
172
+ command: comspec,
173
+ args: ['/d', '/s', '/c', [quoteWindowsArg(command), ...allArgs.map(quoteWindowsArg)].join(' ')],
174
+ shell: false,
175
+ };
176
+ }
177
+ return { command, args: allArgs, shell: false };
178
+ }
179
+
180
+ export function runResolvedDsh(cli, args = [], {
181
+ home = homedir(),
182
+ env = process.env,
183
+ runner = spawnSync,
184
+ platform = process.platform,
185
+ comspec = process.env.ComSpec ?? 'cmd.exe',
186
+ } = {}) {
187
+ if (!cli) return { ok: false, status: -1, stdout: '', stderr: 'DSH CLI unavailable' };
188
+ const invocation = buildDshInvocation(cli, args, { platform, comspec });
189
+ const result = runner(invocation.command, invocation.args, {
190
+ encoding: 'utf8',
191
+ stdio: ['ignore', 'pipe', 'pipe'],
192
+ shell: invocation.shell,
193
+ env: { ...env, DSH_HOME: crewDshHome({ home }) },
194
+ });
195
+ return {
196
+ ok: result.status === 0,
197
+ status: result.status ?? -1,
198
+ stdout: result.stdout ?? '',
199
+ stderr: result.stderr ?? '',
200
+ invocation,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Install a reusable DSH CLI into Crew-owned state. This is the only helper
206
+ * that may invoke a package manager, and callers must explicitly opt into it.
207
+ *
208
+ * The caller-supplied `version` defaults to the pinned TARGET. Reuse happens
209
+ * only when the live runtime already matches `version`; a mismatch is
210
+ * reported as DSH_RUNTIME_COHORT_MISMATCH and NEVER upgraded in place.
211
+ * Callers that intend an upgrade must interpret that code as needsMigration
212
+ * and run the staged transactional path — there is deliberately no `force`.
213
+ */
214
+ export function ensureCrewDshRuntime({
215
+ home = homedir(),
216
+ version = TARGET_DSH_VERSION,
217
+ packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
218
+ npmCommand = null,
219
+ pnpmCommand = null,
220
+ findCommand = defaultFindCommand,
221
+ exists = defaultExists,
222
+ runner = spawnSync,
223
+ platform = process.platform,
224
+ comspec = process.env.ComSpec ?? 'cmd.exe',
225
+ env = process.env,
226
+ } = {}) {
227
+ const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
228
+ // Reuse only when the installed runtime already matches the requested
229
+ // cohort. A stale cohort (e.g. an online 0.1.1-rc.2 runtime) must never be
230
+ // mistaken for the target, and it must never be upgraded in place under a
231
+ // live hub.
232
+ if (existing?.kind === 'crew-runtime' && existing?.version === version) {
233
+ return { ok: true, cli: existing, reused: true };
234
+ }
235
+ if (existing?.kind === 'crew-runtime') {
236
+ return {
237
+ ok: false,
238
+ code: 'DSH_RUNTIME_COHORT_MISMATCH',
239
+ error: `Crew runtime is ${existing.version ?? 'unknown version'} but ${version} is required; refusing in-place upgrade of a live runtime`,
240
+ installed: existing.version ?? null,
241
+ target: version,
242
+ };
243
+ }
244
+
245
+ const pnpm = pnpmCommand ?? findCommand('pnpm');
246
+ const npm = npmCommand ?? findCommand('npm');
247
+ if (!pnpm && !npm) return { ok: false, code: 'DSH_RUNTIME_INSTALLER_NOT_FOUND', error: 'pnpm/npm unavailable' };
248
+ const runtimeRoot = crewDshRuntimeRoot({ home });
249
+ mkdirSync(runtimeRoot, { recursive: true });
250
+ const packageManager = pnpm
251
+ ? descriptor({ kind: 'pnpm', command: pnpm, source: 'pnpm' })
252
+ : descriptor({ kind: 'npm', command: npm, source: 'npm' });
253
+ const packageArgs = pnpm
254
+ ? ['add', '--dir', runtimeRoot, '--ignore-scripts', packageSpec]
255
+ : ['install', '--prefix', runtimeRoot, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
256
+ const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
257
+ const result = runner(invocation.command, invocation.args, {
258
+ encoding: 'utf8',
259
+ stdio: ['ignore', 'pipe', 'pipe'],
260
+ shell: invocation.shell,
261
+ env: { ...env },
262
+ });
263
+ const cli = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
264
+ if (result.status !== 0 || !cli || cli.kind !== 'crew-runtime') {
265
+ return {
266
+ ok: false,
267
+ code: 'DSH_RUNTIME_INSTALL_FAILED',
268
+ error: 'Crew-owned DSH runtime install failed',
269
+ status: result.status ?? -1,
270
+ stderrTail: String(result.stderr || result.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
271
+ };
272
+ }
273
+ // Fail closed when the installed cohort drifts from the requested target
274
+ // (registry race, hoisted pollution, partial install).
275
+ if (cli.version !== version) {
276
+ return {
277
+ ok: false,
278
+ code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
279
+ error: `installed Crew runtime is ${cli.version ?? 'unknown version'} but ${version} is required`,
280
+ installed: cli.version ?? null,
281
+ target: version,
282
+ };
283
+ }
284
+ return { ok: true, cli, reused: false, version: cli.version, runtimeRoot };
285
+ }
286
+
287
+ export function describeDshCli(cli) {
288
+ if (!cli) return 'unavailable';
289
+ const version = cli.version ? `@${cli.version}` : '';
290
+ return `${cli.kind}${version}`;
291
+ }
292
+
293
+ // Shared pnpm/npm install of the pinned DSH cohort into a specific root
294
+ // directory. pnpm materializes ABSOLUTE-path .cmd shims and junctions that
295
+ // point at the install root's own .pnpm store, so the tree must be installed
296
+ // at its FINAL resting path: renaming a pnpm tree breaks every shim. Callers
297
+ // that need an atomic swap must install at the live root only after moving
298
+ // the old tree aside.
299
+ export function installDshInto({
300
+ root,
301
+ version,
302
+ packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
303
+ npmCommand = null,
304
+ pnpmCommand = null,
305
+ findCommand = defaultFindCommand,
306
+ exists = defaultExists,
307
+ runner = spawnSync,
308
+ platform = process.platform,
309
+ comspec = process.env.ComSpec ?? 'cmd.exe',
310
+ env = process.env,
311
+ }) {
312
+ const pnpm = pnpmCommand ?? findCommand('pnpm');
313
+ const npm = npmCommand ?? findCommand('npm');
314
+ if (!pnpm && !npm) return { ok: false, code: 'DSH_RUNTIME_INSTALLER_NOT_FOUND', error: 'pnpm/npm unavailable' };
315
+ mkdirSync(root, { recursive: true });
316
+ const packageManager = pnpm
317
+ ? descriptor({ kind: 'pnpm', command: pnpm, source: 'pnpm' })
318
+ : descriptor({ kind: 'npm', command: npm, source: 'npm' });
319
+ const packageArgs = pnpm
320
+ ? ['add', '--dir', root, '--ignore-scripts', packageSpec]
321
+ : ['install', '--prefix', root, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
322
+ const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
323
+ const result = runner(invocation.command, invocation.args, {
324
+ encoding: 'utf8',
325
+ stdio: ['ignore', 'pipe', 'pipe'],
326
+ shell: invocation.shell,
327
+ env: { ...env },
328
+ });
329
+ if (result.status !== 0) {
330
+ return {
331
+ ok: false,
332
+ code: 'DSH_RUNTIME_INSTALL_FAILED',
333
+ error: 'Crew runtime install failed',
334
+ status: result.status ?? -1,
335
+ stderrTail: String(result.stderr || result.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
336
+ };
337
+ }
338
+ const moduleEntry = join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
339
+ if (!exists(moduleEntry)) {
340
+ return { ok: false, code: 'DSH_RUNTIME_INSTALL_INCOMPLETE', error: 'runtime entry missing after install' };
341
+ }
342
+ const installedVersion = packageVersion(moduleEntry);
343
+ if (installedVersion !== version) {
344
+ return {
345
+ ok: false,
346
+ code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
347
+ error: `installed Crew runtime is ${installedVersion ?? 'unknown version'} but ${version} is required`,
348
+ installed: installedVersion ?? null,
349
+ target: version,
350
+ };
351
+ }
352
+ return { ok: true, root, version: installedVersion };
353
+ }
354
+
355
+ // Staged cohort migration: install the target cohort into a versioned
356
+ // directory WITHOUT touching the live runtime/, verify its manifest, then
357
+ // report the staged root for an atomic switch by the caller (stop owned
358
+ // 3210 -> swap directories -> clean restart -> identity check). Never
359
+ // upgrades a live runtime in place.
360
+ //
361
+ // NOTE: pnpm trees embed absolute paths in .cmd shims and junctions, so a
362
+ // staged tree CANNOT be renamed onto the live root afterwards. migrate
363
+ //CrewDshRuntime therefore installs at the live root in place (after parking
364
+ // the old tree); stageCrewDshRuntime remains for callers that consume the
365
+ // stage dir at its fixed versioned path (tests, dry runs, audits).
366
+ export function stageCrewDshRuntime({
367
+ home = homedir(),
368
+ version = TARGET_DSH_VERSION,
369
+ packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
370
+ npmCommand = null,
371
+ pnpmCommand = null,
372
+ findCommand = defaultFindCommand,
373
+ exists = defaultExists,
374
+ runner = spawnSync,
375
+ platform = process.platform,
376
+ comspec = process.env.ComSpec ?? 'cmd.exe',
377
+ env = process.env,
378
+ read = readFileSync,
379
+ } = {}) {
380
+ const stagedRoot = crewDshRuntimeVersionDir({ home, version });
381
+ // Clean/recreate the versioned stage dir: a failed older attempt must
382
+ // never contaminate the next staging.
383
+ try { rmSync(stagedRoot, { recursive: true, force: true }); } catch {}
384
+ const installed = installDshInto({
385
+ root: stagedRoot,
386
+ version,
387
+ packageSpec,
388
+ npmCommand,
389
+ pnpmCommand,
390
+ findCommand,
391
+ exists,
392
+ runner,
393
+ platform,
394
+ comspec,
395
+ env,
396
+ });
397
+ if (!installed.ok) {
398
+ const code = installed.code === 'DSH_RUNTIME_INSTALL_FAILED'
399
+ ? 'DSH_RUNTIME_STAGE_FAILED'
400
+ : installed.code === 'DSH_RUNTIME_INSTALL_INCOMPLETE'
401
+ ? 'DSH_RUNTIME_STAGE_INCOMPLETE'
402
+ : installed.code;
403
+ return { ...installed, code, error: code === 'DSH_RUNTIME_STAGE_FAILED' ? 'staged Crew runtime install failed' : installed.error };
404
+ }
405
+ const stagedModule = join(stagedRoot, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
406
+ if (!exists(stagedModule)) {
407
+ return { ok: false, code: 'DSH_RUNTIME_STAGE_INCOMPLETE', error: 'staged runtime entry missing' };
408
+ }
409
+ const stagedVersion = packageVersion(stagedModule, read);
410
+ if (stagedVersion !== version) {
411
+ return {
412
+ ok: false,
413
+ code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
414
+ error: `staged Crew runtime is ${stagedVersion ?? 'unknown version'} but ${version} is required`,
415
+ installed: stagedVersion ?? null,
416
+ target: version,
417
+ };
418
+ }
419
+ return { ok: true, stagedRoot, version: stagedVersion };
420
+ }
421
+
422
+ // Full cohort migration transaction (caller holds the update lock):
423
+ // stop owned 3210 -> park the live tree (rename to prev, its shims stay
424
+ // valid because the tree returns to the same path on rollback) -> install
425
+ // the target cohort AT THE LIVE ROOT (pnpm shims are absolute-path, so the
426
+ // tree must be born at its final resting path; a renamed pnpm tree has
427
+ // dangling shims) -> restart -> identity verify -> rollback on any failure.
428
+ // stopOwned/startOwned/verifyOwned have NO defaults: a missing callback
429
+ // fails closed instead of pretending an unverified step succeeded.
430
+ //
431
+ // prepareOnly=true stops + swaps the tree but NEVER starts the process:
432
+ // the caller (a pair-ordered rollback/compensation) will activate the
433
+ // matching Crew payload and start exactly once itself. The parked prior
434
+ // tree is returned as prevRoot for durable retention by the caller.
435
+ export async function migrateCrewDshRuntime({
436
+ home = homedir(),
437
+ version = TARGET_DSH_VERSION,
438
+ stageOptions = {},
439
+ stopOwned,
440
+ startOwned,
441
+ verifyOwned,
442
+ rename = renameSync,
443
+ log = () => {},
444
+ prepareOnly = false,
445
+ } = {}) {
446
+ const liveRoot = crewDshRuntimeRoot({ home });
447
+ if (typeof stopOwned !== 'function') {
448
+ return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'stop callback is required' };
449
+ }
450
+ if (!prepareOnly && (typeof startOwned !== 'function' || typeof verifyOwned !== 'function')) {
451
+ return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'start/verify callbacks are required unless prepareOnly' };
452
+ }
453
+ const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
454
+ const prevRoot = join(crewDshHome({ home }), `runtime-prev-${nonce}`);
455
+ const stop = await stopOwned();
456
+ if (!stop.ok) {
457
+ return { ok: false, code: stop.code ?? 'DSH_RUNTIME_STOP_FAILED', error: stop.error ?? 'could not stop owned 3210' };
458
+ }
459
+ let liveMoved = false;
460
+ try {
461
+ if (existsSync(liveRoot)) {
462
+ rename(liveRoot, prevRoot);
463
+ liveMoved = true;
464
+ }
465
+ mkdirSync(liveRoot, { recursive: true });
466
+ } catch (error) {
467
+ // Park failed: restore the live tree before reporting.
468
+ let recovery = { ok: false };
469
+ try {
470
+ if (liveMoved && existsSync(prevRoot) && !existsSync(liveRoot)) rename(prevRoot, liveRoot);
471
+ if (!prepareOnly) {
472
+ const restarted = await startOwned();
473
+ recovery = restarted?.ok ? { ok: true } : { ok: false, code: restarted?.code ?? 'DSH_RUNTIME_RESTART_FAILED' };
474
+ } else {
475
+ recovery = { ok: true };
476
+ }
477
+ } catch (recoveryError) {
478
+ recovery = { ok: false, code: 'DSH_RUNTIME_SWAP_RECOVERY_FAILED', error: String(recoveryError?.message ?? recoveryError) };
479
+ }
480
+ return { ok: false, code: 'DSH_RUNTIME_PARK_FAILED', error: String(error?.message ?? error), recovery };
481
+ }
482
+ // Install the target cohort AT the live root so every pnpm shim/junction
483
+ // carries the correct final absolute path.
484
+ const installed = installDshInto({ root: liveRoot, version, ...stageOptions });
485
+ if (!installed.ok) {
486
+ const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename, prepareOnly });
487
+ return { ok: false, code: installed.code ?? 'DSH_RUNTIME_INSTALL_FAILED', error: installed.error ?? 'runtime install at live root failed', recovery };
488
+ }
489
+ if (prepareOnly) {
490
+ log(`- runtime tree prepared at live root (@${version}); process not started`);
491
+ return { ok: true, version, liveRoot, prevRoot, prepared: true };
492
+ }
493
+ const start = await startOwned();
494
+ if (!start.ok) {
495
+ const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename });
496
+ return { ok: false, code: start.code ?? 'DSH_RUNTIME_START_FAILED', error: start.error ?? 'restart after install failed', recovery };
497
+ }
498
+ const verified = await verifyOwned();
499
+ if (!verified.ok) {
500
+ const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename });
501
+ return { ok: false, code: verified.code ?? 'DSH_RUNTIME_VERIFY_FAILED', error: verified.error ?? 'identity check failed', recovery };
502
+ }
503
+ // Retain the prior cohort under retained-runtimes/<version> instead of
504
+ // deleting it: a later cross-cohort rollback can restore it offline with
505
+ // no registry round-trip. Retention is best-effort (a failure here must
506
+ // not fail the migration), and an existing retained copy of the same
507
+ // version is replaced so the retained set always holds the newest tree.
508
+ retainPriorRuntime({ home, prevRoot });
509
+ log(`- runtime cohort migrated to ${version}`);
510
+ return { ok: true, version, liveRoot };
511
+ }
512
+
513
+ async function rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename = renameSync, prepareOnly = false }) {
514
+ const recovery = { stoppedCandidate: false, restore: false, restart: false };
515
+ // The failed candidate 3210 may still be running against the tree we are
516
+ // about to replace: stop it FIRST, otherwise the swap races a live
517
+ // process (and on Windows the live handles can fail the rename/delete).
518
+ if (typeof stopOwned === 'function') {
519
+ try {
520
+ const stopped = await stopOwned();
521
+ recovery.stoppedCandidate = stopped?.ok === true;
522
+ if (!recovery.stoppedCandidate) recovery.stopError = stopped?.code ?? stopped?.error ?? 'candidate stop failed';
523
+ } catch (error) {
524
+ recovery.stopError = String(error?.message ?? error);
525
+ }
526
+ if (!recovery.stoppedCandidate) {
527
+ recovery.ok = false;
528
+ return recovery;
529
+ }
530
+ }
531
+ try { rmSync(liveRoot, { recursive: true, force: true }); } catch {}
532
+ try {
533
+ if (existsSync(prevRoot)) { rename(prevRoot, liveRoot); recovery.restore = true; }
534
+ } catch (error) {
535
+ recovery.restoreError = String(error?.message ?? error);
536
+ }
537
+ if (prepareOnly) {
538
+ recovery.ok = recovery.restore === true;
539
+ return recovery;
540
+ }
541
+ try {
542
+ const restarted = await startOwned();
543
+ recovery.restart = restarted?.ok === true;
544
+ if (!recovery.restart) recovery.restartCode = restarted?.code ?? 'DSH_RUNTIME_RESTART_FAILED';
545
+ } catch (error) {
546
+ recovery.restartError = String(error?.message ?? error);
547
+ }
548
+ recovery.ok = recovery.restore === true && recovery.restart === true;
549
+ return recovery;
550
+ }
551
+
552
+ // ---- retained runtime cohorts -------------------------------------------------
553
+
554
+ function retainedRuntimesRoot({ home }) {
555
+ return join(crewDshHome({ home }), RETAINED_RUNTIMES_DIRNAME);
556
+ }
557
+
558
+ function retainedRuntimeDir({ home, version }) {
559
+ return join(retainedRuntimesRoot({ home }), version);
560
+ }
561
+
562
+ // Probe the dsh package version inside a runtime tree (live or prev/retained).
563
+ function runtimeTreeVersion(root, read = readFileSync) {
564
+ if (!root) return null;
565
+ try {
566
+ const file = join(root, 'node_modules', '@deepseek-ai', 'dsh', 'package.json');
567
+ const parsed = JSON.parse(read(file, 'utf8'));
568
+ return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null;
569
+ } catch { return null; }
570
+ }
571
+
572
+ // Best-effort retention of the swapped-out prior runtime tree. Never throws
573
+ // and never fails the caller: retention is an optimization for offline
574
+ // rollback, not a correctness requirement of the migration itself.
575
+ function retainPriorRuntime({ home, prevRoot, rename = renameSync }) {
576
+ try {
577
+ if (!existsSync(prevRoot)) return { ok: true, retained: false };
578
+ const version = runtimeTreeVersion(prevRoot);
579
+ if (!version) {
580
+ // No version to key retention on; the tree is unreadable junk.
581
+ try { rmSync(prevRoot, { recursive: true, force: true }); } catch {}
582
+ return { ok: true, retained: false, reason: 'prior tree version unreadable; removed' };
583
+ }
584
+ const retainedRoot = retainedRuntimesRoot({ home });
585
+ const target = retainedRuntimeDir({ home, version });
586
+ mkdirSync(retainedRoot, { recursive: true });
587
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true });
588
+ rename(prevRoot, target);
589
+ return { ok: true, retained: true, version, path: target };
590
+ } catch (error) {
591
+ // A failed retain must not fail an otherwise-successful migration, and it
592
+ // must NOT delete the parked prior tree: that tree is the only offline
593
+ // rollback copy of the previous cohort. Leave it in place (a later
594
+ // operator/GC pass can retry or reap it) and report the failure loudly.
595
+ return { ok: false, retained: false, prevRoot, error: String(error?.message ?? error) };
596
+ }
597
+ }
598
+
599
+ // Locate a usable retained cohort tree. Returns the retained path when one
600
+ // exists and its manifest reports the requested version; otherwise null.
601
+ export function findRetainedRuntime({ home = homedir(), version, exists = existsSync, read = readFileSync } = {}) {
602
+ if (typeof version !== 'string' || version.length === 0) return null;
603
+ const dir = retainedRuntimeDir({ home, version });
604
+ if (!exists(dir)) return null;
605
+ if (runtimeTreeVersion(dir, read) !== version) return null;
606
+ return dir;
607
+ }
608
+
609
+ // Offline cohort restore for cross-cohort rollback: move the retained tree
610
+ // back onto live runtime/. The caller must hold the update lock.
611
+ //
612
+ // Pair-ordering contract: with prepareOnly=false this helper ALSO starts and
613
+ // verifies the 3210, which is only safe when the caller has already
614
+ // activated the matching Crew payload. Callers that must activate the
615
+ // payload FIRST (rollback/compensation pair transition) pass prepareOnly=true
616
+ // to swap the tree WITHOUT starting the process, then activate the payload,
617
+ // then start once and dual-verify. On success the retained copy is consumed
618
+ // (moved), so a subsequent rollback to the same cohort re-stages from the
619
+ // registry.
620
+ export async function restoreRetainedRuntime({
621
+ home = homedir(),
622
+ version,
623
+ stopOwned,
624
+ startOwned,
625
+ verifyOwned,
626
+ rename = renameSync,
627
+ log = () => {},
628
+ prepareOnly = false,
629
+ } = {}) {
630
+ const retained = findRetainedRuntime({ home, version });
631
+ if (!retained) {
632
+ return { ok: false, code: 'DSH_RUNTIME_RETAINED_MISSING', error: `no retained runtime for cohort ${version}` };
633
+ }
634
+ if (typeof stopOwned !== 'function') {
635
+ return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'stop callback is required' };
636
+ }
637
+ if (!prepareOnly && (typeof startOwned !== 'function' || typeof verifyOwned !== 'function')) {
638
+ return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'start/verify callbacks are required unless prepareOnly' };
639
+ }
640
+ const liveRoot = crewDshRuntimeRoot({ home });
641
+ const stop = await stopOwned();
642
+ if (!stop.ok) {
643
+ return { ok: false, code: stop.code ?? 'DSH_RUNTIME_STOP_FAILED', error: stop.error ?? 'could not stop owned 3210' };
644
+ }
645
+ try {
646
+ if (existsSync(liveRoot)) rmSync(liveRoot, { recursive: true, force: true });
647
+ rename(retained, liveRoot);
648
+ } catch (error) {
649
+ // Restore the pre-existing live tree is impossible (it was replaced only
650
+ // on success above); report and let the caller reconcile.
651
+ return { ok: false, code: 'DSH_RUNTIME_RESTORE_SWAP_FAILED', error: String(error?.message ?? error) };
652
+ }
653
+ if (prepareOnly) {
654
+ log(`- runtime tree prepared offline from retained tree (@${version}); process not started`);
655
+ return { ok: true, version, liveRoot, prepared: true };
656
+ }
657
+ const start = await startOwned();
658
+ if (!start.ok) {
659
+ return { ok: false, code: start.code ?? 'DSH_RUNTIME_START_FAILED', error: start.error ?? 'restart after restore failed', restored: true };
660
+ }
661
+ const verified = await verifyOwned();
662
+ if (!verified.ok) {
663
+ return { ok: false, code: verified.code ?? 'DSH_RUNTIME_VERIFY_FAILED', error: verified.error ?? 'identity check after restore failed', restored: true };
664
+ }
665
+ log(`- runtime cohort restored offline from retained tree (@${version})`);
666
+ return { ok: true, version, liveRoot };
667
+ }
668
+
669
+ // GC retained runtimes that no release pins. A retained cohort is needed only
670
+ // while some managed release (current or retained) declares it as its exact
671
+ // @deepseek-ai/dsh dependency. Best-effort; never throws.
672
+ export function gcRetainedRuntimes({ home = homedir(), releases = [], log = () => {} } = {}) {
673
+ const root = retainedRuntimesRoot({ home });
674
+ if (!existsSync(root)) return [];
675
+ const needed = new Set();
676
+ for (const release of releases) {
677
+ const spec = payloadDshSpec(release);
678
+ if (spec && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) needed.add(spec);
679
+ }
680
+ const removed = [];
681
+ for (const name of readdirSync(root)) {
682
+ const dir = join(root, name);
683
+ if (!needed.has(name)) {
684
+ try { rmSync(dir, { recursive: true, force: true }); removed.push(name); } catch { /* best effort */ }
685
+ }
686
+ }
687
+ return removed;
688
+ }
689
+
690
+ // Read the exact @deepseek-ai/dsh pin from a payload manifest (dependencies
691
+ // first, then peerDependencies). Exact pins only: a range or absence yields
692
+ // null so callers fail closed instead of guessing a cohort.
693
+ function payloadDshSpec(manifest) {
694
+ if (!manifest || typeof manifest !== 'object') return null;
695
+ const direct = manifest.dependencies?.['@deepseek-ai/dsh'];
696
+ if (typeof direct === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(direct)) return direct;
697
+ const peer = manifest.peerDependencies?.['@deepseek-ai/dsh'];
698
+ if (typeof peer === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(peer)) return peer;
699
+ return null;
700
+ }
701
+
702
+ export function payloadDshVersion(manifest) {
703
+ return payloadDshSpec(manifest);
704
+ }
705
+
706
+ function isObject(value) {
707
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
708
+ }
709
+
710
+ function isSafePackageName(name) {
711
+ return typeof name === 'string'
712
+ && /^(?:@[^/\s]+\/)?[^/\s]+$/u.test(name)
713
+ && !name.split('/').some((part) => part === '.' || part === '..');
714
+ }
715
+
716
+ function isWithin(root, candidate) {
717
+ const rel = relative(root, candidate);
718
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
719
+ }
720
+
721
+ function fileSpec(target) {
722
+ return `link:${target.replace(/\\/g, '/')}`;
723
+ }
724
+
725
+ function profileLinkPath(profileRoot, name) {
726
+ return join(profileRoot, 'node_modules', ...name.split('/'));
727
+ }
728
+
729
+ function readPluginRoot(root, expectedName) {
730
+ if (typeof root !== 'string' || !isAbsolute(root)) return { ok: false, code: 'INVALID_CREW_PLUGIN_ROOT' };
731
+ let targetRoot;
732
+ try { targetRoot = realpathSync(root); } catch { return { ok: false, code: 'CREW_PLUGIN_ROOT_NOT_FOUND' }; }
733
+ const packageFile = join(targetRoot, 'package.json');
734
+ let pkg;
735
+ try { pkg = JSON.parse(readFileSync(packageFile, 'utf8')); } catch { return { ok: false, code: 'CREW_PLUGIN_MANIFEST_INVALID' }; }
736
+ if (!isObject(pkg) || !isSafePackageName(pkg.name) || (expectedName !== undefined && pkg.name !== expectedName)) {
737
+ return { ok: false, code: 'CREW_PLUGIN_MANIFEST_INVALID' };
738
+ }
739
+ const patchSpec = pkg.dsh?.bundle?.patch;
740
+ if (typeof patchSpec !== 'string' || !patchSpec.trim()) return { ok: false, code: 'CREW_PLUGIN_BUNDLE_INVALID' };
741
+ const patchPath = resolve(targetRoot, patchSpec);
742
+ if (!isWithin(targetRoot, patchPath) || !existsSync(patchPath)) return { ok: false, code: 'CREW_PLUGIN_BUNDLE_INVALID' };
743
+ return { ok: true, targetRoot, packageFile, packageName: pkg.name, packageVersion: pkg.version ?? null, patchPath, pkg };
744
+ }
745
+
746
+ function readProfileManifestForRegistration(profileRoot, { create = true, defaultBundles = CREW_PROFILE_DEFAULT_BUNDLES } = {}) {
747
+ const profileManifest = join(profileRoot, 'package.json');
748
+ if (!existsSync(profileManifest)) {
749
+ if (!create) return { ok: false, code: 'PROFILE_NOT_FOUND', profileManifest };
750
+ return {
751
+ profileManifest,
752
+ manifest: {
753
+ name: `dsh-profile-${profileRoot.split(/[\\/]/u).at(-1)}`,
754
+ private: true,
755
+ dependencies: {},
756
+ dsh: { profile: { bundles: [...defaultBundles] } },
757
+ },
758
+ created: true,
759
+ };
760
+ }
761
+ let manifest;
762
+ try { manifest = JSON.parse(readFileSync(profileManifest, 'utf8')); } catch { return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' }; }
763
+ if (!isObject(manifest)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
764
+ if (manifest.dependencies !== undefined && !isObject(manifest.dependencies)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
765
+ if (manifest.dependencies && Object.values(manifest.dependencies).some((value) => typeof value !== 'string')) {
766
+ return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
767
+ }
768
+ if (manifest.dsh !== undefined && !isObject(manifest.dsh)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
769
+ if (manifest.dsh?.profile !== undefined && !isObject(manifest.dsh.profile)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
770
+ const bundles = manifest.dsh?.profile?.bundles;
771
+ if (bundles !== undefined && (!Array.isArray(bundles) || bundles.some((name) => !isSafePackageName(name)))) {
772
+ return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
773
+ }
774
+ return { profileManifest, manifest, created: false };
775
+ }
776
+
777
+ function ensureProfileScaffold(profileRoot) {
778
+ mkdirSync(profileRoot, { recursive: true });
779
+ let changed = false;
780
+ const patchFile = join(profileRoot, 'cordis.patch.yml');
781
+ if (!existsSync(patchFile)) { writeFileSync(patchFile, PROFILE_PATCH_TEMPLATE); changed = true; }
782
+ const workspaceFile = join(profileRoot, 'pnpm-workspace.yaml');
783
+ if (!existsSync(workspaceFile)) { writeFileSync(workspaceFile, PROFILE_PNPM_WORKSPACE); changed = true; }
784
+ return changed;
785
+ }
786
+
787
+ function readPackageVersionAt(dir, read = readFileSync) {
788
+ try {
789
+ const parsed = JSON.parse(read(join(dir, 'package.json'), 'utf8'));
790
+ return typeof parsed.version === 'string' && parsed.version ? parsed.version : null;
791
+ } catch { return null; }
792
+ }
793
+
794
+ // Nearest `<dir>/node_modules/<name>` walking up from `dir`, mirroring the
795
+ // resolution a require() from that directory would take.
796
+ export function resolveNearestBundleVersion(dir, name, { exists = defaultExists, read = readFileSync } = {}) {
797
+ let cursor = dir;
798
+ for (let depth = 0; depth < 12; depth += 1) {
799
+ const candidate = join(cursor, 'node_modules', ...name.split('/'));
800
+ if (exists(candidate)) return readPackageVersionAt(candidate, read);
801
+ const parent = dirname(cursor);
802
+ if (parent === cursor) break;
803
+ cursor = parent;
804
+ }
805
+ return null;
806
+ }
807
+
808
+ /**
809
+ * Stop a profile from resolving its bundle packages out of a foreign cohort.
810
+ *
811
+ * A profile first installed against a source checkout records `dsh-base` and
812
+ * `dsh-web-app` as links into that checkout. Switching the CLI to another
813
+ * cohort leaves those links behind, and because they sit in the profile's own
814
+ * node_modules they shadow the matching packages installed beside the profile.
815
+ * The host then composes the web app of one cohort while the plugin table comes
816
+ * from another; the client module table is built per cohort, so the mismatched
817
+ * plugin dies at require time with "missed the module table".
818
+ *
819
+ * Only an entry whose fallthrough provably resolves the target cohort is
820
+ * removed. A profile that deliberately tracks a different cohort keeps its
821
+ * links and is reported as skipped, never as an error.
822
+ */
823
+ export function reconcileProfileBundleCohort({
824
+ home = homedir(),
825
+ profileRoot,
826
+ version = TARGET_DSH_VERSION,
827
+ bundles = CREW_PROFILE_DEFAULT_BUNDLES,
828
+ exists = defaultExists,
829
+ read = readFileSync,
830
+ remove = (path) => rmSync(path, { recursive: true, force: true }),
831
+ } = {}) {
832
+ const root = profileRoot ?? crewProfileDir({ home });
833
+ if (typeof root !== 'string' || !isAbsolute(root)) return { ok: false, code: 'INVALID_PROFILE_ROOT' };
834
+ const modulesDir = join(root, 'node_modules');
835
+ const parentDir = dirname(root);
836
+ const unshadowed = [];
837
+ const skipped = [];
838
+ const removeBundle = (path) => remove(path);
839
+ for (const name of bundles) {
840
+ const shadowPath = join(modulesDir, ...name.split('/'));
841
+ if (!exists(shadowPath)) continue;
842
+ const shadowed = readPackageVersionAt(shadowPath, read);
843
+ if (shadowed === version) continue;
844
+ const fallthrough = resolveNearestBundleVersion(parentDir, name, { exists, read });
845
+ if (fallthrough !== version) {
846
+ skipped.push({ bundle: name, shadowed: shadowed ?? null, fallthrough: fallthrough ?? null });
847
+ continue;
848
+ }
849
+ try { removeBundle(shadowPath); } catch { return { ok: false, code: 'PROFILE_BUNDLE_UNSHADOW_FAILED', profileRoot: root, bundle: name }; }
850
+ if (exists(shadowPath)) return { ok: false, code: 'PROFILE_BUNDLE_UNSHADOW_FAILED', profileRoot: root, bundle: name };
851
+ unshadowed.push({ bundle: name, shadowed: shadowed ?? null });
852
+ }
853
+ return { ok: true, changed: unshadowed.length > 0, profileRoot: root, unshadowed, skipped };
854
+ }
855
+
856
+ function readCrewTombstones(home) {
857
+ const lifecycleFile = join(dirname(crewDshHome({ home })), 'provider-lifecycle.json');
858
+ if (!existsSync(lifecycleFile)) return { ok: true, tombstones: {} };
859
+ try {
860
+ const state = JSON.parse(readFileSync(lifecycleFile, 'utf8'));
861
+ return { ok: true, tombstones: state?.tombstones && typeof state.tombstones === 'object' && !Array.isArray(state.tombstones) ? state.tombstones : {} };
862
+ } catch { return { ok: false, code: 'PROVIDER_LIFECYCLE_STATE_INVALID' }; }
863
+ }
864
+
865
+ function reconcileCrewProfileProviders({ home, profileRoot }) {
866
+ const patchFile = join(profileRoot, 'cordis.patch.yml');
867
+ if (!existsSync(patchFile)) return { ok: true, changed: false, removed: [] };
868
+ const lifecycle = readCrewTombstones(home);
869
+ if (!lifecycle.ok) return lifecycle;
870
+ const source = readFileSync(patchFile, 'utf8');
871
+ if (source.trim() === '[]') return { ok: true, changed: false, removed: [] };
872
+ const reconciled = reconcileProviderDesiredState(source, { tombstones: lifecycle.tombstones });
873
+ if (!reconciled.ok) return reconciled;
874
+ if (reconciled.changed) writeFileSync(patchFile, reconciled.text);
875
+ return reconciled;
876
+ }
877
+
878
+ function ensureDirectoryLink(linkPath, targetRoot) {
879
+ let stat;
880
+ try { stat = lstatSync(linkPath); } catch { stat = null; }
881
+ if (stat && !stat.isSymbolicLink()) return { ok: false, code: 'CREW_PLUGIN_LINK_CONFLICT' };
882
+ if (stat) {
883
+ try {
884
+ if (realpathSync(linkPath) === targetRoot) return { ok: true, changed: false };
885
+ } catch {}
886
+ unlinkSync(linkPath);
887
+ }
888
+ mkdirSync(dirname(linkPath), { recursive: true });
889
+ symlinkSync(targetRoot, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
890
+ return { ok: true, changed: true };
891
+ }
892
+
893
+ /**
894
+ * Register a local Crew plugin without invoking DSH's pnpm-forwarding plugin
895
+ * command. The profile manifest and one loader-visible directory link are the
896
+ * only state changed; no dependency resolution or policy bypass is attempted.
897
+ */
898
+ export function ensurePluginRegistration({
899
+ profileRoot,
900
+ root,
901
+ name,
902
+ createProfile = true,
903
+ defaultBundles = CREW_PROFILE_DEFAULT_BUNDLES,
904
+ } = {}) {
905
+ const plugin = readPluginRoot(root, name);
906
+ if (!plugin.ok) return plugin;
907
+ if (typeof profileRoot !== 'string' || !isAbsolute(profileRoot)) return { ok: false, code: 'INVALID_PROFILE_ROOT' };
908
+ const profile = readProfileManifestForRegistration(profileRoot, { create: createProfile, defaultBundles });
909
+ if (profile.ok === false) return profile;
910
+ const scaffoldChanged = createProfile ? ensureProfileScaffold(profileRoot) : false;
911
+ const current = profile.manifest;
912
+ const dependencies = { ...(current.dependencies ?? {}) };
913
+ const currentBundles = current.dsh?.profile?.bundles ?? [...CREW_PROFILE_DEFAULT_BUNDLES];
914
+ const nextBundles = [];
915
+ let seen = false;
916
+ for (const bundle of currentBundles) {
917
+ if (bundle === plugin.packageName) {
918
+ if (!seen) { nextBundles.push(bundle); seen = true; }
919
+ } else nextBundles.push(bundle);
920
+ }
921
+ if (!seen) nextBundles.push(plugin.packageName);
922
+ dependencies[plugin.packageName] = fileSpec(plugin.targetRoot);
923
+ const next = {
924
+ ...current,
925
+ dependencies,
926
+ dsh: {
927
+ ...(current.dsh ?? {}),
928
+ profile: { ...(current.dsh?.profile ?? {}), bundles: nextBundles },
929
+ },
930
+ };
931
+ const manifestChanged = JSON.stringify(current) !== JSON.stringify(next) || profile.created;
932
+ const linkPath = profileLinkPath(profileRoot, plugin.packageName);
933
+ let link;
934
+ try { link = ensureDirectoryLink(linkPath, plugin.targetRoot); } catch { return { ok: false, code: 'CREW_PLUGIN_LINK_FAILED' }; }
935
+ if (!link.ok) return link;
936
+ try {
937
+ if (manifestChanged) writeFileSync(profile.profileManifest, JSON.stringify(next, null, 2) + '\n');
938
+ // Resolve the intended package entry from the validated checkout. Node's
939
+ // package-name resolution cache can retain the old target after a stale
940
+ // junction is replaced in the same process, so loader visibility is proven
941
+ // by the link target plus a normal package entry resolution from that
942
+ // target rather than by trusting a cached package-name lookup.
943
+ const resolvedEntry = createRequire(join(plugin.targetRoot, 'package.json')).resolve('.');
944
+ const resolvedRoot = realpathSync(linkPath);
945
+ if (resolvedRoot !== plugin.targetRoot || !isWithin(plugin.targetRoot, realpathSync(resolvedEntry))) {
946
+ return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' };
947
+ }
948
+ return {
949
+ ok: true,
950
+ changed: scaffoldChanged || manifestChanged || link.changed,
951
+ profileRoot,
952
+ profileManifest: profile.profileManifest,
953
+ linkPath,
954
+ targetRoot: plugin.targetRoot,
955
+ packageName: plugin.packageName,
956
+ packageVersion: plugin.packageVersion,
957
+ patchPath: plugin.patchPath,
958
+ resolvedEntry,
959
+ };
960
+ } catch { return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' }; }
961
+ }
962
+
963
+ export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
964
+ const result = ensurePluginRegistration({ profileRoot: crewProfileDir({ home }), root, name });
965
+ if (!result.ok) return result;
966
+ const reconciliation = reconcileProfileBundleCohort({ home, profileRoot: result.profileRoot });
967
+ if (!reconciliation.ok) return reconciliation;
968
+ const reconciled = reconcileCrewProfileProviders({ home, profileRoot: result.profileRoot });
969
+ if (!reconciled.ok) return reconciled;
970
+ return {
971
+ ...result,
972
+ changed: result.changed || reconciled.changed || reconciliation.changed,
973
+ provider_reconciliation: reconciled.removed ?? [],
974
+ bundle_reconciliation: reconciliation.unshadowed ?? [],
975
+ };
976
+ }
977
+
978
+ /**
979
+ * Remove one Crew plugin registration without invoking a package manager.
980
+ * This is intentionally limited to the derived Crew profile directory so a
981
+ * status/uninstall probe cannot touch the official web profile or download
982
+ * anything merely to remove a stale registration.
983
+ */
984
+ export function removeCrewPluginRegistration({ home = homedir(), name, profileRoot = crewProfileDir({ home }) } = {}) {
985
+ if (!isSafePackageName(name)) {
986
+ return { ok: false, code: 'INVALID_CREW_PLUGIN_NAME' };
987
+ }
988
+ const packageFile = join(profileRoot, 'package.json');
989
+ if (!existsSync(packageFile)) return { ok: true, removed: false };
990
+ let pkg;
991
+ try { pkg = JSON.parse(readFileSync(packageFile, 'utf8')); } catch { return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' }; }
992
+ if (!isObject(pkg)
993
+ || (pkg.dependencies !== undefined && !isObject(pkg.dependencies))
994
+ || (pkg.dsh !== undefined && !isObject(pkg.dsh))
995
+ || (pkg.dsh?.profile !== undefined && !isObject(pkg.dsh.profile))
996
+ || (pkg.dsh?.profile?.bundles !== undefined
997
+ && (!Array.isArray(pkg.dsh.profile.bundles)
998
+ || pkg.dsh.profile.bundles.some((item) => !isSafePackageName(item))))) {
999
+ return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
1000
+ }
1001
+ const hadDependency = Boolean(pkg.dependencies?.[name]);
1002
+ const bundles = Array.isArray(pkg.dsh?.profile?.bundles) ? pkg.dsh.profile.bundles : [];
1003
+ const hadBundle = bundles.includes(name);
1004
+ if (!hadDependency && !hadBundle) return { ok: true, removed: false };
1005
+ const linkPath = profileLinkPath(profileRoot, name);
1006
+ let linkStat;
1007
+ try { linkStat = lstatSync(linkPath); } catch { linkStat = null; }
1008
+ if (linkStat && !linkStat.isSymbolicLink()) return { ok: false, code: 'CREW_PLUGIN_LINK_CONFLICT' };
1009
+ const next = { ...pkg };
1010
+ if (hadDependency) {
1011
+ next.dependencies = { ...pkg.dependencies };
1012
+ delete next.dependencies[name];
1013
+ if (Object.keys(next.dependencies).length === 0) delete next.dependencies;
1014
+ }
1015
+ if (hadBundle) {
1016
+ next.dsh = { ...pkg.dsh, profile: { ...pkg.dsh.profile, bundles: bundles.filter((item) => item !== name) } };
1017
+ }
1018
+ writeFileSync(packageFile, JSON.stringify(next, null, 2) + '\n');
1019
+ if (linkStat) unlinkSync(linkPath);
1020
+ return { ok: true, removed: true };
1021
+ }