@thorprovider/storefront 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +333 -0
- package/assets/templates/app/app/[[...slug]]/page.tsx +17 -0
- package/assets/templates/app/app/[[...slug]]/renderer.tsx +10 -0
- package/assets/templates/app/app/globals.css +101 -0
- package/assets/templates/app/app/layout.tsx +35 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +88 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +42 -0
- package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +47 -0
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +98 -0
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +17 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +48 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/types.ts +14 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.ts +35 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/index.ts +7 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +29 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +29 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +33 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +29 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +28 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +46 -0
- package/assets/templates/views/home/index.tsx +142 -0
- package/assets/templates/views/home/registry.tsx +65 -0
- package/bin/storefront.js +531 -0
- package/commands/sf-add-view.md +26 -0
- package/commands/sf-theme.md +20 -0
- package/commands/sf-view.md +25 -0
- package/package.json +48 -0
- package/recipes/archetype.schema.json +39 -0
- package/recipes/archetypes.json +148 -0
- package/recipes/recipe.schema.json +59 -0
- package/recipes/recipes.json +90 -0
- package/recipes/sections.json +46 -0
- package/recipes/validate.mjs +190 -0
- package/scripts/render-templates.mjs +255 -0
- package/scripts/sync-assets.mjs +87 -0
- package/skills/building-storefronts/SKILL.md +178 -0
- package/skills/building-storefronts/references/frontend-integration.md +229 -0
- package/skills/json-render-core/SKILL.md +291 -0
- package/skills/json-render-next/SKILL.md +194 -0
- package/skills/json-render-react/SKILL.md +298 -0
- package/skills/json-render-remotion/SKILL.md +111 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/json-render-solid/SKILL.md +204 -0
- package/skills/nextjs-shadcn/SKILL.md +367 -0
- package/skills/nextjs-shadcn/references/architecture.md +499 -0
- package/skills/nextjs-shadcn/references/project-setup.md +162 -0
- package/skills/nextjs-shadcn/references/shadcn-platform.md +261 -0
- package/skills/nextjs-shadcn/references/sidebar.md +274 -0
- package/skills/nextjs-shadcn/references/styling.md +555 -0
- package/skills/sf-theme-gen/SKILL.md +61 -0
- package/skills/sf-view-gen/SKILL.md +116 -0
- package/skills/sf-view-gen/assets/recipes/archetypes.json +148 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +90 -0
- package/skills/sf-view-gen/assets/recipes/sections.json +46 -0
- package/skills/sf-view-gen/assets/templates/views/category/index.tsx +86 -0
- package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +48 -0
- package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +192 -0
- package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +45 -0
- package/skills/sf-view-gen/assets/templates/views/product/index.tsx +122 -0
- package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +49 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +20 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +21 -0
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +21 -0
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +20 -0
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +23 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +21 -0
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +19 -0
- package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +168 -0
- package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +49 -0
- package/skills/sf-view-gen/references/views.md +69 -0
- package/skills/sf-view-gen/scripts/validate.mjs +190 -0
- package/skills/shadcn-component-discovery/SKILL.md +273 -0
- package/skills/shadcn-component-discovery/references/registries.md +226 -0
- package/skills/shadcn-theming/SKILL.md +104 -0
- package/skills/shadcn-theming/references/templates/theme-setup.md +109 -0
- package/skills/shadcn-theming/references/theming-guide.md +90 -0
- package/skills/storefront-best-practices/SKILL.md +421 -0
- package/skills/storefront-best-practices/reference/components/breadcrumbs.md +123 -0
- package/skills/storefront-best-practices/reference/components/cart-popup.md +189 -0
- package/skills/storefront-best-practices/reference/components/country-selector.md +298 -0
- package/skills/storefront-best-practices/reference/components/footer.md +112 -0
- package/skills/storefront-best-practices/reference/components/hero.md +241 -0
- package/skills/storefront-best-practices/reference/components/megamenu.md +239 -0
- package/skills/storefront-best-practices/reference/components/navbar.md +397 -0
- package/skills/storefront-best-practices/reference/components/popups.md +221 -0
- package/skills/storefront-best-practices/reference/components/product-card.md +125 -0
- package/skills/storefront-best-practices/reference/components/product-reviews.md +217 -0
- package/skills/storefront-best-practices/reference/components/product-slider.md +174 -0
- package/skills/storefront-best-practices/reference/components/search.md +101 -0
- package/skills/storefront-best-practices/reference/connecting-to-backend.md +391 -0
- package/skills/storefront-best-practices/reference/design.md +388 -0
- package/skills/storefront-best-practices/reference/features/promotions.md +307 -0
- package/skills/storefront-best-practices/reference/features/wishlist.md +230 -0
- package/skills/storefront-best-practices/reference/layouts/account.md +380 -0
- package/skills/storefront-best-practices/reference/layouts/cart.md +316 -0
- package/skills/storefront-best-practices/reference/layouts/checkout.md +486 -0
- package/skills/storefront-best-practices/reference/layouts/home-page.md +264 -0
- package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +231 -0
- package/skills/storefront-best-practices/reference/layouts/product-details.md +527 -0
- package/skills/storefront-best-practices/reference/layouts/product-listing.md +520 -0
- package/skills/storefront-best-practices/reference/layouts/static-pages.md +356 -0
- package/skills/storefront-best-practices/reference/medusa.md +307 -0
- package/skills/storefront-best-practices/reference/mobile-responsiveness.md +183 -0
- package/skills/storefront-best-practices/reference/seo.md +195 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @thorprovider/storefront — CLI
|
|
3
|
+
// Deterministic storefront scaffolder + installable OpenCode command suite.
|
|
4
|
+
//
|
|
5
|
+
// Actions:
|
|
6
|
+
// create Scaffold a complete, runnable storefront AND install the command suite
|
|
7
|
+
// update Refresh an existing install (prunes removed files, migrates the record)
|
|
8
|
+
// doctor Report the recorded version and whether the installed skills match this package
|
|
9
|
+
//
|
|
10
|
+
// On a TTY, `create` prompts for missing inputs (name, site name, backend URL, preset —
|
|
11
|
+
// Maia is preselected). Without a TTY, every required input must be a flag; the command
|
|
12
|
+
// fails fast and writes nothing rather than guessing. Brand color is never prompted.
|
|
13
|
+
//
|
|
14
|
+
// The package's generated copies are verified against their source before anything is
|
|
15
|
+
// copied into a target project. On divergence the command stops and writes nothing.
|
|
16
|
+
|
|
17
|
+
import { cp, mkdir, readdir, readFile, writeFile, rm } from 'node:fs/promises'
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
19
|
+
import { createHash } from 'node:crypto'
|
|
20
|
+
import { spawnSync } from 'node:child_process'
|
|
21
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts'
|
|
24
|
+
import { checkAssets } from '../scripts/sync-assets.mjs'
|
|
25
|
+
import { renderScaffold, toKebab } from '../scripts/render-templates.mjs'
|
|
26
|
+
|
|
27
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
const PKG_ROOT = resolve(__dirname, '..')
|
|
29
|
+
|
|
30
|
+
const VERSION_RECORD_FILE = 'storefront.json'
|
|
31
|
+
const LEGACY_RECORD_FILE = '.create-storefront.json'
|
|
32
|
+
const COPY_DIRS = ['commands', 'skills']
|
|
33
|
+
// Files that shipped in earlier versions and are no longer part of the suite.
|
|
34
|
+
// `update` prunes them so an upgraded project does not keep running the
|
|
35
|
+
// eliminated /sf-init scaffold wizard.
|
|
36
|
+
const PRUNED_PATHS = ['commands/sf-init.md', 'skills/sf-scaffold']
|
|
37
|
+
|
|
38
|
+
const PRESETS = ['maia', 'nova', 'vega', 'luma', 'lyra', 'mira']
|
|
39
|
+
const PRESET_LABELS = {
|
|
40
|
+
maia: 'Rounded and spacious. Warm and approachable — fits a store.',
|
|
41
|
+
nova: 'Compact and balanced. The neutral default.',
|
|
42
|
+
vega: 'The classic shadcn look. Polished, modern.',
|
|
43
|
+
luma: 'Very rounded and airy. Soft and premium.',
|
|
44
|
+
lyra: 'Square corners and minimal. Geometric and strict.',
|
|
45
|
+
mira: 'Very dense. For lots of information on screen.',
|
|
46
|
+
}
|
|
47
|
+
const DEFAULT_BACKEND_URL = 'http://localhost:9000'
|
|
48
|
+
|
|
49
|
+
const HELP = `@thorprovider/storefront — scaffold Thor Commerce storefronts
|
|
50
|
+
|
|
51
|
+
Usage:
|
|
52
|
+
storefront create <dir> [options] Scaffold a storefront and install the suite
|
|
53
|
+
storefront <update|doctor> <dir> Manage an existing install
|
|
54
|
+
|
|
55
|
+
create options:
|
|
56
|
+
--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)
|
|
59
|
+
--preset <preset> maia | nova | vega | luma | lyra | mira (required without a terminal)
|
|
60
|
+
--brand <color> brand color over preset tokens (not prompted by the wizard)
|
|
61
|
+
-f, --force overwrite files that already exist
|
|
62
|
+
-h, --help show this help
|
|
63
|
+
|
|
64
|
+
In a terminal, create prompts for missing inputs (Maia is preselected).
|
|
65
|
+
Without a terminal, every required input must be a flag — the command fails fast.
|
|
66
|
+
`
|
|
67
|
+
|
|
68
|
+
function parseArgs(argv) {
|
|
69
|
+
const out = {
|
|
70
|
+
action: null,
|
|
71
|
+
dir: null,
|
|
72
|
+
name: null,
|
|
73
|
+
siteName: null,
|
|
74
|
+
backendUrl: null,
|
|
75
|
+
preset: null,
|
|
76
|
+
brand: null,
|
|
77
|
+
force: false,
|
|
78
|
+
help: false,
|
|
79
|
+
}
|
|
80
|
+
const positionals = []
|
|
81
|
+
for (let i = 0; i < argv.length; i++) {
|
|
82
|
+
const arg = argv[i]
|
|
83
|
+
if (arg === '--name') {
|
|
84
|
+
out.name = argv[++i]
|
|
85
|
+
} else if (arg === '--site-name') {
|
|
86
|
+
out.siteName = argv[++i]
|
|
87
|
+
} else if (arg === '--backend-url') {
|
|
88
|
+
out.backendUrl = argv[++i]
|
|
89
|
+
} else if (arg === '--preset') {
|
|
90
|
+
out.preset = argv[++i]
|
|
91
|
+
} else if (arg === '--brand') {
|
|
92
|
+
out.brand = argv[++i]
|
|
93
|
+
} else if (arg === '--force' || arg === '-f') {
|
|
94
|
+
out.force = true
|
|
95
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
96
|
+
out.help = true
|
|
97
|
+
} else if (arg.startsWith('-')) {
|
|
98
|
+
console.error(`Unknown option: ${arg}`)
|
|
99
|
+
process.exitCode = 1
|
|
100
|
+
return null
|
|
101
|
+
} else {
|
|
102
|
+
positionals.push(arg)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
out.action = positionals[0] ?? null
|
|
106
|
+
if (positionals[1]) out.dir = resolve(positionals[1])
|
|
107
|
+
else if (!out.dir) out.dir = process.cwd()
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function copyDir(from, to, force) {
|
|
112
|
+
const entries = await readdir(from, { withFileTypes: true })
|
|
113
|
+
let copied = 0
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const src = join(from, entry.name)
|
|
116
|
+
const dest = join(to, entry.name)
|
|
117
|
+
if (entry.isDirectory()) {
|
|
118
|
+
await mkdir(dest, { recursive: true })
|
|
119
|
+
copied += await copyDir(src, dest, force)
|
|
120
|
+
} else {
|
|
121
|
+
if (existsSync(dest) && !force) continue
|
|
122
|
+
await mkdir(to, { recursive: true })
|
|
123
|
+
await cp(src, dest)
|
|
124
|
+
copied++
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return copied
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function prunePath(opencodeDir, relPath) {
|
|
131
|
+
await rm(join(opencodeDir, relPath), { recursive: true, force: true })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sha256(text) {
|
|
135
|
+
return createHash('sha256').update(text).digest('hex')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function hashDir(dir) {
|
|
139
|
+
const files = []
|
|
140
|
+
async function walk(d) {
|
|
141
|
+
for (const entry of await readdir(d, { withFileTypes: true })) {
|
|
142
|
+
const full = join(d, entry.name)
|
|
143
|
+
if (entry.isDirectory()) await walk(full)
|
|
144
|
+
else files.push(full)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (existsSync(dir)) await walk(dir)
|
|
148
|
+
files.sort()
|
|
149
|
+
const lines = []
|
|
150
|
+
for (const f of files) {
|
|
151
|
+
lines.push(`${relative(dir, f)}:${sha256(await readFile(f, 'utf8'))}`)
|
|
152
|
+
}
|
|
153
|
+
return sha256(lines.join('\n'))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function pkgVersion() {
|
|
157
|
+
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
|
|
158
|
+
return pkg.version
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function readRecord(opencodeDir) {
|
|
162
|
+
for (const name of [VERSION_RECORD_FILE, LEGACY_RECORD_FILE]) {
|
|
163
|
+
const path = join(opencodeDir, name)
|
|
164
|
+
if (existsSync(path)) {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
167
|
+
} catch {
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeRecord(projectDir, version) {
|
|
176
|
+
const opencodeDir = join(projectDir, '.opencode')
|
|
177
|
+
await mkdir(opencodeDir, { recursive: true })
|
|
178
|
+
await writeFile(
|
|
179
|
+
join(opencodeDir, VERSION_RECORD_FILE),
|
|
180
|
+
JSON.stringify({ version, installedAt: new Date().toISOString() }, null, 2) + '\n',
|
|
181
|
+
'utf8',
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function verifyConsistency() {
|
|
186
|
+
const diverged = await checkAssets()
|
|
187
|
+
if (diverged.length > 0) {
|
|
188
|
+
console.error('Generated assets are out of sync with recipes/:')
|
|
189
|
+
for (const file of diverged) console.error(` ${file}`)
|
|
190
|
+
console.error('Run `yarn workspace @thorprovider/storefront sync-assets` to regenerate.')
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function installSuite(opencodeDir, force) {
|
|
197
|
+
let total = 0
|
|
198
|
+
for (const dir of COPY_DIRS) {
|
|
199
|
+
const from = join(PKG_ROOT, dir)
|
|
200
|
+
const to = join(opencodeDir, dir)
|
|
201
|
+
await mkdir(to, { recursive: true })
|
|
202
|
+
total += await copyDir(from, to, force)
|
|
203
|
+
}
|
|
204
|
+
return total
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function run(cmd, cmdArgs, cwd) {
|
|
208
|
+
const res = spawnSync(cmd, cmdArgs, { cwd, stdio: 'inherit', shell: false })
|
|
209
|
+
return res.status ?? 1
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const BRAND_TOKEN_KEYS = {
|
|
213
|
+
light: ['--primary', '--primary-foreground', '--nav', '--ring'],
|
|
214
|
+
dark: ['--primary', '--primary-foreground', '--nav', '--ring'],
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const COLOR_HUES = {
|
|
218
|
+
red: 27,
|
|
219
|
+
orange: 55,
|
|
220
|
+
amber: 75,
|
|
221
|
+
yellow: 95,
|
|
222
|
+
lime: 130,
|
|
223
|
+
green: 155,
|
|
224
|
+
teal: 185,
|
|
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
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function promptScaffoldInputs(args) {
|
|
256
|
+
const inputs = {
|
|
257
|
+
name: toKebab(args.name ?? '') || null,
|
|
258
|
+
siteName: args.siteName ?? null,
|
|
259
|
+
backendUrl: args.backendUrl ?? null,
|
|
260
|
+
preset: args.preset ?? null,
|
|
261
|
+
brand: args.brand ?? null,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const tty = Boolean(process.stdout.isTTY)
|
|
265
|
+
if (tty) {
|
|
266
|
+
intro('storefront create')
|
|
267
|
+
|
|
268
|
+
if (!inputs.name) {
|
|
269
|
+
const value = await text({
|
|
270
|
+
message: 'Storefront name (kebab-case — lib/<name>/):',
|
|
271
|
+
placeholder: 'my-shop',
|
|
272
|
+
validate: (v) => (toKebab(v ?? '').length ? undefined : 'Enter a kebab-case name'),
|
|
273
|
+
})
|
|
274
|
+
if (isCancel(value)) {
|
|
275
|
+
cancel('Operation cancelled.')
|
|
276
|
+
process.exit(0)
|
|
277
|
+
}
|
|
278
|
+
inputs.name = toKebab(value)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!inputs.siteName) {
|
|
282
|
+
const value = await text({
|
|
283
|
+
message: 'Site name:',
|
|
284
|
+
initialValue: inputs.name,
|
|
285
|
+
})
|
|
286
|
+
if (isCancel(value)) {
|
|
287
|
+
cancel('Operation cancelled.')
|
|
288
|
+
process.exit(0)
|
|
289
|
+
}
|
|
290
|
+
inputs.siteName = value
|
|
291
|
+
}
|
|
292
|
+
|
|
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
|
+
if (!inputs.preset) {
|
|
306
|
+
const value = await select({
|
|
307
|
+
message: 'Visual preset:',
|
|
308
|
+
initialValue: 'maia',
|
|
309
|
+
options: PRESETS.map((preset) => ({
|
|
310
|
+
value: preset,
|
|
311
|
+
label: `${preset} — ${PRESET_LABELS[preset]}`,
|
|
312
|
+
})),
|
|
313
|
+
})
|
|
314
|
+
if (isCancel(value)) {
|
|
315
|
+
cancel('Operation cancelled.')
|
|
316
|
+
process.exit(0)
|
|
317
|
+
}
|
|
318
|
+
inputs.preset = value
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
outro('Scaffolding…')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const missing = []
|
|
325
|
+
if (!inputs.name) missing.push('storefront name (--name <name>)')
|
|
326
|
+
if (!inputs.preset) missing.push('--preset (one of: ' + PRESETS.join(', ') + ')')
|
|
327
|
+
else if (!PRESETS.includes(inputs.preset)) missing.push(`--preset must be one of: ${PRESETS.join(', ')}`)
|
|
328
|
+
|
|
329
|
+
if (missing.length > 0) return { missing }
|
|
330
|
+
return {
|
|
331
|
+
inputs: {
|
|
332
|
+
name: inputs.name,
|
|
333
|
+
siteName: inputs.siteName ?? inputs.name,
|
|
334
|
+
backendUrl: inputs.backendUrl ?? DEFAULT_BACKEND_URL,
|
|
335
|
+
preset: inputs.preset,
|
|
336
|
+
brand: inputs.brand,
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function create(args) {
|
|
342
|
+
const target = args.dir
|
|
343
|
+
const { inputs, missing } = await promptScaffoldInputs(args)
|
|
344
|
+
if (missing && missing.length > 0) {
|
|
345
|
+
console.error('Missing or invalid input (run in a terminal to use the wizard, or pass flags):')
|
|
346
|
+
for (const item of missing) console.error(` - ${item}`)
|
|
347
|
+
return 1
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const { name, siteName, backendUrl, preset, brand } = inputs
|
|
351
|
+
|
|
352
|
+
console.log('storefront create — deterministic scaffold')
|
|
353
|
+
console.log(` name: ${name}`)
|
|
354
|
+
console.log(` site name: ${siteName}`)
|
|
355
|
+
console.log(` backend: ${backendUrl}`)
|
|
356
|
+
console.log(` preset: ${preset}`)
|
|
357
|
+
if (brand) console.log(` brand: ${brand}`)
|
|
358
|
+
console.log(` target: ${target}`)
|
|
359
|
+
|
|
360
|
+
// 1. Preconditions
|
|
361
|
+
if (!existsSync(target)) {
|
|
362
|
+
console.log(`Creating target directory: ${target}`)
|
|
363
|
+
await mkdir(target, { recursive: true })
|
|
364
|
+
}
|
|
365
|
+
const libDir = join(target, 'lib', name)
|
|
366
|
+
if (existsSync(libDir)) {
|
|
367
|
+
console.error(`lib/${name}/ already exists — refusing to overwrite.`)
|
|
368
|
+
console.error('Use /sf-add-view to extend an existing storefront.')
|
|
369
|
+
return 1
|
|
370
|
+
}
|
|
371
|
+
if (!(await verifyConsistency())) return 1
|
|
372
|
+
|
|
373
|
+
// 2. Next.js base
|
|
374
|
+
const hasPackageJson = existsSync(join(target, 'package.json'))
|
|
375
|
+
if (!hasPackageJson) {
|
|
376
|
+
const stash = join(target, '.opencode.__stash__')
|
|
377
|
+
const opencodeDir = join(target, '.opencode')
|
|
378
|
+
const hadOpenCode = existsSync(opencodeDir)
|
|
379
|
+
if (hadOpenCode) {
|
|
380
|
+
await mkdir(stash, { recursive: true })
|
|
381
|
+
await cp(opencodeDir, stash, { recursive: true })
|
|
382
|
+
await rm(opencodeDir, { recursive: true, force: true })
|
|
383
|
+
}
|
|
384
|
+
const status = run('yarn', ['create', 'next-app', '.', '--ts', '--tailwind', '--app', '--import-alias', '@/*', '--no-turbopack'], target)
|
|
385
|
+
if (hadOpenCode) {
|
|
386
|
+
await cp(stash, opencodeDir, { recursive: true })
|
|
387
|
+
await rm(stash, { recursive: true, force: true })
|
|
388
|
+
}
|
|
389
|
+
if (status !== 0) {
|
|
390
|
+
console.error('create-next-app failed.')
|
|
391
|
+
return status
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 3. shadcn base
|
|
396
|
+
const shadcnStatus = run(
|
|
397
|
+
'yarn',
|
|
398
|
+
['dlx', 'shadcn@latest', 'init', '--template', 'next', '--base', 'base', '--preset', preset, '--no-monorepo', '--no-rtl', '--yes'],
|
|
399
|
+
target,
|
|
400
|
+
)
|
|
401
|
+
if (shadcnStatus !== 0) {
|
|
402
|
+
console.error('shadcn init failed.')
|
|
403
|
+
return shadcnStatus
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 4. Yarn node-modules linker
|
|
407
|
+
await writeFile(join(target, '.yarnrc.yml'), 'nodeLinker: node-modules\n', 'utf8')
|
|
408
|
+
await rm(join(target, '.pnp.cjs'), { force: true })
|
|
409
|
+
|
|
410
|
+
// 5. Dependencies
|
|
411
|
+
const STOREFRONT_DEPS = [
|
|
412
|
+
'@thorprovider/blocks',
|
|
413
|
+
'@thorprovider/adapters',
|
|
414
|
+
'@thorprovider/runtime',
|
|
415
|
+
'@json-render/core',
|
|
416
|
+
'@json-render/react',
|
|
417
|
+
'@json-render/next',
|
|
418
|
+
'motion',
|
|
419
|
+
'zod',
|
|
420
|
+
]
|
|
421
|
+
const addStatus = run('yarn', ['add', ...STOREFRONT_DEPS], target)
|
|
422
|
+
if (addStatus !== 0) {
|
|
423
|
+
console.error('yarn add failed.')
|
|
424
|
+
return addStatus
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 6. Template copy + placeholder substitution
|
|
428
|
+
await renderScaffold({ targetDir: target, name, siteName })
|
|
429
|
+
|
|
430
|
+
// 7. .env.local + theme
|
|
431
|
+
const envLocal = `MEDUSA_BACKEND_URL=${backendUrl}\nMEDUSA_REGION_ID=\nMEDUSA_PUBLISHABLE_KEY=\n`
|
|
432
|
+
await writeFile(join(target, '.env.local'), envLocal, 'utf8')
|
|
433
|
+
|
|
434
|
+
const globalsPath = join(target, 'app', 'globals.css')
|
|
435
|
+
if (existsSync(globalsPath)) {
|
|
436
|
+
const css = readFileSync(globalsPath, 'utf8')
|
|
437
|
+
await writeFile(globalsPath, applyBrand(css, brand), 'utf8')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 8. Suite install + version record
|
|
441
|
+
const opencodeDir = join(target, '.opencode')
|
|
442
|
+
const total = await installSuite(opencodeDir, args.force)
|
|
443
|
+
await writeRecord(target, pkgVersion())
|
|
444
|
+
console.log(`installed ${total} file(s) and recorded the version`)
|
|
445
|
+
|
|
446
|
+
console.log('')
|
|
447
|
+
console.log('Done. Next steps:')
|
|
448
|
+
console.log(` cd ${relative(process.cwd(), target) || '.'}`)
|
|
449
|
+
console.log(' yarn dev')
|
|
450
|
+
console.log('Then use /sf-add-view, /sf-view, /sf-theme to extend the store.')
|
|
451
|
+
return 0
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function update(args) {
|
|
455
|
+
const target = args.dir
|
|
456
|
+
if (!existsSync(target)) {
|
|
457
|
+
console.error(`Target directory does not exist: ${target}`)
|
|
458
|
+
return 1
|
|
459
|
+
}
|
|
460
|
+
if (!(await verifyConsistency())) return 1
|
|
461
|
+
|
|
462
|
+
const opencodeDir = join(target, '.opencode')
|
|
463
|
+
if (!existsSync(opencodeDir)) {
|
|
464
|
+
console.error(`No .opencode/ found in ${target}`)
|
|
465
|
+
console.error('Run `storefront create <dir>` to scaffold and install the suite.')
|
|
466
|
+
return 1
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const total = await installSuite(opencodeDir, args.force)
|
|
470
|
+
for (const rel of PRUNED_PATHS) await prunePath(opencodeDir, rel)
|
|
471
|
+
await writeRecord(target, pkgVersion())
|
|
472
|
+
await rm(join(opencodeDir, LEGACY_RECORD_FILE), { force: true })
|
|
473
|
+
|
|
474
|
+
console.log(`Refreshed install in ${target}:`)
|
|
475
|
+
console.log(` installed ${total} file(s)`)
|
|
476
|
+
console.log(' pruned removed files (sf-init, sf-scaffold)')
|
|
477
|
+
console.log(' migrated version record to storefront.json')
|
|
478
|
+
console.log('Done. Available in OpenCode:')
|
|
479
|
+
console.log(' /sf-add-view /sf-view /sf-theme')
|
|
480
|
+
if (args.force) console.log('(used --force: existing files were overwritten)')
|
|
481
|
+
return 0
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function doctor(args) {
|
|
485
|
+
const target = args.dir
|
|
486
|
+
console.log(`@thorprovider/storefront doctor — ${target}`)
|
|
487
|
+
if (!existsSync(target)) {
|
|
488
|
+
console.error(`Target directory does not exist: ${target}`)
|
|
489
|
+
return 1
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const opencodeDir = join(target, '.opencode')
|
|
493
|
+
const record = await readRecord(opencodeDir)
|
|
494
|
+
if (!record) {
|
|
495
|
+
console.log('No version record found in .opencode/.')
|
|
496
|
+
console.log('Run `storefront update` to install and record the suite.')
|
|
497
|
+
return 0
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const version = pkgVersion()
|
|
501
|
+
console.log(` recorded version: ${record.version} (installed ${record.installedAt})`)
|
|
502
|
+
console.log(` package version: ${version}`)
|
|
503
|
+
|
|
504
|
+
const installedHash = await hashDir(opencodeDir)
|
|
505
|
+
const sourceHash = await hashDir(PKG_ROOT)
|
|
506
|
+
const match = installedHash === sourceHash
|
|
507
|
+
console.log(` installed suite matches package: ${match ? 'yes' : 'no'}`)
|
|
508
|
+
if (!match) {
|
|
509
|
+
console.log(' status: update available — run "storefront update"')
|
|
510
|
+
}
|
|
511
|
+
return 0
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function main() {
|
|
515
|
+
const args = parseArgs(process.argv.slice(2))
|
|
516
|
+
if (!args) return 1
|
|
517
|
+
if (args.help || !args.action) {
|
|
518
|
+
console.log(HELP)
|
|
519
|
+
return 0
|
|
520
|
+
}
|
|
521
|
+
if (args.action === 'create') return create(args)
|
|
522
|
+
if (args.action === 'update') return update(args)
|
|
523
|
+
if (args.action === 'doctor') return doctor(args)
|
|
524
|
+
console.error(`Unknown action: ${args.action}`)
|
|
525
|
+
console.log(HELP)
|
|
526
|
+
return 1
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
main().then((code) => {
|
|
530
|
+
process.exitCode = code
|
|
531
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Add a single view to an existing Thor Commerce storefront (product, category, checkout, user-panel)"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Add one view to an existing Thor Commerce storefront, generated from a recipe.
|
|
6
|
+
|
|
7
|
+
**Input**: view name, optionally followed by a description of what the view should contain or emphasize.
|
|
8
|
+
Examples:
|
|
9
|
+
- `/sf-add-view product`
|
|
10
|
+
- `/sf-add-view category specialize in power stations, use a dense grid`
|
|
11
|
+
- `/sf-add-view checkout keep it single-page`
|
|
12
|
+
|
|
13
|
+
**Provided arguments**: $ARGUMENTS
|
|
14
|
+
|
|
15
|
+
Use the `sf-view-gen` skill.
|
|
16
|
+
|
|
17
|
+
The skill generates from the assets inside its own directory (`assets/recipes/`, `assets/templates/views/`); when a choice is not resolved there, it asks you.
|
|
18
|
+
|
|
19
|
+
**Reads (target project):** existence of `package.json`, `lib/<name>/`, `.opencode/`, and the view name + description.
|
|
20
|
+
**Writes (target project):** the view module and one barrel entry, all reported.
|
|
21
|
+
|
|
22
|
+
**Constraints (do not violate without explicit user authorization):**
|
|
23
|
+
- Allowed view names: `home`, `product`, `category`, `checkout`, `user-panel`
|
|
24
|
+
- Block variants and optional sections MUST come from the skill's own `assets/recipes/` (`recipes.json`, `archetypes.json`, `sections.json`)
|
|
25
|
+
- UI components come ONLY from `@thorprovider/blocks`
|
|
26
|
+
- Data access goes ONLY through `@thorprovider/adapters` / `@thorprovider/runtime/commerce`
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Customize storefront theme: OKLCH colors (light/dark), typography, and identity metadata"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Customize the theme and identity of a Thor Commerce storefront.
|
|
6
|
+
|
|
7
|
+
**Input**: optional — the wizard asks what to customize (colors, typography, identity, SEO, Open Graph).
|
|
8
|
+
**Provided arguments**: $ARGUMENTS
|
|
9
|
+
|
|
10
|
+
Use the `sf-theme-gen` skill.
|
|
11
|
+
|
|
12
|
+
The skill works from its own directory and the vendored `shadcn-theming` skill beside it; when a choice is not resolved there, it asks you.
|
|
13
|
+
|
|
14
|
+
**Reads (target project):** existence of `package.json`, `lib/<name>/`, `.opencode/`, and the wizard answers.
|
|
15
|
+
**Writes (target project):** `app/globals.css` and `app/layout.tsx`, all reported.
|
|
16
|
+
|
|
17
|
+
**Constraints (do not violate without explicit user authorization):**
|
|
18
|
+
- Colors are CSS variables in OKLCH with complete background/foreground pairs (light AND dark)
|
|
19
|
+
- Never use raw Tailwind color utilities for themed colors
|
|
20
|
+
- Typography and metadata follow the skill's own assets
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Modify an existing storefront view's spec while preserving its structure"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Modify an existing view in a Thor Commerce storefront.
|
|
6
|
+
|
|
7
|
+
**Input**: view name followed by the change to make.
|
|
8
|
+
Examples:
|
|
9
|
+
- `/sf-view home add a statistics band after the hero`
|
|
10
|
+
- `/sf-view category switch to a 4-column grid`
|
|
11
|
+
|
|
12
|
+
**Provided arguments**: $ARGUMENTS
|
|
13
|
+
|
|
14
|
+
Use the `sf-view-gen` skill (modification workflow).
|
|
15
|
+
|
|
16
|
+
The skill works from the assets inside its own directory (`assets/recipes/`, `assets/templates/views/`); when a choice is not resolved there, it asks you.
|
|
17
|
+
|
|
18
|
+
**Reads (target project):** existence of `package.json`, `lib/<name>/`, `.opencode/`, the existing view module, and the change requested.
|
|
19
|
+
**Writes (target project):** the edited view module, all reported.
|
|
20
|
+
|
|
21
|
+
**Constraints (do not violate without explicit user authorization):**
|
|
22
|
+
- Allowed view names: `home`, `product`, `category`, `checkout`, `user-panel`
|
|
23
|
+
- Preserve element ids, `type`, untouched `props`, `$state` bindings, and `on` wiring
|
|
24
|
+
- Only add block variants/sections declared in the skill's own `assets/recipes/`
|
|
25
|
+
- UI components come ONLY from `@thorprovider/blocks`
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thorprovider/storefront",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Deterministic storefront scaffolder and installable OpenCode command suite for Thor Commerce (Next.js, @thorprovider/blocks, @thorprovider/adapters, Medusa)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": "./bin/storefront.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"assets",
|
|
9
|
+
"bin",
|
|
10
|
+
"commands",
|
|
11
|
+
"skills",
|
|
12
|
+
"recipes",
|
|
13
|
+
"scripts",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.17.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"sync-assets": "node ./scripts/sync-assets.mjs",
|
|
21
|
+
"check-assets": "node ./scripts/sync-assets.mjs --check",
|
|
22
|
+
"pretest": "node ./scripts/sync-assets.mjs --check",
|
|
23
|
+
"test": "vitest run --config vitest.config.ts"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org/"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/thorprovider/thor-commerce",
|
|
32
|
+
"directory": "packages/storefront"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"thor-commerce",
|
|
36
|
+
"opencode",
|
|
37
|
+
"storefront",
|
|
38
|
+
"json-render",
|
|
39
|
+
"scaffolding"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@clack/prompts": "^1.8.1"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"vitest": "^3.2.4"
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT"
|
|
48
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "archetype.schema.json",
|
|
4
|
+
"title": "Storefront archetype manifest",
|
|
5
|
+
"description": "Maps slots to named intents. Each intent resolves to a block variant that must be allowed by the view recipe for that slot.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["version", "slots"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"$schema": { "type": "string" },
|
|
11
|
+
"version": { "type": "string" },
|
|
12
|
+
"description": { "type": "string" },
|
|
13
|
+
"slots": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"minProperties": 1,
|
|
16
|
+
"additionalProperties": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": ["default", "intents"],
|
|
19
|
+
"additionalProperties": false,
|
|
20
|
+
"properties": {
|
|
21
|
+
"default": { "type": "string" },
|
|
22
|
+
"intents": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"minProperties": 1,
|
|
25
|
+
"additionalProperties": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"required": ["variant", "when"],
|
|
28
|
+
"additionalProperties": false,
|
|
29
|
+
"properties": {
|
|
30
|
+
"variant": { "type": "string" },
|
|
31
|
+
"when": { "type": "string", "minLength": 10 }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|