magus-cli 7.6.0 → 7.8.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 +29 -15
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1203 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +920 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +724 -0
- package/src/__tests__/format-update-outcome.test.ts +9 -34
- package/src/__tests__/gitignore-profile.test.ts +0 -4
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +23 -17
- package/src/__tests__/useStaleLoadModal.test.ts +1 -42
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/doctor.ts +151 -30
- package/src/cli/install.ts +5 -6
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +27 -5
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +84 -36
- package/src/services/gitignore-service.ts +0 -19
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +31 -19
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-absorb.ts +9 -0
- package/src/services/profile-activate.ts +1 -1
- package/src/services/profile-startup.ts +30 -27
- package/src/services/profile-state.ts +81 -2
- package/src/services/resolver.ts +95 -1
- package/src/services/update-engine.ts +326 -31
- package/src/services/update-plan.ts +293 -33
- package/src/types/index.ts +7 -0
- package/src/ui/App.tsx +186 -123
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +299 -28
- package/src/ui/adapters/startupReports.ts +282 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -13
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +31 -31
- package/src/ui/hooks/useProfileStartupModal.ts +127 -122
- package/src/ui/hooks/useStaleLoadModal.ts +33 -53
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -44
- package/src/ui/screens/CliToolsScreen.tsx +65 -51
- package/src/ui/screens/EnvVarsScreen.tsx +21 -6
- package/src/ui/screens/GitignoreScreen.tsx +49 -75
- package/src/ui/screens/McpRegistryScreen.tsx +72 -40
- package/src/ui/screens/McpScreen.tsx +55 -24
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +316 -245
- package/src/ui/screens/ProfilesScreen.tsx +88 -73
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +72 -7
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +25 -14
- package/src/ui/state/types.ts +61 -31
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/version-snapshot.ts +0 -189
|
@@ -59,6 +59,7 @@ import { readManifest, writeManifest } from "./manifest.js";
|
|
|
59
59
|
import { validateModelsConfig } from "./models-core.js";
|
|
60
60
|
import {
|
|
61
61
|
type ActivateOptions,
|
|
62
|
+
type ActivationResult,
|
|
62
63
|
activateProfile,
|
|
63
64
|
activationNotes,
|
|
64
65
|
releasedCommittedSkillNote,
|
|
@@ -588,6 +589,12 @@ export interface ReconcileResult {
|
|
|
588
589
|
conflicts: string[];
|
|
589
590
|
/** Everything worth printing, in order. */
|
|
590
591
|
lines: string[];
|
|
592
|
+
/**
|
|
593
|
+
* The re-render, when profiles.json changed and the live config was
|
|
594
|
+
* generated again. `lines` already carries its CLI wording; this is for a
|
|
595
|
+
* surface that words it differently (the TUI's notices).
|
|
596
|
+
*/
|
|
597
|
+
activation: ActivationResult | null;
|
|
591
598
|
}
|
|
592
599
|
|
|
593
600
|
function emptyResult(profile: string | null): ReconcileResult {
|
|
@@ -600,6 +607,7 @@ function emptyResult(profile: string | null): ReconcileResult {
|
|
|
600
607
|
unreadable: [],
|
|
601
608
|
conflicts: [],
|
|
602
609
|
lines: [],
|
|
610
|
+
activation: null,
|
|
603
611
|
};
|
|
604
612
|
}
|
|
605
613
|
|
|
@@ -1529,6 +1537,7 @@ export async function reconcileActiveProfile(
|
|
|
1529
1537
|
// command that ran it reports that once, itself, and repeating it here
|
|
1530
1538
|
// printed the same warning three times in one `magus update`.
|
|
1531
1539
|
notes.push(...activationNotes(activation, { tracked: false }));
|
|
1540
|
+
result.activation = activation;
|
|
1532
1541
|
} catch (error) {
|
|
1533
1542
|
result.problems.push(
|
|
1534
1543
|
error instanceof Error ? error.message : String(error),
|
|
@@ -209,7 +209,7 @@ export function releasedCommittedSkillNote(folder: string): string {
|
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
211
|
* Lines describing what activation did beyond the switch itself, phrased for
|
|
212
|
-
*
|
|
212
|
+
* the terminal.
|
|
213
213
|
*
|
|
214
214
|
* `tracked: false` leaves the tracked-path notices out, for the CLI, which
|
|
215
215
|
* prints them itself as a warning with the fix beneath it
|
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
findEnclosingManifest,
|
|
70
70
|
readManifest,
|
|
71
71
|
} from "./manifest.js";
|
|
72
|
-
import type { ProfileChoice } from "./profile-activate.js";
|
|
72
|
+
import type { ActivationResult, ProfileChoice } from "./profile-activate.js";
|
|
73
73
|
import { freeProfileId } from "./profile-adopt.js";
|
|
74
74
|
import {
|
|
75
75
|
activeProfile,
|
|
@@ -307,25 +307,29 @@ export async function oldFormatNeed(
|
|
|
307
307
|
};
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
/**
|
|
310
|
+
/** The TUI's startup: the question (if any), and what the gate refused. */
|
|
311
311
|
export interface ProfileStartup {
|
|
312
312
|
need: ProfileStartupNeed;
|
|
313
|
-
/** One line per thing the gate did: absorbed edits, regeneration, repairs. */
|
|
314
|
-
notices: string[];
|
|
315
313
|
/**
|
|
316
|
-
* One line per thing the gate REFUSED — the lines the CLI stops on.
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
314
|
+
* One line per thing the gate REFUSED — the lines the CLI stops on. A
|
|
315
|
+
* refusal can leave detection reading `ok`: an old layout whose settings
|
|
316
|
+
* file does not parse is not converted, yet still names its profile, so only
|
|
317
|
+
* this says the update did not happen.
|
|
320
318
|
*/
|
|
321
319
|
problems: string[];
|
|
320
|
+
/**
|
|
321
|
+
* Every activation the gate ran — a re-render after profiles.json changed,
|
|
322
|
+
* an older format converted — so the TUI can say what one of them could
|
|
323
|
+
* not do. Absent is none.
|
|
324
|
+
*/
|
|
325
|
+
activations?: ActivationResult[];
|
|
322
326
|
}
|
|
323
327
|
|
|
324
328
|
/**
|
|
325
329
|
* The TUI's startup: the same gate every CLI command runs, then detection.
|
|
326
330
|
*
|
|
327
|
-
* A failure in the gate never blocks the TUI — its message
|
|
328
|
-
* and detection still runs, so the user is asked whatever is left
|
|
331
|
+
* A failure in the gate never blocks the TUI — its message is kept in
|
|
332
|
+
* `problems` and detection still runs, so the user is asked whatever is left.
|
|
329
333
|
*/
|
|
330
334
|
export async function prepareProfileStartup(
|
|
331
335
|
projectPath: string,
|
|
@@ -338,14 +342,16 @@ export async function prepareProfileStartup(
|
|
|
338
342
|
): Promise<ProfileStartup> {
|
|
339
343
|
if (!opts.convert) {
|
|
340
344
|
const format = await oldFormatNeed(projectPath);
|
|
341
|
-
if (format !== null)
|
|
345
|
+
if (format !== null) {
|
|
346
|
+
return { need: format, problems: [] };
|
|
347
|
+
}
|
|
342
348
|
}
|
|
343
|
-
const notices: string[] = [];
|
|
344
349
|
const problems: string[] = [];
|
|
350
|
+
const activations: ActivationResult[] = [];
|
|
345
351
|
try {
|
|
346
352
|
const prep = await prepareProfileState(projectPath);
|
|
347
|
-
notices.push(...prep.lines);
|
|
348
353
|
problems.push(...prep.problems);
|
|
354
|
+
activations.push(...prep.activations);
|
|
349
355
|
} catch (error) {
|
|
350
356
|
if (!(error instanceof SettingsParseError)) {
|
|
351
357
|
problems.push(error instanceof Error ? error.message : String(error));
|
|
@@ -353,15 +359,18 @@ export async function prepareProfileStartup(
|
|
|
353
359
|
}
|
|
354
360
|
return {
|
|
355
361
|
need: await detectProfileStartupNeed(projectPath),
|
|
356
|
-
notices,
|
|
357
362
|
problems,
|
|
363
|
+
...(activations.length > 0 ? { activations } : {}),
|
|
358
364
|
};
|
|
359
365
|
}
|
|
360
366
|
|
|
361
|
-
/**
|
|
367
|
+
/**
|
|
368
|
+
* What the TUI does once "Update it now" has run: ask another question, carry
|
|
369
|
+
* on, or show an error modal.
|
|
370
|
+
*/
|
|
362
371
|
export type FormatUpdateOutcome =
|
|
363
372
|
| { kind: "ask"; need: ProfileStartupNeed }
|
|
364
|
-
| { kind: "updated"
|
|
373
|
+
| { kind: "updated" }
|
|
365
374
|
| { kind: "failed"; title: string; message: string };
|
|
366
375
|
|
|
367
376
|
/**
|
|
@@ -381,14 +390,7 @@ export function formatUpdateOutcome(
|
|
|
381
390
|
};
|
|
382
391
|
}
|
|
383
392
|
if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
|
|
384
|
-
return {
|
|
385
|
-
kind: "updated",
|
|
386
|
-
title: "Profile format updated",
|
|
387
|
-
message:
|
|
388
|
-
result.notices.length > 0
|
|
389
|
-
? result.notices.join("\n")
|
|
390
|
-
: "This project now uses the current profile format.",
|
|
391
|
-
};
|
|
393
|
+
return { kind: "updated" };
|
|
392
394
|
}
|
|
393
395
|
|
|
394
396
|
// ─── What to offer, as data both surfaces render ─────────────────────────────
|
|
@@ -426,9 +428,10 @@ export const UPDATE_FORMAT_VALUE = "update-format";
|
|
|
426
428
|
* Neither mentions the backup, deliberately. A sentence promising "nothing is
|
|
427
429
|
* deleted — the old files are moved to a backup" raises a question the reader
|
|
428
430
|
* did not have (which files? what was at risk?) and implies data loss, when what
|
|
429
|
-
* changes is settings.
|
|
430
|
-
*
|
|
431
|
-
*
|
|
431
|
+
* changes is settings. The TUI does not name the backup afterwards either: the
|
|
432
|
+
* backup is magus's own storage, and the user acts on nothing there. Only a
|
|
433
|
+
* failed activation lists what was moved, because then it is something to act
|
|
434
|
+
* on (see `describeFailure` in ui/hooks/useProfileStartupModal.ts).
|
|
432
435
|
*
|
|
433
436
|
* No file paths in these strings, deliberately. The TUI modal soft-wraps its
|
|
434
437
|
* message at whatever width the terminal gives it, and OpenTUI treats `.` as a
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import path from "node:path";
|
|
29
|
-
import type { ProfileManifest } from "../types/index.js";
|
|
30
|
-
import {
|
|
29
|
+
import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
|
|
30
|
+
import { selectProfileId } from "./active-closure.js";
|
|
31
|
+
import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
|
|
31
32
|
import {
|
|
32
33
|
type ReconcileResult,
|
|
33
34
|
absorbOlderLayout,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
type ActiveMarker,
|
|
48
49
|
LIVE_FILE_KEYS,
|
|
49
50
|
MACHINE_LOCAL_SETTINGS_KEYS,
|
|
51
|
+
activeProfile,
|
|
50
52
|
folderHash,
|
|
51
53
|
hasCurrentMarker,
|
|
52
54
|
hasOlderLayout,
|
|
@@ -68,6 +70,7 @@ import {
|
|
|
68
70
|
inspectManagedSkills,
|
|
69
71
|
skillFolder,
|
|
70
72
|
} from "./profile-skills.js";
|
|
73
|
+
import { resolveExtends } from "./resolver.js";
|
|
71
74
|
import type { SettingsParseError } from "./settings-file.js";
|
|
72
75
|
|
|
73
76
|
export type ActiveProfileCheck =
|
|
@@ -299,6 +302,12 @@ export interface ProfileStatePrep {
|
|
|
299
302
|
problems: string[];
|
|
300
303
|
/** The files behind `problems` that could not be parsed. */
|
|
301
304
|
unreadable: SettingsParseError[];
|
|
305
|
+
/**
|
|
306
|
+
* Every activation the gate ran, in order: a re-render after profiles.json
|
|
307
|
+
* changed, an older layout converted. `lines` already carries them in the
|
|
308
|
+
* CLI's words; the TUI words them itself from these.
|
|
309
|
+
*/
|
|
310
|
+
activations: ActivationResult[];
|
|
302
311
|
}
|
|
303
312
|
|
|
304
313
|
/**
|
|
@@ -319,11 +328,13 @@ export async function prepareProfileState(
|
|
|
319
328
|
lines: [],
|
|
320
329
|
problems: [],
|
|
321
330
|
unreadable: [],
|
|
331
|
+
activations: [],
|
|
322
332
|
};
|
|
323
333
|
const take = (r: ReconcileResult) => {
|
|
324
334
|
out.lines.push(...r.lines.filter((l) => !r.problems.includes(l)));
|
|
325
335
|
out.problems.push(...r.problems);
|
|
326
336
|
out.unreadable.push(...r.unreadable);
|
|
337
|
+
if (r.activation) out.activations.push(r.activation);
|
|
327
338
|
};
|
|
328
339
|
|
|
329
340
|
if (check.state === "active" && check.restored) {
|
|
@@ -358,6 +369,7 @@ export async function prepareProfileState(
|
|
|
358
369
|
}
|
|
359
370
|
// Tracked files are the command's to report, once (see profile-absorb).
|
|
360
371
|
out.lines.push(...activationNotes(result, { tracked: false }));
|
|
372
|
+
out.activations.push(result);
|
|
361
373
|
}
|
|
362
374
|
out.check = { state: "active", profile, restored: false };
|
|
363
375
|
}
|
|
@@ -643,3 +655,70 @@ export async function withCliBookkeeping<T>(
|
|
|
643
655
|
for (const line of reconciled.lines) opts.report?.(line);
|
|
644
656
|
}
|
|
645
657
|
}
|
|
658
|
+
|
|
659
|
+
/** Whether a profile entry, `extends` included, turns the plugin on. */
|
|
660
|
+
function profileLists(
|
|
661
|
+
entry: ProfileManifestEntry | undefined,
|
|
662
|
+
pluginId: string,
|
|
663
|
+
): boolean {
|
|
664
|
+
if (!entry) return false;
|
|
665
|
+
const value = resolveExtends(entry).plugins?.[pluginId];
|
|
666
|
+
return value !== undefined && value !== false;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Stop a profile listing `pluginId`, so its next activation does not turn the
|
|
671
|
+
* plugin back on. Answers whether the profile no longer lists it — read back
|
|
672
|
+
* from profiles.json after the write, never assumed.
|
|
673
|
+
*
|
|
674
|
+
* For a deprecated plugin `magus update` removes. The uninstall that runs first
|
|
675
|
+
* deletes the plugin from this project's settings (measured, Claude Code
|
|
676
|
+
* 2.1.284), and `reconcileActiveProfile` would absorb that into the ACTIVE
|
|
677
|
+
* profile at the next entry point — but only there, and only for the active
|
|
678
|
+
* one. The Plugins screen's `a` runs no reconcile afterwards, so the screen kept
|
|
679
|
+
* the plugin declared and planned the same removal again; `magus update <name>`
|
|
680
|
+
* plans a profile that is not active, whose live files nothing absorbs from;
|
|
681
|
+
* and a profile declaring a plugin that is installed nowhere has no settings
|
|
682
|
+
* change to absorb at all. So the entry is edited here, directly, through
|
|
683
|
+
* {@link updateProfileEntry} — the editor `magus models use` writes through.
|
|
684
|
+
* The reconcile that follows (`withCliBookkeeping`, or the next entry point)
|
|
685
|
+
* finds profiles.json and the live files agreeing, and regenerates from it.
|
|
686
|
+
*
|
|
687
|
+
* `profile` undefined means the active one, chosen as the Plugins screen and
|
|
688
|
+
* `magus update` choose it; null means no profile, which lists nothing.
|
|
689
|
+
*
|
|
690
|
+
* Deletes the entry's own pin. When `extends` supplies the plugin, the pin
|
|
691
|
+
* becomes `false`, which is how an entry turns an inherited plugin off.
|
|
692
|
+
* Throws `SettingsParseError` when profiles.json exists and does not parse.
|
|
693
|
+
*/
|
|
694
|
+
export async function removePluginFromProfile(
|
|
695
|
+
projectPath: string,
|
|
696
|
+
pluginId: string,
|
|
697
|
+
profile?: string | null,
|
|
698
|
+
): Promise<boolean> {
|
|
699
|
+
const manifest = await readManifest(projectPath);
|
|
700
|
+
const id =
|
|
701
|
+
profile === undefined
|
|
702
|
+
? selectProfileId(
|
|
703
|
+
Object.keys(manifest.profiles),
|
|
704
|
+
undefined,
|
|
705
|
+
await activeProfile(projectPath),
|
|
706
|
+
)
|
|
707
|
+
: profile;
|
|
708
|
+
if (!id || !profileLists(manifest.profiles[id], pluginId)) return true;
|
|
709
|
+
|
|
710
|
+
await updateProfileEntry(projectPath, id, (entry) => {
|
|
711
|
+
const plugins = { ...(entry.plugins ?? {}) };
|
|
712
|
+
delete plugins[pluginId];
|
|
713
|
+
if (profileLists({ ...entry, plugins }, pluginId))
|
|
714
|
+
plugins[pluginId] = false;
|
|
715
|
+
if (Object.keys(plugins).length > 0) entry.plugins = plugins;
|
|
716
|
+
// biome-ignore lint/performance/noDelete: absent, not undefined, in profiles.json
|
|
717
|
+
else delete entry.plugins;
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
return !profileLists(
|
|
721
|
+
(await readManifest(projectPath)).profiles[id],
|
|
722
|
+
pluginId,
|
|
723
|
+
);
|
|
724
|
+
}
|
package/src/services/resolver.ts
CHANGED
|
@@ -60,6 +60,7 @@ export async function resolveDependencies(
|
|
|
60
60
|
read: PluginDependencyReader,
|
|
61
61
|
): Promise<DependencyReport> {
|
|
62
62
|
const derived: string[] = [];
|
|
63
|
+
const requiredBy: Record<string, string> = {};
|
|
63
64
|
const unreadable: string[] = [];
|
|
64
65
|
const blocked: DependencyReport["blocked"] = [];
|
|
65
66
|
const queue = [...enabled];
|
|
@@ -75,13 +76,105 @@ export async function resolveDependencies(
|
|
|
75
76
|
blocked.push({ plugin: id, dependency });
|
|
76
77
|
continue;
|
|
77
78
|
}
|
|
79
|
+
// Every edge, including to a plugin the profile names itself: "still
|
|
80
|
+
// required" is a fact about the plugin that needs it, not about how
|
|
81
|
+
// the profile came to enable it. Recording only derived ones let a
|
|
82
|
+
// no-longer-published dependency the profile ALSO listed be planned
|
|
83
|
+
// for removal from under the plugin that needs it.
|
|
84
|
+
if (dependency !== id && !Object.hasOwn(requiredBy, dependency)) {
|
|
85
|
+
requiredBy[dependency] = id;
|
|
86
|
+
}
|
|
78
87
|
if (seen.has(dependency)) continue;
|
|
79
88
|
seen.add(dependency);
|
|
80
89
|
derived.push(dependency);
|
|
81
90
|
queue.push(dependency);
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
derived: derived.sort(),
|
|
95
|
+
requiredBy,
|
|
96
|
+
unreadable: unreadable.sort(),
|
|
97
|
+
blocked,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Which plugin requires each plugin, for the planner's STILL-REQUIRED rule:
|
|
103
|
+
* the profile's edges (`DependencyReport.requiredBy`) and, beside them, the
|
|
104
|
+
* edges every INSTALLED plugin's own plugin.json declares.
|
|
105
|
+
*
|
|
106
|
+
* The profile alone is not enough. A plugin installed at USER scope, or in a
|
|
107
|
+
* project with no profile at all, is planned too — and without its edges a
|
|
108
|
+
* dependency it needs that stopped being published was planned for removal
|
|
109
|
+
* from under it. One plugin.json read per installed plugin, the same read the
|
|
110
|
+
* profile's resolve makes. The profile's answer wins where both name one.
|
|
111
|
+
*
|
|
112
|
+
* ONE function for `magus update` and the Plugins screen, so the two plan the
|
|
113
|
+
* same removals.
|
|
114
|
+
*/
|
|
115
|
+
export async function requiredByHere(
|
|
116
|
+
profileRequiredBy: Readonly<Record<string, string>> | undefined,
|
|
117
|
+
installedIds: readonly string[],
|
|
118
|
+
projectPath?: string,
|
|
119
|
+
read: PluginDependencyReader = readPluginDependencies,
|
|
120
|
+
): Promise<Record<string, string>> {
|
|
121
|
+
const all = await requirersHere(
|
|
122
|
+
profileRequiredBy,
|
|
123
|
+
installedIds,
|
|
124
|
+
projectPath,
|
|
125
|
+
read,
|
|
126
|
+
);
|
|
127
|
+
const first: Record<string, string> = {};
|
|
128
|
+
for (const [dependency, requirers] of Object.entries(all)) {
|
|
129
|
+
const [requirer] = requirers;
|
|
130
|
+
if (requirer) first[dependency] = requirer;
|
|
131
|
+
}
|
|
132
|
+
return first;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* {@link requiredByHere} with EVERY requirer of each plugin, not only the
|
|
137
|
+
* first — what the planner needs to remove deprecated plugins that require
|
|
138
|
+
* one another together, while keeping one that anything staying still needs.
|
|
139
|
+
* The profile's requirer comes first; the installed plugins' follow, sorted.
|
|
140
|
+
*
|
|
141
|
+
* This is what `magus update` and the Plugins screen plan with.
|
|
142
|
+
*/
|
|
143
|
+
export async function requirersHere(
|
|
144
|
+
profileRequiredBy: Readonly<Record<string, string>> | undefined,
|
|
145
|
+
installedIds: readonly string[],
|
|
146
|
+
projectPath?: string,
|
|
147
|
+
read: PluginDependencyReader = readPluginDependencies,
|
|
148
|
+
): Promise<Record<string, string[]>> {
|
|
149
|
+
const declared = await Promise.all(
|
|
150
|
+
[...installedIds].sort().map(async (id) => {
|
|
151
|
+
try {
|
|
152
|
+
return [id, await read(id, projectPath)] as const;
|
|
153
|
+
} catch {
|
|
154
|
+
return [id, null] as const;
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
const edges: Record<string, string[]> = {};
|
|
159
|
+
const add = (dependency: string, requirer: string, front = false) => {
|
|
160
|
+
if (dependency === requirer) return;
|
|
161
|
+
const list = (
|
|
162
|
+
Object.hasOwn(edges, dependency) ? (edges[dependency] as string[]) : []
|
|
163
|
+
).filter((r) => !front || r !== requirer);
|
|
164
|
+
if (list.includes(requirer)) return;
|
|
165
|
+
if (front) list.unshift(requirer);
|
|
166
|
+
else list.push(requirer);
|
|
167
|
+
edges[dependency] = list;
|
|
168
|
+
};
|
|
169
|
+
for (const [id, dependencies] of declared) {
|
|
170
|
+
for (const dependency of dependencies ?? []) add(dependency, id);
|
|
171
|
+
}
|
|
172
|
+
for (const [dependency, requirer] of Object.entries(
|
|
173
|
+
profileRequiredBy ?? {},
|
|
174
|
+
)) {
|
|
175
|
+
add(dependency, requirer, true);
|
|
176
|
+
}
|
|
177
|
+
return edges;
|
|
85
178
|
}
|
|
86
179
|
|
|
87
180
|
function hasDependencies(report: DependencyReport): boolean {
|
|
@@ -444,6 +537,7 @@ function mergeDependencyReports(
|
|
|
444
537
|
);
|
|
445
538
|
return {
|
|
446
539
|
derived: [...new Set([...a.derived, ...b.derived])].sort(),
|
|
540
|
+
requiredBy: { ...b.requiredBy, ...a.requiredBy },
|
|
447
541
|
unreadable: [...new Set([...a.unreadable, ...b.unreadable])].sort(),
|
|
448
542
|
blocked: [...blocked.values()],
|
|
449
543
|
};
|