@astryxdesign/cli 0.7.2-canary.fdd384f → 0.7.2

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 (450) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +169 -149
  3. package/api/build/_adapter.d.mts +8 -0
  4. package/api/build/_adapter.mjs +12 -1
  5. package/api/build/build.doc.mjs +4 -0
  6. package/api/build/kit/kit.mjs +3 -11
  7. package/api/component/_adapter.d.mts +31 -0
  8. package/api/component/_adapter.mjs +119 -11
  9. package/api/component/component.doc.mjs +6 -1
  10. package/api/component/component.mjs +60 -10
  11. package/api/component/doc-links.test.mjs +386 -0
  12. package/api/discover/_adapter.d.mts +132 -12
  13. package/api/discover/_adapter.mjs +488 -27
  14. package/api/discover/_adapter.test.mjs +599 -17
  15. package/api/discover/_catalog-view.d.mts +16 -5
  16. package/api/discover/_catalog-view.mjs +32 -8
  17. package/api/discover/detail/doc/doc.mjs +9 -1
  18. package/api/discover/discover.d.mts +11 -0
  19. package/api/discover/discover.doc.mjs +9 -7
  20. package/api/discover/discover.mjs +121 -19
  21. package/api/discover/discover.type.d.mts +20 -7
  22. package/api/discover/discover.type.mjs +17 -7
  23. package/api/discover/list/list.d.mts +4 -3
  24. package/api/discover/list/list.mjs +17 -10
  25. package/api/docs/_adapter.d.mts +63 -0
  26. package/api/docs/_adapter.mjs +312 -18
  27. package/api/docs/docs.doc.mjs +2 -1
  28. package/api/docs/docs.mjs +2 -1
  29. package/api/docs/docs.test.mjs +5 -2
  30. package/api/docs/integration-tree.test.mjs +22 -0
  31. package/api/docs/list/list.mjs +12 -2
  32. package/api/docs/reference-blocks.test.mjs +1 -3
  33. package/api/doctor/_adapter.d.mts +108 -0
  34. package/api/doctor/_adapter.mjs +223 -4
  35. package/api/doctor/doctor.d.mts +199 -0
  36. package/api/doctor/doctor.doc.mjs +3 -2
  37. package/api/doctor/doctor.mjs +530 -34
  38. package/api/doctor/doctor.test.mjs +180 -14
  39. package/api/doctor/release-checks.test.mjs +156 -0
  40. package/api/doctor/theme-checks.mjs +16 -3
  41. package/api/doctor/theme-checks.test.mjs +223 -7
  42. package/api/error.d.mts +10 -0
  43. package/api/error.mjs +25 -0
  44. package/api/gap-report/gap-report.d.mts +3 -0
  45. package/api/gap-report/gap-report.doc.mjs +11 -3
  46. package/api/gap-report/gap-report.mjs +152 -48
  47. package/api/gap-report/gap-report.test.mjs +233 -0
  48. package/api/gap-report/gap-report.type.d.mts +41 -0
  49. package/api/gap-report/gap-report.type.mjs +18 -0
  50. package/api/hook/_adapter.mjs +3 -11
  51. package/api/hook/hook.doc.mjs +4 -0
  52. package/api/hook/list/list.d.mts +1 -1
  53. package/api/init/_adapter.d.mts +22 -0
  54. package/api/init/_adapter.mjs +59 -0
  55. package/api/init/init.d.mts +7 -5
  56. package/api/init/init.doc.mjs +28 -7
  57. package/api/init/init.mjs +28 -4
  58. package/api/init/init.type.d.mts +48 -3
  59. package/api/init/init.type.mjs +37 -3
  60. package/api/init/run/run.d.mts +1 -1
  61. package/api/init/run/run.mjs +15 -4
  62. package/api/init/user/user.d.mts +14 -0
  63. package/api/init/user/user.mjs +87 -0
  64. package/api/init/user/user.test.mjs +232 -0
  65. package/api/integration/_adapter.d.mts +33 -0
  66. package/api/integration/_adapter.mjs +58 -0
  67. package/api/integration/authoring-checks.mjs +100 -5
  68. package/api/integration/authoring-checks.test.mjs +769 -0
  69. package/api/integration/authoring-checks.type.d.mts +134 -0
  70. package/api/integration/authoring-checks.type.mjs +60 -1
  71. package/api/integration/integrationCheck.doc.mjs +1 -1
  72. package/api/integration/integrationComponentConflicts.doc.mjs +11 -3
  73. package/api/integration/integrationDocConflicts.doc.mjs +22 -5
  74. package/api/integration/pack-check.mjs +303 -13
  75. package/api/integration/pack-check.test.mjs +371 -10
  76. package/api/integration/validate-integration-fixes.test.mjs +67 -0
  77. package/api/integration/validate-integration.d.mts +8 -0
  78. package/api/integration/validate-integration.mjs +55 -9
  79. package/api/integration/validate-integration.test.mjs +77 -8
  80. package/api/integration/validate-integration.type.d.mts +8 -0
  81. package/api/integration/validate-integration.type.mjs +3 -1
  82. package/api/integration/validate-unread-theme-folders.test.mjs +3 -2
  83. package/api/integration/validateIntegration.doc.mjs +4 -2
  84. package/api/search/_adapter.d.mts +11 -0
  85. package/api/search/_adapter.mjs +25 -0
  86. package/api/search/search.d.mts +73 -13
  87. package/api/search/search.doc.mjs +8 -3
  88. package/api/search/search.domain-priority.test.mjs +108 -18
  89. package/api/search/search.mjs +541 -52
  90. package/api/search/search.test.mjs +143 -1
  91. package/api/search/search.type.d.mts +14 -1
  92. package/api/search/search.type.mjs +6 -1
  93. package/api/swizzle/_adapter.mjs +2 -2
  94. package/api/swizzle/copy/copy.mjs +1 -1
  95. package/api/template/template-integration.test.mjs +1 -1
  96. package/api/theme/_adapter.d.mts +16 -0
  97. package/api/theme/_adapter.mjs +32 -5
  98. package/api/theme/build/build.d.mts +2 -1
  99. package/api/theme/build/build.mjs +70 -14
  100. package/api/theme/build/build.packed-old-core.test.mjs +20 -3
  101. package/api/theme/build/font-warning.mjs +5 -3
  102. package/api/theme/build/font-warning.test.mjs +2 -1
  103. package/api/theme/remove/remove.test.mjs +2 -2
  104. package/api/theme/targets/targets.mjs +3 -10
  105. package/api/theme/themeTargets.doc.mjs +4 -0
  106. package/api/theme/use/use.test.mjs +1 -1
  107. package/api/upgrade/_adapter.d.mts +40 -2
  108. package/api/upgrade/_adapter.mjs +118 -5
  109. package/api/upgrade/run/cli-behind-core.test.mjs +438 -0
  110. package/api/upgrade/run/run.mjs +35 -2
  111. package/api/upgrade/status/status.d.mts +21 -5
  112. package/api/upgrade/status/status.mjs +45 -8
  113. package/api/upgrade/upgrade.doc.mjs +6 -3
  114. package/api/upgrade/upgrade.mjs +5 -1
  115. package/api/upgrade/upgrade.type.d.mts +23 -0
  116. package/api/upgrade/upgrade.type.mjs +16 -1
  117. package/assets/codemods/__tests__/registry.test.mjs +1 -0
  118. package/assets/codemods/registry.mjs +1 -0
  119. package/assets/codemods/runner.mjs +7 -1
  120. package/assets/codemods/transforms/next/index.mjs +1 -11
  121. package/assets/codemods/transforms/v0.7.2/index.mjs +20 -0
  122. package/assets/discover/official-packages.json +367 -0
  123. package/assets/docs/author-a-theme.doc.mjs +2 -2
  124. package/assets/docs/browser-support.doc.mjs +1 -1
  125. package/assets/docs/getting-started.doc.mjs +23 -3
  126. package/assets/docs/styling-overview.doc.mjs +1 -1
  127. package/assets/docs/theme.doc.mjs +1 -1
  128. package/assets/docs/tree/add-a-component.doc.mjs +12 -2
  129. package/assets/docs/tree/add-a-theme.doc.mjs +13 -3
  130. package/assets/docs/tree/add-a-topic.doc.mjs +54 -9
  131. package/assets/docs/tree/agent-guidance.doc.mjs +19 -5
  132. package/assets/docs/tree/block-template.doc.mjs +6 -18
  133. package/assets/docs/tree/build-the-template.doc.mjs +1 -1
  134. package/assets/docs/tree/building-blocks.doc.mjs +4 -0
  135. package/assets/docs/tree/check-the-props.doc.mjs +8 -3
  136. package/assets/docs/tree/check-your-docs.doc.mjs +30 -13
  137. package/assets/docs/tree/checks.doc.mjs +17 -17
  138. package/assets/docs/tree/ci-and-containers.doc.mjs +88 -0
  139. package/assets/docs/tree/cli-command.doc.mjs +142 -0
  140. package/assets/docs/tree/cli.doc.mjs +6 -0
  141. package/assets/docs/tree/codemods.doc.mjs +17 -7
  142. package/assets/docs/tree/commands.doc.mjs +7 -1
  143. package/assets/docs/tree/component-family.doc.mjs +3 -5
  144. package/assets/docs/tree/component-imports.doc.mjs +13 -3
  145. package/assets/docs/tree/components.doc.mjs +7 -1
  146. package/assets/docs/tree/computer-agent-guide.doc.mjs +160 -0
  147. package/assets/docs/tree/computer-integrations.doc.mjs +93 -0
  148. package/assets/docs/tree/configuration.doc.mjs +7 -3
  149. package/assets/docs/tree/debug-and-gap-reports.doc.mjs +41 -22
  150. package/assets/docs/tree/define-the-theme.doc.mjs +6 -5
  151. package/assets/docs/tree/describe-the-component.doc.mjs +2 -2
  152. package/assets/docs/tree/docs.doc.mjs +9 -3
  153. package/assets/docs/tree/document-the-template.doc.mjs +1 -1
  154. package/assets/docs/tree/document-the-theme.doc.mjs +3 -3
  155. package/assets/docs/tree/export-template-assets.doc.mjs +1 -1
  156. package/assets/docs/tree/extend-or-replace.doc.mjs +19 -9
  157. package/assets/docs/tree/fonts-and-assets.doc.mjs +2 -2
  158. package/assets/docs/tree/generate-a-palette.doc.mjs +7 -5
  159. package/assets/docs/tree/global-integrations.doc.mjs +124 -0
  160. package/assets/docs/tree/global.doc.mjs +131 -0
  161. package/assets/docs/tree/grade-template-with-agent.doc.mjs +5 -5
  162. package/assets/docs/tree/install-once.doc.mjs +105 -0
  163. package/assets/docs/tree/integrations.doc.mjs +13 -1
  164. package/assets/docs/tree/layout-spacing.doc.mjs +2 -3
  165. package/assets/docs/tree/layout.doc.mjs +6 -6
  166. package/assets/docs/tree/links.doc.mjs +6 -5
  167. package/assets/docs/tree/migration-setup.doc.mjs +3 -2
  168. package/assets/docs/tree/migration-verification.doc.mjs +0 -1
  169. package/assets/docs/tree/migration.doc.mjs +0 -1
  170. package/assets/docs/tree/monorepos.doc.mjs +84 -0
  171. package/assets/docs/tree/package-and-test.doc.mjs +1 -1
  172. package/assets/docs/tree/page-template.doc.mjs +3 -7
  173. package/assets/docs/tree/problems.doc.mjs +205 -0
  174. package/assets/docs/tree/project-global-or-both.doc.mjs +131 -0
  175. package/assets/docs/tree/publishing.doc.mjs +6 -6
  176. package/assets/docs/tree/quick-start.doc.mjs +52 -26
  177. package/assets/docs/tree/reference.doc.mjs +192 -0
  178. package/assets/docs/tree/replace-a-core-component.doc.mjs +15 -7
  179. package/assets/docs/tree/replace-a-core-template.doc.mjs +9 -11
  180. package/assets/docs/tree/scaffold.doc.mjs +1 -1
  181. package/assets/docs/tree/sections-and-placement.doc.mjs +9 -11
  182. package/assets/docs/tree/see-it-in-an-app.doc.mjs +17 -5
  183. package/assets/docs/tree/short-and-findable.doc.mjs +9 -8
  184. package/assets/docs/tree/single-component.doc.mjs +14 -6
  185. package/assets/docs/tree/start-a-template.doc.mjs +18 -8
  186. package/assets/docs/tree/subcomponent.doc.mjs +6 -6
  187. package/assets/docs/tree/template-assets.doc.mjs +2 -2
  188. package/assets/docs/tree/template-doc-overview.doc.mjs +6 -17
  189. package/assets/docs/tree/template-fonts.doc.mjs +1 -1
  190. package/assets/docs/tree/template-icons.doc.mjs +1 -1
  191. package/assets/docs/tree/template-images-media.doc.mjs +1 -1
  192. package/assets/docs/tree/template-styles.doc.mjs +2 -2
  193. package/assets/docs/tree/templates.doc.mjs +7 -1
  194. package/assets/docs/tree/test-in-an-app.doc.mjs +7 -7
  195. package/assets/docs/tree/test-template-in-app.doc.mjs +5 -5
  196. package/assets/docs/tree/themes.doc.mjs +4 -0
  197. package/assets/docs/tree/tokens-and-setup.doc.mjs +19 -15
  198. package/assets/docs/tree/troubleshooting.doc.mjs +22 -14
  199. package/assets/docs/tree/uninstall.doc.mjs +109 -0
  200. package/assets/docs/tree/updates.doc.mjs +92 -0
  201. package/assets/docs/tree/upgrading.doc.mjs +6 -6
  202. package/assets/docs/tree/use-a-theme-in-an-app.doc.mjs +14 -2
  203. package/assets/docs/tree/verify-packed-template.doc.mjs +3 -3
  204. package/assets/docs/tree/versioning.doc.mjs +9 -9
  205. package/assets/docs/tree/who-answers.doc.mjs +119 -0
  206. package/assets/docs/tree/write-the-template-file.doc.mjs +5 -5
  207. package/assets/docs/working-with-ai.doc.mjs +23 -9
  208. package/authoring/codemod/codemod.doc.mjs +7 -3
  209. package/authoring/config/config.doc.mjs +9 -6
  210. package/authoring/config/type.ts +4 -2
  211. package/authoring/debug/parse.d.mts +5 -5
  212. package/authoring/discover/discover.doc.mjs +7 -1
  213. package/authoring/doctypes/_schema.d.mts +8 -3
  214. package/authoring/doctypes/_schema.mjs +1 -0
  215. package/authoring/doctypes/base/graph-fields.doc.mjs +6 -4
  216. package/authoring/doctypes/command/command.doc.mjs +20 -12
  217. package/authoring/doctypes/command/type.ts +14 -0
  218. package/authoring/doctypes/component/component.doc.mjs +29 -26
  219. package/authoring/doctypes/component/type.ts +15 -5
  220. package/authoring/doctypes/enum/enum.doc.mjs +7 -4
  221. package/authoring/doctypes/function/function.doc.mjs +9 -8
  222. package/authoring/doctypes/hook/hook.doc.mjs +5 -4
  223. package/authoring/doctypes/hook/type.ts +4 -0
  224. package/authoring/doctypes/namespace/namespace.doc.mjs +7 -6
  225. package/authoring/doctypes/reference/reference.doc.mjs +12 -11
  226. package/authoring/doctypes/schema/schema.doc.mjs +9 -9
  227. package/authoring/doctypes/template/template.doc.mjs +2 -2
  228. package/authoring/doctypes/theme/theme.doc.mjs +2 -2
  229. package/authoring/gap-report/gap-report.doc.mjs +2 -2
  230. package/authoring/identity/identity.doc.mjs +3 -3
  231. package/authoring/index.d.ts +1 -0
  232. package/authoring/integration/integration.doc.mjs +22 -5
  233. package/authoring/integration/parse.test.mjs +11 -0
  234. package/authoring/integration/schema.d.mts +14 -3
  235. package/authoring/integration/schema.mjs +33 -2
  236. package/authoring/integration/type.ts +8 -0
  237. package/clients/cli/bin/astryx.mjs +20 -3
  238. package/clients/cli/cli-command.test.mjs +166 -0
  239. package/clients/cli/commands/blog.doc.mjs +1 -0
  240. package/clients/cli/commands/build-theme.batch-report.test.mjs +14 -2
  241. package/clients/cli/commands/build-theme.font-warning.test.mjs +3 -3
  242. package/clients/cli/commands/build-theme.mjs +25 -12
  243. package/clients/cli/commands/build.doc.mjs +1 -0
  244. package/clients/cli/commands/build.exit-codes-doc.test.mjs +4 -0
  245. package/clients/cli/commands/cli-postinstall.test.mjs +49 -1
  246. package/clients/cli/commands/component.doc.mjs +1 -0
  247. package/clients/cli/commands/discover.broken-integration.test.mjs +11 -7
  248. package/clients/cli/commands/discover.doc.mjs +9 -1
  249. package/clients/cli/commands/discover.mjs +144 -93
  250. package/clients/cli/commands/discover.no-source.test.mjs +448 -33
  251. package/clients/cli/commands/discover.sources.test.mjs +20 -1
  252. package/clients/cli/commands/docs.doc.mjs +1 -0
  253. package/clients/cli/commands/docs.mjs +49 -19
  254. package/clients/cli/commands/docs.test.mjs +2 -2
  255. package/clients/cli/commands/doctor-integration-components.doc.mjs +7 -3
  256. package/clients/cli/commands/doctor-integration-docs.doc.mjs +20 -5
  257. package/clients/cli/commands/doctor-integration-templates.doc.mjs +1 -0
  258. package/clients/cli/commands/doctor-integration-validate.doc.mjs +13 -2
  259. package/clients/cli/commands/doctor-integration.test.mjs +52 -4
  260. package/clients/cli/commands/doctor.doc.mjs +12 -2
  261. package/clients/cli/commands/doctor.mjs +102 -7
  262. package/clients/cli/commands/doctor.test.mjs +10 -4
  263. package/clients/cli/commands/gap-report.doc.mjs +15 -2
  264. package/clients/cli/commands/gap-report.mjs +24 -2
  265. package/clients/cli/commands/gap-report.test.mjs +79 -0
  266. package/clients/cli/commands/hook.doc.mjs +1 -0
  267. package/clients/cli/commands/init.behavior.test.mjs +5 -0
  268. package/clients/cli/commands/init.doc.mjs +29 -3
  269. package/clients/cli/commands/integration-add.doc.mjs +7 -0
  270. package/clients/cli/commands/integration-authoring.test.mjs +49 -3
  271. package/clients/cli/commands/integration-check.doc.mjs +10 -2
  272. package/clients/cli/commands/integration-check.test.mjs +175 -1
  273. package/clients/cli/commands/integration-pack.doc.mjs +8 -1
  274. package/clients/cli/commands/integration-verify.doc.mjs +8 -1
  275. package/clients/cli/commands/integration.mjs +15 -9
  276. package/clients/cli/commands/json-contract.test.mjs +11 -2
  277. package/clients/cli/commands/manifest.doc.mjs +1 -0
  278. package/clients/cli/commands/outside-project.test.mjs +774 -0
  279. package/clients/cli/commands/search.doc.mjs +1 -0
  280. package/clients/cli/commands/search.mjs +16 -11
  281. package/clients/cli/commands/search.test.mjs +429 -296
  282. package/clients/cli/commands/setup-nudge.test.mjs +102 -2
  283. package/clients/cli/commands/swizzle.doc.mjs +7 -0
  284. package/clients/cli/commands/swizzle.mjs +5 -3
  285. package/clients/cli/commands/swizzle.routing.test.mjs +19 -2
  286. package/clients/cli/commands/template.doc.mjs +1 -0
  287. package/clients/cli/commands/theme-add-lifecycle.test.mjs +3 -3
  288. package/clients/cli/commands/theme-add.doc.mjs +8 -1
  289. package/clients/cli/commands/theme-build.doc.mjs +1 -0
  290. package/clients/cli/commands/theme-eject.doc.mjs +1 -0
  291. package/clients/cli/commands/theme-list.behavior.test.mjs +6 -0
  292. package/clients/cli/commands/theme-list.doc.mjs +1 -0
  293. package/clients/cli/commands/theme-palette-generate.doc.mjs +1 -0
  294. package/clients/cli/commands/theme-remove.doc.mjs +7 -0
  295. package/clients/cli/commands/theme-targets.doc.mjs +1 -0
  296. package/clients/cli/commands/theme-template.doc.mjs +1 -0
  297. package/clients/cli/commands/theme-use.doc.mjs +7 -0
  298. package/clients/cli/commands/upgrade.config-ordering.test.mjs +2 -1
  299. package/clients/cli/commands/upgrade.doc.mjs +22 -1
  300. package/clients/cli/commands/upgrade.file-protection.test.mjs +3 -2
  301. package/clients/cli/commands/upgrade.hook-output.test.mjs +2 -1
  302. package/clients/cli/commands/upgrade.older-cli.test.mjs +160 -0
  303. package/clients/cli/index.mjs +111 -16
  304. package/clients/cli/lib/cli-error.mjs +4 -1
  305. package/clients/cli/lib/component-format.test.mjs +1 -1
  306. package/clients/cli/lib/define-command.mjs +2 -2
  307. package/clients/cli/lib/json-shim.mjs +3 -1
  308. package/clients/cli/lib/manifest.d.ts +9 -0
  309. package/clients/cli/lib/manifest.mjs +17 -1
  310. package/clients/cli/lib/manifest.test.mjs +25 -0
  311. package/foundation/agent-docs/agent-doc-state.d.mts +35 -6
  312. package/foundation/agent-docs/agent-doc-state.mjs +66 -6
  313. package/foundation/agent-docs/agent-docs.d.mts +4 -3
  314. package/foundation/agent-docs/agent-docs.mjs +11 -9
  315. package/foundation/agent-docs/computer-agent-text.d.mts +133 -0
  316. package/foundation/agent-docs/computer-agent-text.mjs +623 -0
  317. package/foundation/agent-docs/computer-agent-text.test.mjs +602 -0
  318. package/foundation/config/project.d.mts +35 -3
  319. package/foundation/config/project.mjs +145 -8
  320. package/foundation/config/project.test.mjs +66 -0
  321. package/foundation/config/theme-state.mjs +5 -4
  322. package/foundation/discovery/authoring-self-docs.mjs +1 -1
  323. package/foundation/discovery/cli-self-docs.mjs +20 -3
  324. package/foundation/discovery/cli-self-docs.test.mjs +3 -2
  325. package/foundation/discovery/component-replacement.mjs +2 -1
  326. package/foundation/discovery/copied-passages.d.mts +216 -0
  327. package/foundation/discovery/copied-passages.mjs +720 -0
  328. package/foundation/discovery/copied-passages.test.mjs +124 -0
  329. package/foundation/discovery/doc-consistency.d.mts +34 -0
  330. package/foundation/discovery/doc-consistency.mjs +513 -0
  331. package/foundation/discovery/doc-consistency.test.mjs +347 -0
  332. package/foundation/discovery/doc-fluff.d.mts +112 -0
  333. package/foundation/discovery/doc-fluff.mjs +328 -0
  334. package/foundation/discovery/doc-fluff.test.mjs +142 -0
  335. package/foundation/discovery/doc-types.d.mts +1 -1
  336. package/foundation/discovery/doc-types.mjs +15 -8
  337. package/foundation/discovery/doc-types.test.mjs +94 -2
  338. package/foundation/discovery/docs-discovery.d.mts +7 -0
  339. package/foundation/discovery/docs-discovery.mjs +26 -2
  340. package/foundation/discovery/project-typescript.d.mts +11 -2
  341. package/foundation/discovery/project-typescript.mjs +32 -4
  342. package/foundation/discovery/prop-drift.d.mts +26 -7
  343. package/foundation/discovery/prop-drift.mjs +173 -8
  344. package/foundation/discovery/prop-drift.test.mjs +120 -2
  345. package/foundation/discovery/runner-allowlist.d.mts +40 -0
  346. package/foundation/discovery/runner-allowlist.mjs +53 -0
  347. package/foundation/discovery/stray-pointers.d.mts +52 -0
  348. package/foundation/discovery/stray-pointers.mjs +293 -0
  349. package/foundation/discovery/stray-pointers.test.mjs +86 -0
  350. package/foundation/discovery/template-needs.mjs +5 -2
  351. package/foundation/discovery/theme-imports.mjs +8 -4
  352. package/foundation/discovery/theme-imports.test.mjs +1 -1
  353. package/foundation/discovery/type-text.mjs +1 -1
  354. package/foundation/discovery/type-text.test.mjs +1 -1
  355. package/foundation/doc-compiler/doc-loads.test.mjs +42 -2
  356. package/foundation/doc-compiler/inputs.mjs +4 -1
  357. package/foundation/doc-compiler/links.d.mts +32 -0
  358. package/foundation/doc-compiler/links.mjs +163 -26
  359. package/foundation/doc-compiler/links.test.mjs +97 -4
  360. package/foundation/doc-compiler/read.d.mts +1 -1
  361. package/foundation/doc-compiler/tree.mjs +30 -1
  362. package/foundation/doc-compiler/tree.test.mjs +8 -1
  363. package/foundation/env/cli-command.d.mts +83 -0
  364. package/foundation/env/cli-command.mjs +152 -0
  365. package/foundation/env/cli-command.test.mjs +129 -0
  366. package/foundation/env/node-version.test.mjs +15 -3
  367. package/foundation/env/package-manager.d.mts +93 -9
  368. package/foundation/env/package-manager.global.test.mjs +66 -0
  369. package/foundation/env/package-manager.mjs +184 -13
  370. package/foundation/env/package-manager.test.mjs +341 -15
  371. package/foundation/env/project-scope.d.mts +9 -0
  372. package/foundation/env/project-scope.mjs +33 -0
  373. package/foundation/env/search-path.d.mts +42 -0
  374. package/foundation/env/search-path.mjs +205 -0
  375. package/foundation/env/search-path.test.mjs +122 -0
  376. package/foundation/env/user-dirs.d.mts +39 -0
  377. package/foundation/env/user-dirs.mjs +93 -0
  378. package/foundation/env/user-dirs.test.mjs +72 -0
  379. package/foundation/env/user-settings.d.mts +46 -0
  380. package/foundation/env/user-settings.mjs +106 -0
  381. package/foundation/env/user-settings.test.mjs +64 -0
  382. package/foundation/fs/file-protection.mjs +15 -1
  383. package/foundation/fs/module-loader.d.mts +2 -1
  384. package/foundation/fs/module-loader.mjs +60 -4
  385. package/foundation/fs/module-loader.stdout.test.mjs +3 -2
  386. package/foundation/fs/module-loader.test.mjs +194 -0
  387. package/foundation/fs/path-compare.d.mts +33 -0
  388. package/foundation/fs/path-compare.mjs +73 -0
  389. package/foundation/fs/paths.d.mts +57 -1
  390. package/foundation/fs/paths.mjs +131 -3
  391. package/foundation/fs/paths.test.mjs +10 -1
  392. package/foundation/integrations/computer-integrations.d.mts +78 -0
  393. package/foundation/integrations/computer-integrations.mjs +316 -0
  394. package/foundation/integrations/computer-integrations.test.mjs +292 -0
  395. package/foundation/integrations/contribution-inventory.d.mts +11 -0
  396. package/foundation/integrations/contribution-inventory.mjs +50 -2
  397. package/foundation/integrations/integration-warnings.mjs +3 -2
  398. package/foundation/integrations/integration-warnings.test.mjs +8 -9
  399. package/foundation/integrations/integrations.d.mts +25 -1
  400. package/foundation/integrations/integrations.mjs +46 -3
  401. package/foundation/integrations/issue.ts +6 -0
  402. package/foundation/integrations/validate-contributions.d.mts +2 -0
  403. package/foundation/integrations/validate-contributions.mjs +41 -1
  404. package/foundation/release/agent-text-worker.d.mts +4 -0
  405. package/foundation/release/agent-text-worker.mjs +52 -0
  406. package/foundation/release/cache.d.mts +310 -0
  407. package/foundation/release/cache.mjs +1216 -0
  408. package/foundation/release/cache.test.mjs +920 -0
  409. package/foundation/release/choose.d.mts +207 -0
  410. package/foundation/release/choose.mjs +677 -0
  411. package/foundation/release/choose.test.mjs +427 -0
  412. package/foundation/release/core-worker.d.mts +4 -0
  413. package/foundation/release/core-worker.mjs +38 -0
  414. package/foundation/release/global-state.d.mts +259 -0
  415. package/foundation/release/global-state.mjs +472 -0
  416. package/foundation/release/global-state.test.mjs +381 -0
  417. package/foundation/release/handoff.d.mts +60 -0
  418. package/foundation/release/handoff.e2e.test.mjs +278 -0
  419. package/foundation/release/handoff.mjs +357 -0
  420. package/foundation/release/lock-lease.d.mts +23 -0
  421. package/foundation/release/lock-lease.mjs +80 -0
  422. package/foundation/release/notices.d.mts +253 -0
  423. package/foundation/release/notices.mjs +596 -0
  424. package/foundation/release/notices.test.mjs +565 -0
  425. package/foundation/release/npm-guard.d.mts +4 -0
  426. package/foundation/release/npm-guard.mjs +126 -0
  427. package/foundation/release/project.d.mts +141 -0
  428. package/foundation/release/project.mjs +466 -0
  429. package/foundation/release/project.test.mjs +288 -0
  430. package/foundation/release/refresh-worker.d.mts +4 -0
  431. package/foundation/release/refresh-worker.mjs +27 -0
  432. package/foundation/release/run-context.d.mts +47 -0
  433. package/foundation/release/run-context.mjs +149 -0
  434. package/foundation/release/update-notice-env.d.mts +28 -0
  435. package/foundation/release/update-notice-env.mjs +39 -0
  436. package/foundation/response/base.d.ts +26 -3
  437. package/foundation/response/error-codes.d.mts +3 -1
  438. package/foundation/response/error-codes.doc.mjs +12 -0
  439. package/foundation/response/error-codes.mjs +17 -0
  440. package/foundation/response/error-codes.test.mjs +2 -0
  441. package/foundation/response/json-contract.test.mjs +7 -2
  442. package/foundation/response/json.mjs +4 -1
  443. package/foundation/response/response-types.doc.mjs +17 -11
  444. package/foundation/response/response-types.doc.test.mjs +46 -0
  445. package/foundation/response/response.doc.mjs +3 -3
  446. package/foundation/xle/browser-imports.test.mjs +74 -0
  447. package/package.json +11 -11
  448. package/scripts/postinstall.mjs +24 -5
  449. /package/assets/codemods/transforms/{next → v0.7.2}/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +0 -0
  450. /package/assets/codemods/transforms/{next → v0.7.2}/migrate-list-edge-compensation-to-edge.mjs +0 -0
@@ -0,0 +1,1216 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The per-user cache of CLI releases.
5
+ *
6
+ * Global mode answers some runs with a release other than the copy that
7
+ * started them (spec AST-081 FR2, FR6): the release that matches a project's
8
+ * Core, the newest release for the global copy, or the Core a copy carries
9
+ * outside a project. Each is installed here, once, through the package
10
+ * manager, and reused by every later run:
11
+ *
12
+ * <user cache>/astryx/releases/<version>/
13
+ * package.json (an empty private package)
14
+ * node_modules/@astryxdesign/cli
15
+ * node_modules/@astryxdesign/core
16
+ * release.json (written last: the release is verified)
17
+ *
18
+ * A release counts only once `release.json` exists. It is written after the
19
+ * install finished, the CLI started and reported its own version, and the
20
+ * release supports this computer's Node. An install builds in a temporary
21
+ * folder beside the others and is renamed into place, so a run never sees a
22
+ * half-installed release, and a lock keeps two runs from installing the same
23
+ * release at once.
24
+ *
25
+ * The package manager is npm, started from the Node that runs the CLI, so the
26
+ * registry, proxy, and offline settings in the user's npm configuration
27
+ * apply. No Astryx setting changes where or how releases install.
28
+ *
29
+ * @input a release version
30
+ * @output the release's folder, or why it is unavailable
31
+ * @position foundation/release — used by the bin's hand-off, by the carried
32
+ * Core lookup, and by the background refresh worker.
33
+ */
34
+
35
+ import * as fs from 'node:fs';
36
+ import * as os from 'node:os';
37
+ import * as path from 'node:path';
38
+ import {spawnSync} from 'node:child_process';
39
+ import {createHash} from 'node:crypto';
40
+ import {fileURLToPath} from 'node:url';
41
+ import {satisfiesRange} from '../env/semver.mjs';
42
+ import {readJson} from './project.mjs';
43
+ import {userCacheDir as userCacheDirFor} from '../env/user-dirs.mjs';
44
+ import {foreignLockIsSilent} from './lock-lease.mjs';
45
+ import {relativeWithin} from '../fs/path-compare.mjs';
46
+
47
+ /**
48
+ * The per-user cache directory every Astryx cache lives in, never inside a
49
+ * project (`foundation/env/user-dirs.mjs`).
50
+ * @returns {string}
51
+ */
52
+ export function userCacheDir() {
53
+ return userCacheDirFor();
54
+ }
55
+
56
+ /**
57
+ * The folder all cached releases live in.
58
+ * @param {string} [cacheDir]
59
+ * @returns {string}
60
+ */
61
+ export function releasesDir(cacheDir = userCacheDir()) {
62
+ return path.join(cacheDir, 'releases');
63
+ }
64
+
65
+ /** A release version safe to use as a folder name and an npm spec. */
66
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
67
+
68
+ /**
69
+ * Is `version` a plain release version?
70
+ * @param {unknown} version
71
+ * @returns {version is string}
72
+ */
73
+ export function isReleaseVersion(version) {
74
+ return typeof version === 'string' && VERSION_RE.test(version);
75
+ }
76
+
77
+ /**
78
+ * A cached, verified release.
79
+ * @typedef {object} CachedRelease
80
+ * @property {string} version
81
+ * @property {string} dir the release folder
82
+ * @property {string} cliDir its `@astryxdesign/cli`
83
+ * @property {string} coreDir its `@astryxdesign/core`
84
+ * @property {string} bin the CLI's `astryx` entry file
85
+ */
86
+
87
+ /**
88
+ * The entry file a CLI package's `astryx` bin points at.
89
+ * @param {string} cliDir
90
+ * @returns {string | null}
91
+ */
92
+ export function cliBinOf(cliDir) {
93
+ const pkg = readJson(path.join(cliDir, 'package.json'));
94
+ const bin =
95
+ typeof pkg?.bin === 'string' ? pkg.bin : pkg?.bin && pkg.bin.astryx;
96
+ if (typeof bin !== 'string') return null;
97
+ const file = path.join(cliDir, bin);
98
+ return fs.existsSync(file) ? file : null;
99
+ }
100
+
101
+ /**
102
+ * The files whose bytes a cached release's marker records, relative to the
103
+ * release folder: enough to notice a damaged or half-written release without
104
+ * reading every file on each run.
105
+ * @param {string} bin the CLI's entry file
106
+ * @param {string} dir the release folder
107
+ * @returns {string[]}
108
+ */
109
+ function checkedFiles(bin, dir) {
110
+ return [
111
+ path.join('node_modules', '@astryxdesign', 'cli', 'package.json'),
112
+ path.join('node_modules', '@astryxdesign', 'core', 'package.json'),
113
+ path.relative(dir, bin),
114
+ ];
115
+ }
116
+
117
+ /**
118
+ * The sha256 of a file, or null when it cannot be read.
119
+ * @param {string} file
120
+ * @returns {string | null}
121
+ */
122
+ function fileHash(file) {
123
+ try {
124
+ return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * The cached release `version`, when it is installed and verified: its
132
+ * marker parses, names the version, and records the bytes of the CLI's and
133
+ * Core's package.json and the CLI's entry file, and those files still have
134
+ * them, and the number and total size of the two packages' files are the
135
+ * ones recorded. Anything else is a damaged release, which counts as absent and is
136
+ * replaced the next time a run needs it.
137
+ * @param {string} version
138
+ * @param {string} [cacheDir]
139
+ * @returns {CachedRelease | null}
140
+ */
141
+ export function cachedRelease(version, cacheDir = userCacheDir()) {
142
+ if (!isReleaseVersion(version)) return null;
143
+ const key = `${cacheDir}\0${version}`;
144
+ if (verified.has(key)) return verified.get(key) ?? null;
145
+ const result = verifyRelease(version, cacheDir);
146
+ // Only a verified release is remembered; an absent one is looked at again,
147
+ // since another run may be installing it.
148
+ if (result) verified.set(key, result);
149
+ return result;
150
+ }
151
+
152
+ /**
153
+ * Releases this process already verified, so a run checks each one once.
154
+ * @type {Map<string, CachedRelease>}
155
+ */
156
+ const verified = new Map();
157
+
158
+ /** Forget which releases this process verified, so the next look checks again. */
159
+ export function forgetVerifiedReleases() {
160
+ verified.clear();
161
+ }
162
+
163
+ /**
164
+ * The number of files and their total size under a package folder.
165
+ * @param {string} dir
166
+ * @returns {{count: number, bytes: number}}
167
+ */
168
+ function treeSize(dir) {
169
+ let count = 0;
170
+ let bytes = 0;
171
+ /** @param {string} d */
172
+ const walk = d => {
173
+ for (const entry of fs.readdirSync(d, {withFileTypes: true})) {
174
+ const p = path.join(d, entry.name);
175
+ if (entry.isDirectory()) walk(p);
176
+ else {
177
+ count += 1;
178
+ bytes += fs.statSync(p).size;
179
+ }
180
+ }
181
+ };
182
+ walk(dir);
183
+ return {count, bytes};
184
+ }
185
+
186
+ /**
187
+ * The size record of a release's two packages.
188
+ * @param {string} dir the release folder
189
+ * @returns {{cli: {count: number, bytes: number}, core: {count: number, bytes: number}} | null}
190
+ */
191
+ export function releaseTree(dir) {
192
+ try {
193
+ return {
194
+ cli: treeSize(path.join(dir, 'node_modules', '@astryxdesign', 'cli')),
195
+ core: treeSize(path.join(dir, 'node_modules', '@astryxdesign', 'core')),
196
+ };
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * The check behind {@link cachedRelease}.
204
+ * @param {string} version
205
+ * @param {string} cacheDir
206
+ * @returns {CachedRelease | null}
207
+ */
208
+ function verifyRelease(version, cacheDir) {
209
+ const dir = path.join(releasesDir(cacheDir), version);
210
+ const marker = readJson(path.join(dir, 'release.json'));
211
+ if (!marker || marker.version !== version || !marker.files) return null;
212
+ const cliDir = path.join(dir, 'node_modules', '@astryxdesign', 'cli');
213
+ const coreDir = path.join(dir, 'node_modules', '@astryxdesign', 'core');
214
+ const bin = cliBinOf(cliDir);
215
+ if (!bin) return null;
216
+ for (const rel of checkedFiles(bin, dir)) {
217
+ const want = marker.files[rel.split(path.sep).join('/')];
218
+ if (typeof want !== 'string' || fileHash(path.join(dir, rel)) !== want) {
219
+ return null;
220
+ }
221
+ }
222
+ // Every other file of the two packages: a deleted or truncated module
223
+ // changes the count or the total size.
224
+ if (
225
+ JSON.stringify(releaseTree(dir)) !== JSON.stringify(marker.tree ?? null)
226
+ ) {
227
+ return null;
228
+ }
229
+ return {version, dir, cliDir, coreDir, bin};
230
+ }
231
+
232
+ /**
233
+ * Is `dir` inside the release cache? A copy that lives there was started by
234
+ * global mode, never installed by a person.
235
+ * @param {string} dir
236
+ * @param {string} [cacheDir]
237
+ * @returns {boolean}
238
+ */
239
+ export function isInReleaseCache(dir, cacheDir = userCacheDir()) {
240
+ return (
241
+ relativeWithin(realOrSelf(releasesDir(cacheDir)), realOrSelf(dir)) !== null
242
+ );
243
+ }
244
+
245
+ /**
246
+ * @param {string} p
247
+ * @returns {string}
248
+ */
249
+ function realOrSelf(p) {
250
+ try {
251
+ return fs.realpathSync(p);
252
+ } catch {
253
+ return path.resolve(p);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * How to start npm, never through a shell, so nothing a project declares is
259
+ * read by one: npm's own entry file run with this Node, from beside the
260
+ * running Node, else from beside an `npm` on the PATH (on Windows, npm's
261
+ * `npm.cmd` shim sits next to its `node_modules`); else `npm` on the PATH,
262
+ * started directly. An `npm` beside the running Node or on the PATH that
263
+ * links to npm's entry file (as distribution packages install it, such as
264
+ * `/usr/bin/npm` to `/usr/lib/node_modules_22/npm/bin/npm-cli.js`) counts as
265
+ * that entry file.
266
+ * @param {{env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, execPath?: string}} [where]
267
+ * `execPath`: the Node that runs npm (default: this one)
268
+ * @returns {{command: string, prefix: string[], shell: false}}
269
+ */
270
+ export function npmCommand(where = {}) {
271
+ const {
272
+ env = process.env,
273
+ platform = process.platform,
274
+ execPath = process.execPath,
275
+ } = where;
276
+ const paths = platform === 'win32' ? path.win32 : path.posix;
277
+ const nodeDir = paths.dirname(execPath);
278
+ const entry = (/** @type {string} */ dir) =>
279
+ paths.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js');
280
+ const candidates = [
281
+ ...(platform === 'win32'
282
+ ? [entry(nodeDir)]
283
+ : [
284
+ paths.join(
285
+ nodeDir,
286
+ '..',
287
+ 'lib',
288
+ 'node_modules',
289
+ 'npm',
290
+ 'bin',
291
+ 'npm-cli.js',
292
+ ),
293
+ entry(nodeDir),
294
+ ]),
295
+ ...String(env.PATH ?? env.Path ?? '')
296
+ .split(platform === 'win32' ? ';' : ':')
297
+ .filter(Boolean)
298
+ .filter(dir =>
299
+ fs.existsSync(
300
+ paths.join(dir, platform === 'win32' ? 'npm.cmd' : 'npm'),
301
+ ),
302
+ )
303
+ .flatMap(dir =>
304
+ platform === 'win32'
305
+ ? [entry(dir)]
306
+ : [
307
+ paths.join(
308
+ dir,
309
+ '..',
310
+ 'lib',
311
+ 'node_modules',
312
+ 'npm',
313
+ 'bin',
314
+ 'npm-cli.js',
315
+ ),
316
+ ],
317
+ ),
318
+ ];
319
+ if (platform !== 'win32') {
320
+ // An `npm` that links to npm's entry file: beside the running Node first.
321
+ for (const dir of [
322
+ nodeDir,
323
+ ...String(env.PATH ?? '')
324
+ .split(':')
325
+ .filter(Boolean),
326
+ ]) {
327
+ try {
328
+ const real = fs.realpathSync(paths.join(dir, 'npm'));
329
+ if (paths.basename(real) === 'npm-cli.js') candidates.push(real);
330
+ } catch {
331
+ // No npm there.
332
+ }
333
+ }
334
+ }
335
+ for (const file of candidates) {
336
+ if (fs.existsSync(file)) {
337
+ return {command: execPath, prefix: [file], shell: false};
338
+ }
339
+ }
340
+ return {command: 'npm', prefix: [], shell: false};
341
+ }
342
+
343
+ /**
344
+ * Whether `spec` reads as an npm version range and nothing else: versions,
345
+ * `x`/`*` wildcards, prerelease tags, the range operators, `||`, and spaces.
346
+ * Anything a shell or npm would read as more, such as `&`, `;`, a lone `|`,
347
+ * quotes, or a URL, is refused.
348
+ * @param {string} spec
349
+ * @returns {boolean}
350
+ */
351
+ export function isVersionRange(spec) {
352
+ if (typeof spec !== 'string' || spec.trim() === '' || spec.length > 256) {
353
+ return false;
354
+ }
355
+ if (/[^0-9A-Za-z.\-+^~<>=|*\s]/u.test(spec)) return false;
356
+ return !/(?:^|[^|])\|(?:[^|]|$)/u.test(spec);
357
+ }
358
+
359
+ /**
360
+ * The result of running npm.
361
+ * @typedef {object} NpmResult
362
+ * @property {number | null} status
363
+ * @property {NodeJS.Signals | null} [signal] the signal that ended npm, such as Ctrl-C's
364
+ * @property {string} stdout
365
+ * @property {string} stderr
366
+ * @property {Error} [error]
367
+ */
368
+
369
+ /**
370
+ * Run npm synchronously with no output on the caller's streams.
371
+ * @param {string[]} args
372
+ * npm runs under npm-guard.mjs, which gives it a process group of its own
373
+ * and ends that group when the time limit passes, when this run goes away
374
+ * (even killed outright), or when this run receives Ctrl-C. So no install
375
+ * outlives the run that started it.
376
+ *
377
+ * @param {string[]} args
378
+ * @param {{cwd?: string, timeoutMs?: number, pidFile?: string, npm?: {command: string, prefix: string[], shell?: false}}} [options]
379
+ * `pidFile` receives npm's process id while it runs (the release lock's
380
+ * record); `npm` replaces how npm is started (tests).
381
+ * @returns {NpmResult}
382
+ */
383
+ export function runNpm(args, options = {}) {
384
+ const {command, prefix} = options.npm ?? npmCommand();
385
+ const timeoutMs = options.timeoutMs ?? 300_000;
386
+ const direct = process.platform === 'win32';
387
+ const result = direct
388
+ ? spawnSync(command, [...prefix, ...args], {
389
+ cwd: options.cwd ?? os.tmpdir(),
390
+ encoding: 'utf-8',
391
+ // Never a shell: a declared range must not reach one.
392
+ shell: false,
393
+ stdio: ['ignore', 'pipe', 'pipe'],
394
+ timeout: timeoutMs,
395
+ // npm defers SIGTERM until its work finishes, so a time limit has to
396
+ // end it outright or the limit never takes effect.
397
+ killSignal: 'SIGKILL',
398
+ windowsHide: true,
399
+ })
400
+ : spawnSync(
401
+ process.execPath,
402
+ [
403
+ GUARD,
404
+ String(timeoutMs),
405
+ options.pidFile ?? '',
406
+ command,
407
+ ...prefix,
408
+ ...args,
409
+ ],
410
+ {
411
+ cwd: options.cwd ?? os.tmpdir(),
412
+ encoding: 'utf-8',
413
+ stdio: ['ignore', 'pipe', 'pipe'],
414
+ // A backstop only: the guard ends npm at the limit itself.
415
+ timeout: timeoutMs + 30_000,
416
+ killSignal: 'SIGKILL',
417
+ windowsHide: true,
418
+ },
419
+ );
420
+ const timedOut = !direct && result.status === 124;
421
+ return {
422
+ status: timedOut ? null : result.status,
423
+ signal: result.signal ?? null,
424
+ stdout: result.stdout ?? '',
425
+ stderr: result.stderr ?? '',
426
+ ...(timedOut
427
+ ? {error: Object.assign(new Error('npm timed out'), {code: 'ETIMEDOUT'})}
428
+ : result.error
429
+ ? {error: result.error}
430
+ : {}),
431
+ };
432
+ }
433
+
434
+ /** The guard npm runs under. */
435
+ const GUARD = fileURLToPath(new URL('./npm-guard.mjs', import.meta.url));
436
+
437
+ /**
438
+ * The newest published version that `spec` (a dist-tag or a range) names,
439
+ * asked of the registry through npm.
440
+ * @param {string} name e.g. `@astryxdesign/core`
441
+ * @param {string} spec e.g. `latest`, `^0.6.8`
442
+ * @param {{run?: typeof runNpm}} [options]
443
+ * @returns {string | null}
444
+ */
445
+ export function resolvePublishedVersion(name, spec, options = {}) {
446
+ if (!isVersionRange(spec) || !/^@astryxdesign\/[a-z-]+$/u.test(name)) {
447
+ return null;
448
+ }
449
+ const run = options.run ?? runNpm;
450
+ const result = run(
451
+ ['view', `${name}@${spec}`, 'version', '--json', ...BOUNDED_FETCH],
452
+ {timeoutMs: 60_000},
453
+ );
454
+ if (result.status !== 0) return null;
455
+ let parsed;
456
+ try {
457
+ parsed = JSON.parse(result.stdout || 'null');
458
+ } catch {
459
+ return null;
460
+ }
461
+ const versions = (Array.isArray(parsed) ? parsed : [parsed]).filter(
462
+ isReleaseVersion,
463
+ );
464
+ return versions.length > 0 ? versions[versions.length - 1] : null;
465
+ }
466
+
467
+ /**
468
+ * The release the registry tags `latest` for the CLI, and the Node range it
469
+ * declares; null when the registry cannot be asked, with the reason given to
470
+ * `onFailure`: `npm-missing` (npm cannot be started), `npm-failed: <npm's
471
+ * error code>` (npm ran and failed, such as on ECONNREFUSED or E404), or
472
+ * `bad-reply` (npm's answer names no release).
473
+ * @param {{run?: typeof runNpm, onFailure?: (reason: string) => void}} [options]
474
+ * @returns {{version: string, node: string | null} | null}
475
+ */
476
+ export function resolveLatestRelease(options = {}) {
477
+ const run = options.run ?? runNpm;
478
+ const fail = (/** @type {string} */ reason) => {
479
+ options.onFailure?.(reason);
480
+ return null;
481
+ };
482
+ const result = run(
483
+ [
484
+ 'view',
485
+ '@astryxdesign/cli@latest',
486
+ 'version',
487
+ 'engines',
488
+ '--json',
489
+ ...BOUNDED_FETCH,
490
+ ],
491
+ {timeoutMs: 60_000},
492
+ );
493
+ if (result.status !== 0) {
494
+ if (/** @type {any} */ (result.error)?.code === 'ENOENT') {
495
+ return fail('npm-missing');
496
+ }
497
+ const code =
498
+ /npm (?:error|ERR!) code (\S+)/u.exec(result.stderr)?.[1] ??
499
+ /** @type {any} */ (result.error)?.code ??
500
+ (result.signal ? result.signal : `exit ${result.status}`);
501
+ return fail(`npm-failed: ${code}`);
502
+ }
503
+ let parsed;
504
+ try {
505
+ parsed = JSON.parse(result.stdout || 'null');
506
+ } catch {
507
+ return fail('bad-reply');
508
+ }
509
+ const info = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
510
+ if (typeof info === 'string') {
511
+ return isReleaseVersion(info)
512
+ ? {version: info, node: null}
513
+ : fail('bad-reply');
514
+ }
515
+ if (!info || !isReleaseVersion(info.version)) return fail('bad-reply');
516
+ const node = info.engines?.node;
517
+ return {version: info.version, node: typeof node === 'string' ? node : null};
518
+ }
519
+
520
+ /**
521
+ * Registry requests give up after one retry and 30 seconds each, so a run with
522
+ * no network fails in seconds with ERR_VERSIONS_UNAVAILABLE instead of waiting
523
+ * out npm's default retries. Registry, proxy, and offline settings still come
524
+ * from the user's npm configuration.
525
+ */
526
+ const BOUNDED_FETCH = [
527
+ '--fetch-retries=1',
528
+ '--fetch-timeout=30000',
529
+ // One quick retry, so an offline run learns it is offline in seconds.
530
+ '--fetch-retry-mintimeout=2000',
531
+ '--fetch-retry-maxtimeout=10000',
532
+ ];
533
+
534
+ /**
535
+ * How long a failed install of a release is remembered, so offline runs do
536
+ * not start npm again on every run (spec AST-081 FR2): 2 minutes after a
537
+ * first failure, so a passing registry hiccup is retried soon, and 10 minutes
538
+ * after a second failure in a row. A successful install forgets it.
539
+ * @param {number} failures the failures in a row, 1 or more
540
+ * @returns {number}
541
+ */
542
+ export function failedInstallWaitMs(failures) {
543
+ return (failures >= 2 ? 10 : 2) * 60_000;
544
+ }
545
+
546
+ /**
547
+ * The record of a failed install of `version`: a file in the release cache,
548
+ * shared by every copy of the CLI that uses this cache.
549
+ * @param {string} version
550
+ * @param {string} cacheDir
551
+ * @returns {string}
552
+ */
553
+ function failedRecordPath(version, cacheDir) {
554
+ return path.join(releasesDir(cacheDir), `.failed-${version}.json`);
555
+ }
556
+
557
+ /**
558
+ * The recorded failed installs of `version` in a row, when the last was
559
+ * within its wait (failedInstallWaitMs), or null.
560
+ * @param {string} version
561
+ * @param {string} cacheDir
562
+ * @param {number} [now]
563
+ * @returns {{at: number, reason: string, failures: number, waitMs: number} | null}
564
+ */
565
+ export function recentFailedInstall(version, cacheDir, now = Date.now()) {
566
+ const record = readFailedInstall(version, cacheDir);
567
+ if (!record) return null;
568
+ const waitMs = failedInstallWaitMs(record.failures);
569
+ const age = now - record.at;
570
+ return age >= 0 && age < waitMs ? {...record, waitMs} : null;
571
+ }
572
+
573
+ /**
574
+ * The record of failed installs of `version`, whatever its age, or null.
575
+ * @param {string} version
576
+ * @param {string} cacheDir
577
+ * @returns {{at: number, reason: string, failures: number} | null}
578
+ */
579
+ function readFailedInstall(version, cacheDir) {
580
+ const record = readJson(failedRecordPath(version, cacheDir));
581
+ if (
582
+ !record ||
583
+ typeof record.at !== 'number' ||
584
+ typeof record.reason !== 'string'
585
+ ) {
586
+ return null;
587
+ }
588
+ const failures =
589
+ Number.isInteger(record.failures) && record.failures > 0
590
+ ? record.failures
591
+ : 1;
592
+ return {at: record.at, reason: record.reason, failures};
593
+ }
594
+
595
+ /**
596
+ * Remember, or forget, a failed install of `version`. Never fails a run.
597
+ * @param {string} version
598
+ * @param {string} cacheDir
599
+ * @param {string | null} reason null forgets it
600
+ */
601
+ function recordFailedInstall(version, cacheDir, reason) {
602
+ const file = failedRecordPath(version, cacheDir);
603
+ try {
604
+ if (reason === null) {
605
+ fs.rmSync(file, {force: true});
606
+ return;
607
+ }
608
+ fs.mkdirSync(path.dirname(file), {recursive: true});
609
+ const failures = (readFailedInstall(version, cacheDir)?.failures ?? 0) + 1;
610
+ const tmp = `${file}.${process.pid}.tmp`;
611
+ fs.writeFileSync(
612
+ tmp,
613
+ `${JSON.stringify({at: Date.now(), reason, failures})}\n`,
614
+ );
615
+ fs.renameSync(tmp, file);
616
+ } catch {
617
+ // An unwritable cache already answers for itself.
618
+ }
619
+ }
620
+
621
+ /**
622
+ * The longest an install may take: generous, so a slow but moving download
623
+ * finishes, while a stalled one still ends in ERR_VERSIONS_UNAVAILABLE.
624
+ */
625
+ export const INSTALL_TIMEOUT_MS = 15 * 60_000;
626
+
627
+ /**
628
+ * The exit statuses a package manager uses when Ctrl-C, a closed terminal, or
629
+ * a termination request stopped it.
630
+ * @type {Record<number, NodeJS.Signals>}
631
+ */
632
+ const EXIT_SIGNALS = {129: 'SIGHUP', 130: 'SIGINT', 143: 'SIGTERM'};
633
+
634
+ /** Sleep the thread without spinning. @param {number} ms */
635
+ function sleep(ms) {
636
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
637
+ }
638
+
639
+ /** How long a lock may stand before a run treats its holder as gone. */
640
+ const STALE_LOCK_MS = 30 * 60_000;
641
+
642
+ /**
643
+ * How long a lock held from another computer (a shared cache) may stand,
644
+ * however often its holder renews it; one whose holder stops renewing it is
645
+ * taken over sooner (lock-lease.mjs).
646
+ */
647
+ const FOREIGN_LOCK_MS = 20 * 60_000;
648
+
649
+ /** How long a lock with no owner record may stand: its holder died taking it. */
650
+ const UNOWNED_LOCK_MS = 10_000;
651
+
652
+ /**
653
+ * Is process `pid` on this computer still running?
654
+ * @param {number} pid
655
+ * @returns {boolean}
656
+ */
657
+ function isAlive(pid) {
658
+ try {
659
+ process.kill(pid, 0);
660
+ return true;
661
+ } catch (error) {
662
+ // EPERM: it runs, as another user.
663
+ return /** @type {NodeJS.ErrnoException} */ (error).code === 'EPERM';
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Has the run holding `lockDir` gone? Its owner record names a process on
669
+ * this computer that no longer runs; or there is no record and the lock is
670
+ * older than a run needs to write one; or another computer holds it for
671
+ * longer than an install takes, or its holder there stopped renewing it; or
672
+ * it is older than any install.
673
+ * @param {string} lockDir
674
+ * @param {number} now
675
+ * @returns {boolean}
676
+ */
677
+ function lockIsStale(lockDir, now) {
678
+ let age;
679
+ try {
680
+ age = now - fs.statSync(lockDir).mtimeMs;
681
+ } catch {
682
+ return false;
683
+ }
684
+ if (age > STALE_LOCK_MS) return true;
685
+ const owner = readJson(path.join(lockDir, 'owner.json'));
686
+ if (!owner) return age > UNOWNED_LOCK_MS;
687
+ if (owner.host !== os.hostname() || !Number.isInteger(owner.pid)) {
688
+ return age > FOREIGN_LOCK_MS || foreignLockIsSilent(lockDir, now);
689
+ }
690
+ // A live holder on this computer is installing, however slowly.
691
+ return !isAlive(owner.pid);
692
+ }
693
+
694
+ /**
695
+ * Take a lock folder, or return false when another live run holds it. A lock
696
+ * whose holder has gone, such as a run stopped with Ctrl-C or killed, is taken
697
+ * over at once, and one from another computer once its holder has stopped
698
+ * renewing it. Throws when the cache folder cannot be written.
699
+ * @param {string} lockDir
700
+ * @param {{now?: number}} [options] the clock, for tests
701
+ * @returns {boolean}
702
+ */
703
+ export function tryLock(lockDir, {now = Date.now()} = {}) {
704
+ fs.mkdirSync(path.dirname(lockDir), {recursive: true});
705
+ try {
706
+ fs.mkdirSync(lockDir);
707
+ } catch (error) {
708
+ if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'EEXIST') {
709
+ throw error;
710
+ }
711
+ if (!lockIsStale(lockDir, now)) return false;
712
+ // The run that held it is gone; end the install it left running, if any,
713
+ // before starting another.
714
+ endRecordedInstall(lockDir, {now});
715
+ const aside = `${lockDir}.stale-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
716
+ try {
717
+ fs.renameSync(lockDir, aside);
718
+ } catch {
719
+ // Another run took it over first.
720
+ return false;
721
+ }
722
+ fs.rmSync(aside, {recursive: true, force: true});
723
+ try {
724
+ fs.mkdirSync(lockDir);
725
+ } catch {
726
+ return false;
727
+ }
728
+ }
729
+ try {
730
+ fs.writeFileSync(
731
+ path.join(lockDir, 'owner.json'),
732
+ JSON.stringify({pid: process.pid, host: os.hostname(), at: Date.now()}),
733
+ );
734
+ } catch {
735
+ // Without a record the lock still expires on its own.
736
+ }
737
+ return true;
738
+ }
739
+
740
+ /**
741
+ * What the system says about the process `pid`: its arguments (exact from
742
+ * `/proc`; split on spaces from `ps` or PowerShell, so a path with a space in
743
+ * it never matches), and when it started (ms since the epoch). Read from `/proc` where there is one, else from `ps` (or
744
+ * PowerShell on Windows); null for what cannot be read.
745
+ * @param {number} pid
746
+ * @param {string} procRoot
747
+ * @returns {{argv: string[] | null, startedAt: number | null}}
748
+ */
749
+ function processFactsOf(pid, procRoot) {
750
+ try {
751
+ let argv = fs
752
+ .readFileSync(path.join(procRoot, String(pid), 'cmdline'), 'utf-8')
753
+ .split('\u0000')
754
+ .filter(Boolean);
755
+ // npm sets its process title to its command line, which Linux shows as
756
+ // one argument ("npm install @astryxdesign/cli@0.7.2 …"): split it as the
757
+ // ps path does.
758
+ if (argv.length === 1) argv = argv[0].trim().split(/\s+/u);
759
+ /** @type {number | null} */
760
+ let startedAt = null;
761
+ try {
762
+ // Field 22 of /proc/<pid>/stat: start time in clock ticks after boot
763
+ // (100 a second on Linux); /proc/stat's btime: boot, in seconds.
764
+ const stat = fs.readFileSync(
765
+ path.join(procRoot, String(pid), 'stat'),
766
+ 'utf-8',
767
+ );
768
+ const ticks = Number(
769
+ stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19],
770
+ );
771
+ const btime = Number(
772
+ /^btime (\d+)$/mu.exec(
773
+ fs.readFileSync(path.join(procRoot, 'stat'), 'utf-8'),
774
+ )?.[1],
775
+ );
776
+ if (Number.isFinite(ticks) && Number.isFinite(btime)) {
777
+ startedAt = btime * 1000 + ticks * 10;
778
+ }
779
+ } catch {
780
+ // Start time unknown.
781
+ }
782
+ return {argv, startedAt};
783
+ } catch {
784
+ // No process table here.
785
+ }
786
+ if (process.platform === 'win32') {
787
+ const result = spawnSync(
788
+ 'powershell.exe',
789
+ [
790
+ '-NoProfile',
791
+ '-NonInteractive',
792
+ '-Command',
793
+ `$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}'; if ($p) { [DateTimeOffset]::new($p.CreationDate).ToUnixTimeMilliseconds(); $p.CommandLine }`,
794
+ ],
795
+ {encoding: 'utf-8', windowsHide: true, timeout: 10_000},
796
+ );
797
+ const [started, ...rest] = (result.stdout ?? '').trim().split(/\r?\n/u);
798
+ const line = rest.join(' ').trim();
799
+ return {
800
+ argv: line
801
+ ? line.split(/\s+/u).map(arg => arg.replace(/^"(.*)"$/u, '$1'))
802
+ : null,
803
+ startedAt: Number(started) || null,
804
+ };
805
+ }
806
+ const result = spawnSync(
807
+ 'ps',
808
+ ['-o', 'lstart=', '-o', 'args=', '-p', String(pid)],
809
+ {
810
+ encoding: 'utf-8',
811
+ timeout: 5000,
812
+ },
813
+ );
814
+ const line = (result.stdout ?? '').split(/\r?\n/u)[0] ?? '';
815
+ // `lstart` is a fixed 24-character date, such as `Tue Oct 20 03:52:54 2026`.
816
+ const startedAt = Date.parse(line.slice(0, 24));
817
+ const args = line.slice(24).trim();
818
+ return {
819
+ argv: args ? args.split(/\s+/u) : null,
820
+ startedAt: Number.isFinite(startedAt) ? startedAt : null,
821
+ };
822
+ }
823
+
824
+ /**
825
+ * Whether a process's arguments are this CLI's install of `version`, as
826
+ * runNpm starts it: the program (`argv[0]`) is npm, or node running npm's
827
+ * entry file `npm-cli.js` (`argv[1]`), each exactly by name, and the arguments
828
+ * include `install` and `@astryxdesign/cli@<version>`. A program that only has
829
+ * `npm` somewhere in its name or arguments is not.
830
+ * @param {string[]} argv
831
+ * @param {string} version
832
+ * @returns {boolean}
833
+ */
834
+ export function isReleaseInstall(argv, version) {
835
+ const name = (/** @type {string | undefined} */ arg) =>
836
+ (arg ?? '')
837
+ .split(/[\\/]/u)
838
+ .pop()
839
+ ?.replace(/\.(?:exe|cmd)$/iu, '')
840
+ .toLowerCase() ?? '';
841
+ const program = name(argv[0]);
842
+ const npm =
843
+ program === 'npm' ||
844
+ (/^node(?:[-.\d]*)$/u.test(program) && name(argv[1]) === 'npm-cli.js');
845
+ return (
846
+ npm &&
847
+ argv.includes('install') &&
848
+ argv.includes(`@astryxdesign/cli@${version}`)
849
+ );
850
+ }
851
+
852
+ /**
853
+ * End the npm process group a stale lock records, only when that process is
854
+ * surely the install the lock was taken for: its command line is this CLI's
855
+ * install of the lock's release (isReleaseInstall), the lock is younger than
856
+ * any install may take, and the process started no earlier than the lock.
857
+ * Anything less certain is left alone, and the lock is simply taken over,
858
+ * since a recorded process id can name an unrelated process once the install
859
+ * ended.
860
+ * @param {string} lockDir a `.lock-<version>` folder
861
+ * @param {{now?: number, procRoot?: string, factsOf?: (pid: number) => {argv: string[] | null, startedAt: number | null}}} [options]
862
+ * the clock, the process table's folder, and the fact reader, for tests
863
+ * @returns {'ended' | 'left'} whether a process group was ended
864
+ */
865
+ export function endRecordedInstall(
866
+ lockDir,
867
+ {now = Date.now(), procRoot = '/proc', factsOf} = {},
868
+ ) {
869
+ let pid;
870
+ try {
871
+ pid = Number(fs.readFileSync(path.join(lockDir, 'npm.pid'), 'utf-8'));
872
+ } catch {
873
+ return 'left';
874
+ }
875
+ if (!Number.isInteger(pid) || pid <= 1 || !isAlive(pid)) return 'left';
876
+ const version = /^\.lock-(.+)$/u.exec(path.basename(lockDir))?.[1];
877
+ if (!version) return 'left';
878
+ const owner = readJson(path.join(lockDir, 'owner.json'));
879
+ const at = typeof owner?.at === 'number' ? owner.at : null;
880
+ if (at === null || !(now - at >= 0 && now - at < INSTALL_TIMEOUT_MS)) {
881
+ return 'left';
882
+ }
883
+ const facts = (factsOf ?? (id => processFactsOf(id, procRoot)))(pid);
884
+ if (!facts.argv || !isReleaseInstall(facts.argv, version)) return 'left';
885
+ // Started before the lock was taken: an older process that reuses the id.
886
+ // A second's slack covers clocks that round.
887
+ if (facts.startedAt === null || facts.startedAt < at - 1000) return 'left';
888
+ try {
889
+ process.kill(process.platform === 'win32' ? pid : -pid, 'SIGKILL');
890
+ return 'ended';
891
+ } catch {
892
+ // Already gone.
893
+ return 'left';
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Remove a folder by renaming it aside first, so a run never sees it half
899
+ * removed.
900
+ * @param {string} dir
901
+ */
902
+ function removeAside(dir) {
903
+ const aside = `${dir}.removing-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
904
+ try {
905
+ fs.renameSync(dir, aside);
906
+ } catch {
907
+ return;
908
+ }
909
+ fs.rmSync(aside, {recursive: true, force: true});
910
+ }
911
+
912
+ /**
913
+ * Why a release could not be made available.
914
+ * @typedef {object} ReleaseUnavailable
915
+ * @property {string} version
916
+ * @property {string} reason one plain sentence
917
+ * @property {NodeJS.Signals} [signal] the install was stopped by this signal
918
+ */
919
+
920
+ /**
921
+ * Install release `version` into the cache, verify it, and return it. A
922
+ * release already cached returns at once. When another run is installing the
923
+ * same release, this waits for it.
924
+ *
925
+ * @param {string} version
926
+ * @param {{cacheDir?: string, run?: typeof runNpm, nodeVersion?: string, waitMs?: number, onProgress?: (event: 'install' | 'wait', version: string) => void}} [options]
927
+ * `onProgress` hears once when an install starts and once when the run
928
+ * starts waiting for another run's install.
929
+ * @returns {CachedRelease | ReleaseUnavailable}
930
+ */
931
+ export function ensureRelease(version, options = {}) {
932
+ const {
933
+ cacheDir = userCacheDir(),
934
+ run = runNpm,
935
+ nodeVersion = process.versions.node,
936
+ waitMs = INSTALL_TIMEOUT_MS + 60_000,
937
+ onProgress = () => {},
938
+ } = options;
939
+ if (!isReleaseVersion(version)) {
940
+ return {version: String(version), reason: 'it is not a release version'};
941
+ }
942
+ const existing = cachedRelease(version, cacheDir);
943
+ if (existing) {
944
+ // Installed by now, by any copy: forget any failure.
945
+ if (readFailedInstall(version, cacheDir)) {
946
+ recordFailedInstall(version, cacheDir, null);
947
+ }
948
+ return existing;
949
+ }
950
+ // A recent failed install is not retried: offline, every run would start
951
+ // npm again and wait for it (FR2).
952
+ const failed = recentFailedInstall(version, cacheDir);
953
+ if (failed) {
954
+ const minutes = Math.max(1, Math.round((Date.now() - failed.at) / 60_000));
955
+ return {
956
+ version,
957
+ reason: `${failed.reason}; last tried ${minutes} minute${minutes === 1 ? '' : 's'} ago, tried again after ${failed.waitMs / 60_000} minutes`,
958
+ };
959
+ }
960
+ try {
961
+ const result = ensureLocked(version, {
962
+ cacheDir,
963
+ run,
964
+ nodeVersion,
965
+ waitMs,
966
+ onProgress,
967
+ });
968
+ // Stopped with Ctrl-C (or closed): the lock and scratch folder are already
969
+ // cleared; end the run the way the signal would have.
970
+ if ('signal' in result && result.signal)
971
+ process.kill(process.pid, result.signal);
972
+ if ('bin' in result) recordFailedInstall(version, cacheDir, null);
973
+ else if (
974
+ 'reason' in result &&
975
+ !('signal' in result && result.signal) &&
976
+ result.reason !== 'another run is still installing it'
977
+ ) {
978
+ recordFailedInstall(version, cacheDir, result.reason);
979
+ }
980
+ return result;
981
+ } catch (error) {
982
+ const code = /** @type {NodeJS.ErrnoException} */ (error).code;
983
+ return {
984
+ version,
985
+ reason: `the release cache ${releasesDir(cacheDir)} cannot be written${code ? ` (${code})` : ''}`,
986
+ };
987
+ }
988
+ }
989
+
990
+ /**
991
+ * `ensureRelease` under the per-release lock.
992
+ * @param {string} version
993
+ * @param {{cacheDir: string, run: typeof runNpm, nodeVersion: string, waitMs: number, onProgress: (event: 'install' | 'wait', version: string) => void}} options
994
+ * @returns {CachedRelease | ReleaseUnavailable}
995
+ */
996
+ function ensureLocked(
997
+ version,
998
+ {cacheDir, run, nodeVersion, waitMs, onProgress},
999
+ ) {
1000
+ const root = releasesDir(cacheDir);
1001
+ const lockDir = path.join(root, `.lock-${version}`);
1002
+ const deadline = Date.now() + waitMs;
1003
+ let waiting = false;
1004
+ while (!tryLock(lockDir)) {
1005
+ const ready = cachedRelease(version, cacheDir);
1006
+ if (ready) return ready;
1007
+ if (Date.now() > deadline) {
1008
+ return {version, reason: 'another run is still installing it'};
1009
+ }
1010
+ if (!waiting) {
1011
+ waiting = true;
1012
+ onProgress('wait', version);
1013
+ }
1014
+ sleep(200);
1015
+ }
1016
+ // A run stopped mid-install (Ctrl-C, a closed terminal) frees the lock and
1017
+ // its scratch folder on the way out; a killed one is taken over by the
1018
+ // owner check in tryLock.
1019
+ const release = () => {
1020
+ for (const entry of safeList(root)) {
1021
+ if (entry.startsWith(`.tmp-${version}-${process.pid}-`)) {
1022
+ fs.rmSync(path.join(root, entry), {recursive: true, force: true});
1023
+ }
1024
+ }
1025
+ fs.rmSync(lockDir, {recursive: true, force: true});
1026
+ };
1027
+ /** @param {NodeJS.Signals} signal */
1028
+ const onSignal = signal => {
1029
+ release();
1030
+ removeSignalHandlers();
1031
+ process.kill(process.pid, signal);
1032
+ };
1033
+ const signals = /** @type {NodeJS.Signals[]} */ ([
1034
+ 'SIGINT',
1035
+ 'SIGTERM',
1036
+ 'SIGHUP',
1037
+ ]);
1038
+ const removeSignalHandlers = () => {
1039
+ for (const signal of signals) process.off(signal, onSignal);
1040
+ };
1041
+ for (const signal of signals) process.on(signal, onSignal);
1042
+ try {
1043
+ const again = cachedRelease(version, cacheDir);
1044
+ if (again) return again;
1045
+ // Under the lock, anything left for this version is a damaged release or
1046
+ // the scratch folder of a run that stopped: clear both.
1047
+ for (const entry of safeList(root)) {
1048
+ if (
1049
+ entry.startsWith(`.tmp-${version}-`) ||
1050
+ entry.startsWith(`${version}.removing-`) ||
1051
+ entry.startsWith(`.lock-${version}.stale-`)
1052
+ ) {
1053
+ fs.rmSync(path.join(root, entry), {recursive: true, force: true});
1054
+ }
1055
+ }
1056
+ removeAside(path.join(root, version));
1057
+ verified.delete(`${cacheDir}\0${version}`);
1058
+ onProgress('install', version);
1059
+ return installLocked(version, {cacheDir, run, nodeVersion});
1060
+ } finally {
1061
+ removeSignalHandlers();
1062
+ release();
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * A folder's entries, or none.
1068
+ * @param {string} dir
1069
+ * @returns {string[]}
1070
+ */
1071
+ function safeList(dir) {
1072
+ try {
1073
+ return fs.readdirSync(dir);
1074
+ } catch {
1075
+ return [];
1076
+ }
1077
+ }
1078
+
1079
+ /**
1080
+ * The install itself, under the lock.
1081
+ * @param {string} version
1082
+ * @param {{cacheDir: string, run: typeof runNpm, nodeVersion: string}} options
1083
+ * @returns {CachedRelease | ReleaseUnavailable}
1084
+ */
1085
+ function installLocked(version, {cacheDir, run, nodeVersion}) {
1086
+ const root = releasesDir(cacheDir);
1087
+ const tmp = path.join(
1088
+ root,
1089
+ `.tmp-${version}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`,
1090
+ );
1091
+ fs.mkdirSync(tmp, {recursive: true});
1092
+ try {
1093
+ fs.writeFileSync(
1094
+ path.join(tmp, 'package.json'),
1095
+ `${JSON.stringify({name: 'astryx-release', private: true}, null, 2)}\n`,
1096
+ );
1097
+ const install = run(
1098
+ [
1099
+ 'install',
1100
+ '--no-save',
1101
+ '--no-package-lock',
1102
+ '--ignore-scripts',
1103
+ '--no-audit',
1104
+ '--no-fund',
1105
+ '--prefer-offline',
1106
+ ...BOUNDED_FETCH,
1107
+ '--loglevel=error',
1108
+ `@astryxdesign/cli@${version}`,
1109
+ `@astryxdesign/core@${version}`,
1110
+ ],
1111
+ {
1112
+ cwd: tmp,
1113
+ timeoutMs: INSTALL_TIMEOUT_MS,
1114
+ pidFile: path.join(root, `.lock-${version}`, 'npm.pid'),
1115
+ },
1116
+ );
1117
+ // Our own time limit ends npm: that is a timeout, not a stopped run.
1118
+ if (/** @type {any} */ (install.error)?.code === 'ETIMEDOUT') {
1119
+ return {
1120
+ version,
1121
+ reason: `the install did not finish within ${INSTALL_TIMEOUT_MS / 60_000} minutes`,
1122
+ };
1123
+ }
1124
+ const stoppedBy = install.signal ?? EXIT_SIGNALS[install.status ?? -1];
1125
+ if (stoppedBy) {
1126
+ return {version, reason: 'the install was stopped', signal: stoppedBy};
1127
+ }
1128
+ if (install.status !== 0) {
1129
+ return {version, reason: npmFailure(install)};
1130
+ }
1131
+ const cliDir = path.join(tmp, 'node_modules', '@astryxdesign', 'cli');
1132
+ const cliPkg = readJson(path.join(cliDir, 'package.json'));
1133
+ if (cliPkg?.version !== version) {
1134
+ return {version, reason: 'the package manager installed another version'};
1135
+ }
1136
+ const engine = cliPkg?.engines?.node;
1137
+ if (typeof engine === 'string' && !satisfiesRange(nodeVersion, engine)) {
1138
+ return {
1139
+ version,
1140
+ reason: `it needs Node ${engine}, and this computer runs Node ${nodeVersion}`,
1141
+ };
1142
+ }
1143
+ const bin = cliBinOf(cliDir);
1144
+ if (!bin) return {version, reason: 'its CLI has no astryx command'};
1145
+ // A check, not a person's run: the shared update-notifier variable keeps
1146
+ // a release that prints update notices from printing them here, and from
1147
+ // using up the day's notices (spec AST-081 FR10).
1148
+ const started = spawnSync(process.execPath, [bin, '--version'], {
1149
+ cwd: tmp,
1150
+ encoding: 'utf-8',
1151
+ env: {...process.env, NO_UPDATE_NOTIFIER: '1'},
1152
+ stdio: ['ignore', 'pipe', 'pipe'],
1153
+ timeout: 60_000,
1154
+ windowsHide: true,
1155
+ });
1156
+ const reported = String(started.stdout ?? '')
1157
+ .trim()
1158
+ .split(/\r?\n/)
1159
+ .pop();
1160
+ if (started.status !== 0 || reported !== version) {
1161
+ return {version, reason: 'its CLI did not start and report its version'};
1162
+ }
1163
+ /** @type {Record<string, string | null>} */
1164
+ const files = {};
1165
+ for (const rel of checkedFiles(bin, tmp)) {
1166
+ files[rel.split(path.sep).join('/')] = fileHash(path.join(tmp, rel));
1167
+ }
1168
+ fs.writeFileSync(
1169
+ path.join(tmp, 'release.json'),
1170
+ `${JSON.stringify({version, verifiedAt: new Date().toISOString(), files, tree: releaseTree(tmp)}, null, 2)}\n`,
1171
+ );
1172
+
1173
+ const dest = path.join(root, version);
1174
+ try {
1175
+ fs.renameSync(tmp, dest);
1176
+ } catch {
1177
+ // A finished release already holds the name; keep that one.
1178
+ }
1179
+ return (
1180
+ cachedRelease(version, cacheDir) ?? {
1181
+ version,
1182
+ reason: 'it could not be saved in the cache',
1183
+ }
1184
+ );
1185
+ } catch (error) {
1186
+ return {
1187
+ version,
1188
+ reason: `installing it failed (${error instanceof Error ? error.message : String(error)})`,
1189
+ };
1190
+ } finally {
1191
+ fs.rmSync(tmp, {recursive: true, force: true});
1192
+ }
1193
+ }
1194
+
1195
+ /**
1196
+ * One sentence on why npm failed.
1197
+ * @param {NpmResult} result
1198
+ * @returns {string}
1199
+ */
1200
+ function npmFailure(result) {
1201
+ if (result.error && /** @type {any} */ (result.error).code === 'ENOENT') {
1202
+ return 'npm, which installs releases, was not found';
1203
+ }
1204
+ const text = `${result.stderr}\n${result.stdout}`;
1205
+ if (/E404|404 Not Found|No matching version|ETARGET/i.test(text)) {
1206
+ return 'the registry does not have it';
1207
+ }
1208
+ if (
1209
+ /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|offline|network/i.test(
1210
+ text,
1211
+ )
1212
+ ) {
1213
+ return 'the registry could not be reached';
1214
+ }
1215
+ return 'the package manager could not install it';
1216
+ }