@saastemly/voidcommerce 0.17.0 → 0.18.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
@@ -21,7 +21,7 @@ import {
21
21
  runVoid,
22
22
  version,
23
23
  voidAppsIn
24
- } from "./index-4jz1448c.js";
24
+ } from "./index-3j6jtjmk.js";
25
25
  import {
26
26
  LOCAL_KEY_FILE,
27
27
  PRIVATE_KEY_VAR,
@@ -32,6 +32,7 @@ import {
32
32
  declaredSecretNames,
33
33
  encryptInto,
34
34
  envSummary,
35
+ findGh,
35
36
  getVariable,
36
37
  ghAuth,
37
38
  ignoresKeyFile,
@@ -45,13 +46,14 @@ import {
45
46
  publicKeyFor,
46
47
  repoSlug,
47
48
  run,
49
+ run1 as run2,
48
50
  secretNames,
49
51
  secretsCommand,
50
52
  setSecret,
51
53
  setVariable,
52
54
  variableNames,
53
55
  verifyCloudflareToken
54
- } from "./index-negctpws.js";
56
+ } from "./index-khzk6a9z.js";
55
57
  import {
56
58
  LAYOUTS,
57
59
  oneOrigin,
@@ -68,9 +70,9 @@ import {
68
70
  } from "./index-0v6na3yp.js";
69
71
 
70
72
  // src/deploy/index.ts
71
- import color2 from "picocolors";
72
- import { existsSync } from "node:fs";
73
- import { join as join2 } from "node:path";
73
+ import color3 from "picocolors";
74
+ import { existsSync as existsSync2 } from "node:fs";
75
+ import { join as join3 } from "node:path";
74
76
 
75
77
  // src/deploy/link.ts
76
78
  import { rmSync } from "node:fs";
@@ -224,6 +226,106 @@ async function linkCommand(project, args) {
224
226
  return 0;
225
227
  }
226
228
 
229
+ // src/deploy/publish.ts
230
+ import { existsSync } from "node:fs";
231
+ import { join as join2 } from "node:path";
232
+ import color2 from "picocolors";
233
+ async function publishCommand(project, args) {
234
+ const p = await import("@clack/prompts");
235
+ const root = project.root;
236
+ const gh = findGh();
237
+ p.intro(color2.bgCyan(color2.black(" vc publish ")));
238
+ const auth = await ghAuth(root);
239
+ if (!auth.ok || !gh) {
240
+ p.cancel(auth.reason ?? "the GitHub CLI is not installed. https://cli.github.com");
241
+ return 1;
242
+ }
243
+ if (!existsSync(join2(root, ".git"))) {
244
+ p.cancel(`${root} is not a git repository. \`git init\` first, and commit what you have.`);
245
+ return 1;
246
+ }
247
+ let slug = await repoSlug(root);
248
+ if (slug) {
249
+ p.log.info(`${slug} already exists — using it`);
250
+ } else {
251
+ const suggested = project.manifest.shop.domain.split(".")[0] ?? "shop";
252
+ const name = await p.text({
253
+ message: "Repository name",
254
+ initialValue: suggested,
255
+ validate: (value) => /^[A-Za-z0-9._-]+$/.test(value.trim()) ? undefined : "letters, digits, dot, dash or underscore"
256
+ });
257
+ if (p.isCancel(name)) {
258
+ p.cancel("nothing was created.");
259
+ return 1;
260
+ }
261
+ const visibility = await p.select({
262
+ message: "Visibility",
263
+ options: [
264
+ { value: "--private", label: "Private", hint: "the key that opens your secrets is readable to anyone with access" },
265
+ { value: "--public", label: "Public", hint: "only if this shop keeps no secrets at all" }
266
+ ],
267
+ initialValue: "--private"
268
+ });
269
+ if (p.isCancel(visibility)) {
270
+ p.cancel("nothing was created.");
271
+ return 1;
272
+ }
273
+ const spinner2 = p.spinner();
274
+ spinner2.start(`creating ${String(name)}`);
275
+ const created = await run(gh, ["repo", "create", String(name).trim(), "--source=.", String(visibility)], root);
276
+ if (created.code !== 0) {
277
+ spinner2.stop(`${color2.red("✗")} could not create it`);
278
+ p.cancel(created.out.trim().split(`
279
+ `).slice(-2).join(" "));
280
+ return 1;
281
+ }
282
+ slug = await repoSlug(root);
283
+ spinner2.stop(`${color2.green("✓")} ${slug ?? String(name)} created, and set as origin`);
284
+ }
285
+ p.log.step("giving the repository what the deploy needs");
286
+ const linked = await linkCommand(project, args);
287
+ if (linked !== 0) {
288
+ p.cancel("the repository exists but is not configured, so a push would not deploy. Fix the above and run `vc publish` again.");
289
+ return 1;
290
+ }
291
+ const dirty = (await run("git", ["status", "--porcelain"], root)).out.trim();
292
+ if (dirty) {
293
+ const staged = await run("git", ["add", "-A"], root);
294
+ const committed = await run("git", ["commit", "-m", "vc publish: link this shop to its repository"], root);
295
+ if (staged.code !== 0 || committed.code !== 0) {
296
+ p.cancel(`could not commit the changes vc link made:
297
+ ${committed.out.trim().split(`
298
+ `).slice(-3).join(`
299
+ `)}`);
300
+ return 1;
301
+ }
302
+ p.log.success(`${color2.green("✓")} committed what linking changed`);
303
+ }
304
+ const branch = (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], root)).out.trim() || "main";
305
+ const spinner = p.spinner();
306
+ spinner.start(`pushing ${branch}`);
307
+ const pushed = await run("git", ["push", "-u", "origin", `${branch}:main`], root);
308
+ if (pushed.code !== 0) {
309
+ spinner.stop(`${color2.red("✗")} the push failed`);
310
+ p.cancel(pushed.out.trim().split(`
311
+ `).slice(-4).join(`
312
+ `));
313
+ return 1;
314
+ }
315
+ spinner.stop(`${color2.green("✓")} pushed to main`);
316
+ p.outro([
317
+ `${color2.bold("Published.")} The deploy is running now.`,
318
+ "",
319
+ ` ${color2.cyan(`https://github.com/${slug}/actions`)}`,
320
+ "",
321
+ ` From here ${color2.cyan("git push")} is the whole loop. \`vc\` is only needed to`,
322
+ ` change a secret (${color2.cyan("vc secrets set KEY")}) or read one back`,
323
+ ` (${color2.cyan("vc keys --restore")}).`
324
+ ].join(`
325
+ `));
326
+ return 0;
327
+ }
328
+
227
329
  // src/deploy/index.ts
228
330
  async function deployCommand(args) {
229
331
  const own = new Set(["--cloudflare", "--provision", "--force"]);
@@ -247,12 +349,12 @@ async function deployCommand(args) {
247
349
  const check = await preflight(project, "void");
248
350
  printPreflight(project, check, "void");
249
351
  if (!check.ready && check.remote !== null && !args.includes("--force")) {
250
- console.error(`${color2.red("✗")} refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force.
352
+ console.error(`${color3.red("✗")} refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force.
251
353
  `);
252
354
  return 1;
253
355
  }
254
356
  if (!check.ready && check.remote === null) {
255
- console.log(color2.dim(`Secrets could not be verified here; void's own deploy gate is the next check.
357
+ console.log(color3.dim(`Secrets could not be verified here; void's own deploy gate is the next check.
256
358
  `));
257
359
  }
258
360
  return runVoid(["deploy", ...voids], project.appDir);
@@ -270,7 +372,7 @@ async function preflightCommand(args) {
270
372
  return result.ready ? 0 : 1;
271
373
  const pending = await uncommittedMigrations(project.root);
272
374
  if (pending.length > 0) {
273
- console.error(`${color2.red("✗")} ${pending.length} migration file${pending.length === 1 ? " is" : "s are"} not committed:
375
+ console.error(`${color3.red("✗")} ${pending.length} migration file${pending.length === 1 ? " is" : "s are"} not committed:
274
376
  ` + ` ${pending.join(`
275
377
  `)}
276
378
 
@@ -282,7 +384,7 @@ async function preflightCommand(args) {
282
384
  return result.ready ? 0 : 1;
283
385
  }
284
386
  async function uncommittedMigrations(root) {
285
- const status = await run("git", ["status", "--porcelain", "--", "migrations"], root);
387
+ const status = await run2("git", ["status", "--porcelain", "--", "migrations"], root);
286
388
  if (status.code !== 0)
287
389
  return [];
288
390
  return status.out.split(`
@@ -295,13 +397,13 @@ async function deployHelp() {
295
397
  line("vc's preflight — every required key set, the hostnames right — then", width),
296
398
  line("void deploy, untouched. Or, with --cloudflare, your own account via wrangler.", width),
297
399
  line("", width),
298
- line(color2.bold("Usage"), width),
400
+ line(color3.bold("Usage"), width),
299
401
  ...row("vc deploy [void's flags]", "preflight, then void deploy — the Void platform, void's login", width, 2),
300
402
  ...row("vc deploy --cloudflare", "wrangler, your Cloudflare account, no Void login: preflight, build, scrub baked secrets, migrate D1, deploy", width, 2),
301
403
  ...row("vc deploy --cloudflare --provision", "first time: create the D1 database and the queue, record them", width, 2),
302
404
  ...row("vc deploy --cloudflare --force", "deploy a shop preflight says is not ready — deliberately", width, 2),
303
405
  line("", width),
304
- line(color2.bold("Needs, for --cloudflare"), width),
406
+ line(color3.bold("Needs, for --cloudflare"), width),
305
407
  ...row("CLOUDFLARE_API_TOKEN", "in the environment; the account is pinned for you when there is one", width, 2),
306
408
  ...row(PRIVATE_KEY_VAR, "to decrypt the shop's own secrets", width, 2),
307
409
  line("", width),
@@ -397,9 +499,9 @@ async function guardCommand() {
397
499
  if (!project)
398
500
  return 0;
399
501
  const root = project.root;
400
- if (existsSync(join2(root, LOCAL_KEY_FILE)) && !ignoresKeyFile(root)) {
502
+ if (existsSync2(join3(root, LOCAL_KEY_FILE)) && !ignoresKeyFile(root)) {
401
503
  console.error(`
402
- ${color2.red("✗ refusing the commit")}: ${LOCAL_KEY_FILE} exists and is NOT gitignored.
504
+ ${color3.red("✗ refusing the commit")}: ${LOCAL_KEY_FILE} exists and is NOT gitignored.
403
505
 
404
506
  ` + ` It holds the private key that opens every secret in this repository.
405
507
  ` + ` Add ${LOCAL_KEY_FILE} to .gitignore before committing anything.
@@ -412,24 +514,24 @@ ${color2.red("✗ refusing the commit")}: ${LOCAL_KEY_FILE} exists and is NOT gi
412
514
  const publicKey = committedPublicKey(root);
413
515
  if (!publicKey) {
414
516
  console.error(`
415
- ${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.
517
+ ${color3.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.
416
518
 
417
519
  ` + ` ${bare.map((entry) => entry.name).join(`
418
520
  `)}
419
521
 
420
- ` + ` ${color2.cyan("vc keys --init")} makes one — no GitHub repository needed yet.
522
+ ` + ` ${color3.cyan("vc keys --init")} makes one — no GitHub repository needed yet.
421
523
 
422
- ` + color2.dim(` Committing a secret in the clear cannot be undone by a later commit;
524
+ ` + color3.dim(` Committing a secret in the clear cannot be undone by a later commit;
423
525
  the value stays in the history and must be treated as burned.
424
526
  `));
425
527
  return 1;
426
528
  }
427
- const staged = (await run("git", ["diff", "--cached", "--name-only", "--", SECRETS_FILE], root)).out.trim().length > 0;
529
+ const staged = (await run2("git", ["diff", "--cached", "--name-only", "--", SECRETS_FILE], root)).out.trim().length > 0;
428
530
  for (const entry of bare) {
429
531
  const sealed = await encryptInto(root, publicKey, entry.name, entry.value);
430
532
  if (!sealed.ok) {
431
533
  console.error(`
432
- ${color2.red("✗ refusing the commit")}: ${sealed.error}
534
+ ${color3.red("✗ refusing the commit")}: ${sealed.error}
433
535
  `);
434
536
  return 1;
435
537
  }
@@ -437,23 +539,23 @@ ${color2.red("✗ refusing the commit")}: ${sealed.error}
437
539
  const left = plaintextSecretNames(root);
438
540
  if (left.length > 0) {
439
541
  console.error(`
440
- ${color2.red("✗ refusing the commit")}: ${left.join(", ")} could not be encrypted.
542
+ ${color3.red("✗ refusing the commit")}: ${left.join(", ")} could not be encrypted.
441
543
  `);
442
544
  return 1;
443
545
  }
444
546
  if (staged) {
445
- const added = await run("git", ["add", "--", SECRETS_FILE], root);
547
+ const added = await run2("git", ["add", "--", SECRETS_FILE], root);
446
548
  if (added.code !== 0) {
447
549
  console.error(`
448
- ${color2.red("✗ refusing the commit")}: ${SECRETS_FILE} was encrypted but could not be re-staged,
550
+ ${color3.red("✗ refusing the commit")}: ${SECRETS_FILE} was encrypted but could not be re-staged,
449
551
  ` + ` so the commit would still carry the plaintext you staged. \`git add ${SECRETS_FILE}\`.
450
552
  `);
451
553
  return 1;
452
554
  }
453
555
  }
454
556
  console.log(`
455
- ${color2.green("✓")} encrypted ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE}${staged ? " and re-staged it" : ""}: ${bare.map((entry) => entry.name).join(", ")}
456
- ` + color2.dim(` Encryption needs only the public key, so this needs no credential.
557
+ ${color3.green("✓")} encrypted ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE}${staged ? " and re-staged it" : ""}: ${bare.map((entry) => entry.name).join(", ")}
558
+ ` + color3.dim(` Encryption needs only the public key, so this needs no credential.
457
559
  `));
458
560
  return 0;
459
561
  }
@@ -474,12 +576,12 @@ async function linkHelp() {
474
576
  ...row("vc link", "store the encryption key and the Cloudflare token on the repository", width, 2),
475
577
  ...row("vc link --force", "replace what is already there", width, 2),
476
578
  line("", width),
477
- line(color2.bold("What it stores, and where"), width),
579
+ line(color3.bold("What it stores, and where"), width),
478
580
  ...row(PRIVATE_KEY_VAR, "a repository SECRET. A key already waiting locally is MOVED here and the local copy deleted", width, 2),
479
581
  ...row("CLOUDFLARE_API_TOKEN", "a repository SECRET, checked against the Cloudflare API before it is stored", width, 2),
480
582
  ...row("CLOUDFLARE_ACCOUNT_ID", "a repository VARIABLE — an identifier, not a credential", width, 2),
481
583
  line("", width),
482
- line(color2.bold("Why one token still has to be typed"), width),
584
+ line(color3.bold("Why one token still has to be typed"), width),
483
585
  line("GitHub cannot mint a Cloudflare credential. There is no OIDC federation", width),
484
586
  line("between them, and the Cloudflare GitHub App runs the other way: it grants", width),
485
587
  line("Cloudflare access to your repository, not your repository access to", width),
@@ -487,16 +589,49 @@ async function linkHelp() {
487
589
  ], width));
488
590
  return 0;
489
591
  }
592
+ async function publishCliCommand(args) {
593
+ const project = await findProject();
594
+ if (!project) {
595
+ console.error("vc: no voidcommerce.json here.");
596
+ return 1;
597
+ }
598
+ return publishCommand(project, args);
599
+ }
600
+ async function publishHelp() {
601
+ const width = 80;
602
+ console.log(box("vc publish", [
603
+ line("Create this shop's GitHub repository, give it what the deploy needs,", width),
604
+ line("and push. The push IS the deploy. Run once; after that, `git push`.", width),
605
+ line("", width),
606
+ ...row("vc publish", "create the repository, set the credentials, push main", width, 2),
607
+ line("", width),
608
+ line(color3.bold("What it does for you"), width),
609
+ ...row("the encryption key", "vc already holds it — set with no input from you", width, 2),
610
+ ...row("the account id", "read from voidcommerce.json", width, 2),
611
+ ...row("the Cloudflare token", "the one thing it has to ask for", width, 2),
612
+ line("", width),
613
+ line(color3.bold("Why the token cannot be automated away"), width),
614
+ line("There is no OIDC between GitHub and Cloudflare, so something must carry", width),
615
+ line("a token across, and only a person can fetch one from the dashboard. It", width),
616
+ line("is asked for once, checked against the Cloudflare API, and stored on the", width),
617
+ line("repository — never on this machine.", width),
618
+ line("", width),
619
+ line("The repository is created WITHOUT pushing, then configured, then pushed.", width),
620
+ line("Pushing first would start a deploy against a repository that has no key", width),
621
+ line("and no token: a red run, for no reason but the wrong order.", width)
622
+ ], width));
623
+ return 0;
624
+ }
490
625
 
491
626
  // src/init.ts
492
627
  import * as p2 from "@clack/prompts";
493
628
  import { mkdir } from "node:fs/promises";
494
- import { basename, join as join3 } from "node:path";
495
- import color4 from "picocolors";
629
+ import { basename, join as join4 } from "node:path";
630
+ import color5 from "picocolors";
496
631
 
497
632
  // src/wizard.ts
498
633
  import * as p from "@clack/prompts";
499
- import color3 from "picocolors";
634
+ import color4 from "picocolors";
500
635
  var bail = () => {
501
636
  p.cancel("Nothing was written.");
502
637
  process.exit(0);
@@ -518,8 +653,8 @@ async function askGroup(group2, previous, alsoRequired = []) {
518
653
  const required = group2.choices.filter((choice) => requiredIds.has(choice.id));
519
654
  const optional = group2.choices.filter((choice) => !requiredIds.has(choice.id));
520
655
  const message = group2.title + (group2.intro ? `
521
- ${color3.dim(group2.intro)}` : "") + (required.length ? `
522
- ${color3.dim(`Included: ${required.map((choice) => choice.label).join(", ")}`)}` : "");
656
+ ${color4.dim(group2.intro)}` : "") + (required.length ? `
657
+ ${color4.dim(`Included: ${required.map((choice) => choice.label).join(", ")}`)}` : "");
523
658
  if (optional.length === 0) {
524
659
  p.log.info(message);
525
660
  return required.map((choice) => choice.id);
@@ -550,7 +685,7 @@ async function runWizard(existing, layout) {
550
685
  validate: (value) => value.trim() ? undefined : "A shop has a name."
551
686
  }),
552
687
  domain: () => p.text({
553
- 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")}`,
688
+ message: `Domain ${color4.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")}`,
554
689
  placeholder: "northwind.com",
555
690
  initialValue: existing?.shop.domain ?? "",
556
691
  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."
@@ -561,7 +696,7 @@ async function runWizard(existing, layout) {
561
696
  if (!domain || domain === guess)
562
697
  return Promise.resolve(undefined);
563
698
  return p.text({
564
- message: `DNS zone ${color3.dim(`— ${domain} lives inside it; records go here, and this zone must be on your DNS provider`)}`,
699
+ message: `DNS zone ${color4.dim(`— ${domain} lives inside it; records go here, and this zone must be on your DNS provider`)}`,
565
700
  placeholder: guess,
566
701
  initialValue: existing?.shop.zone ?? guess,
567
702
  validate: (value) => {
@@ -573,13 +708,13 @@ async function runWizard(existing, layout) {
573
708
  });
574
709
  },
575
710
  country: () => p.text({
576
- message: `Country ${color3.dim("— sets the declared VAT rate and the default region")}`,
711
+ message: `Country ${color4.dim("— sets the declared VAT rate and the default region")}`,
577
712
  placeholder: "US",
578
713
  initialValue: existing?.shop.country ?? "",
579
714
  validate: (value) => /^[A-Za-z]{2}$/.test(value.trim()) ? undefined : "ISO-3166 alpha-2, like DK."
580
715
  }),
581
716
  taxRegistered: () => p.select({
582
- 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")}`,
717
+ message: `Registered to charge tax? ${color4.dim("— a business below its registration threshold must NOT add tax to a price, and one above it must")}`,
583
718
  options: [
584
719
  { value: "no", label: "Not registered", hint: "prices are what the customer pays; no tax is added. The common case for a new shop" },
585
720
  { value: "yes", label: "Registered", hint: "the country's standard rate is declared and added" }
@@ -593,14 +728,14 @@ async function runWizard(existing, layout) {
593
728
  validate: (value) => /^[A-Za-z]{3}$/.test(value.trim()) ? undefined : "ISO-4217, like dkk."
594
729
  }),
595
730
  locale: () => p.text({
596
- message: `Locale ${color3.dim("— the language of emails and launch content")}`,
731
+ message: `Locale ${color4.dim("— the language of emails and launch content")}`,
597
732
  placeholder: "en",
598
733
  initialValue: existing?.shop.locale ?? "",
599
734
  validate: (value) => /^[a-z]{2}(-[A-Z]{2})?$/.test(value.trim()) ? undefined : "like da, or en-GB."
600
735
  }),
601
736
  ...onPages ? {
602
737
  pagesHost: () => p.text({
603
- message: `GitHub Pages host ${color3.dim("— where the storefront is published; leave blank if you serve the branch elsewhere")}`,
738
+ message: `GitHub Pages host ${color4.dim("— where the storefront is published; leave blank if you serve the branch elsewhere")}`,
604
739
  placeholder: "northwind.github.io",
605
740
  initialValue: existing?.shop.pagesHost ?? "",
606
741
  validate: (value) => !value.trim() || /^[a-z0-9-]+\.github\.io$/i.test(value.trim()) ? undefined : "<owner>.github.io, or blank"
@@ -611,7 +746,7 @@ async function runWizard(existing, layout) {
611
746
  for (const group2 of GROUPS) {
612
747
  if (group2.id === "ui" && layout === "strict") {
613
748
  p.log.info(`${group2.title}
614
- ${color3.dim("The generated storefront and panel. Strict has no custom code to host anything else.")}`);
749
+ ${color4.dim("The generated storefront and panel. Strict has no custom code to host anything else.")}`);
615
750
  chosen[group2.id] = ["admin"];
616
751
  continue;
617
752
  }
@@ -636,14 +771,14 @@ ${color3.dim("The generated storefront and panel. Strict has no custom code to h
636
771
  }
637
772
  function summarise(manifest) {
638
773
  const lines = [
639
- `${color3.dim("Layout".padEnd(34))} ${LAYOUTS.find((l) => l.id === manifest.layout)?.label ?? manifest.layout}`
774
+ `${color4.dim("Layout".padEnd(34))} ${LAYOUTS.find((l) => l.id === manifest.layout)?.label ?? manifest.layout}`
640
775
  ];
641
776
  for (const group2 of GROUPS) {
642
777
  const ids = manifest.chosen[group2.id] ?? [];
643
778
  if (ids.length === 0)
644
779
  continue;
645
780
  const labels = ids.map((id) => group2.choices.find((choice) => choice.id === id)?.label ?? id).join(", ");
646
- lines.push(`${color3.dim(group2.title.padEnd(34))} ${labels}`);
781
+ lines.push(`${color4.dim(group2.title.padEnd(34))} ${labels}`);
647
782
  }
648
783
  return lines.join(`
649
784
  `);
@@ -684,10 +819,10 @@ async function init(args) {
684
819
  if (flags.voids.length > 0)
685
820
  return runVoid(["init", ...flags.voids]);
686
821
  let root = process.cwd();
687
- p2.intro(color4.bgCyan(color4.black(" voidcommerce ")));
822
+ p2.intro(color5.bgCyan(color5.black(" voidcommerce ")));
688
823
  const existing = await readManifest(root);
689
824
  if (existing) {
690
- p2.log.info(`Found ${color4.cyan("voidcommerce.json")} for ${color4.bold(existing.shop.name)} — answers are pre-filled; the layout (${existing.layout}) is fixed.`);
825
+ p2.log.info(`Found ${color5.cyan("voidcommerce.json")} for ${color5.bold(existing.shop.name)} — answers are pre-filled; the layout (${existing.layout}) is fixed.`);
691
826
  if (flags.layout && flags.layout !== existing.layout) {
692
827
  p2.log.error(`This shop is laid out as "${existing.layout}". The layout cannot change after init — moving files is not a regeneration.`);
693
828
  return 1;
@@ -695,10 +830,10 @@ async function init(args) {
695
830
  }
696
831
  const layout = existing?.layout ?? flags.layout ?? await askLayout();
697
832
  if (layout === "strict") {
698
- p2.log.step(`Strict: the app will be generated under ${color4.cyan(".vc/app")} — nothing there is yours to edit.`);
833
+ p2.log.step(`Strict: the app will be generated under ${color5.cyan(".vc/app")} — nothing there is yours to edit.`);
699
834
  } else if (layout === "app") {
700
835
  if (!isVoidApp(root)) {
701
- p2.log.step(`No Void app here yet — ${color4.cyan("void init")} first, then the shop.`);
836
+ p2.log.step(`No Void app here yet — ${color5.cyan("void init")} first, then the shop.`);
702
837
  const before = new Set(voidAppsIn(root));
703
838
  const code = await runVoid(["init"]);
704
839
  if (code !== 0)
@@ -711,7 +846,7 @@ async function init(args) {
711
846
  }
712
847
  root = created[0];
713
848
  process.chdir(root);
714
- p2.log.step(`Continuing in ${color4.cyan(`${basename(root)}/`)}`);
849
+ p2.log.step(`Continuing in ${color5.cyan(`${basename(root)}/`)}`);
715
850
  }
716
851
  }
717
852
  } else {
@@ -720,11 +855,11 @@ async function init(args) {
720
855
  ["frontend", "the storefront — prerendered and served from the worker's own assets"]
721
856
  ];
722
857
  for (const [dir, starter] of parts) {
723
- const target = join3(root, dir);
858
+ const target = join4(root, dir);
724
859
  if (isVoidApp(target))
725
860
  continue;
726
861
  await mkdir(target, { recursive: true });
727
- p2.log.step(`${color4.cyan("void init")} in ${color4.cyan(`${dir}/`)} — choose ${starter}.`);
862
+ p2.log.step(`${color5.cyan("void init")} in ${color5.cyan(`${dir}/`)} — choose ${starter}.`);
728
863
  const code = await runVoid(["init"], target);
729
864
  if (code !== 0)
730
865
  return code;
@@ -742,7 +877,7 @@ async function init(args) {
742
877
  if (problems.length > 0) {
743
878
  p2.log.error("These answers contradict each other:");
744
879
  for (const problem of problems)
745
- p2.log.message(` ${color4.red("✗")} ${problem}`);
880
+ p2.log.message(` ${color5.red("✗")} ${problem}`);
746
881
  p2.cancel("Nothing was written.");
747
882
  return 1;
748
883
  }
@@ -758,16 +893,16 @@ async function init(args) {
758
893
  spinner2.stop("Generated");
759
894
  const { secrets, plaintext } = envSummary(manifest);
760
895
  p2.note([
761
- ...result.written.map((file) => `${color4.green("+")} ${file}`),
762
- ...result.kept.map((file) => `${color4.dim("=")} ${file} ${color4.dim("(kept — yours)")}`),
763
- ...result.retired.map((file) => `${color4.red("-")} ${file} ${color4.dim("(retired — no longer generated)")}`)
896
+ ...result.written.map((file) => `${color5.green("+")} ${file}`),
897
+ ...result.kept.map((file) => `${color5.dim("=")} ${file} ${color5.dim("(kept — yours)")}`),
898
+ ...result.retired.map((file) => `${color5.red("-")} ${file} ${color5.dim("(retired — no longer generated)")}`)
764
899
  ].join(`
765
900
  `), "Files");
766
901
  p2.note([
767
- `${color4.bold(String(secrets.length))} secrets ${color4.dim("→ wrangler secret put <NAME>")}`,
902
+ `${color5.bold(String(secrets.length))} secrets ${color5.dim("→ wrangler secret put <NAME>")}`,
768
903
  ...secrets.map((key) => ` ${key}`),
769
904
  "",
770
- `${color4.bold(String(plaintext.length))} plaintext ${color4.dim("→ .env.production, already written")}`,
905
+ `${color5.bold(String(plaintext.length))} plaintext ${color5.dim("→ .env.production, already written")}`,
771
906
  ...plaintext.map((key) => ` ${key}`)
772
907
  ].join(`
773
908
  `), "Before going live");
@@ -777,21 +912,21 @@ async function init(args) {
777
912
  return code;
778
913
  }
779
914
  const next = layout === "app" ? [
780
- ` ${color4.cyan("bun install")}`,
781
- ` ${color4.cyan("bun run maildev")} ${color4.dim("local inbox at http://localhost:1080")}`,
782
- ` ${color4.cyan("vc dev")}`,
783
- ` ${color4.cyan("vc preflight")} ${color4.dim("what is still missing, and why it matters")}`
915
+ ` ${color5.cyan("bun install")}`,
916
+ ` ${color5.cyan("bun run maildev")} ${color5.dim("local inbox at http://localhost:1080")}`,
917
+ ` ${color5.cyan("vc dev")}`,
918
+ ` ${color5.cyan("vc preflight")} ${color5.dim("what is still missing, and why it matters")}`
784
919
  ] : layout === "strict" ? [
785
- ` ${color4.cyan("bun install")}`,
786
- ` ${color4.cyan("vc generate")} ${color4.dim("the app under .vc/app, void's artifacts, the migrations")}`,
787
- ` ${color4.cyan("bun run maildev")} ${color4.dim("local inbox at http://localhost:1080")}`,
788
- ` ${color4.cyan("vc dev")} ${color4.dim("runs in .vc/app")}`
920
+ ` ${color5.cyan("bun install")}`,
921
+ ` ${color5.cyan("vc generate")} ${color5.dim("the app under .vc/app, void's artifacts, the migrations")}`,
922
+ ` ${color5.cyan("bun run maildev")} ${color5.dim("local inbox at http://localhost:1080")}`,
923
+ ` ${color5.cyan("vc dev")} ${color5.dim("runs in .vc/app")}`
789
924
  ] : [
790
- ` ${color4.cyan("bun install")} ${color4.dim("one install for both workspaces")}`,
791
- ` ${color4.cyan("bun run maildev")} ${color4.dim("local inbox at http://localhost:1080")}`,
792
- ` ${color4.cyan("bun run dev:api")} ${color4.dim("the worker, with the panel")}`,
793
- ` ${color4.cyan("bun run dev:frontend")} ${color4.dim("the storefront, against the local API")}`,
794
- ` ${color4.cyan("bun run preflight")} ${color4.dim("what is still missing, and why it matters")}`
925
+ ` ${color5.cyan("bun install")} ${color5.dim("one install for both workspaces")}`,
926
+ ` ${color5.cyan("bun run maildev")} ${color5.dim("local inbox at http://localhost:1080")}`,
927
+ ` ${color5.cyan("bun run dev:api")} ${color5.dim("the worker, with the panel")}`,
928
+ ` ${color5.cyan("bun run dev:frontend")} ${color5.dim("the storefront, against the local API")}`,
929
+ ` ${color5.cyan("bun run preflight")} ${color5.dim("what is still missing, and why it matters")}`
795
930
  ];
796
931
  p2.outro(["Next:", ...next].join(`
797
932
  `));
@@ -800,7 +935,7 @@ async function init(args) {
800
935
 
801
936
  // src/regenerate.ts
802
937
  import * as p3 from "@clack/prompts";
803
- import color5 from "picocolors";
938
+ import color6 from "picocolors";
804
939
  async function generateCommand() {
805
940
  const project = await findProject();
806
941
  if (!project) {
@@ -816,9 +951,9 @@ async function generateCommand() {
816
951
  }
817
952
  const result = await generate(project.root, project.manifest);
818
953
  p3.log.step([
819
- ...result.written.map((file) => `${color5.green("+")} ${file}`),
820
- ...result.kept.map((file) => `${color5.dim("=")} ${file} ${color5.dim("(kept)")}`),
821
- ...result.retired.map((file) => `${color5.red("-")} ${file} ${color5.dim("(retired — no longer generated)")}`)
954
+ ...result.written.map((file) => `${color6.green("+")} ${file}`),
955
+ ...result.kept.map((file) => `${color6.dim("=")} ${file} ${color6.dim("(kept)")}`),
956
+ ...result.retired.map((file) => `${color6.red("-")} ${file} ${color6.dim("(retired — no longer generated)")}`)
822
957
  ].join(`
823
958
  `));
824
959
  return project.manifest.layout === "strict" ? finishStrict(project.root) : 0;
@@ -837,8 +972,8 @@ async function generateHelp() {
837
972
  }
838
973
 
839
974
  // src/scripts.ts
840
- import { existsSync as existsSync2, readFileSync } from "node:fs";
841
- import { join as join4, relative } from "node:path";
975
+ import { existsSync as existsSync3, readFileSync } from "node:fs";
976
+ import { join as join5, relative } from "node:path";
842
977
  function appScript(name) {
843
978
  return async (args) => {
844
979
  const project = await findProject();
@@ -847,8 +982,8 @@ function appScript(name) {
847
982
  const code = await ensureGenerated(project);
848
983
  if (code !== 0)
849
984
  return code;
850
- const pkgPath = join4(project.appDir, "package.json");
851
- const scripts = existsSync2(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {} : {};
985
+ const pkgPath = join5(project.appDir, "package.json");
986
+ const scripts = existsSync3(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {} : {};
852
987
  if (!scripts[name]) {
853
988
  console.error(`vc: ${relative(process.cwd(), pkgPath) || "package.json"} has no "${name}" script — void init writes one.`);
854
989
  return 1;
@@ -884,6 +1019,7 @@ var EXTENDED = {
884
1019
  secrets: { run: secretsCliCommand, help: secretsHelp },
885
1020
  keys: { run: keysCliCommand, help: keysHelp },
886
1021
  link: { run: linkCliCommand, help: linkHelp },
1022
+ publish: { run: publishCliCommand, help: publishHelp },
887
1023
  guard: { run: guardCommand, help: async () => guardCommand() },
888
1024
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
889
1025
  build: { run: appScript("build"), help: appScriptHelp("build") },
@@ -46,3 +46,6 @@ export declare function guardCommand(): Promise<number>;
46
46
  /** `vc link` — GitHub holds the credentials; this is what puts them there. */
47
47
  export declare function linkCliCommand(args: string[]): Promise<number>;
48
48
  export declare function linkHelp(): Promise<number>;
49
+ /** `vc publish` — create the repository, configure it, push. The push deploys. */
50
+ export declare function publishCliCommand(args: string[]): Promise<number>;
51
+ export declare function publishHelp(): Promise<number>;
@@ -0,0 +1,32 @@
1
+ import type { Project } from "../project";
2
+ /**
3
+ * `vc publish` — make the repository, give it what it needs, push.
4
+ *
5
+ * ── Why this exists ──────────────────────────────────────────────────────
6
+ *
7
+ * The goal was always "publishing to GitHub is the deploy". `vc link` sat in
8
+ * front of that as a separate step, and it is worth being exact about what
9
+ * it was actually for, because two of its three jobs never needed a person:
10
+ *
11
+ * DOTENV_PRIVATE_KEY_SECRETS vc already holds it — automatic
12
+ * CLOUDFLARE_ACCOUNT_ID it is in the manifest — automatic
13
+ * CLOUDFLARE_API_TOKEN exists only in Cloudflare's dashboard
14
+ *
15
+ * Only the third needs a human, and it cannot be removed: there is no OIDC
16
+ * or workload identity federation from GitHub to the Cloudflare API, so
17
+ * something has to carry a token across, and only a person can fetch one.
18
+ * Everything else is ceremony that a command can do.
19
+ *
20
+ * So this is the one command. It creates the repository, sets all three,
21
+ * and pushes — and the push is what deploys. After it, `git push` is the
22
+ * whole loop forever, and `vc` is only needed to read or change a secret.
23
+ *
24
+ * ── The order matters ────────────────────────────────────────────────────
25
+ *
26
+ * The repository is created WITHOUT pushing, the credentials go on, and only
27
+ * then does the push happen. `gh repo create --push` would put main there
28
+ * first, which starts a deploy against a repository that has no key and no
29
+ * token — a red run, an email, and a shop that did not deploy, for no
30
+ * reason other than doing two things in the wrong order.
31
+ */
32
+ export declare function publishCommand(project: Project, args: string[]): Promise<number>;
@@ -12,7 +12,7 @@ import {
12
12
  renderEnvProduction,
13
13
  renderEnvTs,
14
14
  unsetSecretNames
15
- } from "./index-negctpws.js";
15
+ } from "./index-khzk6a9z.js";
16
16
  import {
17
17
  MANIFEST_FILE,
18
18
  has,
@@ -1379,7 +1379,7 @@ import color from "picocolors";
1379
1379
  // package.json
1380
1380
  var package_default = {
1381
1381
  name: "@saastemly/voidcommerce",
1382
- version: "0.17.0",
1382
+ version: "0.18.0",
1383
1383
  description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
1384
1384
  type: "module",
1385
1385
  license: "MIT",
@@ -1896,14 +1896,21 @@ You do not need wrangler on your machine, and you do not need to be logged
1896
1896
  into it. You do not need to open the Cloudflare dashboard after the one step
1897
1897
  below.
1898
1898
 
1899
- ## Once, before the first push
1899
+ ## Once, to start
1900
1900
 
1901
1901
  \`\`\`sh
1902
- gh repo create --source=. --private --push # if there is no repo yet
1903
- vc link
1902
+ vc publish
1904
1903
  \`\`\`
1905
1904
 
1906
- \`vc link\` does three things and stores all of them on the GitHub repository:
1905
+ One command: it creates the repository, gives it what the deploy needs, and
1906
+ pushes — and the push is the deploy. After it, \`git push\` is the whole loop.
1907
+
1908
+ Two of the three things it sets need nothing from you. The encryption key vc
1909
+ already holds; the account id is in \`voidcommerce.json\`. It asks only for a
1910
+ Cloudflare API token, because there is no OIDC between GitHub and Cloudflare
1911
+ and only a person can fetch one from the dashboard.
1912
+
1913
+ It stores three things on the GitHub repository:
1907
1914
 
1908
1915
  | what | where | why |
1909
1916
  |---|---|---|
@@ -414,7 +414,7 @@ async function decryptSecrets(project) {
414
414
  }
415
415
  const expected = committedPublicKey(root);
416
416
  if (expected) {
417
- const { publicKeyFor } = await import("./keys-w65kbdp6.js");
417
+ const { publicKeyFor } = await import("./keys-fn6wbv40.js");
418
418
  const derived = await publicKeyFor(privateKey);
419
419
  if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
420
420
  return {
@@ -995,4 +995,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
995
995
  return 0;
996
996
  }
997
997
 
998
- export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, ghAuth, repoSlug, setSecret, secretNames, setVariable, getVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, unsetSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
998
+ export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, findGh, run, ghAuth, repoSlug, setSecret, secretNames, setVariable, getVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run1, committedPublicKeyInto, declaredSecretNames, unsetSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  routeProblem,
53
53
  strictDependencies,
54
54
  upsertJsonc
55
- } from "./index-4jz1448c.js";
55
+ } from "./index-3j6jtjmk.js";
56
56
  import {
57
57
  allEnvKeys,
58
58
  envSummary,
@@ -60,7 +60,7 @@ import {
60
60
  renderEnvLocal,
61
61
  renderEnvProduction,
62
62
  renderEnvTs
63
- } from "./index-negctpws.js";
63
+ } from "./index-khzk6a9z.js";
64
64
  import {
65
65
  LAYOUTS,
66
66
  MANIFEST_FILE,
@@ -8,7 +8,7 @@ import {
8
8
  localPrivateKey,
9
9
  provisionKey,
10
10
  publicKeyFor
11
- } from "./index-negctpws.js";
11
+ } from "./index-khzk6a9z.js";
12
12
  import"./index-30y19qz5.js";
13
13
  import"./index-xyjhy6kp.js";
14
14
  import"./index-0v6na3yp.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  keysHelp,
7
7
  linkCliCommand,
8
8
  linkHelp,
9
+ publishCliCommand,
10
+ publishHelp,
9
11
  preflightCommand,
10
12
  preflightHelp,
11
13
  secretsCliCommand,
@@ -53,6 +55,7 @@ export const EXTENDED: Record<string, Extended> = {
53
55
  secrets: { run: secretsCliCommand, help: secretsHelp },
54
56
  keys: { run: keysCliCommand, help: keysHelp },
55
57
  link: { run: linkCliCommand, help: linkHelp },
58
+ publish: { run: publishCliCommand, help: publishHelp },
56
59
  // Called by the generated pre-commit hook; exit code is the interface.
57
60
  guard: { run: guardCommand, help: async () => guardCommand() },
58
61
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
@@ -9,6 +9,7 @@ import { join } from "node:path";
9
9
  import { PRIVATE_KEY_VAR, SECRETS_FILE, encryptInto, plaintextSecretEntries, plaintextSecretNames, run, secretsCommand } from "./secrets";
10
10
  import { LOCAL_KEY_FILE, committedPublicKey, ignoresKeyFile, keysCommand } from "./keys";
11
11
  import { linkCommand } from "./link";
12
+ import { publishCommand } from "./publish";
12
13
 
13
14
  /**
14
15
  * `vc deploy` extends `void deploy`: vc's preflight first, then void's
@@ -341,3 +342,42 @@ export async function linkHelp(): Promise<number> {
341
342
  );
342
343
  return 0;
343
344
  }
345
+
346
+
347
+ /** `vc publish` — create the repository, configure it, push. The push deploys. */
348
+ export async function publishCliCommand(args: string[]): Promise<number> {
349
+ const project = await findProject();
350
+ if (!project) {
351
+ console.error("vc: no voidcommerce.json here.");
352
+ return 1;
353
+ }
354
+ return publishCommand(project, args);
355
+ }
356
+
357
+ export async function publishHelp(): Promise<number> {
358
+ const width = 80;
359
+ console.log(
360
+ box("vc publish", [
361
+ line("Create this shop's GitHub repository, give it what the deploy needs,", width),
362
+ line("and push. The push IS the deploy. Run once; after that, `git push`.", width),
363
+ line("", width),
364
+ ...row("vc publish", "create the repository, set the credentials, push main", width, 2),
365
+ line("", width),
366
+ line(color.bold("What it does for you"), width),
367
+ ...row("the encryption key", "vc already holds it — set with no input from you", width, 2),
368
+ ...row("the account id", "read from voidcommerce.json", width, 2),
369
+ ...row("the Cloudflare token", "the one thing it has to ask for", width, 2),
370
+ line("", width),
371
+ line(color.bold("Why the token cannot be automated away"), width),
372
+ line("There is no OIDC between GitHub and Cloudflare, so something must carry", width),
373
+ line("a token across, and only a person can fetch one from the dashboard. It", width),
374
+ line("is asked for once, checked against the Cloudflare API, and stored on the", width),
375
+ line("repository — never on this machine.", width),
376
+ line("", width),
377
+ line("The repository is created WITHOUT pushing, then configured, then pushed.", width),
378
+ line("Pushing first would start a deploy against a repository that has no key", width),
379
+ line("and no token: a red run, for no reason but the wrong order.", width),
380
+ ], width),
381
+ );
382
+ return 0;
383
+ }
@@ -0,0 +1,145 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import color from "picocolors";
4
+ import type { Project } from "../project";
5
+ import { findGh, ghAuth, repoSlug, run } from "./github";
6
+ import { linkCommand } from "./link";
7
+
8
+ /**
9
+ * `vc publish` — make the repository, give it what it needs, push.
10
+ *
11
+ * ── Why this exists ──────────────────────────────────────────────────────
12
+ *
13
+ * The goal was always "publishing to GitHub is the deploy". `vc link` sat in
14
+ * front of that as a separate step, and it is worth being exact about what
15
+ * it was actually for, because two of its three jobs never needed a person:
16
+ *
17
+ * DOTENV_PRIVATE_KEY_SECRETS vc already holds it — automatic
18
+ * CLOUDFLARE_ACCOUNT_ID it is in the manifest — automatic
19
+ * CLOUDFLARE_API_TOKEN exists only in Cloudflare's dashboard
20
+ *
21
+ * Only the third needs a human, and it cannot be removed: there is no OIDC
22
+ * or workload identity federation from GitHub to the Cloudflare API, so
23
+ * something has to carry a token across, and only a person can fetch one.
24
+ * Everything else is ceremony that a command can do.
25
+ *
26
+ * So this is the one command. It creates the repository, sets all three,
27
+ * and pushes — and the push is what deploys. After it, `git push` is the
28
+ * whole loop forever, and `vc` is only needed to read or change a secret.
29
+ *
30
+ * ── The order matters ────────────────────────────────────────────────────
31
+ *
32
+ * The repository is created WITHOUT pushing, the credentials go on, and only
33
+ * then does the push happen. `gh repo create --push` would put main there
34
+ * first, which starts a deploy against a repository that has no key and no
35
+ * token — a red run, an email, and a shop that did not deploy, for no
36
+ * reason other than doing two things in the wrong order.
37
+ */
38
+
39
+ export async function publishCommand(project: Project, args: string[]): Promise<number> {
40
+ const p = await import("@clack/prompts");
41
+ const root = project.root;
42
+ const gh = findGh();
43
+
44
+ p.intro(color.bgCyan(color.black(" vc publish ")));
45
+
46
+ const auth = await ghAuth(root);
47
+ if (!auth.ok || !gh) {
48
+ p.cancel(auth.reason ?? "the GitHub CLI is not installed. https://cli.github.com");
49
+ return 1;
50
+ }
51
+ if (!existsSync(join(root, ".git"))) {
52
+ p.cancel(`${root} is not a git repository. \`git init\` first, and commit what you have.`);
53
+ return 1;
54
+ }
55
+
56
+ // 1. The repository. Created without pushing: see above.
57
+ let slug = await repoSlug(root);
58
+ if (slug) {
59
+ p.log.info(`${slug} already exists — using it`);
60
+ } else {
61
+ const suggested = project.manifest.shop.domain.split(".")[0] ?? "shop";
62
+ const name = await p.text({
63
+ message: "Repository name",
64
+ initialValue: suggested,
65
+ validate: (value) => (/^[A-Za-z0-9._-]+$/.test(value.trim()) ? undefined : "letters, digits, dot, dash or underscore"),
66
+ });
67
+ if (p.isCancel(name)) {
68
+ p.cancel("nothing was created.");
69
+ return 1;
70
+ }
71
+ // PRIVATE by default and deliberately: the encryption key becomes a
72
+ // repository variable, so anyone who can read this repository can
73
+ // decrypt its secrets.
74
+ const visibility = await p.select({
75
+ message: "Visibility",
76
+ options: [
77
+ { value: "--private", label: "Private", hint: "the key that opens your secrets is readable to anyone with access" },
78
+ { value: "--public", label: "Public", hint: "only if this shop keeps no secrets at all" },
79
+ ],
80
+ initialValue: "--private",
81
+ });
82
+ if (p.isCancel(visibility)) {
83
+ p.cancel("nothing was created.");
84
+ return 1;
85
+ }
86
+
87
+ const spinner = p.spinner();
88
+ spinner.start(`creating ${String(name)}`);
89
+ const created = await run(gh, ["repo", "create", String(name).trim(), "--source=.", String(visibility)], root);
90
+ if (created.code !== 0) {
91
+ spinner.stop(`${color.red("✗")} could not create it`);
92
+ p.cancel(created.out.trim().split("\n").slice(-2).join(" "));
93
+ return 1;
94
+ }
95
+ slug = await repoSlug(root);
96
+ spinner.stop(`${color.green("✓")} ${slug ?? String(name)} created, and set as origin`);
97
+ }
98
+
99
+ // 2. The credentials. Two are automatic; one is asked for.
100
+ p.log.step("giving the repository what the deploy needs");
101
+ const linked = await linkCommand(project, args);
102
+ if (linked !== 0) {
103
+ p.cancel("the repository exists but is not configured, so a push would not deploy. Fix the above and run `vc publish` again.");
104
+ return 1;
105
+ }
106
+
107
+ // 3. Anything `vc link` wrote — the public key, a fresh .env.secrets, the
108
+ // pinned account id — has to be IN the push, or the deploy reads a
109
+ // repository that disagrees with the one on this machine.
110
+ const dirty = (await run("git", ["status", "--porcelain"], root)).out.trim();
111
+ if (dirty) {
112
+ const staged = await run("git", ["add", "-A"], root);
113
+ const committed = await run("git", ["commit", "-m", "vc publish: link this shop to its repository"], root);
114
+ if (staged.code !== 0 || committed.code !== 0) {
115
+ p.cancel(`could not commit the changes vc link made:\n${committed.out.trim().split("\n").slice(-3).join("\n")}`);
116
+ return 1;
117
+ }
118
+ p.log.success(`${color.green("✓")} committed what linking changed`);
119
+ }
120
+
121
+ // 4. Push. This is the deploy.
122
+ const branch = (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], root)).out.trim() || "main";
123
+ const spinner = p.spinner();
124
+ spinner.start(`pushing ${branch}`);
125
+ const pushed = await run("git", ["push", "-u", "origin", `${branch}:main`], root);
126
+ if (pushed.code !== 0) {
127
+ spinner.stop(`${color.red("✗")} the push failed`);
128
+ p.cancel(pushed.out.trim().split("\n").slice(-4).join("\n"));
129
+ return 1;
130
+ }
131
+ spinner.stop(`${color.green("✓")} pushed to main`);
132
+
133
+ p.outro(
134
+ [
135
+ `${color.bold("Published.")} The deploy is running now.`,
136
+ "",
137
+ ` ${color.cyan(`https://github.com/${slug}/actions`)}`,
138
+ "",
139
+ ` From here ${color.cyan("git push")} is the whole loop. \`vc\` is only needed to`,
140
+ ` change a secret (${color.cyan("vc secrets set KEY")}) or read one back`,
141
+ ` (${color.cyan("vc keys --restore")}).`,
142
+ ].join("\n"),
143
+ );
144
+ return 0;
145
+ }
@@ -228,14 +228,21 @@ You do not need wrangler on your machine, and you do not need to be logged
228
228
  into it. You do not need to open the Cloudflare dashboard after the one step
229
229
  below.
230
230
 
231
- ## Once, before the first push
231
+ ## Once, to start
232
232
 
233
233
  \`\`\`sh
234
- gh repo create --source=. --private --push # if there is no repo yet
235
- vc link
234
+ vc publish
236
235
  \`\`\`
237
236
 
238
- \`vc link\` does three things and stores all of them on the GitHub repository:
237
+ One command: it creates the repository, gives it what the deploy needs, and
238
+ pushes — and the push is the deploy. After it, \`git push\` is the whole loop.
239
+
240
+ Two of the three things it sets need nothing from you. The encryption key vc
241
+ already holds; the account id is in \`voidcommerce.json\`. It asks only for a
242
+ Cloudflare API token, because there is no OIDC between GitHub and Cloudflare
243
+ and only a person can fetch one from the dashboard.
244
+
245
+ It stores three things on the GitHub repository:
239
246
 
240
247
  | what | where | why |
241
248
  |---|---|---|