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
package/src/cli/bootstrap.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
needsNothing,
|
|
36
36
|
oldFormatNeed,
|
|
37
37
|
parseProfileChoice,
|
|
38
|
+
profileIdForName,
|
|
38
39
|
} from "../services/profile-startup.js";
|
|
39
40
|
import {
|
|
40
41
|
applyProfileChoice,
|
|
@@ -47,7 +48,7 @@ import { SettingsParseError } from "../services/settings-file.js";
|
|
|
47
48
|
import type { ProfileManifest } from "../types/index.js";
|
|
48
49
|
import { brand } from "../ui/theme.js";
|
|
49
50
|
import { badge, bold, dim } from "./ansi.js";
|
|
50
|
-
import { askYesNo } from "./prompt.js";
|
|
51
|
+
import { askYesNo, isPromptAborted, promptValue } from "./prompt.js";
|
|
51
52
|
import { selectOption } from "./select.js";
|
|
52
53
|
import {
|
|
53
54
|
type ScopeName,
|
|
@@ -447,6 +448,31 @@ export async function ensureManifest(
|
|
|
447
448
|
return result.manifest;
|
|
448
449
|
}
|
|
449
450
|
|
|
451
|
+
/**
|
|
452
|
+
* The name for a profile saved from what the project has, or null when the
|
|
453
|
+
* user gave none (Ctrl+C, or input ended). Empty accepts `suggestion`. An
|
|
454
|
+
* invalid name is explained and asked again — never swapped for another id.
|
|
455
|
+
*/
|
|
456
|
+
async function askProfileName(
|
|
457
|
+
suggestion: string,
|
|
458
|
+
manifest: ProfileManifest,
|
|
459
|
+
): Promise<{ id: string; name: string } | null> {
|
|
460
|
+
for (;;) {
|
|
461
|
+
let typed: string;
|
|
462
|
+
try {
|
|
463
|
+
typed = await promptValue(
|
|
464
|
+
question("Name for the new profile", `[${suggestion}]`),
|
|
465
|
+
);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (isPromptAborted(error)) return null;
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
const check = profileIdForName(typed === "" ? suggestion : typed, manifest);
|
|
471
|
+
if (check.ok) return { id: check.id, name: check.name };
|
|
472
|
+
console.log(warnLine(check.reason));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
450
476
|
/**
|
|
451
477
|
* Require an active profile, or stop the command.
|
|
452
478
|
*
|
|
@@ -545,7 +571,13 @@ export async function requireActiveProfile(
|
|
|
545
571
|
// "Quit magus", Esc and Ctrl+C all land here, and there is no third
|
|
546
572
|
// outcome: the command stops rather than proceed without a profile.
|
|
547
573
|
const value = picked === null ? undefined : options[picked]?.value;
|
|
548
|
-
|
|
574
|
+
let choice = value === undefined ? null : parseProfileChoice(value, need);
|
|
575
|
+
// Saving asks for the name, and an unanswered name is an unanswered
|
|
576
|
+
// question like any other: nothing is created and the command stops.
|
|
577
|
+
if (choice?.kind === "adopt") {
|
|
578
|
+
const named = await askProfileName(choice.id, need.manifest);
|
|
579
|
+
choice = named === null ? null : { kind: "adopt", ...named };
|
|
580
|
+
}
|
|
549
581
|
if (!choice) {
|
|
550
582
|
console.error(failLine("No profile chosen — stopping."));
|
|
551
583
|
return null;
|
package/src/cli/prompt.ts
CHANGED
|
@@ -39,15 +39,25 @@ export function isPromptAborted(error: unknown): boolean {
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** One readline question, with Ctrl+C normalised. */
|
|
42
|
+
/** One readline question, with Ctrl+C and end of input normalised. */
|
|
43
43
|
async function ask(question: string): Promise<string> {
|
|
44
44
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
+
// End of input (Ctrl+D, or a pipe that ran dry) closes the interface and
|
|
46
|
+
// never settles `question()` — MEASURED under Bun, it waits forever. Input
|
|
47
|
+
// that ends before an answer is a cancellation, the same as Ctrl+C.
|
|
48
|
+
let settled = false;
|
|
49
|
+
const closed = new Promise<never>((_, reject) => {
|
|
50
|
+
rl.once("close", () => {
|
|
51
|
+
if (!settled) reject(new PromptAborted());
|
|
52
|
+
});
|
|
53
|
+
});
|
|
45
54
|
try {
|
|
46
|
-
return (await rl.question(question)).trim();
|
|
55
|
+
return (await Promise.race([rl.question(question), closed])).trim();
|
|
47
56
|
} catch (error) {
|
|
48
57
|
if (isPromptAborted(error)) throw new PromptAborted();
|
|
49
58
|
throw error;
|
|
50
59
|
} finally {
|
|
60
|
+
settled = true;
|
|
51
61
|
rl.close();
|
|
52
62
|
}
|
|
53
63
|
}
|
package/src/cli/update-view.ts
CHANGED
|
@@ -214,11 +214,13 @@ export function scopeBadge(scope: ScopeName): string {
|
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
/**
|
|
217
|
-
* The prompt for a
|
|
218
|
-
* Printed by readline, so the answer is typed on the same line.
|
|
217
|
+
* The prompt for a question, in the glyph column: `? <question> [Y/n] `.
|
|
218
|
+
* Printed by readline, so the answer is typed on the same line. `hint` is what
|
|
219
|
+
* an empty answer means — yes by default, or the suggested value for a
|
|
220
|
+
* free-text question.
|
|
219
221
|
*/
|
|
220
|
-
export function question(text: string): string {
|
|
221
|
-
return `${PAD}${fg(brand.accent, "?")} ${bold(text)} ${dim(
|
|
222
|
+
export function question(text: string, hint = "[Y/n]"): string {
|
|
223
|
+
return `${PAD}${fg(brand.accent, "?")} ${bold(text)} ${dim(hint)} `;
|
|
222
224
|
}
|
|
223
225
|
|
|
224
226
|
/**
|
|
@@ -516,5 +516,6 @@ export async function recordRefetchedSkills(
|
|
|
516
516
|
|
|
517
517
|
/** The two ways a project can be put onto a profile. */
|
|
518
518
|
export type ProfileChoice =
|
|
519
|
-
|
|
519
|
+
/** `name` is what the user typed; without it the adopted default name is used. */
|
|
520
|
+
| { kind: "adopt"; id: string; name?: string }
|
|
520
521
|
| { kind: "activate"; id: string };
|
|
@@ -36,12 +36,19 @@ export const ADOPTED_PROFILE_ID = "default";
|
|
|
36
36
|
* Lives here rather than at a call site because both surfaces adopt now — the
|
|
37
37
|
* CLI prompt and the TUI's startup offer — and two copies of "which id is
|
|
38
38
|
* free?" is two chances to overwrite somebody's profile.
|
|
39
|
+
*
|
|
40
|
+
* Own keys, the same test `profileIdForName` applies to the name the user
|
|
41
|
+
* submits. A truthiness test called a hand-written `"default": null` free, so
|
|
42
|
+
* the suggestion offered in the name field was the one name it then refused,
|
|
43
|
+
* and Enter looped on it.
|
|
39
44
|
*/
|
|
40
45
|
export function freeProfileId(manifest: ProfileManifest): string {
|
|
41
|
-
if (!manifest.profiles
|
|
46
|
+
if (!Object.hasOwn(manifest.profiles, ADOPTED_PROFILE_ID)) {
|
|
47
|
+
return ADOPTED_PROFILE_ID;
|
|
48
|
+
}
|
|
42
49
|
for (let n = 2; ; n++) {
|
|
43
50
|
const id = `${ADOPTED_PROFILE_ID}-${n}`;
|
|
44
|
-
if (!manifest.profiles
|
|
51
|
+
if (!Object.hasOwn(manifest.profiles, id)) return id;
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -555,7 +555,11 @@ export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
|
|
|
555
555
|
// Kept short on purpose: this is the longest label in the list, and the
|
|
556
556
|
// meta column is measured against the longest one. "…and use it" made it
|
|
557
557
|
// 41 cells and pushed every plugin count onto a line of its own.
|
|
558
|
-
|
|
558
|
+
//
|
|
559
|
+
// No id in it. The generated one read as a copy of an existing profile
|
|
560
|
+
// ("default-2" as "overwrite Default"); the name is asked for once this
|
|
561
|
+
// row is picked, with that id only as the suggestion.
|
|
562
|
+
label: "Save what you have as a new profile",
|
|
559
563
|
value: ADOPT_VALUE,
|
|
560
564
|
});
|
|
561
565
|
}
|
|
@@ -595,6 +599,47 @@ export function defaultOptionIndex(need: ProfileStartupNeed): number {
|
|
|
595
599
|
return at === -1 ? 0 : at;
|
|
596
600
|
}
|
|
597
601
|
|
|
602
|
+
/** A typed profile name, judged: the id it becomes, or why it cannot. */
|
|
603
|
+
export type ProfileNameCheck =
|
|
604
|
+
| { ok: true; id: string; name: string }
|
|
605
|
+
| { ok: false; reason: string };
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Validate a profile name typed by the user and derive its id. Pure.
|
|
609
|
+
*
|
|
610
|
+
* The one rule both surfaces ask with, so the CLI and the TUI cannot accept
|
|
611
|
+
* different names. A clash is refused, never resolved: quietly saving as
|
|
612
|
+
* `<id>-2` is the very thing that made users read the old generated label as
|
|
613
|
+
* "overwrite Default".
|
|
614
|
+
*/
|
|
615
|
+
export function profileIdForName(
|
|
616
|
+
input: string,
|
|
617
|
+
manifest: ProfileManifest,
|
|
618
|
+
): ProfileNameCheck {
|
|
619
|
+
const name = input.trim();
|
|
620
|
+
if (name === "") {
|
|
621
|
+
return { ok: false, reason: "Enter a name for the profile." };
|
|
622
|
+
}
|
|
623
|
+
const id = name
|
|
624
|
+
.toLowerCase()
|
|
625
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
626
|
+
.replace(/^-+|-+$/g, "");
|
|
627
|
+
if (id === "") {
|
|
628
|
+
return { ok: false, reason: "Use at least one letter or digit." };
|
|
629
|
+
}
|
|
630
|
+
// Own keys only: a name like "constructor" is free, not a clash with the
|
|
631
|
+
// object prototype.
|
|
632
|
+
if (Object.hasOwn(manifest.profiles, id)) {
|
|
633
|
+
// Named as the user knows it; a hand-written entry may carry no name.
|
|
634
|
+
const known = manifest.profiles[id]?.name || id;
|
|
635
|
+
return {
|
|
636
|
+
ok: false,
|
|
637
|
+
reason: `A profile called "${known}" already exists. Choose another name.`,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return { ok: true, id, name };
|
|
641
|
+
}
|
|
642
|
+
|
|
598
643
|
/**
|
|
599
644
|
* Turn a chosen option value into the action it means.
|
|
600
645
|
*
|
package/src/ui/App.tsx
CHANGED
|
@@ -287,9 +287,15 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
287
287
|
// non-fatal: the Git State tab still shows the same rows
|
|
288
288
|
});
|
|
289
289
|
}, []);
|
|
290
|
+
// Stable, so `profileModal.show` is not rebuilt for it on every render.
|
|
291
|
+
const profileAnswered = React.useCallback(() => setProfileNeed(null), []);
|
|
290
292
|
// The profile modal cannot be dismissed, so it needs the app's own exit: its
|
|
291
293
|
// only non-action is to leave.
|
|
292
|
-
const profileModal = useProfileStartupModal(
|
|
294
|
+
const profileModal = useProfileStartupModal({
|
|
295
|
+
onQuit: onExit,
|
|
296
|
+
onActivated: checkGitignoreNow,
|
|
297
|
+
onAnswered: profileAnswered,
|
|
298
|
+
});
|
|
293
299
|
|
|
294
300
|
// Check for updates on startup, then re-check periodically (force-bypassing
|
|
295
301
|
// the session cache) so the banner reflects a release that lands while
|
|
@@ -324,51 +330,91 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
324
330
|
return () => clearTimeout(timer);
|
|
325
331
|
}, [recoveryReport]);
|
|
326
332
|
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
333
|
+
// The startup questions — gitignore, then broken plugins, then profile — are
|
|
334
|
+
// asked ONE at a time, from ONE effect, and only while the modal slot is free.
|
|
335
|
+
//
|
|
336
|
+
// They used to be three effects, each checking `state.modal` from the same
|
|
337
|
+
// render. Two needs arriving in one commit both saw an empty slot, both
|
|
338
|
+
// showed, and the later SHOW_MODAL replaced the earlier question after its
|
|
339
|
+
// need had already been cleared — so it never came back. The profile question
|
|
340
|
+
// was lost exactly that way, and the user updated plugins believing a profile
|
|
341
|
+
// was active. So: show at most one per run and return; the next question
|
|
342
|
+
// follows once that one's last modal is dismissed and the slot is empty
|
|
343
|
+
// again.
|
|
344
|
+
//
|
|
345
|
+
// The hooks keep the slot occupied for their whole flow (a loading modal
|
|
346
|
+
// while their fix runs, never HIDE_MODAL mid-flow), because an empty slot is
|
|
347
|
+
// what lets the next question in. That does not stop a SCREEN from emptying
|
|
348
|
+
// it: `useModal().confirm` hides its modal when answered and the screen shows
|
|
349
|
+
// its loading modal a microtask later. A key pressed before the profile check
|
|
350
|
+
// resolved lands the profile question in exactly that gap, and the screen's
|
|
351
|
+
// loading modal then replaces it.
|
|
352
|
+
//
|
|
353
|
+
// So the two kinds of question are cleared at different points.
|
|
354
|
+
//
|
|
355
|
+
// - The profile need is cleared when it is ANSWERED (`profileAnswered`, from
|
|
356
|
+
// the hook: a profile activated, or quit), never when shown. The modal
|
|
357
|
+
// cannot be dismissed, so an empty slot while the need is still set means
|
|
358
|
+
// it was lost, and this effect puts it back. The hook keeps what was last
|
|
359
|
+
// asked and whether work is in flight, so a re-show re-asks the question
|
|
360
|
+
// that was on screen and never starts a second activation.
|
|
361
|
+
// - The gitignore and broken-plugin needs are still cleared on show. Both
|
|
362
|
+
// can be dismissed, so an empty slot does not mean they were lost —
|
|
363
|
+
// re-showing would re-ask a question the user just declined. Losing one
|
|
364
|
+
// costs nothing lasting: the Git State and Plugins tabs show the same rows,
|
|
365
|
+
// and gitignore is asked again after a profile activates.
|
|
347
366
|
useEffect(() => {
|
|
348
|
-
if (brokenPluginCount === null) return;
|
|
349
367
|
if (state.modal) return;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
368
|
+
if (gitignoreData) {
|
|
369
|
+
gitignoreModal.show({
|
|
370
|
+
violationCount: gitignoreData.violationCount,
|
|
371
|
+
safeCount: gitignoreData.safeCount,
|
|
372
|
+
generated: gitignoreData.generated,
|
|
373
|
+
// In place, then back to the app: tracked generated files are
|
|
374
|
+
// untracked, missing entries added; other items stay for the tab.
|
|
375
|
+
onFixSafe: async () =>
|
|
376
|
+
describeStartupFix(await applyStartupFixes(state.projectPath)),
|
|
377
|
+
});
|
|
378
|
+
setGitignoreData(null);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (brokenPluginCount !== null) {
|
|
382
|
+
staleLoadModal.show({
|
|
383
|
+
brokenCount: brokenPluginCount,
|
|
384
|
+
// Re-surveys before repairing, like the gitignore fixer: the registry
|
|
385
|
+
// may have moved between the startup scan and the user pressing Enter,
|
|
386
|
+
// and repairing from the stale count would act on rows that are no
|
|
387
|
+
// longer there. `repairAllStaleLoads` is the same `repairStaleLoad` the
|
|
388
|
+
// Plugins screen's `a` key drives through the apply engine.
|
|
389
|
+
//
|
|
390
|
+
// The pins are re-read with it, from the same helper the survey used: a
|
|
391
|
+
// repair that capped at a different ceiling than the offer was counted
|
|
392
|
+
// against would move rows the dialog never mentioned.
|
|
393
|
+
onFix: async () =>
|
|
394
|
+
staleLoadRepairSummary(
|
|
395
|
+
await repairAllStaleLoads(
|
|
396
|
+
state.projectPath,
|
|
397
|
+
undefined,
|
|
398
|
+
await activePluginPins(state.projectPath),
|
|
399
|
+
),
|
|
367
400
|
),
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
401
|
+
});
|
|
402
|
+
setBrokenPluginCount(null);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Not cleared here — see above. Re-called on every empty slot until the
|
|
406
|
+
// hook reports it answered.
|
|
407
|
+
if (profileNeed) profileModal.show(profileNeed);
|
|
408
|
+
}, [
|
|
409
|
+
state.modal,
|
|
410
|
+
state.projectPath,
|
|
411
|
+
gitignoreData,
|
|
412
|
+
gitignoreModal,
|
|
413
|
+
brokenPluginCount,
|
|
414
|
+
staleLoadModal,
|
|
415
|
+
profileNeed,
|
|
416
|
+
profileModal,
|
|
417
|
+
]);
|
|
372
418
|
|
|
373
419
|
// Does this project load an older plugin than it installed? Detection only;
|
|
374
420
|
// nothing is written until the user chooses Fix.
|
|
@@ -393,17 +439,6 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
393
439
|
})();
|
|
394
440
|
}, []);
|
|
395
441
|
|
|
396
|
-
// Show the profile modal on the same terms as the gitignore one: only once
|
|
397
|
-
// the modal slot is free, so two startup checks that both found something do
|
|
398
|
-
// not stomp on each other. Gitignore is asked first simply because its
|
|
399
|
-
// effect is declared first; whichever the user dismisses, the other follows.
|
|
400
|
-
useEffect(() => {
|
|
401
|
-
if (!profileNeed) return;
|
|
402
|
-
if (state.modal) return;
|
|
403
|
-
profileModal.show(profileNeed);
|
|
404
|
-
setProfileNeed(null);
|
|
405
|
-
}, [profileNeed, profileModal, state.modal]);
|
|
406
|
-
|
|
407
442
|
// The gitignore question on every open. Its own effect, NOT part of the
|
|
408
443
|
// marketplace block below, which returns early on `--no-refresh`: that flag
|
|
409
444
|
// is about network calls and must not silence this question.
|
|
@@ -34,6 +34,9 @@ const isPlain = (key: KeyPress) => !key.ctrl && !key.meta;
|
|
|
34
34
|
* question that must be answered).
|
|
35
35
|
* - input: Esc cancels; everything else goes to the focused <input>, which
|
|
36
36
|
* OpenTUI delivers after this layer and which submits on Enter.
|
|
37
|
+
* Only an <input> that was ALREADY focused when the key arrived:
|
|
38
|
+
* the Enter that opens an input modal from a select never reaches
|
|
39
|
+
* it (KeyRouter's `dispatchThenGuardFocus`).
|
|
37
40
|
* - message: any key dismisses.
|
|
38
41
|
* - loading: keys are swallowed until the work finishes.
|
|
39
42
|
* - help: ↑↓ / PgUp / PgDn / Ctrl+U / Ctrl+D scroll; Esc or `?` close.
|
|
@@ -98,10 +98,28 @@ export function useGitignoreModal() {
|
|
|
98
98
|
safeCount: args.safeCount,
|
|
99
99
|
generated: args.generated,
|
|
100
100
|
onFixSafe: () => {
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
// A loading modal, NOT HIDE_MODAL. The fix is async, and an empty
|
|
102
|
+
// modal slot is App's signal that the next startup question may be
|
|
103
|
+
// asked: hiding here let the profile question show during the await,
|
|
104
|
+
// be marked as asked, and then be replaced by "Gitignore fixed" —
|
|
105
|
+
// lost for the whole session. The slot stays occupied until the
|
|
106
|
+
// result below is dismissed.
|
|
107
|
+
dispatch({
|
|
108
|
+
type: "SHOW_MODAL",
|
|
109
|
+
modal: { type: "loading", message: "Fixing gitignore…" },
|
|
110
|
+
});
|
|
111
|
+
// Called inside `.then`, not passed to `Promise.resolve`: a fixer
|
|
112
|
+
// that throws synchronously must land in `.catch` below too, or
|
|
113
|
+
// the loading modal above stays up and swallows every key.
|
|
114
|
+
void Promise.resolve()
|
|
115
|
+
.then(() => args.onFixSafe())
|
|
103
116
|
.then((report) => {
|
|
104
|
-
|
|
117
|
+
// Nothing to report still has to clear the loading modal: it
|
|
118
|
+
// swallows every key, so leaving it would hang the app.
|
|
119
|
+
if (!report) {
|
|
120
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
105
123
|
dispatch({
|
|
106
124
|
type: "SHOW_MODAL",
|
|
107
125
|
modal: {
|