@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,987 @@
1
+ #!/usr/bin/env node
2
+ // Inspect / plan / apply boundary for the composition engine (E1 tasks 4.1 and
3
+ // 4.3; the remaining 4.x tasks harden each branch).
4
+ //
5
+ // `plan` is read-only against the project: it reduces the operation envelope
6
+ // through `operations.mjs`, records before/after identity fingerprints, and (when
7
+ // `--out` is given) writes only the plan artifact. `apply` resolves the current
8
+ // state against the recorded before/after fingerprints under a local lock, then
9
+ // writes derived files and the manifest last, rolling back every touched file on
10
+ // a handled failure. The legacy `view`/`feature` aliases reduce and commit
11
+ // through the same code (`executeOperations`), so alias and plan/apply agree.
12
+
13
+ import { createHash } from 'node:crypto'
14
+ import {
15
+ closeSync,
16
+ existsSync,
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ openSync,
20
+ readFileSync,
21
+ readdirSync,
22
+ realpathSync,
23
+ renameSync,
24
+ rmSync,
25
+ statSync,
26
+ unlinkSync,
27
+ writeFileSync,
28
+ } from 'node:fs'
29
+ import { cp, mkdir, readFile, rm } from 'node:fs/promises'
30
+ import { tmpdir } from 'node:os'
31
+ import path from 'node:path'
32
+ import { fileURLToPath } from 'node:url'
33
+ import { getFeature } from '../recipes/validate.mjs'
34
+ import { classifyFile } from './file-classes.mjs'
35
+ import { manifestPath, readManifest, writeManifest } from './manifest.mjs'
36
+ import { parseOperationsEnvelope, reduceManifest } from './operations.mjs'
37
+ import { detectOwnershipConflicts } from './ownership-conflict.mjs'
38
+ import { preflightManifest } from './preflight.mjs'
39
+ import { customizedViewFiles, reconcile, removeFeatureFiles, removeViewFiles } from './reconcile.mjs'
40
+
41
+ const here = path.dirname(fileURLToPath(import.meta.url))
42
+ const PKG_ROOT = path.resolve(here, '..')
43
+
44
+ export const PLAN_FORMAT_VERSION = 1
45
+
46
+ const SKIP_DIRS = new Set(['node_modules', '.next', '.git', '.turbo', '.yarn', 'dist', 'build'])
47
+ const SECRET_BASENAME = [/^\.env($|\.)/, /\.pem$/i, /\.key$/i]
48
+
49
+ function isPlainObject(value) {
50
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
51
+ }
52
+
53
+ function hasOwn(object, key) {
54
+ return Object.prototype.hasOwnProperty.call(object, key)
55
+ }
56
+
57
+ function diagnostic(code, message, extra = {}) {
58
+ return { code, severity: 'error', message, ...extra }
59
+ }
60
+
61
+ function preflightDiagnostics(errors) {
62
+ return errors.map((message) => diagnostic('E_INVALID_COMPOSITION', message))
63
+ }
64
+
65
+ function stableStringify(value) {
66
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
67
+ if (isPlainObject(value)) {
68
+ const keys = Object.keys(value).sort()
69
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`
70
+ }
71
+ return JSON.stringify(value)
72
+ }
73
+
74
+ function sha256(text) {
75
+ return createHash('sha256').update(text).digest('hex')
76
+ }
77
+
78
+ function sha256File(abs) {
79
+ return createHash('sha256').update(readFileSync(abs)).digest('hex')
80
+ }
81
+
82
+ function isSecret(rel) {
83
+ const base = path.basename(rel)
84
+ return SECRET_BASENAME.some((re) => re.test(base))
85
+ }
86
+
87
+ /**
88
+ * Copy a project tree (e.g. `app/` or `lib/`) into a staging directory while
89
+ * excluding secret-bearing files (`.env`, `.env.*`, `.env.local`, `*.pem`,
90
+ * `*.key`). `fs.cp`'s `filter` runs before a file is read, so a credential is
91
+ * never copied — and therefore never written to the OS temp dir — even though
92
+ * the fingerprint domain already skips it (E1 task 4.13). Exported for the
93
+ * regression test; production callers use it through `withStagedProject`.
94
+ */
95
+ export async function copyTreeExcludingSecrets(from, to) {
96
+ await cp(from, to, { recursive: true, filter: (src) => !isSecret(src) })
97
+ }
98
+
99
+ function walkFiles(dir, base = dir, out = []) {
100
+ if (!existsSync(dir)) return out
101
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
102
+ if (entry.isDirectory()) {
103
+ if (SKIP_DIRS.has(entry.name)) continue
104
+ walkFiles(path.join(dir, entry.name), base, out)
105
+ } else {
106
+ out.push(path.relative(base, path.join(dir, entry.name)).split(path.sep).join('/'))
107
+ }
108
+ }
109
+ return out
110
+ }
111
+
112
+ // Classify a project-relative file into the identity domain's derived vs
113
+ // user-owned buckets, reusing the single file-classification rules.
114
+ function classifyProjectRel(rel, name) {
115
+ if (rel.startsWith('lib/')) {
116
+ const viewsPrefix = `lib/${name}/views/`
117
+ const featuresPrefix = `lib/${name}/features/`
118
+ if (rel.startsWith(viewsPrefix)) return classifyFile(`views/${rel.slice(viewsPrefix.length)}`)
119
+ if (rel.startsWith(featuresPrefix)) {
120
+ return classifyFile(`features/${rel.slice(featuresPrefix.length)}`)
121
+ }
122
+ return classifyFile(`app/${rel}`)
123
+ }
124
+ if (rel.startsWith('app/')) return classifyFile(`app/app/${rel.slice('app/'.length)}`)
125
+ return null
126
+ }
127
+
128
+ // The generator's meaningful inputs: the CLI entry, the generation scripts, the
129
+ // recipe/config registry (archetypes, features, sections, catalog, schemas), and
130
+ // the authoring templates/assemblies. Hashing the whole set — not only
131
+ // `scripts/` — keeps a template, registry, or archetype change from
132
+ // masquerading as an unchanged generator (E1 task 4.4).
133
+ const GENERATOR_ROOTS = ['bin', 'scripts', 'recipes', 'assets/templates']
134
+
135
+ // Consumer-side installed-toolset evidence. The install record is normalized to
136
+ // drop its per-install `installedAt` timestamp (nonsemantic: it changes on every
137
+ // install) while keeping `pinning` (the semantic declared/resolved/asset BOM).
138
+ const INSTALL_RECORD = path.join('.opencode', 'storefront.json')
139
+
140
+ function sha256FileOrNull(abs) {
141
+ return existsSync(abs) && statSync(abs).isFile() ? sha256File(abs) : null
142
+ }
143
+
144
+ function hashRoots(rootDir, relRoots) {
145
+ const entries = []
146
+ for (const relRoot of relRoots) {
147
+ const abs = path.join(rootDir, relRoot)
148
+ if (!existsSync(abs)) continue
149
+ if (statSync(abs).isFile()) {
150
+ entries.push(`${relRoot}\0${sha256File(abs)}`)
151
+ continue
152
+ }
153
+ for (const rel of walkFiles(abs)) {
154
+ entries.push(`${relRoot}/${rel}\0${sha256File(path.join(abs, rel))}`)
155
+ }
156
+ }
157
+ entries.sort()
158
+ return sha256(entries.join('\n'))
159
+ }
160
+
161
+ function readInstallRecord(projectDir) {
162
+ const abs = path.join(projectDir, INSTALL_RECORD)
163
+ if (!existsSync(abs) || !statSync(abs).isFile()) return null
164
+ let parsed
165
+ try {
166
+ parsed = JSON.parse(readFileSync(abs, 'utf8'))
167
+ } catch {
168
+ return null
169
+ }
170
+ if (!isPlainObject(parsed)) return null
171
+ const normalized = { ...parsed }
172
+ delete normalized.installedAt
173
+ return normalized
174
+ }
175
+
176
+ /**
177
+ * The pinned toolkit/toolset identity (Decision 14). `catalog` and `generator`
178
+ * are the package's semantic catalog and generation-authoring sources; `toolset`
179
+ * is the consumer's actual installed-toolset evidence (package manifest,
180
+ * lockfile, and the `installedAt`-normalized install record). An absent consumer
181
+ * file is recorded as `null`, so no phantom pin is emitted.
182
+ */
183
+ export function computePins({ pkgRoot = PKG_ROOT, projectDir } = {}) {
184
+ return {
185
+ catalog: sha256File(path.join(pkgRoot, 'recipes', 'catalog.json')),
186
+ generator: hashRoots(pkgRoot, GENERATOR_ROOTS),
187
+ toolset: sha256(
188
+ stableStringify({
189
+ packageJson: sha256FileOrNull(path.join(projectDir, 'package.json')),
190
+ lockfile: sha256FileOrNull(path.join(projectDir, 'yarn.lock')),
191
+ installRecord: readInstallRecord(projectDir),
192
+ }),
193
+ ),
194
+ }
195
+ }
196
+
197
+ /**
198
+ * The canonical identity domain (Decision 14): manifest, pins, derived and
199
+ * user-owned file-state hashes, and the canonical operations. Secret-bearing
200
+ * files are excluded by construction, so a fingerprint never absorbs `.env*`,
201
+ * `*.pem`, or `*.key`. Returns `{ identity, hash }`.
202
+ */
203
+ export function projectIdentity(
204
+ projectDir,
205
+ manifest,
206
+ operations,
207
+ pins = computePins({ projectDir }),
208
+ ) {
209
+ const derived = {}
210
+ const userOwned = {}
211
+ const name = typeof manifest?.name === 'string' ? manifest.name : ''
212
+ for (const root of ['app', 'lib']) {
213
+ for (const rel of walkFiles(path.join(projectDir, root))) {
214
+ const projectRel = `${root}/${rel}`
215
+ if (isSecret(projectRel)) continue
216
+ const abs = path.join(projectDir, projectRel)
217
+ const fileClass = classifyProjectRel(projectRel, name)
218
+ const target = fileClass === 'derived' ? derived : userOwned
219
+ target[projectRel] = sha256File(abs)
220
+ }
221
+ }
222
+ const identity = {
223
+ formatVersion: PLAN_FORMAT_VERSION,
224
+ manifest,
225
+ pins,
226
+ derived,
227
+ userOwned,
228
+ operations,
229
+ }
230
+ return { identity, hash: sha256(stableStringify(identity)) }
231
+ }
232
+
233
+ export function resultEnvelope(command, ok, exitCode, diagnostics = [], result = null) {
234
+ return {
235
+ formatVersion: PLAN_FORMAT_VERSION,
236
+ command,
237
+ ok,
238
+ exitCode,
239
+ diagnostics,
240
+ result,
241
+ }
242
+ }
243
+
244
+ // The stable diagnostic for an unexpected internal failure (E1 task 4.11). It
245
+ // carries no stack, no absolute path, and no captured value — only the error
246
+ // class name — so the envelope is safe to print even when the underlying error
247
+ // message embeds a secret or a machine path. The raw error is logged to stderr
248
+ // (sanitized) by `runGuarded`, so it is never swallowed.
249
+ export function unexpectedDiagnostic(error) {
250
+ return {
251
+ code: 'E_UNEXPECTED',
252
+ severity: 'error',
253
+ message: 'unexpected internal error',
254
+ detail: { name: error instanceof Error ? error.name : 'Error' },
255
+ }
256
+ }
257
+
258
+ // Replace the project root and the OS temp root with stable placeholders before
259
+ // a message reaches stderr, so an unexpected error cannot leak an absolute
260
+ // machine path even in the log.
261
+ function sanitizeForLog(text, projectDir) {
262
+ let out = text
263
+ if (projectDir) out = out.split(projectDir).join('<project>')
264
+ out = out.split(tmpdir()).join('<tmp>')
265
+ return out
266
+ }
267
+
268
+ // Run a plan/apply body and convert an *unexpected* throw into an exit-2
269
+ // envelope instead of an unhandled rejection. Handled failures already return
270
+ // their own exit-1 envelope from inside the body, so they never reach here.
271
+ async function runGuarded(command, projectDir, body) {
272
+ try {
273
+ return await body()
274
+ } catch (error) {
275
+ const name = error instanceof Error ? error.name : 'Error'
276
+ const message = error instanceof Error ? error.message : String(error)
277
+ console.error(
278
+ `storefront ${command}: unexpected internal error (${name}): ${sanitizeForLog(message, projectDir)}`,
279
+ )
280
+ return {
281
+ envelope: resultEnvelope(command, false, 2, [unexpectedDiagnostic(error)]),
282
+ exitCode: 2,
283
+ }
284
+ }
285
+ }
286
+
287
+ function diffManifests(before, after) {
288
+ return { before, after }
289
+ }
290
+
291
+ function diffFileState(before, after) {
292
+ const added = []
293
+ const removed = []
294
+ const changed = []
295
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)])
296
+ for (const key of [...keys].sort()) {
297
+ if (!hasOwn(before, key)) added.push(key)
298
+ else if (!hasOwn(after, key)) removed.push(key)
299
+ else if (before[key] !== after[key]) changed.push(key)
300
+ }
301
+ return { added, removed, changed }
302
+ }
303
+
304
+ // Capture the prior bytes of every file the attempt will touch plus the prior
305
+ // existence of each ancestor directory under the project. Recording directories
306
+ // is what lets a rollback remove the directories a create-only attempt made,
307
+ // instead of leaving orphaned empty dirs behind a restored tree (E1 task 4.7).
308
+ export function takeSnapshot(projectDir, absPaths) {
309
+ const files = new Map()
310
+ const dirs = new Map()
311
+ for (const abs of absPaths) {
312
+ if (existsSync(abs) && statSync(abs).isFile()) {
313
+ files.set(abs, { existed: true, content: readFileSync(abs) })
314
+ } else {
315
+ files.set(abs, { existed: false })
316
+ }
317
+ let dir = path.dirname(abs)
318
+ while (dir !== projectDir && dir.startsWith(`${projectDir}${path.sep}`)) {
319
+ if (!dirs.has(dir)) dirs.set(dir, existsSync(dir))
320
+ dir = path.dirname(dir)
321
+ }
322
+ }
323
+ return { files, dirs }
324
+ }
325
+
326
+ export function restoreSnapshot(snapshot) {
327
+ for (const [abs, prior] of snapshot.files) {
328
+ if (prior.existed) {
329
+ mkdirSync(path.dirname(abs), { recursive: true })
330
+ writeFileSync(abs, prior.content)
331
+ } else if (existsSync(abs)) {
332
+ rmSync(abs, { force: true })
333
+ }
334
+ }
335
+ // Remove directories the attempt created, deepest first, once empty. A dir
336
+ // that held a preexisting file is not in this set, so prior structure stays.
337
+ const created = [...snapshot.dirs.entries()]
338
+ .filter(([, existed]) => !existed)
339
+ .map(([dir]) => dir)
340
+ .sort((a, b) => b.length - a.length)
341
+ for (const dir of created) {
342
+ if (existsSync(dir) && statSync(dir).isDirectory() && readdirSync(dir).length === 0) {
343
+ rmSync(dir, { recursive: true, force: true })
344
+ }
345
+ }
346
+ }
347
+
348
+ // Remove files for explicitly removed views/features, reconcile derived files
349
+ // to the desired manifest, then write the manifest LAST (Decision 7). Returns
350
+ // the changed derived files and the preserved customized files.
351
+ //
352
+ // `inject(phase, context)` is a controlled test seam (E1 task 4.7): production
353
+ // callers pass nothing, so it is inert in the CLI. A test throws from it at a
354
+ // named phase — `after-derived-writes` (writes and deletions have landed, the
355
+ // manifest is not yet published), `before-manifest-write`, or
356
+ // `after-manifest-write` — to prove the handled-failure rollback. It is never
357
+ // read from the environment, so an untrusted env cannot arm it.
358
+ async function commitReduction(projectDir, reduction, { snapshot = false, inject = null } = {}) {
359
+ const dry = await reconcile(projectDir, reduction.manifest, { dryRun: true })
360
+ if (!dry.ok) return { ok: false, diagnostics: preflightDiagnostics(dry.errors) }
361
+
362
+ const removalRemoved = []
363
+ const preserved = []
364
+ for (const rem of reduction.removals) {
365
+ // A `section.remove` drops an editorial instance from the manifest but keeps
366
+ // the instance's user-owned content: `reconcile` never rewrites the view's
367
+ // `content.ts`, so the removal only reports that file's location as
368
+ // preserved (E3 task 3.2, CU-15). Nothing is deleted or rewritten here.
369
+ if (rem.kind === 'section') {
370
+ const abs = path.join(projectDir, rem.contentPath)
371
+ if (existsSync(abs) && statSync(abs).isFile()) preserved.push(rem.contentPath)
372
+ continue
373
+ }
374
+ const result =
375
+ rem.kind === 'view'
376
+ ? await removeViewFiles(projectDir, reduction.manifest, rem.name, {
377
+ dryRun: true,
378
+ legacyDerivedFiles: rem.legacyDerivedFiles,
379
+ })
380
+ : await removeFeatureFiles(projectDir, reduction.manifest, rem.name, { dryRun: true })
381
+ removalRemoved.push(...result.removed)
382
+ preserved.push(...result.preserved)
383
+ }
384
+ for (const rem of reduction.removals) {
385
+ if (rem.kind !== 'feature') continue
386
+ for (const mount of getFeature(rem.name)?.mounts ?? []) {
387
+ for (const rel of await customizedViewFiles(projectDir, reduction.manifest, mount.host)) {
388
+ if (!preserved.includes(rel)) preserved.push(rel)
389
+ }
390
+ }
391
+ }
392
+
393
+ const touched = new Set([
394
+ ...dry.changed.map((rel) => path.resolve(projectDir, rel)),
395
+ ...removalRemoved.map((rel) => path.resolve(projectDir, rel)),
396
+ manifestPath(projectDir),
397
+ ])
398
+ const snapshotState = snapshot ? takeSnapshot(projectDir, [...touched]) : null
399
+ try {
400
+ for (const rem of reduction.removals) {
401
+ if (rem.kind === 'view') {
402
+ await removeViewFiles(projectDir, reduction.manifest, rem.name, {
403
+ legacyDerivedFiles: rem.legacyDerivedFiles,
404
+ })
405
+ }
406
+ if (rem.kind === 'feature') await removeFeatureFiles(projectDir, reduction.manifest, rem.name)
407
+ }
408
+ const result = await reconcile(projectDir, reduction.manifest)
409
+ if (!result.ok) throw new Error(result.errors.join('; '))
410
+ inject?.('after-derived-writes', { projectDir, manifest: reduction.manifest })
411
+ inject?.('before-manifest-write', {
412
+ projectDir,
413
+ manifest: reduction.manifest,
414
+ manifestPath: manifestPath(projectDir),
415
+ })
416
+ writeManifest(projectDir, reduction.manifest)
417
+ inject?.('after-manifest-write', {
418
+ projectDir,
419
+ manifest: reduction.manifest,
420
+ manifestPath: manifestPath(projectDir),
421
+ })
422
+ return { ok: true, changed: result.changed, preserved: [...new Set(preserved)] }
423
+ } catch (error) {
424
+ if (snapshotState) restoreSnapshot(snapshotState)
425
+ return {
426
+ ok: false,
427
+ diagnostics: [
428
+ diagnostic('E_WRITE_FAILED', error instanceof Error ? error.message : String(error)),
429
+ ],
430
+ }
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Reduce and commit a set of operations against a project in one step. This is
436
+ * the shared path behind the `view`/`feature` aliases and the apply command's
437
+ * write step. Returns `{ ok, diagnostics, changed, preserved, manifest }`.
438
+ */
439
+ export async function executeOperations(
440
+ projectDir,
441
+ manifest,
442
+ operations,
443
+ { force = true, inject = null, registry = null } = {},
444
+ ) {
445
+ const reduction = reduceManifest(manifest, operations, { force, registry })
446
+ if (!reduction.ok) return { ok: false, diagnostics: reduction.diagnostics }
447
+ // Ownership conflicts are a write-boundary check (E1 task 5.1 / CU-08): the
448
+ // legacy aliases must reject a customized contract that no longer provides a
449
+ // selected variant's data shape before any file is touched, exactly like
450
+ // plan/apply.
451
+ const conflicts = detectOwnershipConflicts(projectDir, reduction.manifest)
452
+ if (!conflicts.ok) return { ok: false, diagnostics: conflicts.diagnostics }
453
+ const commit = await commitReduction(projectDir, reduction, { snapshot: true, inject })
454
+ if (!commit.ok) return { ok: false, diagnostics: commit.diagnostics }
455
+ return {
456
+ ok: true,
457
+ diagnostics: [],
458
+ changed: commit.changed,
459
+ preserved: commit.preserved,
460
+ manifest: reduction.manifest,
461
+ }
462
+ }
463
+
464
+ // The retired view ids the bounded `view remove` cleanup may still transition.
465
+ // Exactly this list is permitted; any other unknown id fails closed so an
466
+ // arbitrary unknown entry is never purged from a user-owned manifest
467
+ // (change: storefront-retire-user-panel, Decision 4 / AC-UP-1.2).
468
+ const LEGACY_VIEW_IDS = new Set(['user-panel'])
469
+ // The derived files the retired view template shipped. They are regenerable and
470
+ // unregistered after retirement, so the bounded cleanup removes exactly these
471
+ // from a stale generated view directory; user-owned `schema.ts` and any unknown
472
+ // file are preserved and reported (never a blind directory delete).
473
+ const LEGACY_DERIVED_VIEW_FILES = Object.freeze({ 'user-panel': ['index.tsx', 'registry.tsx'] })
474
+ const LEGACY_BACKUP_DIR = path.join('.opencode', 'backups', 'retire-user-panel')
475
+
476
+ export function isLegacyViewId(name) {
477
+ return LEGACY_VIEW_IDS.has(name)
478
+ }
479
+
480
+ /**
481
+ * Containment guard for a path a caller is about to create under the project.
482
+ * Rejects a lexically-escaping path and, crucially, an existing ancestor whose
483
+ * realpath leaves the project (a symlinked `.opencode`/`backups` must never
484
+ * redirect a backup write outside the project). `root` must exist.
485
+ */
486
+ export function assertInsideProject(root, abs) {
487
+ const rel = path.relative(root, abs)
488
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
489
+ throw new Error(`refusing to write outside the project: ${abs}`)
490
+ }
491
+ const realRoot = realpathSync(root)
492
+ let probe = abs
493
+ while (!existsSync(probe)) {
494
+ const parent = path.dirname(probe)
495
+ if (parent === probe) break
496
+ probe = parent
497
+ }
498
+ if (existsSync(probe)) {
499
+ const realProbe = realpathSync(probe)
500
+ if (realProbe !== realRoot && !realProbe.startsWith(`${realRoot}${path.sep}`)) {
501
+ throw new Error(`refusing to write through a symlinked path outside the project: ${abs}`)
502
+ }
503
+ }
504
+ return abs
505
+ }
506
+
507
+ // No-clobber durable backup of the manifest before the bounded cleanup writes.
508
+ // An existing backup is reused only when it already holds this exact content;
509
+ // otherwise the cleanup refuses, so a backup is never silently replaced.
510
+ function ensureLegacyManifestBackup(projectDir) {
511
+ const abs = path.join(projectDir, LEGACY_BACKUP_DIR, 'storefront.config.json')
512
+ assertInsideProject(projectDir, abs)
513
+ const raw = readFileSync(manifestPath(projectDir), 'utf8')
514
+ mkdirSync(path.dirname(abs), { recursive: true })
515
+ if (existsSync(abs)) {
516
+ if (readFileSync(abs, 'utf8') !== raw) {
517
+ throw new Error(`refusing to overwrite an existing backup with different content: ${abs}`)
518
+ }
519
+ return { path: abs, created: false }
520
+ }
521
+ writeFileSync(abs, raw, { flag: 'wx' })
522
+ return { path: abs, created: true }
523
+ }
524
+
525
+ /**
526
+ * Bounded legacy cleanup for a retired view id (`user-panel`). A manifest
527
+ * written before the view was retired carries an entry the registry no longer
528
+ * knows, so the normal reduce path refuses the input. This cleanup instead
529
+ * validates the resulting FUTURE manifest through the structural owner
530
+ * (`preflightManifest`), backs the manifest up, and commits transactionally
531
+ * through `commitReduction` — the same reconcile/plan-apply write boundary, no
532
+ * runtime-engine bypass and no re-enable. Only a `LEGACY_VIEW_IDS` member is
533
+ * permitted; any other id fails closed with an actionable diagnostic and no
534
+ * write. Idempotent: an already-absent entry is a truthful no-op.
535
+ */
536
+ export async function removeLegacyView(projectDir, name, { inject = null } = {}) {
537
+ if (!isLegacyViewId(name)) {
538
+ const allowed = [...LEGACY_VIEW_IDS].map((id) => `"${id}"`).join(', ')
539
+ return {
540
+ ok: false,
541
+ diagnostics: [
542
+ diagnostic(
543
+ 'E_UNKNOWN_VIEW',
544
+ `Unknown view "${name}". Only the retired legacy id ${allowed} can be removed by this cleanup; edit the canonical storefront.config.json to remove any other view.`,
545
+ ),
546
+ ],
547
+ }
548
+ }
549
+ const manifest = readManifest(projectDir)
550
+ if (!manifest) {
551
+ return {
552
+ ok: false,
553
+ diagnostics: [diagnostic('E_NO_MANIFEST', 'No storefront.config.json found in the project.')],
554
+ }
555
+ }
556
+ if (!manifest.views.some((view) => view.name === name)) {
557
+ return { ok: true, diagnostics: [], changed: [], preserved: [], alreadyRemoved: true }
558
+ }
559
+ const future = { ...manifest, views: manifest.views.filter((view) => view.name !== name) }
560
+ const preflight = preflightManifest(future)
561
+ if (!preflight.ok) {
562
+ return { ok: false, diagnostics: preflightDiagnostics(preflight.errors) }
563
+ }
564
+ // Same write-boundary ownership check as `executeOperations`: the future
565
+ // composition must not render a selected variant against a customized
566
+ // user-owned contract that no longer provides its data shape (E1 task 5.1).
567
+ const conflicts = detectOwnershipConflicts(projectDir, future)
568
+ if (!conflicts.ok) return { ok: false, diagnostics: conflicts.diagnostics }
569
+ let backup
570
+ try {
571
+ backup = ensureLegacyManifestBackup(projectDir)
572
+ } catch (error) {
573
+ return {
574
+ ok: false,
575
+ diagnostics: [
576
+ diagnostic('E_BACKUP_FAILED', error instanceof Error ? error.message : String(error)),
577
+ ],
578
+ }
579
+ }
580
+ const commit = await commitReduction(
581
+ projectDir,
582
+ {
583
+ ok: true,
584
+ manifest: future,
585
+ removals: [{ kind: 'view', name, legacyDerivedFiles: LEGACY_DERIVED_VIEW_FILES[name] ?? [] }],
586
+ },
587
+ { snapshot: true, inject },
588
+ )
589
+ if (!commit.ok) return { ok: false, diagnostics: commit.diagnostics, backup: backup.path }
590
+ return {
591
+ ok: true,
592
+ diagnostics: [],
593
+ changed: commit.changed,
594
+ preserved: commit.preserved,
595
+ backup: backup.path,
596
+ alreadyRemoved: false,
597
+ }
598
+ }
599
+
600
+ async function withStagedProject(projectDir, reduction, fn) {
601
+ const stage = mkdtempSync(path.join(tmpdir(), 'cs-plan-stage-'))
602
+ try {
603
+ // Stage `app`/`lib` without secret-bearing files, so the predicted after-state
604
+ // is computed from the same secret-free domain as the before-state and no
605
+ // credential is copied into the temp dir (E1 task 4.13).
606
+ for (const rel of ['app', 'lib']) {
607
+ const from = path.join(projectDir, rel)
608
+ if (existsSync(from)) await copyTreeExcludingSecrets(from, path.join(stage, rel))
609
+ }
610
+ writeManifest(stage, reduction.manifest)
611
+ await commitReduction(stage, reduction, { snapshot: false })
612
+ return await fn(stage)
613
+ } finally {
614
+ rmSync(stage, { recursive: true, force: true })
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Plan a set of operations: validate the whole intended state, record the
620
+ * before/after identity fingerprints and diffs, and write only the `--out`
621
+ * artifact (atomically) when requested. Never mutates the project. Returns
622
+ * `{ envelope, exitCode, outWritten }`.
623
+ */
624
+ export function planProject(projectDir, rawOperations, options = {}) {
625
+ return runGuarded('plan', projectDir, () =>
626
+ planProjectInner(projectDir, rawOperations, options),
627
+ )
628
+ }
629
+
630
+ async function planProjectInner(projectDir, rawOperations, { outPath = null, operationsPath = null, inject = null, registry = null } = {}) {
631
+ // Every input goes through the versioned envelope parser (E1 task 4.10): a
632
+ // bare operations array, a missing/wrong `formatVersion`, or a non-array
633
+ // `operations` is `E_INVALID_ENVELOPE` and writes nothing. `registry` selects
634
+ // the active operation vocabulary (defaults to E1's six); E3 passes its own so
635
+ // a `section.add` is accepted and an unregistered op is still rejected.
636
+ const parsed = parseOperationsEnvelope(rawOperations, { registry })
637
+ const manifest = readManifest(projectDir)
638
+ if (!manifest) {
639
+ return {
640
+ envelope: resultEnvelope('plan', false, 1, [
641
+ ...parsed.diagnostics,
642
+ diagnostic('E_NO_MANIFEST', 'No storefront.config.json found in the project.'),
643
+ ]),
644
+ exitCode: 1,
645
+ }
646
+ }
647
+ // Validate every parsed operation against the resulting complete state even
648
+ // when some operations failed to parse, so parse-level and semantic errors are
649
+ // all reported together (E1 task 4.3). Any diagnostic means no plan is emitted
650
+ // and no file is written. The active `registry` dispatches E3's namespaced
651
+ // reducers through the same engine.
652
+ const reduction = reduceManifest(manifest, parsed.operations, { force: true, registry })
653
+ const diagnostics = [...parsed.diagnostics, ...reduction.diagnostics]
654
+ if (diagnostics.length > 0) {
655
+ return { envelope: resultEnvelope('plan', false, 1, diagnostics), exitCode: 1 }
656
+ }
657
+
658
+ // Ownership conflicts are detected before staging so `plan` reports the file
659
+ // and differing fields and emits no plan; it stays read-only against the
660
+ // project (E1 task 5.1 / CU-08).
661
+ const conflicts = detectOwnershipConflicts(projectDir, reduction.manifest)
662
+ if (!conflicts.ok) {
663
+ return { envelope: resultEnvelope('plan', false, 1, conflicts.diagnostics), exitCode: 1 }
664
+ }
665
+
666
+ // Controlled programmatic seam (E1 task 4.11): production callers pass
667
+ // nothing, so it is inert in the CLI and is never read from the environment.
668
+ // A test throws here to exercise the unexpected-internal-error branch (exit
669
+ // 2) on the real engine.
670
+ inject?.('plan-after-validation', { projectDir, operations: parsed.operations })
671
+
672
+ // Compute the pins once and reuse them for both states so the after-state
673
+ // retains the same catalog/generator/toolset identity as the before-state.
674
+ const pins = computePins({ projectDir })
675
+ const before = projectIdentity(projectDir, manifest, parsed.operations, pins)
676
+ const after = await withStagedProject(projectDir, reduction, (stage) =>
677
+ projectIdentity(stage, reduction.manifest, parsed.operations, pins),
678
+ )
679
+
680
+ const result = {
681
+ beforeFingerprint: before.hash,
682
+ afterFingerprint: after.hash,
683
+ operations: parsed.operations,
684
+ manifestDiff: diffManifests(manifest, reduction.manifest),
685
+ fileDiff: {
686
+ derived: diffFileState(before.identity.derived, after.identity.derived),
687
+ userOwned: diffFileState(before.identity.userOwned, after.identity.userOwned),
688
+ },
689
+ }
690
+ const envelope = resultEnvelope('plan', true, 0, [], result)
691
+
692
+ let outWritten = false
693
+ if (outPath) {
694
+ const safety = assertSafeOutPath(projectDir, outPath, operationsPath)
695
+ if (!safety.ok) {
696
+ // A refused path inside the project is reported as a project-root-relative
697
+ // POSIX path (E1 task 4.11); a path outside the project has no such path
698
+ // and is reported by message only.
699
+ const extra = safety.path ? { path: safety.path } : {}
700
+ return {
701
+ envelope: resultEnvelope('plan', false, 1, [
702
+ diagnostic('E_OUT_PATH_REFUSED', safety.message, extra),
703
+ ]),
704
+ exitCode: 1,
705
+ }
706
+ }
707
+ atomicWrite(resolveOut(outPath), `${JSON.stringify(envelope, null, 2)}\n`, inject)
708
+ outWritten = true
709
+ }
710
+ return { envelope, exitCode: 0, outWritten }
711
+ }
712
+
713
+ // `--out` resolves against the consumer cwd, not the project root (Decision 12).
714
+ function resolveOut(outPath) {
715
+ return path.resolve(outPath)
716
+ }
717
+
718
+ const REFUSED_TOP_SEGMENTS = new Set([
719
+ 'app',
720
+ 'public',
721
+ 'lib',
722
+ 'node_modules',
723
+ '.next',
724
+ '.git',
725
+ '.opencode',
726
+ '.yarn',
727
+ 'skills',
728
+ 'commands',
729
+ 'recipes',
730
+ 'scripts',
731
+ ])
732
+ const REFUSED_BASENAMES = new Set([
733
+ 'package.json',
734
+ 'package-lock.json',
735
+ 'yarn.lock',
736
+ 'pnpm-lock.yaml',
737
+ 'pnpm-workspace.yaml',
738
+ 'storefront.config.json',
739
+ 'storefront.json',
740
+ '.npmrc',
741
+ '.yarnrc',
742
+ '.yarnrc.yml',
743
+ 'components.json',
744
+ 'tsconfig.json',
745
+ 'jsconfig.json',
746
+ 'next.config.js',
747
+ 'next.config.mjs',
748
+ 'next.config.ts',
749
+ ])
750
+
751
+ // Resolve the canonical path of a possibly non-existent target by realpath-ing
752
+ // its longest existing ancestor and re-appending the missing tail. `realpathSync`
753
+ // alone throws `ENOENT` for a target whose *parent* is a symlink into a protected
754
+ // tree (e.g. `link -> project/lib`, `--out link/plan.json`), so the lexical path
755
+ // would otherwise look outside the project and be allowed while the write lands
756
+ // in the owned tree. Returns the input unchanged when no ancestor can be resolved.
757
+ function realpathThroughExistingAncestor(abs) {
758
+ const missing = []
759
+ let current = abs
760
+ while (!existsSync(current)) {
761
+ const parent = path.dirname(current)
762
+ if (parent === current) return abs
763
+ missing.unshift(path.basename(current))
764
+ current = parent
765
+ }
766
+ let resolved
767
+ try {
768
+ resolved = realpathSync(current)
769
+ } catch {
770
+ return abs
771
+ }
772
+ return missing.length > 0 ? path.join(resolved, ...missing) : resolved
773
+ }
774
+
775
+ /**
776
+ * Refuse an `--out` path that resolves to an owned, derived, secret-bearing, or
777
+ * installation file/path tree, a symlink-resolved equivalent, or the operation
778
+ * input file (Decision 12). Returns `{ ok }` or
779
+ * `{ ok: false, message, path? }`, where `path` is the project-root-relative
780
+ * POSIX path of the refused candidate when it lies inside the project (a
781
+ * diagnostic path is project-root-relative POSIX; E1 task 4.11).
782
+ */
783
+ export function assertSafeOutPath(projectDir, outPath, operationsPath = null) {
784
+ const abs = path.resolve(outPath)
785
+ // Both the lexical path and its symlink-resolved equivalent are candidates, so
786
+ // a link whose target is owned/derived/secret/installation (including a
787
+ // non-existent child reached through a symlinked parent) is refused even when
788
+ // the link itself lives outside the project.
789
+ const candidates = [abs]
790
+ const resolved = realpathThroughExistingAncestor(abs)
791
+ if (resolved !== abs) candidates.push(resolved)
792
+
793
+ // The operations input must never be overwritten by `--out`. This is an input
794
+ // collision check, NOT a project-membership check, so it is compared directly
795
+ // against the out path (lexical and symlink-resolved) rather than by adding
796
+ // the input to the candidate tree list — which would otherwise refuse every
797
+ // safe `--out` because the input always equals itself.
798
+ if (operationsPath) {
799
+ const opsLexical = path.resolve(operationsPath)
800
+ const opsResolved = realpathThroughExistingAncestor(opsLexical)
801
+ if (candidates.includes(opsLexical) || candidates.includes(opsResolved)) {
802
+ return { ok: false, message: '--out must not overwrite the operations input.' }
803
+ }
804
+ }
805
+
806
+ for (const candidate of candidates) {
807
+ const rel = path.relative(projectDir, candidate)
808
+ const inside = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
809
+ const projectRel = inside && rel !== '' ? rel.split(path.sep).join('/') : null
810
+ const refuse = (message) =>
811
+ projectRel !== null ? { ok: false, message, path: projectRel } : { ok: false, message }
812
+ if (rel === '') return refuse('--out must not resolve to the project root.')
813
+ // Owned/derived/installation trees are project-relative: a path *outside*
814
+ // the project is a safe explicit artifact even if a segment shares a name
815
+ // (e.g. `/tmp/lib/plan.json`). Only a candidate that is genuinely inside the
816
+ // project — lexically or through a symlink — is checked against the tree set.
817
+ if (inside) {
818
+ const segments = rel.split(path.sep)
819
+ if (segments.some((segment) => REFUSED_TOP_SEGMENTS.has(segment))) {
820
+ return refuse(`--out resolves inside the owned tree "${segments[0]}".`)
821
+ }
822
+ }
823
+ const base = path.basename(candidate)
824
+ if (REFUSED_BASENAMES.has(base) || SECRET_BASENAME.some((re) => re.test(base))) {
825
+ return refuse(`--out resolves to the owned or secret-bearing file "${base}".`)
826
+ }
827
+ }
828
+ return { ok: true }
829
+ }
830
+
831
+ // Atomic artifact write: a same-directory temp file plus `rename` so a reader
832
+ // never observes a partial plan, and the pre-existing artifact stays byte-exact
833
+ // until the rename commits. On any failure between the temp write and the
834
+ // rename, the attempt's OWN temp file is removed; pre-existing directories and
835
+ // files are never touched. `inject(phase, context)` is a controlled test seam
836
+ // (never read from the environment): production callers pass nothing, and a test
837
+ // throws from `plan-after-temp-write` to prove the cleanup and byte-stability.
838
+ export function atomicWrite(abs, text, inject = null) {
839
+ mkdirSync(path.dirname(abs), { recursive: true })
840
+ const tmp = `${abs}.tmp-${process.pid}-${Date.now()}`
841
+ try {
842
+ writeFileSync(tmp, text)
843
+ inject?.('plan-after-temp-write', { outPath: abs, tmpPath: tmp })
844
+ renameSync(tmp, abs)
845
+ } catch (error) {
846
+ try {
847
+ unlinkSync(tmp)
848
+ } catch {
849
+ // The temp may never have been created, or may already be gone.
850
+ }
851
+ throw error
852
+ }
853
+ }
854
+
855
+ // Exclusive-create retry budget for the local apply lock (E1 task 4.6). The
856
+ // wait must comfortably exceed a normal apply so a concurrent run observes the
857
+ // winner's after-state and resolves to a verified no-op instead of giving up
858
+ // with `E_LOCK_HELD`. A lock left by a crashed process is NOT stolen: there is no
859
+ // PID probing and no liveness check, so a stale lock can only be cleared by a
860
+ // human removing `.opencode/.storefront-apply.lock` (the lock this worker
861
+ // created, never another process's). Interruption recovery is `sync`, which
862
+ // repairs the manifest/derived divergence and does not need or release the lock
863
+ // (E1 task 4.8).
864
+ const LOCK_RETRIES = 300
865
+ const LOCK_WAIT_MS = 20
866
+
867
+ function sleepSync(ms) {
868
+ const buffer = new Int32Array(new SharedArrayBuffer(4))
869
+ Atomics.wait(buffer, 0, 0, ms)
870
+ }
871
+
872
+ function acquireLock(projectDir) {
873
+ const lockPath = path.join(projectDir, '.opencode', '.storefront-apply.lock')
874
+ mkdirSync(path.dirname(lockPath), { recursive: true })
875
+ for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
876
+ try {
877
+ const fd = openSync(lockPath, 'wx')
878
+ writeFileSync(fd, String(process.pid))
879
+ closeSync(fd)
880
+ return { ok: true, lockPath }
881
+ } catch (error) {
882
+ if (error.code !== 'EEXIST') return { ok: false, message: error.message }
883
+ sleepSync(LOCK_WAIT_MS)
884
+ }
885
+ }
886
+ return { ok: false, message: 'another apply holds the project lock' }
887
+ }
888
+
889
+ function releaseLock(lock) {
890
+ if (!lock?.lockPath) return
891
+ try {
892
+ unlinkSync(lock.lockPath)
893
+ } catch {
894
+ // A lock already gone is not an error.
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Apply a recorded plan. Under a local lock, re-evaluate the current state into
900
+ * three branches: matching the before-state applies; matching the recorded
901
+ * after-state is a verified no-op; matching neither is `E_STALE_PLAN`. All
902
+ * validation happens before any write, and a handled write failure restores
903
+ * every touched file. Returns `{ envelope, exitCode }`.
904
+ */
905
+ export function applyProject(projectDir, planArtifact, options = {}) {
906
+ return runGuarded('apply', projectDir, () => applyProjectInner(projectDir, planArtifact, options))
907
+ }
908
+
909
+ async function applyProjectInner(projectDir, planArtifact, { inject = null, registry = null } = {}) {
910
+ if (
911
+ !isPlainObject(planArtifact) ||
912
+ planArtifact.formatVersion !== PLAN_FORMAT_VERSION ||
913
+ !isPlainObject(planArtifact.result) ||
914
+ !Array.isArray(planArtifact.result.operations)
915
+ ) {
916
+ return {
917
+ envelope: resultEnvelope('apply', false, 1, [
918
+ diagnostic('E_PLAN_INVALID', 'the plan artifact is missing a version-1 result envelope.'),
919
+ ]),
920
+ exitCode: 1,
921
+ }
922
+ }
923
+ // Controlled programmatic seam (E1 task 4.11) invoked BEFORE the handled
924
+ // commit region: production callers pass nothing; a test throws here to
925
+ // exercise the unexpected-internal-error branch (exit 2) rather than the
926
+ // handled-failure rollback (which is reached only through `commitReduction`).
927
+ inject?.('apply-before-lock', { projectDir, operations: planArtifact.result.operations })
928
+ const { operations, beforeFingerprint, afterFingerprint } = planArtifact.result
929
+ const lock = acquireLock(projectDir)
930
+ if (!lock.ok) {
931
+ return {
932
+ envelope: resultEnvelope('apply', false, 1, [diagnostic('E_LOCK_HELD', lock.message)]),
933
+ exitCode: 1,
934
+ }
935
+ }
936
+ try {
937
+ const manifest = readManifest(projectDir)
938
+ if (!manifest) {
939
+ return {
940
+ envelope: resultEnvelope('apply', false, 1, [
941
+ diagnostic('E_NO_MANIFEST', 'No storefront.config.json found in the project.'),
942
+ ]),
943
+ exitCode: 1,
944
+ }
945
+ }
946
+ const reduction = reduceManifest(manifest, operations, { force: true, registry })
947
+ if (!reduction.ok) {
948
+ return { envelope: resultEnvelope('apply', false, 1, reduction.diagnostics), exitCode: 1 }
949
+ }
950
+ // Re-check ownership conflicts under the lock, before any state resolution
951
+ // or write, so an apply never renders against an unprovided shape
952
+ // (E1 task 5.1 / CU-08).
953
+ const conflicts = detectOwnershipConflicts(projectDir, reduction.manifest)
954
+ if (!conflicts.ok) {
955
+ return { envelope: resultEnvelope('apply', false, 1, conflicts.diagnostics), exitCode: 1 }
956
+ }
957
+ const current = projectIdentity(projectDir, manifest, operations, computePins({ projectDir }))
958
+ if (current.hash === afterFingerprint) {
959
+ return {
960
+ envelope: resultEnvelope('apply', true, 0, [], { state: 'verified-noop' }),
961
+ exitCode: 0,
962
+ }
963
+ }
964
+ if (current.hash !== beforeFingerprint) {
965
+ return {
966
+ envelope: resultEnvelope('apply', false, 1, [
967
+ diagnostic('E_STALE_PLAN', 'the project state matches neither the before-state nor the after-state.'),
968
+ ]),
969
+ exitCode: 1,
970
+ }
971
+ }
972
+ const commit = await commitReduction(projectDir, reduction, { snapshot: true, inject })
973
+ if (!commit.ok) {
974
+ return { envelope: resultEnvelope('apply', false, 1, commit.diagnostics), exitCode: 1 }
975
+ }
976
+ return {
977
+ envelope: resultEnvelope('apply', true, 0, [], {
978
+ state: 'applied',
979
+ changed: commit.changed,
980
+ preserved: commit.preserved,
981
+ }),
982
+ exitCode: 0,
983
+ }
984
+ } finally {
985
+ releaseLock(lock)
986
+ }
987
+ }