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
package/src/ui/theme.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { RGBA } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* magus's palette: our own vivid accents, on the terminal's own canvas.
|
|
5
|
+
*
|
|
6
|
+
* Three rules, and the reason for each:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Backgrounds are never ours.** Surfaces and modals use the terminal's
|
|
9
|
+
* background. Painting an absolute fill boxes the UI into whichever theme we
|
|
10
|
+
* guessed, and it was the original sin here — modals hardcoded #1C1C1E.
|
|
11
|
+
* 2. **Body text is never ours.** Normal text uses the terminal's foreground,
|
|
12
|
+
* which is the only colour guaranteed to clear a text-grade contrast ratio
|
|
13
|
+
* against that terminal's own background. `text` used to be the string
|
|
14
|
+
* `"white"` — OpenTUI resolves that to rgb(255,255,255), not to "the
|
|
15
|
+
* terminal's white" — so on a light theme every installed plugin was drawn
|
|
16
|
+
* white-on-cream and vanished while the *un*installed ones, in grey, stayed
|
|
17
|
+
* readable. The list read exactly backwards.
|
|
18
|
+
* 3. **Accents ARE ours, and vivid.** Deferring accents to ANSI slots 0-15 made
|
|
19
|
+
* everything legible but flattened it: a theme that renders slots 2 and 3 as
|
|
20
|
+
* neighbouring olives leaves user/project/local scope squares indistinguishable,
|
|
21
|
+
* and slot 8 chips melt into the page. Distinction is a product decision, so
|
|
22
|
+
* these are chosen, not inherited.
|
|
23
|
+
*
|
|
24
|
+
* ## How the accents were chosen
|
|
25
|
+
*
|
|
26
|
+
* By measurement, not taste. A single colour CANNOT reach 4.5:1 against both a
|
|
27
|
+
* light and a dark terminal — clearing 4.5:1 on cream needs relative luminance
|
|
28
|
+
* <= ~0.17, clearing it on near-black needs >= ~0.22, and those do not overlap.
|
|
29
|
+
* So the bar is 3:1, WCAG's threshold for UI components and large text, which
|
|
30
|
+
* admits a band of mid-dark saturated colours. Every accent below clears 3:1
|
|
31
|
+
* against BOTH reference backgrounds, verified in theme-adaptive-colors.test.ts —
|
|
32
|
+
* so a colour that only works on dark cannot be added without failing the build.
|
|
33
|
+
*
|
|
34
|
+
* Where we own a badge's background we also own its ink, so `ink` is plain white
|
|
35
|
+
* and its contrast is deterministic rather than a function of the user's theme.
|
|
36
|
+
* That is why badge ink is not slot 0 or the terminal background: on a light
|
|
37
|
+
* theme, terminal-background ink on a yellow chip is unreadable.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Reference backgrounds every accent is measured against. */
|
|
41
|
+
export const CONTRAST_REFERENCE = {
|
|
42
|
+
light: "#FAFAD2",
|
|
43
|
+
dark: "#1C1C1E",
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The vivid set. Ratios are (on light / on dark), measured — see the test.
|
|
48
|
+
* Hues for the three scopes are deliberately far apart (blue / green / amber)
|
|
49
|
+
* because they appear as single-character squares, where a subtle hue shift is
|
|
50
|
+
* not readable at all.
|
|
51
|
+
*/
|
|
52
|
+
export const brand = {
|
|
53
|
+
accent: "#9333EA", // 5.04 / 3.16 — brand purple
|
|
54
|
+
link: "#2563EB", // 4.84 / 3.29
|
|
55
|
+
info: "#0E7490", // 5.02 / 3.18 — teal
|
|
56
|
+
success: "#15803D", // 4.70 / 3.39
|
|
57
|
+
warning: "#B45309", // 4.70 / 3.39 — amber
|
|
58
|
+
danger: "#DC2626", // 4.52 / 3.52
|
|
59
|
+
muted: "#6B7280", // 4.53 / 3.52 — de-emphasis, still legible on both
|
|
60
|
+
/** Ink on OUR badges. We own both sides, so contrast is deterministic. */
|
|
61
|
+
ink: "#FFFFFF",
|
|
62
|
+
/**
|
|
63
|
+
* De-emphasised ink on the SELECTION fill, and nowhere else.
|
|
64
|
+
*
|
|
65
|
+
* Measured on a screenshot: `muted` (#6B7280) on the selection purple is
|
|
66
|
+
* 1.11:1, `warning` 1.07:1, `danger` 1.11:1 — every meta tone was invisible
|
|
67
|
+
* the moment its row was selected, and `(default)` on the Models screen
|
|
68
|
+
* disappeared entirely. The page-level greys cannot help here: they are
|
|
69
|
+
* chosen to sit between a cream and a near-black terminal, and the selection
|
|
70
|
+
* is neither.
|
|
71
|
+
*
|
|
72
|
+
* 3.64:1 on the selection fill, and 1.48:1 against `ink` — far enough from
|
|
73
|
+
* white to still read as secondary. Like `ink`, it is measured against a fill
|
|
74
|
+
* WE paint rather than against the terminal, so it is exempt from the
|
|
75
|
+
* both-backgrounds rule the accents above obey.
|
|
76
|
+
*/
|
|
77
|
+
selectionDim: "#D4D4D8",
|
|
78
|
+
} as const;
|
|
79
|
+
|
|
80
|
+
/** Any colour a component may hand to a JSX colour prop. */
|
|
81
|
+
export type UiColor = string | RGBA;
|
|
82
|
+
|
|
83
|
+
/** The terminal's own foreground — the only honest "normal text" colour. */
|
|
84
|
+
const fg = () => RGBA.defaultForeground();
|
|
85
|
+
|
|
86
|
+
/** The terminal's own background — for surfaces, never for ink. */
|
|
87
|
+
const bg = () => RGBA.defaultBackground();
|
|
88
|
+
|
|
89
|
+
export const theme = {
|
|
90
|
+
colors: {
|
|
91
|
+
/** Adaptive by rule 2 — never make this one of ours. */
|
|
92
|
+
text: fg(),
|
|
93
|
+
muted: brand.muted,
|
|
94
|
+
/** Same grey as `muted`; a third level should come from layout, not colour. */
|
|
95
|
+
dim: brand.muted,
|
|
96
|
+
border: brand.muted,
|
|
97
|
+
link: brand.link,
|
|
98
|
+
accent: brand.accent,
|
|
99
|
+
/** Low-key brand tint for bylines — readable, deliberately not attention-grabbing. */
|
|
100
|
+
byline: brand.accent,
|
|
101
|
+
success: brand.success,
|
|
102
|
+
warning: brand.warning,
|
|
103
|
+
danger: brand.danger,
|
|
104
|
+
info: brand.info,
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The selected row owns the colour channel.
|
|
109
|
+
*
|
|
110
|
+
* A row's normal palette is chosen against the TERMINAL's background; on the
|
|
111
|
+
* selection fill none of it clears even 1.2:1. So a selected row has exactly
|
|
112
|
+
* two inks — `fg` for what it is saying, `dim` for what it is saying quietly —
|
|
113
|
+
* and any tone a row would otherwise carry is surrendered while it is
|
|
114
|
+
* selected. The words survive; the hue does not, and cannot.
|
|
115
|
+
*/
|
|
116
|
+
selection: {
|
|
117
|
+
bg: brand.accent,
|
|
118
|
+
fg: brand.ink,
|
|
119
|
+
dim: brand.selectionDim,
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/** Blue / green / amber: the widest hue separation available, because these
|
|
123
|
+
* render as one-character squares where a subtle shift reads as noise. */
|
|
124
|
+
scopes: {
|
|
125
|
+
user: brand.link,
|
|
126
|
+
project: brand.success,
|
|
127
|
+
local: brand.warning,
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/** Badge colours. `fg` is the ink ON the block; `badgeFg` is the same hue used
|
|
131
|
+
* as ink on the normal background instead. */
|
|
132
|
+
category: {
|
|
133
|
+
purple: { bg: brand.accent, fg: brand.ink, badgeFg: brand.accent },
|
|
134
|
+
green: { bg: brand.success, fg: brand.ink, badgeFg: brand.success },
|
|
135
|
+
teal: { bg: brand.info, fg: brand.ink, badgeFg: brand.info },
|
|
136
|
+
yellow: { bg: brand.warning, fg: brand.ink, badgeFg: brand.warning },
|
|
137
|
+
gray: { bg: brand.muted, fg: brand.ink, badgeFg: brand.muted },
|
|
138
|
+
red: { bg: brand.danger, fg: brand.ink, badgeFg: brand.danger },
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
meta: {
|
|
142
|
+
muted: brand.muted,
|
|
143
|
+
warning: brand.warning,
|
|
144
|
+
success: brand.success,
|
|
145
|
+
danger: brand.danger,
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Footer key chips. A vivid block with white ink, because the point of a chip
|
|
150
|
+
* is to read as a pressable key at a glance — the previous neutral-grey chip
|
|
151
|
+
* melted into a light page. Dark slates were measured and rejected: they clear
|
|
152
|
+
* 3:1 on cream but drop to ~2.2 on a dark terminal, i.e. an invisible block.
|
|
153
|
+
*/
|
|
154
|
+
hints: {
|
|
155
|
+
defaultBg: brand.accent,
|
|
156
|
+
primaryBg: brand.info,
|
|
157
|
+
dangerBg: brand.danger,
|
|
158
|
+
fg: brand.ink,
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/** Panels and modals sit on the terminal's own background and are separated by
|
|
162
|
+
* their border, never by an absolute fill that fights the user's theme. */
|
|
163
|
+
surface: {
|
|
164
|
+
bg: bg(),
|
|
165
|
+
border: brand.muted,
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
spacing: {
|
|
169
|
+
rowIndent: 1,
|
|
170
|
+
sectionGap: 1,
|
|
171
|
+
panelPadding: 1,
|
|
172
|
+
},
|
|
173
|
+
} as const;
|
|
174
|
+
|
|
175
|
+
export { fg as defaultFg, bg as defaultBg };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write text to the system clipboard.
|
|
5
|
+
* macOS: uses pbcopy
|
|
6
|
+
* Linux: uses xclip
|
|
7
|
+
* Throws ClipboardUnavailableError if no clipboard tool is available.
|
|
8
|
+
*/
|
|
9
|
+
export async function writeClipboard(text: string): Promise<void> {
|
|
10
|
+
if (process.platform === "darwin") {
|
|
11
|
+
execSync("pbcopy", { input: text });
|
|
12
|
+
} else if (process.platform === "linux") {
|
|
13
|
+
execSync("xclip -selection clipboard", { input: text });
|
|
14
|
+
} else {
|
|
15
|
+
throw new ClipboardUnavailableError(
|
|
16
|
+
`Clipboard not supported on platform: ${process.platform}`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read text from the system clipboard.
|
|
23
|
+
* macOS: uses pbpaste
|
|
24
|
+
* Linux: uses xclip
|
|
25
|
+
* Throws ClipboardUnavailableError if no clipboard tool is available.
|
|
26
|
+
*/
|
|
27
|
+
export async function readClipboard(): Promise<string> {
|
|
28
|
+
if (process.platform === "darwin") {
|
|
29
|
+
return execSync("pbpaste").toString();
|
|
30
|
+
}
|
|
31
|
+
if (process.platform === "linux") {
|
|
32
|
+
return execSync("xclip -selection clipboard -o").toString();
|
|
33
|
+
}
|
|
34
|
+
throw new ClipboardUnavailableError(
|
|
35
|
+
`Clipboard not supported on platform: ${process.platform}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ClipboardUnavailableError extends Error {
|
|
40
|
+
constructor(message: string) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "ClipboardUnavailableError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Check if clipboard operations are available on this platform/system */
|
|
47
|
+
export function isClipboardAvailable(): boolean {
|
|
48
|
+
if (process.platform === "darwin") return true;
|
|
49
|
+
if (process.platform === "linux") {
|
|
50
|
+
try {
|
|
51
|
+
execSync("which xclip", { stdio: "ignore" });
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { constants, accessSync, statSync } from "node:fs";
|
|
2
|
+
import { access, realpath } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A command as an executable plus its already-separated arguments.
|
|
7
|
+
*
|
|
8
|
+
* The alternative — a single string handed to `/bin/sh` — has cost this
|
|
9
|
+
* codebase twice, in two different ways, and both were shipped rather than
|
|
10
|
+
* hypothetical:
|
|
11
|
+
*
|
|
12
|
+
* 1. **A word the shell could not resolve.** `pip install <pkg>` died with
|
|
13
|
+
* `/bin/sh: pip: command not found` on machines where `pip` is only an
|
|
14
|
+
* interactive-zsh alias. `services/toolchain.ts` fixed *which word* gets
|
|
15
|
+
* emitted; it could not fix that the words are re-parsed at spawn time.
|
|
16
|
+
* 2. **Package names are re-parsed as shell syntax.** The builders are fed
|
|
17
|
+
* `bin.package` / `bin.formula` / `bin.module`, which come from
|
|
18
|
+
* `.claude/profiles.json` and from plugin `requires.bin` — hand-authored,
|
|
19
|
+
* committed by teammates, fetched from marketplaces. Under `shell: true`
|
|
20
|
+
* those go through word-splitting and `$(…)` expansion. Today's catalogue is
|
|
21
|
+
* benign; the surface is not, and nothing checks it.
|
|
22
|
+
*
|
|
23
|
+
* As argv there is no shell, so a package name is one token no matter what
|
|
24
|
+
* characters it contains, and `cmd` must name a real executable rather than
|
|
25
|
+
* anything PATH-plus-a-profile might have meant.
|
|
26
|
+
*
|
|
27
|
+
* `services/plugin-setup.ts` arrived at this same shape independently for the
|
|
28
|
+
* MCP dependency installer. This is the codebase agreeing with itself.
|
|
29
|
+
*/
|
|
30
|
+
export interface Command {
|
|
31
|
+
readonly cmd: string;
|
|
32
|
+
readonly args: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Characters that need no quoting in any POSIX shell.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately conservative, and the reason {@link formatCommand} is display-
|
|
39
|
+
* neutral: every token magus actually builds — `@anthropic-ai/claude-code`,
|
|
40
|
+
* `claudish@1.2.0`, `aider==0.1`, `github.com/x/t@latest`, `--upgrade` — is
|
|
41
|
+
* made only of these, so quoting never fires for real input and the rendered
|
|
42
|
+
* string is byte-identical to the one this codebase printed before commands
|
|
43
|
+
* became argv. `doctor-bins.test.ts` is the gate on that claim.
|
|
44
|
+
*/
|
|
45
|
+
const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render a {@link Command} as the shell line a human would type.
|
|
49
|
+
*
|
|
50
|
+
* FOR DISPLAY ONLY. Nothing may feed this back to a shell — that would undo the
|
|
51
|
+
* whole point of carrying argv. It exists so `doctor` can print "run: brew
|
|
52
|
+
* install tmux" and the updater can show what it is about to do.
|
|
53
|
+
*
|
|
54
|
+
* A token outside {@link SHELL_SAFE} is single-quoted, with `'` closed,
|
|
55
|
+
* escaped and reopened (`'\''`) — the only POSIX-portable escape, since single
|
|
56
|
+
* quotes have no escape character inside them. An empty token quotes too:
|
|
57
|
+
* unquoted it would vanish, which would misrepresent the argv it came from.
|
|
58
|
+
*/
|
|
59
|
+
export function formatCommand(c: Command): string {
|
|
60
|
+
return [c.cmd, ...c.args].map(quoteToken).join(" ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function quoteToken(token: string): string {
|
|
64
|
+
if (SHELL_SAFE.test(token)) return token;
|
|
65
|
+
return `'${token.replaceAll("'", `'\\''`)}'`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a command exists in PATH.
|
|
70
|
+
*
|
|
71
|
+
* Async only for its callers' sake — it spawns nothing. This used to run
|
|
72
|
+
* `execFileAsync("which", [command])`, a real `which(1)` process with no
|
|
73
|
+
* timeout, on every one of its dozen call sites. Answering "is bun on PATH?"
|
|
74
|
+
* does not need a process, and under load that process is what stalls:
|
|
75
|
+
* `detectToolchains` calls this per toolchain, and in a 103-file parallel test
|
|
76
|
+
* run the spawn queue backed up until the test failed at **20,007 ms** — after
|
|
77
|
+
* the per-test timeout had already been raised from 5s to 20s to accommodate
|
|
78
|
+
* the earlier 5s failures. Raising the limit moved the goalposts; removing the
|
|
79
|
+
* spawn removes the stall.
|
|
80
|
+
*
|
|
81
|
+
* {@link whichSync} is also STRICTER than `which(1)`, which is a bonus rather
|
|
82
|
+
* than a risk: it stats the candidate and checks the execute bit, so a dangling
|
|
83
|
+
* symlink answers null here instead of reporting the link's own path. That is
|
|
84
|
+
* the exact failure {@link resolveExecutable} was written to catch afterwards.
|
|
85
|
+
*
|
|
86
|
+
* @param command - Command name to check, or an explicit path
|
|
87
|
+
* @returns Path to command if found, null otherwise
|
|
88
|
+
*/
|
|
89
|
+
export async function which(command: string): Promise<string | null> {
|
|
90
|
+
return whichSync(command);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Synchronous PATH lookup — walks `$PATH` rather than spawning `which(1)`.
|
|
95
|
+
*
|
|
96
|
+
* Synchronous on purpose. The installer-command builders in
|
|
97
|
+
* `services/toolchain.ts` are sync and used by printers; making them async just
|
|
98
|
+
* to answer "does this machine have uv?" would ripple through every caller.
|
|
99
|
+
*
|
|
100
|
+
* Node's `fs` is used rather than `Bun.which` so this needs no `@types/bun`,
|
|
101
|
+
* whose globals collide with `@types/node` in this project.
|
|
102
|
+
*
|
|
103
|
+
* Unlike {@link resolveExecutable} this does NOT follow symlinks to prove the
|
|
104
|
+
* target still exists — use it to pick between candidates, not to prove a
|
|
105
|
+
* binary works.
|
|
106
|
+
*/
|
|
107
|
+
export function whichSync(command: string): string | null {
|
|
108
|
+
// An explicit path is not a PATH lookup; honour it as given.
|
|
109
|
+
if (command.includes("/")) {
|
|
110
|
+
return isExecutableFile(command) ? command : null;
|
|
111
|
+
}
|
|
112
|
+
for (const dir of (process.env.PATH ?? "").split(":")) {
|
|
113
|
+
if (!dir) continue;
|
|
114
|
+
const candidate = join(dir, command);
|
|
115
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isExecutableFile(p: string): boolean {
|
|
121
|
+
try {
|
|
122
|
+
if (!statSync(p).isFile()) return false;
|
|
123
|
+
accessSync(p, constants.X_OK);
|
|
124
|
+
return true;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Like {@link which}, but verifies the command actually RESOLVES and is
|
|
132
|
+
* executable — a dangling symlink (e.g. a stale npx-cache link) returns null.
|
|
133
|
+
* `which` alone reports the symlink's own path even when its target is gone,
|
|
134
|
+
* which is exactly how a plugin's binary can look "present" yet fail at runtime.
|
|
135
|
+
* @returns The real (symlink-resolved) executable path, or null.
|
|
136
|
+
*/
|
|
137
|
+
export async function resolveExecutable(
|
|
138
|
+
command: string,
|
|
139
|
+
): Promise<string | null> {
|
|
140
|
+
const p = await which(command);
|
|
141
|
+
if (!p) return null;
|
|
142
|
+
try {
|
|
143
|
+
const real = await realpath(p); // follows symlinks; throws if dangling
|
|
144
|
+
await access(real, constants.X_OK); // must be executable
|
|
145
|
+
return real;
|
|
146
|
+
} catch {
|
|
147
|
+
return null; // dangling link or not executable
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-dir.ts — where Claude Code's config lives, with a test guard.
|
|
3
|
+
*
|
|
4
|
+
* `CLAUDE_CONFIG_DIR` is Claude Code's own override and must be honoured. It is
|
|
5
|
+
* resolved per call rather than captured at import time, because a module-level
|
|
6
|
+
* `os.homedir()` bakes in the operator's real directory and cannot be overridden
|
|
7
|
+
* afterwards.
|
|
8
|
+
*
|
|
9
|
+
* The test guard
|
|
10
|
+
* --------------
|
|
11
|
+
* Under `bun test`, a missing `CLAUDE_CONFIG_DIR` returns null instead of
|
|
12
|
+
* `~/.claude`. Callers treat null as "no cache, no clone" — an empty, inert state.
|
|
13
|
+
*
|
|
14
|
+
* This is not defensive decoration. Once the catalog cache and the rate-limit
|
|
15
|
+
* cooldown moved to disk, and the catalog resolver gained a `git fetch` fallback,
|
|
16
|
+
* any test that had not isolated its config dir began reading the operator's real
|
|
17
|
+
* cooldown file and fetching from their real marketplace clones. The suite went
|
|
18
|
+
* from 6s to 59s and started failing intermittently, because results depended on
|
|
19
|
+
* whether this particular machine happened to be rate-limited at that moment.
|
|
20
|
+
*
|
|
21
|
+
* A test that forgets to isolate should get nothing, not the developer's live
|
|
22
|
+
* state. Production is unaffected: NODE_ENV is not "test" there.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The Claude config directory, or null when a test has not chosen one.
|
|
30
|
+
*
|
|
31
|
+
* Callers that must always have a path (writing real user config) should use
|
|
32
|
+
* `requireClaudeConfigDir`. Callers holding regenerable state — caches — should
|
|
33
|
+
* treat null as "cache unavailable" and carry on.
|
|
34
|
+
*/
|
|
35
|
+
export function claudeConfigDirOrNull(): string | null {
|
|
36
|
+
const explicit = process.env.CLAUDE_CONFIG_DIR;
|
|
37
|
+
if (explicit) return explicit;
|
|
38
|
+
if (process.env.NODE_ENV === "test") return null;
|
|
39
|
+
return path.join(os.homedir(), ".claude");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The Claude config directory, falling back to `~/.claude` even under test. */
|
|
43
|
+
export function requireClaudeConfigDir(): string {
|
|
44
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
45
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File locking utilities for concurrent write protection
|
|
3
|
+
*
|
|
4
|
+
* Prevents race conditions when multiple processes modify settings files
|
|
5
|
+
* Uses lock files for cross-platform compatibility
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { mkdir, open } from "node:fs/promises";
|
|
10
|
+
import type { FileHandle } from "node:fs/promises";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Maximum time to wait for a lock (milliseconds)
|
|
15
|
+
*/
|
|
16
|
+
const LOCK_TIMEOUT_MS = 10000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Lock retry interval (milliseconds)
|
|
20
|
+
*/
|
|
21
|
+
const LOCK_RETRY_INTERVAL_MS = 100;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Acquire exclusive lock on file and execute operation
|
|
25
|
+
* Uses lock files for cross-platform compatibility
|
|
26
|
+
*
|
|
27
|
+
* @param filePath - Path to file to lock
|
|
28
|
+
* @param operation - Async operation to execute with lock held
|
|
29
|
+
* @returns Result of the operation
|
|
30
|
+
* @throws Error if lock cannot be acquired within timeout
|
|
31
|
+
*/
|
|
32
|
+
export async function withFileLock<T>(
|
|
33
|
+
filePath: string,
|
|
34
|
+
operation: () => Promise<T>,
|
|
35
|
+
): Promise<T> {
|
|
36
|
+
const lockPath = `${filePath}.lock`;
|
|
37
|
+
const lockDir = dirname(lockPath);
|
|
38
|
+
|
|
39
|
+
// Ensure lock directory exists
|
|
40
|
+
if (!existsSync(lockDir)) {
|
|
41
|
+
await mkdir(lockDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const startTime = Date.now();
|
|
45
|
+
let fileHandle: FileHandle | null = null;
|
|
46
|
+
|
|
47
|
+
// Try to acquire lock with retries
|
|
48
|
+
while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
|
|
49
|
+
try {
|
|
50
|
+
// Try to create lock file exclusively (fails if exists)
|
|
51
|
+
fileHandle = await open(lockPath, "wx");
|
|
52
|
+
|
|
53
|
+
// Write PID to lock file for debugging
|
|
54
|
+
await fileHandle.write(`${process.pid}\n${new Date().toISOString()}\n`);
|
|
55
|
+
|
|
56
|
+
break;
|
|
57
|
+
} catch (error: unknown) {
|
|
58
|
+
// Lock file exists, retry after interval
|
|
59
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
60
|
+
await new Promise((resolve) =>
|
|
61
|
+
setTimeout(resolve, LOCK_RETRY_INTERVAL_MS),
|
|
62
|
+
);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (fileHandle === null) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Failed to acquire lock on ${filePath} after ${LOCK_TIMEOUT_MS}ms. ` +
|
|
72
|
+
`Another process may be holding the lock. Lock file: ${lockPath}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Execute operation with lock held
|
|
78
|
+
return await operation();
|
|
79
|
+
} finally {
|
|
80
|
+
// Release lock
|
|
81
|
+
try {
|
|
82
|
+
await fileHandle.close();
|
|
83
|
+
// Delete lock file
|
|
84
|
+
const fs = await import("node:fs/promises");
|
|
85
|
+
await fs.unlink(lockPath);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// Log error but don't throw - operation succeeded
|
|
88
|
+
console.error(`Warning: Failed to release lock ${lockPath}:`, error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if file is currently locked
|
|
95
|
+
*
|
|
96
|
+
* @param filePath - Path to file to check
|
|
97
|
+
* @returns True if lock file exists
|
|
98
|
+
*/
|
|
99
|
+
export function isFileLocked(filePath: string): boolean {
|
|
100
|
+
const lockPath = `${filePath}.lock`;
|
|
101
|
+
return existsSync(lockPath);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Force remove lock file (use with caution)
|
|
106
|
+
* Only use if you're certain the lock is stale
|
|
107
|
+
*
|
|
108
|
+
* @param filePath - Path to file whose lock to remove
|
|
109
|
+
*/
|
|
110
|
+
export async function forceRemoveLock(filePath: string): Promise<void> {
|
|
111
|
+
const lockPath = `${filePath}.lock`;
|
|
112
|
+
try {
|
|
113
|
+
const fs = await import("node:fs/promises");
|
|
114
|
+
await fs.unlink(lockPath);
|
|
115
|
+
} catch (error: unknown) {
|
|
116
|
+
// Ignore ENOENT (lock doesn't exist)
|
|
117
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get information about current lock
|
|
125
|
+
*
|
|
126
|
+
* @param filePath - Path to file to check
|
|
127
|
+
* @returns Lock info or null if not locked
|
|
128
|
+
*/
|
|
129
|
+
export async function getLockInfo(
|
|
130
|
+
filePath: string,
|
|
131
|
+
): Promise<{ pid: string; timestamp: string } | null> {
|
|
132
|
+
const lockPath = `${filePath}.lock`;
|
|
133
|
+
|
|
134
|
+
if (!existsSync(lockPath)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const fs = await import("node:fs/promises");
|
|
140
|
+
const content = await fs.readFile(lockPath, "utf-8");
|
|
141
|
+
const [pid, timestamp] = content.trim().split("\n");
|
|
142
|
+
return { pid, timestamp };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|