magus-cli 7.1.0 → 7.2.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__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/cli/ansi.ts +39 -0
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +1 -0
- package/src/cli/update.ts +50 -7
- package/src/main.tsx +10 -1
- package/src/services/manifest.ts +21 -0
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/symlink-manager.ts +184 -30
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +60 -0
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +54 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What magus requires about profiles before it will work on a project.
|
|
3
|
+
*
|
|
4
|
+
* Two behaviours are pinned here, and both were defects:
|
|
5
|
+
*
|
|
6
|
+
* 1. The TUI could show a Profiles tab listing two profiles while the project
|
|
7
|
+
* ran neither, and say nothing.
|
|
8
|
+
* 2. Both surfaces offered a way to carry on anyway — the TUI as a "Not now"
|
|
9
|
+
* row, the CLI as "using X for this run only". With no active profile there
|
|
10
|
+
* is no profile state to read and nowhere to write updates, so that let the
|
|
11
|
+
* user into a magus that could not do its job.
|
|
12
|
+
*
|
|
13
|
+
* And one thing is pinned by its ABSENCE: nothing compares the manifest to
|
|
14
|
+
* `enabledPlugins`. See the header of `services/profile-startup.ts`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import fs from "fs-extra";
|
|
22
|
+
import { validateManifest } from "../services/manifest.js";
|
|
23
|
+
import { materializeAndActivate } from "../services/profile-activate.js";
|
|
24
|
+
import { adoptCurrentProject } from "../services/profile-adopt.js";
|
|
25
|
+
import {
|
|
26
|
+
ACTIVATE_PREFIX,
|
|
27
|
+
ADOPT_VALUE,
|
|
28
|
+
QUIT_VALUE,
|
|
29
|
+
buildProfileOptions,
|
|
30
|
+
defaultOptionIndex,
|
|
31
|
+
describeNeed,
|
|
32
|
+
detectProfileStartupNeed,
|
|
33
|
+
needsNothing,
|
|
34
|
+
parseProfileChoice,
|
|
35
|
+
} from "../services/profile-startup.js";
|
|
36
|
+
import { listProfiles } from "../services/profiles.js";
|
|
37
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
38
|
+
import {
|
|
39
|
+
buildProfileStartupModal,
|
|
40
|
+
describeFailure,
|
|
41
|
+
} from "../ui/hooks/useProfileStartupModal.js";
|
|
42
|
+
|
|
43
|
+
const LEAN: ProfileManifest = {
|
|
44
|
+
version: 2,
|
|
45
|
+
profiles: {
|
|
46
|
+
lean: { name: "Lean", plugins: { "terminal@magus": "latest" } },
|
|
47
|
+
full: {
|
|
48
|
+
name: "Full",
|
|
49
|
+
plugins: { "terminal@magus": "latest", "dev@magus": "latest" },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
async function enable(project: string, ids: string[]): Promise<void> {
|
|
55
|
+
await fs.outputJson(join(project, ".claude", "settings.json"), {
|
|
56
|
+
enabledPlugins: Object.fromEntries(ids.map((id) => [id, true])),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("detectProfileStartupNeed", () => {
|
|
61
|
+
let project: string;
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
project = await mkdtemp(join(tmpdir(), "startup-"));
|
|
64
|
+
});
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
await rm(project, { recursive: true, force: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("no manifest — unmigrated, offers to adopt, nothing to activate", async () => {
|
|
70
|
+
await enable(project, ["dev@magus"]);
|
|
71
|
+
|
|
72
|
+
const need = await detectProfileStartupNeed(project);
|
|
73
|
+
|
|
74
|
+
expect(need.state).toBe("unmigrated");
|
|
75
|
+
expect(need.adopt).toBe("default");
|
|
76
|
+
expect(need.activate).toEqual([]);
|
|
77
|
+
expect(needsNothing(need)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("profiles but none active — inactive, offers both routes", async () => {
|
|
81
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
82
|
+
await enable(project, ["terminal@magus"]);
|
|
83
|
+
|
|
84
|
+
const need = await detectProfileStartupNeed(project);
|
|
85
|
+
|
|
86
|
+
expect(need.state).toBe("inactive");
|
|
87
|
+
expect(need.activate.map((p) => p.id).sort()).toEqual(["full", "lean"]);
|
|
88
|
+
// Adopting stays available so a manifest full of stale profiles is not a
|
|
89
|
+
// dead end where activating a wrong one is the only way forward.
|
|
90
|
+
expect(need.adopt).toBe("default");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a profile is active — asks nothing, whatever is enabled", async () => {
|
|
94
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
95
|
+
await enable(project, ["terminal@magus"]);
|
|
96
|
+
await materializeAndActivate(LEAN, "lean", project, () => {});
|
|
97
|
+
|
|
98
|
+
expect(
|
|
99
|
+
needsNothing(await detectProfileStartupNeed(project)).valueOf(),
|
|
100
|
+
).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("an active profile whose generated settings were edited still asks nothing", async () => {
|
|
104
|
+
// THE point of dropping the comparison. `settings.json` is a symlink into
|
|
105
|
+
// `_profiles/lean/`, which is generated FROM the manifest — so judging the
|
|
106
|
+
// manifest against it reads the manifest's own output. The old code offered
|
|
107
|
+
// to create a third profile describing the corrupted file; the right answer
|
|
108
|
+
// is to re-materialize, and the wrong answer is what made magus behave as
|
|
109
|
+
// though the profileless world were still live.
|
|
110
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
111
|
+
await enable(project, ["terminal@magus"]);
|
|
112
|
+
await materializeAndActivate(LEAN, "lean", project, () => {});
|
|
113
|
+
await fs.outputJson(
|
|
114
|
+
join(project, ".claude", "_profiles", "lean", "settings.json"),
|
|
115
|
+
{ enabledPlugins: { "terminal@magus": true, "brand-new@magus": true } },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const need = await detectProfileStartupNeed(project);
|
|
119
|
+
|
|
120
|
+
expect(need.state).toBe("ok");
|
|
121
|
+
expect(need.adopt).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("what is enabled never changes the answer", async () => {
|
|
125
|
+
// Nothing reads enabledPlugins, so two wildly different settings files on
|
|
126
|
+
// the same manifest must produce identical needs.
|
|
127
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
128
|
+
|
|
129
|
+
await enable(project, ["terminal@magus"]);
|
|
130
|
+
const matching = await detectProfileStartupNeed(project);
|
|
131
|
+
await enable(project, ["nothing-any-profile-declares@magus"]);
|
|
132
|
+
const mismatching = await detectProfileStartupNeed(project);
|
|
133
|
+
|
|
134
|
+
expect(mismatching.state).toBe(matching.state);
|
|
135
|
+
expect(mismatching.adopt).toBe(matching.adopt);
|
|
136
|
+
expect(mismatching.activate).toEqual(matching.activate);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("listProfiles marks the active profile, and only that one", async () => {
|
|
140
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
141
|
+
await enable(project, ["terminal@magus"]);
|
|
142
|
+
|
|
143
|
+
const before = await listProfiles(project);
|
|
144
|
+
expect(before.every((p) => p.active !== true)).toBe(true);
|
|
145
|
+
|
|
146
|
+
await materializeAndActivate(LEAN, "lean", project, () => {});
|
|
147
|
+
const after = await listProfiles(project);
|
|
148
|
+
|
|
149
|
+
expect(after.find((p) => p.id === "lean")?.active).toBe(true);
|
|
150
|
+
expect(after.find((p) => p.id === "full")?.active).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a declared default is applied without asking on a fresh clone", async () => {
|
|
154
|
+
// The whole point of the field: the repo already answered, and there is
|
|
155
|
+
// nothing of this developer's to replace, so nobody is asked.
|
|
156
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), {
|
|
157
|
+
...LEAN,
|
|
158
|
+
default: "full",
|
|
159
|
+
});
|
|
160
|
+
// No settings.json, no .mcp.json — nothing activation would displace.
|
|
161
|
+
|
|
162
|
+
const need = await detectProfileStartupNeed(project);
|
|
163
|
+
|
|
164
|
+
expect(need.state).toBe("inactive");
|
|
165
|
+
expect(need.declaredDefault).toBe("full");
|
|
166
|
+
expect(need.autoActivate).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a declared default is NOT applied when there is work to replace", async () => {
|
|
170
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), {
|
|
171
|
+
...LEAN,
|
|
172
|
+
default: "full",
|
|
173
|
+
});
|
|
174
|
+
await enable(project, ["dev@magus"]);
|
|
175
|
+
|
|
176
|
+
const need = await detectProfileStartupNeed(project);
|
|
177
|
+
|
|
178
|
+
// Still declared, so it is preselected — but never automatic. Replacing
|
|
179
|
+
// someone's working files does not happen without them saying so.
|
|
180
|
+
expect(need.declaredDefault).toBe("full");
|
|
181
|
+
expect(need.autoActivate).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a default naming a profile that does not exist falls back to asking", async () => {
|
|
185
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), {
|
|
186
|
+
...LEAN,
|
|
187
|
+
default: "renamed-away",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const need = await detectProfileStartupNeed(project);
|
|
191
|
+
|
|
192
|
+
// validateManifest reports the typo; detection must stay usable meanwhile
|
|
193
|
+
// rather than auto-activating a profile that is not there.
|
|
194
|
+
expect(need.declaredDefault).toBeNull();
|
|
195
|
+
expect(need.autoActivate).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("adopting records the default, and never overwrites one", async () => {
|
|
199
|
+
await enable(project, ["dev@magus"]);
|
|
200
|
+
|
|
201
|
+
const first = await adoptCurrentProject(
|
|
202
|
+
project,
|
|
203
|
+
undefined,
|
|
204
|
+
undefined,
|
|
205
|
+
"default",
|
|
206
|
+
);
|
|
207
|
+
expect(first.manifest.default).toBe("default");
|
|
208
|
+
|
|
209
|
+
// A second adoption into a manifest that already declares one must leave
|
|
210
|
+
// the team's committed choice alone — one developer saving local state is
|
|
211
|
+
// not a decision about what everyone else gets.
|
|
212
|
+
const second = await adoptCurrentProject(
|
|
213
|
+
project,
|
|
214
|
+
undefined,
|
|
215
|
+
{ ...LEAN, default: "lean" },
|
|
216
|
+
"default",
|
|
217
|
+
);
|
|
218
|
+
expect(second.manifest.default).toBe("lean");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("adopt never proposes an id that already exists", async () => {
|
|
222
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), {
|
|
223
|
+
version: 2,
|
|
224
|
+
profiles: {
|
|
225
|
+
default: { name: "Default", plugins: { "terminal@magus": "latest" } },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
await enable(project, ["something-else@magus"]);
|
|
229
|
+
|
|
230
|
+
expect((await detectProfileStartupNeed(project)).adopt).toBe("default-2");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("what the user is offered", () => {
|
|
235
|
+
const manifest = LEAN;
|
|
236
|
+
const unmigrated = {
|
|
237
|
+
state: "unmigrated" as const,
|
|
238
|
+
adopt: "default",
|
|
239
|
+
activate: [],
|
|
240
|
+
manifest: { version: 2 as const, profiles: {} },
|
|
241
|
+
};
|
|
242
|
+
const inactive = {
|
|
243
|
+
state: "inactive" as const,
|
|
244
|
+
adopt: "default",
|
|
245
|
+
activate: [
|
|
246
|
+
{ id: "lean", name: "Lean", pluginCount: 1 },
|
|
247
|
+
{ id: "full", name: "Full", pluginCount: 2 },
|
|
248
|
+
],
|
|
249
|
+
manifest,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
test("there is NO way to carry on without a profile", () => {
|
|
253
|
+
for (const need of [unmigrated, inactive]) {
|
|
254
|
+
const values = buildProfileOptions(need).map((o) => o.value);
|
|
255
|
+
// The old "Not now" / "for this run only" escape. Its absence is the
|
|
256
|
+
// behaviour, so it is asserted rather than assumed.
|
|
257
|
+
expect(values).not.toContain("dismiss");
|
|
258
|
+
// ...but leaving must always be reachable, and visibly so.
|
|
259
|
+
expect(values[values.length - 1]).toBe(QUIT_VALUE);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("no option claims to know what is currently installed", () => {
|
|
264
|
+
// The "matches now" badge compared declared plugins against enabled ones.
|
|
265
|
+
// It is gone with the comparison; a row may only describe its own profile.
|
|
266
|
+
for (const option of buildProfileOptions(inactive)) {
|
|
267
|
+
expect(option.badge).toBeUndefined();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("activate rows carry the profile's own plugin count", () => {
|
|
272
|
+
const options = buildProfileOptions(inactive);
|
|
273
|
+
expect(options[1]?.meta).toBe("1 plugin");
|
|
274
|
+
expect(options[2]?.meta).toBe("2 plugins");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("each state is titled as the thing that is actually wrong", () => {
|
|
278
|
+
expect(describeNeed(unmigrated).title).toBe("magus uses profiles");
|
|
279
|
+
expect(describeNeed(inactive).title).toBe("No profile selected");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("the unmigrated message reassures instead of warning", () => {
|
|
283
|
+
// Adopting an unmigrated project captures what is already present, so it
|
|
284
|
+
// must NOT carry the overwrite warning the inactive state needs.
|
|
285
|
+
const { message } = describeNeed(unmigrated);
|
|
286
|
+
|
|
287
|
+
expect(message).toContain("nothing about what you run changes");
|
|
288
|
+
expect(message).not.toContain("overwritten");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("the inactive message names what is lost, in concrete terms", () => {
|
|
292
|
+
// Two defects this replaced. It explained magus's internals ("the manifest
|
|
293
|
+
// is not in effect", "nowhere to write updates"), and then it went abstract
|
|
294
|
+
// ("the setup you are running belongs to no profile") — neither said that
|
|
295
|
+
// switching OVERWRITES the plugins and skills currently turned on, which is
|
|
296
|
+
// the only fact that makes the save option worth choosing.
|
|
297
|
+
const { message } = describeNeed(inactive);
|
|
298
|
+
|
|
299
|
+
expect(message).toContain("plugins and skills turned on here");
|
|
300
|
+
expect(message).toContain("not saved in any profile");
|
|
301
|
+
expect(message).toContain("overwritten");
|
|
302
|
+
|
|
303
|
+
// Neither the internal vocabulary nor the abstraction comes back. "backup"
|
|
304
|
+
// is banned here too: promising that nothing is deleted raises a question
|
|
305
|
+
// the reader did not have and implies data loss, when what changes is
|
|
306
|
+
// settings. Where the files went is reported AFTER the switch instead.
|
|
307
|
+
for (const banned of [
|
|
308
|
+
"manifest",
|
|
309
|
+
"profile state",
|
|
310
|
+
"declares profiles",
|
|
311
|
+
"setup",
|
|
312
|
+
"belongs to",
|
|
313
|
+
"backup",
|
|
314
|
+
"deleted",
|
|
315
|
+
]) {
|
|
316
|
+
expect(message).not.toContain(banned);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("the declared profile's row is the one under the cursor", () => {
|
|
321
|
+
const withDefault = { ...inactive, declaredDefault: "full" };
|
|
322
|
+
// options: [save, Activate Lean, Activate Full, Quit]
|
|
323
|
+
expect(defaultOptionIndex(withDefault)).toBe(2);
|
|
324
|
+
|
|
325
|
+
// Nothing declared: start on the save row, the only option that keeps
|
|
326
|
+
// what is turned on.
|
|
327
|
+
expect(defaultOptionIndex(inactive)).toBe(0);
|
|
328
|
+
// Declared but absent from the offered list — never point past the end.
|
|
329
|
+
expect(defaultOptionIndex({ ...inactive, declaredDefault: "ghost" })).toBe(
|
|
330
|
+
0,
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("a default naming no profile is a manifest error", () => {
|
|
335
|
+
const errors = validateManifest({ ...LEAN, default: "ghost" });
|
|
336
|
+
expect(errors.map((e) => e.path)).toContain("default");
|
|
337
|
+
|
|
338
|
+
// A valid one is not.
|
|
339
|
+
expect(validateManifest({ ...LEAN, default: "lean" })).toEqual([]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("the save option says what it preserves", () => {
|
|
343
|
+
// "Create <id> from this project" described the mechanism. The reason to
|
|
344
|
+
// pick this row is that it is the only one that keeps what is turned on.
|
|
345
|
+
expect(buildProfileOptions(inactive)[0]?.label).toBe(
|
|
346
|
+
'Save what you have as "default"',
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("a value maps to the action it names", () => {
|
|
351
|
+
expect(parseProfileChoice(ADOPT_VALUE, inactive)).toEqual({
|
|
352
|
+
kind: "adopt",
|
|
353
|
+
id: "default",
|
|
354
|
+
});
|
|
355
|
+
expect(parseProfileChoice(`${ACTIVATE_PREFIX}lean`, inactive)).toEqual({
|
|
356
|
+
kind: "activate",
|
|
357
|
+
id: "lean",
|
|
358
|
+
});
|
|
359
|
+
expect(parseProfileChoice(QUIT_VALUE, inactive)).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a value that names nothing real cannot act", () => {
|
|
363
|
+
// adopt with nothing to adopt, and activate naming a profile not offered.
|
|
364
|
+
expect(
|
|
365
|
+
parseProfileChoice(ADOPT_VALUE, { ...inactive, adopt: null }),
|
|
366
|
+
).toBeNull();
|
|
367
|
+
expect(parseProfileChoice(`${ACTIVATE_PREFIX}ghost`, inactive)).toBeNull();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe("a failure that already moved something", () => {
|
|
372
|
+
const at = (name: string) => ({
|
|
373
|
+
link: `/p/.claude/${name}`,
|
|
374
|
+
savedTo: `/p/.claude/.magus-backups/S/${name}`,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("a true no-op says so", () => {
|
|
378
|
+
const msg = describeFailure(new Error("boom"), [], "/p", null);
|
|
379
|
+
expect(msg).toContain("boom");
|
|
380
|
+
expect(msg).toContain("exactly as it was");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("a partial failure never claims nothing happened", () => {
|
|
384
|
+
// Activation moves up to four artifacts in sequence and settings.json goes
|
|
385
|
+
// FIRST, so a throw on the second leaves it already in the backup. The old
|
|
386
|
+
// message said "Nothing was activated" unconditionally — false about the
|
|
387
|
+
// user's own file, and `.magus-backups/` is gitignored, so git status
|
|
388
|
+
// showed the deletion with no sign of the copy.
|
|
389
|
+
const msg = describeFailure(
|
|
390
|
+
new Error("EACCES: skills"),
|
|
391
|
+
[at("settings.json")],
|
|
392
|
+
"/p",
|
|
393
|
+
"lean",
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
expect(msg).not.toContain("Nothing was moved");
|
|
397
|
+
expect(msg).not.toContain("exactly as it was");
|
|
398
|
+
// It must name where the file went, project-relative.
|
|
399
|
+
expect(msg).toContain(".claude/settings.json");
|
|
400
|
+
expect(msg).toContain(".claude/.magus-backups/S/settings.json");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("it warns against the re-attempt that would capture an empty set", () => {
|
|
404
|
+
// Re-choosing save-what-you-have runs adoption again, and adoption reads
|
|
405
|
+
// settings.json — now in the backup. It would capture an empty plugin set
|
|
406
|
+
// into a second profile and call that the user's setup.
|
|
407
|
+
const msg = describeFailure(
|
|
408
|
+
new Error("boom"),
|
|
409
|
+
[at("settings.json")],
|
|
410
|
+
"/p",
|
|
411
|
+
"lean",
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
expect(msg).toContain("Do NOT save-what-you-have");
|
|
415
|
+
expect(msg).toContain("Activating lean again is safe");
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("the TUI modal", () => {
|
|
420
|
+
const inactive = {
|
|
421
|
+
state: "inactive" as const,
|
|
422
|
+
adopt: "default",
|
|
423
|
+
activate: [{ id: "lean", name: "Lean", pluginCount: 1 }],
|
|
424
|
+
manifest: LEAN,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
test("escape and q do nothing — they cannot dismiss it", () => {
|
|
428
|
+
const calls: string[] = [];
|
|
429
|
+
const modal = buildProfileStartupModal({
|
|
430
|
+
need: inactive,
|
|
431
|
+
onChoose: (c) => calls.push(`${c.kind}:${c.id}`),
|
|
432
|
+
onQuit: () => calls.push("quit"),
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
if (modal.type !== "select") throw new Error("expected a select modal");
|
|
436
|
+
// ModalContainer routes both Escape and `q` here. Neither may leave the
|
|
437
|
+
// user in the app this modal exists to say is unusable.
|
|
438
|
+
modal.onCancel();
|
|
439
|
+
|
|
440
|
+
expect(calls).toEqual([]);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("choosing routes to the action, quitting routes to quit", () => {
|
|
444
|
+
const calls: string[] = [];
|
|
445
|
+
const modal = buildProfileStartupModal({
|
|
446
|
+
need: inactive,
|
|
447
|
+
onChoose: (c) => calls.push(`${c.kind}:${c.id}`),
|
|
448
|
+
onQuit: () => calls.push("quit"),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (modal.type !== "select") throw new Error("expected a select modal");
|
|
452
|
+
modal.onSelect(ADOPT_VALUE);
|
|
453
|
+
modal.onSelect(`${ACTIVATE_PREFIX}lean`);
|
|
454
|
+
modal.onSelect(QUIT_VALUE);
|
|
455
|
+
|
|
456
|
+
expect(calls).toEqual(["adopt:default", "activate:lean", "quit"]);
|
|
457
|
+
});
|
|
458
|
+
});
|
package/src/cli/ansi.ts
CHANGED
|
@@ -513,6 +513,45 @@ export function badge(label: string, hex: string, ink?: string): string {
|
|
|
513
513
|
*/
|
|
514
514
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
515
515
|
|
|
516
|
+
/**
|
|
517
|
+
* A bordered dialog: title in the top rule, body inside, one blank row of
|
|
518
|
+
* breathing space top and bottom.
|
|
519
|
+
*
|
|
520
|
+
* Built on `padEnd`, which measures visible cells, so a body line carrying
|
|
521
|
+
* colour still aligns — padding a coloured string by `String.length` counts the
|
|
522
|
+
* escape bytes and shears the right border by however many bytes the colour
|
|
523
|
+
* cost.
|
|
524
|
+
*
|
|
525
|
+
* The border and the rule are dim and the body is not: a frame that competes
|
|
526
|
+
* with its own contents is why a boxed message can read as louder but say less.
|
|
527
|
+
*/
|
|
528
|
+
export function dialog(
|
|
529
|
+
title: string,
|
|
530
|
+
lines: string[],
|
|
531
|
+
opts: { width?: number; color?: string } = {},
|
|
532
|
+
): string[] {
|
|
533
|
+
const inner = Math.max(
|
|
534
|
+
opts.width ?? 0,
|
|
535
|
+
...lines.map((l) => width(l)),
|
|
536
|
+
width(title) + 4,
|
|
537
|
+
);
|
|
538
|
+
const accent = opts.color;
|
|
539
|
+
const paint = (s: string) => (accent ? fg(accent, s) : s);
|
|
540
|
+
|
|
541
|
+
// Every row must be `inner + 4` cells: corner, space, body, space, corner.
|
|
542
|
+
// The head spends 2 on "╭─" and `width(title) + 2` on the titled gap, so the
|
|
543
|
+
// rule takes the rest. Getting this off by one is invisible in source and
|
|
544
|
+
// obvious on screen — the right border jags one column in at the title row.
|
|
545
|
+
const head = `${paint("╭─")}${paint(` ${title} `)}${dim(
|
|
546
|
+
"─".repeat(Math.max(0, inner - width(title) - 1)),
|
|
547
|
+
)}${dim("╮")}`;
|
|
548
|
+
const foot = dim(`╰${"─".repeat(inner + 2)}╯`);
|
|
549
|
+
const row = (text: string) =>
|
|
550
|
+
`${dim("│")} ${padEnd(text, inner)} ${dim("│")}`;
|
|
551
|
+
|
|
552
|
+
return [head, row(""), ...lines.map(row), row(""), foot];
|
|
553
|
+
}
|
|
554
|
+
|
|
516
555
|
export function spinner(tick: number): string {
|
|
517
556
|
return SPINNER[Math.abs(Math.trunc(tick)) % SPINNER.length];
|
|
518
557
|
}
|