@ran-sh/dsh-crew 2.0.12 → 2.0.13
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
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// render Codex agent roles with real paths. Called from the CLI entry or the
|
|
3
3
|
// DSH settings page. All edits are backed up and idempotent.
|
|
4
4
|
|
|
5
|
-
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, rmSync, statSync, lstatSync, opendirSync } from 'node:fs';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
8
9
|
import { dirname, join, resolve, relative } from 'node:path';
|
|
9
10
|
import { fileURLToPath } from 'node:url';
|
|
10
11
|
import { homedir } from 'node:os';
|
|
@@ -25,6 +26,9 @@ const PLUGIN_KEY = `dsh-crew@${MARKETPLACE_NAME}`;
|
|
|
25
26
|
// plugin the same run had just removed.
|
|
26
27
|
const CLAUDE_STEP_TIMEOUT_MS = 300_000;
|
|
27
28
|
const CLAUDE_INSTALL_TIMEOUT_MS = 900_000;
|
|
29
|
+
// How long a landing kill gets to actually close the child before the step ends
|
|
30
|
+
// and reports the termination as unconfirmed.
|
|
31
|
+
const CLAUDE_KILL_GRACE_MS = 15_000;
|
|
28
32
|
const CLAUDE_SNAPSHOT_SETTLE_MS = 180_000;
|
|
29
33
|
const CLAUDE_SNAPSHOT_POLL_MS = 5_000;
|
|
30
34
|
// The one scope this installer writes, and therefore the only scope whose record
|
|
@@ -182,10 +186,11 @@ function claudePluginRootReady(root) {
|
|
|
182
186
|
&& mcp.args[0] === '${CLAUDE_PLUGIN_ROOT}/src/server.mjs';
|
|
183
187
|
}
|
|
184
188
|
|
|
185
|
-
export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024, maxDirectories = 256, maxDepth = 12 } = {}) {
|
|
189
|
+
export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024, maxDirectories = 256, maxDepth = 12, maxEntries = 2048 } = {}) {
|
|
186
190
|
const files = [];
|
|
187
191
|
let bytes = 0;
|
|
188
192
|
let directories = 0;
|
|
193
|
+
let entries = 0;
|
|
189
194
|
const add = (file, relativePath) => {
|
|
190
195
|
const info = lstatSync(file);
|
|
191
196
|
if (info.isSymbolicLink() || !info.isFile()) throw new Error('unsupported snapshot entry');
|
|
@@ -196,13 +201,29 @@ export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 *
|
|
|
196
201
|
bytes += content.length;
|
|
197
202
|
files.push([relativePath.replace(/\\/g, '/'), createHash('sha256').update(content).digest('hex')]);
|
|
198
203
|
};
|
|
204
|
+
const listEntries = (directory) => {
|
|
205
|
+
// Enumerated incrementally, and rejected the moment it goes over budget:
|
|
206
|
+
// `readdirSync` materialises the whole listing first, so a bound applied to
|
|
207
|
+
// its result is a bound applied after the cost has already been paid.
|
|
208
|
+
const found = [];
|
|
209
|
+
const handle = opendirSync(directory);
|
|
210
|
+
try {
|
|
211
|
+
for (let entry = handle.readSync(); entry !== null; entry = handle.readSync()) {
|
|
212
|
+
if (++entries > maxEntries) throw new Error('snapshot entry count exceeded');
|
|
213
|
+
found.push(entry);
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
try { handle.closeSync(); } catch { /* already closed */ }
|
|
217
|
+
}
|
|
218
|
+
return found.sort((a, b) => a.name.localeCompare(b.name));
|
|
219
|
+
};
|
|
199
220
|
const walk = (directory, relativeDirectory, depth = 0) => {
|
|
200
221
|
if (depth > maxDepth) throw new Error('snapshot directory depth exceeded');
|
|
201
222
|
if (!existsSync(directory)) return;
|
|
202
223
|
if (++directories > maxDirectories) throw new Error('snapshot directory count exceeded');
|
|
203
224
|
const info = lstatSync(directory);
|
|
204
225
|
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('unsupported snapshot directory');
|
|
205
|
-
for (const entry of
|
|
226
|
+
for (const entry of listEntries(directory)) {
|
|
206
227
|
const file = join(directory, entry.name);
|
|
207
228
|
const relativePath = join(relativeDirectory, entry.name);
|
|
208
229
|
if (entry.isSymbolicLink()) throw new Error('snapshot symlink not allowed');
|
|
@@ -568,30 +589,60 @@ export function claudeSnapshotSettleMs(err) {
|
|
|
568
589
|
/**
|
|
569
590
|
* Whether the snapshot can actually run, not merely whether its files match.
|
|
570
591
|
*
|
|
571
|
-
* `src/server.mjs` is the MCP server Claude Code launches,
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
* comparison instead.
|
|
592
|
+
* `src/server.mjs` is the MCP server Claude Code launches, so what has to be
|
|
593
|
+
* present is what *it* imports. The manifest is not a substitute: it declares 28
|
|
594
|
+
* dependencies, including meta-packages the server never imports from here, and
|
|
595
|
+
* requiring all of them to resolve read a working install as broken. Resolution
|
|
596
|
+
* is asked from the entry itself, and subpath specifiers are kept as they are —
|
|
597
|
+
* `@modelcontextprotocol/sdk/server/mcp.js` resolves through its package's
|
|
598
|
+
* `exports`, which the bare package name does not.
|
|
579
599
|
*/
|
|
580
600
|
function claudeSnapshotResolvable(snapshotRoot) {
|
|
581
601
|
if (typeof snapshotRoot !== 'string' || !snapshotRoot.trim()) return false;
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
602
|
+
const entry = join(snapshotRoot, 'src', 'server.mjs');
|
|
603
|
+
if (!existsSync(entry)) return false;
|
|
604
|
+
let source;
|
|
605
|
+
try { source = readFileSync(entry, 'utf8'); } catch { return false; }
|
|
606
|
+
const specifiers = [...source.matchAll(/(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g)]
|
|
607
|
+
.map((match) => match[1])
|
|
608
|
+
.filter((specifier) => !specifier.startsWith('.') && !specifier.startsWith('node:'));
|
|
609
|
+
if (!specifiers.length) return true;
|
|
610
|
+
let fromEntry;
|
|
611
|
+
try { fromEntry = createRequire(entry); } catch { return false; }
|
|
612
|
+
return specifiers.every((specifier) => {
|
|
613
|
+
try { return existsSync(fromEntry.resolve(specifier)); } catch { return false; }
|
|
592
614
|
});
|
|
593
615
|
}
|
|
594
616
|
|
|
617
|
+
/**
|
|
618
|
+
* The `claude` executable this machine actually has.
|
|
619
|
+
*
|
|
620
|
+
* `where claude` reports every match — the extensionless shim, `claude.cmd`, and
|
|
621
|
+
* `claude.exe` on an install that ships the native binary. Detection and execution
|
|
622
|
+
* are different questions: hardcoding `.cmd` meant a host with only the native
|
|
623
|
+
* executable was detected and then could not be run. A native executable is
|
|
624
|
+
* preferred because it starts without a command processor, so there is no quoting
|
|
625
|
+
* to get wrong.
|
|
626
|
+
*/
|
|
627
|
+
export function pickClaudeCommand(candidates, { platform = process.platform } = {}) {
|
|
628
|
+
const list = (Array.isArray(candidates) ? candidates : []).map((line) => String(line).trim()).filter(Boolean);
|
|
629
|
+
if (!list.length) return null;
|
|
630
|
+
if (platform !== 'win32') return list[0];
|
|
631
|
+
// A native executable is preferred because it starts without a command
|
|
632
|
+
// processor, so there is no quoting to get wrong.
|
|
633
|
+
return list.find((candidate) => /\.exe$/i.test(candidate))
|
|
634
|
+
?? list.find((candidate) => /\.(cmd|bat)$/i.test(candidate))
|
|
635
|
+
?? list[0];
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export function resolveClaudeCommand({ platform = process.platform } = {}) {
|
|
639
|
+
const probe = spawnSync(platform === 'win32' ? 'where' : 'which', ['claude'], {
|
|
640
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
641
|
+
});
|
|
642
|
+
if (probe.status !== 0) return null;
|
|
643
|
+
return pickClaudeCommand(String(probe.stdout ?? '').split(/\r?\n/), { platform });
|
|
644
|
+
}
|
|
645
|
+
|
|
595
646
|
/**
|
|
596
647
|
* How to run the `claude` CLI, as an executable plus an argument array.
|
|
597
648
|
*
|
|
@@ -602,14 +653,19 @@ function claudeSnapshotResolvable(snapshotRoot) {
|
|
|
602
653
|
* argument the processor would act on is refused rather than escaped — a refused
|
|
603
654
|
* install is recoverable, a silently relocated path is not.
|
|
604
655
|
*/
|
|
605
|
-
export function claudeCliInvocation(args, { platform = process.platform, environment = process.env } = {}) {
|
|
656
|
+
export function claudeCliInvocation(args, { platform = process.platform, environment = process.env, executable = null } = {}) {
|
|
606
657
|
const argv = args.map((arg) => String(arg));
|
|
607
|
-
if (platform !== 'win32') return { command: 'claude', args: argv };
|
|
608
|
-
const
|
|
658
|
+
if (platform !== 'win32') return { command: executable ?? 'claude', args: argv };
|
|
659
|
+
const target = executable ?? 'claude.cmd';
|
|
660
|
+
// A native executable starts directly: no command processor, so no quoting rules
|
|
661
|
+
// to be wrong about.
|
|
662
|
+
if (/\.exe$/i.test(target)) return { command: target, args: argv };
|
|
663
|
+
const unsafe = [target, ...argv].find((arg) => /[\0\r\n"%!^&|<>]/.test(arg));
|
|
609
664
|
if (unsafe !== undefined) throw new Error(`unsafe claude CLI argument: ${unsafe}`);
|
|
665
|
+
const quotedTarget = /\s/.test(target) ? `"${target}"` : target;
|
|
610
666
|
return {
|
|
611
667
|
command: environment.ComSpec || environment.COMSPEC || 'cmd.exe',
|
|
612
|
-
args: ['/d', '/s', '/c', [
|
|
668
|
+
args: ['/d', '/s', '/c', `"${[quotedTarget, ...argv.map((arg) => `"${arg}"`)].join(' ')}"`],
|
|
613
669
|
windowsVerbatimArguments: true,
|
|
614
670
|
};
|
|
615
671
|
}
|
|
@@ -623,62 +679,106 @@ export function claudeCliInvocation(args, { platform = process.platform, environ
|
|
|
623
679
|
* the plugin the install just registered, so the tree has to go, and the caller
|
|
624
680
|
* must not return until it has.
|
|
625
681
|
*/
|
|
626
|
-
async function killClaudeProcessTree(pid, { platform = process.platform } = {}) {
|
|
627
|
-
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
682
|
+
async function killClaudeProcessTree(pid, { platform = process.platform, timeoutMs = CLAUDE_KILL_GRACE_MS } = {}) {
|
|
683
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
628
684
|
const { spawn } = await import('node:child_process');
|
|
629
685
|
if (platform === 'win32') {
|
|
630
|
-
await new Promise((done) => {
|
|
686
|
+
return await new Promise((done) => {
|
|
631
687
|
let settled = false;
|
|
632
|
-
const finish = () => { if (!settled) { settled = true; done(); } };
|
|
688
|
+
const finish = (value) => { if (!settled) { settled = true; clearTimeout(timer); done(value); } };
|
|
689
|
+
const timer = setTimeout(() => { killer.kill(); finish(false); }, timeoutMs);
|
|
633
690
|
const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
634
|
-
killer.on('error', finish);
|
|
635
|
-
killer.on('close', finish);
|
|
691
|
+
killer.on('error', () => finish(false));
|
|
692
|
+
killer.on('close', (code) => finish(code === 0));
|
|
636
693
|
});
|
|
637
|
-
return;
|
|
638
694
|
}
|
|
639
|
-
try { process.kill(-pid, 'SIGKILL'); } catch {
|
|
695
|
+
try { process.kill(-pid, 'SIGKILL'); return true; } catch { /* fall through to the single pid */ }
|
|
696
|
+
try { process.kill(pid, 'SIGKILL'); return true; } catch { return false; }
|
|
640
697
|
}
|
|
641
698
|
|
|
642
699
|
/**
|
|
643
700
|
* Run one CLI step to completion, or end it and its descendants on timeout.
|
|
644
701
|
*
|
|
645
|
-
* Resolves only once nothing from this step can still be running, so the next
|
|
646
|
-
*
|
|
647
|
-
*
|
|
702
|
+
* Resolves only once nothing from this step can still be running, so the next step
|
|
703
|
+
* cannot be raced by the previous one — but it does resolve. A termination that
|
|
704
|
+
* does not land is reported as unconfirmed rather than waited on forever, and a
|
|
705
|
+
* timeout carries the shape `execSync` uses so the settle rule reads it the same
|
|
706
|
+
* way.
|
|
648
707
|
*/
|
|
649
|
-
async function runClaudeStep(args, { timeoutMs = CLAUDE_STEP_TIMEOUT_MS } = {}) {
|
|
708
|
+
export async function runClaudeStep(args, { timeoutMs = CLAUDE_STEP_TIMEOUT_MS, executable = null, killGraceMs = CLAUDE_KILL_GRACE_MS, terminate = killClaudeProcessTree } = {}) {
|
|
650
709
|
let invocation;
|
|
651
|
-
try { invocation = claudeCliInvocation(args); }
|
|
652
|
-
catch (error) { return { ok: false, timedOut: false, status: null, error }; }
|
|
710
|
+
try { invocation = claudeCliInvocation(args, { executable }); }
|
|
711
|
+
catch (error) { return { ok: false, timedOut: false, refused: true, terminated: null, status: null, detail: '', error }; }
|
|
653
712
|
const { spawn } = await import('node:child_process');
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
detached: process.platform !== 'win32',
|
|
660
|
-
});
|
|
661
|
-
let timedOut = false;
|
|
662
|
-
let kill = null;
|
|
663
|
-
let stderr = '';
|
|
664
|
-
let settled = false;
|
|
665
|
-
const finish = () => {
|
|
666
|
-
if (settled) return;
|
|
667
|
-
settled = true;
|
|
668
|
-
clearTimeout(timer);
|
|
669
|
-
const detail = stderr.slice(-400);
|
|
670
|
-
// Never report a step finished while its tree may still be writing.
|
|
671
|
-
const done = () => resolve(timedOut
|
|
672
|
-
? { ok: false, timedOut: true, status: null, detail, error: Object.assign(new Error('claude CLI step timed out'), { code: 'ETIMEDOUT', signal: 'SIGTERM', status: null }) }
|
|
673
|
-
: { ok: code === 0, timedOut: false, status: code, detail, error: null });
|
|
674
|
-
if (kill) kill.then(done, done); else done();
|
|
675
|
-
};
|
|
676
|
-
let code = null;
|
|
677
|
-
const timer = setTimeout(() => { timedOut = true; kill = killClaudeProcessTree(child.pid); }, timeoutMs);
|
|
678
|
-
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
|
679
|
-
child.on('error', (error) => { stderr += String(error?.message ?? error); code = null; finish(); });
|
|
680
|
-
child.on('close', (value) => { code = value; finish(); });
|
|
713
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
714
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
715
|
+
windowsHide: true,
|
|
716
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
|
|
717
|
+
detached: process.platform !== 'win32',
|
|
681
718
|
});
|
|
719
|
+
|
|
720
|
+
// Both pipes are drained. An unread pipe fills — 64 KiB is enough — and the
|
|
721
|
+
// child then blocks on write, so a step that prints a lot of output never exits
|
|
722
|
+
// and is killed at the ceiling for being talkative rather than for being stuck.
|
|
723
|
+
let output = '';
|
|
724
|
+
const drain = (chunk) => { output = (output + String(chunk)).slice(-400); };
|
|
725
|
+
child.stdout?.on('data', drain);
|
|
726
|
+
child.stderr?.on('data', drain);
|
|
727
|
+
|
|
728
|
+
const exited = new Promise((resolve) => {
|
|
729
|
+
child.on('close', (code) => resolve({ code, error: null }));
|
|
730
|
+
child.on('error', (error) => resolve({ code: null, error }));
|
|
731
|
+
});
|
|
732
|
+
const bounded = async (promise, ms) => {
|
|
733
|
+
let timer;
|
|
734
|
+
try { return await Promise.race([promise, new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })]); }
|
|
735
|
+
finally { clearTimeout(timer); }
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
let timedOut = false;
|
|
739
|
+
let raiseCeiling;
|
|
740
|
+
const ceiling = new Promise((resolve) => { raiseCeiling = resolve; });
|
|
741
|
+
const timer = setTimeout(() => { timedOut = true; raiseCeiling(); }, timeoutMs);
|
|
742
|
+
|
|
743
|
+
const outcome = await Promise.race([exited.then(() => 'exited'), ceiling.then(() => 'ceiling')]);
|
|
744
|
+
clearTimeout(timer);
|
|
745
|
+
|
|
746
|
+
if (outcome === 'ceiling') {
|
|
747
|
+
// The kill is bounded too: a `taskkill` that itself hangs must not leave the
|
|
748
|
+
// step pending any more than one that returns non-zero.
|
|
749
|
+
const killLanded = await bounded(Promise.resolve().then(() => terminate(child.pid, { timeoutMs: killGraceMs })).catch(() => false), killGraceMs);
|
|
750
|
+
// Give a landing kill its moment to actually close the child, and take the
|
|
751
|
+
// close as proof if it arrives. Never wait on a termination that did not land:
|
|
752
|
+
// the step has to end either way, and saying the tree is unconfirmed is the
|
|
753
|
+
// honest result, not a pending promise.
|
|
754
|
+
const closed = await bounded(exited.then(() => true), killGraceMs);
|
|
755
|
+
const terminated = killLanded === true && closed === true;
|
|
756
|
+
if (!closed) {
|
|
757
|
+
child.stdout?.destroy();
|
|
758
|
+
child.stderr?.destroy();
|
|
759
|
+
child.unref();
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
ok: false,
|
|
763
|
+
timedOut: true,
|
|
764
|
+
refused: false,
|
|
765
|
+
terminated,
|
|
766
|
+
status: null,
|
|
767
|
+
detail: output,
|
|
768
|
+
error: Object.assign(new Error('claude CLI step timed out'), { code: 'ETIMEDOUT', signal: 'SIGTERM', status: null }),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const { code, error } = await exited;
|
|
773
|
+
return {
|
|
774
|
+
ok: code === 0 && !error,
|
|
775
|
+
timedOut: false,
|
|
776
|
+
refused: false,
|
|
777
|
+
terminated: null,
|
|
778
|
+
status: code ?? null,
|
|
779
|
+
detail: error ? `${output}${String(error?.message ?? error)}`.slice(-400) : output,
|
|
780
|
+
error: error ?? null,
|
|
781
|
+
};
|
|
682
782
|
}
|
|
683
783
|
|
|
684
784
|
export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
|
|
@@ -781,8 +881,12 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
781
881
|
const marketplaceCurrent = registered?.source?.source === 'directory'
|
|
782
882
|
&& normalizedPath(registered.source.path) === normalizedPath(root)
|
|
783
883
|
&& normalizedPath(registered.installLocation) === normalizedPath(root);
|
|
884
|
+
// The same predicate the post-attempt check uses, dependency resolution
|
|
885
|
+
// included: a fast path that skipped it reported "already current" for a
|
|
886
|
+
// snapshot the status surface reads as not ready.
|
|
784
887
|
if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === CLAUDE_PLUGIN_SCOPE
|
|
785
|
-
&& sameManagedClaudeFiles(root, entry.installPath)
|
|
888
|
+
&& sameManagedClaudeFiles(root, entry.installPath)
|
|
889
|
+
&& claudeSnapshotResolvable(entry.installPath))) {
|
|
786
890
|
actions.push('cli: skipped (registered marketplace and snapshot already current)');
|
|
787
891
|
return { ok: true, actions };
|
|
788
892
|
}
|
|
@@ -793,14 +897,16 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
793
897
|
// The CLI is best-effort, but a host that does not have it must not be told to
|
|
794
898
|
// run it. The checkout entry already gates on this; doing it here too keeps the
|
|
795
899
|
// two entries describing the same machine the same way.
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
799
|
-
}).status === 0;
|
|
800
|
-
if (!claudePresent) {
|
|
900
|
+
const claudeCommand = resolveClaudeCommand();
|
|
901
|
+
if (!claudeCommand) {
|
|
801
902
|
actions.push('cli: skipped (claude not found)');
|
|
802
903
|
return { ok: true, detected: false, actions };
|
|
803
904
|
}
|
|
905
|
+
return refreshClaudePlugin({ home, root, actions, claudeCommand });
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Keep CLI transaction ordering testable without changing the operator's home.
|
|
909
|
+
export async function refreshClaudePlugin({ home, root, actions = [], claudeCommand, runStep = runClaudeStep }) {
|
|
804
910
|
// Each step runs to completion — including ending the whole process tree on a
|
|
805
911
|
// timeout — before the next one starts, and the settle window is the widest any
|
|
806
912
|
// step asks for. Two things this replaces: an `uninstall` left running past its
|
|
@@ -815,25 +921,44 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
815
921
|
let settleMs = 0;
|
|
816
922
|
const noteStep = (step) => { settleMs = Math.max(settleMs, claudeSnapshotSettleMs(step?.error)); };
|
|
817
923
|
|
|
818
|
-
const marketplace = await
|
|
924
|
+
const marketplace = await runStep(['plugin', 'marketplace', 'add', root], { executable: claudeCommand });
|
|
819
925
|
noteStep(marketplace);
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
926
|
+
if (!marketplace.ok) {
|
|
927
|
+
// Registering is what says where the plugin comes from. Removing the current
|
|
928
|
+
// one after it failed leaves the machine with neither, and that is exactly the
|
|
929
|
+
// shape a refused path produces — where nothing was even attempted. The
|
|
930
|
+
// existing integration is left exactly as it was.
|
|
931
|
+
actions.push(`cli: plugin refresh skipped — marketplace registration failed${marketplace.timedOut ? ' (timed out)' : ''}`);
|
|
932
|
+
return {
|
|
933
|
+
ok: true,
|
|
934
|
+
degraded: true,
|
|
935
|
+
code: 'CLAUDE_MARKETPLACE_UNAVAILABLE',
|
|
936
|
+
reason: `marketplace registration failed; the installed plugin was left untouched. Run: claude plugin marketplace add ${root}`,
|
|
937
|
+
actions,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
actions.push('cli: marketplace registered');
|
|
823
941
|
|
|
824
942
|
// `plugin install` on an already-installed plugin is a no-op and leaves a
|
|
825
943
|
// stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
|
|
826
944
|
// always re-copies the current code.
|
|
827
|
-
|
|
945
|
+
const uninstall = await runStep(['plugin', 'uninstall', PLUGIN_KEY], { executable: claudeCommand });
|
|
946
|
+
noteStep(uninstall);
|
|
947
|
+
if (uninstall.timedOut && !uninstall.terminated) {
|
|
948
|
+
return { ok: false, code: 'CLAUDE_TERMINATION_UNCONFIRMED', reason: 'Uninstall termination could not be confirmed; plugin installation was not started.', actions };
|
|
949
|
+
}
|
|
828
950
|
|
|
829
951
|
// Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted it.
|
|
830
952
|
// Try without it first, fall back to the legacy flag. This is the step that
|
|
831
953
|
// copies, so it carries the ceiling sized for the copy.
|
|
832
|
-
let install = await
|
|
954
|
+
let install = await runStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE], { timeoutMs: CLAUDE_INSTALL_TIMEOUT_MS, executable: claudeCommand });
|
|
833
955
|
if (!install.ok && !install.timedOut && /unknown option/i.test(String(install.detail ?? ''))) {
|
|
834
|
-
install = await
|
|
956
|
+
install = await runStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE, '-y'], { timeoutMs: CLAUDE_INSTALL_TIMEOUT_MS, executable: claudeCommand });
|
|
835
957
|
}
|
|
836
958
|
noteStep(install);
|
|
959
|
+
if (install.timedOut && !install.terminated) {
|
|
960
|
+
return { ok: false, code: 'CLAUDE_TERMINATION_UNCONFIRMED', reason: 'Install termination could not be confirmed; snapshot readiness was not accepted.', actions };
|
|
961
|
+
}
|
|
837
962
|
actions.push(install.ok
|
|
838
963
|
? `cli: plugin snapshot refreshed (${PLUGIN_KEY})`
|
|
839
964
|
: `cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY}${install.timedOut ? ' (timed out)' : ''}`);
|
|
@@ -844,13 +969,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
|
|
|
844
969
|
// snapshot that `installStatus` reads. Saying so here is what lets the caller
|
|
845
970
|
// stop printing a checkmark for a state it never verified.
|
|
846
971
|
if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) return { ok: true, actions };
|
|
847
|
-
//
|
|
848
|
-
// plugin tree — 163s measured on an idle machine, and ~6 minutes measured
|
|
849
|
-
// during an activation, where the record landed well after any ceiling — and a
|
|
850
|
-
// timed-out child on Windows is not in the shell's process tree, so a copy that
|
|
851
|
-
// is still running keeps writing while this function has already given up on
|
|
852
|
-
// it. Wait for the snapshot to settle before reporting it missing, or the
|
|
853
|
-
// update says "not loaded" about a plugin that is loading.
|
|
972
|
+
// Reconcile snapshot records after a timed-out attempt, without starting another CLI step.
|
|
854
973
|
const settleDeadline = Date.now() + settleMs;
|
|
855
974
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
856
975
|
while (Date.now() < settleDeadline) {
|
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.13';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|