@ran-sh/dsh-crew 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,9 +38,25 @@ Codex Desktop / Claude Code
38
38
 
39
39
  ## Install
40
40
 
41
- Prerequisites: **Node.js**, **Git**, and **pnpm**.
41
+ Prerequisites: **Node.js** (Git is needed only for coding-worker isolation).
42
42
 
43
- Recommended source setup:
43
+ Recommended: manage DSH Crew directly through npm/npx — no clone required:
44
+
45
+ ```bash
46
+ npx @ran-sh/dsh-crew@latest install
47
+ ```
48
+
49
+ Manage the installation later with the same CLI:
50
+
51
+ ```bash
52
+ npx @ran-sh/dsh-crew@latest status
53
+ npx @ran-sh/dsh-crew@latest update
54
+ npx @ran-sh/dsh-crew@latest uninstall # add --purge to also remove config/backups
55
+ ```
56
+
57
+ An npx install persists the already-built package under Crew-owned state (`~/.config/dsh-crew/app`) before registering it, so the installation never depends on a transient npx cache and does not require pnpm, a lockfile, or rebuilding anything.
58
+
59
+ Developer / source setup (alternative path):
44
60
 
45
61
  ```bash
46
62
  git clone https://github.com/Ran-sh/dsh-crew.git
@@ -48,7 +64,7 @@ cd dsh-crew
48
64
  node scripts/setup.mjs install
49
65
  ```
50
66
 
51
- Windows can use:
67
+ Windows source checkouts can use:
52
68
 
53
69
  ```bat
54
70
  install.cmd
@@ -97,14 +113,26 @@ A coding worker receives a temporary git worktree at the requested revision, so
97
113
 
98
114
  ## Update / uninstall
99
115
 
100
- Update a source install:
116
+ npx-managed installs update in place (config, credentials, and backups are preserved; the candidate is staged and validated before switching):
117
+
118
+ ```bash
119
+ npx @ran-sh/dsh-crew@latest update
120
+ ```
121
+
122
+ Uninstall an npx-managed install:
123
+
124
+ ```bash
125
+ npx @ran-sh/dsh-crew@latest uninstall
126
+ ```
127
+
128
+ Update a source install instead with:
101
129
 
102
130
  ```bash
103
131
  git pull --ff-only
104
132
  node scripts/setup.mjs install
105
133
  ```
106
134
 
107
- Uninstall Crew and its integrations:
135
+ Uninstall Crew from a source checkout:
108
136
 
109
137
  ```bash
110
138
  node scripts/setup.mjs uninstall
package/README.zh.md CHANGED
@@ -38,9 +38,25 @@ Codex Desktop / Claude Code
38
38
 
39
39
  ## 安装
40
40
 
41
- 需要:**Node.jsGit、pnpm**。
41
+ 需要:**Node.js**(编码 worker 隔离需要 **Git**)。
42
42
 
43
- 推荐从源码安装:
43
+ 推荐直接通过 npm/npx 管理,无需克隆仓库:
44
+
45
+ ```bash
46
+ npx @ran-sh/dsh-crew@latest install
47
+ ```
48
+
49
+ 后续管理使用同一 CLI:
50
+
51
+ ```bash
52
+ npx @ran-sh/dsh-crew@latest status
53
+ npx @ran-sh/dsh-crew@latest update
54
+ npx @ran-sh/dsh-crew@latest uninstall # 加 --purge 才会同时删除配置/备份
55
+ ```
56
+
57
+ npx 安装会把已构建好的包持久化到 Crew 自有状态(`~/.config/dsh-crew/app`)后再注册,安装不依赖临时的 npx 缓存,也不需要 pnpm、lockfile 或任何构建步骤。
58
+
59
+ 开发者 / 源码安装(备选路径):
44
60
 
45
61
  ```bash
46
62
  git clone https://github.com/Ran-sh/dsh-crew.git
@@ -48,7 +64,7 @@ cd dsh-crew
48
64
  node scripts/setup.mjs install
49
65
  ```
50
66
 
51
- Windows 也可以直接运行:
67
+ Windows 源码检出也可以直接运行:
52
68
 
53
69
  ```bat
54
70
  install.cmd
@@ -97,14 +113,26 @@ Use ds-reviewer to review the implementation.
97
113
 
98
114
  ## 更新 / 卸载
99
115
 
100
- 更新源码安装:
116
+ npx 安装使用 update 原地升级(配置、凭据和备份保留;候选包先暂存校验再切换):
117
+
118
+ ```bash
119
+ npx @ran-sh/dsh-crew@latest update
120
+ ```
121
+
122
+ 卸载 npx 安装:
123
+
124
+ ```bash
125
+ npx @ran-sh/dsh-crew@latest uninstall
126
+ ```
127
+
128
+ 源码安装的更新方式:
101
129
 
102
130
  ```bash
103
131
  git pull --ff-only
104
132
  node scripts/setup.mjs install
105
133
  ```
106
134
 
107
- 卸载 Crew 与集成:
135
+ 卸载源码安装:
108
136
 
109
137
  ```bash
110
138
  node scripts/setup.mjs uninstall
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // dsh-crew CLI entry for the packaged npm payload.
3
+ //
4
+ // npx @ran-sh/dsh-crew@latest install|status|update|uninstall
5
+ // npm exec --package=@ran-sh/dsh-crew@latest -- dsh-crew status
6
+ //
7
+ // The lifecycle implementation lives in src/install/npx-lifecycle.mjs so the
8
+ // commands are testable in-process; this wrapper only dispatches.
9
+
10
+ import { runNpxCli } from '../src/install/npx-lifecycle.mjs';
11
+
12
+ process.exitCode = await runNpxCli();
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
+ "bin": {
7
+ "dsh-crew": "./bin/dsh-crew.mjs"
8
+ },
6
9
  "description": "DeepSeek Harness plugin: dispatch work to DSH agents from Claude Code / Codex, as native subagents with live progress",
7
10
  "dsh": {
8
11
  "bundle": {
@@ -105,6 +108,7 @@
105
108
  "agent"
106
109
  ],
107
110
  "files": [
111
+ "bin",
108
112
  "lib",
109
113
  "src",
110
114
  "scripts",
@@ -31,7 +31,13 @@ function defaultFindCommand(name) {
31
31
  const probe = process.platform === 'win32' ? 'where.exe' : 'which';
32
32
  const result = spawnSync(probe, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
33
33
  if (result.status !== 0) return null;
34
- return String(result.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? 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];
35
41
  }
36
42
 
37
43
  function defaultExists(path) {
@@ -219,7 +225,13 @@ export function ensureCrewDshRuntime({
219
225
  });
220
226
  const cli = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
221
227
  if (result.status !== 0 || !cli || cli.kind !== 'crew-runtime') {
222
- return { ok: false, code: 'DSH_RUNTIME_INSTALL_FAILED', error: 'Crew-owned DSH runtime install failed', status: result.status ?? -1 };
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
+ };
223
235
  }
224
236
  return { ok: true, cli, reused: false, version: cli.version, runtimeRoot };
225
237
  }
@@ -229,14 +229,16 @@ export function uninstallCodex({ home = homedir() } = {}) {
229
229
  return { ok: true, actions: actions.length ? actions : ['nothing to remove'] };
230
230
  }
231
231
 
232
- export async function installClaudeCode({ home = homedir(), statusline = false } = {}) {
232
+ export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
233
233
  const actions = [];
234
234
 
235
235
  // The repository carries its own marketplace manifest (.claude-plugin/
236
236
  // marketplace.json with source "."), so the marketplace root IS the plugin
237
237
  // checkout itself — no parent-directory layout assumption. This keeps the
238
238
  // installer working when the repo is cloned anywhere (e.g. Desktop\dsh-crew).
239
- const mpDir = ROOT;
239
+ // `root` lets an npx-managed install point every rendered path at the
240
+ // durable Crew-owned payload instead of a transient package extraction dir.
241
+ const mpDir = root;
240
242
  actions.push(`marketplace: ${mpDir} (self-marketplace, source .)`);
241
243
 
242
244
  // 2. settings.json: marketplace + enabledPlugins + permissions allowlist.
@@ -286,7 +288,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false }
286
288
  settings.permissions.allow = allow;
287
289
 
288
290
  if (statusline && !settings.statusLine) {
289
- settings.statusLine = { type: 'command', command: `bash ${join(ROOT, 'statusline', 'statusline.sh')}` };
291
+ settings.statusLine = { type: 'command', command: `bash ${join(root, 'statusline', 'statusline.sh')}` };
290
292
  actions.push('statusline: installed');
291
293
  } else if (statusline) {
292
294
  actions.push('statusline: skipped (one already configured)');
@@ -327,17 +329,17 @@ export async function installClaudeCode({ home = homedir(), statusline = false }
327
329
  return { ok: true, actions };
328
330
  }
329
331
 
330
- export function installCodex({ home = homedir(), scope } = {}) {
332
+ export function installCodex({ home = homedir(), scope, root = ROOT } = {}) {
331
333
  const actions = [];
332
334
  const agentsDir = scope === 'project' ? join(process.cwd(), '.codex', 'agents') : join(home, '.codex', 'agents');
333
335
  mkdirSync(agentsDir, { recursive: true });
334
- const srcDir = join(ROOT, 'codex', 'agents');
336
+ const srcDir = join(root, 'codex', 'agents');
335
337
  // Windows drive paths must render with forward slashes: backslashes in a
336
338
  // TOML basic string are escape sequences (\\U\\u... are invalid), which made
337
339
  // the role files unparseable for Codex Desktop. node accepts forward slashes
338
340
  // on Windows, so the absolute path rendered as D:/... is both valid TOML and
339
- // runnable.
340
- const renderedPath = join(ROOT, 'src', 'server.mjs').replace(/\\/g, '/');
341
+ // runnable. `root` targets the durable installed payload for npx installs.
342
+ const renderedPath = join(root, 'src', 'server.mjs').replace(/\\/g, '/');
341
343
  for (const f of readdirSync(srcDir).filter((f) => f.endsWith('.toml'))) {
342
344
  const dest = join(agentsDir, f);
343
345
  const bak = backup(dest);
@@ -349,7 +351,7 @@ export function installCodex({ home = homedir(), scope } = {}) {
349
351
  }
350
352
  const promptsDir = scope === 'project' ? join(process.cwd(), '.codex', 'prompts') : join(home, '.codex', 'prompts');
351
353
  mkdirSync(promptsDir, { recursive: true });
352
- const promptsSrc = join(ROOT, 'codex', 'prompts');
354
+ const promptsSrc = join(root, 'codex', 'prompts');
353
355
  for (const f of readdirSync(promptsSrc).filter((f) => f.endsWith('.md'))) {
354
356
  writeFileSync(join(promptsDir, f), readFileSync(join(promptsSrc, f), 'utf8'));
355
357
  actions.push(`prompt: ${join(promptsDir, f)}`);
@@ -0,0 +1,797 @@
1
+ // v0.3.3 npx-managed lifecycle: install / status / update / uninstall.
2
+ //
3
+ // Public UX:
4
+ // npx @ran-sh/dsh-crew@latest install|status|update|uninstall
5
+ //
6
+ // An npx invocation runs from a transient package-manager extraction/cache
7
+ // path. This module therefore persists the already-built package payload into
8
+ // Crew-owned state BEFORE registering it with the Harness profile, so the
9
+ // registration never depends on the cache, tarball, or temp extraction dir:
10
+ //
11
+ // <home>/.config/dsh-crew/app/current.json active-release pointer
12
+ // <home>/.config/dsh-crew/app/releases/<stamp>/ durable installed payloads
13
+ //
14
+ // Everything else (config.json, credentials, backups, the isolated Harness
15
+ // home) is never touched by install/update; uninstall removes only the
16
+ // Crew-managed payload above plus registrations/integrations, and keeps the
17
+ // normal Crew config unless --purge is explicitly requested.
18
+ //
19
+ // Source checkouts keep using scripts/setup.mjs; this module is for the
20
+ // packaged npm payload and never requires pnpm lockfiles, devDependencies,
21
+ // or a client rebuild.
22
+
23
+ import { spawnSync } from 'node:child_process';
24
+ import {
25
+ cpSync,
26
+ existsSync,
27
+ lstatSync,
28
+ mkdirSync,
29
+ readdirSync,
30
+ readFileSync,
31
+ realpathSync,
32
+ rmSync,
33
+ writeFileSync,
34
+ } from 'node:fs';
35
+ import { createRequire } from 'node:module';
36
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { homedir } from 'node:os';
39
+ import * as realInstaller from './install.mjs';
40
+ import { crewProfileDir } from './install.mjs';
41
+ import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration } from '../dsh-cli-runtime.mjs';
42
+
43
+ export const CREW_APP_DIRNAME = 'app';
44
+ export const RELEASES_DIRNAME = 'releases';
45
+ export const CURRENT_POINTER_FILENAME = 'current.json';
46
+ export const KEEP_RELEASES = 2;
47
+ export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
48
+
49
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
50
+
51
+ export function crewAppRoot({ home = homedir() } = {}) {
52
+ return join(home, '.config', 'dsh-crew', CREW_APP_DIRNAME);
53
+ }
54
+
55
+ export function crewReleasesDir({ home = homedir() } = {}) {
56
+ return join(crewAppRoot({ home }), RELEASES_DIRNAME);
57
+ }
58
+
59
+ export function currentPointerFile({ home = homedir() } = {}) {
60
+ return join(crewAppRoot({ home }), CURRENT_POINTER_FILENAME);
61
+ }
62
+
63
+ /** Package root of the currently executing (candidate) instance. */
64
+ export function runningPackageRoot() {
65
+ return PACKAGE_ROOT;
66
+ }
67
+
68
+ function readManifest(root) {
69
+ try {
70
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function timestampStamp(now = new Date()) {
77
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
78
+ return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}T${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}Z`;
79
+ }
80
+
81
+ let releaseSalt = 0;
82
+ function uniqueReleaseName(version) {
83
+ // Second-resolution timestamps can collide across rapid sequential runs in
84
+ // the same process; a process-local counter keeps release names unique so
85
+ // an update never reuses (and thus never clobbers) an existing release.
86
+ releaseSalt += 1;
87
+ return `${timestampStamp()}-${process.pid}-${releaseSalt.toString(36)}-${version}`;
88
+ }
89
+
90
+ function isoNow() {
91
+ const offsetMinutes = -new Date().getTimezoneOffset();
92
+ const sign = offsetMinutes >= 0 ? '+' : '-';
93
+ const abs = Math.abs(offsetMinutes);
94
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
95
+ return `${new Date().getFullYear()}-${pad(new Date().getMonth() + 1)}-${pad(new Date().getDate())}T${pad(new Date().getHours())}:${pad(new Date().getMinutes())}:${pad(new Date().getSeconds())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
96
+ }
97
+
98
+ // ---- installed-payload pointer ----------------------------------------------
99
+
100
+ /**
101
+ * Read the active installed-release pointer. Returns null when no valid
102
+ * pointer exists. The pointed-at directory must still exist with a matching
103
+ * manifest to count as installed.
104
+ */
105
+ export function readCurrentPointer({ home = homedir() } = {}) {
106
+ const file = currentPointerFile({ home });
107
+ if (!existsSync(file)) return null;
108
+ let raw;
109
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
110
+ if (!raw || typeof raw !== 'object') return null;
111
+ if (typeof raw.name !== 'string' || typeof raw.version !== 'string' || typeof raw.path !== 'string') return null;
112
+ if (!isAbsolute(raw.path)) return null;
113
+ return raw;
114
+ }
115
+
116
+ function writeCurrentPointer({ home, name, version, path }) {
117
+ mkdirSync(dirname(currentPointerFile({ home })), { recursive: true });
118
+ const pointer = { name, version, path, installed_at: isoNow(), managed_by: 'npx' };
119
+ writeFileSync(currentPointerFile({ home }), JSON.stringify(pointer, null, 2) + '\n');
120
+ return pointer;
121
+ }
122
+
123
+ // ---- dependency tree materialization ----------------------------------------
124
+
125
+ function listPackageEdges(manifest) {
126
+ // Returns [{name, optional}] covering dependencies and
127
+ // optionalDependencies; platform-specific optional bits stay non-fatal.
128
+ const edges = [];
129
+ for (const [name] of Object.entries(manifest?.dependencies ?? {})) {
130
+ edges.push({ name, optional: false });
131
+ }
132
+ for (const [name] of Object.entries(manifest?.optionalDependencies ?? {})) {
133
+ if (!edges.some((edge) => edge.name === name)) edges.push({ name, optional: true });
134
+ }
135
+ return edges.filter((edge) => typeof edge.name === 'string' && edge.name.trim());
136
+ }
137
+
138
+ function findPackageDir(fromRoot, name, exists = existsSync) {
139
+ // Node-style upward resolution: for each ancestor directory of fromRoot,
140
+ // probe <ancestor>/node_modules/<name>. Covers npm's hoisted npx cache and
141
+ // pnpm's virtual store (once a package is realpathed into .pnpm/<pkg>/
142
+ // node_modules, walking up finds its exact sibling closure).
143
+ const parts = name.split('/');
144
+ const seen = new Set();
145
+ let cursor = fromRoot;
146
+ while (true) {
147
+ const candidate = join(cursor, 'node_modules', ...parts);
148
+ if (exists(candidate)) {
149
+ try {
150
+ const real = realpathSync(candidate);
151
+ if (!seen.has(real)) { seen.add(real); return real; }
152
+ } catch { /* skip unreadable entry */ }
153
+ }
154
+ const parent = dirname(cursor);
155
+ if (parent === cursor) return null;
156
+ cursor = parent;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Copy the dependency closure of `names` out of the running instance's
162
+ * dependency tree into `<toRoot>/node_modules`. Offline-safe: it replicates
163
+ * exactly the dependency bits the candidate just executed with. Optional
164
+ * edges (e.g. cross-platform native binaries) are copied when present but
165
+ * reported separately when absent, so npm can reconcile them per-platform.
166
+ * Returns { copied: Map<name, realSource>, missing: string[], missingOptional: string[] }.
167
+ */
168
+ export function copyProductionDependencyTree({
169
+ fromRoot,
170
+ toRoot,
171
+ names,
172
+ exists = existsSync,
173
+ copyDir = (src, dest) => cpSync(src, dest, { recursive: true, force: true, dereference: true }),
174
+ } = {}) {
175
+ const toNodeModules = join(toRoot, 'node_modules');
176
+ mkdirSync(toNodeModules, { recursive: true });
177
+ const copied = new Map();
178
+ const missing = [];
179
+ const missingOptional = [];
180
+ const queue = names.map((entry) => (typeof entry === 'string'
181
+ ? { name: entry, fromRoot, optional: false }
182
+ : { ...entry, fromRoot }));
183
+ while (queue.length > 0) {
184
+ const { name, fromRoot: searchRoot, optional } = queue.shift();
185
+ if (copied.has(name) || missing.includes(name) || missingOptional.includes(name)) continue;
186
+ const source = findPackageDir(searchRoot ?? fromRoot, name, exists);
187
+ if (!source) {
188
+ if (optional) missingOptional.push(name);
189
+ else missing.push(name);
190
+ continue;
191
+ }
192
+ const dest = join(toNodeModules, ...name.split('/'));
193
+ copyDir(source, dest);
194
+ copied.set(name, source);
195
+ const depManifest = readManifest(source);
196
+ for (const edge of listPackageEdges(depManifest)) {
197
+ if (!copied.has(edge.name) && !missing.includes(edge.name) && !missingOptional.includes(edge.name)) {
198
+ // Resolve children relative to the copied package's own physical
199
+ // location so nested/virtual-store layouts resolve correctly.
200
+ queue.push({ name: edge.name, fromRoot: dirname(source), optional: edge.optional || optional });
201
+ }
202
+ }
203
+ }
204
+ return { copied, missing, missingOptional };
205
+ }
206
+
207
+ function defaultNpmInstaller(stageRoot, log) {
208
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
209
+ const result = spawnSync(npm, [
210
+ 'install', '--prefix', stageRoot,
211
+ '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund',
212
+ '--no-package-lock', '--loglevel=error',
213
+ ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', timeout: 600_000, windowsHide: true, env: sanitizedPackageManagerEnv() });
214
+ if (result.status !== 0) {
215
+ log(`- npm fallback install failed (${result.status}): ${(result.stderr || result.stdout || '').trim().slice(-300)}`);
216
+ return false;
217
+ }
218
+ return true;
219
+ }
220
+
221
+ // ---- payload staging ---------------------------------------------------------
222
+
223
+ /**
224
+ * Stage the candidate into its FINAL release directory, guarded by an
225
+ * incompleteness marker. Committing = removing the marker (a single-file
226
+ * operation that is reliable on all platforms); activation = writing the
227
+ * pointer last. This avoids directory renames, which Windows can refuse with
228
+ * transient EPERM while a freshly written tree is being scanned.
229
+ *
230
+ * The persisted manifest merges the exact-pinned DSH peer cohort into
231
+ * `dependencies` (peer declarations are dropped): the installed payload runs
232
+ * standalone — `src/server.mjs` statically imports DSH peers — so those
233
+ * packages must exist inside the release, not be assumed from a host.
234
+ */
235
+ export function stageCandidatePayload({
236
+ sourceRoot = runningPackageRoot(),
237
+ home = homedir(),
238
+ log = () => {},
239
+ now = new Date(),
240
+ npmInstaller = defaultNpmInstaller,
241
+ copyTree = copyProductionDependencyTree,
242
+ smoke = defaultPayloadSmoke,
243
+ } = {}) {
244
+ const manifest = readManifest(sourceRoot);
245
+ if (!manifest?.name || !manifest?.version) return { ok: false, code: 'CANDIDATE_MANIFEST_INVALID' };
246
+ if (!existsSync(join(sourceRoot, 'src', 'server.mjs')) || !existsSync(join(sourceRoot, 'cordis.patch.yml'))) {
247
+ return { ok: false, code: 'CANDIDATE_NOT_RUNNABLE' };
248
+ }
249
+
250
+ const releasesDir = crewReleasesDir({ home });
251
+ mkdirSync(releasesDir, { recursive: true });
252
+ const stageDir = join(releasesDir, uniqueReleaseName(manifest.version));
253
+ rmSync(stageDir, { recursive: true, force: true });
254
+ mkdirSync(stageDir, { recursive: true });
255
+ writeFileSync(join(stageDir, INCOMPLETE_MARKER), String(Date.now()) + '\n');
256
+
257
+ // Copy every top-level entry referenced by manifest.files (directories are
258
+ // copied whole; single-segment wildcards match within their directory).
259
+ const entries = new Set(['package.json']);
260
+ for (const pattern of manifest.files ?? []) {
261
+ if (typeof pattern !== 'string' || !pattern.trim()) continue;
262
+ if (!pattern.includes('*')) { entries.add(pattern); continue; }
263
+ const base = dirname(pattern);
264
+ const regex = new RegExp(`^${pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')}$`);
265
+ const scanDir = join(sourceRoot, base);
266
+ if (!existsSync(scanDir)) continue;
267
+ for (const item of readdirSync(scanDir)) {
268
+ const candidatePath = base === '.' ? item : `${base.replace(/\\/g, '/')}/${item}`;
269
+ if (regex.test(candidatePath)) entries.add(candidatePath);
270
+ }
271
+ }
272
+
273
+ for (const entry of entries) {
274
+ const source = join(sourceRoot, entry);
275
+ if (!existsSync(source)) continue;
276
+ cpSync(source, join(stageDir, entry), { recursive: true, force: true, dereference: true });
277
+ }
278
+
279
+ // Persist an adjusted manifest: identity/runtime fields stay; production
280
+ // dependencies gain the exact-pinned peer cohort so the payload runs
281
+ // standalone (src/server.mjs statically imports DSH peers). Optional
282
+ // dependencies keep their own section so npm reconciles them per-platform.
283
+ const stagedManifest = { ...manifest };
284
+ delete stagedManifest.devDependencies;
285
+ delete stagedManifest.peerDependencies;
286
+ delete stagedManifest.peerDependenciesMeta;
287
+ const optionalNames = new Set(Object.keys(manifest.optionalDependencies ?? {}));
288
+ stagedManifest.dependencies = {
289
+ ...(manifest.peerDependencies ?? {}),
290
+ ...(manifest.dependencies ?? {}),
291
+ };
292
+ writeFileSync(join(stageDir, 'package.json'), JSON.stringify(stagedManifest, null, 2) + '\n');
293
+
294
+ // Materialize dependencies: prefer replicating the exact bits the candidate
295
+ // ran with (offline-safe); fall back to npm for the rest (under npx this is
296
+ // normally the exact-pinned DSH peer cohort).
297
+ const depEdges = [
298
+ ...Object.keys(stagedManifest.dependencies).map((name) => ({ name, optional: false })),
299
+ ...Object.keys(manifest.optionalDependencies ?? {}).map((name) => ({ name, optional: true })),
300
+ ];
301
+ const { missing, missingOptional } = copyTree({ fromRoot: sourceRoot, toRoot: stageDir, names: depEdges });
302
+ if (missingOptional.length > 0) {
303
+ log(`- platform-optional dependencies left to npm (${missingOptional.slice(0, 4).join(', ')}${missingOptional.length > 4 ? ', …' : ''})`);
304
+ }
305
+ if (missing.length > 0) {
306
+ log(`- local dependency replication incomplete (${missing.join(', ')}); falling back to npm`);
307
+ if (!npmInstaller(stageDir, log)) {
308
+ rmSync(stageDir, { recursive: true, force: true });
309
+ return { ok: false, code: 'DEPENDENCY_MATERIALIZE_FAILED', missing };
310
+ }
311
+ }
312
+
313
+ const validated = validateInstalledPayload(stageDir, { expectedName: manifest.name, expectedVersion: manifest.version, allowIncomplete: true });
314
+ if (!validated.ok) {
315
+ rmSync(stageDir, { recursive: true, force: true });
316
+ return { ok: false, code: 'STAGE_VALIDATION_FAILED', detail: validated.errors };
317
+ }
318
+ const smoked = smoke(stageDir);
319
+ if (!smoked.ok) {
320
+ rmSync(stageDir, { recursive: true, force: true });
321
+ return { ok: false, code: 'STAGE_SMOKE_FAILED', detail: smoked.detail };
322
+ }
323
+ return { ok: true, stageDir, manifest, releasesDir };
324
+ }
325
+
326
+ /**
327
+ * Boot-smoke a staged payload with the real Node binary: the CLI entry must
328
+ * start and answer --help from inside the staged tree alone.
329
+ */
330
+ export function defaultPayloadSmoke(dir, { nodePath = process.execPath, runner = spawnSync } = {}) {
331
+ const result = runner(nodePath, [join(dir, 'bin', 'dsh-crew.mjs'), '--help'], {
332
+ encoding: 'utf8', timeout: 120_000, windowsHide: true,
333
+ env: { ...process.env },
334
+ });
335
+ if (result.status === 0) return { ok: true };
336
+ return { ok: false, detail: `bin --help exited ${result.status}: ${(result.stderr || result.stdout || '').trim().slice(-300)}` };
337
+ }
338
+
339
+ // Keyword boundaries reject identifiers containing the keywords
340
+ // ('legacy-import') and member calls (.from); captures stay on one line and
341
+ // bounded — real specifiers never span lines.
342
+ const IMPORT_SPECIFIER_RE = /(?<![\w.\-])(?:from|import|require)\b\s*\(?\s*["']([^"'\n]{1,200})["']/g;
343
+
344
+ function collectExternalSpecifiers(dir) {
345
+ const specifiers = new Set();
346
+ const walk = (root) => {
347
+ if (!existsSync(root)) return;
348
+ for (const item of readdirSync(root, { withFileTypes: true })) {
349
+ const full = join(root, item.name);
350
+ if (item.isDirectory()) walk(full);
351
+ else if (/\.(?:mjs|js)$/.test(item.name)) {
352
+ let content = '';
353
+ try { content = readFileSync(full, 'utf8'); } catch { continue; }
354
+ for (const match of content.matchAll(IMPORT_SPECIFIER_RE)) {
355
+ specifiers.add(match[1]);
356
+ }
357
+ }
358
+ }
359
+ };
360
+ walk(join(dir, 'src'));
361
+ walk(join(dir, 'bin'));
362
+ return [...specifiers].filter((spec) =>
363
+ !spec.startsWith('.') && !spec.startsWith('/') && !spec.startsWith('node:') && !isAbsolute(spec));
364
+ }
365
+
366
+ /**
367
+ * Prove a payload directory is runnable on its own: correct identity, all
368
+ * shipped runtime artifacts present, every declared dependency vendored, and
369
+ * every external module specifier the payload's source actually imports
370
+ * resolvable FROM THE PERSISTED LOCATION (never from a cache or checkout).
371
+ */
372
+ export function validateInstalledPayload(dir, { expectedName, expectedVersion, allowIncomplete = false } = {}) {
373
+ const errors = [];
374
+ const manifest = readManifest(dir);
375
+ if (!manifest) errors.push('package.json missing or invalid');
376
+ else {
377
+ if (expectedName && manifest.name !== expectedName) errors.push(`package name mismatch (${manifest.name})`);
378
+ if (expectedVersion && manifest.version !== expectedVersion) errors.push(`package version mismatch (${manifest.version})`);
379
+ for (const dep of Object.keys(manifest.dependencies ?? {})) {
380
+ if (!existsSync(join(dir, 'node_modules', ...dep.split('/'), 'package.json'))) {
381
+ errors.push(`vendored dependency missing from payload: ${dep}`);
382
+ }
383
+ }
384
+ }
385
+ if (manifest) {
386
+ const requireFromPayload = createRequire(join(dir, 'package.json'));
387
+ for (const spec of collectExternalSpecifiers(dir)) {
388
+ try {
389
+ requireFromPayload.resolve(spec);
390
+ } catch {
391
+ errors.push(`import target not resolvable from payload: ${spec}`);
392
+ }
393
+ }
394
+ }
395
+ for (const rel of ['cordis.patch.yml', 'src/server.mjs', 'src/hub/entry.mjs', 'lib/client.js', 'bin/dsh-crew.mjs']) {
396
+ if (!existsSync(join(dir, rel))) errors.push(`payload artifact missing: ${rel}`);
397
+ }
398
+ if (!allowIncomplete && existsSync(join(dir, INCOMPLETE_MARKER))) errors.push('release is still marked incomplete');
399
+ return { ok: errors.length === 0, errors, manifest };
400
+ }
401
+
402
+ // ---- release commit / activation ---------------------------------------------
403
+
404
+ function commitStagedRelease({ stageDir, manifest, home }) {
405
+ rmSync(join(stageDir, INCOMPLETE_MARKER));
406
+ writeCurrentPointer({ home, name: manifest.name, version: manifest.version, path: stageDir });
407
+ gcOldReleases({ home });
408
+ return stageDir;
409
+ }
410
+
411
+ const STALE_INCOMPLETE_MS = 24 * 60 * 60 * 1000;
412
+
413
+ function gcOldReleases({ home, keep = KEEP_RELEASES }) {
414
+ const pointer = readCurrentPointer({ home });
415
+ const releasesDir = crewReleasesDir({ home });
416
+ if (!existsSync(releasesDir)) return;
417
+ const removed = [];
418
+ const dirs = readdirSync(releasesDir)
419
+ .map((name) => join(releasesDir, name))
420
+ .filter((dir) => !pointer || dir !== pointer.path);
421
+ const incomplete = [];
422
+ const complete = [];
423
+ for (const dir of dirs) {
424
+ if (existsSync(join(dir, INCOMPLETE_MARKER))) {
425
+ // Never race a concurrent staging; only reap clearly abandoned ones.
426
+ try {
427
+ if (Date.now() - lstatSync(dir).mtimeMs > STALE_INCOMPLETE_MS) incomplete.push(dir);
428
+ } catch { /* ignore */ }
429
+ continue;
430
+ }
431
+ complete.push(dir);
432
+ }
433
+ complete.sort();
434
+ while (complete.length > Math.max(0, keep - 1)) {
435
+ const victim = complete.shift();
436
+ try { rmSync(victim, { recursive: true, force: true }); removed.push(victim); } catch { /* best effort */ }
437
+ }
438
+ for (const victim of incomplete) {
439
+ try { rmSync(victim, { recursive: true, force: true }); removed.push(victim); } catch { /* best effort */ }
440
+ }
441
+ return removed;
442
+ }
443
+
444
+ function registrationLinkPath({ home, name }) {
445
+ return join(crewProfileDir({ home }), 'node_modules', ...name.split('/'));
446
+ }
447
+
448
+ function registrationHealthy({ home, name, releaseDir }) {
449
+ const link = registrationLinkPath({ home, name });
450
+ if (!existsSync(link)) return false;
451
+ try { return realpathSync(link) === realpathSync(releaseDir); } catch { return false; }
452
+ }
453
+
454
+ /**
455
+ * Environment for child package managers. Under `npm exec`/npx the process
456
+ * inherits dozens of npm_config_* variables describing the transient exec
457
+ * context; leaking them into pnpm/npm children can misdirect their stores and
458
+ * config resolution. Package-manager children must see the user's real
459
+ * environment, not our execution context.
460
+ */
461
+ export function sanitizedPackageManagerEnv(baseEnv = process.env) {
462
+ const env = {};
463
+ for (const [key, value] of Object.entries(baseEnv)) {
464
+ if (/^npm_(config_|lifecycle|package_|execpath$|node_execpath$)/i.test(key)) continue;
465
+ env[key] = value;
466
+ }
467
+ return env;
468
+ }
469
+
470
+ async function ensureRuntimeStep({ home, log, ensureRuntime }) {
471
+ const ensure = ensureRuntime ?? ((opts) => {
472
+ const r = ensureCrewDshRuntime({ ...opts, env: sanitizedPackageManagerEnv() });
473
+ if (!r.ok && r.stderrTail) {
474
+ log(` (runtime installer said: ${r.stderrTail})`);
475
+ }
476
+ return r.ok ? { ok: true, version: r.cli?.version ?? null } : { ok: false, error: r.error ?? r.code ?? 'runtime bootstrap failed' };
477
+ });
478
+ const r = await ensure({ home });
479
+ if (!r?.ok) {
480
+ log(`✗ reusable Crew DSH runtime unavailable: ${r?.error ?? 'unknown error'}`);
481
+ return false;
482
+ }
483
+ log(`✓ reusable Crew DSH runtime${r.version ? ` (@${r.version})` : ''}`);
484
+ return true;
485
+ }
486
+
487
+ // ---- commands -----------------------------------------------------------------
488
+
489
+ function currentInstallationHealth({ home }) {
490
+ const pointer = readCurrentPointer({ home });
491
+ if (!pointer) return { installed: false, healthy: false };
492
+ if (!existsSync(pointer.path)) return { installed: false, healthy: false, pointer };
493
+ const validated = validateInstalledPayload(pointer.path, { expectedName: pointer.name, expectedVersion: pointer.version });
494
+ const registered = registrationHealthy({ home, name: pointer.name, releaseDir: pointer.path });
495
+ return { installed: true, healthy: validated.ok && registered, validated, registered, pointer };
496
+ }
497
+
498
+ async function activateRelease({ home, releaseDir, manifest, log, installer }) {
499
+ const registration = ensureCrewPluginRegistration({ home, root: releaseDir, name: manifest.name });
500
+ if (!registration.ok) {
501
+ log(`✗ Harness registration failed (${registration.code ?? 'unknown'})`);
502
+ return false;
503
+ }
504
+ log(`✓ Harness plugin registered (dedicated dsh-crew profile → ${releaseDir})`);
505
+
506
+ const codex = installer.installCodex({ home, root: releaseDir });
507
+ if (codex.ok === false) {
508
+ log(`✗ Codex Desktop integration failed: ${(codex.actions ?? []).join('; ')}`);
509
+ return false;
510
+ }
511
+ log('✓ Codex Desktop integration');
512
+
513
+ const claude = await installer.installClaudeCode({ home, root: releaseDir });
514
+ if (claude.ok === false) {
515
+ log(`✗ Claude Code integration failed`);
516
+ return false;
517
+ }
518
+ log('✓ Claude Code integration');
519
+ return true;
520
+ }
521
+
522
+ export async function npxInstall({
523
+ home = homedir(),
524
+ log = console.log,
525
+ sourceRoot,
526
+ installer = realInstaller,
527
+ ensureRuntime,
528
+ npmInstaller,
529
+ } = {}) {
530
+ log('DSH Crew installer (npx-managed)');
531
+ const candidateRoot = sourceRoot ?? runningPackageRoot();
532
+ const manifest = readManifest(candidateRoot);
533
+ if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
534
+
535
+ const health = currentInstallationHealth({ home });
536
+ if (health.installed && health.pointer.version === manifest.version && health.validated.ok) {
537
+ // Same-version reinstall: repair activation surfaces without restaging.
538
+ log(`- installed payload ${manifest.version} already present and valid; repairing activation`);
539
+ const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest, log, installer });
540
+ if (!activated) return { ok: false, error: 'activation failed' };
541
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
542
+ log('');
543
+ log('Done.');
544
+ return { ok: true, repaired: true, version: manifest.version, path: health.pointer.path };
545
+ }
546
+
547
+ const staged = stageCandidatePayload({ sourceRoot: candidateRoot, home, log, npmInstaller });
548
+ if (!staged.ok) {
549
+ log(`✗ staging failed (${staged.code})${staged.detail ? `: ${staged.detail.join('; ')}` : ''}`);
550
+ return { ok: false, error: `staging failed (${staged.code})` };
551
+ }
552
+ log(`✓ candidate payload staged (${manifest.version})`);
553
+
554
+ const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
555
+ log(`✓ durable release committed under Crew-owned state`);
556
+
557
+ const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
558
+ if (!activated) return { ok: false, error: 'activation failed' };
559
+
560
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
561
+
562
+ log('');
563
+ log('Done.');
564
+ log('Restart DeepSeek Harness and Codex Desktop.');
565
+ return { ok: true, version: manifest.version, path: releaseDir };
566
+ }
567
+
568
+ export async function npxUpdate({
569
+ home = homedir(),
570
+ log = console.log,
571
+ sourceRoot,
572
+ installer = realInstaller,
573
+ ensureRuntime,
574
+ npmInstaller,
575
+ } = {}) {
576
+ log('DSH Crew updater (npx-managed)');
577
+ const candidateRoot = sourceRoot ?? runningPackageRoot();
578
+ const manifest = readManifest(candidateRoot);
579
+ if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
580
+
581
+ const health = currentInstallationHealth({ home });
582
+
583
+ if (health.installed && health.healthy && health.pointer.version === manifest.version) {
584
+ log(`- already current (${manifest.version}); repairing registration/integrations idempotently`);
585
+ const activated = await activateRelease({ home, releaseDir: health.pointer.path, manifest, log, installer });
586
+ if (!activated) return { ok: false, error: 'activation failed' };
587
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
588
+ log('');
589
+ log('Done.');
590
+ return { ok: true, idempotent: true, version: manifest.version, path: health.pointer.path };
591
+ }
592
+
593
+ // Stale, unhealthy, or missing installation: stage the candidate fully and
594
+ // validate it before switching. The previous usable release is left in place
595
+ // until the replacement has been committed and activated successfully.
596
+ if (health.installed && !health.healthy) {
597
+ log('- existing installation is stale or incomplete; repairing via fresh candidate staging');
598
+ }
599
+
600
+ const staged = stageCandidatePayload({ sourceRoot: candidateRoot, home, log, npmInstaller });
601
+ if (!staged.ok) {
602
+ log(`✗ staging failed (${staged.code})${staged.detail ? `: ${staged.detail.join('; ')}` : ''}`);
603
+ return { ok: false, error: `staging failed (${staged.code})` };
604
+ }
605
+ log(`✓ candidate payload staged and validated (${manifest.version})`);
606
+
607
+ const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
608
+ log('✓ durable release committed under Crew-owned state');
609
+
610
+ const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
611
+ if (!activated) return { ok: false, error: 'activation failed' };
612
+
613
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
614
+
615
+ log('');
616
+ log('Done.');
617
+ log('Restart DeepSeek Harness and Codex Desktop.');
618
+ return { ok: true, updated: true, version: manifest.version, path: releaseDir };
619
+ }
620
+
621
+ export function npxStatus({
622
+ home = homedir(),
623
+ log = console.log,
624
+ sourceRoot,
625
+ installer = realInstaller,
626
+ } = {}) {
627
+ const candidateRoot = sourceRoot ?? runningPackageRoot();
628
+ const manifest = readManifest(candidateRoot);
629
+ const candidateVersion = manifest?.version ?? null;
630
+
631
+ const pointer = readCurrentPointer({ home });
632
+ let installedLine = 'not installed';
633
+ let installedVersion = null;
634
+ if (pointer) {
635
+ if (existsSync(pointer.path)) {
636
+ const validated = validateInstalledPayload(pointer.path, { expectedName: pointer.name, expectedVersion: pointer.version });
637
+ if (validated.ok) {
638
+ installedVersion = pointer.version;
639
+ installedLine = `${pointer.version} (${pointer.path})`;
640
+ } else {
641
+ installedVersion = pointer.version;
642
+ installedLine = `${pointer.version} at ${pointer.path} (unverifiable/damaged)`;
643
+ }
644
+ } else {
645
+ installedLine = `pointer references missing payload (${pointer.path})`;
646
+ }
647
+ }
648
+
649
+ // Read-only DSH plugin state from the dedicated Crew profile only; the
650
+ // official web profile layout is intentionally never inspected.
651
+ let dshPlugin = 'not installed';
652
+ const profilePkgFile = join(crewProfileDir({ home }), 'package.json');
653
+ const packageName = manifest?.name ?? pointer?.name ?? null;
654
+ if (packageName && existsSync(profilePkgFile)) {
655
+ try {
656
+ const pkg = JSON.parse(readFileSync(profilePkgFile, 'utf8'));
657
+ const listed = Boolean(pkg.dependencies?.[packageName]) || Boolean(pkg.dsh?.profile?.bundles?.includes?.(packageName));
658
+ const linkOk = packageName && existsSync(registrationLinkPath({ home, name: packageName }));
659
+ dshPlugin = listed && linkOk ? 'installed' : listed ? 'registered but payload link missing' : 'not installed';
660
+ } catch { dshPlugin = 'unknown'; }
661
+ }
662
+
663
+ const st = installer.installStatus ? installer.installStatus({ home }) : realInstaller.installStatus({ home });
664
+ const codex = st?.codex?.installed ? 'installed' : 'not installed';
665
+ const claude = st?.claude?.installed ? 'installed' : 'not installed';
666
+
667
+ log(`DSH Crew CLI (npx candidate): ${candidateVersion ?? 'unknown'}`);
668
+ log(`Installed DSH Crew: ${installedLine}`);
669
+ log(`DSH plugin: ${dshPlugin} (dedicated dsh-crew profile; official web profile ignored)`);
670
+ log(`Codex Desktop integration: ${codex}`);
671
+ log(`Claude Code integration: ${claude}`);
672
+
673
+ return {
674
+ ok: true,
675
+ candidateVersion,
676
+ installedVersion,
677
+ installedPath: pointer?.path ?? null,
678
+ dshPlugin,
679
+ codex,
680
+ claude,
681
+ };
682
+ }
683
+
684
+ export async function npxUninstall({
685
+ home = homedir(),
686
+ purge = false,
687
+ log = console.log,
688
+ installer = realInstaller,
689
+ } = {}) {
690
+ log('DSH Crew uninstaller (npx-managed)');
691
+ const failures = [];
692
+ const fail = (text) => { log(`✗ ${text}`); failures.push(text); };
693
+
694
+ const pointer = readCurrentPointer({ home });
695
+ const name = pointer?.name ?? readManifest(runningPackageRoot())?.name;
696
+
697
+ const cx = installer.uninstallCodex({ home });
698
+ if (cx.ok !== false) log('✓ Codex Desktop integration removed');
699
+ else fail('Codex Desktop integration removal failed');
700
+
701
+ const cl = installer.uninstallClaudeCode ? await installer.uninstallClaudeCode({ home }) : realInstaller.uninstallClaudeCode({ home });
702
+ if (cl.ok !== false) log('✓ Claude Code integration removed');
703
+ else fail('Claude Code integration removal failed');
704
+
705
+ if (name) {
706
+ const removed = removeCrewPluginRegistration({ home, name });
707
+ if (!removed.ok) fail(`Harness registration removal failed (${removed.code ?? 'unknown'})`);
708
+ else log(removed.removed ? '✓ Harness plugin registration removed (offline, Crew-owned state)' : '- Harness plugin registration already absent');
709
+ } else {
710
+ fail('installed package name unknown; registration not removed');
711
+ }
712
+
713
+ if (purge) {
714
+ try {
715
+ rmSync(join(home, '.config', 'dsh-crew'), { recursive: true, force: true });
716
+ log('✓ ~/.config/dsh-crew purged');
717
+ } catch { fail('could not purge ~/.config/dsh-crew'); }
718
+ } else {
719
+ // Remove ONLY the Crew-managed installed payload; config, credentials,
720
+ // backups, and the isolated Harness home stay untouched.
721
+ try {
722
+ rmSync(crewAppRoot({ home }), { recursive: true, force: true });
723
+ log('✓ Crew-managed installed payload removed (config/backups kept)');
724
+ } catch { fail('could not remove the Crew-managed installed payload'); }
725
+ }
726
+
727
+ if (failures.length > 0) {
728
+ log('');
729
+ log('FAILED: uninstall incomplete');
730
+ return { ok: false, failures };
731
+ }
732
+ log('');
733
+ log('Done.');
734
+ return { ok: true, failures: [] };
735
+ }
736
+
737
+ // ---- CLI dispatch --------------------------------------------------------------
738
+
739
+ export const USAGE = `usage: dsh-crew <command> [--purge]
740
+
741
+ Commands:
742
+ install persist the candidate package into Crew-owned state and register it
743
+ status read-only report of candidate/installed versions and integrations
744
+ update upgrade-aware update: stages/validates first, repairs, idempotent when current
745
+ uninstall remove the Crew-managed payload, registration, and integrations (config kept)
746
+
747
+ Options:
748
+ --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
749
+ --help show this help
750
+
751
+ Source checkouts use scripts/setup.mjs instead.`;
752
+
753
+ function normalizeCommand(argv) {
754
+ const flags = argv.slice(1);
755
+ const knownFlags = new Set(['--purge']);
756
+ const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
757
+ return { command: argv[0], purge: flags.includes('--purge'), unknown };
758
+ }
759
+
760
+ /**
761
+ * CLI dispatcher used by bin/dsh-crew.mjs. Returns a process exit code.
762
+ */
763
+ export async function runNpxCli({
764
+ argv = process.argv.slice(2),
765
+ log = console.log,
766
+ error = console.error,
767
+ commands = {},
768
+ } = {}) {
769
+ const { command, purge, unknown } = normalizeCommand(argv);
770
+ if (command === '--help' || command === '-h' || command === 'help') {
771
+ log(USAGE);
772
+ return 0;
773
+ }
774
+ if (!command) {
775
+ error(USAGE);
776
+ return 1;
777
+ }
778
+ if (unknown.length > 0 || !['install', 'status', 'update', 'uninstall'].includes(command)) {
779
+ error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
780
+ return 1;
781
+ }
782
+ try {
783
+ const actions = {
784
+ install: commands.install ?? npxInstall,
785
+ status: commands.status ?? npxStatus,
786
+ update: commands.update ?? npxUpdate,
787
+ uninstall: commands.uninstall ?? npxUninstall,
788
+ };
789
+ const result = command === 'uninstall'
790
+ ? await actions.uninstall({ purge, log })
791
+ : await actions[command]({ log });
792
+ return result?.ok === false ? 1 : 0;
793
+ } catch (err) {
794
+ error(`dsh-crew ${command} failed: ${err?.message ?? err}`);
795
+ return 1;
796
+ }
797
+ }
@@ -8,7 +8,7 @@
8
8
  // Keep this module pure and dependency-free so Hub, MCP and tests all use the
9
9
  // exact same compatibility rules.
10
10
 
11
- export const RUNTIME_VERSION = '0.3.2';
11
+ export const RUNTIME_VERSION = '0.3.3';
12
12
  export const HUB_PROTOCOL_VERSION = 1;
13
13
 
14
14
  export const HUB_CAPABILITIES = Object.freeze([