@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 +209 -73
- package/dist/deploy/index.d.ts +3 -0
- package/dist/deploy/publish.d.ts +32 -0
- package/dist/{index-4jz1448c.js → index-3j6jtjmk.js} +13 -6
- package/dist/{index-negctpws.js → index-khzk6a9z.js} +2 -2
- package/dist/index.js +2 -2
- package/dist/{keys-w65kbdp6.js → keys-fn6wbv40.js} +1 -1
- package/package.json +1 -1
- package/src/cli.ts +3 -0
- package/src/deploy/index.ts +40 -0
- package/src/deploy/publish.ts +145 -0
- package/src/generate/ci.ts +11 -4
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
runVoid,
|
|
22
22
|
version,
|
|
23
23
|
voidAppsIn
|
|
24
|
-
} from "./index-
|
|
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-
|
|
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
|
|
72
|
-
import { existsSync } from "node:fs";
|
|
73
|
-
import { join as
|
|
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(`${
|
|
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(
|
|
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(`${
|
|
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
|
|
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(
|
|
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(
|
|
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 (
|
|
502
|
+
if (existsSync2(join3(root, LOCAL_KEY_FILE)) && !ignoresKeyFile(root)) {
|
|
401
503
|
console.error(`
|
|
402
|
-
${
|
|
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
|
-
${
|
|
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
|
-
` + ` ${
|
|
522
|
+
` + ` ${color3.cyan("vc keys --init")} makes one — no GitHub repository needed yet.
|
|
421
523
|
|
|
422
|
-
` +
|
|
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
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
|
547
|
+
const added = await run2("git", ["add", "--", SECRETS_FILE], root);
|
|
446
548
|
if (added.code !== 0) {
|
|
447
549
|
console.error(`
|
|
448
|
-
${
|
|
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
|
-
${
|
|
456
|
-
` +
|
|
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(
|
|
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(
|
|
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
|
|
495
|
-
import
|
|
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
|
|
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
|
-
${
|
|
522
|
-
${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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? ${
|
|
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 ${
|
|
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 ${
|
|
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
|
-
${
|
|
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
|
-
`${
|
|
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(`${
|
|
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(
|
|
822
|
+
p2.intro(color5.bgCyan(color5.black(" voidcommerce ")));
|
|
688
823
|
const existing = await readManifest(root);
|
|
689
824
|
if (existing) {
|
|
690
|
-
p2.log.info(`Found ${
|
|
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 ${
|
|
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 — ${
|
|
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 ${
|
|
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 =
|
|
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(`${
|
|
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(` ${
|
|
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) => `${
|
|
762
|
-
...result.kept.map((file) => `${
|
|
763
|
-
...result.retired.map((file) => `${
|
|
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
|
-
`${
|
|
902
|
+
`${color5.bold(String(secrets.length))} secrets ${color5.dim("→ wrangler secret put <NAME>")}`,
|
|
768
903
|
...secrets.map((key) => ` ${key}`),
|
|
769
904
|
"",
|
|
770
|
-
`${
|
|
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
|
-
` ${
|
|
781
|
-
` ${
|
|
782
|
-
` ${
|
|
783
|
-
` ${
|
|
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
|
-
` ${
|
|
786
|
-
` ${
|
|
787
|
-
` ${
|
|
788
|
-
` ${
|
|
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
|
-
` ${
|
|
791
|
-
` ${
|
|
792
|
-
` ${
|
|
793
|
-
` ${
|
|
794
|
-
` ${
|
|
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
|
|
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) => `${
|
|
820
|
-
...result.kept.map((file) => `${
|
|
821
|
-
...result.retired.map((file) => `${
|
|
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
|
|
841
|
-
import { join as
|
|
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 =
|
|
851
|
-
const 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") },
|
package/dist/deploy/index.d.ts
CHANGED
|
@@ -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-
|
|
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.
|
|
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,
|
|
1899
|
+
## Once, to start
|
|
1900
1900
|
|
|
1901
1901
|
\`\`\`sh
|
|
1902
|
-
|
|
1903
|
-
vc link
|
|
1902
|
+
vc publish
|
|
1904
1903
|
\`\`\`
|
|
1905
1904
|
|
|
1906
|
-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
63
|
+
} from "./index-khzk6a9z.js";
|
|
64
64
|
import {
|
|
65
65
|
LAYOUTS,
|
|
66
66
|
MANIFEST_FILE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "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") },
|
package/src/deploy/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/generate/ci.ts
CHANGED
|
@@ -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,
|
|
231
|
+
## Once, to start
|
|
232
232
|
|
|
233
233
|
\`\`\`sh
|
|
234
|
-
|
|
235
|
-
vc link
|
|
234
|
+
vc publish
|
|
236
235
|
\`\`\`
|
|
237
236
|
|
|
238
|
-
|
|
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
|
|---|---|---|
|