@thorprovider/storefront 0.3.1 → 0.4.0-mvp.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 (263) hide show
  1. package/README.md +321 -152
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +17 -7
  9. package/assets/templates/app/app/robots.ts +19 -0
  10. package/assets/templates/app/app/sitemap.ts +71 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
  14. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  18. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  20. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
  21. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
  22. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  24. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  27. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
  30. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  31. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
  32. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  34. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  39. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
  40. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  41. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  42. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  43. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  44. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  45. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  46. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  47. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  48. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  49. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  50. package/assets/templates/features/checkout/handlers.ts +159 -0
  51. package/assets/templates/features/checkout/state.ts +61 -0
  52. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  53. package/assets/templates/features/customer-auth/state.ts +45 -0
  54. package/assets/templates/features/recommendations/state.ts +11 -0
  55. package/assets/templates/views/account/content.ts +35 -0
  56. package/assets/templates/views/account/index.tsx +114 -0
  57. package/assets/templates/views/account/registry.tsx +439 -0
  58. package/assets/templates/views/account/schema.ts +53 -0
  59. package/assets/templates/views/cart/content.ts +11 -0
  60. package/assets/templates/views/cart/data.ts +23 -0
  61. package/assets/templates/views/cart/index.tsx +53 -0
  62. package/assets/templates/views/cart/props.ts +12 -0
  63. package/assets/templates/views/cart/registry.tsx +81 -0
  64. package/assets/templates/views/cart/schema.ts +52 -0
  65. package/assets/templates/views/category/assembly/assembly.json +29 -0
  66. package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
  67. package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
  68. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  69. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  70. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  71. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  72. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  73. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  74. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  75. package/assets/templates/views/category/content.ts +30 -0
  76. package/assets/templates/views/category/data.ts +12 -0
  77. package/assets/templates/views/category/index.tsx +299 -0
  78. package/assets/templates/views/category/props.ts +23 -0
  79. package/assets/templates/views/category/registry.tsx +122 -0
  80. package/assets/templates/views/category/schema.ts +91 -0
  81. package/assets/templates/views/checkout/content.ts +105 -0
  82. package/assets/templates/views/checkout/data.ts +23 -0
  83. package/assets/templates/views/checkout/index.tsx +111 -0
  84. package/assets/templates/views/checkout/props.ts +49 -0
  85. package/assets/templates/views/checkout/registry.tsx +217 -0
  86. package/assets/templates/views/checkout/schema.ts +229 -0
  87. package/assets/templates/views/home/assembly/assembly.json +33 -0
  88. package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
  89. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  90. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  91. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  92. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  93. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  94. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  95. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  96. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  97. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  98. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  99. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  100. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  101. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  102. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  103. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  104. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  105. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  106. package/assets/templates/views/home/content.ts +70 -0
  107. package/assets/templates/views/home/data.ts +24 -0
  108. package/assets/templates/views/home/index.tsx +57 -85
  109. package/assets/templates/views/home/props.ts +22 -0
  110. package/assets/templates/views/home/registry.tsx +38 -22
  111. package/assets/templates/views/home/schema.ts +93 -0
  112. package/assets/templates/views/product/content.ts +25 -0
  113. package/assets/templates/views/product/data.ts +50 -0
  114. package/assets/templates/views/product/index.tsx +148 -0
  115. package/assets/templates/views/product/props.ts +27 -0
  116. package/assets/templates/views/product/registry.tsx +103 -0
  117. package/assets/templates/views/product/schema.ts +121 -0
  118. package/assets/templates/views/static/content.ts +99 -0
  119. package/assets/templates/views/static/index.tsx +60 -0
  120. package/assets/templates/views/static/registry.tsx +66 -0
  121. package/assets/templates/views/static/schema.ts +45 -0
  122. package/bin/storefront.js +2424 -148
  123. package/examples/merchandising/category-led.json +37 -0
  124. package/examples/merchandising/collection-led.json +37 -0
  125. package/package.json +15 -9
  126. package/recipes/archetypes.json +28 -14
  127. package/recipes/catalog.json +400 -0
  128. package/recipes/catalog.mjs +128 -0
  129. package/recipes/catalog.schema.json +64 -0
  130. package/recipes/feature.schema.json +89 -0
  131. package/recipes/features.json +162 -0
  132. package/recipes/manifest.schema.json +69 -0
  133. package/recipes/operations.schema.json +43 -0
  134. package/recipes/recipe.schema.json +11 -2
  135. package/recipes/recipes.json +76 -19
  136. package/recipes/sections.json +34 -5
  137. package/recipes/validate.mjs +838 -32
  138. package/scripts/candidate.mjs +236 -0
  139. package/scripts/composers.mjs +160 -0
  140. package/scripts/env-file.mjs +86 -0
  141. package/scripts/file-classes.mjs +121 -0
  142. package/scripts/logical-controls.mjs +61 -0
  143. package/scripts/manifest.mjs +298 -0
  144. package/scripts/migrate-english-routes.mjs +276 -0
  145. package/scripts/operation-registry.mjs +256 -0
  146. package/scripts/operations.mjs +720 -0
  147. package/scripts/ownership-conflict.mjs +356 -0
  148. package/scripts/pinning.mjs +573 -0
  149. package/scripts/plan-apply.mjs +987 -0
  150. package/scripts/preflight.mjs +51 -0
  151. package/scripts/reconcile.mjs +549 -0
  152. package/scripts/render-templates.mjs +338 -68
  153. package/scripts/schema-validate.mjs +132 -0
  154. package/scripts/section-compat.mjs +300 -0
  155. package/scripts/section-config.mjs +253 -0
  156. package/scripts/section-operations.mjs +508 -0
  157. package/scripts/starter.mjs +17 -0
  158. package/scripts/sync-assets.mjs +22 -15
  159. package/scripts/theme/color.mjs +185 -0
  160. package/scripts/theme/css-theme.mjs +233 -0
  161. package/scripts/theme/fonts.mjs +123 -0
  162. package/scripts/theme/import-export.mjs +125 -0
  163. package/scripts/theme/preset.mjs +93 -0
  164. package/scripts/theme/theme-plan.mjs +292 -0
  165. package/scripts/theme/tokens.mjs +137 -0
  166. package/scripts/view-assembly.mjs +337 -0
  167. package/scripts/view-selection.mjs +211 -0
  168. package/skills/sf-feature/SKILL.md +80 -0
  169. package/skills/sf-setup/SKILL.md +79 -0
  170. package/skills/sf-theme-gen/SKILL.md +100 -30
  171. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  172. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  173. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  174. package/skills/sf-view-gen/SKILL.md +146 -93
  175. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
  176. package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
  177. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  178. package/skills/sf-view-gen/assets/recipes/features.json +162 -0
  179. package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
  180. package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
  181. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  182. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  183. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  184. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  185. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  186. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  187. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  188. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  189. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  190. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  191. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  192. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  193. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  194. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  195. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  196. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  197. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  198. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  199. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  200. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  201. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  202. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  203. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  204. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  205. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  206. package/skills/sf-view-gen/references/views.md +58 -33
  207. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  208. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  209. package/skills/sf-view-gen/scripts/validate.mjs +838 -32
  210. package/skills/storefront-workflow/SKILL.md +179 -0
  211. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  212. package/CHANGELOG.md +0 -61
  213. package/commands/sf-add-view.md +0 -26
  214. package/commands/sf-theme.md +0 -20
  215. package/commands/sf-view.md +0 -25
  216. package/skills/building-storefronts/SKILL.md +0 -178
  217. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  218. package/skills/json-render-remotion/SKILL.md +0 -111
  219. package/skills/json-render-solid/SKILL.md +0 -204
  220. package/skills/nextjs-shadcn/SKILL.md +0 -367
  221. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  222. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  223. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  224. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  225. package/skills/nextjs-shadcn/references/styling.md +0 -555
  226. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  227. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  228. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  229. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  230. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  231. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
  232. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
  233. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
  234. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  235. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  236. package/skills/shadcn-theming/SKILL.md +0 -104
  237. package/skills/storefront-best-practices/SKILL.md +0 -421
  238. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  239. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  240. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  241. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  242. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  243. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  244. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  245. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  246. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  247. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  248. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  249. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  250. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  251. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  252. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  253. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  254. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  255. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  256. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  257. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  258. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  259. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  260. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  261. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  262. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  263. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
package/bin/storefront.js CHANGED
@@ -4,38 +4,132 @@
4
4
  //
5
5
  // Actions:
6
6
  // create Scaffold a complete, runnable storefront AND install the command suite
7
+ // setup Connect (or re-point) a real Medusa backend for an existing storefront
7
8
  // update Refresh an existing install (prunes removed files, migrates the record)
8
9
  // doctor Report the recorded version and whether the installed skills match this package
9
10
  //
10
- // On a TTY, `create` prompts for missing inputs (name, site name, backend URL, preset —
11
- // Maia is preselected). Without a TTY, every required input must be a flag; the command
12
- // fails fast and writes nothing rather than guessing. Brand color is never prompted.
11
+ // On a TTY, `create` prompts for missing inputs (name, site name, preset —
12
+ // Maia is preselected). Empty prompts fall back to their suggested default, so pressing
13
+ // Enter accepts it: the name is suggested from the target directory basename and the
14
+ // site name from the Title Case of that name. Without a TTY, every required input must
15
+ // be a flag; the command fails fast and writes nothing rather than guessing. Brand
16
+ // color is never prompted.
13
17
  //
14
18
  // The package's generated copies are verified against their source before anything is
15
19
  // copied into a target project. On divergence the command stops and writes nothing.
16
20
 
17
- import { cp, mkdir, readdir, readFile, writeFile, rm } from 'node:fs/promises'
21
+ import { cp, mkdir, readdir, writeFile, rm } from 'node:fs/promises'
18
22
  import { existsSync, readFileSync } from 'node:fs'
19
- import { createHash } from 'node:crypto'
20
23
  import { spawnSync } from 'node:child_process'
21
- import { dirname, join, relative, resolve } from 'node:path'
24
+ import { createRequire } from 'node:module'
25
+ import { tmpdir } from 'node:os'
26
+ import { basename, dirname, join, relative, resolve } from 'node:path'
22
27
  import { fileURLToPath } from 'node:url'
23
28
  import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts'
24
29
  import { checkAssets } from '../scripts/sync-assets.mjs'
25
- import { renderScaffold, toKebab } from '../scripts/render-templates.mjs'
30
+ import { writeInitialEnv, upsertEnv, readEnvFile } from '../scripts/env-file.mjs'
31
+ import { renderScaffold, toKebab, toTitle, injectThemeMode } from '../scripts/render-templates.mjs'
32
+ import { normalizeOperation, planCss, parseSetter } from '../scripts/theme/theme-plan.mjs'
33
+ import { readThemeCss, applyBlockDeclarations } from '../scripts/theme/css-theme.mjs'
34
+ import { applyLayoutFonts, fontCssVars } from '../scripts/theme/fonts.mjs'
35
+ import {
36
+ buildThemeExport,
37
+ parseRegistryTheme,
38
+ parseCssExport,
39
+ parseThemeExport,
40
+ classifyImportSource,
41
+ } from '../scripts/theme/import-export.mjs'
42
+ import { resolvePreset, makeShadcnRunner } from '../scripts/theme/preset.mjs'
43
+ import { THEME_COLOR_TOKENS, FONT_VARS, DEFAULT_GLOBALS_TOKENS } from '../scripts/theme/tokens.mjs'
44
+ import { NAMED_COLOR_NAMES, parseColor, formatOklch } from '../scripts/theme/color.mjs'
45
+ import {
46
+ findProjectRoot,
47
+ hasManifest,
48
+ looksLikeStorefront,
49
+ readManifest,
50
+ validateManifest,
51
+ createManifest,
52
+ writeManifest,
53
+ } from '../scripts/manifest.mjs'
54
+ import { reconcile } from '../scripts/reconcile.mjs'
55
+ import { preflightManifest } from '../scripts/preflight.mjs'
56
+ import { scaffoldComposition } from '../scripts/starter.mjs'
57
+ import {
58
+ buildInstallPlan,
59
+ CANDIDATE_TOOLSET,
60
+ findPack,
61
+ parseRuntimeCommerceImports,
62
+ verifyPackExports,
63
+ verifyPackManifest,
64
+ } from '../scripts/candidate.mjs'
65
+ import {
66
+ declaredOperativeSkills,
67
+ hashManagedSuite,
68
+ hashPaths,
69
+ managedSuitePaths,
70
+ readDirectDependencies,
71
+ readResolvedVersions,
72
+ readLockfile,
73
+ readToolchainEvidence,
74
+ recordedOwnedSet,
75
+ retirementCandidates,
76
+ buildPinningRecord,
77
+ validateDeclaredContract,
78
+ validatePinning,
79
+ } from '../scripts/pinning.mjs'
80
+ import {
81
+ loadRecipes,
82
+ loadArchetypes,
83
+ loadFeatures,
84
+ isCuratedView,
85
+ featureSurfaces,
86
+ allowedVariantsForView,
87
+ supportedVariantsForView,
88
+ supportedIntents,
89
+ viewCatalog,
90
+ sectionRegistry,
91
+ sectionIsDataBound,
92
+ } from '../recipes/validate.mjs'
93
+ import { CATALOG_VERSION, loadCatalog } from '../recipes/catalog.mjs'
94
+ import { OPERATION_NAMES } from '../scripts/operations.mjs'
95
+ import {
96
+ applyProject,
97
+ executeOperations,
98
+ isLegacyViewId,
99
+ planProject,
100
+ removeLegacyView,
101
+ resultEnvelope,
102
+ } from '../scripts/plan-apply.mjs'
103
+ import { SECTION_OPERATION_REGISTRY } from '../scripts/section-operations.mjs'
104
+ import { auditSectionConfig, collectDataBoundInstances } from '../scripts/section-config.mjs'
105
+ import { normalizeViewSectionsForView } from '../scripts/section-compat.mjs'
26
106
 
27
107
  const __dirname = dirname(fileURLToPath(import.meta.url))
28
108
  const PKG_ROOT = resolve(__dirname, '..')
29
109
 
30
110
  const VERSION_RECORD_FILE = 'storefront.json'
31
111
  const LEGACY_RECORD_FILE = '.create-storefront.json'
32
- const COPY_DIRS = ['commands', 'skills']
33
- // Files that shipped in earlier versions and are no longer part of the suite.
34
- // `update` prunes them so an upgraded project does not keep running the
35
- // eliminated /sf-init scaffold wizard.
36
- const PRUNED_PATHS = ['commands/sf-init.md', 'skills/sf-scaffold']
112
+ // The OpenSpec tool suite owns its own namespace inside the project: it
113
+ // provisions `openspec/`, the `opsx-*` commands, and the `openspec-*` skills.
114
+ // Those files are never storefront-owned, so the suite must not count them as
115
+ // storefront skills, report them as obsolete, or prune them.
116
+ const OPENSPEC_SKILL_PREFIX = 'openspec-'
117
+ // Paths that shipped in earlier versions and are no longer part of the suite.
118
+ // `update` prunes exactly these so an upgraded project does not keep running the
119
+ // eliminated /sf-* commands or the /sf-init scaffold wizard. The whole
120
+ // `commands/` directory is NOT pruned: another tool (OpenSpec) provisions its
121
+ // own commands there, so legacy /sf-* command files are pruned by name and every
122
+ // other command file is preserved. This fixed list is RETAINED: `commands/` is
123
+ // outside the managed suite directories, so the generalized BOM-difference
124
+ // retirement cannot derive it. `skills/sf-scaffold` is additionally reachable
125
+ // through that generalized path (it sits under `skills/`), so the two overlap
126
+ // where ownership is recorded.
127
+ const LEGACY_SKILL_PATHS = ['skills/sf-scaffold']
128
+ const LEGACY_COMMAND_FILE = /^sf-.*\.md$/
37
129
 
38
130
  const PRESETS = ['maia', 'nova', 'vega', 'luma', 'lyra', 'mira']
131
+ const THEME_MODES = ['light', 'dark', 'system']
132
+ const DEFAULT_THEME_MODE = 'system'
39
133
  const PRESET_LABELS = {
40
134
  maia: 'Rounded and spacious. Warm and approachable — fits a store.',
41
135
  nova: 'Compact and balanced. The neutral default.',
@@ -50,18 +144,98 @@ const HELP = `@thorprovider/storefront — scaffold Thor Commerce storefronts
50
144
 
51
145
  Usage:
52
146
  storefront create <dir> [options] Scaffold a storefront and install the suite
53
- storefront <update|doctor> <dir> Manage an existing install
147
+ storefront setup [options] Connect a real Medusa backend (re-runnable)
148
+ storefront theme [options] Customize the theme (mode, colors, fonts, presets)
149
+ storefront sync Reconcile derived files to the manifest
150
+ storefront views [--json] List the view registry (recipes, intents, installed)
151
+ storefront features [--json] List the feature registry (bundles, installed)
152
+ storefront recipes [--json] Print the whole curated registry (views + features)
153
+ storefront view add|remove <name> Add or remove a view (reconciles derived files)
154
+ storefront feature add|remove <name> Add or remove a feature (installs its views)
155
+ storefront section add|remove|move <view> ... Add, remove, or move a section instance
156
+ storefront sections [<view>] [--json] List installed views' section instances (read-only)
157
+ storefront inspect [--json] Report the versioned catalog and composition (read-only)
158
+ storefront plan --operations <file> [--out <file>] [--json]
159
+ Validate a whole intended state and emit a plan
160
+ storefront apply --plan <file> [--json] Apply a recorded plan from its before-state
161
+ storefront <update|doctor> Manage an existing install
162
+
163
+ Every command except create runs inside the project: it resolves the storefront
164
+ from the current directory upward (storefront.config.json) and errors outside one.
54
165
 
55
166
  create options:
56
167
  --name <name> storefront name = lib/<name>/ (required without a terminal)
57
- --site-name <name> human site name (default: <name>)
58
- --backend-url <url> Medusa backend URL (default: http://localhost:9000)
168
+ --site-name <name> human site name (default: Title Case of <name>)
59
169
  --preset <preset> maia | nova | vega | luma | lyra | mira (required without a terminal)
170
+ --mode <mode> light | dark | system (default: system)
171
+ --starter install every curated view and feature (a full store),
172
+ not just the home+cart baseline
60
173
  --brand <color> brand color over preset tokens (not prompted by the wizard)
174
+ --packs <dir> directory of local @thorprovider/* .tgz tarballs; installs
175
+ the local candidate via Yarn resolutions (no publish)
61
176
  -f, --force overwrite files that already exist
62
177
  -h, --help show this help
63
178
 
64
- In a terminal, create prompts for missing inputs (Maia is preselected).
179
+ setup options:
180
+ --publishable-key <key> Medusa publishable API key (without a key the store stays
181
+ in mock mode)
182
+ --sales-channel-id <id> Medusa sales channel id (from the Medusa admin; required)
183
+ --region-id <id> Medusa region id (its currency is written)
184
+ --backend-url <url> Medusa backend URL (default: http://localhost:9000)
185
+
186
+ theme options:
187
+ (no options) Inspect the current theme, supported tokens and formats
188
+ --inspect Inspect only (read-only); combine with --json
189
+ --mode <mode> light | dark | system (rewritten in app/layout.tsx)
190
+ --brand <name> brand color: preset name, #hex or oklch(...) (primary family)
191
+ --primary <color> set the primary color (name, #hex, oklch(...))
192
+ --accent <color> set the accent color
193
+ --radius <length> base corner radius (e.g. 0.75rem); scale derives from it
194
+ --font-sans <sel> google:<Family> | system:<stack> (also --font-mono/--font-serif)
195
+ --set <scope.token=value> set one token (scope: light | dark | shared); repeatable
196
+ --preset <ref> shadcn preset name, code or builder URL (theme + fonts only)
197
+ --only <parts> with --preset: theme | font | theme,font (default theme,font)
198
+ --import <source> import a theme: local .css, local .json, or registry:theme URL
199
+ --export <file> write a portable theme snapshot (read-only; never overwrites)
200
+ --dry-run validate and describe the change without writing
201
+ --json machine-readable result
202
+
203
+ Visual values live in app/globals.css; the mode lives in storefront.config.json.
204
+ A later sync never reverts a theme change.
205
+
206
+ view options:
207
+ --intent <slot=intent> choose a slot's intent from the view's recipe
208
+ --variant <slot=Variant> choose a slot's variant directly
209
+ -f, --force re-add a view that is already installed
210
+
211
+ feature options:
212
+ --config <json> set the feature's config (a JSON object), validated
213
+ against its registry contract and persisted
214
+ -f, --force re-add a feature that is already installed
215
+ (preserves the existing config unless --config is given)
216
+
217
+ views options:
218
+ --json print the registry (recipes, intents, installed) as JSON
219
+
220
+ features options:
221
+ --json print the feature registry (bundles, installed) as JSON
222
+
223
+ recipes options:
224
+ --json print the whole curated registry (views + features) as JSON
225
+
226
+ section options:
227
+ --id <id> explicit instance id (default: auto-minted <view>-<type>-<n>)
228
+ --as <Variant> choose the section's declared variant
229
+ --after <id> | --before <id> place relative to an existing instance id
230
+ --first | --last place at the start or end of the composition
231
+ --at <n> place at a 0-based index (escape hatch)
232
+ -f, --force allow the write when ownership conflicts are detected
233
+
234
+ sections options:
235
+ --json print the installed views' section instances as JSON
236
+
237
+ In a terminal, create prompts for missing inputs (Maia is preselected), and setup
238
+ prompts for the backend URL, publishable key, and region.
65
239
  Without a terminal, every required input must be a flag — the command fails fast.
66
240
  `
67
241
 
@@ -72,10 +246,42 @@ function parseArgs(argv) {
72
246
  name: null,
73
247
  siteName: null,
74
248
  backendUrl: null,
249
+ publishableKey: null,
250
+ salesChannelId: null,
251
+ regionId: null,
75
252
  preset: null,
253
+ mode: null,
254
+ starter: false,
76
255
  brand: null,
77
256
  force: false,
78
257
  help: false,
258
+ intents: {},
259
+ variants: {},
260
+ json: false,
261
+ packs: null,
262
+ config: null,
263
+ operations: null,
264
+ out: null,
265
+ plan: null,
266
+ id: null,
267
+ as: null,
268
+ after: null,
269
+ before: null,
270
+ first: false,
271
+ last: false,
272
+ at: null,
273
+ primary: null,
274
+ accent: null,
275
+ radius: null,
276
+ sets: [],
277
+ fontSans: null,
278
+ fontMono: null,
279
+ fontSerif: null,
280
+ inspect: false,
281
+ only: null,
282
+ importSource: null,
283
+ exportPath: null,
284
+ dryRun: false,
79
285
  }
80
286
  const positionals = []
81
287
  for (let i = 0; i < argv.length; i++) {
@@ -86,10 +292,94 @@ function parseArgs(argv) {
86
292
  out.siteName = argv[++i]
87
293
  } else if (arg === '--backend-url') {
88
294
  out.backendUrl = argv[++i]
295
+ } else if (arg === '--publishable-key') {
296
+ out.publishableKey = argv[++i]
297
+ } else if (arg === '--sales-channel-id') {
298
+ out.salesChannelId = argv[++i]
299
+ } else if (arg === '--region-id') {
300
+ out.regionId = argv[++i]
89
301
  } else if (arg === '--preset') {
90
302
  out.preset = argv[++i]
303
+ } else if (arg === '--mode') {
304
+ out.mode = argv[++i]
305
+ } else if (arg === '--starter') {
306
+ out.starter = true
91
307
  } else if (arg === '--brand') {
92
308
  out.brand = argv[++i]
309
+ } else if (arg === '--primary') {
310
+ out.primary = argv[++i]
311
+ } else if (arg === '--accent') {
312
+ out.accent = argv[++i]
313
+ } else if (arg === '--radius') {
314
+ out.radius = argv[++i]
315
+ } else if (arg === '--set') {
316
+ out.sets.push(argv[++i])
317
+ } else if (arg === '--font-sans') {
318
+ out.fontSans = argv[++i]
319
+ } else if (arg === '--font-mono') {
320
+ out.fontMono = argv[++i]
321
+ } else if (arg === '--font-serif') {
322
+ out.fontSerif = argv[++i]
323
+ } else if (arg === '--inspect') {
324
+ out.inspect = true
325
+ } else if (arg === '--only') {
326
+ out.only = argv[++i]
327
+ } else if (arg === '--import') {
328
+ out.importSource = argv[++i]
329
+ } else if (arg === '--export') {
330
+ out.exportPath = argv[++i]
331
+ } else if (arg === '--dry-run') {
332
+ out.dryRun = true
333
+ } else if (arg === '--packs') {
334
+ out.packs = argv[++i]
335
+ } else if (arg === '--operations') {
336
+ out.operations = argv[++i]
337
+ } else if (arg === '--out') {
338
+ out.out = argv[++i]
339
+ } else if (arg === '--plan') {
340
+ out.plan = argv[++i]
341
+ } else if (arg === '--id') {
342
+ out.id = argv[++i] ?? null
343
+ } else if (arg === '--as') {
344
+ out.as = argv[++i] ?? null
345
+ } else if (arg === '--after') {
346
+ out.after = argv[++i] ?? null
347
+ } else if (arg === '--before') {
348
+ out.before = argv[++i] ?? null
349
+ } else if (arg === '--at') {
350
+ out.at = argv[++i] ?? null
351
+ } else if (arg === '--first') {
352
+ out.first = true
353
+ } else if (arg === '--last') {
354
+ out.last = true
355
+ } else if (arg === '--intent') {
356
+ const pair = argv[++i] ?? ''
357
+ const eq = pair.indexOf('=')
358
+ if (eq === -1) {
359
+ console.error(`--intent expects slot=intent (got "${pair}")`)
360
+ process.exitCode = 1
361
+ return null
362
+ }
363
+ out.intents[pair.slice(0, eq)] = pair.slice(eq + 1)
364
+ } else if (arg === '--variant') {
365
+ const pair = argv[++i] ?? ''
366
+ const eq = pair.indexOf('=')
367
+ if (eq === -1) {
368
+ console.error(`--variant expects slot=Variant (got "${pair}")`)
369
+ process.exitCode = 1
370
+ return null
371
+ }
372
+ out.variants[pair.slice(0, eq)] = pair.slice(eq + 1)
373
+ } else if (arg === '--json') {
374
+ out.json = true
375
+ } else if (arg === '--config') {
376
+ const value = argv[++i]
377
+ if (value === undefined) {
378
+ console.error('--config expects a JSON object value.')
379
+ process.exitCode = 1
380
+ return null
381
+ }
382
+ out.config = value
93
383
  } else if (arg === '--force' || arg === '-f') {
94
384
  out.force = true
95
385
  } else if (arg === '--help' || arg === '-h') {
@@ -103,11 +393,84 @@ function parseArgs(argv) {
103
393
  }
104
394
  }
105
395
  out.action = positionals[0] ?? null
396
+ out.positionals = positionals
106
397
  if (positionals[1]) out.dir = resolve(positionals[1])
107
398
  else if (!out.dir) out.dir = process.cwd()
108
399
  return out
109
400
  }
110
401
 
402
+ // Only `create` takes a directory. Every other command runs inside the project
403
+ // and resolves the storefront from the current directory upward. When none is
404
+ // found the command fails before touching the filesystem.
405
+ // Search upward for a pre-manifest install record (the old `.create-storefront.json`).
406
+ // Used only as a fallback signal when no storefront-shaped project is found.
407
+ function findLegacyRoot(startDir) {
408
+ let current = startDir
409
+ while (true) {
410
+ if (existsSync(join(current, '.opencode', LEGACY_RECORD_FILE))) return current
411
+ const parent = dirname(current)
412
+ if (parent === current) return null
413
+ current = parent
414
+ }
415
+ }
416
+
417
+ // Legacy (pre-manifest) projects are detected and refused with a no-write
418
+ // diagnostic. Automatic migration is held post-MVP (see trace-matrix.md), so
419
+ // this must run before any command function and touch nothing on disk.
420
+ function rejectLegacyProject(target) {
421
+ console.error(`No storefront.config.json found in ${target}, but it looks like a pre-manifest (legacy) storefront.`)
422
+ console.error('Automatic legacy migration is held post-MVP (traced in trace-matrix.md). No files were changed.')
423
+ console.error('Run `storefront create <dir>` to scaffold a new storefront, or add storefront.config.json manually.')
424
+ return 1
425
+ }
426
+
427
+ function resolveProject({ quiet = false } = {}) {
428
+ const root = findProjectRoot(process.cwd()) ?? findLegacyRoot(process.cwd())
429
+ if (!root) {
430
+ if (!quiet) {
431
+ console.error('Not inside a storefront project (no storefront.config.json found in this directory or any parent).')
432
+ console.error('Run `storefront create <dir>` to create one, or run this command from inside a storefront.')
433
+ }
434
+ return null
435
+ }
436
+ if (!hasManifest(root) && (looksLikeStorefront(root) || existsSync(join(root, '.opencode', LEGACY_RECORD_FILE)))) {
437
+ if (!quiet) rejectLegacyProject(root)
438
+ return null
439
+ }
440
+ return root
441
+ }
442
+
443
+ // Read and validate the project manifest, distinguishing a physically absent
444
+ // config (legacy project with no manifest yet) from a present config that parses
445
+ // to a non-object JSON value (`null`, `false`, `0`, `""`, `[]`). A present config
446
+ // is validated against the published shape here, at the shared read boundary, so
447
+ // no caller can mistake an invalid existing config for absence and write around
448
+ // it. Returns { manifest } (object) for a valid config, { manifest: null } for a
449
+ // truly absent one, or { error } with a precise input diagnostic.
450
+ function readManifestOrError(target) {
451
+ if (!hasManifest(target)) return { manifest: null }
452
+
453
+ let manifest
454
+ try {
455
+ manifest = readManifest(target)
456
+ } catch (err) {
457
+ return { error: `storefront.config.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
458
+ }
459
+
460
+ if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
461
+ return { error: 'storefront.config.json must contain a JSON object.' }
462
+ }
463
+
464
+ const shape = validateManifest(manifest)
465
+ if (!shape.ok) {
466
+ return {
467
+ error: `storefront.config.json does not satisfy the published schema:\n${shape.errors.map((error) => ` ${error}`).join('\n')}`,
468
+ }
469
+ }
470
+
471
+ return { manifest }
472
+ }
473
+
111
474
  async function copyDir(from, to, force) {
112
475
  const entries = await readdir(from, { withFileTypes: true })
113
476
  let copied = 0
@@ -131,26 +494,81 @@ async function prunePath(opencodeDir, relPath) {
131
494
  await rm(join(opencodeDir, relPath), { recursive: true, force: true })
132
495
  }
133
496
 
134
- function sha256(text) {
135
- return createHash('sha256').update(text).digest('hex')
497
+ // Prune the legacy storefront-owned paths an earlier generation left behind,
498
+ // file by file, so a co-tenant tool's files survive. The retired /sf-* command
499
+ // files are removed individually; the `commands/` directory itself is removed
500
+ // only when it is left empty. Every other command file (for example OpenSpec's
501
+ // `opsx-*`) is preserved. Returns the pruned command paths.
502
+ async function pruneLegacySuitePaths(opencodeDir) {
503
+ for (const rel of LEGACY_SKILL_PATHS) await prunePath(opencodeDir, rel)
504
+ const commandsDir = join(opencodeDir, 'commands')
505
+ const pruned = []
506
+ if (!existsSync(commandsDir)) return pruned
507
+ for (const entry of await readdir(commandsDir, { withFileTypes: true })) {
508
+ if (!entry.isFile()) continue
509
+ if (!LEGACY_COMMAND_FILE.test(entry.name)) continue
510
+ await rm(join(commandsDir, entry.name), { force: true })
511
+ pruned.push(`commands/${entry.name}`)
512
+ }
513
+ // Remove the directory only when nothing else is co-tenanted there.
514
+ if ((await readdir(commandsDir)).length === 0) await rm(commandsDir, { recursive: true, force: true })
515
+ return pruned
136
516
  }
137
517
 
138
- async function hashDir(dir) {
139
- const files = []
140
- async function walk(d) {
141
- for (const entry of await readdir(d, { withFileTypes: true })) {
142
- const full = join(d, entry.name)
143
- if (entry.isDirectory()) await walk(full)
144
- else files.push(full)
518
+ // Resolve the pinned OpenSpec CLI from the package's own dependency so
519
+ // provisioning is deterministic and offline once installed. `@fission-ai/openspec`
520
+ // declares an `exports` map without `./package.json`, so resolve its entry point
521
+ // and read the `bin` field from the package directory instead.
522
+ function resolveOpenSpecBin() {
523
+ try {
524
+ const require = createRequire(import.meta.url)
525
+ const entry = require.resolve('@fission-ai/openspec')
526
+ const pkgDir = dirname(dirname(entry))
527
+ const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
528
+ const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.openspec
529
+ if (rel) {
530
+ const abs = resolve(pkgDir, rel)
531
+ if (existsSync(abs)) return { ok: true, bin: abs, version: pkg.version }
532
+ }
533
+ return { ok: false, error: 'the @fission-ai/openspec package has no resolvable bin' }
534
+ } catch {
535
+ return {
536
+ ok: false,
537
+ error: 'the pinned @fission-ai/openspec dependency is not installed; run `yarn install`',
145
538
  }
146
539
  }
147
- if (existsSync(dir)) await walk(dir)
148
- files.sort()
149
- const lines = []
150
- for (const f of files) {
151
- lines.push(`${relative(dir, f)}:${sha256(await readFile(f, 'utf8'))}`)
540
+ }
541
+
542
+ // Seed the storefront-aware OpenSpec model into the generated project. The
543
+ // config is storefront-owned: `update` re-applies it after re-running init, so
544
+ // the model stays authoritative and customization belongs in storefront files,
545
+ // not in `openspec/`.
546
+ async function seedOpenSpecConfig(target) {
547
+ const template = join(PKG_ROOT, 'assets', 'openspec', 'config.yaml')
548
+ const dest = join(target, 'openspec', 'config.yaml')
549
+ await mkdir(dirname(dest), { recursive: true })
550
+ await writeFile(dest, readFileSync(template, 'utf8'), 'utf8')
551
+ }
552
+
553
+ // Provision the OpenSpec spec-driven workflow into the target through the pinned
554
+ // CLI. The CLI runs under the current Node so the invocation is portable and
555
+ // non-interactive; a failure is returned as a diagnostic and never masked. After
556
+ // a successful init the storefront model is seeded into `openspec/config.yaml`.
557
+ async function provisionOpenSpec(target) {
558
+ const resolved = resolveOpenSpecBin()
559
+ if (!resolved.ok) return { ok: false, error: resolved.error }
560
+ const result = spawnSync(
561
+ process.execPath,
562
+ [resolved.bin, 'init', '--tools', 'opencode', '--no-animation'],
563
+ { cwd: target, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
564
+ )
565
+ if (result.error) return { ok: false, error: result.error.message }
566
+ if (result.status !== 0) {
567
+ const detail = (result.stderr || result.stdout || '').trim().split('\n').pop()
568
+ return { ok: false, error: detail || `openspec init exited with code ${result.status}` }
152
569
  }
153
- return sha256(lines.join('\n'))
570
+ await seedOpenSpecConfig(target)
571
+ return { ok: true, version: resolved.version }
154
572
  }
155
573
 
156
574
  function pkgVersion() {
@@ -172,14 +590,82 @@ async function readRecord(opencodeDir) {
172
590
  return null
173
591
  }
174
592
 
593
+ // The installed package-owned suite digests. Only the package's own file set is
594
+ // hashed, so a user's custom skill/command file under `.opencode/` never shows
595
+ // up as drift or gets stamped into the record.
596
+ async function installedSuiteBom(projectDir) {
597
+ return hashPaths(join(projectDir, '.opencode'), managedSuitePaths(PKG_ROOT))
598
+ }
599
+
600
+ async function collectLivePinning(projectDir) {
601
+ const direct = readDirectDependencies(projectDir)
602
+ if (!direct.ok) return { ok: false, error: direct.error }
603
+ const resolvedVersions = readResolvedVersions(projectDir, Object.keys(direct.dependencies))
604
+ const lock = readLockfile(projectDir)
605
+ if (!lock.ok) return { ok: false, error: lock.error }
606
+ const toolchain = readToolchainEvidence(projectDir)
607
+ if (!toolchain.ok) return { ok: false, error: toolchain.error }
608
+ const assets = await installedSuiteBom(projectDir)
609
+ return {
610
+ ok: true,
611
+ live: {
612
+ directDependencies: direct.dependencies,
613
+ resolvedVersions,
614
+ lockfile: lock.lockfile,
615
+ toolchain: toolchain.toolchain,
616
+ assets,
617
+ },
618
+ }
619
+ }
620
+
621
+ // Validate every *precomputable* layer of the incoming record — declared ranges,
622
+ // resolved versions, the lockfile, and the toolchain/linker — BEFORE any
623
+ // structural write. Assets are expected to change when the suite is refreshed,
624
+ // so they are checked after the copy at stamp time (`writeRecord`). Returns
625
+ // `{ ok, errors }`.
626
+ async function validateIncomingContract(projectDir, version) {
627
+ const collected = await collectLivePinning(projectDir)
628
+ if (!collected.ok) return { ok: false, errors: [collected.error] }
629
+ const incoming = buildPinningRecord({
630
+ cliVersion: version,
631
+ directDependencies: collected.live.directDependencies,
632
+ resolvedVersions: collected.live.resolvedVersions,
633
+ lockfile: collected.live.lockfile,
634
+ assets: await hashManagedSuite(PKG_ROOT),
635
+ })
636
+ return validatePinning(incoming, {
637
+ distributedVersion: version,
638
+ live: collected.live,
639
+ includeAssets: false,
640
+ })
641
+ }
642
+
643
+ // Stamp the install record from the actual, complete project evidence. The
644
+ // record pins the *distributed* package BOM; it is validated against the live
645
+ // installed artifacts before it is written, so a failed copy or an incoherent
646
+ // pin set is reported and never stamped.
175
647
  async function writeRecord(projectDir, version) {
648
+ const collected = await collectLivePinning(projectDir)
649
+ if (!collected.ok) return { ok: false, errors: [collected.error] }
650
+ const { directDependencies, resolvedVersions, lockfile } = collected.live
651
+ const assets = await hashManagedSuite(PKG_ROOT)
652
+ const pinning = buildPinningRecord({
653
+ cliVersion: version,
654
+ directDependencies,
655
+ resolvedVersions,
656
+ lockfile,
657
+ assets,
658
+ })
659
+ const check = validatePinning(pinning, { distributedVersion: version, live: collected.live })
660
+ if (!check.ok) return { ok: false, errors: check.errors }
176
661
  const opencodeDir = join(projectDir, '.opencode')
177
662
  await mkdir(opencodeDir, { recursive: true })
178
663
  await writeFile(
179
664
  join(opencodeDir, VERSION_RECORD_FILE),
180
- JSON.stringify({ version, installedAt: new Date().toISOString() }, null, 2) + '\n',
665
+ JSON.stringify({ version, installedAt: new Date().toISOString(), pinning }, null, 2) + '\n',
181
666
  'utf8',
182
667
  )
668
+ return { ok: true, pinning }
183
669
  }
184
670
 
185
671
  async function verifyConsistency() {
@@ -195,69 +681,115 @@ async function verifyConsistency() {
195
681
 
196
682
  async function installSuite(opencodeDir, force) {
197
683
  let total = 0
198
- for (const dir of COPY_DIRS) {
199
- const from = join(PKG_ROOT, dir)
200
- const to = join(opencodeDir, dir)
201
- await mkdir(to, { recursive: true })
684
+ // The operative set comes from the single declared source — the package skill
685
+ // layout (`skills/<name>/SKILL.md`). A directory without a SKILL.md is not a
686
+ // skill and is not installed; the files inside an operative skill (its scoped
687
+ // references, assets, and scripts) travel with it but are never treated as
688
+ // skills. `create` and `update` read the same source, so the installed set
689
+ // cannot drift between them.
690
+ for (const skillRel of declaredOperativeSkills(PKG_ROOT)) {
691
+ const from = join(PKG_ROOT, skillRel)
692
+ const to = join(opencodeDir, skillRel)
693
+ await mkdir(dirname(to), { recursive: true })
202
694
  total += await copyDir(from, to, force)
203
695
  }
204
696
  return total
205
697
  }
206
698
 
207
- function run(cmd, cmdArgs, cwd) {
208
- const res = spawnSync(cmd, cmdArgs, { cwd, stdio: 'inherit', shell: false })
699
+ // Remove `dir` and any now-empty parent directories up to (but not including)
700
+ // `stopAt`. Used after retiring obsolete owned files so a removed skill does not
701
+ // leave an empty discoverable directory behind.
702
+ async function pruneEmptyParents(dir, stopAt) {
703
+ let current = dir
704
+ while (current.startsWith(stopAt) && current !== stopAt) {
705
+ let entries
706
+ try {
707
+ entries = await readdir(current)
708
+ } catch {
709
+ return
710
+ }
711
+ if (entries.length > 0) return
712
+ await rm(current, { recursive: true, force: true })
713
+ current = dirname(current)
714
+ }
715
+ }
716
+
717
+ // Retire the recorded owned files the package no longer ships. A candidate is
718
+ // removed ONLY when its live content still matches the recorded package render
719
+ // (its recorded digest). A modified candidate is preserved and returned so the
720
+ // caller can report it; a file with no package record is never a candidate and
721
+ // is left untouched. Returns the retired and preserved (modified) paths.
722
+ async function retireObsoleteOwnedFiles(opencodeDir, recordedAssets) {
723
+ const retired = []
724
+ const preserved = []
725
+ const candidates = retirementCandidates(recordedAssets, managedSuitePaths(PKG_ROOT))
726
+ for (const rel of candidates) {
727
+ const abs = join(opencodeDir, rel)
728
+ if (!existsSync(abs)) continue
729
+ const liveDigest = hashPaths(opencodeDir, [rel])[rel]
730
+ if (liveDigest === recordedAssets[rel]) {
731
+ await rm(abs, { force: true })
732
+ retired.push(rel)
733
+ } else {
734
+ preserved.push(rel)
735
+ }
736
+ }
737
+ for (const rel of retired) await pruneEmptyParents(dirname(join(opencodeDir, rel)), opencodeDir)
738
+ return { retired, preserved }
739
+ }
740
+
741
+ // The installed skills that are not part of the package's current operative set.
742
+ // A directory under `.opencode/skills/` containing a `SKILL.md` is a
743
+ // discoverable skill; one the package no longer ships (or that was added by
744
+ // hand) is not in the incoming set. Returned sorted so the completion output can
745
+ // refuse a clean migration while one remains.
746
+ async function remainingNonSuiteSkills(opencodeDir) {
747
+ const incoming = new Set(
748
+ declaredOperativeSkills(PKG_ROOT).map((rel) => rel.slice(rel.lastIndexOf('/') + 1)),
749
+ )
750
+ const skillsDir = join(opencodeDir, 'skills')
751
+ const remaining = []
752
+ if (!existsSync(skillsDir)) return remaining
753
+ for (const entry of await readdir(skillsDir, { withFileTypes: true })) {
754
+ if (!entry.isDirectory()) continue
755
+ // The OpenSpec suite owns its own namespaced skills; they are not part of
756
+ // the storefront operative set and must never be reported as leftovers.
757
+ if (entry.name.startsWith(OPENSPEC_SKILL_PREFIX)) continue
758
+ if (!existsSync(join(skillsDir, entry.name, 'SKILL.md'))) continue
759
+ if (!incoming.has(entry.name)) remaining.push(entry.name)
760
+ }
761
+ return remaining.sort()
762
+ }
763
+
764
+ // Run Yarn under the pinned Corepack version (`yarn@x.y.z`), so the scaffold
765
+ // and every install use the canonical package manager deterministically rather
766
+ // than whatever "known good release" Corepack would otherwise pick.
767
+ function runYarn(yarnSpec, cmdArgs, cwd) {
768
+ const res = spawnSync('corepack', [yarnSpec, ...cmdArgs], { cwd, stdio: 'inherit', shell: false })
209
769
  return res.status ?? 1
210
770
  }
211
771
 
212
- const BRAND_TOKEN_KEYS = {
213
- light: ['--primary', '--primary-foreground', '--nav', '--ring'],
214
- dark: ['--primary', '--primary-foreground', '--nav', '--ring'],
215
- }
216
-
217
- const COLOR_HUES = {
218
- red: 27,
219
- orange: 55,
220
- amber: 75,
221
- yellow: 95,
222
- lime: 130,
223
- green: 155,
224
- teal: 185,
225
- cyan: 210,
226
- blue: 232,
227
- indigo: 264,
228
- violet: 290,
229
- purple: 310,
230
- pink: 350,
231
- slate: 240,
232
- gray: 240,
233
- neutral: 0,
234
- stone: 60,
235
- }
236
-
237
- function applyBrand(globalsCss, brand) {
238
- if (!brand) return globalsCss
239
- const hue = COLOR_HUES[brand]
240
- if (hue === undefined) {
241
- console.error(`Unknown brand color: ${brand}`)
242
- process.exitCode = 1
243
- return globalsCss
244
- }
245
- // Swap the hue channel (third component) of each brand token's oklch value in
246
- // both modes. Lightness/chroma stay from the preset so the contrast pair rule
247
- // is preserved; only the hue carries the brand color.
248
- let css = globalsCss
249
- for (const key of [...BRAND_TOKEN_KEYS.light, ...BRAND_TOKEN_KEYS.dark]) {
250
- css = css.replace(new RegExp(`(${key}: oklch\\([^)]*?\\s)([^\\s)]+)(\\))`, 'g'), `$1${hue}$3`)
251
- }
252
- return css
772
+ // Apply a named brand color through the same transformation the `theme` command
773
+ // uses, so `create --brand` and `theme --brand` agree: real chromatic palette
774
+ // values (not a hue swap on a possibly-achromatic preset), a measured-contrast
775
+ // foreground, and the documented primary family. Returns `{ css }` or `{ error }`.
776
+ function applyBrandToGlobals(globalsCss, brand) {
777
+ if (!brand) return { css: globalsCss }
778
+ const { operation, diagnostics } = normalizeOperation({ brand })
779
+ const error = diagnostics.find((d) => d.severity === 'error')
780
+ if (error) return { error: error.message }
781
+ const planned = planCss(globalsCss, operation)
782
+ const planError = planned.diagnostics.find((d) => d.severity === 'error')
783
+ if (planError) return { error: planError.message }
784
+ return { css: planned.css }
253
785
  }
254
786
 
255
787
  async function promptScaffoldInputs(args) {
256
788
  const inputs = {
257
789
  name: toKebab(args.name ?? '') || null,
258
790
  siteName: args.siteName ?? null,
259
- backendUrl: args.backendUrl ?? null,
260
791
  preset: args.preset ?? null,
792
+ mode: args.mode ?? null,
261
793
  brand: args.brand ?? null,
262
794
  }
263
795
 
@@ -265,11 +797,20 @@ async function promptScaffoldInputs(args) {
265
797
  if (tty) {
266
798
  intro('storefront create')
267
799
 
800
+ // The suggested name comes from the target directory basename, so
801
+ // `storefront create .` or `../shop` still suggest the right thing.
802
+ const suggestedName = toKebab(basename(args.dir ?? '')) || 'my-shop'
803
+
268
804
  if (!inputs.name) {
269
805
  const value = await text({
270
806
  message: 'Storefront name (kebab-case — lib/<name>/):',
271
- placeholder: 'my-shop',
272
- validate: (v) => (toKebab(v ?? '').length ? undefined : 'Enter a kebab-case name'),
807
+ placeholder: suggestedName,
808
+ defaultValue: suggestedName,
809
+ // Empty is valid: clack validates the raw value before applying
810
+ // `defaultValue`, so rejecting empty here would block Enter from
811
+ // accepting the suggested name. Only input that normalizes to nothing
812
+ // (e.g. "!!!") is rejected.
813
+ validate: (v) => (!v || toKebab(v).length ? undefined : 'Enter a kebab-case name'),
273
814
  })
274
815
  if (isCancel(value)) {
275
816
  cancel('Operation cancelled.')
@@ -279,9 +820,11 @@ async function promptScaffoldInputs(args) {
279
820
  }
280
821
 
281
822
  if (!inputs.siteName) {
823
+ const suggestedSiteName = toTitle(inputs.name)
282
824
  const value = await text({
283
825
  message: 'Site name:',
284
- initialValue: inputs.name,
826
+ placeholder: suggestedSiteName,
827
+ defaultValue: suggestedSiteName,
285
828
  })
286
829
  if (isCancel(value)) {
287
830
  cancel('Operation cancelled.')
@@ -290,18 +833,6 @@ async function promptScaffoldInputs(args) {
290
833
  inputs.siteName = value
291
834
  }
292
835
 
293
- if (!inputs.backendUrl) {
294
- const value = await text({
295
- message: 'Medusa backend URL:',
296
- initialValue: DEFAULT_BACKEND_URL,
297
- })
298
- if (isCancel(value)) {
299
- cancel('Operation cancelled.')
300
- process.exit(0)
301
- }
302
- inputs.backendUrl = value
303
- }
304
-
305
836
  if (!inputs.preset) {
306
837
  const value = await select({
307
838
  message: 'Visual preset:',
@@ -318,6 +849,23 @@ async function promptScaffoldInputs(args) {
318
849
  inputs.preset = value
319
850
  }
320
851
 
852
+ if (!inputs.mode) {
853
+ const value = await select({
854
+ message: 'Site color mode:',
855
+ initialValue: DEFAULT_THEME_MODE,
856
+ options: [
857
+ { value: 'system', label: 'system — follow the visitor’s OS preference' },
858
+ { value: 'light', label: 'light — always render light' },
859
+ { value: 'dark', label: 'dark — always render dark' },
860
+ ],
861
+ })
862
+ if (isCancel(value)) {
863
+ cancel('Operation cancelled.')
864
+ process.exit(0)
865
+ }
866
+ inputs.mode = value
867
+ }
868
+
321
869
  outro('Scaffolding…')
322
870
  }
323
871
 
@@ -325,19 +873,38 @@ async function promptScaffoldInputs(args) {
325
873
  if (!inputs.name) missing.push('storefront name (--name <name>)')
326
874
  if (!inputs.preset) missing.push('--preset (one of: ' + PRESETS.join(', ') + ')')
327
875
  else if (!PRESETS.includes(inputs.preset)) missing.push(`--preset must be one of: ${PRESETS.join(', ')}`)
876
+ if (inputs.mode && !THEME_MODES.includes(inputs.mode)) missing.push(`--mode must be one of: ${THEME_MODES.join(', ')}`)
328
877
 
329
878
  if (missing.length > 0) return { missing }
330
879
  return {
331
880
  inputs: {
332
881
  name: inputs.name,
333
- siteName: inputs.siteName ?? inputs.name,
334
- backendUrl: inputs.backendUrl ?? DEFAULT_BACKEND_URL,
882
+ siteName: inputs.siteName ?? toTitle(inputs.name),
335
883
  preset: inputs.preset,
884
+ mode: inputs.mode ?? DEFAULT_THEME_MODE,
336
885
  brand: inputs.brand,
337
886
  },
338
887
  }
339
888
  }
340
889
 
890
+ // Every structural write boundary validates the complete desired composition
891
+ // first. On rejection nothing is written: no manifest, no derived file, and no
892
+ // directory change.
893
+ function rejectComposition(errors) {
894
+ console.error('Refusing to write: the desired composition is invalid.')
895
+ for (const err of errors) console.error(` ${err}`)
896
+ return 1
897
+ }
898
+
899
+ // Print the structured diagnostics the operation engine returns, using the same
900
+ // header every structural write boundary prints so existing consumers keep their
901
+ // wording (E1 task 4.1).
902
+ function rejectDiagnostics(diagnostics) {
903
+ console.error('Refusing to write: the desired composition is invalid.')
904
+ for (const diag of diagnostics) console.error(` ${diag.message}`)
905
+ return 1
906
+ }
907
+
341
908
  async function create(args) {
342
909
  const target = args.dir
343
910
  const { inputs, missing } = await promptScaffoldInputs(args)
@@ -347,13 +914,96 @@ async function create(args) {
347
914
  return 1
348
915
  }
349
916
 
350
- const { name, siteName, backendUrl, preset, brand } = inputs
917
+ const { name, siteName, preset, mode, brand } = inputs
918
+
919
+ if (args.backendUrl) {
920
+ console.error('storefront create no longer takes --backend-url; a created store starts in mock mode.')
921
+ console.error('Run `storefront setup` after creating to connect a real backend.')
922
+ return 1
923
+ }
924
+
925
+ // The candidate CLI is the structural authority; the generated project pins
926
+ // the exact compatible toolset (and the CLI itself). Declared here so the
927
+ // contract can be validated before the first write.
928
+ const cliVersion = pkgVersion()
929
+ const plan = buildInstallPlan({ cliVersion, packsDir: args.packs ? resolve(args.packs) : null })
930
+ if (plan.missingPacks.length > 0) {
931
+ console.error(`Refusing to create: missing packed local candidate(s) in ${resolve(args.packs)}:`)
932
+ for (const name of plan.missingPacks) console.error(` ${name}`)
933
+ console.error('Pack each @thorprovider/* package with `yarn pack --filename <slug>-<version>.tgz`.')
934
+ return 1
935
+ }
936
+
937
+ // Integrity guard for the local candidate packs. A `--packs` directory can be
938
+ // stale: a tarball packed before a public export was added still resolves for
939
+ // the same exact version, so the generated project imports a symbol the
940
+ // packed dist does not export. Verify each pack declares the expected
941
+ // name+version (Tier 1) and that the packed runtime actually exports every
942
+ // value the generated host imports from `@thorprovider/runtime/commerce`
943
+ // (Tier 2) before the first write.
944
+ if (args.packs) {
945
+ const packsDir = resolve(args.packs)
946
+ const localPacks = { ...CANDIDATE_TOOLSET.local, '@thorprovider/storefront': cliVersion }
947
+ for (const [name, version] of Object.entries(localPacks)) {
948
+ const guard = verifyPackManifest({ tgzPath: findPack(packsDir, name, version), name, version })
949
+ if (!guard.ok) {
950
+ console.error(`Refusing to create: ${guard.message} (${guard.code})`)
951
+ return 1
952
+ }
953
+ }
954
+ const commerceTemplate = join(PKG_ROOT, 'assets', 'templates', 'app', 'lib', '__STOREFRONT__', 'commerce.ts')
955
+ const requiredExports = parseRuntimeCommerceImports(readFileSync(commerceTemplate, 'utf8'))
956
+ const runtimeName = '@thorprovider/runtime'
957
+ const runtimeGuard = verifyPackExports({
958
+ tgzPath: findPack(packsDir, runtimeName, CANDIDATE_TOOLSET.local[runtimeName]),
959
+ innerPath: 'package/dist/commerce/index.mjs',
960
+ requiredExports,
961
+ })
962
+ if (!runtimeGuard.ok) {
963
+ console.error(`Refusing to create: ${runtimeGuard.message} (${runtimeGuard.code})`)
964
+ return 1
965
+ }
966
+ }
967
+
968
+ // Validate the complete desired composition before the first filesystem
969
+ // operation. Without `--starter`, `create` composes the home baseline plus the
970
+ // standalone cart review view. With `--starter`, it composes the full curated
971
+ // set: every curated feature and every curated view (the standalone views plus
972
+ // the feature-owned views each feature installs). Either way it must pass the
973
+ // same preflight as every other write boundary — and it must run before any
974
+ // mkdir, external scaffold, or write so rejected input leaves no trace.
975
+ const starter = args.starter === true
976
+ const composition = scaffoldComposition({ starter })
977
+ const manifest = createManifest({
978
+ name,
979
+ siteName,
980
+ mode,
981
+ views: composition.views,
982
+ features: composition.features,
983
+ })
984
+ const preflight = preflightManifest(manifest)
985
+ if (!preflight.ok) return rejectComposition(preflight.errors)
986
+
987
+ // Validate the incoming pinning contract before the first write too. The
988
+ // generated project declares the exact compatible toolset and pins the CLI
989
+ // itself; the intended linker is the one `create` writes below.
990
+ const declared = validateDeclaredContract({
991
+ directDependencies: plan.dependencies,
992
+ cliVersion,
993
+ distributedVersion: cliVersion,
994
+ toolchain: { linker: 'node-modules', nodeVersion: process.versions.node, yarnVersion: null },
995
+ })
996
+ if (!declared.ok) {
997
+ console.error('Refusing to create: the declared pinning contract is invalid.')
998
+ for (const err of declared.errors) console.error(` ${err}`)
999
+ return 1
1000
+ }
351
1001
 
352
1002
  console.log('storefront create — deterministic scaffold')
353
1003
  console.log(` name: ${name}`)
354
1004
  console.log(` site name: ${siteName}`)
355
- console.log(` backend: ${backendUrl}`)
356
1005
  console.log(` preset: ${preset}`)
1006
+ console.log(` mode: ${mode}`)
357
1007
  if (brand) console.log(` brand: ${brand}`)
358
1008
  console.log(` target: ${target}`)
359
1009
 
@@ -365,7 +1015,7 @@ async function create(args) {
365
1015
  const libDir = join(target, 'lib', name)
366
1016
  if (existsSync(libDir)) {
367
1017
  console.error(`lib/${name}/ already exists — refusing to overwrite.`)
368
- console.error('Use /sf-add-view to extend an existing storefront.')
1018
+ console.error('This target already holds a storefront; extend it through the storefront CLI (see AGENTS.md).')
369
1019
  return 1
370
1020
  }
371
1021
  if (!(await verifyConsistency())) return 1
@@ -381,7 +1031,11 @@ async function create(args) {
381
1031
  await cp(opencodeDir, stash, { recursive: true })
382
1032
  await rm(opencodeDir, { recursive: true, force: true })
383
1033
  }
384
- const status = run('yarn', ['create', 'next-app', '.', '--ts', '--tailwind', '--app', '--import-alias', '@/*', '--no-turbopack'], target)
1034
+ // --skip-install: the first install is ours to run, under the .yarnrc.yml
1035
+ // written in step 3, so network hardening applies to every install. The
1036
+ // pinned package manager is forced for the bootstrap too (Corepack), so the
1037
+ // whole scaffold runs under the canonical Yarn, not a floating release.
1038
+ const status = runYarn(plan.packageManager, ['dlx', `create-next-app@${plan.scaffolder.createNextApp}`, '.', '--ts', '--tailwind', '--app', '--import-alias', '@/*', '--no-turbopack', '--use-yarn', '--skip-install'], target)
385
1039
  if (hadOpenCode) {
386
1040
  await cp(stash, opencodeDir, { recursive: true })
387
1041
  await rm(stash, { recursive: true, force: true })
@@ -392,10 +1046,42 @@ async function create(args) {
392
1046
  }
393
1047
  }
394
1048
 
395
- // 3. shadcn base
396
- const shadcnStatus = run(
397
- 'yarn',
398
- ['dlx', 'shadcn@latest', 'init', '--template', 'next', '--base', 'base', '--preset', preset, '--no-monorepo', '--no-rtl', '--yes'],
1049
+ // 3. Yarn config — before ANY install (shadcn included): node-modules linker,
1050
+ // retries and low concurrency to survive unreliable networks. With the
1051
+ // global cache warm, repeat scaffolds only need tiny metadata requests —
1052
+ // or none at all via YARN_ENABLE_OFFLINE_MODE=1. No package-manager safety
1053
+ // gate is suppressed: the pinned toolset versions are already published
1054
+ // past Yarn's `npmMinimalAgeGate`, so no allowlist is needed.
1055
+ await writeFile(
1056
+ join(target, '.yarnrc.yml'),
1057
+ [
1058
+ 'nodeLinker: node-modules',
1059
+ 'httpRetry: 10',
1060
+ 'networkConcurrency: 8',
1061
+ ].join('\n') + '\n',
1062
+ 'utf8',
1063
+ )
1064
+ await rm(join(target, '.pnp.cjs'), { force: true })
1065
+
1066
+ // 3b. Pin the package manager (Corepack reads `packageManager`) and the local
1067
+ // candidate. Yarn `resolutions` map each local @thorprovider/* package to
1068
+ // its packed tarball while the declared dependency ranges stay honest
1069
+ // semver. Written before any install so the whole tree resolves to the
1070
+ // local candidate (no publish, no workspace import) under the pinned Yarn.
1071
+ {
1072
+ const pkgPath = join(target, 'package.json')
1073
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
1074
+ pkg.packageManager = plan.packageManager
1075
+ if (Object.keys(plan.resolutions).length > 0) {
1076
+ pkg.resolutions = { ...(pkg.resolutions ?? {}), ...plan.resolutions }
1077
+ }
1078
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
1079
+ }
1080
+
1081
+ // 4. shadcn base
1082
+ const shadcnStatus = runYarn(
1083
+ plan.packageManager,
1084
+ ['dlx', `shadcn@${plan.scaffolder.shadcn}`, 'init', '--template', 'next', '--base', 'base', '--preset', preset, '--no-monorepo', '--no-rtl', '--yes'],
399
1085
  target,
400
1086
  )
401
1087
  if (shadcnStatus !== 0) {
@@ -403,51 +1089,233 @@ async function create(args) {
403
1089
  return shadcnStatus
404
1090
  }
405
1091
 
406
- // 4. Yarn node-modules linker
407
- await writeFile(join(target, '.yarnrc.yml'), 'nodeLinker: node-modules\n', 'utf8')
408
- await rm(join(target, '.pnp.cjs'), { force: true })
1092
+ // 4b. Pin the shadcn CLI exactly. `shadcn init` adds itself with a caret; a
1093
+ // caret would re-resolve to the newest (possibly age-gated) release on the
1094
+ // final install, so write the exact scaffolded version first.
1095
+ {
1096
+ const pkgPath = join(target, 'package.json')
1097
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
1098
+ for (const field of ['dependencies', 'devDependencies']) {
1099
+ if (pkg[field] && pkg[field].shadcn) pkg[field].shadcn = plan.scaffolder.shadcn
1100
+ }
1101
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
1102
+ }
409
1103
 
410
- // 5. Dependencies
411
- const STOREFRONT_DEPS = [
412
- '@thorprovider/blocks',
413
- '@thorprovider/adapters',
414
- '@thorprovider/runtime',
415
- '@json-render/core',
416
- '@json-render/react',
417
- '@json-render/next',
418
- 'motion',
419
- 'zod',
420
- ]
421
- const addStatus = run('yarn', ['add', ...STOREFRONT_DEPS], target)
1104
+ // 5. Dependencies — every declared dep at its exact compatible version,
1105
+ // including the exact candidate CLI.
1106
+ const installDeps = Object.entries(plan.dependencies).map(([dep, version]) => `${dep}@${version}`)
1107
+ const addStatus = runYarn(plan.packageManager, ['add', ...installDeps], target)
422
1108
  if (addStatus !== 0) {
423
1109
  console.error('yarn add failed.')
424
1110
  return addStatus
425
1111
  }
426
1112
 
427
- // 6. Template copy + placeholder substitution
428
- await renderScaffold({ targetDir: target, name, siteName })
1113
+ // 6. Template copy + placeholder substitution, then reconcile to the manifest.
1114
+ // The manifest was built and validated before any write above. Passing its
1115
+ // view entries means the fresh scaffold resolves explicit slot selections
1116
+ // through the same pipeline a later reconcile uses.
1117
+ await renderScaffold({ targetDir: target, name, siteName, mode, views: manifest.views, features: manifest.features })
1118
+ await writeManifest(target, manifest)
1119
+ const result = await reconcile(target, manifest)
1120
+ if (!result.ok) return rejectComposition(result.errors)
429
1121
 
430
1122
  // 7. .env.local + theme
431
- const envLocal = `MEDUSA_BACKEND_URL=${backendUrl}\nMEDUSA_REGION_ID=\nMEDUSA_PUBLISHABLE_KEY=\n`
432
- await writeFile(join(target, '.env.local'), envLocal, 'utf8')
1123
+ // `create` is offline and structural: it starts the store in mock mode, a
1124
+ // complete state that needs no backend. The only secret it writes is the
1125
+ // per-install session signing secret, which the guest cart and checkout
1126
+ // cookie signing requires in every mode. Real backend configuration is
1127
+ // written by `storefront setup`, never here, so no blank MEDUSA_* value is
1128
+ // ever produced. An existing .env.local is user-owned (a `setup` run or a
1129
+ // hand edit) and is left untouched.
1130
+ await writeInitialEnv(target, name)
433
1131
 
434
1132
  const globalsPath = join(target, 'app', 'globals.css')
435
1133
  if (existsSync(globalsPath)) {
436
1134
  const css = readFileSync(globalsPath, 'utf8')
437
- await writeFile(globalsPath, applyBrand(css, brand), 'utf8')
1135
+ const branded = applyBrandToGlobals(css, brand)
1136
+ if (branded.error) {
1137
+ console.error(`Unknown brand color: ${branded.error}`)
1138
+ return 1
1139
+ }
1140
+ if (branded.css !== css) await writeFile(globalsPath, branded.css, 'utf8')
438
1141
  }
439
1142
 
440
1143
  // 8. Suite install + version record
441
1144
  const opencodeDir = join(target, '.opencode')
442
1145
  const total = await installSuite(opencodeDir, args.force)
443
- await writeRecord(target, pkgVersion())
1146
+ const record = await writeRecord(target, cliVersion)
1147
+ if (!record.ok) {
1148
+ console.error('Refusing to stamp the install record: the pinning contract is not complete.')
1149
+ for (const err of record.errors) console.error(` ${err}`)
1150
+ return 1
1151
+ }
444
1152
  console.log(`installed ${total} file(s) and recorded the version`)
445
1153
 
1154
+ // 9. OpenSpec provisioning (final step). The spec-driven workflow is installed
1155
+ // last, after the suite and the version record, so a failure never leaves a
1156
+ // storefront that claims to be fully provisioned. A failure is surfaced as
1157
+ // a diagnostic; the storefront itself is still created.
1158
+ const provisioned = await provisionOpenSpec(target)
1159
+ if (provisioned.ok) {
1160
+ console.log(`provisioned OpenSpec ${provisioned.version} (openspec init --tools opencode)`)
1161
+ } else {
1162
+ console.error(`Warning: OpenSpec provisioning failed — ${provisioned.error}`)
1163
+ console.error(' The storefront was created but is NOT fully provisioned: the spec-driven')
1164
+ console.error(' workflow is missing. Re-run `npx @thorprovider/storefront update` once fixed.')
1165
+ }
1166
+
446
1167
  console.log('')
447
1168
  console.log('Done. Next steps:')
448
1169
  console.log(` cd ${relative(process.cwd(), target) || '.'}`)
449
1170
  console.log(' yarn dev')
450
- console.log('Then use /sf-add-view, /sf-view, /sf-theme to extend the store.')
1171
+ console.log('Then open the project in OpenCode and ask in natural language — the router in AGENTS.md points to the right skill.')
1172
+ return 0
1173
+ }
1174
+
1175
+ // Connect (or re-point) a real Medusa backend for an existing storefront. `create`
1176
+ // leaves a store running in mock mode; `setup` is the single, re-runnable step that
1177
+ // writes the real-mode environment. The developer supplies the publishable key, the
1178
+ // sales channel id (from the Medusa admin), and the region; `setup` derives only the
1179
+ // region's currency by reading the store API with the key.
1180
+ async function setup(args) {
1181
+ const target = args.dir
1182
+ const envPath = join(target, '.env.local')
1183
+ const tty = Boolean(process.stdout.isTTY)
1184
+
1185
+ let backendUrl = args.backendUrl ?? null
1186
+ let publishableKey = args.publishableKey ?? null
1187
+ let salesChannelId = args.salesChannelId ?? null
1188
+ let regionId = args.regionId ?? null
1189
+
1190
+ if (tty) {
1191
+ intro('storefront setup')
1192
+ if (!backendUrl) {
1193
+ const value = await text({ message: 'Medusa backend URL:', initialValue: DEFAULT_BACKEND_URL })
1194
+ if (isCancel(value)) {
1195
+ cancel('Operation cancelled.')
1196
+ process.exit(0)
1197
+ }
1198
+ backendUrl = value
1199
+ }
1200
+ if (!publishableKey) {
1201
+ const value = await text({ message: 'Publishable API key (blank to stay in mock mode):', defaultValue: '' })
1202
+ if (isCancel(value)) {
1203
+ cancel('Operation cancelled.')
1204
+ process.exit(0)
1205
+ }
1206
+ publishableKey = value || null
1207
+ }
1208
+ if (publishableKey && !salesChannelId) {
1209
+ const value = await text({
1210
+ message: 'Sales channel ID (Medusa admin → Settings → Sales Channels):',
1211
+ defaultValue: '',
1212
+ })
1213
+ if (isCancel(value)) {
1214
+ cancel('Operation cancelled.')
1215
+ process.exit(0)
1216
+ }
1217
+ salesChannelId = value || null
1218
+ }
1219
+ outro('Connecting…')
1220
+ }
1221
+ backendUrl = (backendUrl ?? DEFAULT_BACKEND_URL).replace(/\/+$/, '')
1222
+
1223
+ // No publishable key: nothing to connect. Leave the store in mock mode and exit
1224
+ // successfully so `setup` is safe to run in CI/bootstrap without a key.
1225
+ if (!publishableKey) {
1226
+ console.log('storefront setup: no publishable key — leaving THOR_DATA_SOURCE=mock.')
1227
+ return 0
1228
+ }
1229
+
1230
+ // The sales channel is a developer-supplied input, never derived: the store API
1231
+ // cannot report it without a StorefrontConfig row existing for the channel, and the
1232
+ // runtime requires MEDUSA_SALES_CHANNEL_ID in real mode.
1233
+ if (!salesChannelId) {
1234
+ if (!tty) {
1235
+ console.error('storefront setup: --sales-channel-id is required without a terminal.')
1236
+ } else {
1237
+ console.error('storefront setup: a sales channel id is required to connect a backend.')
1238
+ }
1239
+ console.error('Find it in the Medusa admin (Settings → Sales Channels).')
1240
+ return 1
1241
+ }
1242
+
1243
+ // Resolve the region and its currency.
1244
+ let regions
1245
+ try {
1246
+ const res = await fetch(`${backendUrl}/store/regions`, {
1247
+ headers: { 'x-publishable-api-key': publishableKey },
1248
+ })
1249
+ if (!res.ok) {
1250
+ console.error(`storefront setup: GET ${backendUrl}/store/regions failed (${res.status}).`)
1251
+ console.error('Leaving the store in mock mode; no files were changed.')
1252
+ return 1
1253
+ }
1254
+ const body = await res.json()
1255
+ regions = Array.isArray(body?.regions) ? body.regions : []
1256
+ } catch (err) {
1257
+ console.error(`storefront setup: could not reach ${backendUrl} (${err instanceof Error ? err.message : String(err)}).`)
1258
+ console.error('Leaving the store in mock mode; no files were changed.')
1259
+ return 1
1260
+ }
1261
+
1262
+ if (regions.length === 0) {
1263
+ console.error('storefront setup: the backend returned no regions.')
1264
+ console.error('Leaving the store in mock mode; no files were changed.')
1265
+ return 1
1266
+ }
1267
+
1268
+ if (!regionId) {
1269
+ if (!tty) {
1270
+ console.error('storefront setup: --region-id is required without a terminal.')
1271
+ console.error(`Available regions: ${regions.map((region) => region.id).join(', ')}`)
1272
+ return 1
1273
+ }
1274
+ const value = await select({
1275
+ message: 'Region:',
1276
+ options: regions.map((region) => ({
1277
+ value: region.id,
1278
+ label: `${region.name ?? region.id} — ${region.currency_code ?? '?'}`,
1279
+ })),
1280
+ })
1281
+ if (isCancel(value)) {
1282
+ cancel('Operation cancelled.')
1283
+ process.exit(0)
1284
+ }
1285
+ regionId = value
1286
+ }
1287
+
1288
+ const region = regions.find((candidate) => candidate.id === regionId)
1289
+ if (!region) {
1290
+ console.error(`storefront setup: region "${regionId}" not found on the backend.`)
1291
+ console.error(`Available regions: ${regions.map((candidate) => candidate.id).join(', ')}`)
1292
+ return 1
1293
+ }
1294
+ const currencyCode = region.currency_code
1295
+ if (!currencyCode) {
1296
+ console.error(`storefront setup: region "${regionId}" has no currency_code.`)
1297
+ return 1
1298
+ }
1299
+
1300
+ // Upsert only the keys setup manages; preserve everything else (the session
1301
+ // secret, the site URL, the store identity, and any user additions).
1302
+ const managed = {
1303
+ THOR_DATA_SOURCE: 'medusa',
1304
+ MEDUSA_BASE_URL: backendUrl,
1305
+ MEDUSA_PUBLISHABLE_KEY: publishableKey,
1306
+ MEDUSA_REGION_ID: regionId,
1307
+ MEDUSA_SALES_CHANNEL_ID: salesChannelId,
1308
+ MEDUSA_CURRENCY_CODE: currencyCode,
1309
+ }
1310
+ const next = upsertEnv(readEnvFile(envPath), managed)
1311
+ await writeFile(envPath, next, 'utf8')
1312
+
1313
+ console.log('storefront setup — backend connected')
1314
+ console.log(` backend: ${backendUrl}`)
1315
+ console.log(` sales channel: ${salesChannelId}`)
1316
+ console.log(` region: ${regionId}`)
1317
+ console.log(` currency: ${currencyCode}`)
1318
+ console.log('Wrote .env.local. Restart the dev server to pick it up.')
451
1319
  return 0
452
1320
  }
453
1321
 
@@ -466,18 +1334,105 @@ async function update(args) {
466
1334
  return 1
467
1335
  }
468
1336
 
469
- const total = await installSuite(opencodeDir, args.force)
470
- for (const rel of PRUNED_PATHS) await prunePath(opencodeDir, rel)
471
- await writeRecord(target, pkgVersion())
1337
+ // Gate the existing composition before any install/prune/stamp write.
1338
+ const { manifest, error } = readManifestOrError(target)
1339
+ if (error) {
1340
+ console.error(`Refusing to update: ${error}`)
1341
+ return 1
1342
+ }
1343
+ if (manifest) {
1344
+ const preflight = preflightManifest(manifest)
1345
+ if (!preflight.ok) return rejectComposition(preflight.errors)
1346
+ }
1347
+
1348
+ // Validate every precomputable layer of the incoming pinning contract BEFORE
1349
+ // any write (declared ranges, resolved versions, lockfile, toolchain/linker),
1350
+ // so a bad range, a missing installed version, a CLI mismatch, a missing lock,
1351
+ // or a wrong linker leaves the project intact. Assets are validated after the
1352
+ // suite refresh at stamp time.
1353
+ const version = pkgVersion()
1354
+ const incoming = await validateIncomingContract(target, version)
1355
+ if (!incoming.ok) {
1356
+ console.error('Refusing to update: the incoming pinning contract is invalid.')
1357
+ for (const err of incoming.errors) console.error(` ${err}`)
1358
+ return 1
1359
+ }
1360
+
1361
+ // Read the previously recorded owned-file set BEFORE the refresh overwrites
1362
+ // the record. It is the only honest basis for retirement: the difference
1363
+ // between what the package shipped last time and what it ships now. A legacy
1364
+ // record has no owned-file set, so `recordedAssets` is null and no file is
1365
+ // retired from an undetermined set.
1366
+ const previousRecord = await readRecord(opencodeDir)
1367
+ const recordedAssets = recordedOwnedSet(previousRecord)
1368
+ const isLegacyRecord = previousRecord !== null && recordedAssets === null
1369
+
1370
+ // Refresh the package-managed suite to the current artifact set, retire the
1371
+ // recorded owned files the package no longer ships (only those that are
1372
+ // verifiably unmodified), prune the fixed legacy paths, reconcile derived
1373
+ // files, then stamp the record from what is actually installed. The managed
1374
+ // suite is package-owned, so it is always refreshed; user-owned storefront
1375
+ // files live outside it and are never touched here. A retirement candidate
1376
+ // that was modified and any unknown file are preserved.
1377
+ const total = await installSuite(opencodeDir, true)
1378
+ const { retired, preserved } = recordedAssets
1379
+ ? await retireObsoleteOwnedFiles(opencodeDir, recordedAssets)
1380
+ : { retired: [], preserved: [] }
1381
+ const prunedCommands = await pruneLegacySuitePaths(opencodeDir)
1382
+ if (manifest) {
1383
+ const result = await reconcile(target, manifest)
1384
+ if (!result.ok) return rejectComposition(result.errors)
1385
+ }
1386
+ // Re-run OpenSpec provisioning so the storefront's OpenSpec commands, skills,
1387
+ // and config track the pinned @fission-ai/openspec version. A failure is
1388
+ // surfaced as a diagnostic; the storefront refresh itself still completes.
1389
+ const provisioned = await provisionOpenSpec(target)
1390
+ // Stamp only a complete, actually-verified record. A failed copy or an
1391
+ // incoherent pin set is reported and never stamped as a clean version.
1392
+ const record = await writeRecord(target, version)
1393
+ if (!record.ok) {
1394
+ console.error('Refusing to stamp the install record: the pinning contract is not complete.')
1395
+ for (const err of record.errors) console.error(` ${err}`)
1396
+ return 1
1397
+ }
1398
+ const pinning = record.pinning
472
1399
  await rm(join(opencodeDir, LEGACY_RECORD_FILE), { force: true })
473
1400
 
1401
+ const remainingSkills = await remainingNonSuiteSkills(opencodeDir)
1402
+
474
1403
  console.log(`Refreshed install in ${target}:`)
475
1404
  console.log(` installed ${total} file(s)`)
476
- console.log(' pruned removed files (sf-init, sf-scaffold)')
477
- console.log(' migrated version record to storefront.json')
478
- console.log('Done. Available in OpenCode:')
479
- console.log(' /sf-add-view /sf-view /sf-theme')
480
- if (args.force) console.log('(used --force: existing files were overwritten)')
1405
+ if (retired.length > 0) {
1406
+ console.log(` retired ${retired.length} obsolete owned file(s):`)
1407
+ for (const rel of retired) console.log(` ${rel}`)
1408
+ }
1409
+ if (prunedCommands.length > 0) {
1410
+ console.log(` pruned legacy suite paths: ${prunedCommands.join(', ')}`)
1411
+ } else {
1412
+ console.log(' pruned legacy suite paths (skills/sf-scaffold, sf-*.md commands) when present')
1413
+ }
1414
+ if (provisioned.ok) {
1415
+ console.log(` provisioned OpenSpec ${provisioned.version} (openspec init --tools opencode)`)
1416
+ } else {
1417
+ console.error(` Warning: OpenSpec provisioning failed — ${provisioned.error}`)
1418
+ }
1419
+ console.log(` recorded version ${version} (${Object.keys(pinning.assets).length} managed asset(s))`)
1420
+ if (isLegacyRecord) {
1421
+ console.log(' install record: legacy format carries no owned-file set —')
1422
+ console.log(' retirement was limited to the fixed pruned paths; no undetermined file was removed.')
1423
+ }
1424
+ // A retirement candidate skipped because it was modified is named explicitly
1425
+ // so the developer can act on it (it is never silently left behind).
1426
+ for (const rel of preserved) {
1427
+ console.log(` skipped ${rel} — preserved because it was modified, not retired.`)
1428
+ }
1429
+ if (remainingSkills.length > 0) {
1430
+ console.log(`Migration incomplete — ${remainingSkills.length} skill(s) outside the current suite remain:`)
1431
+ for (const name of remainingSkills) console.log(` skills/${name}`)
1432
+ console.log('Resolve or remove them, then run "storefront update" again.')
1433
+ return 0
1434
+ }
1435
+ console.log('Done. The skill suite is installed; see AGENTS.md for the workflow.')
481
1436
  return 0
482
1437
  }
483
1438
 
@@ -489,25 +1444,1252 @@ async function doctor(args) {
489
1444
  return 1
490
1445
  }
491
1446
 
1447
+ let problems = 0
1448
+
1449
+ const { manifest, error } = readManifestOrError(target)
1450
+ if (error) {
1451
+ console.log(` composition: invalid — ${error}`)
1452
+ return 1
1453
+ }
1454
+ if (!manifest) {
1455
+ console.log(' composition: no storefront.config.json found (run "storefront sync" to derive one)')
1456
+ } else {
1457
+ // Dry-run reconcile reports only derived files that diverge; user-owned
1458
+ // files are never drift, so they are excluded by construction.
1459
+ const drift = await reconcile(target, manifest, { dryRun: true })
1460
+ if (!drift.ok) {
1461
+ console.log(' composition: invalid — the manifest is not a supported composition:')
1462
+ for (const err of drift.errors) console.log(` ${err}`)
1463
+ console.log(' status: fix storefront.config.json before running "storefront sync"')
1464
+ return 1
1465
+ }
1466
+ if (drift.changed.length === 0) {
1467
+ console.log(' composition: derived files match the manifest')
1468
+ } else {
1469
+ problems++
1470
+ console.log(` composition: ${drift.changed.length} derived file(s) drifted from the manifest:`)
1471
+ for (const rel of drift.changed) console.log(` ${rel}`)
1472
+ console.log(' status: run "storefront sync" to reconcile')
1473
+ }
1474
+
1475
+ // Data-bound section instances resolve their selection from a user-owned
1476
+ // `sectionSources` entry. `sync` never patches user files, so a freshly
1477
+ // added instance is unconfigured until the user supplies its entry; report
1478
+ // each gap with the file and instance id so the repair is actionable.
1479
+ const sectionConfig = auditSectionConfig({ dir: target, manifest, isDataBound: sectionIsDataBound })
1480
+ if (sectionConfig.diagnostics.length > 0) {
1481
+ problems++
1482
+ console.log(` section config: ${sectionConfig.diagnostics.length} data-bound section(s) need attention:`)
1483
+ for (const diag of sectionConfig.diagnostics) console.log(` ${diag.message}`)
1484
+ console.log(' status: supply the missing source configuration, then run "storefront sync"')
1485
+ }
1486
+ }
1487
+
492
1488
  const opencodeDir = join(target, '.opencode')
493
1489
  const record = await readRecord(opencodeDir)
494
1490
  if (!record) {
495
- console.log('No version record found in .opencode/.')
496
- console.log('Run `storefront update` to install and record the suite.')
497
- return 0
1491
+ console.log(' suite: no version record found in .opencode/')
1492
+ console.log(' status: run "storefront update" to install and record the suite')
1493
+ return problems === 0 ? 0 : 1
498
1494
  }
499
1495
 
500
1496
  const version = pkgVersion()
501
1497
  console.log(` recorded version: ${record.version} (installed ${record.installedAt})`)
502
1498
  console.log(` package version: ${version}`)
503
1499
 
504
- const installedHash = await hashDir(opencodeDir)
505
- const sourceHash = await hashDir(PKG_ROOT)
506
- const match = installedHash === sourceHash
507
- console.log(` installed suite matches package: ${match ? 'yes' : 'no'}`)
508
- if (!match) {
1500
+ const packageAssets = await hashManagedSuite(PKG_ROOT)
1501
+ console.log(` distributed assets: ${Object.keys(packageAssets).length} file(s)`)
1502
+
1503
+ // Truthful version: a record that claims a version the installed artifacts
1504
+ // do not match is a mismatch, not a clean stamp.
1505
+ if (record.version !== version) {
1506
+ problems++
1507
+ console.log(` version mismatch: record says "${record.version}" but the package is "${version}"`)
1508
+ }
1509
+
1510
+ // Compare the record against the live project evidence, not against itself.
1511
+ const collected = await collectLivePinning(target)
1512
+ if (!collected.ok) {
1513
+ problems++
1514
+ console.log(` pinning: cannot read the live project evidence — ${collected.error}`)
1515
+ } else {
1516
+ const tc = collected.live.toolchain
1517
+ console.log(` toolchain (observed): linker=${tc.linker} node=${tc.nodeVersion} yarn=${tc.yarnVersion}`)
1518
+ const pinning = validatePinning(record.pinning, {
1519
+ distributedVersion: version,
1520
+ live: collected.live,
1521
+ })
1522
+ if (!pinning.ok) {
1523
+ problems++
1524
+ console.log(' pinning: the compatibility/pinning contract does not match the live project:')
1525
+ for (const err of pinning.errors) console.log(` ${err}`)
1526
+ } else {
1527
+ console.log(' pinning: declared ranges, resolved versions, lockfile, and asset digests agree')
1528
+ }
1529
+ }
1530
+
1531
+ const installedAssets = await installedSuiteBom(target)
1532
+ const suiteMatches =
1533
+ Object.keys(installedAssets).length === Object.keys(packageAssets).length &&
1534
+ Object.entries(packageAssets).every(([rel, digest]) => installedAssets[rel] === digest)
1535
+ console.log(` installed suite matches package: ${suiteMatches ? 'yes' : 'no'}`)
1536
+ if (!suiteMatches) {
1537
+ problems++
509
1538
  console.log(' status: update available — run "storefront update"')
510
1539
  }
1540
+
1541
+ return problems === 0 ? 0 : 1
1542
+ }
1543
+
1544
+ // Spawn a command inheriting stdio, for the isolated shadcn preset run.
1545
+ function runInherit(cmd, cmdArgs, cwd) {
1546
+ const res = spawnSync(cmd, cmdArgs, { cwd, stdio: 'inherit', shell: false })
1547
+ return res.status ?? 1
1548
+ }
1549
+
1550
+ // The mode the project currently renders: manifest first (authoritative), then
1551
+ // the layout's ThemeProvider wiring, then the default.
1552
+ function detectLayoutMode(layout) {
1553
+ const m = layout.match(/defaultTheme="([^"]+)"/)
1554
+ return m && THEME_MODES.includes(m[1]) ? m[1] : null
1555
+ }
1556
+
1557
+ // Best-effort declarative font export: recover the family slug from the
1558
+ // `--font-<slug>-<slot>` variable a `next/font/google` loader declares.
1559
+ function detectExportFonts(layout) {
1560
+ const fonts = {}
1561
+ for (const slot of ['sans', 'mono', 'serif']) {
1562
+ const m = layout.match(new RegExp(`variable:\\s*'--font-([a-z0-9-]+)-${slot}'`))
1563
+ if (m) fonts[slot] = { kind: 'google', value: m[1] }
1564
+ }
1565
+ return fonts
1566
+ }
1567
+
1568
+ // Read-only inspection: current tokens, supported names, accepted formats.
1569
+ function themeInspect(args, { globals, currentMode }) {
1570
+ const tokens = readThemeCss(globals)
1571
+ if (args.json) {
1572
+ printEnvelope({
1573
+ success: true,
1574
+ command: 'theme',
1575
+ status: 'inspect',
1576
+ mode: currentMode,
1577
+ tokens: { light: tokens.light, dark: tokens.dark },
1578
+ supportedTokens: THEME_COLOR_TOKENS.map((t) => `--${t}`),
1579
+ colorNames: NAMED_COLOR_NAMES,
1580
+ colorFormats: ['<name>', '#hex', 'oklch(...)'],
1581
+ fontVars: FONT_VARS,
1582
+ })
1583
+ return 0
1584
+ }
1585
+ console.log(`Theme mode: ${currentMode}`)
1586
+ console.log(`Color formats: ${NAMED_COLOR_NAMES.join(', ')}, #hex, oklch(...)`)
1587
+ console.log(`Tokens: ${[...THEME_COLOR_ITEMS()].join(' ')}`)
1588
+ console.log(`Fonts: ${FONT_VARS.join(' ')}`)
1589
+ console.log('Fine-grained: --set light.primary=#f97316 (scopes: light, dark, shared)')
1590
+ return 0
1591
+ }
1592
+
1593
+ function THEME_COLOR_ITEMS() {
1594
+ return THEME_COLOR_TOKENS.map((t) => `--${t}`)
1595
+ }
1596
+
1597
+ function themeFail(args, diagnostics) {
1598
+ if (args.json) {
1599
+ printEnvelope({ success: false, command: 'theme', status: 'failed', changes: [], diagnostics })
1600
+ } else {
1601
+ for (const d of diagnostics) console.error(d.message)
1602
+ }
1603
+ return 1
1604
+ }
1605
+
1606
+ function themeReport(args, { status, changes, diagnostics, values }) {
1607
+ const diags = diagnostics ?? []
1608
+ if (args.json) {
1609
+ printEnvelope({ success: true, command: 'theme', status, changes, diagnostics: diags, values })
1610
+ return 0
1611
+ }
1612
+ if (status === 'noop') {
1613
+ console.log('Theme already up to date — no change.')
1614
+ } else if (status === 'dry-run') {
1615
+ console.log('Dry run — no files written. Planned changes:')
1616
+ for (const c of changes) console.log(` ${c}`)
1617
+ } else {
1618
+ for (const c of changes) console.log(`Updated ${c}`)
1619
+ }
1620
+ for (const d of diags) if (d.severity === 'warning') console.error(`warning: ${d.message}`)
1621
+ return 0
1622
+ }
1623
+
1624
+ // Write a portable theme snapshot. Read-only over the project: it never mutates
1625
+ // the storefront and refuses to clobber an existing file.
1626
+ async function themeExport(args, { globals, layout, currentMode }) {
1627
+ const outPath = resolve(process.cwd(), args.exportPath)
1628
+ if (existsSync(outPath)) {
1629
+ return themeFail(args, [
1630
+ envelopeDiagnostic('E_EXPORT_EXISTS', `Refusing to overwrite existing file: ${args.exportPath}`),
1631
+ ])
1632
+ }
1633
+ const data = buildThemeExport({
1634
+ tokens: readThemeCss(globals),
1635
+ mode: currentMode,
1636
+ fonts: detectExportFonts(layout),
1637
+ })
1638
+ await mkdir(dirname(outPath), { recursive: true })
1639
+ await writeFile(outPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
1640
+ if (args.json) {
1641
+ printEnvelope({ success: true, command: 'theme', status: 'exported', changes: [args.exportPath] })
1642
+ return 0
1643
+ }
1644
+ console.log(`Exported theme to ${args.exportPath}`)
1645
+ return 0
1646
+ }
1647
+
1648
+ // Resolve a `--import` source into a normalized importSource for planCss:
1649
+ // a local CSS export, a local JSON (registry:theme item or a prior storefront
1650
+ // export), or a remote registry:theme URL.
1651
+ async function resolveThemeImport(raw) {
1652
+ const kind = classifyImportSource(raw)
1653
+ try {
1654
+ if (kind === 'css' || kind === 'json') {
1655
+ const p = resolve(process.cwd(), raw)
1656
+ if (!existsSync(p)) {
1657
+ return { ok: false, diagnostics: [envelopeDiagnostic('E_IMPORT_PATH', `Import file not found: ${raw}`)] }
1658
+ }
1659
+ const text = readFileSync(p, 'utf8')
1660
+ if (kind === 'json' || text.trimStart().startsWith('{')) {
1661
+ const asExport = parseThemeExport(text)
1662
+ if (asExport.ok) {
1663
+ return {
1664
+ ok: true,
1665
+ importSource: { type: 'tokens', tokens: asExport.tokens },
1666
+ fonts: asExport.fonts ?? {},
1667
+ mode: asExport.mode ?? null,
1668
+ }
1669
+ }
1670
+ let json
1671
+ try {
1672
+ json = JSON.parse(text)
1673
+ } catch (err) {
1674
+ return { ok: false, diagnostics: [envelopeDiagnostic('E_IMPORT_JSON', `Invalid JSON in ${raw}: ${err.message}`)] }
1675
+ }
1676
+ const asRegistry = parseRegistryTheme(json)
1677
+ if (asRegistry.ok) {
1678
+ return { ok: true, importSource: { type: 'tokens', tokens: asRegistry.tokens }, fonts: asRegistry.fonts ?? {} }
1679
+ }
1680
+ return { ok: false, diagnostics: asRegistry.diagnostics }
1681
+ }
1682
+ const parsed = parseCssExport(text)
1683
+ if (!parsed.ok) return { ok: false, diagnostics: parsed.diagnostics }
1684
+ return { ok: true, importSource: { type: 'css', css: text } }
1685
+ }
1686
+ if (kind === 'registry-url') {
1687
+ const res = await fetch(raw)
1688
+ if (!res.ok) {
1689
+ return { ok: false, diagnostics: [envelopeDiagnostic('E_IMPORT_FETCH', `Failed to fetch ${raw}: HTTP ${res.status}`)] }
1690
+ }
1691
+ const parsed = parseRegistryTheme(await res.json())
1692
+ if (!parsed.ok) return { ok: false, diagnostics: parsed.diagnostics }
1693
+ return { ok: true, importSource: { type: 'tokens', tokens: parsed.tokens }, fonts: parsed.fonts ?? {} }
1694
+ }
1695
+ if (kind === 'preset-url') {
1696
+ return {
1697
+ ok: false,
1698
+ diagnostics: [
1699
+ envelopeDiagnostic('E_IMPORT_PRESET', `Use --preset for a builder/preset URL (got "${raw}")`),
1700
+ ],
1701
+ }
1702
+ }
1703
+ } catch (err) {
1704
+ return { ok: false, diagnostics: [envelopeDiagnostic('E_IMPORT', `Failed to read import "${raw}": ${err.message}`)] }
1705
+ }
1706
+ return { ok: false, diagnostics: [envelopeDiagnostic('E_IMPORT', `Unsupported import source: ${raw}`)] }
1707
+ }
1708
+
1709
+ // Complete theme control: mode + colors + radius + fonts + presets/imports +
1710
+ // inspect/export, all resolved into one validated, in-memory change plan over the
1711
+ // external files. Visual values live in app/globals.css; the mode stays in the
1712
+ // manifest. The whole operation is validated before any write, so a failure (or
1713
+ // --dry-run) leaves the project untouched.
1714
+ async function theme(args) {
1715
+ const target = args.dir
1716
+ if (!existsSync(target)) {
1717
+ console.error(`Target directory does not exist: ${target}`)
1718
+ return 1
1719
+ }
1720
+
1721
+ const layoutPath = join(target, 'app', 'layout.tsx')
1722
+ if (!existsSync(layoutPath)) {
1723
+ console.error(`No app/layout.tsx found in ${target}`)
1724
+ console.error('Run `storefront create <dir>` to scaffold the storefront first.')
1725
+ return 1
1726
+ }
1727
+ const globalsPath = join(target, 'app', 'globals.css')
1728
+ const globals = existsSync(globalsPath) ? readFileSync(globalsPath, 'utf8') : ''
1729
+ const layout = readFileSync(layoutPath, 'utf8')
1730
+
1731
+ // Gate the existing composition before any write (same contract as the other
1732
+ // scoped commands): never stamp an unsupported or invalid composition.
1733
+ const { manifest, error } = readManifestOrError(target)
1734
+ if (error) {
1735
+ console.error(`Refusing to change theme: ${error}`)
1736
+ return 1
1737
+ }
1738
+ if (manifest) {
1739
+ const preflight = preflightManifest(manifest)
1740
+ if (!preflight.ok) return rejectComposition(preflight.errors)
1741
+ }
1742
+
1743
+ const currentMode = manifest?.theme?.mode ?? detectLayoutMode(layout) ?? DEFAULT_THEME_MODE
1744
+ const mutationFlags = Boolean(
1745
+ args.mode ||
1746
+ args.brand ||
1747
+ args.primary ||
1748
+ args.accent ||
1749
+ args.radius ||
1750
+ args.fontSans ||
1751
+ args.fontMono ||
1752
+ args.fontSerif ||
1753
+ (args.sets && args.sets.length) ||
1754
+ args.preset ||
1755
+ args.importSource,
1756
+ )
1757
+
1758
+ // Inspect: explicit --inspect, or a bare `theme` invocation. Read-only.
1759
+ if (args.inspect || (!mutationFlags && !args.exportPath)) {
1760
+ return themeInspect(args, { globals, currentMode })
1761
+ }
1762
+
1763
+ // Export: portable snapshot, never mutates the project.
1764
+ if (args.exportPath) {
1765
+ return themeExport(args, { globals, layout, currentMode })
1766
+ }
1767
+
1768
+ // Resolve a local/remote import source. A versioned storefront export also
1769
+ // carries its recorded mode and declarative font selection, restored unless
1770
+ // the flags override them.
1771
+ let importSource = null
1772
+ let importFonts = {}
1773
+ let importMode = null
1774
+ if (args.importSource) {
1775
+ const resolved = await resolveThemeImport(args.importSource)
1776
+ if (!resolved.ok) return themeFail(args, resolved.diagnostics)
1777
+ importSource = resolved.importSource
1778
+ importFonts = resolved.fonts ?? {}
1779
+ importMode = resolved.mode ?? null
1780
+ }
1781
+
1782
+ // Resolve a shadcn preset through the pinned CLI in isolated staging.
1783
+ let presetFonts = {}
1784
+ if (args.preset) {
1785
+ const only = args.only ?? 'theme,font'
1786
+ const allowed = ['theme', 'font', 'theme,font']
1787
+ if (!allowed.includes(only)) {
1788
+ return themeFail(args, [
1789
+ envelopeDiagnostic('E_ONLY', `--only must be one of ${allowed.join(', ')} (got "${only}")`),
1790
+ ])
1791
+ }
1792
+ const runPreset = makeShadcnRunner({
1793
+ shadcnVersion: CANDIDATE_TOOLSET.shadcn,
1794
+ projectDir: target,
1795
+ run: runInherit,
1796
+ })
1797
+ const resolved = await resolvePreset(args.preset, { projectDir: target, runPreset })
1798
+ if (!resolved.ok) return themeFail(args, resolved.diagnostics)
1799
+ importSource = { type: 'tokens', tokens: resolved.tokens }
1800
+ presetFonts = resolved.fonts ?? {}
1801
+ }
1802
+
1803
+ // Normalize explicit color setters to OKLCH so the stylesheet keeps one
1804
+ // color space; non-color values (lengths, stacks) pass through untouched.
1805
+ const normalizedSets = (args.sets ?? []).map((entry) => {
1806
+ const parsed = parseSetter(entry)
1807
+ if (parsed.error) return entry
1808
+ const color = parseColor(parsed.value)
1809
+ return color ? `${parsed.scope}.${parsed.name}=${formatOklch(color)}` : entry
1810
+ })
1811
+
1812
+ const { operation, diagnostics } = normalizeOperation({
1813
+ mode: args.mode ?? importMode,
1814
+ brand: args.brand,
1815
+ primary: args.primary,
1816
+ accent: args.accent,
1817
+ radius: args.radius,
1818
+ fontSans: args.fontSans,
1819
+ fontMono: args.fontMono,
1820
+ fontSerif: args.fontSerif,
1821
+ sets: normalizedSets,
1822
+ preset: args.preset,
1823
+ presetOnly: args.only,
1824
+ importSource,
1825
+ dryRun: args.dryRun,
1826
+ })
1827
+ if (diagnostics.some((d) => d.severity === 'error')) return themeFail(args, diagnostics)
1828
+ for (const slot of ['sans', 'mono', 'serif']) {
1829
+ if (!operation.fonts[slot] && importFonts[slot]) operation.fonts[slot] = importFonts[slot]
1830
+ if (!operation.fonts[slot] && presetFonts[slot]) operation.fonts[slot] = presetFonts[slot]
1831
+ }
1832
+
1833
+ const planned = planCss(globals, operation)
1834
+ if (planned.diagnostics.some((d) => d.severity === 'error')) return themeFail(args, planned.diagnostics)
1835
+
1836
+ // Font wiring: layout loaders + the CSS variables that point at them. The
1837
+ // `font-*` utilities read the `@theme inline` mapping, so update that block
1838
+ // (falling back to `:root` when the stylesheet has no theme block).
1839
+ const fontResult = applyLayoutFonts(layout, operation.fonts)
1840
+ const fontVars = fontCssVars(operation.fonts, fontResult.loaderVars)
1841
+ const fontEdits = Object.entries(fontVars).map(([name, value]) => ({ name, value }))
1842
+ const nextGlobals = fontEdits.length
1843
+ ? applyBlockDeclarations(planned.css, '@theme inline', fontEdits)
1844
+ : planned.css
1845
+
1846
+ let nextLayout = fontResult.content
1847
+ if (operation.mode) nextLayout = injectThemeMode(nextLayout, operation.mode)
1848
+
1849
+ const changes = []
1850
+ if (nextGlobals !== globals) changes.push(relative(process.cwd(), globalsPath) || globalsPath)
1851
+ if (nextLayout !== layout) changes.push(relative(process.cwd(), layoutPath) || layoutPath)
1852
+ const modeChanged = Boolean(operation.mode) && operation.mode !== currentMode
1853
+ if (modeChanged) changes.push('storefront.config.json#theme.mode')
1854
+
1855
+ if (args.dryRun) {
1856
+ return themeReport(args, { status: 'dry-run', changes, diagnostics: planned.diagnostics, values: planned.values })
1857
+ }
1858
+ if (!changes.length) {
1859
+ return themeReport(args, { status: 'noop', changes: [], diagnostics: planned.diagnostics, values: planned.values })
1860
+ }
1861
+
1862
+ // Commit: manifest mode first (validated), then the external files, then
1863
+ // reconcile so every derived file agrees with the manifest.
1864
+ if (manifest && modeChanged) {
1865
+ manifest.theme = { ...manifest.theme, mode: operation.mode }
1866
+ const nextPreflight = preflightManifest(manifest)
1867
+ if (!nextPreflight.ok) return rejectComposition(nextPreflight.errors)
1868
+ await writeManifest(target, manifest)
1869
+ }
1870
+ if (nextGlobals !== globals) await writeFile(globalsPath, nextGlobals, 'utf8')
1871
+ if (nextLayout !== layout) await writeFile(layoutPath, nextLayout, 'utf8')
1872
+ if (manifest) {
1873
+ const result = await reconcile(target, manifest)
1874
+ if (!result.ok) return rejectComposition(result.errors)
1875
+ }
1876
+
1877
+ return themeReport(args, { status: 'changed', changes, diagnostics: planned.diagnostics, values: planned.values })
1878
+ }
1879
+
1880
+ async function sync(args) {
1881
+ const target = args.dir
1882
+ const { manifest, error } = readManifestOrError(target)
1883
+ if (error) {
1884
+ console.error(`Refusing to sync: ${error}`)
1885
+ return 1
1886
+ }
1887
+ if (!manifest) {
1888
+ // resolveProject already refuses legacy projects before sync runs; this is
1889
+ // a defensive guard so no code path can derive or migrate one.
1890
+ return rejectLegacyProject(target)
1891
+ }
1892
+
1893
+ // Validate the complete desired composition before touching any derived file.
1894
+ const preflight = preflightManifest(manifest)
1895
+ if (!preflight.ok) return rejectComposition(preflight.errors)
1896
+
1897
+ const result = await reconcile(target, manifest)
1898
+ if (!result.ok) return rejectComposition(result.errors)
1899
+ if (result.changed.length === 0) {
1900
+ console.log('No drift detected — the derived files match the manifest.')
1901
+ return 0
1902
+ }
1903
+ console.log(`Repaired ${result.changed.length} derived file(s) drifted from the manifest:`)
1904
+ for (const rel of result.changed) console.log(` ${rel}`)
1905
+ return 0
1906
+ }
1907
+
1908
+ // Add or remove a view through the shared operation engine. `view add|remove`
1909
+ // is a thin alias over `view.add`/`view.remove`, so it produces the same
1910
+ // manifest, derived files, and diagnostics as planning and applying the
1911
+ // equivalent operation (E1 task 4.1 / Decision 17).
1912
+ async function view(args) {
1913
+ const target = args.dir
1914
+ const sub = args.positionals[1]
1915
+ const name = args.positionals[2]
1916
+
1917
+ if (sub !== 'add' && sub !== 'remove') {
1918
+ console.error('Usage: storefront view <add|remove> <name> [--intent slot=intent] [--variant slot=Variant]')
1919
+ return 1
1920
+ }
1921
+ if (!name) {
1922
+ console.error(`storefront view ${sub} requires a view name.`)
1923
+ return 1
1924
+ }
1925
+
1926
+ const { manifest, error } = readManifestOrError(target)
1927
+ if (error) {
1928
+ console.error(`Refusing to modify views: ${error}`)
1929
+ return 1
1930
+ }
1931
+ if (!manifest) {
1932
+ console.error(`No storefront.config.json found in ${target}. Run \`storefront sync\` first.`)
1933
+ return 1
1934
+ }
1935
+
1936
+ // Bounded legacy cleanup (change: storefront-retire-user-panel, AC-UP-1.2): a
1937
+ // manifest written before a view was retired carries an entry the registry no
1938
+ // longer knows, so the normal reduce path refuses the input. `view remove
1939
+ // user-panel` is the single supported transition; every other unknown id
1940
+ // still fails closed through the normal path below.
1941
+ if (sub === 'remove' && isLegacyViewId(name)) {
1942
+ const result = await removeLegacyView(target, name)
1943
+ if (!result.ok) {
1944
+ if (args.json) {
1945
+ printEnvelope(resultEnvelope('view', false, 1, result.diagnostics))
1946
+ return 1
1947
+ }
1948
+ return rejectDiagnostics(result.diagnostics)
1949
+ }
1950
+ if (args.json) {
1951
+ printEnvelope(
1952
+ resultEnvelope('view', true, 0, [], {
1953
+ action: 'remove',
1954
+ view: name,
1955
+ changed: result.changed,
1956
+ preserved: result.preserved,
1957
+ alreadyRemoved: result.alreadyRemoved,
1958
+ }),
1959
+ )
1960
+ return 0
1961
+ }
1962
+ if (result.alreadyRemoved) {
1963
+ console.log(`View "${name}" is already absent; nothing to remove.`)
1964
+ return 0
1965
+ }
1966
+ console.log(`Removed legacy view "${name}".`)
1967
+ if (result.backup) console.log(` backed up ${result.backup}`)
1968
+ for (const leftover of result.preserved) {
1969
+ console.log(`Preserved user-owned file: ${leftover} (remove it manually if no longer needed).`)
1970
+ }
1971
+ if (result.changed.length > 0) console.log(` reconciled ${result.changed.length} derived file(s).`)
1972
+ return 0
1973
+ }
1974
+
1975
+ const present = manifest.views.some((v) => v.name === name)
1976
+ const operations =
1977
+ sub === 'add'
1978
+ ? [{ op: 'view.add', view: name, intents: args.intents, variants: args.variants }]
1979
+ : [{ op: 'view.remove', view: name }]
1980
+ const result = await executeOperations(target, manifest, operations, { force: args.force })
1981
+ if (!result.ok) {
1982
+ // `--json` emits the shared versioned envelope (same diagnostics the
1983
+ // plan/apply engine returns); the default human output is unchanged
1984
+ // (E1 task 4.16 / Decision 17).
1985
+ if (args.json) {
1986
+ printEnvelope(resultEnvelope('view', false, 1, result.diagnostics))
1987
+ return 1
1988
+ }
1989
+ return rejectDiagnostics(result.diagnostics)
1990
+ }
1991
+ if (args.json) {
1992
+ printEnvelope(
1993
+ resultEnvelope('view', true, 0, [], {
1994
+ action: sub,
1995
+ view: name,
1996
+ changed: result.changed,
1997
+ preserved: result.preserved,
1998
+ }),
1999
+ )
2000
+ return 0
2001
+ }
2002
+
2003
+ if (sub === 'add') {
2004
+ console.log(`${present ? 'Re-added' : 'Added'} view "${name}".`)
2005
+ } else {
2006
+ console.log(`Removed view "${name}".`)
2007
+ for (const leftover of result.preserved) {
2008
+ console.log(`Preserved user-owned file: ${leftover} (remove it manually if no longer needed).`)
2009
+ }
2010
+ if (result.preserved.length > 0) {
2011
+ console.log(` ${result.preserved.length} user-owned file(s) left in place.`)
2012
+ }
2013
+ }
2014
+ if (result.changed.length > 0) console.log(` reconciled ${result.changed.length} derived file(s).`)
2015
+ return 0
2016
+ }
2017
+
2018
+ // Add or remove a feature (a capability bundle) through the shared operation
2019
+ // engine. `feature add|remove` is a thin alias over `feature.add`/`feature.remove`
2020
+ // and preserves every flag (`--config`, `--force`), so it produces the same
2021
+ // manifest, derived files, and diagnostics as the plan/apply equivalent
2022
+ // (E1 task 4.1 / Decision 17).
2023
+ async function feature(args) {
2024
+ const target = args.dir
2025
+ const sub = args.positionals[1]
2026
+ const name = args.positionals[2]
2027
+ if (sub !== 'add' && sub !== 'remove') {
2028
+ console.error('Usage: storefront feature <add|remove> <name> [-f]')
2029
+ return 1
2030
+ }
2031
+ if (!name) {
2032
+ console.error(`storefront feature ${sub} requires a feature name.`)
2033
+ return 1
2034
+ }
2035
+ const { manifest, error } = readManifestOrError(target)
2036
+ if (error) {
2037
+ console.error(`Refusing to modify features: ${error}`)
2038
+ return 1
2039
+ }
2040
+ if (!manifest) {
2041
+ console.error(`No storefront.config.json found in ${target}. Run \`storefront sync\` first.`)
2042
+ return 1
2043
+ }
2044
+
2045
+ const present = manifest.features.some((f) => f.name === name)
2046
+ let operations
2047
+ if (sub === 'add') {
2048
+ let config
2049
+ if (args.config !== null && args.config !== undefined) {
2050
+ try {
2051
+ config = JSON.parse(args.config)
2052
+ } catch (err) {
2053
+ console.error(
2054
+ `--config expects a JSON object (${err instanceof Error ? err.message : String(err)}).`
2055
+ )
2056
+ return 1
2057
+ }
2058
+ }
2059
+ operations = [
2060
+ { op: 'feature.add', feature: name, ...(config === undefined ? {} : { config }) },
2061
+ ]
2062
+ } else {
2063
+ operations = [{ op: 'feature.remove', feature: name }]
2064
+ }
2065
+ const result = await executeOperations(target, manifest, operations, { force: args.force })
2066
+ if (!result.ok) {
2067
+ // `--json` emits the shared versioned envelope (same diagnostics the
2068
+ // plan/apply engine returns); the default human output is unchanged
2069
+ // (E1 task 4.16 / Decision 17).
2070
+ if (args.json) {
2071
+ printEnvelope(resultEnvelope('feature', false, 1, result.diagnostics))
2072
+ return 1
2073
+ }
2074
+ return rejectDiagnostics(result.diagnostics)
2075
+ }
2076
+ if (args.json) {
2077
+ printEnvelope(
2078
+ resultEnvelope('feature', true, 0, [], {
2079
+ action: sub,
2080
+ feature: name,
2081
+ changed: result.changed,
2082
+ preserved: result.preserved,
2083
+ }),
2084
+ )
2085
+ return 0
2086
+ }
2087
+
2088
+ if (sub === 'add') {
2089
+ console.log(`${present ? 'Re-added' : 'Added'} feature "${name}".`)
2090
+ } else {
2091
+ console.log(`Removed feature "${name}".`)
2092
+ for (const leftover of result.preserved) {
2093
+ console.log(`Preserved customized file: ${leftover} (remove it manually if no longer needed).`)
2094
+ }
2095
+ if (result.preserved.length > 0) console.log(` ${result.preserved.length} file(s) left in place.`)
2096
+ }
2097
+ if (result.changed.length > 0) console.log(` reconciled ${result.changed.length} derived file(s).`)
2098
+ return 0
2099
+ }
2100
+
2101
+ // Collect the position anchors supplied on the command line. Exactly one may be
2102
+ // given: `--after`/`--before` name a sibling id, `--first`/`--last` target an
2103
+ // extreme, and `--at` is the raw 0-based index escape hatch (change:
2104
+ // storefront-section-cli-alias / design D3).
2105
+ function readSectionAnchor(args) {
2106
+ const anchors = []
2107
+ if (args.after != null) anchors.push({ kind: 'after', id: args.after })
2108
+ if (args.before != null) anchors.push({ kind: 'before', id: args.before })
2109
+ if (args.first) anchors.push({ kind: 'first' })
2110
+ if (args.last) anchors.push({ kind: 'last' })
2111
+ if (args.at != null) anchors.push({ kind: 'at', value: args.at })
2112
+ return anchors
2113
+ }
2114
+
2115
+ // The ordered entry ids of a view as the engine sees them: the view's declared
2116
+ // `sections` normalized through the single `section-compat.mjs` authority (which
2117
+ // turns an empty list into the registered core slots). Returns null when the view
2118
+ // cannot be normalized, so the caller defers to the reducer and its canonical
2119
+ // diagnostic instead of inventing one.
2120
+ function orderedSectionIds(manifest, view) {
2121
+ const installed = (manifest.views ?? []).find((entry) => entry.name === view)
2122
+ const normalized = normalizeViewSectionsForView(view, installed?.sections ?? [])
2123
+ if (!normalized.ok) return null
2124
+ return normalized.sections.map((entry) => entry.id)
2125
+ }
2126
+
2127
+ // Translate an `add` anchor into the operation's 0-based `at`. An `--after`/
2128
+ // `--before` id that is absent fails closed with E_UNKNOWN_SECTION_REF before any
2129
+ // write; a view that cannot be normalized defers to the reducer (design D5).
2130
+ function resolveAddAnchor(manifest, view, anchor) {
2131
+ if (!anchor) return {}
2132
+ if (anchor.kind === 'at') {
2133
+ const value = Number(anchor.value)
2134
+ if (!Number.isInteger(value) || value < 0) {
2135
+ return {
2136
+ error: {
2137
+ code: 'E_INVALID_OPERATION',
2138
+ message: `--at expects a non-negative integer (got "${anchor.value}").`,
2139
+ },
2140
+ }
2141
+ }
2142
+ return { value }
2143
+ }
2144
+ const ids = orderedSectionIds(manifest, view)
2145
+ if (ids === null) return {}
2146
+ if (anchor.kind === 'first') return { value: 0 }
2147
+ if (anchor.kind === 'last') return { value: ids.length }
2148
+ const index = ids.indexOf(anchor.id)
2149
+ if (index === -1) {
2150
+ return {
2151
+ error: {
2152
+ code: 'E_UNKNOWN_SECTION_REF',
2153
+ message: `--${anchor.kind} names section "${anchor.id}" which is not present in view "${view}".`,
2154
+ },
2155
+ }
2156
+ }
2157
+ return { value: anchor.kind === 'after' ? index + 1 : index }
2158
+ }
2159
+
2160
+ // Build the full entry order for a `move`: drop the target from the normalized
2161
+ // list, resolve the destination against that reduced list, then reinsert. Unknown
2162
+ // target -> E_UNKNOWN_ORDER_REF (the same code a direct `section.reorder` emits);
2163
+ // unknown sibling anchor -> E_UNKNOWN_SECTION_REF; an unnormalizable view defers
2164
+ // to the reducer (change: storefront-section-cli-alias / design D4/D5).
2165
+ function buildMoveOrder(manifest, view, id, anchor) {
2166
+ const ids = orderedSectionIds(manifest, view)
2167
+ if (ids === null) return { order: [] }
2168
+ if (!ids.includes(id)) {
2169
+ return {
2170
+ error: {
2171
+ code: 'E_UNKNOWN_ORDER_REF',
2172
+ message: `section.reorder names id "${id}" which is not present in view "${view}".`,
2173
+ },
2174
+ }
2175
+ }
2176
+ const reduced = ids.filter((entryId) => entryId !== id)
2177
+ let index
2178
+ if (anchor.kind === 'at') {
2179
+ index = Number(anchor.value)
2180
+ if (!Number.isInteger(index) || index < 0 || index > reduced.length) {
2181
+ return {
2182
+ error: {
2183
+ code: 'E_INVALID_OPERATION',
2184
+ message: `--at must be an integer between 0 and ${reduced.length}.`,
2185
+ },
2186
+ }
2187
+ }
2188
+ } else if (anchor.kind === 'first') {
2189
+ index = 0
2190
+ } else if (anchor.kind === 'last') {
2191
+ index = reduced.length
2192
+ } else {
2193
+ const sibling = reduced.indexOf(anchor.id)
2194
+ if (sibling === -1) {
2195
+ return {
2196
+ error: {
2197
+ code: 'E_UNKNOWN_SECTION_REF',
2198
+ message: `--${anchor.kind} names section "${anchor.id}" which is not present in view "${view}".`,
2199
+ },
2200
+ }
2201
+ }
2202
+ index = anchor.kind === 'after' ? sibling + 1 : sibling
2203
+ }
2204
+ return { order: [...reduced.slice(0, index), id, ...reduced.slice(index)] }
2205
+ }
2206
+
2207
+ // Add, remove, or move a section instance through the shared operation engine.
2208
+ // `section add|remove|move` is a thin alias over `section.add`/`section.remove`/
2209
+ // `section.reorder`, threading SECTION_OPERATION_REGISTRY so the section ops are
2210
+ // recognized; it produces the same manifest, derived files, and diagnostics as
2211
+ // planning and applying the equivalent operations (change:
2212
+ // storefront-section-cli-alias / CU-17).
2213
+ async function section(args) {
2214
+ const target = args.dir
2215
+ const sub = args.positionals[1]
2216
+ const view = args.positionals[2]
2217
+ if (sub !== 'add' && sub !== 'remove' && sub !== 'move') {
2218
+ console.error('Usage: storefront section <add|remove|move> <view> [...]')
2219
+ return 1
2220
+ }
2221
+ if (!view) {
2222
+ console.error(`storefront section ${sub} requires a view name.`)
2223
+ return 1
2224
+ }
2225
+
2226
+ const anchors = readSectionAnchor(args)
2227
+ if (anchors.length > 1) {
2228
+ console.error(
2229
+ 'Only one position anchor may be given: --after, --before, --first, --last, --at.'
2230
+ )
2231
+ return 1
2232
+ }
2233
+ const anchor = anchors[0] ?? null
2234
+
2235
+ const { manifest, error } = readManifestOrError(target)
2236
+ if (error) {
2237
+ console.error(`Refusing to modify sections: ${error}`)
2238
+ return 1
2239
+ }
2240
+ if (!manifest) {
2241
+ console.error(`No storefront.config.json found in ${target}. Run \`storefront sync\` first.`)
2242
+ return 1
2243
+ }
2244
+
2245
+ // A section edit is deterministic; a usage/validation error is reported the
2246
+ // same way whether or not `--json` is set (the shared versioned envelope).
2247
+ const failWith = (code, message) => {
2248
+ const diagnostics = [envelopeDiagnostic(code, message)]
2249
+ if (args.json) {
2250
+ printEnvelope(resultEnvelope('section', false, 1, diagnostics))
2251
+ return 1
2252
+ }
2253
+ return rejectDiagnostics(diagnostics)
2254
+ }
2255
+
2256
+ let operations
2257
+ if (sub === 'add') {
2258
+ const type = args.positionals[3]
2259
+ if (!type) return failWith('E_INVALID_OPERATION', 'storefront section add requires a section type.')
2260
+ const op = { op: 'section.add', view, type }
2261
+ if (args.id != null) op.id = args.id
2262
+ if (args.as != null) op.variant = args.as
2263
+ const placement = resolveAddAnchor(manifest, view, anchor)
2264
+ if (placement.error) return failWith(placement.error.code, placement.error.message)
2265
+ if (placement.value != null) op.at = placement.value
2266
+ operations = [op]
2267
+ } else if (sub === 'remove') {
2268
+ const id = args.positionals[3]
2269
+ if (!id) return failWith('E_INVALID_OPERATION', 'storefront section remove requires a section id.')
2270
+ if (anchor) {
2271
+ return failWith('E_INVALID_OPERATION', 'storefront section remove does not take a position anchor.')
2272
+ }
2273
+ if (args.id != null) {
2274
+ return failWith('E_INVALID_OPERATION', 'storefront section remove takes the id positionally, not via --id.')
2275
+ }
2276
+ operations = [{ op: 'section.remove', view, id }]
2277
+ } else {
2278
+ const id = args.positionals[3]
2279
+ if (!id) return failWith('E_INVALID_OPERATION', 'storefront section move requires a section id.')
2280
+ if (!anchor) {
2281
+ return failWith(
2282
+ 'E_INVALID_OPERATION',
2283
+ 'storefront section move requires a destination (--after, --before, --first, --last, --at).'
2284
+ )
2285
+ }
2286
+ const built = buildMoveOrder(manifest, view, id, anchor)
2287
+ if (built.error) return failWith(built.error.code, built.error.message)
2288
+ operations = [{ op: 'section.reorder', view, order: built.order }]
2289
+ }
2290
+
2291
+ const result = await executeOperations(target, manifest, operations, {
2292
+ force: args.force,
2293
+ registry: SECTION_OPERATION_REGISTRY,
2294
+ })
2295
+ if (!result.ok) {
2296
+ if (args.json) {
2297
+ printEnvelope(resultEnvelope('section', false, 1, result.diagnostics))
2298
+ return 1
2299
+ }
2300
+ return rejectDiagnostics(result.diagnostics)
2301
+ }
2302
+ if (args.json) {
2303
+ printEnvelope(
2304
+ resultEnvelope('section', true, 0, [], {
2305
+ action: sub,
2306
+ view,
2307
+ changed: result.changed,
2308
+ preserved: result.preserved,
2309
+ }),
2310
+ )
2311
+ return 0
2312
+ }
2313
+
2314
+ if (sub === 'add') {
2315
+ console.log(`Added a "${args.positionals[3]}" section to view "${view}".`)
2316
+ } else if (sub === 'remove') {
2317
+ console.log(`Removed section "${args.positionals[3]}" from view "${view}".`)
2318
+ for (const leftover of result.preserved) {
2319
+ console.log(`Preserved user-owned file: ${leftover} (remove it manually if no longer needed).`)
2320
+ }
2321
+ } else {
2322
+ console.log(`Moved section "${args.positionals[3]}" in view "${view}".`)
2323
+ }
2324
+ if (result.changed.length > 0) console.log(` reconciled ${result.changed.length} derived file(s).`)
2325
+ return 0
2326
+ }
2327
+
2328
+ // List the section instances of installed views, in order, as the engine sees
2329
+ // them (the declared list normalized through section-compat). Read-only and
2330
+ // deterministic — it writes no file (change: storefront-section-cli-alias /
2331
+ // task 3.1). `--json` mirrors the `views` envelope shape.
2332
+ async function sections(args) {
2333
+ const target = args.dir
2334
+ const manifest = await readManifest(target)
2335
+ const installed = manifest?.views ?? []
2336
+ const requested = args.positionals[1] ?? null
2337
+ if (requested && !installed.some((view) => view.name === requested)) {
2338
+ console.error(`View "${requested}" is not installed in ${target}.`)
2339
+ return 1
2340
+ }
2341
+ const selected = requested ? installed.filter((view) => view.name === requested) : installed
2342
+
2343
+ const entries = selected.map((view) => {
2344
+ const normalized = normalizeViewSectionsForView(view.name, view.sections ?? [])
2345
+ const list = normalized.ok ? normalized.sections : []
2346
+ return {
2347
+ name: view.name,
2348
+ installed: true,
2349
+ sections: list.map((entry) => ({
2350
+ id: entry.id,
2351
+ type: entry.type,
2352
+ variant: entry.variant ?? null,
2353
+ })),
2354
+ }
2355
+ })
2356
+
2357
+ if (args.json) {
2358
+ console.log(
2359
+ JSON.stringify(
2360
+ { catalogVersion: CATALOG_VERSION, sectionTypes: sectionRegistry(), views: entries },
2361
+ null,
2362
+ 2,
2363
+ ),
2364
+ )
2365
+ return 0
2366
+ }
2367
+
2368
+ console.log(`Storefront sections (${target}):`)
2369
+ for (const entry of entries) {
2370
+ console.log(` ${entry.name}`)
2371
+ for (const instance of entry.sections) {
2372
+ const variant = instance.variant ? ` (${instance.variant})` : ''
2373
+ console.log(` ${instance.id.padEnd(24)} ${instance.type}${variant}`)
2374
+ }
2375
+ }
2376
+ return 0
2377
+ }
2378
+
2379
+ // Build a diagnostic for the result/error envelope. `path`, when present, is a
2380
+ // project-root-relative POSIX file path; operation-field diagnostics use a
2381
+ // semantic locator instead (e.g. `operations[0].surprise`), never a filesystem
2382
+ // path (E1 task 4.11).
2383
+ function envelopeDiagnostic(code, message, extra = {}) {
2384
+ return { code, severity: 'error', message, ...extra }
2385
+ }
2386
+
2387
+ // Print a versioned result/error envelope. Errors are always emitted as the
2388
+ // envelope so a machine caller can read the stable code and exit behavior.
2389
+ function printEnvelope(envelope) {
2390
+ console.log(JSON.stringify(envelope, null, 2))
2391
+ }
2392
+
2393
+ // Read-only, deterministic report of the versioned catalog and the current
2394
+ // composition. Writes nothing (E1 task 4.1 foundation; tasks 4.2/4.11 harden
2395
+ // the catalog/capability surface and the result envelope).
2396
+ function inspect(args) {
2397
+ const target = args.dir
2398
+ let manifest = null
2399
+ if (hasManifest(target)) {
2400
+ const read = readManifestOrError(target)
2401
+ if (read.error) {
2402
+ printEnvelope(
2403
+ resultEnvelope('inspect', false, 1, [
2404
+ envelopeDiagnostic('E_INVALID_COMPOSITION', read.error, {
2405
+ path: 'storefront.config.json',
2406
+ }),
2407
+ ]),
2408
+ )
2409
+ return 1
2410
+ }
2411
+ manifest = read.manifest
2412
+ }
2413
+ const catalog = loadCatalog()
2414
+ const result = {
2415
+ catalogVersion: CATALOG_VERSION,
2416
+ slots: catalog.slots ?? {},
2417
+ capabilities: catalog.capabilities ?? {},
2418
+ composition: {
2419
+ views: manifest?.views ?? [],
2420
+ features: manifest?.features ?? [],
2421
+ },
2422
+ dataBoundSections: collectDataBoundInstances(manifest, sectionIsDataBound),
2423
+ }
2424
+ const envelope = resultEnvelope('inspect', true, 0, [], result)
2425
+ if (args.json) {
2426
+ printEnvelope(envelope)
2427
+ return 0
2428
+ }
2429
+ console.log(`Storefront inspect (${target}):`)
2430
+ console.log(` catalog version: ${result.catalogVersion}`)
2431
+ console.log(` slots: ${Object.keys(result.slots).length}`)
2432
+ console.log(` views: ${result.composition.views.map((v) => v.name).join(', ') || '(none)'}`)
2433
+ console.log(` features: ${result.composition.features.map((f) => f.name).join(', ') || '(none)'}`)
2434
+ console.log(
2435
+ ` data-bound: ${result.dataBoundSections.map((s) => `${s.id} (${s.type})`).join(', ') || '(none)'}`,
2436
+ )
2437
+ return 0
2438
+ }
2439
+
2440
+ // Plan a whole intended state from an operations envelope. Validates every
2441
+ // operation against the resulting complete state and reports all errors; on any
2442
+ // failure it emits no plan and writes nothing. Without `--out` this is fully
2443
+ // read-only and always prints the JSON envelope; with `--out` it writes only the
2444
+ // plan artifact and prints a human summary unless `--json` is given
2445
+ // (E1 tasks 4.1 and 4.3; task 4.4 hardens identity).
2446
+ async function plan(args) {
2447
+ if (!args.operations) {
2448
+ printEnvelope(
2449
+ resultEnvelope('plan', false, 1, [
2450
+ envelopeDiagnostic(
2451
+ 'E_INVALID_ENVELOPE',
2452
+ 'Usage: storefront plan --operations <file> [--out <file>] [--json]',
2453
+ ),
2454
+ ]),
2455
+ )
2456
+ return 1
2457
+ }
2458
+ // `--operations` resolves against the consumer cwd, not the project root
2459
+ // (Decision 12 / E1 task 4.11); the project root is found upward separately.
2460
+ const operationsPath = resolve(args.operations)
2461
+ let raw
2462
+ try {
2463
+ raw = JSON.parse(readFileSync(operationsPath, 'utf8'))
2464
+ } catch (err) {
2465
+ printEnvelope(
2466
+ resultEnvelope('plan', false, 1, [
2467
+ envelopeDiagnostic(
2468
+ 'E_INVALID_ENVELOPE',
2469
+ `Cannot read operations envelope file ${operationsPath}: ${err instanceof Error ? err.message : String(err)}`,
2470
+ ),
2471
+ ]),
2472
+ )
2473
+ return 1
2474
+ }
2475
+ const { envelope, exitCode } = await planProject(args.dir, raw, {
2476
+ outPath: args.out ? resolve(args.out) : null,
2477
+ operationsPath,
2478
+ registry: SECTION_OPERATION_REGISTRY,
2479
+ })
2480
+ if (args.json || !args.out || exitCode !== 0) {
2481
+ printEnvelope(envelope)
2482
+ } else {
2483
+ console.log('storefront plan — valid')
2484
+ console.log(` before: ${envelope.result.beforeFingerprint}`)
2485
+ console.log(` after: ${envelope.result.afterFingerprint}`)
2486
+ console.log(` wrote plan artifact: ${resolve(args.out)}`)
2487
+ }
2488
+ return exitCode
2489
+ }
2490
+
2491
+ // Apply a recorded plan (E1 task 4.1 foundation; tasks 4.5-4.19 harden the
2492
+ // three-state resolution, lock, and rollback branches).
2493
+ async function apply(args) {
2494
+ if (!args.plan) {
2495
+ printEnvelope(
2496
+ resultEnvelope('apply', false, 1, [
2497
+ envelopeDiagnostic('E_PLAN_NOT_FOUND', 'Usage: storefront apply --plan <file> [--json]'),
2498
+ ]),
2499
+ )
2500
+ return 1
2501
+ }
2502
+ // `--plan` resolves against the consumer cwd, not the project root
2503
+ // (Decision 12 / E1 task 4.11).
2504
+ const planPath = resolve(args.plan)
2505
+ let artifact
2506
+ try {
2507
+ artifact = JSON.parse(readFileSync(planPath, 'utf8'))
2508
+ } catch (err) {
2509
+ const code = err?.code === 'ENOENT' ? 'E_PLAN_NOT_FOUND' : 'E_PLAN_INVALID'
2510
+ printEnvelope(
2511
+ resultEnvelope('apply', false, 1, [
2512
+ envelopeDiagnostic(
2513
+ code,
2514
+ `Cannot read plan file ${planPath}: ${err instanceof Error ? err.message : String(err)}`,
2515
+ ),
2516
+ ]),
2517
+ )
2518
+ return 1
2519
+ }
2520
+ const { envelope, exitCode } = await applyProject(args.dir, artifact, {
2521
+ registry: SECTION_OPERATION_REGISTRY,
2522
+ })
2523
+ if (args.json || exitCode !== 0) {
2524
+ printEnvelope(envelope)
2525
+ } else {
2526
+ console.log(`storefront apply — ${envelope.result?.state ?? 'ok'}`)
2527
+ }
2528
+ return exitCode
2529
+ }
2530
+
2531
+ // List the view registry — the recipes and intents the CLI validates against —
2532
+ // annotated with whether each view is installed in the current storefront.
2533
+ async function views(args) {
2534
+ const target = args.dir
2535
+ const manifest = await readManifest(target)
2536
+ const installed = new Set((manifest?.views ?? []).map((v) => v.name))
2537
+ const { views: recipes } = loadRecipes()
2538
+ const { slots } = loadArchetypes()
2539
+
2540
+ const entries = recipes.map((recipe) => {
2541
+ const intents = {}
2542
+ for (const [key, value] of Object.entries(slots ?? {})) {
2543
+ if (!key.startsWith(`${recipe.name}.`)) continue
2544
+ intents[key.slice(recipe.name.length + 1)] = value
2545
+ }
2546
+ return {
2547
+ name: recipe.name,
2548
+ description: recipe.description,
2549
+ route: recipe.route,
2550
+ loader: recipe.loader,
2551
+ requiredDataKeys: recipe.requiredDataKeys ?? [],
2552
+ installed: installed.has(recipe.name),
2553
+ supported: isCuratedView(recipe.name),
2554
+ defaultVariants: recipe.defaultVariants ?? {},
2555
+ // Allowed/supported are derived from the single semantic catalog; a held
2556
+ // variant is reported (with its reason) under `catalog` and never listed
2557
+ // as selectable here.
2558
+ allowedVariants: allowedVariantsForView(recipe.name),
2559
+ supportedVariants: supportedVariantsForView(recipe.name),
2560
+ supportedIntents: supportedIntents(recipe.name),
2561
+ catalog: viewCatalog(recipe.name),
2562
+ optionalSections: recipe.optionalSections ?? [],
2563
+ // E3 section registry (CU-14/CU-16): the ordered, non-removable core
2564
+ // presentation slots, the host allowlist, and the editorial-instance
2565
+ // technical bound. Reported so an agent sees the available hosts without
2566
+ // reading the JSON registry.
2567
+ registeredSlots: recipe.registeredSlots ?? [],
2568
+ allowedSections: recipe.allowedSections ?? [],
2569
+ maxSections: recipe.maxSections ?? 4,
2570
+ intents,
2571
+ }
2572
+ })
2573
+
2574
+ if (args.json) {
2575
+ console.log(
2576
+ JSON.stringify(
2577
+ { catalogVersion: CATALOG_VERSION, sectionTypes: sectionRegistry(), views: entries },
2578
+ null,
2579
+ 2,
2580
+ ),
2581
+ )
2582
+ return 0
2583
+ }
2584
+
2585
+ console.log(`Storefront views (${target}):`)
2586
+ for (const entry of entries) {
2587
+ const status = entry.installed ? 'installed' : entry.supported ? 'available' : 'held'
2588
+ console.log(` ${entry.name.padEnd(12)} ${status.padEnd(10)} ${entry.route}`)
2589
+ }
2590
+ return 0
2591
+ }
2592
+
2593
+ // List the feature registry — the capability bundles the CLI validates against —
2594
+ // annotated with whether each feature is installed in the current storefront.
2595
+ // Held (non-MVP) features are reported as held, never as available.
2596
+ async function features(args) {
2597
+ const target = args.dir
2598
+ const manifest = await readManifest(target)
2599
+ const installed = new Set((manifest?.features ?? []).map((f) => f.name))
2600
+ const { features: specs } = loadFeatures()
2601
+
2602
+ const entries = specs.map((spec) => ({
2603
+ name: spec.name,
2604
+ description: spec.description,
2605
+ mvp: spec.mvp === true,
2606
+ installed: installed.has(spec.name),
2607
+ route: spec.route ?? null,
2608
+ views: spec.views ?? [],
2609
+ state: spec.state ?? [],
2610
+ handlers: spec.handlers ?? [],
2611
+ adapter: spec.adapter ?? null,
2612
+ config: spec.config ?? null,
2613
+ presentations: spec.presentations ?? {},
2614
+ mounts: spec.mounts ?? [],
2615
+ surfaces: featureSurfaces(spec),
2616
+ }))
2617
+
2618
+ if (args.json) {
2619
+ console.log(JSON.stringify({ features: entries }, null, 2))
2620
+ return 0
2621
+ }
2622
+
2623
+ console.log(`Storefront features (${target}):`)
2624
+ for (const entry of entries) {
2625
+ const status = entry.installed ? 'installed' : entry.mvp ? 'available' : 'held'
2626
+ const owned = entry.views.join(', ') || '—'
2627
+ console.log(` ${entry.name.padEnd(16)} ${status.padEnd(10)} ${owned}`)
2628
+ }
2629
+ return 0
2630
+ }
2631
+
2632
+ // Print the whole curated registry — views and features — deterministically, so
2633
+ // agents can introspect what the CLI will accept without reading the JSON files.
2634
+ async function recipes(args) {
2635
+ const { views: viewRecipes } = loadRecipes()
2636
+ const { features: featureSpecs } = loadFeatures()
2637
+
2638
+ const views = viewRecipes.map((recipe) => {
2639
+ return {
2640
+ name: recipe.name,
2641
+ description: recipe.description,
2642
+ route: recipe.route,
2643
+ loader: recipe.loader,
2644
+ mvp: recipe.mvp === true,
2645
+ defaultVariants: recipe.defaultVariants ?? {},
2646
+ // Derived from the single semantic catalog: selectable variants only,
2647
+ // with held variants visible (and reasoned) under `catalog`.
2648
+ allowedVariants: allowedVariantsForView(recipe.name),
2649
+ supportedVariants: supportedVariantsForView(recipe.name),
2650
+ supportedIntents: supportedIntents(recipe.name),
2651
+ catalog: viewCatalog(recipe.name),
2652
+ optionalSections: recipe.optionalSections ?? [],
2653
+ // E3 section registry (CU-14/CU-16): core slots, host allowlist, and the
2654
+ // editorial-instance technical bound, per view.
2655
+ registeredSlots: recipe.registeredSlots ?? [],
2656
+ allowedSections: recipe.allowedSections ?? [],
2657
+ maxSections: recipe.maxSections ?? 4,
2658
+ }
2659
+ })
2660
+ const features = featureSpecs.map((spec) => ({
2661
+ name: spec.name,
2662
+ description: spec.description,
2663
+ mvp: spec.mvp === true,
2664
+ route: spec.route ?? null,
2665
+ views: spec.views ?? [],
2666
+ state: spec.state ?? [],
2667
+ handlers: spec.handlers ?? [],
2668
+ adapter: spec.adapter ?? null,
2669
+ config: spec.config ?? null,
2670
+ presentations: spec.presentations ?? {},
2671
+ mounts: spec.mounts ?? [],
2672
+ surfaces: featureSurfaces(spec),
2673
+ }))
2674
+
2675
+ if (args.json) {
2676
+ console.log(
2677
+ JSON.stringify(
2678
+ {
2679
+ catalogVersion: CATALOG_VERSION,
2680
+ sectionTypes: sectionRegistry(),
2681
+ recipes: { views, features },
2682
+ },
2683
+ null,
2684
+ 2,
2685
+ ),
2686
+ )
2687
+ return 0
2688
+ }
2689
+
2690
+ console.log('Storefront recipes:')
2691
+ console.log(` views ${views.map((v) => v.name).join(', ')}`)
2692
+ console.log(` features ${features.map((f) => f.name).join(', ')}`)
511
2693
  return 0
512
2694
  }
513
2695
 
@@ -519,13 +2701,107 @@ async function main() {
519
2701
  return 0
520
2702
  }
521
2703
  if (args.action === 'create') return create(args)
522
- if (args.action === 'update') return update(args)
523
- if (args.action === 'doctor') return doctor(args)
2704
+ if (args.action === 'view') {
2705
+ const root = resolveProject()
2706
+ if (!root) return 1
2707
+ return view({ ...args, dir: root })
2708
+ }
2709
+ if (args.action === 'views') {
2710
+ const root = resolveProject()
2711
+ if (!root) return 1
2712
+ return views({ ...args, dir: root })
2713
+ }
2714
+ if (args.action === 'features') {
2715
+ const root = resolveProject()
2716
+ if (!root) return 1
2717
+ return features({ ...args, dir: root })
2718
+ }
2719
+ if (args.action === 'recipes') {
2720
+ const root = resolveProject()
2721
+ if (!root) return 1
2722
+ return recipes({ ...args, dir: root })
2723
+ }
2724
+ if (args.action === 'feature') {
2725
+ const root = resolveProject()
2726
+ if (!root) return 1
2727
+ return feature({ ...args, dir: root })
2728
+ }
2729
+ if (args.action === 'section') {
2730
+ const root = resolveProject()
2731
+ if (!root) return 1
2732
+ return section({ ...args, dir: root })
2733
+ }
2734
+ if (args.action === 'sections') {
2735
+ const root = resolveProject()
2736
+ if (!root) return 1
2737
+ return sections({ ...args, dir: root })
2738
+ }
2739
+ if (args.action === 'inspect' || args.action === 'plan' || args.action === 'apply') {
2740
+ const root = resolveProject({ quiet: true })
2741
+ if (!root) {
2742
+ // Every inspect/plan/apply invocation emits a versioned error envelope,
2743
+ // including "not inside a project" (E1 task 4.11).
2744
+ printEnvelope(
2745
+ resultEnvelope(args.action, false, 1, [
2746
+ envelopeDiagnostic(
2747
+ 'E_NO_MANIFEST',
2748
+ 'Not inside a storefront project (no storefront.config.json found in this directory or any parent).',
2749
+ ),
2750
+ ]),
2751
+ )
2752
+ return 1
2753
+ }
2754
+ if (args.action === 'inspect') return inspect({ ...args, dir: root })
2755
+ if (args.action === 'plan') return plan({ ...args, dir: root })
2756
+ return apply({ ...args, dir: root })
2757
+ }
2758
+
2759
+ const SCOPED = ['theme', 'update', 'doctor', 'sync', 'setup']
2760
+ if (SCOPED.includes(args.action)) {
2761
+ if (args.positionals.length > 1) {
2762
+ console.error(`storefront ${args.action} does not take a directory; run it from inside the storefront project.`)
2763
+ return 1
2764
+ }
2765
+ const root = resolveProject()
2766
+ if (!root) return 1
2767
+ const scoped = { ...args, dir: root }
2768
+ if (args.action === 'theme') return theme(scoped)
2769
+ if (args.action === 'update') return update(scoped)
2770
+ if (args.action === 'sync') return sync(scoped)
2771
+ if (args.action === 'setup') return setup(scoped)
2772
+ return doctor(scoped)
2773
+ }
2774
+
524
2775
  console.error(`Unknown action: ${args.action}`)
525
2776
  console.log(HELP)
526
2777
  return 1
527
2778
  }
528
2779
 
529
- main().then((code) => {
530
- process.exitCode = code
531
- })
2780
+ main()
2781
+ .then((code) => {
2782
+ process.exitCode = code
2783
+ })
2784
+ .catch((error) => {
2785
+ // An unexpected throw that escaped a handler is an internal error: emit a
2786
+ // sanitized versioned error envelope and exit 2. No stack, absolute path,
2787
+ // or captured value is printed to stdout; the error name/message is logged
2788
+ // (not swallowed) so the failure is still diagnosable.
2789
+ const command = process.argv[2] ?? 'unknown'
2790
+ const name = error instanceof Error ? error.name : 'Error'
2791
+ const rawMessage = error instanceof Error ? error.message : String(error)
2792
+ // Redact the consumer cwd and the OS temp root so the log cannot leak an
2793
+ // absolute machine path.
2794
+ const message = rawMessage.split(process.cwd()).join('<cwd>').split(tmpdir()).join('<tmp>')
2795
+ console.error(`storefront ${command}: unexpected internal error (${name}): ${message}`)
2796
+ printEnvelope(
2797
+ resultEnvelope(command, false, 2, [
2798
+ {
2799
+ code: 'E_UNEXPECTED',
2800
+ severity: 'error',
2801
+ message: 'unexpected internal error',
2802
+ detail: { name },
2803
+ },
2804
+ ]),
2805
+ )
2806
+ process.exitCode = 2
2807
+ })