@saastemly/voidcommerce 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +96 -27
- package/dist/deploy/index.d.ts +18 -1
- package/dist/deploy/keys.d.ts +16 -0
- package/dist/deploy/secrets.d.ts +11 -0
- package/dist/{index-6w8rwtsh.js → index-j5xjh6qp.js} +113 -22
- package/dist/{index-85m0qkmg.js → index-y6qzjd9h.js} +13 -5
- package/dist/index.js +2 -2
- package/dist/{keys-92tcdjrc.js → keys-3zjezaxk.js} +8 -2
- package/package.json +1 -1
- package/src/deploy/index.ts +90 -20
- package/src/deploy/keys.ts +78 -24
- package/src/deploy/link.ts +26 -7
- package/src/deploy/secrets.ts +68 -0
- package/src/generate/index.ts +11 -3
package/dist/cli.js
CHANGED
|
@@ -21,28 +21,36 @@ import {
|
|
|
21
21
|
runVoid,
|
|
22
22
|
version,
|
|
23
23
|
voidAppsIn
|
|
24
|
-
} from "./index-
|
|
24
|
+
} from "./index-y6qzjd9h.js";
|
|
25
25
|
import {
|
|
26
|
+
LOCAL_KEY_FILE,
|
|
26
27
|
PRIVATE_KEY_VAR,
|
|
27
28
|
SECRETS_FILE,
|
|
28
29
|
cloudflareAccounts,
|
|
30
|
+
committedPublicKey,
|
|
29
31
|
committedPublicKeyInto,
|
|
30
32
|
declaredSecretNames,
|
|
33
|
+
encryptInto,
|
|
31
34
|
envSummary,
|
|
32
35
|
ghAuth,
|
|
36
|
+
ignoresKeyFile,
|
|
33
37
|
initSecrets,
|
|
34
38
|
keyState,
|
|
35
39
|
keysCommand,
|
|
40
|
+
localPrivateKey,
|
|
41
|
+
plaintextSecretEntries,
|
|
36
42
|
plaintextSecretNames,
|
|
37
43
|
provisionKey,
|
|
44
|
+
publicKeyFor,
|
|
38
45
|
repoSlug,
|
|
46
|
+
run,
|
|
39
47
|
secretNames,
|
|
40
48
|
secretsCommand,
|
|
41
49
|
setSecret,
|
|
42
50
|
setVariable,
|
|
43
51
|
variableNames,
|
|
44
52
|
verifyCloudflareToken
|
|
45
|
-
} from "./index-
|
|
53
|
+
} from "./index-j5xjh6qp.js";
|
|
46
54
|
import {
|
|
47
55
|
LAYOUTS,
|
|
48
56
|
oneOrigin,
|
|
@@ -60,8 +68,12 @@ import {
|
|
|
60
68
|
|
|
61
69
|
// src/deploy/index.ts
|
|
62
70
|
import color2 from "picocolors";
|
|
71
|
+
import { existsSync } from "node:fs";
|
|
72
|
+
import { join as join2 } from "node:path";
|
|
63
73
|
|
|
64
74
|
// src/deploy/link.ts
|
|
75
|
+
import { rmSync } from "node:fs";
|
|
76
|
+
import { join } from "node:path";
|
|
65
77
|
import color from "picocolors";
|
|
66
78
|
var TOKEN_SECRET = "CLOUDFLARE_API_TOKEN";
|
|
67
79
|
var ACCOUNT_VAR = "CLOUDFLARE_ACCOUNT_ID";
|
|
@@ -102,13 +114,27 @@ async function linkCommand(project, args) {
|
|
|
102
114
|
p.log.warn(`--force: replacing ${PRIVATE_KEY_VAR} makes every value in ${SECRETS_FILE} unreadable. Use \`vc keys --rotate\` to re-encrypt instead.`);
|
|
103
115
|
return 1;
|
|
104
116
|
} else {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
117
|
+
const parked = localPrivateKey(root);
|
|
118
|
+
if (parked) {
|
|
119
|
+
const sent = await setSecret(root, PRIVATE_KEY_VAR, parked);
|
|
120
|
+
if (!sent.ok) {
|
|
121
|
+
p.cancel(`GitHub refused the secret: ${sent.error ?? "unknown error"}`);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
const publicKey = await publicKeyFor(parked);
|
|
125
|
+
if (publicKey)
|
|
126
|
+
committedPublicKeyInto(root, publicKey);
|
|
127
|
+
rmSync(join(root, LOCAL_KEY_FILE), { force: true });
|
|
128
|
+
p.log.success(`${color.green("✓")} the key waiting in ${LOCAL_KEY_FILE} moved to GitHub, and the file deleted`);
|
|
129
|
+
} else {
|
|
130
|
+
const made = await provisionKey(project);
|
|
131
|
+
if (!made.ok) {
|
|
132
|
+
p.cancel(made.reason);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
committedPublicKeyInto(root, made.publicKey);
|
|
136
|
+
p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
|
|
109
137
|
}
|
|
110
|
-
committedPublicKeyInto(root, made.publicKey);
|
|
111
|
-
p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
|
|
112
138
|
}
|
|
113
139
|
const existing = await secretNames(root);
|
|
114
140
|
if (existing.has(TOKEN_SECRET) && !relink) {
|
|
@@ -335,21 +361,66 @@ async function guardCommand() {
|
|
|
335
361
|
const project = await findProject();
|
|
336
362
|
if (!project)
|
|
337
363
|
return 0;
|
|
338
|
-
const
|
|
364
|
+
const root = project.root;
|
|
365
|
+
if (existsSync(join2(root, LOCAL_KEY_FILE)) && !ignoresKeyFile(root)) {
|
|
366
|
+
console.error(`
|
|
367
|
+
${color2.red("✗ refusing the commit")}: ${LOCAL_KEY_FILE} exists and is NOT gitignored.
|
|
368
|
+
|
|
369
|
+
` + ` It holds the private key that opens every secret in this repository.
|
|
370
|
+
` + ` Add ${LOCAL_KEY_FILE} to .gitignore before committing anything.
|
|
371
|
+
`);
|
|
372
|
+
return 1;
|
|
373
|
+
}
|
|
374
|
+
const bare = plaintextSecretEntries(root);
|
|
339
375
|
if (bare.length === 0)
|
|
340
376
|
return 0;
|
|
341
|
-
|
|
342
|
-
|
|
377
|
+
const publicKey = committedPublicKey(root);
|
|
378
|
+
if (!publicKey) {
|
|
379
|
+
console.error(`
|
|
380
|
+
${color2.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${bare.length === 1 ? "is" : "are"} in the clear, and there is no key to encrypt ${bare.length === 1 ? "it" : "them"} with.
|
|
343
381
|
|
|
344
|
-
` + ` ${bare.join(`
|
|
382
|
+
` + ` ${bare.map((entry) => entry.name).join(`
|
|
345
383
|
`)}
|
|
346
384
|
|
|
347
|
-
` + `
|
|
385
|
+
` + ` ${color2.cyan("vc keys --init")} makes one — no GitHub repository needed yet.
|
|
348
386
|
|
|
349
387
|
` + color2.dim(` Committing a secret in the clear cannot be undone by a later commit;
|
|
350
388
|
the value stays in the history and must be treated as burned.
|
|
351
389
|
`));
|
|
352
|
-
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
const staged = (await run("git", ["diff", "--cached", "--name-only", "--", SECRETS_FILE], root)).out.trim().length > 0;
|
|
393
|
+
for (const entry of bare) {
|
|
394
|
+
const sealed = await encryptInto(root, publicKey, entry.name, entry.value);
|
|
395
|
+
if (!sealed.ok) {
|
|
396
|
+
console.error(`
|
|
397
|
+
${color2.red("✗ refusing the commit")}: ${sealed.error}
|
|
398
|
+
`);
|
|
399
|
+
return 1;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const left = plaintextSecretNames(root);
|
|
403
|
+
if (left.length > 0) {
|
|
404
|
+
console.error(`
|
|
405
|
+
${color2.red("✗ refusing the commit")}: ${left.join(", ")} could not be encrypted.
|
|
406
|
+
`);
|
|
407
|
+
return 1;
|
|
408
|
+
}
|
|
409
|
+
if (staged) {
|
|
410
|
+
const added = await run("git", ["add", "--", SECRETS_FILE], root);
|
|
411
|
+
if (added.code !== 0) {
|
|
412
|
+
console.error(`
|
|
413
|
+
${color2.red("✗ refusing the commit")}: ${SECRETS_FILE} was encrypted but could not be re-staged,
|
|
414
|
+
` + ` so the commit would still carry the plaintext you staged. \`git add ${SECRETS_FILE}\`.
|
|
415
|
+
`);
|
|
416
|
+
return 1;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
console.log(`
|
|
420
|
+
${color2.green("✓")} encrypted ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE}${staged ? " and re-staged it" : ""}: ${bare.map((entry) => entry.name).join(", ")}
|
|
421
|
+
` + color2.dim(` Encryption needs only the public key, so this needs no credential.
|
|
422
|
+
`));
|
|
423
|
+
return 0;
|
|
353
424
|
}
|
|
354
425
|
async function linkCliCommand(args) {
|
|
355
426
|
const project = await findProject();
|
|
@@ -365,21 +436,19 @@ async function linkHelp() {
|
|
|
365
436
|
line("Put this shop's credentials on its GitHub repository, once, so that", width),
|
|
366
437
|
line("every deploy after this is a `git push`.", width),
|
|
367
438
|
line("", width),
|
|
368
|
-
...row("vc link", "
|
|
439
|
+
...row("vc link", "store the encryption key and the Cloudflare token on the repository", width, 2),
|
|
369
440
|
...row("vc link --force", "replace what is already there", width, 2),
|
|
370
441
|
line("", width),
|
|
371
442
|
line(color2.bold("What it stores, and where"), width),
|
|
372
|
-
...row(
|
|
373
|
-
...row("CLOUDFLARE_API_TOKEN", "
|
|
374
|
-
...row("CLOUDFLARE_ACCOUNT_ID", "an identifier, not a credential
|
|
443
|
+
...row(PRIVATE_KEY_VAR, "a repository SECRET. A key already waiting locally is MOVED here and the local copy deleted", width, 2),
|
|
444
|
+
...row("CLOUDFLARE_API_TOKEN", "a repository SECRET, checked against the Cloudflare API before it is stored", width, 2),
|
|
445
|
+
...row("CLOUDFLARE_ACCOUNT_ID", "a repository VARIABLE — an identifier, not a credential", width, 2),
|
|
375
446
|
line("", width),
|
|
376
447
|
line(color2.bold("Why one token still has to be typed"), width),
|
|
377
448
|
line("GitHub cannot mint a Cloudflare credential. There is no OIDC federation", width),
|
|
378
449
|
line("between them, and the Cloudflare GitHub App runs the other way: it grants", width),
|
|
379
450
|
line("Cloudflare access to your repository, not your repository access to", width),
|
|
380
|
-
line("Cloudflare.
|
|
381
|
-
line("and only Cloudflare can issue that. So it is typed once, here, and never", width),
|
|
382
|
-
line("stored on this machine.", width)
|
|
451
|
+
line("Cloudflare. So it is typed once, here, and never stored on this machine.", width)
|
|
383
452
|
], width));
|
|
384
453
|
return 0;
|
|
385
454
|
}
|
|
@@ -387,7 +456,7 @@ async function linkHelp() {
|
|
|
387
456
|
// src/init.ts
|
|
388
457
|
import * as p2 from "@clack/prompts";
|
|
389
458
|
import { mkdir } from "node:fs/promises";
|
|
390
|
-
import { basename, join } from "node:path";
|
|
459
|
+
import { basename, join as join3 } from "node:path";
|
|
391
460
|
import color4 from "picocolors";
|
|
392
461
|
|
|
393
462
|
// src/wizard.ts
|
|
@@ -616,7 +685,7 @@ async function init(args) {
|
|
|
616
685
|
["frontend", "the storefront — prerendered and served from the worker's own assets"]
|
|
617
686
|
];
|
|
618
687
|
for (const [dir, starter] of parts) {
|
|
619
|
-
const target =
|
|
688
|
+
const target = join3(root, dir);
|
|
620
689
|
if (isVoidApp(target))
|
|
621
690
|
continue;
|
|
622
691
|
await mkdir(target, { recursive: true });
|
|
@@ -733,8 +802,8 @@ async function generateHelp() {
|
|
|
733
802
|
}
|
|
734
803
|
|
|
735
804
|
// src/scripts.ts
|
|
736
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
737
|
-
import { join as
|
|
805
|
+
import { existsSync as existsSync2, readFileSync } from "node:fs";
|
|
806
|
+
import { join as join4, relative } from "node:path";
|
|
738
807
|
function appScript(name) {
|
|
739
808
|
return async (args) => {
|
|
740
809
|
const project = await findProject();
|
|
@@ -743,8 +812,8 @@ function appScript(name) {
|
|
|
743
812
|
const code = await ensureGenerated(project);
|
|
744
813
|
if (code !== 0)
|
|
745
814
|
return code;
|
|
746
|
-
const pkgPath =
|
|
747
|
-
const scripts =
|
|
815
|
+
const pkgPath = join4(project.appDir, "package.json");
|
|
816
|
+
const scripts = existsSync2(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {} : {};
|
|
748
817
|
if (!scripts[name]) {
|
|
749
818
|
console.error(`vc: ${relative(process.cwd(), pkgPath) || "package.json"} has no "${name}" script — void init writes one.`);
|
|
750
819
|
return 1;
|
package/dist/deploy/index.d.ts
CHANGED
|
@@ -21,7 +21,24 @@ export declare function secretsHelp(): Promise<number>;
|
|
|
21
21
|
export declare function keysCliCommand(args: string[]): Promise<number>;
|
|
22
22
|
export declare function keysHelp(): Promise<number>;
|
|
23
23
|
/**
|
|
24
|
-
* The pre-commit gate:
|
|
24
|
+
* The pre-commit gate: ENCRYPT anything in the clear, then let the commit through.
|
|
25
|
+
*
|
|
26
|
+
* ── Why this encrypts rather than refuses ────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* It used to refuse, and refusing was the wrong shape. Encryption needs only
|
|
29
|
+
* the public key, which is committed right there in the file — so the hook
|
|
30
|
+
* can simply fix the problem instead of handing it back. A person who typed
|
|
31
|
+
* a real key into `.env.secrets` with an editor gets it sealed rather than a
|
|
32
|
+
* lecture, which is what actually keeps secrets out of history: a gate you
|
|
33
|
+
* have to satisfy by hand is a gate people learn to pass with --no-verify.
|
|
34
|
+
*
|
|
35
|
+
* ── The part that is easy to get wrong ───────────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* Git commits the INDEX, not the working tree. Encrypting the file on disk
|
|
38
|
+
* would leave the STAGED plaintext exactly where it was, and the commit
|
|
39
|
+
* would carry it anyway — the hook would report success while doing nothing.
|
|
40
|
+
* So a file that was staged is re-staged after encrypting, and the result is
|
|
41
|
+
* verified before the commit is allowed.
|
|
25
42
|
*
|
|
26
43
|
* Exit code is the whole interface — a hook cares about nothing else.
|
|
27
44
|
*/
|
package/dist/deploy/keys.d.ts
CHANGED
|
@@ -38,6 +38,19 @@ import type { Project } from "../project";
|
|
|
38
38
|
* leaked. Those must be replaced at Stripe and everywhere else regardless.
|
|
39
39
|
* Re-entering them is not extra work — it is the work.
|
|
40
40
|
*/
|
|
41
|
+
/**
|
|
42
|
+
* Where a private key waits before the repository exists.
|
|
43
|
+
*
|
|
44
|
+
* Encryption needs only the public key, so a shop can be filled in with real
|
|
45
|
+
* secrets long before anyone has made a GitHub repo to put the private half
|
|
46
|
+
* on. Refusing to make a key until then would block exactly that, so
|
|
47
|
+
* `vc keys --init` will write one here instead — 0600, gitignored, and
|
|
48
|
+
* TEMPORARY: `vc link` uploads this key to the repository and deletes the
|
|
49
|
+
* file, which is how the end state stays "no private key on disk".
|
|
50
|
+
*/
|
|
51
|
+
export declare const LOCAL_KEY_FILE = ".env.keys";
|
|
52
|
+
/** The private key parked locally, if there is one. */
|
|
53
|
+
export declare function localPrivateKey(root: string): string | null;
|
|
41
54
|
/** The public key the repository was encrypted under, from the committed file. */
|
|
42
55
|
export declare function committedPublicKey(root: string): string | null;
|
|
43
56
|
/** A fresh secp256k1 pair. The private half must reach GitHub and then be forgotten. */
|
|
@@ -69,9 +82,12 @@ export declare function keyState(project: Project): Promise<KeyState>;
|
|
|
69
82
|
export declare function provisionKey(project: Project): Promise<{
|
|
70
83
|
ok: true;
|
|
71
84
|
publicKey: string;
|
|
85
|
+
parked: boolean;
|
|
72
86
|
} | {
|
|
73
87
|
ok: false;
|
|
74
88
|
reason: string;
|
|
75
89
|
}>;
|
|
90
|
+
/** Is the local key file ignored by git? Checked before one is ever written. */
|
|
91
|
+
export declare function ignoresKeyFile(root: string): boolean;
|
|
76
92
|
/** `vc keys` — where the key is, and what is missing. */
|
|
77
93
|
export declare function keysCommand(project: Project, args: string[]): Promise<number>;
|
package/dist/deploy/secrets.d.ts
CHANGED
|
@@ -68,6 +68,17 @@ export declare function committedPublicKeyInto(root: string, publicKey: string):
|
|
|
68
68
|
export declare function declaredSecretNames(root: string): Set<string>;
|
|
69
69
|
/** Is a declared value actually encrypted, or was it committed in the clear? */
|
|
70
70
|
export declare function plaintextSecretNames(root: string): string[];
|
|
71
|
+
/**
|
|
72
|
+
* The declared values that are NOT encrypted, with their values.
|
|
73
|
+
*
|
|
74
|
+
* Separate from `plaintextSecretNames` on purpose: names are safe to print
|
|
75
|
+
* and values never are, so the function that returns values is the one you
|
|
76
|
+
* have to go looking for.
|
|
77
|
+
*/
|
|
78
|
+
export declare function plaintextSecretEntries(root: string): Array<{
|
|
79
|
+
name: string;
|
|
80
|
+
value: string;
|
|
81
|
+
}>;
|
|
71
82
|
export interface DecryptedSecrets {
|
|
72
83
|
/** A temp file in `.env` format, for `wrangler deploy --secrets-file`. */
|
|
73
84
|
path: string;
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./index-0v6na3yp.js";
|
|
11
11
|
|
|
12
12
|
// src/deploy/keys.ts
|
|
13
|
-
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
|
|
13
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
14
14
|
import { join as join3 } from "node:path";
|
|
15
15
|
import color2 from "picocolors";
|
|
16
16
|
|
|
@@ -353,6 +353,22 @@ function plaintextSecretNames(root) {
|
|
|
353
353
|
}
|
|
354
354
|
return bare;
|
|
355
355
|
}
|
|
356
|
+
function plaintextSecretEntries(root) {
|
|
357
|
+
const path = join2(root, SECRETS_FILE);
|
|
358
|
+
if (!existsSync2(path))
|
|
359
|
+
return [];
|
|
360
|
+
const out = [];
|
|
361
|
+
for (const line of readFileSync(path, "utf8").split(`
|
|
362
|
+
`)) {
|
|
363
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
364
|
+
if (!match || match[1].startsWith("DOTENV_"))
|
|
365
|
+
continue;
|
|
366
|
+
const value = match[2].trim().replace(/^['"]|['"]$/g, "");
|
|
367
|
+
if (value && value !== "unset" && !value.startsWith("encrypted:"))
|
|
368
|
+
out.push({ name: match[1], value });
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
356
372
|
async function decryptSecrets(project) {
|
|
357
373
|
const root = project.root;
|
|
358
374
|
if (!existsSync2(join2(root, SECRETS_FILE)))
|
|
@@ -368,7 +384,7 @@ async function decryptSecrets(project) {
|
|
|
368
384
|
}
|
|
369
385
|
const expected = committedPublicKey(root);
|
|
370
386
|
if (expected) {
|
|
371
|
-
const { publicKeyFor } = await import("./keys-
|
|
387
|
+
const { publicKeyFor } = await import("./keys-3zjezaxk.js");
|
|
372
388
|
const derived = await publicKeyFor(privateKey);
|
|
373
389
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
374
390
|
return {
|
|
@@ -415,6 +431,8 @@ async function secretsCommand(project, args) {
|
|
|
415
431
|
return initSecrets(project);
|
|
416
432
|
if (args[0] === "set")
|
|
417
433
|
return setSecretValue(project, args.slice(1));
|
|
434
|
+
if (args.includes("--sync"))
|
|
435
|
+
return syncSecrets(project);
|
|
418
436
|
if (!existsSync2(join2(root, SECRETS_FILE))) {
|
|
419
437
|
console.log(`
|
|
420
438
|
${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
|
|
@@ -607,8 +625,58 @@ function upsertLine(path, name, value) {
|
|
|
607
625
|
`)}${name}=${value}
|
|
608
626
|
`);
|
|
609
627
|
}
|
|
628
|
+
async function syncSecrets(project) {
|
|
629
|
+
const root = project.root;
|
|
630
|
+
const path = join2(root, SECRETS_FILE);
|
|
631
|
+
if (!existsSync2(path)) {
|
|
632
|
+
console.error(`
|
|
633
|
+
vc: no ${SECRETS_FILE} yet. \`vc secrets --init\` writes one.
|
|
634
|
+
`);
|
|
635
|
+
return 1;
|
|
636
|
+
}
|
|
637
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
638
|
+
const declared = declaredSecretNames(root);
|
|
639
|
+
const missing = required.filter((key) => !declared.has(key.key));
|
|
640
|
+
const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
|
|
641
|
+
if (missing.length > 0) {
|
|
642
|
+
const body = readFileSync(path, "utf8").replace(/\n*$/, `
|
|
643
|
+
`);
|
|
644
|
+
const added = missing.flatMap((key) => ["", `# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`]);
|
|
645
|
+
writeFileSync(path, `${body}${added.join(`
|
|
646
|
+
`)}
|
|
647
|
+
`);
|
|
648
|
+
console.log(`
|
|
649
|
+
${color.green("+")} ${missing.length} key${missing.length === 1 ? "" : "s"} added to ${SECRETS_FILE}, as \`unset\`:
|
|
650
|
+
`);
|
|
651
|
+
for (const key of missing)
|
|
652
|
+
console.log(` ${color.cyan(`vc secrets set ${key.key}`)} ${color.dim(key.where ?? "")}`);
|
|
653
|
+
console.log("");
|
|
654
|
+
} else {
|
|
655
|
+
console.log(`
|
|
656
|
+
${color.green("✓")} every secret this shop needs is already declared.
|
|
657
|
+
`);
|
|
658
|
+
}
|
|
659
|
+
if (extra.length > 0) {
|
|
660
|
+
console.log(`${color.yellow("!")} ${extra.length} declared key${extra.length === 1 ? " is" : "s are"} no longer required by this shop:
|
|
661
|
+
${extra.join(`
|
|
662
|
+
`)}
|
|
663
|
+
|
|
664
|
+
` + color.dim(` Left alone — they cost nothing, and deleting a value you may still want
|
|
665
|
+
is not this command's call. Remove them by hand when you are sure.
|
|
666
|
+
`));
|
|
667
|
+
}
|
|
668
|
+
return 0;
|
|
669
|
+
}
|
|
610
670
|
|
|
611
671
|
// src/deploy/keys.ts
|
|
672
|
+
var LOCAL_KEY_FILE = ".env.keys";
|
|
673
|
+
function localPrivateKey(root) {
|
|
674
|
+
const path = join3(root, LOCAL_KEY_FILE);
|
|
675
|
+
if (!existsSync3(path))
|
|
676
|
+
return null;
|
|
677
|
+
const match = new RegExp(`^\\s*${PRIVATE_KEY_VAR}\\s*=\\s*["']?([0-9a-fA-F]+)["']?`, "m").exec(readFileSync2(path, "utf8"));
|
|
678
|
+
return match?.[1] ?? null;
|
|
679
|
+
}
|
|
612
680
|
function committedPublicKey(root) {
|
|
613
681
|
const path = join3(root, SECRETS_FILE);
|
|
614
682
|
if (!existsSync3(path))
|
|
@@ -636,7 +704,7 @@ async function keyState(project) {
|
|
|
636
704
|
const publicKey = committedPublicKey(project.root);
|
|
637
705
|
const slug = await repoSlug(project.root);
|
|
638
706
|
const names = slug ? await secretNames(project.root) : new Set;
|
|
639
|
-
const localKey = process.env[PRIVATE_KEY_VAR] ??
|
|
707
|
+
const localKey = process.env[PRIVATE_KEY_VAR] ?? localPrivateKey(project.root);
|
|
640
708
|
let mismatch;
|
|
641
709
|
if (localKey && publicKey) {
|
|
642
710
|
const derived = await publicKeyFor(localKey);
|
|
@@ -647,27 +715,41 @@ async function keyState(project) {
|
|
|
647
715
|
return { publicKey, inGitHub: names.has(PRIVATE_KEY_VAR), slug, localKey, mismatch };
|
|
648
716
|
}
|
|
649
717
|
async function provisionKey(project) {
|
|
718
|
+
const parked = localPrivateKey(project.root);
|
|
719
|
+
if (parked) {
|
|
720
|
+
const publicKey = await publicKeyFor(parked);
|
|
721
|
+
if (publicKey)
|
|
722
|
+
return { ok: true, publicKey, parked: true };
|
|
723
|
+
}
|
|
724
|
+
const pair = await generateKeypair();
|
|
725
|
+
if (!pair)
|
|
726
|
+
return { ok: false, reason: "@dotenvx/dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`" };
|
|
650
727
|
const auth = await ghAuth(project.root);
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
728
|
+
const slug = auth.ok ? await repoSlug(project.root) : null;
|
|
729
|
+
if (slug) {
|
|
730
|
+
const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
|
|
731
|
+
if (!sent.ok)
|
|
732
|
+
return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
|
|
733
|
+
return { ok: true, publicKey: pair.publicKey, parked: false };
|
|
734
|
+
}
|
|
735
|
+
if (!ignoresKeyFile(project.root)) {
|
|
655
736
|
return {
|
|
656
737
|
ok: false,
|
|
657
|
-
reason: `
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
gh repo create --source=. --private --push
|
|
661
|
-
`
|
|
738
|
+
reason: `there is no GitHub repository yet, so the private key would have to wait in ${LOCAL_KEY_FILE} —
|
|
739
|
+
` + ` and ${LOCAL_KEY_FILE} is NOT gitignored here. Add it to .gitignore first; committing it
|
|
740
|
+
would publish the key that opens every secret in the repository.`
|
|
662
741
|
};
|
|
663
742
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
743
|
+
writeFileSync2(join3(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${pair.privateKey}"
|
|
744
|
+
`, { mode: 384 });
|
|
745
|
+
return { ok: true, publicKey: pair.publicKey, parked: true };
|
|
746
|
+
}
|
|
747
|
+
function ignoresKeyFile(root) {
|
|
748
|
+
const path = join3(root, ".gitignore");
|
|
749
|
+
if (!existsSync3(path))
|
|
750
|
+
return false;
|
|
751
|
+
return readFileSync2(path, "utf8").split(`
|
|
752
|
+
`).map((line) => line.trim()).some((line) => line === LOCAL_KEY_FILE || line === `/${LOCAL_KEY_FILE}` || line === ".env.keys*" || line === ".env*");
|
|
671
753
|
}
|
|
672
754
|
async function keysCommand(project, args) {
|
|
673
755
|
if (args.includes("--rotate"))
|
|
@@ -682,7 +764,8 @@ Secrets are encrypted with a public key that is COMMITTED, and read with a`);
|
|
|
682
764
|
const hasFile = existsSync3(join3(project.root, SECRETS_FILE));
|
|
683
765
|
const publicNote = state.publicKey ? `${state.publicKey.slice(0, 20)}… in ${SECRETS_FILE}` : color2.dim(hasFile ? `${SECRETS_FILE} has no key yet — \`vc link\` makes one` : `no ${SECRETS_FILE} yet — \`vc secrets --init\``);
|
|
684
766
|
console.log(` ${state.publicKey ? color2.green("✓") : color2.dim("·")} public key ${publicNote}`);
|
|
685
|
-
|
|
767
|
+
const parked = localPrivateKey(project.root) !== null;
|
|
768
|
+
console.log(` ${state.inGitHub || parked ? color2.green("✓") : color2.red("✗")} private key ${state.inGitHub ? `${PRIVATE_KEY_VAR} is set on ${state.slug}` : parked ? color2.yellow(`waiting in ${LOCAL_KEY_FILE} — \`vc link\` moves it to GitHub and deletes it`) : state.slug ? color2.red(`not set on ${state.slug} — \`vc keys --init\``) : color2.dim("no key yet — `vc keys --init`")}`);
|
|
686
769
|
if (state.mismatch) {
|
|
687
770
|
console.error(`
|
|
688
771
|
${color2.red("✗")} ${state.mismatch}
|
|
@@ -716,7 +799,15 @@ vc: ${made.reason}
|
|
|
716
799
|
}
|
|
717
800
|
const fresh = !existsSync3(join3(project.root, SECRETS_FILE));
|
|
718
801
|
committedPublicKeyInto(project.root, made.publicKey);
|
|
719
|
-
console.log(`
|
|
802
|
+
console.log(made.parked ? `
|
|
803
|
+
${color2.green("✓")} key made; public half in ${SECRETS_FILE}, private half waiting in ${LOCAL_KEY_FILE}
|
|
804
|
+
|
|
805
|
+
` + color2.yellow(` ${LOCAL_KEY_FILE} is TEMPORARY. It is gitignored, but it is the one file that
|
|
806
|
+
opens every secret in this shop. \`vc link\` moves it onto the GitHub
|
|
807
|
+
` + ` repository and deletes it — do that as soon as the repository exists.
|
|
808
|
+
`) + (fresh ? `
|
|
809
|
+
Next: ${color2.cyan("vc secrets --init")} fills in the keys this shop needs.
|
|
810
|
+
` : "") : `
|
|
720
811
|
${color2.green("✓")} ${PRIVATE_KEY_VAR} set on ${state.slug}; public key in ${SECRETS_FILE}
|
|
721
812
|
` + (fresh ? `
|
|
722
813
|
Next: ${color2.cyan("vc secrets --init")} fills in the keys this shop needs.
|
|
@@ -794,4 +885,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
|
|
|
794
885
|
return 0;
|
|
795
886
|
}
|
|
796
887
|
|
|
797
|
-
export { allEnvKeys, renderEnvTs, renderEnvExample, renderEnvLocal, renderEnvProduction, envSummary, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, committedPublicKeyInto, declaredSecretNames, plaintextSecretNames, decryptSecrets, secretsCommand, initSecrets };
|
|
888
|
+
export { allEnvKeys, renderEnvTs, renderEnvExample, renderEnvLocal, renderEnvProduction, envSummary, 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 };
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
renderEnvLocal,
|
|
10
10
|
renderEnvProduction,
|
|
11
11
|
renderEnvTs
|
|
12
|
-
} from "./index-
|
|
12
|
+
} from "./index-j5xjh6qp.js";
|
|
13
13
|
import {
|
|
14
14
|
MANIFEST_FILE,
|
|
15
15
|
has,
|
|
@@ -1344,7 +1344,7 @@ import color from "picocolors";
|
|
|
1344
1344
|
// package.json
|
|
1345
1345
|
var package_default = {
|
|
1346
1346
|
name: "@saastemly/voidcommerce",
|
|
1347
|
-
version: "0.
|
|
1347
|
+
version: "0.8.0",
|
|
1348
1348
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1349
1349
|
type: "module",
|
|
1350
1350
|
license: "MIT",
|
|
@@ -2501,9 +2501,17 @@ dist
|
|
|
2501
2501
|
`, result, "own");
|
|
2502
2502
|
await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
|
|
2503
2503
|
await put(root, ".husky/pre-commit", `#!/usr/bin/env sh
|
|
2504
|
-
# Generated by \`vc init\`.
|
|
2505
|
-
#
|
|
2506
|
-
#
|
|
2504
|
+
# Generated by \`vc init\`.
|
|
2505
|
+
#
|
|
2506
|
+
# Encrypts any value sitting in the clear in .env.secrets, and re-stages the
|
|
2507
|
+
# file so the COMMIT carries the ciphertext rather than what you staged.
|
|
2508
|
+
# Encryption needs only the public key in that file, so this needs no
|
|
2509
|
+
# credential and works on a fresh clone.
|
|
2510
|
+
#
|
|
2511
|
+
# It refuses only when it cannot fix the problem itself: no key yet
|
|
2512
|
+
# (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
|
|
2513
|
+
# committed in the clear cannot be un-committed — the value stays in the
|
|
2514
|
+
# history and has to be treated as burned.
|
|
2507
2515
|
bunx vc guard
|
|
2508
2516
|
`, result, "regenerate");
|
|
2509
2517
|
await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
|
package/dist/index.js
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
routeProblem,
|
|
52
52
|
strictDependencies,
|
|
53
53
|
upsertJsonc
|
|
54
|
-
} from "./index-
|
|
54
|
+
} from "./index-y6qzjd9h.js";
|
|
55
55
|
import {
|
|
56
56
|
allEnvKeys,
|
|
57
57
|
envSummary,
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
renderEnvLocal,
|
|
60
60
|
renderEnvProduction,
|
|
61
61
|
renderEnvTs
|
|
62
|
-
} from "./index-
|
|
62
|
+
} from "./index-j5xjh6qp.js";
|
|
63
63
|
import {
|
|
64
64
|
LAYOUTS,
|
|
65
65
|
MANIFEST_FILE,
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import {
|
|
2
|
+
LOCAL_KEY_FILE,
|
|
2
3
|
committedPublicKey,
|
|
3
4
|
generateKeypair,
|
|
5
|
+
ignoresKeyFile,
|
|
4
6
|
keyState,
|
|
5
7
|
keysCommand,
|
|
8
|
+
localPrivateKey,
|
|
6
9
|
provisionKey,
|
|
7
10
|
publicKeyFor
|
|
8
|
-
} from "./index-
|
|
11
|
+
} from "./index-j5xjh6qp.js";
|
|
9
12
|
import"./index-tjy6yygc.js";
|
|
10
13
|
import"./index-mnb7fz2t.js";
|
|
11
14
|
import"./index-0v6na3yp.js";
|
|
12
15
|
export {
|
|
13
16
|
publicKeyFor,
|
|
14
17
|
provisionKey,
|
|
18
|
+
localPrivateKey,
|
|
15
19
|
keysCommand,
|
|
16
20
|
keyState,
|
|
21
|
+
ignoresKeyFile,
|
|
17
22
|
generateKeypair,
|
|
18
|
-
committedPublicKey
|
|
23
|
+
committedPublicKey,
|
|
24
|
+
LOCAL_KEY_FILE
|
|
19
25
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/deploy/index.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { findProject } from "../project";
|
|
|
4
4
|
import { captureVoid, runVoid } from "../void";
|
|
5
5
|
import { deployCloudflare } from "./cloudflare";
|
|
6
6
|
import { preflight, printPreflight } from "./preflight";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { PRIVATE_KEY_VAR, SECRETS_FILE, encryptInto, plaintextSecretEntries, plaintextSecretNames, run, secretsCommand } from "./secrets";
|
|
10
|
+
import { LOCAL_KEY_FILE, committedPublicKey, ignoresKeyFile, keysCommand } from "./keys";
|
|
9
11
|
import { linkCommand } from "./link";
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -178,27 +180,97 @@ export async function keysHelp(): Promise<number> {
|
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
/**
|
|
181
|
-
* The pre-commit gate:
|
|
183
|
+
* The pre-commit gate: ENCRYPT anything in the clear, then let the commit through.
|
|
184
|
+
*
|
|
185
|
+
* ── Why this encrypts rather than refuses ────────────────────────────────
|
|
186
|
+
*
|
|
187
|
+
* It used to refuse, and refusing was the wrong shape. Encryption needs only
|
|
188
|
+
* the public key, which is committed right there in the file — so the hook
|
|
189
|
+
* can simply fix the problem instead of handing it back. A person who typed
|
|
190
|
+
* a real key into `.env.secrets` with an editor gets it sealed rather than a
|
|
191
|
+
* lecture, which is what actually keeps secrets out of history: a gate you
|
|
192
|
+
* have to satisfy by hand is a gate people learn to pass with --no-verify.
|
|
193
|
+
*
|
|
194
|
+
* ── The part that is easy to get wrong ───────────────────────────────────
|
|
195
|
+
*
|
|
196
|
+
* Git commits the INDEX, not the working tree. Encrypting the file on disk
|
|
197
|
+
* would leave the STAGED plaintext exactly where it was, and the commit
|
|
198
|
+
* would carry it anyway — the hook would report success while doing nothing.
|
|
199
|
+
* So a file that was staged is re-staged after encrypting, and the result is
|
|
200
|
+
* verified before the commit is allowed.
|
|
182
201
|
*
|
|
183
202
|
* Exit code is the whole interface — a hook cares about nothing else.
|
|
184
203
|
*/
|
|
185
204
|
export async function guardCommand(): Promise<number> {
|
|
186
205
|
const project = await findProject();
|
|
187
206
|
if (!project) return 0; // Not a shop; nothing to guard.
|
|
188
|
-
const
|
|
207
|
+
const root = project.root;
|
|
208
|
+
|
|
209
|
+
// A key file that is not ignored is a worse leak than any single value:
|
|
210
|
+
// it opens every secret in the repository, including ones already rotated.
|
|
211
|
+
if (existsSync(join(root, LOCAL_KEY_FILE)) && !ignoresKeyFile(root)) {
|
|
212
|
+
console.error(
|
|
213
|
+
`\n${color.red("✗ refusing the commit")}: ${LOCAL_KEY_FILE} exists and is NOT gitignored.\n\n` +
|
|
214
|
+
` It holds the private key that opens every secret in this repository.\n` +
|
|
215
|
+
` Add ${LOCAL_KEY_FILE} to .gitignore before committing anything.\n`,
|
|
216
|
+
);
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const bare = plaintextSecretEntries(root);
|
|
189
221
|
if (bare.length === 0) return 0;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
222
|
+
|
|
223
|
+
const publicKey = committedPublicKey(root);
|
|
224
|
+
if (!publicKey) {
|
|
225
|
+
console.error(
|
|
226
|
+
`\n${color.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${
|
|
227
|
+
bare.length === 1 ? "is" : "are"
|
|
228
|
+
} in the clear, and there is no key to encrypt ${bare.length === 1 ? "it" : "them"} with.\n\n` +
|
|
229
|
+
` ${bare.map((entry) => entry.name).join("\n ")}\n\n` +
|
|
230
|
+
` ${color.cyan("vc keys --init")} makes one — no GitHub repository needed yet.\n\n` +
|
|
231
|
+
color.dim(" Committing a secret in the clear cannot be undone by a later commit;\n the value stays in the history and must be treated as burned.\n"),
|
|
232
|
+
);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Was it staged? Decides whether the commit is carrying the plaintext.
|
|
237
|
+
const staged = (await run("git", ["diff", "--cached", "--name-only", "--", SECRETS_FILE], root)).out.trim().length > 0;
|
|
238
|
+
|
|
239
|
+
for (const entry of bare) {
|
|
240
|
+
const sealed = await encryptInto(root, publicKey, entry.name, entry.value);
|
|
241
|
+
if (!sealed.ok) {
|
|
242
|
+
console.error(`\n${color.red("✗ refusing the commit")}: ${sealed.error}\n`);
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Never trust the loop: re-read and check. This is the last thing standing
|
|
248
|
+
// between a live credential and a permanent record of it.
|
|
249
|
+
const left = plaintextSecretNames(root);
|
|
250
|
+
if (left.length > 0) {
|
|
251
|
+
console.error(`\n${color.red("✗ refusing the commit")}: ${left.join(", ")} could not be encrypted.\n`);
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (staged) {
|
|
256
|
+
const added = await run("git", ["add", "--", SECRETS_FILE], root);
|
|
257
|
+
if (added.code !== 0) {
|
|
258
|
+
console.error(
|
|
259
|
+
`\n${color.red("✗ refusing the commit")}: ${SECRETS_FILE} was encrypted but could not be re-staged,\n` +
|
|
260
|
+
` so the commit would still carry the plaintext you staged. \`git add ${SECRETS_FILE}\`.\n`,
|
|
261
|
+
);
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
console.log(
|
|
267
|
+
`\n${color.green("✓")} encrypted ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE}${staged ? " and re-staged it" : ""}: ${bare
|
|
268
|
+
.map((entry) => entry.name)
|
|
269
|
+
.join(", ")}\n` + color.dim(` Encryption needs only the public key, so this needs no credential.\n`),
|
|
197
270
|
);
|
|
198
|
-
return
|
|
271
|
+
return 0;
|
|
199
272
|
}
|
|
200
273
|
|
|
201
|
-
|
|
202
274
|
/** `vc link` — GitHub holds the credentials; this is what puts them there. */
|
|
203
275
|
export async function linkCliCommand(args: string[]): Promise<number> {
|
|
204
276
|
const project = await findProject();
|
|
@@ -216,21 +288,19 @@ export async function linkHelp(): Promise<number> {
|
|
|
216
288
|
line("Put this shop's credentials on its GitHub repository, once, so that", width),
|
|
217
289
|
line("every deploy after this is a `git push`.", width),
|
|
218
290
|
line("", width),
|
|
219
|
-
...row("vc link", "
|
|
291
|
+
...row("vc link", "store the encryption key and the Cloudflare token on the repository", width, 2),
|
|
220
292
|
...row("vc link --force", "replace what is already there", width, 2),
|
|
221
293
|
line("", width),
|
|
222
294
|
line(color.bold("What it stores, and where"), width),
|
|
223
|
-
...row(
|
|
224
|
-
...row("CLOUDFLARE_API_TOKEN", "
|
|
225
|
-
...row("CLOUDFLARE_ACCOUNT_ID", "an identifier, not a credential
|
|
295
|
+
...row(PRIVATE_KEY_VAR, "a repository SECRET. A key already waiting locally is MOVED here and the local copy deleted", width, 2),
|
|
296
|
+
...row("CLOUDFLARE_API_TOKEN", "a repository SECRET, checked against the Cloudflare API before it is stored", width, 2),
|
|
297
|
+
...row("CLOUDFLARE_ACCOUNT_ID", "a repository VARIABLE — an identifier, not a credential", width, 2),
|
|
226
298
|
line("", width),
|
|
227
299
|
line(color.bold("Why one token still has to be typed"), width),
|
|
228
300
|
line("GitHub cannot mint a Cloudflare credential. There is no OIDC federation", width),
|
|
229
301
|
line("between them, and the Cloudflare GitHub App runs the other way: it grants", width),
|
|
230
302
|
line("Cloudflare access to your repository, not your repository access to", width),
|
|
231
|
-
line("Cloudflare.
|
|
232
|
-
line("and only Cloudflare can issue that. So it is typed once, here, and never", width),
|
|
233
|
-
line("stored on this machine.", width),
|
|
303
|
+
line("Cloudflare. So it is typed once, here, and never stored on this machine.", width),
|
|
234
304
|
], width),
|
|
235
305
|
);
|
|
236
306
|
return 0;
|
package/src/deploy/keys.ts
CHANGED
|
@@ -45,6 +45,26 @@ import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, findDotenvx, run
|
|
|
45
45
|
* Re-entering them is not extra work — it is the work.
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Where a private key waits before the repository exists.
|
|
50
|
+
*
|
|
51
|
+
* Encryption needs only the public key, so a shop can be filled in with real
|
|
52
|
+
* secrets long before anyone has made a GitHub repo to put the private half
|
|
53
|
+
* on. Refusing to make a key until then would block exactly that, so
|
|
54
|
+
* `vc keys --init` will write one here instead — 0600, gitignored, and
|
|
55
|
+
* TEMPORARY: `vc link` uploads this key to the repository and deletes the
|
|
56
|
+
* file, which is how the end state stays "no private key on disk".
|
|
57
|
+
*/
|
|
58
|
+
export const LOCAL_KEY_FILE = ".env.keys";
|
|
59
|
+
|
|
60
|
+
/** The private key parked locally, if there is one. */
|
|
61
|
+
export function localPrivateKey(root: string): string | null {
|
|
62
|
+
const path = join(root, LOCAL_KEY_FILE);
|
|
63
|
+
if (!existsSync(path)) return null;
|
|
64
|
+
const match = new RegExp(`^\\s*${PRIVATE_KEY_VAR}\\s*=\\s*["']?([0-9a-fA-F]+)["']?`, "m").exec(readFileSync(path, "utf8"));
|
|
65
|
+
return match?.[1] ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
/** The public key the repository was encrypted under, from the committed file. */
|
|
49
69
|
export function committedPublicKey(root: string): string | null {
|
|
50
70
|
const path = join(root, SECRETS_FILE);
|
|
@@ -89,7 +109,7 @@ export async function keyState(project: Project): Promise<KeyState> {
|
|
|
89
109
|
const publicKey = committedPublicKey(project.root);
|
|
90
110
|
const slug = await repoSlug(project.root);
|
|
91
111
|
const names = slug ? await secretNames(project.root) : new Set<string>();
|
|
92
|
-
const localKey = process.env[PRIVATE_KEY_VAR] ??
|
|
112
|
+
const localKey = process.env[PRIVATE_KEY_VAR] ?? localPrivateKey(project.root);
|
|
93
113
|
|
|
94
114
|
let mismatch: string | undefined;
|
|
95
115
|
if (localKey && publicKey) {
|
|
@@ -108,28 +128,51 @@ export async function keyState(project: Project): Promise<KeyState> {
|
|
|
108
128
|
* private key is deliberately not returned and not logged: it exists as a
|
|
109
129
|
* local variable for the length of one `gh` call and then goes out of scope.
|
|
110
130
|
*/
|
|
111
|
-
export async function provisionKey(project: Project): Promise<{ ok: true; publicKey: string } | { ok: false; reason: string }> {
|
|
131
|
+
export async function provisionKey(project: Project): Promise<{ ok: true; publicKey: string; parked: boolean } | { ok: false; reason: string }> {
|
|
132
|
+
// An existing key is REUSED, never replaced: a new one would orphan
|
|
133
|
+
// everything the old one has already encrypted.
|
|
134
|
+
const parked = localPrivateKey(project.root);
|
|
135
|
+
if (parked) {
|
|
136
|
+
const publicKey = await publicKeyFor(parked);
|
|
137
|
+
if (publicKey) return { ok: true, publicKey, parked: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const pair = await generateKeypair();
|
|
141
|
+
if (!pair) return { ok: false, reason: "@dotenvx/dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`" };
|
|
142
|
+
|
|
112
143
|
const auth = await ghAuth(project.root);
|
|
113
|
-
|
|
144
|
+
const slug = auth.ok ? await repoSlug(project.root) : null;
|
|
114
145
|
|
|
115
|
-
|
|
116
|
-
|
|
146
|
+
if (slug) {
|
|
147
|
+
const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
|
|
148
|
+
if (!sent.ok) return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
|
|
149
|
+
return { ok: true, publicKey: pair.publicKey, parked: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// No repository yet. Park the key rather than refusing: the alternative is
|
|
153
|
+
// that nobody can put a secret in the shop until they have made a repo,
|
|
154
|
+
// which is the wrong order to force on anyone.
|
|
155
|
+
if (!ignoresKeyFile(project.root)) {
|
|
117
156
|
return {
|
|
118
157
|
ok: false,
|
|
119
158
|
reason:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"
|
|
159
|
+
`there is no GitHub repository yet, so the private key would have to wait in ${LOCAL_KEY_FILE} —\n` +
|
|
160
|
+
` and ${LOCAL_KEY_FILE} is NOT gitignored here. Add it to .gitignore first; committing it\n` +
|
|
161
|
+
" would publish the key that opens every secret in the repository.",
|
|
123
162
|
};
|
|
124
163
|
}
|
|
164
|
+
writeFileSync(join(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${pair.privateKey}"\n`, { mode: 0o600 });
|
|
165
|
+
return { ok: true, publicKey: pair.publicKey, parked: true };
|
|
166
|
+
}
|
|
125
167
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
168
|
+
/** Is the local key file ignored by git? Checked before one is ever written. */
|
|
169
|
+
export function ignoresKeyFile(root: string): boolean {
|
|
170
|
+
const path = join(root, ".gitignore");
|
|
171
|
+
if (!existsSync(path)) return false;
|
|
172
|
+
return readFileSync(path, "utf8")
|
|
173
|
+
.split("\n")
|
|
174
|
+
.map((line) => line.trim())
|
|
175
|
+
.some((line) => line === LOCAL_KEY_FILE || line === `/${LOCAL_KEY_FILE}` || line === ".env.keys*" || line === ".env*");
|
|
133
176
|
}
|
|
134
177
|
|
|
135
178
|
/** `vc keys` — where the key is, and what is missing. */
|
|
@@ -149,9 +192,16 @@ export async function keysCommand(project: Project, args: string[]): Promise<num
|
|
|
149
192
|
? `${state.publicKey.slice(0, 20)}… in ${SECRETS_FILE}`
|
|
150
193
|
: color.dim(hasFile ? `${SECRETS_FILE} has no key yet — \`vc link\` makes one` : `no ${SECRETS_FILE} yet — \`vc secrets --init\``);
|
|
151
194
|
console.log(` ${state.publicKey ? color.green("✓") : color.dim("·")} public key ${publicNote}`);
|
|
195
|
+
const parked = localPrivateKey(project.root) !== null;
|
|
152
196
|
console.log(
|
|
153
|
-
` ${state.inGitHub ? color.green("✓") : color.red("✗")} private key ${
|
|
154
|
-
state.inGitHub
|
|
197
|
+
` ${state.inGitHub || parked ? color.green("✓") : color.red("✗")} private key ${
|
|
198
|
+
state.inGitHub
|
|
199
|
+
? `${PRIVATE_KEY_VAR} is set on ${state.slug}`
|
|
200
|
+
: parked
|
|
201
|
+
? color.yellow(`waiting in ${LOCAL_KEY_FILE} — \`vc link\` moves it to GitHub and deletes it`)
|
|
202
|
+
: state.slug
|
|
203
|
+
? color.red(`not set on ${state.slug} — \`vc keys --init\``)
|
|
204
|
+
: color.dim("no key yet — `vc keys --init`")
|
|
155
205
|
}`,
|
|
156
206
|
);
|
|
157
207
|
|
|
@@ -171,7 +221,7 @@ export async function keysCommand(project: Project, args: string[]): Promise<num
|
|
|
171
221
|
return 0;
|
|
172
222
|
}
|
|
173
223
|
|
|
174
|
-
/** Generate a key,
|
|
224
|
+
/** Generate a key, put the public half in the file, and say where the private half went. */
|
|
175
225
|
async function initKey(project: Project): Promise<number> {
|
|
176
226
|
const state = await keyState(project);
|
|
177
227
|
if (state.inGitHub && state.publicKey) {
|
|
@@ -188,15 +238,19 @@ async function initKey(project: Project): Promise<number> {
|
|
|
188
238
|
return 1;
|
|
189
239
|
}
|
|
190
240
|
|
|
191
|
-
// ALWAYS written, creating the file if need be. A private key on GitHub
|
|
192
|
-
// with no public half in the repository is a key nobody can encrypt to,
|
|
193
|
-
// and the next command would quietly generate a second one — orphaning
|
|
194
|
-
// this one and anything already sealed with it.
|
|
195
241
|
const fresh = !existsSync(join(project.root, SECRETS_FILE));
|
|
196
242
|
committedPublicKeyInto(project.root, made.publicKey);
|
|
197
243
|
console.log(
|
|
198
|
-
|
|
199
|
-
|
|
244
|
+
made.parked
|
|
245
|
+
? `\n${color.green("✓")} key made; public half in ${SECRETS_FILE}, private half waiting in ${LOCAL_KEY_FILE}\n\n` +
|
|
246
|
+
color.yellow(
|
|
247
|
+
` ${LOCAL_KEY_FILE} is TEMPORARY. It is gitignored, but it is the one file that\n` +
|
|
248
|
+
` opens every secret in this shop. \`vc link\` moves it onto the GitHub\n` +
|
|
249
|
+
" repository and deletes it — do that as soon as the repository exists.\n",
|
|
250
|
+
) +
|
|
251
|
+
(fresh ? `\n Next: ${color.cyan("vc secrets --init")} fills in the keys this shop needs.\n` : "")
|
|
252
|
+
: `\n${color.green("✓")} ${PRIVATE_KEY_VAR} set on ${state.slug}; public key in ${SECRETS_FILE}\n` +
|
|
253
|
+
(fresh ? `\n Next: ${color.cyan("vc secrets --init")} fills in the keys this shop needs.\n` : "\n"),
|
|
200
254
|
);
|
|
201
255
|
return 0;
|
|
202
256
|
}
|
package/src/deploy/link.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import color from "picocolors";
|
|
2
4
|
import { writeManifest } from "../manifest";
|
|
3
5
|
import type { Project } from "../project";
|
|
4
6
|
import { cloudflareAccounts, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
|
|
5
|
-
import { keyState, provisionKey } from "./keys";
|
|
7
|
+
import { LOCAL_KEY_FILE, keyState, localPrivateKey, provisionKey, publicKeyFor } from "./keys";
|
|
6
8
|
import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, declaredSecretNames, initSecrets } from "./secrets";
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -83,13 +85,30 @@ export async function linkCommand(project: Project, args: string[]): Promise<num
|
|
|
83
85
|
p.log.warn(`--force: replacing ${PRIVATE_KEY_VAR} makes every value in ${SECRETS_FILE} unreadable. Use \`vc keys --rotate\` to re-encrypt instead.`);
|
|
84
86
|
return 1;
|
|
85
87
|
} else {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
// A key parked by `vc keys --init` before this repository existed is
|
|
89
|
+
// ADOPTED, not replaced: it may already have encrypted the whole shop.
|
|
90
|
+
const parked = localPrivateKey(root);
|
|
91
|
+
if (parked) {
|
|
92
|
+
const sent = await setSecret(root, PRIVATE_KEY_VAR, parked);
|
|
93
|
+
if (!sent.ok) {
|
|
94
|
+
p.cancel(`GitHub refused the secret: ${sent.error ?? "unknown error"}`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const publicKey = await publicKeyFor(parked);
|
|
98
|
+
if (publicKey) committedPublicKeyInto(root, publicKey);
|
|
99
|
+
// Only now, once GitHub has it: deleting first would lose the key
|
|
100
|
+
// outright if the upload failed.
|
|
101
|
+
rmSync(join(root, LOCAL_KEY_FILE), { force: true });
|
|
102
|
+
p.log.success(`${color.green("✓")} the key waiting in ${LOCAL_KEY_FILE} moved to GitHub, and the file deleted`);
|
|
103
|
+
} else {
|
|
104
|
+
const made = await provisionKey(project);
|
|
105
|
+
if (!made.ok) {
|
|
106
|
+
p.cancel(made.reason);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
committedPublicKeyInto(root, made.publicKey);
|
|
110
|
+
p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
|
|
90
111
|
}
|
|
91
|
-
committedPublicKeyInto(root, made.publicKey);
|
|
92
|
-
p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
|
|
93
112
|
}
|
|
94
113
|
|
|
95
114
|
// 3. The Cloudflare token — the one thing that cannot be derived.
|
package/src/deploy/secrets.ts
CHANGED
|
@@ -141,6 +141,26 @@ export function plaintextSecretNames(root: string): string[] {
|
|
|
141
141
|
return bare;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The declared values that are NOT encrypted, with their values.
|
|
146
|
+
*
|
|
147
|
+
* Separate from `plaintextSecretNames` on purpose: names are safe to print
|
|
148
|
+
* and values never are, so the function that returns values is the one you
|
|
149
|
+
* have to go looking for.
|
|
150
|
+
*/
|
|
151
|
+
export function plaintextSecretEntries(root: string): Array<{ name: string; value: string }> {
|
|
152
|
+
const path = join(root, SECRETS_FILE);
|
|
153
|
+
if (!existsSync(path)) return [];
|
|
154
|
+
const out: Array<{ name: string; value: string }> = [];
|
|
155
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
156
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
157
|
+
if (!match || match[1]!.startsWith("DOTENV_")) continue;
|
|
158
|
+
const value = match[2]!.trim().replace(/^['"]|['"]$/g, "");
|
|
159
|
+
if (value && value !== "unset" && !value.startsWith("encrypted:")) out.push({ name: match[1]!, value });
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
144
164
|
export interface DecryptedSecrets {
|
|
145
165
|
/** A temp file in `.env` format, for `wrangler deploy --secrets-file`. */
|
|
146
166
|
path: string;
|
|
@@ -230,6 +250,7 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
|
|
|
230
250
|
|
|
231
251
|
if (args.includes("--init")) return initSecrets(project);
|
|
232
252
|
if (args[0] === "set") return setSecretValue(project, args.slice(1));
|
|
253
|
+
if (args.includes("--sync")) return syncSecrets(project);
|
|
233
254
|
|
|
234
255
|
if (!existsSync(join(root, SECRETS_FILE))) {
|
|
235
256
|
console.log(
|
|
@@ -446,3 +467,50 @@ function upsertLine(path: string, name: string, value: string): void {
|
|
|
446
467
|
const pattern = new RegExp(`^\\s*${name}\\s*=.*$`, "m");
|
|
447
468
|
writeFileSync(path, pattern.test(body) ? body.replace(pattern, `${name}=${value}`) : `${body.replace(/\n*$/, "\n")}${name}=${value}\n`);
|
|
448
469
|
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* `vc secrets --sync` — add the keys this shop needs and does not declare.
|
|
474
|
+
*
|
|
475
|
+
* The manifest changes: a carrier is swapped, a plugin is added, and the set
|
|
476
|
+
* of required secrets moves with it. Without this the only way to declare a
|
|
477
|
+
* new one is to remember its exact name and type it in by hand, which is how
|
|
478
|
+
* a shop ends up deploying without a key it needed.
|
|
479
|
+
*
|
|
480
|
+
* Strictly additive. It never deletes a key that is no longer required,
|
|
481
|
+
* because "no longer required" and "safe to throw away" are different
|
|
482
|
+
* claims — the value may still be wanted, and it is not this command's to
|
|
483
|
+
* destroy. Extras are reported instead.
|
|
484
|
+
*/
|
|
485
|
+
async function syncSecrets(project: Project): Promise<number> {
|
|
486
|
+
const root = project.root;
|
|
487
|
+
const path = join(root, SECRETS_FILE);
|
|
488
|
+
if (!existsSync(path)) {
|
|
489
|
+
console.error(`\nvc: no ${SECRETS_FILE} yet. \`vc secrets --init\` writes one.\n`);
|
|
490
|
+
return 1;
|
|
491
|
+
}
|
|
492
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
493
|
+
const declared = declaredSecretNames(root);
|
|
494
|
+
const missing = required.filter((key) => !declared.has(key.key));
|
|
495
|
+
const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
|
|
496
|
+
|
|
497
|
+
if (missing.length > 0) {
|
|
498
|
+
const body = readFileSync(path, "utf8").replace(/\n*$/, "\n");
|
|
499
|
+
const added = missing.flatMap((key) => ["", `# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`]);
|
|
500
|
+
writeFileSync(path, `${body}${added.join("\n")}\n`);
|
|
501
|
+
console.log(`\n${color.green("+")} ${missing.length} key${missing.length === 1 ? "" : "s"} added to ${SECRETS_FILE}, as \`unset\`:\n`);
|
|
502
|
+
for (const key of missing) console.log(` ${color.cyan(`vc secrets set ${key.key}`)} ${color.dim(key.where ?? "")}`);
|
|
503
|
+
console.log("");
|
|
504
|
+
} else {
|
|
505
|
+
console.log(`\n${color.green("✓")} every secret this shop needs is already declared.\n`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (extra.length > 0) {
|
|
509
|
+
console.log(
|
|
510
|
+
`${color.yellow("!")} ${extra.length} declared key${extra.length === 1 ? " is" : "s are"} no longer required by this shop:\n` +
|
|
511
|
+
` ${extra.join("\n ")}\n\n` +
|
|
512
|
+
color.dim(" Left alone — they cost nothing, and deleting a value you may still want\n is not this command's call. Remove them by hand when you are sure.\n"),
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
package/src/generate/index.ts
CHANGED
|
@@ -374,9 +374,17 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
|
|
|
374
374
|
root,
|
|
375
375
|
".husky/pre-commit",
|
|
376
376
|
`#!/usr/bin/env sh
|
|
377
|
-
# Generated by \`vc init\`.
|
|
378
|
-
#
|
|
379
|
-
#
|
|
377
|
+
# Generated by \`vc init\`.
|
|
378
|
+
#
|
|
379
|
+
# Encrypts any value sitting in the clear in .env.secrets, and re-stages the
|
|
380
|
+
# file so the COMMIT carries the ciphertext rather than what you staged.
|
|
381
|
+
# Encryption needs only the public key in that file, so this needs no
|
|
382
|
+
# credential and works on a fresh clone.
|
|
383
|
+
#
|
|
384
|
+
# It refuses only when it cannot fix the problem itself: no key yet
|
|
385
|
+
# (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
|
|
386
|
+
# committed in the clear cannot be un-committed — the value stays in the
|
|
387
|
+
# history and has to be treated as burned.
|
|
380
388
|
bunx vc guard
|
|
381
389
|
`,
|
|
382
390
|
result,
|