magus-cli 0.0.0-placeholder → 7.0.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/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +445 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +335 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/moved-marketplace.test.ts +86 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1373 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1589 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +587 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +253 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +878 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +344 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +863 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +538 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1464 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +352 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +144 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update-engine.ts — applying an update plan. The ONLY implementation.
|
|
3
|
+
*
|
|
4
|
+
* This used to live inside `cli/update.ts`, which meant the TUI could not reach
|
|
5
|
+
* it and grew its own loop in `PluginsScreen.handleUpdateAll`. The two drifted
|
|
6
|
+
* exactly where you would expect:
|
|
7
|
+
*
|
|
8
|
+
* - the CLI advanced **every scope** holding an outdated install; the TUI
|
|
9
|
+
* updated one, derived from whichever scope tab was open, so drift in the
|
|
10
|
+
* others survived and reappeared on the next run;
|
|
11
|
+
* - the CLI recorded the version it **read back** after installing; the TUI
|
|
12
|
+
* recorded the version it had *expected*, which is the bug the CLI's own
|
|
13
|
+
* comments describe at length — `claude plugin install` takes no version
|
|
14
|
+
* argument, so writing the intended value made `install --check` compare a
|
|
15
|
+
* pin against a copy of itself and report clean forever;
|
|
16
|
+
* - only the CLI reported pins the installer could not honour.
|
|
17
|
+
*
|
|
18
|
+
* None of that was a decision. It was two copies of one idea, edited apart.
|
|
19
|
+
*
|
|
20
|
+
* So the engine lives here, in `services/`, and both front ends drive it.
|
|
21
|
+
* `cli/` and `ui/` are sibling presentation layers: they own how progress looks
|
|
22
|
+
* and nothing else. Everything IO-shaped is injected — `PluginApplyDeps` for
|
|
23
|
+
* the five Claude Code calls, `ApplyReporter` for progress, `runCommand` for
|
|
24
|
+
* installers — so the same code drives an animated terminal meter, a TUI modal,
|
|
25
|
+
* and a test that wants silence.
|
|
26
|
+
*
|
|
27
|
+
* NOTHING HERE PRINTS. A `console.log` in this file is a layering violation:
|
|
28
|
+
* the CLI paints an animated region that a stray write corrupts, and the TUI
|
|
29
|
+
* has no stdout to write to at all.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import type {
|
|
34
|
+
ProfileSkillRef,
|
|
35
|
+
ResolvedBin,
|
|
36
|
+
SkillInfo,
|
|
37
|
+
} from "../types/index.js";
|
|
38
|
+
import type { Command } from "../utils/command-utils.js";
|
|
39
|
+
import { formatCommand } from "../utils/command-utils.js";
|
|
40
|
+
import { runCommand } from "../utils/run.js";
|
|
41
|
+
import type { PluginScope } from "./claude-cli.js";
|
|
42
|
+
import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
|
|
43
|
+
import {
|
|
44
|
+
readInstalledVersionForScope,
|
|
45
|
+
saveInstalledPluginVersionForScope,
|
|
46
|
+
} from "./plugin-manager.js";
|
|
47
|
+
import { installSkill } from "./skills-manager.js";
|
|
48
|
+
import {
|
|
49
|
+
type ToolFacts,
|
|
50
|
+
compareToolVersions,
|
|
51
|
+
probeTool,
|
|
52
|
+
} from "./tool-probe.js";
|
|
53
|
+
import { binCommandForState } from "./toolchain.js";
|
|
54
|
+
import {
|
|
55
|
+
type BinUpdateItem,
|
|
56
|
+
type PluginUpdateItem,
|
|
57
|
+
type UpdatePlan,
|
|
58
|
+
binNeedsWork,
|
|
59
|
+
} from "./update-plan.js";
|
|
60
|
+
|
|
61
|
+
// -- results -----------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export interface ApplyResult {
|
|
64
|
+
/** Items that CHANGED — the installer exited 0 and read-back shows movement. */
|
|
65
|
+
ok: number;
|
|
66
|
+
failed: string[];
|
|
67
|
+
/**
|
|
68
|
+
* Items whose installer exited 0 but whose read-back shows nothing moved.
|
|
69
|
+
*
|
|
70
|
+
* Not a failure, and not a change. A stale tap, a PATH that still resolves
|
|
71
|
+
* an older copy, a marketplace clone behind its catalog — each "succeeds",
|
|
72
|
+
* lands on the same version, and is planned identically next run. Counting
|
|
73
|
+
* those as changed is how "3 changed" was printed on every run forever.
|
|
74
|
+
*/
|
|
75
|
+
unchanged: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** An exact pin the installer could not deliver, discovered by reading back. */
|
|
79
|
+
export interface PinMismatch {
|
|
80
|
+
pluginId: string;
|
|
81
|
+
pinned: string;
|
|
82
|
+
actual: string | null;
|
|
83
|
+
scope: PluginScope;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface PluginApplyResult extends ApplyResult {
|
|
87
|
+
mismatches: PinMismatch[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Everything one full apply produced, for whichever front end is reporting. */
|
|
91
|
+
export interface UpdateOutcome {
|
|
92
|
+
plugins: PluginApplyResult;
|
|
93
|
+
bins: ApplyResult;
|
|
94
|
+
skills: ApplyResult;
|
|
95
|
+
/** Items that changed, across all three. */
|
|
96
|
+
changed: number;
|
|
97
|
+
/** Names whose installer exited 0 but read-back shows no movement. */
|
|
98
|
+
unchanged: string[];
|
|
99
|
+
/** Names that failed, across all three. */
|
|
100
|
+
failed: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// -- injected edges ----------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* How the apply loop reports itself.
|
|
107
|
+
*
|
|
108
|
+
* Injected rather than hard-coded, because the loop is the same work whether it
|
|
109
|
+
* is driving an animated meter, a TUI modal, or a test that wants silence.
|
|
110
|
+
*/
|
|
111
|
+
export interface ApplyReporter {
|
|
112
|
+
/** An item is starting. Called before any of its work. */
|
|
113
|
+
begin(name: string): void;
|
|
114
|
+
/** An item finished. `detail` is already formatted for display. */
|
|
115
|
+
finish(ok: boolean, name: string, detail: string, ms: number): void;
|
|
116
|
+
/**
|
|
117
|
+
* Run `work` with the terminal released.
|
|
118
|
+
*
|
|
119
|
+
* A binary upgrade spawns `brew`/`uv` with inherited stdio: it writes and
|
|
120
|
+
* moves the cursor itself, so any animated block must be erased first or the
|
|
121
|
+
* two interleave into garbage. A TUI implementation suspends its renderer;
|
|
122
|
+
* a silent one just awaits.
|
|
123
|
+
*/
|
|
124
|
+
detach<T>(work: () => Promise<T>): Promise<T>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Reports nothing. The right default for a caller that renders its own state. */
|
|
128
|
+
export const SILENT_REPORTER: ApplyReporter = {
|
|
129
|
+
begin: () => {},
|
|
130
|
+
finish: () => {},
|
|
131
|
+
detach: (work) => work(),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The four side-effecting calls the plugin apply path makes, injectable so the
|
|
136
|
+
* path itself can be tested.
|
|
137
|
+
*
|
|
138
|
+
* This exists because the apply half shipped with no coverage while the pure
|
|
139
|
+
* planner had forty tests — and every real defect found in review lived here,
|
|
140
|
+
* not there.
|
|
141
|
+
*/
|
|
142
|
+
export interface PluginApplyDeps {
|
|
143
|
+
/**
|
|
144
|
+
* A plugin NOT installed at this scope yet.
|
|
145
|
+
*
|
|
146
|
+
* Separate from {@link PluginApplyDeps.update} because the two are separate
|
|
147
|
+
* Claude Code commands that each no-op in the other's case — see the note on
|
|
148
|
+
* `updatePlugin` in claude-cli.ts.
|
|
149
|
+
*/
|
|
150
|
+
install: (pluginId: string, scope: PluginScope) => Promise<void>;
|
|
151
|
+
update: (pluginId: string, scope: PluginScope) => Promise<void>;
|
|
152
|
+
repair: (
|
|
153
|
+
pluginId: string,
|
|
154
|
+
scope: PluginScope,
|
|
155
|
+
projectPath: string,
|
|
156
|
+
) => Promise<void>;
|
|
157
|
+
readInstalled: (
|
|
158
|
+
pluginId: string,
|
|
159
|
+
scope: PluginScope,
|
|
160
|
+
projectPath: string,
|
|
161
|
+
) => Promise<string | null>;
|
|
162
|
+
saveInstalled: (
|
|
163
|
+
pluginId: string,
|
|
164
|
+
version: string,
|
|
165
|
+
scope: PluginScope,
|
|
166
|
+
projectPath: string,
|
|
167
|
+
) => Promise<void>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
171
|
+
install: installPlugin,
|
|
172
|
+
update: updatePlugin,
|
|
173
|
+
repair: repairPlugin,
|
|
174
|
+
readInstalled: readInstalledVersionForScope,
|
|
175
|
+
saveInstalled: saveInstalledPluginVersionForScope,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// -- selection ---------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Scopes the apply loop would touch for `item`. Empty means it is skipped.
|
|
182
|
+
*
|
|
183
|
+
* A plugin the profile declares but nothing has installed goes in at PROJECT
|
|
184
|
+
* scope, matching `install`: a profile is a property of one repo, so its
|
|
185
|
+
* plugins must not be enabled machine-wide.
|
|
186
|
+
*
|
|
187
|
+
* Every scope holding an outdated install is returned, not just one. Fixing a
|
|
188
|
+
* single hardcoded scope does not converge: it reports success while the
|
|
189
|
+
* project row stays untouched, so the same drift reappears on the next run.
|
|
190
|
+
*/
|
|
191
|
+
function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
192
|
+
if (item.action === "current" || item.action === "unknown") return [];
|
|
193
|
+
return item.action === "install" ? ["project"] : item.scopes;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Whether the apply loop will do anything for `item`.
|
|
198
|
+
*
|
|
199
|
+
* Exported for ONE reason: a progress meter's denominator must be the same
|
|
200
|
+
* predicate the loop applies. Re-deriving it at the call site is how the two
|
|
201
|
+
* drift, and a bar that stops short of full at the end of a successful run
|
|
202
|
+
* looks exactly like a hang.
|
|
203
|
+
*/
|
|
204
|
+
export function pluginNeedsWork(item: PluginUpdateItem): boolean {
|
|
205
|
+
return scopesToApply(item).length > 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// -- plugins -----------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Advance a plugin at `scope`, choosing the command by whether THIS project
|
|
212
|
+
* actually holds an install row there.
|
|
213
|
+
*
|
|
214
|
+
* The check is not defensive tidiness — without it `update` mutates a project
|
|
215
|
+
* nobody named. `claude plugin update --scope project` resolves the project
|
|
216
|
+
* from the CLI's working directory, and when that directory has no row for the
|
|
217
|
+
* plugin it falls back to some OTHER project's row and updates that one.
|
|
218
|
+
* Measured on Claude Code 2.1.252, run from a directory with no madbench row:
|
|
219
|
+
*
|
|
220
|
+
* $ claude plugin update madbench@magus --scope project
|
|
221
|
+
* ✔ Plugin "madbench" updated from 0.2.4 to 0.3.0 for scope project
|
|
222
|
+
* (/Users/jack/mag/tmux-mcp). Restart to apply changes.
|
|
223
|
+
*
|
|
224
|
+
* `/Users/jack/mag/tmux-mcp` was not the working directory and had nothing to
|
|
225
|
+
* do with the request. `plugin install` has no such fallback — it keys on the
|
|
226
|
+
* working directory — so the absent case must go there instead.
|
|
227
|
+
*
|
|
228
|
+
* This is live for git worktrees in particular: magus resolves a worktree's
|
|
229
|
+
* installed version by inheriting the parent repo's rows, so a worktree can
|
|
230
|
+
* report "0.2.4 installed, 0.3.0 available" while owning no row of its own.
|
|
231
|
+
* That is exactly the shape that used to reach `update`.
|
|
232
|
+
*
|
|
233
|
+
* `readInstalled` matches the project path EXACTLY (no worktree inheritance),
|
|
234
|
+
* which is what makes it the right question to ask here.
|
|
235
|
+
*/
|
|
236
|
+
async function advanceInScope(
|
|
237
|
+
deps: PluginApplyDeps,
|
|
238
|
+
pluginId: string,
|
|
239
|
+
scope: PluginScope,
|
|
240
|
+
projectPath: string,
|
|
241
|
+
): Promise<void> {
|
|
242
|
+
const here = await deps.readInstalled(pluginId, scope, projectPath);
|
|
243
|
+
if (here) await deps.update(pluginId, scope);
|
|
244
|
+
else await deps.install(pluginId, scope);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Why nothing moved for `item`, judged by READ-BACK; null when something did.
|
|
249
|
+
*
|
|
250
|
+
* No installer output is parsed — "already at the latest version" is
|
|
251
|
+
* locale- and version-fragile. The registry row the apply loop already reads
|
|
252
|
+
* back is compared with the before-version the plan carries:
|
|
253
|
+
* - update: every touched scope reads back exactly what it held before;
|
|
254
|
+
* - install: no touched scope reads back any version at all;
|
|
255
|
+
* - repair: never — same version is its definition; the files moved.
|
|
256
|
+
*/
|
|
257
|
+
function pluginNoMove(
|
|
258
|
+
item: PluginUpdateItem,
|
|
259
|
+
landed: ReadonlyArray<[PluginScope, string | null]>,
|
|
260
|
+
): string | null {
|
|
261
|
+
if (landed.length === 0) return null;
|
|
262
|
+
if (item.action === "update") {
|
|
263
|
+
const same = landed.every(
|
|
264
|
+
([scope, actual]) => (actual ?? undefined) === item.beforeVersions[scope],
|
|
265
|
+
);
|
|
266
|
+
if (!same) return null;
|
|
267
|
+
const v = landed.find(([, a]) => a)?.[1];
|
|
268
|
+
return v
|
|
269
|
+
? `the installer exited 0 but ${item.pluginId} is still ${v}`
|
|
270
|
+
: `the installer exited 0 but no version of ${item.pluginId} was recorded`;
|
|
271
|
+
}
|
|
272
|
+
if (item.action === "install") {
|
|
273
|
+
return landed.every(([, actual]) => actual === null)
|
|
274
|
+
? `the installer exited 0 but no version of ${item.pluginId} was recorded`
|
|
275
|
+
: null;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function applyPlugins(
|
|
281
|
+
items: PluginUpdateItem[],
|
|
282
|
+
projectPath: string,
|
|
283
|
+
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
284
|
+
report: ApplyReporter = SILENT_REPORTER,
|
|
285
|
+
): Promise<PluginApplyResult> {
|
|
286
|
+
const result: PluginApplyResult = {
|
|
287
|
+
ok: 0,
|
|
288
|
+
failed: [],
|
|
289
|
+
unchanged: [],
|
|
290
|
+
mismatches: [],
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
for (const item of items) {
|
|
294
|
+
const scopes = scopesToApply(item);
|
|
295
|
+
if (scopes.length === 0) continue;
|
|
296
|
+
|
|
297
|
+
report.begin(item.pluginId);
|
|
298
|
+
const startedAt = Date.now();
|
|
299
|
+
const landed: Array<[PluginScope, string | null]> = [];
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
for (const scope of scopes) {
|
|
303
|
+
// Three actions, three DIFFERENT Claude Code commands. Each is a
|
|
304
|
+
// silent no-op in the other two cases:
|
|
305
|
+
// repair — same version, changed files: only uninstall+install
|
|
306
|
+
// re-copies; `update` sees no version bump to act on.
|
|
307
|
+
// install — absent at this scope: `update` answers "already at the
|
|
308
|
+
// latest version" and installs nothing.
|
|
309
|
+
// update — installed but behind: `install` answers "already
|
|
310
|
+
// installed", ignores the version, and exits 0.
|
|
311
|
+
// Folding install and update together is the bug that made every
|
|
312
|
+
// magus update path do nothing while reporting success.
|
|
313
|
+
if (item.action === "repair") {
|
|
314
|
+
await deps.repair(item.pluginId, scope, projectPath);
|
|
315
|
+
} else if (item.action === "install") {
|
|
316
|
+
await deps.install(item.pluginId, scope);
|
|
317
|
+
} else {
|
|
318
|
+
await advanceInScope(deps, item.pluginId, scope, projectPath);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Record what LANDED, not what we asked for.
|
|
322
|
+
//
|
|
323
|
+
// `claude plugin install` accepts no version, so an exact pin is a
|
|
324
|
+
// request the installer cannot honour — it fetches whatever the
|
|
325
|
+
// marketplace publishes now. Writing `item.target` here recorded the
|
|
326
|
+
// pin as satisfied whatever actually installed, and `install --check`
|
|
327
|
+
// reads that same field to decide whether the machine has drifted.
|
|
328
|
+
// The gate would have compared the pin against a copy of itself and
|
|
329
|
+
// reported clean forever.
|
|
330
|
+
const actual = await deps.readInstalled(
|
|
331
|
+
item.pluginId,
|
|
332
|
+
scope,
|
|
333
|
+
projectPath,
|
|
334
|
+
);
|
|
335
|
+
landed.push([scope, actual]);
|
|
336
|
+
if (actual) {
|
|
337
|
+
await deps.saveInstalled(item.pluginId, actual, scope, projectPath);
|
|
338
|
+
}
|
|
339
|
+
if (item.pinned !== "latest" && actual !== item.pinned) {
|
|
340
|
+
result.mismatches.push({
|
|
341
|
+
pluginId: item.pluginId,
|
|
342
|
+
pinned: item.pinned,
|
|
343
|
+
actual,
|
|
344
|
+
scope,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const noMove = pluginNoMove(item, landed);
|
|
349
|
+
if (noMove) {
|
|
350
|
+
report.finish(
|
|
351
|
+
true,
|
|
352
|
+
item.pluginId,
|
|
353
|
+
`no change — ${noMove}`,
|
|
354
|
+
Date.now() - startedAt,
|
|
355
|
+
);
|
|
356
|
+
result.unchanged.push(item.pluginId);
|
|
357
|
+
} else {
|
|
358
|
+
report.finish(
|
|
359
|
+
true,
|
|
360
|
+
item.pluginId,
|
|
361
|
+
`${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
|
|
362
|
+
Date.now() - startedAt,
|
|
363
|
+
);
|
|
364
|
+
result.ok++;
|
|
365
|
+
}
|
|
366
|
+
} catch (error) {
|
|
367
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
368
|
+
report.finish(false, item.pluginId, msg, Date.now() - startedAt);
|
|
369
|
+
result.failed.push(item.pluginId);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* One plugin, ONE explicitly chosen scope, recording what landed.
|
|
378
|
+
*
|
|
379
|
+
* For the scope-targeted actions — a menu where the user picked "install in user
|
|
380
|
+
* scope" — where {@link applyPlugins}'s "every scope that is behind" would
|
|
381
|
+
* override the choice they just made.
|
|
382
|
+
*
|
|
383
|
+
* These exist so those call sites still get the read-back: writing the version
|
|
384
|
+
* you EXPECTED is the bug documented at length in `applyPlugins`, and it was
|
|
385
|
+
* live in the TUI's per-scope menu long after the CLI had fixed it.
|
|
386
|
+
*
|
|
387
|
+
* They take no version parameter, deliberately: there is nothing for a caller
|
|
388
|
+
* to pass that could be wrong.
|
|
389
|
+
*/
|
|
390
|
+
async function applyOneInScope(
|
|
391
|
+
run: (pluginId: string, scope: PluginScope) => Promise<void>,
|
|
392
|
+
pluginId: string,
|
|
393
|
+
scope: PluginScope,
|
|
394
|
+
projectPath: string,
|
|
395
|
+
deps: PluginApplyDeps,
|
|
396
|
+
): Promise<string | null> {
|
|
397
|
+
await run(pluginId, scope);
|
|
398
|
+
const actual = await deps.readInstalled(pluginId, scope, projectPath);
|
|
399
|
+
if (actual) await deps.saveInstalled(pluginId, actual, scope, projectPath);
|
|
400
|
+
return actual;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Install a plugin NOT yet present at `scope`.
|
|
405
|
+
*
|
|
406
|
+
* This used to cover updating too, on the premise that `installPlugin` and
|
|
407
|
+
* `updatePlugin` issued the identical command. They no longer do, and never
|
|
408
|
+
* safely did: `claude plugin install` ignores the version of an install that is
|
|
409
|
+
* already there. Sending an update through here is why the TUI's scope menu
|
|
410
|
+
* reported success and changed nothing. Use {@link updatePluginInScope}.
|
|
411
|
+
*
|
|
412
|
+
* @returns the version actually on disk afterwards, or null if it could not be
|
|
413
|
+
* read — which is reported, never guessed.
|
|
414
|
+
*/
|
|
415
|
+
export async function installPluginInScope(
|
|
416
|
+
pluginId: string,
|
|
417
|
+
scope: PluginScope,
|
|
418
|
+
projectPath: string,
|
|
419
|
+
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
420
|
+
): Promise<string | null> {
|
|
421
|
+
return applyOneInScope(deps.install, pluginId, scope, projectPath, deps);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Advance a plugin ALREADY installed at `scope` to the latest published version.
|
|
426
|
+
*
|
|
427
|
+
* @returns the version actually on disk afterwards, or null if it could not be
|
|
428
|
+
* read — which is reported, never guessed.
|
|
429
|
+
*/
|
|
430
|
+
export async function updatePluginInScope(
|
|
431
|
+
pluginId: string,
|
|
432
|
+
scope: PluginScope,
|
|
433
|
+
projectPath: string,
|
|
434
|
+
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
435
|
+
): Promise<string | null> {
|
|
436
|
+
// Same guard as the apply loop, for the same reason — see advanceInScope.
|
|
437
|
+
return applyOneInScope(
|
|
438
|
+
(id, sc) => advanceInScope(deps, id, sc, projectPath),
|
|
439
|
+
pluginId,
|
|
440
|
+
scope,
|
|
441
|
+
projectPath,
|
|
442
|
+
deps,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// -- binaries ----------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The command a bin item runs, or null when it is not work.
|
|
450
|
+
*
|
|
451
|
+
* The state→command rule itself is {@link binCommandForState}, shared with the
|
|
452
|
+
* TUI's CLI Tools row so the two front ends cannot run different commands for
|
|
453
|
+
* the same row. The CLI's plan report renders this same function, so what it
|
|
454
|
+
* shows is what runs.
|
|
455
|
+
*/
|
|
456
|
+
export function binCommandFor(
|
|
457
|
+
item: BinUpdateItem,
|
|
458
|
+
spec: ResolvedBin,
|
|
459
|
+
): Command | null {
|
|
460
|
+
if (!binNeedsWork(item)) return null;
|
|
461
|
+
return binCommandForState(
|
|
462
|
+
item.action === "install" ? "install" : "upgrade",
|
|
463
|
+
item.target,
|
|
464
|
+
spec,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Why nothing moved for a bin, judged by RE-PROBING it; null when it moved.
|
|
470
|
+
*
|
|
471
|
+
* The re-probe resolves PATH again, so it reads the binary that runs NOW —
|
|
472
|
+
* which is what catches the no-ops planning cannot see: a stale brew tap, an
|
|
473
|
+
* installer that wrote a copy PATH does not resolve, `~/.bun/bin` missing from
|
|
474
|
+
* PATH. Versions are compared with the same comparator the planner used,
|
|
475
|
+
* because before and after can come from different sources ("v0.13.10" from
|
|
476
|
+
* `uv tool list` against "0.13.10").
|
|
477
|
+
*
|
|
478
|
+
* A read-back that cannot read a version is NOT a no-op: failing to read is not
|
|
479
|
+
* evidence nothing changed, and saying so would turn every go-less machine's
|
|
480
|
+
* upgrade into a warning.
|
|
481
|
+
*/
|
|
482
|
+
function binNoMove(item: BinUpdateItem, after: ToolFacts): string | null {
|
|
483
|
+
if (item.action === "install" && !after.present) {
|
|
484
|
+
return `the installer exited 0 but ${item.name} is still not on PATH`;
|
|
485
|
+
}
|
|
486
|
+
if (after.managed === false) {
|
|
487
|
+
return `installed, but PATH still runs ${after.resolvedPath ?? "another copy"}`;
|
|
488
|
+
}
|
|
489
|
+
if (
|
|
490
|
+
item.action === "upgrade" &&
|
|
491
|
+
after.installed !== null &&
|
|
492
|
+
item.installed !== null &&
|
|
493
|
+
compareToolVersions(after.installed, item.installed) === 0
|
|
494
|
+
) {
|
|
495
|
+
return `the installer exited 0 but ${item.name} is still ${after.installed}`;
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The read-back probe, and the ONLY caller that lets the probe run a tool's
|
|
502
|
+
* `check` command.
|
|
503
|
+
*
|
|
504
|
+
* Planning never does: `check` comes from a marketplace `plugin.json`, and
|
|
505
|
+
* `--dry-run` / `--check` must execute nothing a catalogue supplied. Here the
|
|
506
|
+
* installer for this very tool has just run with the user's consent, and the
|
|
507
|
+
* probe's own narrowing means the only argv that can run is the tool asking
|
|
508
|
+
* itself its version (`tool-probe.ts`, `versionFromCheck`). Without it a tool
|
|
509
|
+
* whose installer keeps no readable record reads back as "version unknown",
|
|
510
|
+
* which R6 counts as changed — the safe answer, but never "no change — still
|
|
511
|
+
* 3.7c", the one the user needs.
|
|
512
|
+
*/
|
|
513
|
+
const REAL_READ_BACK = (spec: ResolvedBin): Promise<ToolFacts> =>
|
|
514
|
+
probeTool(spec, undefined, { allowCheckCommand: true });
|
|
515
|
+
|
|
516
|
+
export async function applyBins(
|
|
517
|
+
plan: UpdatePlan,
|
|
518
|
+
report: ApplyReporter = SILENT_REPORTER,
|
|
519
|
+
// Named `run`, not `shell`. The old name was the misnomer that let a
|
|
520
|
+
// profile-supplied package name reach `/bin/sh`: a seam called "shell" reads
|
|
521
|
+
// as an invitation to hand it a string. It takes argv, and nothing here
|
|
522
|
+
// builds a shell line.
|
|
523
|
+
run: (command: Command) => Promise<boolean> = runCommand,
|
|
524
|
+
/** Read-back seam: re-probe ONE tool after its installer ran. */
|
|
525
|
+
probe: (spec: ResolvedBin) => Promise<ToolFacts> = REAL_READ_BACK,
|
|
526
|
+
): Promise<ApplyResult> {
|
|
527
|
+
const result: ApplyResult = { ok: 0, failed: [], unchanged: [] };
|
|
528
|
+
for (const item of plan.bins) {
|
|
529
|
+
// The SAME predicate planWorkItems and planHasWork use. Skipping only
|
|
530
|
+
// `current` here would apply an `unknown` — "cannot tell" run as an
|
|
531
|
+
// upgrade.
|
|
532
|
+
if (!binNeedsWork(item)) continue;
|
|
533
|
+
const bin = plan.binSpecs.get(item.name);
|
|
534
|
+
if (!bin) continue;
|
|
535
|
+
report.begin(item.name);
|
|
536
|
+
const startedAt = Date.now();
|
|
537
|
+
const cmd = binCommandFor(item, bin);
|
|
538
|
+
if (!cmd) {
|
|
539
|
+
// Unreachable from the planner (a brew pin is never planned as work),
|
|
540
|
+
// but the meter counted this item, so it must finish.
|
|
541
|
+
report.finish(
|
|
542
|
+
false,
|
|
543
|
+
item.name,
|
|
544
|
+
`no command can move ${bin.via} to ${item.target ?? "the pin"}`,
|
|
545
|
+
Date.now() - startedAt,
|
|
546
|
+
);
|
|
547
|
+
result.failed.push(item.name);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
// The installer owns the terminal for the duration: it streams its own
|
|
551
|
+
// output through inherited stdio.
|
|
552
|
+
const succeeded = await report.detach(() => run(cmd));
|
|
553
|
+
if (!succeeded) {
|
|
554
|
+
report.finish(
|
|
555
|
+
false,
|
|
556
|
+
item.name,
|
|
557
|
+
formatCommand(cmd),
|
|
558
|
+
Date.now() - startedAt,
|
|
559
|
+
);
|
|
560
|
+
result.failed.push(item.name);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
let noMove: string | null = null;
|
|
564
|
+
try {
|
|
565
|
+
noMove = binNoMove(item, await probe(bin));
|
|
566
|
+
} catch {
|
|
567
|
+
// Could not read back: not evidence of a no-op.
|
|
568
|
+
}
|
|
569
|
+
// The reporter renders; only the argv above is executed.
|
|
570
|
+
if (noMove) {
|
|
571
|
+
report.finish(
|
|
572
|
+
true,
|
|
573
|
+
item.name,
|
|
574
|
+
`no change — ${noMove}`,
|
|
575
|
+
Date.now() - startedAt,
|
|
576
|
+
);
|
|
577
|
+
result.unchanged.push(item.name);
|
|
578
|
+
} else {
|
|
579
|
+
report.finish(
|
|
580
|
+
true,
|
|
581
|
+
item.name,
|
|
582
|
+
formatCommand(cmd),
|
|
583
|
+
Date.now() - startedAt,
|
|
584
|
+
);
|
|
585
|
+
result.ok++;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// -- skills ------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
/** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
|
|
594
|
+
function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
|
|
595
|
+
return {
|
|
596
|
+
id: `${ref.repo}/${ref.path}`,
|
|
597
|
+
name: ref.name,
|
|
598
|
+
source: {
|
|
599
|
+
label: ref.repo,
|
|
600
|
+
repo: ref.repo,
|
|
601
|
+
skillsPath: path.dirname(ref.path),
|
|
602
|
+
},
|
|
603
|
+
repoPath: ref.path,
|
|
604
|
+
gitBlobSha: "",
|
|
605
|
+
frontmatter: null,
|
|
606
|
+
installed: false,
|
|
607
|
+
installedScope: null,
|
|
608
|
+
hasUpdate: false,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export async function applySkills(
|
|
613
|
+
plan: UpdatePlan,
|
|
614
|
+
projectPath: string,
|
|
615
|
+
report: ApplyReporter = SILENT_REPORTER,
|
|
616
|
+
install: typeof installSkill = installSkill,
|
|
617
|
+
): Promise<ApplyResult> {
|
|
618
|
+
const result: ApplyResult = { ok: 0, failed: [], unchanged: [] };
|
|
619
|
+
for (const item of plan.skills) {
|
|
620
|
+
report.begin(item.name);
|
|
621
|
+
const startedAt = Date.now();
|
|
622
|
+
try {
|
|
623
|
+
await install(toSkillInfo(item.ref), "project", projectPath);
|
|
624
|
+
report.finish(true, item.name, item.ref.repo, Date.now() - startedAt);
|
|
625
|
+
result.ok++;
|
|
626
|
+
} catch (error) {
|
|
627
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
628
|
+
report.finish(false, item.name, msg, Date.now() - startedAt);
|
|
629
|
+
result.failed.push(item.name);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return result;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// -- the whole plan ----------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
export interface ApplyPlanOptions {
|
|
638
|
+
deps?: PluginApplyDeps;
|
|
639
|
+
report?: ApplyReporter;
|
|
640
|
+
/** Installer execution seam — see the note on {@link applyBins}. */
|
|
641
|
+
run?: (command: Command) => Promise<boolean>;
|
|
642
|
+
/** Read-back seam — see {@link applyBins}. Default: `probeTool(spec)`. */
|
|
643
|
+
probe?: (spec: ResolvedBin) => Promise<ToolFacts>;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Apply a whole plan: plugins, then binaries, then skills.
|
|
648
|
+
*
|
|
649
|
+
* Order matters. Plugins first because a binary requirement is declared BY a
|
|
650
|
+
* plugin, so installing the plugin is what makes the requirement real; skills
|
|
651
|
+
* last because they are the only re-fetched-every-time item and a failure there
|
|
652
|
+
* must not stop the two that actually advance versions.
|
|
653
|
+
*/
|
|
654
|
+
export async function applyPlan(
|
|
655
|
+
plan: UpdatePlan,
|
|
656
|
+
projectPath: string,
|
|
657
|
+
opts: ApplyPlanOptions = {},
|
|
658
|
+
): Promise<UpdateOutcome> {
|
|
659
|
+
const report = opts.report ?? SILENT_REPORTER;
|
|
660
|
+
const plugins = await applyPlugins(
|
|
661
|
+
plan.plugins,
|
|
662
|
+
projectPath,
|
|
663
|
+
opts.deps ?? REAL_PLUGIN_DEPS,
|
|
664
|
+
report,
|
|
665
|
+
);
|
|
666
|
+
const bins = await applyBins(plan, report, opts.run, opts.probe);
|
|
667
|
+
const skills = await applySkills(plan, projectPath, report);
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
plugins,
|
|
671
|
+
bins,
|
|
672
|
+
skills,
|
|
673
|
+
// `ok` counts only items read-back shows moved, so a no-op installer
|
|
674
|
+
// run is never "changed".
|
|
675
|
+
changed: plugins.ok + bins.ok + skills.ok,
|
|
676
|
+
unchanged: [...plugins.unchanged, ...bins.unchanged, ...skills.unchanged],
|
|
677
|
+
failed: [...plugins.failed, ...bins.failed, ...skills.failed],
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The items `applyPlan` will actually touch — a progress denominator that
|
|
683
|
+
* cannot drift from the loops, because it is derived from the same predicates.
|
|
684
|
+
*/
|
|
685
|
+
export function planWorkItems(plan: UpdatePlan): string[] {
|
|
686
|
+
return [
|
|
687
|
+
...plan.plugins.filter(pluginNeedsWork).map((p) => p.pluginId),
|
|
688
|
+
...plan.bins.filter(binNeedsWork).map((b) => b.name),
|
|
689
|
+
...plan.skills.map((s) => s.name),
|
|
690
|
+
];
|
|
691
|
+
}
|