@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.
@@ -9,7 +9,7 @@ import {
9
9
  renderEnvLocal,
10
10
  renderEnvProduction,
11
11
  renderEnvTs
12
- } from "./index-2qt2yh3z.js";
12
+ } from "./index-j5xjh6qp.js";
13
13
  import {
14
14
  MANIFEST_FILE,
15
15
  has,
@@ -22,10 +22,10 @@ import {
22
22
  workerHosts,
23
23
  writeManifest,
24
24
  zone
25
- } from "./index-xx5p4b8d.js";
25
+ } from "./index-tjy6yygc.js";
26
26
  import {
27
27
  CHOICES
28
- } from "./index-844b3qn9.js";
28
+ } from "./index-mnb7fz2t.js";
29
29
  import {
30
30
  __require
31
31
  } from "./index-0v6na3yp.js";
@@ -48,10 +48,17 @@ var VAT = {
48
48
  PL: { rate: 2300, name: "VAT" }
49
49
  };
50
50
  var social = (manifest, id, envPrefix) => has(manifest, id) ? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },` : null;
51
+ var WIRED_CARRIERS = ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
52
+ var needsShippingConfig = (id) => WIRED_CARRIERS.includes(id) && id !== "nft";
51
53
  function renderAuthTs(manifest) {
52
54
  const { shop } = manifest;
53
55
  const lines = [];
54
56
  const CARRIER_CALLS = {
57
+ "royal-mail": {
58
+ label: "Royal Mail",
59
+ call: `royalMailProvider({ apiKey: must(env, "CLICK_DROP_AUTH_KEY"), services: royalMailServices, currency: "${shop.currency}", carrierName: "Royal Mail" })`,
60
+ import: `import { royalMailProvider } from "@saastemly/better-commerce/providers/royal-mail";`
61
+ },
55
62
  "dhl-express": {
56
63
  label: "DHL Express",
57
64
  call: `dhlExpressProvider({ apiKey: must(env, "DHL_API_KEY"), apiSecret: must(env, "DHL_API_SECRET"), accountNumber: must(env, "DHL_ACCOUNT_NUMBER"), from: shipFrom })`,
@@ -86,8 +93,12 @@ import { mintProvider, destinationFor } from "./lib/mint.ts";`
86
93
  };
87
94
  const carriers = Object.keys(CARRIER_CALLS).filter((id) => has(manifest, id));
88
95
  const carrierImports = carriers.map((id) => CARRIER_CALLS[id].import);
89
- if (carriers.some((id) => id !== "nft"))
90
- carrierImports.push(`import { shipFrom } from "./lib/shipping.ts";`);
96
+ const shipping = [
97
+ ...carriers.some((id) => id !== "nft" && id !== "royal-mail") ? ["shipFrom"] : [],
98
+ ...carriers.includes("royal-mail") ? ["royalMailServices"] : []
99
+ ];
100
+ if (shipping.length > 0)
101
+ carrierImports.push(`import { ${shipping.join(", ")} } from "./lib/shipping.ts";`);
91
102
  const wantsNft = has(manifest, "nft");
92
103
  const carrierFulfillment = carriers.length ? `, deliveryFulfillment([${carriers.map((id) => CARRIER_CALLS[id].call).join(", ")}])` : "";
93
104
  const nftDefault = carriers.length ? `, defaultProvider: "delivery:${carriers.join("+")}"` : "";
@@ -1333,7 +1344,7 @@ import color from "picocolors";
1333
1344
  // package.json
1334
1345
  var package_default = {
1335
1346
  name: "@saastemly/voidcommerce",
1336
- version: "0.6.0",
1347
+ version: "0.8.0",
1337
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`.",
1338
1349
  type: "module",
1339
1350
  license: "MIT",
@@ -2181,13 +2192,18 @@ export async function destinationFor(orderId: string): Promise<string | null> {
2181
2192
  `;
2182
2193
  }
2183
2194
  function renderShippingTs(manifest) {
2184
- return `import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";
2185
-
2195
+ const royalMail = has(manifest, "royal-mail");
2196
+ const origin = ["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
2197
+ const imports = [
2198
+ ...origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : [],
2199
+ ...royalMail ? [`import type { RoyalMailService } from "@saastemly/better-commerce/providers/royal-mail";`] : []
2200
+ ];
2201
+ const originBlock = `
2186
2202
  /**
2187
2203
  * The address every parcel is quoted and shipped FROM.
2188
2204
  *
2189
- * TODO VERIFY: this is a placeholder. A carrier quotes against it, and DHL
2190
- * refuses a shipment whose origin has no \`name\` and \`phone\`.
2205
+ * TODO VERIFY: this is a placeholder. A carrier quotes against it, and most
2206
+ * refuse a shipment whose origin has no \`name\` and \`phone\`.
2191
2207
  */
2192
2208
  export const shipFrom: DeliveryAddress = {
2193
2209
  name: "${manifest.shop.name}",
@@ -2198,6 +2214,9 @@ export const shipFrom: DeliveryAddress = {
2198
2214
  phone: "TODO VERIFY: +44…",
2199
2215
  };
2200
2216
  `;
2217
+ return `${imports.join(`
2218
+ `)}
2219
+ ${origin ? originBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
2201
2220
  }
2202
2221
  function renderErpTs() {
2203
2222
  return `import { businessCentralProvider } from "@saastemly/better-commerce/providers/business-central";
@@ -2226,6 +2245,44 @@ export const erpFulfillment = (currency: string): FulfillmentProvider =>
2226
2245
  }).fulfillment();
2227
2246
  `;
2228
2247
  }
2248
+ var ROYAL_MAIL_SERVICES = () => `
2249
+ /**
2250
+ * What this shop charges for Royal Mail, by total parcel weight.
2251
+ *
2252
+ * TODO VERIFY: every \`amount\` below is a placeholder in pence. Replace them
2253
+ * with your own OBA rate card — Click & Drop → Settings → Shipping services
2254
+ * lists exactly which services your account has, and the codes here must be
2255
+ * among them. Service codes are account-specific: the published list is from
2256
+ * 2021 and Royal Mail warn that "available services are unique to your OBA
2257
+ * account and individual service agreements".
2258
+ *
2259
+ * The band whose \`maxGrams\` first covers the parcel wins, so order does not
2260
+ * matter. A parcel heavier than every band gets no rate at all rather than a
2261
+ * wrong one, and Click & Drop refuses anything over 30 kg.
2262
+ */
2263
+ export const royalMailServices: RoyalMailService[] = [
2264
+ {
2265
+ code: "TPN24",
2266
+ name: "Tracked 24",
2267
+ estimatedDays: 1,
2268
+ bands: [
2269
+ { maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
2270
+ { maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
2271
+ { maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
2272
+ ],
2273
+ },
2274
+ {
2275
+ code: "TPS48",
2276
+ name: "Tracked 48",
2277
+ estimatedDays: 2,
2278
+ bands: [
2279
+ { maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
2280
+ { maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
2281
+ { maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
2282
+ ],
2283
+ },
2284
+ ];
2285
+ `;
2229
2286
 
2230
2287
  // src/generate/index.ts
2231
2288
  async function exists(path) {
@@ -2314,7 +2371,7 @@ async function generateApi(root, dir, manifest, result, opts) {
2314
2371
  if (has(manifest, "nft")) {
2315
2372
  await put(root, at("lib/mint.ts"), renderMintTs(), result, own);
2316
2373
  }
2317
- if (["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id))) {
2374
+ if (WIRED_CARRIERS.some((id) => needsShippingConfig(id) && has(manifest, id))) {
2318
2375
  await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result, own);
2319
2376
  }
2320
2377
  if (has(manifest, "business-central")) {
@@ -2444,9 +2501,17 @@ dist
2444
2501
  `, result, "own");
2445
2502
  await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
2446
2503
  await put(root, ".husky/pre-commit", `#!/usr/bin/env sh
2447
- # Generated by \`vc init\`. Refuses a commit that would put a secret in the
2448
- # clear in .env.secrets — which cannot be undone by a later commit,
2449
- # because the value stays in the history.
2504
+ # Generated by \`vc init\`.
2505
+ #
2506
+ # Encrypts any value sitting in the clear in .env.secrets, and re-stages the
2507
+ # file so the COMMIT carries the ciphertext rather than what you staged.
2508
+ # Encryption needs only the public key in that file, so this needs no
2509
+ # credential and works on a fresh clone.
2510
+ #
2511
+ # It refuses only when it cannot fix the problem itself: no key yet
2512
+ # (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
2513
+ # committed in the clear cannot be un-committed — the value stays in the
2514
+ # history and has to be treated as burned.
2450
2515
  bunx vc guard
2451
2516
  `, result, "regenerate");
2452
2517
  await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  routeProblem,
52
52
  strictDependencies,
53
53
  upsertJsonc
54
- } from "./index-f1wds190.js";
54
+ } from "./index-y6qzjd9h.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-2qt2yh3z.js";
62
+ } from "./index-j5xjh6qp.js";
63
63
  import {
64
64
  LAYOUTS,
65
65
  MANIFEST_FILE,
@@ -80,7 +80,7 @@ import {
80
80
  writeManifest,
81
81
  zone,
82
82
  zoneOf
83
- } from "./index-xx5p4b8d.js";
83
+ } from "./index-tjy6yygc.js";
84
84
  import {
85
85
  AUTH_PLUGINS,
86
86
  CARRIERS,
@@ -97,7 +97,7 @@ import {
97
97
  SIGN_IN,
98
98
  TAX,
99
99
  UI
100
- } from "./index-844b3qn9.js";
100
+ } from "./index-mnb7fz2t.js";
101
101
  import"./index-0v6na3yp.js";
102
102
  export {
103
103
  zoneOf,
@@ -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-2qt2yh3z.js";
9
- import"./index-xx5p4b8d.js";
10
- import"./index-844b3qn9.js";
11
+ } from "./index-j5xjh6qp.js";
12
+ import"./index-tjy6yygc.js";
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/dist/manifest.js CHANGED
@@ -18,8 +18,8 @@ import {
18
18
  writeManifest,
19
19
  zone,
20
20
  zoneOf
21
- } from "./index-xx5p4b8d.js";
22
- import"./index-844b3qn9.js";
21
+ } from "./index-tjy6yygc.js";
22
+ import"./index-mnb7fz2t.js";
23
23
  import"./index-0v6na3yp.js";
24
24
  export {
25
25
  zoneOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/catalog.ts CHANGED
@@ -519,7 +519,18 @@ export const CARRIERS: Group = {
519
519
  { 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" }] },
520
520
  { 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" }] },
521
521
  { 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" }] },
522
- { 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" }] },
522
+ {
523
+ id: "royal-mail",
524
+ label: "Royal Mail (Click & Drop)",
525
+ 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",
526
+ env: [
527
+ {
528
+ key: "CLICK_DROP_AUTH_KEY",
529
+ breaks: "no label and no tracking number: orders are created in Click & Drop but never despatched",
530
+ where: "Click & Drop → Settings → Integrations → Click & Drop API",
531
+ },
532
+ ],
533
+ },
523
534
  { 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" }] },
524
535
  { id: "doordash-drive", label: "DoorDash Drive", hint: "same-day local delivery", env: [{ key: "DOORDASH_DEVELOPER_ID", breaks: "DoorDash is not offered" }] },
525
536
  { id: "stuart", label: "Stuart", hint: "same-day courier, Europe", env: [{ key: "STUART_CLIENT_ID", breaks: "Stuart is not offered" }] },
@@ -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.