@thorprovider/storefront 0.4.0-mvp.0 → 0.4.0-mvp.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +173 -153
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +42 -1
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +1 -5
  9. package/assets/templates/app/app/robots.ts +9 -3
  10. package/assets/templates/app/app/sitemap.ts +64 -5
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +70 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +15 -5
  13. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +71 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +36 -4
  16. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +82 -50
  17. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +29 -14
  19. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +31 -7
  22. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +4 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +56 -26
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +165 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +26 -7
  26. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +2 -1
  27. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  30. package/assets/templates/features/checkout/handlers.ts +6 -0
  31. package/assets/templates/features/customer-auth/state.ts +10 -1
  32. package/assets/templates/views/account/index.tsx +1 -1
  33. package/assets/templates/views/account/registry.tsx +80 -19
  34. package/assets/templates/views/account/schema.ts +1 -0
  35. package/assets/templates/views/cart/content.ts +11 -0
  36. package/assets/templates/views/cart/data.ts +23 -0
  37. package/assets/templates/views/cart/index.tsx +53 -0
  38. package/assets/templates/views/cart/props.ts +12 -0
  39. package/assets/templates/views/cart/registry.tsx +81 -0
  40. package/assets/templates/views/cart/schema.ts +52 -0
  41. package/assets/templates/views/category/assembly/assembly.json +1 -0
  42. package/assets/templates/views/category/assembly/index.skeleton.tsx +64 -6
  43. package/assets/templates/views/category/assembly/registry.skeleton.tsx +2 -1
  44. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  45. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  46. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  47. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  48. package/assets/templates/views/category/index.tsx +76 -7
  49. package/assets/templates/views/category/props.ts +7 -0
  50. package/assets/templates/views/category/registry.tsx +19 -0
  51. package/assets/templates/views/category/schema.ts +13 -0
  52. package/assets/templates/views/home/assembly/assembly.json +9 -0
  53. package/assets/templates/views/home/assembly/index.skeleton.tsx +22 -4
  54. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +1 -1
  55. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +2 -2
  56. package/assets/templates/views/home/data.ts +9 -0
  57. package/assets/templates/views/home/index.tsx +23 -5
  58. package/assets/templates/views/home/registry.tsx +2 -2
  59. package/assets/templates/views/product/data.ts +4 -2
  60. package/assets/templates/views/product/index.tsx +49 -5
  61. package/assets/templates/views/product/props.ts +7 -0
  62. package/assets/templates/views/product/registry.tsx +25 -0
  63. package/assets/templates/views/product/schema.ts +13 -0
  64. package/assets/templates/views/static/content.ts +4 -4
  65. package/assets/templates/views/static/index.tsx +1 -1
  66. package/bin/storefront.js +1231 -154
  67. package/examples/merchandising/category-led.json +37 -0
  68. package/examples/merchandising/collection-led.json +37 -0
  69. package/package.json +11 -7
  70. package/recipes/archetypes.json +9 -22
  71. package/recipes/catalog.json +40 -1
  72. package/recipes/features.json +25 -2
  73. package/recipes/recipe.schema.json +1 -1
  74. package/recipes/recipes.json +32 -30
  75. package/recipes/sections.json +10 -0
  76. package/recipes/validate.mjs +54 -0
  77. package/scripts/candidate.mjs +4 -4
  78. package/scripts/composers.mjs +25 -5
  79. package/scripts/env-file.mjs +86 -0
  80. package/scripts/file-classes.mjs +5 -0
  81. package/scripts/migrate-english-routes.mjs +276 -0
  82. package/scripts/pinning.mjs +74 -5
  83. package/scripts/plan-apply.mjs +149 -5
  84. package/scripts/reconcile.mjs +49 -11
  85. package/scripts/render-templates.mjs +97 -44
  86. package/scripts/section-config.mjs +253 -0
  87. package/scripts/starter.mjs +17 -0
  88. package/scripts/theme/color.mjs +185 -0
  89. package/scripts/theme/css-theme.mjs +233 -0
  90. package/scripts/theme/fonts.mjs +123 -0
  91. package/scripts/theme/import-export.mjs +125 -0
  92. package/scripts/theme/preset.mjs +93 -0
  93. package/scripts/theme/theme-plan.mjs +292 -0
  94. package/scripts/theme/tokens.mjs +137 -0
  95. package/scripts/view-assembly.mjs +47 -1
  96. package/skills/sf-feature/SKILL.md +32 -19
  97. package/skills/sf-setup/SKILL.md +79 -0
  98. package/skills/sf-theme-gen/SKILL.md +99 -36
  99. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  100. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  101. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  102. package/skills/sf-view-gen/SKILL.md +63 -40
  103. package/skills/sf-view-gen/assets/recipes/archetypes.json +9 -22
  104. package/skills/sf-view-gen/assets/recipes/catalog.json +40 -1
  105. package/skills/sf-view-gen/assets/recipes/features.json +25 -2
  106. package/skills/sf-view-gen/assets/recipes/recipes.json +32 -30
  107. package/skills/sf-view-gen/assets/recipes/sections.json +10 -0
  108. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  109. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  110. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  111. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  112. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  113. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  114. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  115. package/skills/sf-view-gen/references/views.md +28 -37
  116. package/skills/sf-view-gen/scripts/validate.mjs +54 -0
  117. package/skills/storefront-workflow/SKILL.md +94 -31
  118. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  119. package/CHANGELOG.md +0 -84
  120. package/assets/templates/views/user-panel/index.tsx +0 -106
  121. package/assets/templates/views/user-panel/registry.tsx +0 -54
  122. package/assets/templates/views/user-panel/schema.ts +0 -79
  123. package/commands/sf-add-view.md +0 -57
  124. package/commands/sf-feature.md +0 -28
  125. package/commands/sf-theme.md +0 -26
  126. package/commands/sf-view.md +0 -67
  127. package/skills/building-storefronts/SKILL.md +0 -178
  128. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  129. package/skills/json-render-remotion/SKILL.md +0 -111
  130. package/skills/json-render-solid/SKILL.md +0 -204
  131. package/skills/nextjs-shadcn/SKILL.md +0 -367
  132. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  133. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  134. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  135. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  136. package/skills/nextjs-shadcn/references/styling.md +0 -555
  137. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  138. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  139. package/skills/shadcn-theming/SKILL.md +0 -104
  140. package/skills/storefront-best-practices/SKILL.md +0 -421
  141. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  142. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  143. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  144. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  145. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  146. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  147. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  148. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  149. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  150. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  151. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  152. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  153. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  154. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  155. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  156. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  157. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  158. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  159. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  160. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  161. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  162. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  163. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  164. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  165. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  166. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
package/bin/storefront.js CHANGED
@@ -4,10 +4,11 @@
4
4
  //
5
5
  // Actions:
6
6
  // create Scaffold a complete, runnable storefront AND install the command suite
7
+ // setup Connect (or re-point) a real Medusa backend for an existing storefront
7
8
  // update Refresh an existing install (prunes removed files, migrates the record)
8
9
  // doctor Report the recorded version and whether the installed skills match this package
9
10
  //
10
- // On a TTY, `create` prompts for missing inputs (name, site name, backend URL, preset —
11
+ // On a TTY, `create` prompts for missing inputs (name, site name, preset —
11
12
  // Maia is preselected). Empty prompts fall back to their suggested default, so pressing
12
13
  // Enter accepts it: the name is suggested from the target directory basename and the
13
14
  // site name from the Title Case of that name. Without a TTY, every required input must
@@ -20,13 +21,27 @@
20
21
  import { cp, mkdir, readdir, writeFile, rm } from 'node:fs/promises'
21
22
  import { existsSync, readFileSync } from 'node:fs'
22
23
  import { spawnSync } from 'node:child_process'
23
- import { randomBytes } from 'node:crypto'
24
+ import { createRequire } from 'node:module'
24
25
  import { tmpdir } from 'node:os'
25
26
  import { basename, dirname, join, relative, resolve } from 'node:path'
26
27
  import { fileURLToPath } from 'node:url'
27
28
  import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts'
28
29
  import { checkAssets } from '../scripts/sync-assets.mjs'
30
+ import { writeInitialEnv, upsertEnv, readEnvFile } from '../scripts/env-file.mjs'
29
31
  import { renderScaffold, toKebab, toTitle, injectThemeMode } from '../scripts/render-templates.mjs'
32
+ import { normalizeOperation, planCss, parseSetter } from '../scripts/theme/theme-plan.mjs'
33
+ import { readThemeCss, applyBlockDeclarations } from '../scripts/theme/css-theme.mjs'
34
+ import { applyLayoutFonts, fontCssVars } from '../scripts/theme/fonts.mjs'
35
+ import {
36
+ buildThemeExport,
37
+ parseRegistryTheme,
38
+ parseCssExport,
39
+ parseThemeExport,
40
+ classifyImportSource,
41
+ } from '../scripts/theme/import-export.mjs'
42
+ import { resolvePreset, makeShadcnRunner } from '../scripts/theme/preset.mjs'
43
+ import { THEME_COLOR_TOKENS, FONT_VARS, DEFAULT_GLOBALS_TOKENS } from '../scripts/theme/tokens.mjs'
44
+ import { NAMED_COLOR_NAMES, parseColor, formatOklch } from '../scripts/theme/color.mjs'
30
45
  import {
31
46
  findProjectRoot,
32
47
  hasManifest,
@@ -38,6 +53,7 @@ import {
38
53
  } from '../scripts/manifest.mjs'
39
54
  import { reconcile } from '../scripts/reconcile.mjs'
40
55
  import { preflightManifest } from '../scripts/preflight.mjs'
56
+ import { scaffoldComposition } from '../scripts/starter.mjs'
41
57
  import {
42
58
  buildInstallPlan,
43
59
  CANDIDATE_TOOLSET,
@@ -47,6 +63,7 @@ import {
47
63
  verifyPackManifest,
48
64
  } from '../scripts/candidate.mjs'
49
65
  import {
66
+ declaredOperativeSkills,
50
67
  hashManagedSuite,
51
68
  hashPaths,
52
69
  managedSuitePaths,
@@ -54,6 +71,8 @@ import {
54
71
  readResolvedVersions,
55
72
  readLockfile,
56
73
  readToolchainEvidence,
74
+ recordedOwnedSet,
75
+ retirementCandidates,
57
76
  buildPinningRecord,
58
77
  validateDeclaredContract,
59
78
  validatePinning,
@@ -69,22 +88,44 @@ import {
69
88
  supportedIntents,
70
89
  viewCatalog,
71
90
  sectionRegistry,
91
+ sectionIsDataBound,
72
92
  } from '../recipes/validate.mjs'
73
93
  import { CATALOG_VERSION, loadCatalog } from '../recipes/catalog.mjs'
74
94
  import { OPERATION_NAMES } from '../scripts/operations.mjs'
75
- import { applyProject, executeOperations, planProject, resultEnvelope } from '../scripts/plan-apply.mjs'
95
+ import {
96
+ applyProject,
97
+ executeOperations,
98
+ isLegacyViewId,
99
+ planProject,
100
+ removeLegacyView,
101
+ resultEnvelope,
102
+ } from '../scripts/plan-apply.mjs'
76
103
  import { SECTION_OPERATION_REGISTRY } from '../scripts/section-operations.mjs'
104
+ import { auditSectionConfig, collectDataBoundInstances } from '../scripts/section-config.mjs'
105
+ import { normalizeViewSectionsForView } from '../scripts/section-compat.mjs'
77
106
 
78
107
  const __dirname = dirname(fileURLToPath(import.meta.url))
79
108
  const PKG_ROOT = resolve(__dirname, '..')
80
109
 
81
110
  const VERSION_RECORD_FILE = 'storefront.json'
82
111
  const LEGACY_RECORD_FILE = '.create-storefront.json'
83
- const COPY_DIRS = ['commands', 'skills']
84
- // Files that shipped in earlier versions and are no longer part of the suite.
85
- // `update` prunes them so an upgraded project does not keep running the
86
- // eliminated /sf-init scaffold wizard.
87
- const PRUNED_PATHS = ['commands/sf-init.md', 'skills/sf-scaffold']
112
+ // The OpenSpec tool suite owns its own namespace inside the project: it
113
+ // provisions `openspec/`, the `opsx-*` commands, and the `openspec-*` skills.
114
+ // Those files are never storefront-owned, so the suite must not count them as
115
+ // storefront skills, report them as obsolete, or prune them.
116
+ const OPENSPEC_SKILL_PREFIX = 'openspec-'
117
+ // Paths that shipped in earlier versions and are no longer part of the suite.
118
+ // `update` prunes exactly these so an upgraded project does not keep running the
119
+ // eliminated /sf-* commands or the /sf-init scaffold wizard. The whole
120
+ // `commands/` directory is NOT pruned: another tool (OpenSpec) provisions its
121
+ // own commands there, so legacy /sf-* command files are pruned by name and every
122
+ // other command file is preserved. This fixed list is RETAINED: `commands/` is
123
+ // outside the managed suite directories, so the generalized BOM-difference
124
+ // retirement cannot derive it. `skills/sf-scaffold` is additionally reachable
125
+ // through that generalized path (it sits under `skills/`), so the two overlap
126
+ // where ownership is recorded.
127
+ const LEGACY_SKILL_PATHS = ['skills/sf-scaffold']
128
+ const LEGACY_COMMAND_FILE = /^sf-.*\.md$/
88
129
 
89
130
  const PRESETS = ['maia', 'nova', 'vega', 'luma', 'lyra', 'mira']
90
131
  const THEME_MODES = ['light', 'dark', 'system']
@@ -103,13 +144,16 @@ const HELP = `@thorprovider/storefront — scaffold Thor Commerce storefronts
103
144
 
104
145
  Usage:
105
146
  storefront create <dir> [options] Scaffold a storefront and install the suite
106
- storefront theme [options] Change the site color mode deterministically
147
+ storefront setup [options] Connect a real Medusa backend (re-runnable)
148
+ storefront theme [options] Customize the theme (mode, colors, fonts, presets)
107
149
  storefront sync Reconcile derived files to the manifest
108
150
  storefront views [--json] List the view registry (recipes, intents, installed)
109
151
  storefront features [--json] List the feature registry (bundles, installed)
110
152
  storefront recipes [--json] Print the whole curated registry (views + features)
111
153
  storefront view add|remove <name> Add or remove a view (reconciles derived files)
112
154
  storefront feature add|remove <name> Add or remove a feature (installs its views)
155
+ storefront section add|remove|move <view> ... Add, remove, or move a section instance
156
+ storefront sections [<view>] [--json] List installed views' section instances (read-only)
113
157
  storefront inspect [--json] Report the versioned catalog and composition (read-only)
114
158
  storefront plan --operations <file> [--out <file>] [--json]
115
159
  Validate a whole intended state and emit a plan
@@ -122,17 +166,42 @@ from the current directory upward (storefront.config.json) and errors outside on
122
166
  create options:
123
167
  --name <name> storefront name = lib/<name>/ (required without a terminal)
124
168
  --site-name <name> human site name (default: Title Case of <name>)
125
- --backend-url <url> Medusa backend URL (default: http://localhost:9000)
126
169
  --preset <preset> maia | nova | vega | luma | lyra | mira (required without a terminal)
127
170
  --mode <mode> light | dark | system (default: system)
171
+ --starter install every curated view and feature (a full store),
172
+ not just the home+cart baseline
128
173
  --brand <color> brand color over preset tokens (not prompted by the wizard)
129
174
  --packs <dir> directory of local @thorprovider/* .tgz tarballs; installs
130
175
  the local candidate via Yarn resolutions (no publish)
131
176
  -f, --force overwrite files that already exist
132
177
  -h, --help show this help
133
178
 
179
+ setup options:
180
+ --publishable-key <key> Medusa publishable API key (without a key the store stays
181
+ in mock mode)
182
+ --sales-channel-id <id> Medusa sales channel id (from the Medusa admin; required)
183
+ --region-id <id> Medusa region id (its currency is written)
184
+ --backend-url <url> Medusa backend URL (default: http://localhost:9000)
185
+
134
186
  theme options:
135
- --mode <mode> light | dark | system (required; rewritten in app/layout.tsx)
187
+ (no options) Inspect the current theme, supported tokens and formats
188
+ --inspect Inspect only (read-only); combine with --json
189
+ --mode <mode> light | dark | system (rewritten in app/layout.tsx)
190
+ --brand <name> brand color: preset name, #hex or oklch(...) (primary family)
191
+ --primary <color> set the primary color (name, #hex, oklch(...))
192
+ --accent <color> set the accent color
193
+ --radius <length> base corner radius (e.g. 0.75rem); scale derives from it
194
+ --font-sans <sel> google:<Family> | system:<stack> (also --font-mono/--font-serif)
195
+ --set <scope.token=value> set one token (scope: light | dark | shared); repeatable
196
+ --preset <ref> shadcn preset name, code or builder URL (theme + fonts only)
197
+ --only <parts> with --preset: theme | font | theme,font (default theme,font)
198
+ --import <source> import a theme: local .css, local .json, or registry:theme URL
199
+ --export <file> write a portable theme snapshot (read-only; never overwrites)
200
+ --dry-run validate and describe the change without writing
201
+ --json machine-readable result
202
+
203
+ Visual values live in app/globals.css; the mode lives in storefront.config.json.
204
+ A later sync never reverts a theme change.
136
205
 
137
206
  view options:
138
207
  --intent <slot=intent> choose a slot's intent from the view's recipe
@@ -154,7 +223,19 @@ features options:
154
223
  recipes options:
155
224
  --json print the whole curated registry (views + features) as JSON
156
225
 
157
- In a terminal, create prompts for missing inputs (Maia is preselected).
226
+ section options:
227
+ --id <id> explicit instance id (default: auto-minted <view>-<type>-<n>)
228
+ --as <Variant> choose the section's declared variant
229
+ --after <id> | --before <id> place relative to an existing instance id
230
+ --first | --last place at the start or end of the composition
231
+ --at <n> place at a 0-based index (escape hatch)
232
+ -f, --force allow the write when ownership conflicts are detected
233
+
234
+ sections options:
235
+ --json print the installed views' section instances as JSON
236
+
237
+ In a terminal, create prompts for missing inputs (Maia is preselected), and setup
238
+ prompts for the backend URL, publishable key, and region.
158
239
  Without a terminal, every required input must be a flag — the command fails fast.
159
240
  `
160
241
 
@@ -165,8 +246,12 @@ function parseArgs(argv) {
165
246
  name: null,
166
247
  siteName: null,
167
248
  backendUrl: null,
249
+ publishableKey: null,
250
+ salesChannelId: null,
251
+ regionId: null,
168
252
  preset: null,
169
253
  mode: null,
254
+ starter: false,
170
255
  brand: null,
171
256
  force: false,
172
257
  help: false,
@@ -178,6 +263,25 @@ function parseArgs(argv) {
178
263
  operations: null,
179
264
  out: null,
180
265
  plan: null,
266
+ id: null,
267
+ as: null,
268
+ after: null,
269
+ before: null,
270
+ first: false,
271
+ last: false,
272
+ at: null,
273
+ primary: null,
274
+ accent: null,
275
+ radius: null,
276
+ sets: [],
277
+ fontSans: null,
278
+ fontMono: null,
279
+ fontSerif: null,
280
+ inspect: false,
281
+ only: null,
282
+ importSource: null,
283
+ exportPath: null,
284
+ dryRun: false,
181
285
  }
182
286
  const positionals = []
183
287
  for (let i = 0; i < argv.length; i++) {
@@ -188,12 +292,44 @@ function parseArgs(argv) {
188
292
  out.siteName = argv[++i]
189
293
  } else if (arg === '--backend-url') {
190
294
  out.backendUrl = argv[++i]
295
+ } else if (arg === '--publishable-key') {
296
+ out.publishableKey = argv[++i]
297
+ } else if (arg === '--sales-channel-id') {
298
+ out.salesChannelId = argv[++i]
299
+ } else if (arg === '--region-id') {
300
+ out.regionId = argv[++i]
191
301
  } else if (arg === '--preset') {
192
302
  out.preset = argv[++i]
193
303
  } else if (arg === '--mode') {
194
304
  out.mode = argv[++i]
305
+ } else if (arg === '--starter') {
306
+ out.starter = true
195
307
  } else if (arg === '--brand') {
196
308
  out.brand = argv[++i]
309
+ } else if (arg === '--primary') {
310
+ out.primary = argv[++i]
311
+ } else if (arg === '--accent') {
312
+ out.accent = argv[++i]
313
+ } else if (arg === '--radius') {
314
+ out.radius = argv[++i]
315
+ } else if (arg === '--set') {
316
+ out.sets.push(argv[++i])
317
+ } else if (arg === '--font-sans') {
318
+ out.fontSans = argv[++i]
319
+ } else if (arg === '--font-mono') {
320
+ out.fontMono = argv[++i]
321
+ } else if (arg === '--font-serif') {
322
+ out.fontSerif = argv[++i]
323
+ } else if (arg === '--inspect') {
324
+ out.inspect = true
325
+ } else if (arg === '--only') {
326
+ out.only = argv[++i]
327
+ } else if (arg === '--import') {
328
+ out.importSource = argv[++i]
329
+ } else if (arg === '--export') {
330
+ out.exportPath = argv[++i]
331
+ } else if (arg === '--dry-run') {
332
+ out.dryRun = true
197
333
  } else if (arg === '--packs') {
198
334
  out.packs = argv[++i]
199
335
  } else if (arg === '--operations') {
@@ -202,6 +338,20 @@ function parseArgs(argv) {
202
338
  out.out = argv[++i]
203
339
  } else if (arg === '--plan') {
204
340
  out.plan = argv[++i]
341
+ } else if (arg === '--id') {
342
+ out.id = argv[++i] ?? null
343
+ } else if (arg === '--as') {
344
+ out.as = argv[++i] ?? null
345
+ } else if (arg === '--after') {
346
+ out.after = argv[++i] ?? null
347
+ } else if (arg === '--before') {
348
+ out.before = argv[++i] ?? null
349
+ } else if (arg === '--at') {
350
+ out.at = argv[++i] ?? null
351
+ } else if (arg === '--first') {
352
+ out.first = true
353
+ } else if (arg === '--last') {
354
+ out.last = true
205
355
  } else if (arg === '--intent') {
206
356
  const pair = argv[++i] ?? ''
207
357
  const eq = pair.indexOf('=')
@@ -344,6 +494,83 @@ async function prunePath(opencodeDir, relPath) {
344
494
  await rm(join(opencodeDir, relPath), { recursive: true, force: true })
345
495
  }
346
496
 
497
+ // Prune the legacy storefront-owned paths an earlier generation left behind,
498
+ // file by file, so a co-tenant tool's files survive. The retired /sf-* command
499
+ // files are removed individually; the `commands/` directory itself is removed
500
+ // only when it is left empty. Every other command file (for example OpenSpec's
501
+ // `opsx-*`) is preserved. Returns the pruned command paths.
502
+ async function pruneLegacySuitePaths(opencodeDir) {
503
+ for (const rel of LEGACY_SKILL_PATHS) await prunePath(opencodeDir, rel)
504
+ const commandsDir = join(opencodeDir, 'commands')
505
+ const pruned = []
506
+ if (!existsSync(commandsDir)) return pruned
507
+ for (const entry of await readdir(commandsDir, { withFileTypes: true })) {
508
+ if (!entry.isFile()) continue
509
+ if (!LEGACY_COMMAND_FILE.test(entry.name)) continue
510
+ await rm(join(commandsDir, entry.name), { force: true })
511
+ pruned.push(`commands/${entry.name}`)
512
+ }
513
+ // Remove the directory only when nothing else is co-tenanted there.
514
+ if ((await readdir(commandsDir)).length === 0) await rm(commandsDir, { recursive: true, force: true })
515
+ return pruned
516
+ }
517
+
518
+ // Resolve the pinned OpenSpec CLI from the package's own dependency so
519
+ // provisioning is deterministic and offline once installed. `@fission-ai/openspec`
520
+ // declares an `exports` map without `./package.json`, so resolve its entry point
521
+ // and read the `bin` field from the package directory instead.
522
+ function resolveOpenSpecBin() {
523
+ try {
524
+ const require = createRequire(import.meta.url)
525
+ const entry = require.resolve('@fission-ai/openspec')
526
+ const pkgDir = dirname(dirname(entry))
527
+ const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
528
+ const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.openspec
529
+ if (rel) {
530
+ const abs = resolve(pkgDir, rel)
531
+ if (existsSync(abs)) return { ok: true, bin: abs, version: pkg.version }
532
+ }
533
+ return { ok: false, error: 'the @fission-ai/openspec package has no resolvable bin' }
534
+ } catch {
535
+ return {
536
+ ok: false,
537
+ error: 'the pinned @fission-ai/openspec dependency is not installed; run `yarn install`',
538
+ }
539
+ }
540
+ }
541
+
542
+ // Seed the storefront-aware OpenSpec model into the generated project. The
543
+ // config is storefront-owned: `update` re-applies it after re-running init, so
544
+ // the model stays authoritative and customization belongs in storefront files,
545
+ // not in `openspec/`.
546
+ async function seedOpenSpecConfig(target) {
547
+ const template = join(PKG_ROOT, 'assets', 'openspec', 'config.yaml')
548
+ const dest = join(target, 'openspec', 'config.yaml')
549
+ await mkdir(dirname(dest), { recursive: true })
550
+ await writeFile(dest, readFileSync(template, 'utf8'), 'utf8')
551
+ }
552
+
553
+ // Provision the OpenSpec spec-driven workflow into the target through the pinned
554
+ // CLI. The CLI runs under the current Node so the invocation is portable and
555
+ // non-interactive; a failure is returned as a diagnostic and never masked. After
556
+ // a successful init the storefront model is seeded into `openspec/config.yaml`.
557
+ async function provisionOpenSpec(target) {
558
+ const resolved = resolveOpenSpecBin()
559
+ if (!resolved.ok) return { ok: false, error: resolved.error }
560
+ const result = spawnSync(
561
+ process.execPath,
562
+ [resolved.bin, 'init', '--tools', 'opencode', '--no-animation'],
563
+ { cwd: target, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
564
+ )
565
+ if (result.error) return { ok: false, error: result.error.message }
566
+ if (result.status !== 0) {
567
+ const detail = (result.stderr || result.stdout || '').trim().split('\n').pop()
568
+ return { ok: false, error: detail || `openspec init exited with code ${result.status}` }
569
+ }
570
+ await seedOpenSpecConfig(target)
571
+ return { ok: true, version: resolved.version }
572
+ }
573
+
347
574
  function pkgVersion() {
348
575
  const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
349
576
  return pkg.version
@@ -454,15 +681,86 @@ async function verifyConsistency() {
454
681
 
455
682
  async function installSuite(opencodeDir, force) {
456
683
  let total = 0
457
- for (const dir of COPY_DIRS) {
458
- const from = join(PKG_ROOT, dir)
459
- const to = join(opencodeDir, dir)
460
- await mkdir(to, { recursive: true })
684
+ // The operative set comes from the single declared source — the package skill
685
+ // layout (`skills/<name>/SKILL.md`). A directory without a SKILL.md is not a
686
+ // skill and is not installed; the files inside an operative skill (its scoped
687
+ // references, assets, and scripts) travel with it but are never treated as
688
+ // skills. `create` and `update` read the same source, so the installed set
689
+ // cannot drift between them.
690
+ for (const skillRel of declaredOperativeSkills(PKG_ROOT)) {
691
+ const from = join(PKG_ROOT, skillRel)
692
+ const to = join(opencodeDir, skillRel)
693
+ await mkdir(dirname(to), { recursive: true })
461
694
  total += await copyDir(from, to, force)
462
695
  }
463
696
  return total
464
697
  }
465
698
 
699
+ // Remove `dir` and any now-empty parent directories up to (but not including)
700
+ // `stopAt`. Used after retiring obsolete owned files so a removed skill does not
701
+ // leave an empty discoverable directory behind.
702
+ async function pruneEmptyParents(dir, stopAt) {
703
+ let current = dir
704
+ while (current.startsWith(stopAt) && current !== stopAt) {
705
+ let entries
706
+ try {
707
+ entries = await readdir(current)
708
+ } catch {
709
+ return
710
+ }
711
+ if (entries.length > 0) return
712
+ await rm(current, { recursive: true, force: true })
713
+ current = dirname(current)
714
+ }
715
+ }
716
+
717
+ // Retire the recorded owned files the package no longer ships. A candidate is
718
+ // removed ONLY when its live content still matches the recorded package render
719
+ // (its recorded digest). A modified candidate is preserved and returned so the
720
+ // caller can report it; a file with no package record is never a candidate and
721
+ // is left untouched. Returns the retired and preserved (modified) paths.
722
+ async function retireObsoleteOwnedFiles(opencodeDir, recordedAssets) {
723
+ const retired = []
724
+ const preserved = []
725
+ const candidates = retirementCandidates(recordedAssets, managedSuitePaths(PKG_ROOT))
726
+ for (const rel of candidates) {
727
+ const abs = join(opencodeDir, rel)
728
+ if (!existsSync(abs)) continue
729
+ const liveDigest = hashPaths(opencodeDir, [rel])[rel]
730
+ if (liveDigest === recordedAssets[rel]) {
731
+ await rm(abs, { force: true })
732
+ retired.push(rel)
733
+ } else {
734
+ preserved.push(rel)
735
+ }
736
+ }
737
+ for (const rel of retired) await pruneEmptyParents(dirname(join(opencodeDir, rel)), opencodeDir)
738
+ return { retired, preserved }
739
+ }
740
+
741
+ // The installed skills that are not part of the package's current operative set.
742
+ // A directory under `.opencode/skills/` containing a `SKILL.md` is a
743
+ // discoverable skill; one the package no longer ships (or that was added by
744
+ // hand) is not in the incoming set. Returned sorted so the completion output can
745
+ // refuse a clean migration while one remains.
746
+ async function remainingNonSuiteSkills(opencodeDir) {
747
+ const incoming = new Set(
748
+ declaredOperativeSkills(PKG_ROOT).map((rel) => rel.slice(rel.lastIndexOf('/') + 1)),
749
+ )
750
+ const skillsDir = join(opencodeDir, 'skills')
751
+ const remaining = []
752
+ if (!existsSync(skillsDir)) return remaining
753
+ for (const entry of await readdir(skillsDir, { withFileTypes: true })) {
754
+ if (!entry.isDirectory()) continue
755
+ // The OpenSpec suite owns its own namespaced skills; they are not part of
756
+ // the storefront operative set and must never be reported as leftovers.
757
+ if (entry.name.startsWith(OPENSPEC_SKILL_PREFIX)) continue
758
+ if (!existsSync(join(skillsDir, entry.name, 'SKILL.md'))) continue
759
+ if (!incoming.has(entry.name)) remaining.push(entry.name)
760
+ }
761
+ return remaining.sort()
762
+ }
763
+
466
764
  // Run Yarn under the pinned Corepack version (`yarn@x.y.z`), so the scaffold
467
765
  // and every install use the canonical package manager deterministically rather
468
766
  // than whatever "known good release" Corepack would otherwise pick.
@@ -471,54 +769,25 @@ function runYarn(yarnSpec, cmdArgs, cwd) {
471
769
  return res.status ?? 1
472
770
  }
473
771
 
474
- const BRAND_TOKEN_KEYS = {
475
- light: ['--primary', '--primary-foreground', '--nav', '--ring'],
476
- dark: ['--primary', '--primary-foreground', '--nav', '--ring'],
477
- }
478
-
479
- const COLOR_HUES = {
480
- red: 27,
481
- orange: 55,
482
- amber: 75,
483
- yellow: 95,
484
- lime: 130,
485
- green: 155,
486
- teal: 185,
487
- cyan: 210,
488
- blue: 232,
489
- indigo: 264,
490
- violet: 290,
491
- purple: 310,
492
- pink: 350,
493
- slate: 240,
494
- gray: 240,
495
- neutral: 0,
496
- stone: 60,
497
- }
498
-
499
- function applyBrand(globalsCss, brand) {
500
- if (!brand) return globalsCss
501
- const hue = COLOR_HUES[brand]
502
- if (hue === undefined) {
503
- console.error(`Unknown brand color: ${brand}`)
504
- process.exitCode = 1
505
- return globalsCss
506
- }
507
- // Swap the hue channel (third component) of each brand token's oklch value in
508
- // both modes. Lightness/chroma stay from the preset so the contrast pair rule
509
- // is preserved; only the hue carries the brand color.
510
- let css = globalsCss
511
- for (const key of [...BRAND_TOKEN_KEYS.light, ...BRAND_TOKEN_KEYS.dark]) {
512
- css = css.replace(new RegExp(`(${key}: oklch\\([^)]*?\\s)([^\\s)]+)(\\))`, 'g'), `$1${hue}$3`)
513
- }
514
- return css
772
+ // Apply a named brand color through the same transformation the `theme` command
773
+ // uses, so `create --brand` and `theme --brand` agree: real chromatic palette
774
+ // values (not a hue swap on a possibly-achromatic preset), a measured-contrast
775
+ // foreground, and the documented primary family. Returns `{ css }` or `{ error }`.
776
+ function applyBrandToGlobals(globalsCss, brand) {
777
+ if (!brand) return { css: globalsCss }
778
+ const { operation, diagnostics } = normalizeOperation({ brand })
779
+ const error = diagnostics.find((d) => d.severity === 'error')
780
+ if (error) return { error: error.message }
781
+ const planned = planCss(globalsCss, operation)
782
+ const planError = planned.diagnostics.find((d) => d.severity === 'error')
783
+ if (planError) return { error: planError.message }
784
+ return { css: planned.css }
515
785
  }
516
786
 
517
787
  async function promptScaffoldInputs(args) {
518
788
  const inputs = {
519
789
  name: toKebab(args.name ?? '') || null,
520
790
  siteName: args.siteName ?? null,
521
- backendUrl: args.backendUrl ?? null,
522
791
  preset: args.preset ?? null,
523
792
  mode: args.mode ?? null,
524
793
  brand: args.brand ?? null,
@@ -564,18 +833,6 @@ async function promptScaffoldInputs(args) {
564
833
  inputs.siteName = value
565
834
  }
566
835
 
567
- if (!inputs.backendUrl) {
568
- const value = await text({
569
- message: 'Medusa backend URL:',
570
- initialValue: DEFAULT_BACKEND_URL,
571
- })
572
- if (isCancel(value)) {
573
- cancel('Operation cancelled.')
574
- process.exit(0)
575
- }
576
- inputs.backendUrl = value
577
- }
578
-
579
836
  if (!inputs.preset) {
580
837
  const value = await select({
581
838
  message: 'Visual preset:',
@@ -623,7 +880,6 @@ async function promptScaffoldInputs(args) {
623
880
  inputs: {
624
881
  name: inputs.name,
625
882
  siteName: inputs.siteName ?? toTitle(inputs.name),
626
- backendUrl: inputs.backendUrl ?? DEFAULT_BACKEND_URL,
627
883
  preset: inputs.preset,
628
884
  mode: inputs.mode ?? DEFAULT_THEME_MODE,
629
885
  brand: inputs.brand,
@@ -658,7 +914,13 @@ async function create(args) {
658
914
  return 1
659
915
  }
660
916
 
661
- const { name, siteName, backendUrl, preset, mode, brand } = inputs
917
+ const { name, siteName, preset, mode, brand } = inputs
918
+
919
+ if (args.backendUrl) {
920
+ console.error('storefront create no longer takes --backend-url; a created store starts in mock mode.')
921
+ console.error('Run `storefront setup` after creating to connect a real backend.')
922
+ return 1
923
+ }
662
924
 
663
925
  // The candidate CLI is the structural authority; the generated project pins
664
926
  // the exact compatible toolset (and the CLI itself). Declared here so the
@@ -704,10 +966,21 @@ async function create(args) {
704
966
  }
705
967
 
706
968
  // Validate the complete desired composition before the first filesystem
707
- // operation. `create` always composes the home baseline, but it must pass the
969
+ // operation. Without `--starter`, `create` composes the home baseline plus the
970
+ // standalone cart review view. With `--starter`, it composes the full curated
971
+ // set: every curated feature and every curated view (the standalone views plus
972
+ // the feature-owned views each feature installs). Either way it must pass the
708
973
  // same preflight as every other write boundary — and it must run before any
709
974
  // mkdir, external scaffold, or write so rejected input leaves no trace.
710
- const manifest = createManifest({ name, siteName, mode, views: ['home'], features: [] })
975
+ const starter = args.starter === true
976
+ const composition = scaffoldComposition({ starter })
977
+ const manifest = createManifest({
978
+ name,
979
+ siteName,
980
+ mode,
981
+ views: composition.views,
982
+ features: composition.features,
983
+ })
711
984
  const preflight = preflightManifest(manifest)
712
985
  if (!preflight.ok) return rejectComposition(preflight.errors)
713
986
 
@@ -729,7 +1002,6 @@ async function create(args) {
729
1002
  console.log('storefront create — deterministic scaffold')
730
1003
  console.log(` name: ${name}`)
731
1004
  console.log(` site name: ${siteName}`)
732
- console.log(` backend: ${backendUrl}`)
733
1005
  console.log(` preset: ${preset}`)
734
1006
  console.log(` mode: ${mode}`)
735
1007
  if (brand) console.log(` brand: ${brand}`)
@@ -743,7 +1015,7 @@ async function create(args) {
743
1015
  const libDir = join(target, 'lib', name)
744
1016
  if (existsSync(libDir)) {
745
1017
  console.error(`lib/${name}/ already exists — refusing to overwrite.`)
746
- console.error('Use /sf-add-view to extend an existing storefront.')
1018
+ console.error('This target already holds a storefront; extend it through the storefront CLI (see AGENTS.md).')
747
1019
  return 1
748
1020
  }
749
1021
  if (!(await verifyConsistency())) return 1
@@ -842,51 +1114,30 @@ async function create(args) {
842
1114
  // The manifest was built and validated before any write above. Passing its
843
1115
  // view entries means the fresh scaffold resolves explicit slot selections
844
1116
  // through the same pipeline a later reconcile uses.
845
- await renderScaffold({ targetDir: target, name, siteName, mode, views: manifest.views })
1117
+ await renderScaffold({ targetDir: target, name, siteName, mode, views: manifest.views, features: manifest.features })
846
1118
  await writeManifest(target, manifest)
847
1119
  const result = await reconcile(target, manifest)
848
1120
  if (!result.ok) return rejectComposition(result.errors)
849
1121
 
850
1122
  // 7. .env.local + theme
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'
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')
1123
+ // `create` is offline and structural: it starts the store in mock mode, a
1124
+ // complete state that needs no backend. The only secret it writes is the
1125
+ // per-install session signing secret, which the guest cart and checkout
1126
+ // cookie signing requires in every mode. Real backend configuration is
1127
+ // written by `storefront setup`, never here, so no blank MEDUSA_* value is
1128
+ // ever produced. An existing .env.local is user-owned (a `setup` run or a
1129
+ // hand edit) and is left untouched.
1130
+ await writeInitialEnv(target, name)
885
1131
 
886
1132
  const globalsPath = join(target, 'app', 'globals.css')
887
1133
  if (existsSync(globalsPath)) {
888
1134
  const css = readFileSync(globalsPath, 'utf8')
889
- await writeFile(globalsPath, applyBrand(css, brand), 'utf8')
1135
+ const branded = applyBrandToGlobals(css, brand)
1136
+ if (branded.error) {
1137
+ console.error(`Unknown brand color: ${branded.error}`)
1138
+ return 1
1139
+ }
1140
+ if (branded.css !== css) await writeFile(globalsPath, branded.css, 'utf8')
890
1141
  }
891
1142
 
892
1143
  // 8. Suite install + version record
@@ -900,11 +1151,171 @@ async function create(args) {
900
1151
  }
901
1152
  console.log(`installed ${total} file(s) and recorded the version`)
902
1153
 
1154
+ // 9. OpenSpec provisioning (final step). The spec-driven workflow is installed
1155
+ // last, after the suite and the version record, so a failure never leaves a
1156
+ // storefront that claims to be fully provisioned. A failure is surfaced as
1157
+ // a diagnostic; the storefront itself is still created.
1158
+ const provisioned = await provisionOpenSpec(target)
1159
+ if (provisioned.ok) {
1160
+ console.log(`provisioned OpenSpec ${provisioned.version} (openspec init --tools opencode)`)
1161
+ } else {
1162
+ console.error(`Warning: OpenSpec provisioning failed — ${provisioned.error}`)
1163
+ console.error(' The storefront was created but is NOT fully provisioned: the spec-driven')
1164
+ console.error(' workflow is missing. Re-run `npx @thorprovider/storefront update` once fixed.')
1165
+ }
1166
+
903
1167
  console.log('')
904
1168
  console.log('Done. Next steps:')
905
1169
  console.log(` cd ${relative(process.cwd(), target) || '.'}`)
906
1170
  console.log(' yarn dev')
907
- console.log('Then use /sf-add-view, /sf-view, /sf-theme to extend the store.')
1171
+ console.log('Then open the project in OpenCode and ask in natural language — the router in AGENTS.md points to the right skill.')
1172
+ return 0
1173
+ }
1174
+
1175
+ // Connect (or re-point) a real Medusa backend for an existing storefront. `create`
1176
+ // leaves a store running in mock mode; `setup` is the single, re-runnable step that
1177
+ // writes the real-mode environment. The developer supplies the publishable key, the
1178
+ // sales channel id (from the Medusa admin), and the region; `setup` derives only the
1179
+ // region's currency by reading the store API with the key.
1180
+ async function setup(args) {
1181
+ const target = args.dir
1182
+ const envPath = join(target, '.env.local')
1183
+ const tty = Boolean(process.stdout.isTTY)
1184
+
1185
+ let backendUrl = args.backendUrl ?? null
1186
+ let publishableKey = args.publishableKey ?? null
1187
+ let salesChannelId = args.salesChannelId ?? null
1188
+ let regionId = args.regionId ?? null
1189
+
1190
+ if (tty) {
1191
+ intro('storefront setup')
1192
+ if (!backendUrl) {
1193
+ const value = await text({ message: 'Medusa backend URL:', initialValue: DEFAULT_BACKEND_URL })
1194
+ if (isCancel(value)) {
1195
+ cancel('Operation cancelled.')
1196
+ process.exit(0)
1197
+ }
1198
+ backendUrl = value
1199
+ }
1200
+ if (!publishableKey) {
1201
+ const value = await text({ message: 'Publishable API key (blank to stay in mock mode):', defaultValue: '' })
1202
+ if (isCancel(value)) {
1203
+ cancel('Operation cancelled.')
1204
+ process.exit(0)
1205
+ }
1206
+ publishableKey = value || null
1207
+ }
1208
+ if (publishableKey && !salesChannelId) {
1209
+ const value = await text({
1210
+ message: 'Sales channel ID (Medusa admin → Settings → Sales Channels):',
1211
+ defaultValue: '',
1212
+ })
1213
+ if (isCancel(value)) {
1214
+ cancel('Operation cancelled.')
1215
+ process.exit(0)
1216
+ }
1217
+ salesChannelId = value || null
1218
+ }
1219
+ outro('Connecting…')
1220
+ }
1221
+ backendUrl = (backendUrl ?? DEFAULT_BACKEND_URL).replace(/\/+$/, '')
1222
+
1223
+ // No publishable key: nothing to connect. Leave the store in mock mode and exit
1224
+ // successfully so `setup` is safe to run in CI/bootstrap without a key.
1225
+ if (!publishableKey) {
1226
+ console.log('storefront setup: no publishable key — leaving THOR_DATA_SOURCE=mock.')
1227
+ return 0
1228
+ }
1229
+
1230
+ // The sales channel is a developer-supplied input, never derived: the store API
1231
+ // cannot report it without a StorefrontConfig row existing for the channel, and the
1232
+ // runtime requires MEDUSA_SALES_CHANNEL_ID in real mode.
1233
+ if (!salesChannelId) {
1234
+ if (!tty) {
1235
+ console.error('storefront setup: --sales-channel-id is required without a terminal.')
1236
+ } else {
1237
+ console.error('storefront setup: a sales channel id is required to connect a backend.')
1238
+ }
1239
+ console.error('Find it in the Medusa admin (Settings → Sales Channels).')
1240
+ return 1
1241
+ }
1242
+
1243
+ // Resolve the region and its currency.
1244
+ let regions
1245
+ try {
1246
+ const res = await fetch(`${backendUrl}/store/regions`, {
1247
+ headers: { 'x-publishable-api-key': publishableKey },
1248
+ })
1249
+ if (!res.ok) {
1250
+ console.error(`storefront setup: GET ${backendUrl}/store/regions failed (${res.status}).`)
1251
+ console.error('Leaving the store in mock mode; no files were changed.')
1252
+ return 1
1253
+ }
1254
+ const body = await res.json()
1255
+ regions = Array.isArray(body?.regions) ? body.regions : []
1256
+ } catch (err) {
1257
+ console.error(`storefront setup: could not reach ${backendUrl} (${err instanceof Error ? err.message : String(err)}).`)
1258
+ console.error('Leaving the store in mock mode; no files were changed.')
1259
+ return 1
1260
+ }
1261
+
1262
+ if (regions.length === 0) {
1263
+ console.error('storefront setup: the backend returned no regions.')
1264
+ console.error('Leaving the store in mock mode; no files were changed.')
1265
+ return 1
1266
+ }
1267
+
1268
+ if (!regionId) {
1269
+ if (!tty) {
1270
+ console.error('storefront setup: --region-id is required without a terminal.')
1271
+ console.error(`Available regions: ${regions.map((region) => region.id).join(', ')}`)
1272
+ return 1
1273
+ }
1274
+ const value = await select({
1275
+ message: 'Region:',
1276
+ options: regions.map((region) => ({
1277
+ value: region.id,
1278
+ label: `${region.name ?? region.id} — ${region.currency_code ?? '?'}`,
1279
+ })),
1280
+ })
1281
+ if (isCancel(value)) {
1282
+ cancel('Operation cancelled.')
1283
+ process.exit(0)
1284
+ }
1285
+ regionId = value
1286
+ }
1287
+
1288
+ const region = regions.find((candidate) => candidate.id === regionId)
1289
+ if (!region) {
1290
+ console.error(`storefront setup: region "${regionId}" not found on the backend.`)
1291
+ console.error(`Available regions: ${regions.map((candidate) => candidate.id).join(', ')}`)
1292
+ return 1
1293
+ }
1294
+ const currencyCode = region.currency_code
1295
+ if (!currencyCode) {
1296
+ console.error(`storefront setup: region "${regionId}" has no currency_code.`)
1297
+ return 1
1298
+ }
1299
+
1300
+ // Upsert only the keys setup manages; preserve everything else (the session
1301
+ // secret, the site URL, the store identity, and any user additions).
1302
+ const managed = {
1303
+ THOR_DATA_SOURCE: 'medusa',
1304
+ MEDUSA_BASE_URL: backendUrl,
1305
+ MEDUSA_PUBLISHABLE_KEY: publishableKey,
1306
+ MEDUSA_REGION_ID: regionId,
1307
+ MEDUSA_SALES_CHANNEL_ID: salesChannelId,
1308
+ MEDUSA_CURRENCY_CODE: currencyCode,
1309
+ }
1310
+ const next = upsertEnv(readEnvFile(envPath), managed)
1311
+ await writeFile(envPath, next, 'utf8')
1312
+
1313
+ console.log('storefront setup — backend connected')
1314
+ console.log(` backend: ${backendUrl}`)
1315
+ console.log(` sales channel: ${salesChannelId}`)
1316
+ console.log(` region: ${regionId}`)
1317
+ console.log(` currency: ${currencyCode}`)
1318
+ console.log('Wrote .env.local. Restart the dev server to pick it up.')
908
1319
  return 0
909
1320
  }
910
1321
 
@@ -947,16 +1358,35 @@ async function update(args) {
947
1358
  return 1
948
1359
  }
949
1360
 
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.
1361
+ // Read the previously recorded owned-file set BEFORE the refresh overwrites
1362
+ // the record. It is the only honest basis for retirement: the difference
1363
+ // between what the package shipped last time and what it ships now. A legacy
1364
+ // record has no owned-file set, so `recordedAssets` is null and no file is
1365
+ // retired from an undetermined set.
1366
+ const previousRecord = await readRecord(opencodeDir)
1367
+ const recordedAssets = recordedOwnedSet(previousRecord)
1368
+ const isLegacyRecord = previousRecord !== null && recordedAssets === null
1369
+
1370
+ // Refresh the package-managed suite to the current artifact set, retire the
1371
+ // recorded owned files the package no longer ships (only those that are
1372
+ // verifiably unmodified), prune the fixed legacy paths, reconcile derived
1373
+ // files, then stamp the record from what is actually installed. The managed
1374
+ // suite is package-owned, so it is always refreshed; user-owned storefront
1375
+ // files live outside it and are never touched here. A retirement candidate
1376
+ // that was modified and any unknown file are preserved.
954
1377
  const total = await installSuite(opencodeDir, true)
955
- for (const rel of PRUNED_PATHS) await prunePath(opencodeDir, rel)
1378
+ const { retired, preserved } = recordedAssets
1379
+ ? await retireObsoleteOwnedFiles(opencodeDir, recordedAssets)
1380
+ : { retired: [], preserved: [] }
1381
+ const prunedCommands = await pruneLegacySuitePaths(opencodeDir)
956
1382
  if (manifest) {
957
1383
  const result = await reconcile(target, manifest)
958
1384
  if (!result.ok) return rejectComposition(result.errors)
959
1385
  }
1386
+ // Re-run OpenSpec provisioning so the storefront's OpenSpec commands, skills,
1387
+ // and config track the pinned @fission-ai/openspec version. A failure is
1388
+ // surfaced as a diagnostic; the storefront refresh itself still completes.
1389
+ const provisioned = await provisionOpenSpec(target)
960
1390
  // Stamp only a complete, actually-verified record. A failed copy or an
961
1391
  // incoherent pin set is reported and never stamped as a clean version.
962
1392
  const record = await writeRecord(target, version)
@@ -968,12 +1398,41 @@ async function update(args) {
968
1398
  const pinning = record.pinning
969
1399
  await rm(join(opencodeDir, LEGACY_RECORD_FILE), { force: true })
970
1400
 
1401
+ const remainingSkills = await remainingNonSuiteSkills(opencodeDir)
1402
+
971
1403
  console.log(`Refreshed install in ${target}:`)
972
1404
  console.log(` installed ${total} file(s)`)
973
- console.log(' pruned removed files (sf-init, sf-scaffold)')
1405
+ if (retired.length > 0) {
1406
+ console.log(` retired ${retired.length} obsolete owned file(s):`)
1407
+ for (const rel of retired) console.log(` ${rel}`)
1408
+ }
1409
+ if (prunedCommands.length > 0) {
1410
+ console.log(` pruned legacy suite paths: ${prunedCommands.join(', ')}`)
1411
+ } else {
1412
+ console.log(' pruned legacy suite paths (skills/sf-scaffold, sf-*.md commands) when present')
1413
+ }
1414
+ if (provisioned.ok) {
1415
+ console.log(` provisioned OpenSpec ${provisioned.version} (openspec init --tools opencode)`)
1416
+ } else {
1417
+ console.error(` Warning: OpenSpec provisioning failed — ${provisioned.error}`)
1418
+ }
974
1419
  console.log(` recorded version ${version} (${Object.keys(pinning.assets).length} managed asset(s))`)
975
- console.log('Done. Available in OpenCode:')
976
- console.log(' /sf-add-view /sf-view /sf-theme')
1420
+ if (isLegacyRecord) {
1421
+ console.log(' install record: legacy format carries no owned-file set —')
1422
+ console.log(' retirement was limited to the fixed pruned paths; no undetermined file was removed.')
1423
+ }
1424
+ // A retirement candidate skipped because it was modified is named explicitly
1425
+ // so the developer can act on it (it is never silently left behind).
1426
+ for (const rel of preserved) {
1427
+ console.log(` skipped ${rel} — preserved because it was modified, not retired.`)
1428
+ }
1429
+ if (remainingSkills.length > 0) {
1430
+ console.log(`Migration incomplete — ${remainingSkills.length} skill(s) outside the current suite remain:`)
1431
+ for (const name of remainingSkills) console.log(` skills/${name}`)
1432
+ console.log('Resolve or remove them, then run "storefront update" again.')
1433
+ return 0
1434
+ }
1435
+ console.log('Done. The skill suite is installed; see AGENTS.md for the workflow.')
977
1436
  return 0
978
1437
  }
979
1438
 
@@ -1012,6 +1471,18 @@ async function doctor(args) {
1012
1471
  for (const rel of drift.changed) console.log(` ${rel}`)
1013
1472
  console.log(' status: run "storefront sync" to reconcile')
1014
1473
  }
1474
+
1475
+ // Data-bound section instances resolve their selection from a user-owned
1476
+ // `sectionSources` entry. `sync` never patches user files, so a freshly
1477
+ // added instance is unconfigured until the user supplies its entry; report
1478
+ // each gap with the file and instance id so the repair is actionable.
1479
+ const sectionConfig = auditSectionConfig({ dir: target, manifest, isDataBound: sectionIsDataBound })
1480
+ if (sectionConfig.diagnostics.length > 0) {
1481
+ problems++
1482
+ console.log(` section config: ${sectionConfig.diagnostics.length} data-bound section(s) need attention:`)
1483
+ for (const diag of sectionConfig.diagnostics) console.log(` ${diag.message}`)
1484
+ console.log(' status: supply the missing source configuration, then run "storefront sync"')
1485
+ }
1015
1486
  }
1016
1487
 
1017
1488
  const opencodeDir = join(target, '.opencode')
@@ -1070,24 +1541,195 @@ async function doctor(args) {
1070
1541
  return problems === 0 ? 0 : 1
1071
1542
  }
1072
1543
 
1544
+ // Spawn a command inheriting stdio, for the isolated shadcn preset run.
1545
+ function runInherit(cmd, cmdArgs, cwd) {
1546
+ const res = spawnSync(cmd, cmdArgs, { cwd, stdio: 'inherit', shell: false })
1547
+ return res.status ?? 1
1548
+ }
1549
+
1550
+ // The mode the project currently renders: manifest first (authoritative), then
1551
+ // the layout's ThemeProvider wiring, then the default.
1552
+ function detectLayoutMode(layout) {
1553
+ const m = layout.match(/defaultTheme="([^"]+)"/)
1554
+ return m && THEME_MODES.includes(m[1]) ? m[1] : null
1555
+ }
1556
+
1557
+ // Best-effort declarative font export: recover the family slug from the
1558
+ // `--font-<slug>-<slot>` variable a `next/font/google` loader declares.
1559
+ function detectExportFonts(layout) {
1560
+ const fonts = {}
1561
+ for (const slot of ['sans', 'mono', 'serif']) {
1562
+ const m = layout.match(new RegExp(`variable:\\s*'--font-([a-z0-9-]+)-${slot}'`))
1563
+ if (m) fonts[slot] = { kind: 'google', value: m[1] }
1564
+ }
1565
+ return fonts
1566
+ }
1567
+
1568
+ // Read-only inspection: current tokens, supported names, accepted formats.
1569
+ function themeInspect(args, { globals, currentMode }) {
1570
+ const tokens = readThemeCss(globals)
1571
+ if (args.json) {
1572
+ printEnvelope({
1573
+ success: true,
1574
+ command: 'theme',
1575
+ status: 'inspect',
1576
+ mode: currentMode,
1577
+ tokens: { light: tokens.light, dark: tokens.dark },
1578
+ supportedTokens: THEME_COLOR_TOKENS.map((t) => `--${t}`),
1579
+ colorNames: NAMED_COLOR_NAMES,
1580
+ colorFormats: ['<name>', '#hex', 'oklch(...)'],
1581
+ fontVars: FONT_VARS,
1582
+ })
1583
+ return 0
1584
+ }
1585
+ console.log(`Theme mode: ${currentMode}`)
1586
+ console.log(`Color formats: ${NAMED_COLOR_NAMES.join(', ')}, #hex, oklch(...)`)
1587
+ console.log(`Tokens: ${[...THEME_COLOR_ITEMS()].join(' ')}`)
1588
+ console.log(`Fonts: ${FONT_VARS.join(' ')}`)
1589
+ console.log('Fine-grained: --set light.primary=#f97316 (scopes: light, dark, shared)')
1590
+ return 0
1591
+ }
1592
+
1593
+ function THEME_COLOR_ITEMS() {
1594
+ return THEME_COLOR_TOKENS.map((t) => `--${t}`)
1595
+ }
1596
+
1597
+ function themeFail(args, diagnostics) {
1598
+ if (args.json) {
1599
+ printEnvelope({ success: false, command: 'theme', status: 'failed', changes: [], diagnostics })
1600
+ } else {
1601
+ for (const d of diagnostics) console.error(d.message)
1602
+ }
1603
+ return 1
1604
+ }
1605
+
1606
+ function themeReport(args, { status, changes, diagnostics, values }) {
1607
+ const diags = diagnostics ?? []
1608
+ if (args.json) {
1609
+ printEnvelope({ success: true, command: 'theme', status, changes, diagnostics: diags, values })
1610
+ return 0
1611
+ }
1612
+ if (status === 'noop') {
1613
+ console.log('Theme already up to date — no change.')
1614
+ } else if (status === 'dry-run') {
1615
+ console.log('Dry run — no files written. Planned changes:')
1616
+ for (const c of changes) console.log(` ${c}`)
1617
+ } else {
1618
+ for (const c of changes) console.log(`Updated ${c}`)
1619
+ }
1620
+ for (const d of diags) if (d.severity === 'warning') console.error(`warning: ${d.message}`)
1621
+ return 0
1622
+ }
1623
+
1624
+ // Write a portable theme snapshot. Read-only over the project: it never mutates
1625
+ // the storefront and refuses to clobber an existing file.
1626
+ async function themeExport(args, { globals, layout, currentMode }) {
1627
+ const outPath = resolve(process.cwd(), args.exportPath)
1628
+ if (existsSync(outPath)) {
1629
+ return themeFail(args, [
1630
+ envelopeDiagnostic('E_EXPORT_EXISTS', `Refusing to overwrite existing file: ${args.exportPath}`),
1631
+ ])
1632
+ }
1633
+ const data = buildThemeExport({
1634
+ tokens: readThemeCss(globals),
1635
+ mode: currentMode,
1636
+ fonts: detectExportFonts(layout),
1637
+ })
1638
+ await mkdir(dirname(outPath), { recursive: true })
1639
+ await writeFile(outPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
1640
+ if (args.json) {
1641
+ printEnvelope({ success: true, command: 'theme', status: 'exported', changes: [args.exportPath] })
1642
+ return 0
1643
+ }
1644
+ console.log(`Exported theme to ${args.exportPath}`)
1645
+ return 0
1646
+ }
1647
+
1648
+ // Resolve a `--import` source into a normalized importSource for planCss:
1649
+ // a local CSS export, a local JSON (registry:theme item or a prior storefront
1650
+ // export), or a remote registry:theme URL.
1651
+ async function resolveThemeImport(raw) {
1652
+ const kind = classifyImportSource(raw)
1653
+ try {
1654
+ if (kind === 'css' || kind === 'json') {
1655
+ const p = resolve(process.cwd(), raw)
1656
+ if (!existsSync(p)) {
1657
+ return { ok: false, diagnostics: [envelopeDiagnostic('E_IMPORT_PATH', `Import file not found: ${raw}`)] }
1658
+ }
1659
+ const text = readFileSync(p, 'utf8')
1660
+ if (kind === 'json' || text.trimStart().startsWith('{')) {
1661
+ const asExport = parseThemeExport(text)
1662
+ if (asExport.ok) {
1663
+ return {
1664
+ ok: true,
1665
+ importSource: { type: 'tokens', tokens: asExport.tokens },
1666
+ fonts: asExport.fonts ?? {},
1667
+ mode: asExport.mode ?? null,
1668
+ }
1669
+ }
1670
+ let json
1671
+ try {
1672
+ json = JSON.parse(text)
1673
+ } catch (err) {
1674
+ return { ok: false, diagnostics: [envelopeDiagnostic('E_IMPORT_JSON', `Invalid JSON in ${raw}: ${err.message}`)] }
1675
+ }
1676
+ const asRegistry = parseRegistryTheme(json)
1677
+ if (asRegistry.ok) {
1678
+ return { ok: true, importSource: { type: 'tokens', tokens: asRegistry.tokens }, fonts: asRegistry.fonts ?? {} }
1679
+ }
1680
+ return { ok: false, diagnostics: asRegistry.diagnostics }
1681
+ }
1682
+ const parsed = parseCssExport(text)
1683
+ if (!parsed.ok) return { ok: false, diagnostics: parsed.diagnostics }
1684
+ return { ok: true, importSource: { type: 'css', css: text } }
1685
+ }
1686
+ if (kind === 'registry-url') {
1687
+ const res = await fetch(raw)
1688
+ if (!res.ok) {
1689
+ return { ok: false, diagnostics: [envelopeDiagnostic('E_IMPORT_FETCH', `Failed to fetch ${raw}: HTTP ${res.status}`)] }
1690
+ }
1691
+ const parsed = parseRegistryTheme(await res.json())
1692
+ if (!parsed.ok) return { ok: false, diagnostics: parsed.diagnostics }
1693
+ return { ok: true, importSource: { type: 'tokens', tokens: parsed.tokens }, fonts: parsed.fonts ?? {} }
1694
+ }
1695
+ if (kind === 'preset-url') {
1696
+ return {
1697
+ ok: false,
1698
+ diagnostics: [
1699
+ envelopeDiagnostic('E_IMPORT_PRESET', `Use --preset for a builder/preset URL (got "${raw}")`),
1700
+ ],
1701
+ }
1702
+ }
1703
+ } catch (err) {
1704
+ return { ok: false, diagnostics: [envelopeDiagnostic('E_IMPORT', `Failed to read import "${raw}": ${err.message}`)] }
1705
+ }
1706
+ return { ok: false, diagnostics: [envelopeDiagnostic('E_IMPORT', `Unsupported import source: ${raw}`)] }
1707
+ }
1708
+
1709
+ // Complete theme control: mode + colors + radius + fonts + presets/imports +
1710
+ // inspect/export, all resolved into one validated, in-memory change plan over the
1711
+ // external files. Visual values live in app/globals.css; the mode stays in the
1712
+ // manifest. The whole operation is validated before any write, so a failure (or
1713
+ // --dry-run) leaves the project untouched.
1073
1714
  async function theme(args) {
1074
1715
  const target = args.dir
1075
1716
  if (!existsSync(target)) {
1076
1717
  console.error(`Target directory does not exist: ${target}`)
1077
1718
  return 1
1078
1719
  }
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}`)
1720
+
1721
+ const layoutPath = join(target, 'app', 'layout.tsx')
1722
+ if (!existsSync(layoutPath)) {
1723
+ console.error(`No app/layout.tsx found in ${target}`)
1724
+ console.error('Run `storefront create <dir>` to scaffold the storefront first.')
1085
1725
  return 1
1086
1726
  }
1727
+ const globalsPath = join(target, 'app', 'globals.css')
1728
+ const globals = existsSync(globalsPath) ? readFileSync(globalsPath, 'utf8') : ''
1729
+ const layout = readFileSync(layoutPath, 'utf8')
1087
1730
 
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.
1731
+ // Gate the existing composition before any write (same contract as the other
1732
+ // scoped commands): never stamp an unsupported or invalid composition.
1091
1733
  const { manifest, error } = readManifestOrError(target)
1092
1734
  if (error) {
1093
1735
  console.error(`Refusing to change theme: ${error}`)
@@ -1098,38 +1740,141 @@ async function theme(args) {
1098
1740
  if (!preflight.ok) return rejectComposition(preflight.errors)
1099
1741
  }
1100
1742
 
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
1743
+ const currentMode = manifest?.theme?.mode ?? detectLayoutMode(layout) ?? DEFAULT_THEME_MODE
1744
+ const mutationFlags = Boolean(
1745
+ args.mode ||
1746
+ args.brand ||
1747
+ args.primary ||
1748
+ args.accent ||
1749
+ args.radius ||
1750
+ args.fontSans ||
1751
+ args.fontMono ||
1752
+ args.fontSerif ||
1753
+ (args.sets && args.sets.length) ||
1754
+ args.preset ||
1755
+ args.importSource,
1756
+ )
1757
+
1758
+ // Inspect: explicit --inspect, or a bare `theme` invocation. Read-only.
1759
+ if (args.inspect || (!mutationFlags && !args.exportPath)) {
1760
+ return themeInspect(args, { globals, currentMode })
1761
+ }
1762
+
1763
+ // Export: portable snapshot, never mutates the project.
1764
+ if (args.exportPath) {
1765
+ return themeExport(args, { globals, layout, currentMode })
1766
+ }
1767
+
1768
+ // Resolve a local/remote import source. A versioned storefront export also
1769
+ // carries its recorded mode and declarative font selection, restored unless
1770
+ // the flags override them.
1771
+ let importSource = null
1772
+ let importFonts = {}
1773
+ let importMode = null
1774
+ if (args.importSource) {
1775
+ const resolved = await resolveThemeImport(args.importSource)
1776
+ if (!resolved.ok) return themeFail(args, resolved.diagnostics)
1777
+ importSource = resolved.importSource
1778
+ importFonts = resolved.fonts ?? {}
1779
+ importMode = resolved.mode ?? null
1780
+ }
1781
+
1782
+ // Resolve a shadcn preset through the pinned CLI in isolated staging.
1783
+ let presetFonts = {}
1784
+ if (args.preset) {
1785
+ const only = args.only ?? 'theme,font'
1786
+ const allowed = ['theme', 'font', 'theme,font']
1787
+ if (!allowed.includes(only)) {
1788
+ return themeFail(args, [
1789
+ envelopeDiagnostic('E_ONLY', `--only must be one of ${allowed.join(', ')} (got "${only}")`),
1790
+ ])
1791
+ }
1792
+ const runPreset = makeShadcnRunner({
1793
+ shadcnVersion: CANDIDATE_TOOLSET.shadcn,
1794
+ projectDir: target,
1795
+ run: runInherit,
1796
+ })
1797
+ const resolved = await resolvePreset(args.preset, { projectDir: target, runPreset })
1798
+ if (!resolved.ok) return themeFail(args, resolved.diagnostics)
1799
+ importSource = { type: 'tokens', tokens: resolved.tokens }
1800
+ presetFonts = resolved.fonts ?? {}
1801
+ }
1802
+
1803
+ // Normalize explicit color setters to OKLCH so the stylesheet keeps one
1804
+ // color space; non-color values (lengths, stacks) pass through untouched.
1805
+ const normalizedSets = (args.sets ?? []).map((entry) => {
1806
+ const parsed = parseSetter(entry)
1807
+ if (parsed.error) return entry
1808
+ const color = parseColor(parsed.value)
1809
+ return color ? `${parsed.scope}.${parsed.name}=${formatOklch(color)}` : entry
1810
+ })
1811
+
1812
+ const { operation, diagnostics } = normalizeOperation({
1813
+ mode: args.mode ?? importMode,
1814
+ brand: args.brand,
1815
+ primary: args.primary,
1816
+ accent: args.accent,
1817
+ radius: args.radius,
1818
+ fontSans: args.fontSans,
1819
+ fontMono: args.fontMono,
1820
+ fontSerif: args.fontSerif,
1821
+ sets: normalizedSets,
1822
+ preset: args.preset,
1823
+ presetOnly: args.only,
1824
+ importSource,
1825
+ dryRun: args.dryRun,
1826
+ })
1827
+ if (diagnostics.some((d) => d.severity === 'error')) return themeFail(args, diagnostics)
1828
+ for (const slot of ['sans', 'mono', 'serif']) {
1829
+ if (!operation.fonts[slot] && importFonts[slot]) operation.fonts[slot] = importFonts[slot]
1830
+ if (!operation.fonts[slot] && presetFonts[slot]) operation.fonts[slot] = presetFonts[slot]
1106
1831
  }
1107
1832
 
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 }
1833
+ const planned = planCss(globals, operation)
1834
+ if (planned.diagnostics.some((d) => d.severity === 'error')) return themeFail(args, planned.diagnostics)
1835
+
1836
+ // Font wiring: layout loaders + the CSS variables that point at them. The
1837
+ // `font-*` utilities read the `@theme inline` mapping, so update that block
1838
+ // (falling back to `:root` when the stylesheet has no theme block).
1839
+ const fontResult = applyLayoutFonts(layout, operation.fonts)
1840
+ const fontVars = fontCssVars(operation.fonts, fontResult.loaderVars)
1841
+ const fontEdits = Object.entries(fontVars).map(([name, value]) => ({ name, value }))
1842
+ const nextGlobals = fontEdits.length
1843
+ ? applyBlockDeclarations(planned.css, '@theme inline', fontEdits)
1844
+ : planned.css
1845
+
1846
+ let nextLayout = fontResult.content
1847
+ if (operation.mode) nextLayout = injectThemeMode(nextLayout, operation.mode)
1848
+
1849
+ const changes = []
1850
+ if (nextGlobals !== globals) changes.push(relative(process.cwd(), globalsPath) || globalsPath)
1851
+ if (nextLayout !== layout) changes.push(relative(process.cwd(), layoutPath) || layoutPath)
1852
+ const modeChanged = Boolean(operation.mode) && operation.mode !== currentMode
1853
+ if (modeChanged) changes.push('storefront.config.json#theme.mode')
1854
+
1855
+ if (args.dryRun) {
1856
+ return themeReport(args, { status: 'dry-run', changes, diagnostics: planned.diagnostics, values: planned.values })
1857
+ }
1858
+ if (!changes.length) {
1859
+ return themeReport(args, { status: 'noop', changes: [], diagnostics: planned.diagnostics, values: planned.values })
1860
+ }
1861
+
1862
+ // Commit: manifest mode first (validated), then the external files, then
1863
+ // reconcile so every derived file agrees with the manifest.
1864
+ if (manifest && modeChanged) {
1865
+ manifest.theme = { ...manifest.theme, mode: operation.mode }
1113
1866
  const nextPreflight = preflightManifest(manifest)
1114
1867
  if (!nextPreflight.ok) return rejectComposition(nextPreflight.errors)
1115
1868
  await writeManifest(target, manifest)
1869
+ }
1870
+ if (nextGlobals !== globals) await writeFile(globalsPath, nextGlobals, 'utf8')
1871
+ if (nextLayout !== layout) await writeFile(layoutPath, nextLayout, 'utf8')
1872
+ if (manifest) {
1116
1873
  const result = await reconcile(target, manifest)
1117
1874
  if (!result.ok) return rejectComposition(result.errors)
1118
- console.log(`Theme mode set to "${args.mode}" in storefront.config.json`)
1119
- return 0
1120
1875
  }
1121
1876
 
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
1877
+ return themeReport(args, { status: 'changed', changes, diagnostics: planned.diagnostics, values: planned.values })
1133
1878
  }
1134
1879
 
1135
1880
  async function sync(args) {
@@ -1188,6 +1933,45 @@ async function view(args) {
1188
1933
  return 1
1189
1934
  }
1190
1935
 
1936
+ // Bounded legacy cleanup (change: storefront-retire-user-panel, AC-UP-1.2): a
1937
+ // manifest written before a view was retired carries an entry the registry no
1938
+ // longer knows, so the normal reduce path refuses the input. `view remove
1939
+ // user-panel` is the single supported transition; every other unknown id
1940
+ // still fails closed through the normal path below.
1941
+ if (sub === 'remove' && isLegacyViewId(name)) {
1942
+ const result = await removeLegacyView(target, name)
1943
+ if (!result.ok) {
1944
+ if (args.json) {
1945
+ printEnvelope(resultEnvelope('view', false, 1, result.diagnostics))
1946
+ return 1
1947
+ }
1948
+ return rejectDiagnostics(result.diagnostics)
1949
+ }
1950
+ if (args.json) {
1951
+ printEnvelope(
1952
+ resultEnvelope('view', true, 0, [], {
1953
+ action: 'remove',
1954
+ view: name,
1955
+ changed: result.changed,
1956
+ preserved: result.preserved,
1957
+ alreadyRemoved: result.alreadyRemoved,
1958
+ }),
1959
+ )
1960
+ return 0
1961
+ }
1962
+ if (result.alreadyRemoved) {
1963
+ console.log(`View "${name}" is already absent; nothing to remove.`)
1964
+ return 0
1965
+ }
1966
+ console.log(`Removed legacy view "${name}".`)
1967
+ if (result.backup) console.log(` backed up ${result.backup}`)
1968
+ for (const leftover of result.preserved) {
1969
+ console.log(`Preserved user-owned file: ${leftover} (remove it manually if no longer needed).`)
1970
+ }
1971
+ if (result.changed.length > 0) console.log(` reconciled ${result.changed.length} derived file(s).`)
1972
+ return 0
1973
+ }
1974
+
1191
1975
  const present = manifest.views.some((v) => v.name === name)
1192
1976
  const operations =
1193
1977
  sub === 'add'
@@ -1314,6 +2098,284 @@ async function feature(args) {
1314
2098
  return 0
1315
2099
  }
1316
2100
 
2101
+ // Collect the position anchors supplied on the command line. Exactly one may be
2102
+ // given: `--after`/`--before` name a sibling id, `--first`/`--last` target an
2103
+ // extreme, and `--at` is the raw 0-based index escape hatch (change:
2104
+ // storefront-section-cli-alias / design D3).
2105
+ function readSectionAnchor(args) {
2106
+ const anchors = []
2107
+ if (args.after != null) anchors.push({ kind: 'after', id: args.after })
2108
+ if (args.before != null) anchors.push({ kind: 'before', id: args.before })
2109
+ if (args.first) anchors.push({ kind: 'first' })
2110
+ if (args.last) anchors.push({ kind: 'last' })
2111
+ if (args.at != null) anchors.push({ kind: 'at', value: args.at })
2112
+ return anchors
2113
+ }
2114
+
2115
+ // The ordered entry ids of a view as the engine sees them: the view's declared
2116
+ // `sections` normalized through the single `section-compat.mjs` authority (which
2117
+ // turns an empty list into the registered core slots). Returns null when the view
2118
+ // cannot be normalized, so the caller defers to the reducer and its canonical
2119
+ // diagnostic instead of inventing one.
2120
+ function orderedSectionIds(manifest, view) {
2121
+ const installed = (manifest.views ?? []).find((entry) => entry.name === view)
2122
+ const normalized = normalizeViewSectionsForView(view, installed?.sections ?? [])
2123
+ if (!normalized.ok) return null
2124
+ return normalized.sections.map((entry) => entry.id)
2125
+ }
2126
+
2127
+ // Translate an `add` anchor into the operation's 0-based `at`. An `--after`/
2128
+ // `--before` id that is absent fails closed with E_UNKNOWN_SECTION_REF before any
2129
+ // write; a view that cannot be normalized defers to the reducer (design D5).
2130
+ function resolveAddAnchor(manifest, view, anchor) {
2131
+ if (!anchor) return {}
2132
+ if (anchor.kind === 'at') {
2133
+ const value = Number(anchor.value)
2134
+ if (!Number.isInteger(value) || value < 0) {
2135
+ return {
2136
+ error: {
2137
+ code: 'E_INVALID_OPERATION',
2138
+ message: `--at expects a non-negative integer (got "${anchor.value}").`,
2139
+ },
2140
+ }
2141
+ }
2142
+ return { value }
2143
+ }
2144
+ const ids = orderedSectionIds(manifest, view)
2145
+ if (ids === null) return {}
2146
+ if (anchor.kind === 'first') return { value: 0 }
2147
+ if (anchor.kind === 'last') return { value: ids.length }
2148
+ const index = ids.indexOf(anchor.id)
2149
+ if (index === -1) {
2150
+ return {
2151
+ error: {
2152
+ code: 'E_UNKNOWN_SECTION_REF',
2153
+ message: `--${anchor.kind} names section "${anchor.id}" which is not present in view "${view}".`,
2154
+ },
2155
+ }
2156
+ }
2157
+ return { value: anchor.kind === 'after' ? index + 1 : index }
2158
+ }
2159
+
2160
+ // Build the full entry order for a `move`: drop the target from the normalized
2161
+ // list, resolve the destination against that reduced list, then reinsert. Unknown
2162
+ // target -> E_UNKNOWN_ORDER_REF (the same code a direct `section.reorder` emits);
2163
+ // unknown sibling anchor -> E_UNKNOWN_SECTION_REF; an unnormalizable view defers
2164
+ // to the reducer (change: storefront-section-cli-alias / design D4/D5).
2165
+ function buildMoveOrder(manifest, view, id, anchor) {
2166
+ const ids = orderedSectionIds(manifest, view)
2167
+ if (ids === null) return { order: [] }
2168
+ if (!ids.includes(id)) {
2169
+ return {
2170
+ error: {
2171
+ code: 'E_UNKNOWN_ORDER_REF',
2172
+ message: `section.reorder names id "${id}" which is not present in view "${view}".`,
2173
+ },
2174
+ }
2175
+ }
2176
+ const reduced = ids.filter((entryId) => entryId !== id)
2177
+ let index
2178
+ if (anchor.kind === 'at') {
2179
+ index = Number(anchor.value)
2180
+ if (!Number.isInteger(index) || index < 0 || index > reduced.length) {
2181
+ return {
2182
+ error: {
2183
+ code: 'E_INVALID_OPERATION',
2184
+ message: `--at must be an integer between 0 and ${reduced.length}.`,
2185
+ },
2186
+ }
2187
+ }
2188
+ } else if (anchor.kind === 'first') {
2189
+ index = 0
2190
+ } else if (anchor.kind === 'last') {
2191
+ index = reduced.length
2192
+ } else {
2193
+ const sibling = reduced.indexOf(anchor.id)
2194
+ if (sibling === -1) {
2195
+ return {
2196
+ error: {
2197
+ code: 'E_UNKNOWN_SECTION_REF',
2198
+ message: `--${anchor.kind} names section "${anchor.id}" which is not present in view "${view}".`,
2199
+ },
2200
+ }
2201
+ }
2202
+ index = anchor.kind === 'after' ? sibling + 1 : sibling
2203
+ }
2204
+ return { order: [...reduced.slice(0, index), id, ...reduced.slice(index)] }
2205
+ }
2206
+
2207
+ // Add, remove, or move a section instance through the shared operation engine.
2208
+ // `section add|remove|move` is a thin alias over `section.add`/`section.remove`/
2209
+ // `section.reorder`, threading SECTION_OPERATION_REGISTRY so the section ops are
2210
+ // recognized; it produces the same manifest, derived files, and diagnostics as
2211
+ // planning and applying the equivalent operations (change:
2212
+ // storefront-section-cli-alias / CU-17).
2213
+ async function section(args) {
2214
+ const target = args.dir
2215
+ const sub = args.positionals[1]
2216
+ const view = args.positionals[2]
2217
+ if (sub !== 'add' && sub !== 'remove' && sub !== 'move') {
2218
+ console.error('Usage: storefront section <add|remove|move> <view> [...]')
2219
+ return 1
2220
+ }
2221
+ if (!view) {
2222
+ console.error(`storefront section ${sub} requires a view name.`)
2223
+ return 1
2224
+ }
2225
+
2226
+ const anchors = readSectionAnchor(args)
2227
+ if (anchors.length > 1) {
2228
+ console.error(
2229
+ 'Only one position anchor may be given: --after, --before, --first, --last, --at.'
2230
+ )
2231
+ return 1
2232
+ }
2233
+ const anchor = anchors[0] ?? null
2234
+
2235
+ const { manifest, error } = readManifestOrError(target)
2236
+ if (error) {
2237
+ console.error(`Refusing to modify sections: ${error}`)
2238
+ return 1
2239
+ }
2240
+ if (!manifest) {
2241
+ console.error(`No storefront.config.json found in ${target}. Run \`storefront sync\` first.`)
2242
+ return 1
2243
+ }
2244
+
2245
+ // A section edit is deterministic; a usage/validation error is reported the
2246
+ // same way whether or not `--json` is set (the shared versioned envelope).
2247
+ const failWith = (code, message) => {
2248
+ const diagnostics = [envelopeDiagnostic(code, message)]
2249
+ if (args.json) {
2250
+ printEnvelope(resultEnvelope('section', false, 1, diagnostics))
2251
+ return 1
2252
+ }
2253
+ return rejectDiagnostics(diagnostics)
2254
+ }
2255
+
2256
+ let operations
2257
+ if (sub === 'add') {
2258
+ const type = args.positionals[3]
2259
+ if (!type) return failWith('E_INVALID_OPERATION', 'storefront section add requires a section type.')
2260
+ const op = { op: 'section.add', view, type }
2261
+ if (args.id != null) op.id = args.id
2262
+ if (args.as != null) op.variant = args.as
2263
+ const placement = resolveAddAnchor(manifest, view, anchor)
2264
+ if (placement.error) return failWith(placement.error.code, placement.error.message)
2265
+ if (placement.value != null) op.at = placement.value
2266
+ operations = [op]
2267
+ } else if (sub === 'remove') {
2268
+ const id = args.positionals[3]
2269
+ if (!id) return failWith('E_INVALID_OPERATION', 'storefront section remove requires a section id.')
2270
+ if (anchor) {
2271
+ return failWith('E_INVALID_OPERATION', 'storefront section remove does not take a position anchor.')
2272
+ }
2273
+ if (args.id != null) {
2274
+ return failWith('E_INVALID_OPERATION', 'storefront section remove takes the id positionally, not via --id.')
2275
+ }
2276
+ operations = [{ op: 'section.remove', view, id }]
2277
+ } else {
2278
+ const id = args.positionals[3]
2279
+ if (!id) return failWith('E_INVALID_OPERATION', 'storefront section move requires a section id.')
2280
+ if (!anchor) {
2281
+ return failWith(
2282
+ 'E_INVALID_OPERATION',
2283
+ 'storefront section move requires a destination (--after, --before, --first, --last, --at).'
2284
+ )
2285
+ }
2286
+ const built = buildMoveOrder(manifest, view, id, anchor)
2287
+ if (built.error) return failWith(built.error.code, built.error.message)
2288
+ operations = [{ op: 'section.reorder', view, order: built.order }]
2289
+ }
2290
+
2291
+ const result = await executeOperations(target, manifest, operations, {
2292
+ force: args.force,
2293
+ registry: SECTION_OPERATION_REGISTRY,
2294
+ })
2295
+ if (!result.ok) {
2296
+ if (args.json) {
2297
+ printEnvelope(resultEnvelope('section', false, 1, result.diagnostics))
2298
+ return 1
2299
+ }
2300
+ return rejectDiagnostics(result.diagnostics)
2301
+ }
2302
+ if (args.json) {
2303
+ printEnvelope(
2304
+ resultEnvelope('section', true, 0, [], {
2305
+ action: sub,
2306
+ view,
2307
+ changed: result.changed,
2308
+ preserved: result.preserved,
2309
+ }),
2310
+ )
2311
+ return 0
2312
+ }
2313
+
2314
+ if (sub === 'add') {
2315
+ console.log(`Added a "${args.positionals[3]}" section to view "${view}".`)
2316
+ } else if (sub === 'remove') {
2317
+ console.log(`Removed section "${args.positionals[3]}" from view "${view}".`)
2318
+ for (const leftover of result.preserved) {
2319
+ console.log(`Preserved user-owned file: ${leftover} (remove it manually if no longer needed).`)
2320
+ }
2321
+ } else {
2322
+ console.log(`Moved section "${args.positionals[3]}" in view "${view}".`)
2323
+ }
2324
+ if (result.changed.length > 0) console.log(` reconciled ${result.changed.length} derived file(s).`)
2325
+ return 0
2326
+ }
2327
+
2328
+ // List the section instances of installed views, in order, as the engine sees
2329
+ // them (the declared list normalized through section-compat). Read-only and
2330
+ // deterministic — it writes no file (change: storefront-section-cli-alias /
2331
+ // task 3.1). `--json` mirrors the `views` envelope shape.
2332
+ async function sections(args) {
2333
+ const target = args.dir
2334
+ const manifest = await readManifest(target)
2335
+ const installed = manifest?.views ?? []
2336
+ const requested = args.positionals[1] ?? null
2337
+ if (requested && !installed.some((view) => view.name === requested)) {
2338
+ console.error(`View "${requested}" is not installed in ${target}.`)
2339
+ return 1
2340
+ }
2341
+ const selected = requested ? installed.filter((view) => view.name === requested) : installed
2342
+
2343
+ const entries = selected.map((view) => {
2344
+ const normalized = normalizeViewSectionsForView(view.name, view.sections ?? [])
2345
+ const list = normalized.ok ? normalized.sections : []
2346
+ return {
2347
+ name: view.name,
2348
+ installed: true,
2349
+ sections: list.map((entry) => ({
2350
+ id: entry.id,
2351
+ type: entry.type,
2352
+ variant: entry.variant ?? null,
2353
+ })),
2354
+ }
2355
+ })
2356
+
2357
+ if (args.json) {
2358
+ console.log(
2359
+ JSON.stringify(
2360
+ { catalogVersion: CATALOG_VERSION, sectionTypes: sectionRegistry(), views: entries },
2361
+ null,
2362
+ 2,
2363
+ ),
2364
+ )
2365
+ return 0
2366
+ }
2367
+
2368
+ console.log(`Storefront sections (${target}):`)
2369
+ for (const entry of entries) {
2370
+ console.log(` ${entry.name}`)
2371
+ for (const instance of entry.sections) {
2372
+ const variant = instance.variant ? ` (${instance.variant})` : ''
2373
+ console.log(` ${instance.id.padEnd(24)} ${instance.type}${variant}`)
2374
+ }
2375
+ }
2376
+ return 0
2377
+ }
2378
+
1317
2379
  // Build a diagnostic for the result/error envelope. `path`, when present, is a
1318
2380
  // project-root-relative POSIX file path; operation-field diagnostics use a
1319
2381
  // semantic locator instead (e.g. `operations[0].surprise`), never a filesystem
@@ -1357,6 +2419,7 @@ function inspect(args) {
1357
2419
  views: manifest?.views ?? [],
1358
2420
  features: manifest?.features ?? [],
1359
2421
  },
2422
+ dataBoundSections: collectDataBoundInstances(manifest, sectionIsDataBound),
1360
2423
  }
1361
2424
  const envelope = resultEnvelope('inspect', true, 0, [], result)
1362
2425
  if (args.json) {
@@ -1368,6 +2431,9 @@ function inspect(args) {
1368
2431
  console.log(` slots: ${Object.keys(result.slots).length}`)
1369
2432
  console.log(` views: ${result.composition.views.map((v) => v.name).join(', ') || '(none)'}`)
1370
2433
  console.log(` features: ${result.composition.features.map((f) => f.name).join(', ') || '(none)'}`)
2434
+ console.log(
2435
+ ` data-bound: ${result.dataBoundSections.map((s) => `${s.id} (${s.type})`).join(', ') || '(none)'}`,
2436
+ )
1371
2437
  return 0
1372
2438
  }
1373
2439
 
@@ -1660,6 +2726,16 @@ async function main() {
1660
2726
  if (!root) return 1
1661
2727
  return feature({ ...args, dir: root })
1662
2728
  }
2729
+ if (args.action === 'section') {
2730
+ const root = resolveProject()
2731
+ if (!root) return 1
2732
+ return section({ ...args, dir: root })
2733
+ }
2734
+ if (args.action === 'sections') {
2735
+ const root = resolveProject()
2736
+ if (!root) return 1
2737
+ return sections({ ...args, dir: root })
2738
+ }
1663
2739
  if (args.action === 'inspect' || args.action === 'plan' || args.action === 'apply') {
1664
2740
  const root = resolveProject({ quiet: true })
1665
2741
  if (!root) {
@@ -1680,7 +2756,7 @@ async function main() {
1680
2756
  return apply({ ...args, dir: root })
1681
2757
  }
1682
2758
 
1683
- const SCOPED = ['theme', 'update', 'doctor', 'sync']
2759
+ const SCOPED = ['theme', 'update', 'doctor', 'sync', 'setup']
1684
2760
  if (SCOPED.includes(args.action)) {
1685
2761
  if (args.positionals.length > 1) {
1686
2762
  console.error(`storefront ${args.action} does not take a directory; run it from inside the storefront project.`)
@@ -1692,6 +2768,7 @@ async function main() {
1692
2768
  if (args.action === 'theme') return theme(scoped)
1693
2769
  if (args.action === 'update') return update(scoped)
1694
2770
  if (args.action === 'sync') return sync(scoped)
2771
+ if (args.action === 'setup') return setup(scoped)
1695
2772
  return doctor(scoped)
1696
2773
  }
1697
2774