@propeller-commerce/create-propeller-shop 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,733 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ PropellerJsonSchema,
4
+ buildPropellerJson,
5
+ getCliVersion
6
+ } from "../chunk-UMI3HB67.js";
7
+
8
+ // src/bin/create-propeller-shop.ts
9
+ import { Command } from "commander";
10
+ import chalk2 from "chalk";
11
+
12
+ // src/commands/scaffold.ts
13
+ import { promises as fs3 } from "fs";
14
+ import * as path3 from "path";
15
+ import chalk from "chalk";
16
+ import ora from "ora";
17
+ import { execa as execa2 } from "execa";
18
+
19
+ // src/prompts/index.ts
20
+ import { input, select, confirm } from "@inquirer/prompts";
21
+ var KEBAB = /^[a-z][a-z0-9-]*$/;
22
+ function defaultPortalForMode(mode) {
23
+ return mode === "b2b" ? "semi-closed" : "open";
24
+ }
25
+ async function collectShopConfig(defaults) {
26
+ const yes = defaults.yes === true;
27
+ const name = defaults.name ?? await input({
28
+ message: "Shop name (kebab-case, used as folder + package name):",
29
+ validate: (v) => KEBAB.test(v) ? true : "Use lowercase letters, digits, and hyphens; start with a letter."
30
+ });
31
+ const stack = defaults.stack ?? (yes ? "next" : await select({
32
+ message: "Frontend stack?",
33
+ choices: [
34
+ { name: "Next.js 16 (React)", value: "next" },
35
+ { name: "Vue 3 + Vite SSR", value: "vue" },
36
+ { name: "Nuxt 3 (Vue SSR)", value: "nuxt" }
37
+ ]
38
+ }));
39
+ const mode = defaults.mode ?? (yes ? "hybrid" : await select({
40
+ message: "Shop mode?",
41
+ choices: [
42
+ { name: "Hybrid (both Contact and Customer users)", value: "hybrid" },
43
+ { name: "B2B only (Contacts; semi-closed by default)", value: "b2b" },
44
+ { name: "B2C only (Customers; open by default)", value: "b2c" }
45
+ ]
46
+ }));
47
+ const cmsChoice = defaults.cms ?? (yes ? "none" : await select({
48
+ message: "CMS adapter?",
49
+ choices: [
50
+ { name: "Strapi (open-source headless CMS)", value: "strapi" },
51
+ { name: "Generic Propeller CMS", value: "cms" },
52
+ { name: "None \u2014 homepage falls back to static, marketing slugs return 404", value: "none" }
53
+ ]
54
+ }));
55
+ const cmsAdapter = cmsChoice === "none" ? null : cmsChoice;
56
+ const localesStr = defaults.locales?.join(",") ?? (yes ? "en" : await input({
57
+ message: "Locales (comma-separated BCP-47 codes):",
58
+ default: "en",
59
+ validate: (v) => v.split(",").every((s) => s.trim().length > 0) ? true : "At least one locale required."
60
+ }));
61
+ const locales = localesStr.split(",").map((s) => s.trim()).filter(Boolean);
62
+ const defaultLocale = defaults.defaultLocale ?? (yes ? locales[0] : await select({
63
+ message: "Default locale?",
64
+ choices: locales.map((l) => ({ name: l, value: l }))
65
+ }));
66
+ const currencyCode = defaults.currencyCode ?? (yes ? "EUR" : await input({
67
+ message: "Currency code (ISO 4217)?",
68
+ default: "EUR",
69
+ validate: (v) => /^[A-Z]{3}$/.test(v) ? true : "Three uppercase letters, e.g. EUR."
70
+ }));
71
+ const currency = defaults.currency ?? defaultCurrencySymbol(currencyCode);
72
+ const portalMode = defaults.portalMode ?? (yes ? defaultPortalForMode(mode) : await select({
73
+ message: "Portal access mode?",
74
+ choices: [
75
+ { name: "open (anonymous users see catalog + prices)", value: "open" },
76
+ { name: "semi-closed (catalog visible, prices hidden until login)", value: "semi-closed" },
77
+ { name: "closed (login required for anything)", value: "closed" }
78
+ ],
79
+ default: defaultPortalForMode(mode)
80
+ }));
81
+ const siteUrl = defaults.siteUrl ?? (yes ? `https://${name}.example.com` : await input({
82
+ message: "Site URL (no trailing slash):",
83
+ default: `https://${name}.example.com`,
84
+ validate: (v) => {
85
+ try {
86
+ new URL(v);
87
+ return v.endsWith("/") ? "Remove trailing slash." : true;
88
+ } catch {
89
+ return "Must be a valid URL.";
90
+ }
91
+ }
92
+ }));
93
+ const skipInstall = defaults.skipInstall ?? (yes ? false : !await confirm({
94
+ message: "Run `npm install` now?",
95
+ default: true
96
+ }));
97
+ return {
98
+ name,
99
+ stack,
100
+ mode,
101
+ cmsAdapter,
102
+ locales,
103
+ defaultLocale,
104
+ currency,
105
+ currencyCode,
106
+ portalMode,
107
+ siteUrl,
108
+ skipInstall
109
+ };
110
+ }
111
+ function defaultCurrencySymbol(code) {
112
+ switch (code) {
113
+ case "EUR":
114
+ return "\u20AC";
115
+ case "USD":
116
+ return "$";
117
+ case "GBP":
118
+ return "\xA3";
119
+ case "CHF":
120
+ return "CHF";
121
+ case "SEK":
122
+ case "NOK":
123
+ case "DKK":
124
+ return "kr";
125
+ case "PLN":
126
+ return "z\u0142";
127
+ default:
128
+ return code;
129
+ }
130
+ }
131
+
132
+ // src/template/substitute.ts
133
+ import Handlebars from "handlebars";
134
+ function buildContext(config, templateVersion) {
135
+ const siteUrlObj = (() => {
136
+ try {
137
+ return new URL(config.siteUrl);
138
+ } catch {
139
+ return null;
140
+ }
141
+ })();
142
+ const siteHostname = siteUrlObj?.hostname ?? "";
143
+ const apiHostname = siteHostname.split(".").slice(-2).join(".") || "example.com";
144
+ return {
145
+ // Identity
146
+ shopName: config.name,
147
+ shopDescription: `${config.name} \u2014 powered by Propeller Commerce`,
148
+ siteUrl: config.siteUrl,
149
+ siteHostname,
150
+ apiHostname,
151
+ // Stack & mode
152
+ stack: config.stack,
153
+ shopMode: config.mode,
154
+ isB2B: config.mode === "b2b",
155
+ isB2C: config.mode === "b2c",
156
+ isHybrid: config.mode === "hybrid",
157
+ // Locale & currency
158
+ defaultLocale: config.defaultLocale,
159
+ defaultLocaleLower: config.defaultLocale.toLowerCase(),
160
+ defaultLanguage: config.defaultLocale.split(/[_-]/)[0].toUpperCase(),
161
+ locales: config.locales,
162
+ localesArray: JSON.stringify(config.locales),
163
+ localesJsonArray: JSON.stringify(config.locales),
164
+ nonDefaultLocalesJsonArray: JSON.stringify(
165
+ config.locales.filter((l) => l.toLowerCase() !== config.defaultLocale.toLowerCase())
166
+ ),
167
+ currency: config.currency,
168
+ currencyCode: config.currencyCode,
169
+ // Portal & integration
170
+ portalMode: config.portalMode,
171
+ portalModeConstant: portalModeConstantFor(config.portalMode),
172
+ channelId: 621,
173
+ anonymousId: 71,
174
+ taxZone: "NL",
175
+ // CMS
176
+ cmsAdapter: config.cmsAdapter,
177
+ cmsAdapterTsValue: cmsAdapterTsValueFor(config.cmsAdapter),
178
+ cmsAdapterJsonValue: cmsAdapterJsonValueFor(config.cmsAdapter),
179
+ cmsAdapterPackage: cmsAdapterPackageFor(config.cmsAdapter),
180
+ cmsAdapterDisplay: config.cmsAdapter ?? "none",
181
+ // Mode-derived UI defaults
182
+ showUserType: showUserTypeFor(config.mode),
183
+ // Feature flags — defaults track shopMode; ejectable later.
184
+ featureQuotes: config.mode !== "b2c",
185
+ featureAuthorization: config.mode !== "b2c",
186
+ featureContacts: config.mode !== "b2c",
187
+ // Versioning
188
+ templateVersion
189
+ };
190
+ }
191
+ function showUserTypeFor(mode) {
192
+ switch (mode) {
193
+ case "b2b":
194
+ return "'Contact'";
195
+ case "b2c":
196
+ return "'Customer'";
197
+ case "hybrid":
198
+ return "null";
199
+ }
200
+ }
201
+ function portalModeConstantFor(mode) {
202
+ switch (mode) {
203
+ case "open":
204
+ return "OPEN";
205
+ case "semi-closed":
206
+ return "SEMI_CLOSED";
207
+ case "closed":
208
+ return "CLOSED";
209
+ }
210
+ }
211
+ function cmsAdapterTsValueFor(adapter) {
212
+ return adapter === null ? "null" : `'${adapter}'`;
213
+ }
214
+ function cmsAdapterJsonValueFor(adapter) {
215
+ return adapter === null ? "null" : JSON.stringify(adapter);
216
+ }
217
+ function cmsAdapterPackageFor(adapter) {
218
+ if (adapter === null) return "";
219
+ return adapter === "strapi" ? "propeller-v2-cms-adapter-strapi" : "propeller-v2-cms-adapter-cms";
220
+ }
221
+ function renderTemplate(source, context) {
222
+ const template = Handlebars.compile(source, {
223
+ strict: true,
224
+ // missing variable → throw with name
225
+ noEscape: true
226
+ // template output is code; do not HTML-escape
227
+ });
228
+ return template(context);
229
+ }
230
+ function stripTemplateSuffix(filename) {
231
+ return filename.replace(/\.template(?=\.[^.]+$)/, "");
232
+ }
233
+ function isTemplateFile(filename) {
234
+ return /\.template\.[^.]+$/.test(filename);
235
+ }
236
+
237
+ // src/template/clone.ts
238
+ import { promises as fs2, existsSync } from "fs";
239
+ import * as path2 from "path";
240
+ import { fileURLToPath } from "url";
241
+ import { execa } from "execa";
242
+
243
+ // src/template/jsonPatch.ts
244
+ import { promises as fs } from "fs";
245
+ import * as path from "path";
246
+ var PATCH_SUFFIX = ".patch.json";
247
+ function isJsonPatch(name) {
248
+ return name.endsWith(PATCH_SUFFIX);
249
+ }
250
+ function jsonPatchTargetName(name) {
251
+ return name.slice(0, -PATCH_SUFFIX.length) + ".json";
252
+ }
253
+ async function applyJsonPatch(args) {
254
+ const patchRaw = await fs.readFile(args.patchPath, "utf8");
255
+ const patchRendered = renderTemplate(patchRaw, args.ctx);
256
+ let patch;
257
+ try {
258
+ patch = JSON.parse(patchRendered);
259
+ } catch (e) {
260
+ throw new Error(
261
+ `JSON patch ${args.patchPath} is not valid JSON after rendering: ${e.message}`
262
+ );
263
+ }
264
+ let target;
265
+ try {
266
+ const targetRaw = await fs.readFile(args.targetPath, "utf8");
267
+ target = JSON.parse(targetRaw);
268
+ } catch (e) {
269
+ throw new Error(
270
+ `Cannot apply patch ${path.basename(args.patchPath)}: target ${args.targetPath} missing or unparseable. The boilerplate is supposed to ship this file. (${e.message})`
271
+ );
272
+ }
273
+ const merged = deepMerge(target, patch);
274
+ await fs.writeFile(args.targetPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
275
+ }
276
+ function deepMerge(base, patch) {
277
+ if (!isPlainObject(base) || !isPlainObject(patch)) {
278
+ return patch;
279
+ }
280
+ const out = { ...base };
281
+ for (const [key, patchValue] of Object.entries(patch)) {
282
+ if (patchValue === null) {
283
+ delete out[key];
284
+ continue;
285
+ }
286
+ if (isPlainObject(out[key]) && isPlainObject(patchValue)) {
287
+ out[key] = deepMerge(out[key], patchValue);
288
+ } else {
289
+ out[key] = patchValue;
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+ function isPlainObject(v) {
295
+ return typeof v === "object" && v !== null && !Array.isArray(v);
296
+ }
297
+
298
+ // src/template/clone.ts
299
+ var BOILERPLATE_REPOS = {
300
+ next: "https://github.com/propeller-commerce/propeller-v2-next-boilerplate.git",
301
+ vue: "https://github.com/propeller-commerce/propeller-v2-vue-boilerplate.git",
302
+ nuxt: "https://github.com/propeller-commerce/propeller-v2-nuxt-boilerplate.git"
303
+ };
304
+ var BOILERPLATE_FRONTEND_SUBPATH = {
305
+ next: ".",
306
+ vue: "frontend",
307
+ nuxt: "."
308
+ };
309
+ var DEFAULT_BOILERPLATE_REF = "master";
310
+ var LOCAL_BOILERPLATE_ENV_VARS = {
311
+ next: "PROPELLER_NEXT_BOILERPLATE_LOCAL",
312
+ vue: "PROPELLER_VUE_BOILERPLATE_LOCAL",
313
+ nuxt: "PROPELLER_NUXT_BOILERPLATE_LOCAL"
314
+ };
315
+ function resolveTemplatesRoot() {
316
+ if (process.env.PROPELLER_TEMPLATES_DIR) return process.env.PROPELLER_TEMPLATES_DIR;
317
+ const here = path2.dirname(fileURLToPath(import.meta.url));
318
+ const published = path2.resolve(here, "..", "..", "templates");
319
+ if (existsSync(published)) return published;
320
+ const dev = path2.resolve(here, "..", "..", "..", "..", "templates");
321
+ return dev;
322
+ }
323
+ async function cloneBoilerplate(args) {
324
+ const localOverride = process.env[LOCAL_BOILERPLATE_ENV_VARS[args.stack]];
325
+ if (localOverride) {
326
+ const subpath = BOILERPLATE_FRONTEND_SUBPATH[args.stack];
327
+ const src = subpath === "." ? localOverride : path2.join(localOverride, subpath);
328
+ if (!existsSync(src)) {
329
+ throw new Error(
330
+ `Local boilerplate override ${LOCAL_BOILERPLATE_ENV_VARS[args.stack]}=${localOverride} points at ${src} which doesn't exist.`
331
+ );
332
+ }
333
+ const filesCloned = await copyDirVerbatim(src, args.destFrontend);
334
+ return { filesCloned, upstreamCommit: "local-override" };
335
+ }
336
+ const repo = BOILERPLATE_REPOS[args.stack];
337
+ const tmpRoot = await fs2.mkdtemp(path2.join(
338
+ process.env.TEMP || process.env.TMPDIR || "/tmp",
339
+ `propeller-boilerplate-${args.stack}-`
340
+ ));
341
+ try {
342
+ await execa("git", [
343
+ "clone",
344
+ "--depth",
345
+ "1",
346
+ "--branch",
347
+ args.ref,
348
+ "--single-branch",
349
+ repo,
350
+ tmpRoot
351
+ ], { stdio: "pipe" });
352
+ const { stdout: commit } = await execa("git", ["-C", tmpRoot, "rev-parse", "HEAD"]);
353
+ const subpath = BOILERPLATE_FRONTEND_SUBPATH[args.stack];
354
+ const src = subpath === "." ? tmpRoot : path2.join(tmpRoot, subpath);
355
+ if (!existsSync(src)) {
356
+ throw new Error(
357
+ `Cloned boilerplate at ${repo}#${args.ref} doesn't have a "${subpath}" subdirectory.`
358
+ );
359
+ }
360
+ const filesCloned = await copyDirVerbatim(src, args.destFrontend);
361
+ return { filesCloned, upstreamCommit: commit.trim() };
362
+ } finally {
363
+ await fs2.rm(tmpRoot, { recursive: true, force: true });
364
+ }
365
+ }
366
+ async function applyOverlay(args) {
367
+ let filesOverlaid = 0;
368
+ let filesTemplated = 0;
369
+ let filesPatched = 0;
370
+ if (!existsSync(args.overlaySrc)) {
371
+ return { filesOverlaid, filesTemplated, filesPatched };
372
+ }
373
+ await walk(args.overlaySrc, async (entry) => {
374
+ const rel = path2.relative(args.overlaySrc, entry.fullPath);
375
+ const destPath = path2.join(args.destFrontend, rel);
376
+ if (entry.isDirectory) {
377
+ await fs2.mkdir(destPath, { recursive: true });
378
+ return;
379
+ }
380
+ await fs2.mkdir(path2.dirname(destPath), { recursive: true });
381
+ if (isJsonPatch(entry.name)) {
382
+ const targetName = jsonPatchTargetName(entry.name);
383
+ const targetPath = path2.join(path2.dirname(destPath), targetName);
384
+ await applyJsonPatch({
385
+ patchPath: entry.fullPath,
386
+ targetPath,
387
+ ctx: args.ctx
388
+ });
389
+ filesPatched += 1;
390
+ } else if (isTemplateFile(entry.name)) {
391
+ const source = await fs2.readFile(entry.fullPath, "utf8");
392
+ const rendered = renderTemplate(source, args.ctx);
393
+ const finalPath = path2.join(
394
+ path2.dirname(destPath),
395
+ stripTemplateSuffix(entry.name)
396
+ );
397
+ await fs2.writeFile(finalPath, rendered, "utf8");
398
+ filesTemplated += 1;
399
+ } else {
400
+ await fs2.copyFile(entry.fullPath, destPath);
401
+ filesOverlaid += 1;
402
+ }
403
+ });
404
+ return { filesOverlaid, filesTemplated, filesPatched };
405
+ }
406
+ async function trimB2C(args) {
407
+ if (!existsSync(args.trimManifestPath)) return 0;
408
+ const raw = await fs2.readFile(args.trimManifestPath, "utf8");
409
+ const manifest = JSON.parse(raw);
410
+ let count = 0;
411
+ for (const rel of manifest.remove) {
412
+ const target = path2.join(args.destFrontend, rel);
413
+ if (existsSync(target)) {
414
+ await fs2.rm(target, { recursive: true, force: true });
415
+ count += 1;
416
+ }
417
+ }
418
+ return count;
419
+ }
420
+ async function scaffoldFromBoilerplate(args) {
421
+ const ref = args.ref ?? DEFAULT_BOILERPLATE_REF;
422
+ const clone = await cloneBoilerplate({
423
+ stack: args.stack,
424
+ ref,
425
+ destFrontend: args.destFrontend
426
+ });
427
+ const templatesRoot = resolveTemplatesRoot();
428
+ const stackRoot = path2.join(templatesRoot, `shop-${args.stack}`);
429
+ const overlay = await applyOverlay({
430
+ overlaySrc: path2.join(stackRoot, "overlay"),
431
+ destFrontend: args.destFrontend,
432
+ ctx: args.ctx
433
+ });
434
+ let filesTrimmed = 0;
435
+ if (args.mode === "b2c") {
436
+ filesTrimmed = await trimB2C({
437
+ trimManifestPath: path2.join(stackRoot, "b2c-trim.json"),
438
+ destFrontend: args.destFrontend
439
+ });
440
+ }
441
+ return {
442
+ filesCloned: clone.filesCloned,
443
+ filesOverlaid: overlay.filesOverlaid,
444
+ filesTemplated: overlay.filesTemplated,
445
+ filesPatched: overlay.filesPatched,
446
+ filesTrimmed,
447
+ upstreamRef: ref,
448
+ upstreamCommit: clone.upstreamCommit
449
+ };
450
+ }
451
+ var VERBATIM_SKIP_TOP = /* @__PURE__ */ new Set([
452
+ ".git",
453
+ "node_modules",
454
+ ".next",
455
+ ".nuxt",
456
+ ".output",
457
+ "dist",
458
+ ".vite",
459
+ ".turbo",
460
+ "playwright-report",
461
+ "test-results",
462
+ "coverage",
463
+ ".claude",
464
+ "memory"
465
+ ]);
466
+ var VERBATIM_SKIP_FILE = /* @__PURE__ */ new Set([
467
+ ".env",
468
+ ".env.local",
469
+ ".env.development",
470
+ ".env.production",
471
+ ".env.development.local",
472
+ ".env.production.local"
473
+ ]);
474
+ async function copyDirVerbatim(src, dest) {
475
+ let count = 0;
476
+ await walkWithSkip(src, async (entry) => {
477
+ const rel = path2.relative(src, entry.fullPath);
478
+ const destPath = path2.join(dest, rel);
479
+ if (entry.isDirectory) {
480
+ await fs2.mkdir(destPath, { recursive: true });
481
+ return;
482
+ }
483
+ await fs2.mkdir(path2.dirname(destPath), { recursive: true });
484
+ await fs2.copyFile(entry.fullPath, destPath);
485
+ count += 1;
486
+ }, VERBATIM_SKIP_TOP);
487
+ return count;
488
+ }
489
+ var SKIP_NAMES = /* @__PURE__ */ new Set([".gitkeep"]);
490
+ async function walk(dir, visit) {
491
+ const entries = await fs2.readdir(dir, { withFileTypes: true });
492
+ for (const entry of entries) {
493
+ if (SKIP_NAMES.has(entry.name)) continue;
494
+ const fullPath = path2.join(dir, entry.name);
495
+ await visit({ fullPath, name: entry.name, isDirectory: entry.isDirectory() });
496
+ if (entry.isDirectory()) {
497
+ await walk(fullPath, visit);
498
+ }
499
+ }
500
+ }
501
+ async function walkWithSkip(root, visit, skipDirNames, current) {
502
+ const here = current ?? root;
503
+ const entries = await fs2.readdir(here, { withFileTypes: true });
504
+ for (const entry of entries) {
505
+ if (SKIP_NAMES.has(entry.name)) continue;
506
+ if (entry.isDirectory() && skipDirNames.has(entry.name)) continue;
507
+ if (!entry.isDirectory() && here === root && VERBATIM_SKIP_FILE.has(entry.name)) continue;
508
+ const fullPath = path2.join(here, entry.name);
509
+ await visit({ fullPath, name: entry.name, isDirectory: entry.isDirectory() });
510
+ if (entry.isDirectory()) {
511
+ await walkWithSkip(root, visit, skipDirNames, fullPath);
512
+ }
513
+ }
514
+ }
515
+
516
+ // src/template/cmsReadme.ts
517
+ function buildCmsReadme(adapter, shopName) {
518
+ const header = `# ${shopName} \u2014 CMS
519
+
520
+ This folder hosts the CMS install for the ${shopName} shop.
521
+
522
+ `;
523
+ switch (adapter) {
524
+ case "strapi":
525
+ return header + `## Install Strapi
526
+
527
+ \`\`\`bash
528
+ npx create-strapi-app@latest .
529
+ \`\`\`
530
+
531
+ Pick **TypeScript** when prompted; the adapter's mappers assume the
532
+ default content-type shape. Once Strapi runs, set its public URL in
533
+ \`../frontend/.env.local\`:
534
+
535
+ \`\`\`
536
+ CMS_URL=http://localhost:1337
537
+ \`\`\`
538
+
539
+ The frontend resolves CMS pages at \`/{slug}\` through the catch-all
540
+ route. Pages not found in Strapi return 404; the homepage falls back
541
+ to its built-in static structure when Strapi returns null.
542
+ `;
543
+ case "cms":
544
+ return header + `## Install Propeller CMS
545
+
546
+ See the Propeller CMS install guide:
547
+ https://docs.propeller-commerce.com/cms/install
548
+
549
+ Once installed, set its public URL in \`../frontend/.env.local\`:
550
+
551
+ \`\`\`
552
+ CMS_URL=http://localhost:8080
553
+ \`\`\`
554
+
555
+ The frontend resolves CMS pages at \`/{slug}\` through the catch-all
556
+ route. Pages not found in the CMS return 404; the homepage falls
557
+ back to its built-in static structure when the CMS returns null.
558
+ `;
559
+ case null:
560
+ return header + `## No CMS configured
561
+
562
+ This shop was scaffolded without a CMS. Marketing-content slugs
563
+ (About Us, FAQ, \u2026) will return 404. The homepage uses the
564
+ built-in static \`<HomeFallback>\` component shipped in the
565
+ template.
566
+
567
+ To add a CMS later:
568
+
569
+ 1. Install Strapi or Propeller CMS in this folder.
570
+ 2. Install the matching adapter package in \`../frontend/\`:
571
+ - \`npm i propeller-v2-cms-adapter-strapi\`
572
+ 3. Wire it in \`../frontend/app/providers.tsx\` (Next) or
573
+ \`src/main.ts\` (Vue): pass the adapter into
574
+ \`<CmsAdapterProvider>\` / \`provideCmsAdapter()\`.
575
+ 4. Set \`CMS_URL\` in \`../frontend/.env.local\`.
576
+ 5. Update \`propeller.json\` \u2192 \`cms.adapter\` so \`propeller doctor\`
577
+ knows which adapter to verify.
578
+ `;
579
+ }
580
+ }
581
+
582
+ // src/commands/scaffold.ts
583
+ async function runScaffold(opts) {
584
+ const cwd = opts.cwd ?? process.cwd();
585
+ const config = await collectShopConfig(opts);
586
+ const root = path3.resolve(cwd, config.name);
587
+ const frontend = path3.join(root, "frontend");
588
+ const cmsFolder = path3.join(root, "cms");
589
+ if (await pathExists(root)) {
590
+ throw new Error(
591
+ `Destination "${root}" already exists. Pick a different name or remove the existing folder.`
592
+ );
593
+ }
594
+ const spinner = ora({ text: `Scaffolding ${config.name}\u2026`, color: "cyan" }).start();
595
+ try {
596
+ await fs3.mkdir(frontend, { recursive: true });
597
+ await fs3.mkdir(cmsFolder, { recursive: true });
598
+ const templateVersion = await getCliVersion();
599
+ const ctx = buildContext(config, templateVersion);
600
+ spinner.text = "Cloning boilerplate\u2026";
601
+ const copyStats = await scaffoldFromBoilerplate({
602
+ stack: config.stack,
603
+ mode: config.mode,
604
+ destFrontend: frontend,
605
+ ctx
606
+ });
607
+ const propellerJson = buildPropellerJson({
608
+ templateName: `propeller-shop-template-${config.stack}`,
609
+ templateVersion,
610
+ stack: config.stack,
611
+ shopName: config.name,
612
+ mode: config.mode,
613
+ locales: config.locales,
614
+ defaultLocale: config.defaultLocale,
615
+ currency: config.currency,
616
+ currencyCode: config.currencyCode,
617
+ portalMode: config.portalMode,
618
+ siteUrl: config.siteUrl,
619
+ cmsAdapter: config.cmsAdapter
620
+ });
621
+ PropellerJsonSchema.parse(propellerJson);
622
+ const asciiSafeJson = JSON.stringify(propellerJson, null, 2).replace(
623
+ /[\u0080-\uFFFF]/g,
624
+ (c) => "\\u" + c.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")
625
+ );
626
+ await fs3.writeFile(
627
+ path3.join(frontend, "propeller.json"),
628
+ asciiSafeJson + "\n",
629
+ "utf8"
630
+ );
631
+ await fs3.writeFile(
632
+ path3.join(cmsFolder, "README.md"),
633
+ buildCmsReadme(config.cmsAdapter, config.name),
634
+ "utf8"
635
+ );
636
+ spinner.succeed(
637
+ `Scaffolded ${config.name} \u2014 ${copyStats.filesCloned} files from boilerplate@${copyStats.upstreamCommit.slice(0, 8)} + ${copyStats.filesOverlaid + copyStats.filesTemplated} overlay (${copyStats.filesTemplated} templated)` + (copyStats.filesTrimmed ? ` \u2212 ${copyStats.filesTrimmed} b2c-trimmed` : "") + "."
638
+ );
639
+ } catch (err) {
640
+ spinner.fail(`Scaffolding failed: ${err.message}`);
641
+ await safeRemove(root);
642
+ throw err;
643
+ }
644
+ if (!config.skipInstall) {
645
+ const installSpinner = ora({ text: "Running npm install in frontend\u2026" }).start();
646
+ try {
647
+ await execa2("npm", ["install"], { cwd: frontend, stdio: "pipe" });
648
+ installSpinner.succeed("npm install complete.");
649
+ } catch (err) {
650
+ installSpinner.warn(
651
+ `npm install failed \u2014 you can re-run it manually. (${err.message})`
652
+ );
653
+ }
654
+ }
655
+ try {
656
+ await execa2("git", ["init", "--initial-branch=main"], { cwd: root, stdio: "pipe" });
657
+ await execa2("git", ["add", "."], { cwd: root, stdio: "pipe" });
658
+ await execa2(
659
+ "git",
660
+ ["commit", "-m", `Scaffolded with create-propeller-shop@${await getCliVersion()}`],
661
+ { cwd: root, stdio: "pipe" }
662
+ );
663
+ } catch {
664
+ }
665
+ printNextSteps(config, root);
666
+ }
667
+ function printNextSteps(config, root) {
668
+ const relFrontend = path3.join(config.name, "frontend");
669
+ const relCms = path3.join(config.name, "cms");
670
+ const [envExample, envTarget] = config.stack === "next" ? [".env.local.example", ".env.local"] : [".env.example", ".env"];
671
+ console.log(
672
+ [
673
+ "",
674
+ chalk.bold.green(`\u2713 ${config.name} is ready.`),
675
+ "",
676
+ chalk.bold("Next steps:"),
677
+ ` 1. cd ${relFrontend}`,
678
+ ` 2. Copy ${envExample} to ${envTarget} and fill in the backend endpoints.`,
679
+ ` 3. npm run dev`,
680
+ "",
681
+ config.cmsAdapter ? ` See ${relCms}/README.md to install the ${config.cmsAdapter} backend.` : ` No CMS configured \u2014 see ${relCms}/README.md to add one later.`,
682
+ ""
683
+ ].join("\n")
684
+ );
685
+ }
686
+ async function pathExists(p) {
687
+ try {
688
+ await fs3.access(p);
689
+ return true;
690
+ } catch {
691
+ return false;
692
+ }
693
+ }
694
+ async function safeRemove(p) {
695
+ try {
696
+ await fs3.rm(p, { recursive: true, force: true });
697
+ } catch {
698
+ }
699
+ }
700
+
701
+ // src/bin/create-propeller-shop.ts
702
+ async function main() {
703
+ const cli = new Command();
704
+ const version = await getCliVersion();
705
+ cli.name("create-propeller-shop").description("Scaffold a Propeller Commerce shop.").version(version).argument("[name]", "Shop name (kebab-case)").option("--stack <stack>", "Frontend stack: next | vue | nuxt").option("--mode <mode>", "Shop mode: b2b | b2c | hybrid").option("--cms <cms>", "CMS adapter: strapi | cms | none").option("--locales <list>", "Comma-separated locale list (e.g. en,nl)").option("--default-locale <code>", "Default locale").option("--currency-code <iso>", "ISO 4217 currency code (e.g. EUR)").option("--portal-mode <mode>", "open | semi-closed | closed").option("--site-url <url>", "Public site origin (no trailing slash)").option("--skip-install", "Skip npm install after scaffolding").option("-y, --yes", "Accept defaults for non-critical prompts").action(async (name, options) => {
706
+ const scaffoldOpts = {
707
+ name: name ?? void 0,
708
+ stack: options.stack,
709
+ mode: options.mode,
710
+ cms: options.cms,
711
+ locales: options.locales ? String(options.locales).split(",").map((s) => s.trim()) : void 0,
712
+ defaultLocale: options.defaultLocale,
713
+ currencyCode: options.currencyCode,
714
+ portalMode: options.portalMode,
715
+ siteUrl: options.siteUrl,
716
+ skipInstall: options.skipInstall === true,
717
+ yes: options.yes === true
718
+ };
719
+ try {
720
+ await runScaffold(scaffoldOpts);
721
+ } catch (err) {
722
+ console.error(chalk2.red(`
723
+ Scaffold failed: ${err.message}`));
724
+ process.exit(1);
725
+ }
726
+ });
727
+ await cli.parseAsync(process.argv);
728
+ }
729
+ main().catch((err) => {
730
+ console.error(chalk2.red(`Unexpected error: ${err.message}`));
731
+ process.exit(1);
732
+ });
733
+ //# sourceMappingURL=create-propeller-shop.js.map