magus-cli 7.7.0 → 7.8.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/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +29 -15
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind-780-cli-tools.test.ts +808 -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 +1203 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +920 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +724 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/models-screen-state.test.ts +18 -40
- 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__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +23 -17
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/doctor.ts +151 -30
- package/src/cli/install.ts +5 -6
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +27 -5
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +84 -36
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +31 -19
- 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 +95 -1
- package/src/services/update-engine.ts +326 -31
- package/src/services/update-plan.ts +293 -33
- package/src/types/index.ts +7 -0
- package/src/ui/App.tsx +90 -146
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +282 -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/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 +65 -54
- 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 +313 -221
- 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/user-path.ts +60 -0
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/version-snapshot.ts +0 -189
|
@@ -64,7 +64,6 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
|
|
|
64
64
|
test('REQ-12a a problem makes the outcome "failed" with the fixed title and the problem in the message, even when the state reads ok', () => {
|
|
65
65
|
const result: ProfileStartup = {
|
|
66
66
|
need: needIn("ok"),
|
|
67
|
-
notices: [],
|
|
68
67
|
problems: ["boom"],
|
|
69
68
|
};
|
|
70
69
|
|
|
@@ -79,7 +78,7 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
|
|
|
79
78
|
|
|
80
79
|
test('REQ-12b no problems and a need still to answer gives "ask" carrying the same need object', () => {
|
|
81
80
|
const need = needIn("inactive");
|
|
82
|
-
const result: ProfileStartup = { need,
|
|
81
|
+
const result: ProfileStartup = { need, problems: [] };
|
|
83
82
|
|
|
84
83
|
const outcome = formatUpdateOutcome(result);
|
|
85
84
|
|
|
@@ -87,34 +86,12 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
|
|
|
87
86
|
expect((outcome as { need?: unknown }).need).toBe(need);
|
|
88
87
|
});
|
|
89
88
|
|
|
90
|
-
test('REQ-12c no problems
|
|
91
|
-
const result: ProfileStartup = {
|
|
92
|
-
need: needIn("ok"),
|
|
93
|
-
notices: ["absorbed 1 edit"],
|
|
94
|
-
problems: [],
|
|
95
|
-
};
|
|
89
|
+
test('REQ-12c no problems and state ok gives "updated", and nothing to say about it', () => {
|
|
90
|
+
const result: ProfileStartup = { need: needIn("ok"), problems: [] };
|
|
96
91
|
|
|
97
92
|
const outcome = formatUpdateOutcome(result);
|
|
98
93
|
|
|
99
|
-
expect(outcome).toEqual({
|
|
100
|
-
kind: "updated",
|
|
101
|
-
notice: "Profile format updated",
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test('REQ-12d no problems, state ok and no notices gives the same "updated" notice', () => {
|
|
106
|
-
const result: ProfileStartup = {
|
|
107
|
-
need: needIn("ok"),
|
|
108
|
-
notices: [],
|
|
109
|
-
problems: [],
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const outcome = formatUpdateOutcome(result);
|
|
113
|
-
|
|
114
|
-
expect(outcome).toEqual({
|
|
115
|
-
kind: "updated",
|
|
116
|
-
notice: "Profile format updated",
|
|
117
|
-
});
|
|
94
|
+
expect(outcome).toEqual({ kind: "updated" });
|
|
118
95
|
});
|
|
119
96
|
});
|
|
120
97
|
|
|
@@ -137,7 +114,6 @@ for (const stream of [process.stdin, process.stdout, process.stderr]) {
|
|
|
137
114
|
|
|
138
115
|
const out = {
|
|
139
116
|
state: null,
|
|
140
|
-
notices: null,
|
|
141
117
|
problems: null,
|
|
142
118
|
outcome: null,
|
|
143
119
|
error: null,
|
|
@@ -148,14 +124,13 @@ try {
|
|
|
148
124
|
? await startup.prepareProfileStartup(project, { convert: true })
|
|
149
125
|
: await startup.prepareProfileStartup(project);
|
|
150
126
|
out.state = prepared.need.state;
|
|
151
|
-
out.notices = prepared.notices;
|
|
152
127
|
out.problems = Array.isArray(prepared.problems) ? prepared.problems : null;
|
|
153
128
|
const outcome = startup.formatUpdateOutcome(prepared);
|
|
154
129
|
out.outcome =
|
|
155
130
|
outcome.kind === "ask"
|
|
156
131
|
? { kind: "ask", needState: outcome.need.state }
|
|
157
132
|
: outcome.kind === "updated"
|
|
158
|
-
? { kind: "updated"
|
|
133
|
+
? { kind: "updated" }
|
|
159
134
|
: { kind: outcome.kind, title: outcome.title, message: outcome.message };
|
|
160
135
|
} catch (e) {
|
|
161
136
|
out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
@@ -166,12 +141,11 @@ process.exit(0);
|
|
|
166
141
|
|
|
167
142
|
interface StartupRun {
|
|
168
143
|
state: ProfileStartupState | null;
|
|
169
|
-
notices: string[] | null;
|
|
170
144
|
/** Null when the result carried no `problems` array at all. */
|
|
171
145
|
problems: string[] | null;
|
|
172
146
|
outcome:
|
|
173
147
|
| { kind: "ask"; needState: ProfileStartupState }
|
|
174
|
-
| { kind: "updated"
|
|
148
|
+
| { kind: "updated" }
|
|
175
149
|
| { kind: "failed"; title: string; message: string }
|
|
176
150
|
| null;
|
|
177
151
|
error: string | null;
|
|
@@ -31,10 +31,7 @@ import {
|
|
|
31
31
|
prepareProfileState,
|
|
32
32
|
} from "../services/profile-state.js";
|
|
33
33
|
import type { ProfileManifest } from "../types/index.js";
|
|
34
|
-
import {
|
|
35
|
-
buildGitignoreModal,
|
|
36
|
-
startupFixNotices,
|
|
37
|
-
} from "../ui/hooks/useGitignoreModal.js";
|
|
34
|
+
import { buildGitignoreModal } from "../ui/hooks/useGitignoreModal.js";
|
|
38
35
|
|
|
39
36
|
const MANIFEST: ProfileManifest = {
|
|
40
37
|
version: 2,
|
|
@@ -155,11 +152,6 @@ describe("the startup fix untracks generated files in place", () => {
|
|
|
155
152
|
expect(await fs.readFile(join(project, ".gitignore"), "utf8")).toContain(
|
|
156
153
|
".claude/settings.json",
|
|
157
154
|
);
|
|
158
|
-
// Untracking is unfinished until the user commits, so it is a warning.
|
|
159
|
-
expect(startupFixNotices(result)).toContainEqual({
|
|
160
|
-
text: "Stopped tracking .claude/settings.json — commit the staged removal",
|
|
161
|
-
tone: "warning",
|
|
162
|
-
});
|
|
163
155
|
// Asked again on the next open: nothing generated is tracked any more.
|
|
164
156
|
expect((await startupGitignoreNeed(project))?.generated ?? []).toEqual([]);
|
|
165
157
|
});
|
|
@@ -51,10 +51,9 @@ const roundTrip: AppAction[] = [
|
|
|
51
51
|
];
|
|
52
52
|
|
|
53
53
|
describe("models screen initial state", () => {
|
|
54
|
-
test("starts idle, unfiltered, and
|
|
54
|
+
test("starts idle, unfiltered, and not writing", () => {
|
|
55
55
|
expect(initialState.models.data.status).toBe("idle");
|
|
56
56
|
expect(initialState.models.searchQuery).toBe("");
|
|
57
|
-
expect(initialState.models.status).toBeNull();
|
|
58
57
|
expect(initialState.models.isApplying).toBe(false);
|
|
59
58
|
// The mate slots are not drawn until something says they can be served. Starting
|
|
60
59
|
// true would put three rows on screen before anything had been asked.
|
|
@@ -166,39 +165,6 @@ describe("models data state", () => {
|
|
|
166
165
|
});
|
|
167
166
|
});
|
|
168
167
|
|
|
169
|
-
describe("models status line", () => {
|
|
170
|
-
test("survives the screen unmounting, because it lives in app state", () => {
|
|
171
|
-
// `Router` swaps the component type on a tab change, so a message held in
|
|
172
|
-
// the screen's own useState is destroyed by 0 → 1 → 0 — and the message is
|
|
173
|
-
// the only record of what the last apply did.
|
|
174
|
-
const messaged = run([
|
|
175
|
-
{
|
|
176
|
-
type: "MODELS_STATUS_SET",
|
|
177
|
-
status: { text: "Routed opus-lead", tone: "success" },
|
|
178
|
-
},
|
|
179
|
-
]);
|
|
180
|
-
expect(run(roundTrip, messaged).models.status?.text).toBe(
|
|
181
|
-
"Routed opus-lead",
|
|
182
|
-
);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test("is replaced by the next message, not appended to", () => {
|
|
186
|
-
const state = run([
|
|
187
|
-
{ type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
|
|
188
|
-
{ type: "MODELS_STATUS_SET", status: { text: "two", tone: "error" } },
|
|
189
|
-
]);
|
|
190
|
-
expect(state.models.status).toEqual({ text: "two", tone: "error" });
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("clearing removes it", () => {
|
|
194
|
-
const state = run([
|
|
195
|
-
{ type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
|
|
196
|
-
{ type: "MODELS_STATUS_CLEAR" },
|
|
197
|
-
]);
|
|
198
|
-
expect(state.models.status).toBeNull();
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
168
|
describe("apply-in-flight state", () => {
|
|
203
169
|
test("survives the screen unmounting", () => {
|
|
204
170
|
// An apply rewrites the profile, re-materializes it and registers a hook.
|
|
@@ -297,11 +263,10 @@ describe("ModelsScreen keybindings", () => {
|
|
|
297
263
|
expect(src.slice(apply, apply + 120)).toContain("handleApply()");
|
|
298
264
|
});
|
|
299
265
|
|
|
300
|
-
test("digits and Tab never reach the screen
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// on the way OUT of the tab — the loss the state was moved to prevent.
|
|
266
|
+
test("digits and Tab never reach the screen", () => {
|
|
267
|
+
// Every digit (0 included) and Tab navigate, and they belong to the global
|
|
268
|
+
// key layer: a Models binding claiming one would make the tab unleavable
|
|
269
|
+
// by that key.
|
|
305
270
|
for (const digit of "1234567890") {
|
|
306
271
|
expect(
|
|
307
272
|
matchBinding(SCREEN_BINDINGS.models, press(digit)),
|
|
@@ -319,6 +284,19 @@ describe("ModelsScreen keybindings", () => {
|
|
|
319
284
|
expect(styles).not.toContain("/^[1-9]$/");
|
|
320
285
|
});
|
|
321
286
|
|
|
287
|
+
test("no status line: a failure goes to the failure popup, a success says nothing", async () => {
|
|
288
|
+
// The TUI shows no status lines, notices or success messages; the screen
|
|
289
|
+
// reloads and shows state. The header row carries state only.
|
|
290
|
+
const src = stripComments(await fs.readFile(SCREEN, "utf8"));
|
|
291
|
+
expect(src).not.toContain("notice=");
|
|
292
|
+
expect(src).not.toContain("MODELS_STATUS");
|
|
293
|
+
expect(src).not.toMatch(/\bApplied\b/);
|
|
294
|
+
// Negative control: the failure path is really wired, so the guard is
|
|
295
|
+
// not passing over a screen that reports nothing at all.
|
|
296
|
+
expect(src).toContain("useFailurePopup()");
|
|
297
|
+
expect(src).toContain("stepFailureReport(");
|
|
298
|
+
});
|
|
299
|
+
|
|
322
300
|
test("screen state is held in the reducer, never in useState", async () => {
|
|
323
301
|
// The rule the two "survives unmounting" tests above enforce at runtime,
|
|
324
302
|
// pinned at the source so a new piece of state cannot quietly opt out.
|
|
@@ -191,17 +191,11 @@ describe("isRelevantInstallation", () => {
|
|
|
191
191
|
).toBe(false);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
test("two rootless paths
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// a
|
|
199
|
-
//
|
|
200
|
-
// The port used to answer false here, which made magus name a
|
|
201
|
-
// `loadedVersion` the loader would not pick in any non-git directory. It is
|
|
202
|
-
// the loader's answer this module owes, not the more cautious one; what
|
|
203
|
-
// magus may WRITE is a separate question, pinned in stale-load-repair.test.ts
|
|
204
|
-
// ("a rootless row from another directory is relevant but NOT rewritten").
|
|
194
|
+
test("two rootless paths do NOT match each other (Claude Code 2.1.284)", () => {
|
|
195
|
+
// 2.1.284 guards the root comparison with `cwdRoot !== null`. Before it,
|
|
196
|
+
// two nulls compared equal and every rootless folder shared every other
|
|
197
|
+
// rootless folder's rows. Measured with `--debug-file` in a rootless folder:
|
|
198
|
+
// a row from an existing non-git folder did not load there.
|
|
205
199
|
const loose = path.join(base, "loose");
|
|
206
200
|
expect(
|
|
207
201
|
isRelevantInstallation(
|
|
@@ -212,12 +206,57 @@ describe("isRelevantInstallation", () => {
|
|
|
212
206
|
loose,
|
|
213
207
|
repoRootForPath(loose),
|
|
214
208
|
),
|
|
209
|
+
).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("in a rootless folder its own row and USER rows still count", () => {
|
|
213
|
+
const loose = path.join(base, "loose");
|
|
214
|
+
const looseRoot = repoRootForPath(loose);
|
|
215
|
+
expect(looseRoot).toBeNull();
|
|
216
|
+
expect(
|
|
217
|
+
isRelevantInstallation(
|
|
218
|
+
entry({ version: "1.0.0", projectPath: loose }),
|
|
219
|
+
loose,
|
|
220
|
+
looseRoot,
|
|
221
|
+
),
|
|
222
|
+
).toBe(true);
|
|
223
|
+
expect(
|
|
224
|
+
isRelevantInstallation(
|
|
225
|
+
entry({ version: "1.0.0", scope: "user", projectPath: undefined }),
|
|
226
|
+
loose,
|
|
227
|
+
looseRoot,
|
|
228
|
+
),
|
|
229
|
+
).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("a row's path is compared RAW, as the binary compares it: '/p/a/' is not '/p/a' in a rootless folder", () => {
|
|
233
|
+
// Normalising the row's path made this pair relevant — and in a rootless
|
|
234
|
+
// folder nothing else could, since the root comparison matches nothing —
|
|
235
|
+
// so the port named a row the loader rejects.
|
|
236
|
+
const loose = path.join(base, "loose");
|
|
237
|
+
const looseRoot = repoRootForPath(loose);
|
|
238
|
+
expect(looseRoot).toBeNull();
|
|
239
|
+
expect(
|
|
240
|
+
isRelevantInstallation(
|
|
241
|
+
entry({ version: "1.0.0", projectPath: `${loose}/` }),
|
|
242
|
+
loose,
|
|
243
|
+
looseRoot,
|
|
244
|
+
),
|
|
245
|
+
).toBe(false);
|
|
246
|
+
// The caller's directory stands in for the binary's own working
|
|
247
|
+
// directory, which is always absolute with no trailing slash.
|
|
248
|
+
expect(
|
|
249
|
+
isRelevantInstallation(
|
|
250
|
+
entry({ version: "1.0.0", projectPath: loose }),
|
|
251
|
+
`${loose}/`,
|
|
252
|
+
looseRoot,
|
|
253
|
+
),
|
|
215
254
|
).toBe(true);
|
|
216
255
|
});
|
|
217
256
|
|
|
218
257
|
test("a rootless row is still irrelevant to a directory inside a repository", () => {
|
|
219
|
-
//
|
|
220
|
-
//
|
|
258
|
+
// `cwdRoot` is a real root here, so a rootless row's null root does not
|
|
259
|
+
// equal it.
|
|
221
260
|
expect(
|
|
222
261
|
isRelevantInstallation(
|
|
223
262
|
entry({ version: "1.0.0", projectPath: path.join(base, "loose") }),
|
|
@@ -140,7 +140,7 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
|
|
|
140
140
|
// match, and it cannot tell a move from a dual publication: claudish ships
|
|
141
141
|
// to two marketplaces with both copies installed, and the row announced
|
|
142
142
|
// "moved → magus" about a plugin that had not moved anywhere.
|
|
143
|
-
test("an orphan is
|
|
143
|
+
test("an orphan is deprecated, and never names a destination", () => {
|
|
144
144
|
const seo = info({
|
|
145
145
|
id: "seo@magus",
|
|
146
146
|
version: "1.8.0",
|
|
@@ -149,16 +149,15 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
|
|
|
149
149
|
projectScope: { installed: true, version: "1.8.0" },
|
|
150
150
|
});
|
|
151
151
|
const row = rows([seo]).get(seo.id) as PluginPluginItem;
|
|
152
|
-
expect(cliNote([seo], seo.id)).toBe(
|
|
153
|
-
|
|
154
|
-
);
|
|
152
|
+
expect(cliNote([seo], seo.id)).toBe("no longer published");
|
|
153
|
+
// The row's own words, not the note: the verdict and the key that acts.
|
|
155
154
|
expect(pluginRowText(row).text).toBe(
|
|
156
|
-
" v1.8.0
|
|
155
|
+
" v1.8.0 deprecated — press a to remove",
|
|
157
156
|
);
|
|
158
157
|
expect(pluginRowText(row).text).not.toMatch(/moved/i);
|
|
159
158
|
});
|
|
160
159
|
|
|
161
|
-
test("an orphan from a marketplace we DID read is
|
|
160
|
+
test("an orphan from a marketplace we DID read is deprecated", () => {
|
|
162
161
|
const gone = info({
|
|
163
162
|
id: "gone@magus",
|
|
164
163
|
version: "2.0.0",
|
|
@@ -168,14 +167,12 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
|
|
|
168
167
|
});
|
|
169
168
|
const row = rows([gone]).get(gone.id) as PluginPluginItem;
|
|
170
169
|
expect(pluginRowText(row).text).toBe(
|
|
171
|
-
|
|
172
|
-
);
|
|
173
|
-
expect(pluginRowText(row).text).toContain(
|
|
174
|
-
"not offered by any configured marketplace",
|
|
170
|
+
" v2.0.0 deprecated — press a to remove",
|
|
175
171
|
);
|
|
172
|
+
expect(row.status?.action).toBe("remove");
|
|
176
173
|
});
|
|
177
174
|
|
|
178
|
-
test("the old words are gone: no '
|
|
175
|
+
test("the old words are gone: no 'moved →', and no marketplace wording", () => {
|
|
179
176
|
const seo = info({
|
|
180
177
|
id: "seo@magus",
|
|
181
178
|
installedVersion: "1.8.0",
|
|
@@ -183,14 +180,14 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
|
|
|
183
180
|
projectScope: { installed: true, version: "1.8.0" },
|
|
184
181
|
});
|
|
185
182
|
const { text } = pluginRowText(rows([seo]).get(seo.id) as PluginPluginItem);
|
|
186
|
-
expect(text).not.
|
|
183
|
+
expect(text).not.toMatch(/marketplace/i);
|
|
187
184
|
expect(text).not.toContain("moved →");
|
|
188
185
|
});
|
|
189
186
|
});
|
|
190
187
|
|
|
191
|
-
describe("pluginRowText —
|
|
192
|
-
const
|
|
193
|
-
info({
|
|
188
|
+
describe("pluginRowText — the current version and the state, no history", () => {
|
|
189
|
+
const row = (over: Partial<PluginInfo>) => {
|
|
190
|
+
const p = info({
|
|
194
191
|
id: "terminal@magus",
|
|
195
192
|
version: "3.3.1",
|
|
196
193
|
installedVersion: "3.0.1",
|
|
@@ -198,40 +195,36 @@ describe("pluginRowText — history badges combine with the state", () => {
|
|
|
198
195
|
author: { name: "MadAppGang" },
|
|
199
196
|
...over,
|
|
200
197
|
});
|
|
198
|
+
return rows([p]).get(p.id) as PluginPluginItem;
|
|
199
|
+
};
|
|
201
200
|
|
|
202
|
-
test("
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const { text, segments } = pluginRowText(row);
|
|
207
|
-
expect(text).toBe(" v2.9.0 → v3.0.1 updated → v3.3.1 available");
|
|
208
|
-
expect(segments.map((s) => s.tone)).toEqual(["success", "warning"]);
|
|
201
|
+
test("a pending update: the installed version, then what is available", () => {
|
|
202
|
+
const { text, segments } = pluginRowText(row({}));
|
|
203
|
+
expect(text).toBe(" v3.0.1 → v3.3.1 available");
|
|
204
|
+
expect(segments.map((s) => s.tone)).toEqual(["muted", "warning"]);
|
|
209
205
|
});
|
|
210
206
|
|
|
211
|
-
test("'new'
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
207
|
+
test("NEGATIVE CONTROL: no 'updated' or 'new' badge — the row has no memory of earlier versions", () => {
|
|
208
|
+
// The badge rendered "v1.8.0 → v1.7.4 updated → v1.8.0 available": a
|
|
209
|
+
// downgrade called "updated". A row now says nothing about what changed.
|
|
210
|
+
const text = pluginRowText(row({})).text;
|
|
211
|
+
expect(text).not.toContain("updated");
|
|
212
|
+
expect(text).not.toContain(" new");
|
|
215
213
|
});
|
|
216
214
|
|
|
217
|
-
test("
|
|
218
|
-
const
|
|
219
|
-
recentlyInstalled: true,
|
|
215
|
+
test("an unknown version is not printed", () => {
|
|
216
|
+
const r = row({
|
|
220
217
|
version: "0.0.0",
|
|
221
218
|
installedVersion: "0.0.0",
|
|
222
219
|
projectScope: { installed: true, version: "0.0.0" },
|
|
223
220
|
});
|
|
224
|
-
|
|
225
|
-
expect(pluginRowText(row).text).toBe(" new");
|
|
221
|
+
expect(pluginRowText(r).text).not.toContain("v0.0.0");
|
|
226
222
|
});
|
|
227
223
|
|
|
228
224
|
test("a current row shows only its version", () => {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const row = rows([p]).get(p.id) as PluginPluginItem;
|
|
233
|
-
expect(row.status?.action).toBe("current");
|
|
234
|
-
expect(pluginRowText(row).text).toBe(" v3.0.1");
|
|
225
|
+
const r = row({ version: "3.0.1" });
|
|
226
|
+
expect(r.status?.action).toBe("current");
|
|
227
|
+
expect(pluginRowText(r).text).toBe(" v3.0.1");
|
|
235
228
|
});
|
|
236
229
|
});
|
|
237
230
|
|
|
@@ -141,6 +141,13 @@ describe("the render enables what the profile's plugins depend on", () => {
|
|
|
141
141
|
"lib@mkt",
|
|
142
142
|
"shared@other",
|
|
143
143
|
]);
|
|
144
|
+
// Which plugin asked for each — what `magus update` names when a derived
|
|
145
|
+
// dependency is no longer published.
|
|
146
|
+
expect(closure.dependencies?.requiredBy).toEqual({
|
|
147
|
+
"lib@mkt": "app@mkt",
|
|
148
|
+
"core@mkt": "lib@mkt",
|
|
149
|
+
"shared@other": "core@mkt",
|
|
150
|
+
});
|
|
144
151
|
await start(manifest, "only");
|
|
145
152
|
expect(await enabled()).toEqual({
|
|
146
153
|
"app@mkt": true,
|
|
@@ -60,7 +60,6 @@ const out = {
|
|
|
60
60
|
described: null,
|
|
61
61
|
options: null,
|
|
62
62
|
updateFormatValue: startup.UPDATE_FORMAT_VALUE,
|
|
63
|
-
notices: null,
|
|
64
63
|
error: null,
|
|
65
64
|
};
|
|
66
65
|
try {
|
|
@@ -78,7 +77,6 @@ try {
|
|
|
78
77
|
mode === "convert" ? { convert: true } : undefined,
|
|
79
78
|
);
|
|
80
79
|
out.need = { state: prepared.need.state };
|
|
81
|
-
out.notices = prepared.notices;
|
|
82
80
|
}
|
|
83
81
|
} catch (e) {
|
|
84
82
|
out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
@@ -93,7 +91,6 @@ interface OldFormatRun {
|
|
|
93
91
|
described: { title: string; message: string } | null;
|
|
94
92
|
options: Array<{ label: string; value: string; meta?: string }> | null;
|
|
95
93
|
updateFormatValue: string;
|
|
96
|
-
notices: string[] | null;
|
|
97
94
|
error: string | null;
|
|
98
95
|
}
|
|
99
96
|
|
|
@@ -137,7 +137,7 @@ describe("detectProfileStartupNeed", () => {
|
|
|
137
137
|
expect(describeNeed(need).message).toContain('"gone"');
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
test("the TUI startup runs the gate first
|
|
140
|
+
test("the TUI startup runs the gate first, absorbing edits into the profile", async () => {
|
|
141
141
|
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
142
142
|
await activateProfile(LEAN, "lean", project, { onDisplace: () => {} });
|
|
143
143
|
const live = join(project, ".claude", "settings.json");
|
|
@@ -145,10 +145,13 @@ describe("detectProfileStartupNeed", () => {
|
|
|
145
145
|
settings.enabledPlugins["code-search@magus"] = true;
|
|
146
146
|
await fs.writeFile(live, JSON.stringify(settings));
|
|
147
147
|
|
|
148
|
-
const { need
|
|
148
|
+
const { need } = await prepareProfileStartup(project);
|
|
149
149
|
|
|
150
150
|
expect(need.state).toBe("ok");
|
|
151
|
-
|
|
151
|
+
const manifest = await fs.readJson(
|
|
152
|
+
join(project, ".claude", "profiles.json"),
|
|
153
|
+
);
|
|
154
|
+
expect(manifest.profiles.lean.plugins["code-search@magus"]).toBeTruthy();
|
|
152
155
|
});
|
|
153
156
|
|
|
154
157
|
test("what is enabled never changes the answer", async () => {
|
|
@@ -403,46 +406,71 @@ describe("a failure that already moved something", () => {
|
|
|
403
406
|
link: `/p/.claude/${name}`,
|
|
404
407
|
savedTo: `/p/.claude/.magus-backups/S/${name}`,
|
|
405
408
|
});
|
|
409
|
+
const lean = { kind: "activate" as const, id: "lean" };
|
|
410
|
+
/** Everything the failure popup says, as one string. */
|
|
411
|
+
const said = (report: ReturnType<typeof describeFailure>): string =>
|
|
412
|
+
[
|
|
413
|
+
report.headline,
|
|
414
|
+
...report.rows.map((r) => `${r.subject} ${r.reason} ${r.fix ?? ""}`),
|
|
415
|
+
report.fix ?? "",
|
|
416
|
+
].join("\n");
|
|
406
417
|
|
|
407
418
|
test("a true no-op says so", () => {
|
|
408
|
-
const
|
|
419
|
+
const report = describeFailure(new Error("boom"), [], "/p", null, lean);
|
|
420
|
+
expect(report.headline).toBe('Profile "lean" was not activated');
|
|
421
|
+
const msg = said(report);
|
|
409
422
|
expect(msg).toContain("boom");
|
|
410
423
|
expect(msg).toContain("exactly as it was");
|
|
411
424
|
});
|
|
412
425
|
|
|
426
|
+
test("an adoption that failed says the profile was not created", () => {
|
|
427
|
+
const report = describeFailure(new Error("boom"), [], "/p", null, {
|
|
428
|
+
kind: "adopt",
|
|
429
|
+
id: "default",
|
|
430
|
+
name: "Mine",
|
|
431
|
+
});
|
|
432
|
+
expect(report.headline).toBe('Profile "Mine" was not created');
|
|
433
|
+
});
|
|
434
|
+
|
|
413
435
|
test("a partial failure never claims nothing happened", () => {
|
|
414
436
|
// Activation moves up to four artifacts in sequence and settings.json goes
|
|
415
437
|
// FIRST, so a throw on the second leaves it already in the backup. The old
|
|
416
438
|
// message said "Nothing was activated" unconditionally — false about the
|
|
417
439
|
// user's own file, and `.magus-backups/` is gitignored, so git status
|
|
418
440
|
// showed the deletion with no sign of the copy.
|
|
419
|
-
const
|
|
441
|
+
const report = describeFailure(
|
|
420
442
|
new Error("EACCES: skills"),
|
|
421
443
|
[at("settings.json")],
|
|
422
444
|
"/p",
|
|
423
445
|
"lean",
|
|
446
|
+
lean,
|
|
424
447
|
);
|
|
448
|
+
const msg = said(report);
|
|
425
449
|
|
|
426
|
-
expect(msg).not.toContain("
|
|
450
|
+
expect(msg.toLowerCase()).not.toContain("nothing was moved");
|
|
427
451
|
expect(msg).not.toContain("exactly as it was");
|
|
428
|
-
// It must name where the file went, project-relative.
|
|
429
|
-
expect(
|
|
430
|
-
|
|
452
|
+
// It must name where the file went, project-relative: one row per file.
|
|
453
|
+
expect(report.rows).toContainEqual({
|
|
454
|
+
subject: ".claude/settings.json",
|
|
455
|
+
reason: "is kept at .claude/.magus-backups/S/settings.json",
|
|
456
|
+
});
|
|
431
457
|
});
|
|
432
458
|
|
|
433
459
|
test("it warns against the re-attempt that would capture an empty set", () => {
|
|
434
460
|
// Re-choosing save-what-you-have runs adoption again, and adoption reads
|
|
435
461
|
// settings.json — now in the backup. It would capture an empty plugin set
|
|
436
462
|
// into a second profile and call that the user's setup.
|
|
437
|
-
const
|
|
463
|
+
const report = describeFailure(
|
|
438
464
|
new Error("boom"),
|
|
439
465
|
[at("settings.json")],
|
|
440
466
|
"/p",
|
|
441
467
|
"lean",
|
|
468
|
+
lean,
|
|
442
469
|
);
|
|
443
470
|
|
|
444
|
-
|
|
445
|
-
expect(
|
|
471
|
+
// The fix line, said once under the rows.
|
|
472
|
+
expect(report.fix).toContain("Do NOT save-what-you-have");
|
|
473
|
+
expect(report.fix).toContain("Activating lean again is safe");
|
|
446
474
|
});
|
|
447
475
|
});
|
|
448
476
|
|