magus-cli 7.5.3 → 7.5.4
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-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +39 -18
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -6
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +7 -2
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useProfileStartupModal.ts +87 -7
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -34,7 +34,11 @@ import type { PluginScope } from "./claude-cli.js";
|
|
|
34
34
|
import {
|
|
35
35
|
type PluginInfo,
|
|
36
36
|
compareVersions,
|
|
37
|
+
filesMissingScopes,
|
|
38
|
+
highestInstalledVersion,
|
|
39
|
+
installedScopeVersions,
|
|
37
40
|
isBehind,
|
|
41
|
+
isInstalledAnywhere,
|
|
38
42
|
isRepairablyBehind,
|
|
39
43
|
} from "./plugin-manager.js";
|
|
40
44
|
import {
|
|
@@ -48,7 +52,7 @@ import {
|
|
|
48
52
|
// ── Plugins ──────────────────────────────────────────────────────────────────
|
|
49
53
|
|
|
50
54
|
export type PluginUpdateAction =
|
|
51
|
-
/** Declared by the profile, installed
|
|
55
|
+
/** Declared by the profile and installed nowhere, or installed with its files missing. */
|
|
52
56
|
| "install"
|
|
53
57
|
/** Installed at a version other than the one we should be on. */
|
|
54
58
|
| "update"
|
|
@@ -83,8 +87,8 @@ export type PluginUpdateAction =
|
|
|
83
87
|
export type PluginReason =
|
|
84
88
|
/** install: declared by the profile, installed nowhere. */
|
|
85
89
|
| "absent"
|
|
86
|
-
/** install:
|
|
87
|
-
| "
|
|
90
|
+
/** install: installed at a scope, but no version resolves at any installed scope. */
|
|
91
|
+
| "files-missing"
|
|
88
92
|
/** unknown: a marketplace we READ does not list it. */
|
|
89
93
|
| "not-offered"
|
|
90
94
|
/** unknown: installed, unlisted, and its marketplace contributed no listed ids. */
|
|
@@ -127,7 +131,11 @@ export interface PluginUpdateItem {
|
|
|
127
131
|
action: PluginUpdateAction;
|
|
128
132
|
/** Why it is in this state. */
|
|
129
133
|
reason: PluginReason;
|
|
130
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Scopes holding an install this action must touch — for `files-missing`, the
|
|
136
|
+
* scopes installed with no version. Empty for an `absent` install, which the
|
|
137
|
+
* engine puts at project scope.
|
|
138
|
+
*/
|
|
131
139
|
scopes: PluginScope[];
|
|
132
140
|
/**
|
|
133
141
|
* The version installed in each scope BEFORE any work, so an apply can tell
|
|
@@ -141,44 +149,12 @@ export interface PluginUpdateItem {
|
|
|
141
149
|
note?: string;
|
|
142
150
|
}
|
|
143
151
|
|
|
144
|
-
/** Scope → installed version, for the three scopes a PluginInfo carries. */
|
|
145
|
-
function scopeVersions(
|
|
146
|
-
info: PluginInfo | undefined,
|
|
147
|
-
): Array<[PluginScope, string]> {
|
|
148
|
-
if (!info) return [];
|
|
149
|
-
const out: Array<[PluginScope, string]> = [];
|
|
150
|
-
for (const [scope, status] of [
|
|
151
|
-
["user", info.userScope],
|
|
152
|
-
["project", info.projectScope],
|
|
153
|
-
["local", info.localScope],
|
|
154
|
-
] as const) {
|
|
155
|
-
if (status?.version) out.push([scope, status.version]);
|
|
156
|
-
}
|
|
157
|
-
return out;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The highest version installed across scopes, or null.
|
|
162
|
-
*
|
|
163
|
-
* `compareVersions` answers null for an unorderable pair, and null is not
|
|
164
|
-
* `>= 0`, so the reduce keeps the right-hand side when two versions cannot be
|
|
165
|
-
* ranked. "Highest" is not meaningful across commit ids; picking one
|
|
166
|
-
* deterministically is, and every caller only needs *an* installed version.
|
|
167
|
-
*/
|
|
168
|
-
function highestInstalled(info: PluginInfo | undefined): string | null {
|
|
169
|
-
const versions = scopeVersions(info).map(([, v]) => v);
|
|
170
|
-
if (versions.length === 0) return null;
|
|
171
|
-
return versions.reduce((a, b) =>
|
|
172
|
-
(compareVersions(a, b) ?? -1) >= 0 ? a : b,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
152
|
/**
|
|
177
153
|
* The highest installed version THE PIN PERMITS — the ceiling a stale-load
|
|
178
154
|
* repair may aim at. Null when no installed version qualifies.
|
|
179
155
|
*
|
|
180
|
-
* Under "latest" that is simply {@link
|
|
181
|
-
* the newest version on this machine is the one to converge on.
|
|
156
|
+
* Under "latest" that is simply {@link highestInstalledVersion}: nothing caps
|
|
157
|
+
* it, and the newest version on this machine is the one to converge on.
|
|
182
158
|
*
|
|
183
159
|
* Under an EXACT pin it is the highest installed version at or below the pin.
|
|
184
160
|
* `installed` reaches across every scope and `scopesNeeding` never downgrades, so
|
|
@@ -196,8 +172,8 @@ function highestPermittedInstalled(
|
|
|
196
172
|
info: PluginInfo | undefined,
|
|
197
173
|
pin: string,
|
|
198
174
|
): string | null {
|
|
199
|
-
if (pin === "latest") return
|
|
200
|
-
const permitted =
|
|
175
|
+
if (pin === "latest") return highestInstalledVersion(info);
|
|
176
|
+
const permitted = installedScopeVersions(info)
|
|
201
177
|
.map(([, v]) => v)
|
|
202
178
|
// pin >= v, i.e. v is at or below the pin. Null (unorderable) fails this.
|
|
203
179
|
.filter((v) => (compareVersions(pin, v) ?? -1) >= 0);
|
|
@@ -238,7 +214,7 @@ export function scopesNeeding(
|
|
|
238
214
|
// A raw `compareVersions(...) > 0` reported false for that pair and the
|
|
239
215
|
// scope was silently left alone. An unknown (null) is still left alone —
|
|
240
216
|
// moving a scope we cannot rank would be a guess.
|
|
241
|
-
return
|
|
217
|
+
return installedScopeVersions(info)
|
|
242
218
|
.filter(([, version]) => isBehind(target, version) === true)
|
|
243
219
|
.map(([scope]) => scope);
|
|
244
220
|
}
|
|
@@ -255,11 +231,11 @@ function marketplaceSuffix(id: string): string | null {
|
|
|
255
231
|
* The catalog, indexed for the questions the classifier asks of it.
|
|
256
232
|
*
|
|
257
233
|
* "Listed" is narrower than "present". `getAvailablePlugins` puts ORPHANS —
|
|
258
|
-
*
|
|
259
|
-
* list with `isOrphaned: true`, so a plain `has(id)` is
|
|
260
|
-
* nothing publishes. Treating an orphan as listed is
|
|
261
|
-
* `current`: its `version` is filled in from its own
|
|
262
|
-
* compare found them equal.
|
|
234
|
+
* in this project's settings or registry, but published by no marketplace we
|
|
235
|
+
* read — into the same list with `isOrphaned: true`, so a plain `has(id)` is
|
|
236
|
+
* true for a plugin that nothing publishes. Treating an orphan as listed is
|
|
237
|
+
* what let an orphan plan as `current`: its `version` is filled in from its own
|
|
238
|
+
* installed version, so the compare found them equal.
|
|
263
239
|
*/
|
|
264
240
|
export interface CatalogIndex {
|
|
265
241
|
get(pluginId: string): PluginInfo | undefined;
|
|
@@ -306,15 +282,6 @@ export function indexCatalog(
|
|
|
306
282
|
};
|
|
307
283
|
}
|
|
308
284
|
|
|
309
|
-
/** True when a settings file enables the plugin in at least one scope. */
|
|
310
|
-
function enabledAnywhere(info: PluginInfo | undefined): boolean {
|
|
311
|
-
return (
|
|
312
|
-
!!info?.userScope?.enabled ||
|
|
313
|
-
!!info?.projectScope?.enabled ||
|
|
314
|
-
!!info?.localScope?.enabled
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
285
|
/**
|
|
319
286
|
* THE plugin classifier. Pure.
|
|
320
287
|
*
|
|
@@ -332,7 +299,7 @@ export function classifyPlugin(
|
|
|
332
299
|
): PluginUpdateItem {
|
|
333
300
|
const info = index.get(pluginId);
|
|
334
301
|
const listed = index.isListed(pluginId);
|
|
335
|
-
const installed =
|
|
302
|
+
const installed = highestInstalledVersion(info);
|
|
336
303
|
// An orphan's `version` is its own installed version (or a stale local
|
|
337
304
|
// clone's), not something a marketplace we read offers — so it is not
|
|
338
305
|
// "available" from anywhere.
|
|
@@ -344,7 +311,7 @@ export function classifyPlugin(
|
|
|
344
311
|
installed,
|
|
345
312
|
loaded: info?.loadedVersion ?? null,
|
|
346
313
|
available,
|
|
347
|
-
beforeVersions: Object.fromEntries(
|
|
314
|
+
beforeVersions: Object.fromEntries(installedScopeVersions(info)) as Partial<
|
|
348
315
|
Record<PluginScope, string>
|
|
349
316
|
>,
|
|
350
317
|
};
|
|
@@ -388,24 +355,29 @@ export function classifyPlugin(
|
|
|
388
355
|
};
|
|
389
356
|
}
|
|
390
357
|
|
|
391
|
-
//
|
|
358
|
+
// No installed scope has a version — the "checked but not installed" case.
|
|
392
359
|
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
360
|
+
// So a scope that IS installed here has its files missing. It is tested
|
|
361
|
+
// directly rather than through `hasMissingFiles`, which excludes orphans: an
|
|
362
|
+
// orphan that is only a settings key would otherwise read as `absent`, and the
|
|
363
|
+
// TUI renders `absent` as "the profile declares it", which an undeclared
|
|
364
|
+
// orphan does not.
|
|
365
|
+
//
|
|
366
|
+
// Files missing carries the scopes to reinstall at: exactly the ones installed
|
|
367
|
+
// with no version. It used to carry none, and the engine read "install" as
|
|
368
|
+
// "project scope" — so a user-scope plugin with its files missing was
|
|
369
|
+
// reinstalled at project scope, turning it on in the team's settings.json.
|
|
370
|
+
// `absent` still carries none: the engine installs it at project scope.
|
|
398
371
|
if (!installed) {
|
|
399
|
-
const
|
|
372
|
+
const missingAt = filesMissingScopes(info);
|
|
373
|
+
const filesMissing = missingAt.length > 0;
|
|
400
374
|
return {
|
|
401
375
|
...base,
|
|
402
376
|
target: pin === "latest" ? available : pin,
|
|
403
377
|
action: "install",
|
|
404
|
-
reason:
|
|
405
|
-
scopes:
|
|
406
|
-
...(
|
|
407
|
-
? { note: "enabled in settings, but no files on disk" }
|
|
408
|
-
: {}),
|
|
378
|
+
reason: filesMissing ? "files-missing" : "absent",
|
|
379
|
+
scopes: missingAt,
|
|
380
|
+
...(filesMissing ? { note: "files missing" } : {}),
|
|
409
381
|
};
|
|
410
382
|
}
|
|
411
383
|
|
|
@@ -622,7 +594,7 @@ export function classifyPlugin(
|
|
|
622
594
|
target: installed,
|
|
623
595
|
action: "repair",
|
|
624
596
|
reason: "content-stale",
|
|
625
|
-
scopes:
|
|
597
|
+
scopes: installedScopeVersions(info).map(([scope]) => scope),
|
|
626
598
|
note: "files changed without a version bump",
|
|
627
599
|
};
|
|
628
600
|
}
|
|
@@ -669,17 +641,38 @@ export function classifyPlugin(
|
|
|
669
641
|
}
|
|
670
642
|
|
|
671
643
|
/**
|
|
672
|
-
*
|
|
644
|
+
* THE plugin plan — for `magus update` and the Plugins screen alike.
|
|
645
|
+
*
|
|
646
|
+
* Every plugin the active profile declares, under its pin, and every plugin
|
|
647
|
+
* installed in any scope (user, project, local), under "latest" — the rule
|
|
648
|
+
* {@link pinForRow} states — each classified by {@link classifyPlugin}, sorted.
|
|
649
|
+
*
|
|
650
|
+
* ONE list, read by both surfaces: the screen's rows, header counts and
|
|
651
|
+
* "update all", and the CLI's report and apply. It used to be two — the CLI
|
|
652
|
+
* planned the profile's plugins only, the screen every installed plugin — so a
|
|
653
|
+
* USER-scope plugin the screen showed as "update available" was never seen by
|
|
654
|
+
* `magus update`, which then reported everything up to date. The owner asked
|
|
655
|
+
* for "absolutely the same code" on both sides; this is it.
|
|
673
656
|
*
|
|
674
657
|
* @param pinned closure.plugins — pluginId -> exact version | "latest"
|
|
675
|
-
* @param catalog getAvailablePlugins() output
|
|
658
|
+
* @param catalog getAvailablePlugins() output — keyed by id, or as a list
|
|
676
659
|
*/
|
|
677
660
|
export function planPluginUpdates(
|
|
678
|
-
pinned: Record<string, string
|
|
679
|
-
catalog:
|
|
661
|
+
pinned: Readonly<Record<string, string>>,
|
|
662
|
+
catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
|
|
680
663
|
): PluginUpdateItem[] {
|
|
681
664
|
const index = indexCatalog(catalog);
|
|
682
|
-
|
|
665
|
+
const plugins =
|
|
666
|
+
catalog instanceof Map
|
|
667
|
+
? [...catalog.values()]
|
|
668
|
+
: (catalog as readonly PluginInfo[]);
|
|
669
|
+
const pins = new Map(Object.entries(pinned));
|
|
670
|
+
for (const info of plugins) {
|
|
671
|
+
if (pins.has(info.id)) continue;
|
|
672
|
+
const pin = pinForRow(info, pinned);
|
|
673
|
+
if (pin !== null) pins.set(info.id, pin);
|
|
674
|
+
}
|
|
675
|
+
return [...pins]
|
|
683
676
|
.map(([pluginId, pin]) => classifyPlugin(pluginId, pin, index))
|
|
684
677
|
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
685
678
|
}
|
|
@@ -689,45 +682,36 @@ export function planPluginUpdates(
|
|
|
689
682
|
*
|
|
690
683
|
* 1. The active profile's pin, when it declares the plugin — the SAME value
|
|
691
684
|
* `magus update` plans with, which is what makes the two agree.
|
|
692
|
-
* 2. Otherwise "latest", when the plugin is installed in any scope
|
|
693
|
-
*
|
|
694
|
-
* the row and the action agree.
|
|
685
|
+
* 2. Otherwise "latest", when the plugin is installed in any scope, files
|
|
686
|
+
* missing included — what the TUI's own update actions have always planned
|
|
687
|
+
* with, so the row and the action agree.
|
|
695
688
|
* 3. Otherwise null: an undeclared catalog entry the user is only browsing. It
|
|
696
689
|
* gets no status at all. `install` means "the profile wants it", and an
|
|
697
690
|
* undeclared plugin carries no such claim.
|
|
698
691
|
*
|
|
699
692
|
* A consequence the renderer relies on: reason `absent` can only come from
|
|
700
|
-
* rule 1, because rule 2 never assigns a pin to a row that is
|
|
701
|
-
* nor enabled.
|
|
693
|
+
* rule 1, because rule 2 never assigns a pin to a row that is not installed.
|
|
702
694
|
*/
|
|
703
695
|
export function pinForRow(
|
|
704
696
|
info: PluginInfo,
|
|
705
697
|
pins: Readonly<Record<string, string>>,
|
|
706
698
|
): string | null {
|
|
707
699
|
if (Object.hasOwn(pins, info.id)) return pins[info.id] ?? null;
|
|
708
|
-
if (
|
|
700
|
+
if (isInstalledAnywhere(info)) return "latest";
|
|
709
701
|
return null;
|
|
710
702
|
}
|
|
711
703
|
|
|
712
704
|
/**
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
* so the row, the detail panel, the header and the scope picker cannot disagree.
|
|
705
|
+
* {@link planPluginUpdates} keyed by plugin id, for the screen's lookups — the
|
|
706
|
+
* SAME plan `magus update` runs, not a second classification. Computed once
|
|
707
|
+
* per data/pins change, and the one map feeds the row, the detail panel, the
|
|
708
|
+
* header and the scope picker, so none of them can disagree.
|
|
718
709
|
*/
|
|
719
710
|
export function classifyPluginRows(
|
|
720
711
|
plugins: readonly PluginInfo[],
|
|
721
712
|
pins: Readonly<Record<string, string>>,
|
|
722
713
|
): Map<string, PluginUpdateItem> {
|
|
723
|
-
|
|
724
|
-
const out = new Map<string, PluginUpdateItem>();
|
|
725
|
-
for (const info of plugins) {
|
|
726
|
-
const pin = pinForRow(info, pins);
|
|
727
|
-
if (pin === null) continue;
|
|
728
|
-
out.set(info.id, classifyPlugin(info.id, pin, index));
|
|
729
|
-
}
|
|
730
|
-
return out;
|
|
714
|
+
return new Map(planPluginUpdates(pins, plugins).map((i) => [i.pluginId, i]));
|
|
731
715
|
}
|
|
732
716
|
|
|
733
717
|
export interface PluginStateCounts {
|
package/src/ui/App.tsx
CHANGED
|
@@ -427,8 +427,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
427
427
|
// edits into the active profile, regenerate from a changed
|
|
428
428
|
// profiles.json, restore a lost marker — then the question, if one
|
|
429
429
|
// is left to ask.
|
|
430
|
-
const { need, notices } = await prepareProfileStartup(
|
|
431
|
-
|
|
430
|
+
const { need, notices, problems } = await prepareProfileStartup(
|
|
431
|
+
process.cwd(),
|
|
432
|
+
);
|
|
433
|
+
// A refusal never blocks the TUI at startup: it is shown beside what
|
|
434
|
+
// the gate did, and the user is still asked whatever is left.
|
|
435
|
+
const shown = [...notices, ...problems];
|
|
436
|
+
if (shown.length > 0) setProfileNotices(shown);
|
|
432
437
|
if (!needsNothing(need)) setProfileNeed(need);
|
|
433
438
|
} catch {
|
|
434
439
|
// non-fatal: the Profiles tab still works by hand
|
|
@@ -43,7 +43,7 @@ export interface PluginPluginItem {
|
|
|
43
43
|
/**
|
|
44
44
|
* The classifier's answer for this plugin — the same item `magus update`
|
|
45
45
|
* plans with. Null for a catalog entry the user is only browsing (neither
|
|
46
|
-
* declared by the profile
|
|
46
|
+
* declared by the profile nor installed): it has no state to claim.
|
|
47
47
|
*/
|
|
48
48
|
status: PluginUpdateItem | null;
|
|
49
49
|
}
|
|
@@ -8,23 +8,28 @@ interface ScopeSquaresProps {
|
|
|
8
8
|
selected?: boolean;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/** On at that scope. */
|
|
12
12
|
export const SCOPE_ON = "■";
|
|
13
|
-
/** Not
|
|
13
|
+
/** Not on at that scope. */
|
|
14
14
|
export const SCOPE_OFF = "□";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Scope bar: three positional squares, user / project / local.
|
|
18
18
|
*
|
|
19
19
|
* POSITION names the scope — the first square is always user, the second
|
|
20
|
-
* project, the third local — and SHAPE says the state: `■`
|
|
21
|
-
*
|
|
20
|
+
* project, the third local — and SHAPE says the state: `■` on, `□` not. "On"
|
|
21
|
+
* is the caller's to decide, and for a plugin and a skill alike it is
|
|
22
|
+
* installed. For a plugin that is `isInstalledInScope` — the scope's settings
|
|
23
|
+
* file turns it on, which is what Claude Code loads by — the same function the
|
|
24
|
+
* detail panel's dot reads, so the two cannot disagree.
|
|
25
|
+
*
|
|
26
|
+
* The shape reads with no colour at all, which the bar never used to: every square
|
|
22
27
|
* was the same filled block, lit in a scope hue or unlit in a fill near the
|
|
23
28
|
* page, so on a monochrome terminal, to a colour-blind reader, or on a theme
|
|
24
29
|
* that drew two hues alike, all three states were one shape.
|
|
25
30
|
*
|
|
26
31
|
* Colour now only repeats what the shape says. A filled square wears `success`
|
|
27
|
-
* (
|
|
32
|
+
* (on is what it means, on every screen); a hollow one wears `muted`,
|
|
28
33
|
* which a thin outline needs to stay visible — the shape, not a pale fill, is
|
|
29
34
|
* what makes it recede. On a selected row the selection owns the colour
|
|
30
35
|
* channel: filled squares take its ink, hollow ones its dim ink.
|
|
@@ -25,10 +25,13 @@ import {
|
|
|
25
25
|
import type { DisplacedArtifact } from "../../services/profile-live.js";
|
|
26
26
|
import {
|
|
27
27
|
type ProfileStartupNeed,
|
|
28
|
+
UPDATE_FORMAT_VALUE,
|
|
28
29
|
buildProfileOptions,
|
|
29
30
|
defaultOptionIndex,
|
|
30
31
|
describeNeed,
|
|
32
|
+
formatUpdateOutcome,
|
|
31
33
|
parseProfileChoice,
|
|
34
|
+
prepareProfileStartup,
|
|
32
35
|
unparseableNeed,
|
|
33
36
|
} from "../../services/profile-startup.js";
|
|
34
37
|
import { applyProfileChoice } from "../../services/profile-state.js";
|
|
@@ -39,6 +42,8 @@ import type { ModalState } from "../state/types.js";
|
|
|
39
42
|
export interface BuildProfileModalArgs {
|
|
40
43
|
need: ProfileStartupNeed;
|
|
41
44
|
onChoose: (choice: ProfileChoice) => void;
|
|
45
|
+
/** "Update it now" on the `old-format` question — not a profile choice. */
|
|
46
|
+
onUpdateFormat?: () => void;
|
|
42
47
|
/**
|
|
43
48
|
* Leave magus. NOT "dismiss" — there is deliberately no way to carry on
|
|
44
49
|
* without a profile, because there is nothing to carry on with.
|
|
@@ -49,7 +54,7 @@ export interface BuildProfileModalArgs {
|
|
|
49
54
|
export function buildProfileStartupModal(
|
|
50
55
|
args: BuildProfileModalArgs,
|
|
51
56
|
): ModalState {
|
|
52
|
-
const { need, onChoose, onQuit } = args;
|
|
57
|
+
const { need, onChoose, onUpdateFormat, onQuit } = args;
|
|
53
58
|
const { title, message } = describeNeed(need);
|
|
54
59
|
return {
|
|
55
60
|
type: "select",
|
|
@@ -60,6 +65,10 @@ export function buildProfileStartupModal(
|
|
|
60
65
|
// developer who has local work and therefore still gets asked.
|
|
61
66
|
defaultIndex: defaultOptionIndex(need),
|
|
62
67
|
onSelect: (value: string) => {
|
|
68
|
+
if (value === UPDATE_FORMAT_VALUE && onUpdateFormat) {
|
|
69
|
+
onUpdateFormat();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
63
72
|
const choice = parseProfileChoice(value, need);
|
|
64
73
|
if (choice) onChoose(choice);
|
|
65
74
|
else onQuit();
|
|
@@ -168,8 +177,16 @@ export function useProfileStartupModal(
|
|
|
168
177
|
|
|
169
178
|
const show = useCallback(
|
|
170
179
|
(need: ProfileStartupNeed) => {
|
|
171
|
-
/**
|
|
172
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Apply a choice, reporting the outcome either way. `current` is the
|
|
182
|
+
* question it answers — after "Update it now" that is a NEW question,
|
|
183
|
+
* whose manifest is the converted one, not the one magus opened with.
|
|
184
|
+
*/
|
|
185
|
+
const apply = (
|
|
186
|
+
choice: ProfileChoice,
|
|
187
|
+
automatic: boolean,
|
|
188
|
+
current: ProfileStartupNeed = need,
|
|
189
|
+
): void => {
|
|
173
190
|
dispatch({
|
|
174
191
|
type: "SHOW_MODAL",
|
|
175
192
|
modal: {
|
|
@@ -191,7 +208,7 @@ export function useProfileStartupModal(
|
|
|
191
208
|
// where they left it. `.magus-backups/` is gitignored, so git status
|
|
192
209
|
// showed the deletion and no sign of the copy.
|
|
193
210
|
const moved: DisplacedArtifact[] = [];
|
|
194
|
-
void applyProfileChoice(choice,
|
|
211
|
+
void applyProfileChoice(choice, current.manifest, projectPath, {
|
|
195
212
|
onDisplace: (artifact) => moved.push(artifact),
|
|
196
213
|
})
|
|
197
214
|
.then((result: ActivationResult) => {
|
|
@@ -228,7 +245,7 @@ export function useProfileStartupModal(
|
|
|
228
245
|
error instanceof SettingsParseError &&
|
|
229
246
|
error.path === getManifestPath(projectPath)
|
|
230
247
|
? unparseableNeed(error)
|
|
231
|
-
:
|
|
248
|
+
: current;
|
|
232
249
|
dispatch({
|
|
233
250
|
type: "SHOW_MODAL",
|
|
234
251
|
modal: {
|
|
@@ -241,7 +258,7 @@ export function useProfileStartupModal(
|
|
|
241
258
|
error,
|
|
242
259
|
moved,
|
|
243
260
|
projectPath,
|
|
244
|
-
|
|
261
|
+
current.declaredDefault,
|
|
245
262
|
),
|
|
246
263
|
variant: "error",
|
|
247
264
|
// A failure leaves the project without an active profile, so
|
|
@@ -254,12 +271,75 @@ export function useProfileStartupModal(
|
|
|
254
271
|
});
|
|
255
272
|
};
|
|
256
273
|
|
|
274
|
+
/**
|
|
275
|
+
* "Update it now" on an old profile format: update it — the same gate
|
|
276
|
+
* the CLI runs after its own question — then ask whatever is left, or
|
|
277
|
+
* say it is done.
|
|
278
|
+
*/
|
|
279
|
+
const updateFormat = (): void => {
|
|
280
|
+
dispatch({
|
|
281
|
+
type: "SHOW_MODAL",
|
|
282
|
+
modal: { type: "loading", message: "Updating the profile format…" },
|
|
283
|
+
});
|
|
284
|
+
void prepareProfileStartup(projectPath, { convert: true })
|
|
285
|
+
.then((result) => {
|
|
286
|
+
const outcome = formatUpdateOutcome(result);
|
|
287
|
+
if (outcome.kind === "ask") {
|
|
288
|
+
onActivated?.();
|
|
289
|
+
ask(outcome.need);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (outcome.kind === "failed") {
|
|
293
|
+
// Refused, so still the old format: back to the question,
|
|
294
|
+
// as the CLI stops on the same lines.
|
|
295
|
+
dispatch({
|
|
296
|
+
type: "SHOW_MODAL",
|
|
297
|
+
modal: {
|
|
298
|
+
type: "message",
|
|
299
|
+
title: outcome.title,
|
|
300
|
+
message: outcome.message,
|
|
301
|
+
variant: "error",
|
|
302
|
+
onDismiss: () => ask(need),
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
onActivated?.();
|
|
308
|
+
dispatch({
|
|
309
|
+
type: "SHOW_MODAL",
|
|
310
|
+
modal: {
|
|
311
|
+
type: "message",
|
|
312
|
+
title: outcome.title,
|
|
313
|
+
message: outcome.message,
|
|
314
|
+
variant: "success",
|
|
315
|
+
onDismiss: () => {
|
|
316
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
317
|
+
navigateToScreen("profiles");
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
})
|
|
322
|
+
.catch((error: unknown) => {
|
|
323
|
+
dispatch({
|
|
324
|
+
type: "SHOW_MODAL",
|
|
325
|
+
modal: {
|
|
326
|
+
type: "message",
|
|
327
|
+
title: "Could not update the profile format",
|
|
328
|
+
message: `${error instanceof Error ? error.message : String(error)}\n\nYour profiles are kept. Open magus again to retry.`,
|
|
329
|
+
variant: "error",
|
|
330
|
+
onDismiss: () => ask(need),
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
|
|
257
336
|
function ask(current: ProfileStartupNeed = need): void {
|
|
258
337
|
dispatch({
|
|
259
338
|
type: "SHOW_MODAL",
|
|
260
339
|
modal: buildProfileStartupModal({
|
|
261
340
|
need: current,
|
|
262
|
-
onChoose: (choice) => apply(choice, false),
|
|
341
|
+
onChoose: (choice) => apply(choice, false, current),
|
|
342
|
+
onUpdateFormat: updateFormat,
|
|
263
343
|
onQuit,
|
|
264
344
|
}),
|
|
265
345
|
});
|