@astrale-os/cli 1.0.0-beta.3 → 1.0.0-beta.30

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 (343) hide show
  1. package/README.md +80 -6
  2. package/dist/astrale.js +55471 -58674
  3. package/dist/public/connect-core.js +2158 -30706
  4. package/dist/public/keys/index.js +1016 -29541
  5. package/dist/public/paths/index.js +370 -28654
  6. package/dist/types/admin/contract.d.ts +26 -0
  7. package/dist/types/admin/instance/client.d.ts +20 -0
  8. package/dist/types/admin/instance/index.d.ts +2 -0
  9. package/dist/types/admin/instance/model.d.ts +6 -10
  10. package/dist/types/connection/auth.d.ts +3 -0
  11. package/dist/types/connection/call.d.ts +3 -0
  12. package/dist/types/connection/command.d.ts +27 -0
  13. package/dist/types/connection/credential.d.ts +14 -0
  14. package/dist/types/connection/errors.d.ts +1 -0
  15. package/dist/types/connection/exchange.d.ts +9 -0
  16. package/dist/types/connection/failure/classify.d.ts +2 -0
  17. package/dist/types/connection/failure/index.d.ts +5 -0
  18. package/dist/types/connection/failure/model.d.ts +25 -0
  19. package/dist/types/connection/failure/render.d.ts +3 -0
  20. package/dist/types/connection/index.d.ts +8 -0
  21. package/dist/types/connection/reasons.d.ts +37 -0
  22. package/dist/types/connection/self.d.ts +37 -0
  23. package/dist/types/connection/session.d.ts +31 -0
  24. package/dist/types/connection/target.d.ts +38 -0
  25. package/dist/types/errors.d.ts +1 -1
  26. package/dist/types/lib/admin-instance.d.ts +17 -0
  27. package/dist/types/lib/admin-target.d.ts +11 -36
  28. package/dist/types/lib/config.d.ts +13 -59
  29. package/dist/types/lib/failure-debug.d.ts +1 -0
  30. package/dist/types/lib/idp.d.ts +25 -173
  31. package/dist/types/lib/instance-target.d.ts +1 -1
  32. package/dist/types/lib/instance.d.ts +30 -88
  33. package/dist/types/lib/log.d.ts +5 -3
  34. package/dist/types/lib/output.d.ts +1 -1
  35. package/dist/types/lib/proc.d.ts +43 -0
  36. package/dist/types/lib/self.d.ts +9 -0
  37. package/dist/types/lib/update.d.ts +109 -0
  38. package/dist/types/lib/validation.d.ts +4 -1
  39. package/dist/types/state/exchange-credentials.d.ts +6 -2
  40. package/dist/types/state/files.d.ts +2 -0
  41. package/dist/types/state/index.d.ts +3 -2
  42. package/dist/types/state/paths.d.ts +2 -0
  43. package/dist/types/state/session-routes.d.ts +10 -0
  44. package/package.json +16 -19
  45. package/src/__tests__/fixtures/publication.ts +20 -0
  46. package/src/admin/.spec/architecture.md +12 -5
  47. package/src/admin/__tests__/fixture.ts +25 -0
  48. package/src/admin/catalog/.spec/api.d.ts +1 -3
  49. package/src/admin/catalog/.spec/architecture.md +5 -4
  50. package/src/admin/catalog/__tests__/client.test.ts +143 -69
  51. package/src/admin/catalog/client.ts +40 -54
  52. package/src/admin/catalog/model.ts +3 -4
  53. package/src/admin/contract.ts +47 -0
  54. package/src/admin/graph/.spec/api.d.ts +4 -10
  55. package/src/admin/graph/nodes.ts +1 -1
  56. package/src/admin/instance/.spec/api.d.ts +5 -9
  57. package/src/admin/instance/.spec/architecture.md +7 -7
  58. package/src/admin/instance/__tests__/client.test.ts +157 -175
  59. package/src/admin/instance/client.ts +39 -128
  60. package/src/admin/instance/index.ts +1 -2
  61. package/src/admin/instance/model.ts +12 -18
  62. package/src/commands/__tests__/admin-instance.test.ts +11 -8
  63. package/src/commands/__tests__/call-describe.test.ts +46 -33
  64. package/src/commands/__tests__/call.test.ts +34 -0
  65. package/src/commands/__tests__/domain-install-operation.test.ts +30 -22
  66. package/src/commands/__tests__/domain-install-owned.test.ts +1 -1
  67. package/src/commands/__tests__/domain-list.test.ts +5 -25
  68. package/src/commands/__tests__/install-direct.test.ts +115 -8
  69. package/src/commands/__tests__/install-identity-override.test.ts +6 -17
  70. package/src/commands/__tests__/instance-list-rows.test.ts +1 -1
  71. package/src/commands/__tests__/instance-status.test.ts +139 -0
  72. package/src/commands/__tests__/introspect-parse.test.ts +80 -1
  73. package/src/commands/__tests__/logs.test.ts +250 -2
  74. package/src/commands/__tests__/read-commands.test.ts +118 -10
  75. package/src/commands/__tests__/token-ttl.test.ts +49 -4
  76. package/src/commands/__tests__/update.test.ts +25 -3
  77. package/src/commands/__tests__/view.test.ts +50 -2
  78. package/src/commands/auth/logout.ts +2 -1
  79. package/src/commands/browser.ts +29 -0
  80. package/src/commands/call-describe.ts +60 -68
  81. package/src/commands/call.ts +71 -59
  82. package/src/commands/domain/install.ts +73 -46
  83. package/src/commands/domain/list.ts +1 -1
  84. package/src/commands/domain/publish.ts +14 -11
  85. package/src/commands/domain/uninstall.ts +2 -2
  86. package/src/commands/get.ts +15 -16
  87. package/src/commands/identity/__tests__/register.test.ts +3 -3
  88. package/src/commands/identity/register.ts +8 -8
  89. package/src/commands/instance/create.ts +8 -16
  90. package/src/commands/instance/delete.ts +12 -9
  91. package/src/commands/instance/list.ts +2 -2
  92. package/src/commands/instance/status.ts +66 -20
  93. package/src/commands/instance/use.ts +16 -12
  94. package/src/commands/introspect.ts +53 -41
  95. package/src/commands/logs.ts +123 -36
  96. package/src/commands/mutate.ts +6 -5
  97. package/src/commands/query.ts +35 -32
  98. package/src/commands/session/analyze.ts +26 -4
  99. package/src/commands/token.ts +42 -27
  100. package/src/commands/ui/__tests__/commands.test.ts +152 -0
  101. package/src/commands/ui/add.ts +51 -0
  102. package/src/commands/ui/doctor.ts +21 -0
  103. package/src/commands/ui/init.ts +38 -0
  104. package/src/commands/ui/list.ts +26 -0
  105. package/src/commands/ui/preset-apply.ts +19 -0
  106. package/src/commands/ui/preset-list.ts +12 -0
  107. package/src/commands/ui/shared.ts +25 -0
  108. package/src/commands/update.ts +89 -48
  109. package/src/commands/view.ts +82 -37
  110. package/src/connection/.spec/api.d.ts +7 -8
  111. package/src/connection/.spec/architecture.md +14 -5
  112. package/src/connection/.spec/flows/session.ts +2 -2
  113. package/src/connection/.spec/laws/connection.ts +6 -2
  114. package/src/connection/.spec/layout.ts +1 -0
  115. package/src/connection/__tests__/auth.test.ts +1 -0
  116. package/src/connection/__tests__/credential.test.ts +21 -1
  117. package/src/connection/__tests__/errors.test.ts +27 -20
  118. package/src/connection/__tests__/exchange.test.ts +64 -11
  119. package/src/connection/__tests__/failure-fixtures.ts +39 -0
  120. package/src/connection/__tests__/failure-safety.test.ts +75 -0
  121. package/src/connection/__tests__/self.test.ts +31 -11
  122. package/src/connection/__tests__/session.test.ts +8 -4
  123. package/src/connection/__tests__/target.test.ts +2 -0
  124. package/src/connection/auth.ts +13 -0
  125. package/src/connection/call.ts +1 -1
  126. package/src/connection/credential.ts +2 -2
  127. package/src/connection/errors.ts +1 -355
  128. package/src/connection/exchange.ts +110 -61
  129. package/src/connection/failure/classify.ts +110 -0
  130. package/src/connection/failure/index.ts +18 -0
  131. package/src/connection/failure/model.ts +28 -0
  132. package/src/connection/failure/render.ts +116 -0
  133. package/src/connection/reasons.ts +16 -0
  134. package/src/connection/self.ts +39 -21
  135. package/src/connection/session.ts +27 -9
  136. package/src/errors.ts +3 -2
  137. package/src/graph/.spec/api.d.ts +12 -5
  138. package/src/graph/.spec/layout.ts +9 -1
  139. package/src/graph/__tests__/mutation.test.ts +2 -2
  140. package/src/graph/__tests__/query.test.ts +25 -6
  141. package/src/graph/class.ts +30 -0
  142. package/src/graph/index.ts +1 -0
  143. package/src/graph/query.ts +21 -32
  144. package/src/identity/.spec/api.d.ts +2 -2
  145. package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
  146. package/src/identity/__tests__/registration.test.ts +1 -1
  147. package/src/identity/__tests__/registry.test.ts +4 -0
  148. package/src/identity/registration.ts +3 -3
  149. package/src/identity/registry.ts +2 -0
  150. package/src/keys/__tests__/keys.test.ts +4 -0
  151. package/src/keys/credential.ts +1 -0
  152. package/src/lib/__tests__/admin-target.test.ts +12 -0
  153. package/src/lib/__tests__/binary.test.ts +16 -1
  154. package/src/lib/__tests__/browser-retention.test.ts +212 -0
  155. package/src/lib/__tests__/command-dx.test.ts +1 -1
  156. package/src/lib/__tests__/config.test.ts +27 -0
  157. package/src/lib/__tests__/domain-publication.test.ts +10 -16
  158. package/src/lib/__tests__/idp.test.ts +34 -4
  159. package/src/lib/__tests__/instance-candidates.test.ts +1 -3
  160. package/src/lib/__tests__/instance-target.test.ts +7 -39
  161. package/src/lib/__tests__/log-errors.test.ts +64 -0
  162. package/src/lib/__tests__/skills.test.ts +809 -0
  163. package/src/lib/__tests__/studio-server-deps.test.ts +1 -1
  164. package/src/lib/__tests__/update.test.ts +264 -11
  165. package/src/lib/__tests__/view-assets.test.ts +25 -0
  166. package/src/lib/__tests__/view-external-open-intent.test.ts +150 -0
  167. package/src/lib/__tests__/view-external-open-origins.test.ts +27 -0
  168. package/src/lib/__tests__/view-open-intent.test.ts +0 -2
  169. package/src/lib/__tests__/view-server.test.ts +26 -3
  170. package/src/lib/__tests__/view-session.test.ts +2 -1
  171. package/src/lib/admin-instance.ts +14 -8
  172. package/src/lib/admin-target.ts +21 -2
  173. package/src/lib/binary.ts +22 -5
  174. package/src/lib/browser-retention.ts +210 -0
  175. package/src/lib/command-dx.ts +9 -9
  176. package/src/lib/config.ts +12 -1
  177. package/src/lib/domain-publication.ts +9 -7
  178. package/src/lib/failure-debug.ts +26 -0
  179. package/src/lib/idp.ts +34 -3
  180. package/src/lib/instance-candidates.ts +2 -2
  181. package/src/lib/instance-target.ts +3 -14
  182. package/src/lib/instance.ts +16 -11
  183. package/src/lib/log.ts +16 -9
  184. package/src/lib/output.ts +1 -1
  185. package/src/lib/proc.ts +7 -2
  186. package/src/lib/provision-instance.ts +34 -57
  187. package/src/lib/skills/lock.ts +117 -0
  188. package/src/lib/skills/sync.ts +814 -0
  189. package/src/lib/skills.ts +1 -52
  190. package/src/lib/update.ts +298 -45
  191. package/src/lib/validation.ts +2 -2
  192. package/src/lib/view/assets.ts +11 -2
  193. package/src/lib/view/external-open-intent.ts +41 -0
  194. package/src/lib/view/external-open-origins.ts +37 -0
  195. package/src/lib/view/host-capabilities.ts +17 -0
  196. package/src/lib/view/resolve.ts +1 -1
  197. package/src/lib/view/server.ts +18 -3
  198. package/src/lib/view/session.ts +2 -0
  199. package/src/program/.spec/api.d.ts +1 -0
  200. package/src/program/__tests__/program.test.ts +163 -6
  201. package/src/program/argv.ts +14 -0
  202. package/src/program/build.ts +26 -4
  203. package/src/program/command.ts +1 -0
  204. package/src/program/index.ts +1 -0
  205. package/src/program/registry.ts +2 -1
  206. package/src/setup/__tests__/instance-step.test.ts +27 -10
  207. package/src/setup/steps/instance.ts +22 -15
  208. package/src/setup/steps/skills.ts +40 -38
  209. package/src/state/.spec/api.d.ts +21 -2
  210. package/src/state/.spec/architecture.md +18 -4
  211. package/src/state/.spec/layout.ts +1 -0
  212. package/src/state/__tests__/exchange-credentials.test.ts +88 -42
  213. package/src/state/__tests__/files.test.ts +24 -1
  214. package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
  215. package/src/state/__tests__/paths.test.ts +1 -0
  216. package/src/state/__tests__/session-routes.test.ts +138 -0
  217. package/src/state/exchange-credentials.ts +22 -9
  218. package/src/state/files.ts +41 -0
  219. package/src/state/index.ts +3 -1
  220. package/src/state/paths.ts +3 -0
  221. package/src/state/session-routes.ts +34 -0
  222. package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
  223. package/src/telemetry/__tests__/recorder.test.ts +1 -0
  224. package/src/telemetry/__tests__/retention.test.ts +267 -0
  225. package/src/telemetry/__tests__/settings.test.ts +102 -0
  226. package/src/telemetry/__tests__/store-scan.test.ts +128 -0
  227. package/src/telemetry/__tests__/trigger.test.ts +33 -4
  228. package/src/telemetry/analyze.ts +24 -2
  229. package/src/telemetry/recorder.ts +15 -4
  230. package/src/telemetry/retention.ts +176 -0
  231. package/src/telemetry/session.ts +18 -12
  232. package/src/telemetry/settings.ts +64 -11
  233. package/src/telemetry/store.ts +92 -9
  234. package/src/telemetry/trigger.ts +16 -28
  235. package/src/ui/.spec/api.d.ts +14 -0
  236. package/src/ui/.spec/architecture.md +14 -0
  237. package/src/ui/.spec/laws.ts +50 -0
  238. package/src/ui/.spec/layout.ts +16 -0
  239. package/src/ui/__tests__/ui.test.ts +1742 -0
  240. package/src/ui/index.ts +13 -0
  241. package/src/ui/lock.ts +89 -0
  242. package/src/ui/model.ts +83 -0
  243. package/src/ui/operations.ts +1044 -0
  244. package/src/ui/project.ts +333 -0
  245. package/src/ui/release.ts +398 -0
  246. package/src/ui/runner.ts +18 -0
  247. package/studio/client/dist/assets/elk-api-DjmKsHXc.js +1 -0
  248. package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
  249. package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
  250. package/studio/client/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  251. package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
  252. package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
  253. package/studio/client/dist/index.html +4 -3
  254. package/studio/package.json +8 -14
  255. package/studio/server/agent/ask.test.ts +1 -1
  256. package/studio/server/agent/bridge/grant.test.ts +1 -1
  257. package/studio/server/agent/bridge/routes.test.ts +1 -1
  258. package/studio/server/agent/harness/claude/adapter.test.ts +15 -5
  259. package/studio/server/agent/harness/gateway/token.test.ts +1 -1
  260. package/studio/server/agent/harness/gateway/token.ts +3 -3
  261. package/studio/server/agent/prompts/anchors.test.ts +41 -32
  262. package/studio/server/agent/prompts/anchors.ts +23 -51
  263. package/studio/server/agent/prompts/system.test.ts +4 -4
  264. package/studio/server/agent/prompts/system.ts +5 -5
  265. package/studio/server/agent/routes.test.ts +1 -1
  266. package/studio/server/agent/run/coordinator.test.ts +1 -1
  267. package/studio/server/agent/run/preparation.ts +1 -1
  268. package/studio/server/api/canvas.ts +0 -1
  269. package/studio/server/api/context.ts +1 -1
  270. package/studio/server/api/deployment.ts +1 -1
  271. package/studio/server/api/views.ts +2 -2
  272. package/studio/server/api/workspace.ts +1 -1
  273. package/studio/server/api-agent-loadout.test.ts +1 -1
  274. package/studio/server/api.test.ts +1 -1
  275. package/studio/server/cache.test.ts +24 -18
  276. package/studio/server/cache.ts +10 -54
  277. package/studio/server/cli-consumers.test.ts +31 -3
  278. package/studio/server/domain.test.ts +50 -68
  279. package/studio/server/domain.ts +82 -63
  280. package/studio/server/handoff/copy.ts +2 -2
  281. package/studio/server/index.ts +1 -1
  282. package/studio/server/instances/probe.test.ts +1 -1
  283. package/studio/server/introspect/anatomy/views/routes.ts +46 -37
  284. package/studio/server/introspect/anatomy/views.ts +10 -16
  285. package/studio/server/introspect/anatomy-extras.test.ts +29 -56
  286. package/studio/server/introspect/anatomy.test.ts +22 -14
  287. package/studio/server/introspect/anatomy.ts +6 -9
  288. package/studio/server/introspect/bundle.ts +0 -3
  289. package/studio/server/introspect/canonical-schema.test.ts +155 -424
  290. package/studio/server/introspect/canonical-schema.ts +248 -601
  291. package/studio/server/introspect/core.ts +14 -22
  292. package/studio/server/introspect/diff.test.ts +21 -9
  293. package/studio/server/introspect/diff.ts +19 -64
  294. package/studio/server/introspect/extractor.ts +23 -122
  295. package/studio/server/introspect/overlay-tsmorph.test.ts +116 -241
  296. package/studio/server/introspect/overlay-tsmorph.ts +0 -1
  297. package/studio/server/introspect/overlay.ts +3 -51
  298. package/studio/server/introspect/revision.test.ts +24 -28
  299. package/studio/server/introspect/revision.ts +11 -22
  300. package/studio/server/introspect/runtime.test.ts +77 -195
  301. package/studio/server/introspect/runtime.ts +5 -67
  302. package/studio/server/introspect/schema-ir-json.test.ts +81 -0
  303. package/studio/server/introspect/schema-ir-json.ts +39 -69
  304. package/studio/server/introspect/schema-refs.test.ts +43 -88
  305. package/studio/server/introspect/schema-refs.ts +14 -49
  306. package/studio/server/introspect/source-overlay/handlers.ts +116 -636
  307. package/studio/server/introspect/source-overlay/kernel-calls.ts +0 -3
  308. package/studio/server/introspect/source-overlay/spans.ts +15 -45
  309. package/studio/server/lifecycle.ts +4 -1
  310. package/studio/server/state/baseline.test.ts +14 -9
  311. package/studio/server/state/baseline.ts +3 -5
  312. package/studio/server/state/documents.test.ts +69 -0
  313. package/studio/server/state/documents.ts +57 -10
  314. package/studio/server/state/visibility.ts +1 -5
  315. package/studio/server/views/model.ts +3 -3
  316. package/studio/server/views/target.test.ts +18 -76
  317. package/studio/server/views/target.ts +24 -50
  318. package/studio/server/watch.test.ts +11 -11
  319. package/studio/server/watch.ts +8 -4
  320. package/studio/server/workspace/updates.ts +23 -1
  321. package/studio/server/workspace-watch.ts +2 -2
  322. package/studio/shared/contracts/runtime.ts +4 -0
  323. package/studio/shared/contracts/schema.ts +44 -131
  324. package/studio/shared/contracts/surface.test.ts +15 -17
  325. package/studio/shared/contracts/workspace.ts +5 -11
  326. package/studio/shared/schema/identity.test.ts +20 -44
  327. package/studio/shared/schema/identity.ts +9 -24
  328. package/studio/tsconfig.json +2 -1
  329. package/tsconfig.json +1 -1
  330. package/viewer/dist/index.html +21 -0
  331. package/viewer/dist/main.js +41 -62
  332. package/.check-workspace.cjs +0 -40
  333. package/src/admin/binding.ts +0 -168
  334. package/src/commands/__tests__/instance-create-hosts.test.ts +0 -29
  335. package/studio/client/dist/assets/elk-api-lwhFo7vB.js +0 -1
  336. package/studio/client/dist/assets/index-B-c-smo9.js +0 -8
  337. package/studio/client/dist/assets/index-BckHuAWk.js +0 -81
  338. package/studio/client/dist/assets/index-C4aNQ6dz.css +0 -1
  339. package/studio/client/dist/assets/index-ChOr3yP0.css +0 -1
  340. package/studio/server/introspect/anatomy/views/legacy.ts +0 -232
  341. package/studio/server/introspect/core-extractor.ts +0 -139
  342. package/studio/server/introspect/overlay.test.ts +0 -72
  343. package/studio/server/introspect/source-overlay/annotations.ts +0 -14
@@ -0,0 +1,1742 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+
6
+ import { digest, parseUiLock } from '../lock'
7
+ import { UiError, type UiLock, type UiRegistry } from '../model'
8
+ import { addUi, applyPreset, doctorUi, initUi, listUi } from '../operations'
9
+ import { resolveUiRelease } from '../release'
10
+ import { shadcnInvocation } from '../runner'
11
+
12
+ const commit = 'a'.repeat(40)
13
+ const registry: UiRegistry = {
14
+ name: 'astrale-ui',
15
+ items: [
16
+ {
17
+ name: 'pattern-chart-line-basic',
18
+ type: 'registry:block',
19
+ description: 'A controlled chart.',
20
+ files: [
21
+ {
22
+ path: 'line-basic.tsx',
23
+ type: 'registry:component',
24
+ target: 'components/astrale/pattern/chart/line-basic.tsx',
25
+ },
26
+ ],
27
+ meta: { canonicalAddress: 'pattern/chart/line/basic' },
28
+ },
29
+ ],
30
+ }
31
+ const componentRegistry: UiRegistry = {
32
+ name: 'astrale-ui',
33
+ items: [
34
+ {
35
+ name: 'component-sidebar',
36
+ type: 'registry:component',
37
+ title: 'Component · sidebar',
38
+ dependencies: ['@astrale-os/ui@^0.3.0-beta.0', 'lucide-react@1.32.0'],
39
+ files: [
40
+ {
41
+ path: 'sidebar.tsx',
42
+ type: 'registry:component',
43
+ target: 'components/astrale/component/sidebar/sidebar.tsx',
44
+ },
45
+ {
46
+ path: 'use-mobile.ts',
47
+ type: 'registry:hook',
48
+ target: 'components/astrale/component/sidebar/use-mobile.ts',
49
+ },
50
+ ],
51
+ meta: { canonicalAddress: 'component/sidebar', ownership: 'consumer-source' },
52
+ },
53
+ ],
54
+ }
55
+ const themeCss = `/* Generated from observatory.astrale-theme.json. Consumer-owned after installation. */
56
+ :root,
57
+ [data-ui-theme='observatory'] {
58
+ --ui-background: #ffffff;
59
+ --ui-foreground: #111111;
60
+ --ui-card: #ffffff;
61
+ --ui-card-foreground: #111111;
62
+ --ui-popover: #ffffff;
63
+ --ui-popover-foreground: #111111;
64
+ --ui-primary: #2244aa;
65
+ --ui-primary-foreground: #ffffff;
66
+ --ui-secondary: #eeeeee;
67
+ --ui-secondary-foreground: #111111;
68
+ --ui-muted: #eeeeee;
69
+ --ui-muted-foreground: #555555;
70
+ --ui-accent: #ddeeff;
71
+ --ui-accent-foreground: #111111;
72
+ --ui-destructive: #aa2222;
73
+ --ui-destructive-foreground: #ffffff;
74
+ --ui-border: #dddddd;
75
+ --ui-input: #dddddd;
76
+ --ui-ring: #2244aa;
77
+ --ui-chart-1: #2244aa;
78
+ --ui-chart-2: #228844;
79
+ --ui-chart-3: #aa7722;
80
+ --ui-chart-4: #aa2222;
81
+ --ui-chart-5: #7722aa;
82
+ --ui-font-body: ui-sans-serif;
83
+ --ui-font-heading: ui-serif;
84
+ --ui-radius: 0.5rem;
85
+ --ui-radius-panel: 0.75rem;
86
+ --ui-control-height: 2.25rem;
87
+ --ui-control-height-sm: 2rem;
88
+ --ui-control-height-lg: 2.5rem;
89
+ --ui-shadow-control: none;
90
+ --ui-shadow-panel: none;
91
+ --ui-motion-fast: 120ms;
92
+ --ui-motion-standard: 180ms;
93
+ }
94
+ [data-ui-theme='observatory'].dark {
95
+ --ui-background: #111111;
96
+ --ui-foreground: #ffffff;
97
+ --ui-card: #191919;
98
+ --ui-card-foreground: #ffffff;
99
+ --ui-popover: #191919;
100
+ --ui-popover-foreground: #ffffff;
101
+ --ui-primary: #88aaff;
102
+ --ui-primary-foreground: #111111;
103
+ --ui-secondary: #292929;
104
+ --ui-secondary-foreground: #ffffff;
105
+ --ui-muted: #292929;
106
+ --ui-muted-foreground: #bbbbbb;
107
+ --ui-accent: #334455;
108
+ --ui-accent-foreground: #ffffff;
109
+ --ui-destructive: #ff7777;
110
+ --ui-destructive-foreground: #111111;
111
+ --ui-border: #333333;
112
+ --ui-input: #333333;
113
+ --ui-ring: #88aaff;
114
+ --ui-chart-1: #88aaff;
115
+ --ui-chart-2: #77dd99;
116
+ --ui-chart-3: #ffcc77;
117
+ --ui-chart-4: #ff7777;
118
+ --ui-chart-5: #cc88ff;
119
+ --ui-font-body: ui-sans-serif;
120
+ --ui-font-heading: ui-serif;
121
+ --ui-radius: 0.5rem;
122
+ --ui-radius-panel: 0.75rem;
123
+ --ui-control-height: 2.25rem;
124
+ --ui-control-height-sm: 2rem;
125
+ --ui-control-height-lg: 2.5rem;
126
+ --ui-shadow-control: none;
127
+ --ui-shadow-panel: none;
128
+ --ui-motion-fast: 120ms;
129
+ --ui-motion-standard: 180ms;
130
+ }
131
+ `
132
+ const themeRegistry: UiRegistry = {
133
+ name: 'astrale-ui',
134
+ items: [
135
+ {
136
+ name: 'theme-observatory',
137
+ type: 'registry:theme',
138
+ description: 'An owned theme.',
139
+ dependencies: ['@astrale-os/ui@^0.3.0-beta.0'],
140
+ files: [
141
+ {
142
+ path: 'observatory.css',
143
+ type: 'registry:file',
144
+ target: 'components/astrale/theme/observatory.css',
145
+ },
146
+ ],
147
+ meta: { canonicalAddress: 'theme/observatory', ownership: 'consumer-source' },
148
+ },
149
+ ],
150
+ }
151
+ const compatibility = {
152
+ version: 1,
153
+ shadcn: '4.18.0',
154
+ base: 'base',
155
+ style: 'nova',
156
+ baseUi: '1.7.0',
157
+ react: '^18.3.1 || ^19.0.0',
158
+ tailwind: '^4.3.3',
159
+ presets: ['astrale', 'compact', 'expressive'],
160
+ }
161
+
162
+ const temporary: string[] = []
163
+
164
+ afterEach(async () => {
165
+ await Promise.all(
166
+ temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
167
+ )
168
+ })
169
+
170
+ function mockFetch(seen: string[] = [], suppliedRegistry: UiRegistry = registry): typeof fetch {
171
+ return (async (input: string | URL | Request) => {
172
+ const url = String(input)
173
+ seen.push(url)
174
+ if (url.includes('/git/ref/tags/')) {
175
+ return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
176
+ }
177
+ if (url.endsWith('/tooling/compatibility.json')) return Response.json(compatibility)
178
+ if (url.endsWith('/' + commit + '/registry.json')) {
179
+ return Response.json({
180
+ name: 'astrale-ui',
181
+ include: ['registry/base/registry.json', 'registry/patterns/chart/registry.json'],
182
+ })
183
+ }
184
+ if (url.endsWith('/registry/base/registry.json')) {
185
+ return Response.json({ items: [{ name: 'astrale-base', type: 'registry:base' }] })
186
+ }
187
+ if (url.endsWith('/registry/patterns/chart/registry.json'))
188
+ return Response.json(suppliedRegistry)
189
+ const item = suppliedRegistry.items.find((candidate) =>
190
+ url.endsWith('/registry/public/r/' + candidate.name + '.json'),
191
+ )
192
+ if (item) return Response.json(builtItem(item))
193
+ return new Response('not found', { status: 404 })
194
+ }) as typeof fetch
195
+ }
196
+
197
+ function themeFetch(seen: string[] = []): typeof fetch {
198
+ return (async (input: string | URL | Request) => {
199
+ const url = String(input)
200
+ seen.push(url)
201
+ if (url.includes('/git/ref/tags/')) {
202
+ return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
203
+ }
204
+ if (url.endsWith('/tooling/compatibility.json')) return Response.json(compatibility)
205
+ if (url.endsWith('/' + commit + '/registry.json')) {
206
+ return Response.json({ name: 'astrale-ui', include: ['registry/themes/registry.json'] })
207
+ }
208
+ if (url.endsWith('/registry/themes/registry.json')) return Response.json(themeRegistry)
209
+ if (url.endsWith('/registry/public/r/theme-observatory.json')) {
210
+ return Response.json(builtThemeItem())
211
+ }
212
+ return new Response('not found', { status: 404 })
213
+ }) as typeof fetch
214
+ }
215
+
216
+ function builtThemeItem() {
217
+ return {
218
+ ...themeRegistry.items[0],
219
+ files: [
220
+ {
221
+ ...themeRegistry.items[0]!.files[0],
222
+ path: 'registry/themes/observatory.css',
223
+ content: themeCss,
224
+ },
225
+ ],
226
+ }
227
+ }
228
+
229
+ function builtItem(item: UiRegistry['items'][number]) {
230
+ return {
231
+ ...item,
232
+ files: item.files.map((file, index) => ({
233
+ ...file,
234
+ path: `registry/patterns/chart/${file.path}`,
235
+ content: index === 0 ? 'export const Chart = true\n' : 'export const Summary = true\n',
236
+ })),
237
+ }
238
+ }
239
+
240
+ async function fixture(): Promise<string> {
241
+ const root = await mkdtemp(path.join(tmpdir(), 'astrale-ui-cli-'))
242
+ temporary.push(root)
243
+ await mkdir(path.join(root, 'src'), { recursive: true })
244
+ await writeFile(
245
+ path.join(root, 'package.json'),
246
+ JSON.stringify({
247
+ name: 'fixture',
248
+ private: true,
249
+ dependencies: {
250
+ react: '19.2.8',
251
+ 'react-dom': '19.2.8',
252
+ tailwindcss: '4.3.3',
253
+ },
254
+ packageManager: 'pnpm@11.13.1',
255
+ }),
256
+ )
257
+ await writeFile(path.join(root, 'src/index.css'), '/* consumer css */\n')
258
+ return root
259
+ }
260
+
261
+ async function lockedFixture(): Promise<string> {
262
+ const root = await fixture()
263
+ const manifestPath = path.join(root, 'package.json')
264
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
265
+ manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
266
+ await writeFile(manifestPath, JSON.stringify(manifest))
267
+ await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
268
+ return root
269
+ }
270
+
271
+ function lock(): UiLock {
272
+ return {
273
+ $schema: 'https://example.invalid/ui-lock.schema.json',
274
+ version: 1,
275
+ package: { name: '@astrale-os/ui', version: '0.3.0-beta.0' },
276
+ registry: { repository: 'astrale-os/ui', ref: 'v0.3.0-beta.0', commit },
277
+ tooling: { shadcn: '4.18.0', base: 'base', style: 'nova', baseUi: '1.7.0' },
278
+ preset: 'astrale',
279
+ items: {},
280
+ }
281
+ }
282
+
283
+ describe('UI release and runner contracts', () => {
284
+ /** @evidence TEST-CLI-UI-BETA-DEFAULT */
285
+ test('resolves the default release from the public beta channel', async () => {
286
+ const seen: string[] = []
287
+ const fallback = mockFetch()
288
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
289
+ const url = String(input)
290
+ seen.push(url)
291
+ if (url.endsWith('/@astrale-os/ui/beta')) {
292
+ return Response.json({ version: '0.3.0-beta.1' })
293
+ }
294
+ return fallback(input, init)
295
+ }) as typeof fetch
296
+
297
+ const release = await resolveUiRelease(undefined, fetcher)
298
+
299
+ expect(release.version).toBe('0.3.0-beta.1')
300
+ expect(seen[0]).toBe('https://registry.npmjs.org/@astrale-os/ui/beta')
301
+ expect(seen).not.toContain('https://registry.npmjs.org/@astrale-os/ui/latest')
302
+ })
303
+
304
+ test('rejects a public beta channel that does not resolve to a beta release', async () => {
305
+ const fetcher = (async (input: string | URL | Request) => {
306
+ const url = String(input)
307
+ if (url.endsWith('/@astrale-os/ui/beta')) return Response.json({ version: '0.3.0' })
308
+ throw new Error('release snapshot must not be fetched')
309
+ }) as typeof fetch
310
+
311
+ await expect(resolveUiRelease(undefined, fetcher)).rejects.toMatchObject({
312
+ code: 'UI_REGISTRY_UNAVAILABLE',
313
+ message: 'Invalid UI beta release version: 0.3.0',
314
+ })
315
+ })
316
+
317
+ /** @evidence TEST-CLI-UI-ONE-SNAPSHOT */
318
+ test('resolves one commit and reads the full release snapshot from it', async () => {
319
+ const seen: string[] = []
320
+ const release = await resolveUiRelease('0.3.0-beta.0', mockFetch(seen))
321
+ expect(release.commit).toBe(commit)
322
+ expect(release.compatibility.base).toBe('base')
323
+ expect(release.registry.items).toHaveLength(1)
324
+ expect(release.registry.items[0]?.files[0]?.path).toBe('registry/patterns/chart/line-basic.tsx')
325
+ expect(seen.filter((url) => new URL(url).hostname === 'raw.githubusercontent.com')).toEqual(
326
+ expect.arrayContaining([
327
+ expect.stringContaining('/' + commit + '/tooling/compatibility.json'),
328
+ expect.stringContaining('/' + commit + '/registry'),
329
+ ]),
330
+ )
331
+ })
332
+
333
+ test('resolves the immutable release commit when the anonymous GitHub API is rate limited', async () => {
334
+ const seen: string[] = []
335
+ const fallback = mockFetch(seen)
336
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
337
+ const url = String(input)
338
+ if (url.includes('/git/ref/tags/')) {
339
+ seen.push(url)
340
+ return Response.json({ message: 'API rate limit exceeded' }, { status: 403 })
341
+ }
342
+ if (url.includes('/releases/tag/')) {
343
+ seen.push(url)
344
+ return new Response(
345
+ `<a data-hovercard-type="commit" href="/astrale-os/ui/commit/${'b'.repeat(40)}">decoy</a>
346
+ <a href="/astrale-os/ui/tree/v0.3.0-beta.0">repository navigation</a>
347
+ ${'x'.repeat(35_000)}
348
+ <a href="/astrale-os/ui/tree/v0.3.0-beta.0">tag</a>
349
+ <a data-hovercard-type="commit" href="/astrale-os/ui/commit/${commit}">commit</a>`,
350
+ { headers: { 'content-type': 'text/html' } },
351
+ )
352
+ }
353
+ return fallback(input, init)
354
+ }) as typeof fetch
355
+
356
+ const release = await resolveUiRelease('0.3.0-beta.0', fetcher)
357
+
358
+ expect(release.commit).toBe(commit)
359
+ expect(seen).toContain('https://github.com/astrale-os/ui/releases/tag/v0.3.0-beta.0')
360
+ expect(seen).toContain(
361
+ 'https://raw.githubusercontent.com/astrale-os/ui/' + commit + '/tooling/compatibility.json',
362
+ )
363
+ })
364
+
365
+ test('rejects a release page without an exact Astrale UI commit target', async () => {
366
+ const fetcher = (async (input: string | URL | Request) => {
367
+ const url = String(input)
368
+ if (url.includes('/git/ref/tags/')) return new Response('limited', { status: 403 })
369
+ if (url.includes('/releases/tag/')) return new Response('<main>release unavailable</main>')
370
+ throw new Error('release snapshot must not be fetched')
371
+ }) as typeof fetch
372
+
373
+ await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
374
+ code: 'UI_REGISTRY_UNAVAILABLE',
375
+ message: 'UI ref v0.3.0-beta.0 did not resolve to a commit.',
376
+ })
377
+ })
378
+
379
+ test('does not replace an authoritative missing tag with release-page HTML', async () => {
380
+ const seen: string[] = []
381
+ const fetcher = (async (input: string | URL | Request) => {
382
+ const url = String(input)
383
+ seen.push(url)
384
+ if (url.includes('/git/ref/tags/')) return new Response('missing', { status: 404 })
385
+ throw new Error('release page must not be fetched')
386
+ }) as typeof fetch
387
+
388
+ await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
389
+ code: 'UI_REGISTRY_UNAVAILABLE',
390
+ message: 'UI ref v0.3.0-beta.0 returned HTTP 404.',
391
+ })
392
+ expect(seen).toEqual(['https://api.github.com/repos/astrale-os/ui/git/ref/tags/v0.3.0-beta.0'])
393
+ })
394
+
395
+ test('bounds declared and streamed release-page HTML before reading a snapshot', async () => {
396
+ for (const oversized of [
397
+ new Response('large', { headers: { 'content-length': '1048577' } }),
398
+ new Response(
399
+ new ReadableStream({
400
+ start(controller) {
401
+ controller.enqueue(new Uint8Array(1_048_577))
402
+ controller.close()
403
+ },
404
+ }),
405
+ ),
406
+ ]) {
407
+ const seen: string[] = []
408
+ const fetcher = (async (input: string | URL | Request) => {
409
+ const url = String(input)
410
+ seen.push(url)
411
+ if (url.includes('/git/ref/tags/')) return new Response('limited', { status: 403 })
412
+ if (url.includes('/releases/tag/')) return oversized
413
+ throw new Error('release snapshot must not be fetched')
414
+ }) as typeof fetch
415
+
416
+ await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
417
+ code: 'UI_REGISTRY_UNAVAILABLE',
418
+ message: 'UI release v0.3.0-beta.0 exceeds the supported response size.',
419
+ })
420
+ expect(seen).toHaveLength(2)
421
+ }
422
+ })
423
+
424
+ test('admits a release theme as one canonical consumer-owned CSS target', async () => {
425
+ const release = await resolveUiRelease('0.3.0-beta.0', themeFetch())
426
+ expect(release.registry.items).toEqual([
427
+ expect.objectContaining({
428
+ name: 'theme-observatory',
429
+ type: 'registry:theme',
430
+ meta: expect.objectContaining({ canonicalAddress: 'theme/observatory' }),
431
+ files: [
432
+ expect.objectContaining({
433
+ type: 'registry:file',
434
+ path: 'registry/themes/observatory.css',
435
+ target: 'components/astrale/theme/observatory.css',
436
+ }),
437
+ ],
438
+ }),
439
+ ])
440
+ })
441
+
442
+ test('admits an exact consumer-owned component with item-local dependencies and files', async () => {
443
+ const release = await resolveUiRelease('0.3.0-beta.0', mockFetch([], componentRegistry))
444
+ expect(release.registry.items).toEqual([
445
+ expect.objectContaining({
446
+ name: 'component-sidebar',
447
+ type: 'registry:component',
448
+ dependencies: ['@astrale-os/ui@^0.3.0-beta.0', 'lucide-react@1.32.0'],
449
+ meta: expect.objectContaining({ canonicalAddress: 'component/sidebar' }),
450
+ files: [
451
+ expect.objectContaining({
452
+ type: 'registry:component',
453
+ target: 'components/astrale/component/sidebar/sidebar.tsx',
454
+ }),
455
+ expect.objectContaining({
456
+ type: 'registry:hook',
457
+ target: 'components/astrale/component/sidebar/use-mobile.ts',
458
+ }),
459
+ ],
460
+ }),
461
+ ])
462
+ })
463
+
464
+ test('rejects a component item that targets another registry ownership category', async () => {
465
+ const invalid = structuredClone(componentRegistry)
466
+ invalid.items[0]!.files[0]!.target = 'components/astrale/pattern/sidebar/sidebar.tsx'
467
+ await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], invalid))).rejects.toMatchObject({
468
+ code: 'UI_REGISTRY_UNAVAILABLE',
469
+ })
470
+ })
471
+
472
+ test('lists release themes through the public type filter', async () => {
473
+ const themes = await listUi(
474
+ undefined,
475
+ { type: 'theme', version: '0.3.0-beta.0' },
476
+ { fetcher: themeFetch() },
477
+ )
478
+ expect(themes.map((item) => item.meta.canonicalAddress)).toEqual(['theme/observatory'])
479
+ })
480
+
481
+ test('constructs exact on-demand commands for every supported package manager', () => {
482
+ expect(shadcnInvocation('pnpm', '4.18.0', ['add', 'item'])).toEqual({
483
+ file: 'pnpm',
484
+ args: ['dlx', 'shadcn@4.18.0', 'add', 'item'],
485
+ })
486
+ expect(shadcnInvocation('npm', '4.18.0', ['add', 'item'])).toEqual({
487
+ file: 'npx',
488
+ args: ['--yes', 'shadcn@4.18.0', 'add', 'item'],
489
+ })
490
+ expect(shadcnInvocation('yarn', '4.18.0', ['add', 'item'])).toEqual({
491
+ file: 'yarn',
492
+ args: ['dlx', 'shadcn@4.18.0', 'add', 'item'],
493
+ })
494
+ expect(shadcnInvocation('bun', '4.18.0', ['add', 'item'])).toEqual({
495
+ file: 'bunx',
496
+ args: ['shadcn@4.18.0', 'add', 'item'],
497
+ })
498
+ })
499
+
500
+ test('rejects an unsafe authoritative root without falling back to a legacy registry', async () => {
501
+ const seen: string[] = []
502
+ const fallback = mockFetch(seen)
503
+ const unsafe = (async (input: string | URL | Request, init?: RequestInit) => {
504
+ const url = String(input)
505
+ if (url.endsWith('/' + commit + '/registry.json')) {
506
+ return Response.json({ name: 'astrale-ui', include: ['../registry.json'] })
507
+ }
508
+ return fallback(input, init)
509
+ }) as typeof fetch
510
+ await expect(resolveUiRelease('0.3.0-beta.0', unsafe)).rejects.toThrow()
511
+ expect(seen.some((url) => url.endsWith('/registry/registry.json'))).toBe(false)
512
+ })
513
+
514
+ test('rejects noncanonical and encoded registry includes before fetching them', async () => {
515
+ const unsafeIncludes = [
516
+ '%2e%2e/other/registry.json',
517
+ 'registry/%2Fother/registry.json',
518
+ 'registry/other/registry.json?raw=1',
519
+ 'registry/other/registry.json#item',
520
+ 'https://example.invalid/registry.json',
521
+ './registry/other/registry.json',
522
+ ]
523
+
524
+ for (const include of unsafeIncludes) {
525
+ const seen: string[] = []
526
+ const fallback = mockFetch(seen)
527
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
528
+ const url = String(input)
529
+ if (url.endsWith('/' + commit + '/registry.json')) {
530
+ return Response.json({ include: [include] })
531
+ }
532
+ return fallback(input, init)
533
+ }) as typeof fetch
534
+
535
+ await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
536
+ code: 'UI_REGISTRY_UNAVAILABLE',
537
+ })
538
+ expect(seen.some((url) => url.includes('example.invalid') || url.includes('/other/'))).toBe(
539
+ false,
540
+ )
541
+ }
542
+ })
543
+
544
+ test('rejects malformed and already-qualified family-local item paths', async () => {
545
+ const unsafePaths = [
546
+ '',
547
+ '/absolute.tsx',
548
+ 'C:/windows.tsx',
549
+ './line-basic.tsx',
550
+ '../line-basic.tsx',
551
+ '%2e%2e/line-basic.tsx',
552
+ 'registry/patterns/chart/line-basic.tsx',
553
+ ]
554
+
555
+ for (const unsafePath of unsafePaths) {
556
+ const supplied = structuredClone(registry)
557
+ supplied.items[0]!.files[0]!.path = unsafePath
558
+ await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], supplied))).rejects.toMatchObject(
559
+ {
560
+ code: 'UI_REGISTRY_UNAVAILABLE',
561
+ },
562
+ )
563
+ }
564
+ })
565
+
566
+ test('qualifies every item file relative to its declaring nested registry', async () => {
567
+ const rootItem = {
568
+ ...structuredClone(registry.items[0]!),
569
+ name: 'pattern-chart-root',
570
+ files: [
571
+ {
572
+ ...structuredClone(registry.items[0]!.files[0]!),
573
+ path: 'registry/root.tsx',
574
+ target: 'components/astrale/pattern/chart/root.tsx',
575
+ },
576
+ ],
577
+ meta: { canonicalAddress: 'pattern/chart/root' },
578
+ }
579
+ const chartItem = {
580
+ ...structuredClone(registry.items[0]!),
581
+ files: [
582
+ structuredClone(registry.items[0]!.files[0]!),
583
+ {
584
+ ...structuredClone(registry.items[0]!.files[0]!),
585
+ path: 'parts/legend.tsx',
586
+ target: 'components/astrale/pattern/chart/parts/legend.tsx',
587
+ },
588
+ ],
589
+ }
590
+ const nestedItem = {
591
+ ...structuredClone(registry.items[0]!),
592
+ name: 'pattern-chart-nested-line',
593
+ files: [structuredClone(registry.items[0]!.files[0]!)],
594
+ meta: { canonicalAddress: 'pattern/chart/nested-line' },
595
+ }
596
+ const fallback = mockFetch()
597
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
598
+ const url = String(input)
599
+ if (url.endsWith('/' + commit + '/registry.json')) {
600
+ return Response.json({
601
+ items: [rootItem],
602
+ include: ['registry/patterns/chart/registry.json'],
603
+ })
604
+ }
605
+ if (url.endsWith('/registry/patterns/chart/registry.json')) {
606
+ return Response.json({ items: [chartItem], include: ['nested/registry.json'] })
607
+ }
608
+ if (url.endsWith('/registry/patterns/chart/nested/registry.json')) {
609
+ return Response.json({ items: [nestedItem] })
610
+ }
611
+ return fallback(input, init)
612
+ }) as typeof fetch
613
+
614
+ const release = await resolveUiRelease('0.3.0-beta.0', fetcher)
615
+ expect(release.registry.items.map((item) => item.files.map((file) => file.path))).toEqual([
616
+ ['registry/root.tsx'],
617
+ ['registry/patterns/chart/line-basic.tsx', 'registry/patterns/chart/parts/legend.tsx'],
618
+ ['registry/patterns/chart/nested/line-basic.tsx'],
619
+ ])
620
+ })
621
+
622
+ /** @evidence TEST-CLI-UI-BOUNDED-REMOTE-DOCUMENTS */
623
+ test('bounds and normalizes malformed registry responses', async () => {
624
+ const fallback = mockFetch()
625
+ const oversized = (async (input: string | URL | Request, init?: RequestInit) => {
626
+ const url = String(input)
627
+ if (url.endsWith('/tooling/compatibility.json')) {
628
+ return new Response('x'.repeat(1_048_577), {
629
+ headers: { 'content-type': 'application/json' },
630
+ })
631
+ }
632
+ return fallback(input, init)
633
+ }) as typeof fetch
634
+ await expect(resolveUiRelease('0.3.0-beta.0', oversized)).rejects.toMatchObject({
635
+ code: 'UI_REGISTRY_UNAVAILABLE',
636
+ })
637
+
638
+ const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
639
+ const url = String(input)
640
+ if (url.endsWith('/' + commit + '/registry.json')) {
641
+ return new Response('{', { headers: { 'content-type': 'application/json' } })
642
+ }
643
+ return fallback(input, init)
644
+ }) as typeof fetch
645
+ await expect(resolveUiRelease('0.3.0-beta.0', malformed)).rejects.toMatchObject({
646
+ code: 'UI_REGISTRY_UNAVAILABLE',
647
+ })
648
+ })
649
+
650
+ test('rejects include fan-out before fetching an unbounded registry graph', async () => {
651
+ const fallback = mockFetch()
652
+ const fanOut = (async (input: string | URL | Request, init?: RequestInit) => {
653
+ const url = String(input)
654
+ if (url.endsWith('/' + commit + '/registry.json')) {
655
+ return Response.json({
656
+ include: Array.from(
657
+ { length: 100 },
658
+ (_, index) => 'registry/patterns/family-' + index + '/registry.json',
659
+ ),
660
+ })
661
+ }
662
+ return fallback(input, init)
663
+ }) as typeof fetch
664
+ await expect(resolveUiRelease('0.3.0-beta.0', fanOut)).rejects.toMatchObject({
665
+ code: 'UI_REGISTRY_UNAVAILABLE',
666
+ })
667
+ })
668
+ })
669
+
670
+ describe('UI initialization transaction', () => {
671
+ test('initializes the generated Domain frontend stylesheet instead of an unused fallback', async () => {
672
+ const root = await fixture()
673
+ await mkdir(path.join(root, 'frontend/src'), { recursive: true })
674
+ await writeFile(
675
+ path.join(root, 'frontend/package.json'),
676
+ JSON.stringify({ name: 'astrale-frontend', private: true, type: 'module' }),
677
+ )
678
+ await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
679
+ await mkdir(path.join(root, 'ui'), { recursive: true })
680
+ await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
681
+ await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
682
+ await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
683
+ const rootCssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
684
+
685
+ await initUi(
686
+ { path: path.join(root, 'frontend'), version: '0.3.0-beta.0', install: false },
687
+ { fetcher: mockFetch() },
688
+ )
689
+
690
+ const css = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
691
+ const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
692
+ expect(css).toContain("@import '@astrale-os/ui/theme.css';")
693
+ expect(css).toContain('/* Domain frontend */')
694
+ expect(components.tailwind.css).toBe('frontend/src/styles.css')
695
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(rootCssBefore)
696
+ expect(
697
+ JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies,
698
+ ).toHaveProperty('@astrale-os/ui', '0.3.0-beta.0')
699
+ expect(await Bun.file(path.join(root, 'src/astrale-ui.css')).exists()).toBe(false)
700
+ expect(JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8'))).toEqual({
701
+ name: 'fixture-ui-registry',
702
+ private: true,
703
+ type: 'module',
704
+ })
705
+ expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toContain('components')
706
+
707
+ await writeFile(path.join(root, 'src/app.css'), '/* later root stylesheet */\n')
708
+ const repeated = await initUi(
709
+ { path: root, version: '0.3.0-beta.0', install: false },
710
+ { fetcher: mockFetch() },
711
+ )
712
+ expect(repeated.status).toBe('unchanged')
713
+ })
714
+
715
+ test('adds the private registry workspace to an npm-authored Domain manifest', async () => {
716
+ const root = await fixture()
717
+ const manifestPath = path.join(root, 'package.json')
718
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
719
+ manifest.packageManager = 'npm@11.16.0'
720
+ manifest.workspaces = ['frontend']
721
+ await writeFile(manifestPath, JSON.stringify(manifest))
722
+ await writeFile(path.join(root, 'package-lock.json'), '{}')
723
+ await mkdir(path.join(root, 'frontend/src'), { recursive: true })
724
+ await writeFile(
725
+ path.join(root, 'frontend/package.json'),
726
+ JSON.stringify({ name: 'astrale-frontend', private: true }),
727
+ )
728
+ await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
729
+ await mkdir(path.join(root, 'ui'), { recursive: true })
730
+ await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
731
+ await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
732
+
733
+ const result = await initUi(
734
+ { path: root, version: '0.3.0-beta.0', install: false },
735
+ { fetcher: mockFetch() },
736
+ )
737
+
738
+ expect(result.files).toEqual(expect.arrayContaining(['components/package.json']))
739
+ expect(JSON.parse(await readFile(manifestPath, 'utf8')).workspaces).toEqual([
740
+ 'frontend',
741
+ 'components',
742
+ ])
743
+ expect(JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8'))).toEqual({
744
+ name: 'fixture-ui-registry',
745
+ private: true,
746
+ type: 'module',
747
+ })
748
+ })
749
+
750
+ test('rejects a Domain registry workspace whose physical parent escapes the project', async () => {
751
+ const root = await fixture()
752
+ const outside = await fixture()
753
+ await mkdir(path.join(root, 'frontend/src'), { recursive: true })
754
+ await writeFile(
755
+ path.join(root, 'frontend/package.json'),
756
+ JSON.stringify({ name: 'astrale-frontend', private: true }),
757
+ )
758
+ await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
759
+ await mkdir(path.join(root, 'ui'), { recursive: true })
760
+ await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
761
+ await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
762
+ await symlink(outside, path.join(root, 'components'), 'dir')
763
+ const outsideManifest = await readFile(path.join(outside, 'package.json'), 'utf8')
764
+ const rootManifest = await readFile(path.join(root, 'package.json'), 'utf8')
765
+
766
+ await expect(
767
+ initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
768
+ ).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
769
+ expect(await readFile(path.join(outside, 'package.json'), 'utf8')).toBe(outsideManifest)
770
+ expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(rootManifest)
771
+ expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
772
+ })
773
+
774
+ test('rejects a symlinked pnpm workspace manifest before any Domain mutation', async () => {
775
+ const root = await fixture()
776
+ const outside = await fixture()
777
+ await mkdir(path.join(root, 'frontend/src'), { recursive: true })
778
+ await writeFile(
779
+ path.join(root, 'frontend/package.json'),
780
+ JSON.stringify({ name: 'astrale-frontend', private: true }),
781
+ )
782
+ await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
783
+ await mkdir(path.join(root, 'ui'), { recursive: true })
784
+ await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
785
+ await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
786
+ const outsideWorkspace = path.join(outside, 'workspace.yaml')
787
+ await writeFile(outsideWorkspace, "packages:\n - 'outside'\n")
788
+ await symlink(outsideWorkspace, path.join(root, 'pnpm-workspace.yaml'), 'file')
789
+ const outsideBefore = await readFile(outsideWorkspace, 'utf8')
790
+
791
+ await expect(
792
+ initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
793
+ ).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
794
+ expect(await readFile(outsideWorkspace, 'utf8')).toBe(outsideBefore)
795
+ expect(await Bun.file(path.join(root, 'components/package.json')).exists()).toBe(false)
796
+ expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
797
+ })
798
+
799
+ test('restores every Domain workspace mutation when dependency installation fails', async () => {
800
+ const root = await fixture()
801
+ await mkdir(path.join(root, 'frontend/src'), { recursive: true })
802
+ await writeFile(
803
+ path.join(root, 'frontend/package.json'),
804
+ JSON.stringify({ name: 'astrale-frontend', private: true }),
805
+ )
806
+ await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
807
+ await mkdir(path.join(root, 'ui'), { recursive: true })
808
+ await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
809
+ await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
810
+ await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
811
+ const manifestBefore = await readFile(path.join(root, 'package.json'), 'utf8')
812
+ const cssBefore = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
813
+ const workspaceBefore = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')
814
+
815
+ await expect(
816
+ initUi(
817
+ { path: root, version: '0.3.0-beta.0' },
818
+ {
819
+ fetcher: mockFetch(),
820
+ runner: async () => ({ code: 1, stdout: '', stderr: 'install failed' }),
821
+ },
822
+ ),
823
+ ).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
824
+
825
+ expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(manifestBefore)
826
+ expect(await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')).toBe(cssBefore)
827
+ expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toBe(workspaceBefore)
828
+ expect(await Bun.file(path.join(root, 'components/package.json')).exists()).toBe(false)
829
+ expect(await Bun.file(path.join(root, 'components.json')).exists()).toBe(false)
830
+ expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
831
+ expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
832
+ })
833
+
834
+ test('rejects a registry workspace that could shadow the public UI package', async () => {
835
+ const root = await fixture()
836
+ await mkdir(path.join(root, 'frontend/src'), { recursive: true })
837
+ await writeFile(
838
+ path.join(root, 'frontend/package.json'),
839
+ JSON.stringify({ name: 'astrale-frontend', private: true }),
840
+ )
841
+ await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
842
+ await mkdir(path.join(root, 'ui'), { recursive: true })
843
+ await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
844
+ await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
845
+ await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
846
+ await mkdir(path.join(root, 'components'), { recursive: true })
847
+ await writeFile(
848
+ path.join(root, 'components/package.json'),
849
+ JSON.stringify({ name: '@astrale-os/ui', private: true, version: '0.3.0-beta.0' }),
850
+ )
851
+ const workspaceBefore = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')
852
+
853
+ await expect(
854
+ initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
855
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
856
+ expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toBe(workspaceBefore)
857
+ expect(
858
+ JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8')).name,
859
+ ).toBe('@astrale-os/ui')
860
+ })
861
+
862
+ test('rejects configured and discovered stylesheets whose physical parent escapes', async () => {
863
+ const root = await fixture()
864
+ const outside = await fixture()
865
+ await symlink(outside, path.join(root, 'escaped'), 'dir')
866
+ await writeFile(
867
+ path.join(root, 'components.json'),
868
+ JSON.stringify({ tailwind: { css: 'escaped/styles.css' } }),
869
+ )
870
+
871
+ await expect(
872
+ initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
873
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
874
+ expect(await Bun.file(path.join(outside, 'styles.css')).exists()).toBe(false)
875
+
876
+ const automaticRoot = await fixture()
877
+ await rm(path.join(automaticRoot, 'src/index.css'))
878
+ const outsideCss = await readFile(path.join(outside, 'src/index.css'), 'utf8')
879
+ await symlink(outside, path.join(automaticRoot, 'frontend'), 'dir')
880
+ await expect(
881
+ initUi(
882
+ { path: automaticRoot, version: '0.3.0-beta.0', install: false },
883
+ { fetcher: mockFetch() },
884
+ ),
885
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
886
+ expect(await readFile(path.join(outside, 'src/index.css'), 'utf8')).toBe(outsideCss)
887
+ })
888
+
889
+ test('dry-run reports every mutation and writes nothing', async () => {
890
+ const root = await fixture()
891
+ const before = await readFile(path.join(root, 'package.json'), 'utf8')
892
+ const result = await initUi(
893
+ { path: root, version: '0.3.0-beta.0', dryRun: true },
894
+ { fetcher: mockFetch() },
895
+ )
896
+ expect(result.status).toBe('planned')
897
+ expect(result.files).toEqual(
898
+ expect.arrayContaining([
899
+ 'package.json',
900
+ 'src/index.css',
901
+ 'components.json',
902
+ 'astrale-ui.lock.json',
903
+ ]),
904
+ )
905
+ expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(before)
906
+ expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
907
+ })
908
+
909
+ /** @evidence TEST-CLI-UI-LOCK-AFTER-SUCCESS */
910
+ test('restores all files and leaves no lock when dependency installation fails', async () => {
911
+ const root = await fixture()
912
+ const packageBefore = await readFile(path.join(root, 'package.json'), 'utf8')
913
+ const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
914
+ await expect(
915
+ initUi(
916
+ { path: root, version: '0.3.0-beta.0' },
917
+ {
918
+ fetcher: mockFetch(),
919
+ runner: async () => ({ code: 1, stdout: '', stderr: 'registry unavailable' }),
920
+ },
921
+ ),
922
+ ).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
923
+ expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(packageBefore)
924
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
925
+ expect(await Bun.file(path.join(root, 'components.json')).exists()).toBe(false)
926
+ expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
927
+ expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
928
+ })
929
+
930
+ test('writes Base Nova config and advances the lock only after configuration succeeds', async () => {
931
+ const root = await fixture()
932
+ await initUi(
933
+ { path: root, version: '0.3.0-beta.0', preset: 'compact', install: false },
934
+ { fetcher: mockFetch() },
935
+ )
936
+ const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
937
+ const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
938
+ expect(components.style).toBe('base-nova')
939
+ expect(written.tooling).toMatchObject({ base: 'base', style: 'nova', baseUi: '1.7.0' })
940
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
941
+ '@astrale-os/ui/presets/compact.css',
942
+ )
943
+ })
944
+
945
+ test('preserves an existing development-only UI dependency during initialization', async () => {
946
+ const root = await fixture()
947
+ const manifestPath = path.join(root, 'package.json')
948
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
949
+ manifest.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
950
+ await writeFile(manifestPath, JSON.stringify(manifest))
951
+
952
+ await initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() })
953
+
954
+ const written = JSON.parse(await readFile(manifestPath, 'utf8'))
955
+ expect(written.dependencies['@astrale-os/ui']).toBeUndefined()
956
+ expect(written.devDependencies['@astrale-os/ui']).toBe('0.3.0-beta.0')
957
+ })
958
+
959
+ test('doctor accepts one exact development dependency and rejects drift or duplication', async () => {
960
+ const root = await lockedFixture()
961
+ const manifestPath = path.join(root, 'package.json')
962
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
963
+ delete manifest.dependencies['@astrale-os/ui']
964
+ manifest.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
965
+ await writeFile(manifestPath, JSON.stringify(manifest))
966
+ await writeFile(
967
+ path.join(root, 'components.json'),
968
+ JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
969
+ )
970
+ await writeFile(
971
+ path.join(root, 'src/index.css'),
972
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
973
+ )
974
+
975
+ expect((await doctorUi(root)).healthy).toBe(true)
976
+
977
+ manifest.devDependencies['@astrale-os/ui'] = '0.3.0-beta.1'
978
+ await writeFile(manifestPath, JSON.stringify(manifest))
979
+ expect((await doctorUi(root)).checks.find(({ check }) => check === 'package')).toMatchObject({
980
+ ok: false,
981
+ detail: 'devDependencies:0.3.0-beta.1',
982
+ })
983
+
984
+ manifest.devDependencies['@astrale-os/ui'] = '0.3.0-beta.0'
985
+ manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
986
+ await writeFile(manifestPath, JSON.stringify(manifest))
987
+ expect((await doctorUi(root)).checks.find(({ check }) => check === 'package')).toMatchObject({
988
+ ok: false,
989
+ detail: 'declared in dependencies and devDependencies',
990
+ })
991
+ })
992
+
993
+ test('repeated exact init performs no release fetch while requested drift rejects', async () => {
994
+ const root = await fixture()
995
+ const manifestPath = path.join(root, 'package.json')
996
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
997
+ manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
998
+ await writeFile(manifestPath, JSON.stringify(manifest))
999
+ await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
1000
+ await writeFile(
1001
+ path.join(root, 'components.json'),
1002
+ JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
1003
+ )
1004
+ await writeFile(
1005
+ path.join(root, 'src/index.css'),
1006
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
1007
+ )
1008
+ let fetched = false
1009
+ const result = await initUi(
1010
+ { path: root, preset: 'astrale' },
1011
+ {
1012
+ fetcher: (async () => {
1013
+ fetched = true
1014
+ throw new Error('must not fetch')
1015
+ }) as unknown as typeof fetch,
1016
+ },
1017
+ )
1018
+ expect(result.status).toBe('unchanged')
1019
+ expect(fetched).toBe(false)
1020
+ await expect(initUi({ path: root, preset: 'compact' })).rejects.toMatchObject({
1021
+ code: 'UI_ITEM_CONFLICT',
1022
+ })
1023
+ manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.1'
1024
+ await writeFile(manifestPath, JSON.stringify(manifest))
1025
+ await expect(initUi({ path: root, preset: 'astrale' })).rejects.toMatchObject({
1026
+ code: 'UI_ITEM_CONFLICT',
1027
+ })
1028
+ })
1029
+ })
1030
+
1031
+ describe('UI source operations', () => {
1032
+ test('add rejects an empty programmatic request before project discovery or tool execution', async () => {
1033
+ await expect(addUi([], {})).rejects.toMatchObject({ code: 'UI_ITEM_NOT_FOUND' })
1034
+ })
1035
+
1036
+ test('rejects hostile lock file records before an operation can escape the project', () => {
1037
+ for (const items of [
1038
+ [],
1039
+ {
1040
+ 'pattern/chart/line/basic': {
1041
+ address: 'pattern/chart/line/basic',
1042
+ sourceDigest: 'bad',
1043
+ files: {},
1044
+ },
1045
+ },
1046
+ {
1047
+ 'pattern/chart/line/basic': {
1048
+ address: 'pattern/chart/line/basic',
1049
+ sourceDigest: 'b'.repeat(64),
1050
+ files: { '../../outside': 'c'.repeat(64) },
1051
+ },
1052
+ },
1053
+ ]) {
1054
+ expect(() => parseUiLock({ ...lock(), items })).toThrow(UiError)
1055
+ }
1056
+ })
1057
+
1058
+ test('add dry-run invokes the exact shadcn version and does not advance the lock', async () => {
1059
+ const root = await lockedFixture()
1060
+ const before = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
1061
+ const calls: Array<{ file: string; args: string[] }> = []
1062
+ const result = await addUi(
1063
+ ['pattern/chart/line/basic'],
1064
+ { project: root, dryRun: true },
1065
+ {
1066
+ fetcher: mockFetch(),
1067
+ runner: async (file, args) => {
1068
+ calls.push({ file, args })
1069
+ return { code: 0, stdout: 'planned', stderr: '' }
1070
+ },
1071
+ },
1072
+ )
1073
+ expect(result.status).toBe('planned')
1074
+ expect(result.sources).toEqual([
1075
+ {
1076
+ address: 'pattern/chart/line/basic',
1077
+ dependencies: [],
1078
+ files: ['components/astrale/pattern/chart/line-basic.tsx'],
1079
+ },
1080
+ ])
1081
+ expect(calls[0]).toMatchObject({ file: 'pnpm' })
1082
+ expect(calls[0]?.args).toEqual(expect.arrayContaining(['dlx', 'shadcn@4.18.0', '--dry-run']))
1083
+ expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(before)
1084
+ })
1085
+
1086
+ /** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
1087
+ test('installs and activates a released theme while recording owned source', async () => {
1088
+ const root = await lockedFixture()
1089
+ await writeFile(
1090
+ path.join(root, 'components.json'),
1091
+ JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
1092
+ )
1093
+ await writeFile(
1094
+ path.join(root, 'src/index.css'),
1095
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
1096
+ )
1097
+ const target = path.join(root, 'components/astrale/theme/observatory.css')
1098
+ let invoked = false
1099
+ const result = await addUi(
1100
+ ['theme/observatory'],
1101
+ { project: root, yes: true },
1102
+ {
1103
+ fetcher: themeFetch(),
1104
+ runner: async () => {
1105
+ invoked = true
1106
+ return { code: 0, stdout: '', stderr: '' }
1107
+ },
1108
+ },
1109
+ )
1110
+
1111
+ expect(invoked).toBe(false)
1112
+ expect(result).toMatchObject({ status: 'installed', items: ['theme/observatory'] })
1113
+ expect(await readFile(target, 'utf8')).toBe(themeCss)
1114
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
1115
+ "@import '../components/astrale/theme/observatory.css';",
1116
+ )
1117
+ const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
1118
+ expect(written.items['theme/observatory']).toMatchObject({
1119
+ address: 'theme/observatory',
1120
+ sourceDigest: digest(JSON.stringify(builtThemeItem())),
1121
+ files: { 'components/astrale/theme/observatory.css': digest(themeCss) },
1122
+ })
1123
+ expect((await doctorUi(root)).healthy).toBe(true)
1124
+ })
1125
+
1126
+ /** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
1127
+ test('installs a playground-exported theme without registry or shadcn and preserves edits', async () => {
1128
+ const root = await lockedFixture()
1129
+ const source = path.join(root, 'observatory.css')
1130
+ const target = path.join(root, 'components/astrale/theme/observatory.css')
1131
+ await writeFile(source, themeCss)
1132
+ let invoked = false
1133
+
1134
+ const planned = await addUi(
1135
+ ['./observatory.css'],
1136
+ { project: root, dryRun: true },
1137
+ {
1138
+ fetcher: (async () => {
1139
+ invoked = true
1140
+ throw new Error('must not fetch')
1141
+ }) as unknown as typeof fetch,
1142
+ runner: async () => {
1143
+ invoked = true
1144
+ return { code: 1, stdout: '', stderr: '' }
1145
+ },
1146
+ },
1147
+ )
1148
+ expect(planned).toMatchObject({
1149
+ status: 'planned',
1150
+ items: ['theme/observatory'],
1151
+ activation: {
1152
+ file: 'src/index.css',
1153
+ import: "@import '../components/astrale/theme/observatory.css';",
1154
+ },
1155
+ })
1156
+ expect(await Bun.file(target).exists()).toBe(false)
1157
+
1158
+ await addUi(
1159
+ ['./observatory.css'],
1160
+ { project: root },
1161
+ {
1162
+ fetcher: (async () => {
1163
+ invoked = true
1164
+ throw new Error('must not fetch')
1165
+ }) as unknown as typeof fetch,
1166
+ runner: async () => {
1167
+ invoked = true
1168
+ return { code: 1, stdout: '', stderr: '' }
1169
+ },
1170
+ },
1171
+ )
1172
+ expect(invoked).toBe(false)
1173
+ expect(await readFile(target, 'utf8')).toBe(themeCss)
1174
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
1175
+ "@import '../components/astrale/theme/observatory.css';",
1176
+ )
1177
+
1178
+ await writeFile(target, 'consumer edit\n')
1179
+ await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
1180
+ code: 'UI_LOCAL_CHANGES',
1181
+ })
1182
+ await addUi(['./observatory.css'], { project: root, overwrite: true, yes: true })
1183
+ expect(await readFile(target, 'utf8')).toBe(themeCss)
1184
+ })
1185
+
1186
+ test('rejects malformed and symlinked local themes before project mutation', async () => {
1187
+ const root = await lockedFixture()
1188
+ const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
1189
+ await writeFile(path.join(root, 'unsafe.css'), "@import 'https://example.invalid/theme.css';\n")
1190
+ await expect(addUi(['./unsafe.css'], { project: root })).rejects.toMatchObject({
1191
+ code: 'UI_ITEM_CONFLICT',
1192
+ })
1193
+
1194
+ const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-theme-'))
1195
+ temporary.push(outside)
1196
+ await writeFile(path.join(outside, 'observatory.css'), themeCss)
1197
+ await symlink(path.join(outside, 'observatory.css'), path.join(root, 'linked.css'))
1198
+ await expect(addUi(['./linked.css'], { project: root })).rejects.toMatchObject({
1199
+ code: 'UI_ITEM_NOT_FOUND',
1200
+ })
1201
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
1202
+ })
1203
+
1204
+ test('rejects a symlinked UI lock before a theme can mutate outside the project', async () => {
1205
+ const root = await lockedFixture()
1206
+ const source = path.join(root, 'observatory.css')
1207
+ await writeFile(source, themeCss)
1208
+ const lockPath = path.join(root, 'astrale-ui.lock.json')
1209
+ const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-lock-'))
1210
+ temporary.push(outside)
1211
+ const outsideLock = path.join(outside, 'astrale-ui.lock.json')
1212
+ const lockSource = await readFile(lockPath, 'utf8')
1213
+ await writeFile(outsideLock, lockSource)
1214
+ await rm(lockPath)
1215
+ await symlink(outsideLock, lockPath)
1216
+ const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
1217
+
1218
+ await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
1219
+ code: 'UI_LOCK_INVALID',
1220
+ })
1221
+ expect(await readFile(outsideLock, 'utf8')).toBe(lockSource)
1222
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
1223
+ expect(
1224
+ await Bun.file(path.join(root, 'components/astrale/theme/observatory.css')).exists(),
1225
+ ).toBe(false)
1226
+ })
1227
+
1228
+ test('rolls back a newly copied local theme and activation when the lock commit fails', async () => {
1229
+ const root = await lockedFixture()
1230
+ await writeFile(path.join(root, 'observatory.css'), themeCss)
1231
+ const lockPath = path.join(root, 'astrale-ui.lock.json')
1232
+ const cssPath = path.join(root, 'src/index.css')
1233
+ const target = path.join(root, 'components/astrale/theme/observatory.css')
1234
+ const lockBefore = await readFile(lockPath, 'utf8')
1235
+ const cssBefore = await readFile(cssPath, 'utf8')
1236
+ await chmod(lockPath, 0o444)
1237
+
1238
+ try {
1239
+ await expect(addUi(['./observatory.css'], { project: root })).rejects.toBeInstanceOf(Error)
1240
+ } finally {
1241
+ await chmod(lockPath, 0o644)
1242
+ }
1243
+
1244
+ expect(await Bun.file(target).exists()).toBe(false)
1245
+ expect(await readFile(cssPath, 'utf8')).toBe(cssBefore)
1246
+ expect(await readFile(lockPath, 'utf8')).toBe(lockBefore)
1247
+ })
1248
+
1249
+ test('successful add records installed file digests and doctor detects later edits', async () => {
1250
+ const root = await lockedFixture()
1251
+ await writeFile(
1252
+ path.join(root, 'components.json'),
1253
+ JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
1254
+ )
1255
+ await writeFile(
1256
+ path.join(root, 'package.json'),
1257
+ JSON.stringify({
1258
+ name: 'fixture',
1259
+ dependencies: {
1260
+ react: '19.2.8',
1261
+ 'react-dom': '19.2.8',
1262
+ tailwindcss: '4.3.3',
1263
+ '@astrale-os/ui': '0.3.0-beta.0',
1264
+ },
1265
+ }),
1266
+ )
1267
+ await writeFile(
1268
+ path.join(root, 'src/index.css'),
1269
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
1270
+ )
1271
+ const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
1272
+ await addUi(
1273
+ ['pattern/chart/line/basic'],
1274
+ { project: root, yes: true },
1275
+ {
1276
+ fetcher: mockFetch(),
1277
+ runner: async () => {
1278
+ await mkdir(path.dirname(installed), { recursive: true })
1279
+ await writeFile(installed, 'export const Chart = true\n')
1280
+ return { code: 0, stdout: '', stderr: '' }
1281
+ },
1282
+ },
1283
+ )
1284
+ const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
1285
+ expect(written.items['pattern/chart/line/basic'].files).toEqual({
1286
+ 'components/astrale/pattern/chart/line-basic.tsx': digest('export const Chart = true\n'),
1287
+ })
1288
+ expect(written.items['pattern/chart/line/basic'].sourceDigest).toBe(
1289
+ digest(JSON.stringify(builtItem(registry.items[0]!))),
1290
+ )
1291
+ expect((await doctorUi(root)).healthy).toBe(true)
1292
+ await writeFile(installed, 'consumer edit\n')
1293
+ expect((await doctorUi(root)).healthy).toBe(false)
1294
+ await expect(
1295
+ addUi(
1296
+ ['pattern/chart/line/basic'],
1297
+ { project: root },
1298
+ { fetcher: mockFetch(), runner: async () => ({ code: 0, stdout: '', stderr: '' }) },
1299
+ ),
1300
+ ).rejects.toBeInstanceOf(UiError)
1301
+ })
1302
+
1303
+ test('records multi-file component targets resolved through the consumer components alias', async () => {
1304
+ const root = await lockedFixture()
1305
+ await writeFile(
1306
+ path.join(root, 'components.json'),
1307
+ JSON.stringify({
1308
+ style: 'base-nova',
1309
+ tailwind: { css: 'src/index.css' },
1310
+ aliases: { components: '@/components' },
1311
+ }),
1312
+ )
1313
+ await writeFile(
1314
+ path.join(root, 'tsconfig.json'),
1315
+ JSON.stringify({
1316
+ compilerOptions: {
1317
+ paths: { '@/*': ['./src/*'], '@/components/*': ['./app/ui/*'] },
1318
+ },
1319
+ }),
1320
+ )
1321
+ await writeFile(
1322
+ path.join(root, 'src/index.css'),
1323
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
1324
+ )
1325
+ const sidebar = path.join(root, 'src/components/astrale/component/sidebar/sidebar.tsx')
1326
+ const hook = path.join(root, 'src/components/astrale/component/sidebar/use-mobile.ts')
1327
+
1328
+ const planned = await addUi(
1329
+ ['component/sidebar'],
1330
+ { project: root, dryRun: true, yes: true },
1331
+ {
1332
+ fetcher: mockFetch([], componentRegistry),
1333
+ runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
1334
+ },
1335
+ )
1336
+ expect(planned.sources).toEqual([
1337
+ expect.objectContaining({
1338
+ files: [
1339
+ 'src/components/astrale/component/sidebar/sidebar.tsx',
1340
+ 'src/components/astrale/component/sidebar/use-mobile.ts',
1341
+ ],
1342
+ }),
1343
+ ])
1344
+
1345
+ await addUi(
1346
+ ['component/sidebar'],
1347
+ { project: root, yes: true },
1348
+ {
1349
+ fetcher: mockFetch([], componentRegistry),
1350
+ runner: async () => {
1351
+ await mkdir(path.dirname(sidebar), { recursive: true })
1352
+ await writeFile(sidebar, 'export const Sidebar = true\n')
1353
+ await writeFile(hook, 'export const useMobile = true\n')
1354
+ return { code: 0, stdout: '', stderr: '' }
1355
+ },
1356
+ },
1357
+ )
1358
+
1359
+ const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
1360
+ expect(written.items['component/sidebar'].files).toEqual({
1361
+ 'src/components/astrale/component/sidebar/sidebar.tsx': digest(
1362
+ 'export const Sidebar = true\n',
1363
+ ),
1364
+ 'src/components/astrale/component/sidebar/use-mobile.ts': digest(
1365
+ 'export const useMobile = true\n',
1366
+ ),
1367
+ })
1368
+ expect((await doctorUi(root)).healthy).toBe(true)
1369
+ })
1370
+
1371
+ test('resolves a components alias through package imports before tsconfig paths', async () => {
1372
+ const root = await lockedFixture()
1373
+ const manifestPath = path.join(root, 'package.json')
1374
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
1375
+ manifest.imports = { '#app/*': './src/app/*' }
1376
+ await writeFile(manifestPath, JSON.stringify(manifest))
1377
+ await writeFile(
1378
+ path.join(root, 'components.json'),
1379
+ JSON.stringify({
1380
+ style: 'base-nova',
1381
+ tailwind: { css: 'src/index.css' },
1382
+ aliases: { components: '#app/components' },
1383
+ }),
1384
+ )
1385
+
1386
+ const planned = await addUi(
1387
+ ['pattern/chart/line/basic'],
1388
+ { project: root, dryRun: true, yes: true },
1389
+ {
1390
+ fetcher: mockFetch(),
1391
+ runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
1392
+ },
1393
+ )
1394
+
1395
+ expect(planned.sources).toEqual([
1396
+ expect.objectContaining({
1397
+ files: ['src/app/components/astrale/pattern/chart/line-basic.tsx'],
1398
+ }),
1399
+ ])
1400
+ })
1401
+
1402
+ test('rejects an unresolved package-import alias before invoking shadcn', async () => {
1403
+ const root = await lockedFixture()
1404
+ await writeFile(
1405
+ path.join(root, 'components.json'),
1406
+ JSON.stringify({
1407
+ style: 'base-nova',
1408
+ tailwind: { css: 'src/index.css' },
1409
+ aliases: { components: '#missing/components' },
1410
+ }),
1411
+ )
1412
+ let invoked = false
1413
+
1414
+ await expect(
1415
+ addUi(
1416
+ ['pattern/chart/line/basic'],
1417
+ { project: root, yes: true },
1418
+ {
1419
+ fetcher: mockFetch(),
1420
+ runner: async () => {
1421
+ invoked = true
1422
+ return { code: 0, stdout: '', stderr: '' }
1423
+ },
1424
+ },
1425
+ ),
1426
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
1427
+ expect(invoked).toBe(false)
1428
+ })
1429
+
1430
+ test('resolves an extended JSONC baseUrl with the pinned shadcn config loader', async () => {
1431
+ const root = await lockedFixture()
1432
+ await writeFile(
1433
+ path.join(root, 'components.json'),
1434
+ JSON.stringify({
1435
+ style: 'base-nova',
1436
+ tailwind: { css: 'src/index.css' },
1437
+ aliases: { components: '@/components' },
1438
+ }),
1439
+ )
1440
+ await mkdir(path.join(root, 'config'), { recursive: true })
1441
+ await writeFile(
1442
+ path.join(root, 'tsconfig.json'),
1443
+ '{\n // shadcn loads this root config.\n "extends": "./config/base.json",\n}\n',
1444
+ )
1445
+ await writeFile(
1446
+ path.join(root, 'config/base.json'),
1447
+ '{\n "compilerOptions": {\n "baseUrl": "..",\n "paths": { "@/*": ["frontend/src/*"], },\n },\n}\n',
1448
+ )
1449
+
1450
+ const planned = await addUi(
1451
+ ['pattern/chart/line/basic'],
1452
+ { project: root, dryRun: true, yes: true },
1453
+ {
1454
+ fetcher: mockFetch(),
1455
+ runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
1456
+ },
1457
+ )
1458
+
1459
+ expect(planned.sources).toEqual([
1460
+ expect.objectContaining({
1461
+ files: ['frontend/src/components/astrale/pattern/chart/line-basic.tsx'],
1462
+ }),
1463
+ ])
1464
+ })
1465
+
1466
+ test('rejects an alias mapping outside the project before invoking shadcn', async () => {
1467
+ const root = await lockedFixture()
1468
+ await writeFile(
1469
+ path.join(root, 'components.json'),
1470
+ JSON.stringify({
1471
+ style: 'base-nova',
1472
+ tailwind: { css: 'src/index.css' },
1473
+ aliases: { components: '@/components' },
1474
+ }),
1475
+ )
1476
+ await writeFile(
1477
+ path.join(root, 'tsconfig.json'),
1478
+ JSON.stringify({ compilerOptions: { paths: { '@/*': ['../outside/*'] } } }),
1479
+ )
1480
+ let invoked = false
1481
+
1482
+ await expect(
1483
+ addUi(
1484
+ ['pattern/chart/line/basic'],
1485
+ { project: root, yes: true },
1486
+ {
1487
+ fetcher: mockFetch(),
1488
+ runner: async () => {
1489
+ invoked = true
1490
+ return { code: 0, stdout: '', stderr: '' }
1491
+ },
1492
+ },
1493
+ ),
1494
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
1495
+ expect(invoked).toBe(false)
1496
+ })
1497
+
1498
+ test('restores the exact locked UI dependency after shadcn applies its compatible range', async () => {
1499
+ const root = await lockedFixture()
1500
+ const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
1501
+ const calls: Array<{ file: string; args: string[] }> = []
1502
+
1503
+ await addUi(
1504
+ ['pattern/chart/line/basic'],
1505
+ { project: root, yes: true },
1506
+ {
1507
+ fetcher: mockFetch(),
1508
+ runner: async (file, args) => {
1509
+ calls.push({ file, args })
1510
+ if (args[0] === 'dlx') {
1511
+ await mkdir(path.dirname(installed), { recursive: true })
1512
+ await writeFile(installed, 'export const Chart = true\n')
1513
+ const manifestPath = path.join(root, 'package.json')
1514
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
1515
+ manifest.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
1516
+ await writeFile(manifestPath, JSON.stringify(manifest))
1517
+ }
1518
+ return { code: 0, stdout: '', stderr: '' }
1519
+ },
1520
+ },
1521
+ )
1522
+
1523
+ expect(calls).toEqual([
1524
+ expect.objectContaining({ file: 'pnpm', args: expect.arrayContaining(['dlx']) }),
1525
+ { file: 'pnpm', args: ['install'] },
1526
+ ])
1527
+ expect(
1528
+ JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies[
1529
+ '@astrale-os/ui'
1530
+ ],
1531
+ ).toBe('0.3.0-beta.0')
1532
+ })
1533
+
1534
+ test('restores a development-only UI dependency without retaining shadcn duplication', async () => {
1535
+ const root = await lockedFixture()
1536
+ const manifestPath = path.join(root, 'package.json')
1537
+ const initial = JSON.parse(await readFile(manifestPath, 'utf8'))
1538
+ delete initial.dependencies['@astrale-os/ui']
1539
+ initial.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
1540
+ await writeFile(manifestPath, JSON.stringify(initial))
1541
+ const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
1542
+
1543
+ await addUi(
1544
+ ['pattern/chart/line/basic'],
1545
+ { project: root, yes: true },
1546
+ {
1547
+ fetcher: mockFetch(),
1548
+ runner: async (_file, args) => {
1549
+ if (args[0] === 'dlx') {
1550
+ await mkdir(path.dirname(installed), { recursive: true })
1551
+ await writeFile(installed, 'export const Chart = true\n')
1552
+ const changed = JSON.parse(await readFile(manifestPath, 'utf8'))
1553
+ changed.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
1554
+ await writeFile(manifestPath, JSON.stringify(changed))
1555
+ }
1556
+ return { code: 0, stdout: '', stderr: '' }
1557
+ },
1558
+ },
1559
+ )
1560
+
1561
+ const written = JSON.parse(await readFile(manifestPath, 'utf8'))
1562
+ expect(written.dependencies['@astrale-os/ui']).toBeUndefined()
1563
+ expect(written.devDependencies['@astrale-os/ui']).toBe('0.3.0-beta.0')
1564
+ })
1565
+
1566
+ test('rolls back item and package state when restoring the locked dependency fails', async () => {
1567
+ const root = await lockedFixture()
1568
+ const manifestPath = path.join(root, 'package.json')
1569
+ const lockPath = path.join(root, 'astrale-ui.lock.json')
1570
+ const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
1571
+ const manifestBefore = await readFile(manifestPath, 'utf8')
1572
+ const lockBefore = await readFile(lockPath, 'utf8')
1573
+
1574
+ await expect(
1575
+ addUi(
1576
+ ['pattern/chart/line/basic'],
1577
+ { project: root, yes: true },
1578
+ {
1579
+ fetcher: mockFetch(),
1580
+ runner: async (_file, args) => {
1581
+ if (args[0] === 'dlx') {
1582
+ await mkdir(path.dirname(installed), { recursive: true })
1583
+ await writeFile(installed, 'export const Chart = true\n')
1584
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
1585
+ manifest.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
1586
+ await writeFile(manifestPath, JSON.stringify(manifest))
1587
+ return { code: 0, stdout: '', stderr: '' }
1588
+ }
1589
+ await writeFile(path.join(root, 'pnpm-lock.yaml'), 'partial lock\n')
1590
+ return { code: 1, stdout: '', stderr: 'registry timeout' }
1591
+ },
1592
+ },
1593
+ ),
1594
+ ).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
1595
+
1596
+ expect(await readFile(manifestPath, 'utf8')).toBe(manifestBefore)
1597
+ expect(await readFile(lockPath, 'utf8')).toBe(lockBefore)
1598
+ expect(await Bun.file(installed).exists()).toBe(false)
1599
+ expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
1600
+ })
1601
+
1602
+ test('preflights symlink targets without invoking shadcn', async () => {
1603
+ const root = await lockedFixture()
1604
+ const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-outside-'))
1605
+ temporary.push(outside)
1606
+ await symlink(outside, path.join(root, 'components'))
1607
+ let invoked = false
1608
+ await expect(
1609
+ addUi(
1610
+ ['pattern/chart/line/basic'],
1611
+ { project: root },
1612
+ {
1613
+ fetcher: mockFetch(),
1614
+ runner: async () => {
1615
+ invoked = true
1616
+ return { code: 0, stdout: '', stderr: '' }
1617
+ },
1618
+ },
1619
+ ),
1620
+ ).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
1621
+ expect(invoked).toBe(false)
1622
+ })
1623
+
1624
+ test('restores alias-resolved files and package state after a partial shadcn failure', async () => {
1625
+ const root = await lockedFixture()
1626
+ await writeFile(
1627
+ path.join(root, 'components.json'),
1628
+ JSON.stringify({
1629
+ style: 'base-nova',
1630
+ tailwind: { css: 'src/index.css' },
1631
+ aliases: { components: '@/components' },
1632
+ }),
1633
+ )
1634
+ await writeFile(
1635
+ path.join(root, 'tsconfig.json'),
1636
+ JSON.stringify({ compilerOptions: { paths: { '@/*': ['./src/*'] } } }),
1637
+ )
1638
+ const first = path.join(root, 'src/components/astrale/pattern/chart/line-basic.tsx')
1639
+ const second = path.join(root, 'src/components/astrale/pattern/chart/summary.tsx')
1640
+ await mkdir(path.dirname(first), { recursive: true })
1641
+ await writeFile(first, 'consumer original\n')
1642
+ const twoFileRegistry: UiRegistry = {
1643
+ ...registry,
1644
+ items: [
1645
+ {
1646
+ ...registry.items[0]!,
1647
+ files: [
1648
+ registry.items[0]!.files[0]!,
1649
+ {
1650
+ path: 'summary.tsx',
1651
+ type: 'registry:component',
1652
+ target: 'components/astrale/pattern/chart/summary.tsx',
1653
+ },
1654
+ ],
1655
+ },
1656
+ ],
1657
+ }
1658
+ const lockBefore = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
1659
+ await expect(
1660
+ addUi(
1661
+ ['pattern/chart/line/basic'],
1662
+ { project: root },
1663
+ {
1664
+ fetcher: mockFetch([], twoFileRegistry),
1665
+ runner: async () => {
1666
+ await writeFile(first, 'partial overwrite\n')
1667
+ await writeFile(second, 'partial create\n')
1668
+ return { code: 1, stdout: '', stderr: 'interrupted' }
1669
+ },
1670
+ },
1671
+ ),
1672
+ ).rejects.toMatchObject({ code: 'UI_TOOL_FAILED' })
1673
+ expect(await readFile(first, 'utf8')).toBe('consumer original\n')
1674
+ expect(await Bun.file(second).exists()).toBe(false)
1675
+ expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(lockBefore)
1676
+ })
1677
+
1678
+ test('overwrite requires explicit yes confirmation before invoking shadcn', async () => {
1679
+ const root = await lockedFixture()
1680
+ let invoked = false
1681
+ await expect(
1682
+ addUi(
1683
+ ['pattern/chart/line/basic'],
1684
+ { project: root, overwrite: true },
1685
+ {
1686
+ fetcher: mockFetch(),
1687
+ runner: async () => {
1688
+ invoked = true
1689
+ return { code: 0, stdout: '', stderr: '' }
1690
+ },
1691
+ },
1692
+ ),
1693
+ ).rejects.toMatchObject({ code: 'UI_LOCAL_CHANGES' })
1694
+ expect(invoked).toBe(false)
1695
+ })
1696
+
1697
+ /** @evidence TEST-CLI-UI-EXACT-ITEM-SOURCE */
1698
+ test('rejects a built item that differs from the admitted release index before invoking shadcn', async () => {
1699
+ const root = await lockedFixture()
1700
+ const fallback = mockFetch()
1701
+ let invoked = false
1702
+ const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
1703
+ const url = String(input)
1704
+ if (url.endsWith('/registry/public/r/pattern-chart-line-basic.json')) {
1705
+ return Response.json(registry.items[0])
1706
+ }
1707
+ return fallback(input, init)
1708
+ }) as typeof fetch
1709
+ await expect(
1710
+ addUi(
1711
+ ['pattern/chart/line/basic'],
1712
+ { project: root },
1713
+ {
1714
+ fetcher: malformed,
1715
+ runner: async () => {
1716
+ invoked = true
1717
+ return { code: 0, stdout: '', stderr: '' }
1718
+ },
1719
+ },
1720
+ ),
1721
+ ).rejects.toMatchObject({ code: 'UI_REGISTRY_UNAVAILABLE' })
1722
+ expect(invoked).toBe(false)
1723
+ })
1724
+
1725
+ test('preset dry-run is read-only and apply changes CSS plus lock without source rewrites', async () => {
1726
+ const root = await fixture()
1727
+ await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
1728
+ await writeFile(
1729
+ path.join(root, 'src/index.css'),
1730
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
1731
+ )
1732
+ const before = await readFile(path.join(root, 'src/index.css'), 'utf8')
1733
+ await applyPreset('expressive', { project: root, dryRun: true })
1734
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(before)
1735
+ await applyPreset('expressive', { project: root })
1736
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
1737
+ '@astrale-os/ui/presets/expressive.css',
1738
+ )
1739
+ const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
1740
+ expect(written.preset).toBe('expressive')
1741
+ })
1742
+ })