@canonical/pragma-cli 0.35.0 → 0.37.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 (295) hide show
  1. package/README.md +2 -2
  2. package/bundled-skills/add-standard/SKILL.md +338 -0
  3. package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
  4. package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
  5. package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
  6. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
  7. package/bundled-skills/anatomy-author/SKILL.md +1226 -0
  8. package/bundled-skills/design-auditor/SKILL.md +455 -0
  9. package/bundled-skills/implement-component/SKILL.md +333 -0
  10. package/bundled-skills/specify-component/SKILL.md +331 -0
  11. package/bundled-skills/specify-pattern/SKILL.md +292 -0
  12. package/dist/package.json +24 -20
  13. package/dist/pragma.conf.js +266 -34
  14. package/dist/pragma.conf.js.map +1 -1
  15. package/dist/src/bin.js +1 -1
  16. package/dist/src/bin.js.map +1 -1
  17. package/dist/src/capabilities/capabilities/hints.js +1 -1
  18. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  19. package/dist/src/capabilities/colophon/colophon.render.js +1 -1
  20. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  21. package/dist/src/capabilities/create/create.verb.js +2 -1
  22. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  23. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  24. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  25. package/dist/src/capabilities/create/mount.js +4 -3
  26. package/dist/src/capabilities/create/mount.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  28. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  29. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
  30. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  31. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
  32. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
  33. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  34. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  35. package/dist/src/capabilities/doctor/checks/index.js +6 -6
  36. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  37. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  38. package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
  39. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  40. package/dist/src/capabilities/doctor/doctor.render.js +43 -64
  41. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
  42. package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
  43. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  44. package/dist/src/capabilities/doctor/runChecks.js +14 -11
  45. package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
  46. package/dist/src/capabilities/graph/query.render.js +2 -2
  47. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  48. package/dist/src/capabilities/info/collectInfo.js +8 -2
  49. package/dist/src/capabilities/info/collectInfo.js.map +1 -1
  50. package/dist/src/capabilities/info/info.render.js +6 -2
  51. package/dist/src/capabilities/info/info.render.js.map +1 -1
  52. package/dist/src/capabilities/prompt/prompt.render.js +2 -2
  53. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
  54. package/dist/src/capabilities/setup/buildPlan.js +43 -39
  55. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  56. package/dist/src/capabilities/setup/mode.js +2 -2
  57. package/dist/src/capabilities/setup/operations/index.js +4 -4
  58. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  60. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/setupGenerator.js +156 -58
  64. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  65. package/dist/src/capabilities/setup/operations/setupLsp.js +136 -38
  66. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  67. package/dist/src/capabilities/setup/operations/setupMcp.js +101 -52
  68. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  69. package/dist/src/capabilities/setup/operations/setupSkills.js +329 -49
  70. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  71. package/dist/src/capabilities/setup/plan.js.map +1 -1
  72. package/dist/src/capabilities/setup/plan.render.js +144 -66
  73. package/dist/src/capabilities/setup/plan.render.js.map +1 -1
  74. package/dist/src/capabilities/setup/setup.render.js +1 -1
  75. package/dist/src/capabilities/setup/setup.verb.js +165 -63
  76. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  77. package/dist/src/capabilities/setup/targets.js +122 -51
  78. package/dist/src/capabilities/setup/targets.js.map +1 -1
  79. package/dist/src/capabilities/shared/index.js +7 -6
  80. package/dist/src/capabilities/shared/index.js.map +1 -1
  81. package/dist/src/capabilities/shared/packageManager.js +300 -54
  82. package/dist/src/capabilities/shared/packageManager.js.map +1 -1
  83. package/dist/src/capabilities/skill/discover.js +97 -11
  84. package/dist/src/capabilities/skill/discover.js.map +1 -1
  85. package/dist/src/capabilities/skill/render.js +2 -2
  86. package/dist/src/capabilities/skill/render.js.map +1 -1
  87. package/dist/src/capabilities/sources/installSkills.js +145 -3
  88. package/dist/src/capabilities/sources/installSkills.js.map +1 -1
  89. package/dist/src/capabilities/sources/runUpdate.js +68 -2
  90. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  91. package/dist/src/capabilities/sources/update.verb.js +3 -3
  92. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  93. package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
  94. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
  95. package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
  96. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
  97. package/dist/src/constants.js +13 -2
  98. package/dist/src/constants.js.map +1 -1
  99. package/dist/src/kernel/index.js +11 -2
  100. package/dist/src/kernel/index.js.map +1 -1
  101. package/dist/src/kernel/interactivity.js +68 -0
  102. package/dist/src/kernel/interactivity.js.map +1 -0
  103. package/dist/src/kernel/packs/renderPack.js +3 -3
  104. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  105. package/dist/src/kernel/packs/resolveEntity.js +63 -12
  106. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  107. package/dist/src/kernel/packs/runBodies.js +57 -4
  108. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  109. package/dist/src/kernel/packs/schema.js +54 -2
  110. package/dist/src/kernel/packs/schema.js.map +1 -1
  111. package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
  112. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
  113. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +310 -43
  114. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  115. package/dist/src/kernel/packs/types.js.map +1 -1
  116. package/dist/src/kernel/project/cli/buildProgram.js +1 -1
  117. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  118. package/dist/src/kernel/project/cli/dispatch.js +46 -24
  119. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  120. package/dist/src/kernel/project/cli/globalFlags.js +4 -1
  121. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
  122. package/dist/src/kernel/project/cli/helpFormat.js +1 -2
  123. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
  124. package/dist/src/kernel/project/cli/rootHelp.js +42 -9
  125. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  126. package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
  127. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  128. package/dist/src/kernel/render/renderers.js +1 -1
  129. package/dist/src/kernel/render/style.js +29 -9
  130. package/dist/src/kernel/render/style.js.map +1 -1
  131. package/dist/src/kernel/render/vocabulary.js +174 -0
  132. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  133. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  134. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  135. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  136. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  137. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  138. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  139. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  140. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  141. package/dist/src/kernel/runtime/graphpack/read.js +14 -9
  142. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  143. package/dist/src/kernel/runtime/loadSession.js +3 -1
  144. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  145. package/dist/src/kernel/runtime/probe.js +1 -1
  146. package/dist/src/kernel/runtime/probe.js.map +1 -1
  147. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  148. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  149. package/dist/src/kernel/runtime/refs/resolve.js +9 -3
  150. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  151. package/dist/src/kernel/spec/emitReference.js +1 -1
  152. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  153. package/package.json +24 -20
  154. package/pragma.conf.ts +272 -34
  155. package/src/bin.ts +8 -1
  156. package/src/capabilities/block.parity.test.ts +25 -11
  157. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  158. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  159. package/src/capabilities/capabilities/hints.ts +1 -1
  160. package/src/capabilities/colophon/colophon.render.ts +1 -2
  161. package/src/capabilities/colophon/colophon.test.ts +2 -2
  162. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  163. package/src/capabilities/create/byteEquality.test.ts +28 -1
  164. package/src/capabilities/create/create.test.ts +1 -0
  165. package/src/capabilities/create/create.verb.ts +2 -1
  166. package/src/capabilities/create/createGrammar.test.ts +5 -2
  167. package/src/capabilities/create/createSurface.generated.ts +23 -1
  168. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  169. package/src/capabilities/create/mount.ts +3 -3
  170. package/src/capabilities/distribution.parity.test.ts +4 -0
  171. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  172. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  173. package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
  174. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
  175. package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
  176. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  177. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  178. package/src/capabilities/doctor/checks/index.ts +6 -6
  179. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  180. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  181. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  182. package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
  183. package/src/capabilities/doctor/doctor.render.test.ts +108 -32
  184. package/src/capabilities/doctor/doctor.render.ts +56 -80
  185. package/src/capabilities/doctor/doctor.test.ts +247 -35
  186. package/src/capabilities/doctor/doctor.verb.ts +7 -2
  187. package/src/capabilities/doctor/runChecks.ts +14 -11
  188. package/src/capabilities/doctor/types.ts +15 -24
  189. package/src/capabilities/emptyRecovery.test.ts +10 -2
  190. package/src/capabilities/graph/query.render.ts +2 -2
  191. package/src/capabilities/info/collectInfo.ts +8 -1
  192. package/src/capabilities/info/info.render.ts +6 -2
  193. package/src/capabilities/info/info.test.ts +37 -1
  194. package/src/capabilities/info/types.ts +10 -2
  195. package/src/capabilities/lazy.test.ts +26 -2
  196. package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
  197. package/src/capabilities/prompt/prompt.render.ts +2 -2
  198. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  199. package/src/capabilities/resources/resources.test.ts +38 -2
  200. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  201. package/src/capabilities/setup/buildPlan.ts +45 -41
  202. package/src/capabilities/setup/index.ts +1 -1
  203. package/src/capabilities/setup/mode.ts +2 -2
  204. package/src/capabilities/setup/operations/index.ts +6 -1
  205. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  206. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  207. package/src/capabilities/setup/operations/setupGenerator.ts +227 -84
  208. package/src/capabilities/setup/operations/setupLsp.ts +203 -38
  209. package/src/capabilities/setup/operations/setupMcp.ts +139 -62
  210. package/src/capabilities/setup/operations/setupSkills.ts +399 -60
  211. package/src/capabilities/setup/plan.render.ts +171 -76
  212. package/src/capabilities/setup/plan.ts +17 -12
  213. package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
  214. package/src/capabilities/setup/setup.render.test.ts +50 -36
  215. package/src/capabilities/setup/setup.render.ts +1 -1
  216. package/src/capabilities/setup/setup.test.ts +1126 -50
  217. package/src/capabilities/setup/setup.verb.ts +174 -68
  218. package/src/capabilities/setup/targets.ts +138 -60
  219. package/src/capabilities/setup/types.ts +10 -18
  220. package/src/capabilities/shared/index.ts +18 -6
  221. package/src/capabilities/shared/packageManager.test.ts +421 -6
  222. package/src/capabilities/shared/packageManager.ts +394 -59
  223. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  224. package/src/capabilities/skill/discover.ts +100 -11
  225. package/src/capabilities/skill/render.ts +2 -2
  226. package/src/capabilities/skill/skill.test.ts +17 -4
  227. package/src/capabilities/sources/installSkills.ts +181 -3
  228. package/src/capabilities/sources/runUpdate.ts +78 -2
  229. package/src/capabilities/sources/sources.test.ts +228 -0
  230. package/src/capabilities/sources/update.verb.ts +3 -3
  231. package/src/capabilities/surface.test.ts +14 -6
  232. package/src/capabilities/upgrade/runUpgrade.ts +45 -6
  233. package/src/capabilities/upgrade/types.ts +10 -2
  234. package/src/capabilities/upgrade/upgrade.render.ts +9 -2
  235. package/src/capabilities/upgrade/upgrade.test.ts +72 -0
  236. package/src/constants.ts +14 -1
  237. package/src/identity.test.ts +18 -5
  238. package/src/kernel/completion/entitySource.test.ts +15 -2
  239. package/src/kernel/completion/safety.test.ts +16 -9
  240. package/src/kernel/config/defaults.test.ts +19 -3
  241. package/src/kernel/config/readConfig.test.ts +1 -1
  242. package/src/kernel/index.ts +11 -2
  243. package/src/kernel/interactivity.test.ts +76 -0
  244. package/src/kernel/interactivity.ts +69 -0
  245. package/src/kernel/packs/compile.test.ts +92 -0
  246. package/src/kernel/packs/renderPack.ts +3 -3
  247. package/src/kernel/packs/resolveEntity.test.ts +75 -14
  248. package/src/kernel/packs/resolveEntity.ts +88 -24
  249. package/src/kernel/packs/runBodies.ts +84 -19
  250. package/src/kernel/packs/schema.ts +60 -4
  251. package/src/kernel/packs/sparql/applyFilters.ts +125 -4
  252. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  253. package/src/kernel/packs/sparql/buildLookupQuery.ts +327 -45
  254. package/src/kernel/packs/toolDescription.test.ts +35 -1
  255. package/src/kernel/packs/types.ts +125 -2
  256. package/src/kernel/project/cli/buildProgram.test.ts +73 -2
  257. package/src/kernel/project/cli/buildProgram.ts +8 -1
  258. package/src/kernel/project/cli/dispatch.test.ts +5 -4
  259. package/src/kernel/project/cli/dispatch.ts +48 -25
  260. package/src/kernel/project/cli/globalFlags.ts +4 -1
  261. package/src/kernel/project/cli/helpFormat.ts +1 -2
  262. package/src/kernel/project/cli/rootHelp.ts +44 -8
  263. package/src/kernel/project/mcp/registerVerb.ts +20 -1
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.ts +1 -1
  266. package/src/kernel/render/style.ts +32 -9
  267. package/src/kernel/render/vocabulary.ts +276 -0
  268. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  269. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  270. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  271. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  272. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  273. package/src/kernel/runtime/graphpack/read.ts +14 -12
  274. package/src/kernel/runtime/loadSession.ts +3 -1
  275. package/src/kernel/runtime/probe.ts +1 -1
  276. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  277. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  278. package/src/kernel/runtime/refs/resolve.ts +9 -3
  279. package/src/kernel/runtime/types.ts +17 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/types.ts +35 -6
  282. package/src/testing/PARITY_GAPS.ts +9 -3
  283. package/src/testing/behavioral/docExamples.test.ts +4 -2
  284. package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
  285. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  286. package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
  287. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  288. package/src/testing/eval/cases/readNouns.ts +245 -9
  289. package/src/testing/fixtures/blockGraph.ts +28 -13
  290. package/src/testing/fixtures/graph/canonical.ts +95 -12
  291. package/src/testing/helpers/runCli.test.ts +44 -0
  292. package/src/testing/helpers/runCli.ts +27 -3
  293. package/dist/src/capabilities/shared/bands.js +0 -16
  294. package/dist/src/capabilities/shared/bands.js.map +0 -1
  295. package/src/capabilities/shared/bands.ts +0 -16
@@ -1,40 +1,146 @@
1
1
  /**
2
- * Storeless install-source heuristic, shared by `info`, `upgrade`, and
2
+ * Storeless install-source detector, shared by `info`, `upgrade`, and
3
3
  * `doctor`'s `pragma version` check.
4
4
  *
5
- * Promotes the detector that lived inline in `info/collectInfo.ts`: the package
6
- * manager comes from npm's `npm_config_user_agent` (set by npm/pnpm/yarn/bun
7
- * when they run a script), the scope from whether the installed entry sits
8
- * under the CURRENT PROJECT. With no agent we report the honest runtime rather
9
- * than guessing. The old shell's full package.json walk (`#package-manager`) is
10
- * dropped — only this heuristic and the update-command map survive.
5
+ * Detection is driven by the FILESYSTEM — the resolved entry path, the invoked
6
+ * bin path, and the symlinks between them — not by environment variables. The
7
+ * previous heuristic read `npm_config_user_agent`, which npm/pnpm/yarn/bun set
8
+ * only when THEY run a script; a user typing the bin name in a shell never has
9
+ * it, so on the path that mattered the detector always fell through to the
10
+ * honest runtime and reported `node` for every install. The agent survives
11
+ * only as a last-resort corroboration for a workspace install whose tree
12
+ * carries no other marker.
13
+ *
14
+ * The two paths that matter, and why both are needed:
15
+ *
16
+ * - `import.meta.url` is REALPATH-RESOLVED by the module loader. For a normal
17
+ * install it sits inside `node_modules/<pkg>`; for a linked development
18
+ * install (`npm link` / `bun link`) it has already escaped to the source
19
+ * checkout and contains no `node_modules` at all — which is precisely why an
20
+ * entry-only detector cannot see a link.
21
+ * - `process.argv[1]` preserves the path the user actually invoked, symlinks
22
+ * intact. Walking its link chain finds the `node_modules/<pkg>` entry the
23
+ * bin points through, and whether THAT directory is itself a symlink
24
+ * pointing outside the install root — the linked-install signal. The chain
25
+ * is only trusted when the package directory it names really contains the
26
+ * running module (`realPath` identity check), so a foreign bin on argv[1]
27
+ * (vitest, a wrapper script) never misattributes the install.
28
+ *
29
+ * The manager is identified by path SHAPE (`.bun/`, `.pnpm`/`pnpm`, `.volta`,
30
+ * `.asdf`, yarn's global dir), ephemeral runners by their cache shapes
31
+ * (`_npx`, `bunx-*`, pnpm's `dlx`), and a workspace's manager by the lockfile
32
+ * beside the install root. When no signal is conclusive the detector answers
33
+ * `unknown` — a state with NO update command, because a confidently wrong
34
+ * `npm i -g` is worse than none (against a linked checkout it would overwrite
35
+ * the link to the tree being developed). `pmUpdateCommand` accepts only the
36
+ * {@link GlobalInstall} arm, so offering a command to any other state is a
37
+ * type error, not a convention.
38
+ *
39
+ * Read-only and storeless: a handful of `lstat`/`readlink`/`realpath`/`exists`
40
+ * probes, bounded by the link-chain cap — nothing is ever written. The module
41
+ * sits on the fast-path static graph (via the shared barrel), so all probing
42
+ * happens at call time; module load stays inert.
43
+ *
44
+ * Windows note: segment matching splits on both separators, so the SHAPE
45
+ * checks (`managerFromPath`, `ephemeralRunner`) are exercised against
46
+ * win32-style fixtures — but scope containment goes through the host's
47
+ * `node:path` and, like `platformPaths.ts`, the win32 arm is a conventional
48
+ * best guess that has NOT been validated on a real Windows machine.
11
49
  */
12
50
 
13
- import { isAbsolute, relative, resolve, sep } from "node:path";
51
+ import { existsSync, readlinkSync, realpathSync } from "node:fs";
52
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
14
53
  import { fileURLToPath } from "node:url";
54
+ import {
55
+ INSTALL_UPDATE_GUIDANCE,
56
+ installSourceLabel,
57
+ } from "../../kernel/render/vocabulary.js";
15
58
 
16
- /** The install source: package manager and a display label. */
17
- export interface InstallSource {
18
- /** Package-manager name (`bun`/`npm`/`pnpm`/`yarn`), else the honest runtime. */
19
- readonly pm: string;
20
- /** `${pm} (${scope})` — the string `info`/`doctor` display. */
59
+ /** The package managers the detector can name (volta counts: it owns the
60
+ * install and its own update command). */
61
+ export type PackageManager = "npm" | "pnpm" | "yarn" | "bun" | "volta";
62
+
63
+ /** The ephemeral runners — cache-resolved, with no meaningful upgrade. */
64
+ export type EphemeralRunner = "npx" | "bunx" | "pnpm dlx";
65
+
66
+ /** A global install by a known manager — the ONLY updatable state. */
67
+ export interface GlobalInstall {
68
+ readonly kind: "global";
69
+ readonly pm: PackageManager;
21
70
  readonly label: string;
22
71
  }
23
72
 
24
- /** The package manager from npm's user-agent, else the honest runtime. */
25
- function packageManager(): string {
26
- const agent = process.env.npm_config_user_agent;
27
- const name = agent?.split("/")[0];
28
- if (name) return name;
29
- return process.versions.bun ? "bun" : "node";
73
+ /** Installed as a dependency of the project the command runs in. */
74
+ export interface WorkspaceInstall {
75
+ readonly kind: "workspace";
76
+ /** The project's manager, when a lockfile/shape/agent names one. */
77
+ readonly pm?: PackageManager;
78
+ /** The project directory the package is installed into. */
79
+ readonly root: string;
80
+ readonly label: string;
81
+ }
82
+
83
+ /** A linked development install: `node_modules/<pkg>` is a symlink pointing
84
+ * OUTSIDE the install root (`npm link` / `bun link` / `pnpm link`). */
85
+ export interface LinkedInstall {
86
+ readonly kind: "linked";
87
+ /** The manager whose global tree hosts the link, when its shape names one. */
88
+ readonly pm?: PackageManager;
89
+ /** The resolved link target — the development checkout. */
90
+ readonly target: string;
91
+ readonly label: string;
92
+ }
93
+
94
+ /** An `npx`/`bunx`/`pnpm dlx` run out of a runner cache. */
95
+ export interface EphemeralInstall {
96
+ readonly kind: "ephemeral";
97
+ readonly runner: EphemeralRunner;
98
+ readonly label: string;
99
+ }
100
+
101
+ /** No conclusive signal — report the honest runtime and offer nothing. */
102
+ export interface UnknownInstall {
103
+ readonly kind: "unknown";
104
+ readonly runtime: "node" | "bun";
105
+ readonly label: string;
30
106
  }
31
107
 
108
+ /** The install source — a closed union; only {@link GlobalInstall} updates. */
109
+ export type InstallSource =
110
+ | GlobalInstall
111
+ | WorkspaceInstall
112
+ | LinkedInstall
113
+ | EphemeralInstall
114
+ | UnknownInstall;
115
+
32
116
  /**
33
- * Detect how the binary was installed — package manager and scope.
34
- *
35
- * @returns The install source (pm, label).
36
- * @note Impure — reads `import.meta.url`, the working directory, and `process.env`.
117
+ * Everything the classifier reads, captured as a value so tests drive any
118
+ * layout as a fixture — the `PlatformEnv` pattern from
119
+ * `@canonical/harnesses/platformPaths.ts`, with the filesystem probes
120
+ * injected the way `detectWsl` injects its `/proc/version` reader.
37
121
  */
122
+ export interface InstallProbe {
123
+ /** The running module's own path (`import.meta.url`, realpath-resolved). */
124
+ readonly entry: string;
125
+ /** The invoked script path (`process.argv[1]`), symlinks preserved. */
126
+ readonly invoked: string | undefined;
127
+ readonly cwd: string;
128
+ /** `npm_config_user_agent` — corroboration only, absent on a bare run. */
129
+ readonly userAgent: string | undefined;
130
+ readonly runtime: "node" | "bun";
131
+ /** A symlink's raw target, or `undefined` when not a symlink/unreadable. */
132
+ readonly readLink: (path: string) => string | undefined;
133
+ /** Fully-resolved real path, or `undefined` when unresolvable. */
134
+ readonly realPath: (path: string) => string | undefined;
135
+ readonly exists: (path: string) => boolean;
136
+ }
137
+
138
+ /** Split on both separators, so shape checks read win32 paths too. */
139
+ const splitPath = (path: string): string[] => path.split(/[\\/]/);
140
+
141
+ /** The separator the input path itself uses (win32 fixtures keep theirs). */
142
+ const sepOf = (path: string): string => (path.includes("\\") ? "\\" : "/");
143
+
38
144
  /**
39
145
  * The directory a package was installed INTO — the parent of the `node_modules`
40
146
  * holding it, so `/proj/node_modules/@scope/x/dist/bin.js` yields `/proj`.
@@ -48,9 +154,9 @@ function packageManager(): string {
48
154
  * @remarks Exported for its unit test; not part of the package's public surface.
49
155
  */
50
156
  export function findInstallRoot(entry: string): string | undefined {
51
- const segments = entry.split(sep);
157
+ const segments = splitPath(entry);
52
158
  const index = segments.lastIndexOf("node_modules");
53
- return index === -1 ? undefined : segments.slice(0, index).join(sep);
159
+ return index === -1 ? undefined : segments.slice(0, index).join(sepOf(entry));
54
160
  }
55
161
 
56
162
  /**
@@ -70,49 +176,278 @@ export function containsPath(directory: string, candidate: string): boolean {
70
176
  return offset === "" || (!offset.startsWith("..") && !isAbsolute(offset));
71
177
  }
72
178
 
73
- export function detectInstallSource(): InstallSource {
74
- // The entry's own location, NOT `process.argv[1]`. Under the compiled binary
75
- // argv[1] was the user's FIRST ARGUMENT (the executable was argv[0]), so the
76
- // old `includes("node_modules")` test read a command word and answered
77
- // "global" for almost everything. Shipping `node dist/src/bin.js` moves the
78
- // entry path INTO argv[1], where it always contains `node_modules` — which
79
- // would answer "local" for everything, the same bug facing the other way.
80
- // `import.meta.url` is the honest source either way.
81
- const entry = fileURLToPath(import.meta.url);
82
- // LOCAL means the working directory sits inside the project this package was
83
- // installed into — not merely that the entry sits under the cwd. Asking the
84
- // narrower question called a perfectly ordinary local install "global" the
85
- // moment the user ran it from a subdirectory, which in a monorepo is the
86
- // usual case. Going the other way, deriving the install root also settles the
87
- // $HOME-nested global prefixes (nvm, `~/.npm-global`): their root is the
88
- // prefix, which contains no user project.
89
- const root = findInstallRoot(entry);
90
- const scope =
91
- root !== undefined && containsPath(root, resolve(process.cwd()))
92
- ? "local"
93
- : "global";
94
- const pm = packageManager();
95
- return { pm, label: `${pm} (${scope})` };
96
- }
97
-
98
- /** The npm global-update command — also the fallback for an unknown manager. */
99
- const npmUpdateCommand = (pkg: string): string => `npm i -g ${pkg}`;
179
+ /** A located installed package: its tree's root and its own directory. */
180
+ interface LocatedPackage {
181
+ /** The directory the package was installed into (parent of node_modules). */
182
+ readonly root: string;
183
+ /** The `node_modules/<pkg>` directory itself (scoped-aware). */
184
+ readonly pkgDir: string;
185
+ }
186
+
187
+ /**
188
+ * Locate the `node_modules` package a path sits inside — root and package
189
+ * directory, scope-aware. `node_modules/.bin/...` is NOT a package (the shim
190
+ * there is a symlink the chain walk should follow instead).
191
+ */
192
+ function locatePackage(path: string): LocatedPackage | undefined {
193
+ const segments = splitPath(path);
194
+ const index = segments.lastIndexOf("node_modules");
195
+ if (index === -1) return undefined;
196
+ const first = segments[index + 1];
197
+ if (first === undefined || first === ".bin") return undefined;
198
+ const span = first.startsWith("@") ? 2 : 1;
199
+ if (span === 2 && segments[index + 2] === undefined) return undefined;
200
+ const sep = sepOf(path);
201
+ return {
202
+ root: segments.slice(0, index).join(sep),
203
+ pkgDir: segments.slice(0, index + 1 + span).join(sep),
204
+ };
205
+ }
206
+
207
+ /** The manager a path's shape names, or `undefined` when it names none. */
208
+ export function managerFromPath(path: string): PackageManager | undefined {
209
+ const segments = splitPath(path).map((s) => s.toLowerCase());
210
+ const has = (name: string): boolean => segments.includes(name);
211
+ if (has(".bun")) return "bun";
212
+ if (has(".pnpm") || has("pnpm")) return "pnpm";
213
+ if (
214
+ has(".volta") ||
215
+ segments.some((s, i) => s === "volta" && segments[i + 1] === "tools")
216
+ ) {
217
+ return "volta";
218
+ }
219
+ // asdf/nvm host an ordinary npm global tree inside a versioned node install.
220
+ if (has(".asdf") || has(".nvm")) return "npm";
221
+ const yarn = segments.findIndex((s) => s === "yarn" || s === ".yarn");
222
+ if (yarn !== -1 && segments.indexOf("global", yarn) !== -1) return "yarn";
223
+ return undefined;
224
+ }
225
+
226
+ /**
227
+ * The ephemeral runner a path's cache shape names, if any.
228
+ *
229
+ * Each pattern is anchored to the runner's ACTUAL cache layout, not to a bare
230
+ * directory name appearing anywhere in the path. A loose match reclassifies an
231
+ * ordinary project — `/work/dlx/app`, or anything under a directory someone
232
+ * named `_npx` — as ephemeral, and because that verdict is reached BEFORE the
233
+ * workspace containment check it wins even when a lockfile sits beside the
234
+ * root. The failure is quiet rather than loud: an ephemeral install correctly
235
+ * offers no upgrade command, so the user simply never learns how to update.
236
+ */
237
+ export function ephemeralRunner(path: string): EphemeralRunner | undefined {
238
+ const segments = splitPath(path);
239
+ // npx caches at `<npm cache>/_npx/<hash>/…` — `~/.npm` on unix,
240
+ // `%LOCALAPPDATA%\npm-cache` on Windows.
241
+ const npx = segments.indexOf("_npx");
242
+ const npxParent = npx > 0 ? segments[npx - 1] : undefined;
243
+ if (npxParent === ".npm" || npxParent === "npm-cache") return "npx";
244
+ // bunx materialises `$TMPDIR/bunx-<uid>-<pkg>@<ver>/…`; requiring the uid
245
+ // keeps a hand-made `bunx-tools` directory from matching.
246
+ if (segments.some((s) => /^bunx-\d+-/.test(s))) return "bunx";
247
+ // pnpm caches at `<pnpm cache>/dlx/<hash>/…`.
248
+ const dlx = segments.indexOf("dlx");
249
+ if (dlx > 0 && segments[dlx - 1] === "pnpm") return "pnpm dlx";
250
+ return undefined;
251
+ }
252
+
253
+ /** The lockfile beside an install root, mapped to its manager. */
254
+ function lockfileManager(
255
+ root: string,
256
+ exists: InstallProbe["exists"],
257
+ sep: string,
258
+ ): PackageManager | undefined {
259
+ const beside = (name: string): boolean => exists(`${root}${sep}${name}`);
260
+ if (beside("bun.lock") || beside("bun.lockb")) return "bun";
261
+ if (beside("pnpm-lock.yaml")) return "pnpm";
262
+ if (beside("yarn.lock")) return "yarn";
263
+ if (beside("package-lock.json")) return "npm";
264
+ return undefined;
265
+ }
266
+
267
+ /** The manager `npm_config_user_agent` names, when present and recognized. */
268
+ function agentManager(agent: string | undefined): PackageManager | undefined {
269
+ const name = agent?.split("/")[0];
270
+ return name === "npm" || name === "pnpm" || name === "yarn" || name === "bun"
271
+ ? name
272
+ : undefined;
273
+ }
274
+
275
+ /** The link-chain hop cap — a symlink cycle must not spin the detector. */
276
+ const MAX_LINK_HOPS = 10;
277
+
278
+ /**
279
+ * Walk `argv[1]`'s symlink chain to the `node_modules` entry it points
280
+ * through, and trust it only when that package directory really contains the
281
+ * running module. Returns the FIRST chain path inside a package — before the
282
+ * package-level symlink is resolved away, which is where the linked-install
283
+ * signal lives.
284
+ */
285
+ function resolveInvokedEntry(probe: InstallProbe): string | undefined {
286
+ if (probe.invoked === undefined) return undefined;
287
+ let path = resolve(probe.cwd, probe.invoked);
288
+ for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
289
+ const located = locatePackage(path);
290
+ if (located !== undefined) {
291
+ // Identity check: only OUR package's bin may classify this process.
292
+ const real = probe.realPath(located.pkgDir);
293
+ return real !== undefined && containsPath(real, probe.entry)
294
+ ? path
295
+ : undefined;
296
+ }
297
+ const target = probe.readLink(path);
298
+ if (target === undefined) return undefined;
299
+ path = resolve(dirname(path), target);
300
+ }
301
+ return undefined;
302
+ }
303
+
304
+ const unknownInstall = (runtime: "node" | "bun"): UnknownInstall => ({
305
+ kind: "unknown",
306
+ runtime,
307
+ label: installSourceLabel("unknown", runtime),
308
+ });
309
+
310
+ /**
311
+ * Classify an install from a captured probe — the PURE core of
312
+ * {@link detectInstallSource}, exercised by the fixture matrix in its test.
313
+ *
314
+ * @param probe - The captured paths, environment, and filesystem readers.
315
+ * @returns The classified install source.
316
+ */
317
+ export function classifyInstall(probe: InstallProbe): InstallSource {
318
+ const invokedEntry = resolveInvokedEntry(probe);
319
+ const installed =
320
+ invokedEntry ??
321
+ (locatePackage(probe.entry) !== undefined ? probe.entry : undefined);
322
+ if (installed === undefined) return unknownInstall(probe.runtime);
323
+ const located = locatePackage(installed);
324
+ /* v8 ignore next -- `installed` is only set when locatePackage succeeded */
325
+ if (located === undefined) return unknownInstall(probe.runtime);
326
+ const { root, pkgDir } = located;
327
+
328
+ // A package dir that is a symlink pointing OUTSIDE its install root is a
329
+ // linked development install (`npm link`/`bun link`). Inside the root it is
330
+ // a store or workspace layout (pnpm's `.pnpm`, a monorepo package) — normal.
331
+ const rawTarget = probe.readLink(pkgDir);
332
+ if (rawTarget !== undefined) {
333
+ const target = resolve(dirname(pkgDir), rawTarget);
334
+ if (!containsPath(root, target)) {
335
+ const pm = managerFromPath(installed);
336
+ return {
337
+ kind: "linked",
338
+ ...(pm !== undefined ? { pm } : {}),
339
+ target,
340
+ label: installSourceLabel("linked", pm),
341
+ };
342
+ }
343
+ }
344
+
345
+ const runner = ephemeralRunner(installed);
346
+ if (runner !== undefined) {
347
+ return {
348
+ kind: "ephemeral",
349
+ runner,
350
+ label: installSourceLabel("ephemeral", runner),
351
+ };
352
+ }
353
+
354
+ const shaped = managerFromPath(installed);
355
+ if (containsPath(root, resolve(probe.cwd))) {
356
+ // A workspace tree can belong to any manager; the lockfile beside its
357
+ // root is the marker, the user-agent (when a script runner set it) the
358
+ // corroboration of last resort.
359
+ const pm =
360
+ shaped ??
361
+ lockfileManager(root, probe.exists, sepOf(installed)) ??
362
+ agentManager(probe.userAgent);
363
+ return {
364
+ kind: "workspace",
365
+ ...(pm !== undefined ? { pm } : {}),
366
+ root,
367
+ label: installSourceLabel("workspace", pm),
368
+ };
369
+ }
370
+
371
+ // A global tree with none of the distinctive shapes is npm's: bun, pnpm,
372
+ // volta and yarn all mark their global roots, npm (and nvm/asdf node
373
+ // installs, which npm serves) do not. The user-agent is NOT consulted here —
374
+ // it names whoever ran the script (`bun run …`), not who installed the bin.
375
+ const pm = shaped ?? "npm";
376
+ return { kind: "global", pm, label: installSourceLabel("global", pm) };
377
+ }
378
+
379
+ /** Capture the live process into a probe (the only impure code here). */
380
+ function liveProbe(): InstallProbe {
381
+ return {
382
+ entry: fileURLToPath(import.meta.url),
383
+ invoked: process.argv[1],
384
+ cwd: process.cwd(),
385
+ userAgent: process.env.npm_config_user_agent,
386
+ runtime: process.versions.bun ? "bun" : "node",
387
+ readLink: (path) => {
388
+ try {
389
+ return readlinkSync(path);
390
+ } catch {
391
+ return undefined;
392
+ }
393
+ },
394
+ realPath: (path) => {
395
+ try {
396
+ return realpathSync(path);
397
+ } catch {
398
+ return undefined;
399
+ }
400
+ },
401
+ exists: (path) => existsSync(path),
402
+ };
403
+ }
404
+
405
+ /**
406
+ * Detect how the binary was installed.
407
+ *
408
+ * @param probe - Injected probe (tests); defaults to the live process.
409
+ * @returns The classified install source.
410
+ * @note Impure by default — reads `import.meta.url`, `process`, and a bounded
411
+ * handful of symlink/realpath/exists probes. Never writes.
412
+ */
413
+ export function detectInstallSource(
414
+ probe: InstallProbe = liveProbe(),
415
+ ): InstallSource {
416
+ return classifyInstall(probe);
417
+ }
100
418
 
101
419
  /** The global-update command per package manager (install-style). */
102
- const PM_UPDATE_COMMAND: Record<string, (pkg: string) => string> = {
420
+ const PM_UPDATE_COMMAND: Record<PackageManager, (pkg: string) => string> = {
103
421
  bun: (pkg) => `bun add -g ${pkg}`,
104
- npm: npmUpdateCommand,
422
+ npm: (pkg) => `npm i -g ${pkg}`,
105
423
  pnpm: (pkg) => `pnpm add -g ${pkg}`,
106
424
  yarn: (pkg) => `yarn global add ${pkg}`,
425
+ volta: (pkg) => `volta install ${pkg}`,
107
426
  };
108
427
 
109
428
  /**
110
- * The command that updates `pkg` globally for a package manager.
429
+ * The command that updates a GLOBAL install of `pkg`. Accepting only the
430
+ * {@link GlobalInstall} arm is the point: a linked, ephemeral, workspace, or
431
+ * unknown install has no sanctioned command, and asking for one is a type
432
+ * error — {@link updateGuidance} is what those states say instead.
111
433
  *
112
- * @param pm - The package-manager name (`bun`/`npm`/`pnpm`/`yarn`).
434
+ * @param install - The detected global install.
113
435
  * @param pkg - The package to update.
114
- * @returns The shell command; falls back to npm for an unknown manager.
436
+ * @returns The shell command.
437
+ */
438
+ export function pmUpdateCommand(install: GlobalInstall, pkg: string): string {
439
+ return PM_UPDATE_COMMAND[install.pm](pkg);
440
+ }
441
+
442
+ /**
443
+ * The honest sentence a command-less install state shows when a newer release
444
+ * exists — see {@link INSTALL_UPDATE_GUIDANCE} for the words.
445
+ *
446
+ * @param install - Any non-global install.
447
+ * @returns The guidance sentence for its state.
115
448
  */
116
- export function pmUpdateCommand(pm: string, pkg: string): string {
117
- return (PM_UPDATE_COMMAND[pm] ?? npmUpdateCommand)(pkg);
449
+ export function updateGuidance(
450
+ install: Exclude<InstallSource, GlobalInstall>,
451
+ ): string {
452
+ return INSTALL_UPDATE_GUIDANCE[install.kind];
118
453
  }