magus-cli 7.5.4 → 7.7.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__/format-update-outcome.test.ts +9 -8
- package/src/__tests__/gitignore-profile.test.ts +9 -5
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/notices-rules.test.ts +476 -0
- package/src/__tests__/notices.test.tsx +604 -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/__tests__/update-apply.test.ts +3 -3
- package/src/__tests__/useStaleLoadModal.test.ts +27 -12
- 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/gitignore-service.ts +0 -19
- package/src/services/profile-absorb.ts +9 -0
- package/src/services/profile-activate.ts +4 -2
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +91 -18
- package/src/services/profile-state.ts +10 -1
- package/src/ui/App.tsx +295 -141
- package/src/ui/adapters/pluginsAdapter.ts +28 -9
- package/src/ui/components/modals/MessageModal.tsx +1 -2
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/hooks/useGitignoreModal.ts +58 -18
- package/src/ui/hooks/useProfileStartupModal.ts +232 -85
- package/src/ui/hooks/useStaleLoadModal.ts +43 -29
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/screens/AliasScreen.tsx +7 -6
- package/src/ui/screens/CliToolsScreen.tsx +19 -16
- package/src/ui/screens/EnvVarsScreen.tsx +4 -1
- package/src/ui/screens/GitignoreScreen.tsx +20 -27
- package/src/ui/screens/McpRegistryScreen.tsx +9 -6
- package/src/ui/screens/McpScreen.tsx +16 -12
- package/src/ui/screens/PluginsScreen.tsx +35 -56
- package/src/ui/screens/ProfilesScreen.tsx +27 -51
- package/src/ui/state/AppContext.tsx +46 -3
- package/src/ui/state/reducer.ts +100 -1
- package/src/ui/state/types.ts +53 -2
|
@@ -789,11 +789,11 @@ describe("applyPlugins — read-back against beforeVersions", () => {
|
|
|
789
789
|
reasons.reporter,
|
|
790
790
|
);
|
|
791
791
|
expect(result.unchanged).toEqual(["dev@magus"]);
|
|
792
|
-
expect(reasons.lines(result.unchanged)).
|
|
792
|
+
expect(reasons.lines(result.unchanged)).toEqual([
|
|
793
793
|
"dev@magus: the installer exited 0 but dev@magus is still 1.0.0",
|
|
794
|
-
);
|
|
794
|
+
]);
|
|
795
795
|
// An item with no recorded cause still gets a line.
|
|
796
|
-
expect(reasons.lines(["other@magus"])).
|
|
796
|
+
expect(reasons.lines(["other@magus"])).toEqual(["other@magus"]);
|
|
797
797
|
});
|
|
798
798
|
});
|
|
799
799
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
buildStaleLoadModal,
|
|
4
|
-
|
|
4
|
+
staleLoadRepairNotices,
|
|
5
5
|
} from "../ui/hooks/useStaleLoadModal";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -138,19 +138,24 @@ describe("buildStaleLoadModal", () => {
|
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
describe("
|
|
142
|
-
it("says what loads NOW for
|
|
141
|
+
describe("staleLoadRepairNotices", () => {
|
|
142
|
+
it("says what loads NOW for the repaired plugins, in one line", () => {
|
|
143
143
|
expect(
|
|
144
|
-
|
|
144
|
+
staleLoadRepairNotices([
|
|
145
145
|
{ pluginId: "madbench@magus", target: "0.5.0", ok: true },
|
|
146
146
|
{ pluginId: "dev@magus", target: "7.4.0", ok: true },
|
|
147
147
|
]),
|
|
148
|
-
).
|
|
148
|
+
).toEqual([
|
|
149
|
+
{
|
|
150
|
+
text: "Now loading madbench@magus v0.5.0, dev@magus v7.4.0",
|
|
151
|
+
tone: "success",
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
149
154
|
});
|
|
150
155
|
|
|
151
156
|
it("reports a failed repair with its own reason, never as a success", () => {
|
|
152
157
|
expect(
|
|
153
|
-
|
|
158
|
+
staleLoadRepairNotices([
|
|
154
159
|
{
|
|
155
160
|
pluginId: "madbench@magus",
|
|
156
161
|
target: "0.5.0",
|
|
@@ -158,20 +163,30 @@ describe("staleLoadRepairSummary", () => {
|
|
|
158
163
|
error: "the loader still resolves v0.3.0 here, not v0.5.0",
|
|
159
164
|
},
|
|
160
165
|
]),
|
|
161
|
-
).
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
).toEqual([
|
|
167
|
+
{
|
|
168
|
+
text: "madbench@magus not repaired: the loader still resolves v0.3.0 here, not v0.5.0",
|
|
169
|
+
tone: "warning",
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
164
172
|
});
|
|
165
173
|
|
|
166
174
|
it("falls back to a plain reason rather than an empty line", () => {
|
|
167
175
|
expect(
|
|
168
|
-
|
|
176
|
+
staleLoadRepairNotices([
|
|
169
177
|
{ pluginId: "a@magus", target: "1.0.0", ok: false },
|
|
170
178
|
]),
|
|
171
|
-
).
|
|
179
|
+
).toEqual([
|
|
180
|
+
{
|
|
181
|
+
text: "a@magus not repaired: repair did not take effect",
|
|
182
|
+
tone: "warning",
|
|
183
|
+
},
|
|
184
|
+
]);
|
|
172
185
|
});
|
|
173
186
|
|
|
174
187
|
it("says so when there was nothing to repair", () => {
|
|
175
|
-
expect(
|
|
188
|
+
expect(staleLoadRepairNotices([])).toEqual([
|
|
189
|
+
{ text: "Nothing needed repairing", tone: "success" },
|
|
190
|
+
]);
|
|
176
191
|
});
|
|
177
192
|
});
|
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
|
/**
|
|
@@ -93,25 +93,6 @@ export async function applyStartupFixes(
|
|
|
93
93
|
return result;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/** What {@link applyStartupFixes} did, for the message the TUI shows. */
|
|
97
|
-
export function describeStartupFix(result: StartupFixResult): string {
|
|
98
|
-
const lines: string[] = [];
|
|
99
|
-
if (result.untracked.length > 0) {
|
|
100
|
-
lines.push(
|
|
101
|
-
"Stopped tracking (the removal is staged — commit it):",
|
|
102
|
-
...result.untracked.map((p) => ` ${p}`),
|
|
103
|
-
' git commit -m "Stop tracking generated Claude Code config"',
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
if (result.appended.length > 0) {
|
|
107
|
-
lines.push("Added to .gitignore:", ...result.appended.map((p) => ` ${p}`));
|
|
108
|
-
}
|
|
109
|
-
for (const f of result.failed) {
|
|
110
|
-
lines.push(`Could not untrack ${f.path}: ${f.reason}`);
|
|
111
|
-
}
|
|
112
|
-
return lines.length > 0 ? lines.join("\n") : "Nothing needed changing.";
|
|
113
|
-
}
|
|
114
|
-
|
|
115
96
|
export async function applyAllSafeFixes(
|
|
116
97
|
cwd: string,
|
|
117
98
|
violations: Violation[],
|
|
@@ -59,6 +59,7 @@ import { readManifest, writeManifest } from "./manifest.js";
|
|
|
59
59
|
import { validateModelsConfig } from "./models-core.js";
|
|
60
60
|
import {
|
|
61
61
|
type ActivateOptions,
|
|
62
|
+
type ActivationResult,
|
|
62
63
|
activateProfile,
|
|
63
64
|
activationNotes,
|
|
64
65
|
releasedCommittedSkillNote,
|
|
@@ -588,6 +589,12 @@ export interface ReconcileResult {
|
|
|
588
589
|
conflicts: string[];
|
|
589
590
|
/** Everything worth printing, in order. */
|
|
590
591
|
lines: string[];
|
|
592
|
+
/**
|
|
593
|
+
* The re-render, when profiles.json changed and the live config was
|
|
594
|
+
* generated again. `lines` already carries its CLI wording; this is for a
|
|
595
|
+
* surface that words it differently (the TUI's notices).
|
|
596
|
+
*/
|
|
597
|
+
activation: ActivationResult | null;
|
|
591
598
|
}
|
|
592
599
|
|
|
593
600
|
function emptyResult(profile: string | null): ReconcileResult {
|
|
@@ -600,6 +607,7 @@ function emptyResult(profile: string | null): ReconcileResult {
|
|
|
600
607
|
unreadable: [],
|
|
601
608
|
conflicts: [],
|
|
602
609
|
lines: [],
|
|
610
|
+
activation: null,
|
|
603
611
|
};
|
|
604
612
|
}
|
|
605
613
|
|
|
@@ -1529,6 +1537,7 @@ export async function reconcileActiveProfile(
|
|
|
1529
1537
|
// command that ran it reports that once, itself, and repeating it here
|
|
1530
1538
|
// printed the same warning three times in one `magus update`.
|
|
1531
1539
|
notes.push(...activationNotes(activation, { tracked: false }));
|
|
1540
|
+
result.activation = activation;
|
|
1532
1541
|
} catch (error) {
|
|
1533
1542
|
result.problems.push(
|
|
1534
1543
|
error instanceof Error ? error.message : String(error),
|
|
@@ -209,7 +209,8 @@ export function releasedCommittedSkillNote(folder: string): string {
|
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
211
|
* Lines describing what activation did beyond the switch itself, phrased for
|
|
212
|
-
*
|
|
212
|
+
* the terminal. The TUI words its own, shorter set (`activationNotices` in
|
|
213
|
+
* ui/hooks/useProfileStartupModal.ts).
|
|
213
214
|
*
|
|
214
215
|
* `tracked: false` leaves the tracked-path notices out, for the CLI, which
|
|
215
216
|
* prints them itself as a warning with the fix beneath it
|
|
@@ -516,5 +517,6 @@ export async function recordRefetchedSkills(
|
|
|
516
517
|
|
|
517
518
|
/** The two ways a project can be put onto a profile. */
|
|
518
519
|
export type ProfileChoice =
|
|
519
|
-
|
|
520
|
+
/** `name` is what the user typed; without it the adopted default name is used. */
|
|
521
|
+
| { kind: "adopt"; id: string; name?: string }
|
|
520
522
|
| { 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
|
|
|
@@ -69,7 +69,11 @@ import {
|
|
|
69
69
|
findEnclosingManifest,
|
|
70
70
|
readManifest,
|
|
71
71
|
} from "./manifest.js";
|
|
72
|
-
import
|
|
72
|
+
import {
|
|
73
|
+
type ActivationResult,
|
|
74
|
+
type ProfileChoice,
|
|
75
|
+
activationNotes,
|
|
76
|
+
} from "./profile-activate.js";
|
|
73
77
|
import { freeProfileId } from "./profile-adopt.js";
|
|
74
78
|
import {
|
|
75
79
|
activeProfile,
|
|
@@ -310,7 +314,10 @@ export async function oldFormatNeed(
|
|
|
310
314
|
/** What the TUI shows at startup, beyond the question (if any). */
|
|
311
315
|
export interface ProfileStartup {
|
|
312
316
|
need: ProfileStartupNeed;
|
|
313
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* One line per thing the gate did: absorbed edits, regeneration, repairs.
|
|
319
|
+
* Not what an activation reported — see `activations`.
|
|
320
|
+
*/
|
|
314
321
|
notices: string[];
|
|
315
322
|
/**
|
|
316
323
|
* One line per thing the gate REFUSED — the lines the CLI stops on. Kept
|
|
@@ -319,6 +326,14 @@ export interface ProfileStartup {
|
|
|
319
326
|
* still names its profile, so only this says the update did not happen.
|
|
320
327
|
*/
|
|
321
328
|
problems: string[];
|
|
329
|
+
/**
|
|
330
|
+
* The activations the gate ran (a re-render, a format update). Handed over
|
|
331
|
+
* as results rather than lines because the CLI's lines are the wrong words
|
|
332
|
+
* for the TUI: they name plugin.json and backups, and they say "will not
|
|
333
|
+
* load" in a line nothing marks as a warning. The TUI words them itself, in
|
|
334
|
+
* user terms and with a tone (`activationWarnings` in the startup hook).
|
|
335
|
+
*/
|
|
336
|
+
activations: ActivationResult[];
|
|
322
337
|
}
|
|
323
338
|
|
|
324
339
|
/**
|
|
@@ -338,14 +353,24 @@ export async function prepareProfileStartup(
|
|
|
338
353
|
): Promise<ProfileStartup> {
|
|
339
354
|
if (!opts.convert) {
|
|
340
355
|
const format = await oldFormatNeed(projectPath);
|
|
341
|
-
if (format !== null)
|
|
356
|
+
if (format !== null) {
|
|
357
|
+
return { need: format, notices: [], problems: [], activations: [] };
|
|
358
|
+
}
|
|
342
359
|
}
|
|
343
360
|
const notices: string[] = [];
|
|
344
361
|
const problems: string[] = [];
|
|
362
|
+
const activations: ActivationResult[] = [];
|
|
345
363
|
try {
|
|
346
364
|
const prep = await prepareProfileState(projectPath);
|
|
347
|
-
|
|
365
|
+
// The gate's lines, less the ones its activations contributed: those
|
|
366
|
+
// reach the TUI as `activations`, and passing both would say the same
|
|
367
|
+
// thing twice — once in the CLI's words, as if it were a success.
|
|
368
|
+
const fromActivations = new Set(
|
|
369
|
+
prep.activations.flatMap((a) => activationNotes(a, { tracked: false })),
|
|
370
|
+
);
|
|
371
|
+
notices.push(...prep.lines.filter((line) => !fromActivations.has(line)));
|
|
348
372
|
problems.push(...prep.problems);
|
|
373
|
+
activations.push(...prep.activations);
|
|
349
374
|
} catch (error) {
|
|
350
375
|
if (!(error instanceof SettingsParseError)) {
|
|
351
376
|
problems.push(error instanceof Error ? error.message : String(error));
|
|
@@ -355,13 +380,17 @@ export async function prepareProfileStartup(
|
|
|
355
380
|
need: await detectProfileStartupNeed(projectPath),
|
|
356
381
|
notices,
|
|
357
382
|
problems,
|
|
383
|
+
activations,
|
|
358
384
|
};
|
|
359
385
|
}
|
|
360
386
|
|
|
361
|
-
/**
|
|
387
|
+
/**
|
|
388
|
+
* What the TUI shows once "Update it now" has run: another question, a notice
|
|
389
|
+
* line, or an error modal.
|
|
390
|
+
*/
|
|
362
391
|
export type FormatUpdateOutcome =
|
|
363
392
|
| { kind: "ask"; need: ProfileStartupNeed }
|
|
364
|
-
| { kind: "updated";
|
|
393
|
+
| { kind: "updated"; notice: string }
|
|
365
394
|
| { kind: "failed"; title: string; message: string };
|
|
366
395
|
|
|
367
396
|
/**
|
|
@@ -381,14 +410,12 @@ export function formatUpdateOutcome(
|
|
|
381
410
|
};
|
|
382
411
|
}
|
|
383
412
|
if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
: "This project now uses the current profile format.",
|
|
391
|
-
};
|
|
413
|
+
// One line, not the gate's notices: those are the conversion's working log
|
|
414
|
+
// (edits carried over, the old format's backup), and the result the user
|
|
415
|
+
// cares about is only that the update is done. What the conversion's
|
|
416
|
+
// activation warns about is NOT dropped: the caller words it from
|
|
417
|
+
// `result.activations`, as it does for any activation.
|
|
418
|
+
return { kind: "updated", notice: "Profile format updated" };
|
|
392
419
|
}
|
|
393
420
|
|
|
394
421
|
// ─── What to offer, as data both surfaces render ─────────────────────────────
|
|
@@ -426,9 +453,10 @@ export const UPDATE_FORMAT_VALUE = "update-format";
|
|
|
426
453
|
* Neither mentions the backup, deliberately. A sentence promising "nothing is
|
|
427
454
|
* deleted — the old files are moved to a backup" raises a question the reader
|
|
428
455
|
* did not have (which files? what was at risk?) and implies data loss, when what
|
|
429
|
-
* changes is settings.
|
|
430
|
-
*
|
|
431
|
-
*
|
|
456
|
+
* changes is settings. The TUI does not name the backup afterwards either: the
|
|
457
|
+
* backup is magus's own storage, and the user acts on nothing there. Only a
|
|
458
|
+
* failed activation lists what was moved, because then it is something to act
|
|
459
|
+
* on (see `describeFailure` in ui/hooks/useProfileStartupModal.ts).
|
|
432
460
|
*
|
|
433
461
|
* No file paths in these strings, deliberately. The TUI modal soft-wraps its
|
|
434
462
|
* message at whatever width the terminal gives it, and OpenTUI treats `.` as a
|
|
@@ -555,7 +583,11 @@ export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
|
|
|
555
583
|
// Kept short on purpose: this is the longest label in the list, and the
|
|
556
584
|
// meta column is measured against the longest one. "…and use it" made it
|
|
557
585
|
// 41 cells and pushed every plugin count onto a line of its own.
|
|
558
|
-
|
|
586
|
+
//
|
|
587
|
+
// No id in it. The generated one read as a copy of an existing profile
|
|
588
|
+
// ("default-2" as "overwrite Default"); the name is asked for once this
|
|
589
|
+
// row is picked, with that id only as the suggestion.
|
|
590
|
+
label: "Save what you have as a new profile",
|
|
559
591
|
value: ADOPT_VALUE,
|
|
560
592
|
});
|
|
561
593
|
}
|
|
@@ -595,6 +627,47 @@ export function defaultOptionIndex(need: ProfileStartupNeed): number {
|
|
|
595
627
|
return at === -1 ? 0 : at;
|
|
596
628
|
}
|
|
597
629
|
|
|
630
|
+
/** A typed profile name, judged: the id it becomes, or why it cannot. */
|
|
631
|
+
export type ProfileNameCheck =
|
|
632
|
+
| { ok: true; id: string; name: string }
|
|
633
|
+
| { ok: false; reason: string };
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Validate a profile name typed by the user and derive its id. Pure.
|
|
637
|
+
*
|
|
638
|
+
* The one rule both surfaces ask with, so the CLI and the TUI cannot accept
|
|
639
|
+
* different names. A clash is refused, never resolved: quietly saving as
|
|
640
|
+
* `<id>-2` is the very thing that made users read the old generated label as
|
|
641
|
+
* "overwrite Default".
|
|
642
|
+
*/
|
|
643
|
+
export function profileIdForName(
|
|
644
|
+
input: string,
|
|
645
|
+
manifest: ProfileManifest,
|
|
646
|
+
): ProfileNameCheck {
|
|
647
|
+
const name = input.trim();
|
|
648
|
+
if (name === "") {
|
|
649
|
+
return { ok: false, reason: "Enter a name for the profile." };
|
|
650
|
+
}
|
|
651
|
+
const id = name
|
|
652
|
+
.toLowerCase()
|
|
653
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
654
|
+
.replace(/^-+|-+$/g, "");
|
|
655
|
+
if (id === "") {
|
|
656
|
+
return { ok: false, reason: "Use at least one letter or digit." };
|
|
657
|
+
}
|
|
658
|
+
// Own keys only: a name like "constructor" is free, not a clash with the
|
|
659
|
+
// object prototype.
|
|
660
|
+
if (Object.hasOwn(manifest.profiles, id)) {
|
|
661
|
+
// Named as the user knows it; a hand-written entry may carry no name.
|
|
662
|
+
const known = manifest.profiles[id]?.name || id;
|
|
663
|
+
return {
|
|
664
|
+
ok: false,
|
|
665
|
+
reason: `A profile called "${known}" already exists. Choose another name.`,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
return { ok: true, id, name };
|
|
669
|
+
}
|
|
670
|
+
|
|
598
671
|
/**
|
|
599
672
|
* Turn a chosen option value into the action it means.
|
|
600
673
|
*
|
|
@@ -299,6 +299,12 @@ export interface ProfileStatePrep {
|
|
|
299
299
|
problems: string[];
|
|
300
300
|
/** The files behind `problems` that could not be parsed. */
|
|
301
301
|
unreadable: SettingsParseError[];
|
|
302
|
+
/**
|
|
303
|
+
* Every activation the gate ran, in order: a re-render after profiles.json
|
|
304
|
+
* changed, an older layout converted. `lines` already carries them in the
|
|
305
|
+
* CLI's words; the TUI words them itself from these.
|
|
306
|
+
*/
|
|
307
|
+
activations: ActivationResult[];
|
|
302
308
|
}
|
|
303
309
|
|
|
304
310
|
/**
|
|
@@ -319,11 +325,13 @@ export async function prepareProfileState(
|
|
|
319
325
|
lines: [],
|
|
320
326
|
problems: [],
|
|
321
327
|
unreadable: [],
|
|
328
|
+
activations: [],
|
|
322
329
|
};
|
|
323
330
|
const take = (r: ReconcileResult) => {
|
|
324
331
|
out.lines.push(...r.lines.filter((l) => !r.problems.includes(l)));
|
|
325
332
|
out.problems.push(...r.problems);
|
|
326
333
|
out.unreadable.push(...r.unreadable);
|
|
334
|
+
if (r.activation) out.activations.push(r.activation);
|
|
327
335
|
};
|
|
328
336
|
|
|
329
337
|
if (check.state === "active" && check.restored) {
|
|
@@ -358,6 +366,7 @@ export async function prepareProfileState(
|
|
|
358
366
|
}
|
|
359
367
|
// Tracked files are the command's to report, once (see profile-absorb).
|
|
360
368
|
out.lines.push(...activationNotes(result, { tracked: false }));
|
|
369
|
+
out.activations.push(result);
|
|
361
370
|
}
|
|
362
371
|
out.check = { state: "active", profile, restored: false };
|
|
363
372
|
}
|
|
@@ -556,7 +565,7 @@ export async function applyProfileChoice(
|
|
|
556
565
|
}
|
|
557
566
|
const result = await adoptCurrentProject(
|
|
558
567
|
projectPath,
|
|
559
|
-
|
|
568
|
+
choice.name,
|
|
560
569
|
manifest,
|
|
561
570
|
choice.id,
|
|
562
571
|
);
|