@canonical/pragma-cli 0.36.0 → 0.38.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 (312) hide show
  1. package/README.md +3 -2
  2. package/bundled-skills/adoption-a1-styles/SKILL.md +24 -0
  3. package/bundled-skills/adoption-a2-components/SKILL.md +22 -6
  4. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +1397 -327
  5. package/bundled-skills/anatomy-author/SKILL.md +840 -137
  6. package/bundled-skills/design-auditor/SKILL.md +133 -9
  7. package/bundled-skills/implement-component/SKILL.md +192 -23
  8. package/bundled-skills/specify-component/SKILL.md +124 -12
  9. package/bundled-skills/specify-pattern/SKILL.md +47 -8
  10. package/dist/package.json +23 -20
  11. package/dist/pragma.conf.js +963 -40
  12. package/dist/pragma.conf.js.map +1 -1
  13. package/dist/src/capabilities/capabilities/catalog.js +14 -5
  14. package/dist/src/capabilities/capabilities/catalog.js.map +1 -1
  15. package/dist/src/capabilities/capabilities/hints.js +33 -5
  16. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  17. package/dist/src/capabilities/config/show.render.js +30 -2
  18. package/dist/src/capabilities/config/show.render.js.map +1 -1
  19. package/dist/src/capabilities/create/create.verb.js +2 -1
  20. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  21. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  22. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  23. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  24. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  25. package/dist/src/capabilities/doctor/checks/harnessInventory.js +1 -1
  26. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  28. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  29. package/dist/src/capabilities/doctor/checks/targetHealth.js +104 -14
  30. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  31. package/dist/src/capabilities/graph/connect.render.js +101 -0
  32. package/dist/src/capabilities/graph/connect.render.js.map +1 -0
  33. package/dist/src/capabilities/graph/connect.types.js +21 -0
  34. package/dist/src/capabilities/graph/connect.types.js.map +1 -0
  35. package/dist/src/capabilities/graph/connect.verb.js +147 -0
  36. package/dist/src/capabilities/graph/connect.verb.js.map +1 -0
  37. package/dist/src/capabilities/graph/index.js +5 -3
  38. package/dist/src/capabilities/graph/index.js.map +1 -1
  39. package/dist/src/capabilities/graph/queryText.js +206 -0
  40. package/dist/src/capabilities/graph/queryText.js.map +1 -0
  41. package/dist/src/capabilities/graph/relationIndex.js +302 -0
  42. package/dist/src/capabilities/graph/relationIndex.js.map +1 -0
  43. package/dist/src/capabilities/graph/runConnect.js +456 -0
  44. package/dist/src/capabilities/graph/runConnect.js.map +1 -0
  45. package/dist/src/capabilities/graph/runQuery.js +21 -4
  46. package/dist/src/capabilities/graph/runQuery.js.map +1 -1
  47. package/dist/src/capabilities/setup/buildPlan.js +6 -2
  48. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  49. package/dist/src/capabilities/setup/operations/index.js +7 -2
  50. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  51. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  52. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  53. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  54. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  55. package/dist/src/capabilities/setup/operations/setupGenerator.js +72 -14
  56. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  57. package/dist/src/capabilities/setup/operations/setupLsp.js +372 -62
  58. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupMcp.js +150 -5
  60. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupSkills.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/writability.js +114 -0
  64. package/dist/src/capabilities/setup/operations/writability.js.map +1 -0
  65. package/dist/src/capabilities/setup/plan.js.map +1 -1
  66. package/dist/src/capabilities/setup/setup.verb.js +80 -31
  67. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  68. package/dist/src/capabilities/setup/targets.js +124 -42
  69. package/dist/src/capabilities/setup/targets.js.map +1 -1
  70. package/dist/src/capabilities/shared/index.js +1 -1
  71. package/dist/src/capabilities/shared/index.js.map +1 -1
  72. package/dist/src/constants.js +7 -1
  73. package/dist/src/constants.js.map +1 -1
  74. package/dist/src/kernel/error/renderError.js +3 -2
  75. package/dist/src/kernel/error/renderError.js.map +1 -1
  76. package/dist/src/kernel/error/validOptions.js +41 -0
  77. package/dist/src/kernel/error/validOptions.js.map +1 -0
  78. package/dist/src/kernel/packs/compile.js +90 -6
  79. package/dist/src/kernel/packs/compile.js.map +1 -1
  80. package/dist/src/kernel/packs/cursor.js +135 -0
  81. package/dist/src/kernel/packs/cursor.js.map +1 -0
  82. package/dist/src/kernel/packs/graphql/buildLookupDocument.js +9 -2
  83. package/dist/src/kernel/packs/graphql/buildLookupDocument.js.map +1 -1
  84. package/dist/src/kernel/packs/paging.js +108 -0
  85. package/dist/src/kernel/packs/paging.js.map +1 -0
  86. package/dist/src/kernel/packs/renderPack.js +154 -5
  87. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  88. package/dist/src/kernel/packs/resolveEntity.js +118 -19
  89. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  90. package/dist/src/kernel/packs/runBodies.js +156 -18
  91. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  92. package/dist/src/kernel/packs/schema.js +160 -12
  93. package/dist/src/kernel/packs/schema.js.map +1 -1
  94. package/dist/src/kernel/packs/sparql/authorQuery.js +227 -0
  95. package/dist/src/kernel/packs/sparql/authorQuery.js.map +1 -0
  96. package/dist/src/kernel/packs/sparql/buildListQuery.js +251 -0
  97. package/dist/src/kernel/packs/sparql/buildListQuery.js.map +1 -0
  98. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +125 -22
  99. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  100. package/dist/src/kernel/packs/sparql/filterValues.js +145 -0
  101. package/dist/src/kernel/packs/sparql/filterValues.js.map +1 -0
  102. package/dist/src/kernel/packs/sparql/index.js +24 -14
  103. package/dist/src/kernel/packs/sparql/index.js.map +1 -1
  104. package/dist/src/kernel/packs/sparql/runSelect.js +10 -4
  105. package/dist/src/kernel/packs/sparql/runSelect.js.map +1 -1
  106. package/dist/src/kernel/packs/sparql/searchTerm.js +25 -0
  107. package/dist/src/kernel/packs/sparql/searchTerm.js.map +1 -0
  108. package/dist/src/kernel/packs/storyRules.js +151 -0
  109. package/dist/src/kernel/packs/storyRules.js.map +1 -0
  110. package/dist/src/kernel/packs/tierScope.js +310 -0
  111. package/dist/src/kernel/packs/tierScope.js.map +1 -0
  112. package/dist/src/kernel/packs/types.js +80 -0
  113. package/dist/src/kernel/packs/types.js.map +1 -1
  114. package/dist/src/kernel/project/cli/buildProgram.js +10 -1
  115. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  116. package/dist/src/kernel/project/cli/dispatch.js +104 -3
  117. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  118. package/dist/src/kernel/project/cli/rootHelp.js +8 -5
  119. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  120. package/dist/src/kernel/project/cli/suggestNames.js +31 -7
  121. package/dist/src/kernel/project/cli/suggestNames.js.map +1 -1
  122. package/dist/src/kernel/project/mcp/registerVerb.js +5 -1
  123. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  124. package/dist/src/kernel/render/renderers.js +34 -2
  125. package/dist/src/kernel/render/renderers.js.map +1 -1
  126. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  127. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  128. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  129. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  130. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  131. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  132. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  133. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  134. package/dist/src/kernel/runtime/graphpack/read.js +17 -10
  135. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  136. package/dist/src/kernel/runtime/loadSession.js +3 -1
  137. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  138. package/dist/src/kernel/runtime/probe.js +1 -1
  139. package/dist/src/kernel/runtime/probe.js.map +1 -1
  140. package/dist/src/kernel/runtime/readEntity.js +19 -4
  141. package/dist/src/kernel/runtime/readEntity.js.map +1 -1
  142. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  143. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  144. package/dist/src/kernel/runtime/refs/resolve.js +38 -5
  145. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  146. package/dist/src/kernel/spec/emitReference.js +1 -1
  147. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  148. package/package.json +23 -20
  149. package/pragma.conf.ts +983 -40
  150. package/src/capabilities/block.parity.test.ts +207 -27
  151. package/src/capabilities/block.shipped.exec.test.ts +156 -14
  152. package/src/capabilities/block.tierRank.exec.test.ts +46 -9
  153. package/src/capabilities/capabilities/catalog.ts +14 -5
  154. package/src/capabilities/capabilities/hints.ts +40 -5
  155. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  156. package/src/capabilities/config/set.test.ts +24 -25
  157. package/src/capabilities/config/show.render.test.ts +9 -4
  158. package/src/capabilities/config/show.render.ts +30 -2
  159. package/src/capabilities/create/byteEquality.test.ts +28 -1
  160. package/src/capabilities/create/create.test.ts +1 -0
  161. package/src/capabilities/create/create.verb.ts +2 -1
  162. package/src/capabilities/create/createGrammar.test.ts +4 -1
  163. package/src/capabilities/create/createSurface.generated.ts +23 -1
  164. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  165. package/src/capabilities/distribution.parity.test.ts +12 -11
  166. package/src/capabilities/distribution.test.ts +77 -7
  167. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  168. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  169. package/src/capabilities/doctor/checks/harnessInventory.test.ts +13 -6
  170. package/src/capabilities/doctor/checks/harnessInventory.ts +8 -4
  171. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  172. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  173. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  174. package/src/capabilities/doctor/checks/targetHealth.ts +114 -13
  175. package/src/capabilities/doctor/doctor.test.ts +327 -36
  176. package/src/capabilities/emptyRecovery.test.ts +84 -28
  177. package/src/capabilities/graph/connect.render.ts +141 -0
  178. package/src/capabilities/graph/connect.shipped.exec.test.ts +370 -0
  179. package/src/capabilities/graph/connect.test.ts +361 -0
  180. package/src/capabilities/graph/connect.types.ts +136 -0
  181. package/src/capabilities/graph/connect.verb.ts +165 -0
  182. package/src/capabilities/graph/index.ts +5 -3
  183. package/src/capabilities/graph/query.shipped.exec.test.ts +66 -0
  184. package/src/capabilities/graph/query.test.ts +17 -0
  185. package/src/capabilities/graph/queryText.test.ts +121 -0
  186. package/src/capabilities/graph/queryText.ts +240 -0
  187. package/src/capabilities/graph/relationIndex.ts +372 -0
  188. package/src/capabilities/graph/runConnect.ts +577 -0
  189. package/src/capabilities/graph/runQuery.ts +21 -6
  190. package/src/capabilities/lazy.test.ts +32 -2
  191. package/src/capabilities/listBudget.shipped.exec.test.ts +239 -0
  192. package/src/capabilities/listLookup.shipped.exec.test.ts +387 -0
  193. package/src/capabilities/listQuery.shipped.exec.test.ts +400 -0
  194. package/src/capabilities/resources/resources.test.ts +38 -2
  195. package/src/capabilities/setup/buildPlan.ts +7 -1
  196. package/src/capabilities/setup/operations/index.ts +14 -0
  197. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  198. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  199. package/src/capabilities/setup/operations/setupGenerator.ts +93 -15
  200. package/src/capabilities/setup/operations/setupLsp.ts +544 -85
  201. package/src/capabilities/setup/operations/setupMcp.ts +212 -4
  202. package/src/capabilities/setup/operations/setupSkills.ts +7 -2
  203. package/src/capabilities/setup/operations/writability.test.ts +151 -0
  204. package/src/capabilities/setup/operations/writability.ts +135 -0
  205. package/src/capabilities/setup/plan.ts +11 -0
  206. package/src/capabilities/setup/setup.test.ts +992 -16
  207. package/src/capabilities/setup/setup.verb.ts +84 -36
  208. package/src/capabilities/setup/targets.ts +152 -48
  209. package/src/capabilities/setup/types.ts +41 -5
  210. package/src/capabilities/shared/index.ts +1 -0
  211. package/src/capabilities/sources/sources.test.ts +55 -0
  212. package/src/capabilities/surface.test.ts +59 -11
  213. package/src/capabilities/tier.parity.test.ts +2 -2
  214. package/src/capabilities/token.parity.test.ts +784 -0
  215. package/src/capabilities/tokenGraph.cli.test.ts +279 -0
  216. package/src/capabilities/tokenGraph.shipped.exec.test.ts +653 -0
  217. package/src/capabilities/variable.parity.test.ts +448 -0
  218. package/src/constants.ts +8 -0
  219. package/src/kernel/completion/shellDrive.test.ts +32 -30
  220. package/src/kernel/config/defaults.test.ts +21 -2
  221. package/src/kernel/config/readConfig.test.ts +1 -1
  222. package/src/kernel/error/renderError.ts +3 -2
  223. package/src/kernel/error/validOptions.test.ts +57 -0
  224. package/src/kernel/error/validOptions.ts +45 -0
  225. package/src/kernel/packs/collect.test.ts +2 -0
  226. package/src/kernel/packs/compile.test.ts +188 -15
  227. package/src/kernel/packs/compile.ts +109 -14
  228. package/src/kernel/packs/cursor.ts +135 -0
  229. package/src/kernel/packs/filteredEmpty.test.ts +213 -0
  230. package/src/kernel/packs/graphql/buildLookupDocument.ts +16 -9
  231. package/src/kernel/packs/graphqlSource.test.ts +152 -0
  232. package/src/kernel/packs/listNotice.test.ts +156 -0
  233. package/src/kernel/packs/paging.ts +110 -0
  234. package/src/kernel/packs/renderPack.ts +186 -13
  235. package/src/kernel/packs/resolveEntity.test.ts +62 -3
  236. package/src/kernel/packs/resolveEntity.ts +186 -26
  237. package/src/kernel/packs/runBodies.ts +196 -26
  238. package/src/kernel/packs/schema.ts +180 -13
  239. package/src/kernel/packs/sparql/authorQuery.test.ts +179 -0
  240. package/src/kernel/packs/sparql/authorQuery.ts +257 -0
  241. package/src/kernel/packs/sparql/buildListQuery.test.ts +344 -0
  242. package/src/kernel/packs/sparql/buildListQuery.ts +344 -0
  243. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +138 -0
  244. package/src/kernel/packs/sparql/buildLookupQuery.ts +140 -20
  245. package/src/kernel/packs/sparql/filterValues.test.ts +86 -0
  246. package/src/kernel/packs/sparql/filterValues.ts +182 -0
  247. package/src/kernel/packs/sparql/index.ts +30 -14
  248. package/src/kernel/packs/sparql/runSelect.test.ts +63 -0
  249. package/src/kernel/packs/sparql/runSelect.ts +42 -2
  250. package/src/kernel/packs/sparql/searchTerm.ts +29 -0
  251. package/src/kernel/packs/storyRules.ts +180 -0
  252. package/src/kernel/packs/tierScope.test.ts +487 -0
  253. package/src/kernel/packs/tierScope.ts +409 -0
  254. package/src/kernel/packs/toolDescription.test.ts +13 -10
  255. package/src/kernel/packs/types.ts +311 -9
  256. package/src/kernel/project/cli/buildProgram.ts +17 -1
  257. package/src/kernel/project/cli/dispatch.test.ts +87 -0
  258. package/src/kernel/project/cli/dispatch.ts +114 -4
  259. package/src/kernel/project/cli/rootHelp.ts +8 -5
  260. package/src/kernel/project/cli/suggest.test.ts +35 -0
  261. package/src/kernel/project/cli/suggestNames.ts +34 -7
  262. package/src/kernel/project/mcp/registerVerb.ts +5 -1
  263. package/src/kernel/render/contracts.ts +2 -0
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.test.ts +43 -0
  266. package/src/kernel/render/renderers.ts +34 -1
  267. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  268. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  269. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  270. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  271. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  272. package/src/kernel/runtime/graphpack/read.ts +17 -13
  273. package/src/kernel/runtime/loadSession.ts +3 -1
  274. package/src/kernel/runtime/probe.ts +1 -1
  275. package/src/kernel/runtime/readEntity.ts +19 -4
  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 +39 -5
  279. package/src/kernel/runtime/types.ts +30 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  282. package/src/kernel/spec/types.ts +17 -0
  283. package/src/testing/PARITY_GAPS.ts +4 -4
  284. package/src/testing/behavioral/agentSession.mcp.test.ts +43 -39
  285. package/src/testing/behavioral/completion.test.ts +7 -6
  286. package/src/testing/behavioral/disclosure.test.ts +39 -37
  287. package/src/testing/behavioral/errorMatrix.mcp.test.ts +82 -64
  288. package/src/testing/behavioral/errorMatrix.storeless.test.ts +11 -10
  289. package/src/testing/behavioral/journeys.cli.test.ts +71 -32
  290. package/src/testing/behavioral/journeys.defaultPack.test.ts +6 -4
  291. package/src/testing/behavioral/journeys.firstInstall.test.ts +70 -5
  292. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +4 -3
  293. package/src/testing/behavioral/journeys.packageStories.test.ts +2 -1
  294. package/src/testing/behavioral/mcpSurface.test.ts +28 -25
  295. package/src/testing/behavioral/parity.test.ts +25 -23
  296. package/src/testing/eval/__snapshots__/eval.test.ts.snap +20 -0
  297. package/src/testing/eval/cases/effectful.ts +7 -1
  298. package/src/testing/eval/cases/readNouns.ts +100 -11
  299. package/src/testing/fixtures/blockGraph.ts +89 -5
  300. package/src/testing/fixtures/connectGraph.ts +159 -0
  301. package/src/testing/fixtures/graph/canonical.ts +128 -32
  302. package/src/testing/helpers/fsProbe.ts +88 -0
  303. package/src/testing/helpers/runCli.test.ts +44 -0
  304. package/src/testing/helpers/runCli.ts +27 -3
  305. package/src/testing/perf/budgets.test.ts +3 -1
  306. package/src/testing/perf/budgets.ts +138 -54
  307. package/dist/src/kernel/packs/sparql/applyFilters.js +0 -167
  308. package/dist/src/kernel/packs/sparql/applyFilters.js.map +0 -1
  309. package/dist/src/kernel/packs/sparql/applySearch.js +0 -25
  310. package/dist/src/kernel/packs/sparql/applySearch.js.map +0 -1
  311. package/src/kernel/packs/sparql/applyFilters.ts +0 -203
  312. package/src/kernel/packs/sparql/applySearch.ts +0 -33
@@ -17,6 +17,14 @@
17
17
  * write itself stays idempotent (a re-merge of the identical entry is
18
18
  * byte-for-byte), so a re-run is a no-op the recap reports as "unchanged" — the
19
19
  * same state-awareness `setup skills` has always had.
20
+ *
21
+ * And it asks, for each group, whether the file could be WRITTEN at all —
22
+ * `probeWritable`, before a single write is composed. A NixOS or home-manager
23
+ * user whose config directory is managed produced a `failed` row carrying a
24
+ * raw `EACCES` and no next step, on a machine configured exactly as its owner
25
+ * intends; that is now a named skip whose remedy is the entry itself, printed
26
+ * through the same serializer the write would have used, for the user to put
27
+ * in the config that owns the directory.
20
28
  */
21
29
 
22
30
  import { dirname } from "node:path";
@@ -29,7 +37,9 @@ import type {
29
37
  import { mkdir, sequence_, type Task } from "@canonical/task";
30
38
  import { MCP_SERVER_NAME } from "../../../constants.js";
31
39
  import type { PragmaRuntime } from "../../../kernel/runtime/index.js";
32
- import type { McpTargetState, Scope } from "../types.js";
40
+ import { type Roots, shortenPath } from "../plan.js";
41
+ import type { McpTargetState, Scope, WriteBlock } from "../types.js";
42
+ import { type FsProbe, probeWritable } from "./writability.js";
33
43
 
34
44
  /** The `writeMcpConfigTargets` builder, captured from the dynamic harness import. */
35
45
  type WriteMcpConfigTargets =
@@ -39,6 +49,10 @@ type WriteMcpConfigTargets =
39
49
  type RemoveMcpConfigFrom =
40
50
  typeof import("@canonical/harnesses").removeMcpConfigFrom;
41
51
 
52
+ /** The one-line TOML entry serializer, captured from the same import. */
53
+ type SerializeTomlInlineEntry =
54
+ typeof import("@canonical/harnesses").serializeTomlInlineEntry;
55
+
42
56
  /**
43
57
  * The detected MCP state: the per-file target groups (already scoped to the
44
58
  * requested `--scope`), a by-path map of each group's prior
@@ -61,10 +75,26 @@ export interface McpDetection {
61
75
  readonly stateByPath: ReadonlyMap<string, McpTargetState>;
62
76
  /** Each write's own state, keyed by {@link mcpWriteKey} — see the docblock. */
63
77
  readonly stateByWrite: ReadonlyMap<string, McpTargetState>;
78
+ /**
79
+ * The groups whose file cannot be written, by group path — a Nix-managed or
80
+ * read-only location, probed up front.
81
+ *
82
+ * Keyed by the group's path rather than per write, because writability is a
83
+ * property of the FILE: two harnesses sharing `.vscode/mcp.json` are blocked
84
+ * or not together, whatever keys they own inside it.
85
+ */
86
+ readonly blockedByPath: ReadonlyMap<string, WriteBlock>;
64
87
  readonly cwd: string;
65
88
  readonly platform: PlatformEnv;
66
89
  readonly writeMcpConfigTargets: WriteMcpConfigTargets;
67
90
  readonly removeMcpConfigFrom: RemoveMcpConfigFrom;
91
+ /**
92
+ * The TOML entry serializer a blocked Codex row prints its remedy with —
93
+ * carried here for the same reason the writer is: this module imports only
94
+ * TYPES from `@canonical/harnesses`, so the package stays off the fast path,
95
+ * and the pure message builders need the function synchronously.
96
+ */
97
+ readonly serializeTomlInlineEntry: SerializeTomlInlineEntry;
68
98
  }
69
99
 
70
100
  /**
@@ -188,12 +218,14 @@ const mcpWriteKey = (write: ConfigTarget): string =>
188
218
  *
189
219
  * @param rt - The per-invocation runtime.
190
220
  * @param scope - The scope being planned (global or project).
221
+ * @param probe - The writability filesystem seam; defaults to the real one.
191
222
  * @returns The scope's target groups, their prior states, + the config writer.
192
223
  * @note Impure — reads the filesystem via `detectHarnesses` + `readMcpConfigFrom`.
193
224
  */
194
225
  export async function detectMcp(
195
226
  rt: PragmaRuntime,
196
227
  scope: Scope,
228
+ probe?: FsProbe,
197
229
  ): Promise<McpDetection> {
198
230
  const cwd = rt.cwd;
199
231
  const [
@@ -204,6 +236,7 @@ export async function detectMcp(
204
236
  readMcpConfigFrom,
205
237
  readPlatformEnv,
206
238
  removeMcpConfigFrom,
239
+ serializeTomlInlineEntry,
207
240
  writeMcpConfigTargets,
208
241
  },
209
242
  { runTask },
@@ -224,6 +257,14 @@ export async function detectMcp(
224
257
  // than measured separately.
225
258
  const stateByPath = new Map<string, McpTargetState>();
226
259
  const stateByWrite = new Map<string, McpTargetState>();
260
+ // Writability, per file, before anything is composed. Cheap (a stat walk and
261
+ // one `access`), and it is what turns an unavoidable failure into a row that
262
+ // says what to do instead.
263
+ const blockedByPath = new Map<string, WriteBlock>();
264
+ for (const group of groups) {
265
+ const block = probeWritable(group.path, probe);
266
+ if (block !== undefined) blockedByPath.set(group.path, block);
267
+ }
227
268
  await Promise.all(
228
269
  groups.map(async (group) => {
229
270
  const want = pragmaMcpEntry(cwd, group.scope);
@@ -252,10 +293,12 @@ export async function detectMcp(
252
293
  groups,
253
294
  stateByPath,
254
295
  stateByWrite,
296
+ blockedByPath,
255
297
  cwd,
256
298
  platform,
257
299
  writeMcpConfigTargets,
258
300
  removeMcpConfigFrom,
301
+ serializeTomlInlineEntry,
259
302
  };
260
303
  }
261
304
 
@@ -283,6 +326,155 @@ export function mcpWriteState(
283
326
  return d.stateByWrite.get(mcpWriteKey(write)) ?? "absent";
284
327
  }
285
328
 
329
+ /**
330
+ * The block on one group's file, or `undefined` when it can be written.
331
+ *
332
+ * @param d - The detection gathered up front.
333
+ * @param path - The group's config file path.
334
+ * @returns The {@link WriteBlock}, or undefined.
335
+ */
336
+ export const mcpGroupBlock = (
337
+ d: McpDetection,
338
+ path: string,
339
+ ): WriteBlock | undefined => d.blockedByPath.get(path);
340
+
341
+ /**
342
+ * Why one BLOCKED group's file cannot be written — one line, as the renderer
343
+ * prints one.
344
+ *
345
+ * @param d - The detection gathered up front.
346
+ * @param group - A group whose file is blocked.
347
+ * @param roots - The two roots the path renders relative to.
348
+ * @returns The reason.
349
+ */
350
+ function describeMcpBlock(
351
+ block: WriteBlock,
352
+ path: string,
353
+ roots: Roots,
354
+ ): string {
355
+ const where = shortenPath(path, roots);
356
+ return block.kind === "nix-store"
357
+ ? `${where} is managed by Nix (it resolves to ${block.resolved})`
358
+ : `${where} is not writable (${block.path})`;
359
+ }
360
+
361
+ /**
362
+ * Why one group's file cannot be written, or `undefined` when it can be.
363
+ *
364
+ * @param d - The detection gathered up front.
365
+ * @param group - The group whose file may be blocked.
366
+ * @param roots - The two roots the path renders relative to.
367
+ * @returns The reason, or `undefined` when the file can be written.
368
+ */
369
+ export function mcpBlockReason(
370
+ d: McpDetection,
371
+ group: TargetGroup,
372
+ roots: Roots,
373
+ ): string | undefined {
374
+ const block = mcpGroupBlock(d, group.path);
375
+ return block === undefined
376
+ ? undefined
377
+ : describeMcpBlock(block, group.path, roots);
378
+ }
379
+
380
+ /**
381
+ * The remedy beneath a blocked group: the EXACT entry a write would have
382
+ * emitted, and nothing else.
383
+ *
384
+ * It is serialized through the group's own `serializeEntry` — the same
385
+ * function the writer uses — so the entry a user copies into their
386
+ * home-manager or NixOS config is byte-for-byte what `setup mcp` would have
387
+ * written, and the next detection classifies it `registered` rather than
388
+ * `drifted`. A hand-written sample in this string would be a second
389
+ * serializer to keep in sync, and the failure mode is a config the tool then
390
+ * offers to "repair" forever.
391
+ *
392
+ * It is written in the file's OWN FORMAT. A JSON body pasted into Codex's
393
+ * `config.toml` is not an entry, it is a syntax error, and the row that
394
+ * printed it was the one row whose whole job was to say what to write. The
395
+ * TOML spelling is the dotted-key inline table — the same entry the writer's
396
+ * `[mcp_servers.pragma]` table holds, in the one-line form a remedy can print
397
+ * (the renderer gives it one dim line).
398
+ *
399
+ * Every write in the group is included, keyed by its own `mcpKey`: a shared
400
+ * `.vscode/mcp.json` holds two independent entries, and half the answer is
401
+ * not an answer.
402
+ *
403
+ * The line does NOT restate the path or the cause. The reason line directly
404
+ * above it has said both ({@link mcpBlockReason}), and a remedy that repeats
405
+ * them is the duplication the LSP remedies beside it dropped: a remedy states
406
+ * the action.
407
+ *
408
+ * @param d - The detection gathered up front.
409
+ * @param group - The group whose file is blocked.
410
+ * @param roots - The two roots the path renders relative to.
411
+ * @returns The remedy line.
412
+ */
413
+ function blockedMcpRemedy(
414
+ d: McpDetection,
415
+ group: TargetGroup,
416
+ roots: Roots,
417
+ ): string {
418
+ const want = pragmaMcpEntry(d.cwd, group.scope);
419
+ const where = shortenPath(group.path, roots);
420
+ const toml = group.writes[0]?.configFormat === "toml";
421
+ const body = toml
422
+ ? group.writes
423
+ .map((write) =>
424
+ d.serializeTomlInlineEntry(
425
+ write.mcpKey,
426
+ MCP_SERVER_NAME,
427
+ write.serializeEntry(want),
428
+ ),
429
+ )
430
+ .join(" ")
431
+ : JSON.stringify(
432
+ Object.fromEntries(
433
+ group.writes.map((write) => [
434
+ write.mcpKey,
435
+ { [MCP_SERVER_NAME]: write.serializeEntry(want) },
436
+ ]),
437
+ ),
438
+ );
439
+ return `put this entry in the config that owns ${where}: ${body}`;
440
+ }
441
+
442
+ /** The groups whose file cannot be written, in `d.groups` order. */
443
+ export const blockedMcpGroups = (d: McpDetection): readonly TargetGroup[] =>
444
+ d.groups.filter((group) => d.blockedByPath.has(group.path));
445
+
446
+ /**
447
+ * The FIRST group whose file cannot be written, with its reason and its remedy
448
+ * already derived — or `undefined` when every file in the scope is writable.
449
+ *
450
+ * It hands back the derived strings rather than the group, for the reason
451
+ * `firstLspBlock` spells out: the blockedness is what makes the two strings
452
+ * exist, so a caller that took the group and asked for them separately had to
453
+ * assert that both were there, on both surfaces.
454
+ *
455
+ * @param d - The detection gathered up front.
456
+ * @param roots - The two roots the path renders relative to.
457
+ * @returns The first block's reason and remedy, or `undefined`.
458
+ */
459
+ export const firstMcpBlock = (
460
+ d: McpDetection,
461
+ roots: Roots,
462
+ ): { readonly reason: string; readonly remedy: string } | undefined => {
463
+ // A walk rather than `blockedMcpGroups(d).at(0)`: the group and its block
464
+ // come out together, so nothing has to assert afterwards that the block a
465
+ // filter just proved is there really is.
466
+ for (const group of d.groups) {
467
+ const block = mcpGroupBlock(d, group.path);
468
+ if (block !== undefined) {
469
+ return {
470
+ reason: describeMcpBlock(block, group.path, roots),
471
+ remedy: blockedMcpRemedy(d, group, roots),
472
+ };
473
+ }
474
+ }
475
+ return undefined;
476
+ };
477
+
286
478
  /** The target groups the user selected (by path), or all when none recorded. */
287
479
  export function selectedGroups(
288
480
  d: McpDetection,
@@ -314,7 +506,11 @@ export function composeMcp(
314
506
  // file (VS Code + Cline) a single read-modify-write — dry-run safe. The
315
507
  // written entry is SCOPE-shaped per group (a global entry omits `cwd`).
316
508
  const pending = groups.filter(
317
- (group) => mcpGroupState(d, group.path) !== "registered",
509
+ (group) =>
510
+ mcpGroupState(d, group.path) !== "registered" &&
511
+ // A blocked file is never written. The write is what USED to discover the
512
+ // block, and it discovered it as a raw fs error on a `failed` row.
513
+ !d.blockedByPath.has(group.path),
318
514
  );
319
515
  return sequence_(
320
516
  pending.map((group) =>
@@ -334,7 +530,13 @@ export function composeMcp(
334
530
  * foreign server in a file that does is never touched.
335
531
  */
336
532
  export const ownedMcpGroups = (d: McpDetection): readonly TargetGroup[] =>
337
- d.groups.filter((group) => mcpGroupState(d, group.path) !== "absent");
533
+ d.groups.filter(
534
+ (group) =>
535
+ mcpGroupState(d, group.path) !== "absent" &&
536
+ // A blocked file cannot be written, so it cannot be un-written either.
537
+ // The row says so rather than composing a reversal that would fail.
538
+ !d.blockedByPath.has(group.path),
539
+ );
338
540
 
339
541
  /**
340
542
  * Compose the removal of the pragma entry from every owned file.
@@ -356,9 +558,14 @@ export const ownedMcpGroups = (d: McpDetection): readonly TargetGroup[] =>
356
558
  * same file is never touched.
357
559
  *
358
560
  * @param d - The detection gathered up front.
561
+ * @param undoKey - Correlation key stamped on the reversal(s), echoed on
562
+ * their undo outcomes so the caller can read results back per row.
359
563
  * @returns A Task whose undo removes the pragma entry from each owned file.
360
564
  */
361
- export function composeMcpRemoval(d: McpDetection): Task<void> {
565
+ export function composeMcpRemoval(
566
+ d: McpDetection,
567
+ undoKey?: string,
568
+ ): Task<void> {
362
569
  return sequence_(
363
570
  ownedMcpGroups(d).map((group) =>
364
571
  mkdir(dirname(group.path), true, {
@@ -367,6 +574,7 @@ export function composeMcpRemoval(d: McpDetection): Task<void> {
367
574
  d.removeMcpConfigFrom(write, MCP_SERVER_NAME),
368
575
  ),
369
576
  ),
577
+ undoKey,
370
578
  }),
371
579
  ),
372
580
  );
@@ -754,14 +754,19 @@ export const ownedSkillLinks = (
754
754
  * this command owns, never from what a fresh forward plan would create.
755
755
  *
756
756
  * @param d - The detection gathered up front.
757
+ * @param undoKey - Correlation key stamped on the reversal(s), echoed on
758
+ * their undo outcomes so the caller can read results back per row.
757
759
  * @returns A Task whose undo removes every owned link.
758
760
  */
759
- export function composeSkillsRemoval(d: SkillsDetection): Task<void> {
761
+ export function composeSkillsRemoval(
762
+ d: SkillsDetection,
763
+ undoKey?: string,
764
+ ): Task<void> {
760
765
  const owned = ownedSkillLinks(d);
761
766
  if (owned.length === 0) return sequence_([]);
762
767
  return sequence_(
763
768
  owned.map((a) =>
764
- symlink(a.target, a.linkPath, { undo: deleteFile(a.linkPath) }),
769
+ symlink(a.target, a.linkPath, { undo: deleteFile(a.linkPath), undoKey }),
765
770
  ),
766
771
  );
767
772
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `probeWritable` — the pure half, driven entirely over its `FsProbe` seam.
3
+ *
4
+ * Every branch here decides whether a row REPORTS a named skip or attempts a
5
+ * write that fails with a raw fs message, and two of them cannot be arranged
6
+ * for real: a test process running as root can write to a `0o500` directory,
7
+ * and no CI host has a `/nix/store`. So the filesystem is three injected
8
+ * functions and each rule is asserted against a fixture that spells out the
9
+ * machine it stands for.
10
+ *
11
+ * The fixture factory is stateful (it records the walk), so it lives in
12
+ * `testing/helpers/fsProbe.ts` and is shared with the setup and doctor suites
13
+ * rather than copied into each.
14
+ */
15
+
16
+ import { mkdtempSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { describe, expect, it } from "vitest";
20
+ import { fakeFsProbe as probe } from "../../../testing/helpers/fsProbe.js";
21
+ import type { WriteBlock } from "../types.js";
22
+ import { probeWritable } from "./writability.js";
23
+
24
+ describe("probeWritable", () => {
25
+ it("probes the TARGET ITSELF when it is present, not only its directory", () => {
26
+ // home-manager symlinks `mcp.json` straight into the store and leaves
27
+ // `User/` writable. Stopping at the parent would call the file writable
28
+ // and then fail on the write.
29
+ const fs = probe({
30
+ present: ["/home/u/.config/Code/User/mcp.json"],
31
+ realpath: {
32
+ "/home/u/.config/Code/User/mcp.json": "/nix/store/abc-vscode/mcp.json",
33
+ },
34
+ });
35
+ expect(probeWritable("/home/u/.config/Code/User/mcp.json", fs)).toEqual({
36
+ kind: "nix-store",
37
+ resolved: "/nix/store/abc-vscode/mcp.json",
38
+ } satisfies WriteBlock);
39
+ expect(fs.seen).toEqual(["/home/u/.config/Code/User/mcp.json"]);
40
+ });
41
+
42
+ it("passes a present, writable target", () => {
43
+ const fs = probe({ present: ["/home/u/.vscode/extensions"] });
44
+ expect(probeWritable("/home/u/.vscode/extensions", fs)).toBeUndefined();
45
+ expect(fs.accessed).toEqual(["/home/u/.vscode/extensions"]);
46
+ });
47
+
48
+ it("passes an ABSENT target whose directory is writable", () => {
49
+ // The ordinary first run: the extensions dir does not exist yet, and it is
50
+ // writable exactly when the directory that would hold it is.
51
+ const fs = probe({ present: ["/home/u"] });
52
+ expect(probeWritable("/home/u/.vscode/extensions", fs)).toBeUndefined();
53
+ expect(fs.accessed).toEqual(["/home/u"]);
54
+ });
55
+
56
+ it("walks up through several missing levels to the nearest existing node", () => {
57
+ const fs = probe({ present: ["/home/u"] });
58
+ expect(
59
+ probeWritable("/home/u/.config/Code/User/mcp.json", fs),
60
+ ).toBeUndefined();
61
+ expect(fs.seen).toEqual([
62
+ "/home/u/.config/Code/User/mcp.json",
63
+ "/home/u/.config/Code/User",
64
+ "/home/u/.config/Code",
65
+ "/home/u/.config",
66
+ "/home/u",
67
+ ]);
68
+ });
69
+
70
+ it("follows the symlink: the STORE path is the answer, not the link's own name", () => {
71
+ // `~/.vscode/extensions` → `/nix/store/…`: nothing about the link's name
72
+ // says it is managed, and the raw fs error the write produced said nothing
73
+ // about Nix either.
74
+ const fs = probe({
75
+ present: ["/home/u/.vscode/extensions"],
76
+ realpath: {
77
+ "/home/u/.vscode/extensions": "/nix/store/xyz-vscode-with-extensions",
78
+ },
79
+ });
80
+ expect(probeWritable("/home/u/.vscode/extensions", fs)).toEqual({
81
+ kind: "nix-store",
82
+ resolved: "/nix/store/xyz-vscode-with-extensions",
83
+ } satisfies WriteBlock);
84
+ // W_OK is never consulted — see the next case for why that matters.
85
+ expect(fs.accessed).toEqual([]);
86
+ });
87
+
88
+ it("the store wins over W_OK: a store path NEVER reports `read-only`", () => {
89
+ // The store is read-only, so `access(W_OK)` fails there too. Reporting
90
+ // `read-only` would print a permissions remedy at a Nix user — advice that
91
+ // cannot work, and that the next `nixos-rebuild` would undo anyway.
92
+ const fs = probe({
93
+ present: ["/home/u/.vscode/extensions"],
94
+ realpath: { "/home/u/.vscode/extensions": "/nix/store/abc/extensions" },
95
+ denied: ["/nix/store/abc/extensions"],
96
+ });
97
+ expect(probeWritable("/home/u/.vscode/extensions", fs)?.kind).toBe(
98
+ "nix-store",
99
+ );
100
+ });
101
+
102
+ it("reports read-only with the RESOLVED path a remedy has to name", () => {
103
+ const fs = probe({
104
+ present: ["/mnt/ro/home/.vscode/extensions"],
105
+ realpath: {
106
+ "/mnt/ro/home/.vscode/extensions": "/mnt/ro/real/extensions",
107
+ },
108
+ denied: ["/mnt/ro/real/extensions"],
109
+ });
110
+ expect(probeWritable("/mnt/ro/home/.vscode/extensions", fs)).toEqual({
111
+ kind: "read-only",
112
+ path: "/mnt/ro/real/extensions",
113
+ } satisfies WriteBlock);
114
+ });
115
+
116
+ it("reports read-only for an absent target under a read-only directory", () => {
117
+ const fs = probe({ present: ["/mnt/ro"], denied: ["/mnt/ro"] });
118
+ expect(probeWritable("/mnt/ro/Code/User/mcp.json", fs)).toEqual({
119
+ kind: "read-only",
120
+ path: "/mnt/ro",
121
+ } satisfies WriteBlock);
122
+ });
123
+
124
+ it("blocks nothing when the walk runs out of parents", () => {
125
+ // `dirname("/")` is `"/"`, so the walk terminates on the fixed point. With
126
+ // nothing existing anywhere there is no permission answer to give, and
127
+ // inventing one either way would be a guess.
128
+ const fs = probe({});
129
+ expect(probeWritable("/nowhere/at/all", fs)).toBeUndefined();
130
+ expect(fs.seen.at(-1)).toBe("/");
131
+ });
132
+
133
+ it("blocks nothing when realpath itself fails — the write is the honest judge", () => {
134
+ // The node existed a moment ago and cannot be resolved now: a race, or a
135
+ // symlink loop. Reporting a skip would be a claim this probe cannot back.
136
+ const fs = probe({
137
+ present: ["/home/u/.vscode/extensions"],
138
+ realpathThrows: true,
139
+ });
140
+ expect(probeWritable("/home/u/.vscode/extensions", fs)).toBeUndefined();
141
+ });
142
+
143
+ it("defaults to the real filesystem — a writable temp dir passes", () => {
144
+ // The one case that touches the disk, so the default argument is covered
145
+ // by something other than a fixture claiming to be it. A temp dir, not
146
+ // `process.cwd()`: the case says "a writable directory", and the repo
147
+ // working tree is neither guaranteed to be one nor this suite's business.
148
+ const dir = mkdtempSync(join(tmpdir(), "pragma-writability-"));
149
+ expect(probeWritable(dir)).toBeUndefined();
150
+ });
151
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * "Could this command write there?" — asked BEFORE anything is written.
3
+ *
4
+ * Two machines answer no, and until this probe existed both answered it the
5
+ * same wrong way. A NixOS or home-manager user's `~/.vscode/extensions` is a
6
+ * symlink into `/nix/store`, which is a read-only filesystem by design:
7
+ * `--install-extension` failed with a raw fs error, or appeared to succeed and
8
+ * changed nothing. A managed config directory turned the MCP write into a
9
+ * `failed` row carrying an EACCES message and no next step. Neither is a
10
+ * failure of this command — the machine is configured exactly as its owner
11
+ * intends — so both become NAMED SKIPS whose remedy is the one action that
12
+ * works there: declaring the extension, or the MCP entry, in the config that
13
+ * owns the directory.
14
+ *
15
+ * Per the owner, 2026-09-16: the decision is made by the DIRECTORY, never by
16
+ * the OS. Nix is not a platform to branch on — a Nix user's home directory is
17
+ * ordinary, a non-Nix user can have a read-only mount, and `/etc/nixos` says
18
+ * nothing about whether THIS path is writable. So the probe walks the path it
19
+ * was actually given.
20
+ *
21
+ * The whole filesystem surface is the injected {@link FsProbe}, so every
22
+ * branch is exercised over a seam rather than by arranging a real read-only
23
+ * directory (which a test running as root cannot do at all). It lives under
24
+ * `setup/operations/` because setup is its one consumer: doctor reads the
25
+ * result off the detection records setup already produced, and never probes
26
+ * a second time.
27
+ */
28
+
29
+ import { accessSync, constants, existsSync, realpathSync } from "node:fs";
30
+ import { dirname } from "node:path";
31
+ import type { WriteBlock } from "../types.js";
32
+
33
+ /** The store prefix a Nix-managed path resolves under. */
34
+ const NIX_STORE_PREFIX = "/nix/store/";
35
+
36
+ /**
37
+ * The filesystem this probe uses — the seam, defaulting to `node:fs`.
38
+ *
39
+ * Narrowed to the three calls, so a fixture is three functions rather than a
40
+ * module mock, and so nothing here can reach a writer by accident.
41
+ */
42
+ export interface FsProbe {
43
+ existsSync(path: string): boolean;
44
+ accessSync(path: string, mode?: number): void;
45
+ realpathSync(path: string): string;
46
+ }
47
+
48
+ /** The real filesystem — the default, and the only impure thing in this module. */
49
+ const nodeFs: FsProbe = { existsSync, accessSync, realpathSync };
50
+
51
+ /**
52
+ * The nearest node at or above `target` that exists, or `undefined` when the
53
+ * walk runs out of parents.
54
+ *
55
+ * `dirname("/")` is `"/"`, so the loop terminates on the fixed point rather
56
+ * than on a path shape — the same is true of a win32 drive root, which is why
57
+ * the comparison is against the previous value and not against a separator.
58
+ *
59
+ * @param target - The path to start at.
60
+ * @param fs - The filesystem seam.
61
+ * @returns The nearest existing node, or `undefined`.
62
+ * @note Impure — stats each path it walks through the injected seam.
63
+ */
64
+ const findNearestExisting = (
65
+ target: string,
66
+ fs: FsProbe,
67
+ ): string | undefined => {
68
+ let current = target;
69
+ for (;;) {
70
+ if (fs.existsSync(current)) return current;
71
+ const parent = dirname(current);
72
+ if (parent === current) return undefined;
73
+ current = parent;
74
+ }
75
+ };
76
+
77
+ /**
78
+ * Probe whether `target` could be written, returning the {@link WriteBlock}
79
+ * that says why not — or `undefined` when the write can go ahead.
80
+ *
81
+ * Three rules, in this order:
82
+ *
83
+ * 1. **Walk up to the nearest node that exists**, starting AT the target. The
84
+ * target itself when it is there — home-manager symlinks `mcp.json`
85
+ * directly into the store while leaving `User/` writable, so stopping at
86
+ * the parent directory would call that file writable and then fail on the
87
+ * write. Otherwise its nearest existing ancestor, because a path that does
88
+ * not exist yet is writable exactly when the directory that would hold it
89
+ * is.
90
+ * 2. **`realpathSync` it**, so the answer is about the real filesystem rather
91
+ * than the symlink standing in front of it.
92
+ * 3. **`/nix/store/` wins over `W_OK`.** Checked first, deliberately: the
93
+ * store is read-only, so `access(W_OK)` would fail there too and the row
94
+ * would report "read-only" with a remedy about file permissions — advice
95
+ * that cannot work, because `chmod` on the store is not the fix and would
96
+ * be undone by the next `nixos-rebuild`. The remedy for a store path is a
97
+ * declaration in the user's own config, and naming the store is what earns
98
+ * it.
99
+ *
100
+ * @param target - The file or directory a write would create or modify.
101
+ * @param fs - The filesystem seam; defaults to the real `node:fs`.
102
+ * @returns The block, or `undefined` when the path is writable.
103
+ * @note Impure by default — it stats and resolves real paths. Every branch is
104
+ * reachable through the injected {@link FsProbe}.
105
+ */
106
+ export function probeWritable(
107
+ target: string,
108
+ fs: FsProbe = nodeFs,
109
+ ): WriteBlock | undefined {
110
+ const existing = findNearestExisting(target, fs);
111
+ // Nothing at or above the target exists. That is not a permission answer,
112
+ // and inventing one either way would be a guess — the write will report its
113
+ // own error if it is really unreachable.
114
+ if (existing === undefined) return undefined;
115
+
116
+ let resolved: string;
117
+ try {
118
+ resolved = fs.realpathSync(existing);
119
+ } catch {
120
+ // The node existed a moment ago and cannot be resolved now (a race, or a
121
+ // symlink loop). Not a block: the caller's write is the honest judge.
122
+ return undefined;
123
+ }
124
+
125
+ if (resolved.startsWith(NIX_STORE_PREFIX)) {
126
+ return { kind: "nix-store", resolved };
127
+ }
128
+
129
+ try {
130
+ fs.accessSync(resolved, constants.W_OK);
131
+ } catch {
132
+ return { kind: "read-only", path: resolved };
133
+ }
134
+ return undefined;
135
+ }
@@ -117,6 +117,17 @@ export interface SetupPlan {
117
117
  readonly rows: readonly PlanRow[];
118
118
  }
119
119
 
120
+ /**
121
+ * The context a path renders against — the plan's two named roots.
122
+ *
123
+ * It lives here, beside {@link SetupPlan}, because it IS that structure's
124
+ * `roots` field and every reader of it already imports this module: the target
125
+ * table, the renderers, the doctor checks, and the operations whose messages
126
+ * shorten a path. Declaring it in the target table instead put the operations
127
+ * in the position of importing the module that imports them.
128
+ */
129
+ export type Roots = SetupPlan["roots"];
130
+
120
131
  /**
121
132
  * Whether a plan action would touch the filesystem. `none` and `skip` are the
122
133
  * two quiet outcomes: a converged re-run composes no effects at all, so mtimes