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,717 @@
1
+ /**
2
+ * update-view.ts — every string `magus update` prints, and nothing else.
3
+ *
4
+ * Pure functions: plan in, lines out. No IO, no timers, no terminal. That is
5
+ * what makes the look testable — the old report was `console.log` calls braided
6
+ * through the control flow, so the only way to check a column lined up was to
7
+ * run the command against a real machine.
8
+ *
9
+ * ## The visual contract
10
+ *
11
+ * Three rules, applied mechanically rather than case by case:
12
+ *
13
+ * 1. **A discrete status is a badge**, not a coloured word: dark-or-white ink
14
+ * on a saturated fill, so it reads as a chip at a glance.
15
+ * 2. **A bounded value is a bar**, not a numeral. A step's share of the run's
16
+ * wall clock and the apply loop's completion are both bars; the number
17
+ * labels the bar rather than replacing it.
18
+ * 3. **Dim the chrome, saturate the signal.** A plugin already at the right
19
+ * version is chrome — grey, no chip. Everything that needs a decision or a
20
+ * download is bright. Nine grey rows and three coloured ones is the whole
21
+ * point: the eye lands on the three.
22
+ *
23
+ * Colour never carries meaning alone. Every action has its own glyph and its
24
+ * own word, so the report survives NO_COLOR, a pipe, and colour blindness.
25
+ */
26
+
27
+ import { binCommandFor } from "../services/update-engine.js";
28
+ import type {
29
+ BinUpdateItem,
30
+ PluginUpdateItem,
31
+ SkillUpdateItem,
32
+ UpdatePlan,
33
+ } from "../services/update-plan.js";
34
+ import { type ReportBadgeKind, reportBadge } from "../ui/theme-mode.js";
35
+ import { brand } from "../ui/theme.js";
36
+ import { formatCommand } from "../utils/command-utils.js";
37
+ import {
38
+ badge,
39
+ bold,
40
+ dim,
41
+ elapsed,
42
+ fg,
43
+ meter,
44
+ padEnd,
45
+ padStart,
46
+ ramps,
47
+ spinner,
48
+ stackedBar,
49
+ sweep,
50
+ width,
51
+ } from "./ansi.js";
52
+
53
+ /** Two spaces of gutter on every line, so the report reads as one block. */
54
+ const PAD = " ";
55
+ /** Bars are this wide everywhere; a bar that changes width cannot be compared. */
56
+ const BAR = 18;
57
+
58
+ // -- action vocabulary -------------------------------------------------------
59
+
60
+ /**
61
+ * The single source of truth for what an action looks like.
62
+ *
63
+ * Glyph, word and hue together — defined once so a plugin `install` and a
64
+ * binary `install` cannot drift into two different blues with two different
65
+ * symbols, which is exactly what happened when each row built its own string.
66
+ */
67
+ interface ActionStyle {
68
+ glyph: string;
69
+ label: string;
70
+ color: string;
71
+ /** Chrome rows are dimmed whole and carry no chip. */
72
+ chrome?: boolean;
73
+ }
74
+
75
+ /** Every action any of the three planners can produce, in one union. */
76
+ type ActionKey =
77
+ | "current"
78
+ | "install"
79
+ | "update"
80
+ | "upgrade"
81
+ | "repair"
82
+ | "refresh"
83
+ | "unknown";
84
+
85
+ const ACTIONS: Record<ActionKey, ActionStyle> = {
86
+ current: { glyph: "=", label: "CURRENT", color: brand.muted, chrome: true },
87
+ install: { glyph: "+", label: "INSTALL", color: brand.link },
88
+ // Green, the tone magus uses for an update everywhere else. Amber stays
89
+ // on `unknown` below, so the two states remain distinguishable: green is
90
+ // "a newer version exists", amber is "we could not tell".
91
+ update: { glyph: "↑", label: "UPDATE", color: brand.success },
92
+ upgrade: { glyph: "↑", label: "UPGRADE", color: brand.success },
93
+ repair: { glyph: "⟳", label: "REPAIR", color: brand.info },
94
+ refresh: { glyph: "⟳", label: "REFRESH", color: brand.info },
95
+ // Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
96
+ // as "broken". The summary line and the TUI already use warning for it.
97
+ unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
98
+ };
99
+
100
+ /** Widest label plus the badge's own two spaces of padding. */
101
+ const CHIP_WIDTH =
102
+ Math.max(...Object.values(ACTIONS).map((a) => a.label.length)) + 2;
103
+
104
+ /** A chip for an actionable row; flat dim text for a chrome one. */
105
+ function chip(key: ActionKey): string {
106
+ const style = ACTIONS[key];
107
+ const text = style.chrome
108
+ ? dim(padEnd(` ${style.label} `, CHIP_WIDTH))
109
+ : badge(style.label, style.color);
110
+ return padEnd(text, CHIP_WIDTH);
111
+ }
112
+
113
+ function glyph(key: ActionKey): string {
114
+ return fg(ACTIONS[key].color, ACTIONS[key].glyph);
115
+ }
116
+
117
+ /** Bright for a row that needs something, grey for a row that does not. */
118
+ function rowText(key: ActionKey, text: string): string {
119
+ return ACTIONS[key].chrome ? dim(text) : text;
120
+ }
121
+
122
+ // -- header ------------------------------------------------------------------
123
+
124
+ export function header(profileId: string): string[] {
125
+ return [
126
+ "",
127
+ `${PAD}${fg(brand.accent, "◆")} ${bold("magus update")} ${dim(
128
+ "profile",
129
+ )} ${fg(brand.accent, profileId)}`,
130
+ "",
131
+ ];
132
+ }
133
+
134
+ // -- phase checklist ---------------------------------------------------------
135
+
136
+ export type StepState = "pending" | "running" | "done" | "warn" | "fail";
137
+
138
+ export interface Step {
139
+ label: string;
140
+ state: StepState;
141
+ /** Right-hand summary. While running, say what it is blocked on. */
142
+ detail: string;
143
+ startedAt?: number;
144
+ endedAt?: number;
145
+ /**
146
+ * Countable work, when the step has any.
147
+ *
148
+ * Present → a determinate meter. Absent → an indeterminate sweep, which is
149
+ * the honest widget for a call with no denominator. Never fake a percentage
150
+ * for one: a bar that creeps to 90% and stops is worse than no bar.
151
+ */
152
+ progress?: { done: number; total: number };
153
+ }
154
+
155
+ const STEP_MARK: Record<Exclude<StepState, "running">, [string, string]> = {
156
+ pending: ["·", brand.muted],
157
+ done: ["✓", brand.success],
158
+ warn: ["⚠", brand.warning],
159
+ fail: ["✗", brand.danger],
160
+ };
161
+
162
+ function stepMark(step: Step, tick: number): string {
163
+ if (step.state === "running") return fg(brand.accent, spinner(tick));
164
+ const [mark, color] = STEP_MARK[step.state];
165
+ return fg(color, mark);
166
+ }
167
+
168
+ function stepDuration(step: Step, now: number): number | null {
169
+ if (step.startedAt === undefined) return null;
170
+ return (step.endedAt ?? now) - step.startedAt;
171
+ }
172
+
173
+ const STEP_LABEL_WIDTH = 14;
174
+
175
+ /**
176
+ * The animated frame: one row per step, spinner on whichever is running.
177
+ *
178
+ * Deliberately shows the pending steps too. A single spinner says "busy"; a
179
+ * four-row checklist with one spinner says "busy, on step two of four, and here
180
+ * is what is left" — which is the difference between a hang and progress.
181
+ */
182
+ export function stepFrame(steps: Step[], tick: number, now: number): string[] {
183
+ return steps.map((step) => {
184
+ const ms = stepDuration(step, now);
185
+ const time = ms === null ? "" : dim(padStart(elapsed(ms), 6));
186
+ const label =
187
+ step.state === "pending"
188
+ ? dim(padEnd(step.label, STEP_LABEL_WIDTH))
189
+ : padEnd(step.label, STEP_LABEL_WIDTH);
190
+ // Only the running row carries a bar. A finished row's bar belongs in the
191
+ // record below, where every step is on one scale and comparable; drawing
192
+ // it here too would put two competing bar columns on the same screen.
193
+ const bar =
194
+ step.state !== "running"
195
+ ? " ".repeat(BAR)
196
+ : step.progress && step.progress.total > 0
197
+ ? meter((step.progress.done / step.progress.total) * 100, BAR)
198
+ : sweep(BAR, tick, brand.accent);
199
+ // The bar carries the shape; this labels it. Without the count a half-full
200
+ // meter says "some of it", which is not the same information as "2 of 4".
201
+ const count =
202
+ step.state === "running" && step.progress && step.progress.total > 0
203
+ ? `${fg(brand.accent, `${step.progress.done}/${step.progress.total}`)} `
204
+ : "";
205
+ const detail =
206
+ step.state === "running"
207
+ ? fg(brand.accent, step.detail)
208
+ : dim(step.detail);
209
+ return `${PAD}${stepMark(step, tick)} ${label} ${bar} ${time} ${count}${detail}`;
210
+ });
211
+ }
212
+
213
+ /**
214
+ * The permanent record: one row per step, its duration, and what it found.
215
+ *
216
+ * NO BAR. Each row carried an 18-cell meter of that step's share of the
217
+ * slowest step — which is precisely what the right-aligned millisecond column
218
+ * beside it already says, so the row stated one fact twice. A shape earns its
219
+ * width by making something readable that the number does not; a share of a
220
+ * five-row set, already sorted into a column, is not that.
221
+ *
222
+ * The LIVE frame keeps its bar (`stepFrame` above) and that is not the same
223
+ * widget: there the bar is a progress meter or an indeterminate sweep on the
224
+ * step running right now, and it answers "is this working or hung?" — a
225
+ * question no printed number can answer.
226
+ */
227
+ export function stepRecord(steps: Step[], now: number): string[] {
228
+ return steps.map((step) => {
229
+ const label = padEnd(step.label, STEP_LABEL_WIDTH);
230
+ const time = dim(padStart(elapsed(stepDuration(step, now) ?? 0), 6));
231
+ return `${PAD}${stepMark(step, 0)} ${label} ${time} ${dim(step.detail)}`;
232
+ });
233
+ }
234
+
235
+ // -- the plan ----------------------------------------------------------------
236
+
237
+ function pluginAction(item: PluginUpdateItem): ActionKey {
238
+ return item.action;
239
+ }
240
+
241
+ /** What the row says on its right: versions, never the action word again. */
242
+ function pluginDetail(item: PluginUpdateItem): string {
243
+ // A note the row's own badge already states is noise, not information. The
244
+ // DEPRECATED chip and the heading above it both say "no configured
245
+ // marketplace offers this", so printing the sentence again put the longest
246
+ // string in the table on the least interesting rows.
247
+ //
248
+ // The note stays on the item. The TUI has no heading to lean on and renders
249
+ // it as that row's only explanation — this is a rendering decision about a
250
+ // table that carries the fact twice, not a change to what is known.
251
+ const note =
252
+ item.note && item.reason !== "not-offered"
253
+ ? ` ${dim(`(${item.note})`)}`
254
+ : "";
255
+ switch (item.action) {
256
+ case "install":
257
+ return `${item.target ?? "latest"}${note}`;
258
+ case "update":
259
+ // The GROUP's page tone, not a literal and not ACTIONS: the arrow and
260
+ // the target sit in the same row as that group's glyph and chip, so
261
+ // they must resolve per page from the same place. A hardcoded tone
262
+ // here is how the row ended up half green and half amber.
263
+ return `${dim(item.installed ?? "—")} ${fg(
264
+ groupStyle("Update").text,
265
+ "→",
266
+ )} ${fg(groupStyle("Update").text, item.target ?? "?")}${note}`;
267
+ case "repair":
268
+ case "unknown":
269
+ return `${item.installed ?? "—"}${note}`;
270
+ case "current":
271
+ return `${item.installed ?? "—"}${note}`;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * What a tool row says on its right.
277
+ *
278
+ * A `current` row used to print `pinned —` unconditionally, which was true only
279
+ * while "current" implied "pinned". An unpinned tool can be current now, so the
280
+ * pin is shown only when there is one.
281
+ */
282
+ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
283
+ const spec = plan.binSpecs.get(item.name);
284
+ // Rendered for the reader's eyes only — this row says what WILL run, while
285
+ // applyBins runs the argv itself. Same builder, so they cannot differ.
286
+ const cmd = spec ? binCommandFor(item, spec) : null;
287
+ const command = cmd ? dim(formatCommand(cmd)) : "";
288
+ switch (item.action) {
289
+ case "current":
290
+ return `${item.installed ?? "—"}${
291
+ item.version ? ` ${dim(`pinned ${item.version}`)}` : ""
292
+ }`;
293
+ case "upgrade":
294
+ return `${dim(item.installed ?? "—")} ${fg(
295
+ ACTIONS.upgrade.color,
296
+ "→",
297
+ )} ${fg(
298
+ ACTIONS.upgrade.color,
299
+ item.target ?? "?",
300
+ )}${command ? ` ${command}` : ""}${
301
+ // An upgrade that also changes the tool's owner says so here; a
302
+ // plain one reads identically to before.
303
+ item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
304
+ }`;
305
+ case "unknown":
306
+ return `${item.installed ?? "—"}${
307
+ item.note ? ` ${dim(`(${item.note})`)}` : ""
308
+ }`;
309
+ case "install":
310
+ return command;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * A section: a stacked bar of the action mix, then one row per item.
316
+ *
317
+ * The bar is the section's whole story in one line — a reader who takes in
318
+ * nothing else knows whether this section is mostly settled or mostly work.
319
+ */
320
+ /**
321
+ * Which heading a plugin row sits under.
322
+ *
323
+ * Read off the plan's machine codes — `action` and `reason` — never the human
324
+ * note. Five headings, and no more:
325
+ *
326
+ * Update something an apply would move forward
327
+ * Deprecated no configured marketplace offers it
328
+ * Fix required declared or enabled, with nothing on disk
329
+ * Unverified the check itself did not complete
330
+ * Current settled — the version was compared, or there was none to compare
331
+ *
332
+ * `Current` is last on purpose: it is the biggest group and the least
333
+ * interesting, and leading with it buries the few rows that need a decision.
334
+ *
335
+ * UNVERIFIED IS NOT CURRENT, and the two must not share a heading. A row whose
336
+ * catalog read failed used to land under `Current` carrying a `?` glyph and a
337
+ * note saying the catalog was unreachable — a row that contradicted the heading
338
+ * above it, while the closing notices insisted those rows are NOT reported as up
339
+ * to date. That is a different claim from "nothing could be compared, so we
340
+ * assume the install is the latest", which IS `current` and reaches it in the
341
+ * classifier, not here.
342
+ *
343
+ * "Unverified" is the word `summaryLine` already prints for this count; it is
344
+ * this report's existing vocabulary, not a new one.
345
+ */
346
+ function pluginGroup(item: PluginUpdateItem): string {
347
+ if (item.action === "update" || item.action === "repair") return "Update";
348
+ if (item.action === "install") return "Fix required";
349
+ if (item.action === "unknown") {
350
+ return item.reason === "not-offered" ? "Deprecated" : "Unverified";
351
+ }
352
+ return "Current";
353
+ }
354
+
355
+ const GROUP_ORDER = [
356
+ "Update",
357
+ "Deprecated",
358
+ "Fix required",
359
+ "Unverified",
360
+ "Current",
361
+ ];
362
+
363
+ /**
364
+ * Colour and emphasis per GROUP.
365
+ *
366
+ * "Dim the chrome, saturate the signal" is this file's own rule, stated at the
367
+ * top; the signal is the groups an apply would act on. Every colour comes from
368
+ * `brand`, which ui/theme.ts contrast-checks against BOTH a cream and a
369
+ * near-black terminal, so a light theme needs no second palette.
370
+ */
371
+ const GROUP_KIND: Record<string, ReportBadgeKind> = {
372
+ Update: "update",
373
+ Deprecated: "deprecated",
374
+ "Fix required": "fix",
375
+ };
376
+
377
+ interface GroupStyle {
378
+ /** Heading and glyph, drawn on the page. */
379
+ text: string;
380
+ /** Badge fill and its ink. Absent on a chrome group, which carries no badge. */
381
+ fill?: string;
382
+ ink?: string;
383
+ chrome?: boolean;
384
+ }
385
+
386
+ /**
387
+ * A group's colours, for the page this run resolved.
388
+ *
389
+ * `reportBadge` answers per page — pale fill with deep ink on cream, saturated
390
+ * fill with pale ink on near-black — so this is the one place the report stops
391
+ * using a single set of hues for both. `brand` is still right for everything
392
+ * else here: those tokens are chosen to clear 3:1 as INK on either page, which
393
+ * is a different question from what a filled chip should look like.
394
+ */
395
+ function groupStyle(group: string): GroupStyle {
396
+ const kind = GROUP_KIND[group];
397
+ if (!kind) return { text: brand.muted, chrome: true };
398
+ const ink = reportBadge(kind);
399
+ return { text: ink.text, fill: ink.fill, ink: ink.ink };
400
+ }
401
+
402
+ /** The badge's word: the heading, uppercased. Never a second vocabulary. */
403
+ function groupBadge(group: string): string {
404
+ return group.toUpperCase();
405
+ }
406
+
407
+ /**
408
+ * Badge column width, from the labels THIS report shows — a run with nothing
409
+ * deprecated does not pay for the longest word. `width` ignores escape bytes,
410
+ * so measuring an already painted badge is safe.
411
+ */
412
+ function badgeWidth(items: PluginUpdateItem[]): number {
413
+ return Math.max(
414
+ 0,
415
+ ...items
416
+ .filter((item) => !groupStyle(pluginGroup(item)).chrome)
417
+ .map((item) => {
418
+ const style = groupStyle(pluginGroup(item));
419
+ return (
420
+ width(
421
+ badge(
422
+ groupBadge(pluginGroup(item)),
423
+ style.fill ?? brand.muted,
424
+ style.ink,
425
+ ),
426
+ ) + 2
427
+ );
428
+ }),
429
+ );
430
+ }
431
+
432
+ function pluginRow(
433
+ item: PluginUpdateItem,
434
+ nameWidth: number,
435
+ chipWidth: number,
436
+ ): string {
437
+ const group = pluginGroup(item);
438
+ const style = groupStyle(group);
439
+ const name = padEnd(item.pluginId, nameWidth);
440
+ // Only an actionable row is badged; a chrome row gets the same width in
441
+ // spaces, so every version still starts in one column.
442
+ const chip = padEnd(
443
+ style.chrome
444
+ ? ""
445
+ : badge(groupBadge(group), style.fill ?? brand.muted, style.ink),
446
+ chipWidth,
447
+ );
448
+ return `${PAD}${fg(style.text, ACTIONS[item.action].glyph)} ${
449
+ style.chrome ? dim(name) : name
450
+ } ${chip}${pluginDetail(item)}`;
451
+ }
452
+
453
+ /** The Plugins section: grouped by state, `Current` last. */
454
+ function pluginSection(items: PluginUpdateItem[], nameWidth: number): string[] {
455
+ if (items.length === 0) return [];
456
+
457
+ const byGroup = new Map<string, PluginUpdateItem[]>();
458
+ for (const item of items) {
459
+ const group = pluginGroup(item);
460
+ const list = byGroup.get(group);
461
+ if (list) list.push(item);
462
+ else byGroup.set(group, [item]);
463
+ }
464
+
465
+ // GROUP_ORDER first, then anything it does not name. A report may never
466
+ // silently drop a row: driving the output purely from a fixed list would
467
+ // render an unexpected group as nothing at all while the header still
468
+ // counted it.
469
+ const ordered = [
470
+ ...GROUP_ORDER.filter((g) => byGroup.has(g)),
471
+ ...[...byGroup.keys()].filter((g) => !GROUP_ORDER.includes(g)),
472
+ ];
473
+
474
+ const chipWidth = badgeWidth(items);
475
+ // No bar: it stacked the mix into one strip above groups that already print
476
+ // their own counts, in a form nobody can read a number off.
477
+ const lines: string[] = [
478
+ "",
479
+ `${PAD}${bold("Plugins")} ${dim(String(items.length))}`,
480
+ ];
481
+
482
+ for (const group of ordered) {
483
+ const rows = byGroup.get(group);
484
+ if (!rows || rows.length === 0) continue;
485
+ lines.push(
486
+ "",
487
+ `${PAD}${bold(fg(groupStyle(group).text, group))} ${dim(
488
+ String(rows.length),
489
+ )}`,
490
+ );
491
+ for (const row of rows) lines.push(pluginRow(row, nameWidth, chipWidth));
492
+ }
493
+
494
+ return lines;
495
+ }
496
+
497
+ function section(
498
+ title: string,
499
+ rows: Array<{ action: ActionKey; name: string; detail: string }>,
500
+ nameWidth: number,
501
+ ): string[] {
502
+ if (rows.length === 0) return [];
503
+ const counts = new Map<ActionKey, number>();
504
+ for (const row of rows)
505
+ counts.set(row.action, (counts.get(row.action) ?? 0) + 1);
506
+ const legend = [...counts]
507
+ .map(([action, n]) =>
508
+ fg(ACTIONS[action].color, `${n} ${ACTIONS[action].label.toLowerCase()}`),
509
+ )
510
+ .join(dim(" · "));
511
+
512
+ return [
513
+ "",
514
+ `${PAD}${bold(padEnd(title, 9))} ${dim(
515
+ padStart(String(rows.length), 3),
516
+ )} ${legend}`,
517
+ "",
518
+ ...rows.map(
519
+ (row) =>
520
+ `${PAD}${glyph(row.action)} ${rowText(
521
+ row.action,
522
+ padEnd(row.name, nameWidth),
523
+ )} ${chip(row.action)} ${row.detail}`,
524
+ ),
525
+ ];
526
+ }
527
+
528
+ export function planReport(plan: UpdatePlan): string[] {
529
+ const nameWidth = Math.max(
530
+ 12,
531
+ ...plan.plugins.map((p) => width(p.pluginId)),
532
+ ...plan.bins.map((b) => width(b.name)),
533
+ ...plan.skills.map((s) => width(s.name)),
534
+ );
535
+
536
+ // EVERY planned plugin gets a row. There is no filter here and there must
537
+ // not be one.
538
+ //
539
+ // A previous revision dropped `unknown` + `not-offered` + not-installed rows
540
+ // — a pin naming a plugin no marketplace offers — on the grounds that a
541
+ // notice named them instead. That notice did not exist: the function the
542
+ // comment cited was never written. The row appeared in no table and no
543
+ // notice, while the closing lines still counted it and said "Reasons are
544
+ // shown above", pointing at nothing.
545
+ //
546
+ // A report may never silently drop a row. That is the whole defect this
547
+ // file's grouping was written to end, and a filter here reintroduces it in a
548
+ // form that is harder to see, because the header count agrees with the rows
549
+ // and only the plan knows something is missing.
550
+ return [
551
+ ...pluginSection(plan.plugins, nameWidth),
552
+ ...section(
553
+ "CLI",
554
+ plan.bins.map((item: BinUpdateItem) => ({
555
+ action: item.action as ActionKey,
556
+ name: item.name,
557
+ detail: binDetail(item, plan),
558
+ })),
559
+ nameWidth,
560
+ ),
561
+ ...section(
562
+ "Skills",
563
+ plan.skills.map((item: SkillUpdateItem) => ({
564
+ action: item.action as ActionKey,
565
+ name: item.name,
566
+ detail: dim(`${item.ref.repo}/${item.ref.path}`),
567
+ })),
568
+ nameWidth,
569
+ ),
570
+ ];
571
+ }
572
+
573
+ // -- apply -------------------------------------------------------------------
574
+
575
+ export interface ApplyState {
576
+ /** Items finished, of `total`. */
577
+ done: number;
578
+ total: number;
579
+ /** What is being worked on right now. */
580
+ current: string;
581
+ startedAt: number;
582
+ }
583
+
584
+ /**
585
+ * The animated apply frame: a gradient meter that turns green as it fills.
586
+ *
587
+ * The meter's ramp encodes POSITION along the fill, which is what a smooth
588
+ * gradient is for — the count beside it is the label, not the signal.
589
+ */
590
+ export function applyFrame(
591
+ state: ApplyState,
592
+ tick: number,
593
+ now: number,
594
+ ): string[] {
595
+ const pct = state.total === 0 ? 100 : (state.done / state.total) * 100;
596
+ const count = `${state.done}/${state.total}`;
597
+ return [
598
+ "",
599
+ `${PAD}${fg(brand.accent, spinner(tick))} ${meter(pct, BAR * 2)} ${padStart(
600
+ count,
601
+ 7,
602
+ )} ${fg(brand.accent, state.current)} ${dim(
603
+ elapsed(now - state.startedAt),
604
+ )}`,
605
+ ];
606
+ }
607
+
608
+ /** One finished item, written permanently above the meter. */
609
+ export function applyRow(
610
+ ok: boolean,
611
+ name: string,
612
+ nameWidth: number,
613
+ detail: string,
614
+ ms: number,
615
+ ): string {
616
+ const mark = ok ? fg(brand.success, "✓") : fg(brand.danger, "✗");
617
+ return `${PAD}${mark} ${padEnd(name, nameWidth)} ${detail} ${dim(
618
+ padStart(elapsed(ms), 7),
619
+ )}`;
620
+ }
621
+
622
+ /**
623
+ * Closing line: the run's shape as a bar, then the counts that label it.
624
+ *
625
+ * `unchanged` is its own count: an installer that exited 0 while read-back
626
+ * shows nothing moved. Folding it into `changed` is how every run printed
627
+ * "3 changed" while changing nothing; it is not a failure either.
628
+ */
629
+ export function summaryLine(
630
+ changed: number,
631
+ current: number,
632
+ unknown: number,
633
+ failed: number,
634
+ ms: number,
635
+ unchanged = 0,
636
+ ): string[] {
637
+ const bar = stackedBar(BAR * 2, [
638
+ { value: changed, color: brand.success },
639
+ { value: current, color: brand.muted },
640
+ { value: unchanged + unknown, color: brand.warning },
641
+ { value: failed, color: brand.danger },
642
+ ]);
643
+ const parts = [
644
+ changed > 0 ? fg(brand.success, `${changed} changed`) : "",
645
+ unchanged > 0 ? fg(brand.warning, `${unchanged} unchanged`) : "",
646
+ current > 0 ? dim(`${current} current`) : "",
647
+ unknown > 0 ? fg(brand.warning, `${unknown} unverified`) : "",
648
+ failed > 0 ? fg(brand.danger, `${failed} failed`) : "",
649
+ ].filter(Boolean);
650
+ return [
651
+ "",
652
+ `${PAD}${bar} ${parts.join(dim(" · "))} ${dim(elapsed(ms))}`,
653
+ "",
654
+ ];
655
+ }
656
+
657
+ // -- one-off notices ---------------------------------------------------------
658
+
659
+ export function ok(text: string): string {
660
+ return `${PAD}${fg(brand.success, "✓")} ${text}`;
661
+ }
662
+
663
+ export function warn(text: string): string {
664
+ return `${PAD}${fg(brand.warning, "⚠")} ${text}`;
665
+ }
666
+
667
+ export function fail(text: string): string {
668
+ return `${PAD}${fg(brand.danger, "✗")} ${text}`;
669
+ }
670
+
671
+ export function info(text: string): string {
672
+ return `${PAD}${fg(brand.info, "›")} ${text}`;
673
+ }
674
+
675
+ export function note(text: string): string {
676
+ return `${PAD} ${dim(text)}`;
677
+ }
678
+
679
+ // -- unverified items ----------------------------------------------------------
680
+ //
681
+ // A plugin is `unknown` for several distinct reasons — its marketplace publishes
682
+ // no version, the two versions cannot be ordered, no marketplace offers it, its
683
+ // marketplace was not read, or the catalog fetch failed — and every row above
684
+ // already prints its own. These lines therefore claim only what holds for EVERY
685
+ // reason. They once advised "re-run once the catalog is reachable", which is
686
+ // false for a plugin whose marketplace publishes no version: re-running changes
687
+ // nothing, and the reader went looking for a network fault that did not exist.
688
+
689
+ /** Unverified tools, under the plan. */
690
+ export function unknownToolsNotice(count: number, names: string[]): string {
691
+ return warn(
692
+ `${bold(String(count))} tool(s) could not be checked: ${names.join(", ")} — not applied. Reasons are shown above.`,
693
+ );
694
+ }
695
+
696
+ /**
697
+ * `--check` is about to exit 0 while N items went unchecked.
698
+ *
699
+ * The gate does not fail on an unknown — plugin or tool — because most unknowns
700
+ * are permanent (a marketplace that publishes no version, a tool another npm
701
+ * owns), and a gate that can never pass protects nothing. This line is what
702
+ * keeps the exit 0 honest: it is "nothing is KNOWN to be behind", not
703
+ * "everything was verified". It counts plugins and tools together because the
704
+ * two per-kind notices above already name which is which.
705
+ */
706
+ export function uncheckedGateNotice(count: number): string {
707
+ return warn(
708
+ `${bold(String(count))} item(s) could not be checked — they do not fail this gate. They are NOT reported as up to date. Reasons are shown above.`,
709
+ );
710
+ }
711
+
712
+ /** The run ends with nothing to apply because every remaining plugin is unverified. */
713
+ export function nothingToApplyNotice(unknownPlugins: number): string {
714
+ return warn(
715
+ `Nothing to apply. ${unknownPlugins} plugin(s) could not be checked. They are NOT reported as up to date. Reasons are shown above.`,
716
+ );
717
+ }