@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.
- package/README.md +173 -153
- package/VENDOR.md +69 -0
- package/assets/openspec/config.yaml +38 -0
- package/assets/templates/AGENTS.md +76 -0
- package/assets/templates/app/app/[[...slug]]/page.tsx +42 -1
- package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
- package/assets/templates/app/app/globals.css +5 -8
- package/assets/templates/app/app/layout.tsx +1 -5
- package/assets/templates/app/app/robots.ts +9 -3
- package/assets/templates/app/app/sitemap.ts +64 -5
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +70 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +15 -5
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +71 -0
- package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +36 -4
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +82 -50
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -0
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +29 -14
- package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +31 -7
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +4 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +56 -26
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +165 -0
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +26 -7
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +2 -1
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
- package/assets/templates/features/checkout/handlers.ts +6 -0
- package/assets/templates/features/customer-auth/state.ts +10 -1
- package/assets/templates/views/account/index.tsx +1 -1
- package/assets/templates/views/account/registry.tsx +80 -19
- package/assets/templates/views/account/schema.ts +1 -0
- package/assets/templates/views/cart/content.ts +11 -0
- package/assets/templates/views/cart/data.ts +23 -0
- package/assets/templates/views/cart/index.tsx +53 -0
- package/assets/templates/views/cart/props.ts +12 -0
- package/assets/templates/views/cart/registry.tsx +81 -0
- package/assets/templates/views/cart/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +1 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +64 -6
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +2 -1
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
- package/assets/templates/views/category/index.tsx +76 -7
- package/assets/templates/views/category/props.ts +7 -0
- package/assets/templates/views/category/registry.tsx +19 -0
- package/assets/templates/views/category/schema.ts +13 -0
- package/assets/templates/views/home/assembly/assembly.json +9 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +22 -4
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +1 -1
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +2 -2
- package/assets/templates/views/home/data.ts +9 -0
- package/assets/templates/views/home/index.tsx +23 -5
- package/assets/templates/views/home/registry.tsx +2 -2
- package/assets/templates/views/product/data.ts +4 -2
- package/assets/templates/views/product/index.tsx +49 -5
- package/assets/templates/views/product/props.ts +7 -0
- package/assets/templates/views/product/registry.tsx +25 -0
- package/assets/templates/views/product/schema.ts +13 -0
- package/assets/templates/views/static/content.ts +4 -4
- package/assets/templates/views/static/index.tsx +1 -1
- package/bin/storefront.js +1231 -154
- package/examples/merchandising/category-led.json +37 -0
- package/examples/merchandising/collection-led.json +37 -0
- package/package.json +11 -7
- package/recipes/archetypes.json +9 -22
- package/recipes/catalog.json +40 -1
- package/recipes/features.json +25 -2
- package/recipes/recipe.schema.json +1 -1
- package/recipes/recipes.json +32 -30
- package/recipes/sections.json +10 -0
- package/recipes/validate.mjs +54 -0
- package/scripts/candidate.mjs +4 -4
- package/scripts/composers.mjs +25 -5
- package/scripts/env-file.mjs +86 -0
- package/scripts/file-classes.mjs +5 -0
- package/scripts/migrate-english-routes.mjs +276 -0
- package/scripts/pinning.mjs +74 -5
- package/scripts/plan-apply.mjs +149 -5
- package/scripts/reconcile.mjs +49 -11
- package/scripts/render-templates.mjs +97 -44
- package/scripts/section-config.mjs +253 -0
- package/scripts/starter.mjs +17 -0
- package/scripts/theme/color.mjs +185 -0
- package/scripts/theme/css-theme.mjs +233 -0
- package/scripts/theme/fonts.mjs +123 -0
- package/scripts/theme/import-export.mjs +125 -0
- package/scripts/theme/preset.mjs +93 -0
- package/scripts/theme/theme-plan.mjs +292 -0
- package/scripts/theme/tokens.mjs +137 -0
- package/scripts/view-assembly.mjs +47 -1
- package/skills/sf-feature/SKILL.md +32 -19
- package/skills/sf-setup/SKILL.md +79 -0
- package/skills/sf-theme-gen/SKILL.md +99 -36
- package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
- package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
- package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
- package/skills/sf-view-gen/SKILL.md +63 -40
- package/skills/sf-view-gen/assets/recipes/archetypes.json +9 -22
- package/skills/sf-view-gen/assets/recipes/catalog.json +40 -1
- package/skills/sf-view-gen/assets/recipes/features.json +25 -2
- package/skills/sf-view-gen/assets/recipes/recipes.json +32 -30
- package/skills/sf-view-gen/assets/recipes/sections.json +10 -0
- package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
- package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
- package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
- package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
- package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
- package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
- package/skills/sf-view-gen/references/views.md +28 -37
- package/skills/sf-view-gen/scripts/validate.mjs +54 -0
- package/skills/storefront-workflow/SKILL.md +94 -31
- package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
- package/CHANGELOG.md +0 -84
- package/assets/templates/views/user-panel/index.tsx +0 -106
- package/assets/templates/views/user-panel/registry.tsx +0 -54
- package/assets/templates/views/user-panel/schema.ts +0 -79
- package/commands/sf-add-view.md +0 -57
- package/commands/sf-feature.md +0 -28
- package/commands/sf-theme.md +0 -26
- package/commands/sf-view.md +0 -67
- package/skills/building-storefronts/SKILL.md +0 -178
- package/skills/building-storefronts/references/frontend-integration.md +0 -229
- package/skills/json-render-remotion/SKILL.md +0 -111
- package/skills/json-render-solid/SKILL.md +0 -204
- package/skills/nextjs-shadcn/SKILL.md +0 -367
- package/skills/nextjs-shadcn/references/architecture.md +0 -499
- package/skills/nextjs-shadcn/references/project-setup.md +0 -162
- package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
- package/skills/nextjs-shadcn/references/sidebar.md +0 -274
- package/skills/nextjs-shadcn/references/styling.md +0 -555
- package/skills/shadcn-component-discovery/SKILL.md +0 -273
- package/skills/shadcn-component-discovery/references/registries.md +0 -226
- package/skills/shadcn-theming/SKILL.md +0 -104
- package/skills/storefront-best-practices/SKILL.md +0 -421
- package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
- package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
- package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
- package/skills/storefront-best-practices/reference/components/footer.md +0 -112
- package/skills/storefront-best-practices/reference/components/hero.md +0 -241
- package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
- package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
- package/skills/storefront-best-practices/reference/components/popups.md +0 -221
- package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
- package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
- package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
- package/skills/storefront-best-practices/reference/components/search.md +0 -101
- package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
- package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
- package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
- package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
- package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
- package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
- package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
- package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
- package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
- package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
- package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
- package/skills/storefront-best-practices/reference/medusa.md +0 -307
- /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
- /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,
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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,
|
|
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`
|
|
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
|
|
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('
|
|
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
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
976
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
|
1089
|
-
//
|
|
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
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
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
|
|