magus-cli 7.9.0 → 7.10.0
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 +4 -4
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +1 -0
- package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
- package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
- package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
- package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
- package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
- package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
- package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
- package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
- package/src/__tests__/deps-plan.test.ts +3 -2
- package/src/__tests__/plugin-mcp-source-shapes.test.ts +250 -0
- package/src/__tests__/worktree-profiles.test.ts +583 -0
- package/src/cli/doctor.ts +24 -2
- package/src/cli/update-view.ts +73 -0
- package/src/cli/update.ts +21 -0
- package/src/cli/worktrees.ts +32 -0
- package/src/services/deps-plan.ts +273 -81
- package/src/services/git-worktree.ts +41 -0
- package/src/services/plugin-mcp-config.ts +3 -1
- package/src/services/worktree-profiles.ts +181 -0
- package/src/shared/plugin/hooks/bun-missing.sh +35 -10
- package/src/shared/plugin/hooks/deps-check.ts +25 -9
- package/src/shared/plugin/hooks/lib/requires-probe.ts +101 -5
- package/src/ui/App.tsx +32 -1
- package/src/ui/adapters/startupReports.ts +22 -2
- package/src/utils/run.ts +21 -1
|
@@ -16,30 +16,32 @@
|
|
|
16
16
|
* Re-running is the recovery: a plan is always built from a fresh probe, so a
|
|
17
17
|
* step whose effect is already present is not planned again; every step is
|
|
18
18
|
* idempotent or writes its result last (a release renames into place); the apt
|
|
19
|
-
* batch starts with `dpkg --configure -a` and waits on the dpkg lock;
|
|
20
|
-
*
|
|
19
|
+
* batch starts with `dpkg --configure -a` and waits on the dpkg lock; only one
|
|
20
|
+
* apply runs at a time per user (`DepsLockBusy`); and the temp dirs a killed
|
|
21
|
+
* apply left in a user prefix are swept by the next one.
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
import { createHash } from "node:crypto";
|
|
24
25
|
import {
|
|
25
26
|
chmodSync,
|
|
26
|
-
|
|
27
|
+
linkSync,
|
|
27
28
|
lstatSync,
|
|
28
29
|
mkdirSync,
|
|
29
30
|
mkdtempSync,
|
|
30
|
-
openSync,
|
|
31
31
|
readFileSync,
|
|
32
|
+
readdirSync,
|
|
32
33
|
renameSync,
|
|
33
34
|
rmSync,
|
|
34
35
|
unlinkSync,
|
|
35
36
|
writeFileSync,
|
|
36
|
-
writeSync,
|
|
37
37
|
} from "node:fs";
|
|
38
38
|
import { tmpdir } from "node:os";
|
|
39
39
|
import { delimiter, dirname, join } from "node:path";
|
|
40
40
|
import {
|
|
41
41
|
type Arch,
|
|
42
42
|
BUN_PREFIX,
|
|
43
|
+
type Bootstrap,
|
|
44
|
+
type BootstrapTool,
|
|
43
45
|
type InstallStep,
|
|
44
46
|
LOCAL_PREFIX,
|
|
45
47
|
type Os,
|
|
@@ -56,6 +58,7 @@ import {
|
|
|
56
58
|
pathRcLine,
|
|
57
59
|
probe,
|
|
58
60
|
rcFile,
|
|
61
|
+
rcFileAdding,
|
|
59
62
|
renderInstall,
|
|
60
63
|
uvScriptArgv,
|
|
61
64
|
whyUnselectable,
|
|
@@ -96,6 +99,7 @@ export interface PlannedStep {
|
|
|
96
99
|
installs: InstallTarget[];
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
/** A PATH line the user has to add: never one an rc file already has. */
|
|
99
103
|
export interface PathAdvice {
|
|
100
104
|
prefix: UserPrefix;
|
|
101
105
|
rcFile: string;
|
|
@@ -183,16 +187,49 @@ export function prefixOnPath(prefix: UserPrefix, env: PlanEnv): boolean {
|
|
|
183
187
|
return env.path.split(delimiter).some((d) => d === dir || d === `${dir}/`);
|
|
184
188
|
}
|
|
185
189
|
|
|
186
|
-
/**
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
/**
|
|
191
|
+
* The PATH line for a prefix, in the user's rc file; null when a startup file
|
|
192
|
+
* of the user's shell already puts the prefix on PATH. Then there is no line
|
|
193
|
+
* to add: Claude Code started without reading that file, and a restart is the
|
|
194
|
+
* fix, which an off-path step's remedy and the hook banner say.
|
|
195
|
+
*/
|
|
196
|
+
export function pathAdviceFor(
|
|
197
|
+
prefix: UserPrefix,
|
|
198
|
+
env: PlanEnv,
|
|
199
|
+
): PathAdvice | null {
|
|
200
|
+
if (rcFileAdding(prefix, env.userShell, env.home)) return null;
|
|
189
201
|
return {
|
|
190
202
|
prefix,
|
|
191
203
|
rcFile: rcFile(env.userShell),
|
|
192
|
-
rcLine:
|
|
204
|
+
rcLine: pathRcLine(prefix, env.userShell),
|
|
193
205
|
};
|
|
194
206
|
}
|
|
195
207
|
|
|
208
|
+
/**
|
|
209
|
+
* What to do about `dir` (a user prefix, or another `~/…` or absolute
|
|
210
|
+
* directory) off the PATH Claude Code starts with: add the line, or — when a
|
|
211
|
+
* startup file of the user's shell already puts it on PATH — only restart.
|
|
212
|
+
*/
|
|
213
|
+
function pathRemedy(dir: string, env: PlanEnv): string {
|
|
214
|
+
const adding = rcFileAdding(dir, env.userShell, env.home);
|
|
215
|
+
return adding
|
|
216
|
+
? `${adding} already puts ${dir} on PATH: restart Claude Code from a new terminal`
|
|
217
|
+
: `add ${pathRcLine(dir, env.userShell)} to ${rcFile(env.userShell)}, then restart Claude Code from a new terminal`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The prefix a planned step's installer adds to the user's rc file itself
|
|
222
|
+
* ({@link Bootstrap.editsRc}), or null. A bootstrap step is named after its
|
|
223
|
+
* tool: a requirement may bootstrap only the tool it is named after.
|
|
224
|
+
*/
|
|
225
|
+
function rcEditedBy(step: InstallStep): UserPrefix | null {
|
|
226
|
+
if (step.kind !== "bootstrap" || !(step.name in TOOLCHAIN_BOOTSTRAP)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const b: Bootstrap = TOOLCHAIN_BOOTSTRAP[step.name as BootstrapTool];
|
|
230
|
+
return b.editsRc ? b.prefix : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
196
233
|
/** A root uv-module step may only come from a plugin of the magus marketplace. */
|
|
197
234
|
function fromMagusMarketplace(bin: ResolvedBin): boolean {
|
|
198
235
|
return bin.sources.some(
|
|
@@ -214,14 +251,13 @@ export function planDeps(
|
|
|
214
251
|
env: PlanEnv,
|
|
215
252
|
): DepsPlan {
|
|
216
253
|
const unfixable: DepsPlan["unfixable"] = [];
|
|
217
|
-
|
|
254
|
+
/** The prefixes off the PATH, in the order they came up. */
|
|
255
|
+
const offPathPrefixes = new Set<UserPrefix>();
|
|
218
256
|
const missing: ResolvedBin[] = [];
|
|
219
257
|
bins.forEach((bin, i) => {
|
|
220
258
|
const p = presence[i];
|
|
221
259
|
if (p?.status === "missing") missing.push(bin);
|
|
222
|
-
if (p?.status === "off-path")
|
|
223
|
-
advice.set(p.prefix, pathAdviceFor(p.prefix, env));
|
|
224
|
-
}
|
|
260
|
+
if (p?.status === "off-path") offPathPrefixes.add(p.prefix);
|
|
225
261
|
});
|
|
226
262
|
|
|
227
263
|
// A pin the owning plugin's option cannot carry: unfixable whatever the
|
|
@@ -325,11 +361,16 @@ export function planDeps(
|
|
|
325
361
|
}
|
|
326
362
|
steps.push(p);
|
|
327
363
|
const prefix = prefixOf(p.step);
|
|
328
|
-
if (prefix && !prefixOnPath(prefix, env))
|
|
329
|
-
advice.set(prefix, pathAdviceFor(prefix, env));
|
|
330
|
-
}
|
|
364
|
+
if (prefix && !prefixOnPath(prefix, env)) offPathPrefixes.add(prefix);
|
|
331
365
|
}
|
|
332
|
-
|
|
366
|
+
// A prefix whose installer writes the rc line itself gets no line ahead of
|
|
367
|
+
// it: following one and then running the installer leaves the line twice.
|
|
368
|
+
const rcEdited = new Set(steps.map((p) => rcEditedBy(p.step)));
|
|
369
|
+
const pathAdvice = [...offPathPrefixes]
|
|
370
|
+
.filter((prefix) => !rcEdited.has(prefix))
|
|
371
|
+
.map((prefix) => pathAdviceFor(prefix, env))
|
|
372
|
+
.filter((a): a is PathAdvice => a !== null);
|
|
373
|
+
return { steps, unfixable, pathAdvice };
|
|
333
374
|
}
|
|
334
375
|
|
|
335
376
|
/**
|
|
@@ -397,23 +438,48 @@ export interface ApplyIO {
|
|
|
397
438
|
}
|
|
398
439
|
|
|
399
440
|
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
|
|
441
|
+
/** Redirects a download follows before it gives up. */
|
|
442
|
+
const MAX_REDIRECTS = 10;
|
|
400
443
|
/** The per-check cap a post-install re-probe runs under: doctor's. */
|
|
401
444
|
const VERIFY_CAP_MS = 60_000;
|
|
402
445
|
|
|
446
|
+
/**
|
|
447
|
+
* The body at `url`, every hop of it over https. Redirects are followed here,
|
|
448
|
+
* one at a time, so a hop that is not https is refused before it is requested:
|
|
449
|
+
* a bootstrap script has no digest to catch what a plain-http hop could swap.
|
|
450
|
+
*/
|
|
451
|
+
async function httpsDownload(url: string): Promise<Uint8Array> {
|
|
452
|
+
const signal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
|
|
453
|
+
let at = new URL(url);
|
|
454
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
455
|
+
if (at.protocol !== "https:") {
|
|
456
|
+
throw new Error(
|
|
457
|
+
hop === 0
|
|
458
|
+
? `${url} is not https — refused`
|
|
459
|
+
: `${url} redirected off https, to ${at.href} — refused`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
const res = await fetch(at, { redirect: "manual", signal });
|
|
463
|
+
const location = res.headers.get("location");
|
|
464
|
+
if (res.status >= 300 && res.status < 400 && location) {
|
|
465
|
+
await res.body?.cancel();
|
|
466
|
+
at = new URL(location, at);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} for ${at.href}`);
|
|
470
|
+
// A fetch that followed a redirect itself reports where it ended up.
|
|
471
|
+
if (res.url && new URL(res.url).protocol !== "https:") {
|
|
472
|
+
throw new Error(`${url} redirected off https, to ${res.url} — refused`);
|
|
473
|
+
}
|
|
474
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
475
|
+
}
|
|
476
|
+
throw new Error(`${url} redirected more than ${MAX_REDIRECTS} times`);
|
|
477
|
+
}
|
|
478
|
+
|
|
403
479
|
export const REAL_APPLY_IO: ApplyIO = {
|
|
404
480
|
run: (argv, opts) =>
|
|
405
481
|
runArgv({ cmd: argv[0] ?? "", args: argv.slice(1) }, opts),
|
|
406
|
-
download:
|
|
407
|
-
const res = await fetch(url, {
|
|
408
|
-
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
409
|
-
});
|
|
410
|
-
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
411
|
-
// Every URL here is https; a redirect off it is refused, not read.
|
|
412
|
-
if (new URL(res.url).protocol !== "https:") {
|
|
413
|
-
throw new Error(`${url} redirected off https, to ${res.url}`);
|
|
414
|
-
}
|
|
415
|
-
return new Uint8Array(await res.arrayBuffer());
|
|
416
|
-
},
|
|
482
|
+
download: httpsDownload,
|
|
417
483
|
uid: () => (typeof process.getuid === "function" ? process.getuid() : null),
|
|
418
484
|
alive: (pid) => {
|
|
419
485
|
try {
|
|
@@ -452,11 +518,11 @@ export interface ApplyOptions {
|
|
|
452
518
|
io?: Partial<ApplyIO>;
|
|
453
519
|
}
|
|
454
520
|
|
|
455
|
-
/** Another apply holds the lock. */
|
|
521
|
+
/** Another apply holds the lock; `pid` is null when its holder is not yet known. */
|
|
456
522
|
export class DepsLockBusy extends Error {
|
|
457
|
-
constructor(readonly pid: number) {
|
|
523
|
+
constructor(readonly pid: number | null) {
|
|
458
524
|
super(
|
|
459
|
-
`another magus dependency install (pid ${pid}) is running — wait for it, then re-run`,
|
|
525
|
+
`another magus dependency install${pid === null ? "" : ` (pid ${pid})`} is running — wait for it, then re-run`,
|
|
460
526
|
);
|
|
461
527
|
this.name = "DepsLockBusy";
|
|
462
528
|
}
|
|
@@ -467,33 +533,140 @@ export function defaultLockPath(home: string, xdgCacheHome?: string): string {
|
|
|
467
533
|
return join(xdgCacheHome || join(home, ".cache"), "magus", "doctor-fix.lock");
|
|
468
534
|
}
|
|
469
535
|
|
|
470
|
-
/**
|
|
471
|
-
|
|
536
|
+
/** How many times a busy lock is tried, and how long apart. */
|
|
537
|
+
const LOCK_ATTEMPTS = 20;
|
|
538
|
+
const LOCK_RETRY_MS = 50;
|
|
539
|
+
/**
|
|
540
|
+
* A lock with no pid in it this old was left by a writer that died between
|
|
541
|
+
* creating it and writing the pid; no live writer is that slow.
|
|
542
|
+
*/
|
|
543
|
+
const EMPTY_LOCK_STALE_MS = 10_000;
|
|
544
|
+
|
|
545
|
+
const sleep = (ms: number) =>
|
|
546
|
+
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
547
|
+
|
|
548
|
+
/** What the lock file says about its holder. */
|
|
549
|
+
type LockHolder =
|
|
550
|
+
/** Released between the failed take and this read. */
|
|
551
|
+
| { kind: "gone" }
|
|
552
|
+
| { kind: "pid"; pid: number }
|
|
553
|
+
/** Created by a writer that has not written its pid yet, or never will. */
|
|
554
|
+
| { kind: "no-pid"; ageMs: number }
|
|
555
|
+
/** Not a regular file (a dangling symlink, say): no holder ever wrote it. */
|
|
556
|
+
| { kind: "not-a-lock" };
|
|
557
|
+
|
|
558
|
+
function readLockHolder(path: string): LockHolder {
|
|
559
|
+
const gone = (error: unknown): LockHolder => {
|
|
560
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
561
|
+
return { kind: "gone" };
|
|
562
|
+
}
|
|
563
|
+
throw error;
|
|
564
|
+
};
|
|
565
|
+
let mtimeMs: number;
|
|
566
|
+
try {
|
|
567
|
+
const stat = lstatSync(path);
|
|
568
|
+
if (!stat.isFile()) return { kind: "not-a-lock" };
|
|
569
|
+
mtimeMs = stat.mtimeMs;
|
|
570
|
+
} catch (error) {
|
|
571
|
+
return gone(error);
|
|
572
|
+
}
|
|
573
|
+
let text: string;
|
|
574
|
+
try {
|
|
575
|
+
text = readFileSync(path, "utf8").trim();
|
|
576
|
+
} catch (error) {
|
|
577
|
+
return gone(error);
|
|
578
|
+
}
|
|
579
|
+
const pid = Number.parseInt(text, 10);
|
|
580
|
+
return Number.isInteger(pid) && pid > 0
|
|
581
|
+
? { kind: "pid", pid }
|
|
582
|
+
: { kind: "no-pid", ageMs: Date.now() - mtimeMs };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Remove a stale lock; one another taker removed first is just as gone. */
|
|
586
|
+
function removeStaleLock(path: string): void {
|
|
587
|
+
try {
|
|
588
|
+
unlinkSync(path);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Take the lock. The pid is written to a file of our own first and the lock is
|
|
596
|
+
* a hard link to it, so the lock never exists without a pid in it. A live
|
|
597
|
+
* holder refuses with {@link DepsLockBusy}; one whose pid is gone is taken
|
|
598
|
+
* over. A lock that vanishes before it is read is free, and taken. A lock with
|
|
599
|
+
* no pid (another writer between creating and writing it) is busy: it is read
|
|
600
|
+
* again shortly, and taken over only once it is too old to be mid-write.
|
|
601
|
+
*/
|
|
602
|
+
async function acquireLock(path: string, io: ApplyIO): Promise<() => void> {
|
|
472
603
|
mkdirSync(dirname(path), { recursive: true });
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
604
|
+
const mine = String(process.pid);
|
|
605
|
+
const staged = `${path}.${mine}`;
|
|
606
|
+
writeFileSync(staged, mine, { mode: 0o600 });
|
|
607
|
+
try {
|
|
608
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
|
|
609
|
+
try {
|
|
610
|
+
linkSync(staged, path);
|
|
611
|
+
return () => {
|
|
612
|
+
try {
|
|
613
|
+
if (readFileSync(path, "utf8").trim() === mine) unlinkSync(path);
|
|
614
|
+
} catch {
|
|
615
|
+
// Already gone.
|
|
482
616
|
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
617
|
+
};
|
|
618
|
+
} catch (error) {
|
|
619
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
620
|
+
}
|
|
621
|
+
const holder = readLockHolder(path);
|
|
622
|
+
if (holder.kind === "gone") continue;
|
|
623
|
+
if (holder.kind === "not-a-lock") {
|
|
624
|
+
removeStaleLock(path);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (holder.kind === "pid") {
|
|
628
|
+
if (io.alive(holder.pid)) throw new DepsLockBusy(holder.pid);
|
|
629
|
+
removeStaleLock(path); // its holder is gone
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (holder.ageMs > EMPTY_LOCK_STALE_MS) {
|
|
633
|
+
removeStaleLock(path);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
await sleep(LOCK_RETRY_MS);
|
|
637
|
+
}
|
|
638
|
+
throw new DepsLockBusy(null);
|
|
639
|
+
} finally {
|
|
640
|
+
rmSync(staged, { force: true });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** The temp dir a release step extracts into, inside its destination. */
|
|
645
|
+
const RELEASE_TMP_PREFIX = ".magus-tmp-";
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Remove the temp dirs a release step left in a user prefix when its apply was
|
|
649
|
+
* killed before the step's `finally` ran. Runs holding the lock, so no apply
|
|
650
|
+
* owns any of them.
|
|
651
|
+
*/
|
|
652
|
+
function sweepStaleTemp(home: string): void {
|
|
653
|
+
for (const prefix of USER_PREFIXES) {
|
|
654
|
+
const dir = expandHome(prefix.dir, home);
|
|
655
|
+
let entries: string[];
|
|
656
|
+
try {
|
|
657
|
+
entries = readdirSync(dir);
|
|
658
|
+
} catch {
|
|
659
|
+
continue; // no such prefix yet: nothing left behind
|
|
660
|
+
}
|
|
661
|
+
for (const entry of entries) {
|
|
662
|
+
if (!entry.startsWith(RELEASE_TMP_PREFIX)) continue;
|
|
663
|
+
try {
|
|
664
|
+
rmSync(join(dir, entry), { recursive: true, force: true });
|
|
665
|
+
} catch {
|
|
666
|
+
// One that cannot be removed leaves the rest to sweep.
|
|
492
667
|
}
|
|
493
|
-
unlinkSync(path); // stale: its holder is gone
|
|
494
668
|
}
|
|
495
669
|
}
|
|
496
|
-
throw new Error(`could not take ${path}`);
|
|
497
670
|
}
|
|
498
671
|
|
|
499
672
|
const STEP_TIMEOUT_MS = 15 * 60_000;
|
|
@@ -544,8 +717,9 @@ export async function applyDepsPlan(
|
|
|
544
717
|
if (plan.steps.length === 0) return [];
|
|
545
718
|
if (!opts.yes && !(await opts.confirm(plan))) return [];
|
|
546
719
|
const io: ApplyIO = { ...REAL_APPLY_IO, ...opts.io };
|
|
547
|
-
const release = acquireLock(opts.lockPath, io);
|
|
720
|
+
const release = await acquireLock(opts.lockPath, io);
|
|
548
721
|
try {
|
|
722
|
+
sweepStaleTemp(opts.env.home);
|
|
549
723
|
return await applySteps(plan, opts, io);
|
|
550
724
|
} finally {
|
|
551
725
|
release();
|
|
@@ -618,11 +792,12 @@ async function applySteps(
|
|
|
618
792
|
}
|
|
619
793
|
opts.onEvent?.({ type: "step-start", name: step.name, describe });
|
|
620
794
|
const started = Date.now();
|
|
621
|
-
const run = (argv: string[],
|
|
795
|
+
const run = (argv: string[], how: RunHow = {}) =>
|
|
622
796
|
io.run(argv, {
|
|
623
797
|
timeoutMs: STEP_TIMEOUT_MS,
|
|
624
|
-
env: childEnv,
|
|
625
|
-
...(escalated ? { killGraceMs: SUDO_KILL_GRACE_MS } : {}),
|
|
798
|
+
env: how.env ? { ...childEnv, ...how.env } : childEnv,
|
|
799
|
+
...(how.escalated ? { killGraceMs: SUDO_KILL_GRACE_MS } : {}),
|
|
800
|
+
...(how.captureStdout ? { captureStdout: true } : {}),
|
|
626
801
|
onOutput: (chunk) =>
|
|
627
802
|
opts.onEvent?.({ type: "output", name: step.name, chunk }),
|
|
628
803
|
});
|
|
@@ -708,9 +883,10 @@ async function landingDir(
|
|
|
708
883
|
step.argv[0] === "bun" &&
|
|
709
884
|
step.argv[1] === "add"
|
|
710
885
|
) {
|
|
711
|
-
const r = await ctx.run([ctx.resolve("bun"), "pm", "bin", "-g"]
|
|
712
|
-
|
|
713
|
-
|
|
886
|
+
const r = await ctx.run([ctx.resolve("bun"), "pm", "bin", "-g"], {
|
|
887
|
+
captureStdout: true,
|
|
888
|
+
});
|
|
889
|
+
return r.exitCode === 0 ? lastLine(r.stdout) : null;
|
|
714
890
|
}
|
|
715
891
|
return null;
|
|
716
892
|
}
|
|
@@ -750,12 +926,11 @@ async function verifyInstalled(
|
|
|
750
926
|
}
|
|
751
927
|
const first = offPath[0];
|
|
752
928
|
if (first?.dir) {
|
|
753
|
-
const line = pathRcLine(first.dir, env.userShell);
|
|
754
929
|
return {
|
|
755
930
|
status: "off-path",
|
|
756
931
|
dir: first.dir,
|
|
757
932
|
why: `installed in ${first.dir}, which is not on the PATH Claude Code starts with`,
|
|
758
|
-
remedy:
|
|
933
|
+
remedy: pathRemedy(first.dir, env),
|
|
759
934
|
};
|
|
760
935
|
}
|
|
761
936
|
return { status: "installed" };
|
|
@@ -768,10 +943,20 @@ interface StepResult {
|
|
|
768
943
|
outputTail: string;
|
|
769
944
|
}
|
|
770
945
|
|
|
946
|
+
/** How one argv of a step runs. */
|
|
947
|
+
interface RunHow {
|
|
948
|
+
/** Through sudo: SIGKILL cannot be relayed, so it gets a longer grace. */
|
|
949
|
+
escalated?: boolean;
|
|
950
|
+
/** Added to the child's environment. */
|
|
951
|
+
env?: Readonly<Record<string, string>>;
|
|
952
|
+
/** A query whose answer is its stdout: `RunResult.stdout` is set. */
|
|
953
|
+
captureStdout?: boolean;
|
|
954
|
+
}
|
|
955
|
+
|
|
771
956
|
interface StepContext {
|
|
772
957
|
env: PlanEnv;
|
|
773
958
|
io: ApplyIO;
|
|
774
|
-
run(argv: string[],
|
|
959
|
+
run(argv: string[], how?: RunHow): Promise<RunResult>;
|
|
775
960
|
escalate(argv: string[]): string[];
|
|
776
961
|
root: boolean;
|
|
777
962
|
resolve(tool: string): string;
|
|
@@ -784,15 +969,25 @@ const fromRun = (argv: string[], r: RunResult): StepResult => ({
|
|
|
784
969
|
outputTail: r.spawnError ?? r.outputTail,
|
|
785
970
|
});
|
|
786
971
|
|
|
972
|
+
/** The last non-empty line of a query's stdout, or null. */
|
|
973
|
+
function lastLine(stdout: string | undefined): string | null {
|
|
974
|
+
const line = (stdout ?? "")
|
|
975
|
+
.split("\n")
|
|
976
|
+
.map((l) => l.trim())
|
|
977
|
+
.filter(Boolean)
|
|
978
|
+
.pop();
|
|
979
|
+
return line || null;
|
|
980
|
+
}
|
|
981
|
+
|
|
787
982
|
/** Run argvs in order, stopping at the first that fails. */
|
|
788
983
|
async function sequence(
|
|
789
984
|
ctx: StepContext,
|
|
790
985
|
argvs: string[][],
|
|
791
|
-
|
|
986
|
+
how: RunHow = {},
|
|
792
987
|
): Promise<StepResult> {
|
|
793
988
|
let last: StepResult = { ok: true, exitCode: 0, outputTail: "" };
|
|
794
989
|
for (const argv of argvs) {
|
|
795
|
-
last = fromRun(argv, await ctx.run(argv,
|
|
990
|
+
last = fromRun(argv, await ctx.run(argv, how));
|
|
796
991
|
if (!last.ok) return last;
|
|
797
992
|
}
|
|
798
993
|
return last;
|
|
@@ -814,14 +1009,12 @@ async function runStep(
|
|
|
814
1009
|
ctx.escalate(["apt-get", ...APT_LOCK, "update"]),
|
|
815
1010
|
install,
|
|
816
1011
|
],
|
|
817
|
-
ctx.root,
|
|
1012
|
+
{ escalated: ctx.root },
|
|
818
1013
|
);
|
|
819
1014
|
}
|
|
820
|
-
return sequence(
|
|
821
|
-
ctx,
|
|
822
|
-
|
|
823
|
-
ctx.root,
|
|
824
|
-
);
|
|
1015
|
+
return sequence(ctx, [ctx.escalate([ctx.resolve(tool), ...args])], {
|
|
1016
|
+
escalated: ctx.root,
|
|
1017
|
+
});
|
|
825
1018
|
}
|
|
826
1019
|
case "uv-module":
|
|
827
1020
|
return runUvModule(step, ctx);
|
|
@@ -839,17 +1032,14 @@ async function runUvModule(
|
|
|
839
1032
|
ctx: StepContext,
|
|
840
1033
|
): Promise<StepResult> {
|
|
841
1034
|
const uv = ctx.resolve("uv");
|
|
842
|
-
const sync = await sequence(
|
|
843
|
-
ctx,
|
|
844
|
-
[uvScriptArgv(["sync"], step.script, uv)],
|
|
845
|
-
false,
|
|
846
|
-
);
|
|
1035
|
+
const sync = await sequence(ctx, [uvScriptArgv(["sync"], step.script, uv)]);
|
|
847
1036
|
if (!sync.ok) return sync;
|
|
848
1037
|
const findArgv = uvScriptArgv(["python", "find"], step.script, uv);
|
|
849
|
-
const found = await ctx.run(findArgv);
|
|
1038
|
+
const found = await ctx.run(findArgv, { captureStdout: true });
|
|
850
1039
|
const findResult = fromRun(findArgv, found);
|
|
851
1040
|
if (!findResult.ok) return findResult;
|
|
852
|
-
|
|
1041
|
+
// The interpreter is what uv prints on stdout; a warning goes to stderr.
|
|
1042
|
+
const py = lastLine(found.stdout);
|
|
853
1043
|
if (!py) {
|
|
854
1044
|
return {
|
|
855
1045
|
...findResult,
|
|
@@ -869,7 +1059,7 @@ async function runUvModule(
|
|
|
869
1059
|
return sequence(
|
|
870
1060
|
ctx,
|
|
871
1061
|
[ctx.escalate([py, "-B", "-m", step.module, ...step.args])],
|
|
872
|
-
ctx.root,
|
|
1062
|
+
{ escalated: ctx.root },
|
|
873
1063
|
);
|
|
874
1064
|
}
|
|
875
1065
|
|
|
@@ -882,7 +1072,9 @@ async function runBootstrap(
|
|
|
882
1072
|
try {
|
|
883
1073
|
const file = join(dir, "install.sh");
|
|
884
1074
|
writeFileSync(file, body, { mode: 0o600 });
|
|
885
|
-
return await sequence(ctx, [[ctx.resolve(step.interpreter), file]],
|
|
1075
|
+
return await sequence(ctx, [[ctx.resolve(step.interpreter), file]], {
|
|
1076
|
+
...(step.env ? { env: step.env } : {}),
|
|
1077
|
+
});
|
|
886
1078
|
} finally {
|
|
887
1079
|
rmSync(dir, { recursive: true, force: true });
|
|
888
1080
|
}
|
|
@@ -919,7 +1111,7 @@ async function runRelease(
|
|
|
919
1111
|
}
|
|
920
1112
|
const dest = expandHome(step.dest, ctx.env.home);
|
|
921
1113
|
mkdirSync(dest, { recursive: true });
|
|
922
|
-
const tmp = mkdtempSync(join(dest,
|
|
1114
|
+
const tmp = mkdtempSync(join(dest, RELEASE_TMP_PREFIX));
|
|
923
1115
|
try {
|
|
924
1116
|
if (step.format === "binary") {
|
|
925
1117
|
// The asset is the binary: the verified bytes are the file.
|
|
@@ -190,6 +190,47 @@ export async function inheritablePaths(projectPath: string): Promise<string[]> {
|
|
|
190
190
|
return paths;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/** One working tree of a repository, as `git worktree list` reports it. */
|
|
194
|
+
export interface WorkingTree {
|
|
195
|
+
/** Absolute path, as git recorded it. */
|
|
196
|
+
path: string;
|
|
197
|
+
/** git says its folder is gone (`prunable`); the record outlives it. */
|
|
198
|
+
prunable: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Every working tree of `projectPath`'s repository — the main one first, then
|
|
203
|
+
* each linked worktree — and the one `projectPath` itself sits in.
|
|
204
|
+
*
|
|
205
|
+
* Null when git cannot say: not a repository, git missing or not answering.
|
|
206
|
+
* A bare repository's own record has no working tree and is left out. Reads
|
|
207
|
+
* only; never caches, because a worktree added while magus runs is exactly
|
|
208
|
+
* what a caller of this wants to see.
|
|
209
|
+
*/
|
|
210
|
+
export async function listWorkingTrees(
|
|
211
|
+
projectPath: string,
|
|
212
|
+
): Promise<{ self: string; trees: WorkingTree[] } | null> {
|
|
213
|
+
const dir = path.resolve(projectPath);
|
|
214
|
+
const [top, list] = await Promise.all([
|
|
215
|
+
git(dir, ["rev-parse", "--show-toplevel"]),
|
|
216
|
+
git(dir, ["worktree", "list", "--porcelain"]),
|
|
217
|
+
]);
|
|
218
|
+
if (top.code !== 0 || !top.out || list.code !== 0) return null;
|
|
219
|
+
const trees: WorkingTree[] = [];
|
|
220
|
+
for (const record of list.out.split(/\n\s*\n/)) {
|
|
221
|
+
const lines = record.split("\n").map((l) => l.trim());
|
|
222
|
+
const line = lines.find((l) => l.startsWith("worktree "));
|
|
223
|
+
if (!line || lines.includes("bare")) continue;
|
|
224
|
+
trees.push({
|
|
225
|
+
path: path.resolve(line.slice("worktree ".length)),
|
|
226
|
+
prunable: lines.some(
|
|
227
|
+
(l) => l === "prunable" || l.startsWith("prunable "),
|
|
228
|
+
),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return { self: path.resolve(top.out), trees };
|
|
232
|
+
}
|
|
233
|
+
|
|
193
234
|
async function resolveInheritable(
|
|
194
235
|
dir: string,
|
|
195
236
|
): Promise<{ paths: string[]; answered: boolean }> {
|
|
@@ -217,7 +217,9 @@ export async function getPluginSourcePath(
|
|
|
217
217
|
const plugin = manifest.plugins.find(
|
|
218
218
|
(p: { name: string }) => p.name === pluginName,
|
|
219
219
|
);
|
|
220
|
-
|
|
220
|
+
// An object source (git-subdir, github, url) is fetched from its own
|
|
221
|
+
// repo, not read from the marketplace clone, so there is no local path.
|
|
222
|
+
return typeof plugin?.source === "string" ? plugin.source : undefined;
|
|
221
223
|
}
|
|
222
224
|
}
|
|
223
225
|
} catch {
|