magus-cli 7.1.0 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/cli/ansi.ts +39 -0
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +1 -0
- package/src/cli/update.ts +50 -7
- package/src/main.tsx +10 -1
- package/src/services/manifest.ts +21 -0
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/symlink-manager.ts +184 -30
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +60 -0
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +54 -0
package/src/cli/bootstrap.ts
CHANGED
|
@@ -8,17 +8,74 @@
|
|
|
8
8
|
* stands, then the original command proceeds against it normally.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import path from "node:path";
|
|
11
12
|
import {
|
|
12
13
|
getManifestPath,
|
|
13
14
|
readManifest,
|
|
14
15
|
writeManifest,
|
|
15
16
|
} from "../services/manifest.js";
|
|
17
|
+
import {
|
|
18
|
+
applyProfileChoice,
|
|
19
|
+
materializeAndActivate,
|
|
20
|
+
reportDisplaced,
|
|
21
|
+
} from "../services/profile-activate.js";
|
|
16
22
|
import {
|
|
17
23
|
type AdoptResult,
|
|
18
24
|
adoptCurrentProject,
|
|
19
25
|
} from "../services/profile-adopt.js";
|
|
26
|
+
import {
|
|
27
|
+
buildProfileOptions,
|
|
28
|
+
defaultOptionIndex,
|
|
29
|
+
describeNeed,
|
|
30
|
+
detectProfileStartupNeed,
|
|
31
|
+
needsNothing,
|
|
32
|
+
parseProfileChoice,
|
|
33
|
+
} from "../services/profile-startup.js";
|
|
20
34
|
import type { ProfileManifest } from "../types/index.js";
|
|
35
|
+
import { dim } from "./ansi.js";
|
|
21
36
|
import { confirm } from "./prompt.js";
|
|
37
|
+
import { selectDialog } from "./tui-prompt.js";
|
|
38
|
+
import { fail as failLine, note as noteLine } from "./update-view.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write the adopted manifest, then put the project ON the profile it describes.
|
|
42
|
+
*
|
|
43
|
+
* Both halves or neither. Writing alone is what made migrating feel like a
|
|
44
|
+
* no-op: `activeProfile()` is derived from the symlink, so a manifest nothing
|
|
45
|
+
* links to changes no behaviour, and the run that just migrated still has to
|
|
46
|
+
* ask which profile to use.
|
|
47
|
+
*
|
|
48
|
+
* Activation is reported in full, including anything it moved aside, because it
|
|
49
|
+
* replaces files the user wrote by hand. A migration is allowed to change the
|
|
50
|
+
* working tree; it is not allowed to do it quietly.
|
|
51
|
+
*/
|
|
52
|
+
async function writeAndActivate(
|
|
53
|
+
result: AdoptResult,
|
|
54
|
+
projectPath: string,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
// Relative to the project throughout. These are all project paths, and an
|
|
57
|
+
// absolute one wraps over two terminal lines — several wrapped lines in a row
|
|
58
|
+
// is where a reader stops reading, and this notice is the one that says where
|
|
59
|
+
// their hand-written files went.
|
|
60
|
+
const rel = (p: string) => path.relative(projectPath, p);
|
|
61
|
+
|
|
62
|
+
await writeManifest(result.manifest, projectPath);
|
|
63
|
+
console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
|
|
64
|
+
|
|
65
|
+
// Each move is printed as it happens, not from the returned list afterwards.
|
|
66
|
+
// Activation touches up to four artifacts in sequence, so a throw on the
|
|
67
|
+
// third would discard the record of the first two — and the first of those
|
|
68
|
+
// is the user's `.claude/settings.json`, now absent from where they left it.
|
|
69
|
+
// Printing first means a failed activation still says where everything went.
|
|
70
|
+
await materializeAndActivate(
|
|
71
|
+
result.manifest,
|
|
72
|
+
result.profileId,
|
|
73
|
+
projectPath,
|
|
74
|
+
reportDisplaced(projectPath),
|
|
75
|
+
);
|
|
76
|
+
console.log(`✓ Active profile: ${result.profileId}`);
|
|
77
|
+
console.log();
|
|
78
|
+
}
|
|
22
79
|
|
|
23
80
|
/** Print what adoption would capture, so the user can judge it before writing. */
|
|
24
81
|
function printAdoptionPlan(result: AdoptResult): void {
|
|
@@ -64,6 +121,16 @@ function printAdoptionPlan(result: AdoptResult): void {
|
|
|
64
121
|
console.log(
|
|
65
122
|
"\nCommit the file to share this setup with the team; edit it to pin versions.",
|
|
66
123
|
);
|
|
124
|
+
// Said before the question, not after it: adopting also SWITCHES the project
|
|
125
|
+
// onto the profile, which replaces two files that are in the working tree
|
|
126
|
+
// right now. The prompt below is the only place to decline.
|
|
127
|
+
console.log(
|
|
128
|
+
"The project then runs this profile: .claude/settings.json, .mcp.json and",
|
|
129
|
+
);
|
|
130
|
+
console.log(
|
|
131
|
+
".claude/skills become links into it, and your current copies are kept where",
|
|
132
|
+
);
|
|
133
|
+
console.log("the run reports.");
|
|
67
134
|
}
|
|
68
135
|
|
|
69
136
|
export interface EnsureManifestOptions {
|
|
@@ -123,12 +190,111 @@ export async function ensureManifest(
|
|
|
123
190
|
"\nRefusing to create .claude/profiles.json without a human: run `magus profile init` to create it deliberately.",
|
|
124
191
|
);
|
|
125
192
|
return null;
|
|
126
|
-
} else if (!(await confirm("\nCreate
|
|
193
|
+
} else if (!(await confirm("\nCreate it and switch to it?"))) {
|
|
127
194
|
console.log("Aborted — nothing written.");
|
|
128
195
|
return null;
|
|
129
196
|
}
|
|
130
197
|
|
|
131
|
-
await
|
|
132
|
-
console.log(`✓ Wrote ${getManifestPath(projectPath)}\n`);
|
|
198
|
+
await writeAndActivate(result, projectPath);
|
|
133
199
|
return result.manifest;
|
|
134
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Require an active profile, or stop the command.
|
|
204
|
+
*
|
|
205
|
+
* magus manages a project THROUGH its active profile: that is where plugin
|
|
206
|
+
* state is read from and where updates are written. With none there is nothing
|
|
207
|
+
* to read and nowhere to write, so there is no useful work to do — and the
|
|
208
|
+
* previous version of this offered exactly that anyway. It asked only when the
|
|
209
|
+
* manifest had drifted, let the user decline, and `update` then carried on with
|
|
210
|
+
* a profile "for this run only", resolving against a manifest the project was
|
|
211
|
+
* not running. Two worlds live at once, which is the confusion being removed.
|
|
212
|
+
*
|
|
213
|
+
* So this asks whenever no profile is active, and the only answers are migrate,
|
|
214
|
+
* activate, or stop. Returns the manifest to carry on with, or null when the
|
|
215
|
+
* command must exit non-zero — by which point it has printed why.
|
|
216
|
+
*
|
|
217
|
+
* Nothing is compared. An earlier version weighed the manifest against
|
|
218
|
+
* `enabledPlugins`; see the header of `services/profile-startup.ts` for why
|
|
219
|
+
* that reads a profiled project's own generated output and cannot be acted on.
|
|
220
|
+
*/
|
|
221
|
+
export async function requireActiveProfile(
|
|
222
|
+
projectPath: string,
|
|
223
|
+
manifest: ProfileManifest,
|
|
224
|
+
opts: EnsureManifestOptions = {},
|
|
225
|
+
): Promise<ProfileManifest | null> {
|
|
226
|
+
const need = await detectProfileStartupNeed(projectPath);
|
|
227
|
+
if (needsNothing(need)) return manifest;
|
|
228
|
+
|
|
229
|
+
// The repo declares which profile it wants and there is nothing of the
|
|
230
|
+
// user's to replace — a fresh clone. Activate it and carry on, even under
|
|
231
|
+
// `--check` and with no TTY: this is the committed answer being applied, not
|
|
232
|
+
// a decision being made, and it is exactly the case CI hits.
|
|
233
|
+
if (need.autoActivate && need.declaredDefault !== null && !opts.check) {
|
|
234
|
+
const { profileId } = await applyProfileChoice(
|
|
235
|
+
{ kind: "activate", id: need.declaredDefault },
|
|
236
|
+
need.manifest,
|
|
237
|
+
projectPath,
|
|
238
|
+
reportDisplaced(projectPath),
|
|
239
|
+
);
|
|
240
|
+
console.log(
|
|
241
|
+
`✓ Active profile: ${profileId} ${dim("(declared by this project)")}`,
|
|
242
|
+
);
|
|
243
|
+
console.log("");
|
|
244
|
+
return readManifest(projectPath);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const { title } = describeNeed(need);
|
|
248
|
+
|
|
249
|
+
// `--check` is a read-only CI gate, and "this project is not on a profile"
|
|
250
|
+
// is a legitimate thing for it to fail on rather than quietly repair.
|
|
251
|
+
if (opts.check) {
|
|
252
|
+
console.error(failLine(title));
|
|
253
|
+
console.error(noteLine("Run `magus update` at a terminal to resolve it."));
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// CI has nobody to ask. Exiting beats hanging on an answer that cannot come.
|
|
258
|
+
if (!process.stdin.isTTY) {
|
|
259
|
+
console.error(failLine(title));
|
|
260
|
+
console.error(
|
|
261
|
+
noteLine(
|
|
262
|
+
need.state === "unmigrated"
|
|
263
|
+
? "Run `magus profile init` to create one deliberately."
|
|
264
|
+
: `Run \`magus profile switch <${need.activate
|
|
265
|
+
.map((p) => p.id)
|
|
266
|
+
.join("|")}>\` first.`,
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log("");
|
|
273
|
+
const chosen = await selectDialog({
|
|
274
|
+
title,
|
|
275
|
+
message: describeNeed(need).message,
|
|
276
|
+
options: buildProfileOptions(need),
|
|
277
|
+
defaultIndex: defaultOptionIndex(need),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Esc, Ctrl+C and the "Quit magus" row all land here, and there is no third
|
|
281
|
+
// outcome: the command stops rather than proceed without a profile.
|
|
282
|
+
const choice = chosen === null ? null : parseProfileChoice(chosen, need);
|
|
283
|
+
if (!choice) {
|
|
284
|
+
console.error(failLine("No active profile — stopping."));
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const { profileId } = await applyProfileChoice(
|
|
289
|
+
choice,
|
|
290
|
+
need.manifest,
|
|
291
|
+
projectPath,
|
|
292
|
+
reportDisplaced(projectPath),
|
|
293
|
+
);
|
|
294
|
+
console.log(`✓ Active profile: ${profileId}`);
|
|
295
|
+
console.log("");
|
|
296
|
+
|
|
297
|
+
// Re-read rather than return `need.manifest`: adopting wrote a new profile
|
|
298
|
+
// into the file, and the caller resolves against whatever comes back.
|
|
299
|
+
return readManifest(projectPath);
|
|
300
|
+
}
|
package/src/cli/install.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "../services/install-plan.js";
|
|
28
28
|
import { validateManifest } from "../services/manifest.js";
|
|
29
29
|
import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
|
|
30
|
+
import { reportDisplaced } from "../services/profile-activate.js";
|
|
30
31
|
import { materializeProfile } from "../services/profile-materializer.js";
|
|
31
32
|
import { detectProfileDrift } from "../services/profile-sync.js";
|
|
32
33
|
import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
|
|
@@ -355,7 +356,10 @@ export async function runInstallCommand(
|
|
|
355
356
|
const activeId =
|
|
356
357
|
flags.profile ?? (await pickActiveProfile(profileIds, flags.yes));
|
|
357
358
|
if (activeId) {
|
|
358
|
-
|
|
359
|
+
// The reporter is not optional here. The first `install` in a project that
|
|
360
|
+
// had a hand-written .claude/settings.json moves it aside, and without
|
|
361
|
+
// this the move is invisible — the file is simply not where it was.
|
|
362
|
+
await activateProfile(activeId, projectPath, reportDisplaced(projectPath));
|
|
359
363
|
console.log(`\n✓ Active profile: ${activeId}`);
|
|
360
364
|
// Skills route through the now-active .claude/skills symlink.
|
|
361
365
|
const activeClosure = await resolveProfile(manifest, activeId);
|
package/src/cli/profile.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { ensureGitignoreEntries } from "../services/conventions-manager.js";
|
|
|
13
13
|
import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
|
|
14
14
|
import { summarizeClosure } from "../services/install-plan.js";
|
|
15
15
|
import { getManifestPath, readManifest } from "../services/manifest.js";
|
|
16
|
+
import { reportDisplaced } from "../services/profile-activate.js";
|
|
16
17
|
import { syncProfile } from "../services/profile-sync.js";
|
|
17
18
|
import { resolveProfile } from "../services/resolver.js";
|
|
18
19
|
import {
|
|
@@ -147,7 +148,10 @@ export async function switchProfile(
|
|
|
147
148
|
"# magus: generated profile build output + active-profile symlinks",
|
|
148
149
|
);
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
// Switching between two already-active profiles displaces nothing — the paths
|
|
152
|
+
// are links magus owns. The FIRST switch in an unmanaged project displaces
|
|
153
|
+
// real files, and that is the run that must say so.
|
|
154
|
+
await activateProfile(name, projectPath, reportDisplaced(projectPath));
|
|
151
155
|
console.log(`✓ Switched to profile: ${name}`);
|
|
152
156
|
|
|
153
157
|
// Verify the profile's binary deps are actually usable — the switch itself
|
package/src/cli/prompt.ts
CHANGED
|
@@ -8,25 +8,57 @@
|
|
|
8
8
|
|
|
9
9
|
import { createInterface } from "node:readline/promises";
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Ctrl+C at a prompt.
|
|
13
|
+
*
|
|
14
|
+
* `readline/promises` rejects `question()` with an `AbortError` carrying
|
|
15
|
+
* `code: "ABORT_ERR"`. That is a Node internal, and letting it escape printed
|
|
16
|
+
* nine frames of `internal:repl/node-errors` under "Error starting magus:" —
|
|
17
|
+
* a stack trace for the one input every terminal user knows how to give.
|
|
18
|
+
*
|
|
19
|
+
* It is converted here rather than at each call site so no future prompt can
|
|
20
|
+
* forget, and it stays a throw rather than a sentinel return so a caller that
|
|
21
|
+
* ignores it aborts instead of proceeding with a missing answer.
|
|
22
|
+
*/
|
|
23
|
+
export class PromptAborted extends Error {
|
|
24
|
+
constructor() {
|
|
25
|
+
super("Aborted with Ctrl+C");
|
|
26
|
+
this.name = "PromptAborted";
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
/**
|
|
25
|
-
export
|
|
30
|
+
/** True for a Ctrl+C at a prompt, ours or readline's own. */
|
|
31
|
+
export function isPromptAborted(error: unknown): boolean {
|
|
32
|
+
if (error instanceof PromptAborted) return true;
|
|
33
|
+
// Belt and braces: an abort raised somewhere that does not route through
|
|
34
|
+
// `ask` still reads as a cancellation rather than a crash.
|
|
35
|
+
return (
|
|
36
|
+
typeof error === "object" &&
|
|
37
|
+
error !== null &&
|
|
38
|
+
(error as { code?: unknown }).code === "ABORT_ERR"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One readline question, with Ctrl+C normalised. */
|
|
43
|
+
async function ask(question: string): Promise<string> {
|
|
26
44
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
27
45
|
try {
|
|
28
46
|
return (await rl.question(question)).trim();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (isPromptAborted(error)) throw new PromptAborted();
|
|
49
|
+
throw error;
|
|
29
50
|
} finally {
|
|
30
51
|
rl.close();
|
|
31
52
|
}
|
|
32
53
|
}
|
|
54
|
+
|
|
55
|
+
/** Y/n prompt. Empty input means yes. */
|
|
56
|
+
export async function confirm(question: string): Promise<boolean> {
|
|
57
|
+
const answer = (await ask(`${question} [Y/n] `)).toLowerCase();
|
|
58
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Free-text prompt, trimmed. */
|
|
62
|
+
export async function promptValue(question: string): Promise<string> {
|
|
63
|
+
return ask(question);
|
|
64
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One OpenTUI dialog, for a command that is otherwise not a TUI.
|
|
3
|
+
*
|
|
4
|
+
* `magus update` prints a report and exits; it is not the interactive app. But
|
|
5
|
+
* a question it must ask deserves the same dialog the TUI asks with, so this
|
|
6
|
+
* mounts the REAL `SelectModal` — the component tab 5 and tab 0 use — for the
|
|
7
|
+
* length of one question, then tears the renderer down and lets the command
|
|
8
|
+
* carry on printing. Nothing here re-draws a list, a border or a highlight: the
|
|
9
|
+
* component and the theme own all of that, and a second implementation is how
|
|
10
|
+
* the two surfaces drift apart.
|
|
11
|
+
*
|
|
12
|
+
* Every import of `@opentui/*` and `ui/` is DYNAMIC. `main.tsx` splits the TUI
|
|
13
|
+
* out precisely so `magus hook …`, which Claude Code runs on every matching
|
|
14
|
+
* tool call, never pays to load the renderer — a static import here would undo
|
|
15
|
+
* that for every command that imports this module's file, whether or not it
|
|
16
|
+
* ever asks a question.
|
|
17
|
+
*
|
|
18
|
+
* `screenMode: "main-screen"` keeps the dialog in the scrollback beside the
|
|
19
|
+
* report. The alternate screen would take the terminal over and then hand back
|
|
20
|
+
* a screen with no trace the question was ever asked.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { SelectOption } from "../ui/state/types.js";
|
|
24
|
+
|
|
25
|
+
export interface SelectDialogInput {
|
|
26
|
+
title: string;
|
|
27
|
+
message: string;
|
|
28
|
+
options: SelectOption[];
|
|
29
|
+
/** Row to start on. Defaults to the first. */
|
|
30
|
+
defaultIndex?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ask the user to pick one option. Resolves to the chosen value, or null when
|
|
35
|
+
* they cancel with Esc or Ctrl+C — the caller decides whether that is fatal.
|
|
36
|
+
*
|
|
37
|
+
* Falls back to `null` when stdin or stdout is not a TTY. A renderer needs both:
|
|
38
|
+
* `magus update > file` has a TTY stdin and a pipe for stdout, and mounting
|
|
39
|
+
* there writes escape sequences into the file.
|
|
40
|
+
*/
|
|
41
|
+
export async function selectDialog(
|
|
42
|
+
input: SelectDialogInput,
|
|
43
|
+
): Promise<string | null> {
|
|
44
|
+
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [{ createCliRenderer }, { createRoot }, React, modals, dimensions] =
|
|
49
|
+
await Promise.all([
|
|
50
|
+
import("@opentui/core"),
|
|
51
|
+
import("@opentui/react"),
|
|
52
|
+
import("react"),
|
|
53
|
+
import("../ui/components/modals/index.js"),
|
|
54
|
+
import("../ui/state/DimensionsContext.js"),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const renderer = await createCliRenderer({
|
|
58
|
+
screenMode: "main-screen",
|
|
59
|
+
// BOTH, so the renderer registers no handler of its own and teardown
|
|
60
|
+
// below is the only path. With either left at its default, a Ctrl+C
|
|
61
|
+
// destroys the renderer underneath this promise and it never settles.
|
|
62
|
+
exitSignals: [],
|
|
63
|
+
exitOnCtrlC: false,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const root = createRoot(renderer);
|
|
67
|
+
|
|
68
|
+
return await new Promise<string | null>((resolve) => {
|
|
69
|
+
// Clamped, not trusted: a caller computing an index from a list it built
|
|
70
|
+
// separately could hand over one that is out of range, and a cursor
|
|
71
|
+
// pointing past the end makes Enter resolve to null.
|
|
72
|
+
let index = Math.min(
|
|
73
|
+
Math.max(0, input.defaultIndex ?? 0),
|
|
74
|
+
Math.max(0, input.options.length - 1),
|
|
75
|
+
);
|
|
76
|
+
let done = false;
|
|
77
|
+
|
|
78
|
+
const finish = (value: string | null): void => {
|
|
79
|
+
if (done) return;
|
|
80
|
+
done = true;
|
|
81
|
+
renderer.keyInput.off("keypress", onKey);
|
|
82
|
+
root.unmount();
|
|
83
|
+
renderer.destroy();
|
|
84
|
+
resolve(value);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function onKey(key: {
|
|
88
|
+
name?: string;
|
|
89
|
+
ctrl?: boolean;
|
|
90
|
+
sequence?: string;
|
|
91
|
+
}): void {
|
|
92
|
+
if ((key.ctrl && key.name === "c") || key.name === "escape") {
|
|
93
|
+
finish(null);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (key.name === "return" || key.name === "enter") {
|
|
97
|
+
finish(input.options[index]?.value ?? null);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (key.name === "up" || key.name === "k") {
|
|
101
|
+
index = (index - 1 + input.options.length) % input.options.length;
|
|
102
|
+
draw();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (key.name === "down" || key.name === "j") {
|
|
106
|
+
index = (index + 1) % input.options.length;
|
|
107
|
+
draw();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// The numbers stay live: the list is short, and the plain-text prompt
|
|
111
|
+
// this replaced numbered it, so the habit already exists.
|
|
112
|
+
const digit = Number(key.sequence);
|
|
113
|
+
if (
|
|
114
|
+
Number.isInteger(digit) &&
|
|
115
|
+
digit >= 1 &&
|
|
116
|
+
digit <= input.options.length
|
|
117
|
+
) {
|
|
118
|
+
index = digit - 1;
|
|
119
|
+
finish(input.options[index]?.value ?? null);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const draw = (): void => {
|
|
124
|
+
root.render(
|
|
125
|
+
React.createElement(
|
|
126
|
+
dimensions.DimensionsProvider,
|
|
127
|
+
null,
|
|
128
|
+
React.createElement(modals.SelectModal, {
|
|
129
|
+
title: input.title,
|
|
130
|
+
message: input.message,
|
|
131
|
+
options: input.options,
|
|
132
|
+
defaultIndex: index,
|
|
133
|
+
onSelect: finish,
|
|
134
|
+
onCancel: () => finish(null),
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
renderer.keyInput.on("keypress", onKey);
|
|
141
|
+
draw();
|
|
142
|
+
});
|
|
143
|
+
}
|
package/src/cli/update-view.ts
CHANGED
package/src/cli/update.ts
CHANGED
|
@@ -70,8 +70,9 @@ import {
|
|
|
70
70
|
} from "../services/update-plan.js";
|
|
71
71
|
import { brand } from "../ui/theme.js";
|
|
72
72
|
import { bold, dim, fg, width } from "./ansi.js";
|
|
73
|
-
import { ensureManifest } from "./bootstrap.js";
|
|
73
|
+
import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
|
|
74
74
|
import { LiveRegion, withPaused } from "./live.js";
|
|
75
|
+
import { selectDialog } from "./tui-prompt.js";
|
|
75
76
|
import {
|
|
76
77
|
type ApplyState,
|
|
77
78
|
type Step,
|
|
@@ -190,8 +191,42 @@ export async function runUpdateCommand(
|
|
|
190
191
|
return 1;
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
// No active profile, no update: that is where plugin state is read from and
|
|
195
|
+
// where changes are written. Asked before the profile is resolved, so what
|
|
196
|
+
// the user activates here is what this run uses.
|
|
197
|
+
//
|
|
198
|
+
// BOTH `--check` and `--dry-run` are read-only here. An earlier version let
|
|
199
|
+
// `--dry-run` activate, reasoning that a preview needs a real profile to
|
|
200
|
+
// report against — but activating writes symlinks and edits a TRACKED
|
|
201
|
+
// `.gitignore`, and `--dry-run` promises to write nothing and exit 0. A
|
|
202
|
+
// preview that mutates the repo is not a preview.
|
|
203
|
+
//
|
|
204
|
+
// So a read-only run refuses instead, and the two flags differ only in what
|
|
205
|
+
// that refusal MEANS: `--check` is a gate, so it fails; `--dry-run` is a
|
|
206
|
+
// preview, and "nothing to preview until a profile is active" is a legitimate
|
|
207
|
+
// preview result rather than an error.
|
|
208
|
+
//
|
|
209
|
+
// NAMING A PROFILE SKIPS THE GATE. `magus update lean` says which profile to
|
|
210
|
+
// update, and updating it needs the manifest, not the symlink — installs go
|
|
211
|
+
// to project scope either way. Gating it would refuse a question the user
|
|
212
|
+
// already answered on the command line, and would break `magus update lean`
|
|
213
|
+
// and `--check lean` in CI, both of which worked before this gate existed.
|
|
214
|
+
// ANY positional argument skips it, not just a valid one. A wrong name has to
|
|
215
|
+
// reach the "Profile not found. Available: …" check below, which says what
|
|
216
|
+
// the user got wrong; gating it first answers a different question — "no
|
|
217
|
+
// profile is selected" — which is true, unhelpful, and not the mistake.
|
|
218
|
+
const current =
|
|
219
|
+
flags.profile !== undefined
|
|
220
|
+
? manifest
|
|
221
|
+
: await requireActiveProfile(projectPath, manifest, {
|
|
222
|
+
check: flags.check || flags.dryRun,
|
|
223
|
+
});
|
|
224
|
+
// A refused dry run exits 0: it wrote nothing and previewed nothing, which is
|
|
225
|
+
// what it promised. A refused `--check` exits 1, because that is a gate.
|
|
226
|
+
if (!current) return flags.dryRun ? 0 : 1;
|
|
227
|
+
|
|
228
|
+
const profileIds = Object.keys(current.profiles);
|
|
229
|
+
if (flags.profile && !current.profiles[flags.profile]) {
|
|
195
230
|
console.error(
|
|
196
231
|
failLine(
|
|
197
232
|
`Profile "${flags.profile}" not found. Available: ${profileIds.join(", ")}`,
|
|
@@ -204,19 +239,27 @@ export async function runUpdateCommand(
|
|
|
204
239
|
// use. A single-profile manifest needs no activation to be unambiguous.
|
|
205
240
|
// Chosen through the same function the TUI uses, so both judge a plugin
|
|
206
241
|
// against the same pin.
|
|
207
|
-
const
|
|
242
|
+
const resolvedId = selectProfileId(
|
|
208
243
|
profileIds,
|
|
209
244
|
flags.profile,
|
|
210
245
|
await activeProfile(projectPath),
|
|
211
246
|
);
|
|
212
|
-
|
|
247
|
+
|
|
248
|
+
// `requireActiveProfile` above has already guaranteed one is active, so rule
|
|
249
|
+
// 2 of `selectProfileId` resolves and this is only unreachable-guard. There
|
|
250
|
+
// is deliberately no "pick one for this run" fallback any more: it used to
|
|
251
|
+
// ask here and then update a profile the project was not running, which is
|
|
252
|
+
// the two-worlds behaviour the gate exists to end. An explicit argument is
|
|
253
|
+
// still honoured — naming a profile is naming intent.
|
|
254
|
+
if (!resolvedId) {
|
|
213
255
|
console.error(
|
|
214
256
|
failLine(
|
|
215
|
-
`No active profile.
|
|
257
|
+
`No active profile. Run \`magus profile switch <${profileIds.join("|")}>\`.`,
|
|
216
258
|
),
|
|
217
259
|
);
|
|
218
260
|
return 1;
|
|
219
261
|
}
|
|
262
|
+
const targetId = resolvedId;
|
|
220
263
|
|
|
221
264
|
for (const line of header(targetId)) console.log(line);
|
|
222
265
|
|
|
@@ -243,7 +286,7 @@ export async function runUpdateCommand(
|
|
|
243
286
|
|
|
244
287
|
const closure = await runStep(steps.profile, async (say) => {
|
|
245
288
|
say(`resolving "${targetId}"`);
|
|
246
|
-
const resolved = await resolveProfile(
|
|
289
|
+
const resolved = await resolveProfile(current, targetId);
|
|
247
290
|
steps.profile.detail = [
|
|
248
291
|
`${Object.keys(resolved.plugins).length} plugins`,
|
|
249
292
|
`${resolved.bins.length} tools`,
|
package/src/main.tsx
CHANGED
|
@@ -104,7 +104,16 @@ async function main(): Promise<void> {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
main().catch((error) => {
|
|
107
|
+
main().catch(async (error) => {
|
|
108
|
+
// Ctrl+C at a prompt is an answer — "stop" — not a failure. 130 is the
|
|
109
|
+
// shell's own convention for SIGINT (128 + 2), so `magus update && next`
|
|
110
|
+
// stops rather than running `next`. The newline lands the shell prompt on
|
|
111
|
+
// its own line instead of against the half-finished question.
|
|
112
|
+
const { isPromptAborted } = await import("./cli/prompt.js");
|
|
113
|
+
if (isPromptAborted(error)) {
|
|
114
|
+
process.stderr.write("\n");
|
|
115
|
+
process.exit(130);
|
|
116
|
+
}
|
|
108
117
|
console.error("Error starting magus:", error);
|
|
109
118
|
process.exit(1);
|
|
110
119
|
});
|
package/src/services/manifest.ts
CHANGED
|
@@ -52,6 +52,12 @@ export function migrateToV2(raw: unknown): ProfileManifest {
|
|
|
52
52
|
// Only carry strictVersions when enabled, so a committed manifest that
|
|
53
53
|
// doesn't opt in stays minimal (no spurious `"strictVersions": false`).
|
|
54
54
|
if (obj.strictVersions === true) manifest.strictVersions = true;
|
|
55
|
+
// Carried through even when it names no existing profile: dropping it here
|
|
56
|
+
// would silently "fix" a typo that `validateManifest` is meant to report,
|
|
57
|
+
// and the commands that read it all check the id resolves first.
|
|
58
|
+
if (typeof obj.default === "string" && obj.default !== "") {
|
|
59
|
+
manifest.default = obj.default;
|
|
60
|
+
}
|
|
55
61
|
return manifest;
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -110,6 +116,21 @@ export function validateManifest(
|
|
|
110
116
|
errors.push({ path: "profiles", message: "no profiles defined" });
|
|
111
117
|
}
|
|
112
118
|
|
|
119
|
+
// A `default` naming nothing is worse than none: a fresh clone would fall
|
|
120
|
+
// through to asking, with no sign that the repo tried to answer and got the
|
|
121
|
+
// id wrong. Reported here so it fails the same gate every other manifest
|
|
122
|
+
// mistake does, rather than being discovered as silence.
|
|
123
|
+
if (manifest.default !== undefined) {
|
|
124
|
+
if (typeof manifest.default !== "string") {
|
|
125
|
+
errors.push({ path: "default", message: "must be a profile id" });
|
|
126
|
+
} else if (!ids.includes(manifest.default)) {
|
|
127
|
+
errors.push({
|
|
128
|
+
path: "default",
|
|
129
|
+
message: `no profile "${manifest.default}" — declared: ${ids.join(", ")}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
113
134
|
for (const [id, entry] of Object.entries(manifest.profiles ?? {})) {
|
|
114
135
|
const base = `profiles.${id}`;
|
|
115
136
|
if (!entry || typeof entry !== "object") {
|