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,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace.json fetcher with offline fallback.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `plugin-manager.ts` so the fetch + fallback logic can be
|
|
5
|
+
* unit-tested without dragging in claude-settings.ts's module-level
|
|
6
|
+
* `os.homedir()` constants (which contaminate the test suite when
|
|
7
|
+
* imported via plugin-manager.ts).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isValidGitHubRepo } from "../utils/string-utils.js";
|
|
11
|
+
import {
|
|
12
|
+
clearCachedCatalogs,
|
|
13
|
+
readCachedCatalog,
|
|
14
|
+
writeCachedCatalog,
|
|
15
|
+
} from "./catalog-cache-store.js";
|
|
16
|
+
import {
|
|
17
|
+
type GitHubHost,
|
|
18
|
+
cooldownFor,
|
|
19
|
+
describeCooldown,
|
|
20
|
+
hydrateGitHubBudget,
|
|
21
|
+
recordRateLimit,
|
|
22
|
+
recordSuccess,
|
|
23
|
+
} from "./github-budget.js";
|
|
24
|
+
import type { LocalMarketplace } from "./local-marketplace.js";
|
|
25
|
+
import {
|
|
26
|
+
clearGitCatalogCache,
|
|
27
|
+
readCatalogFromRemote,
|
|
28
|
+
} from "./marketplace-catalog-git.js";
|
|
29
|
+
import {
|
|
30
|
+
type MarketplacePlugin,
|
|
31
|
+
clearMarketplaceVersionCache,
|
|
32
|
+
normalizeCatalogJson,
|
|
33
|
+
} from "./marketplace-catalog.js";
|
|
34
|
+
|
|
35
|
+
// Re-exported: these used to live here, and plugin-manager plus several screens
|
|
36
|
+
// import them from this module.
|
|
37
|
+
export {
|
|
38
|
+
getMarketplaceVersion,
|
|
39
|
+
normalizeCatalogJson,
|
|
40
|
+
type MarketplacePlugin,
|
|
41
|
+
} from "./marketplace-catalog.js";
|
|
42
|
+
|
|
43
|
+
// Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
|
|
44
|
+
const marketplaceCache = new Map<string, MarketplacePlugin[]>();
|
|
45
|
+
|
|
46
|
+
/** Why a catalog fetch did not produce an authoritative plugin list. */
|
|
47
|
+
export type MarketplaceFetchFailureKind =
|
|
48
|
+
| "rate-limited"
|
|
49
|
+
| "http"
|
|
50
|
+
| "timeout"
|
|
51
|
+
| "network"
|
|
52
|
+
| "invalid-repo"
|
|
53
|
+
| "invalid-content";
|
|
54
|
+
|
|
55
|
+
export interface MarketplaceFetchFailure {
|
|
56
|
+
marketplace: string;
|
|
57
|
+
kind: MarketplaceFetchFailureKind;
|
|
58
|
+
httpStatus?: number;
|
|
59
|
+
/** Short, user-facing reason — rendered verbatim in the TUI. */
|
|
60
|
+
detail: string;
|
|
61
|
+
/**
|
|
62
|
+
* Wall-clock ms when a retry is due, for rate limits. Lets the UI count down
|
|
63
|
+
* and retry itself instead of telling the user to try again "later" and leaving
|
|
64
|
+
* them to guess how much later.
|
|
65
|
+
*/
|
|
66
|
+
retryAt?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MarketplaceFetchResult {
|
|
70
|
+
plugins: MarketplacePlugin[];
|
|
71
|
+
/** Present exactly when `plugins` is not an authoritative remote answer. */
|
|
72
|
+
failure?: MarketplaceFetchFailure;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where a resolved plugin list actually came from.
|
|
77
|
+
*
|
|
78
|
+
* `remote` raw.githubusercontent.com — authoritative.
|
|
79
|
+
* `remote-git` the clone's `origin/<branch>` ref, refreshed by `git fetch` —
|
|
80
|
+
* also authoritative: it IS upstream's file. Reached when HTTP is
|
|
81
|
+
* rate-limited, and it draws on neither GitHub HTTP budget.
|
|
82
|
+
* `local-clone` the checked-out working tree — usable but NOT authoritative. It
|
|
83
|
+
* is deliberately pinned (`autoUpdate: false`), so its versions can
|
|
84
|
+
* be arbitrarily old.
|
|
85
|
+
* `none` nothing could be read.
|
|
86
|
+
*/
|
|
87
|
+
export type CatalogSource = "remote" | "remote-git" | "local-clone" | "none";
|
|
88
|
+
|
|
89
|
+
/** Sources whose versions may be reported to the user as current. */
|
|
90
|
+
export function isAuthoritative(source: CatalogSource): boolean {
|
|
91
|
+
return source === "remote" || source === "remote-git";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface MarketplaceResolution {
|
|
95
|
+
plugins: MarketplacePlugin[];
|
|
96
|
+
source: CatalogSource;
|
|
97
|
+
/**
|
|
98
|
+
* The remote failure, if any. Set even when `source` is `local-clone` — the
|
|
99
|
+
* fallback succeeded, but the versions in it are not known to be current.
|
|
100
|
+
*/
|
|
101
|
+
failure?: MarketplaceFetchFailure;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Failures are remembered, not just returned.
|
|
106
|
+
*
|
|
107
|
+
* Two reasons. First, so the TUI can render "4 of 6 catalogs unreachable"
|
|
108
|
+
* without every caller threading a result type through. Second, so a failure is
|
|
109
|
+
* cached like a success: an un-cached failure was re-attempted on every screen
|
|
110
|
+
* mount, and with a 10s timeout per marketplace that alone cost 30-45s of
|
|
111
|
+
* "Loading..." each time the user switched tabs and came back.
|
|
112
|
+
*
|
|
113
|
+
* The TTL is short and an explicit refresh clears it, so a transient blip never
|
|
114
|
+
* freezes the catalog for a whole session — which would be the same
|
|
115
|
+
* silent-staleness trap this module exists to close.
|
|
116
|
+
*/
|
|
117
|
+
const failureCache = new Map<
|
|
118
|
+
string,
|
|
119
|
+
{ failure: MarketplaceFetchFailure; at: number }
|
|
120
|
+
>();
|
|
121
|
+
const NEGATIVE_TTL_MS = 60_000;
|
|
122
|
+
|
|
123
|
+
/** Monotonic clock, so a system clock change cannot expire the cache early. */
|
|
124
|
+
function now(): number {
|
|
125
|
+
return performance.now();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function clearMarketplaceCache(): void {
|
|
129
|
+
marketplaceCache.clear();
|
|
130
|
+
clearMarketplaceVersionCache();
|
|
131
|
+
clearGitCatalogCache();
|
|
132
|
+
failureCache.clear();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Drop the on-disk catalog cache as well.
|
|
137
|
+
*
|
|
138
|
+
* Separate and async because it must be AWAITED before the refetch that follows
|
|
139
|
+
* it: fire-and-forget would race the next `resolveMarketplacePlugins`, which would
|
|
140
|
+
* then read the cache it was told to discard and make `r` a no-op.
|
|
141
|
+
*
|
|
142
|
+
* Deliberately does NOT clear the host cooldown. That records GitHub's state, not
|
|
143
|
+
* ours, and retrying inside a known window cannot succeed — the screen counts down
|
|
144
|
+
* and refetches by itself when the window opens.
|
|
145
|
+
*/
|
|
146
|
+
export async function clearPersistedCatalogs(): Promise<void> {
|
|
147
|
+
await clearCachedCatalogs();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every marketplace whose catalog could not be fetched, freshest answer first.
|
|
152
|
+
*
|
|
153
|
+
* A caller that renders plugin versions MUST consult this. An empty list is the
|
|
154
|
+
* only proof that the versions on screen were actually checked against GitHub.
|
|
155
|
+
*/
|
|
156
|
+
export function getMarketplaceFetchFailures(): MarketplaceFetchFailure[] {
|
|
157
|
+
const out: MarketplaceFetchFailure[] = [];
|
|
158
|
+
for (const [, entry] of failureCache) {
|
|
159
|
+
if (now() - entry.at < NEGATIVE_TTL_MS) out.push(entry.failure);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Record a failure and return it as the result, so callers cannot ignore it. */
|
|
165
|
+
function fail(
|
|
166
|
+
marketplace: string,
|
|
167
|
+
kind: MarketplaceFetchFailureKind,
|
|
168
|
+
detail: string,
|
|
169
|
+
httpStatus?: number,
|
|
170
|
+
retryAt?: number,
|
|
171
|
+
): MarketplaceFetchResult {
|
|
172
|
+
const failure: MarketplaceFetchFailure = {
|
|
173
|
+
marketplace,
|
|
174
|
+
kind,
|
|
175
|
+
detail,
|
|
176
|
+
httpStatus,
|
|
177
|
+
retryAt,
|
|
178
|
+
};
|
|
179
|
+
failureCache.set(marketplace, { failure, at: now() });
|
|
180
|
+
return { plugins: [], failure };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Turn a thrown fetch error into a classified failure.
|
|
185
|
+
*
|
|
186
|
+
* A timeout is distinguished from an outright network error because they mean
|
|
187
|
+
* different things to the user: the first is "GitHub is slow or unreachable
|
|
188
|
+
* right now", the second is usually "this machine has no route at all".
|
|
189
|
+
*/
|
|
190
|
+
function classifyThrown(
|
|
191
|
+
marketplace: string,
|
|
192
|
+
error: unknown,
|
|
193
|
+
): MarketplaceFetchResult {
|
|
194
|
+
const name = (error as { name?: string } | null)?.name;
|
|
195
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
196
|
+
return fail(
|
|
197
|
+
marketplace,
|
|
198
|
+
"timeout",
|
|
199
|
+
`no response in ${FETCH_TIMEOUT_MS / 1000}s`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
203
|
+
return fail(marketplace, "network", message);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Fetch a marketplace catalog from GitHub.
|
|
210
|
+
*
|
|
211
|
+
* Returns a result, never a bare list, because "the fetch failed" and "this
|
|
212
|
+
* marketplace has no plugins" used to be the same value (`[]`) and the caller
|
|
213
|
+
* could not tell them apart. That single conflation is what let a rate-limited
|
|
214
|
+
* request render as "you are up to date": the empty list fell through to the
|
|
215
|
+
* on-disk clone, and a stale clone's version compared equal to the installed
|
|
216
|
+
* one. A failure must be a value the caller has to look at.
|
|
217
|
+
*/
|
|
218
|
+
export async function fetchMarketplacePlugins(
|
|
219
|
+
marketplaceName: string,
|
|
220
|
+
repo: string,
|
|
221
|
+
): Promise<MarketplaceFetchResult> {
|
|
222
|
+
// Check cache first - session-level, no TTL
|
|
223
|
+
const cached = marketplaceCache.get(marketplaceName);
|
|
224
|
+
if (cached) {
|
|
225
|
+
return { plugins: cached };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A fresh failure is reused rather than re-attempted: see `failureCache`.
|
|
229
|
+
const priorFailure = failureCache.get(marketplaceName);
|
|
230
|
+
if (priorFailure && now() - priorFailure.at < NEGATIVE_TTL_MS) {
|
|
231
|
+
return { plugins: [], failure: priorFailure.failure };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Validate repo format to prevent SSRF
|
|
235
|
+
if (!isValidGitHubRepo(repo)) {
|
|
236
|
+
return fail(
|
|
237
|
+
marketplaceName,
|
|
238
|
+
"invalid-repo",
|
|
239
|
+
`not a valid GitHub repo: "${repo}"`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const host: GitHubHost = "raw.githubusercontent.com";
|
|
244
|
+
|
|
245
|
+
// Cooldowns recorded by a PREVIOUS launch count too. Without this the gate is
|
|
246
|
+
// per-process, and a TUI's process lives for one screen session.
|
|
247
|
+
await hydrateGitHubBudget();
|
|
248
|
+
|
|
249
|
+
// Already known to be rate-limited: do not spend an attempt to rediscover it.
|
|
250
|
+
// The budget is per-host and shared with every other service, so six
|
|
251
|
+
// marketplaces used to trip the same limit six times over — and on some
|
|
252
|
+
// endpoints a rejected request extends the penalty.
|
|
253
|
+
const cooling = cooldownFor(host);
|
|
254
|
+
if (cooling) {
|
|
255
|
+
return fail(
|
|
256
|
+
marketplaceName,
|
|
257
|
+
"rate-limited",
|
|
258
|
+
describeCooldown(cooling),
|
|
259
|
+
429,
|
|
260
|
+
cooling.until,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
// Fetch marketplace.json from GitHub
|
|
266
|
+
const url = `https://raw.githubusercontent.com/${repo}/main/.claude-plugin/marketplace.json`;
|
|
267
|
+
const response = await fetch(url, {
|
|
268
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (!response.ok) {
|
|
272
|
+
// 429/403 are the rate-limit shapes GitHub returns for unauthenticated
|
|
273
|
+
// traffic. Called out separately because the fix is "wait, or
|
|
274
|
+
// authenticate", not "check your network".
|
|
275
|
+
const rateLimited = response.status === 429 || response.status === 403;
|
|
276
|
+
if (rateLimited) {
|
|
277
|
+
// Hand the response headers over: api.github.com states when the window
|
|
278
|
+
// reopens, raw.githubusercontent.com states nothing, and the budget
|
|
279
|
+
// module is what knows the difference.
|
|
280
|
+
const cooldown = recordRateLimit(host, response.headers);
|
|
281
|
+
return fail(
|
|
282
|
+
marketplaceName,
|
|
283
|
+
"rate-limited",
|
|
284
|
+
describeCooldown(cooldown),
|
|
285
|
+
response.status,
|
|
286
|
+
cooldown.until,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return fail(
|
|
290
|
+
marketplaceName,
|
|
291
|
+
"http",
|
|
292
|
+
`HTTP ${response.status} ${response.statusText}`.trim(),
|
|
293
|
+
response.status,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Validate content-type
|
|
298
|
+
const contentType = response.headers.get("content-type");
|
|
299
|
+
if (
|
|
300
|
+
contentType &&
|
|
301
|
+
!contentType.includes("application/json") &&
|
|
302
|
+
!contentType.includes("text/plain")
|
|
303
|
+
) {
|
|
304
|
+
return fail(
|
|
305
|
+
marketplaceName,
|
|
306
|
+
"invalid-content",
|
|
307
|
+
`unexpected content-type "${contentType}"`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const plugins = normalizeCatalogJson(
|
|
312
|
+
marketplaceName,
|
|
313
|
+
await response.json(),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// Cache the result (session-level)
|
|
317
|
+
marketplaceCache.set(marketplaceName, plugins);
|
|
318
|
+
// A success retires any recorded failure, so the banner clears itself.
|
|
319
|
+
failureCache.delete(marketplaceName);
|
|
320
|
+
recordSuccess(host);
|
|
321
|
+
|
|
322
|
+
return { plugins };
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return classifyThrown(marketplaceName, error);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolve marketplace plugins with offline fallback.
|
|
330
|
+
*
|
|
331
|
+
* Calls `fetchMarketplacePlugins` (which hits GitHub) and, when it returns
|
|
332
|
+
* empty AND a local marketplace cache exists for the same name, falls back
|
|
333
|
+
* to the cached plugin manifest. This prevents every installed plugin from
|
|
334
|
+
* being flagged `isOrphaned: true` (and rendered "deprecated") whenever the
|
|
335
|
+
* machine can't reach `raw.githubusercontent.com` — the local cache already
|
|
336
|
+
* has the data, it just wasn't being consulted as a fallback.
|
|
337
|
+
*
|
|
338
|
+
* The two shapes are nearly identical; we drop a couple of LocalMarketplacePlugin
|
|
339
|
+
* fields (lspServers, agents, etc.) that MarketplacePlugin doesn't carry
|
|
340
|
+
* since the caller doesn't read them at this layer.
|
|
341
|
+
*
|
|
342
|
+
* The fallback stays — being offline should not make every plugin read as
|
|
343
|
+
* deprecated — but the return value now says WHICH source answered. The clone is
|
|
344
|
+
* a usable catalog, not an authoritative one: nothing refreshes it when a
|
|
345
|
+
* marketplace carries `autoUpdate: false`, so its versions can be arbitrarily
|
|
346
|
+
* old. A caller that reports "up to date" off a `local-clone` answer is
|
|
347
|
+
* reporting a guess as a fact.
|
|
348
|
+
*/
|
|
349
|
+
export async function resolveMarketplacePlugins(
|
|
350
|
+
mpName: string,
|
|
351
|
+
repo: string,
|
|
352
|
+
localMarketplaces: Map<string, LocalMarketplace>,
|
|
353
|
+
): Promise<MarketplaceResolution> {
|
|
354
|
+
// Disk cache first, so a relaunch costs no network at all. Measured before this
|
|
355
|
+
// existed: every process spent six requests and ~13s re-fetching catalogs it had
|
|
356
|
+
// already read minutes earlier.
|
|
357
|
+
const cached = await readCachedCatalog(mpName);
|
|
358
|
+
if (cached) {
|
|
359
|
+
return {
|
|
360
|
+
plugins: cached.plugins,
|
|
361
|
+
source: cached.source as CatalogSource,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const remote = await fetchMarketplacePlugins(mpName, repo);
|
|
366
|
+
if (remote.plugins.length > 0) {
|
|
367
|
+
void writeCachedCatalog(mpName, remote.plugins, "remote");
|
|
368
|
+
return { plugins: remote.plugins, source: "remote" };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// HTTP failed — most often rate-limited. Before falling back to the pinned
|
|
372
|
+
// working tree, ask git for upstream's catalog. `git fetch` moves only remote
|
|
373
|
+
// refs, so this respects `autoUpdate: false` (the install source stays pinned)
|
|
374
|
+
// while still answering "what version exists upstream". It is also on a
|
|
375
|
+
// different transport, so a raw.githubusercontent.com rate limit does not
|
|
376
|
+
// affect it.
|
|
377
|
+
const viaGit = await readCatalogFromRemote(mpName);
|
|
378
|
+
if (viaGit && viaGit.length > 0) {
|
|
379
|
+
void writeCachedCatalog(mpName, viaGit, "remote-git");
|
|
380
|
+
return { plugins: viaGit, source: "remote-git", failure: remote.failure };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const local = localMarketplaces.get(mpName);
|
|
384
|
+
if (!local || local.plugins.length === 0) {
|
|
385
|
+
return { plugins: [], source: "none", failure: remote.failure };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
plugins: local.plugins.map((p) => ({
|
|
390
|
+
name: p.name,
|
|
391
|
+
version: p.version,
|
|
392
|
+
description: p.description,
|
|
393
|
+
category: p.category,
|
|
394
|
+
author: p.author,
|
|
395
|
+
releases: p.releases,
|
|
396
|
+
})),
|
|
397
|
+
source: "local-clone",
|
|
398
|
+
failure: remote.failure,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep on-disk marketplace clones current — safely.
|
|
3
|
+
*
|
|
4
|
+
* The plugin *list* magus shows is fetched fresh from GitHub, but a plugin's
|
|
5
|
+
* `setup` block and Claude Code's own installer read the on-disk marketplace
|
|
6
|
+
* clone (~/.claude/plugins/marketplaces/<name>). Nothing else keeps that clone
|
|
7
|
+
* current — recoverMarketplaceSettings only pulls entries it had to re-register —
|
|
8
|
+
* so a healthy marketplace's clone goes stale and its plugin metadata (e.g. a
|
|
9
|
+
* pinned dependency version in a plugin's setup) is never seen, even while the
|
|
10
|
+
* displayed version looks up to date.
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT: this refreshes the clone with a NON-DESTRUCTIVE `git pull --ff-only`,
|
|
13
|
+
* never `claude plugin marketplace update`. The latter (cacheMarketplaceFromGit)
|
|
14
|
+
* deletes the clone directory before re-cloning, so a failed clone leaves the
|
|
15
|
+
* marketplace permanently deleted — a documented hazard this codebase deliberately
|
|
16
|
+
* avoids (see marketplace-sync.ts and ai-docs/plugin-marketplace-bug-investigation.md).
|
|
17
|
+
* A failed `git pull --ff-only` leaves the clone exactly as it was: stale, but intact.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
getConfiguredMarketplaces,
|
|
25
|
+
getGlobalClaudeDir,
|
|
26
|
+
getMarketplaceAutoUpdate,
|
|
27
|
+
} from "./claude-settings.js";
|
|
28
|
+
|
|
29
|
+
/** Per-git-invocation timeout. A hung pull must never block launching Claude. */
|
|
30
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
export interface RefreshResult {
|
|
33
|
+
/** Clones fast-forwarded to a newer commit (or already current). */
|
|
34
|
+
refreshed: string[];
|
|
35
|
+
/** Pull errored (network, diverged, timeout) — clone left intact and stale. */
|
|
36
|
+
failed: string[];
|
|
37
|
+
/** Nothing to do: no clone on disk, not a git repo, or dirty tree. */
|
|
38
|
+
skipped: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cloneDir(name: string): string {
|
|
42
|
+
return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`. */
|
|
46
|
+
function runGit(
|
|
47
|
+
cwd: string,
|
|
48
|
+
args: string[],
|
|
49
|
+
timeoutMs: number,
|
|
50
|
+
): Promise<string> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const proc = spawn("git", args, {
|
|
53
|
+
cwd,
|
|
54
|
+
timeout: timeoutMs,
|
|
55
|
+
killSignal: "SIGKILL",
|
|
56
|
+
});
|
|
57
|
+
let stdout = "";
|
|
58
|
+
let stderr = "";
|
|
59
|
+
proc.stdout?.on("data", (b) => {
|
|
60
|
+
stdout += b.toString();
|
|
61
|
+
});
|
|
62
|
+
proc.stderr?.on("data", (b) => {
|
|
63
|
+
stderr += b.toString();
|
|
64
|
+
});
|
|
65
|
+
proc.on("error", reject);
|
|
66
|
+
proc.on("close", (code, signal) => {
|
|
67
|
+
if (code === 0) {
|
|
68
|
+
resolve(stdout);
|
|
69
|
+
} else {
|
|
70
|
+
reject(
|
|
71
|
+
new Error(
|
|
72
|
+
`git ${args.join(" ")} ${
|
|
73
|
+
signal ? `killed (${signal})` : `exited ${code}`
|
|
74
|
+
}: ${stderr.trim()}`,
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True if the clone has uncommitted changes we must not clobber. */
|
|
83
|
+
async function isDirty(dir: string): Promise<boolean> {
|
|
84
|
+
const out = await runGit(dir, ["status", "--porcelain"], GIT_TIMEOUT_MS);
|
|
85
|
+
return out.trim().length > 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Outcome of fast-forwarding one clone:
|
|
90
|
+
* - `absent` no clone on disk (caller may create it via `marketplace add`)
|
|
91
|
+
* - `skipped` dirty tree — left untouched so local edits survive
|
|
92
|
+
* - `refreshed` fast-forwarded, or already current
|
|
93
|
+
* - `failed` pull errored (network, diverged, timeout) — clone left intact
|
|
94
|
+
*/
|
|
95
|
+
export type CloneRefresh = "absent" | "skipped" | "refreshed" | "failed";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Non-destructively fast-forward ONE marketplace clone. Never throws, never
|
|
99
|
+
* deletes: the worst case is `failed` (stale clone left intact). Shared by the
|
|
100
|
+
* batch refresh below and by claude-cli's "plugin not found" recovery, so both
|
|
101
|
+
* paths get the same safe semantics instead of the old `marketplace update`.
|
|
102
|
+
*/
|
|
103
|
+
export async function fastForwardCloneIfPresent(
|
|
104
|
+
name: string,
|
|
105
|
+
): Promise<CloneRefresh> {
|
|
106
|
+
const dir = cloneDir(name);
|
|
107
|
+
// No clone on disk (claude never installed it) or not a git repo.
|
|
108
|
+
if (!fs.existsSync(path.join(dir, ".git"))) return "absent";
|
|
109
|
+
try {
|
|
110
|
+
// Never clobber a user's local edits to the clone.
|
|
111
|
+
if (await isDirty(dir)) return "skipped";
|
|
112
|
+
// --ff-only is the safety guarantee: it fetches and fast-forwards, and
|
|
113
|
+
// aborts without mutating the tree if it can't. It never deletes the dir,
|
|
114
|
+
// so a failure degrades to "stale clone", never "deleted marketplace".
|
|
115
|
+
await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS);
|
|
116
|
+
return "refreshed";
|
|
117
|
+
} catch {
|
|
118
|
+
return "failed";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Progress taps, so a caller can show what is happening during the pulls.
|
|
124
|
+
*
|
|
125
|
+
* This is the slowest thing on the update path — a git fetch per marketplace,
|
|
126
|
+
* over the network, with a 30s timeout each — and it used to report only after
|
|
127
|
+
* every clone had settled.
|
|
128
|
+
*
|
|
129
|
+
* Both callbacks are purely observational and CANNOT affect the refresh: each
|
|
130
|
+
* is invoked inside a try/catch that discards whatever it throws. `onSettled`
|
|
131
|
+
* in particular runs inside the `Promise.all` fan-out, so an unguarded throw
|
|
132
|
+
* there would reject the batch and turn a display error into a failed refresh —
|
|
133
|
+
* exactly the coupling this function's "never throws" contract exists to
|
|
134
|
+
* prevent.
|
|
135
|
+
*/
|
|
136
|
+
export interface RefreshProgress {
|
|
137
|
+
/** The eligible set, once selection is done and before any pull starts. */
|
|
138
|
+
onStart?: (names: string[]) => void;
|
|
139
|
+
/** One clone settled. Fires in completion order, not in `names` order. */
|
|
140
|
+
onSettled?: (name: string, status: CloneRefresh) => void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Invoke an observer without letting it affect the caller. */
|
|
144
|
+
function tap(fn: (() => void) | undefined): void {
|
|
145
|
+
if (!fn) return;
|
|
146
|
+
try {
|
|
147
|
+
fn();
|
|
148
|
+
} catch {
|
|
149
|
+
// A progress display is never worth failing a refresh over.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Non-destructively fast-forward the on-disk clone of every registered,
|
|
155
|
+
* auto-update-enabled, github-sourced marketplace.
|
|
156
|
+
*
|
|
157
|
+
* @param skip marketplaces already refreshed this run (e.g. re-registered ones),
|
|
158
|
+
* so they aren't pulled twice.
|
|
159
|
+
* @param progress optional taps for a live progress display.
|
|
160
|
+
* @returns which marketplaces were refreshed, which failed (clone left intact),
|
|
161
|
+
* and which were skipped. Failures and skips are collected rather than thrown —
|
|
162
|
+
* one unreachable marketplace must not block launching Claude. Pulls run
|
|
163
|
+
* concurrently, so a slow one doesn't serialize the rest onto the launch path.
|
|
164
|
+
*/
|
|
165
|
+
/**
|
|
166
|
+
* Select and report, writing nothing.
|
|
167
|
+
*
|
|
168
|
+
* Everything above the pull loop is a read — the marketplace registry and each
|
|
169
|
+
* entry's `autoUpdate` flag — so a read-only caller can have the full selection
|
|
170
|
+
* verdict for free. `--dry-run` and `--check` promise to write nothing and
|
|
171
|
+
* therefore cannot pull, but they must still be able to say "your magus clone
|
|
172
|
+
* has autoUpdate off, so its catalog will never advance". Gating the whole call
|
|
173
|
+
* off silently dropped that warning, which is the same "no signal reads as no
|
|
174
|
+
* problem" failure the flag exists to prevent.
|
|
175
|
+
*/
|
|
176
|
+
export async function reportRegisteredMarketplaces(
|
|
177
|
+
skip: Iterable<string> = [],
|
|
178
|
+
): Promise<RefreshResult> {
|
|
179
|
+
return refreshRegisteredMarketplaces(skip, {}, { reportOnly: true });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function refreshRegisteredMarketplaces(
|
|
183
|
+
skip: Iterable<string> = [],
|
|
184
|
+
progress: RefreshProgress = {},
|
|
185
|
+
options: { reportOnly?: boolean } = {},
|
|
186
|
+
): Promise<RefreshResult> {
|
|
187
|
+
const refreshed: string[] = [];
|
|
188
|
+
const failed: string[] = [];
|
|
189
|
+
const skipped: string[] = [];
|
|
190
|
+
|
|
191
|
+
const configured = await getConfiguredMarketplaces();
|
|
192
|
+
const skipSet = new Set(skip);
|
|
193
|
+
|
|
194
|
+
// Select eligible marketplaces first so the pulls can run concurrently.
|
|
195
|
+
const eligible: string[] = [];
|
|
196
|
+
for (const [name, cfg] of Object.entries(configured)) {
|
|
197
|
+
if (skipSet.has(name)) continue;
|
|
198
|
+
if (cfg.source?.source !== "github" || !cfg.source?.repo) {
|
|
199
|
+
skipped.push(name);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// Claude Code's own refresh policy. Honour it by not pulling, and say
|
|
203
|
+
// nothing: the version answer does not come from this clone.
|
|
204
|
+
// `readCatalogFromRemote` reads `origin/<branch>` with fetch + show, never
|
|
205
|
+
// the working tree — measured, a clone holding dev 7.4.0 with autoUpdate
|
|
206
|
+
// off still reported 7.4.1 available.
|
|
207
|
+
if ((await getMarketplaceAutoUpdate(name)) === false) {
|
|
208
|
+
skipped.push(name);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
eligible.push(name);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A read-only caller stops here, with the selection verdict intact and not
|
|
215
|
+
// one clone touched. `eligible` becomes `skipped`: nothing was refreshed, so
|
|
216
|
+
// claiming otherwise would be the false "all current" this module guards.
|
|
217
|
+
if (options.reportOnly) {
|
|
218
|
+
return {
|
|
219
|
+
refreshed: [],
|
|
220
|
+
failed: [],
|
|
221
|
+
skipped: [...skipped, ...eligible],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Fast-forward every eligible clone in parallel so a slow one doesn't
|
|
226
|
+
// serialize the rest onto the launch path. fastForwardCloneIfPresent never
|
|
227
|
+
// throws, so the batch always resolves.
|
|
228
|
+
tap(() => progress.onStart?.([...eligible]));
|
|
229
|
+
const statuses = await Promise.all(
|
|
230
|
+
eligible.map(async (name) => {
|
|
231
|
+
const status = await fastForwardCloneIfPresent(name);
|
|
232
|
+
tap(() => progress.onSettled?.(name, status));
|
|
233
|
+
return status;
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
statuses.forEach((status, i) => {
|
|
238
|
+
const name = eligible[i];
|
|
239
|
+
if (status === "refreshed") refreshed.push(name);
|
|
240
|
+
else if (status === "failed") failed.push(name);
|
|
241
|
+
else skipped.push(name); // absent | skipped
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return { refreshed, failed, skipped };
|
|
245
|
+
}
|