magus-cli 0.0.0-placeholder → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +445 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +335 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/moved-marketplace.test.ts +86 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1373 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1589 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +587 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +253 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +878 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +344 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +863 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +538 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1464 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +352 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +144 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory types and defaults for the managed `claude` alias.
|
|
3
|
+
*
|
|
4
|
+
* Persistence has moved:
|
|
5
|
+
* - Alias name lives in Claude Code's `settings.json` under `magus.aliasName`
|
|
6
|
+
* (read/written via `alias-settings.ts`).
|
|
7
|
+
* - Flag values are read from the user's shell rc file on screen mount
|
|
8
|
+
* (via `parseAliasFromRc` in `alias-shell-writer.ts`). Edits stay in
|
|
9
|
+
* memory until the user presses `w` to write the rc file.
|
|
10
|
+
*
|
|
11
|
+
* This module is filesystem-free so it can be imported by tests and pure
|
|
12
|
+
* logic without dragging in `claude-settings.ts`'s module-level `os.homedir()`
|
|
13
|
+
* constants.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ALIAS_FLAGS, type AliasFlag } from "../data/alias-flags.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per-flag value shapes. Discriminator is `kind`, mirroring `AliasFlag.kind`.
|
|
20
|
+
*/
|
|
21
|
+
export type FlagValue =
|
|
22
|
+
| { kind: "boolean"; enabled: boolean }
|
|
23
|
+
| { kind: "tri-state"; state: "unset" | "on" | "off" }
|
|
24
|
+
| { kind: "select"; enabled: boolean; value: string }
|
|
25
|
+
| { kind: "text"; enabled: boolean; value: string }
|
|
26
|
+
| { kind: "optional-text"; enabled: boolean; value: string }
|
|
27
|
+
| { kind: "text-list"; enabled: boolean; values: string[] }
|
|
28
|
+
| {
|
|
29
|
+
kind: "multi-with-custom";
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
picked: string[];
|
|
32
|
+
custom: string[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* In-memory alias configuration. No `version` field since this isn't
|
|
37
|
+
* persisted as-is anymore — the alias name is stored in `settings.json`
|
|
38
|
+
* and flag values come from the rc file.
|
|
39
|
+
*/
|
|
40
|
+
export interface AliasConfig {
|
|
41
|
+
/**
|
|
42
|
+
* Name of the shell alias. Default `"c"` so users can keep typing the
|
|
43
|
+
* vanilla `claude` command alongside the wrapped one.
|
|
44
|
+
*/
|
|
45
|
+
aliasName: string;
|
|
46
|
+
/** Per-flag state, keyed by `AliasFlag.id`. */
|
|
47
|
+
flags: Record<string, FlagValue>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Default alias name. POSIX/fish-safe, short, and not in conflict with `claude`. */
|
|
51
|
+
export const DEFAULT_ALIAS_NAME = "c";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate a candidate alias name. Returns null when valid, or a human
|
|
55
|
+
* reason when invalid.
|
|
56
|
+
*
|
|
57
|
+
* We refuse anything that isn't `[A-Za-z_][A-Za-z0-9_-]*` — that's the
|
|
58
|
+
* intersection of POSIX `name` rules and fish's `alias NAME …` accepting
|
|
59
|
+
* set, and avoids the need to quote the name on either side of the `=`.
|
|
60
|
+
*/
|
|
61
|
+
export function validateAliasName(name: string): string | null {
|
|
62
|
+
if (name.length === 0) return "Alias name cannot be empty.";
|
|
63
|
+
if (name.length > 32) return "Alias name is unreasonably long (>32 chars).";
|
|
64
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)) {
|
|
65
|
+
return "Alias name must start with a letter or underscore and contain only letters, digits, _ or -.";
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the default in-memory config. Used when no managed block exists in
|
|
72
|
+
* the rc file (every flag disabled, default alias name).
|
|
73
|
+
*/
|
|
74
|
+
export function defaultAliasConfig(): AliasConfig {
|
|
75
|
+
const flags: Record<string, FlagValue> = {};
|
|
76
|
+
for (const flag of ALIAS_FLAGS) {
|
|
77
|
+
flags[flag.id] = defaultValueFor(flag);
|
|
78
|
+
}
|
|
79
|
+
return { aliasName: DEFAULT_ALIAS_NAME, flags };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Channels ⊇ dev-load (derive-on-display) ───────────────────────────
|
|
83
|
+
//
|
|
84
|
+
// `--channels` is the approved-channel allowlist; `--dangerously-load-
|
|
85
|
+
// development-channels` sideloads channels for local dev. A dev-loaded
|
|
86
|
+
// channel must also run, so at the COMMAND LINE the channels list is the
|
|
87
|
+
// union of both. We do NOT store that union — provenance would be lost
|
|
88
|
+
// (you couldn't tell an own channel from a derived one). Instead:
|
|
89
|
+
// - `channels.values` holds the user's OWN approved channels only.
|
|
90
|
+
// - The dev-load flag is the single source of truth for derived channels.
|
|
91
|
+
// - The editor shows them as a separate read-only section (display).
|
|
92
|
+
// - The writer unions `own ∪ dev-load(if enabled)` at RENDER time.
|
|
93
|
+
// This keeps the two lists distinct everywhere except the emitted alias.
|
|
94
|
+
|
|
95
|
+
/** Id of the channels allowlist flag. */
|
|
96
|
+
export const CHANNELS_FLAG_ID = "channels";
|
|
97
|
+
/** Id of the dev-load flag whose values are derived into channels. */
|
|
98
|
+
export const DEVLOAD_FLAG_ID = "dangerously-load-development-channels";
|
|
99
|
+
|
|
100
|
+
/** Read the value list off a flag value, or `[]` for non-list kinds. */
|
|
101
|
+
export function listValues(v: FlagValue | undefined): string[] {
|
|
102
|
+
return v && v.kind === "text-list" ? v.values : [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Channels that `--channels` derives from the dev-load flag for DISPLAY and
|
|
107
|
+
* for the render-time union: the dev-load flag's values, but only when that
|
|
108
|
+
* flag is enabled (a disabled dev-load contributes nothing to the alias).
|
|
109
|
+
*/
|
|
110
|
+
export function derivedChannelValues(
|
|
111
|
+
flags: Record<string, FlagValue>,
|
|
112
|
+
): string[] {
|
|
113
|
+
const devload = flags[DEVLOAD_FLAG_ID];
|
|
114
|
+
if (!devload || devload.kind !== "text-list" || !devload.enabled) return [];
|
|
115
|
+
return devload.values;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Return a new flags map with `value` removed from the dev-load flag (the
|
|
120
|
+
* SOURCE of derived channels). Used when the user deletes a "Dev-loaded
|
|
121
|
+
* channels" row inside the --channels editor: the deletion must land on the
|
|
122
|
+
* source flag, never on `channels.values`. Disables dev-load when emptied so
|
|
123
|
+
* it stops emitting. Pure — leaves `channels.values` untouched.
|
|
124
|
+
*/
|
|
125
|
+
export function withoutDerivedChannel(
|
|
126
|
+
flags: Record<string, FlagValue>,
|
|
127
|
+
value: string,
|
|
128
|
+
): Record<string, FlagValue> {
|
|
129
|
+
const cur = flags[DEVLOAD_FLAG_ID];
|
|
130
|
+
if (!cur || cur.kind !== "text-list") return flags;
|
|
131
|
+
const values = cur.values.filter((v) => v !== value);
|
|
132
|
+
return {
|
|
133
|
+
...flags,
|
|
134
|
+
[DEVLOAD_FLAG_ID]: {
|
|
135
|
+
kind: "text-list",
|
|
136
|
+
enabled: values.length > 0 && cur.enabled,
|
|
137
|
+
values,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function defaultValueFor(flag: AliasFlag): FlagValue {
|
|
143
|
+
switch (flag.kind) {
|
|
144
|
+
case "boolean":
|
|
145
|
+
return { kind: "boolean", enabled: false };
|
|
146
|
+
case "tri-state":
|
|
147
|
+
return { kind: "tri-state", state: "unset" };
|
|
148
|
+
case "select":
|
|
149
|
+
return {
|
|
150
|
+
kind: "select",
|
|
151
|
+
enabled: false,
|
|
152
|
+
value: flag.options?.[0]?.value ?? "",
|
|
153
|
+
};
|
|
154
|
+
case "text":
|
|
155
|
+
return { kind: "text", enabled: false, value: "" };
|
|
156
|
+
case "optional-text":
|
|
157
|
+
return { kind: "optional-text", enabled: false, value: "" };
|
|
158
|
+
case "text-list":
|
|
159
|
+
return {
|
|
160
|
+
kind: "text-list",
|
|
161
|
+
enabled: false,
|
|
162
|
+
values: [...(flag.defaultValues ?? [])],
|
|
163
|
+
};
|
|
164
|
+
case "multi-with-custom":
|
|
165
|
+
return {
|
|
166
|
+
kind: "multi-with-custom",
|
|
167
|
+
enabled: false,
|
|
168
|
+
picked: [],
|
|
169
|
+
custom: [...(flag.defaultValues ?? [])],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-sign a compiled magus binary, and refuse to ship one that does not
|
|
3
|
+
* verify.
|
|
4
|
+
*
|
|
5
|
+
* `bun build --compile` produces a Mach-O carrying an ad-hoc, linker-signed
|
|
6
|
+
* signature (`flags=0x20002(adhoc,linker-signed)`) and then appends the
|
|
7
|
+
* JavaScript bundle to the end of the file. The signature therefore describes
|
|
8
|
+
* a shorter file than the one that ships. `codesign -v` has reported
|
|
9
|
+
* "invalid signature (code or signature have been modified)" on every
|
|
10
|
+
* published magus binary back to 4.22.0.
|
|
11
|
+
*
|
|
12
|
+
* macOS 27 refuses to exec such a binary: SIGKILL at startup, no output. The
|
|
13
|
+
* build never signed anything, so nothing caught it — `codesign` appeared
|
|
14
|
+
* nowhere in this package.
|
|
15
|
+
*
|
|
16
|
+
* Signing is ad-hoc (`--sign -`) rather than Developer ID on purpose: files
|
|
17
|
+
* installed by npm are not quarantined, so Gatekeeper never evaluates them.
|
|
18
|
+
* The kernel's signature check is the only gate that was failing, and an
|
|
19
|
+
* ad-hoc signature satisfies it — with no Apple account, no CI secrets and no
|
|
20
|
+
* notarization wait.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
/** Result of one external command. Injected so the logic is testable. */
|
|
26
|
+
export type Run = (
|
|
27
|
+
cmd: string,
|
|
28
|
+
args: string[],
|
|
29
|
+
) => { status: number | null; stderr: string };
|
|
30
|
+
|
|
31
|
+
const realRun: Run = (cmd, args) => {
|
|
32
|
+
const result = spawnSync(cmd, args, { encoding: "utf8" });
|
|
33
|
+
// Fail closed and say why. A missing `codesign` must stop the build, not
|
|
34
|
+
// quietly skip signing — and the raw ENOENT names neither the binary being
|
|
35
|
+
// signed nor the reason signing is mandatory.
|
|
36
|
+
if (result.error) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Could not run \`${cmd}\` while signing ${args[args.length - 1]}: ${result.error.message}\nSigning is mandatory for darwin binaries — macOS kills an unsigned one at exec.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return { status: result.status, stderr: result.stderr ?? "" };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Only darwin. `codesign` does not exist elsewhere, and no other platform
|
|
46
|
+
* validates a signature at exec.
|
|
47
|
+
*/
|
|
48
|
+
export const needsSigning = (target: { os: string }): boolean =>
|
|
49
|
+
target.os === "darwin";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sign the binary, then verify it. A failing verify THROWS.
|
|
53
|
+
*
|
|
54
|
+
* This is a gate, not an advisory. The whole defect being fixed is a release
|
|
55
|
+
* that shipped a binary whose signature did not match it; a warning here would
|
|
56
|
+
* reproduce that exactly, one log line louder.
|
|
57
|
+
*/
|
|
58
|
+
export function signAndVerify(
|
|
59
|
+
target: { os: string },
|
|
60
|
+
binPath: string,
|
|
61
|
+
run: Run = realRun,
|
|
62
|
+
): void {
|
|
63
|
+
if (!needsSigning(target)) return;
|
|
64
|
+
|
|
65
|
+
const signed = run("codesign", ["--force", "--sign", "-", binPath]);
|
|
66
|
+
if (signed.status !== 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`codesign --sign failed for ${binPath} (exit ${signed.status})\n${signed.stderr}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const verified = run("codesign", ["--verify", "--strict", binPath]);
|
|
73
|
+
if (verified.status !== 0) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`codesign --verify --strict rejected ${binPath} (exit ${verified.status}).\nRefusing to ship a binary macOS will kill at exec.\n${verified.stderr}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catalog-cache-store.ts — the catalog cache and the rate-limit cooldown, on disk.
|
|
3
|
+
*
|
|
4
|
+
* Why on disk
|
|
5
|
+
* -----------
|
|
6
|
+
* Every cache in this area used to be a module-level `Map`, which for a TUI means
|
|
7
|
+
* "cleared on every launch". Measured 2026-08-17, two consecutive processes:
|
|
8
|
+
*
|
|
9
|
+
* [process-1] 6 HTTP requests in 13.32s
|
|
10
|
+
* [process-2] 6 HTTP requests in 13.01s
|
|
11
|
+
*
|
|
12
|
+
* So each launch re-fetched all six catalogs, and the second process had no idea
|
|
13
|
+
* the first had just been rate-limited — it spent six more requests rediscovering
|
|
14
|
+
* that. On some endpoints a rejected request extends the penalty, so an in-memory
|
|
15
|
+
* cooldown is not merely useless across launches, it is counterproductive.
|
|
16
|
+
*
|
|
17
|
+
* Persisting the cooldown is the more important half. A stale catalog costs the
|
|
18
|
+
* user a version number that is an hour old; re-firing six doomed requests on
|
|
19
|
+
* every launch costs them the ability to check at all.
|
|
20
|
+
*
|
|
21
|
+
* Trade-off, stated
|
|
22
|
+
* -----------------
|
|
23
|
+
* A cached catalog means a release published inside the TTL is not visible yet.
|
|
24
|
+
* That is why the TTL is short, why `r` forces a real refetch, and why the age is
|
|
25
|
+
* available for display. A cache the user cannot see through or override would be
|
|
26
|
+
* the same trap as a frozen clone.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { promises as fs } from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { claudeConfigDirOrNull } from "../utils/config-dir.js";
|
|
32
|
+
import { withFileLock } from "../utils/file-locking.js";
|
|
33
|
+
import type { MarketplacePlugin } from "./marketplace-catalog.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How long a cached catalog is served before we ask again.
|
|
37
|
+
*
|
|
38
|
+
* This is the ONLY refresh rhythm magus has, and it is why the TUI and
|
|
39
|
+
* `magus update` can disagree about the same plugin for up to an hour:
|
|
40
|
+
* `update` fast-forwards the clones and clears this cache before planning,
|
|
41
|
+
* while the TUI serves whatever is here. That is not a bug in either path, but
|
|
42
|
+
* anything that reports "no update" off a cached catalog has to say the answer
|
|
43
|
+
* is up to an hour old.
|
|
44
|
+
*/
|
|
45
|
+
export const CATALOG_TTL_MS = 60 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
interface StoredCatalog {
|
|
48
|
+
fetchedAt: number;
|
|
49
|
+
/** Which transport answered — carried so the UI can still say "unverified". */
|
|
50
|
+
source: string;
|
|
51
|
+
plugins: MarketplacePlugin[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface StoredCooldown {
|
|
55
|
+
until: number;
|
|
56
|
+
exact: boolean;
|
|
57
|
+
strikes: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What the last upstream check learned about one community-styles repository.
|
|
62
|
+
*
|
|
63
|
+
* Lives here rather than in a fourth cache file so it inherits this store's file
|
|
64
|
+
* lock, atomic write and CLAUDE_CONFIG_DIR test isolation — and, more to the
|
|
65
|
+
* point, so it sits beside the rate-limit cooldowns it is budgeted against.
|
|
66
|
+
*
|
|
67
|
+
* `dirHeadSha` is the pivot of the two-phase check: one API call per REPO
|
|
68
|
+
* answers "did anything in its styles directory move", and only a repo that
|
|
69
|
+
* moved is drilled into with free raw fetches. Per-style API calls would spend
|
|
70
|
+
* the machine's whole hourly budget on first use.
|
|
71
|
+
*/
|
|
72
|
+
export interface StoredCommunityCheck {
|
|
73
|
+
/** Wall-clock ms of the last successful phase A. Drives the 24h TTL. */
|
|
74
|
+
checkedAt: number;
|
|
75
|
+
/** Latest commit touching the repo's styles directory, as of that check. */
|
|
76
|
+
dirHeadSha: string;
|
|
77
|
+
/** Per coordinate id: the bytes we hold, and any update sitting in .pending/. */
|
|
78
|
+
styles: Record<
|
|
79
|
+
string,
|
|
80
|
+
{
|
|
81
|
+
sha256: string;
|
|
82
|
+
pendingSha256?: string;
|
|
83
|
+
/** Changed-line count of that pending update, so it survives a restart. */
|
|
84
|
+
pendingLines?: number;
|
|
85
|
+
}
|
|
86
|
+
>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface StoreShape {
|
|
90
|
+
version: 1;
|
|
91
|
+
catalogs: Record<string, StoredCatalog>;
|
|
92
|
+
cooldowns: Record<string, StoredCooldown>;
|
|
93
|
+
communityStyles: Record<string, StoredCommunityCheck>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const EMPTY: StoreShape = {
|
|
97
|
+
version: 1,
|
|
98
|
+
catalogs: {},
|
|
99
|
+
cooldowns: {},
|
|
100
|
+
communityStyles: {},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolved per call, honouring CLAUDE_CONFIG_DIR — same reasoning as
|
|
105
|
+
* content-drift.ts and marketplace-catalog-git.ts.
|
|
106
|
+
*
|
|
107
|
+
* Null when a test has not chosen a config dir, which makes this whole store inert
|
|
108
|
+
* rather than letting an unisolated test read the operator's live cooldown and
|
|
109
|
+
* decide its own outcome from it. See utils/config-dir.ts.
|
|
110
|
+
*/
|
|
111
|
+
function storePath(): string | null {
|
|
112
|
+
const configDir = claudeConfigDirOrNull();
|
|
113
|
+
if (!configDir) return null;
|
|
114
|
+
return path.join(configDir, "magus-catalog-cache.json");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read-through memo for this process. The disk file is the cross-launch cache;
|
|
119
|
+
* this just avoids re-reading and re-parsing it on every lookup within one run.
|
|
120
|
+
*/
|
|
121
|
+
let memo: { path: string; data: StoreShape } | null = null;
|
|
122
|
+
|
|
123
|
+
async function load(): Promise<StoreShape> {
|
|
124
|
+
const file = storePath();
|
|
125
|
+
if (!file)
|
|
126
|
+
return { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
|
|
127
|
+
if (memo?.path === file) return memo.data;
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
|
|
130
|
+
// A cache from a future/unknown layout is discarded rather than migrated:
|
|
131
|
+
// it is regenerable by definition, so guessing at it buys nothing.
|
|
132
|
+
const data = parsed?.version === 1 ? parsed : { ...EMPTY };
|
|
133
|
+
data.catalogs ??= {};
|
|
134
|
+
data.cooldowns ??= {};
|
|
135
|
+
// Absent in every file written before community styles existed, so this
|
|
136
|
+
// default is the migration — an upgrade must not read as a corrupt cache.
|
|
137
|
+
data.communityStyles ??= {};
|
|
138
|
+
memo = { path: file, data };
|
|
139
|
+
return data;
|
|
140
|
+
} catch {
|
|
141
|
+
// Absent or corrupt. A cache must never be a failure mode — start empty.
|
|
142
|
+
const data: StoreShape = {
|
|
143
|
+
...EMPTY,
|
|
144
|
+
catalogs: {},
|
|
145
|
+
cooldowns: {},
|
|
146
|
+
communityStyles: {},
|
|
147
|
+
};
|
|
148
|
+
memo = { path: file, data };
|
|
149
|
+
return data;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Apply a mutation and persist it.
|
|
155
|
+
*
|
|
156
|
+
* Locked and re-read inside the lock, because two magus processes can be open
|
|
157
|
+
* at once and a blind read-modify-write would drop the other's cooldown — exactly
|
|
158
|
+
* the record we most need to keep.
|
|
159
|
+
*/
|
|
160
|
+
async function mutate(fn: (data: StoreShape) => void): Promise<void> {
|
|
161
|
+
const file = storePath();
|
|
162
|
+
if (!file) return;
|
|
163
|
+
try {
|
|
164
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
165
|
+
await withFileLock(file, async () => {
|
|
166
|
+
let onDisk: StoreShape;
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(
|
|
169
|
+
await fs.readFile(file, "utf-8"),
|
|
170
|
+
) as StoreShape;
|
|
171
|
+
// Every section is named explicitly: a key omitted here is silently
|
|
172
|
+
// dropped on the next write of any OTHER section.
|
|
173
|
+
onDisk =
|
|
174
|
+
parsed?.version === 1
|
|
175
|
+
? {
|
|
176
|
+
version: 1,
|
|
177
|
+
catalogs: parsed.catalogs ?? {},
|
|
178
|
+
cooldowns: parsed.cooldowns ?? {},
|
|
179
|
+
communityStyles: parsed.communityStyles ?? {},
|
|
180
|
+
}
|
|
181
|
+
: { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
|
|
182
|
+
} catch {
|
|
183
|
+
onDisk = {
|
|
184
|
+
version: 1,
|
|
185
|
+
catalogs: {},
|
|
186
|
+
cooldowns: {},
|
|
187
|
+
communityStyles: {},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
fn(onDisk);
|
|
191
|
+
// Temp + rename, so a crash mid-write cannot leave a half-written file
|
|
192
|
+
// that the next launch has to treat as corrupt.
|
|
193
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
194
|
+
await fs.writeFile(tmp, JSON.stringify(onDisk), "utf-8");
|
|
195
|
+
await fs.rename(tmp, file);
|
|
196
|
+
memo = { path: file, data: onDisk };
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
// Never let a cache write break the run. Worst case we re-fetch next time.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Catalogs ────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export interface CachedCatalog {
|
|
206
|
+
plugins: MarketplacePlugin[];
|
|
207
|
+
source: string;
|
|
208
|
+
/** Milliseconds since it was fetched — for display, and for the TTL test. */
|
|
209
|
+
ageMs: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A cached catalog for this marketplace, if one is present and still fresh. */
|
|
213
|
+
export async function readCachedCatalog(
|
|
214
|
+
marketplace: string,
|
|
215
|
+
now: number = Date.now(),
|
|
216
|
+
): Promise<CachedCatalog | null> {
|
|
217
|
+
const data = await load();
|
|
218
|
+
const entry = data.catalogs[marketplace];
|
|
219
|
+
if (!entry) return null;
|
|
220
|
+
const ageMs = now - entry.fetchedAt;
|
|
221
|
+
if (ageMs < 0 || ageMs > CATALOG_TTL_MS) return null;
|
|
222
|
+
return { plugins: entry.plugins, source: entry.source, ageMs };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function writeCachedCatalog(
|
|
226
|
+
marketplace: string,
|
|
227
|
+
plugins: MarketplacePlugin[],
|
|
228
|
+
source: string,
|
|
229
|
+
now: number = Date.now(),
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
// An empty catalog is never worth caching: it is indistinguishable from a
|
|
232
|
+
// failure we did not classify, and serving it would resurrect the original bug.
|
|
233
|
+
if (plugins.length === 0) return;
|
|
234
|
+
await mutate((data) => {
|
|
235
|
+
data.catalogs[marketplace] = { fetchedAt: now, source, plugins };
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Drop every cached catalog. Backs the explicit refresh (`r`). */
|
|
240
|
+
export async function clearCachedCatalogs(): Promise<void> {
|
|
241
|
+
await mutate((data) => {
|
|
242
|
+
data.catalogs = {};
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── Cooldowns ───────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
export async function readStoredCooldowns(
|
|
249
|
+
now: number = Date.now(),
|
|
250
|
+
): Promise<Record<string, StoredCooldown>> {
|
|
251
|
+
const data = await load();
|
|
252
|
+
const live: Record<string, StoredCooldown> = {};
|
|
253
|
+
for (const [host, cooldown] of Object.entries(data.cooldowns)) {
|
|
254
|
+
// Expired entries are dropped on read rather than persisted forever. The
|
|
255
|
+
// strike count goes with them: a cooldown that lapsed a day ago should not
|
|
256
|
+
// make today's first failure back off for ten minutes.
|
|
257
|
+
if (cooldown.until > now) live[host] = cooldown;
|
|
258
|
+
}
|
|
259
|
+
return live;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function writeStoredCooldown(
|
|
263
|
+
host: string,
|
|
264
|
+
cooldown: StoredCooldown,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
await mutate((data) => {
|
|
267
|
+
data.cooldowns[host] = cooldown;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function clearStoredCooldown(host: string): Promise<void> {
|
|
272
|
+
await mutate((data) => {
|
|
273
|
+
delete data.cooldowns[host];
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── Community style checks ──────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Every recorded upstream check, keyed by community source id.
|
|
281
|
+
*
|
|
282
|
+
* Deliberately NOT expired on read, unlike cooldowns. An expired cooldown means
|
|
283
|
+
* "you may call again"; an expired CHECK still carries the shas we compare
|
|
284
|
+
* against, and only its `checkedAt` has gone stale. Dropping it would throw away
|
|
285
|
+
* the one thing that makes the next check cheap, and would make a 25-hour-old
|
|
286
|
+
* result indistinguishable from never having looked. Freshness is judged by the
|
|
287
|
+
* reader, which is what lets an expired check present as `unknown` rather than
|
|
288
|
+
* as up to date.
|
|
289
|
+
*/
|
|
290
|
+
export async function readCommunityChecks(): Promise<
|
|
291
|
+
Record<string, StoredCommunityCheck>
|
|
292
|
+
> {
|
|
293
|
+
return { ...(await load()).communityStyles };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function writeCommunityCheck(
|
|
297
|
+
sourceId: string,
|
|
298
|
+
check: StoredCommunityCheck,
|
|
299
|
+
): Promise<void> {
|
|
300
|
+
await mutate((data) => {
|
|
301
|
+
data.communityStyles ??= {};
|
|
302
|
+
data.communityStyles[sourceId] = check;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Drop every recorded check. Backs an explicit refresh. */
|
|
307
|
+
export async function clearCommunityChecks(): Promise<void> {
|
|
308
|
+
await mutate((data) => {
|
|
309
|
+
data.communityStyles = {};
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Test seam: forget this process's memo so the next read hits disk. */
|
|
314
|
+
export function resetCatalogCacheMemo(): void {
|
|
315
|
+
memo = null;
|
|
316
|
+
}
|