@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
@@ -0,0 +1,51 @@
1
+ // Shared preflight for every structural write boundary.
2
+ //
3
+ // A composition is only accepted once it passes all layers:
4
+ // 1. shape — the manifest validates against the published JSON Schema
5
+ // (`recipes/manifest.schema.json`),
6
+ // 2. path safety — the project name is joined into filesystem paths
7
+ // (`lib/<name>/...`), so it must satisfy the same kebab-case contract the
8
+ // CLI produces (`toKebab`), and
9
+ // 3. semantics — every unit, variant, slot, section, and feature/view
10
+ // dependency resolves against the registry (`recipes/validate.mjs`).
11
+ //
12
+ // Callers MUST reject (write nothing) when the result is not ok.
13
+
14
+ import { validateManifest } from './manifest.mjs'
15
+ import { validateCompositionManifest } from '../recipes/validate.mjs'
16
+ import { isValidIdentifier } from './render-templates.mjs'
17
+ import { toCamel } from './composers.mjs'
18
+
19
+ // Mirrors `toKebab` output in scripts/render-templates.mjs: lowercase letters,
20
+ // digits, and single hyphens. `manifest.name` is joined into paths by
21
+ // `reconcile`, so a value like "../escape" must never reach a write.
22
+ const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
23
+
24
+ export function preflightManifest(manifest, options = {}) {
25
+ const shape = validateManifest(manifest)
26
+ if (!shape.ok) return shape
27
+
28
+ const errors = []
29
+ if (typeof manifest.name === 'string' && !SAFE_NAME.test(manifest.name)) {
30
+ errors.push(
31
+ `Manifest "name" must be a kebab-case identifier (lowercase letters, digits, and single hyphens): got "${manifest.name}".`
32
+ )
33
+ }
34
+
35
+ // Every installed unit becomes an exported binding in generated wiring
36
+ // (views barrel, feature composers). Reject any name that cannot yield a
37
+ // valid JS identifier before a derived file is written.
38
+ for (const view of manifest.views) {
39
+ if (!isValidIdentifier(toCamel(view.name))) {
40
+ errors.push(`View "${view.name}" does not yield a valid export identifier.`)
41
+ }
42
+ }
43
+ for (const feature of manifest.features) {
44
+ if (!isValidIdentifier(toCamel(feature.name))) {
45
+ errors.push(`Feature "${feature.name}" does not yield a valid export identifier.`)
46
+ }
47
+ }
48
+
49
+ const semantic = validateCompositionManifest(manifest, options)
50
+ return { ok: errors.length === 0 && semantic.ok, errors: [...errors, ...semantic.errors] }
51
+ }
@@ -0,0 +1,549 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import {
6
+ templatesRoot,
7
+ replacePlaceholders,
8
+ buildBarrel,
9
+ writeBarrel,
10
+ buildClientRegistryBarrel,
11
+ writeClientRegistryBarrel,
12
+ injectLayout,
13
+ migrateLayoutInjection,
14
+ injectThemeMode,
15
+ injectGlobals,
16
+ } from './render-templates.mjs'
17
+ import { buildStateComposer, buildHandlersComposer, buildCompositionComposer, buildFeatureAdapter } from './composers.mjs'
18
+ import { hasViewAssembly, isAssemblyRel, writeViewAssembly } from './view-assembly.mjs'
19
+ import {
20
+ resolveFeatureMountOverrides,
21
+ resolveViewGenerationSelection,
22
+ } from './view-selection.mjs'
23
+ import { classifyFile } from './file-classes.mjs'
24
+ import { DEFAULT_THEME_MODE } from './manifest.mjs'
25
+ import { preflightManifest } from './preflight.mjs'
26
+ import { allowedViewNames, getFeature } from '../recipes/validate.mjs'
27
+
28
+ const here = path.dirname(fileURLToPath(import.meta.url))
29
+ const pkgRoot = path.resolve(here, '..')
30
+ const VIEW_GEN_VIEWS = path.join(pkgRoot, 'skills', 'sf-view-gen', 'assets', 'templates', 'views')
31
+
32
+ // Resolve the template directory for a view: the scaffold templates first, then
33
+ // the view-generator skill's vendored copy (the four non-home views live there
34
+ // until they are unified under assets/templates/views).
35
+ export function viewTemplateDir(name) {
36
+ const primary = path.join(templatesRoot, 'views', name)
37
+ if (existsSync(primary)) return primary
38
+ const vendored = path.join(VIEW_GEN_VIEWS, name)
39
+ if (existsSync(vendored)) return vendored
40
+ return null
41
+ }
42
+
43
+ async function walkFiles(dir, base = dir, out = []) {
44
+ const entries = await readdir(dir, { withFileTypes: true })
45
+ for (const entry of entries) {
46
+ const abs = path.join(dir, entry.name)
47
+ if (entry.isDirectory()) {
48
+ await walkFiles(abs, base, out)
49
+ } else {
50
+ out.push(path.relative(base, abs))
51
+ }
52
+ }
53
+ return out
54
+ }
55
+
56
+ // Render a template tree into the project, writing every derived file but only
57
+ // seeding user-owned files that do not exist yet, so reconcile never clobbers a
58
+ // hand edit. `classifyRel` maps a template-relative path to its file class.
59
+ // With `dryRun`, no file is written; `changed` still lists the files that would
60
+ // change, so callers (doctor) can report drift without mutating the project.
61
+ async function renderDerivedTree(fromDir, toDir, subs, classifyRel, changed, skip, dryRun) {
62
+ if (!existsSync(fromDir)) return
63
+ const files = await walkFiles(fromDir)
64
+ for (const rel of files) {
65
+ if (skip && skip(rel)) continue
66
+ const dest = path.join(toDir, rel.replaceAll('__STOREFRONT__', subs.name))
67
+ const fileClass = classifyRel(rel)
68
+ if (fileClass === 'user-owned' && existsSync(dest)) continue
69
+ const raw = await readFile(path.join(fromDir, rel), 'utf8')
70
+ const next = replacePlaceholders(raw, subs)
71
+ const prev = existsSync(dest) ? await readFile(dest, 'utf8') : null
72
+ if (prev === next) continue
73
+ if (!dryRun) {
74
+ await mkdir(path.dirname(dest), { recursive: true })
75
+ await writeFile(dest, next)
76
+ }
77
+ changed.push(dest)
78
+ }
79
+ }
80
+
81
+ async function ensureLayoutAppend(targetDir, name, siteName, mode, dryRun) {
82
+ const layoutPath = path.join(targetDir, 'app', 'layout.tsx')
83
+ if (!existsSync(layoutPath)) {
84
+ if (dryRun) return { changed: true, conflict: null }
85
+ const raw = await readFile(path.join(templatesRoot, 'app', 'app', 'layout.tsx'), 'utf8')
86
+ const content = injectThemeMode(replacePlaceholders(raw, { name, siteName, mode }), mode)
87
+ await mkdir(path.dirname(layoutPath), { recursive: true })
88
+ await writeFile(layoutPath, content)
89
+ return { changed: true, conflict: null }
90
+ }
91
+ const existing = await readFile(layoutPath, 'utf8')
92
+ // Migrate the recognized default-palette injection (CSS owns the theme now).
93
+ // A custom injection is reported as a conflict and nothing is written.
94
+ const migration = migrateLayoutInjection(existing)
95
+ if (migration.conflict) return { changed: false, conflict: migration.conflict }
96
+ const content = injectThemeMode(injectLayout(migration.content, name), mode)
97
+ if (content === existing) return { changed: false, conflict: null }
98
+ if (!dryRun) await writeFile(layoutPath, content)
99
+ return { changed: true, conflict: null }
100
+ }
101
+
102
+ async function ensureGlobalsAppend(targetDir, name, siteName, dryRun) {
103
+ const globalsPath = path.join(targetDir, 'app', 'globals.css')
104
+ if (!existsSync(globalsPath)) {
105
+ if (dryRun) return true
106
+ const raw = await readFile(path.join(templatesRoot, 'app', 'app', 'globals.css'), 'utf8')
107
+ const content = injectGlobals(replacePlaceholders(raw, { name, siteName }))
108
+ await mkdir(path.dirname(globalsPath), { recursive: true })
109
+ await writeFile(globalsPath, content)
110
+ return true
111
+ }
112
+ const existing = await readFile(globalsPath, 'utf8')
113
+ const content = injectGlobals(existing)
114
+ if (content === existing) return false
115
+ if (!dryRun) await writeFile(globalsPath, content)
116
+ return true
117
+ }
118
+
119
+ // Seed a storefront-owned app-root file once, like layout.tsx/globals.css: when
120
+ // the target is absent it is written from the template; an existing file (a
121
+ // store's customization) is never overwritten. Returns whether it wrote.
122
+ async function ensureSeedFile(targetDir, relFromApp, subs, dryRun) {
123
+ const destPath = path.join(targetDir, 'app', relFromApp)
124
+ if (existsSync(destPath)) return false
125
+ if (dryRun) return true
126
+ const raw = await readFile(path.join(templatesRoot, 'app', 'app', relFromApp), 'utf8')
127
+ await mkdir(path.dirname(destPath), { recursive: true })
128
+ await writeFile(destPath, replacePlaceholders(raw, subs))
129
+ return true
130
+ }
131
+
132
+ async function pruneEmptyDirs(dir) {
133
+ if (!existsSync(dir)) return
134
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
135
+ if (entry.isDirectory()) await pruneEmptyDirs(path.join(dir, entry.name))
136
+ }
137
+ if ((await readdir(dir)).length === 0) await rm(dir, { recursive: true, force: true })
138
+ }
139
+
140
+ // Remove a view's files when the view leaves the manifest. Nothing the user
141
+ // could have authored is silently deleted: a user-owned file that differs from
142
+ // the pristine template (i.e. was customized) and any file with no shipped
143
+ // template (unknown) are preserved, and their locations are returned so the
144
+ // caller can report them. A pristine template file is removed so a pure
145
+ // add/remove round-trip restores the tree exactly.
146
+ export async function removeViewFiles(
147
+ projectDir,
148
+ manifest,
149
+ viewName,
150
+ { dryRun = false, legacyDerivedFiles = null } = {},
151
+ ) {
152
+ const name = manifest.name
153
+ const viewDir = path.join(projectDir, 'lib', name, 'views', viewName)
154
+ if (!existsSync(viewDir)) return { removed: [], preserved: [] }
155
+ const subs = {
156
+ name,
157
+ siteName: manifest.siteName ?? name,
158
+ mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
159
+ }
160
+ const templateDir = viewTemplateDir(viewName)
161
+ // A retired view's template no longer exists, so its old derived files would
162
+ // otherwise be treated as unknown and preserved. An explicit bounded list
163
+ // marks exactly those derived files as known (and therefore removable); a
164
+ // user-owned or unknown file is still preserved by the class check below.
165
+ const legacySet = new Set(Array.isArray(legacyDerivedFiles) ? legacyDerivedFiles : [])
166
+ const removed = []
167
+ const preserved = []
168
+ for (const rel of await walkFiles(viewDir)) {
169
+ const abs = path.join(viewDir, rel)
170
+ const posixRel = rel.split(path.sep).join('/')
171
+ const fileClass = classifyFile(path.posix.join('views', viewName, posixRel))
172
+ const templatePath = templateDir ? path.join(templateDir, rel) : null
173
+ const known = Boolean((templatePath && existsSync(templatePath)) || legacySet.has(posixRel))
174
+ const maybeAuthored = !known || fileClass === 'user-owned'
175
+ if (maybeAuthored) {
176
+ let pristine = false
177
+ if (known && fileClass === 'user-owned') {
178
+ const rendered = replacePlaceholders(await readFile(templatePath, 'utf8'), subs)
179
+ pristine = (await readFile(abs, 'utf8')) === rendered
180
+ }
181
+ if (!pristine) {
182
+ preserved.push(abs)
183
+ continue
184
+ }
185
+ }
186
+ if (!dryRun) await rm(abs, { force: true })
187
+ removed.push(abs)
188
+ }
189
+ if (!dryRun) await pruneEmptyDirs(viewDir)
190
+ const toProjectRel = (abs) => path.relative(projectDir, abs)
191
+ return { removed: removed.map(toProjectRel), preserved: preserved.map(toProjectRel) }
192
+ }
193
+
194
+ // Read-only: the user-owned files of a view that differ from their pristine
195
+ // template render (i.e. were customized), as project-relative paths. A feature
196
+ // instance mount claims a slot of its host view; removing the feature does not
197
+ // remove the host view, so the slot's user-owned content stays on disk. This
198
+ // lets the caller report where that content remains (E1 task 3.4 / CU-06)
199
+ // without removing or rewriting anything.
200
+ export async function customizedViewFiles(projectDir, manifest, viewName) {
201
+ const name = manifest.name
202
+ const viewDir = path.join(projectDir, 'lib', name, 'views', viewName)
203
+ if (!existsSync(viewDir)) return []
204
+ const subs = {
205
+ name,
206
+ siteName: manifest.siteName ?? name,
207
+ mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
208
+ }
209
+ const templateDir = viewTemplateDir(viewName)
210
+ const customized = []
211
+ for (const rel of await walkFiles(viewDir)) {
212
+ const posixRel = rel.split(path.sep).join('/')
213
+ if (classifyFile(path.posix.join('views', viewName, posixRel)) !== 'user-owned') continue
214
+ const abs = path.join(viewDir, rel)
215
+ const templatePath = templateDir ? path.join(templateDir, rel) : null
216
+ if (!templatePath || !existsSync(templatePath)) {
217
+ customized.push(path.relative(projectDir, abs))
218
+ continue
219
+ }
220
+ const rendered = replacePlaceholders(await readFile(templatePath, 'utf8'), subs)
221
+ if ((await readFile(abs, 'utf8')) !== rendered) customized.push(path.relative(projectDir, abs))
222
+ }
223
+ return customized
224
+ }
225
+
226
+ // Remove a feature's files when the feature leaves the manifest. A file that
227
+ // does not match a shipped template (customized or unknown) is preserved and
228
+ // its location returned; pristine template files and the generated adapter are
229
+ // removed so a pure add/remove round-trip restores the tree exactly.
230
+ export async function removeFeatureFiles(projectDir, manifest, featureName, { dryRun = false } = {}) {
231
+ const name = manifest.name
232
+ const featureDir = path.join(projectDir, 'lib', name, 'features', featureName)
233
+ if (!existsSync(featureDir)) return { removed: [], preserved: [] }
234
+ const subs = {
235
+ name,
236
+ siteName: manifest.siteName ?? name,
237
+ mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
238
+ }
239
+ const templateDir = path.join(templatesRoot, 'features', featureName)
240
+ const removed = []
241
+ const preserved = []
242
+ for (const rel of await walkFiles(featureDir)) {
243
+ const abs = path.join(featureDir, rel)
244
+ const posixRel = rel.split(path.sep).join('/')
245
+ if (posixRel === 'adapter.ts') {
246
+ if (!dryRun) await rm(abs, { force: true })
247
+ removed.push(abs)
248
+ continue
249
+ }
250
+ const templatePath = path.join(templateDir, rel)
251
+ let pristine = false
252
+ if (existsSync(templatePath)) {
253
+ const rendered = replacePlaceholders(await readFile(templatePath, 'utf8'), subs)
254
+ pristine = (await readFile(abs, 'utf8')) === rendered
255
+ }
256
+ if (!pristine) {
257
+ preserved.push(abs)
258
+ continue
259
+ }
260
+ if (!dryRun) await rm(abs, { force: true })
261
+ removed.push(abs)
262
+ }
263
+ if (!dryRun) await pruneEmptyDirs(featureDir)
264
+ const toProjectRel = (abs) => path.relative(projectDir, abs)
265
+ return { removed: removed.map(toProjectRel), preserved: preserved.map(toProjectRel) }
266
+ }
267
+
268
+ // Reconcile a storefront project to its manifest: regenerate every derived
269
+ // structural file from the versioned templates, seed any missing user-owned
270
+ // file, and leave existing user-owned files untouched. Idempotent by design.
271
+ export async function reconcile(projectDir, manifest, { dryRun = false } = {}) {
272
+ // A manifest that is not a supported composition is never reconciled, not
273
+ // even in dry-run: report it and write nothing so a diagnostic cannot
274
+ // silently proceed as if the input were valid.
275
+ const preflight = preflightManifest(manifest)
276
+ if (!preflight.ok) {
277
+ return { ok: false, errors: preflight.errors, views: [], changed: [] }
278
+ }
279
+
280
+ const name = manifest.name
281
+ const siteName = manifest.siteName ?? manifest.name
282
+ const mode = manifest.theme?.mode ?? DEFAULT_THEME_MODE
283
+ const subs = { name, siteName, mode }
284
+ const viewsDir = path.join(projectDir, 'lib', name, 'views')
285
+ const changed = []
286
+ const preserved = []
287
+
288
+ // Resolve every assembled view's explicit slot selection from the manifest
289
+ // before any write, so a conflicting or non-executable selection fails the
290
+ // whole reconcile instead of rendering part of it. A view without an assembly
291
+ // has no fragments to select. Feature instance mounts claim a host slot and
292
+ // override its presentation from the feature's config; the view's own stored
293
+ // selection is preserved dormant (never written) and restored on removal.
294
+ const mountOverrides = resolveFeatureMountOverrides(manifest)
295
+ if (!mountOverrides.ok) {
296
+ return { ok: false, errors: mountOverrides.errors, views: [], changed: [] }
297
+ }
298
+ const viewSelections = new Map()
299
+ for (const view of manifest.views) {
300
+ const src = viewTemplateDir(view.name)
301
+ if (!src || !hasViewAssembly(src)) continue
302
+ const resolved = resolveViewGenerationSelection(view, mountOverrides.byView)
303
+ if (!resolved.ok) {
304
+ return { ok: false, errors: resolved.errors, views: [], changed: [] }
305
+ }
306
+ viewSelections.set(view.name, resolved)
307
+ }
308
+
309
+ await renderDerivedTree(
310
+ path.join(templatesRoot, 'app', 'app', '[[...slug]]'),
311
+ path.join(projectDir, 'app', '[[...slug]]'),
312
+ subs,
313
+ (rel) => classifyFile(path.posix.join('app/app/[[...slug]]', rel)),
314
+ changed,
315
+ undefined,
316
+ dryRun,
317
+ )
318
+
319
+ await renderDerivedTree(
320
+ path.join(templatesRoot, 'app', 'lib'),
321
+ path.join(projectDir, 'lib'),
322
+ subs,
323
+ (rel) => classifyFile(path.posix.join('app/lib', rel)),
324
+ changed,
325
+ (rel) => {
326
+ const normalized = rel.replaceAll('\\', '/')
327
+ // The barrel and the composers are generated, not copied.
328
+ return (
329
+ normalized.endsWith('views/index.ts') ||
330
+ normalized === '__STOREFRONT__/state.ts' ||
331
+ normalized === '__STOREFRONT__/handlers.ts' ||
332
+ normalized === '__STOREFRONT__/composition.ts'
333
+ )
334
+ },
335
+ dryRun,
336
+ )
337
+
338
+ const featuresDir = path.join(projectDir, 'lib', name, 'features')
339
+ const featureNames = manifest.features.map((feature) => feature.name).sort()
340
+ if (!dryRun) await mkdir(featuresDir, { recursive: true })
341
+ for (const featureName of featureNames) {
342
+ await renderDerivedTree(
343
+ path.join(templatesRoot, 'features', featureName),
344
+ path.join(featuresDir, featureName),
345
+ subs,
346
+ (rel) => classifyFile(path.posix.join('features', featureName, rel)),
347
+ changed,
348
+ undefined,
349
+ dryRun,
350
+ )
351
+ // Adapter needs are declared by the feature recipe, not templated, so the
352
+ // generated file always matches the registry the CLI validates against. A
353
+ // feature that declares no adapter needs (a pure editorial/mount feature)
354
+ // generates no runtime file at all — there is nothing for it to import.
355
+ const adapterNeeds = getFeature(featureName)?.adapter?.needs ?? []
356
+ if (adapterNeeds.length > 0) {
357
+ const adapterPath = path.join(featuresDir, featureName, 'adapter.ts')
358
+ const adapterContent = buildFeatureAdapter(featureName, adapterNeeds)
359
+ const prevAdapter = existsSync(adapterPath) ? await readFile(adapterPath, 'utf8') : null
360
+ if (prevAdapter !== adapterContent) {
361
+ if (!dryRun) {
362
+ await mkdir(path.dirname(adapterPath), { recursive: true })
363
+ await writeFile(adapterPath, adapterContent)
364
+ }
365
+ changed.push(adapterPath)
366
+ }
367
+ }
368
+ }
369
+ // Feature folders no longer in the manifest are removed, but a customized or
370
+ // unknown file inside one is preserved (never silently deleted) and reported
371
+ // back to the caller, exactly like a removed view's user-owned files.
372
+ if (existsSync(featuresDir)) {
373
+ const installed = new Set(featureNames)
374
+ for (const entry of await readdir(featuresDir, { withFileTypes: true })) {
375
+ if (!entry.isDirectory() || installed.has(entry.name)) continue
376
+ const removal = await removeFeatureFiles(projectDir, manifest, entry.name, { dryRun })
377
+ for (const rel of removal.removed) changed.push(path.join(projectDir, rel))
378
+ preserved.push(...removal.preserved)
379
+ }
380
+ }
381
+
382
+ if (!dryRun) await mkdir(viewsDir, { recursive: true })
383
+ for (const view of manifest.views) {
384
+ const src = viewTemplateDir(view.name)
385
+ if (!src) continue
386
+ // A view with an assembly composes its derived index.tsx/registry.tsx from
387
+ // the selected per-slot fragments; the assembly sources are never copied and
388
+ // the assembled files are written below instead of copied verbatim.
389
+ const assembled = hasViewAssembly(src)
390
+ const skip = (rel) => {
391
+ const normalized = rel.split(path.sep).join('/')
392
+ if (isAssemblyRel(rel)) return true
393
+ return assembled && (normalized === 'index.tsx' || normalized === 'registry.tsx')
394
+ }
395
+ await renderDerivedTree(
396
+ src,
397
+ path.join(viewsDir, view.name),
398
+ subs,
399
+ (rel) => classifyFile(path.posix.join('views', view.name, rel)),
400
+ changed,
401
+ skip,
402
+ dryRun,
403
+ )
404
+ if (assembled) {
405
+ const resolved = viewSelections.get(view.name)
406
+ const written = await writeViewAssembly({
407
+ viewDir: path.join(viewsDir, view.name),
408
+ templateDir: src,
409
+ selection: resolved?.selection ?? {},
410
+ sections: resolved?.sections ?? null,
411
+ render: (source) => replacePlaceholders(source, subs),
412
+ dryRun,
413
+ })
414
+ for (const abs of written) changed.push(abs)
415
+ }
416
+ }
417
+
418
+ // View directories no longer in the manifest are removed, exactly like the
419
+ // feature folders below. Without this, an abrupt apply kill after the derived
420
+ // writes but before the manifest leaves an orphan derived view on disk that a
421
+ // later `sync` regenerates the barrels around but never deletes, so the
422
+ // divergence would survive silently (E1 task 4.8). Only registered view names
423
+ // are considered, so a shared directory such as `views/sections` (rendered
424
+ // from the app/lib template tree, not a view) is never touched. A pristine
425
+ // template file is removed; a customized or unknown user-owned file is
426
+ // preserved and reported.
427
+ const installedViews = new Set(manifest.views.map((view) => view.name))
428
+ const registeredViews = new Set(allowedViewNames())
429
+ if (existsSync(viewsDir)) {
430
+ for (const entry of await readdir(viewsDir, { withFileTypes: true })) {
431
+ if (!entry.isDirectory() || installedViews.has(entry.name)) continue
432
+ if (!registeredViews.has(entry.name)) continue
433
+ const removal = await removeViewFiles(projectDir, manifest, entry.name, { dryRun })
434
+ for (const rel of removal.removed) changed.push(path.join(projectDir, rel))
435
+ preserved.push(...removal.preserved)
436
+ }
437
+ }
438
+
439
+ // The barrel is generated from the manifest's installed views — never by
440
+ // scanning leftover view directories, so a view removed from the manifest
441
+ // cannot reappear in the wiring.
442
+ const viewNames = manifest.views.map((view) => view.name)
443
+ const barrelPath = path.join(viewsDir, 'index.ts')
444
+ const prevBarrel = existsSync(barrelPath) ? await readFile(barrelPath, 'utf8') : null
445
+ const nextBarrel = buildBarrel(viewNames)
446
+ if (prevBarrel !== nextBarrel) {
447
+ if (!dryRun) await writeBarrel(viewsDir, viewNames)
448
+ changed.push(barrelPath)
449
+ }
450
+
451
+ // The client view-registry barrel imports only each view's client mapping, so
452
+ // the generated shell registry never pulls the server loaders into the client
453
+ // graph. Generated from the manifest's views, like the server barrel.
454
+ const clientBarrelPath = path.join(viewsDir, 'registry.ts')
455
+ const prevClientBarrel = existsSync(clientBarrelPath) ? await readFile(clientBarrelPath, 'utf8') : null
456
+ const nextClientBarrel = buildClientRegistryBarrel(viewNames)
457
+ if (prevClientBarrel !== nextClientBarrel) {
458
+ if (!dryRun) await writeClientRegistryBarrel(viewsDir, viewNames)
459
+ changed.push(clientBarrelPath)
460
+ }
461
+
462
+ const layoutResult = await ensureLayoutAppend(projectDir, name, siteName, mode, dryRun)
463
+ if (layoutResult.conflict) {
464
+ return { ok: false, errors: [layoutResult.conflict], views: [], changed: [] }
465
+ }
466
+ if (layoutResult.changed) {
467
+ changed.push(path.join(projectDir, 'app', 'layout.tsx'))
468
+ }
469
+ if (await ensureGlobalsAppend(projectDir, name, siteName, dryRun)) {
470
+ changed.push(path.join(projectDir, 'app', 'globals.css'))
471
+ }
472
+ for (const seoFile of ['sitemap.ts', 'robots.ts']) {
473
+ if (await ensureSeedFile(projectDir, seoFile, subs, dryRun)) {
474
+ changed.push(path.join(projectDir, 'app', seoFile))
475
+ }
476
+ }
477
+
478
+ // The managed root guidance file is derived: rendered from the package
479
+ // template with the project placeholders, refreshed by sync, and reported as
480
+ // drift by doctor's dry-run. It is never seeded-and-frozen (not user-owned),
481
+ // so the rules a store enforces always match the installed CLI generation.
482
+ if (classifyFile('AGENTS.md') === 'derived') {
483
+ const agentsTemplate = path.join(templatesRoot, 'AGENTS.md')
484
+ if (existsSync(agentsTemplate)) {
485
+ const agentsPath = path.join(projectDir, 'AGENTS.md')
486
+ const nextAgents = replacePlaceholders(await readFile(agentsTemplate, 'utf8'), subs)
487
+ const prevAgents = existsSync(agentsPath) ? await readFile(agentsPath, 'utf8') : null
488
+ if (prevAgents !== nextAgents) {
489
+ if (!dryRun) await writeFile(agentsPath, nextAgents)
490
+ changed.push(agentsPath)
491
+ }
492
+ }
493
+ }
494
+
495
+ // Composer files are generated from the installed features, not templated.
496
+ // A feature contributes to a composer only when the registry declares it owns
497
+ // state/handlers for it; a pure editorial feature (no state, no handlers)
498
+ // contributes nothing and its missing fragment is never imported.
499
+ const stateFeatures = featureNames.filter((n) => (getFeature(n)?.state?.length ?? 0) > 0)
500
+ const handlerFeatures = featureNames.filter((n) => (getFeature(n)?.handlers?.length ?? 0) > 0)
501
+ for (const [composerPath, content] of [
502
+ [path.join(projectDir, 'lib', name, 'state.ts'), buildStateComposer(stateFeatures)],
503
+ [path.join(projectDir, 'lib', name, 'handlers.ts'), buildHandlersComposer(handlerFeatures)],
504
+ [path.join(projectDir, 'lib', name, 'composition.ts'), buildCompositionComposer(manifest.features, manifest.views)],
505
+ ]) {
506
+ const prev = existsSync(composerPath) ? await readFile(composerPath, 'utf8') : null
507
+ if (prev === content) continue
508
+ if (!dryRun) {
509
+ await mkdir(path.dirname(composerPath), { recursive: true })
510
+ await writeFile(composerPath, content)
511
+ }
512
+ changed.push(composerPath)
513
+ }
514
+
515
+ return {
516
+ ok: true,
517
+ views: manifest.views.map((view) => view.name),
518
+ changed: changed.map((abs) => path.relative(projectDir, abs)),
519
+ preserved,
520
+ }
521
+ }
522
+
523
+ async function main() {
524
+ const projectDir = process.argv[2]
525
+ if (!projectDir) {
526
+ console.error('Usage: node scripts/reconcile.mjs <project-dir>')
527
+ return 1
528
+ }
529
+ const { readManifest } = await import('./manifest.mjs')
530
+ const manifest = await readManifest(projectDir)
531
+ if (!manifest) {
532
+ console.error('No storefront.config.json found in ' + projectDir)
533
+ return 1
534
+ }
535
+ const result = await reconcile(projectDir, manifest)
536
+ if (!result.ok) {
537
+ console.error('Refusing to reconcile: the composition is invalid.')
538
+ for (const error of result.errors) console.error(` ${error}`)
539
+ return 1
540
+ }
541
+ console.log('Reconciled ' + projectDir)
542
+ return 0
543
+ }
544
+
545
+ if (import.meta.url === `file://${process.argv[1]}`) {
546
+ main().then((code) => {
547
+ process.exitCode = code
548
+ })
549
+ }