@saastemly/voidcommerce 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  runVoid,
22
22
  version,
23
23
  voidAppsIn
24
- } from "./index-jvsednjr.js";
24
+ } from "./index-x70qxh3m.js";
25
25
  import {
26
26
  LOCAL_KEY_FILE,
27
27
  PRIVATE_KEY_VAR,
@@ -50,7 +50,7 @@ import {
50
50
  setVariable,
51
51
  variableNames,
52
52
  verifyCloudflareToken
53
- } from "./index-paeg95y4.js";
53
+ } from "./index-s8se0x2d.js";
54
54
  import {
55
55
  LAYOUTS,
56
56
  oneOrigin,
@@ -255,11 +255,31 @@ async function preflightCommand(args) {
255
255
  console.error("vc: no voidcommerce.json here.");
256
256
  return 1;
257
257
  }
258
- const source = args.includes("--cloudflare") ? "wrangler" : "void";
258
+ const source = args.includes("--repo") ? "repository" : args.includes("--cloudflare") ? "wrangler" : "void";
259
259
  const result = await preflight(project, source);
260
260
  printPreflight(project, result, source);
261
+ if (source !== "repository")
262
+ return result.ready ? 0 : 1;
263
+ const pending = await uncommittedMigrations(project.root);
264
+ if (pending.length > 0) {
265
+ console.error(`${color2.red("✗")} ${pending.length} migration file${pending.length === 1 ? " is" : "s are"} not committed:
266
+ ` + ` ${pending.join(`
267
+ `)}
268
+
269
+ ` + ` The deploy applies what is in the repository. Commit these, or the
270
+ ` + ` schema the shop runs on is not the one you tested.
271
+ `);
272
+ return 1;
273
+ }
261
274
  return result.ready ? 0 : 1;
262
275
  }
276
+ async function uncommittedMigrations(root) {
277
+ const status = await run("git", ["status", "--porcelain", "--", "migrations"], root);
278
+ if (status.code !== 0)
279
+ return [];
280
+ return status.out.split(`
281
+ `).map((line2) => line2.trim()).filter(Boolean).map((line2) => line2.replace(/^\S+\s+/, ""));
282
+ }
263
283
  async function deployHelp() {
264
284
  const captured = await captureVoid(["deploy", "--help"]);
265
285
  const width = 80;
@@ -21,7 +21,16 @@ export interface Preflight {
21
21
  bareSecrets: string[];
22
22
  ready: boolean;
23
23
  }
24
- export type SecretSource = "wrangler" | "void";
24
+ /**
25
+ * Where a secret's presence is established.
26
+ *
27
+ * `repository` asks only what is committed, and is the right question before
28
+ * a PUSH: the deploy happens off this machine from what was pushed, so a
29
+ * check that needs local credentials is checking the wrong thing. It is also
30
+ * the only mode that works on a machine which has none — which, if config is
31
+ * properly in the environment, is every machine.
32
+ */
33
+ export type SecretSource = "wrangler" | "void" | "repository";
25
34
  /** Keys committed in .env.production — safe values only, by construction. */
26
35
  export declare function productionEnv(appDir: string): Map<string, string>;
27
36
  /** The worker's hostnames must be what the layout says. */
@@ -66,6 +66,16 @@ export declare function committedPublicKeyInto(root: string, publicKey: string):
66
66
  * can say "all five are declared" on a machine that cannot read any of them.
67
67
  */
68
68
  export declare function declaredSecretNames(root: string): Set<string>;
69
+ /**
70
+ * Declared names whose value is still the `unset` placeholder.
71
+ *
72
+ * Readable WITHOUT the key, because `unset` is plaintext — which is what
73
+ * lets a push-time gate tell "this shop has a Stripe key" from "this shop
74
+ * has a line that says STRIPE_SECRET_KEY". Preflight used to count the
75
+ * second as present, so a shop could pass every check and then fail to take
76
+ * money in production.
77
+ */
78
+ export declare function unsetSecretNames(root: string): string[];
69
79
  /** Is a declared value actually encrypted, or was it committed in the clear? */
70
80
  export declare function plaintextSecretNames(root: string): string[];
71
81
  /**
@@ -342,6 +342,21 @@ function declaredSecretNames(root) {
342
342
  }
343
343
  return names;
344
344
  }
345
+ function unsetSecretNames(root) {
346
+ const path = join2(root, SECRETS_FILE);
347
+ if (!existsSync2(path))
348
+ return [];
349
+ const out = [];
350
+ for (const line of readFileSync(path, "utf8").split(`
351
+ `)) {
352
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
353
+ if (!match || match[1].startsWith("DOTENV_"))
354
+ continue;
355
+ if (match[2].trim().replace(/^['"]|['"]$/g, "") === "unset")
356
+ out.push(match[1]);
357
+ }
358
+ return out;
359
+ }
345
360
  function plaintextSecretNames(root) {
346
361
  const path = join2(root, SECRETS_FILE);
347
362
  if (!existsSync2(path))
@@ -389,7 +404,7 @@ async function decryptSecrets(project) {
389
404
  }
390
405
  const expected = committedPublicKey(root);
391
406
  if (expected) {
392
- const { publicKeyFor } = await import("./keys-nntc65pq.js");
407
+ const { publicKeyFor } = await import("./keys-6neyajsv.js");
393
408
  const derived = await publicKeyFor(privateKey);
394
409
  if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
395
410
  return {
@@ -916,4 +931,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
916
931
  return 0;
917
932
  }
918
933
 
919
- export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, 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 };
934
+ export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, 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, unsetSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
@@ -10,8 +10,9 @@ import {
10
10
  renderEnvExample,
11
11
  renderEnvLocal,
12
12
  renderEnvProduction,
13
- renderEnvTs
14
- } from "./index-paeg95y4.js";
13
+ renderEnvTs,
14
+ unsetSecretNames
15
+ } from "./index-s8se0x2d.js";
15
16
  import {
16
17
  MANIFEST_FILE,
17
18
  has,
@@ -1378,7 +1379,7 @@ import color from "picocolors";
1378
1379
  // package.json
1379
1380
  var package_default = {
1380
1381
  name: "@saastemly/voidcommerce",
1381
- version: "0.14.0",
1382
+ version: "0.16.0",
1382
1383
  description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
1383
1384
  type: "module",
1384
1385
  license: "MIT",
@@ -1711,8 +1712,10 @@ function renderDistWorkflow(manifest) {
1711
1712
 
1712
1713
  # Generated by \`vc init\` from voidcommerce.json.
1713
1714
  #
1714
- # A push to main regenerates the Void app from the manifest, publishes it to
1715
- # \`${DIST_BRANCH}\` as a standalone tree, and deploys it to Cloudflare.
1715
+ # A push to main is checked, then regenerates the Void app from the manifest,
1716
+ # publishes it to \`${DIST_BRANCH}\` as a standalone tree, and deploys it to
1717
+ # Cloudflare. The check runs FIRST and on its own: nothing is built, created
1718
+ # or replaced for a shop that is not ready.
1716
1719
  #
1717
1720
  # The credentials come from the repository, set once by \`vc link\`:
1718
1721
  #
@@ -1737,8 +1740,32 @@ permissions:
1737
1740
  contents: write
1738
1741
 
1739
1742
  jobs:
1743
+ # ── Is what was just pushed fit to deploy? ─────────────────────────────
1744
+ #
1745
+ # First, and on its own, because everything after it costs something: a
1746
+ # build, a database, a worker replaced. It answers the question the deploy
1747
+ # would ask, against exactly what was pushed, and needs NO CREDENTIALS to
1748
+ # do it — config lives in the repository, encrypted, so readiness is a
1749
+ # property of the commit rather than of whoever pushed it.
1750
+ #
1751
+ # This is the gate. It cannot be skipped with --no-verify, and it runs the
1752
+ # same way for every push and every person.
1753
+ preflight:
1754
+ runs-on: ubuntu-latest
1755
+ steps:
1756
+ - uses: actions/checkout@v6
1757
+ - uses: oven-sh/setup-bun@v2
1758
+ - run: bun install --frozen-lockfile
1759
+
1760
+ # Every required key actually SET rather than merely declared, nothing
1761
+ # committed in the clear, the hostnames the manifest says, and no
1762
+ # migration generated but left uncommitted.
1763
+ - name: vc preflight --repo
1764
+ run: bunx vc preflight --repo
1765
+
1740
1766
  # ── Regenerate, and publish the standalone tree ────────────────────────
1741
1767
  dist:
1768
+ needs: preflight
1742
1769
  runs-on: ubuntu-latest
1743
1770
  steps:
1744
1771
  - uses: actions/checkout@v6
@@ -1838,7 +1865,12 @@ regenerate with \`vc generate\`.
1838
1865
  git push
1839
1866
  \`\`\`
1840
1867
 
1841
- That is the deploy. GitHub Actions regenerates the app from
1868
+ That is the deploy. It is checked first: a \`preflight\` job runs before
1869
+ anything is built, and refuses a shop whose secrets are declared but unset,
1870
+ whose values were committed in the clear, or whose migrations are not all
1871
+ committed. It needs no credentials — readiness is a property of the commit,
1872
+ not of the machine that pushed it — so it fails the same way for everyone,
1873
+ and cannot be skipped. GitHub Actions regenerates the app from
1842
1874
  \`voidcommerce.json\`, publishes a standalone copy to \`${DIST_BRANCH}\`, creates the
1843
1875
  database and queue if they are not there, applies the migrations, and deploys
1844
1876
  the worker with this repository's secrets attached.
@@ -2433,6 +2465,23 @@ async function link(root, file, target, result) {
2433
2465
  }
2434
2466
  }
2435
2467
  var sorted = (record) => Object.fromEntries(Object.entries(record).sort());
2468
+ async function generateHooks(root, result) {
2469
+ await put(root, ".husky/pre-commit", `#!/usr/bin/env sh
2470
+ # Generated by \`vc init\`.
2471
+ #
2472
+ # Encrypts any value sitting in the clear in .env.secrets, and re-stages the
2473
+ # file so the COMMIT carries the ciphertext rather than what you staged.
2474
+ # Encryption needs only the public key in that file, so this needs no
2475
+ # credential and works on a fresh clone.
2476
+ #
2477
+ # It refuses only when it cannot fix the problem itself: no key yet
2478
+ # (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
2479
+ # committed in the clear cannot be un-committed — the value stays in the
2480
+ # history and has to be treated as burned.
2481
+ bunx vc guard
2482
+ `, result, "regenerate");
2483
+ await retire(root, ".husky/pre-push", result);
2484
+ }
2436
2485
  async function generate(root, manifest) {
2437
2486
  const result = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
2438
2487
  await writeManifest(root, manifest);
@@ -2453,6 +2502,7 @@ async function generate(root, manifest) {
2453
2502
  await generateStrictApp(root, STRICT_APP, manifest, result);
2454
2503
  break;
2455
2504
  }
2505
+ await generateHooks(root, result);
2456
2506
  return result;
2457
2507
  }
2458
2508
  async function generateApi(root, dir, manifest, result, opts) {
@@ -2601,20 +2651,6 @@ dist
2601
2651
  .DS_Store
2602
2652
  `, result, "own");
2603
2653
  await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
2604
- await put(root, ".husky/pre-commit", `#!/usr/bin/env sh
2605
- # Generated by \`vc init\`.
2606
- #
2607
- # Encrypts any value sitting in the clear in .env.secrets, and re-stages the
2608
- # file so the COMMIT carries the ciphertext rather than what you staged.
2609
- # Encryption needs only the public key in that file, so this needs no
2610
- # credential and works on a fresh clone.
2611
- #
2612
- # It refuses only when it cannot fix the problem itself: no key yet
2613
- # (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
2614
- # committed in the clear cannot be un-committed — the value stays in the
2615
- # history and has to be treated as burned.
2616
- bunx vc guard
2617
- `, result, "regenerate");
2618
2654
  await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
2619
2655
  await retire(root, ".github/workflows/void-dist.yml", result);
2620
2656
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
@@ -3036,7 +3072,9 @@ function routeProblem(project) {
3036
3072
  async function preflight(project, source) {
3037
3073
  const present = productionEnv(project.appDir);
3038
3074
  let remote = null;
3039
- if (source === "wrangler") {
3075
+ if (source === "repository") {
3076
+ remote = null;
3077
+ } else if (source === "wrangler") {
3040
3078
  const bin = findWrangler(project.appDir);
3041
3079
  remote = bin ? await secretNames(bin, project.appDir) : null;
3042
3080
  } else {
@@ -3044,8 +3082,11 @@ async function preflight(project, source) {
3044
3082
  }
3045
3083
  for (const name of remote ?? [])
3046
3084
  present.set(name, "<secret>");
3047
- for (const name of declaredSecretNames(project.root))
3048
- present.set(name, "<in the repository>");
3085
+ const stillUnset = new Set(unsetSecretNames(project.root));
3086
+ for (const name of declaredSecretNames(project.root)) {
3087
+ if (!stillUnset.has(name))
3088
+ present.set(name, "<in the repository>");
3089
+ }
3049
3090
  const missing = allEnvKeys(project.manifest).filter((key) => {
3050
3091
  const value = present.get(key.key);
3051
3092
  return value === undefined || value === "" || value === UNSET;
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  routeProblem,
53
53
  strictDependencies,
54
54
  upsertJsonc
55
- } from "./index-jvsednjr.js";
55
+ } from "./index-x70qxh3m.js";
56
56
  import {
57
57
  allEnvKeys,
58
58
  envSummary,
@@ -60,7 +60,7 @@ import {
60
60
  renderEnvLocal,
61
61
  renderEnvProduction,
62
62
  renderEnvTs
63
- } from "./index-paeg95y4.js";
63
+ } from "./index-s8se0x2d.js";
64
64
  import {
65
65
  LAYOUTS,
66
66
  MANIFEST_FILE,
@@ -8,7 +8,7 @@ import {
8
8
  localPrivateKey,
9
9
  provisionKey,
10
10
  publicKeyFor
11
- } from "./index-paeg95y4.js";
11
+ } from "./index-s8se0x2d.js";
12
12
  import"./index-30y19qz5.js";
13
13
  import"./index-xyjhy6kp.js";
14
14
  import"./index-0v6na3yp.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",
@@ -61,12 +61,40 @@ export async function preflightCommand(args: string[]): Promise<number> {
61
61
  console.error("vc: no voidcommerce.json here.");
62
62
  return 1;
63
63
  }
64
- const source = args.includes("--cloudflare") ? "wrangler" : "void";
64
+ const source = args.includes("--repo") ? "repository" : args.includes("--cloudflare") ? "wrangler" : "void";
65
65
  const result = await preflight(project, source);
66
66
  printPreflight(project, result, source);
67
+ if (source !== "repository") return result.ready ? 0 : 1;
68
+
69
+ // A repository check is a check on what will be PUSHED, so it also asks
70
+ // the question CI would ask and cannot answer any earlier: is there a
71
+ // migration that was generated and never committed? Applied under a fresh
72
+ // name on the next run, against tables that already exist, it fails the
73
+ // deploy — and it fails it after the shop is already down for the update.
74
+ const pending = await uncommittedMigrations(project.root);
75
+ if (pending.length > 0) {
76
+ console.error(
77
+ `${color.red("✗")} ${pending.length} migration file${pending.length === 1 ? " is" : "s are"} not committed:\n` +
78
+ ` ${pending.join("\n ")}\n\n` +
79
+ " The deploy applies what is in the repository. Commit these, or the\n" +
80
+ " schema the shop runs on is not the one you tested.\n",
81
+ );
82
+ return 1;
83
+ }
67
84
  return result.ready ? 0 : 1;
68
85
  }
69
86
 
87
+ /** Migration files git does not have — untracked or modified. */
88
+ async function uncommittedMigrations(root: string): Promise<string[]> {
89
+ const status = await run("git", ["status", "--porcelain", "--", "migrations"], root);
90
+ if (status.code !== 0) return [];
91
+ return status.out
92
+ .split("\n")
93
+ .map((line) => line.trim())
94
+ .filter(Boolean)
95
+ .map((line) => line.replace(/^\S+\s+/, ""));
96
+ }
97
+
70
98
  /** void's deploy help, then what vc adds. */
71
99
  export async function deployHelp(): Promise<number> {
72
100
  const captured = await captureVoid(["deploy", "--help"]);
@@ -7,7 +7,7 @@ import { workerHosts } from "../manifest";
7
7
  import type { Project } from "../project";
8
8
  import { captureVoid } from "../void";
9
9
  import { parseJsonc } from "./jsonc";
10
- import { SECRETS_FILE, declaredSecretNames, plaintextSecretNames } from "./secrets";
10
+ import { SECRETS_FILE, declaredSecretNames, plaintextSecretNames, unsetSecretNames } from "./secrets";
11
11
  import { findWrangler, secretNames } from "./wrangler";
12
12
 
13
13
  /**
@@ -34,7 +34,16 @@ export interface Preflight {
34
34
  ready: boolean;
35
35
  }
36
36
 
37
- export type SecretSource = "wrangler" | "void";
37
+ /**
38
+ * Where a secret's presence is established.
39
+ *
40
+ * `repository` asks only what is committed, and is the right question before
41
+ * a PUSH: the deploy happens off this machine from what was pushed, so a
42
+ * check that needs local credentials is checking the wrong thing. It is also
43
+ * the only mode that works on a machine which has none — which, if config is
44
+ * properly in the environment, is every machine.
45
+ */
46
+ export type SecretSource = "wrangler" | "void" | "repository";
38
47
 
39
48
  /** Keys committed in .env.production — safe values only, by construction. */
40
49
  export function productionEnv(appDir: string): Map<string, string> {
@@ -81,7 +90,9 @@ export function routeProblem(project: Project): string | null {
81
90
  export async function preflight(project: Project, source: SecretSource): Promise<Preflight> {
82
91
  const present = productionEnv(project.appDir);
83
92
  let remote: Set<string> | null = null;
84
- if (source === "wrangler") {
93
+ if (source === "repository") {
94
+ remote = null;
95
+ } else if (source === "wrangler") {
85
96
  const bin = findWrangler(project.appDir);
86
97
  remote = bin ? await secretNames(bin, project.appDir) : null;
87
98
  } else {
@@ -94,7 +105,12 @@ export async function preflight(project: Project, source: SecretSource): Promise
94
105
  * uploads it. The names are readable without the key — dotenvx encrypts
95
106
  * values, not keys — so this works on a machine that cannot decrypt.
96
107
  */
97
- for (const name of declaredSecretNames(project.root)) present.set(name, "<in the repository>");
108
+ const stillUnset = new Set(unsetSecretNames(project.root));
109
+ for (const name of declaredSecretNames(project.root)) {
110
+ // A name is not a value. `unset` is the documented placeholder, so a
111
+ // key carrying it is declared and absent at the same time.
112
+ if (!stillUnset.has(name)) present.set(name, "<in the repository>");
113
+ }
98
114
 
99
115
  const missing = allEnvKeys(project.manifest).filter((key) => {
100
116
  const value = present.get(key.key);
@@ -126,6 +126,27 @@ export function declaredSecretNames(root: string): Set<string> {
126
126
  return names;
127
127
  }
128
128
 
129
+ /**
130
+ * Declared names whose value is still the `unset` placeholder.
131
+ *
132
+ * Readable WITHOUT the key, because `unset` is plaintext — which is what
133
+ * lets a push-time gate tell "this shop has a Stripe key" from "this shop
134
+ * has a line that says STRIPE_SECRET_KEY". Preflight used to count the
135
+ * second as present, so a shop could pass every check and then fail to take
136
+ * money in production.
137
+ */
138
+ export function unsetSecretNames(root: string): string[] {
139
+ const path = join(root, SECRETS_FILE);
140
+ if (!existsSync(path)) return [];
141
+ const out: string[] = [];
142
+ for (const line of readFileSync(path, "utf8").split("\n")) {
143
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
144
+ if (!match || match[1]!.startsWith("DOTENV_")) continue;
145
+ if (match[2]!.trim().replace(/^['"]|['"]$/g, "") === "unset") out.push(match[1]!);
146
+ }
147
+ return out;
148
+ }
149
+
129
150
  /** Is a declared value actually encrypted, or was it committed in the clear? */
130
151
  export function plaintextSecretNames(root: string): string[] {
131
152
  const path = join(root, SECRETS_FILE);
@@ -42,8 +42,10 @@ export function renderDistWorkflow(manifest: Manifest): string {
42
42
 
43
43
  # Generated by \`vc init\` from voidcommerce.json.
44
44
  #
45
- # A push to main regenerates the Void app from the manifest, publishes it to
46
- # \`${DIST_BRANCH}\` as a standalone tree, and deploys it to Cloudflare.
45
+ # A push to main is checked, then regenerates the Void app from the manifest,
46
+ # publishes it to \`${DIST_BRANCH}\` as a standalone tree, and deploys it to
47
+ # Cloudflare. The check runs FIRST and on its own: nothing is built, created
48
+ # or replaced for a shop that is not ready.
47
49
  #
48
50
  # The credentials come from the repository, set once by \`vc link\`:
49
51
  #
@@ -68,8 +70,32 @@ permissions:
68
70
  contents: write
69
71
 
70
72
  jobs:
73
+ # ── Is what was just pushed fit to deploy? ─────────────────────────────
74
+ #
75
+ # First, and on its own, because everything after it costs something: a
76
+ # build, a database, a worker replaced. It answers the question the deploy
77
+ # would ask, against exactly what was pushed, and needs NO CREDENTIALS to
78
+ # do it — config lives in the repository, encrypted, so readiness is a
79
+ # property of the commit rather than of whoever pushed it.
80
+ #
81
+ # This is the gate. It cannot be skipped with --no-verify, and it runs the
82
+ # same way for every push and every person.
83
+ preflight:
84
+ runs-on: ubuntu-latest
85
+ steps:
86
+ - uses: actions/checkout@v6
87
+ - uses: oven-sh/setup-bun@v2
88
+ - run: bun install --frozen-lockfile
89
+
90
+ # Every required key actually SET rather than merely declared, nothing
91
+ # committed in the clear, the hostnames the manifest says, and no
92
+ # migration generated but left uncommitted.
93
+ - name: vc preflight --repo
94
+ run: bunx vc preflight --repo
95
+
71
96
  # ── Regenerate, and publish the standalone tree ────────────────────────
72
97
  dist:
98
+ needs: preflight
73
99
  runs-on: ubuntu-latest
74
100
  steps:
75
101
  - uses: actions/checkout@v6
@@ -171,7 +197,12 @@ regenerate with \`vc generate\`.
171
197
  git push
172
198
  \`\`\`
173
199
 
174
- That is the deploy. GitHub Actions regenerates the app from
200
+ That is the deploy. It is checked first: a \`preflight\` job runs before
201
+ anything is built, and refuses a shop whose secrets are declared but unset,
202
+ whose values were committed in the clear, or whose migrations are not all
203
+ committed. It needs no credentials — readiness is a property of the commit,
204
+ not of the machine that pushed it — so it fails the same way for everyone,
205
+ and cannot be skipped. GitHub Actions regenerates the app from
175
206
  \`voidcommerce.json\`, publishes a standalone copy to \`${DIST_BRANCH}\`, creates the
176
207
  database and queue if they are not there, applies the migrations, and deploys
177
208
  the worker with this repository's secrets attached.
@@ -195,6 +195,46 @@ async function link(root: string, file: string, target: string, result: Generate
195
195
 
196
196
  const sorted = (record: Record<string, string>) => Object.fromEntries(Object.entries(record).sort());
197
197
 
198
+ /**
199
+ * Git hooks, for every layout.
200
+ *
201
+ * They guard `.env.secrets`, which exists whatever the layout, so making
202
+ * them strict-only left an `app` shop able to commit a credential in the
203
+ * clear with nothing to stop it. Installed by husky on `bun install`, so a
204
+ * fresh clone is guarded without anyone remembering.
205
+ */
206
+ async function generateHooks(root: string, result: GenerateResult) {
207
+ /**
208
+ * The hook lives in `.husky/`, which is COMMITTED, so the guard travels
209
+ * with the repository instead of being something each clone remembers to
210
+ * install. A secret committed in the clear cannot be un-committed.
211
+ */
212
+ await put(
213
+ root,
214
+ ".husky/pre-commit",
215
+ `#!/usr/bin/env sh
216
+ # Generated by \`vc init\`.
217
+ #
218
+ # Encrypts any value sitting in the clear in .env.secrets, and re-stages the
219
+ # file so the COMMIT carries the ciphertext rather than what you staged.
220
+ # Encryption needs only the public key in that file, so this needs no
221
+ # credential and works on a fresh clone.
222
+ #
223
+ # It refuses only when it cannot fix the problem itself: no key yet
224
+ # (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
225
+ # committed in the clear cannot be un-committed — the value stays in the
226
+ # history and has to be treated as burned.
227
+ bunx vc guard
228
+ `,
229
+ result,
230
+ "regenerate",
231
+ );
232
+ // The pre-PUSH gate lives in CI now: the deploy runs off-machine, so the
233
+ // authoritative check belongs there, where nobody can --no-verify past it.
234
+ // See renderDistWorkflow.
235
+ await retire(root, ".husky/pre-push", result);
236
+ }
237
+
198
238
  export async function generate(root: string, manifest: Manifest): Promise<GenerateResult> {
199
239
  const result: GenerateResult = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
200
240
 
@@ -218,6 +258,7 @@ export async function generate(root: string, manifest: Manifest): Promise<Genera
218
258
  await generateStrictApp(root, STRICT_APP, manifest, result);
219
259
  break;
220
260
  }
261
+ await generateHooks(root, result);
221
262
  return result;
222
263
  }
223
264
 
@@ -424,31 +465,6 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
424
465
  "own",
425
466
  );
426
467
  await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
427
- /**
428
- * The hook lives in `.husky/`, which is COMMITTED, so the guard travels
429
- * with the repository instead of being something each clone remembers to
430
- * install. A secret committed in the clear cannot be un-committed.
431
- */
432
- await put(
433
- root,
434
- ".husky/pre-commit",
435
- `#!/usr/bin/env sh
436
- # Generated by \`vc init\`.
437
- #
438
- # Encrypts any value sitting in the clear in .env.secrets, and re-stages the
439
- # file so the COMMIT carries the ciphertext rather than what you staged.
440
- # Encryption needs only the public key in that file, so this needs no
441
- # credential and works on a fresh clone.
442
- #
443
- # It refuses only when it cannot fix the problem itself: no key yet
444
- # (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
445
- # committed in the clear cannot be un-committed — the value stays in the
446
- # history and has to be treated as burned.
447
- bunx vc guard
448
- `,
449
- result,
450
- "regenerate",
451
- );
452
468
  await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
453
469
  // Renamed when GitHub Actions took over the deploy from Cloudflare's own
454
470
  // build. Two workflows on the same push would deploy the shop twice.