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.
@@ -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
- const choice = value === undefined ? null : parseProfileChoice(value, need);
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
  }
@@ -214,11 +214,13 @@ export function scopeBadge(scope: ScopeName): string {
214
214
  }
215
215
 
216
216
  /**
217
- * The prompt for a yes/no question, in the glyph column: `? <question> [Y/n] `.
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("[Y/n]")} `;
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
- | { kind: "adopt"; id: string }
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[ADOPTED_PROFILE_ID]) return ADOPTED_PROFILE_ID;
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[id]) return id;
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
- label: `Save what you have as "${need.adopt}"`,
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
  *
@@ -556,7 +556,7 @@ export async function applyProfileChoice(
556
556
  }
557
557
  const result = await adoptCurrentProject(
558
558
  projectPath,
559
- undefined,
559
+ choice.name,
560
560
  manifest,
561
561
  choice.id,
562
562
  );
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(onExit, checkGitignoreNow);
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
- // Show gitignore modal — wait for the modal slot to be free so we don't
328
- // stomp on another modal that may have just been shown.
329
- useEffect(() => {
330
- if (!gitignoreData) return;
331
- if (state.modal) return;
332
- gitignoreModal.show({
333
- violationCount: gitignoreData.violationCount,
334
- safeCount: gitignoreData.safeCount,
335
- generated: gitignoreData.generated,
336
- // In place, then back to the app: tracked generated files are
337
- // untracked, missing entries added; other items stay for the tab.
338
- onFixSafe: async () =>
339
- describeStartupFix(await applyStartupFixes(state.projectPath)),
340
- });
341
- setGitignoreData(null);
342
- }, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
343
-
344
- // Show the broken-plugin modal — same "wait for the modal slot" rule as the
345
- // gitignore one above, so two startup findings queue instead of one stomping
346
- // the other.
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
- staleLoadModal.show({
351
- brokenCount: brokenPluginCount,
352
- // Re-surveys before repairing, like the gitignore fixer: the registry may
353
- // have moved between the startup scan and the user pressing Enter, and
354
- // repairing from the stale count would act on rows that are no longer
355
- // there. `repairAllStaleLoads` is the same `repairStaleLoad` the Plugins
356
- // screen's `a` key drives through the apply engine.
357
- //
358
- // The pins are re-read with it, from the same helper the survey used: a
359
- // repair that capped at a different ceiling than the offer was counted
360
- // against would move rows the dialog never mentioned.
361
- onFix: async () =>
362
- staleLoadRepairSummary(
363
- await repairAllStaleLoads(
364
- state.projectPath,
365
- undefined,
366
- await activePluginPins(state.projectPath),
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
- setBrokenPluginCount(null);
371
- }, [brokenPluginCount, staleLoadModal, state.modal, state.projectPath]);
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
- dispatch({ type: "HIDE_MODAL" });
102
- void Promise.resolve(args.onFixSafe())
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
- if (!report) return;
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: {