@thorprovider/storefront 0.3.1 → 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 +321 -152
- 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 +54 -2
- 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 +17 -7
- package/assets/templates/app/app/robots.ts +19 -0
- package/assets/templates/app/app/sitemap.ts +71 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
- package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -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 +264 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
- 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__/session.core.ts +470 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -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 +161 -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 +18 -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/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.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/schema.products.ts +39 -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 +159 -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 +45 -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 +439 -0
- package/assets/templates/views/account/schema.ts +53 -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 +29 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
- 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/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 +299 -0
- package/assets/templates/views/category/props.ts +23 -0
- package/assets/templates/views/category/registry.tsx +122 -0
- package/assets/templates/views/category/schema.ts +91 -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 +33 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -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 +24 -0
- package/assets/templates/views/home/index.tsx +57 -85
- 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 +50 -0
- package/assets/templates/views/product/index.tsx +148 -0
- package/assets/templates/views/product/props.ts +27 -0
- package/assets/templates/views/product/registry.tsx +103 -0
- package/assets/templates/views/product/schema.ts +121 -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/bin/storefront.js +2424 -148
- package/examples/merchandising/category-led.json +37 -0
- package/examples/merchandising/collection-led.json +37 -0
- package/package.json +15 -9
- package/recipes/archetypes.json +28 -14
- package/recipes/catalog.json +400 -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 +162 -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 +76 -19
- package/recipes/sections.json +34 -5
- package/recipes/validate.mjs +838 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +160 -0
- package/scripts/env-file.mjs +86 -0
- package/scripts/file-classes.mjs +121 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/migrate-english-routes.mjs +276 -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 +573 -0
- package/scripts/plan-apply.mjs +987 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +549 -0
- package/scripts/render-templates.mjs +338 -68
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-config.mjs +253 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/starter.mjs +17 -0
- package/scripts/sync-assets.mjs +22 -15
- 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 +337 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +80 -0
- package/skills/sf-setup/SKILL.md +79 -0
- package/skills/sf-theme-gen/SKILL.md +100 -30
- 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 +146 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
- package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +162 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
- package/skills/sf-view-gen/assets/recipes/sections.json +34 -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/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.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.products.ts +39 -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/{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 +58 -33
- 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 +838 -32
- package/skills/storefront-workflow/SKILL.md +179 -0
- package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
- package/CHANGELOG.md +0 -61
- package/commands/sf-add-view.md +0 -26
- package/commands/sf-theme.md +0 -20
- package/commands/sf-view.md +0 -25
- 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/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/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
- package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
- 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,38 +4,132 @@
|
|
|
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
|
-
// Maia is preselected).
|
|
12
|
-
//
|
|
11
|
+
// On a TTY, `create` prompts for missing inputs (name, site name, preset —
|
|
12
|
+
// Maia is preselected). Empty prompts fall back to their suggested default, so pressing
|
|
13
|
+
// Enter accepts it: the name is suggested from the target directory basename and the
|
|
14
|
+
// site name from the Title Case of that name. Without a TTY, every required input must
|
|
15
|
+
// be a flag; the command fails fast and writes nothing rather than guessing. Brand
|
|
16
|
+
// color is never prompted.
|
|
13
17
|
//
|
|
14
18
|
// The package's generated copies are verified against their source before anything is
|
|
15
19
|
// copied into a target project. On divergence the command stops and writes nothing.
|
|
16
20
|
|
|
17
|
-
import { cp, mkdir, readdir,
|
|
21
|
+
import { cp, mkdir, readdir, writeFile, rm } from 'node:fs/promises'
|
|
18
22
|
import { existsSync, readFileSync } from 'node:fs'
|
|
19
|
-
import { createHash } from 'node:crypto'
|
|
20
23
|
import { spawnSync } from 'node:child_process'
|
|
21
|
-
import {
|
|
24
|
+
import { createRequire } from 'node:module'
|
|
25
|
+
import { tmpdir } from 'node:os'
|
|
26
|
+
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
22
27
|
import { fileURLToPath } from 'node:url'
|
|
23
28
|
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts'
|
|
24
29
|
import { checkAssets } from '../scripts/sync-assets.mjs'
|
|
25
|
-
import {
|
|
30
|
+
import { writeInitialEnv, upsertEnv, readEnvFile } from '../scripts/env-file.mjs'
|
|
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'
|
|
45
|
+
import {
|
|
46
|
+
findProjectRoot,
|
|
47
|
+
hasManifest,
|
|
48
|
+
looksLikeStorefront,
|
|
49
|
+
readManifest,
|
|
50
|
+
validateManifest,
|
|
51
|
+
createManifest,
|
|
52
|
+
writeManifest,
|
|
53
|
+
} from '../scripts/manifest.mjs'
|
|
54
|
+
import { reconcile } from '../scripts/reconcile.mjs'
|
|
55
|
+
import { preflightManifest } from '../scripts/preflight.mjs'
|
|
56
|
+
import { scaffoldComposition } from '../scripts/starter.mjs'
|
|
57
|
+
import {
|
|
58
|
+
buildInstallPlan,
|
|
59
|
+
CANDIDATE_TOOLSET,
|
|
60
|
+
findPack,
|
|
61
|
+
parseRuntimeCommerceImports,
|
|
62
|
+
verifyPackExports,
|
|
63
|
+
verifyPackManifest,
|
|
64
|
+
} from '../scripts/candidate.mjs'
|
|
65
|
+
import {
|
|
66
|
+
declaredOperativeSkills,
|
|
67
|
+
hashManagedSuite,
|
|
68
|
+
hashPaths,
|
|
69
|
+
managedSuitePaths,
|
|
70
|
+
readDirectDependencies,
|
|
71
|
+
readResolvedVersions,
|
|
72
|
+
readLockfile,
|
|
73
|
+
readToolchainEvidence,
|
|
74
|
+
recordedOwnedSet,
|
|
75
|
+
retirementCandidates,
|
|
76
|
+
buildPinningRecord,
|
|
77
|
+
validateDeclaredContract,
|
|
78
|
+
validatePinning,
|
|
79
|
+
} from '../scripts/pinning.mjs'
|
|
80
|
+
import {
|
|
81
|
+
loadRecipes,
|
|
82
|
+
loadArchetypes,
|
|
83
|
+
loadFeatures,
|
|
84
|
+
isCuratedView,
|
|
85
|
+
featureSurfaces,
|
|
86
|
+
allowedVariantsForView,
|
|
87
|
+
supportedVariantsForView,
|
|
88
|
+
supportedIntents,
|
|
89
|
+
viewCatalog,
|
|
90
|
+
sectionRegistry,
|
|
91
|
+
sectionIsDataBound,
|
|
92
|
+
} from '../recipes/validate.mjs'
|
|
93
|
+
import { CATALOG_VERSION, loadCatalog } from '../recipes/catalog.mjs'
|
|
94
|
+
import { OPERATION_NAMES } from '../scripts/operations.mjs'
|
|
95
|
+
import {
|
|
96
|
+
applyProject,
|
|
97
|
+
executeOperations,
|
|
98
|
+
isLegacyViewId,
|
|
99
|
+
planProject,
|
|
100
|
+
removeLegacyView,
|
|
101
|
+
resultEnvelope,
|
|
102
|
+
} from '../scripts/plan-apply.mjs'
|
|
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'
|
|
26
106
|
|
|
27
107
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
28
108
|
const PKG_ROOT = resolve(__dirname, '..')
|
|
29
109
|
|
|
30
110
|
const VERSION_RECORD_FILE = 'storefront.json'
|
|
31
111
|
const LEGACY_RECORD_FILE = '.create-storefront.json'
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
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$/
|
|
37
129
|
|
|
38
130
|
const PRESETS = ['maia', 'nova', 'vega', 'luma', 'lyra', 'mira']
|
|
131
|
+
const THEME_MODES = ['light', 'dark', 'system']
|
|
132
|
+
const DEFAULT_THEME_MODE = 'system'
|
|
39
133
|
const PRESET_LABELS = {
|
|
40
134
|
maia: 'Rounded and spacious. Warm and approachable — fits a store.',
|
|
41
135
|
nova: 'Compact and balanced. The neutral default.',
|
|
@@ -50,18 +144,98 @@ const HELP = `@thorprovider/storefront — scaffold Thor Commerce storefronts
|
|
|
50
144
|
|
|
51
145
|
Usage:
|
|
52
146
|
storefront create <dir> [options] Scaffold a storefront and install the suite
|
|
53
|
-
storefront
|
|
147
|
+
storefront setup [options] Connect a real Medusa backend (re-runnable)
|
|
148
|
+
storefront theme [options] Customize the theme (mode, colors, fonts, presets)
|
|
149
|
+
storefront sync Reconcile derived files to the manifest
|
|
150
|
+
storefront views [--json] List the view registry (recipes, intents, installed)
|
|
151
|
+
storefront features [--json] List the feature registry (bundles, installed)
|
|
152
|
+
storefront recipes [--json] Print the whole curated registry (views + features)
|
|
153
|
+
storefront view add|remove <name> Add or remove a view (reconciles derived files)
|
|
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)
|
|
157
|
+
storefront inspect [--json] Report the versioned catalog and composition (read-only)
|
|
158
|
+
storefront plan --operations <file> [--out <file>] [--json]
|
|
159
|
+
Validate a whole intended state and emit a plan
|
|
160
|
+
storefront apply --plan <file> [--json] Apply a recorded plan from its before-state
|
|
161
|
+
storefront <update|doctor> Manage an existing install
|
|
162
|
+
|
|
163
|
+
Every command except create runs inside the project: it resolves the storefront
|
|
164
|
+
from the current directory upward (storefront.config.json) and errors outside one.
|
|
54
165
|
|
|
55
166
|
create options:
|
|
56
167
|
--name <name> storefront name = lib/<name>/ (required without a terminal)
|
|
57
|
-
--site-name <name> human site name (default: <name>)
|
|
58
|
-
--backend-url <url> Medusa backend URL (default: http://localhost:9000)
|
|
168
|
+
--site-name <name> human site name (default: Title Case of <name>)
|
|
59
169
|
--preset <preset> maia | nova | vega | luma | lyra | mira (required without a terminal)
|
|
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
|
|
60
173
|
--brand <color> brand color over preset tokens (not prompted by the wizard)
|
|
174
|
+
--packs <dir> directory of local @thorprovider/* .tgz tarballs; installs
|
|
175
|
+
the local candidate via Yarn resolutions (no publish)
|
|
61
176
|
-f, --force overwrite files that already exist
|
|
62
177
|
-h, --help show this help
|
|
63
178
|
|
|
64
|
-
|
|
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
|
+
|
|
186
|
+
theme options:
|
|
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.
|
|
205
|
+
|
|
206
|
+
view options:
|
|
207
|
+
--intent <slot=intent> choose a slot's intent from the view's recipe
|
|
208
|
+
--variant <slot=Variant> choose a slot's variant directly
|
|
209
|
+
-f, --force re-add a view that is already installed
|
|
210
|
+
|
|
211
|
+
feature options:
|
|
212
|
+
--config <json> set the feature's config (a JSON object), validated
|
|
213
|
+
against its registry contract and persisted
|
|
214
|
+
-f, --force re-add a feature that is already installed
|
|
215
|
+
(preserves the existing config unless --config is given)
|
|
216
|
+
|
|
217
|
+
views options:
|
|
218
|
+
--json print the registry (recipes, intents, installed) as JSON
|
|
219
|
+
|
|
220
|
+
features options:
|
|
221
|
+
--json print the feature registry (bundles, installed) as JSON
|
|
222
|
+
|
|
223
|
+
recipes options:
|
|
224
|
+
--json print the whole curated registry (views + features) as JSON
|
|
225
|
+
|
|
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.
|
|
65
239
|
Without a terminal, every required input must be a flag — the command fails fast.
|
|
66
240
|
`
|
|
67
241
|
|
|
@@ -72,10 +246,42 @@ function parseArgs(argv) {
|
|
|
72
246
|
name: null,
|
|
73
247
|
siteName: null,
|
|
74
248
|
backendUrl: null,
|
|
249
|
+
publishableKey: null,
|
|
250
|
+
salesChannelId: null,
|
|
251
|
+
regionId: null,
|
|
75
252
|
preset: null,
|
|
253
|
+
mode: null,
|
|
254
|
+
starter: false,
|
|
76
255
|
brand: null,
|
|
77
256
|
force: false,
|
|
78
257
|
help: false,
|
|
258
|
+
intents: {},
|
|
259
|
+
variants: {},
|
|
260
|
+
json: false,
|
|
261
|
+
packs: null,
|
|
262
|
+
config: null,
|
|
263
|
+
operations: null,
|
|
264
|
+
out: null,
|
|
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,
|
|
79
285
|
}
|
|
80
286
|
const positionals = []
|
|
81
287
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -86,10 +292,94 @@ function parseArgs(argv) {
|
|
|
86
292
|
out.siteName = argv[++i]
|
|
87
293
|
} else if (arg === '--backend-url') {
|
|
88
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]
|
|
89
301
|
} else if (arg === '--preset') {
|
|
90
302
|
out.preset = argv[++i]
|
|
303
|
+
} else if (arg === '--mode') {
|
|
304
|
+
out.mode = argv[++i]
|
|
305
|
+
} else if (arg === '--starter') {
|
|
306
|
+
out.starter = true
|
|
91
307
|
} else if (arg === '--brand') {
|
|
92
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
|
|
333
|
+
} else if (arg === '--packs') {
|
|
334
|
+
out.packs = argv[++i]
|
|
335
|
+
} else if (arg === '--operations') {
|
|
336
|
+
out.operations = argv[++i]
|
|
337
|
+
} else if (arg === '--out') {
|
|
338
|
+
out.out = argv[++i]
|
|
339
|
+
} else if (arg === '--plan') {
|
|
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
|
|
355
|
+
} else if (arg === '--intent') {
|
|
356
|
+
const pair = argv[++i] ?? ''
|
|
357
|
+
const eq = pair.indexOf('=')
|
|
358
|
+
if (eq === -1) {
|
|
359
|
+
console.error(`--intent expects slot=intent (got "${pair}")`)
|
|
360
|
+
process.exitCode = 1
|
|
361
|
+
return null
|
|
362
|
+
}
|
|
363
|
+
out.intents[pair.slice(0, eq)] = pair.slice(eq + 1)
|
|
364
|
+
} else if (arg === '--variant') {
|
|
365
|
+
const pair = argv[++i] ?? ''
|
|
366
|
+
const eq = pair.indexOf('=')
|
|
367
|
+
if (eq === -1) {
|
|
368
|
+
console.error(`--variant expects slot=Variant (got "${pair}")`)
|
|
369
|
+
process.exitCode = 1
|
|
370
|
+
return null
|
|
371
|
+
}
|
|
372
|
+
out.variants[pair.slice(0, eq)] = pair.slice(eq + 1)
|
|
373
|
+
} else if (arg === '--json') {
|
|
374
|
+
out.json = true
|
|
375
|
+
} else if (arg === '--config') {
|
|
376
|
+
const value = argv[++i]
|
|
377
|
+
if (value === undefined) {
|
|
378
|
+
console.error('--config expects a JSON object value.')
|
|
379
|
+
process.exitCode = 1
|
|
380
|
+
return null
|
|
381
|
+
}
|
|
382
|
+
out.config = value
|
|
93
383
|
} else if (arg === '--force' || arg === '-f') {
|
|
94
384
|
out.force = true
|
|
95
385
|
} else if (arg === '--help' || arg === '-h') {
|
|
@@ -103,11 +393,84 @@ function parseArgs(argv) {
|
|
|
103
393
|
}
|
|
104
394
|
}
|
|
105
395
|
out.action = positionals[0] ?? null
|
|
396
|
+
out.positionals = positionals
|
|
106
397
|
if (positionals[1]) out.dir = resolve(positionals[1])
|
|
107
398
|
else if (!out.dir) out.dir = process.cwd()
|
|
108
399
|
return out
|
|
109
400
|
}
|
|
110
401
|
|
|
402
|
+
// Only `create` takes a directory. Every other command runs inside the project
|
|
403
|
+
// and resolves the storefront from the current directory upward. When none is
|
|
404
|
+
// found the command fails before touching the filesystem.
|
|
405
|
+
// Search upward for a pre-manifest install record (the old `.create-storefront.json`).
|
|
406
|
+
// Used only as a fallback signal when no storefront-shaped project is found.
|
|
407
|
+
function findLegacyRoot(startDir) {
|
|
408
|
+
let current = startDir
|
|
409
|
+
while (true) {
|
|
410
|
+
if (existsSync(join(current, '.opencode', LEGACY_RECORD_FILE))) return current
|
|
411
|
+
const parent = dirname(current)
|
|
412
|
+
if (parent === current) return null
|
|
413
|
+
current = parent
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Legacy (pre-manifest) projects are detected and refused with a no-write
|
|
418
|
+
// diagnostic. Automatic migration is held post-MVP (see trace-matrix.md), so
|
|
419
|
+
// this must run before any command function and touch nothing on disk.
|
|
420
|
+
function rejectLegacyProject(target) {
|
|
421
|
+
console.error(`No storefront.config.json found in ${target}, but it looks like a pre-manifest (legacy) storefront.`)
|
|
422
|
+
console.error('Automatic legacy migration is held post-MVP (traced in trace-matrix.md). No files were changed.')
|
|
423
|
+
console.error('Run `storefront create <dir>` to scaffold a new storefront, or add storefront.config.json manually.')
|
|
424
|
+
return 1
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function resolveProject({ quiet = false } = {}) {
|
|
428
|
+
const root = findProjectRoot(process.cwd()) ?? findLegacyRoot(process.cwd())
|
|
429
|
+
if (!root) {
|
|
430
|
+
if (!quiet) {
|
|
431
|
+
console.error('Not inside a storefront project (no storefront.config.json found in this directory or any parent).')
|
|
432
|
+
console.error('Run `storefront create <dir>` to create one, or run this command from inside a storefront.')
|
|
433
|
+
}
|
|
434
|
+
return null
|
|
435
|
+
}
|
|
436
|
+
if (!hasManifest(root) && (looksLikeStorefront(root) || existsSync(join(root, '.opencode', LEGACY_RECORD_FILE)))) {
|
|
437
|
+
if (!quiet) rejectLegacyProject(root)
|
|
438
|
+
return null
|
|
439
|
+
}
|
|
440
|
+
return root
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Read and validate the project manifest, distinguishing a physically absent
|
|
444
|
+
// config (legacy project with no manifest yet) from a present config that parses
|
|
445
|
+
// to a non-object JSON value (`null`, `false`, `0`, `""`, `[]`). A present config
|
|
446
|
+
// is validated against the published shape here, at the shared read boundary, so
|
|
447
|
+
// no caller can mistake an invalid existing config for absence and write around
|
|
448
|
+
// it. Returns { manifest } (object) for a valid config, { manifest: null } for a
|
|
449
|
+
// truly absent one, or { error } with a precise input diagnostic.
|
|
450
|
+
function readManifestOrError(target) {
|
|
451
|
+
if (!hasManifest(target)) return { manifest: null }
|
|
452
|
+
|
|
453
|
+
let manifest
|
|
454
|
+
try {
|
|
455
|
+
manifest = readManifest(target)
|
|
456
|
+
} catch (err) {
|
|
457
|
+
return { error: `storefront.config.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
|
|
461
|
+
return { error: 'storefront.config.json must contain a JSON object.' }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const shape = validateManifest(manifest)
|
|
465
|
+
if (!shape.ok) {
|
|
466
|
+
return {
|
|
467
|
+
error: `storefront.config.json does not satisfy the published schema:\n${shape.errors.map((error) => ` ${error}`).join('\n')}`,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return { manifest }
|
|
472
|
+
}
|
|
473
|
+
|
|
111
474
|
async function copyDir(from, to, force) {
|
|
112
475
|
const entries = await readdir(from, { withFileTypes: true })
|
|
113
476
|
let copied = 0
|
|
@@ -131,26 +494,81 @@ async function prunePath(opencodeDir, relPath) {
|
|
|
131
494
|
await rm(join(opencodeDir, relPath), { recursive: true, force: true })
|
|
132
495
|
}
|
|
133
496
|
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
136
516
|
}
|
|
137
517
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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`',
|
|
145
538
|
}
|
|
146
539
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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}` }
|
|
152
569
|
}
|
|
153
|
-
|
|
570
|
+
await seedOpenSpecConfig(target)
|
|
571
|
+
return { ok: true, version: resolved.version }
|
|
154
572
|
}
|
|
155
573
|
|
|
156
574
|
function pkgVersion() {
|
|
@@ -172,14 +590,82 @@ async function readRecord(opencodeDir) {
|
|
|
172
590
|
return null
|
|
173
591
|
}
|
|
174
592
|
|
|
593
|
+
// The installed package-owned suite digests. Only the package's own file set is
|
|
594
|
+
// hashed, so a user's custom skill/command file under `.opencode/` never shows
|
|
595
|
+
// up as drift or gets stamped into the record.
|
|
596
|
+
async function installedSuiteBom(projectDir) {
|
|
597
|
+
return hashPaths(join(projectDir, '.opencode'), managedSuitePaths(PKG_ROOT))
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function collectLivePinning(projectDir) {
|
|
601
|
+
const direct = readDirectDependencies(projectDir)
|
|
602
|
+
if (!direct.ok) return { ok: false, error: direct.error }
|
|
603
|
+
const resolvedVersions = readResolvedVersions(projectDir, Object.keys(direct.dependencies))
|
|
604
|
+
const lock = readLockfile(projectDir)
|
|
605
|
+
if (!lock.ok) return { ok: false, error: lock.error }
|
|
606
|
+
const toolchain = readToolchainEvidence(projectDir)
|
|
607
|
+
if (!toolchain.ok) return { ok: false, error: toolchain.error }
|
|
608
|
+
const assets = await installedSuiteBom(projectDir)
|
|
609
|
+
return {
|
|
610
|
+
ok: true,
|
|
611
|
+
live: {
|
|
612
|
+
directDependencies: direct.dependencies,
|
|
613
|
+
resolvedVersions,
|
|
614
|
+
lockfile: lock.lockfile,
|
|
615
|
+
toolchain: toolchain.toolchain,
|
|
616
|
+
assets,
|
|
617
|
+
},
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Validate every *precomputable* layer of the incoming record — declared ranges,
|
|
622
|
+
// resolved versions, the lockfile, and the toolchain/linker — BEFORE any
|
|
623
|
+
// structural write. Assets are expected to change when the suite is refreshed,
|
|
624
|
+
// so they are checked after the copy at stamp time (`writeRecord`). Returns
|
|
625
|
+
// `{ ok, errors }`.
|
|
626
|
+
async function validateIncomingContract(projectDir, version) {
|
|
627
|
+
const collected = await collectLivePinning(projectDir)
|
|
628
|
+
if (!collected.ok) return { ok: false, errors: [collected.error] }
|
|
629
|
+
const incoming = buildPinningRecord({
|
|
630
|
+
cliVersion: version,
|
|
631
|
+
directDependencies: collected.live.directDependencies,
|
|
632
|
+
resolvedVersions: collected.live.resolvedVersions,
|
|
633
|
+
lockfile: collected.live.lockfile,
|
|
634
|
+
assets: await hashManagedSuite(PKG_ROOT),
|
|
635
|
+
})
|
|
636
|
+
return validatePinning(incoming, {
|
|
637
|
+
distributedVersion: version,
|
|
638
|
+
live: collected.live,
|
|
639
|
+
includeAssets: false,
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Stamp the install record from the actual, complete project evidence. The
|
|
644
|
+
// record pins the *distributed* package BOM; it is validated against the live
|
|
645
|
+
// installed artifacts before it is written, so a failed copy or an incoherent
|
|
646
|
+
// pin set is reported and never stamped.
|
|
175
647
|
async function writeRecord(projectDir, version) {
|
|
648
|
+
const collected = await collectLivePinning(projectDir)
|
|
649
|
+
if (!collected.ok) return { ok: false, errors: [collected.error] }
|
|
650
|
+
const { directDependencies, resolvedVersions, lockfile } = collected.live
|
|
651
|
+
const assets = await hashManagedSuite(PKG_ROOT)
|
|
652
|
+
const pinning = buildPinningRecord({
|
|
653
|
+
cliVersion: version,
|
|
654
|
+
directDependencies,
|
|
655
|
+
resolvedVersions,
|
|
656
|
+
lockfile,
|
|
657
|
+
assets,
|
|
658
|
+
})
|
|
659
|
+
const check = validatePinning(pinning, { distributedVersion: version, live: collected.live })
|
|
660
|
+
if (!check.ok) return { ok: false, errors: check.errors }
|
|
176
661
|
const opencodeDir = join(projectDir, '.opencode')
|
|
177
662
|
await mkdir(opencodeDir, { recursive: true })
|
|
178
663
|
await writeFile(
|
|
179
664
|
join(opencodeDir, VERSION_RECORD_FILE),
|
|
180
|
-
JSON.stringify({ version, installedAt: new Date().toISOString() }, null, 2) + '\n',
|
|
665
|
+
JSON.stringify({ version, installedAt: new Date().toISOString(), pinning }, null, 2) + '\n',
|
|
181
666
|
'utf8',
|
|
182
667
|
)
|
|
668
|
+
return { ok: true, pinning }
|
|
183
669
|
}
|
|
184
670
|
|
|
185
671
|
async function verifyConsistency() {
|
|
@@ -195,69 +681,115 @@ async function verifyConsistency() {
|
|
|
195
681
|
|
|
196
682
|
async function installSuite(opencodeDir, force) {
|
|
197
683
|
let total = 0
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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 })
|
|
202
694
|
total += await copyDir(from, to, force)
|
|
203
695
|
}
|
|
204
696
|
return total
|
|
205
697
|
}
|
|
206
698
|
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
|
|
764
|
+
// Run Yarn under the pinned Corepack version (`yarn@x.y.z`), so the scaffold
|
|
765
|
+
// and every install use the canonical package manager deterministically rather
|
|
766
|
+
// than whatever "known good release" Corepack would otherwise pick.
|
|
767
|
+
function runYarn(yarnSpec, cmdArgs, cwd) {
|
|
768
|
+
const res = spawnSync('corepack', [yarnSpec, ...cmdArgs], { cwd, stdio: 'inherit', shell: false })
|
|
209
769
|
return res.status ?? 1
|
|
210
770
|
}
|
|
211
771
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
cyan: 210,
|
|
226
|
-
blue: 232,
|
|
227
|
-
indigo: 264,
|
|
228
|
-
violet: 290,
|
|
229
|
-
purple: 310,
|
|
230
|
-
pink: 350,
|
|
231
|
-
slate: 240,
|
|
232
|
-
gray: 240,
|
|
233
|
-
neutral: 0,
|
|
234
|
-
stone: 60,
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function applyBrand(globalsCss, brand) {
|
|
238
|
-
if (!brand) return globalsCss
|
|
239
|
-
const hue = COLOR_HUES[brand]
|
|
240
|
-
if (hue === undefined) {
|
|
241
|
-
console.error(`Unknown brand color: ${brand}`)
|
|
242
|
-
process.exitCode = 1
|
|
243
|
-
return globalsCss
|
|
244
|
-
}
|
|
245
|
-
// Swap the hue channel (third component) of each brand token's oklch value in
|
|
246
|
-
// both modes. Lightness/chroma stay from the preset so the contrast pair rule
|
|
247
|
-
// is preserved; only the hue carries the brand color.
|
|
248
|
-
let css = globalsCss
|
|
249
|
-
for (const key of [...BRAND_TOKEN_KEYS.light, ...BRAND_TOKEN_KEYS.dark]) {
|
|
250
|
-
css = css.replace(new RegExp(`(${key}: oklch\\([^)]*?\\s)([^\\s)]+)(\\))`, 'g'), `$1${hue}$3`)
|
|
251
|
-
}
|
|
252
|
-
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 }
|
|
253
785
|
}
|
|
254
786
|
|
|
255
787
|
async function promptScaffoldInputs(args) {
|
|
256
788
|
const inputs = {
|
|
257
789
|
name: toKebab(args.name ?? '') || null,
|
|
258
790
|
siteName: args.siteName ?? null,
|
|
259
|
-
backendUrl: args.backendUrl ?? null,
|
|
260
791
|
preset: args.preset ?? null,
|
|
792
|
+
mode: args.mode ?? null,
|
|
261
793
|
brand: args.brand ?? null,
|
|
262
794
|
}
|
|
263
795
|
|
|
@@ -265,11 +797,20 @@ async function promptScaffoldInputs(args) {
|
|
|
265
797
|
if (tty) {
|
|
266
798
|
intro('storefront create')
|
|
267
799
|
|
|
800
|
+
// The suggested name comes from the target directory basename, so
|
|
801
|
+
// `storefront create .` or `../shop` still suggest the right thing.
|
|
802
|
+
const suggestedName = toKebab(basename(args.dir ?? '')) || 'my-shop'
|
|
803
|
+
|
|
268
804
|
if (!inputs.name) {
|
|
269
805
|
const value = await text({
|
|
270
806
|
message: 'Storefront name (kebab-case — lib/<name>/):',
|
|
271
|
-
placeholder:
|
|
272
|
-
|
|
807
|
+
placeholder: suggestedName,
|
|
808
|
+
defaultValue: suggestedName,
|
|
809
|
+
// Empty is valid: clack validates the raw value before applying
|
|
810
|
+
// `defaultValue`, so rejecting empty here would block Enter from
|
|
811
|
+
// accepting the suggested name. Only input that normalizes to nothing
|
|
812
|
+
// (e.g. "!!!") is rejected.
|
|
813
|
+
validate: (v) => (!v || toKebab(v).length ? undefined : 'Enter a kebab-case name'),
|
|
273
814
|
})
|
|
274
815
|
if (isCancel(value)) {
|
|
275
816
|
cancel('Operation cancelled.')
|
|
@@ -279,9 +820,11 @@ async function promptScaffoldInputs(args) {
|
|
|
279
820
|
}
|
|
280
821
|
|
|
281
822
|
if (!inputs.siteName) {
|
|
823
|
+
const suggestedSiteName = toTitle(inputs.name)
|
|
282
824
|
const value = await text({
|
|
283
825
|
message: 'Site name:',
|
|
284
|
-
|
|
826
|
+
placeholder: suggestedSiteName,
|
|
827
|
+
defaultValue: suggestedSiteName,
|
|
285
828
|
})
|
|
286
829
|
if (isCancel(value)) {
|
|
287
830
|
cancel('Operation cancelled.')
|
|
@@ -290,18 +833,6 @@ async function promptScaffoldInputs(args) {
|
|
|
290
833
|
inputs.siteName = value
|
|
291
834
|
}
|
|
292
835
|
|
|
293
|
-
if (!inputs.backendUrl) {
|
|
294
|
-
const value = await text({
|
|
295
|
-
message: 'Medusa backend URL:',
|
|
296
|
-
initialValue: DEFAULT_BACKEND_URL,
|
|
297
|
-
})
|
|
298
|
-
if (isCancel(value)) {
|
|
299
|
-
cancel('Operation cancelled.')
|
|
300
|
-
process.exit(0)
|
|
301
|
-
}
|
|
302
|
-
inputs.backendUrl = value
|
|
303
|
-
}
|
|
304
|
-
|
|
305
836
|
if (!inputs.preset) {
|
|
306
837
|
const value = await select({
|
|
307
838
|
message: 'Visual preset:',
|
|
@@ -318,6 +849,23 @@ async function promptScaffoldInputs(args) {
|
|
|
318
849
|
inputs.preset = value
|
|
319
850
|
}
|
|
320
851
|
|
|
852
|
+
if (!inputs.mode) {
|
|
853
|
+
const value = await select({
|
|
854
|
+
message: 'Site color mode:',
|
|
855
|
+
initialValue: DEFAULT_THEME_MODE,
|
|
856
|
+
options: [
|
|
857
|
+
{ value: 'system', label: 'system — follow the visitor’s OS preference' },
|
|
858
|
+
{ value: 'light', label: 'light — always render light' },
|
|
859
|
+
{ value: 'dark', label: 'dark — always render dark' },
|
|
860
|
+
],
|
|
861
|
+
})
|
|
862
|
+
if (isCancel(value)) {
|
|
863
|
+
cancel('Operation cancelled.')
|
|
864
|
+
process.exit(0)
|
|
865
|
+
}
|
|
866
|
+
inputs.mode = value
|
|
867
|
+
}
|
|
868
|
+
|
|
321
869
|
outro('Scaffolding…')
|
|
322
870
|
}
|
|
323
871
|
|
|
@@ -325,19 +873,38 @@ async function promptScaffoldInputs(args) {
|
|
|
325
873
|
if (!inputs.name) missing.push('storefront name (--name <name>)')
|
|
326
874
|
if (!inputs.preset) missing.push('--preset (one of: ' + PRESETS.join(', ') + ')')
|
|
327
875
|
else if (!PRESETS.includes(inputs.preset)) missing.push(`--preset must be one of: ${PRESETS.join(', ')}`)
|
|
876
|
+
if (inputs.mode && !THEME_MODES.includes(inputs.mode)) missing.push(`--mode must be one of: ${THEME_MODES.join(', ')}`)
|
|
328
877
|
|
|
329
878
|
if (missing.length > 0) return { missing }
|
|
330
879
|
return {
|
|
331
880
|
inputs: {
|
|
332
881
|
name: inputs.name,
|
|
333
|
-
siteName: inputs.siteName ?? inputs.name,
|
|
334
|
-
backendUrl: inputs.backendUrl ?? DEFAULT_BACKEND_URL,
|
|
882
|
+
siteName: inputs.siteName ?? toTitle(inputs.name),
|
|
335
883
|
preset: inputs.preset,
|
|
884
|
+
mode: inputs.mode ?? DEFAULT_THEME_MODE,
|
|
336
885
|
brand: inputs.brand,
|
|
337
886
|
},
|
|
338
887
|
}
|
|
339
888
|
}
|
|
340
889
|
|
|
890
|
+
// Every structural write boundary validates the complete desired composition
|
|
891
|
+
// first. On rejection nothing is written: no manifest, no derived file, and no
|
|
892
|
+
// directory change.
|
|
893
|
+
function rejectComposition(errors) {
|
|
894
|
+
console.error('Refusing to write: the desired composition is invalid.')
|
|
895
|
+
for (const err of errors) console.error(` ${err}`)
|
|
896
|
+
return 1
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Print the structured diagnostics the operation engine returns, using the same
|
|
900
|
+
// header every structural write boundary prints so existing consumers keep their
|
|
901
|
+
// wording (E1 task 4.1).
|
|
902
|
+
function rejectDiagnostics(diagnostics) {
|
|
903
|
+
console.error('Refusing to write: the desired composition is invalid.')
|
|
904
|
+
for (const diag of diagnostics) console.error(` ${diag.message}`)
|
|
905
|
+
return 1
|
|
906
|
+
}
|
|
907
|
+
|
|
341
908
|
async function create(args) {
|
|
342
909
|
const target = args.dir
|
|
343
910
|
const { inputs, missing } = await promptScaffoldInputs(args)
|
|
@@ -347,13 +914,96 @@ async function create(args) {
|
|
|
347
914
|
return 1
|
|
348
915
|
}
|
|
349
916
|
|
|
350
|
-
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
|
+
}
|
|
924
|
+
|
|
925
|
+
// The candidate CLI is the structural authority; the generated project pins
|
|
926
|
+
// the exact compatible toolset (and the CLI itself). Declared here so the
|
|
927
|
+
// contract can be validated before the first write.
|
|
928
|
+
const cliVersion = pkgVersion()
|
|
929
|
+
const plan = buildInstallPlan({ cliVersion, packsDir: args.packs ? resolve(args.packs) : null })
|
|
930
|
+
if (plan.missingPacks.length > 0) {
|
|
931
|
+
console.error(`Refusing to create: missing packed local candidate(s) in ${resolve(args.packs)}:`)
|
|
932
|
+
for (const name of plan.missingPacks) console.error(` ${name}`)
|
|
933
|
+
console.error('Pack each @thorprovider/* package with `yarn pack --filename <slug>-<version>.tgz`.')
|
|
934
|
+
return 1
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Integrity guard for the local candidate packs. A `--packs` directory can be
|
|
938
|
+
// stale: a tarball packed before a public export was added still resolves for
|
|
939
|
+
// the same exact version, so the generated project imports a symbol the
|
|
940
|
+
// packed dist does not export. Verify each pack declares the expected
|
|
941
|
+
// name+version (Tier 1) and that the packed runtime actually exports every
|
|
942
|
+
// value the generated host imports from `@thorprovider/runtime/commerce`
|
|
943
|
+
// (Tier 2) before the first write.
|
|
944
|
+
if (args.packs) {
|
|
945
|
+
const packsDir = resolve(args.packs)
|
|
946
|
+
const localPacks = { ...CANDIDATE_TOOLSET.local, '@thorprovider/storefront': cliVersion }
|
|
947
|
+
for (const [name, version] of Object.entries(localPacks)) {
|
|
948
|
+
const guard = verifyPackManifest({ tgzPath: findPack(packsDir, name, version), name, version })
|
|
949
|
+
if (!guard.ok) {
|
|
950
|
+
console.error(`Refusing to create: ${guard.message} (${guard.code})`)
|
|
951
|
+
return 1
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const commerceTemplate = join(PKG_ROOT, 'assets', 'templates', 'app', 'lib', '__STOREFRONT__', 'commerce.ts')
|
|
955
|
+
const requiredExports = parseRuntimeCommerceImports(readFileSync(commerceTemplate, 'utf8'))
|
|
956
|
+
const runtimeName = '@thorprovider/runtime'
|
|
957
|
+
const runtimeGuard = verifyPackExports({
|
|
958
|
+
tgzPath: findPack(packsDir, runtimeName, CANDIDATE_TOOLSET.local[runtimeName]),
|
|
959
|
+
innerPath: 'package/dist/commerce/index.mjs',
|
|
960
|
+
requiredExports,
|
|
961
|
+
})
|
|
962
|
+
if (!runtimeGuard.ok) {
|
|
963
|
+
console.error(`Refusing to create: ${runtimeGuard.message} (${runtimeGuard.code})`)
|
|
964
|
+
return 1
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Validate the complete desired composition before the first filesystem
|
|
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
|
|
973
|
+
// same preflight as every other write boundary — and it must run before any
|
|
974
|
+
// mkdir, external scaffold, or write so rejected input leaves no trace.
|
|
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
|
+
})
|
|
984
|
+
const preflight = preflightManifest(manifest)
|
|
985
|
+
if (!preflight.ok) return rejectComposition(preflight.errors)
|
|
986
|
+
|
|
987
|
+
// Validate the incoming pinning contract before the first write too. The
|
|
988
|
+
// generated project declares the exact compatible toolset and pins the CLI
|
|
989
|
+
// itself; the intended linker is the one `create` writes below.
|
|
990
|
+
const declared = validateDeclaredContract({
|
|
991
|
+
directDependencies: plan.dependencies,
|
|
992
|
+
cliVersion,
|
|
993
|
+
distributedVersion: cliVersion,
|
|
994
|
+
toolchain: { linker: 'node-modules', nodeVersion: process.versions.node, yarnVersion: null },
|
|
995
|
+
})
|
|
996
|
+
if (!declared.ok) {
|
|
997
|
+
console.error('Refusing to create: the declared pinning contract is invalid.')
|
|
998
|
+
for (const err of declared.errors) console.error(` ${err}`)
|
|
999
|
+
return 1
|
|
1000
|
+
}
|
|
351
1001
|
|
|
352
1002
|
console.log('storefront create — deterministic scaffold')
|
|
353
1003
|
console.log(` name: ${name}`)
|
|
354
1004
|
console.log(` site name: ${siteName}`)
|
|
355
|
-
console.log(` backend: ${backendUrl}`)
|
|
356
1005
|
console.log(` preset: ${preset}`)
|
|
1006
|
+
console.log(` mode: ${mode}`)
|
|
357
1007
|
if (brand) console.log(` brand: ${brand}`)
|
|
358
1008
|
console.log(` target: ${target}`)
|
|
359
1009
|
|
|
@@ -365,7 +1015,7 @@ async function create(args) {
|
|
|
365
1015
|
const libDir = join(target, 'lib', name)
|
|
366
1016
|
if (existsSync(libDir)) {
|
|
367
1017
|
console.error(`lib/${name}/ already exists — refusing to overwrite.`)
|
|
368
|
-
console.error('
|
|
1018
|
+
console.error('This target already holds a storefront; extend it through the storefront CLI (see AGENTS.md).')
|
|
369
1019
|
return 1
|
|
370
1020
|
}
|
|
371
1021
|
if (!(await verifyConsistency())) return 1
|
|
@@ -381,7 +1031,11 @@ async function create(args) {
|
|
|
381
1031
|
await cp(opencodeDir, stash, { recursive: true })
|
|
382
1032
|
await rm(opencodeDir, { recursive: true, force: true })
|
|
383
1033
|
}
|
|
384
|
-
|
|
1034
|
+
// --skip-install: the first install is ours to run, under the .yarnrc.yml
|
|
1035
|
+
// written in step 3, so network hardening applies to every install. The
|
|
1036
|
+
// pinned package manager is forced for the bootstrap too (Corepack), so the
|
|
1037
|
+
// whole scaffold runs under the canonical Yarn, not a floating release.
|
|
1038
|
+
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
1039
|
if (hadOpenCode) {
|
|
386
1040
|
await cp(stash, opencodeDir, { recursive: true })
|
|
387
1041
|
await rm(stash, { recursive: true, force: true })
|
|
@@ -392,10 +1046,42 @@ async function create(args) {
|
|
|
392
1046
|
}
|
|
393
1047
|
}
|
|
394
1048
|
|
|
395
|
-
// 3. shadcn
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1049
|
+
// 3. Yarn config — before ANY install (shadcn included): node-modules linker,
|
|
1050
|
+
// retries and low concurrency to survive unreliable networks. With the
|
|
1051
|
+
// global cache warm, repeat scaffolds only need tiny metadata requests —
|
|
1052
|
+
// or none at all via YARN_ENABLE_OFFLINE_MODE=1. No package-manager safety
|
|
1053
|
+
// gate is suppressed: the pinned toolset versions are already published
|
|
1054
|
+
// past Yarn's `npmMinimalAgeGate`, so no allowlist is needed.
|
|
1055
|
+
await writeFile(
|
|
1056
|
+
join(target, '.yarnrc.yml'),
|
|
1057
|
+
[
|
|
1058
|
+
'nodeLinker: node-modules',
|
|
1059
|
+
'httpRetry: 10',
|
|
1060
|
+
'networkConcurrency: 8',
|
|
1061
|
+
].join('\n') + '\n',
|
|
1062
|
+
'utf8',
|
|
1063
|
+
)
|
|
1064
|
+
await rm(join(target, '.pnp.cjs'), { force: true })
|
|
1065
|
+
|
|
1066
|
+
// 3b. Pin the package manager (Corepack reads `packageManager`) and the local
|
|
1067
|
+
// candidate. Yarn `resolutions` map each local @thorprovider/* package to
|
|
1068
|
+
// its packed tarball while the declared dependency ranges stay honest
|
|
1069
|
+
// semver. Written before any install so the whole tree resolves to the
|
|
1070
|
+
// local candidate (no publish, no workspace import) under the pinned Yarn.
|
|
1071
|
+
{
|
|
1072
|
+
const pkgPath = join(target, 'package.json')
|
|
1073
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
1074
|
+
pkg.packageManager = plan.packageManager
|
|
1075
|
+
if (Object.keys(plan.resolutions).length > 0) {
|
|
1076
|
+
pkg.resolutions = { ...(pkg.resolutions ?? {}), ...plan.resolutions }
|
|
1077
|
+
}
|
|
1078
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// 4. shadcn base
|
|
1082
|
+
const shadcnStatus = runYarn(
|
|
1083
|
+
plan.packageManager,
|
|
1084
|
+
['dlx', `shadcn@${plan.scaffolder.shadcn}`, 'init', '--template', 'next', '--base', 'base', '--preset', preset, '--no-monorepo', '--no-rtl', '--yes'],
|
|
399
1085
|
target,
|
|
400
1086
|
)
|
|
401
1087
|
if (shadcnStatus !== 0) {
|
|
@@ -403,51 +1089,233 @@ async function create(args) {
|
|
|
403
1089
|
return shadcnStatus
|
|
404
1090
|
}
|
|
405
1091
|
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
1092
|
+
// 4b. Pin the shadcn CLI exactly. `shadcn init` adds itself with a caret; a
|
|
1093
|
+
// caret would re-resolve to the newest (possibly age-gated) release on the
|
|
1094
|
+
// final install, so write the exact scaffolded version first.
|
|
1095
|
+
{
|
|
1096
|
+
const pkgPath = join(target, 'package.json')
|
|
1097
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
1098
|
+
for (const field of ['dependencies', 'devDependencies']) {
|
|
1099
|
+
if (pkg[field] && pkg[field].shadcn) pkg[field].shadcn = plan.scaffolder.shadcn
|
|
1100
|
+
}
|
|
1101
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
|
|
1102
|
+
}
|
|
409
1103
|
|
|
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)
|
|
1104
|
+
// 5. Dependencies — every declared dep at its exact compatible version,
|
|
1105
|
+
// including the exact candidate CLI.
|
|
1106
|
+
const installDeps = Object.entries(plan.dependencies).map(([dep, version]) => `${dep}@${version}`)
|
|
1107
|
+
const addStatus = runYarn(plan.packageManager, ['add', ...installDeps], target)
|
|
422
1108
|
if (addStatus !== 0) {
|
|
423
1109
|
console.error('yarn add failed.')
|
|
424
1110
|
return addStatus
|
|
425
1111
|
}
|
|
426
1112
|
|
|
427
|
-
// 6. Template copy + placeholder substitution
|
|
428
|
-
|
|
1113
|
+
// 6. Template copy + placeholder substitution, then reconcile to the manifest.
|
|
1114
|
+
// The manifest was built and validated before any write above. Passing its
|
|
1115
|
+
// view entries means the fresh scaffold resolves explicit slot selections
|
|
1116
|
+
// through the same pipeline a later reconcile uses.
|
|
1117
|
+
await renderScaffold({ targetDir: target, name, siteName, mode, views: manifest.views, features: manifest.features })
|
|
1118
|
+
await writeManifest(target, manifest)
|
|
1119
|
+
const result = await reconcile(target, manifest)
|
|
1120
|
+
if (!result.ok) return rejectComposition(result.errors)
|
|
429
1121
|
|
|
430
1122
|
// 7. .env.local + theme
|
|
431
|
-
|
|
432
|
-
|
|
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)
|
|
433
1131
|
|
|
434
1132
|
const globalsPath = join(target, 'app', 'globals.css')
|
|
435
1133
|
if (existsSync(globalsPath)) {
|
|
436
1134
|
const css = readFileSync(globalsPath, 'utf8')
|
|
437
|
-
|
|
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')
|
|
438
1141
|
}
|
|
439
1142
|
|
|
440
1143
|
// 8. Suite install + version record
|
|
441
1144
|
const opencodeDir = join(target, '.opencode')
|
|
442
1145
|
const total = await installSuite(opencodeDir, args.force)
|
|
443
|
-
await writeRecord(target,
|
|
1146
|
+
const record = await writeRecord(target, cliVersion)
|
|
1147
|
+
if (!record.ok) {
|
|
1148
|
+
console.error('Refusing to stamp the install record: the pinning contract is not complete.')
|
|
1149
|
+
for (const err of record.errors) console.error(` ${err}`)
|
|
1150
|
+
return 1
|
|
1151
|
+
}
|
|
444
1152
|
console.log(`installed ${total} file(s) and recorded the version`)
|
|
445
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
|
+
|
|
446
1167
|
console.log('')
|
|
447
1168
|
console.log('Done. Next steps:')
|
|
448
1169
|
console.log(` cd ${relative(process.cwd(), target) || '.'}`)
|
|
449
1170
|
console.log(' yarn dev')
|
|
450
|
-
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.')
|
|
451
1319
|
return 0
|
|
452
1320
|
}
|
|
453
1321
|
|
|
@@ -466,18 +1334,105 @@ async function update(args) {
|
|
|
466
1334
|
return 1
|
|
467
1335
|
}
|
|
468
1336
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1337
|
+
// Gate the existing composition before any install/prune/stamp write.
|
|
1338
|
+
const { manifest, error } = readManifestOrError(target)
|
|
1339
|
+
if (error) {
|
|
1340
|
+
console.error(`Refusing to update: ${error}`)
|
|
1341
|
+
return 1
|
|
1342
|
+
}
|
|
1343
|
+
if (manifest) {
|
|
1344
|
+
const preflight = preflightManifest(manifest)
|
|
1345
|
+
if (!preflight.ok) return rejectComposition(preflight.errors)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Validate every precomputable layer of the incoming pinning contract BEFORE
|
|
1349
|
+
// any write (declared ranges, resolved versions, lockfile, toolchain/linker),
|
|
1350
|
+
// so a bad range, a missing installed version, a CLI mismatch, a missing lock,
|
|
1351
|
+
// or a wrong linker leaves the project intact. Assets are validated after the
|
|
1352
|
+
// suite refresh at stamp time.
|
|
1353
|
+
const version = pkgVersion()
|
|
1354
|
+
const incoming = await validateIncomingContract(target, version)
|
|
1355
|
+
if (!incoming.ok) {
|
|
1356
|
+
console.error('Refusing to update: the incoming pinning contract is invalid.')
|
|
1357
|
+
for (const err of incoming.errors) console.error(` ${err}`)
|
|
1358
|
+
return 1
|
|
1359
|
+
}
|
|
1360
|
+
|
|
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.
|
|
1377
|
+
const total = await installSuite(opencodeDir, true)
|
|
1378
|
+
const { retired, preserved } = recordedAssets
|
|
1379
|
+
? await retireObsoleteOwnedFiles(opencodeDir, recordedAssets)
|
|
1380
|
+
: { retired: [], preserved: [] }
|
|
1381
|
+
const prunedCommands = await pruneLegacySuitePaths(opencodeDir)
|
|
1382
|
+
if (manifest) {
|
|
1383
|
+
const result = await reconcile(target, manifest)
|
|
1384
|
+
if (!result.ok) return rejectComposition(result.errors)
|
|
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)
|
|
1390
|
+
// Stamp only a complete, actually-verified record. A failed copy or an
|
|
1391
|
+
// incoherent pin set is reported and never stamped as a clean version.
|
|
1392
|
+
const record = await writeRecord(target, version)
|
|
1393
|
+
if (!record.ok) {
|
|
1394
|
+
console.error('Refusing to stamp the install record: the pinning contract is not complete.')
|
|
1395
|
+
for (const err of record.errors) console.error(` ${err}`)
|
|
1396
|
+
return 1
|
|
1397
|
+
}
|
|
1398
|
+
const pinning = record.pinning
|
|
472
1399
|
await rm(join(opencodeDir, LEGACY_RECORD_FILE), { force: true })
|
|
473
1400
|
|
|
1401
|
+
const remainingSkills = await remainingNonSuiteSkills(opencodeDir)
|
|
1402
|
+
|
|
474
1403
|
console.log(`Refreshed install in ${target}:`)
|
|
475
1404
|
console.log(` installed ${total} file(s)`)
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if (
|
|
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
|
+
}
|
|
1419
|
+
console.log(` recorded version ${version} (${Object.keys(pinning.assets).length} managed asset(s))`)
|
|
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.')
|
|
481
1436
|
return 0
|
|
482
1437
|
}
|
|
483
1438
|
|
|
@@ -489,25 +1444,1252 @@ async function doctor(args) {
|
|
|
489
1444
|
return 1
|
|
490
1445
|
}
|
|
491
1446
|
|
|
1447
|
+
let problems = 0
|
|
1448
|
+
|
|
1449
|
+
const { manifest, error } = readManifestOrError(target)
|
|
1450
|
+
if (error) {
|
|
1451
|
+
console.log(` composition: invalid — ${error}`)
|
|
1452
|
+
return 1
|
|
1453
|
+
}
|
|
1454
|
+
if (!manifest) {
|
|
1455
|
+
console.log(' composition: no storefront.config.json found (run "storefront sync" to derive one)')
|
|
1456
|
+
} else {
|
|
1457
|
+
// Dry-run reconcile reports only derived files that diverge; user-owned
|
|
1458
|
+
// files are never drift, so they are excluded by construction.
|
|
1459
|
+
const drift = await reconcile(target, manifest, { dryRun: true })
|
|
1460
|
+
if (!drift.ok) {
|
|
1461
|
+
console.log(' composition: invalid — the manifest is not a supported composition:')
|
|
1462
|
+
for (const err of drift.errors) console.log(` ${err}`)
|
|
1463
|
+
console.log(' status: fix storefront.config.json before running "storefront sync"')
|
|
1464
|
+
return 1
|
|
1465
|
+
}
|
|
1466
|
+
if (drift.changed.length === 0) {
|
|
1467
|
+
console.log(' composition: derived files match the manifest')
|
|
1468
|
+
} else {
|
|
1469
|
+
problems++
|
|
1470
|
+
console.log(` composition: ${drift.changed.length} derived file(s) drifted from the manifest:`)
|
|
1471
|
+
for (const rel of drift.changed) console.log(` ${rel}`)
|
|
1472
|
+
console.log(' status: run "storefront sync" to reconcile')
|
|
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
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
492
1488
|
const opencodeDir = join(target, '.opencode')
|
|
493
1489
|
const record = await readRecord(opencodeDir)
|
|
494
1490
|
if (!record) {
|
|
495
|
-
console.log('
|
|
496
|
-
console.log('
|
|
497
|
-
return 0
|
|
1491
|
+
console.log(' suite: no version record found in .opencode/')
|
|
1492
|
+
console.log(' status: run "storefront update" to install and record the suite')
|
|
1493
|
+
return problems === 0 ? 0 : 1
|
|
498
1494
|
}
|
|
499
1495
|
|
|
500
1496
|
const version = pkgVersion()
|
|
501
1497
|
console.log(` recorded version: ${record.version} (installed ${record.installedAt})`)
|
|
502
1498
|
console.log(` package version: ${version}`)
|
|
503
1499
|
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1500
|
+
const packageAssets = await hashManagedSuite(PKG_ROOT)
|
|
1501
|
+
console.log(` distributed assets: ${Object.keys(packageAssets).length} file(s)`)
|
|
1502
|
+
|
|
1503
|
+
// Truthful version: a record that claims a version the installed artifacts
|
|
1504
|
+
// do not match is a mismatch, not a clean stamp.
|
|
1505
|
+
if (record.version !== version) {
|
|
1506
|
+
problems++
|
|
1507
|
+
console.log(` version mismatch: record says "${record.version}" but the package is "${version}"`)
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// Compare the record against the live project evidence, not against itself.
|
|
1511
|
+
const collected = await collectLivePinning(target)
|
|
1512
|
+
if (!collected.ok) {
|
|
1513
|
+
problems++
|
|
1514
|
+
console.log(` pinning: cannot read the live project evidence — ${collected.error}`)
|
|
1515
|
+
} else {
|
|
1516
|
+
const tc = collected.live.toolchain
|
|
1517
|
+
console.log(` toolchain (observed): linker=${tc.linker} node=${tc.nodeVersion} yarn=${tc.yarnVersion}`)
|
|
1518
|
+
const pinning = validatePinning(record.pinning, {
|
|
1519
|
+
distributedVersion: version,
|
|
1520
|
+
live: collected.live,
|
|
1521
|
+
})
|
|
1522
|
+
if (!pinning.ok) {
|
|
1523
|
+
problems++
|
|
1524
|
+
console.log(' pinning: the compatibility/pinning contract does not match the live project:')
|
|
1525
|
+
for (const err of pinning.errors) console.log(` ${err}`)
|
|
1526
|
+
} else {
|
|
1527
|
+
console.log(' pinning: declared ranges, resolved versions, lockfile, and asset digests agree')
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
const installedAssets = await installedSuiteBom(target)
|
|
1532
|
+
const suiteMatches =
|
|
1533
|
+
Object.keys(installedAssets).length === Object.keys(packageAssets).length &&
|
|
1534
|
+
Object.entries(packageAssets).every(([rel, digest]) => installedAssets[rel] === digest)
|
|
1535
|
+
console.log(` installed suite matches package: ${suiteMatches ? 'yes' : 'no'}`)
|
|
1536
|
+
if (!suiteMatches) {
|
|
1537
|
+
problems++
|
|
509
1538
|
console.log(' status: update available — run "storefront update"')
|
|
510
1539
|
}
|
|
1540
|
+
|
|
1541
|
+
return problems === 0 ? 0 : 1
|
|
1542
|
+
}
|
|
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.
|
|
1714
|
+
async function theme(args) {
|
|
1715
|
+
const target = args.dir
|
|
1716
|
+
if (!existsSync(target)) {
|
|
1717
|
+
console.error(`Target directory does not exist: ${target}`)
|
|
1718
|
+
return 1
|
|
1719
|
+
}
|
|
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.')
|
|
1725
|
+
return 1
|
|
1726
|
+
}
|
|
1727
|
+
const globalsPath = join(target, 'app', 'globals.css')
|
|
1728
|
+
const globals = existsSync(globalsPath) ? readFileSync(globalsPath, 'utf8') : ''
|
|
1729
|
+
const layout = readFileSync(layoutPath, 'utf8')
|
|
1730
|
+
|
|
1731
|
+
// Gate the existing composition before any write (same contract as the other
|
|
1732
|
+
// scoped commands): never stamp an unsupported or invalid composition.
|
|
1733
|
+
const { manifest, error } = readManifestOrError(target)
|
|
1734
|
+
if (error) {
|
|
1735
|
+
console.error(`Refusing to change theme: ${error}`)
|
|
1736
|
+
return 1
|
|
1737
|
+
}
|
|
1738
|
+
if (manifest) {
|
|
1739
|
+
const preflight = preflightManifest(manifest)
|
|
1740
|
+
if (!preflight.ok) return rejectComposition(preflight.errors)
|
|
1741
|
+
}
|
|
1742
|
+
|
|
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]
|
|
1831
|
+
}
|
|
1832
|
+
|
|
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 }
|
|
1866
|
+
const nextPreflight = preflightManifest(manifest)
|
|
1867
|
+
if (!nextPreflight.ok) return rejectComposition(nextPreflight.errors)
|
|
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) {
|
|
1873
|
+
const result = await reconcile(target, manifest)
|
|
1874
|
+
if (!result.ok) return rejectComposition(result.errors)
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
return themeReport(args, { status: 'changed', changes, diagnostics: planned.diagnostics, values: planned.values })
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
async function sync(args) {
|
|
1881
|
+
const target = args.dir
|
|
1882
|
+
const { manifest, error } = readManifestOrError(target)
|
|
1883
|
+
if (error) {
|
|
1884
|
+
console.error(`Refusing to sync: ${error}`)
|
|
1885
|
+
return 1
|
|
1886
|
+
}
|
|
1887
|
+
if (!manifest) {
|
|
1888
|
+
// resolveProject already refuses legacy projects before sync runs; this is
|
|
1889
|
+
// a defensive guard so no code path can derive or migrate one.
|
|
1890
|
+
return rejectLegacyProject(target)
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Validate the complete desired composition before touching any derived file.
|
|
1894
|
+
const preflight = preflightManifest(manifest)
|
|
1895
|
+
if (!preflight.ok) return rejectComposition(preflight.errors)
|
|
1896
|
+
|
|
1897
|
+
const result = await reconcile(target, manifest)
|
|
1898
|
+
if (!result.ok) return rejectComposition(result.errors)
|
|
1899
|
+
if (result.changed.length === 0) {
|
|
1900
|
+
console.log('No drift detected — the derived files match the manifest.')
|
|
1901
|
+
return 0
|
|
1902
|
+
}
|
|
1903
|
+
console.log(`Repaired ${result.changed.length} derived file(s) drifted from the manifest:`)
|
|
1904
|
+
for (const rel of result.changed) console.log(` ${rel}`)
|
|
1905
|
+
return 0
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Add or remove a view through the shared operation engine. `view add|remove`
|
|
1909
|
+
// is a thin alias over `view.add`/`view.remove`, so it produces the same
|
|
1910
|
+
// manifest, derived files, and diagnostics as planning and applying the
|
|
1911
|
+
// equivalent operation (E1 task 4.1 / Decision 17).
|
|
1912
|
+
async function view(args) {
|
|
1913
|
+
const target = args.dir
|
|
1914
|
+
const sub = args.positionals[1]
|
|
1915
|
+
const name = args.positionals[2]
|
|
1916
|
+
|
|
1917
|
+
if (sub !== 'add' && sub !== 'remove') {
|
|
1918
|
+
console.error('Usage: storefront view <add|remove> <name> [--intent slot=intent] [--variant slot=Variant]')
|
|
1919
|
+
return 1
|
|
1920
|
+
}
|
|
1921
|
+
if (!name) {
|
|
1922
|
+
console.error(`storefront view ${sub} requires a view name.`)
|
|
1923
|
+
return 1
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
const { manifest, error } = readManifestOrError(target)
|
|
1927
|
+
if (error) {
|
|
1928
|
+
console.error(`Refusing to modify views: ${error}`)
|
|
1929
|
+
return 1
|
|
1930
|
+
}
|
|
1931
|
+
if (!manifest) {
|
|
1932
|
+
console.error(`No storefront.config.json found in ${target}. Run \`storefront sync\` first.`)
|
|
1933
|
+
return 1
|
|
1934
|
+
}
|
|
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
|
+
|
|
1975
|
+
const present = manifest.views.some((v) => v.name === name)
|
|
1976
|
+
const operations =
|
|
1977
|
+
sub === 'add'
|
|
1978
|
+
? [{ op: 'view.add', view: name, intents: args.intents, variants: args.variants }]
|
|
1979
|
+
: [{ op: 'view.remove', view: name }]
|
|
1980
|
+
const result = await executeOperations(target, manifest, operations, { force: args.force })
|
|
1981
|
+
if (!result.ok) {
|
|
1982
|
+
// `--json` emits the shared versioned envelope (same diagnostics the
|
|
1983
|
+
// plan/apply engine returns); the default human output is unchanged
|
|
1984
|
+
// (E1 task 4.16 / Decision 17).
|
|
1985
|
+
if (args.json) {
|
|
1986
|
+
printEnvelope(resultEnvelope('view', false, 1, result.diagnostics))
|
|
1987
|
+
return 1
|
|
1988
|
+
}
|
|
1989
|
+
return rejectDiagnostics(result.diagnostics)
|
|
1990
|
+
}
|
|
1991
|
+
if (args.json) {
|
|
1992
|
+
printEnvelope(
|
|
1993
|
+
resultEnvelope('view', true, 0, [], {
|
|
1994
|
+
action: sub,
|
|
1995
|
+
view: name,
|
|
1996
|
+
changed: result.changed,
|
|
1997
|
+
preserved: result.preserved,
|
|
1998
|
+
}),
|
|
1999
|
+
)
|
|
2000
|
+
return 0
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
if (sub === 'add') {
|
|
2004
|
+
console.log(`${present ? 'Re-added' : 'Added'} view "${name}".`)
|
|
2005
|
+
} else {
|
|
2006
|
+
console.log(`Removed view "${name}".`)
|
|
2007
|
+
for (const leftover of result.preserved) {
|
|
2008
|
+
console.log(`Preserved user-owned file: ${leftover} (remove it manually if no longer needed).`)
|
|
2009
|
+
}
|
|
2010
|
+
if (result.preserved.length > 0) {
|
|
2011
|
+
console.log(` ${result.preserved.length} user-owned file(s) left in place.`)
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
if (result.changed.length > 0) console.log(` reconciled ${result.changed.length} derived file(s).`)
|
|
2015
|
+
return 0
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// Add or remove a feature (a capability bundle) through the shared operation
|
|
2019
|
+
// engine. `feature add|remove` is a thin alias over `feature.add`/`feature.remove`
|
|
2020
|
+
// and preserves every flag (`--config`, `--force`), so it produces the same
|
|
2021
|
+
// manifest, derived files, and diagnostics as the plan/apply equivalent
|
|
2022
|
+
// (E1 task 4.1 / Decision 17).
|
|
2023
|
+
async function feature(args) {
|
|
2024
|
+
const target = args.dir
|
|
2025
|
+
const sub = args.positionals[1]
|
|
2026
|
+
const name = args.positionals[2]
|
|
2027
|
+
if (sub !== 'add' && sub !== 'remove') {
|
|
2028
|
+
console.error('Usage: storefront feature <add|remove> <name> [-f]')
|
|
2029
|
+
return 1
|
|
2030
|
+
}
|
|
2031
|
+
if (!name) {
|
|
2032
|
+
console.error(`storefront feature ${sub} requires a feature name.`)
|
|
2033
|
+
return 1
|
|
2034
|
+
}
|
|
2035
|
+
const { manifest, error } = readManifestOrError(target)
|
|
2036
|
+
if (error) {
|
|
2037
|
+
console.error(`Refusing to modify features: ${error}`)
|
|
2038
|
+
return 1
|
|
2039
|
+
}
|
|
2040
|
+
if (!manifest) {
|
|
2041
|
+
console.error(`No storefront.config.json found in ${target}. Run \`storefront sync\` first.`)
|
|
2042
|
+
return 1
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
const present = manifest.features.some((f) => f.name === name)
|
|
2046
|
+
let operations
|
|
2047
|
+
if (sub === 'add') {
|
|
2048
|
+
let config
|
|
2049
|
+
if (args.config !== null && args.config !== undefined) {
|
|
2050
|
+
try {
|
|
2051
|
+
config = JSON.parse(args.config)
|
|
2052
|
+
} catch (err) {
|
|
2053
|
+
console.error(
|
|
2054
|
+
`--config expects a JSON object (${err instanceof Error ? err.message : String(err)}).`
|
|
2055
|
+
)
|
|
2056
|
+
return 1
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
operations = [
|
|
2060
|
+
{ op: 'feature.add', feature: name, ...(config === undefined ? {} : { config }) },
|
|
2061
|
+
]
|
|
2062
|
+
} else {
|
|
2063
|
+
operations = [{ op: 'feature.remove', feature: name }]
|
|
2064
|
+
}
|
|
2065
|
+
const result = await executeOperations(target, manifest, operations, { force: args.force })
|
|
2066
|
+
if (!result.ok) {
|
|
2067
|
+
// `--json` emits the shared versioned envelope (same diagnostics the
|
|
2068
|
+
// plan/apply engine returns); the default human output is unchanged
|
|
2069
|
+
// (E1 task 4.16 / Decision 17).
|
|
2070
|
+
if (args.json) {
|
|
2071
|
+
printEnvelope(resultEnvelope('feature', false, 1, result.diagnostics))
|
|
2072
|
+
return 1
|
|
2073
|
+
}
|
|
2074
|
+
return rejectDiagnostics(result.diagnostics)
|
|
2075
|
+
}
|
|
2076
|
+
if (args.json) {
|
|
2077
|
+
printEnvelope(
|
|
2078
|
+
resultEnvelope('feature', true, 0, [], {
|
|
2079
|
+
action: sub,
|
|
2080
|
+
feature: name,
|
|
2081
|
+
changed: result.changed,
|
|
2082
|
+
preserved: result.preserved,
|
|
2083
|
+
}),
|
|
2084
|
+
)
|
|
2085
|
+
return 0
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
if (sub === 'add') {
|
|
2089
|
+
console.log(`${present ? 'Re-added' : 'Added'} feature "${name}".`)
|
|
2090
|
+
} else {
|
|
2091
|
+
console.log(`Removed feature "${name}".`)
|
|
2092
|
+
for (const leftover of result.preserved) {
|
|
2093
|
+
console.log(`Preserved customized file: ${leftover} (remove it manually if no longer needed).`)
|
|
2094
|
+
}
|
|
2095
|
+
if (result.preserved.length > 0) console.log(` ${result.preserved.length} file(s) left in place.`)
|
|
2096
|
+
}
|
|
2097
|
+
if (result.changed.length > 0) console.log(` reconciled ${result.changed.length} derived file(s).`)
|
|
2098
|
+
return 0
|
|
2099
|
+
}
|
|
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
|
+
|
|
2379
|
+
// Build a diagnostic for the result/error envelope. `path`, when present, is a
|
|
2380
|
+
// project-root-relative POSIX file path; operation-field diagnostics use a
|
|
2381
|
+
// semantic locator instead (e.g. `operations[0].surprise`), never a filesystem
|
|
2382
|
+
// path (E1 task 4.11).
|
|
2383
|
+
function envelopeDiagnostic(code, message, extra = {}) {
|
|
2384
|
+
return { code, severity: 'error', message, ...extra }
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// Print a versioned result/error envelope. Errors are always emitted as the
|
|
2388
|
+
// envelope so a machine caller can read the stable code and exit behavior.
|
|
2389
|
+
function printEnvelope(envelope) {
|
|
2390
|
+
console.log(JSON.stringify(envelope, null, 2))
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// Read-only, deterministic report of the versioned catalog and the current
|
|
2394
|
+
// composition. Writes nothing (E1 task 4.1 foundation; tasks 4.2/4.11 harden
|
|
2395
|
+
// the catalog/capability surface and the result envelope).
|
|
2396
|
+
function inspect(args) {
|
|
2397
|
+
const target = args.dir
|
|
2398
|
+
let manifest = null
|
|
2399
|
+
if (hasManifest(target)) {
|
|
2400
|
+
const read = readManifestOrError(target)
|
|
2401
|
+
if (read.error) {
|
|
2402
|
+
printEnvelope(
|
|
2403
|
+
resultEnvelope('inspect', false, 1, [
|
|
2404
|
+
envelopeDiagnostic('E_INVALID_COMPOSITION', read.error, {
|
|
2405
|
+
path: 'storefront.config.json',
|
|
2406
|
+
}),
|
|
2407
|
+
]),
|
|
2408
|
+
)
|
|
2409
|
+
return 1
|
|
2410
|
+
}
|
|
2411
|
+
manifest = read.manifest
|
|
2412
|
+
}
|
|
2413
|
+
const catalog = loadCatalog()
|
|
2414
|
+
const result = {
|
|
2415
|
+
catalogVersion: CATALOG_VERSION,
|
|
2416
|
+
slots: catalog.slots ?? {},
|
|
2417
|
+
capabilities: catalog.capabilities ?? {},
|
|
2418
|
+
composition: {
|
|
2419
|
+
views: manifest?.views ?? [],
|
|
2420
|
+
features: manifest?.features ?? [],
|
|
2421
|
+
},
|
|
2422
|
+
dataBoundSections: collectDataBoundInstances(manifest, sectionIsDataBound),
|
|
2423
|
+
}
|
|
2424
|
+
const envelope = resultEnvelope('inspect', true, 0, [], result)
|
|
2425
|
+
if (args.json) {
|
|
2426
|
+
printEnvelope(envelope)
|
|
2427
|
+
return 0
|
|
2428
|
+
}
|
|
2429
|
+
console.log(`Storefront inspect (${target}):`)
|
|
2430
|
+
console.log(` catalog version: ${result.catalogVersion}`)
|
|
2431
|
+
console.log(` slots: ${Object.keys(result.slots).length}`)
|
|
2432
|
+
console.log(` views: ${result.composition.views.map((v) => v.name).join(', ') || '(none)'}`)
|
|
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
|
+
)
|
|
2437
|
+
return 0
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// Plan a whole intended state from an operations envelope. Validates every
|
|
2441
|
+
// operation against the resulting complete state and reports all errors; on any
|
|
2442
|
+
// failure it emits no plan and writes nothing. Without `--out` this is fully
|
|
2443
|
+
// read-only and always prints the JSON envelope; with `--out` it writes only the
|
|
2444
|
+
// plan artifact and prints a human summary unless `--json` is given
|
|
2445
|
+
// (E1 tasks 4.1 and 4.3; task 4.4 hardens identity).
|
|
2446
|
+
async function plan(args) {
|
|
2447
|
+
if (!args.operations) {
|
|
2448
|
+
printEnvelope(
|
|
2449
|
+
resultEnvelope('plan', false, 1, [
|
|
2450
|
+
envelopeDiagnostic(
|
|
2451
|
+
'E_INVALID_ENVELOPE',
|
|
2452
|
+
'Usage: storefront plan --operations <file> [--out <file>] [--json]',
|
|
2453
|
+
),
|
|
2454
|
+
]),
|
|
2455
|
+
)
|
|
2456
|
+
return 1
|
|
2457
|
+
}
|
|
2458
|
+
// `--operations` resolves against the consumer cwd, not the project root
|
|
2459
|
+
// (Decision 12 / E1 task 4.11); the project root is found upward separately.
|
|
2460
|
+
const operationsPath = resolve(args.operations)
|
|
2461
|
+
let raw
|
|
2462
|
+
try {
|
|
2463
|
+
raw = JSON.parse(readFileSync(operationsPath, 'utf8'))
|
|
2464
|
+
} catch (err) {
|
|
2465
|
+
printEnvelope(
|
|
2466
|
+
resultEnvelope('plan', false, 1, [
|
|
2467
|
+
envelopeDiagnostic(
|
|
2468
|
+
'E_INVALID_ENVELOPE',
|
|
2469
|
+
`Cannot read operations envelope file ${operationsPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2470
|
+
),
|
|
2471
|
+
]),
|
|
2472
|
+
)
|
|
2473
|
+
return 1
|
|
2474
|
+
}
|
|
2475
|
+
const { envelope, exitCode } = await planProject(args.dir, raw, {
|
|
2476
|
+
outPath: args.out ? resolve(args.out) : null,
|
|
2477
|
+
operationsPath,
|
|
2478
|
+
registry: SECTION_OPERATION_REGISTRY,
|
|
2479
|
+
})
|
|
2480
|
+
if (args.json || !args.out || exitCode !== 0) {
|
|
2481
|
+
printEnvelope(envelope)
|
|
2482
|
+
} else {
|
|
2483
|
+
console.log('storefront plan — valid')
|
|
2484
|
+
console.log(` before: ${envelope.result.beforeFingerprint}`)
|
|
2485
|
+
console.log(` after: ${envelope.result.afterFingerprint}`)
|
|
2486
|
+
console.log(` wrote plan artifact: ${resolve(args.out)}`)
|
|
2487
|
+
}
|
|
2488
|
+
return exitCode
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// Apply a recorded plan (E1 task 4.1 foundation; tasks 4.5-4.19 harden the
|
|
2492
|
+
// three-state resolution, lock, and rollback branches).
|
|
2493
|
+
async function apply(args) {
|
|
2494
|
+
if (!args.plan) {
|
|
2495
|
+
printEnvelope(
|
|
2496
|
+
resultEnvelope('apply', false, 1, [
|
|
2497
|
+
envelopeDiagnostic('E_PLAN_NOT_FOUND', 'Usage: storefront apply --plan <file> [--json]'),
|
|
2498
|
+
]),
|
|
2499
|
+
)
|
|
2500
|
+
return 1
|
|
2501
|
+
}
|
|
2502
|
+
// `--plan` resolves against the consumer cwd, not the project root
|
|
2503
|
+
// (Decision 12 / E1 task 4.11).
|
|
2504
|
+
const planPath = resolve(args.plan)
|
|
2505
|
+
let artifact
|
|
2506
|
+
try {
|
|
2507
|
+
artifact = JSON.parse(readFileSync(planPath, 'utf8'))
|
|
2508
|
+
} catch (err) {
|
|
2509
|
+
const code = err?.code === 'ENOENT' ? 'E_PLAN_NOT_FOUND' : 'E_PLAN_INVALID'
|
|
2510
|
+
printEnvelope(
|
|
2511
|
+
resultEnvelope('apply', false, 1, [
|
|
2512
|
+
envelopeDiagnostic(
|
|
2513
|
+
code,
|
|
2514
|
+
`Cannot read plan file ${planPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2515
|
+
),
|
|
2516
|
+
]),
|
|
2517
|
+
)
|
|
2518
|
+
return 1
|
|
2519
|
+
}
|
|
2520
|
+
const { envelope, exitCode } = await applyProject(args.dir, artifact, {
|
|
2521
|
+
registry: SECTION_OPERATION_REGISTRY,
|
|
2522
|
+
})
|
|
2523
|
+
if (args.json || exitCode !== 0) {
|
|
2524
|
+
printEnvelope(envelope)
|
|
2525
|
+
} else {
|
|
2526
|
+
console.log(`storefront apply — ${envelope.result?.state ?? 'ok'}`)
|
|
2527
|
+
}
|
|
2528
|
+
return exitCode
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
// List the view registry — the recipes and intents the CLI validates against —
|
|
2532
|
+
// annotated with whether each view is installed in the current storefront.
|
|
2533
|
+
async function views(args) {
|
|
2534
|
+
const target = args.dir
|
|
2535
|
+
const manifest = await readManifest(target)
|
|
2536
|
+
const installed = new Set((manifest?.views ?? []).map((v) => v.name))
|
|
2537
|
+
const { views: recipes } = loadRecipes()
|
|
2538
|
+
const { slots } = loadArchetypes()
|
|
2539
|
+
|
|
2540
|
+
const entries = recipes.map((recipe) => {
|
|
2541
|
+
const intents = {}
|
|
2542
|
+
for (const [key, value] of Object.entries(slots ?? {})) {
|
|
2543
|
+
if (!key.startsWith(`${recipe.name}.`)) continue
|
|
2544
|
+
intents[key.slice(recipe.name.length + 1)] = value
|
|
2545
|
+
}
|
|
2546
|
+
return {
|
|
2547
|
+
name: recipe.name,
|
|
2548
|
+
description: recipe.description,
|
|
2549
|
+
route: recipe.route,
|
|
2550
|
+
loader: recipe.loader,
|
|
2551
|
+
requiredDataKeys: recipe.requiredDataKeys ?? [],
|
|
2552
|
+
installed: installed.has(recipe.name),
|
|
2553
|
+
supported: isCuratedView(recipe.name),
|
|
2554
|
+
defaultVariants: recipe.defaultVariants ?? {},
|
|
2555
|
+
// Allowed/supported are derived from the single semantic catalog; a held
|
|
2556
|
+
// variant is reported (with its reason) under `catalog` and never listed
|
|
2557
|
+
// as selectable here.
|
|
2558
|
+
allowedVariants: allowedVariantsForView(recipe.name),
|
|
2559
|
+
supportedVariants: supportedVariantsForView(recipe.name),
|
|
2560
|
+
supportedIntents: supportedIntents(recipe.name),
|
|
2561
|
+
catalog: viewCatalog(recipe.name),
|
|
2562
|
+
optionalSections: recipe.optionalSections ?? [],
|
|
2563
|
+
// E3 section registry (CU-14/CU-16): the ordered, non-removable core
|
|
2564
|
+
// presentation slots, the host allowlist, and the editorial-instance
|
|
2565
|
+
// technical bound. Reported so an agent sees the available hosts without
|
|
2566
|
+
// reading the JSON registry.
|
|
2567
|
+
registeredSlots: recipe.registeredSlots ?? [],
|
|
2568
|
+
allowedSections: recipe.allowedSections ?? [],
|
|
2569
|
+
maxSections: recipe.maxSections ?? 4,
|
|
2570
|
+
intents,
|
|
2571
|
+
}
|
|
2572
|
+
})
|
|
2573
|
+
|
|
2574
|
+
if (args.json) {
|
|
2575
|
+
console.log(
|
|
2576
|
+
JSON.stringify(
|
|
2577
|
+
{ catalogVersion: CATALOG_VERSION, sectionTypes: sectionRegistry(), views: entries },
|
|
2578
|
+
null,
|
|
2579
|
+
2,
|
|
2580
|
+
),
|
|
2581
|
+
)
|
|
2582
|
+
return 0
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
console.log(`Storefront views (${target}):`)
|
|
2586
|
+
for (const entry of entries) {
|
|
2587
|
+
const status = entry.installed ? 'installed' : entry.supported ? 'available' : 'held'
|
|
2588
|
+
console.log(` ${entry.name.padEnd(12)} ${status.padEnd(10)} ${entry.route}`)
|
|
2589
|
+
}
|
|
2590
|
+
return 0
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// List the feature registry — the capability bundles the CLI validates against —
|
|
2594
|
+
// annotated with whether each feature is installed in the current storefront.
|
|
2595
|
+
// Held (non-MVP) features are reported as held, never as available.
|
|
2596
|
+
async function features(args) {
|
|
2597
|
+
const target = args.dir
|
|
2598
|
+
const manifest = await readManifest(target)
|
|
2599
|
+
const installed = new Set((manifest?.features ?? []).map((f) => f.name))
|
|
2600
|
+
const { features: specs } = loadFeatures()
|
|
2601
|
+
|
|
2602
|
+
const entries = specs.map((spec) => ({
|
|
2603
|
+
name: spec.name,
|
|
2604
|
+
description: spec.description,
|
|
2605
|
+
mvp: spec.mvp === true,
|
|
2606
|
+
installed: installed.has(spec.name),
|
|
2607
|
+
route: spec.route ?? null,
|
|
2608
|
+
views: spec.views ?? [],
|
|
2609
|
+
state: spec.state ?? [],
|
|
2610
|
+
handlers: spec.handlers ?? [],
|
|
2611
|
+
adapter: spec.adapter ?? null,
|
|
2612
|
+
config: spec.config ?? null,
|
|
2613
|
+
presentations: spec.presentations ?? {},
|
|
2614
|
+
mounts: spec.mounts ?? [],
|
|
2615
|
+
surfaces: featureSurfaces(spec),
|
|
2616
|
+
}))
|
|
2617
|
+
|
|
2618
|
+
if (args.json) {
|
|
2619
|
+
console.log(JSON.stringify({ features: entries }, null, 2))
|
|
2620
|
+
return 0
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
console.log(`Storefront features (${target}):`)
|
|
2624
|
+
for (const entry of entries) {
|
|
2625
|
+
const status = entry.installed ? 'installed' : entry.mvp ? 'available' : 'held'
|
|
2626
|
+
const owned = entry.views.join(', ') || '—'
|
|
2627
|
+
console.log(` ${entry.name.padEnd(16)} ${status.padEnd(10)} ${owned}`)
|
|
2628
|
+
}
|
|
2629
|
+
return 0
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// Print the whole curated registry — views and features — deterministically, so
|
|
2633
|
+
// agents can introspect what the CLI will accept without reading the JSON files.
|
|
2634
|
+
async function recipes(args) {
|
|
2635
|
+
const { views: viewRecipes } = loadRecipes()
|
|
2636
|
+
const { features: featureSpecs } = loadFeatures()
|
|
2637
|
+
|
|
2638
|
+
const views = viewRecipes.map((recipe) => {
|
|
2639
|
+
return {
|
|
2640
|
+
name: recipe.name,
|
|
2641
|
+
description: recipe.description,
|
|
2642
|
+
route: recipe.route,
|
|
2643
|
+
loader: recipe.loader,
|
|
2644
|
+
mvp: recipe.mvp === true,
|
|
2645
|
+
defaultVariants: recipe.defaultVariants ?? {},
|
|
2646
|
+
// Derived from the single semantic catalog: selectable variants only,
|
|
2647
|
+
// with held variants visible (and reasoned) under `catalog`.
|
|
2648
|
+
allowedVariants: allowedVariantsForView(recipe.name),
|
|
2649
|
+
supportedVariants: supportedVariantsForView(recipe.name),
|
|
2650
|
+
supportedIntents: supportedIntents(recipe.name),
|
|
2651
|
+
catalog: viewCatalog(recipe.name),
|
|
2652
|
+
optionalSections: recipe.optionalSections ?? [],
|
|
2653
|
+
// E3 section registry (CU-14/CU-16): core slots, host allowlist, and the
|
|
2654
|
+
// editorial-instance technical bound, per view.
|
|
2655
|
+
registeredSlots: recipe.registeredSlots ?? [],
|
|
2656
|
+
allowedSections: recipe.allowedSections ?? [],
|
|
2657
|
+
maxSections: recipe.maxSections ?? 4,
|
|
2658
|
+
}
|
|
2659
|
+
})
|
|
2660
|
+
const features = featureSpecs.map((spec) => ({
|
|
2661
|
+
name: spec.name,
|
|
2662
|
+
description: spec.description,
|
|
2663
|
+
mvp: spec.mvp === true,
|
|
2664
|
+
route: spec.route ?? null,
|
|
2665
|
+
views: spec.views ?? [],
|
|
2666
|
+
state: spec.state ?? [],
|
|
2667
|
+
handlers: spec.handlers ?? [],
|
|
2668
|
+
adapter: spec.adapter ?? null,
|
|
2669
|
+
config: spec.config ?? null,
|
|
2670
|
+
presentations: spec.presentations ?? {},
|
|
2671
|
+
mounts: spec.mounts ?? [],
|
|
2672
|
+
surfaces: featureSurfaces(spec),
|
|
2673
|
+
}))
|
|
2674
|
+
|
|
2675
|
+
if (args.json) {
|
|
2676
|
+
console.log(
|
|
2677
|
+
JSON.stringify(
|
|
2678
|
+
{
|
|
2679
|
+
catalogVersion: CATALOG_VERSION,
|
|
2680
|
+
sectionTypes: sectionRegistry(),
|
|
2681
|
+
recipes: { views, features },
|
|
2682
|
+
},
|
|
2683
|
+
null,
|
|
2684
|
+
2,
|
|
2685
|
+
),
|
|
2686
|
+
)
|
|
2687
|
+
return 0
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
console.log('Storefront recipes:')
|
|
2691
|
+
console.log(` views ${views.map((v) => v.name).join(', ')}`)
|
|
2692
|
+
console.log(` features ${features.map((f) => f.name).join(', ')}`)
|
|
511
2693
|
return 0
|
|
512
2694
|
}
|
|
513
2695
|
|
|
@@ -519,13 +2701,107 @@ async function main() {
|
|
|
519
2701
|
return 0
|
|
520
2702
|
}
|
|
521
2703
|
if (args.action === 'create') return create(args)
|
|
522
|
-
if (args.action === '
|
|
523
|
-
|
|
2704
|
+
if (args.action === 'view') {
|
|
2705
|
+
const root = resolveProject()
|
|
2706
|
+
if (!root) return 1
|
|
2707
|
+
return view({ ...args, dir: root })
|
|
2708
|
+
}
|
|
2709
|
+
if (args.action === 'views') {
|
|
2710
|
+
const root = resolveProject()
|
|
2711
|
+
if (!root) return 1
|
|
2712
|
+
return views({ ...args, dir: root })
|
|
2713
|
+
}
|
|
2714
|
+
if (args.action === 'features') {
|
|
2715
|
+
const root = resolveProject()
|
|
2716
|
+
if (!root) return 1
|
|
2717
|
+
return features({ ...args, dir: root })
|
|
2718
|
+
}
|
|
2719
|
+
if (args.action === 'recipes') {
|
|
2720
|
+
const root = resolveProject()
|
|
2721
|
+
if (!root) return 1
|
|
2722
|
+
return recipes({ ...args, dir: root })
|
|
2723
|
+
}
|
|
2724
|
+
if (args.action === 'feature') {
|
|
2725
|
+
const root = resolveProject()
|
|
2726
|
+
if (!root) return 1
|
|
2727
|
+
return feature({ ...args, dir: root })
|
|
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
|
+
}
|
|
2739
|
+
if (args.action === 'inspect' || args.action === 'plan' || args.action === 'apply') {
|
|
2740
|
+
const root = resolveProject({ quiet: true })
|
|
2741
|
+
if (!root) {
|
|
2742
|
+
// Every inspect/plan/apply invocation emits a versioned error envelope,
|
|
2743
|
+
// including "not inside a project" (E1 task 4.11).
|
|
2744
|
+
printEnvelope(
|
|
2745
|
+
resultEnvelope(args.action, false, 1, [
|
|
2746
|
+
envelopeDiagnostic(
|
|
2747
|
+
'E_NO_MANIFEST',
|
|
2748
|
+
'Not inside a storefront project (no storefront.config.json found in this directory or any parent).',
|
|
2749
|
+
),
|
|
2750
|
+
]),
|
|
2751
|
+
)
|
|
2752
|
+
return 1
|
|
2753
|
+
}
|
|
2754
|
+
if (args.action === 'inspect') return inspect({ ...args, dir: root })
|
|
2755
|
+
if (args.action === 'plan') return plan({ ...args, dir: root })
|
|
2756
|
+
return apply({ ...args, dir: root })
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
const SCOPED = ['theme', 'update', 'doctor', 'sync', 'setup']
|
|
2760
|
+
if (SCOPED.includes(args.action)) {
|
|
2761
|
+
if (args.positionals.length > 1) {
|
|
2762
|
+
console.error(`storefront ${args.action} does not take a directory; run it from inside the storefront project.`)
|
|
2763
|
+
return 1
|
|
2764
|
+
}
|
|
2765
|
+
const root = resolveProject()
|
|
2766
|
+
if (!root) return 1
|
|
2767
|
+
const scoped = { ...args, dir: root }
|
|
2768
|
+
if (args.action === 'theme') return theme(scoped)
|
|
2769
|
+
if (args.action === 'update') return update(scoped)
|
|
2770
|
+
if (args.action === 'sync') return sync(scoped)
|
|
2771
|
+
if (args.action === 'setup') return setup(scoped)
|
|
2772
|
+
return doctor(scoped)
|
|
2773
|
+
}
|
|
2774
|
+
|
|
524
2775
|
console.error(`Unknown action: ${args.action}`)
|
|
525
2776
|
console.log(HELP)
|
|
526
2777
|
return 1
|
|
527
2778
|
}
|
|
528
2779
|
|
|
529
|
-
main()
|
|
530
|
-
|
|
531
|
-
|
|
2780
|
+
main()
|
|
2781
|
+
.then((code) => {
|
|
2782
|
+
process.exitCode = code
|
|
2783
|
+
})
|
|
2784
|
+
.catch((error) => {
|
|
2785
|
+
// An unexpected throw that escaped a handler is an internal error: emit a
|
|
2786
|
+
// sanitized versioned error envelope and exit 2. No stack, absolute path,
|
|
2787
|
+
// or captured value is printed to stdout; the error name/message is logged
|
|
2788
|
+
// (not swallowed) so the failure is still diagnosable.
|
|
2789
|
+
const command = process.argv[2] ?? 'unknown'
|
|
2790
|
+
const name = error instanceof Error ? error.name : 'Error'
|
|
2791
|
+
const rawMessage = error instanceof Error ? error.message : String(error)
|
|
2792
|
+
// Redact the consumer cwd and the OS temp root so the log cannot leak an
|
|
2793
|
+
// absolute machine path.
|
|
2794
|
+
const message = rawMessage.split(process.cwd()).join('<cwd>').split(tmpdir()).join('<tmp>')
|
|
2795
|
+
console.error(`storefront ${command}: unexpected internal error (${name}): ${message}`)
|
|
2796
|
+
printEnvelope(
|
|
2797
|
+
resultEnvelope(command, false, 2, [
|
|
2798
|
+
{
|
|
2799
|
+
code: 'E_UNEXPECTED',
|
|
2800
|
+
severity: 'error',
|
|
2801
|
+
message: 'unexpected internal error',
|
|
2802
|
+
detail: { name },
|
|
2803
|
+
},
|
|
2804
|
+
]),
|
|
2805
|
+
)
|
|
2806
|
+
process.exitCode = 2
|
|
2807
|
+
})
|