@saastemly/voidcommerce 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  distHelp,
7
7
  ensureGenerated,
8
8
  findProject,
9
+ findWrangler,
9
10
  finishStrict,
10
11
  fullHelp,
11
12
  generate,
@@ -14,14 +15,17 @@ import {
14
15
  initHelp,
15
16
  isVoidApp,
16
17
  line,
18
+ parseJsonc,
17
19
  preflight,
18
20
  printPreflight,
19
21
  row,
20
22
  runInherit,
21
23
  runVoid,
24
+ upsertJsonc,
22
25
  version,
23
- voidAppsIn
24
- } from "./index-tk46w7yn.js";
26
+ voidAppsIn,
27
+ wrangler
28
+ } from "./index-pghh8nnq.js";
25
29
  import {
26
30
  LOCAL_KEY_FILE,
27
31
  PRIVATE_KEY_VAR,
@@ -34,24 +38,138 @@ import {
34
38
  plaintextSecretEntries,
35
39
  plaintextSecretNames,
36
40
  run,
41
+ secretValue,
37
42
  secretsCommand
38
- } from "./index-hsc97bf2.js";
43
+ } from "./index-940vpkwa.js";
39
44
  import {
40
45
  LAYOUTS,
41
46
  oneOrigin,
42
47
  readManifest,
43
48
  validate,
49
+ workerHosts,
50
+ writeManifest,
44
51
  zoneOf
45
52
  } from "./index-30y19qz5.js";
46
53
  import {
47
54
  GROUPS
48
55
  } from "./index-xyjhy6kp.js";
56
+ import"./index-6e4eh08g.js";
49
57
  import"./index-0v6na3yp.js";
50
58
 
51
59
  // src/deploy/index.ts
52
- import color from "picocolors";
53
- import { existsSync } from "node:fs";
60
+ import color2 from "picocolors";
61
+ import { existsSync as existsSync2 } from "node:fs";
62
+ import { join as join2 } from "node:path";
63
+
64
+ // src/deploy/teardown.ts
65
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
54
66
  import { join } from "node:path";
67
+ import color from "picocolors";
68
+ var fail = (message) => {
69
+ console.error(`
70
+ ${color.red("✗")} ${message}
71
+ `);
72
+ return 1;
73
+ };
74
+ function teardownPlan(config, fallbackWorker) {
75
+ const queues = new Set;
76
+ for (const producer of config.queues?.producers ?? [])
77
+ if (producer.queue)
78
+ queues.add(producer.queue);
79
+ for (const consumer of config.queues?.consumers ?? [])
80
+ if (consumer.queue)
81
+ queues.add(consumer.queue);
82
+ const d1 = config.d1_databases?.find((db) => db.binding === "DB");
83
+ return {
84
+ worker: config.name || fallbackWorker,
85
+ d1: d1?.database_name && d1.database_id !== "local" ? d1.database_name : null,
86
+ queues: [...queues]
87
+ };
88
+ }
89
+ async function teardownCloudflare(project, opts) {
90
+ const app = project.appDir;
91
+ const configPath = join(app, "wrangler.jsonc");
92
+ if (!existsSync(configPath))
93
+ return fail("wrangler.jsonc is missing — there is nothing here to tear down.");
94
+ if (!process.env["CLOUDFLARE_API_TOKEN"]) {
95
+ const token = await secretValue(project, "CLOUDFLARE_API_TOKEN");
96
+ if (token)
97
+ process.env["CLOUDFLARE_API_TOKEN"] = token;
98
+ }
99
+ if (!process.env["CLOUDFLARE_ACCOUNT_ID"]) {
100
+ const account = await secretValue(project, "CLOUDFLARE_ACCOUNT_ID");
101
+ if (account)
102
+ process.env["CLOUDFLARE_ACCOUNT_ID"] = account;
103
+ }
104
+ const bin = findWrangler(app);
105
+ if (!bin)
106
+ return fail("wrangler is not installed. `bun add -d wrangler`.");
107
+ const config = parseJsonc(readFileSync(configPath, "utf8"));
108
+ const plan = teardownPlan(config, project.manifest.shop.domain.split(".")[0]);
109
+ const host = workerHosts(project.manifest)[0] ?? project.manifest.shop.domain;
110
+ console.log(`
111
+ ${color.bold("This will destroy, on Cloudflare:")}
112
+ `);
113
+ console.log(` worker ${plan.worker} ${color.dim(`(and its custom domain ${host})`)}`);
114
+ console.log(plan.d1 ? ` database ${plan.d1} ${opts.keepData ? color.green("KEPT — --keep-data") : color.red("DELETED, with every order in it")}` : ` database ${color.dim("none recorded")}`);
115
+ console.log(plan.queues.length ? ` queues ${plan.queues.join(", ")}` : ` queues ${color.dim("none recorded")}`);
116
+ console.log("");
117
+ if (opts.dryRun) {
118
+ console.log(color.dim(`--dry-run: nothing was touched.
119
+ `));
120
+ return 0;
121
+ }
122
+ if (!opts.confirm) {
123
+ return fail(`refusing to destroy anything without --confirm.
124
+ ` + ` This is not reversible: D1 has no undo, and the orders go with it.
125
+
126
+ ` + ` ${color.cyan(`vc teardown --cloudflare --confirm ${project.manifest.shop.domain}`)}`);
127
+ }
128
+ console.log(`▸ wrangler delete (${plan.worker})`);
129
+ const deleted = await wrangler(bin, ["delete", "--name", plan.worker, "--force"], app, true);
130
+ if (deleted.code !== 0 && !/not found|does not exist|10007|10090/i.test(deleted.out)) {
131
+ return fail(`could not delete the worker:
132
+ ${deleted.out.trim().split(`
133
+ `).slice(-3).join(`
134
+ `)}`);
135
+ }
136
+ console.log(`${color.green("✓")} worker gone${/not found|does not exist/i.test(deleted.out) ? color.dim(" (it was already)") : ""}`);
137
+ for (const queue of plan.queues) {
138
+ const dropped = await wrangler(bin, ["queues", "delete", queue], app, true);
139
+ if (dropped.code !== 0 && !/not found|does not exist|11009|11018/i.test(dropped.out)) {
140
+ console.error(`${color.yellow("!")} queue "${queue}" could not be deleted: ${dropped.out.trim().split(`
141
+ `).slice(-1)[0]}`);
142
+ } else {
143
+ console.log(`${color.green("✓")} queue "${queue}" gone`);
144
+ }
145
+ }
146
+ if (plan.d1 && !opts.keepData) {
147
+ const dropped = await wrangler(bin, ["d1", "delete", plan.d1, "--skip-confirmation"], app, true);
148
+ if (dropped.code !== 0 && !/not found|does not exist|7404/i.test(dropped.out)) {
149
+ return fail(`the worker and queues are gone but D1 "${plan.d1}" is not:
150
+ ${dropped.out.trim().split(`
151
+ `).slice(-3).join(`
152
+ `)}`);
153
+ }
154
+ console.log(`${color.green("✓")} database "${plan.d1}" gone`);
155
+ }
156
+ if (!opts.keepData) {
157
+ writeFileSync(configPath, upsertJsonc(readFileSync(configPath, "utf8"), "d1_databases", []));
158
+ if (project.manifest.cloudflare?.d1) {
159
+ const { d1: _dropped, ...rest } = project.manifest.cloudflare;
160
+ project.manifest.cloudflare = rest;
161
+ await writeManifest(project.root, project.manifest);
162
+ }
163
+ console.log(`${color.green("✓")} the database id is out of wrangler.jsonc and the manifest, so the next deploy provisions afresh`);
164
+ }
165
+ console.log(`
166
+ ${color.bold("Torn down.")} ${color.dim(`The shop's secrets are untouched in ${SECRETS_FILE} — this removed infrastructure, not configuration.`)}
167
+ ` + ` Push again, or ${color.cyan("vc deploy --cloudflare --provision")}, and it rebuilds.
168
+ `);
169
+ return 0;
170
+ }
171
+
172
+ // src/deploy/index.ts
55
173
  async function deployCommand(args) {
56
174
  const own = new Set(["--cloudflare", "--provision", "--force"]);
57
175
  const cloudflare = args.includes("--cloudflare");
@@ -74,12 +192,12 @@ async function deployCommand(args) {
74
192
  const check = await preflight(project, "void");
75
193
  printPreflight(project, check, "void");
76
194
  if (!check.ready && check.remote !== null && !args.includes("--force")) {
77
- console.error(`${color.red("✗")} refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force.
195
+ console.error(`${color2.red("✗")} refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force.
78
196
  `);
79
197
  return 1;
80
198
  }
81
199
  if (!check.ready && check.remote === null) {
82
- console.log(color.dim(`Secrets could not be verified here; void's own deploy gate is the next check.
200
+ console.log(color2.dim(`Secrets could not be verified here; void's own deploy gate is the next check.
83
201
  `));
84
202
  }
85
203
  return runVoid(["deploy", ...voids], project.appDir);
@@ -97,7 +215,7 @@ async function preflightCommand(args) {
97
215
  return result.ready ? 0 : 1;
98
216
  const pending = await uncommittedMigrations(project.root);
99
217
  if (pending.length > 0) {
100
- console.error(`${color.red("✗")} ${pending.length} migration file${pending.length === 1 ? " is" : "s are"} not committed:
218
+ console.error(`${color2.red("✗")} ${pending.length} migration file${pending.length === 1 ? " is" : "s are"} not committed:
101
219
  ` + ` ${pending.join(`
102
220
  `)}
103
221
 
@@ -122,13 +240,13 @@ async function deployHelp() {
122
240
  line("vc's preflight — every required key set, the hostnames right — then", width),
123
241
  line("void deploy, untouched. Or, with --cloudflare, your own account via wrangler.", width),
124
242
  line("", width),
125
- line(color.bold("Usage"), width),
243
+ line(color2.bold("Usage"), width),
126
244
  ...row("vc deploy [void's flags]", "preflight, then void deploy — the Void platform, void's login", width, 2),
127
245
  ...row("vc deploy --cloudflare", "wrangler, your Cloudflare account, no Void login: preflight, build, scrub baked secrets, migrate D1, deploy", width, 2),
128
246
  ...row("vc deploy --cloudflare --provision", "first time: create the D1 database and the queue, record them", width, 2),
129
247
  ...row("vc deploy --cloudflare --force", "deploy a shop preflight says is not ready — deliberately", width, 2),
130
248
  line("", width),
131
- line(color.bold("Needs, for --cloudflare"), width),
249
+ line(color2.bold("Needs, for --cloudflare"), width),
132
250
  ...row("CLOUDFLARE_API_TOKEN", "in the environment; the account is pinned for you when there is one", width, 2),
133
251
  ...row(PRIVATE_KEY_VAR, "to decrypt the shop's own secrets", width, 2),
134
252
  line("", width),
@@ -224,9 +342,9 @@ async function guardCommand() {
224
342
  if (!project)
225
343
  return 0;
226
344
  const root = project.root;
227
- if (existsSync(join(root, LOCAL_KEY_FILE)) && !ignoresKeyFile(root)) {
345
+ if (existsSync2(join2(root, LOCAL_KEY_FILE)) && !ignoresKeyFile(root)) {
228
346
  console.error(`
229
- ${color.red("✗ refusing the commit")}: ${LOCAL_KEY_FILE} exists and is NOT gitignored.
347
+ ${color2.red("✗ refusing the commit")}: ${LOCAL_KEY_FILE} exists and is NOT gitignored.
230
348
 
231
349
  ` + ` It holds the private key that opens every secret in this repository.
232
350
  ` + ` Add ${LOCAL_KEY_FILE} to .gitignore before committing anything.
@@ -239,14 +357,14 @@ ${color.red("✗ refusing the commit")}: ${LOCAL_KEY_FILE} exists and is NOT git
239
357
  const publicKey = committedPublicKey(root);
240
358
  if (!publicKey) {
241
359
  console.error(`
242
- ${color.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${bare.length === 1 ? "is" : "are"} in the clear, and there is no key to encrypt ${bare.length === 1 ? "it" : "them"} with.
360
+ ${color2.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${bare.length === 1 ? "is" : "are"} in the clear, and there is no key to encrypt ${bare.length === 1 ? "it" : "them"} with.
243
361
 
244
362
  ` + ` ${bare.map((entry) => entry.name).join(`
245
363
  `)}
246
364
 
247
- ` + ` ${color.cyan("vc keys --init")} makes one — no GitHub repository needed yet.
365
+ ` + ` ${color2.cyan("vc keys --init")} makes one — no GitHub repository needed yet.
248
366
 
249
- ` + color.dim(` Committing a secret in the clear cannot be undone by a later commit;
367
+ ` + color2.dim(` Committing a secret in the clear cannot be undone by a later commit;
250
368
  the value stays in the history and must be treated as burned.
251
369
  `));
252
370
  return 1;
@@ -256,7 +374,7 @@ ${color.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1
256
374
  const sealed = await encryptInto(root, publicKey, entry.name, entry.value);
257
375
  if (!sealed.ok) {
258
376
  console.error(`
259
- ${color.red("✗ refusing the commit")}: ${sealed.error}
377
+ ${color2.red("✗ refusing the commit")}: ${sealed.error}
260
378
  `);
261
379
  return 1;
262
380
  }
@@ -264,7 +382,7 @@ ${color.red("✗ refusing the commit")}: ${sealed.error}
264
382
  const left = plaintextSecretNames(root);
265
383
  if (left.length > 0) {
266
384
  console.error(`
267
- ${color.red("✗ refusing the commit")}: ${left.join(", ")} could not be encrypted.
385
+ ${color2.red("✗ refusing the commit")}: ${left.join(", ")} could not be encrypted.
268
386
  `);
269
387
  return 1;
270
388
  }
@@ -272,28 +390,81 @@ ${color.red("✗ refusing the commit")}: ${left.join(", ")} could not be encrypt
272
390
  const added = await run("git", ["add", "--", SECRETS_FILE], root);
273
391
  if (added.code !== 0) {
274
392
  console.error(`
275
- ${color.red("✗ refusing the commit")}: ${SECRETS_FILE} was encrypted but could not be re-staged,
393
+ ${color2.red("✗ refusing the commit")}: ${SECRETS_FILE} was encrypted but could not be re-staged,
276
394
  ` + ` so the commit would still carry the plaintext you staged. \`git add ${SECRETS_FILE}\`.
277
395
  `);
278
396
  return 1;
279
397
  }
280
398
  }
281
399
  console.log(`
282
- ${color.green("✓")} encrypted ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE}${staged ? " and re-staged it" : ""}: ${bare.map((entry) => entry.name).join(", ")}
283
- ` + color.dim(` Encryption needs only the public key, so this needs no credential.
400
+ ${color2.green("✓")} encrypted ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE}${staged ? " and re-staged it" : ""}: ${bare.map((entry) => entry.name).join(", ")}
401
+ ` + color2.dim(` Encryption needs only the public key, so this needs no credential.
284
402
  `));
285
403
  return 0;
286
404
  }
405
+ async function teardownCliCommand(args) {
406
+ const project = await findProject();
407
+ if (!project) {
408
+ console.error("vc: no voidcommerce.json here.");
409
+ return 1;
410
+ }
411
+ if (!args.includes("--cloudflare")) {
412
+ console.error("vc: teardown only knows how to undo a Cloudflare deploy. `vc teardown --cloudflare`.");
413
+ return 1;
414
+ }
415
+ const typed = args[args.indexOf("--confirm") + 1];
416
+ const confirmed = args.includes("--confirm") && typed === project.manifest.shop.domain;
417
+ if (args.includes("--confirm") && !confirmed) {
418
+ console.error(`
419
+ vc: --confirm needs this shop's domain, exactly:
420
+
421
+ ` + ` vc teardown --cloudflare --confirm ${project.manifest.shop.domain}
422
+
423
+ ` + ` You typed ${typed ? `"${typed}"` : "nothing"}. Naming the shop is the check — it is
424
+ ` + ` what stops this running in the wrong directory.
425
+ `);
426
+ return 1;
427
+ }
428
+ return teardownCloudflare(project, {
429
+ confirm: confirmed,
430
+ keepData: args.includes("--keep-data"),
431
+ dryRun: args.includes("--dry-run")
432
+ });
433
+ }
434
+ async function teardownHelp() {
435
+ const width = 80;
436
+ console.log(box("vc teardown", [
437
+ line("Destroy this shop's Cloudflare infrastructure, so the whole", width),
438
+ line("publish-to-live flow can be proved again from nothing.", width),
439
+ line("", width),
440
+ ...row("vc teardown --cloudflare --dry-run", "list what would be destroyed, touch nothing", width, 2),
441
+ ...row("vc teardown --cloudflare --confirm <domain>", "do it", width, 2),
442
+ ...row("--keep-data", "leave the D1 database alone", width, 2),
443
+ line("", width),
444
+ line(color2.bold("Read this first"), width),
445
+ line("D1 deletion has NO UNDO. Every order, customer and address in the", width),
446
+ line("database goes with it, and there is no snapshot unless you took one.", width),
447
+ line("", width),
448
+ line("So --confirm takes the shop's DOMAIN, not `y`: a `y` is muscle memory,", width),
449
+ line("and typing the domain is what stops this running in the wrong shop.", width),
450
+ line("", width),
451
+ line("It is idempotent — deleting what is already gone is success — and it", width),
452
+ line("clears the recorded database id, because `wrangler deploy` FAILS on a", width),
453
+ line("dangling id rather than making a new database. Secrets are untouched:", width),
454
+ line("this removes infrastructure, not configuration.", width)
455
+ ], width));
456
+ return 0;
457
+ }
287
458
 
288
459
  // src/init.ts
289
460
  import * as p2 from "@clack/prompts";
290
461
  import { mkdir } from "node:fs/promises";
291
- import { basename, join as join2 } from "node:path";
292
- import color3 from "picocolors";
462
+ import { basename, join as join3 } from "node:path";
463
+ import color4 from "picocolors";
293
464
 
294
465
  // src/wizard.ts
295
466
  import * as p from "@clack/prompts";
296
- import color2 from "picocolors";
467
+ import color3 from "picocolors";
297
468
  var bail = () => {
298
469
  p.cancel("Nothing was written.");
299
470
  process.exit(0);
@@ -315,8 +486,8 @@ async function askGroup(group2, previous, alsoRequired = []) {
315
486
  const required = group2.choices.filter((choice) => requiredIds.has(choice.id));
316
487
  const optional = group2.choices.filter((choice) => !requiredIds.has(choice.id));
317
488
  const message = group2.title + (group2.intro ? `
318
- ${color2.dim(group2.intro)}` : "") + (required.length ? `
319
- ${color2.dim(`Included: ${required.map((choice) => choice.label).join(", ")}`)}` : "");
489
+ ${color3.dim(group2.intro)}` : "") + (required.length ? `
490
+ ${color3.dim(`Included: ${required.map((choice) => choice.label).join(", ")}`)}` : "");
320
491
  if (optional.length === 0) {
321
492
  p.log.info(message);
322
493
  return required.map((choice) => choice.id);
@@ -347,7 +518,7 @@ async function runWizard(existing, layout) {
347
518
  validate: (value) => value.trim() ? undefined : "A shop has a name."
348
519
  }),
349
520
  domain: () => p.text({
350
- message: `Domain ${color2.dim(monorepo ? "— the storefront; api.<domain> is the worker; the CORS list and DNS records derive from it" : "— the shop answers here; the worker's custom domain")}`,
521
+ message: `Domain ${color3.dim(monorepo ? "— the storefront; api.<domain> is the worker; the CORS list and DNS records derive from it" : "— the shop answers here; the worker's custom domain")}`,
351
522
  placeholder: "northwind.com",
352
523
  initialValue: existing?.shop.domain ?? "",
353
524
  validate: (value) => /^[a-z0-9-]+(\.[a-z0-9-]+)+\.[a-z]{2,}$|^[a-z0-9-]+\.[a-z]{2,}$/i.test(value.trim()) ? undefined : "A hostname, like northwind.com or shop.northwind.com."
@@ -358,7 +529,7 @@ async function runWizard(existing, layout) {
358
529
  if (!domain || domain === guess)
359
530
  return Promise.resolve(undefined);
360
531
  return p.text({
361
- message: `DNS zone ${color2.dim(`— ${domain} lives inside it; records go here, and this zone must be on your DNS provider`)}`,
532
+ message: `DNS zone ${color3.dim(`— ${domain} lives inside it; records go here, and this zone must be on your DNS provider`)}`,
362
533
  placeholder: guess,
363
534
  initialValue: existing?.shop.zone ?? guess,
364
535
  validate: (value) => {
@@ -370,13 +541,13 @@ async function runWizard(existing, layout) {
370
541
  });
371
542
  },
372
543
  country: () => p.text({
373
- message: `Country ${color2.dim("— sets the declared VAT rate and the default region")}`,
544
+ message: `Country ${color3.dim("— sets the declared VAT rate and the default region")}`,
374
545
  placeholder: "US",
375
546
  initialValue: existing?.shop.country ?? "",
376
547
  validate: (value) => /^[A-Za-z]{2}$/.test(value.trim()) ? undefined : "ISO-3166 alpha-2, like DK."
377
548
  }),
378
549
  taxRegistered: () => p.select({
379
- message: `Registered to charge tax? ${color2.dim("— a business below its registration threshold must NOT add tax to a price, and one above it must")}`,
550
+ message: `Registered to charge tax? ${color3.dim("— a business below its registration threshold must NOT add tax to a price, and one above it must")}`,
380
551
  options: [
381
552
  { value: "no", label: "Not registered", hint: "prices are what the customer pays; no tax is added. The common case for a new shop" },
382
553
  { value: "yes", label: "Registered", hint: "the country's standard rate is declared and added" }
@@ -390,14 +561,14 @@ async function runWizard(existing, layout) {
390
561
  validate: (value) => /^[A-Za-z]{3}$/.test(value.trim()) ? undefined : "ISO-4217, like dkk."
391
562
  }),
392
563
  locale: () => p.text({
393
- message: `Locale ${color2.dim("— the language of emails and launch content")}`,
564
+ message: `Locale ${color3.dim("— the language of emails and launch content")}`,
394
565
  placeholder: "en",
395
566
  initialValue: existing?.shop.locale ?? "",
396
567
  validate: (value) => /^[a-z]{2}(-[A-Z]{2})?$/.test(value.trim()) ? undefined : "like da, or en-GB."
397
568
  }),
398
569
  ...onPages ? {
399
570
  pagesHost: () => p.text({
400
- message: `GitHub Pages host ${color2.dim("— where the storefront is published; leave blank if you serve the branch elsewhere")}`,
571
+ message: `GitHub Pages host ${color3.dim("— where the storefront is published; leave blank if you serve the branch elsewhere")}`,
401
572
  placeholder: "northwind.github.io",
402
573
  initialValue: existing?.shop.pagesHost ?? "",
403
574
  validate: (value) => !value.trim() || /^[a-z0-9-]+\.github\.io$/i.test(value.trim()) ? undefined : "<owner>.github.io, or blank"
@@ -408,7 +579,7 @@ async function runWizard(existing, layout) {
408
579
  for (const group2 of GROUPS) {
409
580
  if (group2.id === "ui" && layout === "strict") {
410
581
  p.log.info(`${group2.title}
411
- ${color2.dim("The generated storefront and panel. Strict has no custom code to host anything else.")}`);
582
+ ${color3.dim("The generated storefront and panel. Strict has no custom code to host anything else.")}`);
412
583
  chosen[group2.id] = ["admin"];
413
584
  continue;
414
585
  }
@@ -433,14 +604,14 @@ ${color2.dim("The generated storefront and panel. Strict has no custom code to h
433
604
  }
434
605
  function summarise(manifest) {
435
606
  const lines = [
436
- `${color2.dim("Layout".padEnd(34))} ${LAYOUTS.find((l) => l.id === manifest.layout)?.label ?? manifest.layout}`
607
+ `${color3.dim("Layout".padEnd(34))} ${LAYOUTS.find((l) => l.id === manifest.layout)?.label ?? manifest.layout}`
437
608
  ];
438
609
  for (const group2 of GROUPS) {
439
610
  const ids = manifest.chosen[group2.id] ?? [];
440
611
  if (ids.length === 0)
441
612
  continue;
442
613
  const labels = ids.map((id) => group2.choices.find((choice) => choice.id === id)?.label ?? id).join(", ");
443
- lines.push(`${color2.dim(group2.title.padEnd(34))} ${labels}`);
614
+ lines.push(`${color3.dim(group2.title.padEnd(34))} ${labels}`);
444
615
  }
445
616
  return lines.join(`
446
617
  `);
@@ -481,10 +652,10 @@ async function init(args) {
481
652
  if (flags.voids.length > 0)
482
653
  return runVoid(["init", ...flags.voids]);
483
654
  let root = process.cwd();
484
- p2.intro(color3.bgCyan(color3.black(" voidcommerce ")));
655
+ p2.intro(color4.bgCyan(color4.black(" voidcommerce ")));
485
656
  const existing = await readManifest(root);
486
657
  if (existing) {
487
- p2.log.info(`Found ${color3.cyan("voidcommerce.json")} for ${color3.bold(existing.shop.name)} — answers are pre-filled; the layout (${existing.layout}) is fixed.`);
658
+ p2.log.info(`Found ${color4.cyan("voidcommerce.json")} for ${color4.bold(existing.shop.name)} — answers are pre-filled; the layout (${existing.layout}) is fixed.`);
488
659
  if (flags.layout && flags.layout !== existing.layout) {
489
660
  p2.log.error(`This shop is laid out as "${existing.layout}". The layout cannot change after init — moving files is not a regeneration.`);
490
661
  return 1;
@@ -492,10 +663,10 @@ async function init(args) {
492
663
  }
493
664
  const layout = existing?.layout ?? flags.layout ?? await askLayout();
494
665
  if (layout === "strict") {
495
- p2.log.step(`Strict: the app will be generated under ${color3.cyan(".vc/app")} — nothing there is yours to edit.`);
666
+ p2.log.step(`Strict: the app will be generated under ${color4.cyan(".vc/app")} — nothing there is yours to edit.`);
496
667
  } else if (layout === "app") {
497
668
  if (!isVoidApp(root)) {
498
- p2.log.step(`No Void app here yet — ${color3.cyan("void init")} first, then the shop.`);
669
+ p2.log.step(`No Void app here yet — ${color4.cyan("void init")} first, then the shop.`);
499
670
  const before = new Set(voidAppsIn(root));
500
671
  const code = await runVoid(["init"]);
501
672
  if (code !== 0)
@@ -508,7 +679,7 @@ async function init(args) {
508
679
  }
509
680
  root = created[0];
510
681
  process.chdir(root);
511
- p2.log.step(`Continuing in ${color3.cyan(`${basename(root)}/`)}`);
682
+ p2.log.step(`Continuing in ${color4.cyan(`${basename(root)}/`)}`);
512
683
  }
513
684
  }
514
685
  } else {
@@ -517,11 +688,11 @@ async function init(args) {
517
688
  ["frontend", "the storefront — prerendered and served from the worker's own assets"]
518
689
  ];
519
690
  for (const [dir, starter] of parts) {
520
- const target = join2(root, dir);
691
+ const target = join3(root, dir);
521
692
  if (isVoidApp(target))
522
693
  continue;
523
694
  await mkdir(target, { recursive: true });
524
- p2.log.step(`${color3.cyan("void init")} in ${color3.cyan(`${dir}/`)} — choose ${starter}.`);
695
+ p2.log.step(`${color4.cyan("void init")} in ${color4.cyan(`${dir}/`)} — choose ${starter}.`);
525
696
  const code = await runVoid(["init"], target);
526
697
  if (code !== 0)
527
698
  return code;
@@ -539,7 +710,7 @@ async function init(args) {
539
710
  if (problems.length > 0) {
540
711
  p2.log.error("These answers contradict each other:");
541
712
  for (const problem of problems)
542
- p2.log.message(` ${color3.red("✗")} ${problem}`);
713
+ p2.log.message(` ${color4.red("✗")} ${problem}`);
543
714
  p2.cancel("Nothing was written.");
544
715
  return 1;
545
716
  }
@@ -555,16 +726,16 @@ async function init(args) {
555
726
  spinner2.stop("Generated");
556
727
  const { secrets, plaintext } = envSummary(manifest);
557
728
  p2.note([
558
- ...result.written.map((file) => `${color3.green("+")} ${file}`),
559
- ...result.kept.map((file) => `${color3.dim("=")} ${file} ${color3.dim("(kept — yours)")}`),
560
- ...result.retired.map((file) => `${color3.red("-")} ${file} ${color3.dim("(retired — no longer generated)")}`)
729
+ ...result.written.map((file) => `${color4.green("+")} ${file}`),
730
+ ...result.kept.map((file) => `${color4.dim("=")} ${file} ${color4.dim("(kept — yours)")}`),
731
+ ...result.retired.map((file) => `${color4.red("-")} ${file} ${color4.dim("(retired — no longer generated)")}`)
561
732
  ].join(`
562
733
  `), "Files");
563
734
  p2.note([
564
- `${color3.bold(String(secrets.length))} secrets ${color3.dim("→ wrangler secret put <NAME>")}`,
735
+ `${color4.bold(String(secrets.length))} secrets ${color4.dim("→ wrangler secret put <NAME>")}`,
565
736
  ...secrets.map((key) => ` ${key}`),
566
737
  "",
567
- `${color3.bold(String(plaintext.length))} plaintext ${color3.dim("→ .env.production, already written")}`,
738
+ `${color4.bold(String(plaintext.length))} plaintext ${color4.dim("→ .env.production, already written")}`,
568
739
  ...plaintext.map((key) => ` ${key}`)
569
740
  ].join(`
570
741
  `), "Before going live");
@@ -574,21 +745,21 @@ async function init(args) {
574
745
  return code;
575
746
  }
576
747
  const next = layout === "app" ? [
577
- ` ${color3.cyan("bun install")}`,
578
- ` ${color3.cyan("bun run maildev")} ${color3.dim("local inbox at http://localhost:1080")}`,
579
- ` ${color3.cyan("vc dev")}`,
580
- ` ${color3.cyan("vc preflight")} ${color3.dim("what is still missing, and why it matters")}`
748
+ ` ${color4.cyan("bun install")}`,
749
+ ` ${color4.cyan("bun run maildev")} ${color4.dim("local inbox at http://localhost:1080")}`,
750
+ ` ${color4.cyan("vc dev")}`,
751
+ ` ${color4.cyan("vc preflight")} ${color4.dim("what is still missing, and why it matters")}`
581
752
  ] : layout === "strict" ? [
582
- ` ${color3.cyan("bun install")}`,
583
- ` ${color3.cyan("vc generate")} ${color3.dim("the app under .vc/app, void's artifacts, the migrations")}`,
584
- ` ${color3.cyan("bun run maildev")} ${color3.dim("local inbox at http://localhost:1080")}`,
585
- ` ${color3.cyan("vc dev")} ${color3.dim("runs in .vc/app")}`
753
+ ` ${color4.cyan("bun install")}`,
754
+ ` ${color4.cyan("vc generate")} ${color4.dim("the app under .vc/app, void's artifacts, the migrations")}`,
755
+ ` ${color4.cyan("bun run maildev")} ${color4.dim("local inbox at http://localhost:1080")}`,
756
+ ` ${color4.cyan("vc dev")} ${color4.dim("runs in .vc/app")}`
586
757
  ] : [
587
- ` ${color3.cyan("bun install")} ${color3.dim("one install for both workspaces")}`,
588
- ` ${color3.cyan("bun run maildev")} ${color3.dim("local inbox at http://localhost:1080")}`,
589
- ` ${color3.cyan("bun run dev:api")} ${color3.dim("the worker, with the panel")}`,
590
- ` ${color3.cyan("bun run dev:frontend")} ${color3.dim("the storefront, against the local API")}`,
591
- ` ${color3.cyan("bun run preflight")} ${color3.dim("what is still missing, and why it matters")}`
758
+ ` ${color4.cyan("bun install")} ${color4.dim("one install for both workspaces")}`,
759
+ ` ${color4.cyan("bun run maildev")} ${color4.dim("local inbox at http://localhost:1080")}`,
760
+ ` ${color4.cyan("bun run dev:api")} ${color4.dim("the worker, with the panel")}`,
761
+ ` ${color4.cyan("bun run dev:frontend")} ${color4.dim("the storefront, against the local API")}`,
762
+ ` ${color4.cyan("bun run preflight")} ${color4.dim("what is still missing, and why it matters")}`
592
763
  ];
593
764
  p2.outro(["Next:", ...next].join(`
594
765
  `));
@@ -597,7 +768,7 @@ async function init(args) {
597
768
 
598
769
  // src/regenerate.ts
599
770
  import * as p3 from "@clack/prompts";
600
- import color4 from "picocolors";
771
+ import color5 from "picocolors";
601
772
  async function generateCommand() {
602
773
  const project = await findProject();
603
774
  if (!project) {
@@ -613,9 +784,9 @@ async function generateCommand() {
613
784
  }
614
785
  const result = await generate(project.root, project.manifest);
615
786
  p3.log.step([
616
- ...result.written.map((file) => `${color4.green("+")} ${file}`),
617
- ...result.kept.map((file) => `${color4.dim("=")} ${file} ${color4.dim("(kept)")}`),
618
- ...result.retired.map((file) => `${color4.red("-")} ${file} ${color4.dim("(retired — no longer generated)")}`)
787
+ ...result.written.map((file) => `${color5.green("+")} ${file}`),
788
+ ...result.kept.map((file) => `${color5.dim("=")} ${file} ${color5.dim("(kept)")}`),
789
+ ...result.retired.map((file) => `${color5.red("-")} ${file} ${color5.dim("(retired — no longer generated)")}`)
619
790
  ].join(`
620
791
  `));
621
792
  return project.manifest.layout === "strict" ? finishStrict(project.root) : 0;
@@ -634,8 +805,8 @@ async function generateHelp() {
634
805
  }
635
806
 
636
807
  // src/scripts.ts
637
- import { existsSync as existsSync2, readFileSync } from "node:fs";
638
- import { join as join3, relative } from "node:path";
808
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
809
+ import { join as join4, relative } from "node:path";
639
810
  function appScript(name) {
640
811
  return async (args) => {
641
812
  const project = await findProject();
@@ -644,8 +815,8 @@ function appScript(name) {
644
815
  const code = await ensureGenerated(project);
645
816
  if (code !== 0)
646
817
  return code;
647
- const pkgPath = join3(project.appDir, "package.json");
648
- const scripts = existsSync2(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {} : {};
818
+ const pkgPath = join4(project.appDir, "package.json");
819
+ const scripts = existsSync3(pkgPath) ? JSON.parse(readFileSync2(pkgPath, "utf8")).scripts ?? {} : {};
649
820
  if (!scripts[name]) {
650
821
  console.error(`vc: ${relative(process.cwd(), pkgPath) || "package.json"} has no "${name}" script — void init writes one.`);
651
822
  return 1;
@@ -680,6 +851,7 @@ var EXTENDED = {
680
851
  preflight: { run: preflightCommand, help: preflightHelp },
681
852
  secrets: { run: secretsCliCommand, help: secretsHelp },
682
853
  keys: { run: keysCliCommand, help: keysHelp },
854
+ teardown: { run: teardownCliCommand, help: teardownHelp },
683
855
  guard: { run: guardCommand, help: async () => guardCommand() },
684
856
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
685
857
  build: { run: appScript("build"), help: appScriptHelp("build") },
@@ -43,3 +43,12 @@ export declare function keysHelp(): Promise<number>;
43
43
  * Exit code is the whole interface — a hook cares about nothing else.
44
44
  */
45
45
  export declare function guardCommand(): Promise<number>;
46
+ /**
47
+ * `vc teardown` — destroy this shop's Cloudflare infrastructure.
48
+ *
49
+ * The confirmation is the shop's DOMAIN, not `y`. A `y` is muscle memory;
50
+ * typing `devprints.saastemly.com` is not, and this deletes a database that
51
+ * has no undo.
52
+ */
53
+ export declare function teardownCliCommand(args: string[]): Promise<number>;
54
+ export declare function teardownHelp(): Promise<number>;