magus-cli 7.7.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 +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- 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 -57
- 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/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-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -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 +90 -146
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- 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 -12
- 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 +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- 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 -45
- package/src/ui/screens/CliToolsScreen.tsx +65 -54
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +313 -221
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- 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
package/src/ui/App.tsx
CHANGED
|
@@ -24,22 +24,20 @@ import {
|
|
|
24
24
|
checkForUpdates,
|
|
25
25
|
getCurrentVersion,
|
|
26
26
|
} from "../services/version-check.js";
|
|
27
|
+
import {
|
|
28
|
+
gitignoreStartupReport,
|
|
29
|
+
marketplaceRefreshReport,
|
|
30
|
+
profileStartupReport,
|
|
31
|
+
staleRepairReport,
|
|
32
|
+
} from "./adapters/startupReports.js";
|
|
27
33
|
import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
|
|
28
34
|
import { TABS } from "./components/TabBar.js";
|
|
29
35
|
import { ProgressBar } from "./components/layout/ProgressBar.js";
|
|
30
36
|
import { ModalContainer } from "./components/modals/index.js";
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} from "./hooks/
|
|
35
|
-
import {
|
|
36
|
-
activationWarnings,
|
|
37
|
-
useProfileStartupModal,
|
|
38
|
-
} from "./hooks/useProfileStartupModal.js";
|
|
39
|
-
import {
|
|
40
|
-
staleLoadRepairNotices,
|
|
41
|
-
useStaleLoadModal,
|
|
42
|
-
} from "./hooks/useStaleLoadModal.js";
|
|
37
|
+
import { nextStartupStep } from "./hooks/startupQueue.js";
|
|
38
|
+
import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
|
|
39
|
+
import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
|
|
40
|
+
import { useStaleLoadModal } from "./hooks/useStaleLoadModal.js";
|
|
43
41
|
import { KeyRouter, useKeyLayer } from "./input/KeyRouter.js";
|
|
44
42
|
import { type GlobalHandlers, runGlobalBindings } from "./keymap.js";
|
|
45
43
|
import {
|
|
@@ -60,14 +58,12 @@ import {
|
|
|
60
58
|
useApp,
|
|
61
59
|
useModal,
|
|
62
60
|
useNavigation,
|
|
63
|
-
useNotify,
|
|
64
61
|
} from "./state/AppContext.js";
|
|
65
62
|
import {
|
|
66
63
|
DimensionsProvider,
|
|
67
64
|
useDimensions,
|
|
68
65
|
} from "./state/DimensionsContext.js";
|
|
69
|
-
import type {
|
|
70
|
-
import { truncate } from "./text-width.js";
|
|
66
|
+
import type { FailureReport, Screen } from "./state/types.js";
|
|
71
67
|
import { theme } from "./theme.js";
|
|
72
68
|
|
|
73
69
|
export const VERSION = getCurrentVersion();
|
|
@@ -185,72 +181,6 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
|
|
|
185
181
|
);
|
|
186
182
|
}
|
|
187
183
|
|
|
188
|
-
/**
|
|
189
|
-
* NoticeBanner Component
|
|
190
|
-
* The app's one notice line (or a few): what an action did, from `useNotify`.
|
|
191
|
-
* It sits above the screen and outside the modal slot, so it never takes a key
|
|
192
|
-
* and never stands in the way of a startup question. Each line clears itself
|
|
193
|
-
* at its `expiresAt` — counting only time with no modal open.
|
|
194
|
-
*
|
|
195
|
-
* One notice is exactly ONE row: the layout reserves one row per notice
|
|
196
|
-
* (`transientBannerCount`), so a line that wrapped would push the screen's
|
|
197
|
-
* last rows off the terminal. Hence no wrapping, and the text clipped to the
|
|
198
|
-
* width — the start of a line says what happened, and that is what survives.
|
|
199
|
-
*/
|
|
200
|
-
function NoticeBanner({ notices }: { notices: readonly Notice[] }) {
|
|
201
|
-
const { state, dispatch } = useApp();
|
|
202
|
-
const { terminalWidth } = useDimensions();
|
|
203
|
-
const modalOpen = state.modal !== null;
|
|
204
|
-
const paused = state.noticesPausedAt !== null;
|
|
205
|
-
|
|
206
|
-
// Hold the notices while a modal is open. A warning posted just before a
|
|
207
|
-
// startup question, or under it, would otherwise run down behind it and be
|
|
208
|
-
// gone by the time the user has answered.
|
|
209
|
-
useEffect(() => {
|
|
210
|
-
dispatch({
|
|
211
|
-
type: modalOpen ? "PAUSE_NOTICES" : "RESUME_NOTICES",
|
|
212
|
-
now: Date.now(),
|
|
213
|
-
});
|
|
214
|
-
}, [modalOpen, dispatch]);
|
|
215
|
-
|
|
216
|
-
// One timer, for the line that expires first; the reducer then drops every
|
|
217
|
-
// line past its time, and the next render schedules the one after. None
|
|
218
|
-
// while paused: RESUME_NOTICES moves every `expiresAt` on first.
|
|
219
|
-
useEffect(() => {
|
|
220
|
-
if (notices.length === 0 || paused) return;
|
|
221
|
-
const next = Math.min(...notices.map((n) => n.expiresAt));
|
|
222
|
-
const timer = setTimeout(
|
|
223
|
-
() => dispatch({ type: "EXPIRE_NOTICES", now: Date.now() }),
|
|
224
|
-
Math.max(0, next - Date.now()),
|
|
225
|
-
);
|
|
226
|
-
return () => clearTimeout(timer);
|
|
227
|
-
}, [notices, paused, dispatch]);
|
|
228
|
-
|
|
229
|
-
// The box's left and right padding.
|
|
230
|
-
const width = Math.max(0, terminalWidth - 2);
|
|
231
|
-
return (
|
|
232
|
-
<>
|
|
233
|
-
{notices.map((notice) => (
|
|
234
|
-
<box key={notice.id} paddingLeft={1} paddingRight={1} height={1}>
|
|
235
|
-
<text
|
|
236
|
-
wrapMode="none"
|
|
237
|
-
fg={
|
|
238
|
-
notice.tone === "warning"
|
|
239
|
-
? theme.colors.warning
|
|
240
|
-
: theme.colors.success
|
|
241
|
-
}
|
|
242
|
-
>
|
|
243
|
-
{truncate(
|
|
244
|
-
`${notice.tone === "warning" ? "⚠" : "✓"} ${notice.text}`,
|
|
245
|
-
width,
|
|
246
|
-
)}
|
|
247
|
-
</text>
|
|
248
|
-
</box>
|
|
249
|
-
))}
|
|
250
|
-
</>
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
184
|
/**
|
|
255
185
|
* ProgressIndicator Component
|
|
256
186
|
* Shows progress bar when operations are running
|
|
@@ -282,13 +212,12 @@ interface AppContentInnerProps {
|
|
|
282
212
|
|
|
283
213
|
function AppContentInner({ updateInfo, onExit }: AppContentInnerProps) {
|
|
284
214
|
const { state } = useApp();
|
|
285
|
-
const { progress
|
|
215
|
+
const { progress } = state;
|
|
286
216
|
const dimensions = useDimensions();
|
|
287
217
|
|
|
288
218
|
return (
|
|
289
219
|
<box flexDirection="column" height={dimensions.terminalHeight}>
|
|
290
220
|
{updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
|
|
291
|
-
<NoticeBanner notices={notices} />
|
|
292
221
|
{progress && <ProgressIndicator {...progress} />}
|
|
293
222
|
<box
|
|
294
223
|
flexDirection="column"
|
|
@@ -336,7 +265,6 @@ interface AppContentProps {
|
|
|
336
265
|
function AppContent({ onExit }: AppContentProps) {
|
|
337
266
|
const { state, dispatch } = useApp();
|
|
338
267
|
const { progress } = state;
|
|
339
|
-
const notify = useNotify();
|
|
340
268
|
const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
|
|
341
269
|
const [gitignoreData, setGitignoreData] = useState<QueuedGitignore | null>(
|
|
342
270
|
null,
|
|
@@ -354,6 +282,19 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
354
282
|
const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
|
|
355
283
|
null,
|
|
356
284
|
);
|
|
285
|
+
// What startup work could not do — the profile gate's refusals and its
|
|
286
|
+
// activations, a marketplace refresh — waiting for the modal slot, oldest
|
|
287
|
+
// first. Each is the one failure popup; the TUI says nothing else.
|
|
288
|
+
const [startupReports, setStartupReports] = useState<FailureReport[]>([]);
|
|
289
|
+
const queueReports = React.useCallback(
|
|
290
|
+
(...reports: Array<FailureReport | null>) => {
|
|
291
|
+
const present = reports.filter((r): r is FailureReport => r !== null);
|
|
292
|
+
if (present.length > 0) {
|
|
293
|
+
setStartupReports((queued) => [...queued, ...present]);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
[],
|
|
297
|
+
);
|
|
357
298
|
const gitignoreModal = useGitignoreModal();
|
|
358
299
|
const [brokenPluginCount, setBrokenPluginCount] = useState<number | null>(
|
|
359
300
|
null,
|
|
@@ -409,8 +350,8 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
409
350
|
// The hooks keep the slot occupied for their whole flow (a loading modal
|
|
410
351
|
// while their fix runs, never HIDE_MODAL mid-flow), because an empty slot is
|
|
411
352
|
// what lets the next question in. A flow that succeeds ends by hiding its
|
|
412
|
-
// loading modal and
|
|
413
|
-
//
|
|
353
|
+
// loading modal and reports nothing: the screen it leaves shows the result.
|
|
354
|
+
// That does not stop a SCREEN from emptying
|
|
414
355
|
// it: `useModal().confirm` hides its modal when answered and the screen shows
|
|
415
356
|
// its loading modal a microtask later. A key pressed before the profile check
|
|
416
357
|
// resolved lands the profile question in exactly that gap, and the screen's
|
|
@@ -436,8 +377,32 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
436
377
|
// it and then the fresh one asked the same question twice. The fresh result
|
|
437
378
|
// replaces the queued one instead (see the check effect below).
|
|
438
379
|
useEffect(() => {
|
|
439
|
-
|
|
440
|
-
|
|
380
|
+
// The order lives in `nextStartupStep`, where it is tested: failure
|
|
381
|
+
// reports first — something that already happened, and the question
|
|
382
|
+
// after it may be answered differently once it is known — so the profile
|
|
383
|
+
// gate's refusals land before the profile question, which they do not
|
|
384
|
+
// replace; then gitignore, broken plugins, and the profile last.
|
|
385
|
+
const step = nextStartupStep({
|
|
386
|
+
modalOpen: !!state.modal,
|
|
387
|
+
reports: startupReports,
|
|
388
|
+
gitignoreReady: !!gitignoreData && !gitignorePending,
|
|
389
|
+
brokenCount: brokenPluginCount,
|
|
390
|
+
profileNeed,
|
|
391
|
+
});
|
|
392
|
+
if (!step) return;
|
|
393
|
+
if (step.kind === "report") {
|
|
394
|
+
setStartupReports(step.rest);
|
|
395
|
+
dispatch({
|
|
396
|
+
type: "SHOW_MODAL",
|
|
397
|
+
modal: {
|
|
398
|
+
type: "failures",
|
|
399
|
+
report: step.report,
|
|
400
|
+
onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (step.kind === "gitignore" && gitignoreData) {
|
|
441
406
|
// Anything older than what is now on screen is dropped on arrival.
|
|
442
407
|
gitignoreCheck.current.shown = gitignoreData.request;
|
|
443
408
|
const need = gitignoreData.need;
|
|
@@ -447,15 +412,18 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
447
412
|
generated: need.generated,
|
|
448
413
|
// In place, then back to the app: tracked generated files are
|
|
449
414
|
// untracked, missing entries added; other items stay for the tab.
|
|
415
|
+
// A file git would not untrack is named with git's reason.
|
|
450
416
|
onFixSafe: async () =>
|
|
451
|
-
|
|
417
|
+
gitignoreStartupReport(
|
|
418
|
+
(await applyStartupFixes(state.projectPath)).failed,
|
|
419
|
+
),
|
|
452
420
|
});
|
|
453
421
|
setGitignoreData(null);
|
|
454
422
|
return;
|
|
455
423
|
}
|
|
456
|
-
if (
|
|
424
|
+
if (step.kind === "stale-load") {
|
|
457
425
|
staleLoadModal.show({
|
|
458
|
-
brokenCount:
|
|
426
|
+
brokenCount: step.brokenCount,
|
|
459
427
|
// Re-surveys before repairing, like the gitignore fixer: the registry
|
|
460
428
|
// may have moved between the startup scan and the user pressing Enter,
|
|
461
429
|
// and repairing from the stale count would act on rows that are no
|
|
@@ -465,8 +433,10 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
465
433
|
// The pins are re-read with it, from the same helper the survey used: a
|
|
466
434
|
// repair that capped at a different ceiling than the offer was counted
|
|
467
435
|
// against would move rows the dialog never mentioned.
|
|
436
|
+
//
|
|
437
|
+
// A repair that did not take is named, with its reason.
|
|
468
438
|
onFix: async () =>
|
|
469
|
-
|
|
439
|
+
staleRepairReport(
|
|
470
440
|
await repairAllStaleLoads(
|
|
471
441
|
state.projectPath,
|
|
472
442
|
undefined,
|
|
@@ -479,10 +449,12 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
479
449
|
}
|
|
480
450
|
// Not cleared here — see above. Re-called on every empty slot until the
|
|
481
451
|
// hook reports it answered.
|
|
482
|
-
if (
|
|
452
|
+
if (step.kind === "profile") profileModal.show(step.need);
|
|
483
453
|
}, [
|
|
484
454
|
state.modal,
|
|
485
455
|
state.projectPath,
|
|
456
|
+
dispatch,
|
|
457
|
+
startupReports,
|
|
486
458
|
gitignoreData,
|
|
487
459
|
gitignorePending,
|
|
488
460
|
gitignoreModal,
|
|
@@ -572,25 +544,21 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
572
544
|
// edits into the active profile, regenerate from a changed
|
|
573
545
|
// profiles.json, restore a lost marker — then the question, if one
|
|
574
546
|
// is left to ask.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
notify(line.text, line.tone);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
for (const line of problems) notify(line, "warning");
|
|
547
|
+
// A refusal never blocks the TUI at startup: the user is still asked
|
|
548
|
+
// whatever is left, and the screens show the state as it is. What
|
|
549
|
+
// the gate refused, and what its re-render could not do, go into ONE
|
|
550
|
+
// failure popup first — one step, one popup — queued in the same
|
|
551
|
+
// tick as the question, so it is shown before it.
|
|
552
|
+
const { need, problems, activations } = await prepareProfileStartup(
|
|
553
|
+
process.cwd(),
|
|
554
|
+
);
|
|
555
|
+
queueReports(profileStartupReport(problems, activations ?? []));
|
|
588
556
|
if (!needsNothing(need)) setProfileNeed(need);
|
|
589
557
|
} catch {
|
|
590
558
|
// non-fatal: the Profiles tab still works by hand
|
|
591
559
|
}
|
|
592
560
|
})();
|
|
593
|
-
}, [
|
|
561
|
+
}, [queueReports]);
|
|
594
562
|
|
|
595
563
|
// Auto-refresh marketplaces on startup
|
|
596
564
|
useEffect(() => {
|
|
@@ -612,50 +580,29 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
612
580
|
(async () => {
|
|
613
581
|
try {
|
|
614
582
|
if (!(await isClaudeAvailable())) return;
|
|
615
|
-
|
|
616
|
-
if (added.length > 0) {
|
|
617
|
-
notify(`Fixed: added marketplace(s): ${added.join(", ")}`);
|
|
618
|
-
}
|
|
583
|
+
await autoAddMissingMarketplaces(process.cwd());
|
|
619
584
|
} catch {
|
|
620
585
|
// non-fatal: user can still install marketplaces manually
|
|
621
586
|
}
|
|
622
587
|
})();
|
|
623
588
|
|
|
624
|
-
// Recover stale marketplace registry entries (e.g. "directory" -> "github")
|
|
589
|
+
// Recover stale marketplace registry entries (e.g. "directory" -> "github"),
|
|
590
|
+
// then refresh the clones whose source that fixed. A refresh that fails
|
|
591
|
+
// leaves that marketplace's plugins read from an old copy, so it is named
|
|
592
|
+
// in the failure popup with why.
|
|
625
593
|
recoverMarketplaceSettings()
|
|
626
594
|
.then(async (recovery) => {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
"../services/claude-cli.js"
|
|
635
|
-
);
|
|
636
|
-
for (const mp of recovery.reregistered) {
|
|
637
|
-
try {
|
|
638
|
-
await updateMarketplace(mp);
|
|
639
|
-
parts.push(`${mp} refreshed`);
|
|
640
|
-
} catch {
|
|
641
|
-
failed.push(mp);
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
if (recovery.removed.length > 0) {
|
|
646
|
-
parts.push(`removed: ${recovery.removed.join(", ")}`);
|
|
647
|
-
}
|
|
648
|
-
if (parts.length > 0) {
|
|
649
|
-
notify(`Fixed: ${parts.join(" | ")}`);
|
|
650
|
-
}
|
|
651
|
-
if (failed.length > 0) {
|
|
652
|
-
notify(
|
|
653
|
-
`Could not refresh marketplace(s): ${failed.join(", ")}`,
|
|
654
|
-
"warning",
|
|
655
|
-
);
|
|
595
|
+
if (recovery.reregistered.length === 0) return;
|
|
596
|
+
const { updateMarketplace } = await import("../services/claude-cli.js");
|
|
597
|
+
const failed: Array<{ marketplace: string; error: unknown }> = [];
|
|
598
|
+
for (const mp of recovery.reregistered) {
|
|
599
|
+
await updateMarketplace(mp).catch((error: unknown) => {
|
|
600
|
+
failed.push({ marketplace: mp, error });
|
|
601
|
+
});
|
|
656
602
|
}
|
|
603
|
+
queueReports(marketplaceRefreshReport(failed));
|
|
657
604
|
})
|
|
658
|
-
.catch(() => {}); // non-fatal
|
|
605
|
+
.catch(() => {}); // the recovery itself: non-fatal
|
|
659
606
|
|
|
660
607
|
repairAllMarketplaces()
|
|
661
608
|
.then(async () => {
|
|
@@ -665,15 +612,12 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
665
612
|
.catch(() => {
|
|
666
613
|
dispatch({ type: "HIDE_PROGRESS" });
|
|
667
614
|
});
|
|
668
|
-
}, [dispatch,
|
|
615
|
+
}, [dispatch, queueReports]);
|
|
669
616
|
|
|
670
617
|
return (
|
|
671
618
|
<DimensionsProvider
|
|
672
619
|
showProgress={!!progress}
|
|
673
620
|
showUpdateBanner={!!updateInfo?.updateAvailable}
|
|
674
|
-
// One row per notice line, so the screen below shrinks rather than
|
|
675
|
-
// being pushed off the bottom of the terminal.
|
|
676
|
-
transientBannerCount={state.notices.length}
|
|
677
621
|
>
|
|
678
622
|
<AppContentInner updateInfo={updateInfo} onExit={onExit} />
|
|
679
623
|
</DimensionsProvider>
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* Pure: no IO, no React. The screen gathers facts; this only assembles.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import path from "node:path";
|
|
20
21
|
import type { CliTool } from "../../data/cli-tools.js";
|
|
21
22
|
import {
|
|
22
23
|
cliToolInstall,
|
|
@@ -31,8 +32,11 @@ import {
|
|
|
31
32
|
} from "../../services/tool-probe.js";
|
|
32
33
|
import { binCommandForState } from "../../services/toolchain.js";
|
|
33
34
|
import type { BinRequirement, ResolvedBin } from "../../types/index.js";
|
|
34
|
-
import type
|
|
35
|
+
import { type Command, formatCommand } from "../../utils/command-utils.js";
|
|
35
36
|
import type { StatusTone } from "../renderers/pluginStatusText.js";
|
|
37
|
+
import type { FailureReport } from "../state/types.js";
|
|
38
|
+
import { counted, failureReport, withoutBinaryPaths } from "./failureReport.js";
|
|
39
|
+
import { CLAUDE_MISSING_REASON, exceptionReason } from "./pluginsAdapter.js";
|
|
36
40
|
|
|
37
41
|
export interface CliToolRow {
|
|
38
42
|
tool: CliTool;
|
|
@@ -303,3 +307,201 @@ export function countToolRows(rows: readonly CliToolRow[]): {
|
|
|
303
307
|
}
|
|
304
308
|
return { installed, updates, unverified };
|
|
305
309
|
}
|
|
310
|
+
|
|
311
|
+
// ─── Failures ────────────────────────────────────────────────────────────────
|
|
312
|
+
//
|
|
313
|
+
// Every installer command that fails goes into the failure popup with why and
|
|
314
|
+
// the command to run by hand. Update-all used to discard them: the reload
|
|
315
|
+
// showed the tool still behind, with no reason anywhere.
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The verb for what an Enter action did not do — "installed", "updated",
|
|
319
|
+
* "reinstalled". The caller writes the headline around it:
|
|
320
|
+
* `${tool} was not ${cliToolNotDone(kind)}`.
|
|
321
|
+
*/
|
|
322
|
+
export function cliToolNotDone(kind: CliToolEnterAction["kind"]): string {
|
|
323
|
+
if (kind === "install") return "installed";
|
|
324
|
+
if (kind === "reinstall") return "reinstalled";
|
|
325
|
+
return "updated";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** One installer command that did not succeed. */
|
|
329
|
+
export interface CliToolCommandFailure {
|
|
330
|
+
/** The tool's display name. */
|
|
331
|
+
tool: string;
|
|
332
|
+
command: Command;
|
|
333
|
+
/** Why, in {@link commandFailureReason}'s words. */
|
|
334
|
+
reason: string;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The longest reason kept from an installer's own output. */
|
|
338
|
+
const MAX_REASON = 160;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* npm's bookkeeping lines, which never say why: the error code and syscall
|
|
342
|
+
* fields, and where its log went.
|
|
343
|
+
*/
|
|
344
|
+
const INSTALLER_NOISE =
|
|
345
|
+
/^(?:code|errno|syscall|path|dest)\b|^(?:A complete log of this run|Log files were not written|You can rerun|For a full report see)\b/i;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The name a user knows a command by: `/usr/local/bin/npm` → `npm`. Which
|
|
349
|
+
* copy of npm the machine found is mechanism, never part of a reason.
|
|
350
|
+
*/
|
|
351
|
+
function commandName(command: Command): string {
|
|
352
|
+
return path.basename(command.cmd) || command.cmd;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** How a timeout reads, so its fix can be told apart from an ordinary failure. */
|
|
356
|
+
const TIMED_OUT_REASON = /^did not finish within \d+ seconds$/;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The fix for a timeout. Not "Run it yourself": the same command would take
|
|
360
|
+
* just as long. A slow download is the usual cause, so it names the network
|
|
361
|
+
* without claiming that is what happened.
|
|
362
|
+
*/
|
|
363
|
+
export const RETRY_TIMEOUT_FIX =
|
|
364
|
+
"Try again; if it keeps timing out, check your network connection";
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Why an installer command failed, as ONE line, from what `execFile` threw.
|
|
368
|
+
*
|
|
369
|
+
* The thrown message's first line is "Command failed: <the command>", which
|
|
370
|
+
* says nothing the fix line does not. The installer's own words are in its
|
|
371
|
+
* stderr: the text after an `error:` (npm, brew and cargo all say it that
|
|
372
|
+
* way), else its first line that is not bookkeeping. A missing installer and
|
|
373
|
+
* a timeout are said plainly — a missing `claude` in the words every other
|
|
374
|
+
* screen uses for it. No absolute binary path survives into the reason.
|
|
375
|
+
*/
|
|
376
|
+
export function commandFailureReason(
|
|
377
|
+
command: Command,
|
|
378
|
+
error: unknown,
|
|
379
|
+
timeoutMs: number,
|
|
380
|
+
): string {
|
|
381
|
+
if (!(error instanceof Error)) {
|
|
382
|
+
return withoutBinaryPaths(String(error).split("\n")[0]?.trim() || "failed");
|
|
383
|
+
}
|
|
384
|
+
const e = error as Error & {
|
|
385
|
+
code?: unknown;
|
|
386
|
+
killed?: boolean;
|
|
387
|
+
signal?: unknown;
|
|
388
|
+
stderr?: unknown;
|
|
389
|
+
};
|
|
390
|
+
const name = commandName(command);
|
|
391
|
+
if (e.code === "ENOENT") {
|
|
392
|
+
return name === "claude"
|
|
393
|
+
? CLAUDE_MISSING_REASON
|
|
394
|
+
: `${name} is not installed on this machine`;
|
|
395
|
+
}
|
|
396
|
+
if (e.killed || e.signal === "SIGTERM") {
|
|
397
|
+
return `did not finish within ${Math.round(timeoutMs / 1000)} seconds`;
|
|
398
|
+
}
|
|
399
|
+
const lines = String(e.stderr ?? "")
|
|
400
|
+
.split("\n")
|
|
401
|
+
.map((line) => line.replace(/^npm (?:ERR!|error)\b/i, "").trim())
|
|
402
|
+
.filter((line) => line !== "" && !INSTALLER_NOISE.test(line));
|
|
403
|
+
const said = lines.find((line) => /\berror:\s*\S/i.test(line));
|
|
404
|
+
let reason = said
|
|
405
|
+
? said.slice(said.toLowerCase().lastIndexOf("error:") + 6).trim()
|
|
406
|
+
: (lines[0] ?? "");
|
|
407
|
+
if (!reason) {
|
|
408
|
+
reason =
|
|
409
|
+
typeof e.code === "number"
|
|
410
|
+
? `${name} exited with code ${e.code}`
|
|
411
|
+
: e.message
|
|
412
|
+
.split("\n")[0]
|
|
413
|
+
?.replace(/^Command failed: .*$/, "")
|
|
414
|
+
.trim() || `${name} failed`;
|
|
415
|
+
}
|
|
416
|
+
reason = withoutBinaryPaths(reason);
|
|
417
|
+
return reason.length > MAX_REASON
|
|
418
|
+
? `${reason.slice(0, MAX_REASON - 1)}…`
|
|
419
|
+
: reason;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The fix for one failed installer command.
|
|
424
|
+
*
|
|
425
|
+
* Usually the command itself, to run by hand. Not for the three causes that
|
|
426
|
+
* running the same command again would not cure: a timeout, no `claude` to
|
|
427
|
+
* run, and no network. Each keeps the fix that does help.
|
|
428
|
+
*/
|
|
429
|
+
export function commandFailureFix(command: Command, reason: string): string {
|
|
430
|
+
if (TIMED_OUT_REASON.test(reason)) return RETRY_TIMEOUT_FIX;
|
|
431
|
+
const known = exceptionReason(reason);
|
|
432
|
+
if (known.fix) return known.fix;
|
|
433
|
+
return `Run it yourself: ${formatCommand(command)}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The popup for failed installer commands, each with the fix that helps. */
|
|
437
|
+
export function cliToolFailureReport(
|
|
438
|
+
headline: string,
|
|
439
|
+
failures: readonly CliToolCommandFailure[],
|
|
440
|
+
): FailureReport | null {
|
|
441
|
+
return failureReport(
|
|
442
|
+
headline,
|
|
443
|
+
failures.map((f) => ({
|
|
444
|
+
subject: f.tool,
|
|
445
|
+
reason: f.reason,
|
|
446
|
+
fix: commandFailureFix(f.command, f.reason),
|
|
447
|
+
})),
|
|
448
|
+
{ group: false },
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Update-all: the tools whose update command failed. */
|
|
453
|
+
export function cliToolsNotUpdatedReport(
|
|
454
|
+
failures: readonly CliToolCommandFailure[],
|
|
455
|
+
): FailureReport | null {
|
|
456
|
+
const n = failures.length;
|
|
457
|
+
return cliToolFailureReport(
|
|
458
|
+
`${counted(n, "tool")} ${n === 1 ? "was" : "were"} not updated`,
|
|
459
|
+
failures,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* A conflict magus cannot resolve: the tool is installed more than one way
|
|
465
|
+
* and none of the installs can be removed from here.
|
|
466
|
+
*/
|
|
467
|
+
export function cliToolConflictStuckReport(
|
|
468
|
+
tool: string,
|
|
469
|
+
blockers: ReadonlyArray<{ method: string; reason: string }>,
|
|
470
|
+
): FailureReport | null {
|
|
471
|
+
return failureReport(
|
|
472
|
+
`${tool} is installed ${blockers.length} ways, and magus can remove none of them`,
|
|
473
|
+
blockers.map(({ method, reason }) => ({
|
|
474
|
+
subject: `${method} install`,
|
|
475
|
+
reason,
|
|
476
|
+
fix: "Remove one by hand, then press r",
|
|
477
|
+
})),
|
|
478
|
+
{ group: false },
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* A conflict resolved only in part: each removal that failed, with the
|
|
484
|
+
* command to run by hand, and each install magus could never remove.
|
|
485
|
+
*/
|
|
486
|
+
export function cliToolConflictPartialReport(
|
|
487
|
+
tool: string,
|
|
488
|
+
failed: ReadonlyArray<{ method: string; command: Command; reason: string }>,
|
|
489
|
+
leftBehind: ReadonlyArray<{ method: string; reason: string }>,
|
|
490
|
+
): FailureReport | null {
|
|
491
|
+
return failureReport(
|
|
492
|
+
`${tool} is still installed more than one way`,
|
|
493
|
+
[
|
|
494
|
+
...failed.map((f) => ({
|
|
495
|
+
subject: `${f.method} install`,
|
|
496
|
+
reason: `was not removed: ${f.reason}`,
|
|
497
|
+
fix: `Run it yourself: ${formatCommand(f.command)}`,
|
|
498
|
+
})),
|
|
499
|
+
...leftBehind.map(({ method, reason }) => ({
|
|
500
|
+
subject: `${method} install`,
|
|
501
|
+
reason: `stays: ${reason}`,
|
|
502
|
+
fix: "Remove it by hand if you want it gone",
|
|
503
|
+
})),
|
|
504
|
+
],
|
|
505
|
+
{ group: false },
|
|
506
|
+
);
|
|
507
|
+
}
|