@ran-sh/dsh-crew 0.3.6 → 0.3.8

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 (85) hide show
  1. package/.claude-plugin/plugin.json +8 -8
  2. package/.mcp.json +8 -8
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +94 -180
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +94 -180
  19. package/agents/ds-flash.md +26 -26
  20. package/agents/ds-pro.md +32 -32
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +22 -22
  23. package/codex/agents/ds-flash.toml +30 -30
  24. package/codex/agents/ds-pro.toml +31 -31
  25. package/codex/agents/ds-reviewer.toml +28 -28
  26. package/codex/agents/ds-worker.toml +28 -28
  27. package/codex/prompts/dsh-config.md +3 -3
  28. package/codex/prompts/dsh-status.md +1 -1
  29. package/commands/config.md +11 -11
  30. package/commands/off.md +5 -5
  31. package/commands/on.md +5 -5
  32. package/commands/status.md +5 -5
  33. package/cordis.patch.yml +4 -4
  34. package/lib/client.js +1655 -974
  35. package/official-web-bridge/cordis.patch.yml +4 -0
  36. package/official-web-bridge/entry.mjs +1 -0
  37. package/official-web-bridge/lib/client.js +3446 -0
  38. package/official-web-bridge/package.json +26 -0
  39. package/package.json +133 -131
  40. package/scripts/build-client.mjs +32 -28
  41. package/scripts/live-crew-smoke.mjs +39 -39
  42. package/scripts/live-policy-matrix.mjs +177 -177
  43. package/scripts/policy-probe.mjs +101 -101
  44. package/scripts/setup.mjs +284 -284
  45. package/scripts/smoke-real.mjs +110 -110
  46. package/scripts/smoke.mjs +78 -78
  47. package/scripts/verify-installer-fix.mjs +26 -26
  48. package/scripts/verify-official-bridge-e2e.mjs +159 -0
  49. package/src/adaptive-routing.mjs +260 -260
  50. package/src/client/activation-summary.tsx +64 -64
  51. package/src/client/collapsible-sections.mjs +55 -0
  52. package/src/client/entry.tsx +236 -236
  53. package/src/client/index.tsx +1213 -1120
  54. package/src/config-readiness.mjs +59 -59
  55. package/src/delivery.mjs +205 -205
  56. package/src/dsh-cli-runtime.mjs +458 -447
  57. package/src/failure-classification.mjs +172 -172
  58. package/src/hub/entry.mjs +98 -98
  59. package/src/hub-client.mjs +132 -132
  60. package/src/hub-compatibility.mjs +49 -49
  61. package/src/i18n.mjs +19 -19
  62. package/src/install/cli.mjs +28 -28
  63. package/src/install/install-legacy.mjs +462 -462
  64. package/src/install/install.mjs +451 -451
  65. package/src/install/npx-lifecycle.mjs +1119 -1055
  66. package/src/install/official-web.mjs +132 -0
  67. package/src/mcp-runtime.mjs +257 -257
  68. package/src/model-catalog.mjs +173 -173
  69. package/src/model-routing.mjs +391 -391
  70. package/src/official-web-bridge.mjs +196 -0
  71. package/src/policy.mjs +197 -197
  72. package/src/readiness-matrix.mjs +169 -169
  73. package/src/runtime-controls.mjs +90 -90
  74. package/src/runtime-identity.mjs +108 -108
  75. package/src/server.mjs +477 -477
  76. package/src/status-shard.mjs +52 -52
  77. package/src/structured-error-code.mjs +38 -38
  78. package/src/vision-route.mjs +138 -138
  79. package/src/workflow-runtime.mjs +573 -573
  80. package/src/workflow.mjs +160 -160
  81. package/src/workspace-audit.mjs +231 -231
  82. package/src/workspace-isolation.mjs +365 -365
  83. package/statusline/statusline.sh +14 -14
  84. package/statusline/worker-segment.sh +35 -35
  85. package/worker.cordis.yml +77 -77
@@ -1,447 +1,458 @@
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
- symlinkSync,
17
- unlinkSync,
18
- writeFileSync,
19
- } from 'node:fs';
20
- import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
21
- import { homedir } from 'node:os';
22
- import { crewDshHome, crewProfileDir } from './install/install.mjs';
23
-
24
- export const DSH_CLI_PACKAGE = '@deepseek-ai/dsh';
25
- export const CREW_DSH_RUNTIME_DIRNAME = 'runtime';
26
- const CREW_PROFILE_DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base'];
27
- const PROFILE_PATCH_TEMPLATE = '[]\n';
28
- const PROFILE_PNPM_WORKSPACE = 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n';
29
-
30
- function defaultFindCommand(name) {
31
- const probe = process.platform === 'win32' ? 'where.exe' : 'which';
32
- const result = spawnSync(probe, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
33
- if (result.status !== 0) return null;
34
- const lines = String(result.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
35
- if (lines.length === 0) return null;
36
- if (process.platform !== 'win32') return lines[0];
37
- // where.exe may list extensionless npm/sh shims first; those are not
38
- // directly spawnable by Node on Windows, so prefer a real executable.
39
- const executable = lines.find((line) => /\.(?:exe|cmd|bat)$/iu.test(line));
40
- return executable ?? lines[0];
41
- }
42
-
43
- function defaultExists(path) {
44
- try { return existsSync(path); } catch { return false; }
45
- }
46
-
47
- function packageVersion(entry, read = readFileSync) {
48
- try {
49
- const packageFile = join(entry, '..', '..', 'package.json');
50
- const parsed = JSON.parse(read(packageFile, 'utf8'));
51
- return typeof parsed.version === 'string' ? parsed.version : null;
52
- } catch { return null; }
53
- }
54
-
55
- export function crewDshRuntimeRoot({ home = homedir() } = {}) {
56
- return join(crewDshHome({ home }), CREW_DSH_RUNTIME_DIRNAME);
57
- }
58
-
59
- export function crewDshRuntimeEntry({ home = homedir(), platform = process.platform } = {}) {
60
- const suffix = platform === 'win32' ? '.cmd' : '';
61
- return join(crewDshRuntimeRoot({ home }), 'node_modules', '.bin', `dsh${suffix}`);
62
- }
63
-
64
- export function crewDshRuntimeModule({ home = homedir() } = {}) {
65
- return join(crewDshRuntimeRoot({ home }), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
66
- }
67
-
68
- function isPathLike(value) {
69
- return typeof value === 'string' && (value.includes('/') || value.includes('\\') || extname(value) === '.cmd' || extname(value) === '.exe');
70
- }
71
-
72
- function descriptor({ kind, command, args = [], source, version = null, reusable = false }) {
73
- return Object.freeze({ kind, command, args: [...args], source, version, reusable });
74
- }
75
-
76
- function explicitDescriptor(value, { exists = defaultExists, platform = process.platform } = {}) {
77
- if (typeof value !== 'string' || !value.trim()) return null;
78
- const trimmed = value.trim();
79
- if (isPathLike(trimmed) && !exists(trimmed)) return null;
80
- if (extname(trimmed).toLowerCase() === '.js') {
81
- return descriptor({ kind: 'explicit-node', command: process.execPath, args: [trimmed], source: 'explicit', reusable: true });
82
- }
83
- return descriptor({ kind: platform === 'win32' && extname(trimmed).toLowerCase() === '.cmd' ? 'explicit-cmd' : 'explicit', command: trimmed, source: 'explicit', reusable: true });
84
- }
85
-
86
- /**
87
- * Resolve a CLI without reading DSH credentials or profile state.
88
- * `allowDownload` only affects the final npx descriptor; resolution itself
89
- * never starts a network operation.
90
- */
91
- export function resolveDshCli({
92
- home = homedir(),
93
- env = process.env,
94
- platform = process.platform,
95
- exists = defaultExists,
96
- findCommand = defaultFindCommand,
97
- allowDownload = false,
98
- includeCompatibility = true,
99
- read = readFileSync,
100
- } = {}) {
101
- const explicit = explicitDescriptor(env.DSH_CREW_DSH_CLI ?? env.DSH_CLI, { exists, platform });
102
- if (explicit) return explicit;
103
-
104
- const moduleEntry = crewDshRuntimeModule({ home });
105
- if (exists(moduleEntry)) {
106
- return descriptor({
107
- kind: 'crew-runtime',
108
- command: process.execPath,
109
- args: [moduleEntry],
110
- source: 'crew-runtime',
111
- version: packageVersion(moduleEntry, read),
112
- reusable: true,
113
- });
114
- }
115
-
116
- if (!includeCompatibility) return null;
117
-
118
- const global = findCommand('dsh');
119
- if (global) return descriptor({ kind: 'global', command: global, source: 'global' });
120
-
121
- const npx = findCommand('npx');
122
- if (!npx) return null;
123
- return descriptor({
124
- kind: allowDownload ? 'npx-download' : 'npx-local',
125
- command: npx,
126
- args: allowDownload ? ['--yes', DSH_CLI_PACKAGE] : ['--no-install', DSH_CLI_PACKAGE],
127
- source: allowDownload ? 'npx-download' : 'npx-local',
128
- });
129
- }
130
-
131
- function quoteWindowsArg(value) {
132
- const input = String(value);
133
- if (input.length > 0 && !/[\s"]/u.test(input)) return input;
134
- let output = '"';
135
- let slashes = 0;
136
- for (const char of input) {
137
- if (char === '\\') { slashes += 1; continue; }
138
- if (char === '"') {
139
- output += '\\'.repeat(slashes * 2 + 1) + '"';
140
- } else {
141
- output += '\\'.repeat(slashes) + char;
142
- }
143
- slashes = 0;
144
- }
145
- output += '\\'.repeat(slashes * 2) + '"';
146
- return output;
147
- }
148
-
149
- export { quoteWindowsArg };
150
-
151
- /** Build a shell-free invocation, including deterministic Windows .cmd handling. */
152
- export function buildDshInvocation(cli, args = [], { platform = process.platform, comspec = process.env.ComSpec ?? 'cmd.exe' } = {}) {
153
- const allArgs = [...(cli?.args ?? []), ...args].map(String);
154
- const command = String(cli?.command ?? '');
155
- if (platform === 'win32' && /\.(?:cmd|bat)$/iu.test(command)) {
156
- return {
157
- command: comspec,
158
- args: ['/d', '/s', '/c', [quoteWindowsArg(command), ...allArgs.map(quoteWindowsArg)].join(' ')],
159
- shell: false,
160
- };
161
- }
162
- return { command, args: allArgs, shell: false };
163
- }
164
-
165
- export function runResolvedDsh(cli, args = [], {
166
- home = homedir(),
167
- env = process.env,
168
- runner = spawnSync,
169
- platform = process.platform,
170
- comspec = process.env.ComSpec ?? 'cmd.exe',
171
- } = {}) {
172
- if (!cli) return { ok: false, status: -1, stdout: '', stderr: 'DSH CLI unavailable' };
173
- const invocation = buildDshInvocation(cli, args, { platform, comspec });
174
- const result = runner(invocation.command, invocation.args, {
175
- encoding: 'utf8',
176
- stdio: ['ignore', 'pipe', 'pipe'],
177
- shell: invocation.shell,
178
- env: { ...env, DSH_HOME: crewDshHome({ home }) },
179
- });
180
- return {
181
- ok: result.status === 0,
182
- status: result.status ?? -1,
183
- stdout: result.stdout ?? '',
184
- stderr: result.stderr ?? '',
185
- invocation,
186
- };
187
- }
188
-
189
- /**
190
- * Install a reusable DSH CLI into Crew-owned state. This is the only helper
191
- * that may invoke a package manager, and callers must explicitly opt into it.
192
- */
193
- export function ensureCrewDshRuntime({
194
- home = homedir(),
195
- packageSpec = DSH_CLI_PACKAGE,
196
- npmCommand = null,
197
- pnpmCommand = null,
198
- findCommand = defaultFindCommand,
199
- exists = defaultExists,
200
- runner = spawnSync,
201
- platform = process.platform,
202
- comspec = process.env.ComSpec ?? 'cmd.exe',
203
- env = process.env,
204
- } = {}) {
205
- const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
206
- if (existing?.kind === 'crew-runtime') return { ok: true, cli: existing, reused: true };
207
-
208
- const pnpm = pnpmCommand ?? findCommand('pnpm');
209
- const npm = npmCommand ?? findCommand('npm');
210
- if (!pnpm && !npm) return { ok: false, code: 'DSH_RUNTIME_INSTALLER_NOT_FOUND', error: 'pnpm/npm unavailable' };
211
- const runtimeRoot = crewDshRuntimeRoot({ home });
212
- mkdirSync(runtimeRoot, { recursive: true });
213
- const packageManager = pnpm
214
- ? descriptor({ kind: 'pnpm', command: pnpm, source: 'pnpm' })
215
- : descriptor({ kind: 'npm', command: npm, source: 'npm' });
216
- const packageArgs = pnpm
217
- ? ['add', '--dir', runtimeRoot, '--ignore-scripts', packageSpec]
218
- : ['install', '--prefix', runtimeRoot, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
219
- const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
220
- const result = runner(invocation.command, invocation.args, {
221
- encoding: 'utf8',
222
- stdio: ['ignore', 'pipe', 'pipe'],
223
- shell: invocation.shell,
224
- env: { ...env },
225
- });
226
- const cli = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
227
- if (result.status !== 0 || !cli || cli.kind !== 'crew-runtime') {
228
- return {
229
- ok: false,
230
- code: 'DSH_RUNTIME_INSTALL_FAILED',
231
- error: 'Crew-owned DSH runtime install failed',
232
- status: result.status ?? -1,
233
- stderrTail: String(result.stderr || result.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
234
- };
235
- }
236
- return { ok: true, cli, reused: false, version: cli.version, runtimeRoot };
237
- }
238
-
239
- export function describeDshCli(cli) {
240
- if (!cli) return 'unavailable';
241
- const version = cli.version ? `@${cli.version}` : '';
242
- return `${cli.kind}${version}`;
243
- }
244
-
245
- function isObject(value) {
246
- return value !== null && typeof value === 'object' && !Array.isArray(value);
247
- }
248
-
249
- function isSafePackageName(name) {
250
- return typeof name === 'string'
251
- && /^(?:@[^/\s]+\/)?[^/\s]+$/u.test(name)
252
- && !name.split('/').some((part) => part === '.' || part === '..');
253
- }
254
-
255
- function isWithin(root, candidate) {
256
- const rel = relative(root, candidate);
257
- return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
258
- }
259
-
260
- function fileSpec(target) {
261
- return `link:${target.replace(/\\/g, '/')}`;
262
- }
263
-
264
- function profileLinkPath(profileRoot, name) {
265
- return join(profileRoot, 'node_modules', ...name.split('/'));
266
- }
267
-
268
- function readPluginRoot(root, expectedName) {
269
- if (typeof root !== 'string' || !isAbsolute(root)) return { ok: false, code: 'INVALID_CREW_PLUGIN_ROOT' };
270
- let targetRoot;
271
- try { targetRoot = realpathSync(root); } catch { return { ok: false, code: 'CREW_PLUGIN_ROOT_NOT_FOUND' }; }
272
- const packageFile = join(targetRoot, 'package.json');
273
- let pkg;
274
- try { pkg = JSON.parse(readFileSync(packageFile, 'utf8')); } catch { return { ok: false, code: 'CREW_PLUGIN_MANIFEST_INVALID' }; }
275
- if (!isObject(pkg) || !isSafePackageName(pkg.name) || (expectedName !== undefined && pkg.name !== expectedName)) {
276
- return { ok: false, code: 'CREW_PLUGIN_MANIFEST_INVALID' };
277
- }
278
- const patchSpec = pkg.dsh?.bundle?.patch;
279
- if (typeof patchSpec !== 'string' || !patchSpec.trim()) return { ok: false, code: 'CREW_PLUGIN_BUNDLE_INVALID' };
280
- const patchPath = resolve(targetRoot, patchSpec);
281
- if (!isWithin(targetRoot, patchPath) || !existsSync(patchPath)) return { ok: false, code: 'CREW_PLUGIN_BUNDLE_INVALID' };
282
- return { ok: true, targetRoot, packageFile, packageName: pkg.name, packageVersion: pkg.version ?? null, patchPath, pkg };
283
- }
284
-
285
- function readProfileManifestForRegistration(profileRoot) {
286
- const profileManifest = join(profileRoot, 'package.json');
287
- if (!existsSync(profileManifest)) {
288
- return {
289
- profileManifest,
290
- manifest: {
291
- name: `dsh-profile-${profileRoot.split(/[\\/]/u).at(-1)}`,
292
- private: true,
293
- dependencies: {},
294
- dsh: { profile: { bundles: [...CREW_PROFILE_DEFAULT_BUNDLES] } },
295
- },
296
- created: true,
297
- };
298
- }
299
- let manifest;
300
- try { manifest = JSON.parse(readFileSync(profileManifest, 'utf8')); } catch { return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' }; }
301
- if (!isObject(manifest)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
302
- if (manifest.dependencies !== undefined && !isObject(manifest.dependencies)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
303
- if (manifest.dependencies && Object.values(manifest.dependencies).some((value) => typeof value !== 'string')) {
304
- return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
305
- }
306
- if (manifest.dsh !== undefined && !isObject(manifest.dsh)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
307
- if (manifest.dsh?.profile !== undefined && !isObject(manifest.dsh.profile)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
308
- const bundles = manifest.dsh?.profile?.bundles;
309
- if (bundles !== undefined && (!Array.isArray(bundles) || bundles.some((name) => !isSafePackageName(name)))) {
310
- return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
311
- }
312
- return { profileManifest, manifest, created: false };
313
- }
314
-
315
- function ensureProfileScaffold(profileRoot) {
316
- mkdirSync(profileRoot, { recursive: true });
317
- let changed = false;
318
- const patchFile = join(profileRoot, 'cordis.patch.yml');
319
- if (!existsSync(patchFile)) { writeFileSync(patchFile, PROFILE_PATCH_TEMPLATE); changed = true; }
320
- const workspaceFile = join(profileRoot, 'pnpm-workspace.yaml');
321
- if (!existsSync(workspaceFile)) { writeFileSync(workspaceFile, PROFILE_PNPM_WORKSPACE); changed = true; }
322
- return changed;
323
- }
324
-
325
- function ensureDirectoryLink(linkPath, targetRoot) {
326
- let stat;
327
- try { stat = lstatSync(linkPath); } catch { stat = null; }
328
- if (stat && !stat.isSymbolicLink()) return { ok: false, code: 'CREW_PLUGIN_LINK_CONFLICT' };
329
- if (stat) {
330
- try {
331
- if (realpathSync(linkPath) === targetRoot) return { ok: true, changed: false };
332
- } catch {}
333
- unlinkSync(linkPath);
334
- }
335
- mkdirSync(dirname(linkPath), { recursive: true });
336
- symlinkSync(targetRoot, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
337
- return { ok: true, changed: true };
338
- }
339
-
340
- /**
341
- * Register a local Crew plugin without invoking DSH's pnpm-forwarding plugin
342
- * command. The profile manifest and one loader-visible directory link are the
343
- * only state changed; no dependency resolution or policy bypass is attempted.
344
- */
345
- export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
346
- const plugin = readPluginRoot(root, name);
347
- if (!plugin.ok) return plugin;
348
- const profileRoot = crewProfileDir({ home });
349
- const profile = readProfileManifestForRegistration(profileRoot);
350
- if (profile.ok === false) return profile;
351
- const scaffoldChanged = ensureProfileScaffold(profileRoot);
352
- const current = profile.manifest;
353
- const dependencies = { ...(current.dependencies ?? {}) };
354
- const currentBundles = current.dsh?.profile?.bundles ?? [...CREW_PROFILE_DEFAULT_BUNDLES];
355
- const nextBundles = [];
356
- let seen = false;
357
- for (const bundle of currentBundles) {
358
- if (bundle === plugin.packageName) {
359
- if (!seen) { nextBundles.push(bundle); seen = true; }
360
- } else nextBundles.push(bundle);
361
- }
362
- if (!seen) nextBundles.push(plugin.packageName);
363
- dependencies[plugin.packageName] = fileSpec(plugin.targetRoot);
364
- const next = {
365
- ...current,
366
- dependencies,
367
- dsh: {
368
- ...(current.dsh ?? {}),
369
- profile: { ...(current.dsh?.profile ?? {}), bundles: nextBundles },
370
- },
371
- };
372
- const manifestChanged = JSON.stringify(current) !== JSON.stringify(next) || profile.created;
373
- const linkPath = profileLinkPath(profileRoot, plugin.packageName);
374
- let link;
375
- try { link = ensureDirectoryLink(linkPath, plugin.targetRoot); } catch { return { ok: false, code: 'CREW_PLUGIN_LINK_FAILED' }; }
376
- if (!link.ok) return link;
377
- try {
378
- if (manifestChanged) writeFileSync(profile.profileManifest, JSON.stringify(next, null, 2) + '\n');
379
- // Resolve the intended package entry from the validated checkout. Node's
380
- // package-name resolution cache can retain the old target after a stale
381
- // junction is replaced in the same process, so loader visibility is proven
382
- // by the link target plus a normal package entry resolution from that
383
- // target rather than by trusting a cached package-name lookup.
384
- const resolvedEntry = createRequire(join(plugin.targetRoot, 'package.json')).resolve('.');
385
- const resolvedRoot = realpathSync(linkPath);
386
- if (resolvedRoot !== plugin.targetRoot || !isWithin(plugin.targetRoot, realpathSync(resolvedEntry))) {
387
- return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' };
388
- }
389
- return {
390
- ok: true,
391
- changed: scaffoldChanged || manifestChanged || link.changed,
392
- profileRoot,
393
- profileManifest: profile.profileManifest,
394
- linkPath,
395
- targetRoot: plugin.targetRoot,
396
- packageName: plugin.packageName,
397
- packageVersion: plugin.packageVersion,
398
- patchPath: plugin.patchPath,
399
- resolvedEntry,
400
- };
401
- } catch { return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' }; }
402
- }
403
-
404
- /**
405
- * Remove one Crew plugin registration without invoking a package manager.
406
- * This is intentionally limited to the derived Crew profile directory so a
407
- * status/uninstall probe cannot touch the official web profile or download
408
- * anything merely to remove a stale registration.
409
- */
410
- export function removeCrewPluginRegistration({ home = homedir(), name, profileRoot = crewProfileDir({ home }) } = {}) {
411
- if (!isSafePackageName(name)) {
412
- return { ok: false, code: 'INVALID_CREW_PLUGIN_NAME' };
413
- }
414
- const packageFile = join(profileRoot, 'package.json');
415
- if (!existsSync(packageFile)) return { ok: true, removed: false };
416
- let pkg;
417
- try { pkg = JSON.parse(readFileSync(packageFile, 'utf8')); } catch { return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' }; }
418
- if (!isObject(pkg)
419
- || (pkg.dependencies !== undefined && !isObject(pkg.dependencies))
420
- || (pkg.dsh !== undefined && !isObject(pkg.dsh))
421
- || (pkg.dsh?.profile !== undefined && !isObject(pkg.dsh.profile))
422
- || (pkg.dsh?.profile?.bundles !== undefined
423
- && (!Array.isArray(pkg.dsh.profile.bundles)
424
- || pkg.dsh.profile.bundles.some((item) => !isSafePackageName(item))))) {
425
- return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
426
- }
427
- const hadDependency = Boolean(pkg.dependencies?.[name]);
428
- const bundles = Array.isArray(pkg.dsh?.profile?.bundles) ? pkg.dsh.profile.bundles : [];
429
- const hadBundle = bundles.includes(name);
430
- if (!hadDependency && !hadBundle) return { ok: true, removed: false };
431
- const linkPath = profileLinkPath(profileRoot, name);
432
- let linkStat;
433
- try { linkStat = lstatSync(linkPath); } catch { linkStat = null; }
434
- if (linkStat && !linkStat.isSymbolicLink()) return { ok: false, code: 'CREW_PLUGIN_LINK_CONFLICT' };
435
- const next = { ...pkg };
436
- if (hadDependency) {
437
- next.dependencies = { ...pkg.dependencies };
438
- delete next.dependencies[name];
439
- if (Object.keys(next.dependencies).length === 0) delete next.dependencies;
440
- }
441
- if (hadBundle) {
442
- next.dsh = { ...pkg.dsh, profile: { ...pkg.dsh.profile, bundles: bundles.filter((item) => item !== name) } };
443
- }
444
- writeFileSync(packageFile, JSON.stringify(next, null, 2) + '\n');
445
- if (linkStat) unlinkSync(linkPath);
446
- return { ok: true, removed: true };
447
- }
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
+ symlinkSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from 'node:fs';
20
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+ import { crewDshHome, crewProfileDir } from './install/install.mjs';
23
+
24
+ export const DSH_CLI_PACKAGE = '@deepseek-ai/dsh';
25
+ export const CREW_DSH_RUNTIME_DIRNAME = 'runtime';
26
+ const CREW_PROFILE_DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'];
27
+ const PROFILE_PATCH_TEMPLATE = '[]\n';
28
+ const PROFILE_PNPM_WORKSPACE = 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n';
29
+
30
+ function defaultFindCommand(name) {
31
+ const probe = process.platform === 'win32' ? 'where.exe' : 'which';
32
+ const result = spawnSync(probe, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
33
+ if (result.status !== 0) return null;
34
+ const lines = String(result.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
35
+ if (lines.length === 0) return null;
36
+ if (process.platform !== 'win32') return lines[0];
37
+ // where.exe may list extensionless npm/sh shims first; those are not
38
+ // directly spawnable by Node on Windows, so prefer a real executable.
39
+ const executable = lines.find((line) => /\.(?:exe|cmd|bat)$/iu.test(line));
40
+ return executable ?? lines[0];
41
+ }
42
+
43
+ function defaultExists(path) {
44
+ try { return existsSync(path); } catch { return false; }
45
+ }
46
+
47
+ function packageVersion(entry, read = readFileSync) {
48
+ try {
49
+ const packageFile = join(entry, '..', '..', 'package.json');
50
+ const parsed = JSON.parse(read(packageFile, 'utf8'));
51
+ return typeof parsed.version === 'string' ? parsed.version : null;
52
+ } catch { return null; }
53
+ }
54
+
55
+ export function crewDshRuntimeRoot({ home = homedir() } = {}) {
56
+ return join(crewDshHome({ home }), CREW_DSH_RUNTIME_DIRNAME);
57
+ }
58
+
59
+ export function crewDshRuntimeEntry({ home = homedir(), platform = process.platform } = {}) {
60
+ const suffix = platform === 'win32' ? '.cmd' : '';
61
+ return join(crewDshRuntimeRoot({ home }), 'node_modules', '.bin', `dsh${suffix}`);
62
+ }
63
+
64
+ export function crewDshRuntimeModule({ home = homedir() } = {}) {
65
+ return join(crewDshRuntimeRoot({ home }), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
66
+ }
67
+
68
+ function isPathLike(value) {
69
+ return typeof value === 'string' && (value.includes('/') || value.includes('\\') || extname(value) === '.cmd' || extname(value) === '.exe');
70
+ }
71
+
72
+ function descriptor({ kind, command, args = [], source, version = null, reusable = false }) {
73
+ return Object.freeze({ kind, command, args: [...args], source, version, reusable });
74
+ }
75
+
76
+ function explicitDescriptor(value, { exists = defaultExists, platform = process.platform } = {}) {
77
+ if (typeof value !== 'string' || !value.trim()) return null;
78
+ const trimmed = value.trim();
79
+ if (isPathLike(trimmed) && !exists(trimmed)) return null;
80
+ if (extname(trimmed).toLowerCase() === '.js') {
81
+ return descriptor({ kind: 'explicit-node', command: process.execPath, args: [trimmed], source: 'explicit', reusable: true });
82
+ }
83
+ return descriptor({ kind: platform === 'win32' && extname(trimmed).toLowerCase() === '.cmd' ? 'explicit-cmd' : 'explicit', command: trimmed, source: 'explicit', reusable: true });
84
+ }
85
+
86
+ /**
87
+ * Resolve a CLI without reading DSH credentials or profile state.
88
+ * `allowDownload` only affects the final npx descriptor; resolution itself
89
+ * never starts a network operation.
90
+ */
91
+ export function resolveDshCli({
92
+ home = homedir(),
93
+ env = process.env,
94
+ platform = process.platform,
95
+ exists = defaultExists,
96
+ findCommand = defaultFindCommand,
97
+ allowDownload = false,
98
+ includeCompatibility = true,
99
+ read = readFileSync,
100
+ } = {}) {
101
+ const explicit = explicitDescriptor(env.DSH_CREW_DSH_CLI ?? env.DSH_CLI, { exists, platform });
102
+ if (explicit) return explicit;
103
+
104
+ const moduleEntry = crewDshRuntimeModule({ home });
105
+ if (exists(moduleEntry)) {
106
+ return descriptor({
107
+ kind: 'crew-runtime',
108
+ command: process.execPath,
109
+ args: [moduleEntry],
110
+ source: 'crew-runtime',
111
+ version: packageVersion(moduleEntry, read),
112
+ reusable: true,
113
+ });
114
+ }
115
+
116
+ if (!includeCompatibility) return null;
117
+
118
+ const global = findCommand('dsh');
119
+ if (global) return descriptor({ kind: 'global', command: global, source: 'global' });
120
+
121
+ const npx = findCommand('npx');
122
+ if (!npx) return null;
123
+ return descriptor({
124
+ kind: allowDownload ? 'npx-download' : 'npx-local',
125
+ command: npx,
126
+ args: allowDownload ? ['--yes', DSH_CLI_PACKAGE] : ['--no-install', DSH_CLI_PACKAGE],
127
+ source: allowDownload ? 'npx-download' : 'npx-local',
128
+ });
129
+ }
130
+
131
+ function quoteWindowsArg(value) {
132
+ const input = String(value);
133
+ if (input.length > 0 && !/[\s"]/u.test(input)) return input;
134
+ let output = '"';
135
+ let slashes = 0;
136
+ for (const char of input) {
137
+ if (char === '\\') { slashes += 1; continue; }
138
+ if (char === '"') {
139
+ output += '\\'.repeat(slashes * 2 + 1) + '"';
140
+ } else {
141
+ output += '\\'.repeat(slashes) + char;
142
+ }
143
+ slashes = 0;
144
+ }
145
+ output += '\\'.repeat(slashes * 2) + '"';
146
+ return output;
147
+ }
148
+
149
+ export { quoteWindowsArg };
150
+
151
+ /** Build a shell-free invocation, including deterministic Windows .cmd handling. */
152
+ export function buildDshInvocation(cli, args = [], { platform = process.platform, comspec = process.env.ComSpec ?? 'cmd.exe' } = {}) {
153
+ const allArgs = [...(cli?.args ?? []), ...args].map(String);
154
+ const command = String(cli?.command ?? '');
155
+ if (platform === 'win32' && /\.(?:cmd|bat)$/iu.test(command)) {
156
+ return {
157
+ command: comspec,
158
+ args: ['/d', '/s', '/c', [quoteWindowsArg(command), ...allArgs.map(quoteWindowsArg)].join(' ')],
159
+ shell: false,
160
+ };
161
+ }
162
+ return { command, args: allArgs, shell: false };
163
+ }
164
+
165
+ export function runResolvedDsh(cli, args = [], {
166
+ home = homedir(),
167
+ env = process.env,
168
+ runner = spawnSync,
169
+ platform = process.platform,
170
+ comspec = process.env.ComSpec ?? 'cmd.exe',
171
+ } = {}) {
172
+ if (!cli) return { ok: false, status: -1, stdout: '', stderr: 'DSH CLI unavailable' };
173
+ const invocation = buildDshInvocation(cli, args, { platform, comspec });
174
+ const result = runner(invocation.command, invocation.args, {
175
+ encoding: 'utf8',
176
+ stdio: ['ignore', 'pipe', 'pipe'],
177
+ shell: invocation.shell,
178
+ env: { ...env, DSH_HOME: crewDshHome({ home }) },
179
+ });
180
+ return {
181
+ ok: result.status === 0,
182
+ status: result.status ?? -1,
183
+ stdout: result.stdout ?? '',
184
+ stderr: result.stderr ?? '',
185
+ invocation,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Install a reusable DSH CLI into Crew-owned state. This is the only helper
191
+ * that may invoke a package manager, and callers must explicitly opt into it.
192
+ */
193
+ export function ensureCrewDshRuntime({
194
+ home = homedir(),
195
+ packageSpec = DSH_CLI_PACKAGE,
196
+ npmCommand = null,
197
+ pnpmCommand = null,
198
+ findCommand = defaultFindCommand,
199
+ exists = defaultExists,
200
+ runner = spawnSync,
201
+ platform = process.platform,
202
+ comspec = process.env.ComSpec ?? 'cmd.exe',
203
+ env = process.env,
204
+ } = {}) {
205
+ const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
206
+ if (existing?.kind === 'crew-runtime') return { ok: true, cli: existing, reused: true };
207
+
208
+ const pnpm = pnpmCommand ?? findCommand('pnpm');
209
+ const npm = npmCommand ?? findCommand('npm');
210
+ if (!pnpm && !npm) return { ok: false, code: 'DSH_RUNTIME_INSTALLER_NOT_FOUND', error: 'pnpm/npm unavailable' };
211
+ const runtimeRoot = crewDshRuntimeRoot({ home });
212
+ mkdirSync(runtimeRoot, { recursive: true });
213
+ const packageManager = pnpm
214
+ ? descriptor({ kind: 'pnpm', command: pnpm, source: 'pnpm' })
215
+ : descriptor({ kind: 'npm', command: npm, source: 'npm' });
216
+ const packageArgs = pnpm
217
+ ? ['add', '--dir', runtimeRoot, '--ignore-scripts', packageSpec]
218
+ : ['install', '--prefix', runtimeRoot, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
219
+ const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
220
+ const result = runner(invocation.command, invocation.args, {
221
+ encoding: 'utf8',
222
+ stdio: ['ignore', 'pipe', 'pipe'],
223
+ shell: invocation.shell,
224
+ env: { ...env },
225
+ });
226
+ const cli = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
227
+ if (result.status !== 0 || !cli || cli.kind !== 'crew-runtime') {
228
+ return {
229
+ ok: false,
230
+ code: 'DSH_RUNTIME_INSTALL_FAILED',
231
+ error: 'Crew-owned DSH runtime install failed',
232
+ status: result.status ?? -1,
233
+ stderrTail: String(result.stderr || result.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
234
+ };
235
+ }
236
+ return { ok: true, cli, reused: false, version: cli.version, runtimeRoot };
237
+ }
238
+
239
+ export function describeDshCli(cli) {
240
+ if (!cli) return 'unavailable';
241
+ const version = cli.version ? `@${cli.version}` : '';
242
+ return `${cli.kind}${version}`;
243
+ }
244
+
245
+ function isObject(value) {
246
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
247
+ }
248
+
249
+ function isSafePackageName(name) {
250
+ return typeof name === 'string'
251
+ && /^(?:@[^/\s]+\/)?[^/\s]+$/u.test(name)
252
+ && !name.split('/').some((part) => part === '.' || part === '..');
253
+ }
254
+
255
+ function isWithin(root, candidate) {
256
+ const rel = relative(root, candidate);
257
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
258
+ }
259
+
260
+ function fileSpec(target) {
261
+ return `link:${target.replace(/\\/g, '/')}`;
262
+ }
263
+
264
+ function profileLinkPath(profileRoot, name) {
265
+ return join(profileRoot, 'node_modules', ...name.split('/'));
266
+ }
267
+
268
+ function readPluginRoot(root, expectedName) {
269
+ if (typeof root !== 'string' || !isAbsolute(root)) return { ok: false, code: 'INVALID_CREW_PLUGIN_ROOT' };
270
+ let targetRoot;
271
+ try { targetRoot = realpathSync(root); } catch { return { ok: false, code: 'CREW_PLUGIN_ROOT_NOT_FOUND' }; }
272
+ const packageFile = join(targetRoot, 'package.json');
273
+ let pkg;
274
+ try { pkg = JSON.parse(readFileSync(packageFile, 'utf8')); } catch { return { ok: false, code: 'CREW_PLUGIN_MANIFEST_INVALID' }; }
275
+ if (!isObject(pkg) || !isSafePackageName(pkg.name) || (expectedName !== undefined && pkg.name !== expectedName)) {
276
+ return { ok: false, code: 'CREW_PLUGIN_MANIFEST_INVALID' };
277
+ }
278
+ const patchSpec = pkg.dsh?.bundle?.patch;
279
+ if (typeof patchSpec !== 'string' || !patchSpec.trim()) return { ok: false, code: 'CREW_PLUGIN_BUNDLE_INVALID' };
280
+ const patchPath = resolve(targetRoot, patchSpec);
281
+ if (!isWithin(targetRoot, patchPath) || !existsSync(patchPath)) return { ok: false, code: 'CREW_PLUGIN_BUNDLE_INVALID' };
282
+ return { ok: true, targetRoot, packageFile, packageName: pkg.name, packageVersion: pkg.version ?? null, patchPath, pkg };
283
+ }
284
+
285
+ function readProfileManifestForRegistration(profileRoot, { create = true, defaultBundles = CREW_PROFILE_DEFAULT_BUNDLES } = {}) {
286
+ const profileManifest = join(profileRoot, 'package.json');
287
+ if (!existsSync(profileManifest)) {
288
+ if (!create) return { ok: false, code: 'PROFILE_NOT_FOUND', profileManifest };
289
+ return {
290
+ profileManifest,
291
+ manifest: {
292
+ name: `dsh-profile-${profileRoot.split(/[\\/]/u).at(-1)}`,
293
+ private: true,
294
+ dependencies: {},
295
+ dsh: { profile: { bundles: [...defaultBundles] } },
296
+ },
297
+ created: true,
298
+ };
299
+ }
300
+ let manifest;
301
+ try { manifest = JSON.parse(readFileSync(profileManifest, 'utf8')); } catch { return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' }; }
302
+ if (!isObject(manifest)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
303
+ if (manifest.dependencies !== undefined && !isObject(manifest.dependencies)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
304
+ if (manifest.dependencies && Object.values(manifest.dependencies).some((value) => typeof value !== 'string')) {
305
+ return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
306
+ }
307
+ if (manifest.dsh !== undefined && !isObject(manifest.dsh)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
308
+ if (manifest.dsh?.profile !== undefined && !isObject(manifest.dsh.profile)) return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
309
+ const bundles = manifest.dsh?.profile?.bundles;
310
+ if (bundles !== undefined && (!Array.isArray(bundles) || bundles.some((name) => !isSafePackageName(name)))) {
311
+ return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
312
+ }
313
+ return { profileManifest, manifest, created: false };
314
+ }
315
+
316
+ function ensureProfileScaffold(profileRoot) {
317
+ mkdirSync(profileRoot, { recursive: true });
318
+ let changed = false;
319
+ const patchFile = join(profileRoot, 'cordis.patch.yml');
320
+ if (!existsSync(patchFile)) { writeFileSync(patchFile, PROFILE_PATCH_TEMPLATE); changed = true; }
321
+ const workspaceFile = join(profileRoot, 'pnpm-workspace.yaml');
322
+ if (!existsSync(workspaceFile)) { writeFileSync(workspaceFile, PROFILE_PNPM_WORKSPACE); changed = true; }
323
+ return changed;
324
+ }
325
+
326
+ function ensureDirectoryLink(linkPath, targetRoot) {
327
+ let stat;
328
+ try { stat = lstatSync(linkPath); } catch { stat = null; }
329
+ if (stat && !stat.isSymbolicLink()) return { ok: false, code: 'CREW_PLUGIN_LINK_CONFLICT' };
330
+ if (stat) {
331
+ try {
332
+ if (realpathSync(linkPath) === targetRoot) return { ok: true, changed: false };
333
+ } catch {}
334
+ unlinkSync(linkPath);
335
+ }
336
+ mkdirSync(dirname(linkPath), { recursive: true });
337
+ symlinkSync(targetRoot, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
338
+ return { ok: true, changed: true };
339
+ }
340
+
341
+ /**
342
+ * Register a local Crew plugin without invoking DSH's pnpm-forwarding plugin
343
+ * command. The profile manifest and one loader-visible directory link are the
344
+ * only state changed; no dependency resolution or policy bypass is attempted.
345
+ */
346
+ export function ensurePluginRegistration({
347
+ profileRoot,
348
+ root,
349
+ name,
350
+ createProfile = true,
351
+ defaultBundles = CREW_PROFILE_DEFAULT_BUNDLES,
352
+ } = {}) {
353
+ const plugin = readPluginRoot(root, name);
354
+ if (!plugin.ok) return plugin;
355
+ if (typeof profileRoot !== 'string' || !isAbsolute(profileRoot)) return { ok: false, code: 'INVALID_PROFILE_ROOT' };
356
+ const profile = readProfileManifestForRegistration(profileRoot, { create: createProfile, defaultBundles });
357
+ if (profile.ok === false) return profile;
358
+ const scaffoldChanged = createProfile ? ensureProfileScaffold(profileRoot) : false;
359
+ const current = profile.manifest;
360
+ const dependencies = { ...(current.dependencies ?? {}) };
361
+ const currentBundles = current.dsh?.profile?.bundles ?? [...CREW_PROFILE_DEFAULT_BUNDLES];
362
+ const nextBundles = [];
363
+ let seen = false;
364
+ for (const bundle of currentBundles) {
365
+ if (bundle === plugin.packageName) {
366
+ if (!seen) { nextBundles.push(bundle); seen = true; }
367
+ } else nextBundles.push(bundle);
368
+ }
369
+ if (!seen) nextBundles.push(plugin.packageName);
370
+ dependencies[plugin.packageName] = fileSpec(plugin.targetRoot);
371
+ const next = {
372
+ ...current,
373
+ dependencies,
374
+ dsh: {
375
+ ...(current.dsh ?? {}),
376
+ profile: { ...(current.dsh?.profile ?? {}), bundles: nextBundles },
377
+ },
378
+ };
379
+ const manifestChanged = JSON.stringify(current) !== JSON.stringify(next) || profile.created;
380
+ const linkPath = profileLinkPath(profileRoot, plugin.packageName);
381
+ let link;
382
+ try { link = ensureDirectoryLink(linkPath, plugin.targetRoot); } catch { return { ok: false, code: 'CREW_PLUGIN_LINK_FAILED' }; }
383
+ if (!link.ok) return link;
384
+ try {
385
+ if (manifestChanged) writeFileSync(profile.profileManifest, JSON.stringify(next, null, 2) + '\n');
386
+ // Resolve the intended package entry from the validated checkout. Node's
387
+ // package-name resolution cache can retain the old target after a stale
388
+ // junction is replaced in the same process, so loader visibility is proven
389
+ // by the link target plus a normal package entry resolution from that
390
+ // target rather than by trusting a cached package-name lookup.
391
+ const resolvedEntry = createRequire(join(plugin.targetRoot, 'package.json')).resolve('.');
392
+ const resolvedRoot = realpathSync(linkPath);
393
+ if (resolvedRoot !== plugin.targetRoot || !isWithin(plugin.targetRoot, realpathSync(resolvedEntry))) {
394
+ return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' };
395
+ }
396
+ return {
397
+ ok: true,
398
+ changed: scaffoldChanged || manifestChanged || link.changed,
399
+ profileRoot,
400
+ profileManifest: profile.profileManifest,
401
+ linkPath,
402
+ targetRoot: plugin.targetRoot,
403
+ packageName: plugin.packageName,
404
+ packageVersion: plugin.packageVersion,
405
+ patchPath: plugin.patchPath,
406
+ resolvedEntry,
407
+ };
408
+ } catch { return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' }; }
409
+ }
410
+
411
+ export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
412
+ return ensurePluginRegistration({ profileRoot: crewProfileDir({ home }), root, name });
413
+ }
414
+
415
+ /**
416
+ * Remove one Crew plugin registration without invoking a package manager.
417
+ * This is intentionally limited to the derived Crew profile directory so a
418
+ * status/uninstall probe cannot touch the official web profile or download
419
+ * anything merely to remove a stale registration.
420
+ */
421
+ export function removeCrewPluginRegistration({ home = homedir(), name, profileRoot = crewProfileDir({ home }) } = {}) {
422
+ if (!isSafePackageName(name)) {
423
+ return { ok: false, code: 'INVALID_CREW_PLUGIN_NAME' };
424
+ }
425
+ const packageFile = join(profileRoot, 'package.json');
426
+ if (!existsSync(packageFile)) return { ok: true, removed: false };
427
+ let pkg;
428
+ try { pkg = JSON.parse(readFileSync(packageFile, 'utf8')); } catch { return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' }; }
429
+ if (!isObject(pkg)
430
+ || (pkg.dependencies !== undefined && !isObject(pkg.dependencies))
431
+ || (pkg.dsh !== undefined && !isObject(pkg.dsh))
432
+ || (pkg.dsh?.profile !== undefined && !isObject(pkg.dsh.profile))
433
+ || (pkg.dsh?.profile?.bundles !== undefined
434
+ && (!Array.isArray(pkg.dsh.profile.bundles)
435
+ || pkg.dsh.profile.bundles.some((item) => !isSafePackageName(item))))) {
436
+ return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' };
437
+ }
438
+ const hadDependency = Boolean(pkg.dependencies?.[name]);
439
+ const bundles = Array.isArray(pkg.dsh?.profile?.bundles) ? pkg.dsh.profile.bundles : [];
440
+ const hadBundle = bundles.includes(name);
441
+ if (!hadDependency && !hadBundle) return { ok: true, removed: false };
442
+ const linkPath = profileLinkPath(profileRoot, name);
443
+ let linkStat;
444
+ try { linkStat = lstatSync(linkPath); } catch { linkStat = null; }
445
+ if (linkStat && !linkStat.isSymbolicLink()) return { ok: false, code: 'CREW_PLUGIN_LINK_CONFLICT' };
446
+ const next = { ...pkg };
447
+ if (hadDependency) {
448
+ next.dependencies = { ...pkg.dependencies };
449
+ delete next.dependencies[name];
450
+ if (Object.keys(next.dependencies).length === 0) delete next.dependencies;
451
+ }
452
+ if (hadBundle) {
453
+ next.dsh = { ...pkg.dsh, profile: { ...pkg.dsh.profile, bundles: bundles.filter((item) => item !== name) } };
454
+ }
455
+ writeFileSync(packageFile, JSON.stringify(next, null, 2) + '\n');
456
+ if (linkStat) unlinkSync(linkPath);
457
+ return { ok: true, removed: true };
458
+ }