@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
package/bin/storefront.js CHANGED
@@ -8,21 +8,72 @@
8
8
  // doctor Report the recorded version and whether the installed skills match this package
9
9
  //
10
10
  // On a TTY, `create` prompts for missing inputs (name, site name, backend URL, preset —
11
- // Maia is preselected). Without a TTY, every required input must be a flag; the command
12
- // fails fast and writes nothing rather than guessing. Brand color is never prompted.
11
+ // Maia is preselected). Empty prompts fall back to their suggested default, so pressing
12
+ // Enter accepts it: the name is suggested from the target directory basename and the
13
+ // site name from the Title Case of that name. Without a TTY, every required input must
14
+ // be a flag; the command fails fast and writes nothing rather than guessing. Brand
15
+ // color is never prompted.
13
16
  //
14
17
  // The package's generated copies are verified against their source before anything is
15
18
  // copied into a target project. On divergence the command stops and writes nothing.
16
19
 
17
- import { cp, mkdir, readdir, readFile, writeFile, rm } from 'node:fs/promises'
20
+ import { cp, mkdir, readdir, writeFile, rm } from 'node:fs/promises'
18
21
  import { existsSync, readFileSync } from 'node:fs'
19
- import { createHash } from 'node:crypto'
20
22
  import { spawnSync } from 'node:child_process'
21
- import { dirname, join, relative, resolve } from 'node:path'
23
+ import { randomBytes } from 'node:crypto'
24
+ import { tmpdir } from 'node:os'
25
+ import { basename, dirname, join, relative, resolve } from 'node:path'
22
26
  import { fileURLToPath } from 'node:url'
23
27
  import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts'
24
28
  import { checkAssets } from '../scripts/sync-assets.mjs'
25
- import { renderScaffold, toKebab } from '../scripts/render-templates.mjs'
29
+ import { renderScaffold, toKebab, toTitle, injectThemeMode } from '../scripts/render-templates.mjs'
30
+ import {
31
+ findProjectRoot,
32
+ hasManifest,
33
+ looksLikeStorefront,
34
+ readManifest,
35
+ validateManifest,
36
+ createManifest,
37
+ writeManifest,
38
+ } from '../scripts/manifest.mjs'
39
+ import { reconcile } from '../scripts/reconcile.mjs'
40
+ import { preflightManifest } from '../scripts/preflight.mjs'
41
+ import {
42
+ buildInstallPlan,
43
+ CANDIDATE_TOOLSET,
44
+ findPack,
45
+ parseRuntimeCommerceImports,
46
+ verifyPackExports,
47
+ verifyPackManifest,
48
+ } from '../scripts/candidate.mjs'
49
+ import {
50
+ hashManagedSuite,
51
+ hashPaths,
52
+ managedSuitePaths,
53
+ readDirectDependencies,
54
+ readResolvedVersions,
55
+ readLockfile,
56
+ readToolchainEvidence,
57
+ buildPinningRecord,
58
+ validateDeclaredContract,
59
+ validatePinning,
60
+ } from '../scripts/pinning.mjs'
61
+ import {
62
+ loadRecipes,
63
+ loadArchetypes,
64
+ loadFeatures,
65
+ isCuratedView,
66
+ featureSurfaces,
67
+ allowedVariantsForView,
68
+ supportedVariantsForView,
69
+ supportedIntents,
70
+ viewCatalog,
71
+ sectionRegistry,
72
+ } from '../recipes/validate.mjs'
73
+ import { CATALOG_VERSION, loadCatalog } from '../recipes/catalog.mjs'
74
+ import { OPERATION_NAMES } from '../scripts/operations.mjs'
75
+ import { applyProject, executeOperations, planProject, resultEnvelope } from '../scripts/plan-apply.mjs'
76
+ import { SECTION_OPERATION_REGISTRY } from '../scripts/section-operations.mjs'
26
77
 
27
78
  const __dirname = dirname(fileURLToPath(import.meta.url))
28
79
  const PKG_ROOT = resolve(__dirname, '..')
@@ -36,6 +87,8 @@ const COPY_DIRS = ['commands', 'skills']
36
87
  const PRUNED_PATHS = ['commands/sf-init.md', 'skills/sf-scaffold']
37
88
 
38
89
  const PRESETS = ['maia', 'nova', 'vega', 'luma', 'lyra', 'mira']
90
+ const THEME_MODES = ['light', 'dark', 'system']
91
+ const DEFAULT_THEME_MODE = 'system'
39
92
  const PRESET_LABELS = {
40
93
  maia: 'Rounded and spacious. Warm and approachable — fits a store.',
41
94
  nova: 'Compact and balanced. The neutral default.',
@@ -50,17 +103,57 @@ const HELP = `@thorprovider/storefront — scaffold Thor Commerce storefronts
50
103
 
51
104
  Usage:
52
105
  storefront create <dir> [options] Scaffold a storefront and install the suite
53
- storefront <update|doctor> <dir> Manage an existing install
106
+ storefront theme [options] Change the site color mode deterministically
107
+ storefront sync Reconcile derived files to the manifest
108
+ storefront views [--json] List the view registry (recipes, intents, installed)
109
+ storefront features [--json] List the feature registry (bundles, installed)
110
+ storefront recipes [--json] Print the whole curated registry (views + features)
111
+ storefront view add|remove <name> Add or remove a view (reconciles derived files)
112
+ storefront feature add|remove <name> Add or remove a feature (installs its views)
113
+ storefront inspect [--json] Report the versioned catalog and composition (read-only)
114
+ storefront plan --operations <file> [--out <file>] [--json]
115
+ Validate a whole intended state and emit a plan
116
+ storefront apply --plan <file> [--json] Apply a recorded plan from its before-state
117
+ storefront <update|doctor> Manage an existing install
118
+
119
+ Every command except create runs inside the project: it resolves the storefront
120
+ from the current directory upward (storefront.config.json) and errors outside one.
54
121
 
55
122
  create options:
56
123
  --name <name> storefront name = lib/<name>/ (required without a terminal)
57
- --site-name <name> human site name (default: <name>)
124
+ --site-name <name> human site name (default: Title Case of <name>)
58
125
  --backend-url <url> Medusa backend URL (default: http://localhost:9000)
59
126
  --preset <preset> maia | nova | vega | luma | lyra | mira (required without a terminal)
127
+ --mode <mode> light | dark | system (default: system)
60
128
  --brand <color> brand color over preset tokens (not prompted by the wizard)
129
+ --packs <dir> directory of local @thorprovider/* .tgz tarballs; installs
130
+ the local candidate via Yarn resolutions (no publish)
61
131
  -f, --force overwrite files that already exist
62
132
  -h, --help show this help
63
133
 
134
+ theme options:
135
+ --mode <mode> light | dark | system (required; rewritten in app/layout.tsx)
136
+
137
+ view options:
138
+ --intent <slot=intent> choose a slot's intent from the view's recipe
139
+ --variant <slot=Variant> choose a slot's variant directly
140
+ -f, --force re-add a view that is already installed
141
+
142
+ feature options:
143
+ --config <json> set the feature's config (a JSON object), validated
144
+ against its registry contract and persisted
145
+ -f, --force re-add a feature that is already installed
146
+ (preserves the existing config unless --config is given)
147
+
148
+ views options:
149
+ --json print the registry (recipes, intents, installed) as JSON
150
+
151
+ features options:
152
+ --json print the feature registry (bundles, installed) as JSON
153
+
154
+ recipes options:
155
+ --json print the whole curated registry (views + features) as JSON
156
+
64
157
  In a terminal, create prompts for missing inputs (Maia is preselected).
65
158
  Without a terminal, every required input must be a flag — the command fails fast.
66
159
  `
@@ -73,9 +166,18 @@ function parseArgs(argv) {
73
166
  siteName: null,
74
167
  backendUrl: null,
75
168
  preset: null,
169
+ mode: null,
76
170
  brand: null,
77
171
  force: false,
78
172
  help: false,
173
+ intents: {},
174
+ variants: {},
175
+ json: false,
176
+ packs: null,
177
+ config: null,
178
+ operations: null,
179
+ out: null,
180
+ plan: null,
79
181
  }
80
182
  const positionals = []
81
183
  for (let i = 0; i < argv.length; i++) {
@@ -88,8 +190,46 @@ function parseArgs(argv) {
88
190
  out.backendUrl = argv[++i]
89
191
  } else if (arg === '--preset') {
90
192
  out.preset = argv[++i]
193
+ } else if (arg === '--mode') {
194
+ out.mode = argv[++i]
91
195
  } else if (arg === '--brand') {
92
196
  out.brand = argv[++i]
197
+ } else if (arg === '--packs') {
198
+ out.packs = argv[++i]
199
+ } else if (arg === '--operations') {
200
+ out.operations = argv[++i]
201
+ } else if (arg === '--out') {
202
+ out.out = argv[++i]
203
+ } else if (arg === '--plan') {
204
+ out.plan = argv[++i]
205
+ } else if (arg === '--intent') {
206
+ const pair = argv[++i] ?? ''
207
+ const eq = pair.indexOf('=')
208
+ if (eq === -1) {
209
+ console.error(`--intent expects slot=intent (got "${pair}")`)
210
+ process.exitCode = 1
211
+ return null
212
+ }
213
+ out.intents[pair.slice(0, eq)] = pair.slice(eq + 1)
214
+ } else if (arg === '--variant') {
215
+ const pair = argv[++i] ?? ''
216
+ const eq = pair.indexOf('=')
217
+ if (eq === -1) {
218
+ console.error(`--variant expects slot=Variant (got "${pair}")`)
219
+ process.exitCode = 1
220
+ return null
221
+ }
222
+ out.variants[pair.slice(0, eq)] = pair.slice(eq + 1)
223
+ } else if (arg === '--json') {
224
+ out.json = true
225
+ } else if (arg === '--config') {
226
+ const value = argv[++i]
227
+ if (value === undefined) {
228
+ console.error('--config expects a JSON object value.')
229
+ process.exitCode = 1
230
+ return null
231
+ }
232
+ out.config = value
93
233
  } else if (arg === '--force' || arg === '-f') {
94
234
  out.force = true
95
235
  } else if (arg === '--help' || arg === '-h') {
@@ -103,11 +243,84 @@ function parseArgs(argv) {
103
243
  }
104
244
  }
105
245
  out.action = positionals[0] ?? null
246
+ out.positionals = positionals
106
247
  if (positionals[1]) out.dir = resolve(positionals[1])
107
248
  else if (!out.dir) out.dir = process.cwd()
108
249
  return out
109
250
  }
110
251
 
252
+ // Only `create` takes a directory. Every other command runs inside the project
253
+ // and resolves the storefront from the current directory upward. When none is
254
+ // found the command fails before touching the filesystem.
255
+ // Search upward for a pre-manifest install record (the old `.create-storefront.json`).
256
+ // Used only as a fallback signal when no storefront-shaped project is found.
257
+ function findLegacyRoot(startDir) {
258
+ let current = startDir
259
+ while (true) {
260
+ if (existsSync(join(current, '.opencode', LEGACY_RECORD_FILE))) return current
261
+ const parent = dirname(current)
262
+ if (parent === current) return null
263
+ current = parent
264
+ }
265
+ }
266
+
267
+ // Legacy (pre-manifest) projects are detected and refused with a no-write
268
+ // diagnostic. Automatic migration is held post-MVP (see trace-matrix.md), so
269
+ // this must run before any command function and touch nothing on disk.
270
+ function rejectLegacyProject(target) {
271
+ console.error(`No storefront.config.json found in ${target}, but it looks like a pre-manifest (legacy) storefront.`)
272
+ console.error('Automatic legacy migration is held post-MVP (traced in trace-matrix.md). No files were changed.')
273
+ console.error('Run `storefront create <dir>` to scaffold a new storefront, or add storefront.config.json manually.')
274
+ return 1
275
+ }
276
+
277
+ function resolveProject({ quiet = false } = {}) {
278
+ const root = findProjectRoot(process.cwd()) ?? findLegacyRoot(process.cwd())
279
+ if (!root) {
280
+ if (!quiet) {
281
+ console.error('Not inside a storefront project (no storefront.config.json found in this directory or any parent).')
282
+ console.error('Run `storefront create <dir>` to create one, or run this command from inside a storefront.')
283
+ }
284
+ return null
285
+ }
286
+ if (!hasManifest(root) && (looksLikeStorefront(root) || existsSync(join(root, '.opencode', LEGACY_RECORD_FILE)))) {
287
+ if (!quiet) rejectLegacyProject(root)
288
+ return null
289
+ }
290
+ return root
291
+ }
292
+
293
+ // Read and validate the project manifest, distinguishing a physically absent
294
+ // config (legacy project with no manifest yet) from a present config that parses
295
+ // to a non-object JSON value (`null`, `false`, `0`, `""`, `[]`). A present config
296
+ // is validated against the published shape here, at the shared read boundary, so
297
+ // no caller can mistake an invalid existing config for absence and write around
298
+ // it. Returns { manifest } (object) for a valid config, { manifest: null } for a
299
+ // truly absent one, or { error } with a precise input diagnostic.
300
+ function readManifestOrError(target) {
301
+ if (!hasManifest(target)) return { manifest: null }
302
+
303
+ let manifest
304
+ try {
305
+ manifest = readManifest(target)
306
+ } catch (err) {
307
+ return { error: `storefront.config.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
308
+ }
309
+
310
+ if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
311
+ return { error: 'storefront.config.json must contain a JSON object.' }
312
+ }
313
+
314
+ const shape = validateManifest(manifest)
315
+ if (!shape.ok) {
316
+ return {
317
+ error: `storefront.config.json does not satisfy the published schema:\n${shape.errors.map((error) => ` ${error}`).join('\n')}`,
318
+ }
319
+ }
320
+
321
+ return { manifest }
322
+ }
323
+
111
324
  async function copyDir(from, to, force) {
112
325
  const entries = await readdir(from, { withFileTypes: true })
113
326
  let copied = 0
@@ -131,28 +344,6 @@ async function prunePath(opencodeDir, relPath) {
131
344
  await rm(join(opencodeDir, relPath), { recursive: true, force: true })
132
345
  }
133
346
 
134
- function sha256(text) {
135
- return createHash('sha256').update(text).digest('hex')
136
- }
137
-
138
- async function hashDir(dir) {
139
- const files = []
140
- async function walk(d) {
141
- for (const entry of await readdir(d, { withFileTypes: true })) {
142
- const full = join(d, entry.name)
143
- if (entry.isDirectory()) await walk(full)
144
- else files.push(full)
145
- }
146
- }
147
- if (existsSync(dir)) await walk(dir)
148
- files.sort()
149
- const lines = []
150
- for (const f of files) {
151
- lines.push(`${relative(dir, f)}:${sha256(await readFile(f, 'utf8'))}`)
152
- }
153
- return sha256(lines.join('\n'))
154
- }
155
-
156
347
  function pkgVersion() {
157
348
  const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
158
349
  return pkg.version
@@ -172,14 +363,82 @@ async function readRecord(opencodeDir) {
172
363
  return null
173
364
  }
174
365
 
366
+ // The installed package-owned suite digests. Only the package's own file set is
367
+ // hashed, so a user's custom skill/command file under `.opencode/` never shows
368
+ // up as drift or gets stamped into the record.
369
+ async function installedSuiteBom(projectDir) {
370
+ return hashPaths(join(projectDir, '.opencode'), managedSuitePaths(PKG_ROOT))
371
+ }
372
+
373
+ async function collectLivePinning(projectDir) {
374
+ const direct = readDirectDependencies(projectDir)
375
+ if (!direct.ok) return { ok: false, error: direct.error }
376
+ const resolvedVersions = readResolvedVersions(projectDir, Object.keys(direct.dependencies))
377
+ const lock = readLockfile(projectDir)
378
+ if (!lock.ok) return { ok: false, error: lock.error }
379
+ const toolchain = readToolchainEvidence(projectDir)
380
+ if (!toolchain.ok) return { ok: false, error: toolchain.error }
381
+ const assets = await installedSuiteBom(projectDir)
382
+ return {
383
+ ok: true,
384
+ live: {
385
+ directDependencies: direct.dependencies,
386
+ resolvedVersions,
387
+ lockfile: lock.lockfile,
388
+ toolchain: toolchain.toolchain,
389
+ assets,
390
+ },
391
+ }
392
+ }
393
+
394
+ // Validate every *precomputable* layer of the incoming record — declared ranges,
395
+ // resolved versions, the lockfile, and the toolchain/linker — BEFORE any
396
+ // structural write. Assets are expected to change when the suite is refreshed,
397
+ // so they are checked after the copy at stamp time (`writeRecord`). Returns
398
+ // `{ ok, errors }`.
399
+ async function validateIncomingContract(projectDir, version) {
400
+ const collected = await collectLivePinning(projectDir)
401
+ if (!collected.ok) return { ok: false, errors: [collected.error] }
402
+ const incoming = buildPinningRecord({
403
+ cliVersion: version,
404
+ directDependencies: collected.live.directDependencies,
405
+ resolvedVersions: collected.live.resolvedVersions,
406
+ lockfile: collected.live.lockfile,
407
+ assets: await hashManagedSuite(PKG_ROOT),
408
+ })
409
+ return validatePinning(incoming, {
410
+ distributedVersion: version,
411
+ live: collected.live,
412
+ includeAssets: false,
413
+ })
414
+ }
415
+
416
+ // Stamp the install record from the actual, complete project evidence. The
417
+ // record pins the *distributed* package BOM; it is validated against the live
418
+ // installed artifacts before it is written, so a failed copy or an incoherent
419
+ // pin set is reported and never stamped.
175
420
  async function writeRecord(projectDir, version) {
421
+ const collected = await collectLivePinning(projectDir)
422
+ if (!collected.ok) return { ok: false, errors: [collected.error] }
423
+ const { directDependencies, resolvedVersions, lockfile } = collected.live
424
+ const assets = await hashManagedSuite(PKG_ROOT)
425
+ const pinning = buildPinningRecord({
426
+ cliVersion: version,
427
+ directDependencies,
428
+ resolvedVersions,
429
+ lockfile,
430
+ assets,
431
+ })
432
+ const check = validatePinning(pinning, { distributedVersion: version, live: collected.live })
433
+ if (!check.ok) return { ok: false, errors: check.errors }
176
434
  const opencodeDir = join(projectDir, '.opencode')
177
435
  await mkdir(opencodeDir, { recursive: true })
178
436
  await writeFile(
179
437
  join(opencodeDir, VERSION_RECORD_FILE),
180
- JSON.stringify({ version, installedAt: new Date().toISOString() }, null, 2) + '\n',
438
+ JSON.stringify({ version, installedAt: new Date().toISOString(), pinning }, null, 2) + '\n',
181
439
  'utf8',
182
440
  )
441
+ return { ok: true, pinning }
183
442
  }
184
443
 
185
444
  async function verifyConsistency() {
@@ -204,8 +463,11 @@ async function installSuite(opencodeDir, force) {
204
463
  return total
205
464
  }
206
465
 
207
- function run(cmd, cmdArgs, cwd) {
208
- const res = spawnSync(cmd, cmdArgs, { cwd, stdio: 'inherit', shell: false })
466
+ // Run Yarn under the pinned Corepack version (`yarn@x.y.z`), so the scaffold
467
+ // and every install use the canonical package manager deterministically rather
468
+ // than whatever "known good release" Corepack would otherwise pick.
469
+ function runYarn(yarnSpec, cmdArgs, cwd) {
470
+ const res = spawnSync('corepack', [yarnSpec, ...cmdArgs], { cwd, stdio: 'inherit', shell: false })
209
471
  return res.status ?? 1
210
472
  }
211
473
 
@@ -258,6 +520,7 @@ async function promptScaffoldInputs(args) {
258
520
  siteName: args.siteName ?? null,
259
521
  backendUrl: args.backendUrl ?? null,
260
522
  preset: args.preset ?? null,
523
+ mode: args.mode ?? null,
261
524
  brand: args.brand ?? null,
262
525
  }
263
526
 
@@ -265,11 +528,20 @@ async function promptScaffoldInputs(args) {
265
528
  if (tty) {
266
529
  intro('storefront create')
267
530
 
531
+ // The suggested name comes from the target directory basename, so
532
+ // `storefront create .` or `../shop` still suggest the right thing.
533
+ const suggestedName = toKebab(basename(args.dir ?? '')) || 'my-shop'
534
+
268
535
  if (!inputs.name) {
269
536
  const value = await text({
270
537
  message: 'Storefront name (kebab-case — lib/<name>/):',
271
- placeholder: 'my-shop',
272
- validate: (v) => (toKebab(v ?? '').length ? undefined : 'Enter a kebab-case name'),
538
+ placeholder: suggestedName,
539
+ defaultValue: suggestedName,
540
+ // Empty is valid: clack validates the raw value before applying
541
+ // `defaultValue`, so rejecting empty here would block Enter from
542
+ // accepting the suggested name. Only input that normalizes to nothing
543
+ // (e.g. "!!!") is rejected.
544
+ validate: (v) => (!v || toKebab(v).length ? undefined : 'Enter a kebab-case name'),
273
545
  })
274
546
  if (isCancel(value)) {
275
547
  cancel('Operation cancelled.')
@@ -279,9 +551,11 @@ async function promptScaffoldInputs(args) {
279
551
  }
280
552
 
281
553
  if (!inputs.siteName) {
554
+ const suggestedSiteName = toTitle(inputs.name)
282
555
  const value = await text({
283
556
  message: 'Site name:',
284
- initialValue: inputs.name,
557
+ placeholder: suggestedSiteName,
558
+ defaultValue: suggestedSiteName,
285
559
  })
286
560
  if (isCancel(value)) {
287
561
  cancel('Operation cancelled.')
@@ -318,6 +592,23 @@ async function promptScaffoldInputs(args) {
318
592
  inputs.preset = value
319
593
  }
320
594
 
595
+ if (!inputs.mode) {
596
+ const value = await select({
597
+ message: 'Site color mode:',
598
+ initialValue: DEFAULT_THEME_MODE,
599
+ options: [
600
+ { value: 'system', label: 'system — follow the visitor’s OS preference' },
601
+ { value: 'light', label: 'light — always render light' },
602
+ { value: 'dark', label: 'dark — always render dark' },
603
+ ],
604
+ })
605
+ if (isCancel(value)) {
606
+ cancel('Operation cancelled.')
607
+ process.exit(0)
608
+ }
609
+ inputs.mode = value
610
+ }
611
+
321
612
  outro('Scaffolding…')
322
613
  }
323
614
 
@@ -325,19 +616,39 @@ async function promptScaffoldInputs(args) {
325
616
  if (!inputs.name) missing.push('storefront name (--name <name>)')
326
617
  if (!inputs.preset) missing.push('--preset (one of: ' + PRESETS.join(', ') + ')')
327
618
  else if (!PRESETS.includes(inputs.preset)) missing.push(`--preset must be one of: ${PRESETS.join(', ')}`)
619
+ if (inputs.mode && !THEME_MODES.includes(inputs.mode)) missing.push(`--mode must be one of: ${THEME_MODES.join(', ')}`)
328
620
 
329
621
  if (missing.length > 0) return { missing }
330
622
  return {
331
623
  inputs: {
332
624
  name: inputs.name,
333
- siteName: inputs.siteName ?? inputs.name,
625
+ siteName: inputs.siteName ?? toTitle(inputs.name),
334
626
  backendUrl: inputs.backendUrl ?? DEFAULT_BACKEND_URL,
335
627
  preset: inputs.preset,
628
+ mode: inputs.mode ?? DEFAULT_THEME_MODE,
336
629
  brand: inputs.brand,
337
630
  },
338
631
  }
339
632
  }
340
633
 
634
+ // Every structural write boundary validates the complete desired composition
635
+ // first. On rejection nothing is written: no manifest, no derived file, and no
636
+ // directory change.
637
+ function rejectComposition(errors) {
638
+ console.error('Refusing to write: the desired composition is invalid.')
639
+ for (const err of errors) console.error(` ${err}`)
640
+ return 1
641
+ }
642
+
643
+ // Print the structured diagnostics the operation engine returns, using the same
644
+ // header every structural write boundary prints so existing consumers keep their
645
+ // wording (E1 task 4.1).
646
+ function rejectDiagnostics(diagnostics) {
647
+ console.error('Refusing to write: the desired composition is invalid.')
648
+ for (const diag of diagnostics) console.error(` ${diag.message}`)
649
+ return 1
650
+ }
651
+
341
652
  async function create(args) {
342
653
  const target = args.dir
343
654
  const { inputs, missing } = await promptScaffoldInputs(args)
@@ -347,13 +658,80 @@ async function create(args) {
347
658
  return 1
348
659
  }
349
660
 
350
- const { name, siteName, backendUrl, preset, brand } = inputs
661
+ const { name, siteName, backendUrl, preset, mode, brand } = inputs
662
+
663
+ // The candidate CLI is the structural authority; the generated project pins
664
+ // the exact compatible toolset (and the CLI itself). Declared here so the
665
+ // contract can be validated before the first write.
666
+ const cliVersion = pkgVersion()
667
+ const plan = buildInstallPlan({ cliVersion, packsDir: args.packs ? resolve(args.packs) : null })
668
+ if (plan.missingPacks.length > 0) {
669
+ console.error(`Refusing to create: missing packed local candidate(s) in ${resolve(args.packs)}:`)
670
+ for (const name of plan.missingPacks) console.error(` ${name}`)
671
+ console.error('Pack each @thorprovider/* package with `yarn pack --filename <slug>-<version>.tgz`.')
672
+ return 1
673
+ }
674
+
675
+ // Integrity guard for the local candidate packs. A `--packs` directory can be
676
+ // stale: a tarball packed before a public export was added still resolves for
677
+ // the same exact version, so the generated project imports a symbol the
678
+ // packed dist does not export. Verify each pack declares the expected
679
+ // name+version (Tier 1) and that the packed runtime actually exports every
680
+ // value the generated host imports from `@thorprovider/runtime/commerce`
681
+ // (Tier 2) before the first write.
682
+ if (args.packs) {
683
+ const packsDir = resolve(args.packs)
684
+ const localPacks = { ...CANDIDATE_TOOLSET.local, '@thorprovider/storefront': cliVersion }
685
+ for (const [name, version] of Object.entries(localPacks)) {
686
+ const guard = verifyPackManifest({ tgzPath: findPack(packsDir, name, version), name, version })
687
+ if (!guard.ok) {
688
+ console.error(`Refusing to create: ${guard.message} (${guard.code})`)
689
+ return 1
690
+ }
691
+ }
692
+ const commerceTemplate = join(PKG_ROOT, 'assets', 'templates', 'app', 'lib', '__STOREFRONT__', 'commerce.ts')
693
+ const requiredExports = parseRuntimeCommerceImports(readFileSync(commerceTemplate, 'utf8'))
694
+ const runtimeName = '@thorprovider/runtime'
695
+ const runtimeGuard = verifyPackExports({
696
+ tgzPath: findPack(packsDir, runtimeName, CANDIDATE_TOOLSET.local[runtimeName]),
697
+ innerPath: 'package/dist/commerce/index.mjs',
698
+ requiredExports,
699
+ })
700
+ if (!runtimeGuard.ok) {
701
+ console.error(`Refusing to create: ${runtimeGuard.message} (${runtimeGuard.code})`)
702
+ return 1
703
+ }
704
+ }
705
+
706
+ // Validate the complete desired composition before the first filesystem
707
+ // operation. `create` always composes the home baseline, but it must pass the
708
+ // same preflight as every other write boundary — and it must run before any
709
+ // mkdir, external scaffold, or write so rejected input leaves no trace.
710
+ const manifest = createManifest({ name, siteName, mode, views: ['home'], features: [] })
711
+ const preflight = preflightManifest(manifest)
712
+ if (!preflight.ok) return rejectComposition(preflight.errors)
713
+
714
+ // Validate the incoming pinning contract before the first write too. The
715
+ // generated project declares the exact compatible toolset and pins the CLI
716
+ // itself; the intended linker is the one `create` writes below.
717
+ const declared = validateDeclaredContract({
718
+ directDependencies: plan.dependencies,
719
+ cliVersion,
720
+ distributedVersion: cliVersion,
721
+ toolchain: { linker: 'node-modules', nodeVersion: process.versions.node, yarnVersion: null },
722
+ })
723
+ if (!declared.ok) {
724
+ console.error('Refusing to create: the declared pinning contract is invalid.')
725
+ for (const err of declared.errors) console.error(` ${err}`)
726
+ return 1
727
+ }
351
728
 
352
729
  console.log('storefront create — deterministic scaffold')
353
730
  console.log(` name: ${name}`)
354
731
  console.log(` site name: ${siteName}`)
355
732
  console.log(` backend: ${backendUrl}`)
356
733
  console.log(` preset: ${preset}`)
734
+ console.log(` mode: ${mode}`)
357
735
  if (brand) console.log(` brand: ${brand}`)
358
736
  console.log(` target: ${target}`)
359
737
 
@@ -381,7 +759,11 @@ async function create(args) {
381
759
  await cp(opencodeDir, stash, { recursive: true })
382
760
  await rm(opencodeDir, { recursive: true, force: true })
383
761
  }
384
- const status = run('yarn', ['create', 'next-app', '.', '--ts', '--tailwind', '--app', '--import-alias', '@/*', '--no-turbopack'], target)
762
+ // --skip-install: the first install is ours to run, under the .yarnrc.yml
763
+ // written in step 3, so network hardening applies to every install. The
764
+ // pinned package manager is forced for the bootstrap too (Corepack), so the
765
+ // whole scaffold runs under the canonical Yarn, not a floating release.
766
+ const status = runYarn(plan.packageManager, ['dlx', `create-next-app@${plan.scaffolder.createNextApp}`, '.', '--ts', '--tailwind', '--app', '--import-alias', '@/*', '--no-turbopack', '--use-yarn', '--skip-install'], target)
385
767
  if (hadOpenCode) {
386
768
  await cp(stash, opencodeDir, { recursive: true })
387
769
  await rm(stash, { recursive: true, force: true })
@@ -392,10 +774,42 @@ async function create(args) {
392
774
  }
393
775
  }
394
776
 
395
- // 3. shadcn base
396
- const shadcnStatus = run(
397
- 'yarn',
398
- ['dlx', 'shadcn@latest', 'init', '--template', 'next', '--base', 'base', '--preset', preset, '--no-monorepo', '--no-rtl', '--yes'],
777
+ // 3. Yarn config — before ANY install (shadcn included): node-modules linker,
778
+ // retries and low concurrency to survive unreliable networks. With the
779
+ // global cache warm, repeat scaffolds only need tiny metadata requests —
780
+ // or none at all via YARN_ENABLE_OFFLINE_MODE=1. No package-manager safety
781
+ // gate is suppressed: the pinned toolset versions are already published
782
+ // past Yarn's `npmMinimalAgeGate`, so no allowlist is needed.
783
+ await writeFile(
784
+ join(target, '.yarnrc.yml'),
785
+ [
786
+ 'nodeLinker: node-modules',
787
+ 'httpRetry: 10',
788
+ 'networkConcurrency: 8',
789
+ ].join('\n') + '\n',
790
+ 'utf8',
791
+ )
792
+ await rm(join(target, '.pnp.cjs'), { force: true })
793
+
794
+ // 3b. Pin the package manager (Corepack reads `packageManager`) and the local
795
+ // candidate. Yarn `resolutions` map each local @thorprovider/* package to
796
+ // its packed tarball while the declared dependency ranges stay honest
797
+ // semver. Written before any install so the whole tree resolves to the
798
+ // local candidate (no publish, no workspace import) under the pinned Yarn.
799
+ {
800
+ const pkgPath = join(target, 'package.json')
801
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
802
+ pkg.packageManager = plan.packageManager
803
+ if (Object.keys(plan.resolutions).length > 0) {
804
+ pkg.resolutions = { ...(pkg.resolutions ?? {}), ...plan.resolutions }
805
+ }
806
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
807
+ }
808
+
809
+ // 4. shadcn base
810
+ const shadcnStatus = runYarn(
811
+ plan.packageManager,
812
+ ['dlx', `shadcn@${plan.scaffolder.shadcn}`, 'init', '--template', 'next', '--base', 'base', '--preset', preset, '--no-monorepo', '--no-rtl', '--yes'],
399
813
  target,
400
814
  )
401
815
  if (shadcnStatus !== 0) {
@@ -403,33 +817,71 @@ async function create(args) {
403
817
  return shadcnStatus
404
818
  }
405
819
 
406
- // 4. Yarn node-modules linker
407
- await writeFile(join(target, '.yarnrc.yml'), 'nodeLinker: node-modules\n', 'utf8')
408
- await rm(join(target, '.pnp.cjs'), { force: true })
820
+ // 4b. Pin the shadcn CLI exactly. `shadcn init` adds itself with a caret; a
821
+ // caret would re-resolve to the newest (possibly age-gated) release on the
822
+ // final install, so write the exact scaffolded version first.
823
+ {
824
+ const pkgPath = join(target, 'package.json')
825
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
826
+ for (const field of ['dependencies', 'devDependencies']) {
827
+ if (pkg[field] && pkg[field].shadcn) pkg[field].shadcn = plan.scaffolder.shadcn
828
+ }
829
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
830
+ }
409
831
 
410
- // 5. Dependencies
411
- const STOREFRONT_DEPS = [
412
- '@thorprovider/blocks',
413
- '@thorprovider/adapters',
414
- '@thorprovider/runtime',
415
- '@json-render/core',
416
- '@json-render/react',
417
- '@json-render/next',
418
- 'motion',
419
- 'zod',
420
- ]
421
- const addStatus = run('yarn', ['add', ...STOREFRONT_DEPS], target)
832
+ // 5. Dependencies — every declared dep at its exact compatible version,
833
+ // including the exact candidate CLI.
834
+ const installDeps = Object.entries(plan.dependencies).map(([dep, version]) => `${dep}@${version}`)
835
+ const addStatus = runYarn(plan.packageManager, ['add', ...installDeps], target)
422
836
  if (addStatus !== 0) {
423
837
  console.error('yarn add failed.')
424
838
  return addStatus
425
839
  }
426
840
 
427
- // 6. Template copy + placeholder substitution
428
- await renderScaffold({ targetDir: target, name, siteName })
841
+ // 6. Template copy + placeholder substitution, then reconcile to the manifest.
842
+ // The manifest was built and validated before any write above. Passing its
843
+ // view entries means the fresh scaffold resolves explicit slot selections
844
+ // through the same pipeline a later reconcile uses.
845
+ await renderScaffold({ targetDir: target, name, siteName, mode, views: manifest.views })
846
+ await writeManifest(target, manifest)
847
+ const result = await reconcile(target, manifest)
848
+ if (!result.ok) return rejectComposition(result.errors)
429
849
 
430
850
  // 7. .env.local + theme
431
- const envLocal = `MEDUSA_BACKEND_URL=${backendUrl}\nMEDUSA_REGION_ID=\nMEDUSA_PUBLISHABLE_KEY=\n`
851
+ // The store context is explicit host configuration (never hardcoded in the
852
+ // app): store identity from the manifest name, channel/region/currency from
853
+ // the environment. The session signing secret is generated per install
854
+ // (32 bytes) and lives only in the git-ignored `.env.local`; the committed
855
+ // `.env.example` carries an empty placeholder, never a real secret.
856
+ const sessionSecret = randomBytes(32).toString('base64url')
857
+ const envLocal = [
858
+ 'THOR_DATA_SOURCE=medusa',
859
+ `MEDUSA_BASE_URL=${backendUrl}`,
860
+ 'MEDUSA_PUBLISHABLE_KEY=',
861
+ 'MEDUSA_REGION_ID=',
862
+ 'MEDUSA_SALES_CHANNEL_ID=',
863
+ `THOR_STORE_ID=${name}`,
864
+ 'THOR_CURRENCY_CODE=',
865
+ `THOR_SESSION_SECRET=${sessionSecret}`,
866
+ ].join('\n') + '\n'
432
867
  await writeFile(join(target, '.env.local'), envLocal, 'utf8')
868
+ const envExample = [
869
+ '# Data source: "mock" for explicit demo data, "medusa" for a real backend.',
870
+ 'THOR_DATA_SOURCE=mock',
871
+ '# Store context (validated before any commerce call; currency is never hardcoded).',
872
+ '# Mock mode must match the fixture context: channel mock-sales-channel,',
873
+ '# region mock-region-us (USD) or mock-region-eu (EUR).',
874
+ `THOR_STORE_ID=${name}`,
875
+ 'THOR_CURRENCY_CODE=USD',
876
+ 'MEDUSA_SALES_CHANNEL_ID=mock-sales-channel',
877
+ 'MEDUSA_REGION_ID=mock-region-us',
878
+ '# Real backend (required only when THOR_DATA_SOURCE=medusa).',
879
+ 'MEDUSA_BASE_URL=http://localhost:9000',
880
+ 'MEDUSA_PUBLISHABLE_KEY=',
881
+ '# Host-only cookie signing secret; >=16 chars, 32 recommended. Generate your own.',
882
+ 'THOR_SESSION_SECRET=',
883
+ ].join('\n') + '\n'
884
+ await writeFile(join(target, '.env.example'), envExample, 'utf8')
433
885
 
434
886
  const globalsPath = join(target, 'app', 'globals.css')
435
887
  if (existsSync(globalsPath)) {
@@ -440,7 +892,12 @@ async function create(args) {
440
892
  // 8. Suite install + version record
441
893
  const opencodeDir = join(target, '.opencode')
442
894
  const total = await installSuite(opencodeDir, args.force)
443
- await writeRecord(target, pkgVersion())
895
+ const record = await writeRecord(target, cliVersion)
896
+ if (!record.ok) {
897
+ console.error('Refusing to stamp the install record: the pinning contract is not complete.')
898
+ for (const err of record.errors) console.error(` ${err}`)
899
+ return 1
900
+ }
444
901
  console.log(`installed ${total} file(s) and recorded the version`)
445
902
 
446
903
  console.log('')
@@ -466,18 +923,57 @@ async function update(args) {
466
923
  return 1
467
924
  }
468
925
 
469
- const total = await installSuite(opencodeDir, args.force)
926
+ // Gate the existing composition before any install/prune/stamp write.
927
+ const { manifest, error } = readManifestOrError(target)
928
+ if (error) {
929
+ console.error(`Refusing to update: ${error}`)
930
+ return 1
931
+ }
932
+ if (manifest) {
933
+ const preflight = preflightManifest(manifest)
934
+ if (!preflight.ok) return rejectComposition(preflight.errors)
935
+ }
936
+
937
+ // Validate every precomputable layer of the incoming pinning contract BEFORE
938
+ // any write (declared ranges, resolved versions, lockfile, toolchain/linker),
939
+ // so a bad range, a missing installed version, a CLI mismatch, a missing lock,
940
+ // or a wrong linker leaves the project intact. Assets are validated after the
941
+ // suite refresh at stamp time.
942
+ const version = pkgVersion()
943
+ const incoming = await validateIncomingContract(target, version)
944
+ if (!incoming.ok) {
945
+ console.error('Refusing to update: the incoming pinning contract is invalid.')
946
+ for (const err of incoming.errors) console.error(` ${err}`)
947
+ return 1
948
+ }
949
+
950
+ // Refresh the package-managed suite to the current artifact set, reconcile
951
+ // derived files, then stamp the record from what is actually installed. The
952
+ // managed suite is package-owned, so it is always refreshed; user-owned
953
+ // storefront files live outside it and are never touched here.
954
+ const total = await installSuite(opencodeDir, true)
470
955
  for (const rel of PRUNED_PATHS) await prunePath(opencodeDir, rel)
471
- await writeRecord(target, pkgVersion())
956
+ if (manifest) {
957
+ const result = await reconcile(target, manifest)
958
+ if (!result.ok) return rejectComposition(result.errors)
959
+ }
960
+ // Stamp only a complete, actually-verified record. A failed copy or an
961
+ // incoherent pin set is reported and never stamped as a clean version.
962
+ const record = await writeRecord(target, version)
963
+ if (!record.ok) {
964
+ console.error('Refusing to stamp the install record: the pinning contract is not complete.')
965
+ for (const err of record.errors) console.error(` ${err}`)
966
+ return 1
967
+ }
968
+ const pinning = record.pinning
472
969
  await rm(join(opencodeDir, LEGACY_RECORD_FILE), { force: true })
473
970
 
474
971
  console.log(`Refreshed install in ${target}:`)
475
972
  console.log(` installed ${total} file(s)`)
476
973
  console.log(' pruned removed files (sf-init, sf-scaffold)')
477
- console.log(' migrated version record to storefront.json')
974
+ console.log(` recorded version ${version} (${Object.keys(pinning.assets).length} managed asset(s))`)
478
975
  console.log('Done. Available in OpenCode:')
479
976
  console.log(' /sf-add-view /sf-view /sf-theme')
480
- if (args.force) console.log('(used --force: existing files were overwritten)')
481
977
  return 0
482
978
  }
483
979
 
@@ -489,25 +985,645 @@ async function doctor(args) {
489
985
  return 1
490
986
  }
491
987
 
988
+ let problems = 0
989
+
990
+ const { manifest, error } = readManifestOrError(target)
991
+ if (error) {
992
+ console.log(` composition: invalid — ${error}`)
993
+ return 1
994
+ }
995
+ if (!manifest) {
996
+ console.log(' composition: no storefront.config.json found (run "storefront sync" to derive one)')
997
+ } else {
998
+ // Dry-run reconcile reports only derived files that diverge; user-owned
999
+ // files are never drift, so they are excluded by construction.
1000
+ const drift = await reconcile(target, manifest, { dryRun: true })
1001
+ if (!drift.ok) {
1002
+ console.log(' composition: invalid — the manifest is not a supported composition:')
1003
+ for (const err of drift.errors) console.log(` ${err}`)
1004
+ console.log(' status: fix storefront.config.json before running "storefront sync"')
1005
+ return 1
1006
+ }
1007
+ if (drift.changed.length === 0) {
1008
+ console.log(' composition: derived files match the manifest')
1009
+ } else {
1010
+ problems++
1011
+ console.log(` composition: ${drift.changed.length} derived file(s) drifted from the manifest:`)
1012
+ for (const rel of drift.changed) console.log(` ${rel}`)
1013
+ console.log(' status: run "storefront sync" to reconcile')
1014
+ }
1015
+ }
1016
+
492
1017
  const opencodeDir = join(target, '.opencode')
493
1018
  const record = await readRecord(opencodeDir)
494
1019
  if (!record) {
495
- console.log('No version record found in .opencode/.')
496
- console.log('Run `storefront update` to install and record the suite.')
497
- return 0
1020
+ console.log(' suite: no version record found in .opencode/')
1021
+ console.log(' status: run "storefront update" to install and record the suite')
1022
+ return problems === 0 ? 0 : 1
498
1023
  }
499
1024
 
500
1025
  const version = pkgVersion()
501
1026
  console.log(` recorded version: ${record.version} (installed ${record.installedAt})`)
502
1027
  console.log(` package version: ${version}`)
503
1028
 
504
- const installedHash = await hashDir(opencodeDir)
505
- const sourceHash = await hashDir(PKG_ROOT)
506
- const match = installedHash === sourceHash
507
- console.log(` installed suite matches package: ${match ? 'yes' : 'no'}`)
508
- if (!match) {
1029
+ const packageAssets = await hashManagedSuite(PKG_ROOT)
1030
+ console.log(` distributed assets: ${Object.keys(packageAssets).length} file(s)`)
1031
+
1032
+ // Truthful version: a record that claims a version the installed artifacts
1033
+ // do not match is a mismatch, not a clean stamp.
1034
+ if (record.version !== version) {
1035
+ problems++
1036
+ console.log(` version mismatch: record says "${record.version}" but the package is "${version}"`)
1037
+ }
1038
+
1039
+ // Compare the record against the live project evidence, not against itself.
1040
+ const collected = await collectLivePinning(target)
1041
+ if (!collected.ok) {
1042
+ problems++
1043
+ console.log(` pinning: cannot read the live project evidence — ${collected.error}`)
1044
+ } else {
1045
+ const tc = collected.live.toolchain
1046
+ console.log(` toolchain (observed): linker=${tc.linker} node=${tc.nodeVersion} yarn=${tc.yarnVersion}`)
1047
+ const pinning = validatePinning(record.pinning, {
1048
+ distributedVersion: version,
1049
+ live: collected.live,
1050
+ })
1051
+ if (!pinning.ok) {
1052
+ problems++
1053
+ console.log(' pinning: the compatibility/pinning contract does not match the live project:')
1054
+ for (const err of pinning.errors) console.log(` ${err}`)
1055
+ } else {
1056
+ console.log(' pinning: declared ranges, resolved versions, lockfile, and asset digests agree')
1057
+ }
1058
+ }
1059
+
1060
+ const installedAssets = await installedSuiteBom(target)
1061
+ const suiteMatches =
1062
+ Object.keys(installedAssets).length === Object.keys(packageAssets).length &&
1063
+ Object.entries(packageAssets).every(([rel, digest]) => installedAssets[rel] === digest)
1064
+ console.log(` installed suite matches package: ${suiteMatches ? 'yes' : 'no'}`)
1065
+ if (!suiteMatches) {
1066
+ problems++
509
1067
  console.log(' status: update available — run "storefront update"')
510
1068
  }
1069
+
1070
+ return problems === 0 ? 0 : 1
1071
+ }
1072
+
1073
+ async function theme(args) {
1074
+ const target = args.dir
1075
+ if (!existsSync(target)) {
1076
+ console.error(`Target directory does not exist: ${target}`)
1077
+ return 1
1078
+ }
1079
+ if (!args.mode) {
1080
+ console.error(`--mode is required (one of: ${THEME_MODES.join(', ')})`)
1081
+ return 1
1082
+ }
1083
+ if (!THEME_MODES.includes(args.mode)) {
1084
+ console.error(`--mode must be one of: ${THEME_MODES.join(', ')}, got: ${args.mode}`)
1085
+ return 1
1086
+ }
1087
+
1088
+ // Gate the existing composition before writing app/layout.tsx. Theme changes
1089
+ // only the color-mode wiring, but it must never stamp a project whose manifest
1090
+ // is not a supported composition.
1091
+ const { manifest, error } = readManifestOrError(target)
1092
+ if (error) {
1093
+ console.error(`Refusing to change theme: ${error}`)
1094
+ return 1
1095
+ }
1096
+ if (manifest) {
1097
+ const preflight = preflightManifest(manifest)
1098
+ if (!preflight.ok) return rejectComposition(preflight.errors)
1099
+ }
1100
+
1101
+ const layoutPath = join(target, 'app', 'layout.tsx')
1102
+ if (!existsSync(layoutPath)) {
1103
+ console.error(`No app/layout.tsx found in ${target}`)
1104
+ console.error('Run `storefront create <dir>` to scaffold the storefront first.')
1105
+ return 1
1106
+ }
1107
+
1108
+ if (manifest) {
1109
+ // Theme is manifest state: record the mode, then reconcile so the derived
1110
+ // layout (and every other derived file) reflects it. A later `sync` is then
1111
+ // a no-op, so the theme change cannot be reverted by regeneration.
1112
+ manifest.theme = { ...manifest.theme, mode: args.mode }
1113
+ const nextPreflight = preflightManifest(manifest)
1114
+ if (!nextPreflight.ok) return rejectComposition(nextPreflight.errors)
1115
+ await writeManifest(target, manifest)
1116
+ const result = await reconcile(target, manifest)
1117
+ if (!result.ok) return rejectComposition(result.errors)
1118
+ console.log(`Theme mode set to "${args.mode}" in storefront.config.json`)
1119
+ return 0
1120
+ }
1121
+
1122
+ // Legacy project without a manifest: only the layout wiring can be updated.
1123
+ // Deterministic rewrite: same mode -> identical bytes (idempotent).
1124
+ const existing = readFileSync(layoutPath, 'utf8')
1125
+ const next = injectThemeMode(existing, args.mode)
1126
+ if (next === existing) {
1127
+ console.log(`Theme mode already "${args.mode}" — no change.`)
1128
+ return 0
1129
+ }
1130
+ await writeFile(layoutPath, next, 'utf8')
1131
+ console.log(`Theme mode set to "${args.mode}" in ${relative(process.cwd(), layoutPath) || layoutPath}`)
1132
+ return 0
1133
+ }
1134
+
1135
+ async function sync(args) {
1136
+ const target = args.dir
1137
+ const { manifest, error } = readManifestOrError(target)
1138
+ if (error) {
1139
+ console.error(`Refusing to sync: ${error}`)
1140
+ return 1
1141
+ }
1142
+ if (!manifest) {
1143
+ // resolveProject already refuses legacy projects before sync runs; this is
1144
+ // a defensive guard so no code path can derive or migrate one.
1145
+ return rejectLegacyProject(target)
1146
+ }
1147
+
1148
+ // Validate the complete desired composition before touching any derived file.
1149
+ const preflight = preflightManifest(manifest)
1150
+ if (!preflight.ok) return rejectComposition(preflight.errors)
1151
+
1152
+ const result = await reconcile(target, manifest)
1153
+ if (!result.ok) return rejectComposition(result.errors)
1154
+ if (result.changed.length === 0) {
1155
+ console.log('No drift detected — the derived files match the manifest.')
1156
+ return 0
1157
+ }
1158
+ console.log(`Repaired ${result.changed.length} derived file(s) drifted from the manifest:`)
1159
+ for (const rel of result.changed) console.log(` ${rel}`)
1160
+ return 0
1161
+ }
1162
+
1163
+ // Add or remove a view through the shared operation engine. `view add|remove`
1164
+ // is a thin alias over `view.add`/`view.remove`, so it produces the same
1165
+ // manifest, derived files, and diagnostics as planning and applying the
1166
+ // equivalent operation (E1 task 4.1 / Decision 17).
1167
+ async function view(args) {
1168
+ const target = args.dir
1169
+ const sub = args.positionals[1]
1170
+ const name = args.positionals[2]
1171
+
1172
+ if (sub !== 'add' && sub !== 'remove') {
1173
+ console.error('Usage: storefront view <add|remove> <name> [--intent slot=intent] [--variant slot=Variant]')
1174
+ return 1
1175
+ }
1176
+ if (!name) {
1177
+ console.error(`storefront view ${sub} requires a view name.`)
1178
+ return 1
1179
+ }
1180
+
1181
+ const { manifest, error } = readManifestOrError(target)
1182
+ if (error) {
1183
+ console.error(`Refusing to modify views: ${error}`)
1184
+ return 1
1185
+ }
1186
+ if (!manifest) {
1187
+ console.error(`No storefront.config.json found in ${target}. Run \`storefront sync\` first.`)
1188
+ return 1
1189
+ }
1190
+
1191
+ const present = manifest.views.some((v) => v.name === name)
1192
+ const operations =
1193
+ sub === 'add'
1194
+ ? [{ op: 'view.add', view: name, intents: args.intents, variants: args.variants }]
1195
+ : [{ op: 'view.remove', view: name }]
1196
+ const result = await executeOperations(target, manifest, operations, { force: args.force })
1197
+ if (!result.ok) {
1198
+ // `--json` emits the shared versioned envelope (same diagnostics the
1199
+ // plan/apply engine returns); the default human output is unchanged
1200
+ // (E1 task 4.16 / Decision 17).
1201
+ if (args.json) {
1202
+ printEnvelope(resultEnvelope('view', false, 1, result.diagnostics))
1203
+ return 1
1204
+ }
1205
+ return rejectDiagnostics(result.diagnostics)
1206
+ }
1207
+ if (args.json) {
1208
+ printEnvelope(
1209
+ resultEnvelope('view', true, 0, [], {
1210
+ action: sub,
1211
+ view: name,
1212
+ changed: result.changed,
1213
+ preserved: result.preserved,
1214
+ }),
1215
+ )
1216
+ return 0
1217
+ }
1218
+
1219
+ if (sub === 'add') {
1220
+ console.log(`${present ? 'Re-added' : 'Added'} view "${name}".`)
1221
+ } else {
1222
+ console.log(`Removed view "${name}".`)
1223
+ for (const leftover of result.preserved) {
1224
+ console.log(`Preserved user-owned file: ${leftover} (remove it manually if no longer needed).`)
1225
+ }
1226
+ if (result.preserved.length > 0) {
1227
+ console.log(` ${result.preserved.length} user-owned file(s) left in place.`)
1228
+ }
1229
+ }
1230
+ if (result.changed.length > 0) console.log(` reconciled ${result.changed.length} derived file(s).`)
1231
+ return 0
1232
+ }
1233
+
1234
+ // Add or remove a feature (a capability bundle) through the shared operation
1235
+ // engine. `feature add|remove` is a thin alias over `feature.add`/`feature.remove`
1236
+ // and preserves every flag (`--config`, `--force`), so it produces the same
1237
+ // manifest, derived files, and diagnostics as the plan/apply equivalent
1238
+ // (E1 task 4.1 / Decision 17).
1239
+ async function feature(args) {
1240
+ const target = args.dir
1241
+ const sub = args.positionals[1]
1242
+ const name = args.positionals[2]
1243
+ if (sub !== 'add' && sub !== 'remove') {
1244
+ console.error('Usage: storefront feature <add|remove> <name> [-f]')
1245
+ return 1
1246
+ }
1247
+ if (!name) {
1248
+ console.error(`storefront feature ${sub} requires a feature name.`)
1249
+ return 1
1250
+ }
1251
+ const { manifest, error } = readManifestOrError(target)
1252
+ if (error) {
1253
+ console.error(`Refusing to modify features: ${error}`)
1254
+ return 1
1255
+ }
1256
+ if (!manifest) {
1257
+ console.error(`No storefront.config.json found in ${target}. Run \`storefront sync\` first.`)
1258
+ return 1
1259
+ }
1260
+
1261
+ const present = manifest.features.some((f) => f.name === name)
1262
+ let operations
1263
+ if (sub === 'add') {
1264
+ let config
1265
+ if (args.config !== null && args.config !== undefined) {
1266
+ try {
1267
+ config = JSON.parse(args.config)
1268
+ } catch (err) {
1269
+ console.error(
1270
+ `--config expects a JSON object (${err instanceof Error ? err.message : String(err)}).`
1271
+ )
1272
+ return 1
1273
+ }
1274
+ }
1275
+ operations = [
1276
+ { op: 'feature.add', feature: name, ...(config === undefined ? {} : { config }) },
1277
+ ]
1278
+ } else {
1279
+ operations = [{ op: 'feature.remove', feature: name }]
1280
+ }
1281
+ const result = await executeOperations(target, manifest, operations, { force: args.force })
1282
+ if (!result.ok) {
1283
+ // `--json` emits the shared versioned envelope (same diagnostics the
1284
+ // plan/apply engine returns); the default human output is unchanged
1285
+ // (E1 task 4.16 / Decision 17).
1286
+ if (args.json) {
1287
+ printEnvelope(resultEnvelope('feature', false, 1, result.diagnostics))
1288
+ return 1
1289
+ }
1290
+ return rejectDiagnostics(result.diagnostics)
1291
+ }
1292
+ if (args.json) {
1293
+ printEnvelope(
1294
+ resultEnvelope('feature', true, 0, [], {
1295
+ action: sub,
1296
+ feature: name,
1297
+ changed: result.changed,
1298
+ preserved: result.preserved,
1299
+ }),
1300
+ )
1301
+ return 0
1302
+ }
1303
+
1304
+ if (sub === 'add') {
1305
+ console.log(`${present ? 'Re-added' : 'Added'} feature "${name}".`)
1306
+ } else {
1307
+ console.log(`Removed feature "${name}".`)
1308
+ for (const leftover of result.preserved) {
1309
+ console.log(`Preserved customized file: ${leftover} (remove it manually if no longer needed).`)
1310
+ }
1311
+ if (result.preserved.length > 0) console.log(` ${result.preserved.length} file(s) left in place.`)
1312
+ }
1313
+ if (result.changed.length > 0) console.log(` reconciled ${result.changed.length} derived file(s).`)
1314
+ return 0
1315
+ }
1316
+
1317
+ // Build a diagnostic for the result/error envelope. `path`, when present, is a
1318
+ // project-root-relative POSIX file path; operation-field diagnostics use a
1319
+ // semantic locator instead (e.g. `operations[0].surprise`), never a filesystem
1320
+ // path (E1 task 4.11).
1321
+ function envelopeDiagnostic(code, message, extra = {}) {
1322
+ return { code, severity: 'error', message, ...extra }
1323
+ }
1324
+
1325
+ // Print a versioned result/error envelope. Errors are always emitted as the
1326
+ // envelope so a machine caller can read the stable code and exit behavior.
1327
+ function printEnvelope(envelope) {
1328
+ console.log(JSON.stringify(envelope, null, 2))
1329
+ }
1330
+
1331
+ // Read-only, deterministic report of the versioned catalog and the current
1332
+ // composition. Writes nothing (E1 task 4.1 foundation; tasks 4.2/4.11 harden
1333
+ // the catalog/capability surface and the result envelope).
1334
+ function inspect(args) {
1335
+ const target = args.dir
1336
+ let manifest = null
1337
+ if (hasManifest(target)) {
1338
+ const read = readManifestOrError(target)
1339
+ if (read.error) {
1340
+ printEnvelope(
1341
+ resultEnvelope('inspect', false, 1, [
1342
+ envelopeDiagnostic('E_INVALID_COMPOSITION', read.error, {
1343
+ path: 'storefront.config.json',
1344
+ }),
1345
+ ]),
1346
+ )
1347
+ return 1
1348
+ }
1349
+ manifest = read.manifest
1350
+ }
1351
+ const catalog = loadCatalog()
1352
+ const result = {
1353
+ catalogVersion: CATALOG_VERSION,
1354
+ slots: catalog.slots ?? {},
1355
+ capabilities: catalog.capabilities ?? {},
1356
+ composition: {
1357
+ views: manifest?.views ?? [],
1358
+ features: manifest?.features ?? [],
1359
+ },
1360
+ }
1361
+ const envelope = resultEnvelope('inspect', true, 0, [], result)
1362
+ if (args.json) {
1363
+ printEnvelope(envelope)
1364
+ return 0
1365
+ }
1366
+ console.log(`Storefront inspect (${target}):`)
1367
+ console.log(` catalog version: ${result.catalogVersion}`)
1368
+ console.log(` slots: ${Object.keys(result.slots).length}`)
1369
+ console.log(` views: ${result.composition.views.map((v) => v.name).join(', ') || '(none)'}`)
1370
+ console.log(` features: ${result.composition.features.map((f) => f.name).join(', ') || '(none)'}`)
1371
+ return 0
1372
+ }
1373
+
1374
+ // Plan a whole intended state from an operations envelope. Validates every
1375
+ // operation against the resulting complete state and reports all errors; on any
1376
+ // failure it emits no plan and writes nothing. Without `--out` this is fully
1377
+ // read-only and always prints the JSON envelope; with `--out` it writes only the
1378
+ // plan artifact and prints a human summary unless `--json` is given
1379
+ // (E1 tasks 4.1 and 4.3; task 4.4 hardens identity).
1380
+ async function plan(args) {
1381
+ if (!args.operations) {
1382
+ printEnvelope(
1383
+ resultEnvelope('plan', false, 1, [
1384
+ envelopeDiagnostic(
1385
+ 'E_INVALID_ENVELOPE',
1386
+ 'Usage: storefront plan --operations <file> [--out <file>] [--json]',
1387
+ ),
1388
+ ]),
1389
+ )
1390
+ return 1
1391
+ }
1392
+ // `--operations` resolves against the consumer cwd, not the project root
1393
+ // (Decision 12 / E1 task 4.11); the project root is found upward separately.
1394
+ const operationsPath = resolve(args.operations)
1395
+ let raw
1396
+ try {
1397
+ raw = JSON.parse(readFileSync(operationsPath, 'utf8'))
1398
+ } catch (err) {
1399
+ printEnvelope(
1400
+ resultEnvelope('plan', false, 1, [
1401
+ envelopeDiagnostic(
1402
+ 'E_INVALID_ENVELOPE',
1403
+ `Cannot read operations envelope file ${operationsPath}: ${err instanceof Error ? err.message : String(err)}`,
1404
+ ),
1405
+ ]),
1406
+ )
1407
+ return 1
1408
+ }
1409
+ const { envelope, exitCode } = await planProject(args.dir, raw, {
1410
+ outPath: args.out ? resolve(args.out) : null,
1411
+ operationsPath,
1412
+ registry: SECTION_OPERATION_REGISTRY,
1413
+ })
1414
+ if (args.json || !args.out || exitCode !== 0) {
1415
+ printEnvelope(envelope)
1416
+ } else {
1417
+ console.log('storefront plan — valid')
1418
+ console.log(` before: ${envelope.result.beforeFingerprint}`)
1419
+ console.log(` after: ${envelope.result.afterFingerprint}`)
1420
+ console.log(` wrote plan artifact: ${resolve(args.out)}`)
1421
+ }
1422
+ return exitCode
1423
+ }
1424
+
1425
+ // Apply a recorded plan (E1 task 4.1 foundation; tasks 4.5-4.19 harden the
1426
+ // three-state resolution, lock, and rollback branches).
1427
+ async function apply(args) {
1428
+ if (!args.plan) {
1429
+ printEnvelope(
1430
+ resultEnvelope('apply', false, 1, [
1431
+ envelopeDiagnostic('E_PLAN_NOT_FOUND', 'Usage: storefront apply --plan <file> [--json]'),
1432
+ ]),
1433
+ )
1434
+ return 1
1435
+ }
1436
+ // `--plan` resolves against the consumer cwd, not the project root
1437
+ // (Decision 12 / E1 task 4.11).
1438
+ const planPath = resolve(args.plan)
1439
+ let artifact
1440
+ try {
1441
+ artifact = JSON.parse(readFileSync(planPath, 'utf8'))
1442
+ } catch (err) {
1443
+ const code = err?.code === 'ENOENT' ? 'E_PLAN_NOT_FOUND' : 'E_PLAN_INVALID'
1444
+ printEnvelope(
1445
+ resultEnvelope('apply', false, 1, [
1446
+ envelopeDiagnostic(
1447
+ code,
1448
+ `Cannot read plan file ${planPath}: ${err instanceof Error ? err.message : String(err)}`,
1449
+ ),
1450
+ ]),
1451
+ )
1452
+ return 1
1453
+ }
1454
+ const { envelope, exitCode } = await applyProject(args.dir, artifact, {
1455
+ registry: SECTION_OPERATION_REGISTRY,
1456
+ })
1457
+ if (args.json || exitCode !== 0) {
1458
+ printEnvelope(envelope)
1459
+ } else {
1460
+ console.log(`storefront apply — ${envelope.result?.state ?? 'ok'}`)
1461
+ }
1462
+ return exitCode
1463
+ }
1464
+
1465
+ // List the view registry — the recipes and intents the CLI validates against —
1466
+ // annotated with whether each view is installed in the current storefront.
1467
+ async function views(args) {
1468
+ const target = args.dir
1469
+ const manifest = await readManifest(target)
1470
+ const installed = new Set((manifest?.views ?? []).map((v) => v.name))
1471
+ const { views: recipes } = loadRecipes()
1472
+ const { slots } = loadArchetypes()
1473
+
1474
+ const entries = recipes.map((recipe) => {
1475
+ const intents = {}
1476
+ for (const [key, value] of Object.entries(slots ?? {})) {
1477
+ if (!key.startsWith(`${recipe.name}.`)) continue
1478
+ intents[key.slice(recipe.name.length + 1)] = value
1479
+ }
1480
+ return {
1481
+ name: recipe.name,
1482
+ description: recipe.description,
1483
+ route: recipe.route,
1484
+ loader: recipe.loader,
1485
+ requiredDataKeys: recipe.requiredDataKeys ?? [],
1486
+ installed: installed.has(recipe.name),
1487
+ supported: isCuratedView(recipe.name),
1488
+ defaultVariants: recipe.defaultVariants ?? {},
1489
+ // Allowed/supported are derived from the single semantic catalog; a held
1490
+ // variant is reported (with its reason) under `catalog` and never listed
1491
+ // as selectable here.
1492
+ allowedVariants: allowedVariantsForView(recipe.name),
1493
+ supportedVariants: supportedVariantsForView(recipe.name),
1494
+ supportedIntents: supportedIntents(recipe.name),
1495
+ catalog: viewCatalog(recipe.name),
1496
+ optionalSections: recipe.optionalSections ?? [],
1497
+ // E3 section registry (CU-14/CU-16): the ordered, non-removable core
1498
+ // presentation slots, the host allowlist, and the editorial-instance
1499
+ // technical bound. Reported so an agent sees the available hosts without
1500
+ // reading the JSON registry.
1501
+ registeredSlots: recipe.registeredSlots ?? [],
1502
+ allowedSections: recipe.allowedSections ?? [],
1503
+ maxSections: recipe.maxSections ?? 4,
1504
+ intents,
1505
+ }
1506
+ })
1507
+
1508
+ if (args.json) {
1509
+ console.log(
1510
+ JSON.stringify(
1511
+ { catalogVersion: CATALOG_VERSION, sectionTypes: sectionRegistry(), views: entries },
1512
+ null,
1513
+ 2,
1514
+ ),
1515
+ )
1516
+ return 0
1517
+ }
1518
+
1519
+ console.log(`Storefront views (${target}):`)
1520
+ for (const entry of entries) {
1521
+ const status = entry.installed ? 'installed' : entry.supported ? 'available' : 'held'
1522
+ console.log(` ${entry.name.padEnd(12)} ${status.padEnd(10)} ${entry.route}`)
1523
+ }
1524
+ return 0
1525
+ }
1526
+
1527
+ // List the feature registry — the capability bundles the CLI validates against —
1528
+ // annotated with whether each feature is installed in the current storefront.
1529
+ // Held (non-MVP) features are reported as held, never as available.
1530
+ async function features(args) {
1531
+ const target = args.dir
1532
+ const manifest = await readManifest(target)
1533
+ const installed = new Set((manifest?.features ?? []).map((f) => f.name))
1534
+ const { features: specs } = loadFeatures()
1535
+
1536
+ const entries = specs.map((spec) => ({
1537
+ name: spec.name,
1538
+ description: spec.description,
1539
+ mvp: spec.mvp === true,
1540
+ installed: installed.has(spec.name),
1541
+ route: spec.route ?? null,
1542
+ views: spec.views ?? [],
1543
+ state: spec.state ?? [],
1544
+ handlers: spec.handlers ?? [],
1545
+ adapter: spec.adapter ?? null,
1546
+ config: spec.config ?? null,
1547
+ presentations: spec.presentations ?? {},
1548
+ mounts: spec.mounts ?? [],
1549
+ surfaces: featureSurfaces(spec),
1550
+ }))
1551
+
1552
+ if (args.json) {
1553
+ console.log(JSON.stringify({ features: entries }, null, 2))
1554
+ return 0
1555
+ }
1556
+
1557
+ console.log(`Storefront features (${target}):`)
1558
+ for (const entry of entries) {
1559
+ const status = entry.installed ? 'installed' : entry.mvp ? 'available' : 'held'
1560
+ const owned = entry.views.join(', ') || '—'
1561
+ console.log(` ${entry.name.padEnd(16)} ${status.padEnd(10)} ${owned}`)
1562
+ }
1563
+ return 0
1564
+ }
1565
+
1566
+ // Print the whole curated registry — views and features — deterministically, so
1567
+ // agents can introspect what the CLI will accept without reading the JSON files.
1568
+ async function recipes(args) {
1569
+ const { views: viewRecipes } = loadRecipes()
1570
+ const { features: featureSpecs } = loadFeatures()
1571
+
1572
+ const views = viewRecipes.map((recipe) => {
1573
+ return {
1574
+ name: recipe.name,
1575
+ description: recipe.description,
1576
+ route: recipe.route,
1577
+ loader: recipe.loader,
1578
+ mvp: recipe.mvp === true,
1579
+ defaultVariants: recipe.defaultVariants ?? {},
1580
+ // Derived from the single semantic catalog: selectable variants only,
1581
+ // with held variants visible (and reasoned) under `catalog`.
1582
+ allowedVariants: allowedVariantsForView(recipe.name),
1583
+ supportedVariants: supportedVariantsForView(recipe.name),
1584
+ supportedIntents: supportedIntents(recipe.name),
1585
+ catalog: viewCatalog(recipe.name),
1586
+ optionalSections: recipe.optionalSections ?? [],
1587
+ // E3 section registry (CU-14/CU-16): core slots, host allowlist, and the
1588
+ // editorial-instance technical bound, per view.
1589
+ registeredSlots: recipe.registeredSlots ?? [],
1590
+ allowedSections: recipe.allowedSections ?? [],
1591
+ maxSections: recipe.maxSections ?? 4,
1592
+ }
1593
+ })
1594
+ const features = featureSpecs.map((spec) => ({
1595
+ name: spec.name,
1596
+ description: spec.description,
1597
+ mvp: spec.mvp === true,
1598
+ route: spec.route ?? null,
1599
+ views: spec.views ?? [],
1600
+ state: spec.state ?? [],
1601
+ handlers: spec.handlers ?? [],
1602
+ adapter: spec.adapter ?? null,
1603
+ config: spec.config ?? null,
1604
+ presentations: spec.presentations ?? {},
1605
+ mounts: spec.mounts ?? [],
1606
+ surfaces: featureSurfaces(spec),
1607
+ }))
1608
+
1609
+ if (args.json) {
1610
+ console.log(
1611
+ JSON.stringify(
1612
+ {
1613
+ catalogVersion: CATALOG_VERSION,
1614
+ sectionTypes: sectionRegistry(),
1615
+ recipes: { views, features },
1616
+ },
1617
+ null,
1618
+ 2,
1619
+ ),
1620
+ )
1621
+ return 0
1622
+ }
1623
+
1624
+ console.log('Storefront recipes:')
1625
+ console.log(` views ${views.map((v) => v.name).join(', ')}`)
1626
+ console.log(` features ${features.map((f) => f.name).join(', ')}`)
511
1627
  return 0
512
1628
  }
513
1629
 
@@ -519,13 +1635,96 @@ async function main() {
519
1635
  return 0
520
1636
  }
521
1637
  if (args.action === 'create') return create(args)
522
- if (args.action === 'update') return update(args)
523
- if (args.action === 'doctor') return doctor(args)
1638
+ if (args.action === 'view') {
1639
+ const root = resolveProject()
1640
+ if (!root) return 1
1641
+ return view({ ...args, dir: root })
1642
+ }
1643
+ if (args.action === 'views') {
1644
+ const root = resolveProject()
1645
+ if (!root) return 1
1646
+ return views({ ...args, dir: root })
1647
+ }
1648
+ if (args.action === 'features') {
1649
+ const root = resolveProject()
1650
+ if (!root) return 1
1651
+ return features({ ...args, dir: root })
1652
+ }
1653
+ if (args.action === 'recipes') {
1654
+ const root = resolveProject()
1655
+ if (!root) return 1
1656
+ return recipes({ ...args, dir: root })
1657
+ }
1658
+ if (args.action === 'feature') {
1659
+ const root = resolveProject()
1660
+ if (!root) return 1
1661
+ return feature({ ...args, dir: root })
1662
+ }
1663
+ if (args.action === 'inspect' || args.action === 'plan' || args.action === 'apply') {
1664
+ const root = resolveProject({ quiet: true })
1665
+ if (!root) {
1666
+ // Every inspect/plan/apply invocation emits a versioned error envelope,
1667
+ // including "not inside a project" (E1 task 4.11).
1668
+ printEnvelope(
1669
+ resultEnvelope(args.action, false, 1, [
1670
+ envelopeDiagnostic(
1671
+ 'E_NO_MANIFEST',
1672
+ 'Not inside a storefront project (no storefront.config.json found in this directory or any parent).',
1673
+ ),
1674
+ ]),
1675
+ )
1676
+ return 1
1677
+ }
1678
+ if (args.action === 'inspect') return inspect({ ...args, dir: root })
1679
+ if (args.action === 'plan') return plan({ ...args, dir: root })
1680
+ return apply({ ...args, dir: root })
1681
+ }
1682
+
1683
+ const SCOPED = ['theme', 'update', 'doctor', 'sync']
1684
+ if (SCOPED.includes(args.action)) {
1685
+ if (args.positionals.length > 1) {
1686
+ console.error(`storefront ${args.action} does not take a directory; run it from inside the storefront project.`)
1687
+ return 1
1688
+ }
1689
+ const root = resolveProject()
1690
+ if (!root) return 1
1691
+ const scoped = { ...args, dir: root }
1692
+ if (args.action === 'theme') return theme(scoped)
1693
+ if (args.action === 'update') return update(scoped)
1694
+ if (args.action === 'sync') return sync(scoped)
1695
+ return doctor(scoped)
1696
+ }
1697
+
524
1698
  console.error(`Unknown action: ${args.action}`)
525
1699
  console.log(HELP)
526
1700
  return 1
527
1701
  }
528
1702
 
529
- main().then((code) => {
530
- process.exitCode = code
531
- })
1703
+ main()
1704
+ .then((code) => {
1705
+ process.exitCode = code
1706
+ })
1707
+ .catch((error) => {
1708
+ // An unexpected throw that escaped a handler is an internal error: emit a
1709
+ // sanitized versioned error envelope and exit 2. No stack, absolute path,
1710
+ // or captured value is printed to stdout; the error name/message is logged
1711
+ // (not swallowed) so the failure is still diagnosable.
1712
+ const command = process.argv[2] ?? 'unknown'
1713
+ const name = error instanceof Error ? error.name : 'Error'
1714
+ const rawMessage = error instanceof Error ? error.message : String(error)
1715
+ // Redact the consumer cwd and the OS temp root so the log cannot leak an
1716
+ // absolute machine path.
1717
+ const message = rawMessage.split(process.cwd()).join('<cwd>').split(tmpdir()).join('<tmp>')
1718
+ console.error(`storefront ${command}: unexpected internal error (${name}): ${message}`)
1719
+ printEnvelope(
1720
+ resultEnvelope(command, false, 2, [
1721
+ {
1722
+ code: 'E_UNEXPECTED',
1723
+ severity: 'error',
1724
+ message: 'unexpected internal error',
1725
+ detail: { name },
1726
+ },
1727
+ ]),
1728
+ )
1729
+ process.exitCode = 2
1730
+ })