@saastemly/voidcommerce 0.7.0 → 0.9.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,28 +21,36 @@ import {
21
21
  runVoid,
22
22
  version,
23
23
  voidAppsIn
24
- } from "./index-85m0qkmg.js";
24
+ } from "./index-0w66yc8y.js";
25
25
  import {
26
+ LOCAL_KEY_FILE,
26
27
  PRIVATE_KEY_VAR,
27
28
  SECRETS_FILE,
28
29
  cloudflareAccounts,
30
+ committedPublicKey,
29
31
  committedPublicKeyInto,
30
32
  declaredSecretNames,
33
+ encryptInto,
31
34
  envSummary,
32
35
  ghAuth,
36
+ ignoresKeyFile,
33
37
  initSecrets,
34
38
  keyState,
35
39
  keysCommand,
40
+ localPrivateKey,
41
+ plaintextSecretEntries,
36
42
  plaintextSecretNames,
37
43
  provisionKey,
44
+ publicKeyFor,
38
45
  repoSlug,
46
+ run,
39
47
  secretNames,
40
48
  secretsCommand,
41
49
  setSecret,
42
50
  setVariable,
43
51
  variableNames,
44
52
  verifyCloudflareToken
45
- } from "./index-6w8rwtsh.js";
53
+ } from "./index-wykd4zmk.js";
46
54
  import {
47
55
  LAYOUTS,
48
56
  oneOrigin,
@@ -60,8 +68,12 @@ import {
60
68
 
61
69
  // src/deploy/index.ts
62
70
  import color2 from "picocolors";
71
+ import { existsSync } from "node:fs";
72
+ import { join as join2 } from "node:path";
63
73
 
64
74
  // src/deploy/link.ts
75
+ import { rmSync } from "node:fs";
76
+ import { join } from "node:path";
65
77
  import color from "picocolors";
66
78
  var TOKEN_SECRET = "CLOUDFLARE_API_TOKEN";
67
79
  var ACCOUNT_VAR = "CLOUDFLARE_ACCOUNT_ID";
@@ -102,13 +114,27 @@ async function linkCommand(project, args) {
102
114
  p.log.warn(`--force: replacing ${PRIVATE_KEY_VAR} makes every value in ${SECRETS_FILE} unreadable. Use \`vc keys --rotate\` to re-encrypt instead.`);
103
115
  return 1;
104
116
  } else {
105
- const made = await provisionKey(project);
106
- if (!made.ok) {
107
- p.cancel(made.reason);
108
- return 1;
117
+ const parked = localPrivateKey(root);
118
+ if (parked) {
119
+ const sent = await setSecret(root, PRIVATE_KEY_VAR, parked);
120
+ if (!sent.ok) {
121
+ p.cancel(`GitHub refused the secret: ${sent.error ?? "unknown error"}`);
122
+ return 1;
123
+ }
124
+ const publicKey = await publicKeyFor(parked);
125
+ if (publicKey)
126
+ committedPublicKeyInto(root, publicKey);
127
+ rmSync(join(root, LOCAL_KEY_FILE), { force: true });
128
+ p.log.success(`${color.green("✓")} the key waiting in ${LOCAL_KEY_FILE} moved to GitHub, and the file deleted`);
129
+ } else {
130
+ const made = await provisionKey(project);
131
+ if (!made.ok) {
132
+ p.cancel(made.reason);
133
+ return 1;
134
+ }
135
+ committedPublicKeyInto(root, made.publicKey);
136
+ p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
109
137
  }
110
- committedPublicKeyInto(root, made.publicKey);
111
- p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
112
138
  }
113
139
  const existing = await secretNames(root);
114
140
  if (existing.has(TOKEN_SECRET) && !relink) {
@@ -335,21 +361,66 @@ async function guardCommand() {
335
361
  const project = await findProject();
336
362
  if (!project)
337
363
  return 0;
338
- const bare = plaintextSecretNames(project.root);
364
+ const root = project.root;
365
+ if (existsSync(join2(root, LOCAL_KEY_FILE)) && !ignoresKeyFile(root)) {
366
+ console.error(`
367
+ ${color2.red("✗ refusing the commit")}: ${LOCAL_KEY_FILE} exists and is NOT gitignored.
368
+
369
+ ` + ` It holds the private key that opens every secret in this repository.
370
+ ` + ` Add ${LOCAL_KEY_FILE} to .gitignore before committing anything.
371
+ `);
372
+ return 1;
373
+ }
374
+ const bare = plaintextSecretEntries(root);
339
375
  if (bare.length === 0)
340
376
  return 0;
341
- console.error(`
342
- ${color2.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${bare.length === 1 ? "is" : "are"} not encrypted.
377
+ const publicKey = committedPublicKey(root);
378
+ if (!publicKey) {
379
+ console.error(`
380
+ ${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.
343
381
 
344
- ` + ` ${bare.join(`
382
+ ` + ` ${bare.map((entry) => entry.name).join(`
345
383
  `)}
346
384
 
347
- ` + ` bunx dotenvx encrypt -f ${SECRETS_FILE}
385
+ ` + ` ${color2.cyan("vc keys --init")} makes one — no GitHub repository needed yet.
348
386
 
349
387
  ` + color2.dim(` Committing a secret in the clear cannot be undone by a later commit;
350
388
  the value stays in the history and must be treated as burned.
351
389
  `));
352
- return 1;
390
+ return 1;
391
+ }
392
+ const staged = (await run("git", ["diff", "--cached", "--name-only", "--", SECRETS_FILE], root)).out.trim().length > 0;
393
+ for (const entry of bare) {
394
+ const sealed = await encryptInto(root, publicKey, entry.name, entry.value);
395
+ if (!sealed.ok) {
396
+ console.error(`
397
+ ${color2.red("✗ refusing the commit")}: ${sealed.error}
398
+ `);
399
+ return 1;
400
+ }
401
+ }
402
+ const left = plaintextSecretNames(root);
403
+ if (left.length > 0) {
404
+ console.error(`
405
+ ${color2.red("✗ refusing the commit")}: ${left.join(", ")} could not be encrypted.
406
+ `);
407
+ return 1;
408
+ }
409
+ if (staged) {
410
+ const added = await run("git", ["add", "--", SECRETS_FILE], root);
411
+ if (added.code !== 0) {
412
+ console.error(`
413
+ ${color2.red("✗ refusing the commit")}: ${SECRETS_FILE} was encrypted but could not be re-staged,
414
+ ` + ` so the commit would still carry the plaintext you staged. \`git add ${SECRETS_FILE}\`.
415
+ `);
416
+ return 1;
417
+ }
418
+ }
419
+ console.log(`
420
+ ${color2.green("✓")} encrypted ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE}${staged ? " and re-staged it" : ""}: ${bare.map((entry) => entry.name).join(", ")}
421
+ ` + color2.dim(` Encryption needs only the public key, so this needs no credential.
422
+ `));
423
+ return 0;
353
424
  }
354
425
  async function linkCliCommand(args) {
355
426
  const project = await findProject();
@@ -365,21 +436,19 @@ async function linkHelp() {
365
436
  line("Put this shop's credentials on its GitHub repository, once, so that", width),
366
437
  line("every deploy after this is a `git push`.", width),
367
438
  line("", width),
368
- ...row("vc link", "generate the encryption key, take the Cloudflare token, store both", width, 2),
439
+ ...row("vc link", "store the encryption key and the Cloudflare token on the repository", width, 2),
369
440
  ...row("vc link --force", "replace what is already there", width, 2),
370
441
  line("", width),
371
442
  line(color2.bold("What it stores, and where"), width),
372
- ...row("DOTENV_PRIVATE_KEY_SECRETS", "generated here, never written to disk a repository SECRET", width, 2),
373
- ...row("CLOUDFLARE_API_TOKEN", "yours, checked against the Cloudflare API first a repository SECRET", width, 2),
374
- ...row("CLOUDFLARE_ACCOUNT_ID", "an identifier, not a credential — a repository VARIABLE", width, 2),
443
+ ...row(PRIVATE_KEY_VAR, "a repository SECRET. A key already waiting locally is MOVED here and the local copy deleted", width, 2),
444
+ ...row("CLOUDFLARE_API_TOKEN", "a repository SECRET, checked against the Cloudflare API before it is stored", width, 2),
445
+ ...row("CLOUDFLARE_ACCOUNT_ID", "a repository VARIABLE — an identifier, not a credential", width, 2),
375
446
  line("", width),
376
447
  line(color2.bold("Why one token still has to be typed"), width),
377
448
  line("GitHub cannot mint a Cloudflare credential. There is no OIDC federation", width),
378
449
  line("between them, and the Cloudflare GitHub App runs the other way: it grants", width),
379
450
  line("Cloudflare access to your repository, not your repository access to", width),
380
- line("Cloudflare. Something must authorise creating a database in your account,", width),
381
- line("and only Cloudflare can issue that. So it is typed once, here, and never", width),
382
- line("stored on this machine.", width)
451
+ line("Cloudflare. So it is typed once, here, and never stored on this machine.", width)
383
452
  ], width));
384
453
  return 0;
385
454
  }
@@ -387,7 +456,7 @@ async function linkHelp() {
387
456
  // src/init.ts
388
457
  import * as p2 from "@clack/prompts";
389
458
  import { mkdir } from "node:fs/promises";
390
- import { basename, join } from "node:path";
459
+ import { basename, join as join3 } from "node:path";
391
460
  import color4 from "picocolors";
392
461
 
393
462
  // src/wizard.ts
@@ -616,7 +685,7 @@ async function init(args) {
616
685
  ["frontend", "the storefront — prerendered and served from the worker's own assets"]
617
686
  ];
618
687
  for (const [dir, starter] of parts) {
619
- const target = join(root, dir);
688
+ const target = join3(root, dir);
620
689
  if (isVoidApp(target))
621
690
  continue;
622
691
  await mkdir(target, { recursive: true });
@@ -733,8 +802,8 @@ async function generateHelp() {
733
802
  }
734
803
 
735
804
  // src/scripts.ts
736
- import { existsSync, readFileSync } from "node:fs";
737
- import { join as join2, relative } from "node:path";
805
+ import { existsSync as existsSync2, readFileSync } from "node:fs";
806
+ import { join as join4, relative } from "node:path";
738
807
  function appScript(name) {
739
808
  return async (args) => {
740
809
  const project = await findProject();
@@ -743,8 +812,8 @@ function appScript(name) {
743
812
  const code = await ensureGenerated(project);
744
813
  if (code !== 0)
745
814
  return code;
746
- const pkgPath = join2(project.appDir, "package.json");
747
- const scripts = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {} : {};
815
+ const pkgPath = join4(project.appDir, "package.json");
816
+ const scripts = existsSync2(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {} : {};
748
817
  if (!scripts[name]) {
749
818
  console.error(`vc: ${relative(process.cwd(), pkgPath) || "package.json"} has no "${name}" script — void init writes one.`);
750
819
  return 1;
@@ -21,7 +21,24 @@ export declare function secretsHelp(): Promise<number>;
21
21
  export declare function keysCliCommand(args: string[]): Promise<number>;
22
22
  export declare function keysHelp(): Promise<number>;
23
23
  /**
24
- * The pre-commit gate: refuse a commit that would put a secret in the clear.
24
+ * The pre-commit gate: ENCRYPT anything in the clear, then let the commit through.
25
+ *
26
+ * ── Why this encrypts rather than refuses ────────────────────────────────
27
+ *
28
+ * It used to refuse, and refusing was the wrong shape. Encryption needs only
29
+ * the public key, which is committed right there in the file — so the hook
30
+ * can simply fix the problem instead of handing it back. A person who typed
31
+ * a real key into `.env.secrets` with an editor gets it sealed rather than a
32
+ * lecture, which is what actually keeps secrets out of history: a gate you
33
+ * have to satisfy by hand is a gate people learn to pass with --no-verify.
34
+ *
35
+ * ── The part that is easy to get wrong ───────────────────────────────────
36
+ *
37
+ * Git commits the INDEX, not the working tree. Encrypting the file on disk
38
+ * would leave the STAGED plaintext exactly where it was, and the commit
39
+ * would carry it anyway — the hook would report success while doing nothing.
40
+ * So a file that was staged is re-staged after encrypting, and the result is
41
+ * verified before the commit is allowed.
25
42
  *
26
43
  * Exit code is the whole interface — a hook cares about nothing else.
27
44
  */
@@ -38,6 +38,19 @@ import type { Project } from "../project";
38
38
  * leaked. Those must be replaced at Stripe and everywhere else regardless.
39
39
  * Re-entering them is not extra work — it is the work.
40
40
  */
41
+ /**
42
+ * Where a private key waits before the repository exists.
43
+ *
44
+ * Encryption needs only the public key, so a shop can be filled in with real
45
+ * secrets long before anyone has made a GitHub repo to put the private half
46
+ * on. Refusing to make a key until then would block exactly that, so
47
+ * `vc keys --init` will write one here instead — 0600, gitignored, and
48
+ * TEMPORARY: `vc link` uploads this key to the repository and deletes the
49
+ * file, which is how the end state stays "no private key on disk".
50
+ */
51
+ export declare const LOCAL_KEY_FILE = ".env.keys";
52
+ /** The private key parked locally, if there is one. */
53
+ export declare function localPrivateKey(root: string): string | null;
41
54
  /** The public key the repository was encrypted under, from the committed file. */
42
55
  export declare function committedPublicKey(root: string): string | null;
43
56
  /** A fresh secp256k1 pair. The private half must reach GitHub and then be forgotten. */
@@ -69,9 +82,12 @@ export declare function keyState(project: Project): Promise<KeyState>;
69
82
  export declare function provisionKey(project: Project): Promise<{
70
83
  ok: true;
71
84
  publicKey: string;
85
+ parked: boolean;
72
86
  } | {
73
87
  ok: false;
74
88
  reason: string;
75
89
  }>;
90
+ /** Is the local key file ignored by git? Checked before one is ever written. */
91
+ export declare function ignoresKeyFile(root: string): boolean;
76
92
  /** `vc keys` — where the key is, and what is missing. */
77
93
  export declare function keysCommand(project: Project, args: string[]): Promise<number>;
@@ -68,6 +68,17 @@ export declare function committedPublicKeyInto(root: string, publicKey: string):
68
68
  export declare function declaredSecretNames(root: string): Set<string>;
69
69
  /** Is a declared value actually encrypted, or was it committed in the clear? */
70
70
  export declare function plaintextSecretNames(root: string): string[];
71
+ /**
72
+ * The declared values that are NOT encrypted, with their values.
73
+ *
74
+ * Separate from `plaintextSecretNames` on purpose: names are safe to print
75
+ * and values never are, so the function that returns values is the one you
76
+ * have to go looking for.
77
+ */
78
+ export declare function plaintextSecretEntries(root: string): Array<{
79
+ name: string;
80
+ value: string;
81
+ }>;
71
82
  export interface DecryptedSecrets {
72
83
  /** A temp file in `.env` format, for `wrangler deploy --secrets-file`. */
73
84
  path: string;
@@ -9,7 +9,7 @@ import {
9
9
  renderEnvLocal,
10
10
  renderEnvProduction,
11
11
  renderEnvTs
12
- } from "./index-6w8rwtsh.js";
12
+ } from "./index-wykd4zmk.js";
13
13
  import {
14
14
  MANIFEST_FILE,
15
15
  has,
@@ -1344,7 +1344,7 @@ import color from "picocolors";
1344
1344
  // package.json
1345
1345
  var package_default = {
1346
1346
  name: "@saastemly/voidcommerce",
1347
- version: "0.7.0",
1347
+ version: "0.9.0",
1348
1348
  description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
1349
1349
  type: "module",
1350
1350
  license: "MIT",
@@ -2293,6 +2293,39 @@ async function exists(path) {
2293
2293
  return false;
2294
2294
  }
2295
2295
  }
2296
+ async function putEnv(root, file, rendered, result) {
2297
+ const path = join4(root, file);
2298
+ if (!await exists(path)) {
2299
+ await mkdir2(dirname2(path), { recursive: true });
2300
+ await writeFile2(path, rendered, "utf8");
2301
+ result.written.push(file);
2302
+ return;
2303
+ }
2304
+ const read = (text) => {
2305
+ const out = new Map;
2306
+ for (const line2 of text.split(`
2307
+ `)) {
2308
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line2);
2309
+ if (match)
2310
+ out.set(match[1], match[2]);
2311
+ }
2312
+ return out;
2313
+ };
2314
+ const existing = read(await readFile2(path, "utf8"));
2315
+ const wanted = read(rendered);
2316
+ const merged = rendered.split(`
2317
+ `).map((line2) => {
2318
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line2);
2319
+ if (!match)
2320
+ return line2;
2321
+ const had = existing.get(match[1]);
2322
+ return had !== undefined && had !== "" && had !== "unset" ? `${match[1]}=${had}` : line2;
2323
+ }).join(`
2324
+ `);
2325
+ const dropped = [...existing.keys()].filter((key) => !wanted.has(key) && !key.startsWith("VITE_"));
2326
+ await writeFile2(path, merged, "utf8");
2327
+ result.written.push(dropped.length > 0 ? `${file} ${`(dropped ${dropped.join(", ")})`}` : file);
2328
+ }
2296
2329
  async function put(root, file, content, result, mode) {
2297
2330
  const path = join4(root, file);
2298
2331
  if (mode === "own" && await exists(path)) {
@@ -2364,7 +2397,7 @@ async function generateApi(root, dir, manifest, result, opts) {
2364
2397
  await put(root, at(".env.production"), renderEnvProduction(manifest), result, "regenerate");
2365
2398
  await put(root, at("lib/deploy/requirements.ts"), renderRequirementsTs(manifest), result, "regenerate");
2366
2399
  if (opts.env)
2367
- await put(root, at(".env"), renderEnvLocal(manifest), result, "own");
2400
+ await putEnv(root, at(".env"), renderEnvLocal(manifest), result);
2368
2401
  await put(root, at("lib/domain.ts"), renderDomainTs(manifest.layout), result, own);
2369
2402
  await put(root, at("lib/payment.ts"), renderPaymentTs(manifest), result, own);
2370
2403
  await put(root, at("lib/notifications.ts"), renderNotificationsTs(), result, own);
@@ -2501,15 +2534,23 @@ dist
2501
2534
  `, result, "own");
2502
2535
  await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
2503
2536
  await put(root, ".husky/pre-commit", `#!/usr/bin/env sh
2504
- # Generated by \`vc init\`. Refuses a commit that would put a secret in the
2505
- # clear in .env.secrets — which cannot be undone by a later commit,
2506
- # because the value stays in the history.
2537
+ # Generated by \`vc init\`.
2538
+ #
2539
+ # Encrypts any value sitting in the clear in .env.secrets, and re-stages the
2540
+ # file so the COMMIT carries the ciphertext rather than what you staged.
2541
+ # Encryption needs only the public key in that file, so this needs no
2542
+ # credential and works on a fresh clone.
2543
+ #
2544
+ # It refuses only when it cannot fix the problem itself: no key yet
2545
+ # (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
2546
+ # committed in the clear cannot be un-committed — the value stays in the
2547
+ # history and has to be treated as burned.
2507
2548
  bunx vc guard
2508
2549
  `, result, "regenerate");
2509
2550
  await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
2510
2551
  await retire(root, ".github/workflows/void-dist.yml", result);
2511
2552
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
2512
- await put(root, ".env", renderEnvLocal(manifest), result, "own");
2553
+ await putEnv(root, ".env", renderEnvLocal(manifest), result);
2513
2554
  await put(root, "data/README.md", DATA_README, result, "own");
2514
2555
  await put(root, "branding/README.md", BRANDING_README, result, "own");
2515
2556
  await put(root, "migrations/README.md", MIGRATIONS_README, result, "own");
@@ -10,7 +10,7 @@ import {
10
10
  } from "./index-0v6na3yp.js";
11
11
 
12
12
  // src/deploy/keys.ts
13
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
13
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
14
14
  import { join as join3 } from "node:path";
15
15
  import color2 from "picocolors";
16
16
 
@@ -353,6 +353,22 @@ function plaintextSecretNames(root) {
353
353
  }
354
354
  return bare;
355
355
  }
356
+ function plaintextSecretEntries(root) {
357
+ const path = join2(root, SECRETS_FILE);
358
+ if (!existsSync2(path))
359
+ return [];
360
+ const out = [];
361
+ for (const line of readFileSync(path, "utf8").split(`
362
+ `)) {
363
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
364
+ if (!match || match[1].startsWith("DOTENV_"))
365
+ continue;
366
+ const value = match[2].trim().replace(/^['"]|['"]$/g, "");
367
+ if (value && value !== "unset" && !value.startsWith("encrypted:"))
368
+ out.push({ name: match[1], value });
369
+ }
370
+ return out;
371
+ }
356
372
  async function decryptSecrets(project) {
357
373
  const root = project.root;
358
374
  if (!existsSync2(join2(root, SECRETS_FILE)))
@@ -368,7 +384,7 @@ async function decryptSecrets(project) {
368
384
  }
369
385
  const expected = committedPublicKey(root);
370
386
  if (expected) {
371
- const { publicKeyFor } = await import("./keys-92tcdjrc.js");
387
+ const { publicKeyFor } = await import("./keys-68c6vmds.js");
372
388
  const derived = await publicKeyFor(privateKey);
373
389
  if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
374
390
  return {
@@ -415,6 +431,8 @@ async function secretsCommand(project, args) {
415
431
  return initSecrets(project);
416
432
  if (args[0] === "set")
417
433
  return setSecretValue(project, args.slice(1));
434
+ if (args.includes("--sync"))
435
+ return syncSecrets(project, args.includes("--prune"));
418
436
  if (!existsSync2(join2(root, SECRETS_FILE))) {
419
437
  console.log(`
420
438
  ${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
@@ -607,8 +625,84 @@ function upsertLine(path, name, value) {
607
625
  `)}${name}=${value}
608
626
  `);
609
627
  }
628
+ async function syncSecrets(project, prune) {
629
+ const root = project.root;
630
+ const path = join2(root, SECRETS_FILE);
631
+ if (!existsSync2(path)) {
632
+ console.error(`
633
+ vc: no ${SECRETS_FILE} yet. \`vc secrets --init\` writes one.
634
+ `);
635
+ return 1;
636
+ }
637
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
638
+ const declared = declaredSecretNames(root);
639
+ const missing = required.filter((key) => !declared.has(key.key));
640
+ const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
641
+ if (missing.length > 0) {
642
+ const body = readFileSync(path, "utf8").replace(/\n*$/, `
643
+ `);
644
+ const added = missing.flatMap((key) => ["", `# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`]);
645
+ writeFileSync(path, `${body}${added.join(`
646
+ `)}
647
+ `);
648
+ console.log(`
649
+ ${color.green("+")} ${missing.length} key${missing.length === 1 ? "" : "s"} added to ${SECRETS_FILE}, as \`unset\`:
650
+ `);
651
+ for (const key of missing)
652
+ console.log(` ${color.cyan(`vc secrets set ${key.key}`)} ${color.dim(key.where ?? "")}`);
653
+ console.log("");
654
+ } else {
655
+ console.log(`
656
+ ${color.green("✓")} every secret this shop needs is already declared.
657
+ `);
658
+ }
659
+ if (extra.length > 0 && prune) {
660
+ const body = readFileSync(path, "utf8");
661
+ const kept = [];
662
+ const lines = body.split(`
663
+ `);
664
+ for (let i = 0;i < lines.length; i++) {
665
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(lines[i] ?? "");
666
+ if (match && extra.includes(match[1])) {
667
+ while (kept.length > 0 && (kept[kept.length - 1].startsWith("#") || kept[kept.length - 1].trim() === ""))
668
+ kept.pop();
669
+ if (lines[i + 1]?.trim() === "")
670
+ i++;
671
+ continue;
672
+ }
673
+ kept.push(lines[i]);
674
+ }
675
+ writeFileSync(path, `${kept.join(`
676
+ `).replace(/\n{3,}/g, `
677
+
678
+ `).replace(/\n*$/, "")}
679
+ `);
680
+ console.log(`${color.red("-")} ${extra.length} key${extra.length === 1 ? "" : "s"} removed from ${SECRETS_FILE}: ${extra.join(", ")}
681
+
682
+ ` + color.dim(` Their ciphertext is still in git history, so nothing is unrecoverable —
683
+ but nothing in this shop reads them any more.
684
+ `));
685
+ } else if (extra.length > 0) {
686
+ console.log(`${color.yellow("!")} ${extra.length} declared key${extra.length === 1 ? " is" : "s are"} no longer required by this shop:
687
+ ${extra.join(`
688
+ `)}
689
+
690
+ ` + color.dim(` Left alone: deleting a value you may still want is not this command's
691
+ call to make by default. \`vc secrets --sync --prune\` makes it.
692
+ `));
693
+ }
694
+ return 0;
695
+ }
610
696
 
611
697
  // src/deploy/keys.ts
698
+ var LOCAL_KEY_FILE = ".env.keys";
699
+ function localPrivateKey(root) {
700
+ const path = join3(root, LOCAL_KEY_FILE);
701
+ if (!existsSync3(path))
702
+ return null;
703
+ const match = new RegExp(`^\\s*${PRIVATE_KEY_VAR}\\s*=\\s*["']?([0-9a-fA-F]+)["']?`, "m").exec(readFileSync2(path, "utf8"));
704
+ return match?.[1] ?? null;
705
+ }
612
706
  function committedPublicKey(root) {
613
707
  const path = join3(root, SECRETS_FILE);
614
708
  if (!existsSync3(path))
@@ -636,7 +730,7 @@ async function keyState(project) {
636
730
  const publicKey = committedPublicKey(project.root);
637
731
  const slug = await repoSlug(project.root);
638
732
  const names = slug ? await secretNames(project.root) : new Set;
639
- const localKey = process.env[PRIVATE_KEY_VAR] ?? null;
733
+ const localKey = process.env[PRIVATE_KEY_VAR] ?? localPrivateKey(project.root);
640
734
  let mismatch;
641
735
  if (localKey && publicKey) {
642
736
  const derived = await publicKeyFor(localKey);
@@ -647,27 +741,41 @@ async function keyState(project) {
647
741
  return { publicKey, inGitHub: names.has(PRIVATE_KEY_VAR), slug, localKey, mismatch };
648
742
  }
649
743
  async function provisionKey(project) {
744
+ const parked = localPrivateKey(project.root);
745
+ if (parked) {
746
+ const publicKey = await publicKeyFor(parked);
747
+ if (publicKey)
748
+ return { ok: true, publicKey, parked: true };
749
+ }
750
+ const pair = await generateKeypair();
751
+ if (!pair)
752
+ return { ok: false, reason: "@dotenvx/dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`" };
650
753
  const auth = await ghAuth(project.root);
651
- if (!auth.ok)
652
- return { ok: false, reason: auth.reason ?? "gh is unavailable" };
653
- const slug = await repoSlug(project.root);
654
- if (!slug) {
754
+ const slug = auth.ok ? await repoSlug(project.root) : null;
755
+ if (slug) {
756
+ const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
757
+ if (!sent.ok)
758
+ return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
759
+ return { ok: true, publicKey: pair.publicKey, parked: false };
760
+ }
761
+ if (!ignoresKeyFile(project.root)) {
655
762
  return {
656
763
  ok: false,
657
- reason: `this checkout has no GitHub repository yet, and the key is stored on the repository.
658
- Create one first:
659
-
660
- gh repo create --source=. --private --push
661
- `
764
+ reason: `there is no GitHub repository yet, so the private key would have to wait in ${LOCAL_KEY_FILE} —
765
+ ` + ` and ${LOCAL_KEY_FILE} is NOT gitignored here. Add it to .gitignore first; committing it
766
+ would publish the key that opens every secret in the repository.`
662
767
  };
663
768
  }
664
- const pair = await generateKeypair();
665
- if (!pair)
666
- return { ok: false, reason: "@dotenvx/dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`" };
667
- const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
668
- if (!sent.ok)
669
- return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
670
- return { ok: true, publicKey: pair.publicKey };
769
+ writeFileSync2(join3(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${pair.privateKey}"
770
+ `, { mode: 384 });
771
+ return { ok: true, publicKey: pair.publicKey, parked: true };
772
+ }
773
+ function ignoresKeyFile(root) {
774
+ const path = join3(root, ".gitignore");
775
+ if (!existsSync3(path))
776
+ return false;
777
+ return readFileSync2(path, "utf8").split(`
778
+ `).map((line) => line.trim()).some((line) => line === LOCAL_KEY_FILE || line === `/${LOCAL_KEY_FILE}` || line === ".env.keys*" || line === ".env*");
671
779
  }
672
780
  async function keysCommand(project, args) {
673
781
  if (args.includes("--rotate"))
@@ -682,7 +790,8 @@ Secrets are encrypted with a public key that is COMMITTED, and read with a`);
682
790
  const hasFile = existsSync3(join3(project.root, SECRETS_FILE));
683
791
  const publicNote = state.publicKey ? `${state.publicKey.slice(0, 20)}… in ${SECRETS_FILE}` : color2.dim(hasFile ? `${SECRETS_FILE} has no key yet — \`vc link\` makes one` : `no ${SECRETS_FILE} yet — \`vc secrets --init\``);
684
792
  console.log(` ${state.publicKey ? color2.green("✓") : color2.dim("·")} public key ${publicNote}`);
685
- console.log(` ${state.inGitHub ? color2.green("✓") : color2.red("✗")} private key ${state.inGitHub ? `${PRIVATE_KEY_VAR} is set on ${state.slug}` : state.slug ? color2.red(`not set on ${state.slug} — \`vc keys --init\``) : color2.dim("no GitHub repository yet")}`);
793
+ const parked = localPrivateKey(project.root) !== null;
794
+ console.log(` ${state.inGitHub || parked ? color2.green("✓") : color2.red("✗")} private key ${state.inGitHub ? `${PRIVATE_KEY_VAR} is set on ${state.slug}` : parked ? color2.yellow(`waiting in ${LOCAL_KEY_FILE} — \`vc link\` moves it to GitHub and deletes it`) : state.slug ? color2.red(`not set on ${state.slug} — \`vc keys --init\``) : color2.dim("no key yet — `vc keys --init`")}`);
686
795
  if (state.mismatch) {
687
796
  console.error(`
688
797
  ${color2.red("✗")} ${state.mismatch}
@@ -716,7 +825,15 @@ vc: ${made.reason}
716
825
  }
717
826
  const fresh = !existsSync3(join3(project.root, SECRETS_FILE));
718
827
  committedPublicKeyInto(project.root, made.publicKey);
719
- console.log(`
828
+ console.log(made.parked ? `
829
+ ${color2.green("✓")} key made; public half in ${SECRETS_FILE}, private half waiting in ${LOCAL_KEY_FILE}
830
+
831
+ ` + color2.yellow(` ${LOCAL_KEY_FILE} is TEMPORARY. It is gitignored, but it is the one file that
832
+ opens every secret in this shop. \`vc link\` moves it onto the GitHub
833
+ ` + ` repository and deletes it — do that as soon as the repository exists.
834
+ `) + (fresh ? `
835
+ Next: ${color2.cyan("vc secrets --init")} fills in the keys this shop needs.
836
+ ` : "") : `
720
837
  ${color2.green("✓")} ${PRIVATE_KEY_VAR} set on ${state.slug}; public key in ${SECRETS_FILE}
721
838
  ` + (fresh ? `
722
839
  Next: ${color2.cyan("vc secrets --init")} fills in the keys this shop needs.
@@ -794,4 +911,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
794
911
  return 0;
795
912
  }
796
913
 
797
- export { allEnvKeys, renderEnvTs, renderEnvExample, renderEnvLocal, renderEnvProduction, envSummary, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, committedPublicKeyInto, declaredSecretNames, plaintextSecretNames, decryptSecrets, secretsCommand, initSecrets };
914
+ export { allEnvKeys, renderEnvTs, renderEnvExample, renderEnvLocal, renderEnvProduction, envSummary, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  routeProblem,
52
52
  strictDependencies,
53
53
  upsertJsonc
54
- } from "./index-85m0qkmg.js";
54
+ } from "./index-0w66yc8y.js";
55
55
  import {
56
56
  allEnvKeys,
57
57
  envSummary,
@@ -59,7 +59,7 @@ import {
59
59
  renderEnvLocal,
60
60
  renderEnvProduction,
61
61
  renderEnvTs
62
- } from "./index-6w8rwtsh.js";
62
+ } from "./index-wykd4zmk.js";
63
63
  import {
64
64
  LAYOUTS,
65
65
  MANIFEST_FILE,
@@ -1,19 +1,25 @@
1
1
  import {
2
+ LOCAL_KEY_FILE,
2
3
  committedPublicKey,
3
4
  generateKeypair,
5
+ ignoresKeyFile,
4
6
  keyState,
5
7
  keysCommand,
8
+ localPrivateKey,
6
9
  provisionKey,
7
10
  publicKeyFor
8
- } from "./index-6w8rwtsh.js";
11
+ } from "./index-wykd4zmk.js";
9
12
  import"./index-tjy6yygc.js";
10
13
  import"./index-mnb7fz2t.js";
11
14
  import"./index-0v6na3yp.js";
12
15
  export {
13
16
  publicKeyFor,
14
17
  provisionKey,
18
+ localPrivateKey,
15
19
  keysCommand,
16
20
  keyState,
21
+ ignoresKeyFile,
17
22
  generateKeypair,
18
- committedPublicKey
23
+ committedPublicKey,
24
+ LOCAL_KEY_FILE
19
25
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
@@ -4,8 +4,10 @@ import { findProject } from "../project";
4
4
  import { captureVoid, runVoid } from "../void";
5
5
  import { deployCloudflare } from "./cloudflare";
6
6
  import { preflight, printPreflight } from "./preflight";
7
- import { PRIVATE_KEY_VAR, SECRETS_FILE, plaintextSecretNames, secretsCommand } from "./secrets";
8
- import { keysCommand } from "./keys";
7
+ import { existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { PRIVATE_KEY_VAR, SECRETS_FILE, encryptInto, plaintextSecretEntries, plaintextSecretNames, run, secretsCommand } from "./secrets";
10
+ import { LOCAL_KEY_FILE, committedPublicKey, ignoresKeyFile, keysCommand } from "./keys";
9
11
  import { linkCommand } from "./link";
10
12
 
11
13
  /**
@@ -178,27 +180,97 @@ export async function keysHelp(): Promise<number> {
178
180
  }
179
181
 
180
182
  /**
181
- * The pre-commit gate: refuse a commit that would put a secret in the clear.
183
+ * The pre-commit gate: ENCRYPT anything in the clear, then let the commit through.
184
+ *
185
+ * ── Why this encrypts rather than refuses ────────────────────────────────
186
+ *
187
+ * It used to refuse, and refusing was the wrong shape. Encryption needs only
188
+ * the public key, which is committed right there in the file — so the hook
189
+ * can simply fix the problem instead of handing it back. A person who typed
190
+ * a real key into `.env.secrets` with an editor gets it sealed rather than a
191
+ * lecture, which is what actually keeps secrets out of history: a gate you
192
+ * have to satisfy by hand is a gate people learn to pass with --no-verify.
193
+ *
194
+ * ── The part that is easy to get wrong ───────────────────────────────────
195
+ *
196
+ * Git commits the INDEX, not the working tree. Encrypting the file on disk
197
+ * would leave the STAGED plaintext exactly where it was, and the commit
198
+ * would carry it anyway — the hook would report success while doing nothing.
199
+ * So a file that was staged is re-staged after encrypting, and the result is
200
+ * verified before the commit is allowed.
182
201
  *
183
202
  * Exit code is the whole interface — a hook cares about nothing else.
184
203
  */
185
204
  export async function guardCommand(): Promise<number> {
186
205
  const project = await findProject();
187
206
  if (!project) return 0; // Not a shop; nothing to guard.
188
- const bare = plaintextSecretNames(project.root);
207
+ const root = project.root;
208
+
209
+ // A key file that is not ignored is a worse leak than any single value:
210
+ // it opens every secret in the repository, including ones already rotated.
211
+ if (existsSync(join(root, LOCAL_KEY_FILE)) && !ignoresKeyFile(root)) {
212
+ console.error(
213
+ `\n${color.red("✗ refusing the commit")}: ${LOCAL_KEY_FILE} exists and is NOT gitignored.\n\n` +
214
+ ` It holds the private key that opens every secret in this repository.\n` +
215
+ ` Add ${LOCAL_KEY_FILE} to .gitignore before committing anything.\n`,
216
+ );
217
+ return 1;
218
+ }
219
+
220
+ const bare = plaintextSecretEntries(root);
189
221
  if (bare.length === 0) return 0;
190
- console.error(
191
- `\n${color.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${
192
- bare.length === 1 ? "is" : "are"
193
- } not encrypted.\n\n` +
194
- ` ${bare.join("\n ")}\n\n` +
195
- ` bunx dotenvx encrypt -f ${SECRETS_FILE}\n\n` +
196
- color.dim(" Committing a secret in the clear cannot be undone by a later commit;\n the value stays in the history and must be treated as burned.\n"),
222
+
223
+ const publicKey = committedPublicKey(root);
224
+ if (!publicKey) {
225
+ console.error(
226
+ `\n${color.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${
227
+ bare.length === 1 ? "is" : "are"
228
+ } in the clear, and there is no key to encrypt ${bare.length === 1 ? "it" : "them"} with.\n\n` +
229
+ ` ${bare.map((entry) => entry.name).join("\n ")}\n\n` +
230
+ ` ${color.cyan("vc keys --init")} makes one — no GitHub repository needed yet.\n\n` +
231
+ color.dim(" Committing a secret in the clear cannot be undone by a later commit;\n the value stays in the history and must be treated as burned.\n"),
232
+ );
233
+ return 1;
234
+ }
235
+
236
+ // Was it staged? Decides whether the commit is carrying the plaintext.
237
+ const staged = (await run("git", ["diff", "--cached", "--name-only", "--", SECRETS_FILE], root)).out.trim().length > 0;
238
+
239
+ for (const entry of bare) {
240
+ const sealed = await encryptInto(root, publicKey, entry.name, entry.value);
241
+ if (!sealed.ok) {
242
+ console.error(`\n${color.red("✗ refusing the commit")}: ${sealed.error}\n`);
243
+ return 1;
244
+ }
245
+ }
246
+
247
+ // Never trust the loop: re-read and check. This is the last thing standing
248
+ // between a live credential and a permanent record of it.
249
+ const left = plaintextSecretNames(root);
250
+ if (left.length > 0) {
251
+ console.error(`\n${color.red("✗ refusing the commit")}: ${left.join(", ")} could not be encrypted.\n`);
252
+ return 1;
253
+ }
254
+
255
+ if (staged) {
256
+ const added = await run("git", ["add", "--", SECRETS_FILE], root);
257
+ if (added.code !== 0) {
258
+ console.error(
259
+ `\n${color.red("✗ refusing the commit")}: ${SECRETS_FILE} was encrypted but could not be re-staged,\n` +
260
+ ` so the commit would still carry the plaintext you staged. \`git add ${SECRETS_FILE}\`.\n`,
261
+ );
262
+ return 1;
263
+ }
264
+ }
265
+
266
+ console.log(
267
+ `\n${color.green("✓")} encrypted ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE}${staged ? " and re-staged it" : ""}: ${bare
268
+ .map((entry) => entry.name)
269
+ .join(", ")}\n` + color.dim(` Encryption needs only the public key, so this needs no credential.\n`),
197
270
  );
198
- return 1;
271
+ return 0;
199
272
  }
200
273
 
201
-
202
274
  /** `vc link` — GitHub holds the credentials; this is what puts them there. */
203
275
  export async function linkCliCommand(args: string[]): Promise<number> {
204
276
  const project = await findProject();
@@ -216,21 +288,19 @@ export async function linkHelp(): Promise<number> {
216
288
  line("Put this shop's credentials on its GitHub repository, once, so that", width),
217
289
  line("every deploy after this is a `git push`.", width),
218
290
  line("", width),
219
- ...row("vc link", "generate the encryption key, take the Cloudflare token, store both", width, 2),
291
+ ...row("vc link", "store the encryption key and the Cloudflare token on the repository", width, 2),
220
292
  ...row("vc link --force", "replace what is already there", width, 2),
221
293
  line("", width),
222
294
  line(color.bold("What it stores, and where"), width),
223
- ...row("DOTENV_PRIVATE_KEY_SECRETS", "generated here, never written to disk a repository SECRET", width, 2),
224
- ...row("CLOUDFLARE_API_TOKEN", "yours, checked against the Cloudflare API first a repository SECRET", width, 2),
225
- ...row("CLOUDFLARE_ACCOUNT_ID", "an identifier, not a credential — a repository VARIABLE", width, 2),
295
+ ...row(PRIVATE_KEY_VAR, "a repository SECRET. A key already waiting locally is MOVED here and the local copy deleted", width, 2),
296
+ ...row("CLOUDFLARE_API_TOKEN", "a repository SECRET, checked against the Cloudflare API before it is stored", width, 2),
297
+ ...row("CLOUDFLARE_ACCOUNT_ID", "a repository VARIABLE — an identifier, not a credential", width, 2),
226
298
  line("", width),
227
299
  line(color.bold("Why one token still has to be typed"), width),
228
300
  line("GitHub cannot mint a Cloudflare credential. There is no OIDC federation", width),
229
301
  line("between them, and the Cloudflare GitHub App runs the other way: it grants", width),
230
302
  line("Cloudflare access to your repository, not your repository access to", width),
231
- line("Cloudflare. Something must authorise creating a database in your account,", width),
232
- line("and only Cloudflare can issue that. So it is typed once, here, and never", width),
233
- line("stored on this machine.", width),
303
+ line("Cloudflare. So it is typed once, here, and never stored on this machine.", width),
234
304
  ], width),
235
305
  );
236
306
  return 0;
@@ -45,6 +45,26 @@ import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, findDotenvx, run
45
45
  * Re-entering them is not extra work — it is the work.
46
46
  */
47
47
 
48
+ /**
49
+ * Where a private key waits before the repository exists.
50
+ *
51
+ * Encryption needs only the public key, so a shop can be filled in with real
52
+ * secrets long before anyone has made a GitHub repo to put the private half
53
+ * on. Refusing to make a key until then would block exactly that, so
54
+ * `vc keys --init` will write one here instead — 0600, gitignored, and
55
+ * TEMPORARY: `vc link` uploads this key to the repository and deletes the
56
+ * file, which is how the end state stays "no private key on disk".
57
+ */
58
+ export const LOCAL_KEY_FILE = ".env.keys";
59
+
60
+ /** The private key parked locally, if there is one. */
61
+ export function localPrivateKey(root: string): string | null {
62
+ const path = join(root, LOCAL_KEY_FILE);
63
+ if (!existsSync(path)) return null;
64
+ const match = new RegExp(`^\\s*${PRIVATE_KEY_VAR}\\s*=\\s*["']?([0-9a-fA-F]+)["']?`, "m").exec(readFileSync(path, "utf8"));
65
+ return match?.[1] ?? null;
66
+ }
67
+
48
68
  /** The public key the repository was encrypted under, from the committed file. */
49
69
  export function committedPublicKey(root: string): string | null {
50
70
  const path = join(root, SECRETS_FILE);
@@ -89,7 +109,7 @@ export async function keyState(project: Project): Promise<KeyState> {
89
109
  const publicKey = committedPublicKey(project.root);
90
110
  const slug = await repoSlug(project.root);
91
111
  const names = slug ? await secretNames(project.root) : new Set<string>();
92
- const localKey = process.env[PRIVATE_KEY_VAR] ?? null;
112
+ const localKey = process.env[PRIVATE_KEY_VAR] ?? localPrivateKey(project.root);
93
113
 
94
114
  let mismatch: string | undefined;
95
115
  if (localKey && publicKey) {
@@ -108,28 +128,51 @@ export async function keyState(project: Project): Promise<KeyState> {
108
128
  * private key is deliberately not returned and not logged: it exists as a
109
129
  * local variable for the length of one `gh` call and then goes out of scope.
110
130
  */
111
- export async function provisionKey(project: Project): Promise<{ ok: true; publicKey: string } | { ok: false; reason: string }> {
131
+ export async function provisionKey(project: Project): Promise<{ ok: true; publicKey: string; parked: boolean } | { ok: false; reason: string }> {
132
+ // An existing key is REUSED, never replaced: a new one would orphan
133
+ // everything the old one has already encrypted.
134
+ const parked = localPrivateKey(project.root);
135
+ if (parked) {
136
+ const publicKey = await publicKeyFor(parked);
137
+ if (publicKey) return { ok: true, publicKey, parked: true };
138
+ }
139
+
140
+ const pair = await generateKeypair();
141
+ if (!pair) return { ok: false, reason: "@dotenvx/dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`" };
142
+
112
143
  const auth = await ghAuth(project.root);
113
- if (!auth.ok) return { ok: false, reason: auth.reason ?? "gh is unavailable" };
144
+ const slug = auth.ok ? await repoSlug(project.root) : null;
114
145
 
115
- const slug = await repoSlug(project.root);
116
- if (!slug) {
146
+ if (slug) {
147
+ const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
148
+ if (!sent.ok) return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
149
+ return { ok: true, publicKey: pair.publicKey, parked: false };
150
+ }
151
+
152
+ // No repository yet. Park the key rather than refusing: the alternative is
153
+ // that nobody can put a secret in the shop until they have made a repo,
154
+ // which is the wrong order to force on anyone.
155
+ if (!ignoresKeyFile(project.root)) {
117
156
  return {
118
157
  ok: false,
119
158
  reason:
120
- "this checkout has no GitHub repository yet, and the key is stored on the repository.\n" +
121
- " Create one first:\n\n" +
122
- " gh repo create --source=. --private --push\n",
159
+ `there is no GitHub repository yet, so the private key would have to wait in ${LOCAL_KEY_FILE} —\n` +
160
+ ` and ${LOCAL_KEY_FILE} is NOT gitignored here. Add it to .gitignore first; committing it\n` +
161
+ " would publish the key that opens every secret in the repository.",
123
162
  };
124
163
  }
164
+ writeFileSync(join(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${pair.privateKey}"\n`, { mode: 0o600 });
165
+ return { ok: true, publicKey: pair.publicKey, parked: true };
166
+ }
125
167
 
126
- const pair = await generateKeypair();
127
- if (!pair) return { ok: false, reason: "@dotenvx/dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`" };
128
-
129
- const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
130
- if (!sent.ok) return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
131
-
132
- return { ok: true, publicKey: pair.publicKey };
168
+ /** Is the local key file ignored by git? Checked before one is ever written. */
169
+ export function ignoresKeyFile(root: string): boolean {
170
+ const path = join(root, ".gitignore");
171
+ if (!existsSync(path)) return false;
172
+ return readFileSync(path, "utf8")
173
+ .split("\n")
174
+ .map((line) => line.trim())
175
+ .some((line) => line === LOCAL_KEY_FILE || line === `/${LOCAL_KEY_FILE}` || line === ".env.keys*" || line === ".env*");
133
176
  }
134
177
 
135
178
  /** `vc keys` — where the key is, and what is missing. */
@@ -149,9 +192,16 @@ export async function keysCommand(project: Project, args: string[]): Promise<num
149
192
  ? `${state.publicKey.slice(0, 20)}… in ${SECRETS_FILE}`
150
193
  : color.dim(hasFile ? `${SECRETS_FILE} has no key yet — \`vc link\` makes one` : `no ${SECRETS_FILE} yet — \`vc secrets --init\``);
151
194
  console.log(` ${state.publicKey ? color.green("✓") : color.dim("·")} public key ${publicNote}`);
195
+ const parked = localPrivateKey(project.root) !== null;
152
196
  console.log(
153
- ` ${state.inGitHub ? color.green("✓") : color.red("✗")} private key ${
154
- state.inGitHub ? `${PRIVATE_KEY_VAR} is set on ${state.slug}` : state.slug ? color.red(`not set on ${state.slug} — \`vc keys --init\``) : color.dim("no GitHub repository yet")
197
+ ` ${state.inGitHub || parked ? color.green("✓") : color.red("✗")} private key ${
198
+ state.inGitHub
199
+ ? `${PRIVATE_KEY_VAR} is set on ${state.slug}`
200
+ : parked
201
+ ? color.yellow(`waiting in ${LOCAL_KEY_FILE} — \`vc link\` moves it to GitHub and deletes it`)
202
+ : state.slug
203
+ ? color.red(`not set on ${state.slug} — \`vc keys --init\``)
204
+ : color.dim("no key yet — `vc keys --init`")
155
205
  }`,
156
206
  );
157
207
 
@@ -171,7 +221,7 @@ export async function keysCommand(project: Project, args: string[]): Promise<num
171
221
  return 0;
172
222
  }
173
223
 
174
- /** Generate a key, store it on the repository, and put the public half in the file. */
224
+ /** Generate a key, put the public half in the file, and say where the private half went. */
175
225
  async function initKey(project: Project): Promise<number> {
176
226
  const state = await keyState(project);
177
227
  if (state.inGitHub && state.publicKey) {
@@ -188,15 +238,19 @@ async function initKey(project: Project): Promise<number> {
188
238
  return 1;
189
239
  }
190
240
 
191
- // ALWAYS written, creating the file if need be. A private key on GitHub
192
- // with no public half in the repository is a key nobody can encrypt to,
193
- // and the next command would quietly generate a second one — orphaning
194
- // this one and anything already sealed with it.
195
241
  const fresh = !existsSync(join(project.root, SECRETS_FILE));
196
242
  committedPublicKeyInto(project.root, made.publicKey);
197
243
  console.log(
198
- `\n${color.green("✓")} ${PRIVATE_KEY_VAR} set on ${state.slug}; public key in ${SECRETS_FILE}\n` +
199
- (fresh ? `\n Next: ${color.cyan("vc secrets --init")} fills in the keys this shop needs.\n` : "\n"),
244
+ made.parked
245
+ ? `\n${color.green("")} key made; public half in ${SECRETS_FILE}, private half waiting in ${LOCAL_KEY_FILE}\n\n` +
246
+ color.yellow(
247
+ ` ${LOCAL_KEY_FILE} is TEMPORARY. It is gitignored, but it is the one file that\n` +
248
+ ` opens every secret in this shop. \`vc link\` moves it onto the GitHub\n` +
249
+ " repository and deletes it — do that as soon as the repository exists.\n",
250
+ ) +
251
+ (fresh ? `\n Next: ${color.cyan("vc secrets --init")} fills in the keys this shop needs.\n` : "")
252
+ : `\n${color.green("✓")} ${PRIVATE_KEY_VAR} set on ${state.slug}; public key in ${SECRETS_FILE}\n` +
253
+ (fresh ? `\n Next: ${color.cyan("vc secrets --init")} fills in the keys this shop needs.\n` : "\n"),
200
254
  );
201
255
  return 0;
202
256
  }
@@ -1,8 +1,10 @@
1
+ import { rmSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import color from "picocolors";
2
4
  import { writeManifest } from "../manifest";
3
5
  import type { Project } from "../project";
4
6
  import { cloudflareAccounts, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
5
- import { keyState, provisionKey } from "./keys";
7
+ import { LOCAL_KEY_FILE, keyState, localPrivateKey, provisionKey, publicKeyFor } from "./keys";
6
8
  import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, declaredSecretNames, initSecrets } from "./secrets";
7
9
 
8
10
  /**
@@ -83,13 +85,30 @@ export async function linkCommand(project: Project, args: string[]): Promise<num
83
85
  p.log.warn(`--force: replacing ${PRIVATE_KEY_VAR} makes every value in ${SECRETS_FILE} unreadable. Use \`vc keys --rotate\` to re-encrypt instead.`);
84
86
  return 1;
85
87
  } else {
86
- const made = await provisionKey(project);
87
- if (!made.ok) {
88
- p.cancel(made.reason);
89
- return 1;
88
+ // A key parked by `vc keys --init` before this repository existed is
89
+ // ADOPTED, not replaced: it may already have encrypted the whole shop.
90
+ const parked = localPrivateKey(root);
91
+ if (parked) {
92
+ const sent = await setSecret(root, PRIVATE_KEY_VAR, parked);
93
+ if (!sent.ok) {
94
+ p.cancel(`GitHub refused the secret: ${sent.error ?? "unknown error"}`);
95
+ return 1;
96
+ }
97
+ const publicKey = await publicKeyFor(parked);
98
+ if (publicKey) committedPublicKeyInto(root, publicKey);
99
+ // Only now, once GitHub has it: deleting first would lose the key
100
+ // outright if the upload failed.
101
+ rmSync(join(root, LOCAL_KEY_FILE), { force: true });
102
+ p.log.success(`${color.green("✓")} the key waiting in ${LOCAL_KEY_FILE} moved to GitHub, and the file deleted`);
103
+ } else {
104
+ const made = await provisionKey(project);
105
+ if (!made.ok) {
106
+ p.cancel(made.reason);
107
+ return 1;
108
+ }
109
+ committedPublicKeyInto(root, made.publicKey);
110
+ p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
90
111
  }
91
- committedPublicKeyInto(root, made.publicKey);
92
- p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
93
112
  }
94
113
 
95
114
  // 3. The Cloudflare token — the one thing that cannot be derived.
@@ -141,6 +141,26 @@ export function plaintextSecretNames(root: string): string[] {
141
141
  return bare;
142
142
  }
143
143
 
144
+ /**
145
+ * The declared values that are NOT encrypted, with their values.
146
+ *
147
+ * Separate from `plaintextSecretNames` on purpose: names are safe to print
148
+ * and values never are, so the function that returns values is the one you
149
+ * have to go looking for.
150
+ */
151
+ export function plaintextSecretEntries(root: string): Array<{ name: string; value: string }> {
152
+ const path = join(root, SECRETS_FILE);
153
+ if (!existsSync(path)) return [];
154
+ const out: Array<{ name: string; value: string }> = [];
155
+ for (const line of readFileSync(path, "utf8").split("\n")) {
156
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
157
+ if (!match || match[1]!.startsWith("DOTENV_")) continue;
158
+ const value = match[2]!.trim().replace(/^['"]|['"]$/g, "");
159
+ if (value && value !== "unset" && !value.startsWith("encrypted:")) out.push({ name: match[1]!, value });
160
+ }
161
+ return out;
162
+ }
163
+
144
164
  export interface DecryptedSecrets {
145
165
  /** A temp file in `.env` format, for `wrangler deploy --secrets-file`. */
146
166
  path: string;
@@ -230,6 +250,7 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
230
250
 
231
251
  if (args.includes("--init")) return initSecrets(project);
232
252
  if (args[0] === "set") return setSecretValue(project, args.slice(1));
253
+ if (args.includes("--sync")) return syncSecrets(project, args.includes("--prune"));
233
254
 
234
255
  if (!existsSync(join(root, SECRETS_FILE))) {
235
256
  console.log(
@@ -446,3 +467,72 @@ function upsertLine(path: string, name: string, value: string): void {
446
467
  const pattern = new RegExp(`^\\s*${name}\\s*=.*$`, "m");
447
468
  writeFileSync(path, pattern.test(body) ? body.replace(pattern, `${name}=${value}`) : `${body.replace(/\n*$/, "\n")}${name}=${value}\n`);
448
469
  }
470
+
471
+
472
+ /**
473
+ * `vc secrets --sync` — add the keys this shop needs and does not declare.
474
+ *
475
+ * The manifest changes: a carrier is swapped, a plugin is added, and the set
476
+ * of required secrets moves with it. Without this the only way to declare a
477
+ * new one is to remember its exact name and type it in by hand, which is how
478
+ * a shop ends up deploying without a key it needed.
479
+ *
480
+ * Strictly additive. It never deletes a key that is no longer required,
481
+ * because "no longer required" and "safe to throw away" are different
482
+ * claims — the value may still be wanted, and it is not this command's to
483
+ * destroy. Extras are reported instead.
484
+ */
485
+ async function syncSecrets(project: Project, prune: boolean): Promise<number> {
486
+ const root = project.root;
487
+ const path = join(root, SECRETS_FILE);
488
+ if (!existsSync(path)) {
489
+ console.error(`\nvc: no ${SECRETS_FILE} yet. \`vc secrets --init\` writes one.\n`);
490
+ return 1;
491
+ }
492
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
493
+ const declared = declaredSecretNames(root);
494
+ const missing = required.filter((key) => !declared.has(key.key));
495
+ const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
496
+
497
+ if (missing.length > 0) {
498
+ const body = readFileSync(path, "utf8").replace(/\n*$/, "\n");
499
+ const added = missing.flatMap((key) => ["", `# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`]);
500
+ writeFileSync(path, `${body}${added.join("\n")}\n`);
501
+ console.log(`\n${color.green("+")} ${missing.length} key${missing.length === 1 ? "" : "s"} added to ${SECRETS_FILE}, as \`unset\`:\n`);
502
+ for (const key of missing) console.log(` ${color.cyan(`vc secrets set ${key.key}`)} ${color.dim(key.where ?? "")}`);
503
+ console.log("");
504
+ } else {
505
+ console.log(`\n${color.green("✓")} every secret this shop needs is already declared.\n`);
506
+ }
507
+
508
+ if (extra.length > 0 && prune) {
509
+ // Deleting a value is the operator's call, and --prune is them making
510
+ // it. The whole entry goes — its comment line too — so the file reads
511
+ // as though the key was never required.
512
+ const body = readFileSync(path, "utf8");
513
+ const kept: string[] = [];
514
+ const lines = body.split("\n");
515
+ for (let i = 0; i < lines.length; i++) {
516
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(lines[i] ?? "");
517
+ if (match && extra.includes(match[1]!)) {
518
+ // Drop the comment that introduces it, and the blank line after.
519
+ while (kept.length > 0 && (kept[kept.length - 1]!.startsWith("#") || kept[kept.length - 1]!.trim() === "")) kept.pop();
520
+ if (lines[i + 1]?.trim() === "") i++;
521
+ continue;
522
+ }
523
+ kept.push(lines[i]!);
524
+ }
525
+ writeFileSync(path, `${kept.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "")}\n`);
526
+ console.log(
527
+ `${color.red("-")} ${extra.length} key${extra.length === 1 ? "" : "s"} removed from ${SECRETS_FILE}: ${extra.join(", ")}\n\n` +
528
+ color.dim(` Their ciphertext is still in git history, so nothing is unrecoverable —\n but nothing in this shop reads them any more.\n`),
529
+ );
530
+ } else if (extra.length > 0) {
531
+ console.log(
532
+ `${color.yellow("!")} ${extra.length} declared key${extra.length === 1 ? " is" : "s are"} no longer required by this shop:\n` +
533
+ ` ${extra.join("\n ")}\n\n` +
534
+ color.dim(` Left alone: deleting a value you may still want is not this command's\n call to make by default. \`vc secrets --sync --prune\` makes it.\n`),
535
+ );
536
+ }
537
+ return 0;
538
+ }
@@ -89,6 +89,57 @@ async function exists(path: string): Promise<boolean> {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * Write a dotenv file that RECONCILES rather than freezes.
94
+ *
95
+ * `.env` used to be written once and never touched, so a shop that swapped a
96
+ * carrier or a payment rail kept the old keys forever and never gained the
97
+ * new ones. devprints ended up carrying five Adyen keys and a Resend key for
98
+ * a shop that uses neither, while missing two it needed. That is the same
99
+ * owned-file drift that shipped a `.gitignore` without `.env.keys` and an
100
+ * api.ts pointing at another shop's domain.
101
+ *
102
+ * So: the template decides which keys EXIST, and the file on disk decides
103
+ * what they are SET TO. A value a person put there is always kept; a key the
104
+ * manifest no longer declares goes, and is reported rather than vanishing.
105
+ */
106
+ async function putEnv(root: string, file: string, rendered: string, result: GenerateResult) {
107
+ const path = join(root, file);
108
+ if (!(await exists(path))) {
109
+ await mkdir(dirname(path), { recursive: true });
110
+ await writeFile(path, rendered, "utf8");
111
+ result.written.push(file);
112
+ return;
113
+ }
114
+
115
+ const read = (text: string): Map<string, string> => {
116
+ const out = new Map<string, string>();
117
+ for (const line of text.split("\n")) {
118
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
119
+ if (match) out.set(match[1]!, match[2]!);
120
+ }
121
+ return out;
122
+ };
123
+ const existing = read(await readFile(path, "utf8"));
124
+ const wanted = read(rendered);
125
+
126
+ const merged = rendered
127
+ .split("\n")
128
+ .map((line) => {
129
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
130
+ if (!match) return line;
131
+ const had = existing.get(match[1]!);
132
+ // A real value the operator set beats the template's placeholder.
133
+ // `unset` and empty are placeholders, not answers.
134
+ return had !== undefined && had !== "" && had !== "unset" ? `${match[1]}=${had}` : line;
135
+ })
136
+ .join("\n");
137
+
138
+ const dropped = [...existing.keys()].filter((key) => !wanted.has(key) && !key.startsWith("VITE_"));
139
+ await writeFile(path, merged, "utf8");
140
+ result.written.push(dropped.length > 0 ? `${file} ${`(dropped ${dropped.join(", ")})`}` : file);
141
+ }
142
+
92
143
  async function put(root: string, file: string, content: string, result: GenerateResult, mode: "regenerate" | "own") {
93
144
  const path = join(root, file);
94
145
  if (mode === "own" && (await exists(path))) {
@@ -182,7 +233,7 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
182
233
  await put(root, at(".env.production"), renderEnvProduction(manifest), result, "regenerate");
183
234
  await put(root, at("lib/deploy/requirements.ts"), renderRequirementsTs(manifest), result, "regenerate");
184
235
 
185
- if (opts.env) await put(root, at(".env"), renderEnvLocal(manifest), result, "own");
236
+ if (opts.env) await putEnv(root, at(".env"), renderEnvLocal(manifest), result);
186
237
  await put(root, at("lib/domain.ts"), renderDomainTs(manifest.layout), result, own);
187
238
  await put(root, at("lib/payment.ts"), renderPaymentTs(manifest), result, own);
188
239
  await put(root, at("lib/notifications.ts"), renderNotificationsTs(), result, own);
@@ -374,9 +425,17 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
374
425
  root,
375
426
  ".husky/pre-commit",
376
427
  `#!/usr/bin/env sh
377
- # Generated by \`vc init\`. Refuses a commit that would put a secret in the
378
- # clear in .env.secrets — which cannot be undone by a later commit,
379
- # because the value stays in the history.
428
+ # Generated by \`vc init\`.
429
+ #
430
+ # Encrypts any value sitting in the clear in .env.secrets, and re-stages the
431
+ # file so the COMMIT carries the ciphertext rather than what you staged.
432
+ # Encryption needs only the public key in that file, so this needs no
433
+ # credential and works on a fresh clone.
434
+ #
435
+ # It refuses only when it cannot fix the problem itself: no key yet
436
+ # (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
437
+ # committed in the clear cannot be un-committed — the value stays in the
438
+ # history and has to be treated as burned.
380
439
  bunx vc guard
381
440
  `,
382
441
  result,
@@ -387,7 +446,7 @@ bunx vc guard
387
446
  // build. Two workflows on the same push would deploy the shop twice.
388
447
  await retire(root, ".github/workflows/void-dist.yml", result);
389
448
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
390
- await put(root, ".env", renderEnvLocal(manifest), result, "own");
449
+ await putEnv(root, ".env", renderEnvLocal(manifest), result);
391
450
  await put(root, "data/README.md", DATA_README, result, "own");
392
451
  await put(root, "branding/README.md", BRANDING_README, result, "own");
393
452
  await put(root, "migrations/README.md", MIGRATIONS_README, result, "own");