@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 +33 -5
- package/README.zh.md +33 -5
- package/bin/dsh-crew.mjs +12 -0
- package/package.json +5 -1
- package/src/dsh-cli-runtime.mjs +14 -2
- package/src/install/install-legacy.mjs +10 -8
- package/src/install/npx-lifecycle.mjs +797 -0
- package/src/runtime-identity.mjs +1 -1
package/README.md
CHANGED
|
@@ -38,9 +38,25 @@ Codex Desktop / Claude Code
|
|
|
38
38
|
|
|
39
39
|
## Install
|
|
40
40
|
|
|
41
|
-
Prerequisites: **Node.js
|
|
41
|
+
Prerequisites: **Node.js** (Git is needed only for coding-worker isolation).
|
|
42
42
|
|
|
43
|
-
Recommended
|
|
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
|
-
|
|
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
|
|
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.js
|
|
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
|
-
|
|
135
|
+
卸载源码安装:
|
|
108
136
|
|
|
109
137
|
```bash
|
|
110
138
|
node scripts/setup.mjs uninstall
|
package/bin/dsh-crew.mjs
ADDED
|
@@ -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.
|
|
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",
|
package/src/dsh-cli-runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -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.
|
|
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([
|