magus-cli 7.7.0 → 7.9.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/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +45 -26
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1178 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +896 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +896 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +740 -0
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +24 -18
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +320 -54
- package/src/cli/install.ts +55 -71
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +55 -12
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +88 -384
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +962 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +35 -37
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +263 -59
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +345 -45
- package/src/services/update-plan.ts +296 -36
- package/src/shared/plugin/hooks/bun-missing.sh +39 -0
- package/src/shared/plugin/hooks/deps-check.ts +224 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
- package/src/types/index.ts +60 -16
- package/src/ui/App.tsx +81 -171
- package/src/ui/adapters/cliToolsAdapter.ts +252 -12
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +260 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +199 -98
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +315 -259
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +123 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/plugin-setup.ts +0 -965
- package/src/services/version-snapshot.ts +0 -189
- package/src/utils/go-module.ts +0 -36
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* Pure: no IO, no React. The screen gathers facts; this only assembles.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import type { CliTool, OwnedCatalogueEntry } from "../../data/cli-tools.js";
|
|
21
22
|
import {
|
|
22
23
|
cliToolInstall,
|
|
23
24
|
cliToolUpdate,
|
|
@@ -30,14 +31,23 @@ import {
|
|
|
30
31
|
classifyTool,
|
|
31
32
|
} from "../../services/tool-probe.js";
|
|
32
33
|
import { binCommandForState } from "../../services/toolchain.js";
|
|
33
|
-
import type {
|
|
34
|
-
import type {
|
|
34
|
+
import type { Presence } from "../../shared/plugin/hooks/lib/requires-probe.js";
|
|
35
|
+
import type {
|
|
36
|
+
ManagedBin,
|
|
37
|
+
ManagedTool,
|
|
38
|
+
ResolvedBin,
|
|
39
|
+
} from "../../types/index.js";
|
|
40
|
+
import { type Command, formatCommand } from "../../utils/command-utils.js";
|
|
41
|
+
import type { RunResult } from "../../utils/run.js";
|
|
35
42
|
import type { StatusTone } from "../renderers/pluginStatusText.js";
|
|
43
|
+
import type { FailureReport } from "../state/types.js";
|
|
44
|
+
import { counted, failureReport, withoutBinaryPaths } from "./failureReport.js";
|
|
45
|
+
import { CLAUDE_MISSING_REASON, exceptionReason } from "./pluginsAdapter.js";
|
|
36
46
|
|
|
37
47
|
export interface CliToolRow {
|
|
38
48
|
tool: CliTool;
|
|
39
49
|
/** The requirement the tool was probed through (closure → detected → catalogue). */
|
|
40
|
-
spec:
|
|
50
|
+
spec: ManagedTool;
|
|
41
51
|
/** True until facts arrive. */
|
|
42
52
|
checking: boolean;
|
|
43
53
|
/** Null while checking. */
|
|
@@ -52,25 +62,65 @@ export interface CliToolRow {
|
|
|
52
62
|
updateCommand?: Command;
|
|
53
63
|
/** What Enter runs for a tool that is not installed. */
|
|
54
64
|
installCommand: Command;
|
|
65
|
+
/**
|
|
66
|
+
* For a tool a plugin owns: that plugin's requirement. Enter on a missing
|
|
67
|
+
* one installs it through the deps executor, like `magus doctor --fix`.
|
|
68
|
+
*/
|
|
69
|
+
owned?: ResolvedBin;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A catalogue tool its plugin installs with an option magus does not version
|
|
74
|
+
* (a release, a Homebrew cask, apt): read-only. No Enter action, no update,
|
|
75
|
+
* no conflict fix — the row says which plugin manages it and whether it is
|
|
76
|
+
* here, from the same probe `magus doctor` runs.
|
|
77
|
+
*/
|
|
78
|
+
export interface PluginManagedRow {
|
|
79
|
+
/** The plugin that installs it: `<plugin>@<marketplace>`. */
|
|
80
|
+
managedBy: string;
|
|
81
|
+
entry: OwnedCatalogueEntry;
|
|
82
|
+
owned: ResolvedBin;
|
|
83
|
+
/** Null while checking. */
|
|
84
|
+
presence: Presence | null;
|
|
85
|
+
/** The plugin's install line, for display; `magus doctor --fix` runs it. */
|
|
86
|
+
installText: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A CLI Tools list row. */
|
|
90
|
+
export type CliToolsRow = CliToolRow | PluginManagedRow;
|
|
91
|
+
|
|
92
|
+
export const isPluginManagedRow = (row: CliToolsRow): row is PluginManagedRow =>
|
|
93
|
+
"managedBy" in row;
|
|
94
|
+
|
|
95
|
+
/** The tool's catalogue name, whichever kind of row. */
|
|
96
|
+
export function rowName(row: CliToolsRow): string {
|
|
97
|
+
return isPluginManagedRow(row) ? row.entry.name : row.tool.name;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** On PATH or in a user prefix, as far as the row knows. */
|
|
101
|
+
function rowInstalled(row: CliToolsRow): boolean {
|
|
102
|
+
if (!isPluginManagedRow(row)) return isInstalled(row.status);
|
|
103
|
+
const status = row.presence?.status;
|
|
104
|
+
return status === "present" || status === "off-path";
|
|
55
105
|
}
|
|
56
106
|
|
|
57
107
|
/**
|
|
58
108
|
* Did this spec come from the resolver's closure — i.e. does the active profile
|
|
59
109
|
* declare this tool?
|
|
60
110
|
*
|
|
61
|
-
* `
|
|
111
|
+
* `ManagedBin` is `ManagedTool` plus `sources`, and only the resolver adds
|
|
62
112
|
* them, so the provenance travels with the spec itself rather than being
|
|
63
113
|
* re-derived here (which would be a second copy of `specForCatalogTool`'s
|
|
64
114
|
* branch 1, free to disagree with it).
|
|
65
115
|
*/
|
|
66
|
-
function declaredSpec(spec:
|
|
67
|
-
const candidate = spec as
|
|
116
|
+
function declaredSpec(spec: ManagedTool): ManagedBin | null {
|
|
117
|
+
const candidate = spec as ManagedBin;
|
|
68
118
|
return Array.isArray(candidate.sources) ? candidate : null;
|
|
69
119
|
}
|
|
70
120
|
|
|
71
121
|
export function buildToolRow(
|
|
72
122
|
tool: CliTool,
|
|
73
|
-
spec:
|
|
123
|
+
spec: ManagedTool,
|
|
74
124
|
facts: ToolFacts | undefined,
|
|
75
125
|
install?: InstallInfo,
|
|
76
126
|
): CliToolRow {
|
|
@@ -118,7 +168,7 @@ export function buildToolRow(
|
|
|
118
168
|
*/
|
|
119
169
|
function updateCommandFor(
|
|
120
170
|
tool: CliTool,
|
|
121
|
-
declared:
|
|
171
|
+
declared: ManagedBin | null,
|
|
122
172
|
status: ToolStatus,
|
|
123
173
|
facts: ToolFacts,
|
|
124
174
|
install: InstallInfo | undefined,
|
|
@@ -233,7 +283,7 @@ export interface CliToolStatusText {
|
|
|
233
283
|
* within 24. Keyed by reason alone — plus the spec's installer, which is a
|
|
234
284
|
* word, never a path.
|
|
235
285
|
*/
|
|
236
|
-
function unknownLabel(reason: ToolReason, via:
|
|
286
|
+
function unknownLabel(reason: ToolReason, via: ManagedTool["via"]): string {
|
|
237
287
|
switch (reason) {
|
|
238
288
|
case "not-managed":
|
|
239
289
|
return `not managed by ${via}`;
|
|
@@ -288,7 +338,7 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
|
|
|
288
338
|
}
|
|
289
339
|
|
|
290
340
|
/** Header counts, from the rows' states. */
|
|
291
|
-
export function countToolRows(rows: readonly
|
|
341
|
+
export function countToolRows(rows: readonly CliToolsRow[]): {
|
|
292
342
|
installed: number;
|
|
293
343
|
updates: number;
|
|
294
344
|
unverified: number;
|
|
@@ -297,9 +347,199 @@ export function countToolRows(rows: readonly CliToolRow[]): {
|
|
|
297
347
|
let updates = 0;
|
|
298
348
|
let unverified = 0;
|
|
299
349
|
for (const row of rows) {
|
|
300
|
-
if (
|
|
350
|
+
if (rowInstalled(row)) installed++;
|
|
351
|
+
if (isPluginManagedRow(row)) {
|
|
352
|
+
if (row.presence?.status === "unknown") unverified++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
301
355
|
if (row.status?.state === "upgrade") updates++;
|
|
302
356
|
if (row.status?.state === "unknown") unverified++;
|
|
303
357
|
}
|
|
304
358
|
return { installed, updates, unverified };
|
|
305
359
|
}
|
|
360
|
+
|
|
361
|
+
// ─── Failures ────────────────────────────────────────────────────────────────
|
|
362
|
+
//
|
|
363
|
+
// Every installer command that fails goes into the failure popup with why and
|
|
364
|
+
// the command to run by hand. Update-all used to discard them: the reload
|
|
365
|
+
// showed the tool still behind, with no reason anywhere.
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The verb for what an Enter action did not do — "installed", "updated",
|
|
369
|
+
* "reinstalled". The caller writes the headline around it:
|
|
370
|
+
* `${tool} was not ${cliToolNotDone(kind)}`.
|
|
371
|
+
*/
|
|
372
|
+
export function cliToolNotDone(kind: CliToolEnterAction["kind"]): string {
|
|
373
|
+
if (kind === "install") return "installed";
|
|
374
|
+
if (kind === "reinstall") return "reinstalled";
|
|
375
|
+
return "updated";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** One installer command that did not succeed. */
|
|
379
|
+
export interface CliToolCommandFailure {
|
|
380
|
+
/** The tool's display name. */
|
|
381
|
+
tool: string;
|
|
382
|
+
command: Command;
|
|
383
|
+
/** Why, in {@link commandFailureReason}'s words. */
|
|
384
|
+
reason: string;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The longest reason kept from an installer's own output. */
|
|
388
|
+
const MAX_REASON = 160;
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* npm's bookkeeping lines, which never say why: the error code and syscall
|
|
392
|
+
* fields, and where its log went.
|
|
393
|
+
*/
|
|
394
|
+
const INSTALLER_NOISE =
|
|
395
|
+
/^(?:code|errno|syscall|path|dest)\b|^(?:A complete log of this run|Log files were not written|You can rerun|For a full report see)\b/i;
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The name a user knows a command by: `/usr/local/bin/npm` → `npm`. Which
|
|
399
|
+
* copy of npm the machine found is mechanism, never part of a reason.
|
|
400
|
+
*/
|
|
401
|
+
function commandName(command: Command): string {
|
|
402
|
+
return path.basename(command.cmd) || command.cmd;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** How a timeout reads, so its fix can be told apart from an ordinary failure. */
|
|
406
|
+
const TIMED_OUT_REASON = /^did not finish within \d+ seconds$/;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The fix for a timeout. Not "Run it yourself": the same command would take
|
|
410
|
+
* just as long. A slow download is the usual cause, so it names the network
|
|
411
|
+
* without claiming that is what happened.
|
|
412
|
+
*/
|
|
413
|
+
export const RETRY_TIMEOUT_FIX =
|
|
414
|
+
"Try again; if it keeps timing out, check your network connection";
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Why an installer command failed, as ONE line, from its {@link RunResult}.
|
|
418
|
+
*
|
|
419
|
+
* The installer's own words are in its output: the text after an `error:`
|
|
420
|
+
* (npm, brew and cargo all say it that way), else its first line that is not
|
|
421
|
+
* bookkeeping. A missing installer and a timeout are said plainly — a missing
|
|
422
|
+
* `claude` in the words every other screen uses for it. No absolute binary
|
|
423
|
+
* path survives into the reason.
|
|
424
|
+
*/
|
|
425
|
+
export function commandFailureReason(
|
|
426
|
+
command: Command,
|
|
427
|
+
result: RunResult,
|
|
428
|
+
timeoutMs: number,
|
|
429
|
+
): string {
|
|
430
|
+
const name = commandName(command);
|
|
431
|
+
if (result.spawnError?.includes("ENOENT")) {
|
|
432
|
+
return name === "claude"
|
|
433
|
+
? CLAUDE_MISSING_REASON
|
|
434
|
+
: `${name} is not installed on this machine`;
|
|
435
|
+
}
|
|
436
|
+
if (result.timedOut) {
|
|
437
|
+
return `did not finish within ${Math.round(timeoutMs / 1000)} seconds`;
|
|
438
|
+
}
|
|
439
|
+
const lines = result.outputTail
|
|
440
|
+
.split("\n")
|
|
441
|
+
.map((line) => line.replace(/^npm (?:ERR!|error)\b/i, "").trim())
|
|
442
|
+
.filter((line) => line !== "" && !INSTALLER_NOISE.test(line));
|
|
443
|
+
const said = lines.find((line) => /\berror:\s*\S/i.test(line));
|
|
444
|
+
let reason = said
|
|
445
|
+
? said.slice(said.toLowerCase().lastIndexOf("error:") + 6).trim()
|
|
446
|
+
: (lines[0] ?? "");
|
|
447
|
+
if (!reason) {
|
|
448
|
+
reason =
|
|
449
|
+
result.spawnError ??
|
|
450
|
+
(result.exitCode !== null
|
|
451
|
+
? `${name} exited with code ${result.exitCode}`
|
|
452
|
+
: `${name} failed`);
|
|
453
|
+
}
|
|
454
|
+
reason = withoutBinaryPaths(reason);
|
|
455
|
+
return reason.length > MAX_REASON
|
|
456
|
+
? `${reason.slice(0, MAX_REASON - 1)}…`
|
|
457
|
+
: reason;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The fix for one failed installer command.
|
|
462
|
+
*
|
|
463
|
+
* Usually the command itself, to run by hand. Not for the three causes that
|
|
464
|
+
* running the same command again would not cure: a timeout, no `claude` to
|
|
465
|
+
* run, and no network. Each keeps the fix that does help.
|
|
466
|
+
*/
|
|
467
|
+
export function commandFailureFix(command: Command, reason: string): string {
|
|
468
|
+
if (TIMED_OUT_REASON.test(reason)) return RETRY_TIMEOUT_FIX;
|
|
469
|
+
const known = exceptionReason(reason);
|
|
470
|
+
if (known.fix) return known.fix;
|
|
471
|
+
return `Run it yourself: ${formatCommand(command)}`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The popup for failed installer commands, each with the fix that helps. */
|
|
475
|
+
export function cliToolFailureReport(
|
|
476
|
+
headline: string,
|
|
477
|
+
failures: readonly CliToolCommandFailure[],
|
|
478
|
+
): FailureReport | null {
|
|
479
|
+
return failureReport(
|
|
480
|
+
headline,
|
|
481
|
+
failures.map((f) => ({
|
|
482
|
+
subject: f.tool,
|
|
483
|
+
reason: f.reason,
|
|
484
|
+
fix: commandFailureFix(f.command, f.reason),
|
|
485
|
+
})),
|
|
486
|
+
{ group: false },
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Update-all: the tools whose update command failed. */
|
|
491
|
+
export function cliToolsNotUpdatedReport(
|
|
492
|
+
failures: readonly CliToolCommandFailure[],
|
|
493
|
+
): FailureReport | null {
|
|
494
|
+
const n = failures.length;
|
|
495
|
+
return cliToolFailureReport(
|
|
496
|
+
`${counted(n, "tool")} ${n === 1 ? "was" : "were"} not updated`,
|
|
497
|
+
failures,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* A conflict magus cannot resolve: the tool is installed more than one way
|
|
503
|
+
* and none of the installs can be removed from here.
|
|
504
|
+
*/
|
|
505
|
+
export function cliToolConflictStuckReport(
|
|
506
|
+
tool: string,
|
|
507
|
+
blockers: ReadonlyArray<{ method: string; reason: string }>,
|
|
508
|
+
): FailureReport | null {
|
|
509
|
+
return failureReport(
|
|
510
|
+
`${tool} is installed ${blockers.length} ways, and magus can remove none of them`,
|
|
511
|
+
blockers.map(({ method, reason }) => ({
|
|
512
|
+
subject: `${method} install`,
|
|
513
|
+
reason,
|
|
514
|
+
fix: "Remove one by hand, then press r",
|
|
515
|
+
})),
|
|
516
|
+
{ group: false },
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* A conflict resolved only in part: each removal that failed, with the
|
|
522
|
+
* command to run by hand, and each install magus could never remove.
|
|
523
|
+
*/
|
|
524
|
+
export function cliToolConflictPartialReport(
|
|
525
|
+
tool: string,
|
|
526
|
+
failed: ReadonlyArray<{ method: string; command: Command; reason: string }>,
|
|
527
|
+
leftBehind: ReadonlyArray<{ method: string; reason: string }>,
|
|
528
|
+
): FailureReport | null {
|
|
529
|
+
return failureReport(
|
|
530
|
+
`${tool} is still installed more than one way`,
|
|
531
|
+
[
|
|
532
|
+
...failed.map((f) => ({
|
|
533
|
+
subject: `${f.method} install`,
|
|
534
|
+
reason: `was not removed: ${f.reason}`,
|
|
535
|
+
fix: `Run it yourself: ${formatCommand(f.command)}`,
|
|
536
|
+
})),
|
|
537
|
+
...leftBehind.map(({ method, reason }) => ({
|
|
538
|
+
subject: `${method} install`,
|
|
539
|
+
reason: `stays: ${reason}`,
|
|
540
|
+
fix: "Remove it by hand if you want it gone",
|
|
541
|
+
})),
|
|
542
|
+
],
|
|
543
|
+
{ group: false },
|
|
544
|
+
);
|
|
545
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin dependencies in the TUI: the same plan and the same `ApplyOutcome[]`
|
|
3
|
+
* `magus doctor --fix` renders as text, rendered as a confirm modal and a
|
|
4
|
+
* failure popup.
|
|
5
|
+
*
|
|
6
|
+
* `installBinsInTui` is the one flow the Plugins screen (a plugin just
|
|
7
|
+
* installed) and the CLI Tools screen (a tool a plugin owns) both run:
|
|
8
|
+
* probe → `planDeps` → confirm listing every step → `applyDepsPlan` → popup.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import {
|
|
13
|
+
type ApplyOutcome,
|
|
14
|
+
type DepsPlan,
|
|
15
|
+
applyDepsPlan,
|
|
16
|
+
defaultLockPath,
|
|
17
|
+
planLines,
|
|
18
|
+
} from "../../services/deps-plan.js";
|
|
19
|
+
import { assessBins } from "../../services/deps-report.js";
|
|
20
|
+
import { machineEnv } from "../../services/doctor-bins.js";
|
|
21
|
+
import type { ResolvedBin } from "../../types/index.js";
|
|
22
|
+
import type { FailureReport, FailureRow } from "../state/types.js";
|
|
23
|
+
import { counted, failureReport } from "./failureReport.js";
|
|
24
|
+
|
|
25
|
+
const STATUS_REASON: Record<ApplyOutcome["status"], string> = {
|
|
26
|
+
installed: "installed",
|
|
27
|
+
"off-path": "installed, but not on the PATH Claude Code starts with",
|
|
28
|
+
failed: "the installer failed",
|
|
29
|
+
"needs-password": "needs your sudo password",
|
|
30
|
+
"needs-root": "needs root, and sudo is not available",
|
|
31
|
+
blocked: "what it needs was not installed",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What did not install, each with why and the exact command; then what can
|
|
36
|
+
* never be installed here; then the PATH lines. Null when there is nothing to
|
|
37
|
+
* say. The headline counts what needs the user.
|
|
38
|
+
*/
|
|
39
|
+
export function depsFailureReport(
|
|
40
|
+
subject: string,
|
|
41
|
+
outcomes: readonly ApplyOutcome[],
|
|
42
|
+
plan: Pick<DepsPlan, "unfixable" | "pathAdvice">,
|
|
43
|
+
): FailureReport | null {
|
|
44
|
+
const rows: FailureRow[] = [];
|
|
45
|
+
for (const o of outcomes) {
|
|
46
|
+
if (o.status === "installed") continue;
|
|
47
|
+
const last = o.outputTail.trim().split("\n").pop()?.trim();
|
|
48
|
+
rows.push({
|
|
49
|
+
subject: o.name,
|
|
50
|
+
reason:
|
|
51
|
+
o.status === "failed" && last
|
|
52
|
+
? `${STATUS_REASON[o.status]}: ${last}`
|
|
53
|
+
: o.dir
|
|
54
|
+
? `${STATUS_REASON[o.status]}: it is in ${o.dir}`
|
|
55
|
+
: STATUS_REASON[o.status],
|
|
56
|
+
...(o.remedy ? { fix: o.remedy } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
for (const u of plan.unfixable) {
|
|
60
|
+
rows.push({
|
|
61
|
+
subject: u.name,
|
|
62
|
+
reason: u.why,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
for (const a of plan.pathAdvice) {
|
|
66
|
+
rows.push({
|
|
67
|
+
subject: a.prefix,
|
|
68
|
+
reason: "not on the PATH Claude Code starts with",
|
|
69
|
+
fix: `add ${a.rcLine} to ${a.rcFile}, then restart Claude Code from a new terminal`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return failureReport(
|
|
73
|
+
`${counted(rows.length, "dependency of", "dependencies of")} ${subject} ${
|
|
74
|
+
rows.length === 1 ? "needs" : "need"
|
|
75
|
+
} you`,
|
|
76
|
+
rows,
|
|
77
|
+
{ group: false },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The part of the modal API the flow uses. */
|
|
82
|
+
export interface DepsModal {
|
|
83
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
84
|
+
loading(message: string): void;
|
|
85
|
+
hideModal(): void;
|
|
86
|
+
failures(report: FailureReport): Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Probe, plan, ask once, install, and report — for `bins` of `subject`. */
|
|
90
|
+
export async function installBinsInTui(
|
|
91
|
+
subject: string,
|
|
92
|
+
bins: ResolvedBin[],
|
|
93
|
+
modal: DepsModal,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const env = machineEnv(
|
|
96
|
+
process.env.HOME || homedir(),
|
|
97
|
+
process.env.PATH ?? "",
|
|
98
|
+
process.env.SHELL ?? "",
|
|
99
|
+
);
|
|
100
|
+
if (!env || bins.length === 0) return;
|
|
101
|
+
const { plan } = await assessBins(
|
|
102
|
+
{ plugins: [subject], bins, problems: [] },
|
|
103
|
+
env,
|
|
104
|
+
);
|
|
105
|
+
let outcomes: ApplyOutcome[] = [];
|
|
106
|
+
if (plan.steps.length > 0) {
|
|
107
|
+
const ok = await modal.confirm(
|
|
108
|
+
"Install Dependencies?",
|
|
109
|
+
`${subject} needs:\n\n${planLines(plan, env.os).join("\n")}\n\nInstall now?`,
|
|
110
|
+
);
|
|
111
|
+
if (!ok) return;
|
|
112
|
+
modal.loading("Installing dependencies...");
|
|
113
|
+
try {
|
|
114
|
+
outcomes = await applyDepsPlan(plan, {
|
|
115
|
+
yes: true,
|
|
116
|
+
confirm: async () => true,
|
|
117
|
+
env,
|
|
118
|
+
lockPath: defaultLockPath(env.home, process.env.XDG_CACHE_HOME),
|
|
119
|
+
onEvent: (e) => {
|
|
120
|
+
if (e.type === "step-start") modal.loading(`Installing ${e.name}...`);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
} finally {
|
|
124
|
+
modal.hideModal();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const report = depsFailureReport(subject, outcomes, plan);
|
|
128
|
+
if (report) await modal.failures(report);
|
|
129
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE failure popup's words, for every screen and every kind of subject.
|
|
3
|
+
*
|
|
4
|
+
* The TUI shows no status lines, notices or success messages: a screen reloads
|
|
5
|
+
* and shows state. What the user must KNOW because something failed — or must
|
|
6
|
+
* ACT on — goes into ONE popup (`modal.failures`, `FailureListModal`), and its
|
|
7
|
+
* shape is built here: a headline, one row per thing that failed (a plugin, a
|
|
8
|
+
* marketplace, a skill, a file, the profile), and the one fix when every row
|
|
9
|
+
* shares it. Pure, so the words are tested without a renderer.
|
|
10
|
+
*
|
|
11
|
+
* Each domain has a builder on top of {@link failureReport} —
|
|
12
|
+
* `pluginFailureReport` (pluginsAdapter.ts), `activationFailureReport` and the
|
|
13
|
+
* startup and Git State builders (startupReports.ts) — so they all read the
|
|
14
|
+
* same way.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { FailureReport, FailureRow } from "../state/types.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A fix a reason names at its end — "… — run magus install", "… — fix it by
|
|
21
|
+
* hand". Taken off the reason and carried as the row's fix, so it is said once
|
|
22
|
+
* (under the rows when every row shares it) rather than inside every reason.
|
|
23
|
+
*/
|
|
24
|
+
const FIX_CLAUSE =
|
|
25
|
+
/ — ((?:run|update it|ask them|fix it|remove|install|check|compare|commit)\b.*)$/;
|
|
26
|
+
|
|
27
|
+
/** `run magus install` → `Run magus install`. */
|
|
28
|
+
function sentence(text: string): string {
|
|
29
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An executable named by absolute path — `/usr/local/bin/npm`,
|
|
34
|
+
* `/Users/someone/.local/bin/claude` — is the mechanism: which copy the
|
|
35
|
+
* machine happened to find. The user knows it by its name.
|
|
36
|
+
*/
|
|
37
|
+
const BINARY_PATH = /(?:\/[^\s'"/:]+)+\/bin\/([\w.-]+)/g;
|
|
38
|
+
|
|
39
|
+
/** Every absolute binary path in `text` replaced by the binary's name. */
|
|
40
|
+
export function withoutBinaryPaths(text: string): string {
|
|
41
|
+
return text.replace(BINARY_PATH, "$1");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Split a reason into what went wrong and what to do about it, when the reason
|
|
46
|
+
* ends in a fix clause ({@link FIX_CLAUSE}). A row's own `fix` wins.
|
|
47
|
+
*/
|
|
48
|
+
export function splitFix(row: FailureRow): FailureRow {
|
|
49
|
+
if (row.fix) return row;
|
|
50
|
+
const match = FIX_CLAUSE.exec(row.reason);
|
|
51
|
+
if (!match?.[1]) return row;
|
|
52
|
+
return {
|
|
53
|
+
subject: row.subject,
|
|
54
|
+
reason: row.reason.slice(0, match.index).trim() || row.reason,
|
|
55
|
+
fix: sentence(match[1]),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FailureReportOptions {
|
|
60
|
+
/**
|
|
61
|
+
* Merge rows whose reason AND fix are identical into one row naming every
|
|
62
|
+
* subject (default true). A caller with its own grouping rules — the plugin
|
|
63
|
+
* builder keeps unexplained exceptions apart — passes false.
|
|
64
|
+
*/
|
|
65
|
+
group?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The popup for `rows`, headed by `headline`; null when nothing failed — a
|
|
70
|
+
* success says nothing, because the reloaded screen shows it.
|
|
71
|
+
*
|
|
72
|
+
* Fixes: a fix clause at the end of a reason becomes the row's fix. When every
|
|
73
|
+
* row has the same fix, it is the report's `fix`, said once under the rows,
|
|
74
|
+
* and taken off each row; otherwise each row keeps its own (or none).
|
|
75
|
+
*/
|
|
76
|
+
export function failureReport(
|
|
77
|
+
headline: string,
|
|
78
|
+
rows: readonly FailureRow[],
|
|
79
|
+
opts: FailureReportOptions = {},
|
|
80
|
+
): FailureReport | null {
|
|
81
|
+
if (rows.length === 0) return null;
|
|
82
|
+
let out = rows.map(splitFix);
|
|
83
|
+
|
|
84
|
+
if (opts.group !== false) {
|
|
85
|
+
const grouped: FailureRow[] = [];
|
|
86
|
+
const byKey = new Map<string, FailureRow>();
|
|
87
|
+
for (const row of out) {
|
|
88
|
+
const key = `${row.reason}\u0000${row.fix ?? ""}`;
|
|
89
|
+
const existing = byKey.get(key);
|
|
90
|
+
if (existing) {
|
|
91
|
+
existing.subject = `${existing.subject}, ${row.subject}`;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const copy = { ...row };
|
|
95
|
+
byKey.set(key, copy);
|
|
96
|
+
grouped.push(copy);
|
|
97
|
+
}
|
|
98
|
+
out = grouped;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const [first] = out;
|
|
102
|
+
const shared =
|
|
103
|
+
first?.fix && out.every((row) => row.fix === first.fix) ? first.fix : null;
|
|
104
|
+
if (shared) {
|
|
105
|
+
out = out.map(({ fix: _fix, ...row }) => row);
|
|
106
|
+
}
|
|
107
|
+
return { headline, rows: out, fix: shared };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Several reports about ONE event, as one popup under `headline` — so a user
|
|
112
|
+
* reads one list, not two popups in a row. A single report passes through
|
|
113
|
+
* with its own headline; none is null.
|
|
114
|
+
*
|
|
115
|
+
* Each report's shared fix goes back onto its own rows first, so no row loses
|
|
116
|
+
* its fix; the merged report then says a fix once again only when every row
|
|
117
|
+
* shares it.
|
|
118
|
+
*/
|
|
119
|
+
export function mergeFailureReports(
|
|
120
|
+
headline: string,
|
|
121
|
+
reports: ReadonlyArray<FailureReport | null | undefined>,
|
|
122
|
+
): FailureReport | null {
|
|
123
|
+
const present = reports.filter((r): r is FailureReport => !!r);
|
|
124
|
+
const [only] = present;
|
|
125
|
+
if (present.length <= 1) return only ?? null;
|
|
126
|
+
const rows = present.flatMap((report) =>
|
|
127
|
+
report.rows.map((row) =>
|
|
128
|
+
row.fix || !report.fix ? row : { ...row, fix: report.fix },
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
return failureReport(headline, rows, { group: false });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `1 plugin`, `3 plugins`. */
|
|
135
|
+
export function counted(n: number, noun: string, plural = `${noun}s`): string {
|
|
136
|
+
return `${n} ${n === 1 ? noun : plural}`;
|
|
137
|
+
}
|