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
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* would ship to every teammate.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import type { Stats } from "node:fs";
|
|
24
25
|
import path from "node:path";
|
|
25
26
|
import fs from "fs-extra";
|
|
26
27
|
|
|
@@ -32,6 +33,7 @@ import fs from "fs-extra";
|
|
|
32
33
|
*/
|
|
33
34
|
export const PROFILE_GITIGNORE_ENTRIES = [
|
|
34
35
|
".claude/_profiles/",
|
|
36
|
+
".claude/.magus-backups/",
|
|
35
37
|
".claude/settings.json",
|
|
36
38
|
".claude/skills",
|
|
37
39
|
".claude/models.json",
|
|
@@ -52,6 +54,30 @@ export function profileDir(name: string, projectPath?: string): string {
|
|
|
52
54
|
/** The artifacts an active profile links into place. */
|
|
53
55
|
export type ProfileLinkKey = "settings" | "skills" | "mcp" | "models";
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Where each linked artifact lives, and what it is called inside a profile.
|
|
59
|
+
*
|
|
60
|
+
* One table, because the link paths do NOT depend on which profile is active
|
|
61
|
+
* and two places listing them is two places to forget one. `linkTargets` adds
|
|
62
|
+
* the profile dir; `linkPaths` needs no profile at all.
|
|
63
|
+
*/
|
|
64
|
+
const LINKED_ARTIFACTS: Array<{
|
|
65
|
+
key: ProfileLinkKey;
|
|
66
|
+
/** Path segments under the project root. */
|
|
67
|
+
at: string[];
|
|
68
|
+
/** File or directory name inside `_profiles/<name>/`. */
|
|
69
|
+
inProfile: string;
|
|
70
|
+
}> = [
|
|
71
|
+
{
|
|
72
|
+
key: "settings",
|
|
73
|
+
at: [".claude", "settings.json"],
|
|
74
|
+
inProfile: "settings.json",
|
|
75
|
+
},
|
|
76
|
+
{ key: "skills", at: [".claude", "skills"], inProfile: "skills" },
|
|
77
|
+
{ key: "mcp", at: [".mcp.json"], inProfile: "mcp.json" },
|
|
78
|
+
{ key: "models", at: [".claude", "models.json"], inProfile: "models.json" },
|
|
79
|
+
];
|
|
80
|
+
|
|
55
81
|
/** The set of links an active profile owns, as [linkPath, targetPath] pairs. */
|
|
56
82
|
export function linkTargets(
|
|
57
83
|
name: string,
|
|
@@ -59,28 +85,121 @@ export function linkTargets(
|
|
|
59
85
|
): Array<{ key: ProfileLinkKey; link: string; target: string }> {
|
|
60
86
|
const base = projectPath ?? process.cwd();
|
|
61
87
|
const dir = profileDir(name, projectPath);
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
return LINKED_ARTIFACTS.map(({ key, at, inProfile }) => ({
|
|
89
|
+
key,
|
|
90
|
+
link: path.join(base, ...at),
|
|
91
|
+
target: path.join(dir, inProfile),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The paths activation writes links at, whichever profile is chosen. */
|
|
96
|
+
export function linkPaths(projectPath?: string): string[] {
|
|
97
|
+
const base = projectPath ?? process.cwd();
|
|
98
|
+
return LINKED_ARTIFACTS.map(({ at }) => path.join(base, ...at));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Real artifacts that activating ANY profile would have to move aside.
|
|
103
|
+
*
|
|
104
|
+
* Empty means activating is lossless — a fresh clone, where the only things at
|
|
105
|
+
* these paths are nothing at all or links magus wrote itself. That is what
|
|
106
|
+
* makes it safe to honour the manifest's declared default without asking: there
|
|
107
|
+
* is no work of the user's to replace, so there is no decision to put to them.
|
|
108
|
+
*
|
|
109
|
+
* Non-empty means a human put something there, and the choice is theirs.
|
|
110
|
+
*/
|
|
111
|
+
export async function displaceableArtifacts(
|
|
112
|
+
projectPath?: string,
|
|
113
|
+
): Promise<string[]> {
|
|
114
|
+
const found: string[] = [];
|
|
115
|
+
for (const link of linkPaths(projectPath)) {
|
|
116
|
+
// A link we own is generated state the manifest reproduces on demand.
|
|
117
|
+
if (await isProfileLink(link, projectPath)) continue;
|
|
118
|
+
try {
|
|
119
|
+
await fs.lstat(link);
|
|
120
|
+
found.push(link);
|
|
121
|
+
} catch {
|
|
122
|
+
// Nothing there.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where activation moves a real file it had to displace.
|
|
130
|
+
*
|
|
131
|
+
* Beside `_profiles/`, never inside it. `_profiles/` is generated and is
|
|
132
|
+
* documented as regenerable from the manifest, so it is the first thing a
|
|
133
|
+
* cleanup — a user's, or ours — deletes. A backup stored there is the only copy
|
|
134
|
+
* of a hand-written file sitting in the one directory everybody is told is safe
|
|
135
|
+
* to remove. Gitignored all the same: these are recovery copies, not something
|
|
136
|
+
* to commit.
|
|
137
|
+
*/
|
|
138
|
+
export function backupRoot(projectPath?: string): string {
|
|
139
|
+
const base = projectPath ?? process.cwd();
|
|
140
|
+
return path.join(base, ".claude", ".magus-backups");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One artifact activation moved aside to make room for its symlink. */
|
|
144
|
+
export interface DisplacedArtifact {
|
|
145
|
+
/** The path that is now a profile symlink. */
|
|
146
|
+
link: string;
|
|
147
|
+
/** Where its previous contents were moved. */
|
|
148
|
+
savedTo: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A directory name that sorts chronologically and is legal on every platform. */
|
|
152
|
+
function backupStamp(): string {
|
|
153
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Move whatever sits at `link` into `backupDir`, unless it is generated state.
|
|
158
|
+
*
|
|
159
|
+
* Activation replaces four paths with symlinks, and replacing means removing
|
|
160
|
+
* what is there. Three of those paths are ordinary files a project writes by
|
|
161
|
+
* hand and commits. `fs.remove` on `.claude/settings.json` is unrecoverable, and
|
|
162
|
+
* it ran on every activation — which is why adoption deliberately stopped short
|
|
163
|
+
* of activating and left migrations half-done.
|
|
164
|
+
*
|
|
165
|
+
* A symlink magus itself wrote is skipped: it points into `_profiles/`, which
|
|
166
|
+
* the manifest regenerates on demand, so copying it would archive nothing and
|
|
167
|
+
* every `profile switch` would leave another directory behind. A symlink
|
|
168
|
+
* pointing ANYWHERE ELSE is somebody's deliberate wiring and is preserved like
|
|
169
|
+
* a real file — the link is moved, never its target.
|
|
170
|
+
*/
|
|
171
|
+
async function displace(
|
|
172
|
+
link: string,
|
|
173
|
+
backupDir: string,
|
|
174
|
+
projectPath?: string,
|
|
175
|
+
): Promise<DisplacedArtifact | null> {
|
|
176
|
+
if (await isProfileLink(link, projectPath)) return null;
|
|
177
|
+
let stat: Stats;
|
|
178
|
+
try {
|
|
179
|
+
stat = await fs.lstat(link);
|
|
180
|
+
} catch {
|
|
181
|
+
return null; // Nothing there — the common case on a first activation.
|
|
182
|
+
}
|
|
183
|
+
// The four link paths have four distinct basenames, so one flat directory
|
|
184
|
+
// holds a whole activation without collisions.
|
|
185
|
+
const savedTo = path.join(backupDir, path.basename(link));
|
|
186
|
+
await fs.ensureDir(backupDir);
|
|
187
|
+
|
|
188
|
+
if (stat.isSymbolicLink()) {
|
|
189
|
+
// A symlink's target is resolved against the directory the LINK sits in,
|
|
190
|
+
// so moving the link two levels deeper silently repoints it: a relative
|
|
191
|
+
// `../shared/mcp.json` that worked becomes dangling, and the "preserved"
|
|
192
|
+
// copy preserves nothing. Rewrite it as absolute at its original meaning.
|
|
193
|
+
const target = await fs.readlink(link);
|
|
194
|
+
const absolute = path.resolve(path.dirname(link), target);
|
|
195
|
+
await fs.remove(savedTo);
|
|
196
|
+
await fs.symlink(absolute, savedTo);
|
|
197
|
+
await fs.remove(link);
|
|
198
|
+
return { link, savedTo };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await fs.move(link, savedTo, { overwrite: true });
|
|
202
|
+
return { link, savedTo };
|
|
84
203
|
}
|
|
85
204
|
|
|
86
205
|
/**
|
|
@@ -100,20 +219,44 @@ async function relink(link: string, target: string): Promise<void> {
|
|
|
100
219
|
* Activate a profile: repoint every shared-config symlink into its
|
|
101
220
|
* materialized directory. Only links whose target exists in the profile dir
|
|
102
221
|
* are created (e.g. a profile with no skills/ dir gets no skills symlink).
|
|
222
|
+
*
|
|
223
|
+
* Returns whatever had to be moved aside to make room — empty for an ordinary
|
|
224
|
+
* `profile switch`, one entry per hand-written artifact the first time a
|
|
225
|
+
* project adopts profiles. Callers report it so the move is never silent.
|
|
103
226
|
*/
|
|
104
227
|
export async function activateProfile(
|
|
105
228
|
name: string,
|
|
106
229
|
projectPath?: string,
|
|
107
|
-
|
|
230
|
+
/**
|
|
231
|
+
* Called the instant an artifact is moved, before the next link is touched.
|
|
232
|
+
*
|
|
233
|
+
* The return value alone is not enough, and the difference is data the user
|
|
234
|
+
* cannot otherwise find: activation moves up to four artifacts in sequence,
|
|
235
|
+
* so a throw on the second discards the array describing the first — leaving
|
|
236
|
+
* `.claude/settings.json` gone from where they put it and no message saying
|
|
237
|
+
* where it went. Reporting as it happens is what makes a half-finished
|
|
238
|
+
* activation recoverable by hand.
|
|
239
|
+
*/
|
|
240
|
+
onDisplace?: (artifact: DisplacedArtifact) => void,
|
|
241
|
+
): Promise<DisplacedArtifact[]> {
|
|
108
242
|
const dir = profileDir(name, projectPath);
|
|
109
243
|
if (!(await fs.pathExists(dir))) {
|
|
110
244
|
throw new Error(
|
|
111
245
|
`Profile "${name}" is not materialized (${dir} missing). Run \`magus install ${name}\`.`,
|
|
112
246
|
);
|
|
113
247
|
}
|
|
248
|
+
// One stamped directory per activation, so the four artifacts that were live
|
|
249
|
+
// together stay together and can be restored as the set they were.
|
|
250
|
+
const backupDir = path.join(backupRoot(projectPath), backupStamp());
|
|
251
|
+
const displaced: DisplacedArtifact[] = [];
|
|
114
252
|
for (const { link, target } of linkTargets(name, projectPath)) {
|
|
115
|
-
await syncLink(link, target, projectPath);
|
|
253
|
+
const moved = await syncLink(link, target, projectPath, backupDir);
|
|
254
|
+
if (moved) {
|
|
255
|
+
displaced.push(moved);
|
|
256
|
+
onDisplace?.(moved);
|
|
257
|
+
}
|
|
116
258
|
}
|
|
259
|
+
return displaced;
|
|
117
260
|
}
|
|
118
261
|
|
|
119
262
|
/**
|
|
@@ -124,13 +267,20 @@ export async function activateProfile(
|
|
|
124
267
|
async function syncLink(
|
|
125
268
|
link: string,
|
|
126
269
|
target: string,
|
|
127
|
-
projectPath
|
|
128
|
-
|
|
270
|
+
projectPath: string | undefined,
|
|
271
|
+
// Required, not optional. An optional backup directory keeps a
|
|
272
|
+
// replace-without-backup path expressible, and that path is the whole defect
|
|
273
|
+
// this function was changed to close — a future caller omitting the argument
|
|
274
|
+
// would reintroduce it silently, and type-check clean while doing so.
|
|
275
|
+
backupDir: string,
|
|
276
|
+
): Promise<DisplacedArtifact | null> {
|
|
129
277
|
if (await fs.pathExists(target)) {
|
|
278
|
+
const moved = await displace(link, backupDir, projectPath);
|
|
130
279
|
await relink(link, target);
|
|
131
|
-
return;
|
|
280
|
+
return moved;
|
|
132
281
|
}
|
|
133
282
|
if (await isProfileLink(link, projectPath)) await fs.remove(link);
|
|
283
|
+
return null;
|
|
134
284
|
}
|
|
135
285
|
|
|
136
286
|
/**
|
|
@@ -145,10 +295,14 @@ export async function syncProfileLink(
|
|
|
145
295
|
name: string,
|
|
146
296
|
key: ProfileLinkKey,
|
|
147
297
|
projectPath?: string,
|
|
148
|
-
): Promise<
|
|
298
|
+
): Promise<DisplacedArtifact | null> {
|
|
149
299
|
const entry = linkTargets(name, projectPath).find((t) => t.key === key);
|
|
150
|
-
if (!entry) return;
|
|
151
|
-
|
|
300
|
+
if (!entry) return null;
|
|
301
|
+
// Backed up for the same reason activation is: `magus models use` on a
|
|
302
|
+
// project with a hand-written `.claude/models.json` would otherwise delete it
|
|
303
|
+
// as a side effect of choosing a model.
|
|
304
|
+
const backupDir = path.join(backupRoot(projectPath), backupStamp());
|
|
305
|
+
return syncLink(entry.link, entry.target, projectPath, backupDir);
|
|
152
306
|
}
|
|
153
307
|
|
|
154
308
|
/** Is `link` a symlink pointing into `.claude/_profiles/`? */
|
|
@@ -40,6 +40,10 @@ import { formatCommand } from "../utils/command-utils.js";
|
|
|
40
40
|
import { runCommand } from "../utils/run.js";
|
|
41
41
|
import type { PluginScope } from "./claude-cli.js";
|
|
42
42
|
import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
|
|
43
|
+
import {
|
|
44
|
+
type StaleLoadRepairResult,
|
|
45
|
+
repairStaleLoad,
|
|
46
|
+
} from "./claude-settings.js";
|
|
43
47
|
import {
|
|
44
48
|
readInstalledVersionForScope,
|
|
45
49
|
saveInstalledPluginVersionForScope,
|
|
@@ -165,6 +169,18 @@ export interface PluginApplyDeps {
|
|
|
165
169
|
scope: PluginScope,
|
|
166
170
|
projectPath: string,
|
|
167
171
|
) => Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* The `stale-load` repair — NOT a Claude Code command, and the only dep here
|
|
174
|
+
* that is not. No `claude plugin` invocation can reach the registry rows that
|
|
175
|
+
* cause a stale load, so this one edits `installed_plugins.json` directly;
|
|
176
|
+
* `repairStaleLoad`'s own doc comment carries the full reasoning and the
|
|
177
|
+
* user's approval of the exception.
|
|
178
|
+
*/
|
|
179
|
+
repairStaleLoad: (
|
|
180
|
+
pluginId: string,
|
|
181
|
+
target: string,
|
|
182
|
+
projectPath: string,
|
|
183
|
+
) => Promise<StaleLoadRepairResult>;
|
|
168
184
|
}
|
|
169
185
|
|
|
170
186
|
export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
@@ -173,6 +189,7 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
|
173
189
|
repair: repairPlugin,
|
|
174
190
|
readInstalled: readInstalledVersionForScope,
|
|
175
191
|
saveInstalled: saveInstalledPluginVersionForScope,
|
|
192
|
+
repairStaleLoad,
|
|
176
193
|
};
|
|
177
194
|
|
|
178
195
|
// -- selection ---------------------------------------------------------------
|
|
@@ -189,7 +206,18 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
|
189
206
|
* project row stays untouched, so the same drift reappears on the next run.
|
|
190
207
|
*/
|
|
191
208
|
function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
192
|
-
|
|
209
|
+
// `stale-load` has no scopes, and that is not "skipped": it is applied by
|
|
210
|
+
// `repairStaleLoad`, which rewrites registry rows rather than installing
|
|
211
|
+
// anything, so there is no per-scope work to enumerate. A reinstall is
|
|
212
|
+
// measured NOT to clear it — it writes another row for this project while the
|
|
213
|
+
// row the loader picks belongs to a different one — which is exactly why it
|
|
214
|
+
// must not come through here. {@link pluginNeedsWork} counts it separately.
|
|
215
|
+
if (
|
|
216
|
+
item.action === "current" ||
|
|
217
|
+
item.action === "unknown" ||
|
|
218
|
+
item.action === "stale-load"
|
|
219
|
+
)
|
|
220
|
+
return [];
|
|
193
221
|
return item.action === "install" ? ["project"] : item.scopes;
|
|
194
222
|
}
|
|
195
223
|
|
|
@@ -200,9 +228,12 @@ function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
|
200
228
|
* predicate the loop applies. Re-deriving it at the call site is how the two
|
|
201
229
|
* drift, and a bar that stops short of full at the end of a successful run
|
|
202
230
|
* looks exactly like a hang.
|
|
231
|
+
*
|
|
232
|
+
* `stale-load` is work without scopes — see {@link scopesToApply} — so it is
|
|
233
|
+
* named here rather than counted through the scope list.
|
|
203
234
|
*/
|
|
204
235
|
export function pluginNeedsWork(item: PluginUpdateItem): boolean {
|
|
205
|
-
return scopesToApply(item).length > 0;
|
|
236
|
+
return item.action === "stale-load" || scopesToApply(item).length > 0;
|
|
206
237
|
}
|
|
207
238
|
|
|
208
239
|
// -- plugins -----------------------------------------------------------------
|
|
@@ -277,6 +308,67 @@ function pluginNoMove(
|
|
|
277
308
|
return null;
|
|
278
309
|
}
|
|
279
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Repair ONE broken plugin — the `stale-load` half of {@link applyPlugins}.
|
|
313
|
+
*
|
|
314
|
+
* Success is the repair's own POST-WRITE assertion (`StaleLoadRepairResult.ok`),
|
|
315
|
+
* never "the call returned". The whole class of bug this state reports is a
|
|
316
|
+
* version that looks right and does not load, so a repair that rewrote rows
|
|
317
|
+
* without changing what the loader resolves is a FAILURE and says so.
|
|
318
|
+
*/
|
|
319
|
+
async function applyStaleLoad(
|
|
320
|
+
item: PluginUpdateItem,
|
|
321
|
+
projectPath: string,
|
|
322
|
+
deps: PluginApplyDeps,
|
|
323
|
+
report: ApplyReporter,
|
|
324
|
+
result: PluginApplyResult,
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
report.begin(item.pluginId);
|
|
327
|
+
const startedAt = Date.now();
|
|
328
|
+
// The classifier sets this to the version installed here; a null one means
|
|
329
|
+
// there is nothing on disk to point the rows at, so there is no repair to run.
|
|
330
|
+
if (!item.target) {
|
|
331
|
+
report.finish(
|
|
332
|
+
false,
|
|
333
|
+
item.pluginId,
|
|
334
|
+
`no installed version to point ${item.pluginId}'s registry rows at`,
|
|
335
|
+
Date.now() - startedAt,
|
|
336
|
+
);
|
|
337
|
+
result.failed.push(item.pluginId);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const outcome = await deps.repairStaleLoad(
|
|
342
|
+
item.pluginId,
|
|
343
|
+
item.target,
|
|
344
|
+
projectPath,
|
|
345
|
+
);
|
|
346
|
+
if (outcome.ok) {
|
|
347
|
+
report.finish(
|
|
348
|
+
true,
|
|
349
|
+
item.pluginId,
|
|
350
|
+
`registry → v${outcome.target} (${outcome.rewritten} row${
|
|
351
|
+
outcome.rewritten === 1 ? "" : "s"
|
|
352
|
+
} moved forward)`,
|
|
353
|
+
Date.now() - startedAt,
|
|
354
|
+
);
|
|
355
|
+
result.ok++;
|
|
356
|
+
} else {
|
|
357
|
+
report.finish(
|
|
358
|
+
false,
|
|
359
|
+
item.pluginId,
|
|
360
|
+
outcome.error ?? `${item.pluginId} still does not load v${item.target}`,
|
|
361
|
+
Date.now() - startedAt,
|
|
362
|
+
);
|
|
363
|
+
result.failed.push(item.pluginId);
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
367
|
+
report.finish(false, item.pluginId, msg, Date.now() - startedAt);
|
|
368
|
+
result.failed.push(item.pluginId);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
280
372
|
export async function applyPlugins(
|
|
281
373
|
items: PluginUpdateItem[],
|
|
282
374
|
projectPath: string,
|
|
@@ -291,6 +383,16 @@ export async function applyPlugins(
|
|
|
291
383
|
};
|
|
292
384
|
|
|
293
385
|
for (const item of items) {
|
|
386
|
+
// A stale load is repaired in the REGISTRY, not by any installer, so it
|
|
387
|
+
// leaves the scope loop entirely. Its own branch rather than a scope with a
|
|
388
|
+
// special case inside: everything below reads back a per-scope version and
|
|
389
|
+
// compares it against the catalog, and none of those questions applies to a
|
|
390
|
+
// row rewrite.
|
|
391
|
+
if (item.action === "stale-load") {
|
|
392
|
+
await applyStaleLoad(item, projectPath, deps, report, result);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
294
396
|
const scopes = scopesToApply(item);
|
|
295
397
|
if (scopes.length === 0) continue;
|
|
296
398
|
|