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
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
validateAliasName,
|
|
33
33
|
withoutDerivedChannel,
|
|
34
34
|
} from "../../services/alias-store.js";
|
|
35
|
+
import { failureReport } from "../adapters/failureReport.js";
|
|
36
|
+
import { stepFailureReport } from "../adapters/startupReports.js";
|
|
35
37
|
import {
|
|
36
38
|
FlagDetailEditor,
|
|
37
39
|
type FlagDetailEditorHandle,
|
|
@@ -39,7 +41,7 @@ import {
|
|
|
39
41
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
40
42
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
41
43
|
import { moveDirection } from "../keymap.js";
|
|
42
|
-
import { useApp,
|
|
44
|
+
import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
|
|
43
45
|
import { displayWidth, truncate } from "../text-width.js";
|
|
44
46
|
import { theme } from "../theme.js";
|
|
45
47
|
|
|
@@ -114,7 +116,7 @@ type ViewMode = { kind: "main" } | { kind: "flag-detail"; flagId: string };
|
|
|
114
116
|
export function AliasScreen() {
|
|
115
117
|
const { state } = useApp();
|
|
116
118
|
const modal = useModal();
|
|
117
|
-
const
|
|
119
|
+
const showFailures = useFailurePopup();
|
|
118
120
|
|
|
119
121
|
const [config, setConfig] = useState<AliasConfig | null>(null);
|
|
120
122
|
const [shells, setShells] = useState<ShellTarget[]>([]);
|
|
@@ -127,11 +129,6 @@ export function AliasScreen() {
|
|
|
127
129
|
string,
|
|
128
130
|
FlagValue
|
|
129
131
|
> | null>(null);
|
|
130
|
-
// Transient write feedback shown on the header status line (not a blocking
|
|
131
|
-
// modal). Auto-clears after a few seconds. Errors still use a modal since
|
|
132
|
-
// they need to be read and acknowledged.
|
|
133
|
-
const [writeStatus, setWriteStatus] = useState<string | null>(null);
|
|
134
|
-
|
|
135
132
|
// A hand-written `claude` alias found OUTSIDE any managed block on mount.
|
|
136
133
|
// Set when the rc file has no managed block but a stray alias we could
|
|
137
134
|
// adopt — drives the one-time "import it?" offer below. Cleared once the
|
|
@@ -144,13 +141,6 @@ export function AliasScreen() {
|
|
|
144
141
|
[number, number] | null
|
|
145
142
|
>(null);
|
|
146
143
|
|
|
147
|
-
// Clear the write status line a few seconds after it appears.
|
|
148
|
-
useEffect(() => {
|
|
149
|
-
if (!writeStatus) return;
|
|
150
|
-
const t = setTimeout(() => setWriteStatus(null), 4000);
|
|
151
|
-
return () => clearTimeout(t);
|
|
152
|
-
}, [writeStatus]);
|
|
153
|
-
|
|
154
144
|
// One-time "adopt a hand-written alias?" offer. Fires when the mount load
|
|
155
145
|
// found a stray `claude` alias outside any managed block. We import only the
|
|
156
146
|
// flags we recognized; whether we also TAKE OWNERSHIP (remove the original
|
|
@@ -421,14 +411,12 @@ export function AliasScreen() {
|
|
|
421
411
|
try {
|
|
422
412
|
await saveAliasName(name);
|
|
423
413
|
} catch (err) {
|
|
424
|
-
await
|
|
425
|
-
"
|
|
426
|
-
err instanceof Error ? err.message : String(err),
|
|
427
|
-
"error",
|
|
414
|
+
await showFailures(
|
|
415
|
+
stepFailureReport("The alias name was not saved", name, err),
|
|
428
416
|
);
|
|
429
417
|
}
|
|
430
418
|
},
|
|
431
|
-
[
|
|
419
|
+
[showFailures],
|
|
432
420
|
);
|
|
433
421
|
|
|
434
422
|
// Cascade dependencies on every mutation:
|
|
@@ -500,12 +488,16 @@ export function AliasScreen() {
|
|
|
500
488
|
if (v === null) return;
|
|
501
489
|
const reason = validateAliasName(v);
|
|
502
490
|
if (reason) {
|
|
503
|
-
await
|
|
491
|
+
await showFailures(
|
|
492
|
+
failureReport("The alias name was not changed", [
|
|
493
|
+
{ subject: v === "" ? "(empty)" : v, reason },
|
|
494
|
+
]),
|
|
495
|
+
);
|
|
504
496
|
return;
|
|
505
497
|
}
|
|
506
498
|
setConfig((prev) => (prev ? { ...prev, aliasName: v } : prev));
|
|
507
499
|
void persistAliasName(v);
|
|
508
|
-
}, [config, modal, persistAliasName]);
|
|
500
|
+
}, [config, modal, persistAliasName, showFailures]);
|
|
509
501
|
|
|
510
502
|
const editSelected = useCallback(async () => {
|
|
511
503
|
if (!selectedRow) return;
|
|
@@ -566,10 +558,16 @@ export function AliasScreen() {
|
|
|
566
558
|
shells.find((s) => s.exists);
|
|
567
559
|
|
|
568
560
|
if (!target) {
|
|
569
|
-
// Nothing was written
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
"
|
|
561
|
+
// Nothing was written — a failure, like the write failing below. Named
|
|
562
|
+
// files, because creating one is the fix.
|
|
563
|
+
await showFailures(
|
|
564
|
+
failureReport("The alias was not written", [
|
|
565
|
+
{
|
|
566
|
+
subject: "shell config",
|
|
567
|
+
reason: "there is no shell config file to write it to",
|
|
568
|
+
fix: "Create ~/.zshrc, ~/.bashrc or ~/.config/fish/config.fish, then press w again",
|
|
569
|
+
},
|
|
570
|
+
]),
|
|
573
571
|
);
|
|
574
572
|
return;
|
|
575
573
|
}
|
|
@@ -579,7 +577,7 @@ export function AliasScreen() {
|
|
|
579
577
|
// the FIRST write replaces that line in place (absorbing it into the
|
|
580
578
|
// managed block) rather than appending a second alias. Once the block
|
|
581
579
|
// exists, the writer ignores the range and splices by markers.
|
|
582
|
-
|
|
580
|
+
await writeAliasToShell(
|
|
583
581
|
config,
|
|
584
582
|
target,
|
|
585
583
|
pendingAdoptRange ? { adoptLineRange: pendingAdoptRange } : {},
|
|
@@ -587,23 +585,20 @@ export function AliasScreen() {
|
|
|
587
585
|
// The adopted line is now gone (replaced by the block); drop the range
|
|
588
586
|
// so subsequent writes don't try to re-target a line that moved.
|
|
589
587
|
if (pendingAdoptRange) setPendingAdoptRange(null);
|
|
590
|
-
//
|
|
591
|
-
|
|
592
|
-
setWriteStatus(
|
|
593
|
-
`✓ ${r.action === "created" ? "Created" : "Updated"} ${name} — source it or open a new shell`,
|
|
594
|
-
);
|
|
588
|
+
// Written: no message. The header shows it — the snapshot now matches
|
|
589
|
+
// the in-memory flags, so "unsaved, w writes …" disappears.
|
|
595
590
|
setShells(await detectShells());
|
|
596
|
-
// We just wrote the rc file with current in-memory flag values, so
|
|
597
|
-
// the snapshot now matches in-memory state. Dirty indicator clears.
|
|
598
591
|
setRcSnapshot(config.flags);
|
|
599
592
|
} catch (err) {
|
|
600
|
-
await
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
593
|
+
await showFailures(
|
|
594
|
+
stepFailureReport(
|
|
595
|
+
"The alias was not written",
|
|
596
|
+
`${target.kind} config`,
|
|
597
|
+
err,
|
|
598
|
+
),
|
|
604
599
|
);
|
|
605
600
|
}
|
|
606
|
-
}, [config, shells,
|
|
601
|
+
}, [config, shells, pendingAdoptRange, showFailures]);
|
|
607
602
|
|
|
608
603
|
// ── Keyboard ──────────────────────────────────────────────────────────
|
|
609
604
|
// j/k navigate the selectable subset, jumping over headers/legend lines.
|
|
@@ -830,13 +825,11 @@ export function AliasScreen() {
|
|
|
830
825
|
: 0;
|
|
831
826
|
const subtitle = inDetail
|
|
832
827
|
? `editing ${detailFlag.flag}`
|
|
833
|
-
:
|
|
834
|
-
?
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
}`
|
|
839
|
-
: "";
|
|
828
|
+
: config
|
|
829
|
+
? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled${
|
|
830
|
+
dirty ? ` │ * unsaved, w writes ${writeLabel}` : ""
|
|
831
|
+
}`
|
|
832
|
+
: "";
|
|
840
833
|
|
|
841
834
|
return (
|
|
842
835
|
<ScreenLayout
|
|
@@ -1,19 +1,26 @@
|
|
|
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";
|
|
6
5
|
import { moveDirection } from "../keymap.js";
|
|
7
|
-
import { useApp,
|
|
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,15 +29,28 @@ 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
|
-
import { formatCommand } from "../../utils/command-utils.js";
|
|
27
34
|
import { createRequestGate } from "../../utils/request-gate.js";
|
|
35
|
+
import { runArgv, succeeded } from "../../utils/run.js";
|
|
28
36
|
import {
|
|
37
|
+
type CliToolCommandFailure,
|
|
29
38
|
type CliToolRow,
|
|
39
|
+
type CliToolsRow,
|
|
40
|
+
type PluginManagedRow,
|
|
30
41
|
buildToolRow,
|
|
42
|
+
cliToolConflictPartialReport,
|
|
43
|
+
cliToolConflictStuckReport,
|
|
31
44
|
cliToolEnterAction,
|
|
45
|
+
cliToolFailureReport,
|
|
46
|
+
cliToolNotDone,
|
|
47
|
+
cliToolsNotUpdatedReport,
|
|
48
|
+
commandFailureReason,
|
|
32
49
|
countToolRows,
|
|
50
|
+
isPluginManagedRow,
|
|
51
|
+
rowName,
|
|
33
52
|
} from "../adapters/cliToolsAdapter.js";
|
|
53
|
+
import { installBinsInTui } from "../adapters/depsAdapter.js";
|
|
34
54
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
35
55
|
import {
|
|
36
56
|
renderCliToolDetail,
|
|
@@ -38,7 +58,8 @@ import {
|
|
|
38
58
|
} from "../renderers/cliToolRenderers.js";
|
|
39
59
|
import { theme } from "../theme.js";
|
|
40
60
|
|
|
41
|
-
|
|
61
|
+
/** How long one installer command may run before it is stopped. */
|
|
62
|
+
const COMMAND_TIMEOUT_MS = 60_000;
|
|
42
63
|
|
|
43
64
|
// ─── Session cache ─────────────────────────────────────────────────────────────
|
|
44
65
|
//
|
|
@@ -47,7 +68,7 @@ const execFileAsync = promisify(execFile);
|
|
|
47
68
|
// used to carry a private copy: a `/bin/bash` `which`, an `npm view` per tool,
|
|
48
69
|
// its own version comparator, and a string-form `exec` of each check command.
|
|
49
70
|
|
|
50
|
-
let cachedRows:
|
|
71
|
+
let cachedRows: CliToolsRow[] | null = null;
|
|
51
72
|
let cacheInitialized = false;
|
|
52
73
|
/** Every fetch takes a token, so a refresh discards the answers of the one before. */
|
|
53
74
|
const fetchGate = createRequestGate();
|
|
@@ -57,9 +78,12 @@ function clearCliToolsCache(): void {
|
|
|
57
78
|
cacheInitialized = false;
|
|
58
79
|
}
|
|
59
80
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
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 }) =>
|
|
63
87
|
buildToolRow(tool, specForCatalogTool(tool, null, undefined), undefined),
|
|
64
88
|
);
|
|
65
89
|
}
|
|
@@ -91,10 +115,10 @@ export function CliToolsScreen() {
|
|
|
91
115
|
const { state, dispatch } = useApp();
|
|
92
116
|
const { cliTools: cliToolsState } = state;
|
|
93
117
|
const modal = useModal();
|
|
94
|
-
const
|
|
118
|
+
const showFailures = useFailurePopup();
|
|
95
119
|
const dimensions = useDimensions();
|
|
96
120
|
|
|
97
|
-
const [rows, setRows] = useState<
|
|
121
|
+
const [rows, setRows] = useState<CliToolsRow[]>(
|
|
98
122
|
() => cachedRows || initialRows(),
|
|
99
123
|
);
|
|
100
124
|
/** Why the last check could not run, shown under the header; null when it ran. */
|
|
@@ -104,7 +128,7 @@ export function CliToolsScreen() {
|
|
|
104
128
|
rowsRef.current = rows;
|
|
105
129
|
|
|
106
130
|
const setAndCache = useCallback(
|
|
107
|
-
(update: (prev:
|
|
131
|
+
(update: (prev: CliToolsRow[]) => CliToolsRow[]) => {
|
|
108
132
|
setRows((prev) => {
|
|
109
133
|
const next = update(prev);
|
|
110
134
|
cachedRows = next;
|
|
@@ -128,45 +152,95 @@ export function CliToolsScreen() {
|
|
|
128
152
|
setProbeError(null);
|
|
129
153
|
try {
|
|
130
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
|
+
);
|
|
131
164
|
const [active, installs] = await Promise.all([
|
|
132
165
|
// Full resolution, not `resolveBins: false`: a catalogue tool may be
|
|
133
166
|
// declared by a PLUGIN's requires.bin, and parity needs the spec the
|
|
134
167
|
// CLI plans with. `resolveBins: false` would still keep the profile's
|
|
135
168
|
// own cliTools, but lose every plugin-declared bin and its pin.
|
|
136
169
|
loadActiveClosure(projectPath),
|
|
137
|
-
mapLimited(
|
|
170
|
+
mapLimited(tracked, PROBE_CONCURRENCY, ({ tool }) =>
|
|
138
171
|
detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
|
|
139
172
|
),
|
|
140
173
|
]);
|
|
141
174
|
if (!fetchGate.isCurrent(token)) return;
|
|
142
175
|
|
|
143
|
-
const closureBins = active
|
|
144
|
-
const planned =
|
|
176
|
+
const closureBins = active ? managedBins(active.closure.bins) : null;
|
|
177
|
+
const planned = tracked.map(({ tool, owned, row }, i) => ({
|
|
145
178
|
tool,
|
|
179
|
+
owned,
|
|
180
|
+
row,
|
|
146
181
|
install: installs[i],
|
|
147
182
|
spec: specForCatalogTool(tool, closureBins, installs[i]),
|
|
148
183
|
}));
|
|
149
|
-
|
|
150
|
-
|
|
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 ?? "",
|
|
151
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
|
+
});
|
|
152
218
|
|
|
153
|
-
await
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const next = [...prev];
|
|
164
|
-
next[index] = row;
|
|
165
|
-
return next;
|
|
166
|
-
});
|
|
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
|
+
},
|
|
167
229
|
},
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
]);
|
|
170
244
|
} catch (error) {
|
|
171
245
|
if (!fetchGate.isCurrent(token)) return;
|
|
172
246
|
setProbeError(error instanceof Error ? error.message : String(error));
|
|
@@ -209,47 +283,64 @@ export function CliToolsScreen() {
|
|
|
209
283
|
};
|
|
210
284
|
|
|
211
285
|
/**
|
|
212
|
-
* Run one installer command
|
|
286
|
+
* Run one installer command: null when it succeeded, else why it failed.
|
|
213
287
|
*
|
|
214
|
-
* `
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* child. Output is captured and discarded here; the modal is the progress
|
|
218
|
-
* indicator.
|
|
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.
|
|
219
291
|
*/
|
|
220
|
-
const runCommand = async (
|
|
221
|
-
command:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return { ok: true };
|
|
226
|
-
} catch (err) {
|
|
227
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
228
|
-
return { ok: false, error: msg };
|
|
229
|
-
}
|
|
292
|
+
const runCommand = async (command: Command): Promise<string | null> => {
|
|
293
|
+
const result = await runArgv(command, { timeoutMs: COMMAND_TIMEOUT_MS });
|
|
294
|
+
return succeeded(result)
|
|
295
|
+
? null
|
|
296
|
+
: commandFailureReason(command, result, COMMAND_TIMEOUT_MS);
|
|
230
297
|
};
|
|
231
298
|
|
|
232
299
|
const handleInstall = async () => {
|
|
233
300
|
const row = rows[cliToolsState.selectedIndex];
|
|
234
|
-
|
|
235
|
-
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;
|
|
236
305
|
|
|
237
306
|
const { tool } = row;
|
|
238
307
|
const { command, progress: action } = enter;
|
|
239
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
|
+
|
|
240
330
|
modal.loading(`${action} ${tool.displayName}...`);
|
|
241
|
-
const
|
|
331
|
+
const reason = await runCommand(command);
|
|
242
332
|
modal.hideModal();
|
|
243
333
|
|
|
244
|
-
if (
|
|
334
|
+
if (reason === null) {
|
|
245
335
|
handleRefresh();
|
|
246
|
-
|
|
247
|
-
await modal.message(
|
|
248
|
-
"Error",
|
|
249
|
-
`Failed to ${action.toLowerCase()} ${tool.displayName}.\n\nTry running manually:\n${formatCommand(command)}`,
|
|
250
|
-
"error",
|
|
251
|
-
);
|
|
336
|
+
return;
|
|
252
337
|
}
|
|
338
|
+
await showFailures(
|
|
339
|
+
cliToolFailureReport(
|
|
340
|
+
`${tool.displayName} was not ${cliToolNotDone(enter.kind)}`,
|
|
341
|
+
[{ tool: tool.displayName, command, reason }],
|
|
342
|
+
),
|
|
343
|
+
);
|
|
253
344
|
};
|
|
254
345
|
|
|
255
346
|
/**
|
|
@@ -268,8 +359,9 @@ export function CliToolsScreen() {
|
|
|
268
359
|
*/
|
|
269
360
|
const handleResolveConflict = async () => {
|
|
270
361
|
const row = rows[cliToolsState.selectedIndex];
|
|
271
|
-
|
|
272
|
-
|
|
362
|
+
if (!row || isPluginManagedRow(row)) return;
|
|
363
|
+
const install: InstallInfo | undefined = row.install;
|
|
364
|
+
if (!install || install.all.length < 2) return;
|
|
273
365
|
|
|
274
366
|
const { tool } = row;
|
|
275
367
|
const allMethods = install.all;
|
|
@@ -285,6 +377,11 @@ export function CliToolsScreen() {
|
|
|
285
377
|
methods
|
|
286
378
|
.map((m) => `The ${m} install stays: ${blockers.get(m)}.`)
|
|
287
379
|
.join("\n");
|
|
380
|
+
const blockerRows = (methods: readonly InstallMethod[]) =>
|
|
381
|
+
methods.map((method) => ({
|
|
382
|
+
method,
|
|
383
|
+
reason: blockers.get(method) ?? "magus cannot remove it",
|
|
384
|
+
}));
|
|
288
385
|
|
|
289
386
|
// One choice per install that can be KEPT — but only where keeping it
|
|
290
387
|
// removes something. "Keep npm" beside an unremovable native copy would
|
|
@@ -298,10 +395,8 @@ export function CliToolsScreen() {
|
|
|
298
395
|
}));
|
|
299
396
|
|
|
300
397
|
if (options.length === 0) {
|
|
301
|
-
await
|
|
302
|
-
|
|
303
|
-
`Installed via ${allMethods.join(" + ")}, and magus can remove none of them.\n\n${stuckList(allMethods)}\n\nRemove one by hand, then press r.`,
|
|
304
|
-
"error",
|
|
398
|
+
await showFailures(
|
|
399
|
+
cliToolConflictStuckReport(tool.displayName, blockerRows(allMethods)),
|
|
305
400
|
);
|
|
306
401
|
return;
|
|
307
402
|
}
|
|
@@ -319,42 +414,37 @@ export function CliToolsScreen() {
|
|
|
319
414
|
const leftBehind = stuck.filter((m) => m !== keep);
|
|
320
415
|
modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
|
|
321
416
|
|
|
322
|
-
const
|
|
417
|
+
const failed: Array<{
|
|
418
|
+
method: InstallMethod;
|
|
419
|
+
command: Command;
|
|
420
|
+
reason: string;
|
|
421
|
+
}> = [];
|
|
422
|
+
const unremovable = blockerRows(leftBehind);
|
|
323
423
|
for (const method of toRemove) {
|
|
324
424
|
const cmd = cliToolUninstall(tool, method, install.brewFormula);
|
|
325
425
|
// Unreachable: `removable` was built from this same call. Reported
|
|
326
426
|
// rather than skipped, because a silent skip is the defect above.
|
|
327
427
|
if (!cmd) {
|
|
328
|
-
|
|
428
|
+
unremovable.push({
|
|
429
|
+
method,
|
|
430
|
+
reason: "magus has no uninstall command for it",
|
|
431
|
+
});
|
|
329
432
|
continue;
|
|
330
433
|
}
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
434
|
+
const reason = await runCommand(cmd);
|
|
435
|
+
if (reason !== null) failed.push({ method, command: cmd, reason });
|
|
333
436
|
}
|
|
334
437
|
|
|
335
438
|
modal.hideModal();
|
|
336
439
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// failed, and the row still shows the conflict until it is dealt with.
|
|
345
|
-
if (errors.length > 0) {
|
|
346
|
-
await modal.message(
|
|
347
|
-
"Partly removed",
|
|
348
|
-
[
|
|
349
|
-
`These removals failed. Run them by hand:\n${errors.join("\n")}`,
|
|
350
|
-
stays,
|
|
351
|
-
]
|
|
352
|
-
.filter((part) => part !== "")
|
|
353
|
-
.join("\n\n"),
|
|
354
|
-
"error",
|
|
440
|
+
// A failed removal goes into the failure popup with the command to run,
|
|
441
|
+
// and so does an install left behind beside it. An install magus could
|
|
442
|
+
// never remove is not, on its own, a failure: nothing failed, and the row
|
|
443
|
+
// still shows the conflict until it is dealt with.
|
|
444
|
+
if (failed.length > 0 || unremovable.length > leftBehind.length) {
|
|
445
|
+
await showFailures(
|
|
446
|
+
cliToolConflictPartialReport(tool.displayName, failed, unremovable),
|
|
355
447
|
);
|
|
356
|
-
} else if (stays !== "") {
|
|
357
|
-
notify(stays.replace(/\n/g, " "), "warning");
|
|
358
448
|
}
|
|
359
449
|
|
|
360
450
|
handleRefresh();
|
|
@@ -364,20 +454,28 @@ export function CliToolsScreen() {
|
|
|
364
454
|
// Only rows the shared classifier says are behind. An `unknown` is never
|
|
365
455
|
// swept up in a bulk action — that is exactly the "cannot tell, so run
|
|
366
456
|
// it anyway" the CLI no longer does either.
|
|
367
|
-
const updatable = rows.filter(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
457
|
+
const updatable = rows.filter(
|
|
458
|
+
(r): r is CliToolRow =>
|
|
459
|
+
!isPluginManagedRow(r) && r.status?.state === "upgrade",
|
|
460
|
+
);
|
|
461
|
+
if (updatable.length === 0) return;
|
|
372
462
|
|
|
373
463
|
modal.loading(`Updating ${updatable.length} tool(s)...`);
|
|
374
464
|
|
|
465
|
+
// Every failure is kept: the reload shows the tool still behind, and
|
|
466
|
+
// only this says why.
|
|
467
|
+
const failed: CliToolCommandFailure[] = [];
|
|
375
468
|
for (const row of updatable) {
|
|
376
|
-
|
|
469
|
+
const command = row.updateCommand ?? row.installCommand;
|
|
470
|
+
const reason = await runCommand(command);
|
|
471
|
+
if (reason !== null) {
|
|
472
|
+
failed.push({ tool: row.tool.displayName, command, reason });
|
|
473
|
+
}
|
|
377
474
|
}
|
|
378
475
|
|
|
379
476
|
modal.hideModal();
|
|
380
477
|
handleRefresh();
|
|
478
|
+
await showFailures(cliToolsNotUpdatedReport(failed));
|
|
381
479
|
};
|
|
382
480
|
|
|
383
481
|
const selectedRow = rows[cliToolsState.selectedIndex];
|
|
@@ -406,7 +504,10 @@ export function CliToolsScreen() {
|
|
|
406
504
|
|
|
407
505
|
// The footer names what Enter does on THIS row, from the same function the
|
|
408
506
|
// detail pane and the handler read; while the row is checked it does nothing.
|
|
409
|
-
const enter =
|
|
507
|
+
const enter =
|
|
508
|
+
selectedRow && !isPluginManagedRow(selectedRow)
|
|
509
|
+
? cliToolEnterAction(selectedRow)
|
|
510
|
+
: null;
|
|
410
511
|
|
|
411
512
|
return (
|
|
412
513
|
<ScreenLayout
|
|
@@ -429,7 +530,7 @@ export function CliToolsScreen() {
|
|
|
429
530
|
selectedIndex={cliToolsState.selectedIndex}
|
|
430
531
|
renderItem={renderCliToolRow}
|
|
431
532
|
maxHeight={dimensions.listPanelHeight}
|
|
432
|
-
getKey={
|
|
533
|
+
getKey={rowName}
|
|
433
534
|
/>
|
|
434
535
|
}
|
|
435
536
|
detailPanel={(detailWidth) =>
|