magus-cli 7.1.0 → 7.3.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__/blackbox-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -0
- package/src/__tests__/plugin-resolution.test.ts +431 -0
- package/src/__tests__/plugin-row-text.test.ts +66 -0
- 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/__tests__/stale-load-repair.test.ts +1076 -0
- package/src/__tests__/update-apply.test.ts +146 -1
- package/src/__tests__/update-plan.test.ts +338 -0
- package/src/__tests__/useStaleLoadModal.test.ts +177 -0
- package/src/cli/ansi.ts +39 -0
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/doctor.ts +128 -2
- 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 +32 -1
- package/src/cli/update.ts +50 -7
- package/src/main.tsx +10 -1
- package/src/services/active-closure.ts +23 -0
- package/src/services/claude-settings.ts +645 -34
- package/src/services/manifest.ts +21 -0
- package/src/services/plugin-manager.ts +61 -0
- package/src/services/plugin-resolution.ts +280 -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/services/update-engine.ts +104 -2
- package/src/services/update-plan.ts +188 -1
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +123 -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/hooks/useStaleLoadModal.ts +157 -0
- package/src/ui/renderers/pluginRenderers.tsx +29 -0
- package/src/ui/renderers/pluginStatusText.ts +14 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +46 -13
- 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/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
|
@@ -37,6 +37,7 @@ import { formatCommand } from "../utils/command-utils.js";
|
|
|
37
37
|
import {
|
|
38
38
|
badge,
|
|
39
39
|
bold,
|
|
40
|
+
dialog,
|
|
40
41
|
dim,
|
|
41
42
|
elapsed,
|
|
42
43
|
fg,
|
|
@@ -80,6 +81,7 @@ type ActionKey =
|
|
|
80
81
|
| "upgrade"
|
|
81
82
|
| "repair"
|
|
82
83
|
| "refresh"
|
|
84
|
+
| "stale-load"
|
|
83
85
|
| "unknown";
|
|
84
86
|
|
|
85
87
|
const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
@@ -92,6 +94,11 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
|
92
94
|
upgrade: { glyph: "↑", label: "UPGRADE", color: brand.success },
|
|
93
95
|
repair: { glyph: "⟳", label: "REPAIR", color: brand.info },
|
|
94
96
|
refresh: { glyph: "⟳", label: "REFRESH", color: brand.info },
|
|
97
|
+
// The installed version is right and an older one loads, so this session runs
|
|
98
|
+
// a version nobody installed. Danger, and the word is BROKEN: "STALE" read as
|
|
99
|
+
// a mild version of `repair`, and this is not that. Six characters, one under
|
|
100
|
+
// UNKNOWN, so CHIP_WIDTH is unchanged and no other section pays for it.
|
|
101
|
+
"stale-load": { glyph: "✗", label: "BROKEN", color: brand.danger },
|
|
95
102
|
// Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
|
|
96
103
|
// as "broken". The summary line and the TUI already use warning for it.
|
|
97
104
|
unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
|
|
@@ -248,8 +255,12 @@ function pluginDetail(item: PluginUpdateItem): string {
|
|
|
248
255
|
// The note stays on the item. The TUI has no heading to lean on and renders
|
|
249
256
|
// it as that row's only explanation — this is a rendering decision about a
|
|
250
257
|
// table that carries the fact twice, not a change to what is known.
|
|
258
|
+
// `older-version-loads` is excluded for the same reason: its note says
|
|
259
|
+
// "loads v0.3.0, installed v0.5.0" and the two columns beside it already do.
|
|
251
260
|
const note =
|
|
252
|
-
item.note &&
|
|
261
|
+
item.note &&
|
|
262
|
+
item.reason !== "not-offered" &&
|
|
263
|
+
item.reason !== "older-version-loads"
|
|
253
264
|
? ` ${dim(`(${item.note})`)}`
|
|
254
265
|
: "";
|
|
255
266
|
switch (item.action) {
|
|
@@ -267,6 +278,16 @@ function pluginDetail(item: PluginUpdateItem): string {
|
|
|
267
278
|
case "repair":
|
|
268
279
|
case "unknown":
|
|
269
280
|
return `${item.installed ?? "—"}${note}`;
|
|
281
|
+
case "stale-load":
|
|
282
|
+
// Both versions, in the order the reader needs them: what runs, then
|
|
283
|
+
// what is installed. This is a report, not a one-line picker row — the
|
|
284
|
+
// TUI's row deliberately shows neither, because there the verdict and
|
|
285
|
+
// the key are all that fit. The note says the same thing in words; the
|
|
286
|
+
// columns are what makes a table of these scannable.
|
|
287
|
+
return `${fg(
|
|
288
|
+
groupStyle("Broken").text,
|
|
289
|
+
item.loaded ?? "?",
|
|
290
|
+
)} ${dim("≠")} ${item.installed ?? "—"}${note}`;
|
|
270
291
|
case "current":
|
|
271
292
|
return `${item.installed ?? "—"}${note}`;
|
|
272
293
|
}
|
|
@@ -346,6 +367,10 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
346
367
|
function pluginGroup(item: PluginUpdateItem): string {
|
|
347
368
|
if (item.action === "update" || item.action === "repair") return "Update";
|
|
348
369
|
if (item.action === "install") return "Fix required";
|
|
370
|
+
// Never "Current". The installed version matching is exactly what makes this
|
|
371
|
+
// state deceptive, and a row that reads as up to date under a CURRENT
|
|
372
|
+
// heading is the defect this state was added to end.
|
|
373
|
+
if (item.action === "stale-load") return "Broken";
|
|
349
374
|
if (item.action === "unknown") {
|
|
350
375
|
return item.reason === "not-offered" ? "Deprecated" : "Unverified";
|
|
351
376
|
}
|
|
@@ -353,6 +378,7 @@ function pluginGroup(item: PluginUpdateItem): string {
|
|
|
353
378
|
}
|
|
354
379
|
|
|
355
380
|
const GROUP_ORDER = [
|
|
381
|
+
"Broken",
|
|
356
382
|
"Update",
|
|
357
383
|
"Deprecated",
|
|
358
384
|
"Fix required",
|
|
@@ -372,6 +398,11 @@ const GROUP_KIND: Record<string, ReportBadgeKind> = {
|
|
|
372
398
|
Update: "update",
|
|
373
399
|
Deprecated: "deprecated",
|
|
374
400
|
"Fix required": "fix",
|
|
401
|
+
// Red earth, borrowed from `deprecated`. The kinds are measured contrast
|
|
402
|
+
// pairs, not meanings — "Hue, not meaning; the badge says the word", as
|
|
403
|
+
// theme-mode.ts puts it — so an unmeasured fourth palette would be the only
|
|
404
|
+
// thing here nobody had checked against both pages.
|
|
405
|
+
Broken: "deprecated",
|
|
375
406
|
};
|
|
376
407
|
|
|
377
408
|
interface GroupStyle {
|
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
|
});
|
|
@@ -115,3 +115,26 @@ export async function loadActiveClosure(
|
|
|
115
115
|
return null;
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The active profile's plugin pins — `pluginId -> exact version | "latest"` — and
|
|
121
|
+
* `{}` when no profile resolves.
|
|
122
|
+
*
|
|
123
|
+
* The one shape every surface that judges a plugin needs: `classifyPlugin`'s
|
|
124
|
+
* `pin`, the Plugins screen's `pins`, and the ceiling `findStaleLoads` caps its
|
|
125
|
+
* repair target at. Reading it through one helper is what keeps the startup popup
|
|
126
|
+
* and the Plugins header from capping differently — a pinned plugin read as
|
|
127
|
+
* BROKEN in one and current in the other is exactly the drift the classifier was
|
|
128
|
+
* centralised to end.
|
|
129
|
+
*
|
|
130
|
+
* `{}` means "latest for everything", which is what `pinForRow` assumes for an
|
|
131
|
+
* installed plugin no profile declares. `resolveBins: false` because no caller of
|
|
132
|
+
* this needs a plugin's binary requirements, and the default resolver reads every
|
|
133
|
+
* declared plugin's `plugin.json` to build them.
|
|
134
|
+
*/
|
|
135
|
+
export async function activePluginPins(
|
|
136
|
+
projectPath: string,
|
|
137
|
+
): Promise<Readonly<Record<string, string>>> {
|
|
138
|
+
const active = await loadActiveClosure(projectPath, { resolveBins: false });
|
|
139
|
+
return active?.closure.plugins ?? {};
|
|
140
|
+
}
|