@saastemly/voidcommerce 0.6.0 → 0.8.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/catalog.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  SIGN_IN,
15
15
  TAX,
16
16
  UI
17
- } from "./index-844b3qn9.js";
17
+ } from "./index-mnb7fz2t.js";
18
18
  import"./index-0v6na3yp.js";
19
19
  export {
20
20
  UI,
package/dist/cli.js CHANGED
@@ -21,28 +21,36 @@ import {
21
21
  runVoid,
22
22
  version,
23
23
  voidAppsIn
24
- } from "./index-f1wds190.js";
24
+ } from "./index-y6qzjd9h.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-2qt2yh3z.js";
53
+ } from "./index-j5xjh6qp.js";
46
54
  import {
47
55
  LAYOUTS,
48
56
  oneOrigin,
@@ -50,18 +58,22 @@ import {
50
58
  validate,
51
59
  writeManifest,
52
60
  zoneOf
53
- } from "./index-xx5p4b8d.js";
61
+ } from "./index-tjy6yygc.js";
54
62
  import {
55
63
  GROUPS
56
- } from "./index-844b3qn9.js";
64
+ } from "./index-mnb7fz2t.js";
57
65
  import {
58
66
  __require
59
67
  } from "./index-0v6na3yp.js";
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;
@@ -1,2 +1,15 @@
1
1
  import { type Manifest } from "../manifest";
2
+ /**
3
+ * Carriers with a provider behind them, and whether each needs `lib/shipping.ts`.
4
+ *
5
+ * Exported because `generate/index.ts` has to know which shops get that file,
6
+ * and a second hand-maintained list of carrier ids WILL drift from this one —
7
+ * it already did: adding Royal Mail here left the file ungenerated, so the
8
+ * generated `auth.ts` imported a price table that did not exist.
9
+ *
10
+ * The token needs nothing: no origin, no tariff. Everything else needs one or
11
+ * the other, so the rule is simply "any carrier but the token".
12
+ */
13
+ export declare const WIRED_CARRIERS: readonly ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
14
+ export declare const needsShippingConfig: (id: string) => boolean;
2
15
  export declare function renderAuthTs(manifest: Manifest): string;
@@ -3,14 +3,14 @@ import {
3
3
  hasFrontend,
4
4
  isApex,
5
5
  zone
6
- } from "./index-xx5p4b8d.js";
6
+ } from "./index-tjy6yygc.js";
7
7
  import {
8
8
  __require,
9
9
  __toESM
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-qwvt324e.js");
387
+ const { publicKeyFor } = await import("./keys-3zjezaxk.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);
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,58 @@ function upsertLine(path, name, value) {
607
625
  `)}${name}=${value}
608
626
  `);
609
627
  }
628
+ async function syncSecrets(project) {
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) {
660
+ console.log(`${color.yellow("!")} ${extra.length} declared key${extra.length === 1 ? " is" : "s are"} no longer required by this shop:
661
+ ${extra.join(`
662
+ `)}
663
+
664
+ ` + color.dim(` Left alone — they cost nothing, and deleting a value you may still want
665
+ is not this command's call. Remove them by hand when you are sure.
666
+ `));
667
+ }
668
+ return 0;
669
+ }
610
670
 
611
671
  // src/deploy/keys.ts
672
+ var LOCAL_KEY_FILE = ".env.keys";
673
+ function localPrivateKey(root) {
674
+ const path = join3(root, LOCAL_KEY_FILE);
675
+ if (!existsSync3(path))
676
+ return null;
677
+ const match = new RegExp(`^\\s*${PRIVATE_KEY_VAR}\\s*=\\s*["']?([0-9a-fA-F]+)["']?`, "m").exec(readFileSync2(path, "utf8"));
678
+ return match?.[1] ?? null;
679
+ }
612
680
  function committedPublicKey(root) {
613
681
  const path = join3(root, SECRETS_FILE);
614
682
  if (!existsSync3(path))
@@ -636,7 +704,7 @@ async function keyState(project) {
636
704
  const publicKey = committedPublicKey(project.root);
637
705
  const slug = await repoSlug(project.root);
638
706
  const names = slug ? await secretNames(project.root) : new Set;
639
- const localKey = process.env[PRIVATE_KEY_VAR] ?? null;
707
+ const localKey = process.env[PRIVATE_KEY_VAR] ?? localPrivateKey(project.root);
640
708
  let mismatch;
641
709
  if (localKey && publicKey) {
642
710
  const derived = await publicKeyFor(localKey);
@@ -647,27 +715,41 @@ async function keyState(project) {
647
715
  return { publicKey, inGitHub: names.has(PRIVATE_KEY_VAR), slug, localKey, mismatch };
648
716
  }
649
717
  async function provisionKey(project) {
718
+ const parked = localPrivateKey(project.root);
719
+ if (parked) {
720
+ const publicKey = await publicKeyFor(parked);
721
+ if (publicKey)
722
+ return { ok: true, publicKey, parked: true };
723
+ }
724
+ const pair = await generateKeypair();
725
+ if (!pair)
726
+ return { ok: false, reason: "@dotenvx/dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`" };
650
727
  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) {
728
+ const slug = auth.ok ? await repoSlug(project.root) : null;
729
+ if (slug) {
730
+ const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
731
+ if (!sent.ok)
732
+ return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
733
+ return { ok: true, publicKey: pair.publicKey, parked: false };
734
+ }
735
+ if (!ignoresKeyFile(project.root)) {
655
736
  return {
656
737
  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
- `
738
+ reason: `there is no GitHub repository yet, so the private key would have to wait in ${LOCAL_KEY_FILE} —
739
+ ` + ` and ${LOCAL_KEY_FILE} is NOT gitignored here. Add it to .gitignore first; committing it
740
+ would publish the key that opens every secret in the repository.`
662
741
  };
663
742
  }
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 };
743
+ writeFileSync2(join3(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${pair.privateKey}"
744
+ `, { mode: 384 });
745
+ return { ok: true, publicKey: pair.publicKey, parked: true };
746
+ }
747
+ function ignoresKeyFile(root) {
748
+ const path = join3(root, ".gitignore");
749
+ if (!existsSync3(path))
750
+ return false;
751
+ return readFileSync2(path, "utf8").split(`
752
+ `).map((line) => line.trim()).some((line) => line === LOCAL_KEY_FILE || line === `/${LOCAL_KEY_FILE}` || line === ".env.keys*" || line === ".env*");
671
753
  }
672
754
  async function keysCommand(project, args) {
673
755
  if (args.includes("--rotate"))
@@ -682,7 +764,8 @@ Secrets are encrypted with a public key that is COMMITTED, and read with a`);
682
764
  const hasFile = existsSync3(join3(project.root, SECRETS_FILE));
683
765
  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
766
  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")}`);
767
+ const parked = localPrivateKey(project.root) !== null;
768
+ 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
769
  if (state.mismatch) {
687
770
  console.error(`
688
771
  ${color2.red("✗")} ${state.mismatch}
@@ -716,7 +799,15 @@ vc: ${made.reason}
716
799
  }
717
800
  const fresh = !existsSync3(join3(project.root, SECRETS_FILE));
718
801
  committedPublicKeyInto(project.root, made.publicKey);
719
- console.log(`
802
+ console.log(made.parked ? `
803
+ ${color2.green("✓")} key made; public half in ${SECRETS_FILE}, private half waiting in ${LOCAL_KEY_FILE}
804
+
805
+ ` + color2.yellow(` ${LOCAL_KEY_FILE} is TEMPORARY. It is gitignored, but it is the one file that
806
+ opens every secret in this shop. \`vc link\` moves it onto the GitHub
807
+ ` + ` repository and deletes it — do that as soon as the repository exists.
808
+ `) + (fresh ? `
809
+ Next: ${color2.cyan("vc secrets --init")} fills in the keys this shop needs.
810
+ ` : "") : `
720
811
  ${color2.green("✓")} ${PRIVATE_KEY_VAR} set on ${state.slug}; public key in ${SECRETS_FILE}
721
812
  ` + (fresh ? `
722
813
  Next: ${color2.cyan("vc secrets --init")} fills in the keys this shop needs.
@@ -794,4 +885,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
794
885
  return 0;
795
886
  }
796
887
 
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 };
888
+ 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 };
@@ -446,7 +446,18 @@ var CARRIERS = {
446
446
  { id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" }, { key: "FEDEX_CLIENT_SECRET", breaks: "the same" }, { key: "FEDEX_ACCOUNT_NUMBER", breaks: "FedEx has no account to bill the transport to" }] },
447
447
  { id: "dhl-express", label: "DHL Express", hint: "international express, and the one worth having for a shop that posts abroad", env: [{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" }, { key: "DHL_API_SECRET", breaks: "the same" }, { key: "DHL_ACCOUNT_NUMBER", breaks: "DHL has no account to bill the transport to" }] },
448
448
  { id: "usps", label: "USPS", hint: "US domestic post — the cheapest way to ship a small parcel inside the US", env: [{ key: "USPS_CLIENT_ID", breaks: "USPS rates are not quoted" }, { key: "USPS_CLIENT_SECRET", breaks: "the same" }] },
449
- { id: "royal-mail", label: "Royal Mail", hint: "UK post, for a shop whose customers are mostly in Britain", env: [{ key: "ROYAL_MAIL_CLIENT_ID", breaks: "Royal Mail rates are not quoted" }] },
449
+ {
450
+ id: "royal-mail",
451
+ label: "Royal Mail (Click & Drop)",
452
+ hint: "UK post. Needs an OBA business account — Click & Drop generates labels through the API only for those. Prices come from your own tariff table, because no Royal Mail API quotes rates",
453
+ env: [
454
+ {
455
+ key: "CLICK_DROP_AUTH_KEY",
456
+ breaks: "no label and no tracking number: orders are created in Click & Drop but never despatched",
457
+ where: "Click & Drop → Settings → Integrations → Click & Drop API"
458
+ }
459
+ ]
460
+ },
450
461
  { id: "aramex", label: "Aramex", hint: "the carrier to reach the Gulf and wider Middle East reliably", env: [{ key: "ARAMEX_API_KEY", breaks: "Aramex rates are not quoted" }] },
451
462
  { id: "doordash-drive", label: "DoorDash Drive", hint: "same-day local delivery", env: [{ key: "DOORDASH_DEVELOPER_ID", breaks: "DoorDash is not offered" }] },
452
463
  { id: "stuart", label: "Stuart", hint: "same-day courier, Europe", env: [{ key: "STUART_CLIENT_ID", breaks: "Stuart is not offered" }] },
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CHOICES,
3
3
  GROUPS
4
- } from "./index-844b3qn9.js";
4
+ } from "./index-mnb7fz2t.js";
5
5
 
6
6
  // src/manifest.ts
7
7
  import { readFile, writeFile } from "node:fs/promises";