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
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The final fixes before magus 7.8.0 — one describe per item (B1–B3, R1, R2,
|
|
3
|
+
* R4, R6, R7, R8). Pure builders are tested directly; screen parts are
|
|
4
|
+
* rendered into a character frame and read the way a user reads them. Every
|
|
5
|
+
* describe carries a negative control: the same input minus the one fact the
|
|
6
|
+
* fix keys on, which must NOT produce the new behaviour.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
11
|
+
import os, { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
14
|
+
import { type ReactNode, act } from "react";
|
|
15
|
+
import { setColorEnabled, stripAnsi } from "../cli/ansi.js";
|
|
16
|
+
import { planReport, summaryLine } from "../cli/update-view.js";
|
|
17
|
+
import { claudeFailureMessage } from "../services/claude-cli.js";
|
|
18
|
+
import {
|
|
19
|
+
type PluginEnvNeedsDeps,
|
|
20
|
+
missingPluginEnvVars,
|
|
21
|
+
} from "../services/plugin-env-needs.js";
|
|
22
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
23
|
+
import type { ActivationResult } from "../services/profile-activate.js";
|
|
24
|
+
import { requiredByHere, requirersHere } from "../services/resolver.js";
|
|
25
|
+
import { SettingsParseError } from "../services/settings-file.js";
|
|
26
|
+
import {
|
|
27
|
+
type PluginUpdateItem,
|
|
28
|
+
type UpdatePlan,
|
|
29
|
+
classifyPlugin,
|
|
30
|
+
indexCatalog,
|
|
31
|
+
isKeptDeprecated,
|
|
32
|
+
planIsBehind,
|
|
33
|
+
planPluginUpdates,
|
|
34
|
+
summarizePlan,
|
|
35
|
+
} from "../services/update-plan.js";
|
|
36
|
+
import {
|
|
37
|
+
RETRY_TIMEOUT_FIX,
|
|
38
|
+
cliToolFailureReport,
|
|
39
|
+
commandFailureReason,
|
|
40
|
+
} from "../ui/adapters/cliToolsAdapter.js";
|
|
41
|
+
import { mergeFailureReports } from "../ui/adapters/failureReport.js";
|
|
42
|
+
import {
|
|
43
|
+
INSTALL_CLAUDE_FIX,
|
|
44
|
+
type PluginPluginItem,
|
|
45
|
+
RETRY_NETWORK_FIX,
|
|
46
|
+
} from "../ui/adapters/pluginsAdapter.js";
|
|
47
|
+
import {
|
|
48
|
+
FIX_BY_HAND,
|
|
49
|
+
PROFILE_STARTUP_HEADLINE,
|
|
50
|
+
profileStartupReport,
|
|
51
|
+
stepFailureReport,
|
|
52
|
+
} from "../ui/adapters/startupReports.js";
|
|
53
|
+
import { nextStartupStep } from "../ui/hooks/startupQueue.js";
|
|
54
|
+
import {
|
|
55
|
+
renderMcpRegistryRow,
|
|
56
|
+
renderMcpRegistryStatus,
|
|
57
|
+
} from "../ui/renderers/mcpRenderers.js";
|
|
58
|
+
import { renderPluginDetail } from "../ui/renderers/pluginRenderers.js";
|
|
59
|
+
import { pluginRowText } from "../ui/renderers/pluginRowText.js";
|
|
60
|
+
import { pluginStatusText } from "../ui/renderers/pluginStatusText.js";
|
|
61
|
+
import type { FailureReport } from "../ui/state/types.js";
|
|
62
|
+
import type { RunResult } from "../utils/run.js";
|
|
63
|
+
import { userFacingPath } from "../utils/user-path.js";
|
|
64
|
+
|
|
65
|
+
// ── builders ────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
|
|
68
|
+
const [name = over.id, marketplace = ""] = over.id.split("@");
|
|
69
|
+
return {
|
|
70
|
+
name,
|
|
71
|
+
version: null,
|
|
72
|
+
description: "",
|
|
73
|
+
marketplace,
|
|
74
|
+
marketplaceDisplay: marketplace,
|
|
75
|
+
...over,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Listed by `magus` — proof that marketplace was read. */
|
|
80
|
+
const LISTED = info({
|
|
81
|
+
id: "dev@magus",
|
|
82
|
+
version: "9.0.0",
|
|
83
|
+
projectScope: { installed: true, version: "9.0.0" },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** Installed here, and no longer listed by a marketplace we read. */
|
|
87
|
+
function orphan(id: string): PluginInfo {
|
|
88
|
+
return info({
|
|
89
|
+
id,
|
|
90
|
+
version: "1.0.0",
|
|
91
|
+
installedVersion: "1.0.0",
|
|
92
|
+
isOrphaned: true,
|
|
93
|
+
projectScope: { installed: true, version: "1.0.0" },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function plan(plugins: PluginUpdateItem[]): UpdatePlan {
|
|
98
|
+
return {
|
|
99
|
+
profileId: "team",
|
|
100
|
+
plugins,
|
|
101
|
+
bins: [],
|
|
102
|
+
skills: [],
|
|
103
|
+
binSpecs: new Map(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function frameOf(
|
|
108
|
+
node: ReactNode,
|
|
109
|
+
width = 100,
|
|
110
|
+
height = 30,
|
|
111
|
+
): Promise<string> {
|
|
112
|
+
const { captureCharFrame, renderOnce, renderer } = await testRender(
|
|
113
|
+
<box flexDirection="column">{node}</box>,
|
|
114
|
+
{ width, height },
|
|
115
|
+
);
|
|
116
|
+
await act(async () => {
|
|
117
|
+
await renderOnce();
|
|
118
|
+
await renderOnce();
|
|
119
|
+
});
|
|
120
|
+
const frame = captureCharFrame();
|
|
121
|
+
await act(async () => renderer.destroy());
|
|
122
|
+
return frame;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** An absolute path to a binary, anywhere in a string. */
|
|
126
|
+
const BINARY_PATH = /(^|[\s'"(])\/[^\s'"]*\/bin\/[\w.-]+/;
|
|
127
|
+
|
|
128
|
+
// ── B1 / B2 ──────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
describe("B1/B2: installer failures keep their own fix and never print a binary path", () => {
|
|
131
|
+
const ABS_NPM = { cmd: "/usr/local/bin/npm", args: ["install", "-g", "x"] };
|
|
132
|
+
|
|
133
|
+
function execFailed(stderr: string, code: number | null = 1): RunResult {
|
|
134
|
+
return {
|
|
135
|
+
exitCode: code,
|
|
136
|
+
signal: null,
|
|
137
|
+
timedOut: false,
|
|
138
|
+
outputTail: stderr,
|
|
139
|
+
durationMs: 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
test("the spec's two examples read without the path", () => {
|
|
144
|
+
expect(
|
|
145
|
+
commandFailureReason(
|
|
146
|
+
ABS_NPM,
|
|
147
|
+
{
|
|
148
|
+
exitCode: null,
|
|
149
|
+
signal: null,
|
|
150
|
+
timedOut: false,
|
|
151
|
+
spawnError: `spawn ${ABS_NPM.cmd} ENOENT`,
|
|
152
|
+
outputTail: "",
|
|
153
|
+
durationMs: 0,
|
|
154
|
+
},
|
|
155
|
+
60_000,
|
|
156
|
+
),
|
|
157
|
+
).toBe("npm is not installed on this machine");
|
|
158
|
+
expect(commandFailureReason(ABS_NPM, execFailed(""), 60_000)).toBe(
|
|
159
|
+
"npm exited with code 1",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a missing claude by absolute path is the claude-not-found wording, with the install fix", () => {
|
|
164
|
+
const cmd = { cmd: "/Users/someone/.local/bin/claude", args: ["update"] };
|
|
165
|
+
const reason = commandFailureReason(
|
|
166
|
+
cmd,
|
|
167
|
+
{
|
|
168
|
+
exitCode: null,
|
|
169
|
+
signal: null,
|
|
170
|
+
timedOut: false,
|
|
171
|
+
spawnError: `spawn ${cmd.cmd} ENOENT`,
|
|
172
|
+
outputTail: "",
|
|
173
|
+
durationMs: 0,
|
|
174
|
+
},
|
|
175
|
+
60_000,
|
|
176
|
+
);
|
|
177
|
+
expect(reason).toBe("claude CLI not found in PATH");
|
|
178
|
+
const report = cliToolFailureReport("Claude Code was not updated", [
|
|
179
|
+
{ tool: "Claude Code", command: cmd, reason },
|
|
180
|
+
]);
|
|
181
|
+
expect(report?.fix).toBe(INSTALL_CLAUDE_FIX);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("an installer's own output that names a binary path loses the path", () => {
|
|
185
|
+
const reason = commandFailureReason(
|
|
186
|
+
ABS_NPM,
|
|
187
|
+
execFailed("sh: /opt/homebrew/bin/node: bad CPU type in executable"),
|
|
188
|
+
60_000,
|
|
189
|
+
);
|
|
190
|
+
expect(reason).toContain("node: bad CPU type");
|
|
191
|
+
expect(reason).not.toMatch(BINARY_PATH);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("timeout, network: each keeps its own fix", () => {
|
|
195
|
+
const timeout = cliToolFailureReport("x was not updated", [
|
|
196
|
+
{
|
|
197
|
+
tool: "x",
|
|
198
|
+
command: ABS_NPM,
|
|
199
|
+
reason: "did not finish within 60 seconds",
|
|
200
|
+
},
|
|
201
|
+
]);
|
|
202
|
+
expect(timeout?.fix).toBe(RETRY_TIMEOUT_FIX);
|
|
203
|
+
const network = cliToolFailureReport("x was not updated", [
|
|
204
|
+
{
|
|
205
|
+
tool: "x",
|
|
206
|
+
command: ABS_NPM,
|
|
207
|
+
reason:
|
|
208
|
+
"network request to https://registry.test failed, reason: getaddrinfo ENOTFOUND",
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
expect(network?.fix).toBe(RETRY_NETWORK_FIX);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("NEGATIVE CONTROL: an ordinary failure still offers the command to run by hand", () => {
|
|
215
|
+
const report = cliToolFailureReport("x was not installed", [
|
|
216
|
+
{ tool: "x", command: ABS_NPM, reason: "permission denied" },
|
|
217
|
+
]);
|
|
218
|
+
expect(report?.fix).toBe(
|
|
219
|
+
"Run it yourself: /usr/local/bin/npm install -g x",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── B3 ───────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
describe("B3: a settings file that does not parse is named the way the user knows it", () => {
|
|
227
|
+
const home = os.homedir();
|
|
228
|
+
|
|
229
|
+
test("USER settings → ~/.claude/settings.json; the reason carries no absolute path", () => {
|
|
230
|
+
const file = join(home, ".claude", "settings.json");
|
|
231
|
+
const report = stepFailureReport(
|
|
232
|
+
"The profile was not switched",
|
|
233
|
+
"profile work",
|
|
234
|
+
new SettingsParseError(
|
|
235
|
+
file,
|
|
236
|
+
new SyntaxError(`JSON Parse error in ${file}: Expected '}'`),
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
expect(report.rows).toHaveLength(1);
|
|
240
|
+
expect(report.rows[0]?.subject).toBe("~/.claude/settings.json");
|
|
241
|
+
expect(report.rows[0]?.reason).toContain("is not valid JSON");
|
|
242
|
+
expect(report.rows[0]?.reason).not.toContain(home);
|
|
243
|
+
expect(report.fix).toBe(FIX_BY_HAND);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("a project's file → relative to the project", () => {
|
|
247
|
+
expect(
|
|
248
|
+
userFacingPath(
|
|
249
|
+
"/work/app/.claude/settings.local.json",
|
|
250
|
+
"/work/app",
|
|
251
|
+
"/home/u",
|
|
252
|
+
),
|
|
253
|
+
).toBe(".claude/settings.local.json");
|
|
254
|
+
// Another project's file still reads from its `.claude/` on.
|
|
255
|
+
expect(
|
|
256
|
+
userFacingPath(
|
|
257
|
+
"/tmp/other/.claude/settings.json",
|
|
258
|
+
"/work/app",
|
|
259
|
+
"/home/u",
|
|
260
|
+
),
|
|
261
|
+
).toBe(".claude/settings.json");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("a spawn-shaped missing claude is recognised, with the install fix", () => {
|
|
265
|
+
const report = stepFailureReport(
|
|
266
|
+
"Plugins were not refreshed",
|
|
267
|
+
"magus",
|
|
268
|
+
Object.assign(new Error("spawn /usr/local/bin/claude ENOENT"), {
|
|
269
|
+
code: "ENOENT",
|
|
270
|
+
}),
|
|
271
|
+
"Run it yourself: claude plugin marketplace update magus",
|
|
272
|
+
);
|
|
273
|
+
expect(report.rows[0]?.reason).toBe("claude CLI not found in PATH");
|
|
274
|
+
expect(report.fix).toBe(INSTALL_CLAUDE_FIX);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("NEGATIVE CONTROL: a file-not-found ENOENT is not read as a missing claude", () => {
|
|
278
|
+
const report = stepFailureReport(
|
|
279
|
+
"Nothing was saved",
|
|
280
|
+
"styles",
|
|
281
|
+
new Error("ENOENT: no such file or directory, open 'x.json'"),
|
|
282
|
+
);
|
|
283
|
+
expect(report.rows[0]?.reason).toContain("no such file");
|
|
284
|
+
expect(report.fix).toBeNull();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── R1 ───────────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
describe("R1: a server added from the registry shows as installed, needing a restart", () => {
|
|
291
|
+
const server = {
|
|
292
|
+
name: "io.example/docs",
|
|
293
|
+
short_description: "docs",
|
|
294
|
+
version: "1.2.0",
|
|
295
|
+
url: "https://example.invalid/mcp",
|
|
296
|
+
} as never;
|
|
297
|
+
|
|
298
|
+
test("its row and detail carry installed and the restart", async () => {
|
|
299
|
+
const item = { server, isInstalled: true, needsRestart: true };
|
|
300
|
+
const frame = await frameOf(
|
|
301
|
+
<>
|
|
302
|
+
{renderMcpRegistryRow(item, 0, false)}
|
|
303
|
+
{renderMcpRegistryRow(item, 0, true)}
|
|
304
|
+
{renderMcpRegistryStatus(item)}
|
|
305
|
+
</>,
|
|
306
|
+
);
|
|
307
|
+
const lines = frame.split("\n");
|
|
308
|
+
expect(lines[0]).toContain(
|
|
309
|
+
"● io.example/docs v1.2.0 ↻ restart Claude Code",
|
|
310
|
+
);
|
|
311
|
+
expect(lines[1]).toContain(
|
|
312
|
+
"● io.example/docs v1.2.0 ↻ restart Claude Code",
|
|
313
|
+
);
|
|
314
|
+
expect(frame).toContain("● Installed");
|
|
315
|
+
expect(frame).toContain("after restarting Claude Code");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("NEGATIVE CONTROL: a server not touched this session says neither", async () => {
|
|
319
|
+
const item = { server, isInstalled: false, needsRestart: false };
|
|
320
|
+
const frame = await frameOf(
|
|
321
|
+
<>
|
|
322
|
+
{renderMcpRegistryRow(item, 0, false)}
|
|
323
|
+
{renderMcpRegistryStatus(item)}
|
|
324
|
+
</>,
|
|
325
|
+
);
|
|
326
|
+
expect(frame).not.toContain("restart");
|
|
327
|
+
expect(frame).not.toContain("●");
|
|
328
|
+
expect(frame).toContain("○ Not installed");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ── R2 ───────────────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
describe("R2: deprecated plugins that require each other are removed in one pass", () => {
|
|
335
|
+
const action = (items: PluginUpdateItem[], id: string) =>
|
|
336
|
+
items.find((i) => i.pluginId === id)?.action;
|
|
337
|
+
|
|
338
|
+
test("a chain A → B, both deprecated: both removed", () => {
|
|
339
|
+
const items = planPluginUpdates(
|
|
340
|
+
{},
|
|
341
|
+
[LISTED, orphan("a@magus"), orphan("b@magus")],
|
|
342
|
+
{ "b@magus": ["a@magus"] },
|
|
343
|
+
);
|
|
344
|
+
expect(action(items, "a@magus")).toBe("remove");
|
|
345
|
+
expect(action(items, "b@magus")).toBe("remove");
|
|
346
|
+
expect(items.find((i) => i.pluginId === "b@magus")?.scopes).toEqual([
|
|
347
|
+
"project",
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("a cycle A ↔ B, both deprecated: both removed", () => {
|
|
352
|
+
const items = planPluginUpdates(
|
|
353
|
+
{},
|
|
354
|
+
[LISTED, orphan("a@magus"), orphan("b@magus")],
|
|
355
|
+
{ "a@magus": ["b@magus"], "b@magus": ["a@magus"] },
|
|
356
|
+
);
|
|
357
|
+
expect(action(items, "a@magus")).toBe("remove");
|
|
358
|
+
expect(action(items, "b@magus")).toBe("remove");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("`--check` right after the removal is clean", () => {
|
|
362
|
+
// What the apply leaves behind: neither installed, and the profile no
|
|
363
|
+
// longer naming them. The edges went with the plugins' own plugin.json.
|
|
364
|
+
const after = planPluginUpdates({ "dev@magus": "latest" }, [LISTED], {});
|
|
365
|
+
expect(planIsBehind(plan(after))).toBe(false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("a requirer that stays keeps its dependency, and the note names THAT requirer", () => {
|
|
369
|
+
// b is required by a (deprecated, removed) and by dev (listed, stays).
|
|
370
|
+
const items = planPluginUpdates(
|
|
371
|
+
{},
|
|
372
|
+
[LISTED, orphan("a@magus"), orphan("b@magus")],
|
|
373
|
+
{ "b@magus": ["a@magus", "dev@magus"] },
|
|
374
|
+
);
|
|
375
|
+
expect(action(items, "a@magus")).toBe("remove");
|
|
376
|
+
const b = items.find((i) => i.pluginId === "b@magus");
|
|
377
|
+
expect(b).toMatchObject({ action: "unknown", reason: "still-required" });
|
|
378
|
+
expect(b?.note).toBe("no longer published, but dev still requires it");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("NEGATIVE CONTROL: judged one at a time, the chain's dependency is kept", () => {
|
|
382
|
+
const index = indexCatalog([LISTED, orphan("a@magus"), orphan("b@magus")]);
|
|
383
|
+
expect(
|
|
384
|
+
classifyPlugin("b@magus", "latest", index, { "b@magus": ["a@magus"] })
|
|
385
|
+
.reason,
|
|
386
|
+
).toBe("still-required");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("requirersHere records every requirer, the profile's first", async () => {
|
|
390
|
+
const edges = await requirersHere(
|
|
391
|
+
{ "b@magus": "p@magus" },
|
|
392
|
+
["a@magus", "c@magus"],
|
|
393
|
+
"/tmp/r2",
|
|
394
|
+
async (id) => (id === "a@magus" || id === "c@magus" ? ["b@magus"] : []),
|
|
395
|
+
);
|
|
396
|
+
expect(edges).toEqual({ "b@magus": ["p@magus", "a@magus", "c@magus"] });
|
|
397
|
+
// The single-requirer view is unchanged: the profile's wins.
|
|
398
|
+
expect(
|
|
399
|
+
await requiredByHere(
|
|
400
|
+
{ "b@magus": "p@magus" },
|
|
401
|
+
["a@magus", "c@magus"],
|
|
402
|
+
"/tmp/r2",
|
|
403
|
+
async (id) => (id === "a@magus" || id === "c@magus" ? ["b@magus"] : []),
|
|
404
|
+
),
|
|
405
|
+
).toEqual({ "b@magus": "p@magus" });
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ── R4 ───────────────────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
describe("R4: an MCP server that still lacks variables is lasting state on the plugin", () => {
|
|
412
|
+
function deps(configured: Record<string, string>): PluginEnvNeedsDeps {
|
|
413
|
+
return {
|
|
414
|
+
sourcePath: async () => "./plugins/figma",
|
|
415
|
+
requirements: async () => [
|
|
416
|
+
{
|
|
417
|
+
name: "FIGMA_TOKEN",
|
|
418
|
+
label: "Figma Token",
|
|
419
|
+
serverName: "figma",
|
|
420
|
+
required: true,
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
name: "FIGMA_TEAM",
|
|
424
|
+
label: "Figma Team",
|
|
425
|
+
serverName: "figma",
|
|
426
|
+
required: true,
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
configured: async () => configured,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
test("derived from current config: two unset, one set, none once both are set", async () => {
|
|
434
|
+
const env = {};
|
|
435
|
+
expect(
|
|
436
|
+
(await missingPluginEnvVars("magus", "figma", "/p", env, deps({})))
|
|
437
|
+
.length,
|
|
438
|
+
).toBe(2);
|
|
439
|
+
expect(
|
|
440
|
+
(
|
|
441
|
+
await missingPluginEnvVars(
|
|
442
|
+
"magus",
|
|
443
|
+
"figma",
|
|
444
|
+
"/p",
|
|
445
|
+
env,
|
|
446
|
+
deps({ FIGMA_TOKEN: "t" }),
|
|
447
|
+
)
|
|
448
|
+
).map((r) => r.name),
|
|
449
|
+
).toEqual(["FIGMA_TEAM"]);
|
|
450
|
+
expect(
|
|
451
|
+
await missingPluginEnvVars(
|
|
452
|
+
"magus",
|
|
453
|
+
"figma",
|
|
454
|
+
"/p",
|
|
455
|
+
{ FIGMA_TEAM: "x" },
|
|
456
|
+
deps({ FIGMA_TOKEN: "t" }),
|
|
457
|
+
),
|
|
458
|
+
).toEqual([]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
function item(needed?: number): PluginPluginItem {
|
|
462
|
+
const plugin = info({
|
|
463
|
+
id: "figma@magus",
|
|
464
|
+
version: "1.0.0",
|
|
465
|
+
installedVersion: "1.0.0",
|
|
466
|
+
description: "Figma.",
|
|
467
|
+
projectScope: { installed: true, version: "1.0.0" },
|
|
468
|
+
});
|
|
469
|
+
return {
|
|
470
|
+
id: `pl:${plugin.id}`,
|
|
471
|
+
kind: "plugin",
|
|
472
|
+
label: plugin.name,
|
|
473
|
+
plugin,
|
|
474
|
+
isExpandable: false,
|
|
475
|
+
isExpanded: false,
|
|
476
|
+
status: null,
|
|
477
|
+
...(needed ? { mcpSettingsNeeded: needed } : {}),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
test("the row carries ⚙ and the detail says how many and the key", async () => {
|
|
482
|
+
expect(pluginRowText(item(2)).text).toContain("⚙");
|
|
483
|
+
const frame = await frameOf(
|
|
484
|
+
renderPluginDetail(item(2), new Set<string>()),
|
|
485
|
+
120,
|
|
486
|
+
40,
|
|
487
|
+
);
|
|
488
|
+
expect(frame).toContain(
|
|
489
|
+
"its MCP server needs 2 settings — press e to set them",
|
|
490
|
+
);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("NEGATIVE CONTROL: nothing missing, nothing said", async () => {
|
|
494
|
+
expect(pluginRowText(item()).text).not.toContain("⚙");
|
|
495
|
+
const frame = await frameOf(
|
|
496
|
+
renderPluginDetail(item(), new Set<string>()),
|
|
497
|
+
120,
|
|
498
|
+
40,
|
|
499
|
+
);
|
|
500
|
+
expect(frame).not.toContain("MCP server needs");
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ── R6 ───────────────────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
describe("R6: moved and still-required are kept on purpose, not unverified", () => {
|
|
507
|
+
const MOVED_TO = info({
|
|
508
|
+
id: "seo@magus-marketing",
|
|
509
|
+
version: "1.1.0",
|
|
510
|
+
marketplaceDisplay: "Magus Marketing",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
function items(): PluginUpdateItem[] {
|
|
514
|
+
return planPluginUpdates(
|
|
515
|
+
{},
|
|
516
|
+
[LISTED, MOVED_TO, orphan("seo@magus"), orphan("kanban@magus")],
|
|
517
|
+
{ "kanban@magus": ["dev@magus"] },
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
beforeEach(() => setColorEnabled(false));
|
|
522
|
+
afterEach(() => setColorEnabled(true));
|
|
523
|
+
|
|
524
|
+
test("CLI: listed under Deprecated as 'kept' with the note; never under Unverified", () => {
|
|
525
|
+
const lines = planReport(plan(items())).map(stripAnsi);
|
|
526
|
+
const text = lines.join("\n");
|
|
527
|
+
expect(text).not.toContain("Unverified");
|
|
528
|
+
const deprecated = lines.findIndex((l) => /^\s*Deprecated\b/.test(l));
|
|
529
|
+
expect(deprecated).toBeGreaterThanOrEqual(0);
|
|
530
|
+
const seo = lines.find((l) => l.includes("seo@magus"));
|
|
531
|
+
const kanban = lines.find((l) => l.includes("kanban@magus"));
|
|
532
|
+
expect(seo).toContain("kept — moved to Magus Marketing");
|
|
533
|
+
expect(kanban).toContain(
|
|
534
|
+
"kept — no longer published, but dev still requires it",
|
|
535
|
+
);
|
|
536
|
+
expect(seo).not.toContain("?");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("CLI: not counted as 'could not be checked'; counted as kept", () => {
|
|
540
|
+
const counts = summarizePlan(plan(items()));
|
|
541
|
+
expect(counts.unknownPlugins).toBe(0);
|
|
542
|
+
expect(counts.unknown).toBe(0);
|
|
543
|
+
expect(counts.kept).toBe(2);
|
|
544
|
+
const line = stripAnsi(
|
|
545
|
+
summaryLine(0, 1, counts.unknown, 0, 10, 0, 2)[1] ?? "",
|
|
546
|
+
);
|
|
547
|
+
expect(line).toContain("2 kept");
|
|
548
|
+
expect(line).not.toContain("unverified");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("TUI: muted with the note; the detail says KEPT", async () => {
|
|
552
|
+
const seo = items().find(
|
|
553
|
+
(i) => i.pluginId === "seo@magus",
|
|
554
|
+
) as PluginUpdateItem;
|
|
555
|
+
expect(isKeptDeprecated(seo)).toBe(true);
|
|
556
|
+
expect(pluginStatusText(seo)).toEqual({
|
|
557
|
+
suffix: " moved to Magus Marketing",
|
|
558
|
+
tone: "muted",
|
|
559
|
+
});
|
|
560
|
+
const plugin = orphan("seo@magus");
|
|
561
|
+
const frame = await frameOf(
|
|
562
|
+
renderPluginDetail(
|
|
563
|
+
{
|
|
564
|
+
id: "pl:seo@magus",
|
|
565
|
+
kind: "plugin",
|
|
566
|
+
label: "seo",
|
|
567
|
+
plugin,
|
|
568
|
+
isExpandable: false,
|
|
569
|
+
isExpanded: false,
|
|
570
|
+
status: seo,
|
|
571
|
+
},
|
|
572
|
+
new Set<string>(),
|
|
573
|
+
),
|
|
574
|
+
120,
|
|
575
|
+
30,
|
|
576
|
+
);
|
|
577
|
+
expect(frame).toContain("KEPT");
|
|
578
|
+
expect(frame).not.toContain("UNKNOWN");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("NEGATIVE CONTROL: a marketplace that was not read is still unverified", () => {
|
|
582
|
+
const unread = planPluginUpdates({}, [orphan("x@elsewhere")]);
|
|
583
|
+
const [x] = unread;
|
|
584
|
+
expect(x).toMatchObject({
|
|
585
|
+
action: "unknown",
|
|
586
|
+
reason: "marketplace-unread",
|
|
587
|
+
});
|
|
588
|
+
expect(isKeptDeprecated(x as PluginUpdateItem)).toBe(false);
|
|
589
|
+
expect(summarizePlan(plan(unread)).unknownPlugins).toBe(1);
|
|
590
|
+
expect(pluginStatusText(x as PluginUpdateItem).tone).toBe("warning");
|
|
591
|
+
setColorEnabled(false);
|
|
592
|
+
expect(planReport(plan(unread)).map(stripAnsi).join("\n")).toContain(
|
|
593
|
+
"Unverified",
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// ── R7 ───────────────────────────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
describe("R7: a spawn in a folder that no longer exists says so", () => {
|
|
601
|
+
let dir: string;
|
|
602
|
+
beforeEach(async () => {
|
|
603
|
+
dir = await mkdtemp(join(tmpdir(), "magus-r7-"));
|
|
604
|
+
});
|
|
605
|
+
afterEach(async () => {
|
|
606
|
+
await rm(dir, { recursive: true, force: true });
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const enoent = () =>
|
|
610
|
+
Object.assign(new Error("spawn /usr/local/bin/claude ENOENT"), {
|
|
611
|
+
code: "ENOENT",
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("ENOENT with a vanished cwd names the folder, not its path", async () => {
|
|
615
|
+
const gone = join(dir, "old-worktree");
|
|
616
|
+
const message = claudeFailureMessage(
|
|
617
|
+
enoent(),
|
|
618
|
+
["plugin", "install"],
|
|
619
|
+
30_000,
|
|
620
|
+
gone,
|
|
621
|
+
);
|
|
622
|
+
expect(message).toBe("the folder old-worktree no longer exists");
|
|
623
|
+
expect(message).not.toContain(dir);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("NEGATIVE CONTROL: ENOENT with a cwd that exists is a missing claude", () => {
|
|
627
|
+
const message = claudeFailureMessage(
|
|
628
|
+
enoent(),
|
|
629
|
+
["plugin", "install"],
|
|
630
|
+
30_000,
|
|
631
|
+
dir,
|
|
632
|
+
);
|
|
633
|
+
expect(message).toMatch(/^claude CLI not found in PATH/);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// ── R8 ───────────────────────────────────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
describe("R8: one startup popup, shown before the profile question", () => {
|
|
640
|
+
function activation(over: Partial<ActivationResult> = {}): ActivationResult {
|
|
641
|
+
return {
|
|
642
|
+
profileId: "team",
|
|
643
|
+
displaced: [],
|
|
644
|
+
skills: { skills: {}, installed: [], removed: [], failed: [] },
|
|
645
|
+
conversion: {
|
|
646
|
+
converted: false,
|
|
647
|
+
managed: {},
|
|
648
|
+
restored: [],
|
|
649
|
+
conflicts: [],
|
|
650
|
+
keptAt: null,
|
|
651
|
+
},
|
|
652
|
+
tracked: [],
|
|
653
|
+
keptAt: null,
|
|
654
|
+
...over,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
const PROBLEM =
|
|
658
|
+
".claude/settings.json cannot be read (bad JSON) — fix it by hand; magus left it as it is and did not switch";
|
|
659
|
+
const UNREADABLE = activation({
|
|
660
|
+
dependencies: { derived: [], unreadable: ["mnemex@magus"], blocked: [] },
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("gate problems and activation failures merge into ONE report, each row keeping its fix", () => {
|
|
664
|
+
const report = profileStartupReport(
|
|
665
|
+
[PROBLEM],
|
|
666
|
+
[UNREADABLE],
|
|
667
|
+
) as FailureReport;
|
|
668
|
+
expect(report.headline).toBe(PROFILE_STARTUP_HEADLINE);
|
|
669
|
+
expect(report.rows.map((r) => r.subject)).toEqual([
|
|
670
|
+
".claude/settings.json",
|
|
671
|
+
"mnemex",
|
|
672
|
+
]);
|
|
673
|
+
expect(report.rows.map((r) => r.fix)).toEqual([
|
|
674
|
+
"Fix it by hand",
|
|
675
|
+
"Run magus install",
|
|
676
|
+
]);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("NEGATIVE CONTROL: only one kind keeps its own headline", () => {
|
|
680
|
+
expect(profileStartupReport([PROBLEM], [])?.headline).toBe(
|
|
681
|
+
"This project's profile was not brought up to date",
|
|
682
|
+
);
|
|
683
|
+
expect(profileStartupReport([], [UNREADABLE])?.headline).toContain(
|
|
684
|
+
'Profile "team" is active',
|
|
685
|
+
);
|
|
686
|
+
expect(profileStartupReport([], [])).toBeNull();
|
|
687
|
+
expect(mergeFailureReports("x", [null, undefined])).toBeNull();
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("a report and the profile question queued in one tick: report first, question second", () => {
|
|
691
|
+
const report = profileStartupReport(
|
|
692
|
+
[PROBLEM],
|
|
693
|
+
[UNREADABLE],
|
|
694
|
+
) as FailureReport;
|
|
695
|
+
const need = { kind: "choose" };
|
|
696
|
+
// Both arrive together — the startup effect queues the report and sets
|
|
697
|
+
// the need in the same tick.
|
|
698
|
+
const first = nextStartupStep({
|
|
699
|
+
modalOpen: false,
|
|
700
|
+
reports: [report],
|
|
701
|
+
gitignoreReady: false,
|
|
702
|
+
brokenCount: null,
|
|
703
|
+
profileNeed: need,
|
|
704
|
+
});
|
|
705
|
+
expect(first).toEqual({ kind: "report", report, rest: [] });
|
|
706
|
+
// While the popup is open nothing else is shown.
|
|
707
|
+
expect(
|
|
708
|
+
nextStartupStep({
|
|
709
|
+
modalOpen: true,
|
|
710
|
+
reports: [],
|
|
711
|
+
gitignoreReady: false,
|
|
712
|
+
brokenCount: null,
|
|
713
|
+
profileNeed: need,
|
|
714
|
+
}),
|
|
715
|
+
).toBeNull();
|
|
716
|
+
// Dismissed: the slot is free and the queue empty — now the question.
|
|
717
|
+
expect(
|
|
718
|
+
nextStartupStep({
|
|
719
|
+
modalOpen: false,
|
|
720
|
+
reports: first?.kind === "report" ? first.rest : [],
|
|
721
|
+
gitignoreReady: false,
|
|
722
|
+
brokenCount: null,
|
|
723
|
+
profileNeed: need,
|
|
724
|
+
}),
|
|
725
|
+
).toEqual({ kind: "profile", need });
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("NEGATIVE CONTROL: with no report queued the question comes straight away", () => {
|
|
729
|
+
const need = { kind: "choose" };
|
|
730
|
+
expect(
|
|
731
|
+
nextStartupStep({
|
|
732
|
+
modalOpen: false,
|
|
733
|
+
reports: [],
|
|
734
|
+
gitignoreReady: false,
|
|
735
|
+
brokenCount: null,
|
|
736
|
+
profileNeed: need,
|
|
737
|
+
}),
|
|
738
|
+
).toEqual({ kind: "profile", need });
|
|
739
|
+
});
|
|
740
|
+
});
|