@saastemly/voidcommerce 0.4.0 → 0.6.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 +269 -76
- package/dist/deploy/github.d.ts +85 -0
- package/dist/deploy/index.d.ts +3 -0
- package/dist/deploy/keys.d.ts +68 -25
- package/dist/deploy/link.d.ts +2 -0
- package/dist/deploy/secrets.d.ts +51 -8
- package/dist/generate/ci.d.ts +33 -18
- package/dist/generate/frontend.d.ts +1 -1
- package/dist/generate/index.d.ts +2 -0
- package/dist/index-2qt2yh3z.js +797 -0
- package/dist/{index-yzezvy5h.js → index-f1wds190.js} +322 -663
- package/dist/{index-pz6m2hkm.js → index-xx5p4b8d.js} +5 -4
- package/dist/index.js +12 -8
- package/dist/keys-qwvt324e.js +19 -0
- package/dist/manifest.d.ts +27 -5
- package/dist/manifest.js +3 -1
- package/package.json +1 -1
- package/src/cli.ts +14 -1
- package/src/deploy/cloudflare.ts +68 -4
- package/src/deploy/github.ts +198 -0
- package/src/deploy/index.ts +66 -18
- package/src/deploy/keys.ts +208 -144
- package/src/deploy/link.ts +185 -0
- package/src/deploy/secrets.ts +217 -35
- package/src/dotenvx-primitives.d.ts +4 -0
- package/src/generate/ci.ts +176 -121
- package/src/generate/frontend.ts +26 -4
- package/src/generate/index.ts +46 -8
- package/src/generate/support.ts +7 -5
- package/src/init.ts +2 -1
- package/src/manifest.ts +33 -8
- package/src/regenerate.ts +1 -0
- package/src/wizard.ts +7 -3
package/src/deploy/secrets.ts
CHANGED
|
@@ -4,7 +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 {
|
|
7
|
+
import { committedPublicKey, provisionKey } from "./keys";
|
|
8
8
|
import type { Project } from "../project";
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -20,10 +20,11 @@ import type { Project } from "../project";
|
|
|
20
20
|
*
|
|
21
21
|
* dotenvx closes it. `.env.secrets` holds ciphertext and a public key, and
|
|
22
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.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* diff shows when a secret changed without showing what it changed to.
|
|
24
|
+
*
|
|
25
|
+
* Because dotenvx is asymmetric, SETTING a secret needs only the committed
|
|
26
|
+
* public key — no credential, no login, no key file. The private key lives
|
|
27
|
+
* in one place, GitHub Actions, and only the deploy uses it. See `./keys.ts`.
|
|
27
28
|
*
|
|
28
29
|
* ── Why the values still become worker SECRETS ───────────────────────────
|
|
29
30
|
*
|
|
@@ -42,10 +43,9 @@ import type { Project } from "../project";
|
|
|
42
43
|
* in the history, including ones that were rotated — which is not true of
|
|
43
44
|
* `wrangler secret put`, where a rotation genuinely retires the old value.
|
|
44
45
|
*
|
|
45
|
-
* And
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* cannot recover values, only explain them.
|
|
46
|
+
* And GitHub will not hand a secret back once set, so re-keying means
|
|
47
|
+
* entering the values again. That is the right trade: a key worth rotating
|
|
48
|
+
* is a key that may have leaked, and the values then need replacing anyway.
|
|
49
49
|
*
|
|
50
50
|
* Both are the price of a shop that rebuilds from a checkout, and both
|
|
51
51
|
* should be decisions rather than surprises.
|
|
@@ -72,7 +72,7 @@ export function findDotenvx(from: string): string | null {
|
|
|
72
72
|
: null;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
function run(cmd: string, args: string[], cwd: string, env: Record<string, string> = {}): Promise<{ code: number; out: string }> {
|
|
75
|
+
export function run(cmd: string, args: string[], cwd: string, env: Record<string, string> = {}): Promise<{ code: number; out: string }> {
|
|
76
76
|
return new Promise((resolve) => {
|
|
77
77
|
const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
|
|
78
78
|
let out = "";
|
|
@@ -87,6 +87,28 @@ function run(cmd: string, args: string[], cwd: string, env: Record<string, strin
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Put a public key at the top of `.env.secrets`, creating the file if it is
|
|
92
|
+
* not there yet.
|
|
93
|
+
*
|
|
94
|
+
* Only the public half is ever written to disk, and it is meant to be
|
|
95
|
+
* committed: it is what lets anyone with a clone SET a secret without
|
|
96
|
+
* holding a credential.
|
|
97
|
+
*/
|
|
98
|
+
export function committedPublicKeyInto(root: string, publicKey: string): void {
|
|
99
|
+
const path = join(root, SECRETS_FILE);
|
|
100
|
+
const line = `DOTENV_PUBLIC_KEY_SECRETS="${publicKey}"`;
|
|
101
|
+
if (!existsSync(path)) {
|
|
102
|
+
writeFileSync(path, `${line}\n`, { mode: 0o600 });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const body = readFileSync(path, "utf8");
|
|
106
|
+
writeFileSync(
|
|
107
|
+
path,
|
|
108
|
+
/^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=.*$/m.test(body) ? body.replace(/^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=.*$/m, line) : `${line}\n${body}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
90
112
|
/**
|
|
91
113
|
* The names a `.env.secrets` declares, read WITHOUT decrypting.
|
|
92
114
|
*
|
|
@@ -136,16 +158,41 @@ export async function decryptSecrets(project: Project): Promise<DecryptedSecrets
|
|
|
136
158
|
const root = project.root;
|
|
137
159
|
if (!existsSync(join(root, SECRETS_FILE))) return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
|
|
138
160
|
|
|
161
|
+
// The private key comes from the environment, which in the normal case is
|
|
162
|
+
// GitHub Actions injecting the repository secret. A laptop has no reason
|
|
163
|
+
// to hold it, so a missing one is explained rather than treated as a fault.
|
|
164
|
+
const privateKey = process.env[PRIVATE_KEY_VAR];
|
|
165
|
+
if (!privateKey) {
|
|
166
|
+
return {
|
|
167
|
+
error:
|
|
168
|
+
`${PRIVATE_KEY_VAR} is not set, so ${SECRETS_FILE} cannot be decrypted here.\n` +
|
|
169
|
+
` That is the normal state on a laptop: the key lives in GitHub Actions and\n` +
|
|
170
|
+
` only the deploy uses it. Push, and the workflow does this step.\n` +
|
|
171
|
+
` To deploy by hand anyway, export the key for one command.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const expected = committedPublicKey(root);
|
|
175
|
+
if (expected) {
|
|
176
|
+
const { publicKeyFor } = await import("./keys");
|
|
177
|
+
const derived = await publicKeyFor(privateKey);
|
|
178
|
+
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
179
|
+
return {
|
|
180
|
+
error:
|
|
181
|
+
`${PRIVATE_KEY_VAR} does not open this repository.\n` +
|
|
182
|
+
` it derives: ${derived.slice(0, 16)}…\n` +
|
|
183
|
+
` encrypted under: ${expected.slice(0, 16)}…\n` +
|
|
184
|
+
` It belongs to a different shop, or the repository has been re-keyed since.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Last, deliberately: "that key belongs to another shop" is more use than
|
|
190
|
+
// "install dotenvx" when both are true.
|
|
139
191
|
const dotenvx = findDotenvx(root);
|
|
140
192
|
if (!dotenvx) return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
|
|
141
193
|
|
|
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
194
|
const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root, {
|
|
148
|
-
[PRIVATE_KEY_VAR]:
|
|
195
|
+
[PRIVATE_KEY_VAR]: privateKey,
|
|
149
196
|
});
|
|
150
197
|
if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
|
|
151
198
|
|
|
@@ -182,6 +229,7 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
|
|
|
182
229
|
const bare = plaintextSecretNames(root);
|
|
183
230
|
|
|
184
231
|
if (args.includes("--init")) return initSecrets(project);
|
|
232
|
+
if (args[0] === "set") return setSecretValue(project, args.slice(1));
|
|
185
233
|
|
|
186
234
|
if (!existsSync(join(root, SECRETS_FILE))) {
|
|
187
235
|
console.log(
|
|
@@ -220,17 +268,26 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
|
|
|
220
268
|
}
|
|
221
269
|
|
|
222
270
|
/** Write a `.env.secrets` with every required key as the `unset` sentinel. */
|
|
223
|
-
async function initSecrets(project: Project): Promise<number> {
|
|
271
|
+
export async function initSecrets(project: Project): Promise<number> {
|
|
224
272
|
const path = join(project.root, SECRETS_FILE);
|
|
225
|
-
|
|
226
|
-
|
|
273
|
+
|
|
274
|
+
// `vc link` leaves a file holding nothing but the public key, so "exists"
|
|
275
|
+
// is not the same as "is already set up". Refusing on existence alone
|
|
276
|
+
// stranded anyone who linked first, which is the order the docs recommend.
|
|
277
|
+
if (existsSync(path) && declaredSecretNames(project.root).size > 0) {
|
|
278
|
+
console.error(`vc: ${SECRETS_FILE} already declares secrets; not overwriting it.\n \`vc secrets set KEY\` changes one.`);
|
|
227
279
|
return 1;
|
|
228
280
|
}
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
281
|
+
|
|
282
|
+
// The PUBLIC key goes in the file so encryption uses this shop's pair
|
|
283
|
+
// rather than inventing one and writing a .env.keys nobody asked for. Its
|
|
284
|
+
// private half goes straight to GitHub and is never seen again — so an
|
|
285
|
+
// existing key is REUSED, never replaced: replacing it would orphan
|
|
286
|
+
// whatever it has already encrypted.
|
|
287
|
+
const existing = committedPublicKey(project.root);
|
|
288
|
+
const made = existing ? ({ ok: true, publicKey: existing } as const) : await provisionKey(project);
|
|
289
|
+
if (!made.ok) {
|
|
290
|
+
console.error(`\nvc: ${made.reason}\n`);
|
|
234
291
|
return 1;
|
|
235
292
|
}
|
|
236
293
|
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
@@ -241,26 +298,151 @@ async function initSecrets(project: Project): Promise<number> {
|
|
|
241
298
|
"# secret changed without showing what it changed to. `unset` is the",
|
|
242
299
|
"# documented placeholder and preflight refuses it.",
|
|
243
300
|
"#",
|
|
244
|
-
"#
|
|
245
|
-
"#
|
|
301
|
+
"# Setting a value needs NO credential: dotenvx encrypts with the public key",
|
|
302
|
+
"# below, which is right here in the repository. Only the deploy decrypts,",
|
|
303
|
+
"# and the private key for that lives in GitHub Actions.",
|
|
246
304
|
"#",
|
|
247
|
-
"#
|
|
248
|
-
"#
|
|
249
|
-
"# have the old token; after that there is no recovery.",
|
|
305
|
+
"# `vc secrets set KEY` is the way to change one — it reads the value from",
|
|
306
|
+
"# the terminal, so it never reaches a command line where `ps` would show it.",
|
|
250
307
|
"",
|
|
251
|
-
`DOTENV_PUBLIC_KEY_SECRETS="${
|
|
308
|
+
`DOTENV_PUBLIC_KEY_SECRETS="${made.publicKey}"`,
|
|
252
309
|
"",
|
|
253
310
|
...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""]),
|
|
254
311
|
].join("\n");
|
|
255
312
|
writeFileSync(path, body, { mode: 0o600 });
|
|
256
313
|
|
|
257
314
|
console.log(
|
|
258
|
-
`\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset
|
|
315
|
+
`\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`\n` +
|
|
316
|
+
`${existing ? color.dim(`= ${PRIVATE_KEY_VAR} was already on GitHub; reused it`) : `${color.green("+")} ${PRIVATE_KEY_VAR} stored on GitHub; the public key is in the file`}\n\n` +
|
|
259
317
|
`Next:\n` +
|
|
260
|
-
`
|
|
261
|
-
`
|
|
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`),
|
|
318
|
+
`${required.map((key) => ` ${color.cyan(`vc secrets set ${key.key}`)}`).join("\n")}\n\n` +
|
|
319
|
+
`then commit ${SECRETS_FILE}. There is no key file to keep out of git.\n`,
|
|
264
320
|
);
|
|
265
321
|
return 0;
|
|
266
322
|
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* `vc secrets set KEY` — change one value, with no credential of any kind.
|
|
327
|
+
*
|
|
328
|
+
* This is the command the whole design exists to make possible. dotenvx
|
|
329
|
+
* encrypts with the public key sitting in the committed file, so a
|
|
330
|
+
* contributor with a clone and nothing else can rotate the Stripe key. They
|
|
331
|
+
* cannot read the one that is there, which is exactly right.
|
|
332
|
+
*
|
|
333
|
+
* The value is read from the terminal or from stdin, never from argv: an
|
|
334
|
+
* argument is visible in `ps` to every process on the machine while the
|
|
335
|
+
* command runs, and shells keep it in history besides.
|
|
336
|
+
*/
|
|
337
|
+
export async function setSecretValue(project: Project, args: string[]): Promise<number> {
|
|
338
|
+
const root = project.root;
|
|
339
|
+
const name = args.find((arg) => !arg.startsWith("-"));
|
|
340
|
+
if (!name) {
|
|
341
|
+
console.error(`\nvc: which secret? \`vc secrets set STRIPE_SECRET_KEY\`\n`);
|
|
342
|
+
return 1;
|
|
343
|
+
}
|
|
344
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
345
|
+
console.error(`\nvc: "${name}" is not a key name — they are SHOUTING_SNAKE_CASE.\n`);
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
if (!existsSync(join(root, SECRETS_FILE))) {
|
|
349
|
+
console.error(`\nvc: no ${SECRETS_FILE} yet. \`vc secrets --init\` writes one.\n`);
|
|
350
|
+
return 1;
|
|
351
|
+
}
|
|
352
|
+
const publicKey = committedPublicKey(root);
|
|
353
|
+
if (!publicKey) {
|
|
354
|
+
console.error(`\nvc: ${SECRETS_FILE} has no DOTENV_PUBLIC_KEY_SECRETS, so there is nothing to encrypt to.\n \`vc keys --init\` makes a key and puts the public half here.\n`);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// A known key gets its consequence shown, so a person setting one knows
|
|
359
|
+
// what it is for without leaving the terminal.
|
|
360
|
+
const known = allEnvKeys(project.manifest).find((key) => key.key === name);
|
|
361
|
+
const value = await readValue(name, known?.breaks, known?.where);
|
|
362
|
+
if (value === null) return 1;
|
|
363
|
+
|
|
364
|
+
const written = await encryptInto(root, publicKey, name, value);
|
|
365
|
+
if (!written.ok) {
|
|
366
|
+
console.error(`\nvc: ${written.error}\n`);
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
const stillBare = plaintextSecretNames(root).includes(name);
|
|
370
|
+
if (stillBare) {
|
|
371
|
+
console.error(`\n${color.red("✗")} ${name} was written in the CLEAR — the public key did not encrypt it.\n Do not commit. Check DOTENV_PUBLIC_KEY_SECRETS in ${SECRETS_FILE}.\n`);
|
|
372
|
+
return 1;
|
|
373
|
+
}
|
|
374
|
+
console.log(`\n${color.green("✓")} ${name} encrypted into ${SECRETS_FILE}. Commit it.\n`);
|
|
375
|
+
return 0;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The value, from a masked prompt or from a pipe. Never from argv. */
|
|
379
|
+
async function readValue(name: string, breaks?: string, where?: string): Promise<string | null> {
|
|
380
|
+
if (!process.stdin.isTTY) {
|
|
381
|
+
const chunks: Buffer[] = [];
|
|
382
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
383
|
+
const piped = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
384
|
+
if (!piped) {
|
|
385
|
+
console.error(`\nvc: nothing on stdin to set ${name} to.\n`);
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
return piped;
|
|
389
|
+
}
|
|
390
|
+
const p = await import("@clack/prompts");
|
|
391
|
+
if (breaks) console.log(`\n${color.dim(breaks)}${where ? color.dim(` — from: ${where}`) : ""}`);
|
|
392
|
+
const answer = await p.password({
|
|
393
|
+
message: name,
|
|
394
|
+
validate: (input) => (input && input.length > 0 ? undefined : "a secret with no value is not a secret"),
|
|
395
|
+
});
|
|
396
|
+
if (p.isCancel(answer)) {
|
|
397
|
+
console.log(color.dim("\ncancelled; nothing changed.\n"));
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
return String(answer);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Encrypt one value into `.env.secrets`, in process.
|
|
406
|
+
*
|
|
407
|
+
* Deliberately NOT `dotenvx set NAME value -f …`: that takes the value as an
|
|
408
|
+
* argument, and an argument is visible in `ps` to every other process on the
|
|
409
|
+
* machine for as long as the call runs. This uses the same secp256k1 the CLI
|
|
410
|
+
* uses — it is the CLI's own library — so what it writes is byte-compatible
|
|
411
|
+
* with what `dotenvx decrypt` expects.
|
|
412
|
+
*/
|
|
413
|
+
export async function encryptInto(root: string, publicKey: string, name: string, value: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
414
|
+
const { encrypt } = await import("@dotenvx/primitives");
|
|
415
|
+
let ciphertext: string;
|
|
416
|
+
try {
|
|
417
|
+
ciphertext = encrypt(publicKey, value);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
return { ok: false, error: `could not encrypt to the key in ${SECRETS_FILE}: ${String(error)}` };
|
|
420
|
+
}
|
|
421
|
+
// A belt-and-braces check on someone else's library, because the failure
|
|
422
|
+
// it guards against is writing a secret to a committed file in the clear.
|
|
423
|
+
//
|
|
424
|
+
// The containment half is gated on length on purpose: base64 output will
|
|
425
|
+
// contain almost any short string by chance, so checking it for a
|
|
426
|
+
// two-character value rejects perfectly good ciphertext. Twelve is above
|
|
427
|
+
// the length at which a coincidental match stops being plausible.
|
|
428
|
+
const looksWrong = !ciphertext.startsWith("encrypted:") || ciphertext === value || (value.length >= 12 && ciphertext.includes(value));
|
|
429
|
+
if (looksWrong) {
|
|
430
|
+
return { ok: false, error: `refusing to write ${name} — the result does not look encrypted.` };
|
|
431
|
+
}
|
|
432
|
+
upsertLine(join(root, SECRETS_FILE), name, ciphertext);
|
|
433
|
+
return { ok: true };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Replace `NAME=…` in a dotenv file, or add it if it is not there.
|
|
438
|
+
*
|
|
439
|
+
* Written by hand rather than with a parser because everything else in the
|
|
440
|
+
* file — the comments explaining what each secret breaks, the order — is
|
|
441
|
+
* meant for a person to read, and a round-trip through a parser would
|
|
442
|
+
* flatten all of it.
|
|
443
|
+
*/
|
|
444
|
+
function upsertLine(path: string, name: string, value: string): void {
|
|
445
|
+
const body = readFileSync(path, "utf8");
|
|
446
|
+
const pattern = new RegExp(`^\\s*${name}\\s*=.*$`, "m");
|
|
447
|
+
writeFileSync(path, pattern.test(body) ? body.replace(pattern, `${name}=${value}`) : `${body.replace(/\n*$/, "\n")}${name}=${value}\n`);
|
|
448
|
+
}
|
|
@@ -7,4 +7,8 @@ declare module "@dotenvx/primitives" {
|
|
|
7
7
|
/** The secp256k1 public key for a private key, both as lowercase hex. */
|
|
8
8
|
export function derive(privateKey: string): string;
|
|
9
9
|
export function keypair(): { publicKey: string; privateKey: string };
|
|
10
|
+
/** Ciphertext for a value, given only the PUBLIC key. Note the argument order. */
|
|
11
|
+
export function encrypt(publicKey: string, value: string): string;
|
|
12
|
+
/** The value back, given the private key. Throws when the key is the wrong one. */
|
|
13
|
+
export function decrypt(privateKey: string, ciphertext: string): string;
|
|
10
14
|
}
|