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