magus-cli 7.0.0 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/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__/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__/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 +58 -11
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/router.ts +59 -2
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +237 -45
- package/src/cli/update.ts +59 -29
- package/src/main.tsx +11 -2
- package/src/services/claude-settings.ts +15 -40
- package/src/services/local-marketplace.ts +76 -6
- package/src/services/manifest.ts +21 -0
- package/src/services/marketplace-refresh.ts +7 -15
- package/src/services/plugin-manager.ts +0 -31
- 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/resolver.ts +33 -0
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-plan.ts +23 -66
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +60 -3
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +9 -50
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +142 -0
- package/src/utils/command-utils.ts +19 -14
- package/src/__tests__/moved-marketplace.test.ts +0 -86
|
@@ -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) {
|
|
@@ -190,7 +190,7 @@ export function ProfilesScreen() {
|
|
|
190
190
|
? "~/.claude/settings.json"
|
|
191
191
|
: ".claude/settings.json";
|
|
192
192
|
|
|
193
|
-
const pluginCount = Object.keys(selectedProfile.plugins).length;
|
|
193
|
+
const pluginCount = Object.keys(selectedProfile.plugins ?? {}).length;
|
|
194
194
|
const emptyWarning =
|
|
195
195
|
pluginCount === 0
|
|
196
196
|
? "\n\nWARNING: This profile has no plugins — applying will disable all plugins."
|
package/src/ui/state/types.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ProfileEntry,
|
|
13
13
|
SkillInfo,
|
|
14
14
|
} from "../../types/index.js";
|
|
15
|
+
import type { ReportBadgeKind } from "../theme-mode.js";
|
|
15
16
|
|
|
16
17
|
// ============================================================================
|
|
17
18
|
// Route Types
|
|
@@ -76,6 +77,13 @@ export interface SelectOption {
|
|
|
76
77
|
label: string;
|
|
77
78
|
value: string;
|
|
78
79
|
description?: string;
|
|
80
|
+
/**
|
|
81
|
+
* A chip for a discrete state — dark ink on a saturated fill, the shape the
|
|
82
|
+
* report already uses. Optional because most selects have no state to say.
|
|
83
|
+
*/
|
|
84
|
+
badge?: { label: string; kind: ReportBadgeKind };
|
|
85
|
+
/** Dim trailing detail — chrome, never the thing being chosen. */
|
|
86
|
+
meta?: string;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
export type ModalState =
|
|
@@ -102,6 +110,16 @@ export type ModalState =
|
|
|
102
110
|
defaultIndex?: number;
|
|
103
111
|
onSelect: (value: string) => void;
|
|
104
112
|
onCancel: () => void;
|
|
113
|
+
/**
|
|
114
|
+
* False for a question that must be answered.
|
|
115
|
+
*
|
|
116
|
+
* Only changes what the footer PROMISES; `onCancel` decides what Escape
|
|
117
|
+
* actually does. The two must agree, and they did not: the profile gate's
|
|
118
|
+
* `onCancel` is a no-op, so the footer's "esc cancel" was an instruction
|
|
119
|
+
* that did nothing when followed — worse than no hint, because the user
|
|
120
|
+
* concludes the app is stuck rather than that the question is required.
|
|
121
|
+
*/
|
|
122
|
+
cancellable?: boolean;
|
|
105
123
|
}
|
|
106
124
|
| {
|
|
107
125
|
type: "message";
|
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;
|
|
@@ -308,6 +396,60 @@ export function trackFill(): string {
|
|
|
308
396
|
return TRACK_FILL[detected];
|
|
309
397
|
}
|
|
310
398
|
|
|
399
|
+
/**
|
|
400
|
+
* A key hint drawn as ONE two-tone chip: the key on a brighter fill, its label
|
|
401
|
+
* on a quieter fill of the same hue, butted together with no gap.
|
|
402
|
+
*
|
|
403
|
+
* Two halves rather than a chip plus loose text, because a bare label beside a
|
|
404
|
+
* filled key reads as prose that happens to follow a badge. Both halves filled
|
|
405
|
+
* makes the pair one object, and the brightness step inside it says which half
|
|
406
|
+
* you press.
|
|
407
|
+
*
|
|
408
|
+
* GREY, and no hue at all. Two earlier passes both failed by colouring it: the
|
|
409
|
+
* first gave `↵` the info blue and `esc` the danger red, which read as a traffic
|
|
410
|
+
* light — three saturated chips of unrelated meaning, none of them signalling
|
|
411
|
+
* anything, since `esc` is not dangerous, it is the way out. The second made all
|
|
412
|
+
* three magus purple, which stopped lying but competed with the selected row's
|
|
413
|
+
* purple fill directly above it, so the two most prominent things in the dialog
|
|
414
|
+
* were the same colour and the eye had nowhere to land.
|
|
415
|
+
*
|
|
416
|
+
* A key hint is chrome. It says "these are keys" and nothing more, so it earns
|
|
417
|
+
* no hue — leaving the dialog exactly one accent, on the row you are choosing.
|
|
418
|
+
* The two halves separate by INTENSITY: the key steps toward the foreground,
|
|
419
|
+
* the label sits near the page.
|
|
420
|
+
*
|
|
421
|
+
* Both pages are built from the greys already in this file — the light label is
|
|
422
|
+
* `SCOPE_OFF_FILL.light`, the dark key is `TRACK_FILL.dark` — so the footer sits
|
|
423
|
+
* in the same neutral family as every other quiet fill rather than introducing a
|
|
424
|
+
* third grey ramp.
|
|
425
|
+
*/
|
|
426
|
+
const HINT_CHIP = {
|
|
427
|
+
light: {
|
|
428
|
+
keyBg: "#B4B4A6",
|
|
429
|
+
keyFg: "#1F1F1A",
|
|
430
|
+
labelBg: "#D9D9CC",
|
|
431
|
+
labelFg: "#44443D",
|
|
432
|
+
},
|
|
433
|
+
dark: {
|
|
434
|
+
keyBg: "#6E6E7A",
|
|
435
|
+
keyFg: "#FBFBFD",
|
|
436
|
+
labelBg: "#44444E",
|
|
437
|
+
labelFg: "#D6D6DE",
|
|
438
|
+
},
|
|
439
|
+
} as const;
|
|
440
|
+
|
|
441
|
+
export interface HintChipInk {
|
|
442
|
+
keyBg: string;
|
|
443
|
+
keyFg: string;
|
|
444
|
+
labelBg: string;
|
|
445
|
+
labelFg: string;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** The two-tone key-hint chip, for the resolved mode. */
|
|
449
|
+
export function hintChip(): HintChipInk {
|
|
450
|
+
return HINT_CHIP[detected];
|
|
451
|
+
}
|
|
452
|
+
|
|
311
453
|
/**
|
|
312
454
|
* Effort's colour ramp — TEXT only, never a fill, so a row never paints two blocks.
|
|
313
455
|
*
|
|
@@ -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
|
-
});
|