@ran-sh/dsh-crew 2.2.1 → 2.2.2
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/plugin.json +1 -1
- package/official-web-bridge/lib/client.js +1 -1
- package/official-web-bridge/overlay-entry.mjs +5 -1
- package/official-web-bridge/package.json +1 -1
- package/package.json +1 -1
- package/scripts/setup.mjs +33 -6
- package/src/client/quick-panel.tsx +6 -1
- package/src/dsh-cli-runtime.mjs +209 -8
- package/src/hub/index.mjs +57 -6
- package/src/install/crew-paths.mjs +16 -11
- package/src/install/desktop-profile.mjs +168 -0
- package/src/install/harness-config-import.mjs +204 -0
- package/src/install/npx-lifecycle.mjs +151 -14
- package/src/official-web-bridge.mjs +3 -1
- package/src/provider-settings-store.mjs +19 -1
- package/src/runtime-identity.mjs +1 -1
|
@@ -61,7 +61,7 @@ function PanelHeader({ title, eyebrow, description, href, linkText, children })
|
|
|
61
61
|
//#endregion
|
|
62
62
|
//#region src/client/quick-panel.tsx
|
|
63
63
|
const API = "/_dsh/dsh-crew";
|
|
64
|
-
const FULL = "http://127.0.0.1:3210/";
|
|
64
|
+
const FULL = "http://127.0.0.1:3210/_dsh/dsh-crew/control-plane";
|
|
65
65
|
const RESTART_KEYS = /* @__PURE__ */ new Set([]);
|
|
66
66
|
const T = {
|
|
67
67
|
zh: {
|
|
@@ -4,6 +4,10 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
|
|
5
5
|
const PREFIX = '/_dsh/dsh-crew';
|
|
6
6
|
const BACKEND = 'http://127.0.0.1:3210';
|
|
7
|
+
// The control plane's session belongs to the hub's own process, so the only usable
|
|
8
|
+
// entry is the hub's redirect: it mints the session and 302s into it. Advertising
|
|
9
|
+
// the bare port here sent operators to "dsh web authentication required".
|
|
10
|
+
const CONTROL_PLANE = `${BACKEND}${PREFIX}/control-plane`;
|
|
7
11
|
const REVISION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).dshCrewFrontendRevision ?? null;
|
|
8
12
|
|
|
9
13
|
function trusted(req) {
|
|
@@ -21,7 +25,7 @@ export function registerFrontend(ctx, { fetchImpl = globalThis.fetch } = {}) {
|
|
|
21
25
|
const disposers = [];
|
|
22
26
|
disposers.push(webServer.register({ kind: 'exact', path: `${PREFIX}/bridge-status`, handler(req, res) {
|
|
23
27
|
if (!trusted(req)) return send(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
|
|
24
|
-
send(res, 200, { ok: true, surface: 'official-bridge', ui_role: 'quick-controls', frontend_revision: REVISION, full_control_plane_url:
|
|
28
|
+
send(res, 200, { ok: true, surface: 'official-bridge', ui_role: 'quick-controls', frontend_revision: REVISION, full_control_plane_url: CONTROL_PLANE });
|
|
25
29
|
} }));
|
|
26
30
|
for (const suffix of ['/quick-config', '/quick-status']) {
|
|
27
31
|
disposers.push(webServer.register({ kind: 'exact', path: `${PREFIX}${suffix}`, async handler(req, res) {
|
package/package.json
CHANGED
package/scripts/setup.mjs
CHANGED
|
@@ -15,11 +15,13 @@ import { crewDshHome, crewProfileDir, CREW_PROFILE_NAME, claudeIntegrationLine }
|
|
|
15
15
|
import {
|
|
16
16
|
ensureCrewDshRuntime,
|
|
17
17
|
ensureCrewPluginRegistration,
|
|
18
|
+
ensureCrewWebProfile,
|
|
18
19
|
resolveDshCli,
|
|
19
20
|
describeDshCli,
|
|
20
21
|
removeCrewPluginRegistration,
|
|
21
22
|
TARGET_DSH_VERSION,
|
|
22
23
|
} from '../src/dsh-cli-runtime.mjs';
|
|
24
|
+
import { integrationRoot } from '../src/install/crew-paths.mjs';
|
|
23
25
|
|
|
24
26
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
27
|
|
|
@@ -146,6 +148,8 @@ export async function setupInstall({
|
|
|
146
148
|
home = homedir(),
|
|
147
149
|
installer = realInstaller,
|
|
148
150
|
depRunner = null,
|
|
151
|
+
ensureRuntime = null,
|
|
152
|
+
ensureWebProfile = null,
|
|
149
153
|
} = {}) {
|
|
150
154
|
log('DSH Crew installer');
|
|
151
155
|
if (!checkRoot(root)) return { ok: false, error: `not a dsh-crew checkout: ${root}` };
|
|
@@ -196,8 +200,15 @@ export async function setupInstall({
|
|
|
196
200
|
error: `Crew runtime ${dsh.version} does not match ${TARGET_DSH_VERSION}; use the transactional update path (dsh-crew update) to migrate the cohort`,
|
|
197
201
|
};
|
|
198
202
|
}
|
|
199
|
-
if (!dryRun && (!dsh || dsh.
|
|
200
|
-
|
|
203
|
+
if (!dryRun && (!dsh || !dsh.reusable)) {
|
|
204
|
+
// The Windows launcher only starts a Crew-owned CLI entry: a global
|
|
205
|
+
// official dsh or a transient npx download cannot pass its preflight, so
|
|
206
|
+
// the launcher would report "DSH CLI was not found" on a machine that has
|
|
207
|
+
// a perfectly good global dsh. A machine without a reusable runtime
|
|
208
|
+
// provisions one here — before the launcher ever runs — and the web
|
|
209
|
+
// profile scaffold below completes what that preflight and the supervisor
|
|
210
|
+
// require. Idempotent: a matching reusable runtime is reused as-is.
|
|
211
|
+
const boot = (ensureRuntime ?? ensureCrewDshRuntime)({ home });
|
|
201
212
|
if (boot.ok) dsh = boot.cli;
|
|
202
213
|
else log(`- reusable Crew DSH runtime unavailable (${boot.code ?? 'unknown'}); trying compatibility fallback`);
|
|
203
214
|
}
|
|
@@ -219,11 +230,27 @@ export async function setupInstall({
|
|
|
219
230
|
return { ok: false, error: 'DSH profile link failed' };
|
|
220
231
|
}
|
|
221
232
|
mark(log, true, `DSH crew profile linked offline (dedicated Crew DSH_HOME, profile dsh-crew; ${registration.changed ? 'updated' : 'already current'})`);
|
|
233
|
+
// The launcher preflight and the supervisor both require
|
|
234
|
+
// <crew DSH_HOME>/profiles/web; without it a fresh install deadlocked at
|
|
235
|
+
// the preflight ("The Harness web profile is missing") and the supervisor
|
|
236
|
+
// heartbeat timed out. Same idempotent scaffold the runtime step creates
|
|
237
|
+
// on the npx-managed path.
|
|
238
|
+
const webProfile = (ensureWebProfile ?? ensureCrewWebProfile)({ home });
|
|
239
|
+
if (!webProfile.ok) {
|
|
240
|
+
log(`✗ Crew web profile bootstrap failed (${webProfile.code ?? 'unknown'})`);
|
|
241
|
+
return { ok: false, error: 'Crew web profile bootstrap failed' };
|
|
242
|
+
}
|
|
243
|
+
if (webProfile.created) mark(log, true, 'Crew web profile scaffold created');
|
|
222
244
|
}
|
|
223
245
|
|
|
246
|
+
// The host integrations must be written against the same root readiness
|
|
247
|
+
// derives (integrationRoot) — see activateRelease. After registration the
|
|
248
|
+
// link resolves to this checkout, so this is the loader link path.
|
|
249
|
+
const integrationWriteRoot = integrationRoot({ home, root, name });
|
|
250
|
+
|
|
224
251
|
if (dryRun) mark(log, true, 'Codex Desktop integration (dry-run)');
|
|
225
252
|
else {
|
|
226
|
-
const r = installer.installCodex ? installer.installCodex({ home, root }) : realInstaller.installCodex({ home, root });
|
|
253
|
+
const r = installer.installCodex ? installer.installCodex({ home, root: integrationWriteRoot }) : realInstaller.installCodex({ home, root: integrationWriteRoot });
|
|
227
254
|
mark(log, r.ok !== false, r.ok === false ? `Codex Desktop integration failed: ${(r.actions ?? []).join('; ')}` : 'Codex Desktop integration');
|
|
228
255
|
if (r.ok === false) return { ok: false, error: 'Codex integration failed' };
|
|
229
256
|
}
|
|
@@ -231,8 +258,8 @@ export async function setupInstall({
|
|
|
231
258
|
if (dryRun) mark(log, true, 'ZCode integration (dry-run)');
|
|
232
259
|
else {
|
|
233
260
|
const r = installer.installZCode
|
|
234
|
-
? installer.installZCode({ home, root })
|
|
235
|
-
: realInstaller.installZCode({ home, root });
|
|
261
|
+
? installer.installZCode({ home, root: integrationWriteRoot })
|
|
262
|
+
: realInstaller.installZCode({ home, root: integrationWriteRoot });
|
|
236
263
|
if (r.ok === false) {
|
|
237
264
|
mark(log, false, `ZCode integration failed (${r.code ?? 'unknown'})`);
|
|
238
265
|
return { ok: false, error: 'ZCode integration failed' };
|
|
@@ -253,7 +280,7 @@ export async function setupInstall({
|
|
|
253
280
|
}
|
|
254
281
|
|
|
255
282
|
if (commandExists('claude')) {
|
|
256
|
-
const r = await runClaudeIntegrationStep({ dryRun, log, root, home, installer });
|
|
283
|
+
const r = await runClaudeIntegrationStep({ dryRun, log, root: integrationWriteRoot, home, installer });
|
|
257
284
|
if (!r.ok) return r;
|
|
258
285
|
} else log('- Claude Code not detected, skipped');
|
|
259
286
|
|
|
@@ -9,7 +9,12 @@ import { PanelHeader, PanelStyles } from './panel-chrome';
|
|
|
9
9
|
// (/_dsh/dsh-crew/quick-config, /quick-status). Runtime maintenance stays on 3210.
|
|
10
10
|
|
|
11
11
|
const API = '/_dsh/dsh-crew';
|
|
12
|
-
|
|
12
|
+
// The control plane needs its own session, and this panel runs in a DIFFERENT
|
|
13
|
+
// harness process (the desktop app, or the 3080 frontend): its connection service
|
|
14
|
+
// cannot mint a token for 3210, so linking the bare port landed the operator on
|
|
15
|
+
// "dsh web authentication required". The hub's own endpoint mints the session and
|
|
16
|
+
// redirects, so no token is ever published to a page or a file.
|
|
17
|
+
const FULL = 'http://127.0.0.1:3210/_dsh/dsh-crew/control-plane';
|
|
13
18
|
|
|
14
19
|
type ModelEntry = { provider: string; model: string };
|
|
15
20
|
type QuickConfig = {
|
package/src/dsh-cli-runtime.mjs
CHANGED
|
@@ -220,14 +220,29 @@ export function ensureCrewDshRuntime({
|
|
|
220
220
|
platform = process.platform,
|
|
221
221
|
comspec = process.env.ComSpec ?? 'cmd.exe',
|
|
222
222
|
env = process.env,
|
|
223
|
+
read = readFileSync,
|
|
224
|
+
write = writeFileSync,
|
|
225
|
+
log = () => {},
|
|
223
226
|
} = {}) {
|
|
224
|
-
const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
|
|
227
|
+
const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false, read });
|
|
225
228
|
// Reuse only when the installed runtime already matches the requested
|
|
226
229
|
// cohort. A stale cohort (e.g. an online 0.1.1-rc.2 runtime) must never be
|
|
227
230
|
// mistaken for the target, and it must never be upgraded in place under a
|
|
228
231
|
// live hub.
|
|
229
232
|
if (existing?.kind === 'crew-runtime' && existing?.version === version) {
|
|
230
|
-
|
|
233
|
+
// The core matching is not enough: a tree whose family drifted boots that
|
|
234
|
+
// core against siblings from another cohort and dies on the first import
|
|
235
|
+
// the newer ones dropped. Report it as the cohort mismatch it is so the
|
|
236
|
+
// caller runs the staged (re)install instead of handing it to the launcher.
|
|
237
|
+
const cohort = verifyCrewRuntimeCohort({ root: crewDshRuntimeRoot({ home }), version, read });
|
|
238
|
+
if (cohort.ok) return { ok: true, cli: existing, reused: true };
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
code: 'DSH_RUNTIME_COHORT_MISMATCH',
|
|
242
|
+
error: `Crew runtime family drifted from ${version}: ${describeCohortDrift(cohort.drift)}`,
|
|
243
|
+
installed: existing.version ?? null,
|
|
244
|
+
target: version,
|
|
245
|
+
};
|
|
231
246
|
}
|
|
232
247
|
if (existing?.kind === 'crew-runtime') {
|
|
233
248
|
return {
|
|
@@ -251,13 +266,14 @@ export function ensureCrewDshRuntime({
|
|
|
251
266
|
? ['add', '--dir', runtimeRoot, '--ignore-scripts', packageSpec]
|
|
252
267
|
: ['install', '--prefix', runtimeRoot, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
|
|
253
268
|
const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
|
|
254
|
-
const
|
|
269
|
+
const runInstall = () => runner(invocation.command, invocation.args, {
|
|
255
270
|
encoding: 'utf8',
|
|
256
271
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
257
272
|
shell: invocation.shell,
|
|
258
273
|
env: { ...env },
|
|
259
274
|
});
|
|
260
|
-
const
|
|
275
|
+
const result = runInstall();
|
|
276
|
+
const cli = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false, read });
|
|
261
277
|
if (result.status !== 0 || !cli || cli.kind !== 'crew-runtime') {
|
|
262
278
|
return {
|
|
263
279
|
ok: false,
|
|
@@ -278,7 +294,30 @@ export function ensureCrewDshRuntime({
|
|
|
278
294
|
target: version,
|
|
279
295
|
};
|
|
280
296
|
}
|
|
281
|
-
|
|
297
|
+
// The core is pinned by the install spec, but npm resolves its siblings from
|
|
298
|
+
// the core's caret ranges: a registry that published a newer prerelease of the
|
|
299
|
+
// family produces a mixed tree here, so lock and re-verify the family before
|
|
300
|
+
// this runtime can be reported reusable.
|
|
301
|
+
const locked = enforceCohortLock({ root: runtimeRoot, version, runInstall, read, write, log });
|
|
302
|
+
if (locked.failed) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
code: 'DSH_RUNTIME_INSTALL_FAILED',
|
|
306
|
+
error: 'Crew-owned DSH runtime install failed',
|
|
307
|
+
status: locked.failed.status ?? -1,
|
|
308
|
+
stderrTail: String(locked.failed.stderr || locked.failed.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (!locked.ok) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
code: 'DSH_RUNTIME_COHORT_INCONSISTENT',
|
|
315
|
+
error: `installed Crew runtime family does not match ${version}: ${describeCohortDrift(locked.drift)}`,
|
|
316
|
+
cohort: { drift: locked.drift },
|
|
317
|
+
target: version,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return { ok: true, cli, reused: false, version: cli.version, runtimeRoot, ...(locked.repairs ? { cohortRepaired: true } : {}) };
|
|
282
321
|
}
|
|
283
322
|
|
|
284
323
|
export function describeDshCli(cli) {
|
|
@@ -287,6 +326,104 @@ export function describeDshCli(cli) {
|
|
|
287
326
|
return `${cli.kind}${version}`;
|
|
288
327
|
}
|
|
289
328
|
|
|
329
|
+
// ---- installed cohort identity ---------------------------------------------
|
|
330
|
+
// The runtime tree is a COHORT, not a single package: @deepseek-ai/dsh plus the
|
|
331
|
+
// @deepseek-ai/dsh-* family it loads. The install pins the core exactly, but npm
|
|
332
|
+
// still resolves the family from the core's own caret ranges, so a registry that
|
|
333
|
+
// has published a newer prerelease of the family yields a MIXED tree — core
|
|
334
|
+
// alpha.1 with alpha.2 siblings — that installs cleanly and then dies at boot:
|
|
335
|
+
// the alpha.1 core imports exports the alpha.2 siblings no longer provide. Both
|
|
336
|
+
// the reuse gate and the install therefore judge every family member, and a tree
|
|
337
|
+
// that drifted is locked with overrides and reinstalled once before anything is
|
|
338
|
+
// reported reusable.
|
|
339
|
+
|
|
340
|
+
// A family package may carry its own nested copy of a sibling, and node
|
|
341
|
+
// resolves that copy FIRST for its parent — so a nested copy at the wrong
|
|
342
|
+
// version breaks the parent exactly like a top-level one. npm does produce
|
|
343
|
+
// these (a reconcile against a mixed tree nested whole subtrees), so the walk
|
|
344
|
+
// follows family packages down through their own node_modules and stops before
|
|
345
|
+
// it can descend into unrelated trees.
|
|
346
|
+
const COHORT_NESTED_DEPTH = 3;
|
|
347
|
+
|
|
348
|
+
function installedCohortEntries(root, read = readFileSync) {
|
|
349
|
+
const entries = [];
|
|
350
|
+
const visit = (scope, prefix, depth) => {
|
|
351
|
+
let names;
|
|
352
|
+
try { names = readdirSync(scope); } catch { return; }
|
|
353
|
+
for (const name of names) {
|
|
354
|
+
if (name !== 'dsh' && !name.startsWith('dsh-')) continue;
|
|
355
|
+
const dir = join(scope, name);
|
|
356
|
+
entries.push({ name: `@deepseek-ai/${name}`, version: readPackageVersionAt(dir, read), scope: prefix || 'node_modules' });
|
|
357
|
+
if (depth >= COHORT_NESTED_DEPTH) continue;
|
|
358
|
+
const nested = join(dir, 'node_modules', '@deepseek-ai');
|
|
359
|
+
if (existsSync(nested)) visit(nested, `${prefix ? `${prefix}/` : ''}${name}/node_modules`, depth + 1);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
visit(join(root, 'node_modules', '@deepseek-ai'), '', 0);
|
|
363
|
+
return entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Judge every installed copy of the family against the pinned cohort version.
|
|
368
|
+
* `drift` names each copy whose on-disk version differs (an unreadable manifest
|
|
369
|
+
* reads as null), so callers can report the exact packages instead of a bare
|
|
370
|
+
* mismatch.
|
|
371
|
+
*/
|
|
372
|
+
export function verifyCrewRuntimeCohort({ root, version, read = readFileSync } = {}) {
|
|
373
|
+
const entries = installedCohortEntries(root, read);
|
|
374
|
+
const drift = entries.filter((entry) => entry.version !== version);
|
|
375
|
+
return { ok: entries.length > 0 && drift.length === 0, entries, drift };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function describeCohortDrift(drift) {
|
|
379
|
+
return drift
|
|
380
|
+
.map((entry) => (entry.scope && entry.scope !== 'node_modules'
|
|
381
|
+
? `${entry.name}@${entry.version ?? 'missing'} (nested in ${entry.scope})`
|
|
382
|
+
: `${entry.name}@${entry.version ?? 'missing'}`))
|
|
383
|
+
.join(', ');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Lock the whole family to the cohort version. npm v11 rejects an override that
|
|
387
|
+
// shares a name with a direct dependency unless the spec repeats it verbatim, so
|
|
388
|
+
// the core keeps its own exact dependency entry and is left out of `overrides`;
|
|
389
|
+
// pnpm reads overrides from its own field, so both are written.
|
|
390
|
+
function writeCohortOverrides({ root, version, names, read = readFileSync, write = writeFileSync }) {
|
|
391
|
+
const overrides = {};
|
|
392
|
+
for (const name of names) {
|
|
393
|
+
if (name === DSH_CLI_PACKAGE) continue;
|
|
394
|
+
overrides[name] = version;
|
|
395
|
+
}
|
|
396
|
+
if (Object.keys(overrides).length === 0) return false;
|
|
397
|
+
const manifestFile = join(root, 'package.json');
|
|
398
|
+
let manifest = null;
|
|
399
|
+
try { manifest = JSON.parse(read(manifestFile, 'utf8')); } catch { manifest = null; }
|
|
400
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) manifest = {};
|
|
401
|
+
const next = {
|
|
402
|
+
...manifest,
|
|
403
|
+
dependencies: { ...(manifest.dependencies ?? {}), [DSH_CLI_PACKAGE]: version },
|
|
404
|
+
overrides,
|
|
405
|
+
pnpm: { ...(manifest.pnpm ?? {}), overrides },
|
|
406
|
+
};
|
|
407
|
+
write(manifestFile, `${JSON.stringify(next, null, 2)}\n`);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Prove the freshly installed tree is a coherent cohort. A drifting family is
|
|
412
|
+
// locked with overrides and reinstalled exactly ONCE; a tree that still drifts is
|
|
413
|
+
// reported to the caller, never handed to the launcher as reusable.
|
|
414
|
+
function enforceCohortLock({ root, version, runInstall, read = readFileSync, write = writeFileSync, log = () => {} }) {
|
|
415
|
+
const observed = verifyCrewRuntimeCohort({ root, version, read });
|
|
416
|
+
if (observed.ok) return { ok: true, repairs: 0 };
|
|
417
|
+
if (!writeCohortOverrides({ root, version, names: observed.entries.map((entry) => entry.name), read, write })) {
|
|
418
|
+
return { ok: false, drift: observed.drift };
|
|
419
|
+
}
|
|
420
|
+
log(` DSH cohort drifted (${describeCohortDrift(observed.drift)}); locking the family with overrides`);
|
|
421
|
+
const result = runInstall();
|
|
422
|
+
if (result?.status !== 0) return { ok: false, failed: result };
|
|
423
|
+
const after = verifyCrewRuntimeCohort({ root, version, read });
|
|
424
|
+
return after.ok ? { ok: true, repairs: 1 } : { ok: false, drift: after.drift };
|
|
425
|
+
}
|
|
426
|
+
|
|
290
427
|
// Shared pnpm/npm install of the pinned DSH cohort into a specific root
|
|
291
428
|
// directory. pnpm materializes ABSOLUTE-path .cmd shims and junctions that
|
|
292
429
|
// point at the install root's own .pnpm store, so the tree must be installed
|
|
@@ -305,6 +442,9 @@ export function installDshInto({
|
|
|
305
442
|
platform = process.platform,
|
|
306
443
|
comspec = process.env.ComSpec ?? 'cmd.exe',
|
|
307
444
|
env = process.env,
|
|
445
|
+
read = readFileSync,
|
|
446
|
+
write = writeFileSync,
|
|
447
|
+
log = () => {},
|
|
308
448
|
}) {
|
|
309
449
|
const pnpm = pnpmCommand ?? findCommand('pnpm');
|
|
310
450
|
const npm = npmCommand ?? findCommand('npm');
|
|
@@ -317,12 +457,13 @@ export function installDshInto({
|
|
|
317
457
|
? ['add', '--dir', root, '--ignore-scripts', packageSpec]
|
|
318
458
|
: ['install', '--prefix', root, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
|
|
319
459
|
const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
|
|
320
|
-
const
|
|
460
|
+
const runInstall = () => runner(invocation.command, invocation.args, {
|
|
321
461
|
encoding: 'utf8',
|
|
322
462
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
323
463
|
shell: invocation.shell,
|
|
324
464
|
env: { ...env },
|
|
325
465
|
});
|
|
466
|
+
const result = runInstall();
|
|
326
467
|
if (result.status !== 0) {
|
|
327
468
|
return {
|
|
328
469
|
ok: false,
|
|
@@ -336,7 +477,7 @@ export function installDshInto({
|
|
|
336
477
|
if (!exists(moduleEntry)) {
|
|
337
478
|
return { ok: false, code: 'DSH_RUNTIME_INSTALL_INCOMPLETE', error: 'runtime entry missing after install' };
|
|
338
479
|
}
|
|
339
|
-
const installedVersion = packageVersion(moduleEntry);
|
|
480
|
+
const installedVersion = packageVersion(moduleEntry, read);
|
|
340
481
|
if (installedVersion !== version) {
|
|
341
482
|
return {
|
|
342
483
|
ok: false,
|
|
@@ -346,7 +487,26 @@ export function installDshInto({
|
|
|
346
487
|
target: version,
|
|
347
488
|
};
|
|
348
489
|
}
|
|
349
|
-
|
|
490
|
+
const locked = enforceCohortLock({ root, version, runInstall, read, write, log });
|
|
491
|
+
if (locked.failed) {
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
code: 'DSH_RUNTIME_INSTALL_FAILED',
|
|
495
|
+
error: 'Crew runtime install failed',
|
|
496
|
+
status: locked.failed.status ?? -1,
|
|
497
|
+
stderrTail: String(locked.failed.stderr || locked.failed.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (!locked.ok) {
|
|
501
|
+
return {
|
|
502
|
+
ok: false,
|
|
503
|
+
code: 'DSH_RUNTIME_COHORT_INCONSISTENT',
|
|
504
|
+
error: `installed Crew runtime family does not match ${version}: ${describeCohortDrift(locked.drift)}`,
|
|
505
|
+
cohort: { drift: locked.drift },
|
|
506
|
+
target: version,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return { ok: true, root, version: installedVersion, ...(locked.repairs ? { cohortRepaired: true } : {}) };
|
|
350
510
|
}
|
|
351
511
|
|
|
352
512
|
// Staged cohort migration: install the target cohort into a versioned
|
|
@@ -1084,3 +1244,44 @@ export function removeCrewPluginRegistration({ home = homedir(), name, profileRo
|
|
|
1084
1244
|
if (linkStat) unlinkSync(linkPath);
|
|
1085
1245
|
return { ok: true, removed: true };
|
|
1086
1246
|
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Idempotently materialize the Crew-owned Harness `web` profile scaffold.
|
|
1250
|
+
*
|
|
1251
|
+
* `ensureCrewDshRuntime` supplies the CLI but nothing else a managed 3210/3080
|
|
1252
|
+
* boot needs: the Windows launcher preflight and the supervisor both require
|
|
1253
|
+
* `<crew DSH_HOME>/profiles/web/package.json`, and a fresh install that never
|
|
1254
|
+
* created it deadlocked (supervisor heartbeat timeout -> pending handoff ->
|
|
1255
|
+
* every later update refused). The scaffold is the same shape the plugin
|
|
1256
|
+
* registration writes for the dsh-crew profile: an existing valid manifest is
|
|
1257
|
+
* left untouched, a missing one is created from CREW_PROFILE_DEFAULT_BUNDLES,
|
|
1258
|
+
* and a corrupt one fails closed instead of being overwritten.
|
|
1259
|
+
*/
|
|
1260
|
+
export function ensureCrewWebProfile({
|
|
1261
|
+
home = homedir(),
|
|
1262
|
+
profileName = 'web',
|
|
1263
|
+
defaultBundles = CREW_PROFILE_DEFAULT_BUNDLES,
|
|
1264
|
+
} = {}) {
|
|
1265
|
+
if (typeof profileName !== 'string' || !/^[A-Za-z0-9._-]+$/u.test(profileName)) {
|
|
1266
|
+
return { ok: false, code: 'INVALID_PROFILE_NAME' };
|
|
1267
|
+
}
|
|
1268
|
+
const profileRoot = join(crewDshHome({ home }), 'profiles', profileName);
|
|
1269
|
+
const profile = readProfileManifestForRegistration(profileRoot, { create: true, defaultBundles });
|
|
1270
|
+
if (profile.ok === false) return { ok: false, code: profile.code, profileRoot };
|
|
1271
|
+
let scaffoldChanged = false;
|
|
1272
|
+
try {
|
|
1273
|
+
scaffoldChanged = ensureProfileScaffold(profileRoot);
|
|
1274
|
+
if (profile.created) {
|
|
1275
|
+
writeFileSync(profile.profileManifest, JSON.stringify(profile.manifest, null, 2) + '\n');
|
|
1276
|
+
}
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
return { ok: false, code: 'CREW_WEB_PROFILE_SCAFFOLD_FAILED', error: String(error?.message ?? error), profileRoot };
|
|
1279
|
+
}
|
|
1280
|
+
return {
|
|
1281
|
+
ok: true,
|
|
1282
|
+
profileRoot,
|
|
1283
|
+
profileManifest: profile.profileManifest,
|
|
1284
|
+
created: profile.created === true,
|
|
1285
|
+
changed: profile.created === true || scaffoldChanged,
|
|
1286
|
+
};
|
|
1287
|
+
}
|
package/src/hub/index.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { raceWaiters } from '../removable-waiter.mjs';
|
|
|
40
40
|
import { buildProviderInventory } from '../provider-inventory.mjs';
|
|
41
41
|
import { buildProviderLayerMigrationPlan, executeProviderLayerMigration } from '../provider-layer-migration.mjs';
|
|
42
42
|
import { inspectProviderProfile, readProviderDeclarations, readProviderMaterialization } from '../provider-profile-store.mjs';
|
|
43
|
-
import { inspectProviderSettings, readHarnessDefault, readProviderSettingsDeclarations, readProviderSettingsMaterialization } from '../provider-settings-store.mjs';
|
|
43
|
+
import { hasHarnessDefaultBlock, inspectProviderSettings, readHarnessDefault, readProviderSettingsDeclarations, readProviderSettingsMaterialization } from '../provider-settings-store.mjs';
|
|
44
44
|
import { normalizeProviderLifecycleState } from '../provider-lifecycle-state.mjs';
|
|
45
45
|
import { createProviderHealthStore } from '../provider-health.mjs';
|
|
46
46
|
import { planProviderDelete } from '../provider-lifecycle.mjs';
|
|
@@ -251,6 +251,26 @@ export function hasCompleteProviderDeclarationEvidence(evidence) {
|
|
|
251
251
|
&& Object.values(evidence.sources ?? {}).every((source) => source?.present !== true || !source.code);
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Decide what the Harness default authority evidence means. A persisted
|
|
256
|
+
* `agent-default-model` that disagrees with the live default is a real
|
|
257
|
+
* inconsistency and fails closed; a live default with nothing persisted is the
|
|
258
|
+
* normal state of a home whose model comes from configuration (the profile and
|
|
259
|
+
* base layers) — and the state every settings mutation materializes FROM, so
|
|
260
|
+
* demanding a persisted copy first deadlocked the very migration that writes one.
|
|
261
|
+
*/
|
|
262
|
+
export function projectHarnessDefaultEvidence({ liveDefault = null, persistedDefault = null, persistedDefaultDeclared = false } = {}) {
|
|
263
|
+
if (!liveDefault && !persistedDefault) return { ok: true, source: 'none' };
|
|
264
|
+
if (liveDefault && persistedDefault) {
|
|
265
|
+
return liveDefault.provider === persistedDefault.provider && liveDefault.model === persistedDefault.model
|
|
266
|
+
? { ok: true, source: 'harness-settings' }
|
|
267
|
+
: { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' };
|
|
268
|
+
}
|
|
269
|
+
return persistedDefaultDeclared
|
|
270
|
+
? { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' }
|
|
271
|
+
: { ok: true, source: 'harness-config' };
|
|
272
|
+
}
|
|
273
|
+
|
|
254
274
|
export function hasAvailableProviderLifecycleEvidence(evidence) {
|
|
255
275
|
return evidence?.ok === true;
|
|
256
276
|
}
|
|
@@ -340,6 +360,7 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
|
|
|
340
360
|
} catch { declarationEvidence.ok = false; declarationEvidence.sources.profile = { present: true, code: 'PROVIDER_SOURCE_UNAVAILABLE' }; }
|
|
341
361
|
const settingsFile = join(CONFIG_DIR, 'harness', 'settings.yaml');
|
|
342
362
|
let settingsDefault = null;
|
|
363
|
+
let persistedDefaultDeclared = false;
|
|
343
364
|
let defaultEvidence = { ok: true, source: 'none' };
|
|
344
365
|
try {
|
|
345
366
|
declarationEvidence.sources.settings = { present: existsSync(settingsFile) };
|
|
@@ -352,8 +373,15 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
|
|
|
352
373
|
}
|
|
353
374
|
else { declarationEvidence.ok = false; declarationEvidence.sources.settings.code = parsed.code; }
|
|
354
375
|
const parsedDefault = readHarnessDefault(source);
|
|
355
|
-
if (parsedDefault.ok)
|
|
356
|
-
|
|
376
|
+
if (parsedDefault.ok) {
|
|
377
|
+
settingsDefault = parsedDefault;
|
|
378
|
+
persistedDefaultDeclared = true;
|
|
379
|
+
} else if (hasHarnessDefaultBlock(source)) {
|
|
380
|
+
// The block exists but cannot be read: fail closed on the mutation
|
|
381
|
+
// authority rather than treating the layer as absent.
|
|
382
|
+
defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_UNAVAILABLE' };
|
|
383
|
+
persistedDefaultDeclared = true;
|
|
384
|
+
}
|
|
357
385
|
}
|
|
358
386
|
} catch {
|
|
359
387
|
declarationEvidence.ok = false;
|
|
@@ -363,9 +391,8 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
|
|
|
363
391
|
const liveDefault = catalog.harness_default && typeof catalog.harness_default.provider === 'string' && typeof catalog.harness_default.model === 'string'
|
|
364
392
|
? { provider: catalog.harness_default.provider, model: catalog.harness_default.model } : null;
|
|
365
393
|
const persistedDefault = settingsDefault ? { provider: settingsDefault.provider, model: settingsDefault.model } : null;
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
else if (liveDefault.provider !== persistedDefault.provider || liveDefault.model !== persistedDefault.model) defaultEvidence = { ok: false, code: 'PROVIDER_DEFAULT_AUTHORITY_MISMATCH' };
|
|
394
|
+
if (defaultEvidence.ok) {
|
|
395
|
+
defaultEvidence = projectHarnessDefaultEvidence({ liveDefault, persistedDefault, persistedDefaultDeclared });
|
|
369
396
|
}
|
|
370
397
|
// The persisted agent-default-model is the mutation authority. Keep it in
|
|
371
398
|
// the inventory even when the live selection is stale; the evidence gate
|
|
@@ -1438,6 +1465,30 @@ export async function apply(ctx) {
|
|
|
1438
1465
|
handler: (req, res) => sendJson(res, 200, { ok: true, service: 'dsh-crew-hub' }),
|
|
1439
1466
|
}));
|
|
1440
1467
|
|
|
1468
|
+
// Operator entry into the full control plane. Its session token is minted per
|
|
1469
|
+
// process, and every surface that links here (the desktop app's panel, the
|
|
1470
|
+
// 3080 panel) runs in a different harness process whose connection service
|
|
1471
|
+
// cannot mint one for this port — linking the bare port landed the operator on
|
|
1472
|
+
// "dsh web authentication required". Only the hub itself can sign its own
|
|
1473
|
+
// session, so it redirects; no token is written to a page, a file or a log.
|
|
1474
|
+
// Loopback-only, because the Location carries a session credential.
|
|
1475
|
+
disposers.push(webServer.register({
|
|
1476
|
+
kind: 'exact', path: `${ROUTE_BASE}/control-plane`,
|
|
1477
|
+
handler: (req, res) => {
|
|
1478
|
+
if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
|
|
1479
|
+
const runtime = getHubRuntimeIdentity();
|
|
1480
|
+
const base = `http://127.0.0.1:${Number(runtime?.listen_port) > 0 ? runtime.listen_port : 3210}/`;
|
|
1481
|
+
let target = base;
|
|
1482
|
+
try {
|
|
1483
|
+
const connection = webCtx.get?.('connection');
|
|
1484
|
+
const authenticated = connection?.authenticatedUrl?.(base);
|
|
1485
|
+
if (typeof authenticated === 'string' && authenticated.startsWith('http://')) target = authenticated;
|
|
1486
|
+
} catch { /* the bare control plane stays the honest fallback */ }
|
|
1487
|
+
res.writeHead(302, { location: target, 'cache-control': 'no-store' });
|
|
1488
|
+
res.end();
|
|
1489
|
+
},
|
|
1490
|
+
}));
|
|
1491
|
+
|
|
1441
1492
|
disposers.push(webServer.register({
|
|
1442
1493
|
kind: 'prefix', path: `${ROUTE_BASE}/jobs`,
|
|
1443
1494
|
handler: async (req, res) => {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// the check disagreed about what "the installed server" is, and only the
|
|
10
10
|
// installer was right.
|
|
11
11
|
|
|
12
|
-
import { existsSync,
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
|
|
@@ -60,21 +60,26 @@ export function loaderLinkPath({ home = homedir(), name } = {}) {
|
|
|
60
60
|
return join(crewProfileDir({ home }), 'node_modules', ...name.split('/'));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function sameDirectory(left, right) {
|
|
64
|
-
try { return realpathSync(left) === realpathSync(right); } catch { return false; }
|
|
65
|
-
}
|
|
66
|
-
|
|
67
63
|
/**
|
|
68
64
|
* The root the host integrations were installed from.
|
|
69
65
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* and
|
|
73
|
-
*
|
|
74
|
-
* the
|
|
66
|
+
* The integrations embed the loader link path itself, and the link is what the
|
|
67
|
+
* host loads through: whichever payload it currently resolves to IS the live
|
|
68
|
+
* one, and an upgrade re-points the link so the same embedded path keeps
|
|
69
|
+
* resolving to the new release. Readiness therefore judges the integrations
|
|
70
|
+
* against the link whenever it resolves to a readable payload of this package,
|
|
71
|
+
* and falls back to the caller's root only when the link is broken or carries
|
|
72
|
+
* a different package. Gating on whether the link happens to realpath to the
|
|
73
|
+
* caller's root made the writer (link-rooted) and the validator
|
|
74
|
+
* (release-rooted) disagree about the same machine, and every clean install
|
|
75
|
+
* read back as "needs repair".
|
|
75
76
|
*/
|
|
76
77
|
export function integrationRoot({ home = homedir(), root, name } = {}) {
|
|
77
78
|
const link = loaderLinkPath({ home, name });
|
|
78
79
|
if (!link || !root || !existsSync(link)) return root;
|
|
79
|
-
|
|
80
|
+
try {
|
|
81
|
+
const payload = JSON.parse(readFileSync(join(link, 'package.json'), 'utf8'));
|
|
82
|
+
if (payload?.name === name) return link;
|
|
83
|
+
} catch { /* broken or foreign link payload */ }
|
|
84
|
+
return root;
|
|
80
85
|
}
|