@thorprovider/storefront 0.3.1 → 0.4.0-mvp.0

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