magus-cli 0.0.0-placeholder → 7.1.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 +725 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +545 -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 +524 -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 +248 -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 +340 -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__/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 +345 -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 +310 -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 +1396 -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 +403 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +528 -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 +264 -0
- package/src/cli/update-view.ts +717 -0
- package/src/cli/update.ts +600 -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 +1564 -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 +657 -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 +245 -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 +847 -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 +377 -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 +820 -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 +535 -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 +1423 -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 +440 -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 +149 -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,1396 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
3
|
+
import { type ToolFacts, classifyTool } from "../services/tool-probe.js";
|
|
4
|
+
import {
|
|
5
|
+
applyBins,
|
|
6
|
+
binCommandFor,
|
|
7
|
+
planWorkItems,
|
|
8
|
+
} from "../services/update-engine.js";
|
|
9
|
+
import {
|
|
10
|
+
type BinUpdateAction,
|
|
11
|
+
type BinUpdateItem,
|
|
12
|
+
type PluginUpdateItem,
|
|
13
|
+
type UpdatePlan,
|
|
14
|
+
binNeedsWork,
|
|
15
|
+
classifyPlugin,
|
|
16
|
+
classifyPluginRows,
|
|
17
|
+
countPluginStates,
|
|
18
|
+
indexCatalog,
|
|
19
|
+
pinForRow,
|
|
20
|
+
planBinUpdates,
|
|
21
|
+
planHasWork,
|
|
22
|
+
planIsBehind,
|
|
23
|
+
planPluginUpdates,
|
|
24
|
+
planSkillUpdates,
|
|
25
|
+
scopeIsBehind,
|
|
26
|
+
scopesNeeding,
|
|
27
|
+
summarizePlan,
|
|
28
|
+
} from "../services/update-plan.js";
|
|
29
|
+
import type { ResolvedBin } from "../types/index.js";
|
|
30
|
+
|
|
31
|
+
/** A PluginInfo with only the fields the planner reads. */
|
|
32
|
+
function info(overrides: Partial<PluginInfo> & { id: string }): PluginInfo {
|
|
33
|
+
return {
|
|
34
|
+
name: overrides.id.split("@")[0]!,
|
|
35
|
+
version: null,
|
|
36
|
+
description: "",
|
|
37
|
+
marketplace: overrides.id.split("@")[1] ?? "magus",
|
|
38
|
+
marketplaceDisplay: "",
|
|
39
|
+
enabled: true,
|
|
40
|
+
...overrides,
|
|
41
|
+
} as PluginInfo;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function catalog(...infos: PluginInfo[]): Map<string, PluginInfo> {
|
|
45
|
+
return new Map(infos.map((i) => [i.id, i]));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Probe facts for a present, managed tool; override what the case is about. */
|
|
49
|
+
function toolFacts(name: string, over: Partial<ToolFacts> = {}): ToolFacts {
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
present: true,
|
|
53
|
+
resolvedPath: `/bin/${name}`,
|
|
54
|
+
installed: "1.0.0",
|
|
55
|
+
latest: null,
|
|
56
|
+
behind: null,
|
|
57
|
+
managed: true,
|
|
58
|
+
sources: [],
|
|
59
|
+
...over,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function plan(overrides: Partial<UpdatePlan> = {}): UpdatePlan {
|
|
64
|
+
return {
|
|
65
|
+
profileId: "test",
|
|
66
|
+
plugins: [],
|
|
67
|
+
bins: [],
|
|
68
|
+
skills: [],
|
|
69
|
+
binSpecs: new Map(),
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("planPluginUpdates — the profile declares it, the machine lacks it", () => {
|
|
75
|
+
test("a plugin absent from every scope is an install", () => {
|
|
76
|
+
const [item] = planPluginUpdates(
|
|
77
|
+
{ "dev@magus": "latest" },
|
|
78
|
+
catalog(info({ id: "dev@magus", version: "4.7.0" })),
|
|
79
|
+
);
|
|
80
|
+
expect(item).toMatchObject({
|
|
81
|
+
action: "install",
|
|
82
|
+
installed: null,
|
|
83
|
+
target: "4.7.0",
|
|
84
|
+
scopes: [],
|
|
85
|
+
});
|
|
86
|
+
expect(item.reason).toBe("absent");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a plugin missing from the catalog entirely is still an install", () => {
|
|
90
|
+
// The marketplace may not be registered yet — install registers it.
|
|
91
|
+
const [item] = planPluginUpdates({ "new@magus": "latest" }, catalog());
|
|
92
|
+
expect(item).toMatchObject({ action: "install", available: null });
|
|
93
|
+
expect(item.reason).toBe("absent");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("an exact pin that is not installed installs that exact version", () => {
|
|
97
|
+
const [item] = planPluginUpdates(
|
|
98
|
+
{ "dev@magus": "4.6.1" },
|
|
99
|
+
catalog(info({ id: "dev@magus", version: "4.7.0" })),
|
|
100
|
+
);
|
|
101
|
+
expect(item.target).toBe("4.6.1");
|
|
102
|
+
expect(item.reason).toBe("absent");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("planPluginUpdates — advancing a 'latest' pin", () => {
|
|
107
|
+
test("a newer catalog version is an update, targeting it", () => {
|
|
108
|
+
const [item] = planPluginUpdates(
|
|
109
|
+
{ "dev@magus": "latest" },
|
|
110
|
+
catalog(
|
|
111
|
+
info({
|
|
112
|
+
id: "dev@magus",
|
|
113
|
+
version: "4.7.0",
|
|
114
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
expect(item).toMatchObject({
|
|
119
|
+
action: "update",
|
|
120
|
+
installed: "4.6.1",
|
|
121
|
+
target: "4.7.0",
|
|
122
|
+
scopes: ["project"],
|
|
123
|
+
});
|
|
124
|
+
expect(item.reason).toBe("behind");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("the same version is current, with no scopes to touch", () => {
|
|
128
|
+
const [item] = planPluginUpdates(
|
|
129
|
+
{ "dev@magus": "latest" },
|
|
130
|
+
catalog(
|
|
131
|
+
info({
|
|
132
|
+
id: "dev@magus",
|
|
133
|
+
version: "4.7.0",
|
|
134
|
+
projectScope: { enabled: true, version: "4.7.0" },
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
expect(item).toMatchObject({ action: "current", scopes: [] });
|
|
139
|
+
expect(item.reason).toBe("up-to-date");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("an older catalog version does not downgrade a 'latest' pin", () => {
|
|
143
|
+
const [item] = planPluginUpdates(
|
|
144
|
+
{ "dev@magus": "latest" },
|
|
145
|
+
catalog(
|
|
146
|
+
info({
|
|
147
|
+
id: "dev@magus",
|
|
148
|
+
version: "4.5.0",
|
|
149
|
+
projectScope: { enabled: true, version: "4.7.0" },
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
expect(item.action).toBe("current");
|
|
154
|
+
expect(item.reason).toBe("up-to-date");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("planPluginUpdates — a failed check is never 'up to date'", () => {
|
|
159
|
+
test("updateCheckFailed yields 'unknown', not 'current'", () => {
|
|
160
|
+
const [item] = planPluginUpdates(
|
|
161
|
+
{ "dev@magus": "latest" },
|
|
162
|
+
catalog(
|
|
163
|
+
info({
|
|
164
|
+
id: "dev@magus",
|
|
165
|
+
// The stale local clone reports the same version we have installed:
|
|
166
|
+
// without the flag this is indistinguishable from a clean check.
|
|
167
|
+
version: "4.6.1",
|
|
168
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
169
|
+
updateCheckFailed: true,
|
|
170
|
+
updateCheckFailure: {
|
|
171
|
+
marketplace: "magus",
|
|
172
|
+
kind: "rate-limit",
|
|
173
|
+
detail: "GitHub rate limit",
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
expect(item.action).toBe("unknown");
|
|
179
|
+
expect(item.note).toBe("GitHub rate limit");
|
|
180
|
+
expect(item.target).toBeNull();
|
|
181
|
+
expect(item.reason).toBe("catalog-unverified");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("an unknown plugin is still work to look at, but does not fail --check", () => {
|
|
185
|
+
// The two predicates split here on purpose: the run has something to
|
|
186
|
+
// report (planHasWork), yet nothing is KNOWN to be behind, so the CI gate
|
|
187
|
+
// passes. Nothing is applied for an unknown either way.
|
|
188
|
+
const p = plan({
|
|
189
|
+
plugins: planPluginUpdates(
|
|
190
|
+
{ "dev@magus": "latest" },
|
|
191
|
+
catalog(
|
|
192
|
+
info({
|
|
193
|
+
id: "dev@magus",
|
|
194
|
+
version: "4.6.1",
|
|
195
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
196
|
+
updateCheckFailed: true,
|
|
197
|
+
}),
|
|
198
|
+
),
|
|
199
|
+
),
|
|
200
|
+
});
|
|
201
|
+
expect(planHasWork(p)).toBe(true);
|
|
202
|
+
expect(planIsBehind(p)).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("planPluginUpdates — content drift", () => {
|
|
207
|
+
test("same version, changed files is a repair across every installed scope", () => {
|
|
208
|
+
const [item] = planPluginUpdates(
|
|
209
|
+
{ "dev@magus": "latest" },
|
|
210
|
+
catalog(
|
|
211
|
+
info({
|
|
212
|
+
id: "dev@magus",
|
|
213
|
+
version: "4.6.1",
|
|
214
|
+
contentStale: true,
|
|
215
|
+
userScope: { enabled: true, version: "4.6.1" },
|
|
216
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
expect(item.action).toBe("repair");
|
|
221
|
+
expect(item.scopes).toEqual(["user", "project"]);
|
|
222
|
+
expect(item.reason).toBe("content-stale");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("planPluginUpdates — an exact pin plans against what is deliverable", () => {
|
|
227
|
+
// This is dependency management, not a package manager: `claude plugin
|
|
228
|
+
// install` takes no version, so the only installable version is whatever the
|
|
229
|
+
// marketplace publishes now. A pin naming anything else cannot be satisfied,
|
|
230
|
+
// and planning toward it anyway never converges.
|
|
231
|
+
|
|
232
|
+
test("targets the marketplace version, not the unreachable pin", () => {
|
|
233
|
+
const [item] = planPluginUpdates(
|
|
234
|
+
{ "dev@magus": "4.6.1" },
|
|
235
|
+
catalog(
|
|
236
|
+
info({
|
|
237
|
+
id: "dev@magus",
|
|
238
|
+
version: "9.9.9",
|
|
239
|
+
projectScope: { enabled: true, version: "4.0.0" },
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
expect(item).toMatchObject({ action: "update", target: "9.9.9" });
|
|
244
|
+
expect(item.reason).toBe("pin-undeliverable");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("an unreachable pin is NOTED, never silently abandoned", () => {
|
|
248
|
+
const [item] = planPluginUpdates(
|
|
249
|
+
{ "dev@magus": "4.6.1" },
|
|
250
|
+
catalog(
|
|
251
|
+
info({
|
|
252
|
+
id: "dev@magus",
|
|
253
|
+
version: "9.9.9",
|
|
254
|
+
projectScope: { enabled: true, version: "9.9.9" },
|
|
255
|
+
}),
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
expect(item.note).toBe(
|
|
259
|
+
"manifest pins 4.6.1; the marketplace offers only 9.9.9",
|
|
260
|
+
);
|
|
261
|
+
expect(item.reason).toBe("pin-undeliverable");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("already on the only installable version is CURRENT — no churn", () => {
|
|
265
|
+
// The bug this guards: planning 9.9.9 → 4.6.1 forever. Install returns
|
|
266
|
+
// 9.9.9, the next run replans the identical move, and --check fails in
|
|
267
|
+
// between every time.
|
|
268
|
+
const [item] = planPluginUpdates(
|
|
269
|
+
{ "dev@magus": "4.6.1" },
|
|
270
|
+
catalog(
|
|
271
|
+
info({
|
|
272
|
+
id: "dev@magus",
|
|
273
|
+
version: "9.9.9",
|
|
274
|
+
projectScope: { enabled: true, version: "9.9.9" },
|
|
275
|
+
}),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
expect(item.action).toBe("current");
|
|
279
|
+
expect(item.scopes).toEqual([]);
|
|
280
|
+
expect(item.reason).toBe("pin-undeliverable");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("an unsatisfiable pin does not fail --check forever", () => {
|
|
284
|
+
const p = plan({
|
|
285
|
+
plugins: planPluginUpdates(
|
|
286
|
+
{ "dev@magus": "4.6.1" },
|
|
287
|
+
catalog(
|
|
288
|
+
info({
|
|
289
|
+
id: "dev@magus",
|
|
290
|
+
version: "9.9.9",
|
|
291
|
+
projectScope: { enabled: true, version: "9.9.9" },
|
|
292
|
+
}),
|
|
293
|
+
),
|
|
294
|
+
),
|
|
295
|
+
});
|
|
296
|
+
expect(planIsBehind(p)).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("a pin the marketplace DOES offer is honoured exactly", () => {
|
|
300
|
+
const [item] = planPluginUpdates(
|
|
301
|
+
{ "dev@magus": "4.6.1" },
|
|
302
|
+
catalog(
|
|
303
|
+
info({
|
|
304
|
+
id: "dev@magus",
|
|
305
|
+
version: "4.6.1",
|
|
306
|
+
projectScope: { enabled: true, version: "4.0.0" },
|
|
307
|
+
}),
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
expect(item).toMatchObject({ action: "update", target: "4.6.1" });
|
|
311
|
+
expect(item.note).toBeUndefined();
|
|
312
|
+
expect(item.reason).toBe("behind");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("falls back to the pin when the catalog offers nothing", () => {
|
|
316
|
+
const [item] = planPluginUpdates(
|
|
317
|
+
{ "dev@magus": "4.6.1" },
|
|
318
|
+
catalog(
|
|
319
|
+
info({
|
|
320
|
+
id: "dev@magus",
|
|
321
|
+
version: null,
|
|
322
|
+
projectScope: { enabled: true, version: "4.0.0" },
|
|
323
|
+
}),
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
expect(item.target).toBe("4.6.1");
|
|
327
|
+
expect(item.reason).toBe("behind");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("per-scope decisions — a stale scope is never masked", () => {
|
|
332
|
+
test("project behind while user is current is an UPDATE, not 'current'", async () => {
|
|
333
|
+
// `installed` is the highest version across scopes. Deciding from it hid
|
|
334
|
+
// exactly this: project scope — the one that actually loads for the repo —
|
|
335
|
+
// sits behind while user scope drags the comparison up to "current".
|
|
336
|
+
const [item] = planPluginUpdates(
|
|
337
|
+
{ "dev@magus": "latest" },
|
|
338
|
+
catalog(
|
|
339
|
+
info({
|
|
340
|
+
id: "dev@magus",
|
|
341
|
+
version: "4.7.0",
|
|
342
|
+
userScope: { enabled: true, version: "4.7.0" },
|
|
343
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
344
|
+
}),
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
expect(item.action).toBe("update");
|
|
348
|
+
expect(item.scopes).toEqual(["project"]);
|
|
349
|
+
expect(item.reason).toBe("behind");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("the same masking under an EXACT pin is also caught", () => {
|
|
353
|
+
// The marketplace offers the pinned version here, so the target IS the pin
|
|
354
|
+
// and only the behind scope should move. (When the marketplace offers
|
|
355
|
+
// something else, every scope moves to that instead — see the
|
|
356
|
+
// "deliverable" suite.)
|
|
357
|
+
const [item] = planPluginUpdates(
|
|
358
|
+
{ "dev@magus": "4.6.1" },
|
|
359
|
+
catalog(
|
|
360
|
+
info({
|
|
361
|
+
id: "dev@magus",
|
|
362
|
+
version: "4.6.1",
|
|
363
|
+
userScope: { enabled: true, version: "4.6.1" },
|
|
364
|
+
projectScope: { enabled: true, version: "4.0.0" },
|
|
365
|
+
}),
|
|
366
|
+
),
|
|
367
|
+
);
|
|
368
|
+
expect(item.action).toBe("update");
|
|
369
|
+
expect(item.scopes).toEqual(["project"]);
|
|
370
|
+
expect(item.reason).toBe("behind");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("an unreachable pin moves EVERY scope to the installable version", () => {
|
|
374
|
+
const [item] = planPluginUpdates(
|
|
375
|
+
{ "dev@magus": "4.6.1" },
|
|
376
|
+
catalog(
|
|
377
|
+
info({
|
|
378
|
+
id: "dev@magus",
|
|
379
|
+
version: "9.9.9",
|
|
380
|
+
userScope: { enabled: true, version: "4.6.1" },
|
|
381
|
+
projectScope: { enabled: true, version: "4.0.0" },
|
|
382
|
+
}),
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
expect(item.target).toBe("9.9.9");
|
|
386
|
+
expect(item.scopes).toEqual(["user", "project"]);
|
|
387
|
+
expect(item.reason).toBe("pin-undeliverable");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("all scopes at the target is genuinely current", () => {
|
|
391
|
+
const [item] = planPluginUpdates(
|
|
392
|
+
{ "dev@magus": "latest" },
|
|
393
|
+
catalog(
|
|
394
|
+
info({
|
|
395
|
+
id: "dev@magus",
|
|
396
|
+
version: "4.7.0",
|
|
397
|
+
userScope: { enabled: true, version: "4.7.0" },
|
|
398
|
+
projectScope: { enabled: true, version: "4.7.0" },
|
|
399
|
+
}),
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
expect(item.action).toBe("current");
|
|
403
|
+
expect(item.reason).toBe("up-to-date");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// REVERSES an earlier decision, deliberately. This used to be `unknown` with
|
|
407
|
+
// a note reading "publishes no version", which put 278 of the 292 plugins in
|
|
408
|
+
// claude-plugins-official into a warning state that no action could ever
|
|
409
|
+
// clear: the marketplace does not publish versions, so re-running changes
|
|
410
|
+
// nothing and the row stays amber forever.
|
|
411
|
+
//
|
|
412
|
+
// The rule now is: if we cannot compare, assume what is installed is the
|
|
413
|
+
// latest. The machine code still says WHY (`no-published-version`), so the
|
|
414
|
+
// distinction is not lost — it just stops being reported as a problem. Do not
|
|
415
|
+
// "fix" this back without changing that rule first.
|
|
416
|
+
test("a catalog that declares no version leaves the install current", () => {
|
|
417
|
+
// dev@magus is IN the catalog with a null version — the declares-no-version
|
|
418
|
+
// case, not the withdrawn one.
|
|
419
|
+
const [item] = planPluginUpdates(
|
|
420
|
+
{ "dev@magus": "latest" },
|
|
421
|
+
catalog(
|
|
422
|
+
info({
|
|
423
|
+
id: "dev@magus",
|
|
424
|
+
version: null,
|
|
425
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
426
|
+
}),
|
|
427
|
+
),
|
|
428
|
+
);
|
|
429
|
+
expect(item.action).toBe("current");
|
|
430
|
+
expect(item.reason).toBe("no-published-version");
|
|
431
|
+
// No note: there is nothing for the reader to do about it.
|
|
432
|
+
expect(item.note).toBeUndefined();
|
|
433
|
+
// And it is not confused with the withdrawn case, which is the next test.
|
|
434
|
+
expect(item.reason).not.toBe("not-offered");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("an installed plugin ABSENT from every catalog is 'not offered'", () => {
|
|
438
|
+
// The genuinely withdrawn case: no catalog entry at all, the installed
|
|
439
|
+
// version known only from the registry.
|
|
440
|
+
const [item] = planPluginUpdates(
|
|
441
|
+
{ "withdrawn@magus": "latest" },
|
|
442
|
+
catalog(info({ id: "dev@magus", version: "4.6.1" })),
|
|
443
|
+
);
|
|
444
|
+
expect(item.action).toBe("unknown");
|
|
445
|
+
expect(item.note).toContain("not offered by any configured marketplace");
|
|
446
|
+
expect(item.reason).toBe("not-offered");
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe("planIsBehind — the --check gate", () => {
|
|
451
|
+
const skillRef = {
|
|
452
|
+
name: "release",
|
|
453
|
+
repo: "MadAppGang/magus",
|
|
454
|
+
path: "skills/release",
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
test("an already-installed skill does NOT fail the gate", () => {
|
|
458
|
+
// A skill ref carries no content hash, so it is re-fetched every run.
|
|
459
|
+
// Counting that as drift made --check exit 1 forever for any profile
|
|
460
|
+
// declaring a single skill — a gate that can never pass is no gate.
|
|
461
|
+
const p = plan({
|
|
462
|
+
skills: planSkillUpdates([skillRef], new Set(["release"])),
|
|
463
|
+
});
|
|
464
|
+
expect(planHasWork(p)).toBe(true);
|
|
465
|
+
expect(planIsBehind(p)).toBe(false);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("a MISSING skill does fail the gate", () => {
|
|
469
|
+
const p = plan({ skills: planSkillUpdates([skillRef], new Set()) });
|
|
470
|
+
expect(planIsBehind(p)).toBe(true);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("a present tool its installer calls current does NOT fail the gate, and is no work", () => {
|
|
474
|
+
const p = plan({
|
|
475
|
+
bins: planBinUpdates(
|
|
476
|
+
[{ name: "tmux", via: "brew", sources: [] }],
|
|
477
|
+
[toolFacts("tmux", { behind: false })],
|
|
478
|
+
),
|
|
479
|
+
});
|
|
480
|
+
expect(planHasWork(p)).toBe(false);
|
|
481
|
+
expect(planIsBehind(p)).toBe(false);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("a tool its installer says is BEHIND does fail the gate", () => {
|
|
485
|
+
// Behaviour change, deliberate: "upgrade" used to mean "re-run the
|
|
486
|
+
// installer on a guess" and was excluded; it now means proven behind.
|
|
487
|
+
const p = plan({
|
|
488
|
+
bins: planBinUpdates(
|
|
489
|
+
[{ name: "claude-swap", via: "pip", sources: [] }],
|
|
490
|
+
[toolFacts("claude-swap", { behind: true, latest: "0.26.0" })],
|
|
491
|
+
),
|
|
492
|
+
});
|
|
493
|
+
expect(planIsBehind(p)).toBe(true);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("an UNKNOWN tool does not fail the gate, and is no work", () => {
|
|
497
|
+
// Tool unknowns are often structural and permanent (another npm owns the
|
|
498
|
+
// file, go is absent) — counting them makes a gate that never passes.
|
|
499
|
+
const p = plan({
|
|
500
|
+
bins: planBinUpdates(
|
|
501
|
+
[{ name: "crush", via: "npm", sources: [] }],
|
|
502
|
+
[toolFacts("crush", { managed: false })],
|
|
503
|
+
),
|
|
504
|
+
});
|
|
505
|
+
expect(p.bins[0]!.action).toBe("unknown");
|
|
506
|
+
expect(planHasWork(p)).toBe(false);
|
|
507
|
+
expect(planIsBehind(p)).toBe(false);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("a MISSING binary does fail the gate", () => {
|
|
511
|
+
const p = plan({
|
|
512
|
+
bins: planBinUpdates(
|
|
513
|
+
[{ name: "tmux-mcp", via: "bun", sources: [] }],
|
|
514
|
+
[toolFacts("tmux-mcp", { present: false, installed: null })],
|
|
515
|
+
),
|
|
516
|
+
});
|
|
517
|
+
expect(planIsBehind(p)).toBe(true);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("an unverifiable plugin does NOT fail the gate, and is not applied", () => {
|
|
521
|
+
// Same rule as an unknown tool. The user's own profile declares 5 plugins
|
|
522
|
+
// whose marketplace publishes no version, so the old behaviour exited 1 on
|
|
523
|
+
// every run for ever — a gate that can never pass protects nothing. The
|
|
524
|
+
// count of unchecked items is printed beside the verdict instead.
|
|
525
|
+
const p = plan({
|
|
526
|
+
plugins: planPluginUpdates(
|
|
527
|
+
{ "dev@magus": "latest" },
|
|
528
|
+
catalog(
|
|
529
|
+
info({
|
|
530
|
+
id: "dev@magus",
|
|
531
|
+
version: "4.6.1",
|
|
532
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
533
|
+
updateCheckFailed: true,
|
|
534
|
+
}),
|
|
535
|
+
),
|
|
536
|
+
),
|
|
537
|
+
});
|
|
538
|
+
expect(p.plugins[0]?.action).toBe("unknown");
|
|
539
|
+
expect(planIsBehind(p)).toBe(false);
|
|
540
|
+
expect(planWorkItems(p)).toEqual([]);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("a plugin whose marketplace publishes no version does NOT fail the gate", () => {
|
|
544
|
+
// The real shape behind dry-run-1: listed, installed, no catalog version.
|
|
545
|
+
const p = plan({
|
|
546
|
+
plugins: planPluginUpdates(
|
|
547
|
+
{ "code-review@claude-plugins-official": "latest" },
|
|
548
|
+
catalog(
|
|
549
|
+
info({
|
|
550
|
+
id: "code-review@claude-plugins-official",
|
|
551
|
+
version: null,
|
|
552
|
+
projectScope: { enabled: true, version: "3ea32df27be7" },
|
|
553
|
+
}),
|
|
554
|
+
),
|
|
555
|
+
),
|
|
556
|
+
});
|
|
557
|
+
expect(p.plugins[0]?.reason).toBe("no-published-version");
|
|
558
|
+
expect(planIsBehind(p)).toBe(false);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("a plugin that is behind, absent or content-stale still fails the gate", () => {
|
|
562
|
+
const behind = plan({
|
|
563
|
+
plugins: planPluginUpdates(
|
|
564
|
+
{ "dev@magus": "latest" },
|
|
565
|
+
catalog(
|
|
566
|
+
info({
|
|
567
|
+
id: "dev@magus",
|
|
568
|
+
version: "4.7.0",
|
|
569
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
570
|
+
}),
|
|
571
|
+
),
|
|
572
|
+
),
|
|
573
|
+
});
|
|
574
|
+
expect(behind.plugins[0]?.action).toBe("update");
|
|
575
|
+
expect(planIsBehind(behind)).toBe(true);
|
|
576
|
+
|
|
577
|
+
const absent = plan({
|
|
578
|
+
plugins: planPluginUpdates(
|
|
579
|
+
{ "dev@magus": "latest" },
|
|
580
|
+
catalog(info({ id: "dev@magus", version: "4.7.0" })),
|
|
581
|
+
),
|
|
582
|
+
});
|
|
583
|
+
expect(absent.plugins[0]?.action).toBe("install");
|
|
584
|
+
expect(planIsBehind(absent)).toBe(true);
|
|
585
|
+
|
|
586
|
+
const stale = plan({
|
|
587
|
+
plugins: planPluginUpdates(
|
|
588
|
+
{ "dev@magus": "latest" },
|
|
589
|
+
catalog(
|
|
590
|
+
info({
|
|
591
|
+
id: "dev@magus",
|
|
592
|
+
version: "4.6.1",
|
|
593
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
594
|
+
contentStale: true,
|
|
595
|
+
}),
|
|
596
|
+
),
|
|
597
|
+
),
|
|
598
|
+
});
|
|
599
|
+
expect(stale.plugins[0]?.action).toBe("repair");
|
|
600
|
+
expect(planIsBehind(stale)).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("a fully current profile with skills and bins passes the gate", () => {
|
|
604
|
+
const p = plan({
|
|
605
|
+
plugins: planPluginUpdates(
|
|
606
|
+
{ "dev@magus": "latest" },
|
|
607
|
+
catalog(
|
|
608
|
+
info({
|
|
609
|
+
id: "dev@magus",
|
|
610
|
+
version: "4.7.0",
|
|
611
|
+
projectScope: { enabled: true, version: "4.7.0" },
|
|
612
|
+
}),
|
|
613
|
+
),
|
|
614
|
+
),
|
|
615
|
+
bins: planBinUpdates(
|
|
616
|
+
[{ name: "tmux-mcp", via: "bun", version: "1.0.0", sources: [] }],
|
|
617
|
+
[toolFacts("tmux-mcp", { installed: "1.0.0" })],
|
|
618
|
+
),
|
|
619
|
+
skills: planSkillUpdates([skillRef], new Set(["release"])),
|
|
620
|
+
});
|
|
621
|
+
expect(planIsBehind(p)).toBe(false);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
describe("nothing ever plans a downgrade", () => {
|
|
626
|
+
// `claude plugin install` installs the newest published version and takes no
|
|
627
|
+
// version argument, so a downgrade is not a move this tool can perform.
|
|
628
|
+
// Planning one produces a step that runs, reports success, lands on the same
|
|
629
|
+
// version as before, and is replanned identically on the next run.
|
|
630
|
+
|
|
631
|
+
test("a scope AHEAD of the marketplace is left alone under an exact pin", () => {
|
|
632
|
+
const [item] = planPluginUpdates(
|
|
633
|
+
{ "dev@magus": "4.6.1" },
|
|
634
|
+
catalog(
|
|
635
|
+
info({
|
|
636
|
+
id: "dev@magus",
|
|
637
|
+
version: "9.9.9",
|
|
638
|
+
projectScope: { enabled: true, version: "10.0.0" },
|
|
639
|
+
}),
|
|
640
|
+
),
|
|
641
|
+
);
|
|
642
|
+
expect(item.action).toBe("current");
|
|
643
|
+
expect(item.scopes).toEqual([]);
|
|
644
|
+
expect(item.reason).toBe("pin-undeliverable");
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("an older pin with no catalog entry does not drag an install backwards", () => {
|
|
648
|
+
// available === null, so the pin is the only target on offer — and it is
|
|
649
|
+
// BELOW what is installed. Moving to it is neither possible nor wanted.
|
|
650
|
+
const [item] = planPluginUpdates(
|
|
651
|
+
{ "dev@magus": "4.6.1" },
|
|
652
|
+
catalog(
|
|
653
|
+
info({
|
|
654
|
+
id: "dev@magus",
|
|
655
|
+
version: null,
|
|
656
|
+
projectScope: { enabled: true, version: "5.0.0" },
|
|
657
|
+
}),
|
|
658
|
+
),
|
|
659
|
+
);
|
|
660
|
+
expect(item.action).toBe("current");
|
|
661
|
+
expect(item.scopes).toEqual([]);
|
|
662
|
+
expect(item.reason).toBe("up-to-date");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("only the behind scope moves when one is ahead and one behind", () => {
|
|
666
|
+
const [item] = planPluginUpdates(
|
|
667
|
+
{ "dev@magus": "latest" },
|
|
668
|
+
catalog(
|
|
669
|
+
info({
|
|
670
|
+
id: "dev@magus",
|
|
671
|
+
version: "9.9.9",
|
|
672
|
+
userScope: { enabled: true, version: "10.0.0" },
|
|
673
|
+
projectScope: { enabled: true, version: "4.0.0" },
|
|
674
|
+
}),
|
|
675
|
+
),
|
|
676
|
+
);
|
|
677
|
+
expect(item.scopes).toEqual(["project"]);
|
|
678
|
+
expect(item.reason).toBe("behind");
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("scopesNeeding never returns a scope ahead of the target", () => {
|
|
682
|
+
const i = info({
|
|
683
|
+
id: "dev@magus",
|
|
684
|
+
userScope: { enabled: true, version: "10.0.0" },
|
|
685
|
+
projectScope: { enabled: true, version: "9.9.9" },
|
|
686
|
+
localScope: { enabled: true, version: "1.0.0" },
|
|
687
|
+
});
|
|
688
|
+
expect(scopesNeeding(i, "9.9.9")).toEqual(["local"]);
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
describe("scopesNeeding", () => {
|
|
693
|
+
test("returns every outdated scope, not just user", () => {
|
|
694
|
+
// Fixing only 'user' leaves the project row behind, so the same drift
|
|
695
|
+
// reappears on every run.
|
|
696
|
+
const i = info({
|
|
697
|
+
id: "dev@magus",
|
|
698
|
+
userScope: { enabled: true, version: "4.6.1" },
|
|
699
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
700
|
+
localScope: { enabled: true, version: "4.7.0" },
|
|
701
|
+
});
|
|
702
|
+
expect(scopesNeeding(i, "4.7.0")).toEqual(["user", "project"]);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("a scope already on the target is left alone", () => {
|
|
706
|
+
const i = info({
|
|
707
|
+
id: "dev@magus",
|
|
708
|
+
projectScope: { enabled: true, version: "4.7.0" },
|
|
709
|
+
});
|
|
710
|
+
expect(scopesNeeding(i, "4.7.0")).toEqual([]);
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
describe("planBinUpdates", () => {
|
|
715
|
+
const bin = (over: Partial<ResolvedBin>): ResolvedBin => ({
|
|
716
|
+
name: "tmux-mcp",
|
|
717
|
+
via: "bun",
|
|
718
|
+
sources: ["profile:test.cliTools"],
|
|
719
|
+
...over,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("a missing binary is an install", () => {
|
|
723
|
+
const [item] = planBinUpdates(
|
|
724
|
+
[bin({})],
|
|
725
|
+
[toolFacts("tmux-mcp", { present: false, installed: null })],
|
|
726
|
+
);
|
|
727
|
+
expect(item).toMatchObject({ action: "install", reason: "absent" });
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("present, unpinned and CURRENT is current — the upgrade loop is gone", () => {
|
|
731
|
+
// This test used to read "present and unpinned is an upgrade — that is
|
|
732
|
+
// what 'update' means", and that was the defect: tmux, browser-use and
|
|
733
|
+
// mcp were current on the reporter's machine (brew / uv said so) and were
|
|
734
|
+
// "upgraded" on every run, forever.
|
|
735
|
+
const [item] = planBinUpdates(
|
|
736
|
+
[bin({ name: "tmux", via: "brew" })],
|
|
737
|
+
[toolFacts("tmux", { installed: "3.7c", behind: false })],
|
|
738
|
+
);
|
|
739
|
+
expect(item).toMatchObject({
|
|
740
|
+
action: "current",
|
|
741
|
+
reason: "up-to-date",
|
|
742
|
+
installed: "3.7c",
|
|
743
|
+
target: null,
|
|
744
|
+
});
|
|
745
|
+
expect(item.version).toBeUndefined();
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("present and unpinned is an upgrade ONLY when its installer says behind", () => {
|
|
749
|
+
const [item] = planBinUpdates(
|
|
750
|
+
[bin({ name: "claude-swap", via: "pip" })],
|
|
751
|
+
[toolFacts("claude-swap", { behind: true, latest: "0.26.0" })],
|
|
752
|
+
);
|
|
753
|
+
expect(item).toMatchObject({
|
|
754
|
+
action: "upgrade",
|
|
755
|
+
reason: "behind",
|
|
756
|
+
target: "0.26.0",
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("present and at its pin is current, keeping the pin", () => {
|
|
761
|
+
const [item] = planBinUpdates(
|
|
762
|
+
[bin({ version: "1.2.3" })],
|
|
763
|
+
[toolFacts("tmux-mcp", { installed: "1.2.3" })],
|
|
764
|
+
);
|
|
765
|
+
expect(item).toMatchObject({
|
|
766
|
+
action: "current",
|
|
767
|
+
reason: "at-pin",
|
|
768
|
+
version: "1.2.3",
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("a bin with no facts was never probed: unknown, with a note", () => {
|
|
773
|
+
const [item] = planBinUpdates([bin({})], []);
|
|
774
|
+
expect(item).toMatchObject({
|
|
775
|
+
action: "unknown",
|
|
776
|
+
reason: "not-probed",
|
|
777
|
+
note: "not probed",
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("the note is classifyTool's note, verbatim (the TUI shows the same string)", () => {
|
|
782
|
+
const f = toolFacts("crush", {
|
|
783
|
+
managed: false,
|
|
784
|
+
resolvedPath: "/n/crush.js",
|
|
785
|
+
owner: "npm (root /l)",
|
|
786
|
+
});
|
|
787
|
+
const [item] = planBinUpdates([bin({ name: "crush", via: "npm" })], [f]);
|
|
788
|
+
expect(item.note).toBe(
|
|
789
|
+
classifyTool(bin({ name: "crush", via: "npm" }), f).note,
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
describe("binNeedsWork — one predicate for apply, the meter and 'has work'", () => {
|
|
795
|
+
const actions: BinUpdateAction[] = [
|
|
796
|
+
"install",
|
|
797
|
+
"upgrade",
|
|
798
|
+
"current",
|
|
799
|
+
"unknown",
|
|
800
|
+
];
|
|
801
|
+
const itemFor = (action: BinUpdateAction): BinUpdateItem => ({
|
|
802
|
+
name: `t-${action}`,
|
|
803
|
+
action,
|
|
804
|
+
reason: "not-probed",
|
|
805
|
+
installed: null,
|
|
806
|
+
latest: null,
|
|
807
|
+
target: null,
|
|
808
|
+
sources: [],
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test("install and upgrade are work; current and unknown are not", () => {
|
|
812
|
+
expect(actions.map((a) => binNeedsWork(itemFor(a)))).toEqual([
|
|
813
|
+
true,
|
|
814
|
+
true,
|
|
815
|
+
false,
|
|
816
|
+
false,
|
|
817
|
+
]);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("applyBins, planWorkItems and planHasWork agree for every action", async () => {
|
|
821
|
+
for (const action of actions) {
|
|
822
|
+
const item = itemFor(action);
|
|
823
|
+
const p = plan({
|
|
824
|
+
bins: [item],
|
|
825
|
+
binSpecs: new Map([
|
|
826
|
+
[item.name, { name: item.name, via: "bun", sources: [] }],
|
|
827
|
+
]),
|
|
828
|
+
});
|
|
829
|
+
const ran: string[] = [];
|
|
830
|
+
await applyBins(
|
|
831
|
+
p,
|
|
832
|
+
undefined,
|
|
833
|
+
async (c) => {
|
|
834
|
+
ran.push(c.cmd);
|
|
835
|
+
return true;
|
|
836
|
+
},
|
|
837
|
+
// Read-back seam: never the real PATH in a unit test.
|
|
838
|
+
async (s) => toolFacts(s.name, { installed: "2.0.0" }),
|
|
839
|
+
);
|
|
840
|
+
const expected = binNeedsWork(item);
|
|
841
|
+
expect(ran.length > 0).toBe(expected);
|
|
842
|
+
expect(planWorkItems(p).includes(item.name)).toBe(expected);
|
|
843
|
+
expect(planHasWork(p)).toBe(expected);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("an upgrade TO the pin runs the pin command, not the upgrade command", () => {
|
|
848
|
+
const spec: ResolvedBin = {
|
|
849
|
+
name: "mnemex",
|
|
850
|
+
via: "bun",
|
|
851
|
+
version: "1.0.0",
|
|
852
|
+
sources: [],
|
|
853
|
+
};
|
|
854
|
+
expect(
|
|
855
|
+
binCommandFor(
|
|
856
|
+
{ ...itemFor("upgrade"), name: "mnemex", target: "1.0.0" },
|
|
857
|
+
spec,
|
|
858
|
+
),
|
|
859
|
+
).toEqual({ cmd: "bun", args: ["install", "-g", "mnemex@1.0.0"] });
|
|
860
|
+
expect(
|
|
861
|
+
binCommandFor(
|
|
862
|
+
{ ...itemFor("upgrade"), target: "2.0.0" },
|
|
863
|
+
{
|
|
864
|
+
...spec,
|
|
865
|
+
version: undefined,
|
|
866
|
+
},
|
|
867
|
+
),
|
|
868
|
+
).toEqual({ cmd: "bun", args: ["install", "-g", "mnemex@latest"] });
|
|
869
|
+
expect(binCommandFor(itemFor("unknown"), spec)).toBeNull();
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
describe("planSkillUpdates", () => {
|
|
874
|
+
const ref = {
|
|
875
|
+
name: "release",
|
|
876
|
+
repo: "MadAppGang/magus",
|
|
877
|
+
path: "skills/release",
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
test("an uninstalled skill is an install", () => {
|
|
881
|
+
expect(planSkillUpdates([ref], new Set())[0]!.action).toBe("install");
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("an installed skill is refreshed, since refs carry no content hash", () => {
|
|
885
|
+
expect(planSkillUpdates([ref], new Set(["release"]))[0]!.action).toBe(
|
|
886
|
+
"refresh",
|
|
887
|
+
);
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
describe("planHasWork / summarizePlan", () => {
|
|
892
|
+
test("an all-current plan has no work", () => {
|
|
893
|
+
const p = plan({
|
|
894
|
+
plugins: planPluginUpdates(
|
|
895
|
+
{ "dev@magus": "latest" },
|
|
896
|
+
catalog(
|
|
897
|
+
info({
|
|
898
|
+
id: "dev@magus",
|
|
899
|
+
version: "4.7.0",
|
|
900
|
+
projectScope: { enabled: true, version: "4.7.0" },
|
|
901
|
+
}),
|
|
902
|
+
),
|
|
903
|
+
),
|
|
904
|
+
});
|
|
905
|
+
expect(planHasWork(p)).toBe(false);
|
|
906
|
+
expect(summarizePlan(p).current).toBe(1);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
test("counts split installs from updates across plugins and bins", () => {
|
|
910
|
+
const p = plan({
|
|
911
|
+
plugins: planPluginUpdates(
|
|
912
|
+
{ "a@magus": "latest", "b@magus": "latest" },
|
|
913
|
+
catalog(
|
|
914
|
+
info({ id: "a@magus", version: "1.0.0" }),
|
|
915
|
+
info({
|
|
916
|
+
id: "b@magus",
|
|
917
|
+
version: "2.0.0",
|
|
918
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
919
|
+
}),
|
|
920
|
+
),
|
|
921
|
+
),
|
|
922
|
+
bins: planBinUpdates(
|
|
923
|
+
[{ name: "tmux-mcp", via: "bun", sources: [] }],
|
|
924
|
+
[toolFacts("tmux-mcp", { installed: "1.0.0", latest: "1.1.0" })],
|
|
925
|
+
),
|
|
926
|
+
});
|
|
927
|
+
expect(summarizePlan(p)).toMatchObject({ installed: 1, updated: 2 });
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test("unknown splits into plugins and tools, and the total is their sum", () => {
|
|
931
|
+
const p = plan({
|
|
932
|
+
plugins: planPluginUpdates(
|
|
933
|
+
// `a@magus` is an ORPHAN — no marketplace offers it — which is the
|
|
934
|
+
// one remaining way a plugin is genuinely `unknown`. A catalog that
|
|
935
|
+
// merely declares no version is `current` now, so using that here
|
|
936
|
+
// would leave `unknownPlugins` at 0 and the sum would hold
|
|
937
|
+
// trivially, testing nothing.
|
|
938
|
+
{ "a@magus": "latest" },
|
|
939
|
+
catalog(orphan("a@magus", "1.0.0")),
|
|
940
|
+
),
|
|
941
|
+
bins: planBinUpdates(
|
|
942
|
+
[
|
|
943
|
+
{ name: "crush", via: "npm", sources: [] },
|
|
944
|
+
{ name: "claude", via: "npm", sources: [] },
|
|
945
|
+
{ name: "tmux", via: "brew", sources: [] },
|
|
946
|
+
],
|
|
947
|
+
[
|
|
948
|
+
toolFacts("crush", { managed: false }),
|
|
949
|
+
toolFacts("claude", { managed: false }),
|
|
950
|
+
toolFacts("tmux", { behind: false }),
|
|
951
|
+
],
|
|
952
|
+
),
|
|
953
|
+
});
|
|
954
|
+
expect(summarizePlan(p)).toMatchObject({
|
|
955
|
+
unknownPlugins: 1,
|
|
956
|
+
unknownTools: 2,
|
|
957
|
+
unknown: 3,
|
|
958
|
+
current: 1,
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
// ── The classifier: rules the TUI and CLI now share ─────────────────────────
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* An orphan as `getAvailablePlugins` builds one: installed, published by no
|
|
967
|
+
* marketplace we read, and — the trap — `version` filled in from its own
|
|
968
|
+
* installed version, so a naive compare finds them equal.
|
|
969
|
+
*/
|
|
970
|
+
function orphan(
|
|
971
|
+
id: string,
|
|
972
|
+
installedVersion: string,
|
|
973
|
+
over: Partial<PluginInfo> = {},
|
|
974
|
+
): PluginInfo {
|
|
975
|
+
return info({
|
|
976
|
+
id,
|
|
977
|
+
version: installedVersion,
|
|
978
|
+
installedVersion,
|
|
979
|
+
isOrphaned: true,
|
|
980
|
+
projectScope: { enabled: true, version: installedVersion },
|
|
981
|
+
...over,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
describe("classifyPlugin — an orphan is never 'current'", () => {
|
|
986
|
+
test("REGRESSION: an orphan used to plan current against its own version", () => {
|
|
987
|
+
// Before: `catalog.has(id)` was true for an orphan, its `version` equalled
|
|
988
|
+
// its installed version, and the plan said `current` while the TUI drew
|
|
989
|
+
// the same row red as "deprecated".
|
|
990
|
+
const [item] = planPluginUpdates(
|
|
991
|
+
{ "seo@magus": "latest" },
|
|
992
|
+
catalog(
|
|
993
|
+
orphan("seo@magus", "1.8.0"),
|
|
994
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
995
|
+
),
|
|
996
|
+
);
|
|
997
|
+
expect(item.action).toBe("unknown");
|
|
998
|
+
expect(item.action).not.toBe("current");
|
|
999
|
+
expect(item.available).toBeNull();
|
|
1000
|
+
expect(item.target).toBeNull();
|
|
1001
|
+
expect(item.scopes).toEqual([]);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// `moved` is GONE, and `movedTo` with it. It was inferred from a bare name
|
|
1005
|
+
// match: a plugin published to two marketplaces — claudish is — reported
|
|
1006
|
+
// "moved → magus" when nothing had moved and both copies were installed.
|
|
1007
|
+
// The inference cannot tell a move from a dual publication, so it is not made
|
|
1008
|
+
// at all. An orphan is `not-offered`, which is a fact rather than a guess.
|
|
1009
|
+
test("an orphan is 'not offered', and never guesses where it went", () => {
|
|
1010
|
+
const [item] = planPluginUpdates(
|
|
1011
|
+
{ "seo@magus": "latest" },
|
|
1012
|
+
catalog(
|
|
1013
|
+
orphan("seo@magus", "1.8.0"),
|
|
1014
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1015
|
+
),
|
|
1016
|
+
);
|
|
1017
|
+
expect(item).toMatchObject({
|
|
1018
|
+
action: "unknown",
|
|
1019
|
+
reason: "not-offered",
|
|
1020
|
+
});
|
|
1021
|
+
expect(item.note).not.toMatch(/moved/i);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
test("an orphan whose marketplace we DID read is 'not offered'", () => {
|
|
1025
|
+
const [item] = planPluginUpdates(
|
|
1026
|
+
{ "seo@magus": "latest" },
|
|
1027
|
+
catalog(
|
|
1028
|
+
orphan("seo@magus", "1.8.0"),
|
|
1029
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1030
|
+
),
|
|
1031
|
+
);
|
|
1032
|
+
expect(item).toMatchObject({
|
|
1033
|
+
action: "unknown",
|
|
1034
|
+
reason: "not-offered",
|
|
1035
|
+
note: "not offered by any configured marketplace",
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
test("an orphan whose marketplace contributed nothing is 'marketplace-unread'", () => {
|
|
1040
|
+
// No listed id from @gone — we never read that catalog, so its absence is
|
|
1041
|
+
// not evidence it was dropped. Claiming "not offered" would be a guess.
|
|
1042
|
+
const [item] = planPluginUpdates(
|
|
1043
|
+
{ "thing@gone": "latest" },
|
|
1044
|
+
catalog(
|
|
1045
|
+
orphan("thing@gone", "2.0.0"),
|
|
1046
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1047
|
+
),
|
|
1048
|
+
);
|
|
1049
|
+
expect(item).toMatchObject({
|
|
1050
|
+
action: "unknown",
|
|
1051
|
+
reason: "marketplace-unread",
|
|
1052
|
+
note: "its marketplace gone was not read, so whether it still offers this plugin is unknown",
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test("another ORPHAN from the same marketplace is not proof we read it", () => {
|
|
1057
|
+
// Two orphans under @gone used to make `marketplaceIsKnown` true for each
|
|
1058
|
+
// other. Neither is listed, so neither proves anything.
|
|
1059
|
+
const [item] = planPluginUpdates(
|
|
1060
|
+
{ "thing@gone": "latest" },
|
|
1061
|
+
catalog(orphan("thing@gone", "2.0.0"), orphan("other@gone", "1.0.0")),
|
|
1062
|
+
);
|
|
1063
|
+
expect(item.reason).toBe("marketplace-unread");
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
test("an orphan is unknown under an exact pin too", () => {
|
|
1067
|
+
const [item] = planPluginUpdates(
|
|
1068
|
+
{ "seo@magus": "1.8.0" },
|
|
1069
|
+
catalog(
|
|
1070
|
+
orphan("seo@magus", "1.8.0"),
|
|
1071
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1072
|
+
),
|
|
1073
|
+
);
|
|
1074
|
+
expect(item.action).toBe("unknown");
|
|
1075
|
+
});
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
// REVERSES F3, deliberately. `unorderable` is gone as a state: when two
|
|
1079
|
+
// versions cannot be ordered we assume what is installed IS the latest, rather
|
|
1080
|
+
// than raising a warning nothing can clear.
|
|
1081
|
+
//
|
|
1082
|
+
// F3's premise was that "cannot compare" might hide a plugin that is behind.
|
|
1083
|
+
// True, but the cost was paid on every run by rows that were fine — an install
|
|
1084
|
+
// recorded as a commit sha can never be ordered against a semver pin, so the
|
|
1085
|
+
// warning was permanent and unactionable. Do not restore `unorderable` without
|
|
1086
|
+
// changing that rule first.
|
|
1087
|
+
describe("classifyPlugin — an unorderable version is assumed current", () => {
|
|
1088
|
+
test("a pinned plugin whose install cannot be ordered is current", () => {
|
|
1089
|
+
const [item] = planPluginUpdates(
|
|
1090
|
+
{ "playground@official": "7.3.0" },
|
|
1091
|
+
catalog(
|
|
1092
|
+
info({
|
|
1093
|
+
id: "playground@official",
|
|
1094
|
+
version: "7.3.0",
|
|
1095
|
+
projectScope: { enabled: true, version: "unknown" },
|
|
1096
|
+
}),
|
|
1097
|
+
),
|
|
1098
|
+
);
|
|
1099
|
+
expect(item.action).toBe("current");
|
|
1100
|
+
expect(item.reason).not.toBe("unorderable");
|
|
1101
|
+
// Nothing is planned against it, which is the part F3 actually protected.
|
|
1102
|
+
expect(item.scopes).toEqual([]);
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test("no published version, under an exact pin, is also current", () => {
|
|
1106
|
+
const [item] = planPluginUpdates(
|
|
1107
|
+
{ "playground@official": "7.3.0" },
|
|
1108
|
+
catalog(
|
|
1109
|
+
info({
|
|
1110
|
+
id: "playground@official",
|
|
1111
|
+
version: null,
|
|
1112
|
+
projectScope: { enabled: true, version: "3ea32df27be7" },
|
|
1113
|
+
}),
|
|
1114
|
+
),
|
|
1115
|
+
);
|
|
1116
|
+
expect(item.action).toBe("current");
|
|
1117
|
+
expect(item.note).toBeUndefined();
|
|
1118
|
+
// The exact-pin path reports `up-to-date` here where the "latest" path
|
|
1119
|
+
// reports `no-published-version` — same verdict, different machine code.
|
|
1120
|
+
// Asserted as observed rather than as preferred: nothing renders the two
|
|
1121
|
+
// differently today, and forcing them to agree is a change to the
|
|
1122
|
+
// classifier, not to this test.
|
|
1123
|
+
expect(item.reason).toBe("up-to-date");
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test("a scope that IS behind still wins: it is planned, not hidden", () => {
|
|
1127
|
+
const [item] = planPluginUpdates(
|
|
1128
|
+
{ "dev@magus": "4.7.0" },
|
|
1129
|
+
catalog(
|
|
1130
|
+
info({
|
|
1131
|
+
id: "dev@magus",
|
|
1132
|
+
version: "4.7.0",
|
|
1133
|
+
userScope: { enabled: true, version: "unknown" },
|
|
1134
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
1135
|
+
}),
|
|
1136
|
+
),
|
|
1137
|
+
);
|
|
1138
|
+
expect(item).toMatchObject({ action: "update", scopes: ["project"] });
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
describe("classifyPlugin — install reasons", () => {
|
|
1143
|
+
test("enabled in settings with no files anywhere says so", () => {
|
|
1144
|
+
const [item] = planPluginUpdates(
|
|
1145
|
+
{ "dev@magus": "latest" },
|
|
1146
|
+
catalog(
|
|
1147
|
+
info({
|
|
1148
|
+
id: "dev@magus",
|
|
1149
|
+
version: "4.7.0",
|
|
1150
|
+
projectScope: { enabled: true },
|
|
1151
|
+
}),
|
|
1152
|
+
),
|
|
1153
|
+
);
|
|
1154
|
+
expect(item).toMatchObject({
|
|
1155
|
+
action: "install",
|
|
1156
|
+
reason: "enabled-not-installed",
|
|
1157
|
+
note: "enabled in settings, but no files on disk",
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
test("a plain absence carries no note", () => {
|
|
1162
|
+
const [item] = planPluginUpdates(
|
|
1163
|
+
{ "dev@magus": "latest" },
|
|
1164
|
+
catalog(info({ id: "dev@magus", version: "4.7.0" })),
|
|
1165
|
+
);
|
|
1166
|
+
expect(item.reason).toBe("absent");
|
|
1167
|
+
expect(item.note).toBeUndefined();
|
|
1168
|
+
});
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
describe("classifyPlugin — beforeVersions records every installed scope", () => {
|
|
1172
|
+
test("each installed scope's version, and nothing for an absent one", () => {
|
|
1173
|
+
const [item] = planPluginUpdates(
|
|
1174
|
+
{ "dev@magus": "latest" },
|
|
1175
|
+
catalog(
|
|
1176
|
+
info({
|
|
1177
|
+
id: "dev@magus",
|
|
1178
|
+
version: "4.7.0",
|
|
1179
|
+
userScope: { enabled: true, version: "4.6.0" },
|
|
1180
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
1181
|
+
localScope: { enabled: true },
|
|
1182
|
+
}),
|
|
1183
|
+
),
|
|
1184
|
+
);
|
|
1185
|
+
expect(item.beforeVersions).toEqual({ user: "4.6.0", project: "4.6.1" });
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
test("an uninstalled plugin has none", () => {
|
|
1189
|
+
const [item] = planPluginUpdates(
|
|
1190
|
+
{ "dev@magus": "latest" },
|
|
1191
|
+
catalog(info({ id: "dev@magus", version: "4.7.0" })),
|
|
1192
|
+
);
|
|
1193
|
+
expect(item.beforeVersions).toEqual({});
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
describe("indexCatalog", () => {
|
|
1198
|
+
const idx = indexCatalog([
|
|
1199
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1200
|
+
orphan("seo@magus", "1.8.0"),
|
|
1201
|
+
orphan("thing@gone", "2.0.0"),
|
|
1202
|
+
]);
|
|
1203
|
+
|
|
1204
|
+
test("isListed excludes orphans", () => {
|
|
1205
|
+
expect(idx.isListed("dev@magus")).toBe(true);
|
|
1206
|
+
expect(idx.isListed("seo@magus")).toBe(false);
|
|
1207
|
+
expect(idx.isListed("absent@magus")).toBe(false);
|
|
1208
|
+
// Still reachable by id — the classifier needs its scopes.
|
|
1209
|
+
expect(idx.get("seo@magus")?.isOrphaned).toBe(true);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
test("marketplaceIsKnown counts OTHER listed ids only", () => {
|
|
1213
|
+
expect(idx.marketplaceIsKnown("seo@magus")).toBe(true); // dev@magus is listed
|
|
1214
|
+
expect(idx.marketplaceIsKnown("absent@magus")).toBe(true);
|
|
1215
|
+
// dev@magus is the only listed @magus id — itself does not count.
|
|
1216
|
+
expect(idx.marketplaceIsKnown("dev@magus")).toBe(false);
|
|
1217
|
+
// Only an orphan there.
|
|
1218
|
+
expect(idx.marketplaceIsKnown("other@gone")).toBe(false);
|
|
1219
|
+
expect(idx.marketplaceIsKnown("no-marketplace")).toBe(false);
|
|
1220
|
+
expect(idx.marketplaceIsKnown("trailing@")).toBe(false);
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
test("a Map and an array index identically", () => {
|
|
1224
|
+
const list = [
|
|
1225
|
+
info({ id: "a@m", version: "1.0.0" }),
|
|
1226
|
+
info({ id: "b@m", version: "1.0.0" }),
|
|
1227
|
+
];
|
|
1228
|
+
const fromMap = indexCatalog(catalog(...list));
|
|
1229
|
+
const fromArray = indexCatalog(list);
|
|
1230
|
+
for (const id of ["a@m", "b@m", "c@m"]) {
|
|
1231
|
+
expect(fromMap.isListed(id)).toBe(fromArray.isListed(id));
|
|
1232
|
+
expect(fromMap.marketplaceIsKnown(id)).toBe(
|
|
1233
|
+
fromArray.marketplaceIsKnown(id),
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
describe("classifyPlugin and planPluginUpdates are the same function", () => {
|
|
1240
|
+
test("planPluginUpdates is classifyPlugin over each pin, sorted", () => {
|
|
1241
|
+
const cat = catalog(
|
|
1242
|
+
info({
|
|
1243
|
+
id: "b@magus",
|
|
1244
|
+
version: "2.0.0",
|
|
1245
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
1246
|
+
}),
|
|
1247
|
+
info({ id: "a@magus", version: "1.0.0" }),
|
|
1248
|
+
);
|
|
1249
|
+
const pins = { "b@magus": "latest", "a@magus": "1.0.0" };
|
|
1250
|
+
const idx = indexCatalog(cat);
|
|
1251
|
+
expect(planPluginUpdates(pins, cat)).toEqual([
|
|
1252
|
+
classifyPlugin("a@magus", "1.0.0", idx),
|
|
1253
|
+
classifyPlugin("b@magus", "latest", idx),
|
|
1254
|
+
]);
|
|
1255
|
+
});
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
describe("pinForRow — which pin a TUI row is judged against", () => {
|
|
1259
|
+
test("the profile's pin wins, exact or latest", () => {
|
|
1260
|
+
const i = info({
|
|
1261
|
+
id: "dev@magus",
|
|
1262
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
1263
|
+
});
|
|
1264
|
+
expect(pinForRow(i, { "dev@magus": "4.6.1" })).toBe("4.6.1");
|
|
1265
|
+
expect(pinForRow(i, { "dev@magus": "latest" })).toBe("latest");
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
test("a declared plugin gets its pin even when it is installed nowhere", () => {
|
|
1269
|
+
expect(
|
|
1270
|
+
pinForRow(info({ id: "dev@magus" }), { "dev@magus": "latest" }),
|
|
1271
|
+
).toBe("latest");
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
test("undeclared but installed in any scope is judged against latest", () => {
|
|
1275
|
+
for (const scope of ["userScope", "projectScope", "localScope"] as const) {
|
|
1276
|
+
const i = info({
|
|
1277
|
+
id: "dev@magus",
|
|
1278
|
+
[scope]: { enabled: false, version: "1.0.0" },
|
|
1279
|
+
});
|
|
1280
|
+
expect(pinForRow(i, {})).toBe("latest");
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test("undeclared, enabled but not installed is judged against latest", () => {
|
|
1285
|
+
expect(
|
|
1286
|
+
pinForRow(info({ id: "dev@magus", localScope: { enabled: true } }), {}),
|
|
1287
|
+
).toBe("latest");
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
test("undeclared and neither installed nor enabled gets NO status", () => {
|
|
1291
|
+
// A catalog entry the user is only browsing. `install` would claim the
|
|
1292
|
+
// profile wants it, which nothing says.
|
|
1293
|
+
expect(
|
|
1294
|
+
pinForRow(info({ id: "dev@magus", version: "1.0.0" }), {}),
|
|
1295
|
+
).toBeNull();
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("an inherited key from Object.prototype is not a pin", () => {
|
|
1299
|
+
expect(
|
|
1300
|
+
pinForRow(info({ id: "toString", version: "1.0.0" }), {}),
|
|
1301
|
+
).toBeNull();
|
|
1302
|
+
});
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
describe("classifyPluginRows", () => {
|
|
1306
|
+
test("one status per row with a pin; browse-only rows are absent", () => {
|
|
1307
|
+
const rows = [
|
|
1308
|
+
info({ id: "declared@magus", version: "1.0.0" }),
|
|
1309
|
+
info({
|
|
1310
|
+
id: "installed@magus",
|
|
1311
|
+
version: "2.0.0",
|
|
1312
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
1313
|
+
}),
|
|
1314
|
+
info({ id: "browse@magus", version: "3.0.0" }),
|
|
1315
|
+
];
|
|
1316
|
+
const statuses = classifyPluginRows(rows, { "declared@magus": "latest" });
|
|
1317
|
+
expect([...statuses.keys()].sort()).toEqual([
|
|
1318
|
+
"declared@magus",
|
|
1319
|
+
"installed@magus",
|
|
1320
|
+
]);
|
|
1321
|
+
expect(statuses.get("declared@magus")).toMatchObject({
|
|
1322
|
+
action: "install",
|
|
1323
|
+
reason: "absent",
|
|
1324
|
+
});
|
|
1325
|
+
expect(statuses.get("installed@magus")).toMatchObject({
|
|
1326
|
+
action: "update",
|
|
1327
|
+
reason: "behind",
|
|
1328
|
+
});
|
|
1329
|
+
expect(statuses.has("browse@magus")).toBe(false);
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
test("a declared row gets exactly what the CLI plans for it", () => {
|
|
1333
|
+
const rows = [
|
|
1334
|
+
info({
|
|
1335
|
+
id: "dev@magus",
|
|
1336
|
+
version: "4.7.0",
|
|
1337
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
1338
|
+
}),
|
|
1339
|
+
info({ id: "terminal@magus", version: "5.0.1" }),
|
|
1340
|
+
];
|
|
1341
|
+
const pins = { "dev@magus": "4.6.1", "terminal@magus": "latest" };
|
|
1342
|
+
const cli = planPluginUpdates(pins, catalog(...rows));
|
|
1343
|
+
const tui = classifyPluginRows(rows, pins);
|
|
1344
|
+
for (const item of cli) expect(tui.get(item.pluginId)).toEqual(item);
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
describe("countPluginStates", () => {
|
|
1349
|
+
test("counts every action, including zeros", () => {
|
|
1350
|
+
const items = [
|
|
1351
|
+
{ action: "install" },
|
|
1352
|
+
{ action: "update" },
|
|
1353
|
+
{ action: "update" },
|
|
1354
|
+
{ action: "repair" },
|
|
1355
|
+
{ action: "unknown" },
|
|
1356
|
+
] as PluginUpdateItem[];
|
|
1357
|
+
expect(countPluginStates(items)).toEqual({
|
|
1358
|
+
install: 1,
|
|
1359
|
+
update: 2,
|
|
1360
|
+
repair: 1,
|
|
1361
|
+
current: 0,
|
|
1362
|
+
unknown: 1,
|
|
1363
|
+
});
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
test("accepts a Map's values directly", () => {
|
|
1367
|
+
const m = new Map([["a", { action: "current" } as PluginUpdateItem]]);
|
|
1368
|
+
expect(countPluginStates(m.values()).current).toBe(1);
|
|
1369
|
+
});
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
describe("scopeIsBehind — the scope picker's one question", () => {
|
|
1373
|
+
const status = (over: Partial<PluginUpdateItem>) =>
|
|
1374
|
+
({ action: "update", scopes: ["project"], ...over }) as PluginUpdateItem;
|
|
1375
|
+
|
|
1376
|
+
test("an update touching the scope says yes", () => {
|
|
1377
|
+
expect(scopeIsBehind(status({}), "project")).toBe(true);
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
test("an update touching OTHER scopes says no for this one", () => {
|
|
1381
|
+
expect(scopeIsBehind(status({}), "user")).toBe(false);
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
test("repair, unknown, current and install all say no", () => {
|
|
1385
|
+
// `claude plugin install` on the same version is a no-op, so a repair
|
|
1386
|
+
// scope must not be offered "update".
|
|
1387
|
+
for (const action of ["repair", "unknown", "current", "install"] as const) {
|
|
1388
|
+
expect(scopeIsBehind(status({ action }), "project")).toBe(false);
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
test("no status says no", () => {
|
|
1393
|
+
expect(scopeIsBehind(null, "project")).toBe(false);
|
|
1394
|
+
expect(scopeIsBehind(undefined, "project")).toBe(false);
|
|
1395
|
+
});
|
|
1396
|
+
});
|