magus-cli 7.8.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 +16 -11
- 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 +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- 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__/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__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- 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__/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__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- 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-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- 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 +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- 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/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- package/src/utils/go-module.ts +0 -36
package/src/types/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { ModelsConfig } from "../services/models-core.js";
|
|
2
|
+
import type {
|
|
3
|
+
InstallOption,
|
|
4
|
+
Requirement,
|
|
5
|
+
Via,
|
|
6
|
+
} from "../shared/plugin/hooks/lib/requires-probe.js";
|
|
2
7
|
|
|
3
8
|
export interface McpServer {
|
|
4
9
|
name: string;
|
|
@@ -409,14 +414,52 @@ export interface ProfileManifest {
|
|
|
409
414
|
profiles: Record<string, ProfileManifestEntry>;
|
|
410
415
|
}
|
|
411
416
|
|
|
412
|
-
// ───
|
|
417
|
+
// ─── Binary requirements (profile cliTools, and plugin requires) ────────────────
|
|
413
418
|
|
|
414
|
-
/**
|
|
415
|
-
|
|
419
|
+
/**
|
|
420
|
+
* What a plugin or a profile needs on the machine. One shape for both: a
|
|
421
|
+
* plugin's `requires` entry as requires-probe parses it, and a profile
|
|
422
|
+
* cliTools entry mapped onto the profile-only `npm`/`pip`/`go` kinds (or
|
|
423
|
+
* `bun`/`brew`) by `cliToolToBin`.
|
|
424
|
+
*/
|
|
425
|
+
export type BinRequirement = Requirement;
|
|
426
|
+
export type BinInstallOption = InstallOption;
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* A requirement with the install option chosen for this machine — selected
|
|
430
|
+
* once, where the requirement is resolved — and the plugin directory a
|
|
431
|
+
* `${CLAUDE_PLUGIN_ROOT}` check or a plugin-relative script resolves against
|
|
432
|
+
* (absent for a profile cliTool).
|
|
433
|
+
*/
|
|
434
|
+
export interface SelectedBin extends Requirement {
|
|
435
|
+
/** Null when no option can be used here (`whyUnselectable` says why). */
|
|
436
|
+
chosen: InstallOption | null;
|
|
437
|
+
root?: string;
|
|
438
|
+
/**
|
|
439
|
+
* Profile pins the chosen option cannot install at: a tool a plugin owns
|
|
440
|
+
* and installs from a release, Homebrew or apt, whose version is the
|
|
441
|
+
* declaration's. Never applied, never dropped: `planDeps` reports each as
|
|
442
|
+
* unfixable and `magus update` names it.
|
|
443
|
+
*/
|
|
444
|
+
refusedPins?: string[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** A selected requirement with its provenance: `plugin:<id>` / `profile:<name>.cliTools`. */
|
|
448
|
+
export interface ResolvedBin extends SelectedBin {
|
|
449
|
+
sources: string[];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** The install kinds whose installed version magus tracks and updates. */
|
|
453
|
+
export type BinInstaller = Extract<Via, "bun" | "brew" | "npm" | "pip" | "go">;
|
|
416
454
|
|
|
417
|
-
/**
|
|
418
|
-
|
|
419
|
-
|
|
455
|
+
/**
|
|
456
|
+
* A requirement whose chosen option is one of the {@link BinInstaller} kinds,
|
|
457
|
+
* in the shape `magus update` and the CLI Tools screen track versions in.
|
|
458
|
+
* Derived from a {@link SelectedBin} by `managedTool` (services/toolchain.ts)
|
|
459
|
+
* or built from a catalogue entry; never declared by hand.
|
|
460
|
+
*/
|
|
461
|
+
export interface ManagedTool {
|
|
462
|
+
/** The executable name expected on PATH. */
|
|
420
463
|
name: string;
|
|
421
464
|
via: BinInstaller;
|
|
422
465
|
/** Package id for bun/npm/pip (defaults to `name`). */
|
|
@@ -425,25 +468,19 @@ export interface BinRequirement {
|
|
|
425
468
|
formula?: string;
|
|
426
469
|
/** Module path for `go install` (defaults to `name`). */
|
|
427
470
|
module?: string;
|
|
428
|
-
/**
|
|
471
|
+
/** The check, as one display line, e.g. "tmux -V" (defaults to `<name> --version`). */
|
|
429
472
|
check?: string;
|
|
430
473
|
/** Optional version pin (real for bun/npm/pip; advisory for brew/go). */
|
|
431
474
|
version?: string;
|
|
432
475
|
}
|
|
433
476
|
|
|
434
|
-
/**
|
|
435
|
-
export interface
|
|
436
|
-
|
|
477
|
+
/** A {@link ManagedTool} with its provenance. */
|
|
478
|
+
export interface ManagedBin extends ManagedTool {
|
|
479
|
+
sources: string[];
|
|
437
480
|
}
|
|
438
481
|
|
|
439
482
|
// ─── Resolved install closure (resolver output) ─────────────────────────────────
|
|
440
483
|
|
|
441
|
-
/** A binary requirement after dedup + pin precedence, with provenance. */
|
|
442
|
-
export interface ResolvedBin extends BinRequirement {
|
|
443
|
-
/** Where this requirement came from, for diagnostics. */
|
|
444
|
-
sources: string[];
|
|
445
|
-
}
|
|
446
|
-
|
|
447
484
|
/**
|
|
448
485
|
* The fully-resolved set of things to install for a profile (or the union of
|
|
449
486
|
* all profiles). Produced by the resolver; consumed by `magus install`.
|
package/src/ui/App.tsx
CHANGED
|
@@ -3,8 +3,6 @@ import { activePluginPins } from "../services/active-closure.js";
|
|
|
3
3
|
import { isClaudeAvailable } from "../services/claude-cli.js";
|
|
4
4
|
import {
|
|
5
5
|
findStaleLoads,
|
|
6
|
-
migrateMarketplaceRename,
|
|
7
|
-
recoverMarketplaceSettings,
|
|
8
6
|
repairAllStaleLoads,
|
|
9
7
|
} from "../services/claude-settings.js";
|
|
10
8
|
import {
|
|
@@ -12,7 +10,6 @@ import {
|
|
|
12
10
|
startupGitignoreNeed,
|
|
13
11
|
} from "../services/gitignore-check.js";
|
|
14
12
|
import { applyStartupFixes } from "../services/gitignore-service.js";
|
|
15
|
-
import { repairAllMarketplaces } from "../services/local-marketplace.js";
|
|
16
13
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
17
14
|
import {
|
|
18
15
|
type ProfileStartupNeed,
|
|
@@ -26,7 +23,6 @@ import {
|
|
|
26
23
|
} from "../services/version-check.js";
|
|
27
24
|
import {
|
|
28
25
|
gitignoreStartupReport,
|
|
29
|
-
marketplaceRefreshReport,
|
|
30
26
|
profileStartupReport,
|
|
31
27
|
staleRepairReport,
|
|
32
28
|
} from "./adapters/startupReports.js";
|
|
@@ -565,14 +561,11 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
565
561
|
const noRefresh = process.argv.includes("--no-refresh");
|
|
566
562
|
if (noRefresh) return;
|
|
567
563
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
// Migrate old marketplace names -> magus (idempotent), then repair plugin.json files
|
|
574
|
-
migrateMarketplaceRename().catch(() => {}); // non-blocking, best-effort
|
|
575
|
-
|
|
564
|
+
// Opening magus reads Claude Code's files and never writes them: the
|
|
565
|
+
// marketplace registry, its clones, installed_plugins.json and every
|
|
566
|
+
// settings.json are Claude Code's. The one change made here goes through
|
|
567
|
+
// Claude Code's own CLI.
|
|
568
|
+
//
|
|
576
569
|
// Auto-add marketplaces referenced by enabledPlugins but missing from
|
|
577
570
|
// the local registry. Fixes the "fresh clone of a project whose
|
|
578
571
|
// .claude/settings.json enables @magus plugins but this machine has
|
|
@@ -585,34 +578,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
585
578
|
// non-fatal: user can still install marketplaces manually
|
|
586
579
|
}
|
|
587
580
|
})();
|
|
588
|
-
|
|
589
|
-
// Recover stale marketplace registry entries (e.g. "directory" -> "github"),
|
|
590
|
-
// then refresh the clones whose source that fixed. A refresh that fails
|
|
591
|
-
// leaves that marketplace's plugins read from an old copy, so it is named
|
|
592
|
-
// in the failure popup with why.
|
|
593
|
-
recoverMarketplaceSettings()
|
|
594
|
-
.then(async (recovery) => {
|
|
595
|
-
if (recovery.reregistered.length === 0) return;
|
|
596
|
-
const { updateMarketplace } = await import("../services/claude-cli.js");
|
|
597
|
-
const failed: Array<{ marketplace: string; error: unknown }> = [];
|
|
598
|
-
for (const mp of recovery.reregistered) {
|
|
599
|
-
await updateMarketplace(mp).catch((error: unknown) => {
|
|
600
|
-
failed.push({ marketplace: mp, error });
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
queueReports(marketplaceRefreshReport(failed));
|
|
604
|
-
})
|
|
605
|
-
.catch(() => {}); // the recovery itself: non-fatal
|
|
606
|
-
|
|
607
|
-
repairAllMarketplaces()
|
|
608
|
-
.then(async () => {
|
|
609
|
-
dispatch({ type: "HIDE_PROGRESS" });
|
|
610
|
-
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
611
|
-
})
|
|
612
|
-
.catch(() => {
|
|
613
|
-
dispatch({ type: "HIDE_PROGRESS" });
|
|
614
|
-
});
|
|
615
|
-
}, [dispatch, queueReports]);
|
|
581
|
+
}, []);
|
|
616
582
|
|
|
617
583
|
return (
|
|
618
584
|
<DimensionsProvider
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import path from "node:path";
|
|
21
|
-
import type { CliTool } from "../../data/cli-tools.js";
|
|
21
|
+
import type { CliTool, OwnedCatalogueEntry } from "../../data/cli-tools.js";
|
|
22
22
|
import {
|
|
23
23
|
cliToolInstall,
|
|
24
24
|
cliToolUpdate,
|
|
@@ -31,8 +31,14 @@ import {
|
|
|
31
31
|
classifyTool,
|
|
32
32
|
} from "../../services/tool-probe.js";
|
|
33
33
|
import { binCommandForState } from "../../services/toolchain.js";
|
|
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";
|
|
35
40
|
import { type Command, formatCommand } from "../../utils/command-utils.js";
|
|
41
|
+
import type { RunResult } from "../../utils/run.js";
|
|
36
42
|
import type { StatusTone } from "../renderers/pluginStatusText.js";
|
|
37
43
|
import type { FailureReport } from "../state/types.js";
|
|
38
44
|
import { counted, failureReport, withoutBinaryPaths } from "./failureReport.js";
|
|
@@ -41,7 +47,7 @@ import { CLAUDE_MISSING_REASON, exceptionReason } from "./pluginsAdapter.js";
|
|
|
41
47
|
export interface CliToolRow {
|
|
42
48
|
tool: CliTool;
|
|
43
49
|
/** The requirement the tool was probed through (closure → detected → catalogue). */
|
|
44
|
-
spec:
|
|
50
|
+
spec: ManagedTool;
|
|
45
51
|
/** True until facts arrive. */
|
|
46
52
|
checking: boolean;
|
|
47
53
|
/** Null while checking. */
|
|
@@ -56,25 +62,65 @@ export interface CliToolRow {
|
|
|
56
62
|
updateCommand?: Command;
|
|
57
63
|
/** What Enter runs for a tool that is not installed. */
|
|
58
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";
|
|
59
105
|
}
|
|
60
106
|
|
|
61
107
|
/**
|
|
62
108
|
* Did this spec come from the resolver's closure — i.e. does the active profile
|
|
63
109
|
* declare this tool?
|
|
64
110
|
*
|
|
65
|
-
* `
|
|
111
|
+
* `ManagedBin` is `ManagedTool` plus `sources`, and only the resolver adds
|
|
66
112
|
* them, so the provenance travels with the spec itself rather than being
|
|
67
113
|
* re-derived here (which would be a second copy of `specForCatalogTool`'s
|
|
68
114
|
* branch 1, free to disagree with it).
|
|
69
115
|
*/
|
|
70
|
-
function declaredSpec(spec:
|
|
71
|
-
const candidate = spec as
|
|
116
|
+
function declaredSpec(spec: ManagedTool): ManagedBin | null {
|
|
117
|
+
const candidate = spec as ManagedBin;
|
|
72
118
|
return Array.isArray(candidate.sources) ? candidate : null;
|
|
73
119
|
}
|
|
74
120
|
|
|
75
121
|
export function buildToolRow(
|
|
76
122
|
tool: CliTool,
|
|
77
|
-
spec:
|
|
123
|
+
spec: ManagedTool,
|
|
78
124
|
facts: ToolFacts | undefined,
|
|
79
125
|
install?: InstallInfo,
|
|
80
126
|
): CliToolRow {
|
|
@@ -122,7 +168,7 @@ export function buildToolRow(
|
|
|
122
168
|
*/
|
|
123
169
|
function updateCommandFor(
|
|
124
170
|
tool: CliTool,
|
|
125
|
-
declared:
|
|
171
|
+
declared: ManagedBin | null,
|
|
126
172
|
status: ToolStatus,
|
|
127
173
|
facts: ToolFacts,
|
|
128
174
|
install: InstallInfo | undefined,
|
|
@@ -237,7 +283,7 @@ export interface CliToolStatusText {
|
|
|
237
283
|
* within 24. Keyed by reason alone — plus the spec's installer, which is a
|
|
238
284
|
* word, never a path.
|
|
239
285
|
*/
|
|
240
|
-
function unknownLabel(reason: ToolReason, via:
|
|
286
|
+
function unknownLabel(reason: ToolReason, via: ManagedTool["via"]): string {
|
|
241
287
|
switch (reason) {
|
|
242
288
|
case "not-managed":
|
|
243
289
|
return `not managed by ${via}`;
|
|
@@ -292,7 +338,7 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
|
|
|
292
338
|
}
|
|
293
339
|
|
|
294
340
|
/** Header counts, from the rows' states. */
|
|
295
|
-
export function countToolRows(rows: readonly
|
|
341
|
+
export function countToolRows(rows: readonly CliToolsRow[]): {
|
|
296
342
|
installed: number;
|
|
297
343
|
updates: number;
|
|
298
344
|
unverified: number;
|
|
@@ -301,7 +347,11 @@ export function countToolRows(rows: readonly CliToolRow[]): {
|
|
|
301
347
|
let updates = 0;
|
|
302
348
|
let unverified = 0;
|
|
303
349
|
for (const row of rows) {
|
|
304
|
-
if (
|
|
350
|
+
if (rowInstalled(row)) installed++;
|
|
351
|
+
if (isPluginManagedRow(row)) {
|
|
352
|
+
if (row.presence?.status === "unknown") unverified++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
305
355
|
if (row.status?.state === "upgrade") updates++;
|
|
306
356
|
if (row.status?.state === "unknown") unverified++;
|
|
307
357
|
}
|
|
@@ -364,39 +414,29 @@ export const RETRY_TIMEOUT_FIX =
|
|
|
364
414
|
"Try again; if it keeps timing out, check your network connection";
|
|
365
415
|
|
|
366
416
|
/**
|
|
367
|
-
* Why an installer command failed, as ONE line, from
|
|
417
|
+
* Why an installer command failed, as ONE line, from its {@link RunResult}.
|
|
368
418
|
*
|
|
369
|
-
* The
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
* screen uses for it. No absolute binary path survives into the reason.
|
|
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.
|
|
375
424
|
*/
|
|
376
425
|
export function commandFailureReason(
|
|
377
426
|
command: Command,
|
|
378
|
-
|
|
427
|
+
result: RunResult,
|
|
379
428
|
timeoutMs: number,
|
|
380
429
|
): string {
|
|
381
|
-
if (!(error instanceof Error)) {
|
|
382
|
-
return withoutBinaryPaths(String(error).split("\n")[0]?.trim() || "failed");
|
|
383
|
-
}
|
|
384
|
-
const e = error as Error & {
|
|
385
|
-
code?: unknown;
|
|
386
|
-
killed?: boolean;
|
|
387
|
-
signal?: unknown;
|
|
388
|
-
stderr?: unknown;
|
|
389
|
-
};
|
|
390
430
|
const name = commandName(command);
|
|
391
|
-
if (
|
|
431
|
+
if (result.spawnError?.includes("ENOENT")) {
|
|
392
432
|
return name === "claude"
|
|
393
433
|
? CLAUDE_MISSING_REASON
|
|
394
434
|
: `${name} is not installed on this machine`;
|
|
395
435
|
}
|
|
396
|
-
if (
|
|
436
|
+
if (result.timedOut) {
|
|
397
437
|
return `did not finish within ${Math.round(timeoutMs / 1000)} seconds`;
|
|
398
438
|
}
|
|
399
|
-
const lines =
|
|
439
|
+
const lines = result.outputTail
|
|
400
440
|
.split("\n")
|
|
401
441
|
.map((line) => line.replace(/^npm (?:ERR!|error)\b/i, "").trim())
|
|
402
442
|
.filter((line) => line !== "" && !INSTALLER_NOISE.test(line));
|
|
@@ -406,12 +446,10 @@ export function commandFailureReason(
|
|
|
406
446
|
: (lines[0] ?? "");
|
|
407
447
|
if (!reason) {
|
|
408
448
|
reason =
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
?.replace(/^Command failed: .*$/, "")
|
|
414
|
-
.trim() || `${name} failed`;
|
|
449
|
+
result.spawnError ??
|
|
450
|
+
(result.exitCode !== null
|
|
451
|
+
? `${name} exited with code ${result.exitCode}`
|
|
452
|
+
: `${name} failed`);
|
|
415
453
|
}
|
|
416
454
|
reason = withoutBinaryPaths(reason);
|
|
417
455
|
return reason.length > MAX_REASON
|
|
@@ -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
|
+
}
|
|
@@ -212,28 +212,6 @@ export function stepFailureReport(
|
|
|
212
212
|
};
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
/**
|
|
216
|
-
* M8: marketplaces whose refresh failed after startup re-registered them.
|
|
217
|
-
* Until one refreshes, its plugins are read from a copy that may be old.
|
|
218
|
-
*/
|
|
219
|
-
export function marketplaceRefreshReport(
|
|
220
|
-
failures: ReadonlyArray<{ marketplace: string; error: unknown }>,
|
|
221
|
-
): FailureReport | null {
|
|
222
|
-
return failureReport(
|
|
223
|
-
`${counted(failures.length, "marketplace")} could not be refreshed`,
|
|
224
|
-
failures.map(({ marketplace, error }) => {
|
|
225
|
-
const e = exceptionReason(
|
|
226
|
-
error instanceof Error ? error.message : String(error),
|
|
227
|
-
);
|
|
228
|
-
return {
|
|
229
|
-
subject: marketplace,
|
|
230
|
-
reason: e.reason,
|
|
231
|
-
...(e.fix ? { fix: e.fix } : {}),
|
|
232
|
-
};
|
|
233
|
-
}),
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
215
|
/**
|
|
238
216
|
* M9: generated files the startup gitignore fix could not untrack, each with
|
|
239
217
|
* git's own reason.
|