@ran-sh/dsh-crew 0.3.1 → 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/.claude-plugin/marketplace.json +17 -17
- package/.claude-plugin/plugin.json +8 -8
- package/.mcp.json +8 -8
- package/LICENSE +21 -21
- package/README.de.md +359 -359
- package/README.es.md +359 -359
- package/README.fr.md +359 -359
- package/README.hi.md +359 -359
- package/README.id.md +359 -359
- package/README.ja.md +359 -359
- package/README.ko.md +359 -359
- package/README.md +168 -140
- package/README.pt.md +359 -359
- package/README.ru.md +359 -359
- package/README.th.md +359 -359
- package/README.tr.md +359 -359
- package/README.vi.md +359 -359
- package/README.zh-TW.md +359 -359
- package/README.zh.md +168 -140
- package/agents/ds-flash.md +26 -26
- package/agents/ds-pro.md +32 -32
- package/agents/ds-reviewer.md +23 -23
- package/agents/ds-worker.md +22 -22
- package/bin/dsh-crew.mjs +12 -0
- package/codex/agents/ds-flash.toml +30 -30
- package/codex/agents/ds-pro.toml +31 -31
- package/codex/agents/ds-reviewer.toml +28 -28
- package/codex/agents/ds-worker.toml +28 -28
- package/codex/prompts/dsh-config.md +3 -3
- package/codex/prompts/dsh-status.md +1 -1
- package/commands/config.md +11 -11
- package/commands/off.md +5 -5
- package/commands/on.md +5 -5
- package/commands/status.md +5 -5
- package/cordis.patch.yml +4 -4
- package/lib/client.js +2765 -2765
- package/package.json +131 -127
- package/scripts/build-client.mjs +28 -28
- package/scripts/live-crew-smoke.mjs +39 -39
- package/scripts/live-policy-matrix.mjs +177 -177
- package/scripts/policy-probe.mjs +101 -101
- package/scripts/setup.mjs +295 -294
- package/scripts/smoke-real.mjs +110 -110
- package/scripts/smoke.mjs +78 -78
- package/scripts/verify-installer-fix.mjs +26 -26
- package/scripts/verify-npm-install.mjs +297 -276
- package/src/adaptive-routing.mjs +260 -260
- package/src/client/activation-summary.tsx +64 -64
- package/src/client/entry.tsx +236 -236
- package/src/client/index.tsx +1120 -1120
- package/src/config-readiness.mjs +59 -59
- package/src/delivery.mjs +205 -205
- package/src/dsh-cli-runtime.mjs +447 -251
- package/src/failure-classification.mjs +172 -172
- package/src/hub/entry.mjs +98 -98
- package/src/hub-client.mjs +132 -132
- package/src/hub-compatibility.mjs +49 -49
- package/src/i18n.mjs +19 -19
- package/src/install/cli.mjs +28 -28
- package/src/install/install-legacy.mjs +462 -460
- package/src/install/install.mjs +451 -451
- package/src/install/npx-lifecycle.mjs +797 -0
- package/src/mcp-runtime.mjs +257 -257
- package/src/model-catalog.mjs +173 -173
- package/src/model-routing.mjs +391 -391
- package/src/policy.mjs +197 -197
- package/src/readiness-matrix.mjs +169 -169
- package/src/runtime-controls.mjs +90 -90
- package/src/runtime-identity.mjs +108 -108
- package/src/server.mjs +477 -477
- package/src/status-shard.mjs +52 -52
- package/src/structured-error-code.mjs +38 -38
- package/src/vision-route.mjs +138 -138
- package/src/workflow-runtime.mjs +573 -567
- package/src/workflow.mjs +160 -160
- package/src/workspace-audit.mjs +231 -231
- package/src/workspace-isolation.mjs +365 -306
- package/statusline/statusline.sh +14 -14
- package/statusline/worker-segment.sh +35 -35
- package/worker.cordis.yml +77 -77
|
@@ -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
|
+
}
|