magus-cli 7.5.4 → 7.6.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__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/cli/bootstrap.ts +34 -2
- package/src/cli/prompt.ts +12 -2
- package/src/cli/update-view.ts +6 -4
- package/src/services/profile-activate.ts +2 -1
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +46 -1
- package/src/services/profile-state.ts +1 -1
- package/src/ui/App.tsx +89 -54
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +128 -9
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Save what you have" asks for a name — written blind from the spec's public
|
|
3
|
+
* contract, never from the implementation.
|
|
4
|
+
*
|
|
5
|
+
* Requirements (from the spec):
|
|
6
|
+
* REQ-1 The save row's label carries no generated id; it is exactly
|
|
7
|
+
* `Save what you have as a new profile`.
|
|
8
|
+
* REQ-3 ONE pure function validates the typed name; an invalid name is a
|
|
9
|
+
* one-line reason, never silently changed to another id.
|
|
10
|
+
* REQ-4 The new profile is written under the derived id with `name` = the
|
|
11
|
+
* trimmed text (via applyProfileChoice's adopt `name`).
|
|
12
|
+
* REQ-6 Applies to both states that offer the save row: inactive, unmigrated.
|
|
13
|
+
* C-1 profileIdForName: empty/whitespace → "Enter a name for the profile."
|
|
14
|
+
* C-2 id = lowercase, every run outside [a-z0-9] → one "-", edges trimmed.
|
|
15
|
+
* C-3 empty id → "Use at least one letter or digit."
|
|
16
|
+
* C-4 id already a key → 'A profile called "<name or id>" already exists.
|
|
17
|
+
* Choose another name.'
|
|
18
|
+
* C-5 otherwise { ok: true, id, name } with name trimmed.
|
|
19
|
+
* C-6 applyProfileChoice adopt without `name` keeps current behaviour.
|
|
20
|
+
*
|
|
21
|
+
* REQ-2 and REQ-5 (the prompt itself, its suggestion default, and cancelling
|
|
22
|
+
* it) live in the CLI/TUI surfaces, which the contract does not expose; they
|
|
23
|
+
* are listed as known gaps in the run report.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
27
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import fs from "fs-extra";
|
|
31
|
+
import { readManifest, writeManifest } from "../services/manifest.js";
|
|
32
|
+
import type { ProfileChoice } from "../services/profile-activate.js";
|
|
33
|
+
import {
|
|
34
|
+
ADOPT_VALUE,
|
|
35
|
+
buildProfileOptions,
|
|
36
|
+
detectProfileStartupNeed,
|
|
37
|
+
parseProfileChoice,
|
|
38
|
+
profileIdForName,
|
|
39
|
+
} from "../services/profile-startup.js";
|
|
40
|
+
import { applyProfileChoice } from "../services/profile-state.js";
|
|
41
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
42
|
+
|
|
43
|
+
const SAVE_LABEL = "Save what you have as a new profile";
|
|
44
|
+
|
|
45
|
+
const MANIFEST: ProfileManifest = {
|
|
46
|
+
version: 2,
|
|
47
|
+
profiles: {
|
|
48
|
+
lean: { name: "Lean", plugins: { "terminal@magus": "latest" } },
|
|
49
|
+
full: {
|
|
50
|
+
name: "Full Stack",
|
|
51
|
+
plugins: { "terminal@magus": "latest", "dev@magus": "latest" },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A manifest with a profile that carries no display name. */
|
|
57
|
+
const NAMELESS = {
|
|
58
|
+
version: 2,
|
|
59
|
+
profiles: {
|
|
60
|
+
default: { plugins: { "terminal@magus": "latest" } },
|
|
61
|
+
},
|
|
62
|
+
} as unknown as ProfileManifest;
|
|
63
|
+
|
|
64
|
+
const EMPTY: ProfileManifest = { version: 2, profiles: {} };
|
|
65
|
+
|
|
66
|
+
describe("profileIdForName — empty input (C-1)", () => {
|
|
67
|
+
test("C-1: an empty string asks for a name", () => {
|
|
68
|
+
expect(profileIdForName("", MANIFEST)).toEqual({
|
|
69
|
+
ok: false,
|
|
70
|
+
reason: "Enter a name for the profile.",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("C-1: whitespace only (spaces, tab, newline) asks for a name, not for a letter", () => {
|
|
75
|
+
// Rule order: trimming to empty is caught BEFORE the slug rule.
|
|
76
|
+
for (const input of [" ", " ", "\t", "\n", " \t \n "]) {
|
|
77
|
+
expect(profileIdForName(input, MANIFEST)).toEqual({
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: "Enter a name for the profile.",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("C-1: empty input is rejected even against an empty manifest", () => {
|
|
85
|
+
expect(profileIdForName("", EMPTY)).toEqual({
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: "Enter a name for the profile.",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("profileIdForName — no usable characters (C-3)", () => {
|
|
93
|
+
test("C-3: symbols only is rejected with the letter-or-digit reason", () => {
|
|
94
|
+
for (const input of ["!!!", "---", "@#$%", " _-_ ", "...", "()[]{}"]) {
|
|
95
|
+
expect(profileIdForName(input, MANIFEST)).toEqual({
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: "Use at least one letter or digit.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("C-3: letters outside [a-z0-9] leave no id and are rejected", () => {
|
|
103
|
+
// Non-ASCII letters are outside [a-z0-9] after lowercasing.
|
|
104
|
+
expect(profileIdForName("ÄÖÜ", EMPTY)).toEqual({
|
|
105
|
+
ok: false,
|
|
106
|
+
reason: "Use at least one letter or digit.",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("profileIdForName — id derivation (C-2, C-5)", () => {
|
|
112
|
+
const cases: Array<[string, string, string]> = [
|
|
113
|
+
// [input, expected id, expected name]
|
|
114
|
+
["backend", "backend", "backend"],
|
|
115
|
+
["Backend", "backend", "Backend"],
|
|
116
|
+
["My Team", "my-team", "My Team"],
|
|
117
|
+
["MiXeD CaSe", "mixed-case", "MiXeD CaSe"],
|
|
118
|
+
["UPPER123", "upper123", "UPPER123"],
|
|
119
|
+
["v2", "v2", "v2"],
|
|
120
|
+
["2026", "2026", "2026"],
|
|
121
|
+
["a.b.c", "a-b-c", "a.b.c"],
|
|
122
|
+
["Front--End!!v2", "front-end-v2", "Front--End!!v2"],
|
|
123
|
+
["one two", "one-two", "one two"],
|
|
124
|
+
["snake_case_name", "snake-case-name", "snake_case_name"],
|
|
125
|
+
["--Hello__World--", "hello-world", "--Hello__World--"],
|
|
126
|
+
["!!Release!!", "release", "!!Release!!"],
|
|
127
|
+
["Café Team", "caf-team", "Café Team"],
|
|
128
|
+
["x", "x", "x"],
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
for (const [input, id, name] of cases) {
|
|
132
|
+
test(`C-2/C-5: "${input}" becomes id "${id}"`, () => {
|
|
133
|
+
expect(profileIdForName(input, MANIFEST)).toEqual({ ok: true, id, name });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
test("C-5: the returned name is trimmed at both ends, inner spacing kept", () => {
|
|
138
|
+
expect(profileIdForName(" My Team \t", MANIFEST)).toEqual({
|
|
139
|
+
ok: true,
|
|
140
|
+
id: "my-team",
|
|
141
|
+
name: "My Team",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("C-2: the id never starts or ends with a dash", () => {
|
|
146
|
+
const result = profileIdForName(" -*- Night Build -*- ", EMPTY);
|
|
147
|
+
expect(result).toEqual({
|
|
148
|
+
ok: true,
|
|
149
|
+
id: "night-build",
|
|
150
|
+
name: "-*- Night Build -*-",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("C-2: a derived id contains only [a-z0-9] and single dashes", () => {
|
|
155
|
+
const result = profileIdForName("Q3 / Mobile & Web — Beta!!", EMPTY);
|
|
156
|
+
expect(result.ok).toBe(true);
|
|
157
|
+
if (!result.ok) return;
|
|
158
|
+
expect(result.id).toBe("q3-mobile-web-beta");
|
|
159
|
+
expect(result.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("profileIdForName — collisions (C-4, REQ-3)", () => {
|
|
164
|
+
test("C-4: a name deriving an existing id is refused, naming that profile's display name", () => {
|
|
165
|
+
expect(profileIdForName("lean", MANIFEST)).toEqual({
|
|
166
|
+
ok: false,
|
|
167
|
+
reason: 'A profile called "Lean" already exists. Choose another name.',
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("C-4: the collision is judged on the derived id, whatever the case or padding", () => {
|
|
172
|
+
for (const input of ["LEAN", " Lean ", "lean!", "--lean--"]) {
|
|
173
|
+
expect(profileIdForName(input, MANIFEST)).toEqual({
|
|
174
|
+
ok: false,
|
|
175
|
+
reason: 'A profile called "Lean" already exists. Choose another name.',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("C-4: the reason quotes the EXISTING profile's name, not what the user typed", () => {
|
|
181
|
+
expect(profileIdForName("FULL", MANIFEST)).toEqual({
|
|
182
|
+
ok: false,
|
|
183
|
+
reason:
|
|
184
|
+
'A profile called "Full Stack" already exists. Choose another name.',
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("C-4: a profile with no name is quoted by its id", () => {
|
|
189
|
+
expect(profileIdForName("Default", NAMELESS)).toEqual({
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: 'A profile called "default" already exists. Choose another name.',
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("REQ-3: a colliding name is never silently moved to another id", () => {
|
|
196
|
+
// The old flow proposed `default-2`; a typed name that collides must be
|
|
197
|
+
// refused, not renumbered.
|
|
198
|
+
const result = profileIdForName("default", NAMELESS);
|
|
199
|
+
expect(result.ok).toBe(false);
|
|
200
|
+
expect(result).not.toHaveProperty("id");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("C-4: matching an existing DISPLAY name under a different id is not a collision", () => {
|
|
204
|
+
// "Full Stack" is the name of id `full`; its derived id is `full-stack`,
|
|
205
|
+
// which is not a key — the rule compares ids only.
|
|
206
|
+
expect(profileIdForName("Full Stack", MANIFEST)).toEqual({
|
|
207
|
+
ok: true,
|
|
208
|
+
id: "full-stack",
|
|
209
|
+
name: "Full Stack",
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("C-4: a name whose id merely starts with an existing id is fine", () => {
|
|
214
|
+
expect(profileIdForName("Lean 2", MANIFEST)).toEqual({
|
|
215
|
+
ok: true,
|
|
216
|
+
id: "lean-2",
|
|
217
|
+
name: "Lean 2",
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("profileIdForName — purity (REQ-3)", () => {
|
|
223
|
+
test("REQ-3: it does not modify the manifest and answers the same twice", () => {
|
|
224
|
+
const before = structuredClone(MANIFEST);
|
|
225
|
+
const first = profileIdForName("New One", MANIFEST);
|
|
226
|
+
const second = profileIdForName("New One", MANIFEST);
|
|
227
|
+
expect(first).toEqual(second);
|
|
228
|
+
expect(MANIFEST).toEqual(before);
|
|
229
|
+
// A valid name is not registered anywhere by being checked.
|
|
230
|
+
expect(Object.keys(MANIFEST.profiles)).toEqual(["lean", "full"]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("the save row (REQ-1, REQ-6)", () => {
|
|
235
|
+
const unmigrated = {
|
|
236
|
+
state: "unmigrated" as const,
|
|
237
|
+
adopt: "default",
|
|
238
|
+
activate: [],
|
|
239
|
+
manifest: EMPTY,
|
|
240
|
+
};
|
|
241
|
+
const inactive = {
|
|
242
|
+
state: "inactive" as const,
|
|
243
|
+
adopt: "default-2",
|
|
244
|
+
activate: [
|
|
245
|
+
{ id: "lean", name: "Lean", pluginCount: 1 },
|
|
246
|
+
{ id: "full", name: "Full Stack", pluginCount: 2 },
|
|
247
|
+
],
|
|
248
|
+
manifest: MANIFEST,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
for (const need of [unmigrated, inactive]) {
|
|
252
|
+
test(`REQ-1/REQ-6: in the ${need.state} state the save row reads exactly "${SAVE_LABEL}"`, () => {
|
|
253
|
+
const save = buildProfileOptions(need).find(
|
|
254
|
+
(o) => o.value === ADOPT_VALUE,
|
|
255
|
+
);
|
|
256
|
+
expect(save).toBeDefined();
|
|
257
|
+
expect(save?.label).toBe(SAVE_LABEL);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test(`REQ-1: in the ${need.state} state the save row does not show the generated id`, () => {
|
|
261
|
+
const save = buildProfileOptions(need).find(
|
|
262
|
+
(o) => o.value === ADOPT_VALUE,
|
|
263
|
+
);
|
|
264
|
+
expect(save?.label).not.toContain(need.adopt);
|
|
265
|
+
expect(save?.label).not.toContain('"');
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("applyProfileChoice writes the user's name (REQ-4, REQ-6, C-6)", () => {
|
|
271
|
+
let project: string;
|
|
272
|
+
let wasTTY: boolean | undefined;
|
|
273
|
+
let log: ReturnType<typeof spyOn>;
|
|
274
|
+
let err: ReturnType<typeof spyOn>;
|
|
275
|
+
|
|
276
|
+
const enable = (ids: string[]) =>
|
|
277
|
+
fs.outputJson(join(project, ".claude", "settings.json"), {
|
|
278
|
+
enabledPlugins: Object.fromEntries(ids.map((id) => [id, true])),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
beforeEach(async () => {
|
|
282
|
+
project = await mkdtemp(join(tmpdir(), "name-on-save-"));
|
|
283
|
+
wasTTY = process.stdin.isTTY;
|
|
284
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
285
|
+
value: false,
|
|
286
|
+
configurable: true,
|
|
287
|
+
});
|
|
288
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
289
|
+
err = spyOn(console, "error").mockImplementation(() => {});
|
|
290
|
+
});
|
|
291
|
+
afterEach(async () => {
|
|
292
|
+
log.mockRestore();
|
|
293
|
+
err.mockRestore();
|
|
294
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
295
|
+
value: wasTTY,
|
|
296
|
+
configurable: true,
|
|
297
|
+
});
|
|
298
|
+
await rm(project, { recursive: true, force: true });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("REQ-4: adopt with a name writes that name under the given id", async () => {
|
|
302
|
+
await writeManifest(MANIFEST, project);
|
|
303
|
+
await enable(["code-search@magus"]);
|
|
304
|
+
|
|
305
|
+
const choice: ProfileChoice = {
|
|
306
|
+
kind: "adopt",
|
|
307
|
+
id: "my-team",
|
|
308
|
+
name: "My Team",
|
|
309
|
+
};
|
|
310
|
+
const result = await applyProfileChoice(choice, MANIFEST, project, {
|
|
311
|
+
onDisplace: () => {},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(result.profileId).toBe("my-team");
|
|
315
|
+
const written = await readManifest(project);
|
|
316
|
+
expect(written.profiles["my-team"]).toBeDefined();
|
|
317
|
+
expect(written.profiles["my-team"]?.name).toBe("My Team");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("REQ-4: adopting under a new name leaves every existing profile untouched", async () => {
|
|
321
|
+
await writeManifest(MANIFEST, project);
|
|
322
|
+
await enable(["code-search@magus"]);
|
|
323
|
+
|
|
324
|
+
await applyProfileChoice(
|
|
325
|
+
{ kind: "adopt", id: "my-team", name: "My Team" },
|
|
326
|
+
MANIFEST,
|
|
327
|
+
project,
|
|
328
|
+
{ onDisplace: () => {} },
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const written = await readManifest(project);
|
|
332
|
+
expect(written.profiles.lean).toEqual(MANIFEST.profiles.lean);
|
|
333
|
+
expect(written.profiles.full).toEqual(MANIFEST.profiles.full);
|
|
334
|
+
expect(Object.keys(written.profiles).sort()).toEqual([
|
|
335
|
+
"full",
|
|
336
|
+
"lean",
|
|
337
|
+
"my-team",
|
|
338
|
+
]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("REQ-4/REQ-6: inactive state — detect, choose save, name it, apply", async () => {
|
|
342
|
+
await writeManifest(MANIFEST, project);
|
|
343
|
+
await enable(["code-search@magus"]);
|
|
344
|
+
|
|
345
|
+
const need = await detectProfileStartupNeed(project);
|
|
346
|
+
expect(need.state).toBe("inactive");
|
|
347
|
+
const picked = parseProfileChoice(ADOPT_VALUE, need);
|
|
348
|
+
expect(picked).toEqual({ kind: "adopt", id: need.adopt as string });
|
|
349
|
+
|
|
350
|
+
const check = profileIdForName(" Release Candidate ", need.manifest);
|
|
351
|
+
expect(check).toEqual({
|
|
352
|
+
ok: true,
|
|
353
|
+
id: "release-candidate",
|
|
354
|
+
name: "Release Candidate",
|
|
355
|
+
});
|
|
356
|
+
if (!check.ok || !picked) throw new Error("unreachable");
|
|
357
|
+
|
|
358
|
+
const result = await applyProfileChoice(
|
|
359
|
+
{ ...picked, id: check.id, name: check.name } as ProfileChoice,
|
|
360
|
+
need.manifest,
|
|
361
|
+
project,
|
|
362
|
+
{ onDisplace: () => {} },
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
expect(result.profileId).toBe("release-candidate");
|
|
366
|
+
const written = await readManifest(project);
|
|
367
|
+
expect(written.profiles["release-candidate"]?.name).toBe(
|
|
368
|
+
"Release Candidate",
|
|
369
|
+
);
|
|
370
|
+
// No auto-numbered profile was created alongside it.
|
|
371
|
+
expect(written.profiles[need.adopt as string]).toBeUndefined();
|
|
372
|
+
expect(written.profiles.lean).toEqual(MANIFEST.profiles.lean);
|
|
373
|
+
expect(written.profiles.full).toEqual(MANIFEST.profiles.full);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("REQ-4/REQ-6: unmigrated state — the first profile gets the typed name", async () => {
|
|
377
|
+
// No profiles.json at all.
|
|
378
|
+
await enable(["dev@magus"]);
|
|
379
|
+
|
|
380
|
+
const need = await detectProfileStartupNeed(project);
|
|
381
|
+
expect(need.state).toBe("unmigrated");
|
|
382
|
+
|
|
383
|
+
const check = profileIdForName("Personal Setup", need.manifest);
|
|
384
|
+
expect(check).toEqual({
|
|
385
|
+
ok: true,
|
|
386
|
+
id: "personal-setup",
|
|
387
|
+
name: "Personal Setup",
|
|
388
|
+
});
|
|
389
|
+
if (!check.ok) throw new Error("unreachable");
|
|
390
|
+
|
|
391
|
+
const result = await applyProfileChoice(
|
|
392
|
+
{ kind: "adopt", id: check.id, name: check.name },
|
|
393
|
+
need.manifest,
|
|
394
|
+
project,
|
|
395
|
+
{ onDisplace: () => {} },
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
expect(result.profileId).toBe("personal-setup");
|
|
399
|
+
const written = await readManifest(project);
|
|
400
|
+
expect(Object.keys(written.profiles)).toEqual(["personal-setup"]);
|
|
401
|
+
expect(written.profiles["personal-setup"]?.name).toBe("Personal Setup");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("REQ-4: accepting the suggestion writes the suggested id as the name", async () => {
|
|
405
|
+
await writeManifest(MANIFEST, project);
|
|
406
|
+
await enable(["code-search@magus"]);
|
|
407
|
+
|
|
408
|
+
const need = await detectProfileStartupNeed(project);
|
|
409
|
+
const suggestion = need.adopt as string;
|
|
410
|
+
const check = profileIdForName(suggestion, need.manifest);
|
|
411
|
+
expect(check).toEqual({ ok: true, id: suggestion, name: suggestion });
|
|
412
|
+
if (!check.ok) throw new Error("unreachable");
|
|
413
|
+
|
|
414
|
+
await applyProfileChoice(
|
|
415
|
+
{ kind: "adopt", id: check.id, name: check.name },
|
|
416
|
+
need.manifest,
|
|
417
|
+
project,
|
|
418
|
+
{ onDisplace: () => {} },
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
expect((await readManifest(project)).profiles[suggestion]?.name).toBe(
|
|
422
|
+
suggestion,
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("C-6: adopt without a name still creates the profile under its id", async () => {
|
|
427
|
+
await writeManifest(MANIFEST, project);
|
|
428
|
+
await enable(["code-search@magus"]);
|
|
429
|
+
|
|
430
|
+
const result = await applyProfileChoice(
|
|
431
|
+
{ kind: "adopt", id: "default" },
|
|
432
|
+
MANIFEST,
|
|
433
|
+
project,
|
|
434
|
+
{ onDisplace: () => {} },
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
expect(result.profileId).toBe("default");
|
|
438
|
+
const written = await readManifest(project);
|
|
439
|
+
expect(written.profiles.default).toBeDefined();
|
|
440
|
+
expect(written.profiles.lean).toEqual(MANIFEST.profiles.lean);
|
|
441
|
+
expect(written.profiles.full).toEqual(MANIFEST.profiles.full);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
@@ -373,7 +373,7 @@ describe("what the user is offered", () => {
|
|
|
373
373
|
// "Create <id> from this project" described the mechanism. The reason to
|
|
374
374
|
// pick this row is that it is the only one that keeps what is turned on.
|
|
375
375
|
expect(buildProfileOptions(inactive)[0]?.label).toBe(
|
|
376
|
-
|
|
376
|
+
"Save what you have as a new profile",
|
|
377
377
|
);
|
|
378
378
|
});
|
|
379
379
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI prompts at end of input.
|
|
3
|
+
*
|
|
4
|
+
* Written blind — from the spec and the export signatures of src/cli/prompt.ts
|
|
5
|
+
* only. `promptValue(question)` and `askYesNo(question)` take no stream, so they
|
|
6
|
+
* read the process's own stdin: each case runs them in a CHILD `bun` process
|
|
7
|
+
* whose stdin is a pipe this test writes to and then closes. The child writes
|
|
8
|
+
* its outcome to a result file; the prompt text it prints is ignored.
|
|
9
|
+
*
|
|
10
|
+
* REQ-B1 stdin ends before a line is answered → promptValue rejects with
|
|
11
|
+
* PromptAborted (instanceof, and isPromptAborted(err) is true),
|
|
12
|
+
* instead of waiting forever.
|
|
13
|
+
* REQ-B2 same for askYesNo.
|
|
14
|
+
* REQ-B3 stdin that stays open a while and THEN ends still rejects.
|
|
15
|
+
* REQ-B4 a line followed by a newline is still returned; promptValue trims it.
|
|
16
|
+
* REQ-B5 askYesNo still answers a line followed by a newline.
|
|
17
|
+
*
|
|
18
|
+
* Each child has HOME, XDG_* and the cwd inside a temp dir and runs with
|
|
19
|
+
* `--no-env-file`, so nothing here reads or writes the real home or a project.
|
|
20
|
+
*/
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
22
|
+
import {
|
|
23
|
+
mkdir,
|
|
24
|
+
mkdtemp,
|
|
25
|
+
readFile,
|
|
26
|
+
realpath,
|
|
27
|
+
rm,
|
|
28
|
+
writeFile,
|
|
29
|
+
} from "node:fs/promises";
|
|
30
|
+
import { tmpdir } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
const PROMPT_MODULE = join(import.meta.dir, "..", "cli", "prompt.ts");
|
|
34
|
+
|
|
35
|
+
/** Bounded wait for a child. A hang past this is the "waits forever" bug. */
|
|
36
|
+
const CHILD_TIMEOUT_MS = 8_000;
|
|
37
|
+
|
|
38
|
+
const CHILD = `
|
|
39
|
+
const [fn, question, out] = process.argv.slice(2);
|
|
40
|
+
const mod = await import(${JSON.stringify(PROMPT_MODULE)});
|
|
41
|
+
let result;
|
|
42
|
+
try {
|
|
43
|
+
const value = await mod[fn](question);
|
|
44
|
+
result = { outcome: "resolved", value };
|
|
45
|
+
} catch (err) {
|
|
46
|
+
result = {
|
|
47
|
+
outcome: "rejected",
|
|
48
|
+
name: err?.name ?? null,
|
|
49
|
+
isPromptAbortedClass: err instanceof mod.PromptAborted,
|
|
50
|
+
isPromptAborted: mod.isPromptAborted(err),
|
|
51
|
+
message: String(err?.message ?? err),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
await Bun.write(out, JSON.stringify(result));
|
|
55
|
+
process.exit(0);
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
type Outcome =
|
|
59
|
+
| { outcome: "resolved"; value: unknown }
|
|
60
|
+
| {
|
|
61
|
+
outcome: "rejected";
|
|
62
|
+
name: string | null;
|
|
63
|
+
isPromptAbortedClass: boolean;
|
|
64
|
+
isPromptAborted: boolean;
|
|
65
|
+
message: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
interface ChildRun {
|
|
69
|
+
result: Outcome | null;
|
|
70
|
+
timedOut: boolean;
|
|
71
|
+
exitCode: number | null;
|
|
72
|
+
stderr: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let root: string;
|
|
76
|
+
let childScript: string;
|
|
77
|
+
let runs = 0;
|
|
78
|
+
|
|
79
|
+
beforeAll(async () => {
|
|
80
|
+
root = await realpath(await mkdtemp(join(tmpdir(), "prompt-eof-")));
|
|
81
|
+
childScript = join(root, "child.ts");
|
|
82
|
+
await writeFile(childScript, CHILD);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await rm(root, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
async function runPrompt(
|
|
90
|
+
fn: "promptValue" | "askYesNo",
|
|
91
|
+
input: string,
|
|
92
|
+
opts: { holdOpenMs?: number } = {},
|
|
93
|
+
): Promise<ChildRun> {
|
|
94
|
+
const dir = join(root, `run-${++runs}`);
|
|
95
|
+
const home = join(dir, "home");
|
|
96
|
+
await mkdir(home, { recursive: true });
|
|
97
|
+
const out = join(dir, "result.json");
|
|
98
|
+
|
|
99
|
+
const proc = Bun.spawn(
|
|
100
|
+
[process.execPath, "--no-env-file", childScript, fn, "Question? ", out],
|
|
101
|
+
{
|
|
102
|
+
cwd: dir,
|
|
103
|
+
stdin: "pipe",
|
|
104
|
+
stdout: "pipe",
|
|
105
|
+
stderr: "pipe",
|
|
106
|
+
env: {
|
|
107
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
108
|
+
HOME: home,
|
|
109
|
+
XDG_CONFIG_HOME: join(dir, "xdg-config"),
|
|
110
|
+
XDG_CACHE_HOME: join(dir, "xdg-cache"),
|
|
111
|
+
XDG_DATA_HOME: join(dir, "xdg-data"),
|
|
112
|
+
XDG_STATE_HOME: join(dir, "xdg-state"),
|
|
113
|
+
TMPDIR: tmpdir(),
|
|
114
|
+
NO_COLOR: "1",
|
|
115
|
+
TERM: "dumb",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (input.length > 0) proc.stdin.write(input);
|
|
121
|
+
await proc.stdin.flush();
|
|
122
|
+
if (opts.holdOpenMs) await Bun.sleep(opts.holdOpenMs);
|
|
123
|
+
await proc.stdin.end();
|
|
124
|
+
|
|
125
|
+
let timedOut = false;
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
timedOut = true;
|
|
128
|
+
proc.kill();
|
|
129
|
+
}, CHILD_TIMEOUT_MS);
|
|
130
|
+
const exitCode = await proc.exited;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
const stderr = await new Response(proc.stderr).text();
|
|
133
|
+
|
|
134
|
+
let result: Outcome | null = null;
|
|
135
|
+
try {
|
|
136
|
+
result = JSON.parse(await readFile(out, "utf8")) as Outcome;
|
|
137
|
+
} catch {
|
|
138
|
+
result = null;
|
|
139
|
+
}
|
|
140
|
+
return { result, timedOut, exitCode, stderr };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function expectAborted(run: ChildRun): void {
|
|
144
|
+
expect({
|
|
145
|
+
timedOut: run.timedOut,
|
|
146
|
+
stderr: run.timedOut ? run.stderr : "",
|
|
147
|
+
}).toEqual({
|
|
148
|
+
timedOut: false,
|
|
149
|
+
stderr: "",
|
|
150
|
+
});
|
|
151
|
+
if (run.result === null) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`child produced no result (exit ${run.exitCode}):\n${run.stderr}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
expect(run.result.outcome).toBe("rejected");
|
|
157
|
+
if (run.result.outcome !== "rejected") return;
|
|
158
|
+
expect(run.result.name).toBe("PromptAborted");
|
|
159
|
+
expect(run.result.isPromptAbortedClass).toBe(true);
|
|
160
|
+
expect(run.result.isPromptAborted).toBe(true);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function expectResolved(run: ChildRun, value: unknown): void {
|
|
164
|
+
expect(run.timedOut).toBe(false);
|
|
165
|
+
if (run.result === null) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`child produced no result (exit ${run.exitCode}):\n${run.stderr}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
expect(run.result).toEqual({ outcome: "resolved", value });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
describe("promptValue at end of input", () => {
|
|
174
|
+
test(
|
|
175
|
+
"REQ-B1 rejects with PromptAborted when stdin closes with nothing on it",
|
|
176
|
+
async () => {
|
|
177
|
+
expectAborted(await runPrompt("promptValue", ""));
|
|
178
|
+
},
|
|
179
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
test(
|
|
183
|
+
"REQ-B3 rejects with PromptAborted when stdin stays open, then closes unanswered",
|
|
184
|
+
async () => {
|
|
185
|
+
expectAborted(await runPrompt("promptValue", "", { holdOpenMs: 300 }));
|
|
186
|
+
},
|
|
187
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
test(
|
|
191
|
+
"REQ-B4 returns a newline-terminated line, trimmed",
|
|
192
|
+
async () => {
|
|
193
|
+
expectResolved(
|
|
194
|
+
await runPrompt("promptValue", " team-profile \n"),
|
|
195
|
+
"team-profile",
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
test(
|
|
202
|
+
"REQ-B4 returns the first line when stdin then ends",
|
|
203
|
+
async () => {
|
|
204
|
+
expectResolved(
|
|
205
|
+
await runPrompt("promptValue", "hello\n", { holdOpenMs: 100 }),
|
|
206
|
+
"hello",
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("askYesNo at end of input", () => {
|
|
214
|
+
test(
|
|
215
|
+
"REQ-B2 rejects with PromptAborted when stdin closes with nothing on it",
|
|
216
|
+
async () => {
|
|
217
|
+
expectAborted(await runPrompt("askYesNo", ""));
|
|
218
|
+
},
|
|
219
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
test(
|
|
223
|
+
"REQ-B3 rejects with PromptAborted when stdin stays open, then closes unanswered",
|
|
224
|
+
async () => {
|
|
225
|
+
expectAborted(await runPrompt("askYesNo", "", { holdOpenMs: 300 }));
|
|
226
|
+
},
|
|
227
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
test(
|
|
231
|
+
'REQ-B5 answers "y" + newline with true',
|
|
232
|
+
async () => {
|
|
233
|
+
expectResolved(await runPrompt("askYesNo", "y\n"), true);
|
|
234
|
+
},
|
|
235
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
test(
|
|
239
|
+
'REQ-B5 answers "n" + newline with false',
|
|
240
|
+
async () => {
|
|
241
|
+
expectResolved(await runPrompt("askYesNo", "n\n"), false);
|
|
242
|
+
},
|
|
243
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
244
|
+
);
|
|
245
|
+
});
|