@ran-sh/dsh-crew 2.0.9 → 2.0.11
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/package.json
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
7
8
|
import { dirname, join, resolve, relative } from 'node:path';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { homedir } from 'node:os';
|
|
@@ -23,6 +24,11 @@ const PLUGIN_KEY = `dsh-crew@${MARKETPLACE_NAME}`;
|
|
|
23
24
|
const CLAUDE_PLUGIN_TIMEOUT_MS = 300_000;
|
|
24
25
|
const CLAUDE_SNAPSHOT_SETTLE_MS = 180_000;
|
|
25
26
|
const CLAUDE_SNAPSHOT_POLL_MS = 5_000;
|
|
27
|
+
// The one scope this installer writes, and therefore the only scope whose record
|
|
28
|
+
// means "the integration is installed". Accepting any scope let a project-scope
|
|
29
|
+
// record stand in for a missing user-scope snapshot, so a failed install read as
|
|
30
|
+
// a current one.
|
|
31
|
+
const CLAUDE_PLUGIN_SCOPE = 'user';
|
|
26
32
|
const POLICY_START = '<!-- DSH CREW MANAGED POLICY:START -->';
|
|
27
33
|
const POLICY_END = '<!-- DSH CREW MANAGED POLICY:END -->';
|
|
28
34
|
|
|
@@ -173,26 +179,31 @@ function claudePluginRootReady(root) {
|
|
|
173
179
|
&& mcp.args[0] === '${CLAUDE_PLUGIN_ROOT}/src/server.mjs';
|
|
174
180
|
}
|
|
175
181
|
|
|
176
|
-
function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024 } = {}) {
|
|
182
|
+
export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024, maxDirectories = 256, maxDepth = 12 } = {}) {
|
|
177
183
|
const files = [];
|
|
178
184
|
let bytes = 0;
|
|
185
|
+
let directories = 0;
|
|
179
186
|
const add = (file, relativePath) => {
|
|
180
187
|
const info = lstatSync(file);
|
|
181
188
|
if (info.isSymbolicLink() || !info.isFile()) throw new Error('unsupported snapshot entry');
|
|
189
|
+
// Bound before reading, not after: reading first and rejecting afterwards
|
|
190
|
+
// loads a whole oversized file on the strength of its name.
|
|
191
|
+
if (files.length >= maxFiles || bytes + info.size > maxBytes) throw new Error('snapshot manifest bound exceeded');
|
|
182
192
|
const content = readFileSync(file);
|
|
183
193
|
bytes += content.length;
|
|
184
|
-
if (bytes > maxBytes || files.length >= maxFiles) throw new Error('snapshot manifest bound exceeded');
|
|
185
194
|
files.push([relativePath.replace(/\\/g, '/'), createHash('sha256').update(content).digest('hex')]);
|
|
186
195
|
};
|
|
187
|
-
const walk = (directory, relativeDirectory) => {
|
|
196
|
+
const walk = (directory, relativeDirectory, depth = 0) => {
|
|
197
|
+
if (depth > maxDepth) throw new Error('snapshot directory depth exceeded');
|
|
188
198
|
if (!existsSync(directory)) return;
|
|
199
|
+
if (++directories > maxDirectories) throw new Error('snapshot directory count exceeded');
|
|
189
200
|
const info = lstatSync(directory);
|
|
190
201
|
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('unsupported snapshot directory');
|
|
191
202
|
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
192
203
|
const file = join(directory, entry.name);
|
|
193
204
|
const relativePath = join(relativeDirectory, entry.name);
|
|
194
205
|
if (entry.isSymbolicLink()) throw new Error('snapshot symlink not allowed');
|
|
195
|
-
if (entry.isDirectory()) walk(file, relativePath);
|
|
206
|
+
if (entry.isDirectory()) walk(file, relativePath, depth + 1);
|
|
196
207
|
else if (entry.isFile()) add(file, relativePath);
|
|
197
208
|
else throw new Error('unsupported snapshot entry');
|
|
198
209
|
}
|
|
@@ -200,7 +211,14 @@ function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 *
|
|
|
200
211
|
try {
|
|
201
212
|
add(join(root, '.claude-plugin', 'plugin.json'), join('.claude-plugin', 'plugin.json'));
|
|
202
213
|
if (isFile(join(root, 'package.json'))) add(join(root, 'package.json'), 'package.json');
|
|
203
|
-
|
|
214
|
+
// What the plugin loads, not just what names it. `worker.cordis.yml` is
|
|
215
|
+
// required before any dispatch (`src/jobs.mjs` throws without it) and the
|
|
216
|
+
// statusline scripts are named by the settings this installer writes, so a
|
|
217
|
+
// snapshot missing either is not this release even when the manifest it came
|
|
218
|
+
// from is byte-identical — which is exactly how a stale snapshot used to read
|
|
219
|
+
// as current.
|
|
220
|
+
if (isFile(join(root, 'worker.cordis.yml'))) add(join(root, 'worker.cordis.yml'), 'worker.cordis.yml');
|
|
221
|
+
for (const directory of ['agents', 'commands', 'skills', 'src', 'statusline']) walk(join(root, directory), directory);
|
|
204
222
|
return files;
|
|
205
223
|
} catch { return null; }
|
|
206
224
|
}
|
|
@@ -212,11 +230,13 @@ function sameManagedClaudeFiles(expectedRoot, snapshotRoot) {
|
|
|
212
230
|
return expected !== null && snapshot !== null && JSON.stringify(snapshot) === JSON.stringify(expected);
|
|
213
231
|
}
|
|
214
232
|
|
|
215
|
-
function claudeSnapshotReady(home, root) {
|
|
233
|
+
function claudeSnapshotReady(home, root, { scope = null } = {}) {
|
|
216
234
|
const installed = readJson(join(home, '.claude', 'plugins', 'installed_plugins.json'), {});
|
|
217
235
|
const record = installed?.plugins?.[PLUGIN_KEY];
|
|
218
236
|
const entries = Array.isArray(record) ? record : [record];
|
|
219
|
-
return entries.some((entry) =>
|
|
237
|
+
return entries.some((entry) => (scope === null || entry?.scope === scope)
|
|
238
|
+
&& sameManagedClaudeFiles(root, entry?.installPath)
|
|
239
|
+
&& claudeSnapshotResolvable(entry?.installPath));
|
|
220
240
|
}
|
|
221
241
|
|
|
222
242
|
function claudePermissionsReady(settings) {
|
|
@@ -399,7 +419,10 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
|
|
|
399
419
|
const claudeComponents = {
|
|
400
420
|
enabled: claudeInstalled,
|
|
401
421
|
marketplace: normalizedPath(marketplaceRoot) === normalizedPath(effectiveRoot) && claudePluginRootReady(effectiveRoot),
|
|
402
|
-
|
|
422
|
+
// User scope only: this installer writes that scope, so it is the one whose
|
|
423
|
+
// record means the integration is installed. Accepting any scope let a
|
|
424
|
+
// project-scope record make a missing user-scope snapshot read as present.
|
|
425
|
+
snapshot: claudeSnapshotReady(home, effectiveRoot, { scope: CLAUDE_PLUGIN_SCOPE }),
|
|
403
426
|
permissions: claudePermissionsReady(settings),
|
|
404
427
|
};
|
|
405
428
|
const claudeMissing = Object.entries(claudeComponents).filter(([, present]) => !present).map(([key]) => key);
|
|
@@ -512,6 +535,9 @@ export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
|
|
|
512
535
|
*/
|
|
513
536
|
export function claudeIntegrationLine(result) {
|
|
514
537
|
if (result?.ok === false) return '✗ Claude Code integration failed';
|
|
538
|
+
if (result?.detected === false) {
|
|
539
|
+
return '- Claude Code not detected; settings registered, CLI step skipped';
|
|
540
|
+
}
|
|
515
541
|
if (result?.degraded === true) {
|
|
516
542
|
return `- Claude Code integration registered, but not loaded: ${result.reason ?? 'plugin snapshot not refreshed'}`;
|
|
517
543
|
}
|
|
@@ -530,8 +556,126 @@ export function claudeIntegrationLine(result) {
|
|
|
530
556
|
* without Claude Code, to learn nothing it did not already know.
|
|
531
557
|
*/
|
|
532
558
|
export function claudeSnapshotSettleMs(err) {
|
|
533
|
-
|
|
534
|
-
|
|
559
|
+
// Only the shell's own timeout can leave a copy running. Matching on the signal
|
|
560
|
+
// as well spent the whole window on an `ENOBUFS` (output over maxBuffer) child
|
|
561
|
+
// that had already stopped and could never make the snapshot current.
|
|
562
|
+
return err?.code === 'ETIMEDOUT' ? CLAUDE_SNAPSHOT_SETTLE_MS : 0;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Whether the snapshot can actually run, not merely whether its files match.
|
|
567
|
+
*
|
|
568
|
+
* `src/server.mjs` is the MCP server Claude Code launches, and it imports its
|
|
569
|
+
* runtime dependencies by bare specifier — so a copy interrupted between `src/`
|
|
570
|
+
* and `node_modules/` leaves a snapshot whose compared files are all present and
|
|
571
|
+
* whose server cannot start. Resolution is asked from the snapshot's own
|
|
572
|
+
* `package.json` against the dependencies it declares, so this stays bounded by
|
|
573
|
+
* what the package says it needs rather than by walking installed packages. A
|
|
574
|
+
* snapshot carrying no `package.json` declares nothing, and fails the file
|
|
575
|
+
* comparison instead.
|
|
576
|
+
*/
|
|
577
|
+
function claudeSnapshotResolvable(snapshotRoot) {
|
|
578
|
+
if (typeof snapshotRoot !== 'string' || !snapshotRoot.trim()) return false;
|
|
579
|
+
const manifest = readJson(join(snapshotRoot, 'package.json'), null);
|
|
580
|
+
const declared = manifest && manifest.dependencies && typeof manifest.dependencies === 'object'
|
|
581
|
+
? Object.keys(manifest.dependencies)
|
|
582
|
+
: [];
|
|
583
|
+
if (!declared.length) return true;
|
|
584
|
+
let fromSnapshot;
|
|
585
|
+
try { fromSnapshot = createRequire(join(snapshotRoot, 'package.json')); } catch { return false; }
|
|
586
|
+
return declared.every((dependency) => {
|
|
587
|
+
try { fromSnapshot.resolve(dependency); return true; } catch { /* try the manifest path below */ }
|
|
588
|
+
try { fromSnapshot.resolve(`${dependency}/package.json`); return true; } catch { return false; }
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* How to run the `claude` CLI, as an executable plus an argument array.
|
|
594
|
+
*
|
|
595
|
+
* Paths go in as arguments and never into a shell string: `JSON.stringify` quotes
|
|
596
|
+
* for JSON, not for a command processor, so a path containing `%NAME%` was
|
|
597
|
+
* expanded before the CLI ever saw it. Windows has to reach the CLI through a
|
|
598
|
+
* command processor because a `.cmd` shim cannot be executed directly, so an
|
|
599
|
+
* argument the processor would act on is refused rather than escaped — a refused
|
|
600
|
+
* install is recoverable, a silently relocated path is not.
|
|
601
|
+
*/
|
|
602
|
+
export function claudeCliInvocation(args, { platform = process.platform, environment = process.env } = {}) {
|
|
603
|
+
const argv = args.map((arg) => String(arg));
|
|
604
|
+
if (platform !== 'win32') return { command: 'claude', args: argv };
|
|
605
|
+
const unsafe = argv.find((arg) => /[\0\r\n"%!^&|<>]/.test(arg));
|
|
606
|
+
if (unsafe !== undefined) throw new Error(`unsafe claude CLI argument: ${unsafe}`);
|
|
607
|
+
return {
|
|
608
|
+
command: environment.ComSpec || environment.COMSPEC || 'cmd.exe',
|
|
609
|
+
args: ['/d', '/s', '/c', ['claude.cmd', ...argv.map((arg) => `"${arg}"`)].join(' ')],
|
|
610
|
+
windowsVerbatimArguments: true,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Kill a process and everything it started.
|
|
616
|
+
*
|
|
617
|
+
* The killed shell is not the process doing the work: `claude` is a grandchild
|
|
618
|
+
* that Windows does not reach when only its parent is terminated. An `uninstall`
|
|
619
|
+
* left running that way finishes *after* the install that followed it and deletes
|
|
620
|
+
* the plugin the install just registered, so the tree has to go, and the caller
|
|
621
|
+
* must not return until it has.
|
|
622
|
+
*/
|
|
623
|
+
async function killClaudeProcessTree(pid, { platform = process.platform } = {}) {
|
|
624
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
625
|
+
const { spawn } = await import('node:child_process');
|
|
626
|
+
if (platform === 'win32') {
|
|
627
|
+
await new Promise((done) => {
|
|
628
|
+
let settled = false;
|
|
629
|
+
const finish = () => { if (!settled) { settled = true; done(); } };
|
|
630
|
+
const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
631
|
+
killer.on('error', finish);
|
|
632
|
+
killer.on('close', finish);
|
|
633
|
+
});
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Run one CLI step to completion, or end it and its descendants on timeout.
|
|
641
|
+
*
|
|
642
|
+
* Resolves only once nothing from this step can still be running, so the next
|
|
643
|
+
* step cannot be raced by the previous one. A timeout is reported in the shape
|
|
644
|
+
* `execSync` uses, so the settle rule reads it the same way.
|
|
645
|
+
*/
|
|
646
|
+
async function runClaudeStep(args, { timeoutMs = CLAUDE_PLUGIN_TIMEOUT_MS } = {}) {
|
|
647
|
+
let invocation;
|
|
648
|
+
try { invocation = claudeCliInvocation(args); }
|
|
649
|
+
catch (error) { return { ok: false, timedOut: false, status: null, error }; }
|
|
650
|
+
const { spawn } = await import('node:child_process');
|
|
651
|
+
return await new Promise((resolve) => {
|
|
652
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
653
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
654
|
+
windowsHide: true,
|
|
655
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
|
|
656
|
+
detached: process.platform !== 'win32',
|
|
657
|
+
});
|
|
658
|
+
let timedOut = false;
|
|
659
|
+
let kill = null;
|
|
660
|
+
let stderr = '';
|
|
661
|
+
let settled = false;
|
|
662
|
+
const finish = () => {
|
|
663
|
+
if (settled) return;
|
|
664
|
+
settled = true;
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
const detail = stderr.slice(-400);
|
|
667
|
+
// Never report a step finished while its tree may still be writing.
|
|
668
|
+
const done = () => resolve(timedOut
|
|
669
|
+
? { ok: false, timedOut: true, status: null, detail, error: Object.assign(new Error('claude CLI step timed out'), { code: 'ETIMEDOUT', signal: 'SIGTERM', status: null }) }
|
|
670
|
+
: { ok: code === 0, timedOut: false, status: code, detail, error: null });
|
|
671
|
+
if (kill) kill.then(done, done); else done();
|
|
672
|
+
};
|
|
673
|
+
let code = null;
|
|
674
|
+
const timer = setTimeout(() => { timedOut = true; kill = killClaudeProcessTree(child.pid); }, timeoutMs);
|
|
675
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
|
676
|
+
child.on('error', (error) => { stderr += String(error?.message ?? error); code = null; finish(); });
|
|
677
|
+
child.on('close', (value) => { code = value; finish(); });
|
|
678
|
+
});
|
|
535
679
|
}
|
|
536
680
|
|
|
537
681
|
export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
|
|
@@ -548,58 +692,80 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
548
692
|
|
|
549
693
|
// 2. settings.json: marketplace + enabledPlugins + permissions allowlist.
|
|
550
694
|
const settingsFile = join(home, '.claude', 'settings.json');
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
695
|
+
try {
|
|
696
|
+
mkdirSync(dirname(settingsFile), { recursive: true });
|
|
697
|
+
const bak = backup(settingsFile);
|
|
698
|
+
if (bak) actions.push(`backup: ${bak}`);
|
|
699
|
+
const settingsPresent = existsSync(settingsFile);
|
|
700
|
+
const parsed = readJson(settingsFile, null);
|
|
701
|
+
if (parsed === null && settingsPresent) {
|
|
702
|
+
// Present but unparseable is not "no settings yet". Rebuilding it as an
|
|
703
|
+
// empty configuration is exactly how an operator's settings disappear, with
|
|
704
|
+
// a backup left for them to restore by hand — the file is theirs, and an
|
|
705
|
+
// unreadable one is not permission to replace it.
|
|
706
|
+
return {
|
|
707
|
+
ok: false,
|
|
708
|
+
code: 'CLAUDE_SETTINGS_UNREADABLE',
|
|
709
|
+
error: `${settingsFile} is present but not valid JSON`,
|
|
710
|
+
actions,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const settings = parsed ?? {};
|
|
714
|
+
|
|
715
|
+
// Both fields are records (see json.schemastore.org/claude-code-settings.json).
|
|
716
|
+
// Older versions of this installer wrote arrays, which Claude Code ignores
|
|
717
|
+
// with a warning — migrate those in place.
|
|
718
|
+
const markets = (settings.extraKnownMarketplaces && !Array.isArray(settings.extraKnownMarketplaces)
|
|
719
|
+
&& typeof settings.extraKnownMarketplaces === 'object') ? settings.extraKnownMarketplaces : {};
|
|
720
|
+
if (Array.isArray(settings.extraKnownMarketplaces)) actions.push('migrated legacy extraKnownMarketplaces array');
|
|
721
|
+
markets[MARKETPLACE_NAME] = { source: { source: 'directory', path: mpDir } };
|
|
722
|
+
if (markets['dsh-workers']) {
|
|
723
|
+
delete markets['dsh-workers'];
|
|
724
|
+
actions.push('removed pre-rename dsh-workers marketplace entry');
|
|
725
|
+
}
|
|
726
|
+
settings.extraKnownMarketplaces = markets;
|
|
568
727
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
728
|
+
const enabled = (settings.enabledPlugins && !Array.isArray(settings.enabledPlugins)
|
|
729
|
+
&& typeof settings.enabledPlugins === 'object') ? settings.enabledPlugins : {};
|
|
730
|
+
if (Array.isArray(settings.enabledPlugins)) {
|
|
731
|
+
for (const key of settings.enabledPlugins) if (typeof key === 'string') enabled[key] = true;
|
|
732
|
+
actions.push('migrated legacy enabledPlugins array');
|
|
733
|
+
}
|
|
734
|
+
enabled[PLUGIN_KEY] = true;
|
|
735
|
+
if (enabled['dsh-workers@dsh-workers']) {
|
|
736
|
+
delete enabled['dsh-workers@dsh-workers'];
|
|
737
|
+
actions.push('removed pre-rename dsh-workers plugin entry');
|
|
738
|
+
}
|
|
739
|
+
settings.enabledPlugins = enabled;
|
|
740
|
+
|
|
741
|
+
settings.permissions = settings.permissions ?? {};
|
|
742
|
+
let allow = Array.isArray(settings.permissions.allow) ? settings.permissions.allow : [];
|
|
743
|
+
const preRename = allow.filter((r) => typeof r === 'string' && r.startsWith('mcp__plugin_dsh-workers_'));
|
|
744
|
+
if (preRename.length) {
|
|
745
|
+
allow = allow.filter((r) => !preRename.includes(r));
|
|
746
|
+
actions.push(`removed ${preRename.length} pre-rename permission rules`);
|
|
747
|
+
}
|
|
748
|
+
for (const tool of MCP_TOOLS) {
|
|
749
|
+
const rule = `mcp__plugin_dsh-crew_dsh-crew__${tool}`;
|
|
750
|
+
if (!allow.includes(rule)) allow.push(rule);
|
|
751
|
+
}
|
|
752
|
+
settings.permissions.allow = allow;
|
|
594
753
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
754
|
+
if (statusline && !settings.statusLine) {
|
|
755
|
+
settings.statusLine = { type: 'command', command: `bash ${join(root, 'statusline', 'statusline.sh')}` };
|
|
756
|
+
actions.push('statusline: installed');
|
|
757
|
+
} else if (statusline) {
|
|
758
|
+
actions.push('statusline: skipped (one already configured)');
|
|
759
|
+
}
|
|
601
760
|
|
|
602
|
-
|
|
761
|
+
writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
|
|
762
|
+
} catch (error) {
|
|
763
|
+
// Both install entries branch on `ok === false` for this integration and
|
|
764
|
+
// neither could ever see it: a settings path that could not be backed up or
|
|
765
|
+
// written threw straight out of both of them. Failing to register is the
|
|
766
|
+
// integration failing, so it gets the shape its callers already handle.
|
|
767
|
+
return { ok: false, code: 'CLAUDE_SETTINGS_UNWRITABLE', error: String(error?.message ?? error), actions };
|
|
768
|
+
}
|
|
603
769
|
actions.push(`settings: registered ${PLUGIN_KEY} + ${MCP_TOOLS.length} permission rules`);
|
|
604
770
|
|
|
605
771
|
// Materialize the install through the claude CLI: registers the marketplace
|
|
@@ -612,7 +778,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
612
778
|
const marketplaceCurrent = registered?.source?.source === 'directory'
|
|
613
779
|
&& normalizedPath(registered.source.path) === normalizedPath(root)
|
|
614
780
|
&& normalizedPath(registered.installLocation) === normalizedPath(root);
|
|
615
|
-
if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope ===
|
|
781
|
+
if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === CLAUDE_PLUGIN_SCOPE
|
|
616
782
|
&& sameManagedClaudeFiles(root, entry.installPath))) {
|
|
617
783
|
actions.push('cli: skipped (registered marketplace and snapshot already current)');
|
|
618
784
|
return { ok: true, actions };
|
|
@@ -621,43 +787,59 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
621
787
|
actions.push('cli: skipped (non-default home; test mode)');
|
|
622
788
|
return { ok: true, actions };
|
|
623
789
|
}
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
790
|
+
// The CLI is best-effort, but a host that does not have it must not be told to
|
|
791
|
+
// run it. The checkout entry already gates on this; doing it here too keeps the
|
|
792
|
+
// two entries describing the same machine the same way.
|
|
793
|
+
const { spawnSync } = await import('node:child_process');
|
|
794
|
+
const claudePresent = spawnSync(/^win/.test(process.platform) ? 'where' : 'which', ['claude'], {
|
|
795
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
796
|
+
}).status === 0;
|
|
797
|
+
if (!claudePresent) {
|
|
798
|
+
actions.push('cli: skipped (claude not found)');
|
|
799
|
+
return { ok: true, detected: false, actions };
|
|
800
|
+
}
|
|
801
|
+
// Each step runs to completion — including ending the whole process tree on a
|
|
802
|
+
// timeout — before the next one starts, and the settle window is the widest any
|
|
803
|
+
// step asks for. Two things this replaces: an `uninstall` left running past its
|
|
804
|
+
// timeout finished *after* the install that followed and deleted the plugin the
|
|
805
|
+
// install had just registered, and a later step's ordinary failure used to erase
|
|
806
|
+
// an earlier step's timeout from the evidence.
|
|
807
|
+
// 300s, not 120: the install runs after the uninstall, so it does a real copy of
|
|
808
|
+
// the plugin tree rather than the no-op an already-installed plugin gets.
|
|
809
|
+
// Measured on this machine: `marketplace add` 3s, `uninstall` 3s, `install` 163s
|
|
810
|
+
// (no-op install: 6s). The old ceiling killed the copy partway and left Claude
|
|
811
|
+
// Code without the plugin the same run had just removed.
|
|
812
|
+
let settleMs = 0;
|
|
813
|
+
const noteStep = (step) => { settleMs = Math.max(settleMs, claudeSnapshotSettleMs(step?.error)); };
|
|
814
|
+
|
|
815
|
+
const marketplace = await runClaudeStep(['plugin', 'marketplace', 'add', mpDir]);
|
|
816
|
+
noteStep(marketplace);
|
|
817
|
+
actions.push(marketplace.ok
|
|
818
|
+
? 'cli: marketplace registered'
|
|
819
|
+
: `cli: marketplace add failed${marketplace.timedOut ? ' (timed out)' : ''}`);
|
|
820
|
+
|
|
821
|
+
// `plugin install` on an already-installed plugin is a no-op and leaves a
|
|
822
|
+
// stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
|
|
823
|
+
// always re-copies the current code.
|
|
824
|
+
noteStep(await runClaudeStep(['plugin', 'uninstall', PLUGIN_KEY]));
|
|
825
|
+
|
|
826
|
+
// Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted it.
|
|
827
|
+
// Try without it first, fall back to the legacy flag.
|
|
828
|
+
let install = await runClaudeStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE]);
|
|
829
|
+
if (!install.ok && !install.timedOut && /unknown option/i.test(String(install.detail ?? ''))) {
|
|
830
|
+
install = await runClaudeStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE, '-y']);
|
|
831
|
+
}
|
|
832
|
+
noteStep(install);
|
|
833
|
+
actions.push(install.ok
|
|
834
|
+
? `cli: plugin snapshot refreshed (${PLUGIN_KEY})`
|
|
835
|
+
: `cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY}${install.timedOut ? ' (timed out)' : ''}`);
|
|
654
836
|
// Report the state that resulted, not the step that was attempted. The CLI is
|
|
655
837
|
// best-effort — a machine without `claude` is a supported install, and its
|
|
656
838
|
// settings alone are correct — but a CLI that is present and slow, timed out,
|
|
657
839
|
// or failed leaves the snapshot exactly as stale as it was, and it is the
|
|
658
840
|
// snapshot that `installStatus` reads. Saying so here is what lets the caller
|
|
659
841
|
// stop printing a checkmark for a state it never verified.
|
|
660
|
-
if (claudeSnapshotReady(home, root)) return { ok: true, actions };
|
|
842
|
+
if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) return { ok: true, actions };
|
|
661
843
|
// The CLI can outlive the ceiling above. The install is a real copy of the
|
|
662
844
|
// plugin tree — 163s measured on an idle machine, and ~6 minutes measured
|
|
663
845
|
// during an activation, where the record landed well after any ceiling — and a
|
|
@@ -665,11 +847,11 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
665
847
|
// is still running keeps writing while this function has already given up on
|
|
666
848
|
// it. Wait for the snapshot to settle before reporting it missing, or the
|
|
667
849
|
// update says "not loaded" about a plugin that is loading.
|
|
668
|
-
const settleDeadline = Date.now() +
|
|
850
|
+
const settleDeadline = Date.now() + settleMs;
|
|
669
851
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
670
852
|
while (Date.now() < settleDeadline) {
|
|
671
853
|
await wait(CLAUDE_SNAPSHOT_POLL_MS);
|
|
672
|
-
if (claudeSnapshotReady(home, root)) {
|
|
854
|
+
if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) {
|
|
673
855
|
return { ok: true, actions: [...actions, 'cli: snapshot confirmed current after the ceiling'] };
|
|
674
856
|
}
|
|
675
857
|
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '2.0.
|
|
39
|
+
export const RUNTIME_VERSION = '2.0.11';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|
|
@@ -437,8 +437,16 @@ function Get-HealthState {
|
|
|
437
437
|
$diskReadable = $true
|
|
438
438
|
}
|
|
439
439
|
$cohortMatches = $diskReadable -and $runtime.dsh_version -eq $expectedDshVersion
|
|
440
|
+
# Read through PSObject: StrictMode is on, so a response that simply omits the
|
|
441
|
+
# property would throw and be swallowed as "not ready" rather than being
|
|
442
|
+
# reported absent.
|
|
443
|
+
$runtimeId = $null
|
|
444
|
+
if ($null -ne $runtime) {
|
|
445
|
+
$runtimeIdProperty = $runtime.PSObject.Properties['runtime_id']
|
|
446
|
+
if ($null -ne $runtimeIdProperty -and $null -ne $runtimeIdProperty.Value) { $runtimeId = [string] $runtimeIdProperty.Value }
|
|
447
|
+
}
|
|
440
448
|
if ($response.ok -eq $true -and $version -and $cohortMatches) {
|
|
441
|
-
return [pscustomobject]@{ Ready = $true; Version = [string] $version; Error = $null }
|
|
449
|
+
return [pscustomobject]@{ Ready = $true; Version = [string] $version; RuntimeId = $runtimeId; Error = $null }
|
|
442
450
|
}
|
|
443
451
|
if (-not $diskReadable) {
|
|
444
452
|
return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'disk runtime manifest unreadable; cannot prove cohort identity' }
|
|
@@ -551,6 +559,92 @@ function Get-FreshSupervisorHeartbeat {
|
|
|
551
559
|
return $null
|
|
552
560
|
}
|
|
553
561
|
|
|
562
|
+
# ---- Persisted ownership of the live Hub -----------------------------------
|
|
563
|
+
# A watcher can exit while the Hub it started keeps serving. Nothing else on the
|
|
564
|
+
# machine can tell a later watcher that such a listener is Crew's: port health
|
|
565
|
+
# says a process answers, not whose it is, and adopting on health alone would put
|
|
566
|
+
# a stranger's listener within reach of Stop-OwnedListener. So the identity is
|
|
567
|
+
# written down when it is established, and re-proven field by field — PID, start
|
|
568
|
+
# time, port, profile, Crew home and live runtime_id — before any later watcher
|
|
569
|
+
# adopts it. A record that merely exists is not authority; it may name a Hub from
|
|
570
|
+
# a previous cohort, another profile, or a PID the system has since recycled.
|
|
571
|
+
|
|
572
|
+
$crewOwnedServiceFile = Join-Path $crewSupervisorRoot 'owned-service.json'
|
|
573
|
+
|
|
574
|
+
function Write-OwnedServiceRecord {
|
|
575
|
+
param([pscustomobject] $Service, [string] $RuntimeId = $null)
|
|
576
|
+
if (-not $Service.CrewOwned -or -not $Service.RootPid -or -not $Service.RootStartedAtUtcTicks) { return }
|
|
577
|
+
$record = @{
|
|
578
|
+
schema_version = 1
|
|
579
|
+
profile = [string] $Service.Profile
|
|
580
|
+
home = [string] $Service.Home
|
|
581
|
+
port = [int] $Service.Port
|
|
582
|
+
root_pid = [int] $Service.RootPid
|
|
583
|
+
root_started_at_utc_ticks = [long] $Service.RootStartedAtUtcTicks
|
|
584
|
+
listener_pid = if ($Service.ListenerPid) { [int] $Service.ListenerPid } else { $null }
|
|
585
|
+
listener_started_at_utc_ticks = if ($Service.ListenerStartedAtUtcTicks) { [long] $Service.ListenerStartedAtUtcTicks } else { $null }
|
|
586
|
+
runtime_id = $RuntimeId
|
|
587
|
+
recorded_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
588
|
+
} | ConvertTo-Json -Compress
|
|
589
|
+
try {
|
|
590
|
+
if (-not (Test-Path -LiteralPath $crewSupervisorRoot -PathType Container)) { New-Item -ItemType Directory -Path $crewSupervisorRoot -Force | Out-Null }
|
|
591
|
+
$temp = Join-Path $crewSupervisorRoot ("owned-service.{0}.tmp" -f $PID)
|
|
592
|
+
Write-Utf8NoBom -Path $temp -Content $record
|
|
593
|
+
Move-Item -LiteralPath $temp -Destination $crewOwnedServiceFile -Force
|
|
594
|
+
} catch { /* ownership record is best-effort, like the heartbeat */ }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function Clear-OwnedServiceRecord {
|
|
598
|
+
try {
|
|
599
|
+
if (Test-Path -LiteralPath $crewOwnedServiceFile -PathType Leaf) { Remove-Item -LiteralPath $crewOwnedServiceFile -Force }
|
|
600
|
+
} catch { }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function Get-OwnedServiceRecord {
|
|
604
|
+
try {
|
|
605
|
+
if (-not (Test-Path -LiteralPath $crewOwnedServiceFile -PathType Leaf)) { return $null }
|
|
606
|
+
$record = Get-Content -LiteralPath $crewOwnedServiceFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
607
|
+
if ($record.schema_version -ne 1) { return $null }
|
|
608
|
+
return $record
|
|
609
|
+
} catch {
|
|
610
|
+
return $null
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function Restore-OwnedServiceRecord {
|
|
615
|
+
param([pscustomobject] $Service, [pscustomobject] $Health = $null)
|
|
616
|
+
if (-not $Service.CrewOwned) { return $false }
|
|
617
|
+
$record = Get-OwnedServiceRecord
|
|
618
|
+
if (-not $record) { return $false }
|
|
619
|
+
if ([string] $record.profile -ne [string] $Service.Profile) { return $false }
|
|
620
|
+
if ([string] $record.home -ne [string] $Service.Home) { return $false }
|
|
621
|
+
if ([int] $record.port -ne [int] $Service.Port) { return $false }
|
|
622
|
+
if (-not $record.root_pid -or -not $record.root_started_at_utc_ticks) { return $false }
|
|
623
|
+
if (-not $record.listener_pid -or -not $record.listener_started_at_utc_ticks) { return $false }
|
|
624
|
+
# Without this, an interrupted write could leave a record whose listener half is
|
|
625
|
+
# simply absent, and "absent" must never read as "the Hub this record names".
|
|
626
|
+
if ([string]::IsNullOrWhiteSpace([string] $record.runtime_id)) { return $false }
|
|
627
|
+
try {
|
|
628
|
+
$processes = @(Get-CimInstance Win32_Process -ErrorAction Stop)
|
|
629
|
+
} catch {
|
|
630
|
+
return $false
|
|
631
|
+
}
|
|
632
|
+
# Start times come from Get-Process, since Win32_Process carries none: the table
|
|
633
|
+
# only has to establish that the PID exists before the ticks are compared.
|
|
634
|
+
if (-not (Test-TrackedProcessIdentity -ProcessId ([int] $record.root_pid) -ExpectedStartTicks ([long] $record.root_started_at_utc_ticks) -ProcessTable $processes)) { return $false }
|
|
635
|
+
if (-not (Test-TrackedProcessIdentity -ProcessId ([int] $record.listener_pid) -ExpectedStartTicks ([long] $record.listener_started_at_utc_ticks) -ProcessTable $processes)) { return $false }
|
|
636
|
+
$port = Get-PortState $Service.Port
|
|
637
|
+
if ($port.State -ne 'occupied' -or -not $port.Pid -or [int] $port.Pid -ne [int] $record.listener_pid) { return $false }
|
|
638
|
+
$live = if ($Health) { $Health } else { Get-HealthState $Service }
|
|
639
|
+
if (-not $live.Ready) { return $false }
|
|
640
|
+
if ([string] $live.RuntimeId -ne [string] $record.runtime_id) { return $false }
|
|
641
|
+
$Service.RootPid = [int] $record.root_pid
|
|
642
|
+
$Service.RootStartedAtUtcTicks = [long] $record.root_started_at_utc_ticks
|
|
643
|
+
$Service.ListenerPid = [int] $record.listener_pid
|
|
644
|
+
$Service.ListenerStartedAtUtcTicks = [long] $record.listener_started_at_utc_ticks
|
|
645
|
+
return $true
|
|
646
|
+
}
|
|
647
|
+
|
|
554
648
|
function Ensure-CrewSupervisorRunning {
|
|
555
649
|
param([int] $TimeoutSeconds = 90)
|
|
556
650
|
$watcher = $null
|
|
@@ -967,7 +1061,7 @@ function Get-TrackedProcessTree {
|
|
|
967
1061
|
}
|
|
968
1062
|
|
|
969
1063
|
function Set-TrackedListenerIdentity {
|
|
970
|
-
param([pscustomobject] $Service)
|
|
1064
|
+
param([pscustomobject] $Service, [string] $RuntimeId = $null)
|
|
971
1065
|
$port = Get-PortState $Service.Port
|
|
972
1066
|
if ($port.State -ne 'occupied' -or -not $port.Pid) { return $false }
|
|
973
1067
|
$tree = @(Get-TrackedProcessTree -Service $Service)
|
|
@@ -976,6 +1070,7 @@ function Set-TrackedListenerIdentity {
|
|
|
976
1070
|
$listener = Get-Process -Id $port.Pid -ErrorAction Stop
|
|
977
1071
|
$Service.ListenerPid = [int] $port.Pid
|
|
978
1072
|
$Service.ListenerStartedAtUtcTicks = $listener.StartTime.ToUniversalTime().Ticks
|
|
1073
|
+
Write-OwnedServiceRecord -Service $Service -RuntimeId $RuntimeId
|
|
979
1074
|
return $true
|
|
980
1075
|
} catch {
|
|
981
1076
|
return $false
|
|
@@ -1057,6 +1152,10 @@ function Start-CrewService {
|
|
|
1057
1152
|
$Service.ListenerStartedAtUtcTicks = $null
|
|
1058
1153
|
$Service.ConsecutiveFailures = 0
|
|
1059
1154
|
$Service.State = 'starting'
|
|
1155
|
+
# Recorded before the Hub is healthy on purpose: the record is written again
|
|
1156
|
+
# with the listener identity once it is, and only that later write is
|
|
1157
|
+
# adoptable, because this one carries no runtime_id to prove itself against.
|
|
1158
|
+
Write-OwnedServiceRecord -Service $Service
|
|
1060
1159
|
Write-LaunchLog ('Started {0} on port {1}; PID={2}; stdout={3}; stderr={4}' -f $Service.Profile, $Service.Port, $process.Id, $stdout, $stderr)
|
|
1061
1160
|
} finally {
|
|
1062
1161
|
$env:DSH_HOME = $previousHome
|
|
@@ -1070,7 +1169,7 @@ function Wait-CrewServices {
|
|
|
1070
1169
|
$health = Get-HealthState $service
|
|
1071
1170
|
$service.LastError = $health.Error
|
|
1072
1171
|
if ($health.Ready) {
|
|
1073
|
-
if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
|
|
1172
|
+
if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service -RuntimeId $health.RuntimeId)) {
|
|
1074
1173
|
throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
|
|
1075
1174
|
}
|
|
1076
1175
|
if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
|
|
@@ -1118,7 +1217,16 @@ function Ensure-CrewServices {
|
|
|
1118
1217
|
$health = Get-HealthState $service
|
|
1119
1218
|
if ($health.Ready) {
|
|
1120
1219
|
$wasReady = $service.State -eq 'ready'
|
|
1121
|
-
if ($service.CrewOwned -and -not $service.ListenerPid
|
|
1220
|
+
if ($service.CrewOwned -and -not $service.ListenerPid) {
|
|
1221
|
+
# A watcher that exited leaves its Hub serving with nothing in memory to
|
|
1222
|
+
# say whose it is. Re-prove the persisted identity before touching it;
|
|
1223
|
+
# this is the only path by which a later watcher may adopt a live Hub,
|
|
1224
|
+
# and it never adopts on port health alone.
|
|
1225
|
+
if (Restore-OwnedServiceRecord -Service $service -Health $health) {
|
|
1226
|
+
Write-LaunchLog ('{0} on {1} recovered from the persisted ownership record; listener PID={2}.' -f $service.Name, $service.Port, $service.ListenerPid)
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service -RuntimeId $health.RuntimeId)) {
|
|
1122
1230
|
throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
|
|
1123
1231
|
}
|
|
1124
1232
|
if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
|
|
@@ -1157,6 +1265,7 @@ function Ensure-CrewServices {
|
|
|
1157
1265
|
$service.ListenerPid = $null
|
|
1158
1266
|
$service.ListenerStartedAtUtcTicks = $null
|
|
1159
1267
|
$service.ConsecutiveFailures = 0
|
|
1268
|
+
Clear-OwnedServiceRecord
|
|
1160
1269
|
$port = Get-PortState $service.Port
|
|
1161
1270
|
}
|
|
1162
1271
|
}
|