magus-cli 7.10.5 → 7.11.1
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 +2 -2
- package/src/__tests__/blind/plugin-deps/hook.test.ts +2 -2
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +1 -1
- package/src/__tests__/blind/plugin-deps/schema.test.ts +6 -6
- package/src/__tests__/blind/requires-min-version/TEST-PLAN.md +54 -0
- package/src/__tests__/blind/requires-min-version/manifests.test.ts +69 -0
- package/src/__tests__/blind/requires-min-version/parse.test.ts +266 -0
- package/src/__tests__/blind/requires-min-version/probe.test.ts +359 -0
- package/src/__tests__/blind/requires-min-version/render.test.ts +252 -0
- package/src/__tests__/blind/requires-min-version/support.ts +83 -0
- package/src/__tests__/blind/requires-min-version/surfaces.test.ts +231 -0
- package/src/__tests__/blind/review-followups/bun-install-path.test.ts +1 -1
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +1 -1
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +1 -1
- package/src/__tests__/blind/review-followups/path-advice.test.ts +1 -1
- package/src/__tests__/deps-check-hook.test.ts +9 -9
- package/src/__tests__/deps-plan.test.ts +1 -1
- package/src/__tests__/deps-report.test.ts +5 -5
- package/src/__tests__/doctor-json.test.ts +2 -2
- package/src/__tests__/marketplace-refresh.test.ts +1 -1
- package/src/__tests__/plugin-requires.test.ts +2 -2
- package/src/__tests__/requires-probe.test.ts +2 -2
- package/src/__tests__/scope-action-deprecated.test.ts +105 -0
- package/src/cli/deps-view.ts +5 -3
- package/src/cli/router.ts +6 -1
- package/src/services/deps-plan.ts +38 -14
- package/src/services/deps-report.ts +24 -7
- package/src/services/doctor-bins.ts +40 -12
- package/src/services/plugin-manager.ts +5 -1
- package/src/services/resolver.ts +1 -0
- package/src/shared/plugin/hooks/deps-check.ts +7 -3
- package/src/shared/plugin/hooks/lib/requires-probe.ts +257 -33
- package/src/ui/adapters/cliToolsAdapter.ts +6 -1
- package/src/ui/renderers/cliToolRenderers.tsx +7 -1
- package/src/ui/screens/CliToolsScreen.tsx +8 -1
- package/src/ui/screens/PluginsScreen.tsx +4 -0
|
@@ -24,8 +24,11 @@ import { createHash } from "node:crypto";
|
|
|
24
24
|
import { constants, accessSync, readFileSync, statSync } from "node:fs";
|
|
25
25
|
import { delimiter, join } from "node:path";
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The one `requires` shape this reader knows. Any change to it bumps this.
|
|
29
|
+
* 2 added a requirement's optional `minVersion`.
|
|
30
|
+
*/
|
|
31
|
+
export const REQUIRES_SCHEMA = 2;
|
|
29
32
|
|
|
30
33
|
export type Os = "darwin" | "linux";
|
|
31
34
|
export type Arch = "amd64" | "arm64";
|
|
@@ -129,6 +132,11 @@ export interface Requirement {
|
|
|
129
132
|
name: string;
|
|
130
133
|
check: string[];
|
|
131
134
|
os?: Os[];
|
|
135
|
+
/**
|
|
136
|
+
* The lowest version that will do, `X.Y.Z`. The check's output is read for
|
|
137
|
+
* the version it reports; one below this is `outdated`.
|
|
138
|
+
*/
|
|
139
|
+
minVersion?: string;
|
|
132
140
|
install: InstallOption[];
|
|
133
141
|
}
|
|
134
142
|
|
|
@@ -154,6 +162,10 @@ const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
|
154
162
|
const MODULE_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/;
|
|
155
163
|
const SCRIPT_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
156
164
|
const ROOT_TOKEN = "${CLAUDE_PLUGIN_ROOT}";
|
|
165
|
+
/** A `minVersion`: three numeric parts, no `v`, no prerelease. */
|
|
166
|
+
const MIN_VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
167
|
+
/** The version a check reports: the first `X.Y.Z` anywhere in its output. */
|
|
168
|
+
const FOUND_VERSION_RE = /(\d+)\.(\d+)\.(\d+)/;
|
|
157
169
|
|
|
158
170
|
const PROFILE_ONLY: readonly Via[] = ["npm", "pip", "go"];
|
|
159
171
|
|
|
@@ -185,6 +197,44 @@ export function releaseAsset(
|
|
|
185
197
|
.replaceAll("{arch}", option.arch?.[arch] ?? arch);
|
|
186
198
|
}
|
|
187
199
|
|
|
200
|
+
// ─── versions ─────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/** `X.Y.Z` (exactly, as `minVersion` spells it) as numbers, or null. */
|
|
203
|
+
export function parseVersion(v: string): [number, number, number] | null {
|
|
204
|
+
if (!MIN_VERSION_RE.test(v)) return null;
|
|
205
|
+
const [a = 0, b = 0, c = 0] = v.split(".").map(Number);
|
|
206
|
+
return [a, b, c];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Compare two `X.Y.Z` versions numerically, part by part: negative when `a`
|
|
211
|
+
* is lower, zero when equal, positive when higher. Either one malformed
|
|
212
|
+
* compares as equal; callers parse before they compare.
|
|
213
|
+
*/
|
|
214
|
+
export function compareVersions(a: string, b: string): number {
|
|
215
|
+
const x = parseVersion(a);
|
|
216
|
+
const y = parseVersion(b);
|
|
217
|
+
if (!x || !y) return 0;
|
|
218
|
+
for (let i = 0; i < 3; i++) {
|
|
219
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
220
|
+
if (d !== 0) return d;
|
|
221
|
+
}
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The version a check's output reports: the first `X.Y.Z` in stdout, or in
|
|
227
|
+
* stderr when stdout has none. A suffix after it (`10.7.0-beta.1`) is not
|
|
228
|
+
* read, so a prerelease satisfies its release. Null when neither has one.
|
|
229
|
+
*/
|
|
230
|
+
export function foundVersion(stdout: string, stderr: string): string | null {
|
|
231
|
+
return (
|
|
232
|
+
stdout.match(FOUND_VERSION_RE)?.[0] ??
|
|
233
|
+
stderr.match(FOUND_VERSION_RE)?.[0] ??
|
|
234
|
+
null
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
188
238
|
/** The keys each `via` allows, besides `via` and `os`. */
|
|
189
239
|
const OPTION_KEYS: Record<Via, readonly string[]> = {
|
|
190
240
|
bun: ["package", "version"],
|
|
@@ -449,7 +499,12 @@ function parseRequirement(
|
|
|
449
499
|
errors.push(`${at}: must be an object`);
|
|
450
500
|
return null;
|
|
451
501
|
}
|
|
452
|
-
unknownKeys(
|
|
502
|
+
unknownKeys(
|
|
503
|
+
at,
|
|
504
|
+
raw,
|
|
505
|
+
["name", "check", "os", "minVersion", "install"],
|
|
506
|
+
errors,
|
|
507
|
+
);
|
|
453
508
|
const name = raw.name;
|
|
454
509
|
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
|
455
510
|
errors.push(`${at}.name: missing or malformed`);
|
|
@@ -469,6 +524,16 @@ function parseRequirement(
|
|
|
469
524
|
}
|
|
470
525
|
}
|
|
471
526
|
const os = parseOs(named, raw.os, errors);
|
|
527
|
+
let minVersion: string | undefined;
|
|
528
|
+
if (raw.minVersion !== undefined) {
|
|
529
|
+
if (typeof raw.minVersion !== "string" || !parseVersion(raw.minVersion)) {
|
|
530
|
+
errors.push(
|
|
531
|
+
`${named}.minVersion: must be X.Y.Z (three numbers, no "v", no prerelease)`,
|
|
532
|
+
);
|
|
533
|
+
} else {
|
|
534
|
+
minVersion = raw.minVersion;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
472
537
|
if (!Array.isArray(raw.install) || raw.install.length === 0) {
|
|
473
538
|
errors.push(`${named}.install: must be a non-empty list of options`);
|
|
474
539
|
return null;
|
|
@@ -476,15 +541,59 @@ function parseRequirement(
|
|
|
476
541
|
const install = raw.install
|
|
477
542
|
.map((o, i) => parseOption(`${named}.install[${i}]`, o, name, errors))
|
|
478
543
|
.filter((o): o is InstallOption => o !== null);
|
|
544
|
+
if (minVersion !== undefined) {
|
|
545
|
+
install.forEach((option, i) =>
|
|
546
|
+
checkPinFloor(`${named}.install[${i}]`, option, minVersion, errors),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
479
549
|
const requirement: Requirement = {
|
|
480
550
|
name,
|
|
481
551
|
check: isStrArray(check) ? check : [],
|
|
482
552
|
install,
|
|
483
553
|
};
|
|
484
554
|
if (os) requirement.os = os;
|
|
555
|
+
if (minVersion !== undefined) requirement.minVersion = minVersion;
|
|
485
556
|
return requirement;
|
|
486
557
|
}
|
|
487
558
|
|
|
559
|
+
/**
|
|
560
|
+
* A pinned option must install at least `minVersion`: a pin below the floor
|
|
561
|
+
* installs a binary the probe then reports as outdated. A release's `tag`
|
|
562
|
+
* (one leading `v` removed) must be `X.Y.Z`; a bun `version` is compared only
|
|
563
|
+
* when it is an exact `X.Y.Z`, never a range or a dist-tag.
|
|
564
|
+
*/
|
|
565
|
+
function checkPinFloor(
|
|
566
|
+
at: string,
|
|
567
|
+
option: InstallOption,
|
|
568
|
+
minVersion: string,
|
|
569
|
+
errors: string[],
|
|
570
|
+
): void {
|
|
571
|
+
if (option.via === "release") {
|
|
572
|
+
if (!option.tag) return; // already reported as malformed
|
|
573
|
+
const tag = option.tag.replace(/^v/, "");
|
|
574
|
+
if (!parseVersion(tag)) {
|
|
575
|
+
errors.push(
|
|
576
|
+
`${at}.tag: "${option.tag}" is not X.Y.Z, so it cannot be held to minVersion ${minVersion}`,
|
|
577
|
+
);
|
|
578
|
+
} else if (compareVersions(tag, minVersion) < 0) {
|
|
579
|
+
errors.push(
|
|
580
|
+
`${at}.tag: "${option.tag}" is below minVersion ${minVersion}; it would install an outdated binary`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (
|
|
586
|
+
option.via === "bun" &&
|
|
587
|
+
option.version !== undefined &&
|
|
588
|
+
parseVersion(option.version) &&
|
|
589
|
+
compareVersions(option.version, minVersion) < 0
|
|
590
|
+
) {
|
|
591
|
+
errors.push(
|
|
592
|
+
`${at}.version: ${option.version} is below minVersion ${minVersion}; it would install an outdated package`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
488
597
|
/**
|
|
489
598
|
* Parse the `requires` key of a plugin.json. Nothing else in the manifest is
|
|
490
599
|
* read. A manifest without `requires` declares nothing and parses as empty.
|
|
@@ -507,7 +616,9 @@ export function parseRequires(pluginJson: unknown): ParseResult {
|
|
|
507
616
|
return {
|
|
508
617
|
ok: false,
|
|
509
618
|
kind: "invalid",
|
|
510
|
-
errors: [
|
|
619
|
+
errors: [
|
|
620
|
+
`requires.schema: missing (this reader reads schema ${REQUIRES_SCHEMA})`,
|
|
621
|
+
],
|
|
511
622
|
};
|
|
512
623
|
}
|
|
513
624
|
if (schema > REQUIRES_SCHEMA) {
|
|
@@ -589,7 +700,9 @@ export function dedupeKey(r: Requirement): string {
|
|
|
589
700
|
* binary (`check[0]` is the requirement's name) is decided by the PATH lookup
|
|
590
701
|
* alone, so a session start never waits on a slow `--version`; a dangling
|
|
591
702
|
* symlink still reads as missing. Every other check runs, because it answers
|
|
592
|
-
* something a lookup cannot (a script env, a marker file)
|
|
703
|
+
* something a lookup cannot (a script env, a marker file) — and so does every
|
|
704
|
+
* check of a requirement with a `minVersion`, because a lookup cannot read a
|
|
705
|
+
* version.
|
|
593
706
|
*
|
|
594
707
|
* "full" (`magus doctor`): every check runs.
|
|
595
708
|
*/
|
|
@@ -771,7 +884,9 @@ export type Presence =
|
|
|
771
884
|
| { name: string; status: "present" }
|
|
772
885
|
| { name: string; status: "missing"; blockedBy?: string }
|
|
773
886
|
| { name: string; status: "off-path"; prefix: UserPrefix }
|
|
774
|
-
| { name: string; status: "unknown"; reason: string }
|
|
887
|
+
| { name: string; status: "unknown"; reason: string }
|
|
888
|
+
/** The check ran and reported `found`, below the requirement's `minVersion`. */
|
|
889
|
+
| { name: string; status: "outdated"; found: string; minVersion: string };
|
|
775
890
|
|
|
776
891
|
/** The check argv with `${CLAUDE_PLUGIN_ROOT}` expanded, and nothing else. */
|
|
777
892
|
export function expandCheck(r: Requirement, pluginRoot: string): string[] {
|
|
@@ -788,35 +903,78 @@ function offPathPrefix(tool: string, home: string): UserPrefix | null {
|
|
|
788
903
|
return null;
|
|
789
904
|
}
|
|
790
905
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
906
|
+
/** How much of each output stream a check keeps: a version is near the top. */
|
|
907
|
+
const OUTPUT_KEEP_BYTES = 64 * 1024;
|
|
908
|
+
/** How the `unknown` of a check that printed no version begins. */
|
|
909
|
+
const NO_VERSION = "no version in";
|
|
910
|
+
/** How long after a check exits its output may take to end. */
|
|
911
|
+
const CLOSE_GRACE_MS = 250;
|
|
912
|
+
|
|
913
|
+
interface CheckResult {
|
|
914
|
+
outcome: "pass" | "fail" | "timeout";
|
|
915
|
+
stdout: string;
|
|
916
|
+
stderr: string;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Run one check argv, no shell, under `env.capMs`. Its output is kept (up to
|
|
921
|
+
* {@link OUTPUT_KEEP_BYTES} a stream) for the version a `minVersion` reads.
|
|
922
|
+
* A check that exited but left its output open (a daemon it started holds the
|
|
923
|
+
* pipe) is decided by its exit, not called slow.
|
|
924
|
+
*/
|
|
925
|
+
function runCheck(argv: string[], env: ProbeEnv): Promise<CheckResult> {
|
|
795
926
|
return new Promise((resolve) => {
|
|
796
927
|
const [cmd = "", ...args] = argv;
|
|
797
928
|
const exe = onPath(cmd, env.path);
|
|
798
929
|
if (!exe) {
|
|
799
|
-
resolve("fail");
|
|
930
|
+
resolve({ outcome: "fail", stdout: "", stderr: "" });
|
|
800
931
|
return;
|
|
801
932
|
}
|
|
933
|
+
const out: Buffer[] = [];
|
|
934
|
+
const err: Buffer[] = [];
|
|
935
|
+
const keep = (into: Buffer[]) => (chunk: Buffer) => {
|
|
936
|
+
const held = into.reduce((n, b) => n + b.length, 0);
|
|
937
|
+
if (held < OUTPUT_KEEP_BYTES) into.push(chunk);
|
|
938
|
+
};
|
|
939
|
+
let exitCode: number | null | undefined;
|
|
802
940
|
let settled = false;
|
|
803
|
-
const settle = (outcome: "
|
|
941
|
+
const settle = (outcome: CheckResult["outcome"]): void => {
|
|
804
942
|
if (settled) return;
|
|
805
943
|
settled = true;
|
|
806
944
|
clearTimeout(timer);
|
|
807
|
-
|
|
945
|
+
// A pipe a daemon still holds must not keep this process alive.
|
|
946
|
+
child.stdout?.destroy();
|
|
947
|
+
child.stderr?.destroy();
|
|
948
|
+
resolve({
|
|
949
|
+
outcome,
|
|
950
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
951
|
+
stderr: Buffer.concat(err).toString("utf8"),
|
|
952
|
+
});
|
|
808
953
|
};
|
|
809
954
|
const child = spawn(exe, args, {
|
|
810
955
|
cwd: env.pluginRoot,
|
|
811
956
|
env: { ...process.env, PATH: env.path, HOME: env.home },
|
|
812
|
-
stdio: "ignore",
|
|
957
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
813
958
|
});
|
|
959
|
+
child.stdout?.on("data", keep(out));
|
|
960
|
+
child.stderr?.on("data", keep(err));
|
|
814
961
|
const timer = setTimeout(() => {
|
|
815
962
|
child.kill("SIGKILL");
|
|
816
|
-
settle("
|
|
963
|
+
if (exitCode !== undefined) settle(exitCode === 0 ? "pass" : "fail");
|
|
964
|
+
else settle("timeout");
|
|
817
965
|
}, env.capMs);
|
|
966
|
+
const byExit = () => settle(exitCode === 0 ? "pass" : "fail");
|
|
818
967
|
child.on("error", () => settle("fail"));
|
|
819
|
-
child.on("exit", (code) =>
|
|
968
|
+
child.on("exit", (code) => {
|
|
969
|
+
exitCode = code;
|
|
970
|
+
// The output normally ends at once; a pipe held open past the grace
|
|
971
|
+
// costs that grace, never the whole cap.
|
|
972
|
+
setTimeout(byExit, CLOSE_GRACE_MS).unref();
|
|
973
|
+
});
|
|
974
|
+
// "close" comes after the output streams end, so every byte is read.
|
|
975
|
+
child.on("close", (code) =>
|
|
976
|
+
settle((exitCode ?? code) === 0 ? "pass" : "fail"),
|
|
977
|
+
);
|
|
820
978
|
});
|
|
821
979
|
}
|
|
822
980
|
|
|
@@ -832,13 +990,31 @@ function notFound(r: Requirement, tool: string, env: ProbeEnv): Presence {
|
|
|
832
990
|
async function probeOne(r: Requirement, env: ProbeEnv): Promise<Presence> {
|
|
833
991
|
const argv = expandCheck(r, env.pluginRoot);
|
|
834
992
|
const tool = argv[0] ?? "";
|
|
835
|
-
|
|
993
|
+
// A lookup cannot read a version: a requirement with a floor always runs.
|
|
994
|
+
if (
|
|
995
|
+
env.depth === "presence" &&
|
|
996
|
+
tool === r.name &&
|
|
997
|
+
r.minVersion === undefined
|
|
998
|
+
) {
|
|
836
999
|
return onPath(tool, env.path)
|
|
837
1000
|
? { name: r.name, status: "present" }
|
|
838
1001
|
: notFound(r, tool, env);
|
|
839
1002
|
}
|
|
840
|
-
const outcome = await runCheck(argv, env);
|
|
841
|
-
if (outcome === "pass")
|
|
1003
|
+
const { outcome, stdout, stderr } = await runCheck(argv, env);
|
|
1004
|
+
if (outcome === "pass") {
|
|
1005
|
+
if (r.minVersion === undefined) return { name: r.name, status: "present" };
|
|
1006
|
+
const found = foundVersion(stdout, stderr);
|
|
1007
|
+
if (found === null) {
|
|
1008
|
+
return {
|
|
1009
|
+
name: r.name,
|
|
1010
|
+
status: "unknown",
|
|
1011
|
+
reason: `${NO_VERSION} "${argv.join(" ")}" output`,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
return compareVersions(found, r.minVersion) < 0
|
|
1015
|
+
? { name: r.name, status: "outdated", found, minVersion: r.minVersion }
|
|
1016
|
+
: { name: r.name, status: "present" };
|
|
1017
|
+
}
|
|
842
1018
|
if (outcome === "timeout") {
|
|
843
1019
|
return {
|
|
844
1020
|
name: r.name,
|
|
@@ -852,7 +1028,8 @@ async function probeOne(r: Requirement, env: ProbeEnv): Promise<Presence> {
|
|
|
852
1028
|
/**
|
|
853
1029
|
* Probe every requirement that applies on `env.os`, in parallel. A check whose
|
|
854
1030
|
* argv[0] is another declared requirement waits for it, and is `missing` with
|
|
855
|
-
* `blockedBy` when that one is
|
|
1031
|
+
* `blockedBy` when that one is neither present nor outdated — its own check
|
|
1032
|
+
* could not run.
|
|
856
1033
|
*/
|
|
857
1034
|
export async function probe(
|
|
858
1035
|
reqs: Requirement[],
|
|
@@ -876,9 +1053,10 @@ export async function probe(
|
|
|
876
1053
|
.map(async (r) => {
|
|
877
1054
|
const dep = dependsOn(r) as string;
|
|
878
1055
|
const depStatus = first.get(dep)?.status;
|
|
1056
|
+
// An outdated tool still exists and runs, so its dependents' checks can.
|
|
879
1057
|
first.set(
|
|
880
1058
|
r.name,
|
|
881
|
-
depStatus === "present"
|
|
1059
|
+
depStatus === "present" || depStatus === "outdated"
|
|
882
1060
|
? await probeOne(r, env)
|
|
883
1061
|
: { name: r.name, status: "missing", blockedBy: dep },
|
|
884
1062
|
);
|
|
@@ -1022,23 +1200,44 @@ export function uvScriptArgv(
|
|
|
1022
1200
|
return [uv, ...sub, "--no-config", "--script", script];
|
|
1023
1201
|
}
|
|
1024
1202
|
|
|
1025
|
-
/**
|
|
1203
|
+
/**
|
|
1204
|
+
* Why a step installs: the requirement is `missing`, or it is there but
|
|
1205
|
+
* `outdated` and the step must replace it.
|
|
1206
|
+
*/
|
|
1207
|
+
export type InstallReason = "missing" | "outdated";
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* The one rendering of an option into the step that installs it. An
|
|
1211
|
+
* `outdated` requirement is upgraded where the installer tells the two apart:
|
|
1212
|
+
* bun asks for a version (the pin, or `latest`, so an installed copy is not
|
|
1213
|
+
* kept), Homebrew runs `upgrade`. Every other option installs the same way
|
|
1214
|
+
* either way.
|
|
1215
|
+
*/
|
|
1026
1216
|
export function renderInstall(
|
|
1027
1217
|
r: Requirement,
|
|
1028
1218
|
option: InstallOption,
|
|
1029
1219
|
env: Pick<ProbeEnv, "os" | "arch" | "pluginRoot">,
|
|
1220
|
+
reason: InstallReason = "missing",
|
|
1030
1221
|
): InstallStep {
|
|
1031
1222
|
const name = r.name;
|
|
1032
1223
|
const argv = (args: string[], escalation: "none" | "sudo" = "none") =>
|
|
1033
1224
|
({ kind: "argv", name, escalation, argv: args }) as const;
|
|
1225
|
+
const brewVerb = reason === "outdated" ? "upgrade" : "install";
|
|
1034
1226
|
switch (option.via) {
|
|
1035
1227
|
case "bun":
|
|
1036
|
-
return argv([
|
|
1228
|
+
return argv([
|
|
1229
|
+
"bun",
|
|
1230
|
+
"add",
|
|
1231
|
+
"-g",
|
|
1232
|
+
reason === "outdated"
|
|
1233
|
+
? `${option.package}@${option.version ?? "latest"}`
|
|
1234
|
+
: pinned(option.package, option.version),
|
|
1235
|
+
]);
|
|
1037
1236
|
case "brew":
|
|
1038
1237
|
return argv(
|
|
1039
1238
|
option.cask !== undefined
|
|
1040
|
-
? ["brew",
|
|
1041
|
-
: ["brew",
|
|
1239
|
+
? ["brew", brewVerb, "--cask", option.cask]
|
|
1240
|
+
: ["brew", brewVerb, option.formula],
|
|
1042
1241
|
);
|
|
1043
1242
|
case "apt":
|
|
1044
1243
|
return argv(
|
|
@@ -1185,10 +1384,11 @@ export function doctorFixCommand(projectDir: string | null): string {
|
|
|
1185
1384
|
}
|
|
1186
1385
|
|
|
1187
1386
|
/**
|
|
1188
|
-
* The banner text for one plugin, or null when nothing is missing or
|
|
1189
|
-
* a healthy machine prints nothing. An `unknown` (a check that was
|
|
1190
|
-
* answer) is mentioned only beside a real
|
|
1191
|
-
* `projectDir` is the directory the session
|
|
1387
|
+
* The banner text for one plugin, or null when nothing is missing, outdated or
|
|
1388
|
+
* off PATH: a healthy machine prints nothing. An `unknown` (a check that was
|
|
1389
|
+
* too slow to answer, or printed no version) is mentioned only beside a real
|
|
1390
|
+
* finding, and never called missing. `projectDir` is the directory the session
|
|
1391
|
+
* runs in, when the hook input says.
|
|
1192
1392
|
*/
|
|
1193
1393
|
export function renderFix(
|
|
1194
1394
|
plugin: string,
|
|
@@ -1199,11 +1399,17 @@ export function renderFix(
|
|
|
1199
1399
|
projectDir: string | null = null,
|
|
1200
1400
|
): string | null {
|
|
1201
1401
|
const missing = presences.filter((p) => p.status === "missing");
|
|
1402
|
+
const outdated = presences.filter(
|
|
1403
|
+
(p): p is Extract<Presence, { status: "outdated" }> =>
|
|
1404
|
+
p.status === "outdated",
|
|
1405
|
+
);
|
|
1202
1406
|
const offPath = presences.filter(
|
|
1203
1407
|
(p): p is Extract<Presence, { status: "off-path" }> =>
|
|
1204
1408
|
p.status === "off-path",
|
|
1205
1409
|
);
|
|
1206
|
-
if (missing.length === 0 && offPath.length === 0)
|
|
1410
|
+
if (missing.length === 0 && outdated.length === 0 && offPath.length === 0) {
|
|
1411
|
+
return null;
|
|
1412
|
+
}
|
|
1207
1413
|
const unknown = presences.filter(
|
|
1208
1414
|
(p): p is Extract<Presence, { status: "unknown" }> =>
|
|
1209
1415
|
p.status === "unknown",
|
|
@@ -1218,8 +1424,21 @@ export function renderFix(
|
|
|
1218
1424
|
.join(", ");
|
|
1219
1425
|
const head: string[] = [];
|
|
1220
1426
|
if (missing.length > 0) head.push(`missing ${names(missing)}`);
|
|
1427
|
+
if (outdated.length > 0) {
|
|
1428
|
+
const each = [...outdated]
|
|
1429
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
1430
|
+
.map((p) => `${p.name} ${p.found} (needs ${p.minVersion})`)
|
|
1431
|
+
.join(", ");
|
|
1432
|
+
head.push(`outdated ${each}`);
|
|
1433
|
+
}
|
|
1221
1434
|
if (offPath.length > 0) head.push(`not on PATH ${names(offPath)}`);
|
|
1222
|
-
|
|
1435
|
+
// Answered with no version to hold to a floor, or too slow to answer.
|
|
1436
|
+
const noVersion = unknown.filter((p) => p.reason.startsWith(NO_VERSION));
|
|
1437
|
+
const slow = unknown.filter((p) => !p.reason.startsWith(NO_VERSION));
|
|
1438
|
+
if (slow.length > 0) head.push(`unknown (slow) ${names(slow)}`);
|
|
1439
|
+
if (noVersion.length > 0) {
|
|
1440
|
+
head.push(`unknown (no version) ${names(noVersion)}`);
|
|
1441
|
+
}
|
|
1223
1442
|
const lines = [`magus-deps · ${plugin}: ${head.join("; ")}.`];
|
|
1224
1443
|
lines.push(
|
|
1225
1444
|
` Fix all: ${magusOnPath ? "" : `${MAGUS_CLI_INSTALL}, then `}${doctorFixCommand(projectDir)}`,
|
|
@@ -1231,7 +1450,10 @@ export function renderFix(
|
|
|
1231
1450
|
const apt: string[] = [];
|
|
1232
1451
|
const aptNames: string[] = [];
|
|
1233
1452
|
const byHand: string[] = [];
|
|
1234
|
-
|
|
1453
|
+
const toFix = [...missing, ...outdated].sort((a, b) =>
|
|
1454
|
+
a.name.localeCompare(b.name),
|
|
1455
|
+
);
|
|
1456
|
+
for (const p of toFix) {
|
|
1235
1457
|
const r = byName.get(p.name);
|
|
1236
1458
|
if (!r) continue;
|
|
1237
1459
|
const option = selectOption(r, env, has);
|
|
@@ -1246,8 +1468,10 @@ export function renderFix(
|
|
|
1246
1468
|
}
|
|
1247
1469
|
const blocked =
|
|
1248
1470
|
p.status === "missing" && p.blockedBy ? ` (after ${p.blockedBy})` : "";
|
|
1471
|
+
const reason: InstallReason =
|
|
1472
|
+
p.status === "outdated" ? "outdated" : "missing";
|
|
1249
1473
|
byHand.push(
|
|
1250
|
-
`${p.name}${blocked}: ${describeStep(renderInstall(r, option, env), env.os)}`,
|
|
1474
|
+
`${p.name}${blocked}: ${describeStep(renderInstall(r, option, env, reason), env.os)}`,
|
|
1251
1475
|
);
|
|
1252
1476
|
}
|
|
1253
1477
|
if (apt.length > 0) {
|
|
@@ -97,7 +97,10 @@ export function rowName(row: CliToolsRow): string {
|
|
|
97
97
|
return isPluginManagedRow(row) ? row.entry.name : row.tool.name;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* On PATH or in a user prefix, as far as the row knows. An `outdated` tool is
|
|
102
|
+
* not installed: it is below its plugin's floor and needs a fix.
|
|
103
|
+
*/
|
|
101
104
|
function rowInstalled(row: CliToolsRow): boolean {
|
|
102
105
|
if (!isPluginManagedRow(row)) return isInstalled(row.status);
|
|
103
106
|
const status = row.presence?.status;
|
|
@@ -366,6 +369,8 @@ export function countToolRows(rows: readonly CliToolsRow[]): {
|
|
|
366
369
|
if (rowInstalled(row)) installed++;
|
|
367
370
|
if (isPluginManagedRow(row)) {
|
|
368
371
|
if (row.presence?.status === "unknown") unverified++;
|
|
372
|
+
// Below its plugin's floor: an update the row needs.
|
|
373
|
+
if (row.presence?.status === "outdated") updates++;
|
|
369
374
|
continue;
|
|
370
375
|
}
|
|
371
376
|
if (row.status?.state === "upgrade") updates++;
|
|
@@ -119,6 +119,12 @@ function managedStatus(presence: PluginManagedRow["presence"]): {
|
|
|
119
119
|
};
|
|
120
120
|
case "missing":
|
|
121
121
|
return { icon: "○", color: theme.colors.muted, text: "○ Not installed" };
|
|
122
|
+
case "outdated":
|
|
123
|
+
return {
|
|
124
|
+
icon: "!",
|
|
125
|
+
color: theme.colors.danger,
|
|
126
|
+
text: `! Outdated: ${presence.found}, needs ${presence.minVersion} or later`,
|
|
127
|
+
};
|
|
122
128
|
case "unknown":
|
|
123
129
|
return {
|
|
124
130
|
icon: "?",
|
|
@@ -192,7 +198,7 @@ function renderPluginManagedDetail(
|
|
|
192
198
|
{!here && presence !== null && row.installText ? (
|
|
193
199
|
<box flexDirection="row">
|
|
194
200
|
<text fg={theme.colors.muted} flexShrink={0}>
|
|
195
|
-
{"Install "}
|
|
201
|
+
{presence.status === "outdated" ? "Update " : "Install "}
|
|
196
202
|
</text>
|
|
197
203
|
<text fg={theme.colors.info} minWidth={0}>
|
|
198
204
|
{wrapValue(row.installText, width)}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../../services/cli-tool-commands.js";
|
|
18
18
|
import {
|
|
19
19
|
DOCTOR_CHECK_CAP_MS,
|
|
20
|
+
installReason,
|
|
20
21
|
installText,
|
|
21
22
|
machineEnv,
|
|
22
23
|
probeBins,
|
|
@@ -201,7 +202,13 @@ export function CliToolsScreen() {
|
|
|
201
202
|
entry: m.entry,
|
|
202
203
|
owned: m.owned,
|
|
203
204
|
presence,
|
|
204
|
-
installText: env
|
|
205
|
+
installText: env
|
|
206
|
+
? installText(
|
|
207
|
+
m.owned,
|
|
208
|
+
env,
|
|
209
|
+
presence ? installReason(presence) : "missing",
|
|
210
|
+
)
|
|
211
|
+
: "",
|
|
205
212
|
});
|
|
206
213
|
const setRow = (row: number, value: CliToolsRow) =>
|
|
207
214
|
setAndCache((prev) => {
|
|
@@ -1009,7 +1009,9 @@ export function PluginsScreen() {
|
|
|
1009
1009
|
const action = resolveScopeAction(
|
|
1010
1010
|
selectedScope,
|
|
1011
1011
|
scopeIsBehind(status, scopeValue as PluginScope),
|
|
1012
|
+
plugin.isOrphaned,
|
|
1012
1013
|
);
|
|
1014
|
+
if (action === "none") return;
|
|
1013
1015
|
|
|
1014
1016
|
try {
|
|
1015
1017
|
const scope = scopeValue as PluginScope;
|
|
@@ -1174,7 +1176,9 @@ export function PluginsScreen() {
|
|
|
1174
1176
|
const action = resolveScopeAction(
|
|
1175
1177
|
scopeData,
|
|
1176
1178
|
scopeIsBehind(statuses.get(plugin.id), scope),
|
|
1179
|
+
plugin.isOrphaned,
|
|
1177
1180
|
);
|
|
1181
|
+
if (action === "none") return;
|
|
1178
1182
|
|
|
1179
1183
|
try {
|
|
1180
1184
|
if (action === "uninstall") {
|