magus-cli 7.8.0 → 7.9.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/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/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__/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 +897 -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 +1154 -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 +64 -0
- package/src/shared/plugin/hooks/deps-check.ts +240 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -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 +143 -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
|
@@ -5,14 +5,18 @@ import {
|
|
|
5
5
|
cliToolUninstall,
|
|
6
6
|
uninstallBlocker,
|
|
7
7
|
} from "../../services/cli-tool-commands.js";
|
|
8
|
+
import { doctorFixCommand } from "../../shared/plugin/hooks/lib/requires-probe.js";
|
|
8
9
|
import { formatCommand } from "../../utils/command-utils.js";
|
|
9
10
|
import {
|
|
10
11
|
type CliToolEnterAction,
|
|
11
12
|
type CliToolRow,
|
|
13
|
+
type CliToolsRow,
|
|
14
|
+
type PluginManagedRow,
|
|
12
15
|
cliToolEnterAction,
|
|
13
16
|
cliToolStatusText,
|
|
14
17
|
displayVersion,
|
|
15
18
|
isInstalled,
|
|
19
|
+
isPluginManagedRow,
|
|
16
20
|
} from "../adapters/cliToolsAdapter.js";
|
|
17
21
|
import { rowLead } from "../components/primitives/index.js";
|
|
18
22
|
import { type UiColor, theme } from "../theme.js";
|
|
@@ -94,13 +98,139 @@ function toneColor(tone: StatusTone): UiColor {
|
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
// ─── Plugin-managed rows ───────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** The mark and words for a plugin-managed row, from the dependency probe. */
|
|
104
|
+
function managedStatus(presence: PluginManagedRow["presence"]): {
|
|
105
|
+
icon: string;
|
|
106
|
+
color: UiColor;
|
|
107
|
+
text: string;
|
|
108
|
+
} {
|
|
109
|
+
switch (presence?.status) {
|
|
110
|
+
case undefined:
|
|
111
|
+
return { icon: "○", color: theme.colors.muted, text: "● Checking..." };
|
|
112
|
+
case "present":
|
|
113
|
+
return { icon: "●", color: theme.colors.success, text: "● Installed" };
|
|
114
|
+
case "off-path":
|
|
115
|
+
return {
|
|
116
|
+
icon: "!",
|
|
117
|
+
color: theme.colors.warning,
|
|
118
|
+
text: `! Installed in ${presence.prefix}, which is not on the PATH Claude Code starts with`,
|
|
119
|
+
};
|
|
120
|
+
case "missing":
|
|
121
|
+
return { icon: "○", color: theme.colors.muted, text: "○ Not installed" };
|
|
122
|
+
case "unknown":
|
|
123
|
+
return {
|
|
124
|
+
icon: "?",
|
|
125
|
+
color: theme.colors.warning,
|
|
126
|
+
text: `? Could not tell: ${presence.reason}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderPluginManagedRow(
|
|
132
|
+
row: PluginManagedRow,
|
|
133
|
+
isSelected: boolean,
|
|
134
|
+
): React.ReactNode {
|
|
135
|
+
const { icon, color } = managedStatus(row.presence);
|
|
136
|
+
const suffix =
|
|
137
|
+
row.presence === null ? " ..." : ` · managed by ${row.managedBy}`;
|
|
138
|
+
if (isSelected) {
|
|
139
|
+
return (
|
|
140
|
+
<text bg={theme.selection.bg} fg={theme.selection.fg}>
|
|
141
|
+
{rowLead(true)}
|
|
142
|
+
{icon} {row.entry.displayName}
|
|
143
|
+
{suffix}{" "}
|
|
144
|
+
</text>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return (
|
|
148
|
+
<text fg={theme.colors.text}>
|
|
149
|
+
<span fg={color}>
|
|
150
|
+
{rowLead(false)}
|
|
151
|
+
{icon}
|
|
152
|
+
</span>
|
|
153
|
+
<span fg={theme.colors.text}> {row.entry.displayName}</span>
|
|
154
|
+
<span fg={theme.colors.dim}>{suffix}</span>
|
|
155
|
+
</text>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderPluginManagedDetail(
|
|
160
|
+
row: PluginManagedRow,
|
|
161
|
+
width: number,
|
|
162
|
+
): React.ReactNode {
|
|
163
|
+
const { entry, managedBy, presence } = row;
|
|
164
|
+
const status = managedStatus(presence);
|
|
165
|
+
const here =
|
|
166
|
+
presence?.status === "present" || presence?.status === "off-path";
|
|
167
|
+
return (
|
|
168
|
+
<box flexDirection="column" width={width}>
|
|
169
|
+
<box flexDirection="row" marginBottom={1}>
|
|
170
|
+
<text fg={theme.colors.info}>
|
|
171
|
+
<strong>{entry.displayName}</strong>
|
|
172
|
+
</text>
|
|
173
|
+
</box>
|
|
174
|
+
|
|
175
|
+
<text fg={theme.colors.muted}>{entry.description}</text>
|
|
176
|
+
|
|
177
|
+
<box marginTop={1} flexDirection="column">
|
|
178
|
+
<box flexDirection="row">
|
|
179
|
+
<text fg={theme.colors.muted} flexShrink={0}>
|
|
180
|
+
{"Status "}
|
|
181
|
+
</text>
|
|
182
|
+
<text fg={status.color} minWidth={0}>
|
|
183
|
+
{wrapValue(status.text, width)}
|
|
184
|
+
</text>
|
|
185
|
+
</box>
|
|
186
|
+
<box flexDirection="row">
|
|
187
|
+
<text fg={theme.colors.muted} flexShrink={0}>
|
|
188
|
+
{"Managed "}
|
|
189
|
+
</text>
|
|
190
|
+
<text fg={theme.colors.text}>by {managedBy}</text>
|
|
191
|
+
</box>
|
|
192
|
+
{!here && presence !== null && row.installText ? (
|
|
193
|
+
<box flexDirection="row">
|
|
194
|
+
<text fg={theme.colors.muted} flexShrink={0}>
|
|
195
|
+
{"Install "}
|
|
196
|
+
</text>
|
|
197
|
+
<text fg={theme.colors.info} minWidth={0}>
|
|
198
|
+
{wrapValue(row.installText, width)}
|
|
199
|
+
</text>
|
|
200
|
+
</box>
|
|
201
|
+
) : null}
|
|
202
|
+
<box flexDirection="row">
|
|
203
|
+
<text fg={theme.colors.muted} flexShrink={0}>
|
|
204
|
+
{"Website "}
|
|
205
|
+
</text>
|
|
206
|
+
<box flexGrow={1} minWidth={0}>
|
|
207
|
+
<text fg={theme.colors.info} wrapMode="char">
|
|
208
|
+
{entry.website}
|
|
209
|
+
</text>
|
|
210
|
+
</box>
|
|
211
|
+
</box>
|
|
212
|
+
</box>
|
|
213
|
+
|
|
214
|
+
<box marginTop={2}>
|
|
215
|
+
<text fg={theme.colors.muted}>
|
|
216
|
+
{wrapText(
|
|
217
|
+
`${managedBy} installs ${entry.name} and decides its version; it cannot be installed, updated or removed from here. ${doctorFixCommand(null)} installs it when it is missing.`,
|
|
218
|
+
width,
|
|
219
|
+
).join("\n")}
|
|
220
|
+
</text>
|
|
221
|
+
</box>
|
|
222
|
+
</box>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
97
226
|
// ─── Row renderer ──────────────────────────────────────────────────────────────
|
|
98
227
|
|
|
99
228
|
export function renderCliToolRow(
|
|
100
|
-
row:
|
|
229
|
+
row: CliToolsRow,
|
|
101
230
|
_index: number,
|
|
102
231
|
isSelected: boolean,
|
|
103
232
|
): React.ReactNode {
|
|
233
|
+
if (isPluginManagedRow(row)) return renderPluginManagedRow(row, isSelected);
|
|
104
234
|
const { tool, status, install } = row;
|
|
105
235
|
const installed = isInstalled(status);
|
|
106
236
|
const allMethods = install?.all ?? [];
|
|
@@ -167,10 +297,13 @@ export function renderCliToolRow(
|
|
|
167
297
|
// ─── Detail renderer ───────────────────────────────────────────────────────────
|
|
168
298
|
|
|
169
299
|
export function renderCliToolDetail(
|
|
170
|
-
row:
|
|
300
|
+
row: CliToolsRow | undefined,
|
|
171
301
|
/** Usable detail-panel columns (`detailPanelWidth`); the scrollbox does not constrain width. */
|
|
172
302
|
width: number,
|
|
173
303
|
): React.ReactNode {
|
|
304
|
+
if (row && isPluginManagedRow(row)) {
|
|
305
|
+
return renderPluginManagedDetail(row, width);
|
|
306
|
+
}
|
|
174
307
|
if (!row) {
|
|
175
308
|
return (
|
|
176
309
|
<box
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { promisify } from "node:util";
|
|
1
|
+
import { homedir } from "node:os";
|
|
3
2
|
import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
4
3
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
5
4
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
@@ -7,13 +6,21 @@ import { moveDirection } from "../keymap.js";
|
|
|
7
6
|
import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
|
|
8
7
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
9
8
|
|
|
10
|
-
import { cliTools } from "../../data/cli-tools.js";
|
|
11
9
|
import { loadActiveClosure } from "../../services/active-closure.js";
|
|
12
10
|
import {
|
|
13
11
|
type InstallMethod,
|
|
14
12
|
cliToolUninstall,
|
|
13
|
+
isPluginManagedItem,
|
|
14
|
+
resolveCatalogue,
|
|
15
15
|
uninstallBlocker,
|
|
16
|
+
unownedCatalogue,
|
|
16
17
|
} from "../../services/cli-tool-commands.js";
|
|
18
|
+
import {
|
|
19
|
+
DOCTOR_CHECK_CAP_MS,
|
|
20
|
+
installText,
|
|
21
|
+
machineEnv,
|
|
22
|
+
probeBins,
|
|
23
|
+
} from "../../services/doctor-bins.js";
|
|
17
24
|
import {
|
|
18
25
|
type InstallInfo,
|
|
19
26
|
PROBE_CONCURRENCY,
|
|
@@ -22,11 +29,15 @@ import {
|
|
|
22
29
|
probeTools,
|
|
23
30
|
specForCatalogTool,
|
|
24
31
|
} from "../../services/tool-probe.js";
|
|
32
|
+
import { managedBins } from "../../services/toolchain.js";
|
|
25
33
|
import type { Command } from "../../utils/command-utils.js";
|
|
26
34
|
import { createRequestGate } from "../../utils/request-gate.js";
|
|
35
|
+
import { runArgv, succeeded } from "../../utils/run.js";
|
|
27
36
|
import {
|
|
28
37
|
type CliToolCommandFailure,
|
|
29
38
|
type CliToolRow,
|
|
39
|
+
type CliToolsRow,
|
|
40
|
+
type PluginManagedRow,
|
|
30
41
|
buildToolRow,
|
|
31
42
|
cliToolConflictPartialReport,
|
|
32
43
|
cliToolConflictStuckReport,
|
|
@@ -36,7 +47,10 @@ import {
|
|
|
36
47
|
cliToolsNotUpdatedReport,
|
|
37
48
|
commandFailureReason,
|
|
38
49
|
countToolRows,
|
|
50
|
+
isPluginManagedRow,
|
|
51
|
+
rowName,
|
|
39
52
|
} from "../adapters/cliToolsAdapter.js";
|
|
53
|
+
import { installBinsInTui } from "../adapters/depsAdapter.js";
|
|
40
54
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
41
55
|
import {
|
|
42
56
|
renderCliToolDetail,
|
|
@@ -44,7 +58,6 @@ import {
|
|
|
44
58
|
} from "../renderers/cliToolRenderers.js";
|
|
45
59
|
import { theme } from "../theme.js";
|
|
46
60
|
|
|
47
|
-
const execFileAsync = promisify(execFile);
|
|
48
61
|
/** How long one installer command may run before it is stopped. */
|
|
49
62
|
const COMMAND_TIMEOUT_MS = 60_000;
|
|
50
63
|
|
|
@@ -55,7 +68,7 @@ const COMMAND_TIMEOUT_MS = 60_000;
|
|
|
55
68
|
// used to carry a private copy: a `/bin/bash` `which`, an `npm view` per tool,
|
|
56
69
|
// its own version comparator, and a string-form `exec` of each check command.
|
|
57
70
|
|
|
58
|
-
let cachedRows:
|
|
71
|
+
let cachedRows: CliToolsRow[] | null = null;
|
|
59
72
|
let cacheInitialized = false;
|
|
60
73
|
/** Every fetch takes a token, so a refresh discards the answers of the one before. */
|
|
61
74
|
const fetchGate = createRequestGate();
|
|
@@ -65,9 +78,12 @@ function clearCliToolsCache(): void {
|
|
|
65
78
|
cacheInitialized = false;
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Rows before anything is known: the catalogue mapping, checking. A tool a
|
|
83
|
+
* plugin owns joins once its plugin's declaration has been read.
|
|
84
|
+
*/
|
|
85
|
+
function initialRows(): CliToolsRow[] {
|
|
86
|
+
return unownedCatalogue().map(({ tool }) =>
|
|
71
87
|
buildToolRow(tool, specForCatalogTool(tool, null, undefined), undefined),
|
|
72
88
|
);
|
|
73
89
|
}
|
|
@@ -102,7 +118,7 @@ export function CliToolsScreen() {
|
|
|
102
118
|
const showFailures = useFailurePopup();
|
|
103
119
|
const dimensions = useDimensions();
|
|
104
120
|
|
|
105
|
-
const [rows, setRows] = useState<
|
|
121
|
+
const [rows, setRows] = useState<CliToolsRow[]>(
|
|
106
122
|
() => cachedRows || initialRows(),
|
|
107
123
|
);
|
|
108
124
|
/** Why the last check could not run, shown under the header; null when it ran. */
|
|
@@ -112,7 +128,7 @@ export function CliToolsScreen() {
|
|
|
112
128
|
rowsRef.current = rows;
|
|
113
129
|
|
|
114
130
|
const setAndCache = useCallback(
|
|
115
|
-
(update: (prev:
|
|
131
|
+
(update: (prev: CliToolsRow[]) => CliToolsRow[]) => {
|
|
116
132
|
setRows((prev) => {
|
|
117
133
|
const next = update(prev);
|
|
118
134
|
cachedRows = next;
|
|
@@ -136,45 +152,95 @@ export function CliToolsScreen() {
|
|
|
136
152
|
setProbeError(null);
|
|
137
153
|
try {
|
|
138
154
|
const projectPath = state.projectPath || process.cwd();
|
|
155
|
+
const catalogue = await resolveCatalogue(projectPath);
|
|
156
|
+
if (!fetchGate.isCurrent(token)) return;
|
|
157
|
+
// Each item keeps its catalogue position as its row.
|
|
158
|
+
const tracked = catalogue.flatMap((item, row) =>
|
|
159
|
+
isPluginManagedItem(item) ? [] : [{ ...item, row }],
|
|
160
|
+
);
|
|
161
|
+
const pluginManaged = catalogue.flatMap((item, row) =>
|
|
162
|
+
isPluginManagedItem(item) ? [{ ...item, row }] : [],
|
|
163
|
+
);
|
|
139
164
|
const [active, installs] = await Promise.all([
|
|
140
165
|
// Full resolution, not `resolveBins: false`: a catalogue tool may be
|
|
141
166
|
// declared by a PLUGIN's requires.bin, and parity needs the spec the
|
|
142
167
|
// CLI plans with. `resolveBins: false` would still keep the profile's
|
|
143
168
|
// own cliTools, but lose every plugin-declared bin and its pin.
|
|
144
169
|
loadActiveClosure(projectPath),
|
|
145
|
-
mapLimited(
|
|
170
|
+
mapLimited(tracked, PROBE_CONCURRENCY, ({ tool }) =>
|
|
146
171
|
detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
|
|
147
172
|
),
|
|
148
173
|
]);
|
|
149
174
|
if (!fetchGate.isCurrent(token)) return;
|
|
150
175
|
|
|
151
|
-
const closureBins = active
|
|
152
|
-
const planned =
|
|
176
|
+
const closureBins = active ? managedBins(active.closure.bins) : null;
|
|
177
|
+
const planned = tracked.map(({ tool, owned, row }, i) => ({
|
|
153
178
|
tool,
|
|
179
|
+
owned,
|
|
180
|
+
row,
|
|
154
181
|
install: installs[i],
|
|
155
182
|
spec: specForCatalogTool(tool, closureBins, installs[i]),
|
|
156
183
|
}));
|
|
157
|
-
|
|
158
|
-
|
|
184
|
+
const rowOf = (
|
|
185
|
+
p: (typeof planned)[number],
|
|
186
|
+
facts: Parameters<typeof buildToolRow>[2],
|
|
187
|
+
): CliToolRow => ({
|
|
188
|
+
...buildToolRow(p.tool, p.spec, facts, p.install),
|
|
189
|
+
...(p.owned ? { owned: p.owned } : {}),
|
|
190
|
+
});
|
|
191
|
+
const env = machineEnv(
|
|
192
|
+
process.env.HOME || homedir(),
|
|
193
|
+
process.env.PATH ?? "",
|
|
194
|
+
process.env.SHELL ?? "",
|
|
159
195
|
);
|
|
196
|
+
const managedRowOf = (
|
|
197
|
+
m: (typeof pluginManaged)[number],
|
|
198
|
+
presence: PluginManagedRow["presence"],
|
|
199
|
+
): PluginManagedRow => ({
|
|
200
|
+
managedBy: m.entry.ownedBy,
|
|
201
|
+
entry: m.entry,
|
|
202
|
+
owned: m.owned,
|
|
203
|
+
presence,
|
|
204
|
+
installText: env ? installText(m.owned, env) : "",
|
|
205
|
+
});
|
|
206
|
+
const setRow = (row: number, value: CliToolsRow) =>
|
|
207
|
+
setAndCache((prev) => {
|
|
208
|
+
const next = [...prev];
|
|
209
|
+
next[row] = value;
|
|
210
|
+
return next;
|
|
211
|
+
});
|
|
212
|
+
setAndCache(() => {
|
|
213
|
+
const next: CliToolsRow[] = new Array(catalogue.length);
|
|
214
|
+
for (const p of planned) next[p.row] = rowOf(p, undefined);
|
|
215
|
+
for (const m of pluginManaged) next[m.row] = managedRowOf(m, null);
|
|
216
|
+
return next;
|
|
217
|
+
});
|
|
160
218
|
|
|
161
|
-
await
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const next = [...prev];
|
|
172
|
-
next[index] = row;
|
|
173
|
-
return next;
|
|
174
|
-
});
|
|
219
|
+
await Promise.all([
|
|
220
|
+
probeTools(
|
|
221
|
+
planned.map((p) => p.spec),
|
|
222
|
+
REAL_TOOL_PROBE_IO,
|
|
223
|
+
{
|
|
224
|
+
onResult: (facts, { index }) => {
|
|
225
|
+
if (!fetchGate.isCurrent(token)) return;
|
|
226
|
+
const p = planned[index];
|
|
227
|
+
if (p) setRow(p.row, rowOf(p, facts));
|
|
228
|
+
},
|
|
175
229
|
},
|
|
176
|
-
|
|
177
|
-
|
|
230
|
+
),
|
|
231
|
+
// The probe `magus doctor` runs, so the row and doctor agree.
|
|
232
|
+
(async () => {
|
|
233
|
+
if (!env || pluginManaged.length === 0) return;
|
|
234
|
+
const presences = await probeBins(
|
|
235
|
+
pluginManaged.map((m) => m.owned),
|
|
236
|
+
{ env, depth: "full", capMs: DOCTOR_CHECK_CAP_MS },
|
|
237
|
+
);
|
|
238
|
+
if (!fetchGate.isCurrent(token)) return;
|
|
239
|
+
pluginManaged.forEach((m, i) => {
|
|
240
|
+
setRow(m.row, managedRowOf(m, presences[i] ?? null));
|
|
241
|
+
});
|
|
242
|
+
})(),
|
|
243
|
+
]);
|
|
178
244
|
} catch (error) {
|
|
179
245
|
if (!fetchGate.isCurrent(token)) return;
|
|
180
246
|
setProbeError(error instanceof Error ? error.message : String(error));
|
|
@@ -219,31 +285,48 @@ export function CliToolsScreen() {
|
|
|
219
285
|
/**
|
|
220
286
|
* Run one installer command: null when it succeeded, else why it failed.
|
|
221
287
|
*
|
|
222
|
-
* `
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* child. Output is captured; the modal is the progress indicator, and on a
|
|
226
|
-
* failure the installer's own words become the popup's reason.
|
|
288
|
+
* Through `runArgv`, which captures output and never touches the terminal;
|
|
289
|
+
* the modal is the progress indicator, and on a failure the installer's own
|
|
290
|
+
* words become the popup's reason.
|
|
227
291
|
*/
|
|
228
292
|
const runCommand = async (command: Command): Promise<string | null> => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return null;
|
|
234
|
-
} catch (err) {
|
|
235
|
-
return commandFailureReason(command, err, COMMAND_TIMEOUT_MS);
|
|
236
|
-
}
|
|
293
|
+
const result = await runArgv(command, { timeoutMs: COMMAND_TIMEOUT_MS });
|
|
294
|
+
return succeeded(result)
|
|
295
|
+
? null
|
|
296
|
+
: commandFailureReason(command, result, COMMAND_TIMEOUT_MS);
|
|
237
297
|
};
|
|
238
298
|
|
|
239
299
|
const handleInstall = async () => {
|
|
240
300
|
const row = rows[cliToolsState.selectedIndex];
|
|
241
|
-
|
|
242
|
-
if (!row ||
|
|
301
|
+
// A plugin-managed row is read-only: its plugin installs it.
|
|
302
|
+
if (!row || isPluginManagedRow(row)) return;
|
|
303
|
+
const enter = cliToolEnterAction(row);
|
|
304
|
+
if (!enter) return;
|
|
243
305
|
|
|
244
306
|
const { tool } = row;
|
|
245
307
|
const { command, progress: action } = enter;
|
|
246
308
|
|
|
309
|
+
// A tool a plugin owns installs from that plugin's declaration, through
|
|
310
|
+
// the same plan and executor as `magus doctor --fix`.
|
|
311
|
+
if (row.owned && enter.kind === "install") {
|
|
312
|
+
try {
|
|
313
|
+
await installBinsInTui(tool.displayName, [row.owned], modal);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
modal.hideModal();
|
|
316
|
+
await showFailures(
|
|
317
|
+
cliToolFailureReport(`${tool.displayName} was not installed`, [
|
|
318
|
+
{
|
|
319
|
+
tool: tool.displayName,
|
|
320
|
+
command,
|
|
321
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
322
|
+
},
|
|
323
|
+
]),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
handleRefresh();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
247
330
|
modal.loading(`${action} ${tool.displayName}...`);
|
|
248
331
|
const reason = await runCommand(command);
|
|
249
332
|
modal.hideModal();
|
|
@@ -276,8 +359,9 @@ export function CliToolsScreen() {
|
|
|
276
359
|
*/
|
|
277
360
|
const handleResolveConflict = async () => {
|
|
278
361
|
const row = rows[cliToolsState.selectedIndex];
|
|
279
|
-
|
|
280
|
-
|
|
362
|
+
if (!row || isPluginManagedRow(row)) return;
|
|
363
|
+
const install: InstallInfo | undefined = row.install;
|
|
364
|
+
if (!install || install.all.length < 2) return;
|
|
281
365
|
|
|
282
366
|
const { tool } = row;
|
|
283
367
|
const allMethods = install.all;
|
|
@@ -370,7 +454,10 @@ export function CliToolsScreen() {
|
|
|
370
454
|
// Only rows the shared classifier says are behind. An `unknown` is never
|
|
371
455
|
// swept up in a bulk action — that is exactly the "cannot tell, so run
|
|
372
456
|
// it anyway" the CLI no longer does either.
|
|
373
|
-
const updatable = rows.filter(
|
|
457
|
+
const updatable = rows.filter(
|
|
458
|
+
(r): r is CliToolRow =>
|
|
459
|
+
!isPluginManagedRow(r) && r.status?.state === "upgrade",
|
|
460
|
+
);
|
|
374
461
|
if (updatable.length === 0) return;
|
|
375
462
|
|
|
376
463
|
modal.loading(`Updating ${updatable.length} tool(s)...`);
|
|
@@ -417,7 +504,10 @@ export function CliToolsScreen() {
|
|
|
417
504
|
|
|
418
505
|
// The footer names what Enter does on THIS row, from the same function the
|
|
419
506
|
// detail pane and the handler read; while the row is checked it does nothing.
|
|
420
|
-
const enter =
|
|
507
|
+
const enter =
|
|
508
|
+
selectedRow && !isPluginManagedRow(selectedRow)
|
|
509
|
+
? cliToolEnterAction(selectedRow)
|
|
510
|
+
: null;
|
|
421
511
|
|
|
422
512
|
return (
|
|
423
513
|
<ScreenLayout
|
|
@@ -440,7 +530,7 @@ export function CliToolsScreen() {
|
|
|
440
530
|
selectedIndex={cliToolsState.selectedIndex}
|
|
441
531
|
renderItem={renderCliToolRow}
|
|
442
532
|
maxHeight={dimensions.listPanelHeight}
|
|
443
|
-
getKey={
|
|
533
|
+
getKey={rowName}
|
|
444
534
|
/>
|
|
445
535
|
}
|
|
446
536
|
detailPanel={(detailWidth) =>
|
|
@@ -29,11 +29,7 @@ import {
|
|
|
29
29
|
scopeFilesMissing,
|
|
30
30
|
versionInScope,
|
|
31
31
|
} from "../../services/plugin-manager.js";
|
|
32
|
-
import {
|
|
33
|
-
checkMissingDeps,
|
|
34
|
-
getPluginSetupFromSource,
|
|
35
|
-
installPluginDeps,
|
|
36
|
-
} from "../../services/plugin-setup.js";
|
|
32
|
+
import { resolvePluginBinRequirements } from "../../services/plugin-requires.js";
|
|
37
33
|
import { saveProfile } from "../../services/profiles.js";
|
|
38
34
|
import { requirersHere } from "../../services/resolver.js";
|
|
39
35
|
import {
|
|
@@ -64,6 +60,7 @@ import {
|
|
|
64
60
|
import { fuzzyFilter } from "../../utils/fuzzy-search.js";
|
|
65
61
|
import { createRequestGate } from "../../utils/request-gate.js";
|
|
66
62
|
import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
|
|
63
|
+
import { installBinsInTui } from "../adapters/depsAdapter.js";
|
|
67
64
|
import { counted, failureReport } from "../adapters/failureReport.js";
|
|
68
65
|
import {
|
|
69
66
|
type FailureVerb,
|
|
@@ -865,57 +862,24 @@ export function PluginsScreen() {
|
|
|
865
862
|
};
|
|
866
863
|
|
|
867
864
|
/**
|
|
868
|
-
* Install
|
|
865
|
+
* Install what a just-installed plugin declares in `requires` and this
|
|
866
|
+
* machine lacks — the same plan and executor as `magus doctor --fix`.
|
|
869
867
|
*/
|
|
870
868
|
const installPluginSystemDeps = async (
|
|
871
869
|
pluginName: string,
|
|
872
870
|
marketplace: string,
|
|
873
871
|
): Promise<void> => {
|
|
872
|
+
const pluginId = `${pluginName}@${marketplace}`;
|
|
874
873
|
try {
|
|
875
|
-
const
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
const missing = await checkMissingDeps(setup);
|
|
879
|
-
const hasMissing =
|
|
880
|
-
(missing.pip?.length || 0) +
|
|
881
|
-
(missing.brew?.length || 0) +
|
|
882
|
-
(missing.npm?.length || 0) +
|
|
883
|
-
(missing.cargo?.length || 0) +
|
|
884
|
-
(missing.go?.length || 0) >
|
|
885
|
-
0;
|
|
886
|
-
|
|
887
|
-
if (!hasMissing) return;
|
|
888
|
-
|
|
889
|
-
const parts: string[] = [];
|
|
890
|
-
if (missing.pip?.length) parts.push(`pip: ${missing.pip.join(", ")}`);
|
|
891
|
-
if (missing.brew?.length) parts.push(`brew: ${missing.brew.join(", ")}`);
|
|
892
|
-
if (missing.npm?.length) parts.push(`npm: ${missing.npm.join(", ")}`);
|
|
893
|
-
if (missing.cargo?.length)
|
|
894
|
-
parts.push(`cargo: ${missing.cargo.join(", ")}`);
|
|
895
|
-
if (missing.go?.length) parts.push(`go: ${missing.go.join(", ")}`);
|
|
896
|
-
|
|
897
|
-
const wantInstall = await modal.confirm(
|
|
898
|
-
"Install Dependencies?",
|
|
899
|
-
`This plugin needs system dependencies:\n\n${parts.join("\n")}\n\nInstall now?`,
|
|
874
|
+
const bins = await resolvePluginBinRequirements(
|
|
875
|
+
pluginId,
|
|
876
|
+
state.projectPath || process.cwd(),
|
|
900
877
|
);
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
const result = await installPluginDeps(missing);
|
|
906
|
-
modal.hideModal();
|
|
907
|
-
|
|
908
|
-
// Only what did not install, each with why; what did shows nowhere.
|
|
909
|
-
const report = failureReport(
|
|
910
|
-
`${counted(result.failed.length, "dependency", "dependencies")} of ${pluginName} ${
|
|
911
|
-
result.failed.length === 1 ? "was" : "were"
|
|
912
|
-
} not installed`,
|
|
913
|
-
result.failed.map((f) => ({
|
|
914
|
-
subject: f.pkg,
|
|
915
|
-
reason: exceptionReason(f.error).reason,
|
|
916
|
-
})),
|
|
878
|
+
await installBinsInTui(
|
|
879
|
+
pluginId,
|
|
880
|
+
bins.map((bin) => ({ ...bin, sources: [`plugin:${pluginId}`] })),
|
|
881
|
+
modal,
|
|
917
882
|
);
|
|
918
|
-
if (report) await modal.failures(report);
|
|
919
883
|
} catch (error) {
|
|
920
884
|
// Hide the loading modal first: it would otherwise stay up forever,
|
|
921
885
|
// and the failure behind it would never be read.
|
|
@@ -24,8 +24,8 @@ import { join } from "node:path";
|
|
|
24
24
|
* characters it contains, and `cmd` must name a real executable rather than
|
|
25
25
|
* anything PATH-plus-a-profile might have meant.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Plugin dependency installs are argv for the same reason: requires-probe
|
|
28
|
+
* renders every install step as one, and `utils/run.ts` runs it.
|
|
29
29
|
*/
|
|
30
30
|
export interface Command {
|
|
31
31
|
readonly cmd: string;
|
|
@@ -71,9 +71,8 @@ function quoteToken(token: string): string {
|
|
|
71
71
|
* Async only for its callers' sake — it spawns nothing. This used to run
|
|
72
72
|
* `execFileAsync("which", [command])`, a real `which(1)` process with no
|
|
73
73
|
* timeout, on every one of its dozen call sites. Answering "is bun on PATH?"
|
|
74
|
-
* does not need a process, and under load that process is what stalls:
|
|
75
|
-
*
|
|
76
|
-
* run the spawn queue backed up until the test failed at **20,007 ms** — after
|
|
74
|
+
* does not need a process, and under load that process is what stalls: in a
|
|
75
|
+
* 103-file parallel test run the spawn queue backed up until the test failed at **20,007 ms** — after
|
|
77
76
|
* the per-test timeout had already been raised from 5s to 20s to accommodate
|
|
78
77
|
* the earlier 5s failures. Raising the limit moved the goalposts; removing the
|
|
79
78
|
* spawn removes the stall.
|