magus-cli 7.0.0 → 7.1.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-parity.test.ts +58 -17
- package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
- package/src/__tests__/cli-update-view.test.ts +100 -21
- package/src/__tests__/local-marketplace-scan.test.ts +74 -0
- package/src/__tests__/marketplace-refresh.test.ts +12 -7
- package/src/__tests__/plugin-row-text.test.ts +33 -24
- package/src/__tests__/theme-env.test.ts +29 -0
- package/src/__tests__/update-plan.test.ts +57 -34
- package/src/__tests__/version-unknown-state.test.ts +29 -14
- package/src/cli/ansi.ts +19 -11
- package/src/cli/router.ts +59 -2
- package/src/cli/update-view.ts +236 -45
- package/src/cli/update.ts +9 -22
- package/src/main.tsx +1 -1
- package/src/services/claude-settings.ts +15 -40
- package/src/services/local-marketplace.ts +76 -6
- package/src/services/marketplace-refresh.ts +7 -15
- package/src/services/plugin-manager.ts +0 -31
- package/src/services/resolver.ts +33 -0
- package/src/services/update-plan.ts +23 -66
- package/src/ui/App.tsx +0 -3
- package/src/ui/screens/PluginsScreen.tsx +9 -50
- package/src/ui/theme-mode.ts +88 -0
- package/src/utils/command-utils.ts +19 -14
- package/src/__tests__/moved-marketplace.test.ts +0 -86
|
@@ -36,17 +36,6 @@ export interface RefreshResult {
|
|
|
36
36
|
failed: string[];
|
|
37
37
|
/** Nothing to do: no clone on disk, not a git repo, or dirty tree. */
|
|
38
38
|
skipped: string[];
|
|
39
|
-
/**
|
|
40
|
-
* Skipped specifically because `autoUpdate: false` in known_marketplaces.json.
|
|
41
|
-
*
|
|
42
|
-
* Called out separately from `skipped` because the consequence is invisible
|
|
43
|
-
* and open-ended: the clone is the catalog, so a marketplace opted out here
|
|
44
|
-
* never learns about new plugin versions, and every plugin from it reads as
|
|
45
|
-
* up to date forever. The reporter's `magus` clone sat 11 days behind this
|
|
46
|
-
* way — Claude Code would not refresh it, and this function skipped it too,
|
|
47
|
-
* with nothing printed either time.
|
|
48
|
-
*/
|
|
49
|
-
autoUpdateDisabled: string[];
|
|
50
39
|
}
|
|
51
40
|
|
|
52
41
|
function cloneDir(name: string): string {
|
|
@@ -198,7 +187,6 @@ export async function refreshRegisteredMarketplaces(
|
|
|
198
187
|
const refreshed: string[] = [];
|
|
199
188
|
const failed: string[] = [];
|
|
200
189
|
const skipped: string[] = [];
|
|
201
|
-
const autoUpdateDisabled: string[] = [];
|
|
202
190
|
|
|
203
191
|
const configured = await getConfiguredMarketplaces();
|
|
204
192
|
const skipSet = new Set(skip);
|
|
@@ -211,8 +199,13 @@ export async function refreshRegisteredMarketplaces(
|
|
|
211
199
|
skipped.push(name);
|
|
212
200
|
continue;
|
|
213
201
|
}
|
|
202
|
+
// Claude Code's own refresh policy. Honour it by not pulling, and say
|
|
203
|
+
// nothing: the version answer does not come from this clone.
|
|
204
|
+
// `readCatalogFromRemote` reads `origin/<branch>` with fetch + show, never
|
|
205
|
+
// the working tree — measured, a clone holding dev 7.4.0 with autoUpdate
|
|
206
|
+
// off still reported 7.4.1 available.
|
|
214
207
|
if ((await getMarketplaceAutoUpdate(name)) === false) {
|
|
215
|
-
|
|
208
|
+
skipped.push(name);
|
|
216
209
|
continue;
|
|
217
210
|
}
|
|
218
211
|
eligible.push(name);
|
|
@@ -226,7 +219,6 @@ export async function refreshRegisteredMarketplaces(
|
|
|
226
219
|
refreshed: [],
|
|
227
220
|
failed: [],
|
|
228
221
|
skipped: [...skipped, ...eligible],
|
|
229
|
-
autoUpdateDisabled,
|
|
230
222
|
};
|
|
231
223
|
}
|
|
232
224
|
|
|
@@ -249,5 +241,5 @@ export async function refreshRegisteredMarketplaces(
|
|
|
249
241
|
else skipped.push(name); // absent | skipped
|
|
250
242
|
});
|
|
251
243
|
|
|
252
|
-
return { refreshed, failed, skipped
|
|
244
|
+
return { refreshed, failed, skipped };
|
|
253
245
|
}
|
|
@@ -122,15 +122,6 @@ export interface PluginInfo {
|
|
|
122
122
|
* a reinstall delivers the current content. See content-drift.ts.
|
|
123
123
|
*/
|
|
124
124
|
contentStale?: boolean;
|
|
125
|
-
/**
|
|
126
|
-
* For an orphaned plugin: another configured marketplace that publishes a
|
|
127
|
-
* plugin of the same name — i.e. it did not disappear, it moved.
|
|
128
|
-
*
|
|
129
|
-
* Splitting `magus` into `magus` + `magus-marketing` left video-editing and
|
|
130
|
-
* image-generate installed under a namespace that no longer lists them. Rendered as bare "deprecated", the only offered action
|
|
131
|
-
* was deletion, which silently drops a plugin that is still published.
|
|
132
|
-
*/
|
|
133
|
-
movedTo?: string;
|
|
134
125
|
/**
|
|
135
126
|
* Set when this plugin's installed version changed since magus last
|
|
136
127
|
* rendered it — including updates made by Claude Code or a direct CLI call
|
|
@@ -467,9 +458,6 @@ export async function getAvailablePlugins(
|
|
|
467
458
|
// Try to get plugin info from local marketplace cache (fallback)
|
|
468
459
|
const localMp = localMarketplaces.get(mpName);
|
|
469
460
|
const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
|
|
470
|
-
const movedTo = localPlugin
|
|
471
|
-
? undefined
|
|
472
|
-
: findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
|
|
473
461
|
|
|
474
462
|
const latestVersion = localPlugin?.version || installedVersion || "unknown";
|
|
475
463
|
const description = localPlugin?.description || "Installed plugin";
|
|
@@ -486,7 +474,6 @@ export async function getAvailablePlugins(
|
|
|
486
474
|
installedVersion: installedVersion,
|
|
487
475
|
hasUpdate,
|
|
488
476
|
isOrphaned: true,
|
|
489
|
-
movedTo,
|
|
490
477
|
...scopeStatus,
|
|
491
478
|
});
|
|
492
479
|
}
|
|
@@ -495,24 +482,6 @@ export async function getAvailablePlugins(
|
|
|
495
482
|
return plugins;
|
|
496
483
|
}
|
|
497
484
|
|
|
498
|
-
/**
|
|
499
|
-
* Find another configured marketplace that still publishes this plugin name.
|
|
500
|
-
*
|
|
501
|
-
* Answers "did it disappear, or did it move?" for an orphaned plugin, so the
|
|
502
|
-
* UI can offer migration rather than only deletion.
|
|
503
|
-
*/
|
|
504
|
-
function findPluginInOtherMarketplace(
|
|
505
|
-
pluginName: string,
|
|
506
|
-
currentMarketplace: string,
|
|
507
|
-
localMarketplaces: Map<string, LocalMarketplace>,
|
|
508
|
-
): string | undefined {
|
|
509
|
-
for (const [name, mp] of localMarketplaces) {
|
|
510
|
-
if (name === currentMarketplace) continue;
|
|
511
|
-
if (mp.plugins.some((p) => p.name === pluginName)) return name;
|
|
512
|
-
}
|
|
513
|
-
return undefined;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
485
|
/**
|
|
517
486
|
* Is this plugin one whose files could have moved while its version did not?
|
|
518
487
|
*
|
package/src/services/resolver.ts
CHANGED
|
@@ -19,7 +19,9 @@ import type {
|
|
|
19
19
|
ResolvedBin,
|
|
20
20
|
ResolvedClosure,
|
|
21
21
|
} from "../types/index.js";
|
|
22
|
+
import { readManifest } from "./manifest.js";
|
|
22
23
|
import { resolvePluginBinRequirements } from "./plugin-requires.js";
|
|
24
|
+
import { activeProfile } from "./symlink-manager.js";
|
|
23
25
|
|
|
24
26
|
export interface ResolveOptions {
|
|
25
27
|
/**
|
|
@@ -190,6 +192,37 @@ export function withDerivedMarketplaces(
|
|
|
190
192
|
return out;
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The active profile's plugin pins, or null when there is nothing to read.
|
|
197
|
+
*
|
|
198
|
+
* A convenience for callers that want the manifest's opinion but must never be
|
|
199
|
+
* blocked by it — the TUI above all. Every failure path answers null: no
|
|
200
|
+
* manifest file, no profiles in it, no active profile and more than one to
|
|
201
|
+
* choose from, an unresolvable profile. A project without a manifest is the
|
|
202
|
+
* normal case, not an error, and a broken one must not stop the plugin list
|
|
203
|
+
* from rendering.
|
|
204
|
+
*
|
|
205
|
+
* `magus update` deliberately does NOT use this: it needs to fail loudly
|
|
206
|
+
* when the profile is ambiguous, and to name the profile it resolved.
|
|
207
|
+
*/
|
|
208
|
+
export async function activeProfilePins(
|
|
209
|
+
projectPath?: string,
|
|
210
|
+
): Promise<Record<string, string> | null> {
|
|
211
|
+
try {
|
|
212
|
+
const manifest = await readManifest(projectPath);
|
|
213
|
+
const ids = Object.keys(manifest.profiles);
|
|
214
|
+
if (ids.length === 0) return null;
|
|
215
|
+
const id =
|
|
216
|
+
(await activeProfile(projectPath)) ??
|
|
217
|
+
(ids.length === 1 ? ids[0] : undefined);
|
|
218
|
+
if (!id) return null;
|
|
219
|
+
const closure = await resolveProfile(manifest, id);
|
|
220
|
+
return closure.plugins;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
193
226
|
export async function resolveProfile(
|
|
194
227
|
manifest: ProfileManifest,
|
|
195
228
|
profileId: string,
|
|
@@ -68,16 +68,12 @@ export type PluginReason =
|
|
|
68
68
|
| "enabled-not-installed"
|
|
69
69
|
/** unknown: a marketplace we READ does not list it. */
|
|
70
70
|
| "not-offered"
|
|
71
|
-
/** unknown: installed, unlisted, and another marketplace publishes the name. */
|
|
72
|
-
| "moved"
|
|
73
71
|
/** unknown: installed, unlisted, and its marketplace contributed no listed ids. */
|
|
74
72
|
| "marketplace-unread"
|
|
75
73
|
/** unknown: the catalog fetch failed ("latest" pins only). */
|
|
76
74
|
| "catalog-unverified"
|
|
77
|
-
/**
|
|
75
|
+
/** current: listed, but its marketplace publishes no version to compare. */
|
|
78
76
|
| "no-published-version"
|
|
79
|
-
/** unknown: an installed scope cannot be ordered against the target. */
|
|
80
|
-
| "unorderable"
|
|
81
77
|
/** repair: files changed without a version bump. */
|
|
82
78
|
| "content-stale"
|
|
83
79
|
/** update: a scope is provably behind. */
|
|
@@ -182,16 +178,6 @@ export function scopesNeeding(
|
|
|
182
178
|
.map(([scope]) => scope);
|
|
183
179
|
}
|
|
184
180
|
|
|
185
|
-
/** True when some installed scope cannot be ordered against `target` at all. */
|
|
186
|
-
function hasUnorderableScope(
|
|
187
|
-
info: PluginInfo | undefined,
|
|
188
|
-
target: string,
|
|
189
|
-
): boolean {
|
|
190
|
-
return scopeVersions(info).some(
|
|
191
|
-
([, version]) => isBehind(target, version) === null,
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
181
|
/** The "@marketplace" suffix of an id, or null when it has none to reason about. */
|
|
196
182
|
function marketplaceSuffix(id: string): string | null {
|
|
197
183
|
const at = id.indexOf("@");
|
|
@@ -373,13 +359,6 @@ export function classifyPlugin(
|
|
|
373
359
|
action: "unknown" as const,
|
|
374
360
|
scopes: [],
|
|
375
361
|
};
|
|
376
|
-
if (info?.movedTo) {
|
|
377
|
-
return {
|
|
378
|
-
...orphan,
|
|
379
|
-
reason: "moved",
|
|
380
|
-
note: `moved to ${info.movedTo}`,
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
362
|
if (index.marketplaceIsKnown(pluginId)) {
|
|
384
363
|
return {
|
|
385
364
|
...orphan,
|
|
@@ -424,22 +403,10 @@ export function classifyPlugin(
|
|
|
424
403
|
const deliverable = available ?? pin;
|
|
425
404
|
const needing = scopesNeeding(info, deliverable);
|
|
426
405
|
|
|
427
|
-
// The same "cannot tell is not current" rule as the "latest" path below.
|
|
428
406
|
// A scope that cannot be ordered against the deliverable contributes
|
|
429
|
-
// nothing to `needing
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
...base,
|
|
433
|
-
target: null,
|
|
434
|
-
action: "unknown",
|
|
435
|
-
reason: "unorderable",
|
|
436
|
-
scopes: [],
|
|
437
|
-
note:
|
|
438
|
-
available !== null
|
|
439
|
-
? `installed version cannot be compared with the published ${available}`
|
|
440
|
-
: `installed version cannot be compared with the pin ${pin}`,
|
|
441
|
-
};
|
|
442
|
-
}
|
|
407
|
+
// nothing to `needing` and lands on `current`. That is the decision: if we
|
|
408
|
+
// cannot compare, assume what is installed is the latest. See the "latest"
|
|
409
|
+
// path below for the same rule and what it costs.
|
|
443
410
|
|
|
444
411
|
const undeliverable = available !== null && available !== pin;
|
|
445
412
|
return {
|
|
@@ -488,47 +455,37 @@ export function classifyPlugin(
|
|
|
488
455
|
};
|
|
489
456
|
}
|
|
490
457
|
|
|
491
|
-
// Installed and listed, with nothing to compare against
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
458
|
+
// Installed and listed, with nothing to compare against — still published,
|
|
459
|
+
// still installable, we simply have no number. 278 of the 292 plugins in
|
|
460
|
+
// `claude-plugins-official` are in this state.
|
|
461
|
+
//
|
|
462
|
+
// By decision this is CURRENT: if we cannot compare, assume what is installed
|
|
463
|
+
// is the latest. It was `unknown`, which put a red badge and a sentence on a
|
|
464
|
+
// quarter of the report for a fact nobody can act on. The `reason` is kept so
|
|
465
|
+
// a caller can still tell this apart from a verified match; the note is not,
|
|
466
|
+
// because there is nothing to say.
|
|
499
467
|
if (!available) {
|
|
500
468
|
return {
|
|
501
469
|
...base,
|
|
502
|
-
target:
|
|
503
|
-
action: "
|
|
470
|
+
target: installed,
|
|
471
|
+
action: "current",
|
|
504
472
|
reason: "no-published-version",
|
|
505
473
|
scopes: [],
|
|
506
|
-
note: "its marketplace publishes no version for this plugin, so there is nothing to compare",
|
|
507
474
|
};
|
|
508
475
|
}
|
|
509
476
|
|
|
510
477
|
// Again per-scope, and only ever forward.
|
|
511
478
|
const needing = scopesNeeding(info, available);
|
|
512
479
|
|
|
513
|
-
//
|
|
514
|
-
// `
|
|
515
|
-
//
|
|
516
|
-
// would fall through to `current`, which is precisely the "cannot tell
|
|
517
|
-
// rendered as nothing wrong" this whole change removes.
|
|
480
|
+
// A scope whose version cannot be ordered against the catalog contributes
|
|
481
|
+
// nothing to `needing`, so it lands on `current`. That is the decision, not
|
|
482
|
+
// an oversight: if we cannot compare, assume the installed one is the latest.
|
|
518
483
|
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
...base,
|
|
525
|
-
target: null,
|
|
526
|
-
action: "unknown",
|
|
527
|
-
reason: "unorderable",
|
|
528
|
-
scopes: [],
|
|
529
|
-
note: `installed version cannot be compared with the published ${available}`,
|
|
530
|
-
};
|
|
531
|
-
}
|
|
484
|
+
// What it costs, stated plainly: such a plugin can never report an update,
|
|
485
|
+
// and reads exactly like a verified-current one. What it buys: `--check` can
|
|
486
|
+
// pass at all. It used to fail forever on the 21 registry rows carrying the
|
|
487
|
+
// literal string "unknown" as their version, across 8
|
|
488
|
+
// `@claude-plugins-official` ids — a gate nothing could make green.
|
|
532
489
|
|
|
533
490
|
return {
|
|
534
491
|
...base,
|
package/src/ui/App.tsx
CHANGED
|
@@ -454,9 +454,6 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
454
454
|
}
|
|
455
455
|
}
|
|
456
456
|
}
|
|
457
|
-
if (recovery.enabledAutoUpdate.length > 0) {
|
|
458
|
-
parts.push(`auto-update: ${recovery.enabledAutoUpdate.join(", ")}`);
|
|
459
|
-
}
|
|
460
457
|
if (recovery.removed.length > 0) {
|
|
461
458
|
parts.push(`removed: ${recovery.removed.join(", ")}`);
|
|
462
459
|
}
|
|
@@ -1193,71 +1193,30 @@ export function PluginsScreen() {
|
|
|
1193
1193
|
|
|
1194
1194
|
const plugin = item.plugin;
|
|
1195
1195
|
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
const choice = await modal.select(
|
|
1203
|
-
"Plugin moved",
|
|
1204
|
-
`${plugin.name} is no longer published by "${plugin.marketplace}", but "${plugin.movedTo}" still publishes it.`,
|
|
1205
|
-
[
|
|
1206
|
-
{
|
|
1207
|
-
label: `Migrate to ${plugin.name}@${plugin.movedTo}`,
|
|
1208
|
-
value: "migrate",
|
|
1209
|
-
},
|
|
1210
|
-
{ label: "Remove it entirely", value: "remove" },
|
|
1211
|
-
],
|
|
1212
|
-
);
|
|
1213
|
-
if (choice === null) return;
|
|
1214
|
-
if (choice === "migrate") migrateTo = plugin.movedTo;
|
|
1215
|
-
}
|
|
1196
|
+
// No "it moved, migrate there?" branch. It offered to uninstall this
|
|
1197
|
+
// plugin and install the same NAME from another marketplace, on the
|
|
1198
|
+
// strength of a name match with no history behind it — and for
|
|
1199
|
+
// `claudish`, dual-published to two marketplaces on purpose, the target
|
|
1200
|
+
// was already installed. An orphan is removed; where else a name happens
|
|
1201
|
+
// to appear is not this screen's guess to make.
|
|
1216
1202
|
|
|
1217
1203
|
try {
|
|
1218
1204
|
// Remove from all scopes — try all to clean up stale references
|
|
1219
1205
|
const scopes: PluginScope[] = ["user", "project", "local"];
|
|
1220
|
-
|
|
1206
|
+
// Nothing tallies which scopes actually held it. The migrate dialog
|
|
1207
|
+
// that consumed that tally is gone with the `moved` inference, and
|
|
1208
|
+
// collecting it for no reader is how a value goes stale unnoticed.
|
|
1221
1209
|
for (const scope of scopes) {
|
|
1222
1210
|
try {
|
|
1223
1211
|
modal.loading(
|
|
1224
1212
|
`Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
|
|
1225
1213
|
);
|
|
1226
|
-
const wasInstalled =
|
|
1227
|
-
scope === "user"
|
|
1228
|
-
? plugin.userScope?.enabled
|
|
1229
|
-
: scope === "project"
|
|
1230
|
-
? plugin.projectScope?.enabled
|
|
1231
|
-
: plugin.localScope?.enabled;
|
|
1232
1214
|
await cliUninstallPlugin(plugin.id, scope, state.projectPath);
|
|
1233
|
-
if (wasInstalled) removedFrom.push(scope);
|
|
1234
1215
|
} catch {
|
|
1235
1216
|
// Ignore errors for scopes where it doesn't exist
|
|
1236
1217
|
}
|
|
1237
1218
|
}
|
|
1238
1219
|
|
|
1239
|
-
if (migrateTo) {
|
|
1240
|
-
// Reinstall under the new namespace in every scope it occupied, so
|
|
1241
|
-
// the migration does not quietly change where the plugin is enabled.
|
|
1242
|
-
const newId = `${plugin.name}@${migrateTo}`;
|
|
1243
|
-
for (const scope of removedFrom.length > 0
|
|
1244
|
-
? removedFrom
|
|
1245
|
-
: ["project" as PluginScope]) {
|
|
1246
|
-
modal.loading(
|
|
1247
|
-
`Installing ${newId} (${scope})…\nclaude plugin install ${newId} --scope ${scope}`,
|
|
1248
|
-
);
|
|
1249
|
-
// The migrated id is a plugin magus has never recorded a
|
|
1250
|
-
// version for, so going through the read-back is what stops the
|
|
1251
|
-
// rename leaving it permanently version-less — a plugin with no
|
|
1252
|
-
// recorded version never reports an available update.
|
|
1253
|
-
await installPluginInScope(
|
|
1254
|
-
newId,
|
|
1255
|
-
scope,
|
|
1256
|
-
state.projectPath || process.cwd(),
|
|
1257
|
-
);
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
1220
|
modal.hideModal();
|
|
1262
1221
|
fetchData();
|
|
1263
1222
|
} catch (error) {
|
package/src/ui/theme-mode.ts
CHANGED
|
@@ -269,6 +269,94 @@ export const MODEL_BADGE = {
|
|
|
269
269
|
|
|
270
270
|
export type ModelBadgeKind = keyof typeof MODEL_BADGE;
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Fill, ink and page-text for the report's state badges — one set per page.
|
|
274
|
+
*
|
|
275
|
+
* Every value here is LIFTED from `MODEL_BADGE` above rather than invented, so
|
|
276
|
+
* the contrast measurements recorded there carry over. Ink on fill, and the
|
|
277
|
+
* page text on its own ground, both measured here:
|
|
278
|
+
*
|
|
279
|
+
* update `sonnet` green ink/fill 5.82 light, 4.80 dark
|
|
280
|
+
* text 6.68 on cream, 9.44 on #1C1C1E
|
|
281
|
+
* deprecated `kangaroo` red earth label 4.86 light on its own bar
|
|
282
|
+
* fix `fable` purple label 4.86 light on its own bar
|
|
283
|
+
*
|
|
284
|
+
* `text` is the tone for a heading or glyph drawn ON THE PAGE, which is not the
|
|
285
|
+
* same question as ink on a fill: on cream it is the deep member of the family,
|
|
286
|
+
* on near-black the pale one. Using the fill for both put a mid-amber heading on
|
|
287
|
+
* a dark page, where it reads as dirt.
|
|
288
|
+
*
|
|
289
|
+
* Green, not ochre, because green is what an update wears elsewhere in magus
|
|
290
|
+
* and the report was the odd one out. It is a like-for-like swap inside the same
|
|
291
|
+
* measured system: the green fill separates from the page by 1.15 on cream and
|
|
292
|
+
* 3.42 on near-black, against the ochre's 1.15 and 3.32.
|
|
293
|
+
*
|
|
294
|
+
* Amber is NOT freed up by this — `unknown` still wears it, and that is the
|
|
295
|
+
* distinction worth keeping: green says "there is a newer version", amber says
|
|
296
|
+
* "we could not tell".
|
|
297
|
+
*
|
|
298
|
+
* Purple carries "fix required" because the warm band is spoken for and the
|
|
299
|
+
* measured set has no blue. Hue, not meaning — the badge says the word.
|
|
300
|
+
*/
|
|
301
|
+
const REPORT_BADGE = {
|
|
302
|
+
update: {
|
|
303
|
+
light: { fill: "#D6EEDD", ink: "#166534", text: "#166534" },
|
|
304
|
+
dark: { fill: "#12804F", ink: "#EFFFF6", text: "#4FD98F" },
|
|
305
|
+
},
|
|
306
|
+
deprecated: {
|
|
307
|
+
light: { fill: "#F6D6D0", ink: "#8E2F26", text: "#8E2F26" },
|
|
308
|
+
dark: { fill: "#A83A2E", ink: "#FFEFEC", text: "#E07A6C" },
|
|
309
|
+
},
|
|
310
|
+
fix: {
|
|
311
|
+
light: { fill: "#E9DCF5", ink: "#6B21A8", text: "#6B21A8" },
|
|
312
|
+
dark: { fill: "#7B4CD6", ink: "#F6F0FF", text: "#A87BE8" },
|
|
313
|
+
},
|
|
314
|
+
} as const;
|
|
315
|
+
|
|
316
|
+
export type ReportBadgeKind = keyof typeof REPORT_BADGE;
|
|
317
|
+
|
|
318
|
+
export interface ReportInk {
|
|
319
|
+
/** The badge's fill. */
|
|
320
|
+
fill: string;
|
|
321
|
+
/** Ink for the word written on `fill`. */
|
|
322
|
+
ink: string;
|
|
323
|
+
/** The tone for a heading or glyph on the page itself. */
|
|
324
|
+
text: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Badge colours for one report state, for the resolved page. */
|
|
328
|
+
export function reportBadge(kind: ReportBadgeKind): ReportInk {
|
|
329
|
+
return REPORT_BADGE[kind][detected];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The unfilled part of a bar — `░` — for the page this run resolved.
|
|
334
|
+
*
|
|
335
|
+
* A track is CHROME. It exists so the eye can see how long the bar could have
|
|
336
|
+
* been, and it must never compete with the fill. "Quiet" is only definable
|
|
337
|
+
* against a page, which is why this lives here and not beside the ramps.
|
|
338
|
+
*
|
|
339
|
+
* It was a single dark grey, `darken(brand.muted, 0.68)` = #222429, chosen
|
|
340
|
+
* against a near-black terminal and applied on both. MEASURED against the two
|
|
341
|
+
* reference grounds:
|
|
342
|
+
*
|
|
343
|
+
* #222429 (was) this table
|
|
344
|
+
* dark 1.10:1 quiet 1.10:1 quiet
|
|
345
|
+
* cream 14.54:1 LOUDEST 1.24:1 quiet
|
|
346
|
+
*
|
|
347
|
+
* 14.54:1 is higher contrast than the body text. On a light terminal the empty
|
|
348
|
+
* half of every step bar was the most prominent thing in the report — a dense
|
|
349
|
+
* stipple that read as hatched texture and buried a 1 ms step's actual fill.
|
|
350
|
+
* The light value steps from the page TOWARD `brand.muted` rather than scaling
|
|
351
|
+
* a grey, so it stays in the page's own hue instead of laying cold grey on warm
|
|
352
|
+
* cream.
|
|
353
|
+
*/
|
|
354
|
+
const BAR_TRACK = { light: "#E0E2C3", dark: "#222429" } as const;
|
|
355
|
+
|
|
356
|
+
export function barTrack(): string {
|
|
357
|
+
return BAR_TRACK[detected];
|
|
358
|
+
}
|
|
359
|
+
|
|
272
360
|
export interface ModelInk {
|
|
273
361
|
bg: string;
|
|
274
362
|
fg: string;
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import { constants, accessSync, statSync } from "node:fs";
|
|
3
2
|
import { access, realpath } from "node:fs/promises";
|
|
4
3
|
import { join } from "node:path";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
|
-
|
|
7
|
-
const execFileAsync = promisify(execFile);
|
|
8
4
|
|
|
9
5
|
/**
|
|
10
6
|
* A command as an executable plus its already-separated arguments.
|
|
@@ -70,19 +66,28 @@ function quoteToken(token: string): string {
|
|
|
70
66
|
}
|
|
71
67
|
|
|
72
68
|
/**
|
|
73
|
-
* Check if a command exists in PATH
|
|
74
|
-
*
|
|
75
|
-
*
|
|
69
|
+
* Check if a command exists in PATH.
|
|
70
|
+
*
|
|
71
|
+
* Async only for its callers' sake — it spawns nothing. This used to run
|
|
72
|
+
* `execFileAsync("which", [command])`, a real `which(1)` process with no
|
|
73
|
+
* timeout, on every one of its dozen call sites. Answering "is bun on PATH?"
|
|
74
|
+
* does not need a process, and under load that process is what stalls:
|
|
75
|
+
* `detectToolchains` calls this per toolchain, and in a 103-file parallel test
|
|
76
|
+
* run the spawn queue backed up until the test failed at **20,007 ms** — after
|
|
77
|
+
* the per-test timeout had already been raised from 5s to 20s to accommodate
|
|
78
|
+
* the earlier 5s failures. Raising the limit moved the goalposts; removing the
|
|
79
|
+
* spawn removes the stall.
|
|
80
|
+
*
|
|
81
|
+
* {@link whichSync} is also STRICTER than `which(1)`, which is a bonus rather
|
|
82
|
+
* than a risk: it stats the candidate and checks the execute bit, so a dangling
|
|
83
|
+
* symlink answers null here instead of reporting the link's own path. That is
|
|
84
|
+
* the exact failure {@link resolveExecutable} was written to catch afterwards.
|
|
85
|
+
*
|
|
86
|
+
* @param command - Command name to check, or an explicit path
|
|
76
87
|
* @returns Path to command if found, null otherwise
|
|
77
88
|
*/
|
|
78
89
|
export async function which(command: string): Promise<string | null> {
|
|
79
|
-
|
|
80
|
-
// Use execFile with argument array to prevent command injection
|
|
81
|
-
const { stdout } = await execFileAsync("which", [command]);
|
|
82
|
-
return stdout.trim() || null;
|
|
83
|
-
} catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
90
|
+
return whichSync(command);
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/**
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* An orphaned plugin that merely changed marketplace is not deprecated.
|
|
3
|
-
*
|
|
4
|
-
* Splitting `magus` into `magus` + `magus-marketing` left `video-editing@magus`
|
|
5
|
-
* and `nanobanana@magus` installed under a namespace that no longer lists them. They rendered as bare
|
|
6
|
-
* "deprecated", whose only action is deletion — which drops a plugin that is
|
|
7
|
-
* still published, just under a different id.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { describe, expect, test } from "bun:test";
|
|
11
|
-
import type { LocalMarketplace } from "../services/local-marketplace.js";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Mirrors `findPluginInOtherMarketplace` in plugin-manager.ts, which is module
|
|
15
|
-
* -private. Kept in lockstep with it; the behaviour, not the binding, is what
|
|
16
|
-
* these tests pin.
|
|
17
|
-
*/
|
|
18
|
-
function findPluginInOtherMarketplace(
|
|
19
|
-
pluginName: string,
|
|
20
|
-
currentMarketplace: string,
|
|
21
|
-
localMarketplaces: Map<string, LocalMarketplace>,
|
|
22
|
-
): string | undefined {
|
|
23
|
-
for (const [name, mp] of localMarketplaces) {
|
|
24
|
-
if (name === currentMarketplace) continue;
|
|
25
|
-
if (mp.plugins.some((p) => p.name === pluginName)) return name;
|
|
26
|
-
}
|
|
27
|
-
return undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const mp = (name: string, plugins: string[]): [string, LocalMarketplace] => [
|
|
31
|
-
name,
|
|
32
|
-
{
|
|
33
|
-
name,
|
|
34
|
-
description: "",
|
|
35
|
-
plugins: plugins.map((p) => ({
|
|
36
|
-
name: p,
|
|
37
|
-
version: "1.0.0",
|
|
38
|
-
description: "",
|
|
39
|
-
})),
|
|
40
|
-
},
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
describe("moved-marketplace detection", () => {
|
|
44
|
-
test("finds a plugin that moved to a sibling marketplace", () => {
|
|
45
|
-
const marketplaces = new Map([
|
|
46
|
-
mp("magus", ["dev", "terminal"]),
|
|
47
|
-
mp("magus-marketing", ["image-generate", "video-editing"]),
|
|
48
|
-
]);
|
|
49
|
-
|
|
50
|
-
expect(
|
|
51
|
-
findPluginInOtherMarketplace("video-editing", "magus", marketplaces),
|
|
52
|
-
).toBe("magus-marketing");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("a genuinely retired plugin reports no destination", () => {
|
|
56
|
-
// `conductor` was removed at magus v8.0.0 and republished nowhere.
|
|
57
|
-
const marketplaces = new Map([
|
|
58
|
-
mp("magus", ["dev"]),
|
|
59
|
-
mp("magus-marketing", ["video-editing"]),
|
|
60
|
-
]);
|
|
61
|
-
|
|
62
|
-
expect(
|
|
63
|
-
findPluginInOtherMarketplace("conductor", "magus", marketplaces),
|
|
64
|
-
).toBeUndefined();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("does not propose the marketplace it is already installed from", () => {
|
|
68
|
-
const marketplaces = new Map([mp("magus", ["dev"])]);
|
|
69
|
-
expect(findPluginInOtherMarketplace("dev", "magus", marketplaces)).toBe(
|
|
70
|
-
undefined,
|
|
71
|
-
);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("a renamed plugin is not matched — the name is the identity", () => {
|
|
75
|
-
// nanobanana became image-generate. Names differ, so this correctly
|
|
76
|
-
// reports no destination rather than guessing at a rename.
|
|
77
|
-
const marketplaces = new Map([
|
|
78
|
-
mp("magus", ["dev"]),
|
|
79
|
-
mp("magus-marketing", ["image-generate"]),
|
|
80
|
-
]);
|
|
81
|
-
|
|
82
|
-
expect(
|
|
83
|
-
findPluginInOtherMarketplace("nanobanana", "magus", marketplaces),
|
|
84
|
-
).toBeUndefined();
|
|
85
|
-
});
|
|
86
|
-
});
|