@saastemly/voidcommerce 0.3.0 → 0.4.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/index.js CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  routeProblem,
58
58
  strictDependencies,
59
59
  upsertJsonc
60
- } from "./index-84wq4qp8.js";
60
+ } from "./index-yzezvy5h.js";
61
61
  import {
62
62
  LAYOUTS,
63
63
  MANIFEST_FILE,
@@ -77,7 +77,7 @@ import {
77
77
  writeManifest,
78
78
  zone,
79
79
  zoneOf
80
- } from "./index-zh3tmj08.js";
80
+ } from "./index-pz6m2hkm.js";
81
81
  import {
82
82
  AUTH_PLUGINS,
83
83
  CARRIERS,
@@ -94,7 +94,8 @@ import {
94
94
  SIGN_IN,
95
95
  TAX,
96
96
  UI
97
- } from "./index-z4qazajn.js";
97
+ } from "./index-844b3qn9.js";
98
+ import"./index-0v6na3yp.js";
98
99
  export {
99
100
  zoneOf,
100
101
  zone,
package/dist/manifest.js CHANGED
@@ -17,8 +17,9 @@ import {
17
17
  writeManifest,
18
18
  zone,
19
19
  zoneOf
20
- } from "./index-zh3tmj08.js";
21
- import"./index-z4qazajn.js";
20
+ } from "./index-pz6m2hkm.js";
21
+ import"./index-844b3qn9.js";
22
+ import"./index-0v6na3yp.js";
22
23
  export {
23
24
  zoneOf,
24
25
  zone,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@clack/prompts": "^0.11.0",
37
+ "@dotenvx/primitives": "^2.2.0",
37
38
  "picocolors": "^1.1.1"
38
39
  },
39
40
  "devDependencies": {
package/src/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { deployCommand, deployHelp, preflightCommand, preflightHelp, secretsCliCommand, secretsHelp } from "./deploy/index";
1
+ import { deployCommand, deployHelp, guardCommand, keysCliCommand, keysHelp, preflightCommand, preflightHelp, secretsCliCommand, secretsHelp } from "./deploy/index";
2
2
  import { distCommand, distHelp } from "./dist";
3
3
  import { importCommand, importHelp } from "./import";
4
4
  import { fullHelp, initHelp, version } from "./help";
@@ -39,6 +39,9 @@ export const EXTENDED: Record<string, Extended> = {
39
39
  deploy: { run: deployCommand, help: deployHelp },
40
40
  preflight: { run: preflightCommand, help: preflightHelp },
41
41
  secrets: { run: secretsCliCommand, help: secretsHelp },
42
+ keys: { run: keysCliCommand, help: keysHelp },
43
+ // Called by the generated pre-commit hook; exit code is the interface.
44
+ guard: { run: guardCommand, help: async () => guardCommand() },
42
45
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
43
46
  build: { run: appScript("build"), help: appScriptHelp("build") },
44
47
  preview: { run: appScript("preview"), help: appScriptHelp("preview") },
@@ -4,7 +4,8 @@ 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, secretsCommand } from "./secrets";
7
+ import { PRIVATE_KEY_VAR, SECRETS_FILE, plaintextSecretNames, secretsCommand } from "./secrets";
8
+ import { keysCommand } from "./keys";
8
9
 
9
10
  /**
10
11
  * `vc deploy` extends `void deploy`: vc's preflight first, then void's
@@ -134,3 +135,55 @@ export async function secretsHelp(): Promise<number> {
134
135
  );
135
136
  return 0;
136
137
  }
138
+
139
+ /** `vc keys` — what the token derives, and the warning that goes with it. */
140
+ export async function keysCliCommand(args: string[]): Promise<number> {
141
+ const project = await findProject();
142
+ if (!project) {
143
+ console.error("vc: no voidcommerce.json here.");
144
+ return 1;
145
+ }
146
+ return keysCommand(project.manifest, project.root, args);
147
+ }
148
+
149
+ export async function keysHelp(): Promise<number> {
150
+ const width = 80;
151
+ console.log(
152
+ box("vc keys", [
153
+ line("The key that encrypts this repository's secrets is DERIVED from", width),
154
+ line("CLOUDFLARE_API_TOKEN, salted with the account id. Nothing is stored.", width),
155
+ line("", width),
156
+ ...row("vc keys", "what this token derives, and whether it matches the repository", width, 2),
157
+ ...row("vc keys --rotate", "re-encrypt under a new token, while the old one still works", width, 2),
158
+ line("", width),
159
+ line("Whoever can deploy the worker can read its secrets, and nobody else can.", width),
160
+ line("The price: rotating the token orphans every encrypted value, a second", width),
161
+ line("admin's token derives a different key, and the build must use the same", width),
162
+ line("token the values were encrypted under. `vc keys` checks the derived", width),
163
+ line("public key against the committed one, so a mismatch is a clear refusal", width),
164
+ line("rather than a decryption error nobody can place.", width),
165
+ ], width),
166
+ );
167
+ return 0;
168
+ }
169
+
170
+ /**
171
+ * The pre-commit gate: refuse a commit that would put a secret in the clear.
172
+ *
173
+ * Exit code is the whole interface — a hook cares about nothing else.
174
+ */
175
+ export async function guardCommand(): Promise<number> {
176
+ const project = await findProject();
177
+ if (!project) return 0; // Not a shop; nothing to guard.
178
+ const bare = plaintextSecretNames(project.root);
179
+ if (bare.length === 0) return 0;
180
+ console.error(
181
+ `\n${color.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${
182
+ bare.length === 1 ? "is" : "are"
183
+ } not encrypted.\n\n` +
184
+ ` ${bare.join("\n ")}\n\n` +
185
+ ` bunx dotenvx encrypt -f ${SECRETS_FILE}\n\n` +
186
+ 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"),
187
+ );
188
+ return 1;
189
+ }
@@ -0,0 +1,212 @@
1
+ import { hkdfSync } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import color from "picocolors";
5
+ import type { Manifest } from "../manifest";
6
+ import { SECRETS_FILE } from "./secrets";
7
+
8
+ /**
9
+ * The key that encrypts the repository's secrets, DERIVED rather than stored.
10
+ *
11
+ * ── What this buys, and what it costs ────────────────────────────────────
12
+ *
13
+ * Nothing has to be kept. There is no `.env.keys` to lose, leak, or forget to
14
+ * gitignore — a real failure that has already happened once here. Whoever can
15
+ * deploy the worker can read its secrets, and nobody else can, which is the
16
+ * property that was wanted.
17
+ *
18
+ * The cost is severe and worth stating plainly, because it is invisible until
19
+ * the day it is not:
20
+ *
21
+ * **Rotating the API token orphans every secret in the repository.** The
22
+ * key changes with the token, and the ciphertext does not. Every
23
+ * `encrypted:` value becomes permanently unreadable, and the only recovery
24
+ * is to type them all in again.
25
+ *
26
+ * **A second admin derives a different key.** Two people with two tokens
27
+ * cannot read each other's values.
28
+ *
29
+ * **The build derives from whichever token it was given**, so the token in
30
+ * the build settings must be the one the values were encrypted under.
31
+ *
32
+ * So this module's real job is not deriving a key — that is eight lines. It
33
+ * is making those three failures LOUD. The committed `.env.secrets` carries
34
+ * the public key, so the derived private key can always be checked against
35
+ * what the repository was actually encrypted under, and a mismatch is caught
36
+ * before anything is attempted rather than surfacing as a decryption failure
37
+ * nobody can explain.
38
+ *
39
+ * @see https://developers.cloudflare.com/workers/wrangler/system-environment-variables/
40
+ */
41
+
42
+ /**
43
+ * Domain separation. A key derived for this purpose must not equal one
44
+ * derived for any other, so the label is versioned: changing it is how a
45
+ * future scheme migrates without colliding with this one.
46
+ */
47
+ const INFO = "voidcommerce/dotenvx/secrets/v1";
48
+
49
+ /** Where the token comes from, in the order wrangler itself reads them. */
50
+ export function apiToken(): string | null {
51
+ return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
52
+ }
53
+
54
+ /**
55
+ * The private key this token and account derive.
56
+ *
57
+ * The account id is the salt rather than a constant, so the same token used
58
+ * against two accounts does not produce one key — tokens are often scoped to
59
+ * several accounts, and a shared key across them would be a wider blast
60
+ * radius than anyone asked for.
61
+ */
62
+ export function derivePrivateKey(token: string, accountId: string): string {
63
+ const bytes = hkdfSync("sha256", Buffer.from(token, "utf8"), Buffer.from(accountId, "utf8"), Buffer.from(INFO), 32);
64
+ return Buffer.from(bytes).toString("hex");
65
+ }
66
+
67
+ /** The public key for a private one, via dotenvx's own primitive. */
68
+ export async function publicKeyFor(privateKey: string): Promise<string | null> {
69
+ try {
70
+ const { derive } = await import("@dotenvx/primitives");
71
+ return derive(privateKey);
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /** The public key the repository was actually encrypted under. */
78
+ export function committedPublicKey(root: string): string | null {
79
+ const path = join(root, SECRETS_FILE);
80
+ if (!existsSync(path)) return null;
81
+ const match = /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=\s*["']?([0-9a-fA-F]+)["']?/m.exec(readFileSync(path, "utf8"));
82
+ return match?.[1] ?? null;
83
+ }
84
+
85
+ export type KeyState =
86
+ | { ok: true; privateKey: string; publicKey: string; fresh: boolean }
87
+ | { ok: false; reason: string };
88
+
89
+ /**
90
+ * The key for this repository, or a refusal that says exactly what is wrong.
91
+ *
92
+ * `fresh` means the repository has no secrets yet, so there is nothing to
93
+ * check the derivation against and any token is as good as another.
94
+ */
95
+ export async function keyFor(manifest: Manifest, root: string): Promise<KeyState> {
96
+ const token = apiToken();
97
+ if (!token) {
98
+ return {
99
+ ok: false,
100
+ reason:
101
+ "CLOUDFLARE_API_TOKEN is not set.\n" +
102
+ " The key is derived from it, so there is nothing to derive from. Note that\n" +
103
+ " `wrangler login` does NOT set one — this scheme needs an API token, from\n" +
104
+ " My Profile → API Tokens.",
105
+ };
106
+ }
107
+ const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
108
+ if (!accountId) {
109
+ return {
110
+ ok: false,
111
+ reason:
112
+ "the account is not pinned, and it salts the derivation.\n" +
113
+ " Run `vc deploy --cloudflare --provision` once, or set CLOUDFLARE_ACCOUNT_ID.",
114
+ };
115
+ }
116
+
117
+ const privateKey = derivePrivateKey(token, accountId);
118
+ const publicKey = await publicKeyFor(privateKey);
119
+ if (!publicKey) {
120
+ return { ok: false, reason: "@dotenvx/dotenvx is not installed here, so a key cannot be derived. `bun add -d @dotenvx/dotenvx`" };
121
+ }
122
+
123
+ const committed = committedPublicKey(root);
124
+ if (!committed) return { ok: true, privateKey, publicKey, fresh: true };
125
+
126
+ if (committed.toLowerCase() !== publicKey.toLowerCase()) {
127
+ return {
128
+ ok: false,
129
+ reason:
130
+ `this token does not derive the key ${SECRETS_FILE} was encrypted under.\n\n` +
131
+ ` encrypted under: ${committed.slice(0, 16)}…\n` +
132
+ ` this token gives: ${publicKey.slice(0, 16)}…\n\n` +
133
+ " That is one of three things, and all of them are the same fix:\n" +
134
+ " · the token was rotated since the secrets were encrypted\n" +
135
+ " · this is a different admin's token\n" +
136
+ " · CLOUDFLARE_ACCOUNT_ID is not the account they were encrypted for\n\n" +
137
+ ` If you still have the ORIGINAL token, \`vc keys rotate\` re-encrypts\n` +
138
+ " everything under the new one. If you do not, the values are unrecoverable\n" +
139
+ " and must be entered again.",
140
+ };
141
+ }
142
+ return { ok: true, privateKey, publicKey, fresh: false };
143
+ }
144
+
145
+ /** `vc keys` — what the derivation says, without printing anything secret. */
146
+ export async function keysCommand(manifest: Manifest, root: string, args: string[]): Promise<number> {
147
+ if (args.includes("--rotate")) return rotate(manifest, root);
148
+
149
+ const state = await keyFor(manifest, root);
150
+ console.log(`\nThe key is DERIVED from CLOUDFLARE_API_TOKEN, salted with the account id.`);
151
+ console.log(color.dim("Nothing is stored, so nothing can leak — and nothing can be recovered.\n"));
152
+
153
+ if (!state.ok) {
154
+ console.error(`${color.red("✗")} ${state.reason}\n`);
155
+ return 1;
156
+ }
157
+ console.log(` derives public key ${state.publicKey.slice(0, 20)}…`);
158
+ console.log(
159
+ state.fresh
160
+ ? ` ${color.dim(`${SECRETS_FILE} does not exist yet, so there is nothing to check against`)}`
161
+ : ` ${color.green("✓")} matches what ${SECRETS_FILE} was encrypted under`,
162
+ );
163
+ console.log(
164
+ color.yellow(
165
+ `\n! Rotating this API token makes every secret in ${SECRETS_FILE} unreadable.\n` +
166
+ " Run `vc keys --rotate` with the NEW token exported and the old one in\n" +
167
+ " CLOUDFLARE_API_TOKEN_OLD, BEFORE the old one stops working.\n",
168
+ ),
169
+ );
170
+ return 0;
171
+ }
172
+
173
+ /**
174
+ * Re-encrypt under a new token's key, while the old one still works.
175
+ *
176
+ * This is the escape hatch that makes a derived key survivable, and it only
177
+ * exists in the window where both tokens are known. Outside that window there
178
+ * is no recovery, which is why `vc keys` says so every time it runs.
179
+ */
180
+ async function rotate(manifest: Manifest, root: string): Promise<number> {
181
+ const oldToken = process.env["CLOUDFLARE_API_TOKEN_OLD"];
182
+ const newToken = apiToken();
183
+ if (!oldToken || !newToken) {
184
+ console.error(
185
+ "\nvc: rotation needs BOTH tokens:\n" +
186
+ " CLOUDFLARE_API_TOKEN_OLD=<the one the secrets were encrypted under>\n" +
187
+ " CLOUDFLARE_API_TOKEN=<the new one>\n\n" +
188
+ " The old one is the only thing that can read the current values.\n",
189
+ );
190
+ return 1;
191
+ }
192
+ const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
193
+ if (!accountId) {
194
+ console.error("vc: the account is not pinned, and it salts the derivation.");
195
+ return 1;
196
+ }
197
+ const oldKey = derivePrivateKey(oldToken, accountId);
198
+ const oldPublic = await publicKeyFor(oldKey);
199
+ const committed = committedPublicKey(root);
200
+ if (committed && oldPublic && committed.toLowerCase() !== oldPublic.toLowerCase()) {
201
+ console.error(`\nvc: CLOUDFLARE_API_TOKEN_OLD does not derive the key ${SECRETS_FILE} was encrypted under either.\n`);
202
+ return 1;
203
+ }
204
+ console.log(
205
+ `\nRotation is a decrypt with the old key and an encrypt with the new one.\n` +
206
+ `Run these two, in this order, from ${root}:\n\n` +
207
+ ` ${color.cyan(`DOTENV_PRIVATE_KEY_SECRETS=<old> bunx dotenvx decrypt -f ${SECRETS_FILE}`)}\n` +
208
+ ` ${color.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)} ${color.dim("# under the new derived key")}\n\n` +
209
+ color.dim("vc does not run them for you: the middle state is your secrets in\nplaintext on disk, and that is a moment to be deliberate about.\n"),
210
+ );
211
+ return 0;
212
+ }
@@ -4,6 +4,7 @@ import { delimiter, dirname, join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import color from "picocolors";
6
6
  import { allEnvKeys } from "../generate/env";
7
+ import { keyFor } from "./keys";
7
8
  import type { Project } from "../project";
8
9
 
9
10
  /**
@@ -20,8 +21,9 @@ import type { Project } from "../project";
20
21
  * dotenvx closes it. `.env.secrets` holds ciphertext and a public key, and
21
22
  * it is COMMITTED: the key names are readable, the values are not, and a
22
23
  * diff shows when a secret changed without showing what it changed to. The
23
- * private key is the one thing that stays out a single value, set once
24
- * where the deploy runs.
24
+ * private key is not stored at all it is derived from the Cloudflare API
25
+ * token, so whoever can deploy the worker can read its secrets and nobody
26
+ * else can. See `./keys.ts`, which also spells out what that costs.
25
27
  *
26
28
  * ── Why the values still become worker SECRETS ───────────────────────────
27
29
  *
@@ -34,14 +36,19 @@ import type { Project } from "../project";
34
36
  * applies ADDITIVELY, so a secret this file does not name is left alone
35
37
  * rather than deleted.
36
38
  *
37
- * ── The tradeoff, stated ─────────────────────────────────────────────────
39
+ * ── The tradeoffs, stated ────────────────────────────────────────────────
38
40
  *
39
- * Ciphertext in git is permanent. If the private key ever leaks, every
40
- * secret in the history is readable, including ones that were rotated —
41
- * which is not true of `wrangler secret put`, where a rotation genuinely
42
- * retires the old value. Rotating the dotenvx key re-encrypts the present,
43
- * not the past. That is the price of a shop that rebuilds from a checkout,
44
- * and it should be a decision rather than a surprise.
41
+ * Ciphertext in git is permanent. A key that ever leaks reads every secret
42
+ * in the history, including ones that were rotated — which is not true of
43
+ * `wrangler secret put`, where a rotation genuinely retires the old value.
44
+ *
45
+ * And because the key is derived, rotating the API token orphans the lot.
46
+ * `keys.ts` guards that with a public-key check, so the failure is a clear
47
+ * refusal rather than a decryption error nobody can place — but the guard
48
+ * cannot recover values, only explain them.
49
+ *
50
+ * Both are the price of a shop that rebuilds from a checkout, and both
51
+ * should be decisions rather than surprises.
45
52
  */
46
53
 
47
54
  export const SECRETS_FILE = ".env.secrets";
@@ -131,13 +138,15 @@ export async function decryptSecrets(project: Project): Promise<DecryptedSecrets
131
138
 
132
139
  const dotenvx = findDotenvx(root);
133
140
  if (!dotenvx) return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
134
- if (!process.env[PRIVATE_KEY_VAR] && !existsSync(join(root, ".env.keys"))) {
135
- return {
136
- error: `${PRIVATE_KEY_VAR} is not set and there is no .env.keys here.\n It is the one value that stays out of the repository; set it where the deploy runs.`,
137
- };
138
- }
139
141
 
140
- const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root);
142
+ // Derived from the API token, never read from disk. A mismatch is caught
143
+ // here rather than surfacing as a decryption failure nobody can explain.
144
+ const key = await keyFor(project.manifest, root);
145
+ if (!key.ok) return { error: key.reason };
146
+
147
+ const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root, {
148
+ [PRIVATE_KEY_VAR]: key.privateKey,
149
+ });
141
150
  if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
142
151
 
143
152
  // Keep only the shop's own keys: dotenvx echoes the public key back, and
@@ -217,6 +226,13 @@ async function initSecrets(project: Project): Promise<number> {
217
226
  console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
218
227
  return 1;
219
228
  }
229
+ // The PUBLIC key goes in the file, so `dotenvx encrypt` uses the derived
230
+ // pair rather than inventing one and writing a .env.keys.
231
+ const key = await keyFor(project.manifest, project.root);
232
+ if (!key.ok) {
233
+ console.error(`\nvc: ${key.reason}\n`);
234
+ return 1;
235
+ }
220
236
  const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
221
237
  const body = [
222
238
  "# Secrets, encrypted, and COMMITTED.",
@@ -225,20 +241,26 @@ async function initSecrets(project: Project): Promise<number> {
225
241
  "# secret changed without showing what it changed to. `unset` is the",
226
242
  "# documented placeholder and preflight refuses it.",
227
243
  "#",
228
- "# The private key stays out of the repository. It lives in .env.keys",
229
- "# locally (gitignored) and as one build variable where the deploy runs.",
244
+ "# The private key is DERIVED from CLOUDFLARE_API_TOKEN, salted with the",
245
+ "# account id it is stored nowhere, so there is no .env.keys to leak.",
246
+ "#",
247
+ "# The cost of that: rotating the token makes every value below",
248
+ "# permanently unreadable. `vc keys --rotate` re-encrypts while you still",
249
+ "# have the old token; after that there is no recovery.",
250
+ "",
251
+ `DOTENV_PUBLIC_KEY_SECRETS="${key.publicKey}"`,
230
252
  "",
231
253
  ...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""]),
232
254
  ].join("\n");
233
255
  writeFileSync(path, body, { mode: 0o600 });
234
256
 
235
257
  console.log(
236
- `\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`\n\n` +
258
+ `\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`, under the key this token derives\n\n` +
237
259
  `Next:\n` +
238
260
  ` 1. put the real values in, then\n` +
239
- ` 2. ${color.cyan(`dotenvx encrypt -f ${SECRETS_FILE}`)}\n` +
240
- ` 3. commit ${SECRETS_FILE}; never commit .env.keys\n` +
241
- ` 4. set ${PRIVATE_KEY_VAR} where the deploy runs\n`,
261
+ ` 2. ${color.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)}\n` +
262
+ ` 3. commit it there is no key file to keep out\n\n` +
263
+ color.yellow(`! Rotating CLOUDFLARE_API_TOKEN makes these unreadable. \`vc keys\` explains.\n`),
242
264
  );
243
265
  return 0;
244
266
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@dotenvx/primitives` ships no types. Only one function is used here, and
3
+ * declaring just that is better than an `any` import: the shape is asserted
4
+ * in one place a reader can check against the package.
5
+ */
6
+ declare module "@dotenvx/primitives" {
7
+ /** The secp256k1 public key for a private key, both as lowercase hex. */
8
+ export function derive(privateKey: string): string;
9
+ export function keypair(): { publicKey: string; privateKey: string };
10
+ }
@@ -320,6 +320,10 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
320
320
  scripts["deploy"] ??= "vc deploy";
321
321
  scripts["import:catalog"] ??= "vc import";
322
322
  scripts["maildev"] ??= "maildev --smtp 1025 --web 1080";
323
+ scripts["secrets"] ??= "vc secrets";
324
+ // husky installs the hook on `bun install`, so a fresh clone is
325
+ // guarded without anybody remembering to run anything.
326
+ scripts["prepare"] ??= "husky";
323
327
  pkg["scripts"] = scripts;
324
328
  },
325
329
  result,
@@ -336,6 +340,23 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
336
340
  "own",
337
341
  );
338
342
  await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
343
+ /**
344
+ * The hook lives in `.husky/`, which is COMMITTED, so the guard travels
345
+ * with the repository instead of being something each clone remembers to
346
+ * install. A secret committed in the clear cannot be un-committed.
347
+ */
348
+ await put(
349
+ root,
350
+ ".husky/pre-commit",
351
+ `#!/usr/bin/env sh
352
+ # Generated by \`vc init\`. Refuses a commit that would put a secret in the
353
+ # clear in .env.secrets — which cannot be undone by a later commit,
354
+ # because the value stays in the history.
355
+ bunx vc guard
356
+ `,
357
+ result,
358
+ "regenerate",
359
+ );
339
360
  await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
340
361
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
341
362
  await put(root, ".env", renderEnvLocal(manifest), result, "own");
@@ -66,6 +66,8 @@ export function strictDependencies(manifest: Manifest): {
66
66
  // The repository's secrets are encrypted with this; a build needs it to
67
67
  // hand them to wrangler.
68
68
  "@dotenvx/dotenvx",
69
+ // Installs the committed pre-commit hook on every fresh clone.
70
+ "husky",
69
71
  "@rolldown/plugin-babel",
70
72
  "@tailwindcss/vite",
71
73
  "@types/node",