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.
@@ -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
- autoUpdateDisabled.push(name);
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, autoUpdateDisabled };
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
  *
@@ -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
- /** unknown: listed, but its marketplace publishes no version for it. */
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`, and used to fall through to `current` here.
430
- if (needing.length === 0 && hasUnorderableScope(info, deliverable)) {
431
- return {
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. The answer is
492
- // `unknown` — "current" is a claim we cannot support — and the note must not
493
- // confuse this with the withdrawn case (handled above as an orphan): it is
494
- // still published, still installable; we simply have no number to compare.
495
- // 278 of the 292 plugins in `claude-plugins-official` are in this state, so
496
- // reporting them as "not offered by any configured marketplace" told the
497
- // user their installed, working, currently-published plugin had been
498
- // withdrawn.
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: null,
503
- action: "unknown",
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
- // No scope is provably behind — but that is not the same as being current.
514
- // `scopesNeeding` filters on `isBehind(...) === true`, so a scope whose
515
- // version cannot be ordered against the catalog contributes nothing and
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
- // Real and measured: 21 registry rows carry the literal string "unknown"
520
- // as their version, across 8 `@claude-plugins-official` ids. Against a
521
- // semver catalog they are unorderable, so they land here every run.
522
- if (needing.length === 0 && hasUnorderableScope(info, available)) {
523
- return {
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
- // A plugin that moved marketplace is not deprecated — it is still
1197
- // published under a new namespace. Offering only "remove" silently drops
1198
- // something the user still wants (the magus → magus-marketing split left
1199
- // video-editing and image-generate in this state).
1200
- let migrateTo: string | undefined;
1201
- if (plugin.movedTo) {
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
- const removedFrom: PluginScope[] = [];
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) {
@@ -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
- * Uses execFile (not exec) to avoid shell injection vulnerabilities
75
- * @param command - Command name to check
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
- try {
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
- });