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.
Files changed (330) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +725 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +524 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +248 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +340 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/open-file.test.ts +59 -0
  96. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  97. package/src/__tests__/plugin-checkout.test.ts +66 -0
  98. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  99. package/src/__tests__/plugin-contents.test.ts +371 -0
  100. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  101. package/src/__tests__/plugin-releases.test.ts +98 -0
  102. package/src/__tests__/plugin-requires.test.ts +187 -0
  103. package/src/__tests__/plugin-row-text.test.ts +345 -0
  104. package/src/__tests__/plugin-setup.test.ts +1016 -0
  105. package/src/__tests__/profile-adopt.test.ts +177 -0
  106. package/src/__tests__/profile-command.test.ts +110 -0
  107. package/src/__tests__/profile-materializer.test.ts +238 -0
  108. package/src/__tests__/profile-sync.test.ts +154 -0
  109. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  110. package/src/__tests__/resolve-executable.test.ts +42 -0
  111. package/src/__tests__/resolver.test.ts +351 -0
  112. package/src/__tests__/scope-action.test.ts +134 -0
  113. package/src/__tests__/scope-squares.test.tsx +164 -0
  114. package/src/__tests__/selection-scope.test.ts +100 -0
  115. package/src/__tests__/settings-file.test.ts +179 -0
  116. package/src/__tests__/shell-script-callers.test.ts +92 -0
  117. package/src/__tests__/skill-install-files.test.ts +136 -0
  118. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  119. package/src/__tests__/style-wrap.test.ts +220 -0
  120. package/src/__tests__/styles-manager.test.ts +1098 -0
  121. package/src/__tests__/styles-origins.test.ts +414 -0
  122. package/src/__tests__/styles-screen-state.test.ts +457 -0
  123. package/src/__tests__/styles-status-line.test.ts +72 -0
  124. package/src/__tests__/styles-sync.test.ts +452 -0
  125. package/src/__tests__/symlink-manager.test.ts +180 -0
  126. package/src/__tests__/tabbar-layout.test.ts +100 -0
  127. package/src/__tests__/terminology-filler.test.ts +214 -0
  128. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  129. package/src/__tests__/theme-env.test.ts +310 -0
  130. package/src/__tests__/theme-resolve.test.ts +380 -0
  131. package/src/__tests__/tool-probe.test.ts +1400 -0
  132. package/src/__tests__/toolchain.test.ts +232 -0
  133. package/src/__tests__/ui-version-writers.test.ts +88 -0
  134. package/src/__tests__/update-apply.test.ts +637 -0
  135. package/src/__tests__/update-plan.test.ts +1396 -0
  136. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  137. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  138. package/src/__tests__/version-check-refresh.test.ts +45 -0
  139. package/src/__tests__/version-snapshot.test.ts +212 -0
  140. package/src/__tests__/version-unknown-state.test.ts +403 -0
  141. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  142. package/src/cli/ansi.ts +528 -0
  143. package/src/cli/bootstrap.ts +134 -0
  144. package/src/cli/doctor.ts +218 -0
  145. package/src/cli/hook.ts +129 -0
  146. package/src/cli/install.ts +382 -0
  147. package/src/cli/live.ts +374 -0
  148. package/src/cli/models.ts +297 -0
  149. package/src/cli/profile.ts +197 -0
  150. package/src/cli/prompt.ts +32 -0
  151. package/src/cli/router.ts +264 -0
  152. package/src/cli/update-view.ts +717 -0
  153. package/src/cli/update.ts +600 -0
  154. package/src/cli/upgrade.ts +97 -0
  155. package/src/data/alias-flags.ts +301 -0
  156. package/src/data/cli-tools.ts +146 -0
  157. package/src/data/community-styles.ts +531 -0
  158. package/src/data/gitignore-defaults.ts +33 -0
  159. package/src/data/gitignore-reasons.ts +93 -0
  160. package/src/data/gitignore-templates.ts +25 -0
  161. package/src/data/marketplaces.ts +278 -0
  162. package/src/data/mcp-servers.ts +51 -0
  163. package/src/data/models-presets.ts +329 -0
  164. package/src/data/predefined-profiles.ts +252 -0
  165. package/src/data/settings-catalog.ts +714 -0
  166. package/src/data/skill-repos.ts +91 -0
  167. package/src/data/styles/asd-ste100.md +38 -0
  168. package/src/data/styles/calibrated.md +24 -0
  169. package/src/data/styles/direct.md +23 -0
  170. package/src/data/styles/evidence-first.md +22 -0
  171. package/src/data/styles/explanatory.md +23 -0
  172. package/src/data/styles/index.ts +57 -0
  173. package/src/data/styles/no-slop.md +32 -0
  174. package/src/data/styles/plain-language.md +23 -0
  175. package/src/data/styles/structured.md +30 -0
  176. package/src/data/styles/terminology.md +28 -0
  177. package/src/data/styles/terse.md +20 -0
  178. package/src/main.tsx +110 -0
  179. package/src/markdown.d.ts +13 -0
  180. package/src/opentui.d.ts +203 -0
  181. package/src/services/active-closure.ts +117 -0
  182. package/src/services/alias-settings.ts +60 -0
  183. package/src/services/alias-shell-writer.ts +1244 -0
  184. package/src/services/alias-store.ts +172 -0
  185. package/src/services/binary-signing.ts +78 -0
  186. package/src/services/catalog-cache-store.ts +316 -0
  187. package/src/services/claude-cli.ts +304 -0
  188. package/src/services/claude-runner.ts +31 -0
  189. package/src/services/claude-settings.ts +1564 -0
  190. package/src/services/cli-tool-commands.ts +133 -0
  191. package/src/services/community-fetcher.ts +90 -0
  192. package/src/services/community-styles.ts +1194 -0
  193. package/src/services/content-drift.ts +208 -0
  194. package/src/services/conventions-manager.ts +945 -0
  195. package/src/services/doctor-bins.ts +63 -0
  196. package/src/services/doctor.ts +570 -0
  197. package/src/services/dotenv.ts +181 -0
  198. package/src/services/git-worktree.ts +129 -0
  199. package/src/services/github-budget.ts +274 -0
  200. package/src/services/gitignore-check.ts +63 -0
  201. package/src/services/gitignore-detector.ts +167 -0
  202. package/src/services/gitignore-fixer.ts +280 -0
  203. package/src/services/gitignore-resolver.ts +208 -0
  204. package/src/services/gitignore-service.ts +167 -0
  205. package/src/services/hook-registration.ts +218 -0
  206. package/src/services/install-plan.ts +107 -0
  207. package/src/services/local-marketplace.ts +657 -0
  208. package/src/services/manifest.ts +250 -0
  209. package/src/services/marketplace-catalog-git.ts +173 -0
  210. package/src/services/marketplace-catalog.ts +129 -0
  211. package/src/services/marketplace-fetcher.ts +400 -0
  212. package/src/services/marketplace-refresh.ts +245 -0
  213. package/src/services/marketplace-sync.ts +145 -0
  214. package/src/services/mate-availability.ts +133 -0
  215. package/src/services/mate-catalog.ts +265 -0
  216. package/src/services/mcp-registry.ts +171 -0
  217. package/src/services/models-core.ts +969 -0
  218. package/src/services/models-manager.ts +606 -0
  219. package/src/services/plugin-checkout.ts +186 -0
  220. package/src/services/plugin-manager.ts +847 -0
  221. package/src/services/plugin-mcp-config.ts +242 -0
  222. package/src/services/plugin-releases.ts +47 -0
  223. package/src/services/plugin-requires.ts +227 -0
  224. package/src/services/plugin-setup.ts +965 -0
  225. package/src/services/profile-adopt.ts +206 -0
  226. package/src/services/profile-materializer.ts +133 -0
  227. package/src/services/profile-sync.ts +158 -0
  228. package/src/services/profiles.ts +225 -0
  229. package/src/services/resolver.ts +377 -0
  230. package/src/services/settings-file.ts +69 -0
  231. package/src/services/settings-manager.ts +307 -0
  232. package/src/services/skills-manager.ts +1111 -0
  233. package/src/services/skillsmp-client.ts +193 -0
  234. package/src/services/styles-manager.ts +1294 -0
  235. package/src/services/symlink-manager.ts +189 -0
  236. package/src/services/terminology-filler.ts +266 -0
  237. package/src/services/tool-probe.ts +1637 -0
  238. package/src/services/toolchain.ts +431 -0
  239. package/src/services/update-engine.ts +691 -0
  240. package/src/services/update-plan.ts +820 -0
  241. package/src/services/version-check.ts +127 -0
  242. package/src/services/version-snapshot.ts +189 -0
  243. package/src/tui.tsx +112 -0
  244. package/src/types/bun.d.ts +27 -0
  245. package/src/types/gitignore.ts +68 -0
  246. package/src/types/index.ts +447 -0
  247. package/src/ui/App.tsx +535 -0
  248. package/src/ui/adapters/catalogNotice.ts +122 -0
  249. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  250. package/src/ui/adapters/modelsAdapter.ts +213 -0
  251. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  252. package/src/ui/adapters/settingsAdapter.ts +168 -0
  253. package/src/ui/adapters/skillsAdapter.ts +229 -0
  254. package/src/ui/adapters/stylesAdapter.ts +403 -0
  255. package/src/ui/components/CategoryHeader.tsx +64 -0
  256. package/src/ui/components/EmptyFilterState.tsx +32 -0
  257. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  258. package/src/ui/components/ScopeIndicator.tsx +63 -0
  259. package/src/ui/components/ScrollableList.tsx +95 -0
  260. package/src/ui/components/SearchInput.tsx +57 -0
  261. package/src/ui/components/StyledText.tsx +75 -0
  262. package/src/ui/components/TabBar.tsx +101 -0
  263. package/src/ui/components/layout/FooterHints.tsx +78 -0
  264. package/src/ui/components/layout/Panel.tsx +63 -0
  265. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  266. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  267. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  268. package/src/ui/components/layout/index.ts +9 -0
  269. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  270. package/src/ui/components/modals/InputModal.tsx +75 -0
  271. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  272. package/src/ui/components/modals/MessageModal.tsx +66 -0
  273. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  274. package/src/ui/components/modals/SelectModal.tsx +90 -0
  275. package/src/ui/components/modals/index.ts +6 -0
  276. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  277. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  278. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  279. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  280. package/src/ui/components/primitives/MetaText.tsx +40 -0
  281. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  282. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  283. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  284. package/src/ui/components/primitives/index.ts +9 -0
  285. package/src/ui/hooks/index.ts +2 -0
  286. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  287. package/src/ui/hooks/useKeyboard.ts +24 -0
  288. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  289. package/src/ui/registry.ts +33 -0
  290. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  291. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  292. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  293. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  294. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  295. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  296. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  297. package/src/ui/renderers/pluginRowText.ts +70 -0
  298. package/src/ui/renderers/pluginStatusText.ts +56 -0
  299. package/src/ui/renderers/profileRenderers.tsx +406 -0
  300. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  301. package/src/ui/renderers/skillRenderers.tsx +479 -0
  302. package/src/ui/renderers/styleRenderers.tsx +813 -0
  303. package/src/ui/screens/AliasScreen.tsx +1558 -0
  304. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  305. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  306. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  307. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  308. package/src/ui/screens/McpScreen.tsx +256 -0
  309. package/src/ui/screens/ModelsScreen.tsx +542 -0
  310. package/src/ui/screens/PluginsScreen.tsx +1423 -0
  311. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  312. package/src/ui/screens/SkillsScreen.tsx +853 -0
  313. package/src/ui/screens/StylesScreen.tsx +1064 -0
  314. package/src/ui/screens/index.ts +11 -0
  315. package/src/ui/state/AppContext.tsx +233 -0
  316. package/src/ui/state/DimensionsContext.tsx +154 -0
  317. package/src/ui/state/reducer.ts +810 -0
  318. package/src/ui/state/types.ts +503 -0
  319. package/src/ui/theme-mode.ts +440 -0
  320. package/src/ui/theme-resolve.ts +147 -0
  321. package/src/ui/theme.ts +175 -0
  322. package/src/utils/clipboard.ts +58 -0
  323. package/src/utils/command-utils.ts +149 -0
  324. package/src/utils/config-dir.ts +45 -0
  325. package/src/utils/file-locking.ts +146 -0
  326. package/src/utils/fuzzy-search.ts +138 -0
  327. package/src/utils/go-module.ts +36 -0
  328. package/src/utils/open-file.ts +84 -0
  329. package/src/utils/run.ts +67 -0
  330. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
3
+ import type { ResolvedBin } from "../types/index.js";
4
+
5
+ const bins: ResolvedBin[] = [
6
+ {
7
+ name: "tmux",
8
+ via: "brew",
9
+ formula: "tmux",
10
+ sources: ["plugin:terminal@magus"],
11
+ },
12
+ {
13
+ name: "claudish",
14
+ via: "bun",
15
+ package: "claudish",
16
+ version: "1.2.0",
17
+ sources: ["profile:x.cliTools"],
18
+ },
19
+ ];
20
+
21
+ describe("checkBinaries", () => {
22
+ test("reports presence and an install command per bin", async () => {
23
+ const present = new Set(["claudish"]);
24
+ const results = await checkBinaries(bins, async (n) => present.has(n));
25
+
26
+ const tmux = results.find((r) => r.name === "tmux")!;
27
+ expect(tmux.present).toBe(false);
28
+ expect(tmux.installCommand).toBe("brew install tmux");
29
+ expect(tmux.sources).toEqual(["plugin:terminal@magus"]);
30
+
31
+ const claudish = results.find((r) => r.name === "claudish")!;
32
+ expect(claudish.present).toBe(true);
33
+ expect(claudish.installCommand).toBe("bun install -g claudish@1.2.0");
34
+ });
35
+ });
36
+
37
+ describe("missingBinaries", () => {
38
+ test("returns only the absent ones", async () => {
39
+ const results = await checkBinaries(bins, async (n) => n === "claudish");
40
+ const missing = missingBinaries(results);
41
+ expect(missing.map((m) => m.name)).toEqual(["tmux"]);
42
+ });
43
+ });
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Unit tests for doctor service
3
+ *
4
+ * Tests cover:
5
+ * - checkProject: project accessible, gitignore entries, claudemd sections, malformed markers
6
+ * - runDoctor: empty registry, registered projects, summary counts
7
+ * - fixDoctorIssues: add gitignore entries, inject claudemd sections, repair markers
8
+ *
9
+ * Strategy: checkProject and fixDoctorIssues are tested against real temp directories
10
+ * with real plugin.json files. runDoctor is tested by mocking the registry.
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
14
+ import { existsSync } from "node:fs";
15
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { checkProject, fixDoctorIssues } from "../services/doctor.js";
19
+ import type { DoctorResult } from "../services/doctor.js";
20
+
21
+ // ============================================================
22
+ // HELPERS
23
+ // ============================================================
24
+
25
+ /**
26
+ * Create a minimal plugin directory with plugin.json and optional template.
27
+ */
28
+ async function createPluginDir(
29
+ baseDir: string,
30
+ pluginName: string,
31
+ conventions?: {
32
+ gitignore?: { project?: string[] };
33
+ claudemd?: { section: string; template: string; version: string };
34
+ },
35
+ templateContent?: string,
36
+ ): Promise<string> {
37
+ const pluginDir = join(baseDir, pluginName);
38
+ await mkdir(pluginDir, { recursive: true });
39
+
40
+ const manifest: Record<string, unknown> = {
41
+ name: pluginName,
42
+ version: "1.0.0",
43
+ description: `Test plugin ${pluginName}`,
44
+ };
45
+
46
+ if (conventions) {
47
+ manifest.conventions = conventions;
48
+ }
49
+
50
+ await writeFile(
51
+ join(pluginDir, "plugin.json"),
52
+ JSON.stringify(manifest, null, 2),
53
+ );
54
+
55
+ if (templateContent && conventions?.claudemd?.template) {
56
+ const templateDir = join(pluginDir, "templates");
57
+ await mkdir(templateDir, { recursive: true });
58
+ await writeFile(
59
+ join(templateDir, "claude-md-conventions.md"),
60
+ templateContent,
61
+ );
62
+ }
63
+
64
+ return pluginDir;
65
+ }
66
+
67
+ describe("doctor", () => {
68
+ let testDir: string;
69
+ let projectDir: string;
70
+ let pluginsDir: string;
71
+
72
+ beforeEach(async () => {
73
+ testDir = await mkdtemp(join(tmpdir(), "doctor-test-"));
74
+ projectDir = join(testDir, "project");
75
+ pluginsDir = join(testDir, "plugins");
76
+ await mkdir(projectDir, { recursive: true });
77
+ await mkdir(pluginsDir, { recursive: true });
78
+ });
79
+
80
+ afterEach(async () => {
81
+ await rm(testDir, { recursive: true, force: true });
82
+ });
83
+
84
+ // ============================================================
85
+ // checkProject
86
+ // ============================================================
87
+
88
+ describe("checkProject", () => {
89
+ it("should report fail when project path does not exist", async () => {
90
+ const result = await checkProject(
91
+ "/nonexistent/path/that/does/not/exist",
92
+ ["some-plugin@magus"],
93
+ );
94
+
95
+ expect(result.projectPath).toBe("/nonexistent/path/that/does/not/exist");
96
+ expect(result.checks).toHaveLength(1);
97
+ expect(result.checks[0].name).toBe("project-accessible");
98
+ expect(result.checks[0].status).toBe("fail");
99
+ expect(result.checks[0].fixable).toBe(false);
100
+ });
101
+
102
+ it("should return empty checks when plugins have no conventions", async () => {
103
+ // checkProject with pluginIds that cannot be resolved will produce no convention checks
104
+ // (since resolvePluginPath returns null for unknown IDs)
105
+ const result = await checkProject(projectDir, [
106
+ "nonexistent-plugin@nonexistent-marketplace",
107
+ ]);
108
+
109
+ // No convention checks should be generated since the plugin could not be resolved
110
+ const conventionChecks = result.checks.filter(
111
+ (c) => c.name !== "project-accessible",
112
+ );
113
+ expect(conventionChecks).toHaveLength(0);
114
+ });
115
+ });
116
+
117
+ // ============================================================
118
+ // checkProject with real conventions (using checkProject directly with
119
+ // pre-setup gitignore/CLAUDE.md, bypassing plugin resolution)
120
+ // ============================================================
121
+
122
+ describe("checkProject gitignore checks", () => {
123
+ it("should pass when gitignore has all required entries", async () => {
124
+ // Since checkProject resolves plugins via marketplace, we test the
125
+ // underlying behavior by checking project files directly.
126
+ // For unit testing, we verify checkProject handles the missing-plugin case gracefully.
127
+ const gitignorePath = join(projectDir, ".gitignore");
128
+ await writeFile(gitignorePath, "node_modules/\n.mnemex/\n");
129
+
130
+ // With no resolvable plugins, no gitignore checks are generated
131
+ const result = await checkProject(projectDir, []);
132
+ expect(result.checks).toHaveLength(0);
133
+ });
134
+ });
135
+
136
+ describe("checkProject CLAUDE.md checks", () => {
137
+ it("should detect malformed CLAUDE.md markers", async () => {
138
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
139
+ // Write CLAUDE.md with unclosed marker
140
+ await writeFile(
141
+ claudeMdPath,
142
+ "# Project\n\n<!-- BEGIN magus:test-plugin v1.0.0 -->\nSome content\n",
143
+ );
144
+
145
+ // checkProject won't find the marker issue unless it resolves plugins.
146
+ // But the parse is done on the file itself. Let's verify via parseClaudeMdSections directly.
147
+ const { parseClaudeMdSections } = await import(
148
+ "../services/conventions-manager.js"
149
+ );
150
+ const parseResult = await parseClaudeMdSections(claudeMdPath);
151
+ expect(parseResult.errors).toHaveLength(1);
152
+ expect(parseResult.errors[0].type).toBe("missing_end");
153
+ });
154
+ });
155
+
156
+ // ============================================================
157
+ // runDoctor with no registered projects
158
+ // ============================================================
159
+
160
+ describe("runDoctor", () => {
161
+ it("should return empty results when no projects are registered", async () => {
162
+ // Mock the registry to return empty
163
+ const doctorModule = await import("../services/doctor.js");
164
+
165
+ // We can't easily mock the internal collectProjects, but we can
166
+ // test via the exported runDoctor if the registry is empty.
167
+ // In practice, runDoctor reads the real registry, so we test the
168
+ // output shape is correct.
169
+ const { runDoctor } = doctorModule;
170
+ const result = await runDoctor();
171
+
172
+ // The result shape should be valid even if we have real projects
173
+ expect(result).toHaveProperty("projects");
174
+ expect(result).toHaveProperty("summary");
175
+ expect(result.summary).toHaveProperty("totalProjects");
176
+ expect(result.summary).toHaveProperty("totalChecks");
177
+ expect(result.summary).toHaveProperty("passed");
178
+ expect(result.summary).toHaveProperty("warnings");
179
+ expect(result.summary).toHaveProperty("failures");
180
+ expect(result.summary).toHaveProperty("fixable");
181
+ expect(typeof result.summary.totalProjects).toBe("number");
182
+ });
183
+
184
+ it("should compute summary counts correctly", async () => {
185
+ // Build a DoctorResult manually and verify summary computation
186
+ // This tests the summary logic without needing real plugin resolution
187
+ const { runDoctor } = await import("../services/doctor.js");
188
+ const result = await runDoctor();
189
+
190
+ // Verify summary math
191
+ const { summary, projects } = result;
192
+ let expectedChecks = 0;
193
+ let expectedPassed = 0;
194
+ let expectedWarnings = 0;
195
+ let expectedFailures = 0;
196
+ let expectedFixable = 0;
197
+
198
+ for (const project of projects) {
199
+ for (const check of project.checks) {
200
+ expectedChecks++;
201
+ if (check.status === "pass") expectedPassed++;
202
+ else if (check.status === "warn") expectedWarnings++;
203
+ else if (check.status === "fail") expectedFailures++;
204
+ if (check.fixable) expectedFixable++;
205
+ }
206
+ }
207
+
208
+ expect(summary.totalChecks).toBe(expectedChecks);
209
+ expect(summary.passed).toBe(expectedPassed);
210
+ expect(summary.warnings).toBe(expectedWarnings);
211
+ expect(summary.failures).toBe(expectedFailures);
212
+ expect(summary.fixable).toBe(expectedFixable);
213
+ expect(summary.totalProjects).toBe(projects.length);
214
+ });
215
+ });
216
+
217
+ // ============================================================
218
+ // fixDoctorIssues
219
+ // ============================================================
220
+
221
+ describe("fixDoctorIssues", () => {
222
+ it("should add missing gitignore entries", async () => {
223
+ const gitignorePath = join(projectDir, ".gitignore");
224
+ await writeFile(gitignorePath, "node_modules/\n");
225
+
226
+ const doctorResult: DoctorResult = {
227
+ projects: [
228
+ {
229
+ projectPath: projectDir,
230
+ plugins: ["test-plugin@magus"],
231
+ checks: [
232
+ {
233
+ name: "gitignore-entries",
234
+ status: "fail",
235
+ message: ".gitignore missing: .mnemex/",
236
+ fixable: true,
237
+ _fixData: {
238
+ type: "add-gitignore-entries",
239
+ gitignorePath,
240
+ entries: [".mnemex/"],
241
+ },
242
+ },
243
+ ],
244
+ },
245
+ ],
246
+ summary: {
247
+ totalProjects: 1,
248
+ totalChecks: 1,
249
+ passed: 0,
250
+ warnings: 0,
251
+ failures: 1,
252
+ fixable: 1,
253
+ },
254
+ };
255
+
256
+ const fixResult = await fixDoctorIssues(doctorResult);
257
+
258
+ expect(fixResult.fixed).toBe(1);
259
+ expect(fixResult.failed).toBe(0);
260
+
261
+ const content = await readFile(gitignorePath, "utf-8");
262
+ expect(content).toContain(".mnemex/");
263
+ expect(content).toContain("node_modules/");
264
+ });
265
+
266
+ it("should inject missing CLAUDE.md sections", async () => {
267
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
268
+ await writeFile(claudeMdPath, "# My Project\n\nSome existing content.\n");
269
+
270
+ // Create a plugin dir with template
271
+ const pluginDir = await createPluginDir(
272
+ pluginsDir,
273
+ "test-plugin",
274
+ {
275
+ claudemd: {
276
+ section: "test-plugin",
277
+ template: "./templates/claude-md-conventions.md",
278
+ version: "1.0.0",
279
+ },
280
+ },
281
+ "## Test Plugin\n\nDo not commit .test-data/ to git.",
282
+ );
283
+
284
+ const doctorResult: DoctorResult = {
285
+ projects: [
286
+ {
287
+ projectPath: projectDir,
288
+ plugins: ["test-plugin@magus"],
289
+ checks: [
290
+ {
291
+ name: "claudemd-section-test-plugin",
292
+ status: "warn",
293
+ message: "CLAUDE.md missing test-plugin conventions",
294
+ fixable: true,
295
+ _fixData: {
296
+ type: "inject-section",
297
+ claudeMdPath,
298
+ pluginName: "test-plugin",
299
+ pluginPath: pluginDir,
300
+ conventions: {
301
+ claudemd: {
302
+ section: "test-plugin",
303
+ template: "./templates/claude-md-conventions.md",
304
+ version: "1.0.0",
305
+ },
306
+ },
307
+ },
308
+ },
309
+ ],
310
+ },
311
+ ],
312
+ summary: {
313
+ totalProjects: 1,
314
+ totalChecks: 1,
315
+ passed: 0,
316
+ warnings: 1,
317
+ failures: 0,
318
+ fixable: 1,
319
+ },
320
+ };
321
+
322
+ const fixResult = await fixDoctorIssues(doctorResult);
323
+
324
+ expect(fixResult.fixed).toBe(1);
325
+ expect(fixResult.failed).toBe(0);
326
+
327
+ const content = await readFile(claudeMdPath, "utf-8");
328
+ expect(content).toContain("<!-- BEGIN magus:test-plugin v1.0.0 -->");
329
+ expect(content).toContain("Do not commit .test-data/ to git.");
330
+ expect(content).toContain("<!-- END magus:test-plugin -->");
331
+ expect(content).toContain("# My Project");
332
+ });
333
+
334
+ it("should create gitignore when project has none", async () => {
335
+ const gitignorePath = join(projectDir, ".gitignore");
336
+ expect(existsSync(gitignorePath)).toBe(false);
337
+
338
+ const doctorResult: DoctorResult = {
339
+ projects: [
340
+ {
341
+ projectPath: projectDir,
342
+ plugins: ["test-plugin@magus"],
343
+ checks: [
344
+ {
345
+ name: "gitignore-exists",
346
+ status: "warn",
347
+ message: ".gitignore does not exist",
348
+ fixable: true,
349
+ _fixData: {
350
+ type: "create-gitignore",
351
+ projectPath: projectDir,
352
+ entries: [".mnemex/", ".claudemem/"],
353
+ },
354
+ },
355
+ ],
356
+ },
357
+ ],
358
+ summary: {
359
+ totalProjects: 1,
360
+ totalChecks: 1,
361
+ passed: 0,
362
+ warnings: 1,
363
+ failures: 0,
364
+ fixable: 1,
365
+ },
366
+ };
367
+
368
+ const fixResult = await fixDoctorIssues(doctorResult);
369
+
370
+ expect(fixResult.fixed).toBe(1);
371
+ expect(existsSync(gitignorePath)).toBe(true);
372
+ const content = await readFile(gitignorePath, "utf-8");
373
+ expect(content).toContain(".mnemex/");
374
+ expect(content).toContain(".claudemem/");
375
+ });
376
+
377
+ it("should skip unfixable issues", async () => {
378
+ const doctorResult: DoctorResult = {
379
+ projects: [
380
+ {
381
+ projectPath: "/nonexistent/path",
382
+ plugins: ["some-plugin@magus"],
383
+ checks: [
384
+ {
385
+ name: "project-accessible",
386
+ status: "fail",
387
+ message: "Project path does not exist",
388
+ fixable: false,
389
+ },
390
+ ],
391
+ },
392
+ ],
393
+ summary: {
394
+ totalProjects: 1,
395
+ totalChecks: 1,
396
+ passed: 0,
397
+ warnings: 0,
398
+ failures: 1,
399
+ fixable: 0,
400
+ },
401
+ };
402
+
403
+ const fixResult = await fixDoctorIssues(doctorResult);
404
+
405
+ expect(fixResult.fixed).toBe(0);
406
+ expect(fixResult.failed).toBe(0);
407
+ });
408
+
409
+ it("should report fix count correctly with mixed results", async () => {
410
+ const gitignorePath = join(projectDir, ".gitignore");
411
+ await writeFile(gitignorePath, "node_modules/\n");
412
+
413
+ const doctorResult: DoctorResult = {
414
+ projects: [
415
+ {
416
+ projectPath: projectDir,
417
+ plugins: ["test-plugin@magus"],
418
+ checks: [
419
+ {
420
+ name: "gitignore-entries",
421
+ status: "fail",
422
+ message: ".gitignore missing: .mnemex/",
423
+ fixable: true,
424
+ _fixData: {
425
+ type: "add-gitignore-entries",
426
+ gitignorePath,
427
+ entries: [".mnemex/"],
428
+ },
429
+ },
430
+ {
431
+ name: "project-accessible",
432
+ status: "fail",
433
+ message: "Project path does not exist",
434
+ fixable: false,
435
+ },
436
+ {
437
+ name: "claudemd-section-test-plugin",
438
+ status: "warn",
439
+ message: "CLAUDE.md missing test-plugin conventions",
440
+ fixable: true,
441
+ // Missing _fixData -- should count as failed
442
+ },
443
+ ],
444
+ },
445
+ ],
446
+ summary: {
447
+ totalProjects: 1,
448
+ totalChecks: 3,
449
+ passed: 0,
450
+ warnings: 1,
451
+ failures: 2,
452
+ fixable: 2,
453
+ },
454
+ };
455
+
456
+ const fixResult = await fixDoctorIssues(doctorResult);
457
+
458
+ // 1 fixed (gitignore), 1 failed (missing _fixData), 1 skipped (not fixable)
459
+ expect(fixResult.fixed).toBe(1);
460
+ expect(fixResult.failed).toBe(1);
461
+ });
462
+ });
463
+ });
@@ -0,0 +1,195 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { loadProjectDotenv, parseDotenv } from "../services/dotenv";
7
+
8
+ function mkfifo(path: string): void {
9
+ const r = spawnSync("mkfifo", [path]);
10
+ if (r.status !== 0) {
11
+ throw new Error(`mkfifo failed: ${r.stderr?.toString() ?? "unknown"}`);
12
+ }
13
+ }
14
+
15
+ describe("loadProjectDotenv", () => {
16
+ let dir: string;
17
+
18
+ beforeEach(async () => {
19
+ dir = await mkdtemp(join(tmpdir(), "magus-dotenv-"));
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await rm(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ it("loads a regular .env", async () => {
27
+ await writeFile(join(dir, ".env"), "FOO=bar\nBAZ=qux\n");
28
+ const env: Record<string, string | undefined> = {};
29
+
30
+ const r = loadProjectDotenv(dir, env);
31
+
32
+ expect(env.FOO).toBe("bar");
33
+ expect(env.BAZ).toBe("qux");
34
+ expect(r.loaded).toEqual([".env"]);
35
+ expect(r.warnings).toEqual([]);
36
+ });
37
+
38
+ it("never overwrites a variable already in the environment", async () => {
39
+ await writeFile(join(dir, ".env"), "FOO=from-file\n");
40
+ const env: Record<string, string | undefined> = { FOO: "from-shell" };
41
+
42
+ const r = loadProjectDotenv(dir, env);
43
+
44
+ expect(env.FOO).toBe("from-shell");
45
+ expect(r.applied).not.toContain("FOO");
46
+ });
47
+
48
+ it("lets .env.local win over .env", async () => {
49
+ await writeFile(join(dir, ".env"), "FOO=base\nONLY_BASE=yes\n");
50
+ await writeFile(join(dir, ".env.local"), "FOO=override\n");
51
+ const env: Record<string, string | undefined> = {};
52
+
53
+ const r = loadProjectDotenv(dir, env);
54
+
55
+ expect(env.FOO).toBe("override");
56
+ expect(env.ONLY_BASE).toBe("yes");
57
+ expect(r.loaded).toEqual([".env", ".env.local"]);
58
+ });
59
+
60
+ it("says nothing when there is no .env at all", () => {
61
+ const env: Record<string, string | undefined> = {};
62
+
63
+ const r = loadProjectDotenv(dir, env);
64
+
65
+ expect(r.loaded).toEqual([]);
66
+ expect(r.warnings).toEqual([]);
67
+ expect(env).toEqual({});
68
+ });
69
+
70
+ // The regression this whole module exists for. magus 4.41.0 shipped a
71
+ // binary whose embedded Bun autoloaded .env and aborted on this exact shape:
72
+ // exit 1, nothing on stdout, nothing on stderr, in every git worktree where
73
+ // 1Password serves secrets through a pipe.
74
+ //
75
+ // Note the failure mode if this ever regresses: opening a FIFO with no writer
76
+ // BLOCKS, so a broken loader hangs here rather than failing an assertion.
77
+ // A timeout on this test is the same bug, reported differently.
78
+ it("skips a .env that is a symlink to a FIFO, and warns", async () => {
79
+ mkfifo(join(dir, "secrets-pipe"));
80
+ await symlink(join(dir, "secrets-pipe"), join(dir, ".env"));
81
+ const env: Record<string, string | undefined> = {};
82
+
83
+ const r = loadProjectDotenv(dir, env);
84
+
85
+ expect(r.loaded).toEqual([]);
86
+ expect(env).toEqual({});
87
+ expect(r.warnings).toHaveLength(1);
88
+ expect(r.warnings[0]).toContain(".env");
89
+ expect(r.warnings[0]).toContain("named pipe");
90
+ // The warning must give the real reason — the open blocks with no writer.
91
+ // It must NOT claim reading would steal another process's secrets: a
92
+ // 1Password pipe re-serves on every open, so that rationale is false.
93
+ expect(r.warnings[0]).toContain("blocks");
94
+ expect(r.warnings[0]).not.toContain("consume");
95
+ });
96
+
97
+ it("skips a .env that is a FIFO directly, and warns", () => {
98
+ mkfifo(join(dir, ".env"));
99
+ const env: Record<string, string | undefined> = {};
100
+
101
+ const r = loadProjectDotenv(dir, env);
102
+
103
+ expect(r.loaded).toEqual([]);
104
+ expect(r.warnings).toHaveLength(1);
105
+ expect(r.warnings[0]).toContain("named pipe");
106
+ });
107
+
108
+ it("follows a symlink that points at a regular file", async () => {
109
+ await writeFile(join(dir, "real-env"), "FOO=bar\n");
110
+ await symlink(join(dir, "real-env"), join(dir, ".env"));
111
+ const env: Record<string, string | undefined> = {};
112
+
113
+ const r = loadProjectDotenv(dir, env);
114
+
115
+ expect(env.FOO).toBe("bar");
116
+ expect(r.loaded).toEqual([".env"]);
117
+ expect(r.warnings).toEqual([]);
118
+ });
119
+
120
+ it("warns instead of throwing on a dangling symlink", async () => {
121
+ await symlink(join(dir, "nothing-here"), join(dir, ".env"));
122
+ const env: Record<string, string | undefined> = {};
123
+
124
+ const r = loadProjectDotenv(dir, env);
125
+
126
+ expect(r.loaded).toEqual([]);
127
+ expect(r.warnings).toHaveLength(1);
128
+ expect(r.warnings[0]).toContain("does not resolve");
129
+ });
130
+
131
+ it("skips a .env that is a directory, and warns", async () => {
132
+ await mkdir(join(dir, ".env"));
133
+ const env: Record<string, string | undefined> = {};
134
+
135
+ const r = loadProjectDotenv(dir, env);
136
+
137
+ expect(r.loaded).toEqual([]);
138
+ expect(r.warnings[0]).toContain("directory");
139
+ });
140
+
141
+ it("loads what it can from a file with unparseable lines", async () => {
142
+ await writeFile(
143
+ join(dir, ".env"),
144
+ "this is not a pair\nFOO=bar\n=novalue\n",
145
+ );
146
+ const env: Record<string, string | undefined> = {};
147
+
148
+ const r = loadProjectDotenv(dir, env);
149
+
150
+ expect(env.FOO).toBe("bar");
151
+ expect(r.warnings).toEqual([]);
152
+ });
153
+ });
154
+
155
+ describe("parseDotenv", () => {
156
+ it("ignores comments and blank lines", () => {
157
+ expect(parseDotenv("# note\n\nFOO=bar\n")).toEqual({ FOO: "bar" });
158
+ });
159
+
160
+ it("strips an export prefix", () => {
161
+ expect(parseDotenv("export FOO=bar\n")).toEqual({ FOO: "bar" });
162
+ });
163
+
164
+ it("strips surrounding quotes", () => {
165
+ expect(parseDotenv(`FOO="bar"\nBAZ='qux'\n`)).toEqual({
166
+ FOO: "bar",
167
+ BAZ: "qux",
168
+ });
169
+ });
170
+
171
+ it("expands escapes in double quotes but not single quotes", () => {
172
+ expect(parseDotenv(`A="one\\ntwo"\nB='one\\ntwo'\n`)).toEqual({
173
+ A: "one\ntwo",
174
+ B: "one\\ntwo",
175
+ });
176
+ });
177
+
178
+ it("drops a trailing comment from an unquoted value", () => {
179
+ expect(parseDotenv("FOO=bar # why\n")).toEqual({ FOO: "bar" });
180
+ });
181
+
182
+ it("keeps a # that is part of a quoted value", () => {
183
+ expect(parseDotenv(`FOO="bar # why"\n`)).toEqual({ FOO: "bar # why" });
184
+ });
185
+
186
+ it("keeps an = that appears inside a value", () => {
187
+ expect(parseDotenv("URL=postgres://u:p@h/db?a=b\n")).toEqual({
188
+ URL: "postgres://u:p@h/db?a=b",
189
+ });
190
+ });
191
+
192
+ it("rejects a key that is not a valid identifier", () => {
193
+ expect(parseDotenv("not-a-key=x\nOK=y\n")).toEqual({ OK: "y" });
194
+ });
195
+ });