@saastemly/voidcommerce 0.2.2 → 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/catalog.js +2 -1
- package/dist/cli.js +87 -3
- package/dist/deploy/cloudflare.d.ts +3 -1
- package/dist/deploy/index.d.ts +12 -0
- package/dist/deploy/keys.d.ts +34 -0
- package/dist/deploy/preflight.d.ts +2 -0
- package/dist/deploy/secrets.d.ts +74 -0
- package/dist/index-0v6na3yp.js +35 -0
- package/dist/{index-z4qazajn.js → index-844b3qn9.js} +1 -4
- package/dist/index-mpr7gm6k.js +4597 -0
- package/dist/{index-zh3tmj08.js → index-pz6m2hkm.js} +1 -1
- package/dist/{index-sbchkpa6.js → index-yzezvy5h.js} +476 -77
- package/dist/index.js +4 -3
- package/dist/manifest.js +3 -2
- package/package.json +2 -1
- package/src/cli.ts +5 -1
- package/src/deploy/cloudflare.ts +25 -3
- package/src/deploy/index.ts +88 -0
- package/src/deploy/keys.ts +212 -0
- package/src/deploy/preflight.ts +38 -3
- package/src/deploy/secrets.ts +266 -0
- package/src/dist.ts +3 -1
- package/src/dotenvx-primitives.d.ts +10 -0
- package/src/generate/ci.ts +30 -5
- package/src/generate/index.ts +30 -1
- package/src/generate/strict.ts +5 -0
- package/src/help.ts +1 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { delimiter, dirname, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import color from "picocolors";
|
|
6
|
+
import { allEnvKeys } from "../generate/env";
|
|
7
|
+
import { keyFor } from "./keys";
|
|
8
|
+
import type { Project } from "../project";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Secrets that live in the repository, encrypted.
|
|
12
|
+
*
|
|
13
|
+
* ── The problem this solves ──────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* A shop's secrets were set by hand, one `wrangler secret put` at a time,
|
|
16
|
+
* on a machine that happened to have them. Nothing in the repository said
|
|
17
|
+
* what the values WERE, only what they were called, so the shop could not
|
|
18
|
+
* be rebuilt from a checkout and a new deploy target needed somebody to
|
|
19
|
+
* remember. That is the last manual step between "push" and "live".
|
|
20
|
+
*
|
|
21
|
+
* dotenvx closes it. `.env.secrets` holds ciphertext and a public key, and
|
|
22
|
+
* it is COMMITTED: the key names are readable, the values are not, and a
|
|
23
|
+
* diff shows when a secret changed without showing what it changed to. The
|
|
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.
|
|
27
|
+
*
|
|
28
|
+
* ── Why the values still become worker SECRETS ───────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* The lazy version of this would decrypt at build time and let the values
|
|
31
|
+
* land in the worker's `vars`. That undoes the thing we built the scrub
|
|
32
|
+
* step for: `vars` are plaintext in the deployed configuration and readable
|
|
33
|
+
* by anyone with dashboard access. So the plaintext exists only inside the
|
|
34
|
+
* build, in a temp file, and goes up through `wrangler deploy
|
|
35
|
+
* --secrets-file`, which stores them as real secrets and — importantly —
|
|
36
|
+
* applies ADDITIVELY, so a secret this file does not name is left alone
|
|
37
|
+
* rather than deleted.
|
|
38
|
+
*
|
|
39
|
+
* ── The tradeoffs, stated ────────────────────────────────────────────────
|
|
40
|
+
*
|
|
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.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
export const SECRETS_FILE = ".env.secrets";
|
|
55
|
+
export const PRIVATE_KEY_VAR = "DOTENV_PRIVATE_KEY_SECRETS";
|
|
56
|
+
|
|
57
|
+
/** dotenvx, from the project's own install rather than a global one. */
|
|
58
|
+
export function findDotenvx(from: string): string | null {
|
|
59
|
+
let dir = from;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const local = join(dir, "node_modules", ".bin", "dotenvx");
|
|
62
|
+
if (existsSync(local)) return local;
|
|
63
|
+
const parent = dirname(dir);
|
|
64
|
+
if (parent === dir) break;
|
|
65
|
+
dir = parent;
|
|
66
|
+
}
|
|
67
|
+
return (process.env["PATH"] ?? "")
|
|
68
|
+
.split(delimiter)
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.some((entry) => existsSync(join(entry, "dotenvx")))
|
|
71
|
+
? "dotenvx"
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function run(cmd: string, args: string[], cwd: string, env: Record<string, string> = {}): Promise<{ code: number; out: string }> {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
|
|
78
|
+
let out = "";
|
|
79
|
+
child.stdout.on("data", (chunk) => {
|
|
80
|
+
out += chunk;
|
|
81
|
+
});
|
|
82
|
+
child.stderr.on("data", (chunk) => {
|
|
83
|
+
out += chunk;
|
|
84
|
+
});
|
|
85
|
+
child.on("error", (error) => resolve({ code: 1, out: String(error) }));
|
|
86
|
+
child.on("exit", (code) => resolve({ code: code ?? 1, out }));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The names a `.env.secrets` declares, read WITHOUT decrypting.
|
|
92
|
+
*
|
|
93
|
+
* dotenvx leaves keys in plaintext and encrypts only values, so preflight
|
|
94
|
+
* can say "all five are declared" on a machine that cannot read any of them.
|
|
95
|
+
*/
|
|
96
|
+
export function declaredSecretNames(root: string): Set<string> {
|
|
97
|
+
const path = join(root, SECRETS_FILE);
|
|
98
|
+
if (!existsSync(path)) return new Set();
|
|
99
|
+
const names = new Set<string>();
|
|
100
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
101
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
|
|
102
|
+
if (match && !match[1]!.startsWith("DOTENV_")) names.add(match[1]!);
|
|
103
|
+
}
|
|
104
|
+
return names;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Is a declared value actually encrypted, or was it committed in the clear? */
|
|
108
|
+
export function plaintextSecretNames(root: string): string[] {
|
|
109
|
+
const path = join(root, SECRETS_FILE);
|
|
110
|
+
if (!existsSync(path)) return [];
|
|
111
|
+
const bare: string[] = [];
|
|
112
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
113
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
114
|
+
if (!match || match[1]!.startsWith("DOTENV_")) continue;
|
|
115
|
+
const value = match[2]!.trim().replace(/^['"]|['"]$/g, "");
|
|
116
|
+
// `unset` is the documented placeholder, not a leak.
|
|
117
|
+
if (value && value !== "unset" && !value.startsWith("encrypted:")) bare.push(match[1]!);
|
|
118
|
+
}
|
|
119
|
+
return bare;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface DecryptedSecrets {
|
|
123
|
+
/** A temp file in `.env` format, for `wrangler deploy --secrets-file`. */
|
|
124
|
+
path: string;
|
|
125
|
+
names: string[];
|
|
126
|
+
cleanup: () => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decrypt into a temp file the deploy can hand to wrangler.
|
|
131
|
+
*
|
|
132
|
+
* Written to disk rather than piped because that is what `--secrets-file`
|
|
133
|
+
* takes, and removed by `cleanup()` whatever happens next.
|
|
134
|
+
*/
|
|
135
|
+
export async function decryptSecrets(project: Project): Promise<DecryptedSecrets | { error: string }> {
|
|
136
|
+
const root = project.root;
|
|
137
|
+
if (!existsSync(join(root, SECRETS_FILE))) return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
|
|
138
|
+
|
|
139
|
+
const dotenvx = findDotenvx(root);
|
|
140
|
+
if (!dotenvx) return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
|
|
141
|
+
|
|
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
|
+
});
|
|
150
|
+
if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
|
|
151
|
+
|
|
152
|
+
// Keep only the shop's own keys: dotenvx echoes the public key back, and
|
|
153
|
+
// it has no business being a worker secret.
|
|
154
|
+
const lines = result.out
|
|
155
|
+
.split("\n")
|
|
156
|
+
.filter((line) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) && !line.trimStart().startsWith("DOTENV_"));
|
|
157
|
+
const names = lines.map((line) => line.slice(0, line.indexOf("=")).trim());
|
|
158
|
+
|
|
159
|
+
const path = join(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
|
|
160
|
+
writeFileSync(path, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
161
|
+
return {
|
|
162
|
+
path,
|
|
163
|
+
names,
|
|
164
|
+
cleanup: () => {
|
|
165
|
+
try {
|
|
166
|
+
writeFileSync(path, "", { mode: 0o600 });
|
|
167
|
+
// Overwrite before unlinking: the plaintext existed on disk and
|
|
168
|
+
// should stop existing there promptly.
|
|
169
|
+
require("node:fs").unlinkSync(path);
|
|
170
|
+
} catch {
|
|
171
|
+
// Already gone, or never written. Nothing to do.
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `vc secrets` — what the repository declares, and whether it is readable here. */
|
|
178
|
+
export async function secretsCommand(project: Project, args: string[]): Promise<number> {
|
|
179
|
+
const root = project.root;
|
|
180
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
181
|
+
const declared = declaredSecretNames(root);
|
|
182
|
+
const bare = plaintextSecretNames(root);
|
|
183
|
+
|
|
184
|
+
if (args.includes("--init")) return initSecrets(project);
|
|
185
|
+
|
|
186
|
+
if (!existsSync(join(root, SECRETS_FILE))) {
|
|
187
|
+
console.log(
|
|
188
|
+
`\n${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,\n` +
|
|
189
|
+
`which means the shop cannot be rebuilt from a checkout.\n\n` +
|
|
190
|
+
` vc secrets --init write one, with every required key as \`unset\`\n`,
|
|
191
|
+
);
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log(`\n${SECRETS_FILE} — committed, encrypted, and read by \`vc deploy --cloudflare\`\n`);
|
|
196
|
+
for (const key of required) {
|
|
197
|
+
const state = declared.has(key.key) ? color.green("declared") : color.red("MISSING ");
|
|
198
|
+
console.log(` ${state} ${key.key}`);
|
|
199
|
+
}
|
|
200
|
+
const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
|
|
201
|
+
for (const name of extra) console.log(` ${color.dim("extra ")} ${name} ${color.dim("— not required by this shop")}`);
|
|
202
|
+
|
|
203
|
+
if (bare.length > 0) {
|
|
204
|
+
console.log(
|
|
205
|
+
`\n${color.red("✗")} ${bare.length} value${bare.length === 1 ? " is" : "s are"} committed IN THE CLEAR: ${bare.join(", ")}\n` +
|
|
206
|
+
` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` before committing again.\n`,
|
|
207
|
+
);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const missing = required.filter((key) => !declared.has(key.key));
|
|
211
|
+
if (missing.length > 0) {
|
|
212
|
+
console.log(
|
|
213
|
+
`\n${color.red("✗")} ${missing.length} required secret${missing.length === 1 ? "" : "s"} not declared.\n` +
|
|
214
|
+
` dotenvx set ${missing[0]!.key} '…' -f ${SECRETS_FILE}\n`,
|
|
215
|
+
);
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
console.log(`\n${color.green("✓")} every secret this shop needs is declared and encrypted.\n`);
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Write a `.env.secrets` with every required key as the `unset` sentinel. */
|
|
223
|
+
async function initSecrets(project: Project): Promise<number> {
|
|
224
|
+
const path = join(project.root, SECRETS_FILE);
|
|
225
|
+
if (existsSync(path)) {
|
|
226
|
+
console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
|
|
227
|
+
return 1;
|
|
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
|
+
}
|
|
236
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
237
|
+
const body = [
|
|
238
|
+
"# Secrets, encrypted, and COMMITTED.",
|
|
239
|
+
"#",
|
|
240
|
+
"# Values are ciphertext; the key names are readable so a diff shows WHICH",
|
|
241
|
+
"# secret changed without showing what it changed to. `unset` is the",
|
|
242
|
+
"# documented placeholder and preflight refuses it.",
|
|
243
|
+
"#",
|
|
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}"`,
|
|
252
|
+
"",
|
|
253
|
+
...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""]),
|
|
254
|
+
].join("\n");
|
|
255
|
+
writeFileSync(path, body, { mode: 0o600 });
|
|
256
|
+
|
|
257
|
+
console.log(
|
|
258
|
+
`\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`, under the key this token derives\n\n` +
|
|
259
|
+
`Next:\n` +
|
|
260
|
+
` 1. put the real values in, then\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`),
|
|
264
|
+
);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
package/src/dist.ts
CHANGED
|
@@ -120,7 +120,9 @@ export async function distCommand(args: string[]): Promise<number> {
|
|
|
120
120
|
|
|
121
121
|
// The patches the pins refer to, and the lockfile, so the install is the
|
|
122
122
|
// one that was tested rather than whatever resolves today.
|
|
123
|
-
|
|
123
|
+
// The encrypted secrets travel with the app: they are ciphertext, and the
|
|
124
|
+
// branch Cloudflare builds is where the deploy command reads them.
|
|
125
|
+
for (const file of ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml", ".env.secrets"]) {
|
|
124
126
|
const source = join(project.root, file);
|
|
125
127
|
if (existsSync(source)) await cp(source, join(target, file));
|
|
126
128
|
}
|
|
@@ -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
|
+
}
|
package/src/generate/ci.ts
CHANGED
|
@@ -137,13 +137,27 @@ and \`voidcommerce.json\` so every later generate carries the real ids.
|
|
|
137
137
|
|
|
138
138
|
### 3. The secrets
|
|
139
139
|
|
|
140
|
+
They live in the repository, encrypted:
|
|
141
|
+
|
|
140
142
|
\`\`\`sh
|
|
141
|
-
vc
|
|
143
|
+
vc secrets --init # every required key, as \`unset\`
|
|
144
|
+
# put the real values in, then
|
|
145
|
+
bunx dotenvx encrypt -f .env.secrets # ciphertext; commit this
|
|
142
146
|
\`\`\`
|
|
143
147
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
148
|
+
\`.env.secrets\` is committed and \`.env.keys\` is not. The deploy decrypts it and
|
|
149
|
+
hands the values to \`wrangler deploy --secrets-file\`, which stores them as
|
|
150
|
+
real Worker secrets — not as plaintext \`vars\`, which anyone with dashboard
|
|
151
|
+
access can read.
|
|
152
|
+
|
|
153
|
+
That turns one \`wrangler secret put\` per value into one build variable, set
|
|
154
|
+
once in step 5. It also means the shop rebuilds from a checkout.
|
|
155
|
+
|
|
156
|
+
**Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
|
|
157
|
+
leaks, every secret in the history is readable, including ones you rotated.
|
|
158
|
+
\`wrangler secret put\` does not have that property, and stays available for
|
|
159
|
+
anything you would rather never commit. \`vc preflight\` counts a secret the
|
|
160
|
+
repository declares as present, and refuses any value committed in the clear.
|
|
147
161
|
|
|
148
162
|
### 4. An API token the build can use
|
|
149
163
|
|
|
@@ -178,9 +192,20 @@ the \`wrangler.jsonc\` at the root directory, or the build fails.
|
|
|
178
192
|
| branch | \`${DIST_BRANCH}\` |
|
|
179
193
|
| root directory | \`/\` |
|
|
180
194
|
| build command | \`bun install && bunx void prepare && bunx vp build\` |
|
|
181
|
-
| deploy command |
|
|
195
|
+
| deploy command | see below |
|
|
196
|
+
| build variable | \`DOTENV_PRIVATE_KEY_SECRETS\`, marked as a secret — the one value that is not in the repository |
|
|
182
197
|
| API token | the one from step 4 |
|
|
183
198
|
|
|
199
|
+
The deploy command, on one line:
|
|
200
|
+
|
|
201
|
+
\`\`\`sh
|
|
202
|
+
bunx dotenvx decrypt -f .env.secrets --stdout > .vc-secrets.env && bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/ssr/wrangler.json --secrets-file .vc-secrets.env
|
|
203
|
+
\`\`\`
|
|
204
|
+
|
|
205
|
+
Plain \`sh\`, so it does not rely on process substitution. The decrypted file
|
|
206
|
+
exists only inside the build sandbox, and \`--secrets-file\` applies additively:
|
|
207
|
+
a secret it does not name is left alone rather than deleted.
|
|
208
|
+
|
|
184
209
|
The migration command names the **binding** (\`DB\`), not the database, so it
|
|
185
210
|
still points at the right database if the name ever differs.
|
|
186
211
|
|
package/src/generate/index.ts
CHANGED
|
@@ -320,14 +320,43 @@ 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,
|
|
326
330
|
);
|
|
327
331
|
// `.void` and `dist` are the STOREFRONT's build artifacts at the root now;
|
|
328
332
|
// `.vc` holds the generated worker and its own.
|
|
329
|
-
|
|
333
|
+
// `.env.keys` is the ONE thing that must never be committed: it is the
|
|
334
|
+
// authority to decrypt everything in `.env.secrets`, which is.
|
|
335
|
+
await put(
|
|
336
|
+
root,
|
|
337
|
+
".gitignore",
|
|
338
|
+
"node_modules\n.vc\n.void\n.wrangler\n.env\n.env.local\n.env.keys\ndist\n*.tsbuildinfo\n.DS_Store\n",
|
|
339
|
+
result,
|
|
340
|
+
"own",
|
|
341
|
+
);
|
|
330
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
|
+
);
|
|
331
360
|
await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
|
|
332
361
|
await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
|
|
333
362
|
await put(root, ".env", renderEnvLocal(manifest), result, "own");
|
package/src/generate/strict.ts
CHANGED
|
@@ -63,6 +63,11 @@ export function strictDependencies(manifest: Manifest): {
|
|
|
63
63
|
// The storefront at the root prerenders on the node target, whose SSR
|
|
64
64
|
// bundle imports this. Absent, a cold install builds nothing.
|
|
65
65
|
"@hono/node-server",
|
|
66
|
+
// The repository's secrets are encrypted with this; a build needs it to
|
|
67
|
+
// hand them to wrangler.
|
|
68
|
+
"@dotenvx/dotenvx",
|
|
69
|
+
// Installs the committed pre-commit hook on every fresh clone.
|
|
70
|
+
"husky",
|
|
66
71
|
"@rolldown/plugin-babel",
|
|
67
72
|
"@tailwindcss/vite",
|
|
68
73
|
"@types/node",
|
package/src/help.ts
CHANGED
|
@@ -17,6 +17,7 @@ export const EXTENDED_ROWS: Array<[command: string, summary: string]> = [
|
|
|
17
17
|
["vc dev | build | preview", "the app's own script, run where the app is — api/ for a monorepo, .vc/app for strict"],
|
|
18
18
|
["vc dist", "the deployable app as a self-contained tree — what the void-dist branch carries"],
|
|
19
19
|
["vc import", "push data/ and content/ into the running shop, as the shop itself — an upsert, safe on every deploy"],
|
|
20
|
+
["vc secrets", "the shop's secrets, encrypted and committed — one key where the deploy runs, instead of one wrangler secret put per value"],
|
|
20
21
|
["vc preflight", "is the shop ready to advertise on? every required key, and what breaks without it"],
|
|
21
22
|
["vc deploy", "preflight, then void deploy; or --cloudflare: wrangler, your own account, no Void login"],
|
|
22
23
|
];
|