@saastemly/voidcommerce 0.2.1 → 0.3.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 +34 -1
- package/dist/deploy/cloudflare.d.ts +3 -1
- package/dist/deploy/index.d.ts +3 -0
- package/dist/deploy/preflight.d.ts +2 -0
- package/dist/deploy/secrets.d.ts +68 -0
- package/dist/{index-xdsp97e4.js → index-84wq4qp8.js} +313 -76
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli.ts +2 -1
- package/src/deploy/cloudflare.ts +25 -3
- package/src/deploy/index.ts +35 -0
- package/src/deploy/preflight.ts +38 -3
- package/src/deploy/secrets.ts +244 -0
- package/src/dist.ts +3 -1
- package/src/generate/ci.ts +30 -5
- package/src/generate/index.ts +9 -1
- package/src/generate/strict.ts +3 -0
- package/src/generate/support.ts +2 -1
- package/src/help.ts +1 -0
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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/cli.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { deployCommand, deployHelp, preflightCommand, preflightHelp } from "./deploy/index";
|
|
1
|
+
import { deployCommand, deployHelp, preflightCommand, preflightHelp, secretsCliCommand, secretsHelp } from "./deploy/index";
|
|
2
2
|
import { distCommand, distHelp } from "./dist";
|
|
3
3
|
import { importCommand, importHelp } from "./import";
|
|
4
4
|
import { fullHelp, initHelp, version } from "./help";
|
|
@@ -38,6 +38,7 @@ export const EXTENDED: Record<string, Extended> = {
|
|
|
38
38
|
import: { run: importCommand, help: importHelp },
|
|
39
39
|
deploy: { run: deployCommand, help: deployHelp },
|
|
40
40
|
preflight: { run: preflightCommand, help: preflightHelp },
|
|
41
|
+
secrets: { run: secretsCliCommand, help: secretsHelp },
|
|
41
42
|
dev: { run: appScript("dev"), help: appScriptHelp("dev") },
|
|
42
43
|
build: { run: appScript("build"), help: appScriptHelp("build") },
|
|
43
44
|
preview: { run: appScript("preview"), help: appScriptHelp("preview") },
|
package/src/deploy/cloudflare.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { hasFrontend, workerHosts, writeManifest } from "../manifest";
|
|
|
6
6
|
import type { Project } from "../project";
|
|
7
7
|
import { parseJsonc, upsertJsonc } from "./jsonc";
|
|
8
8
|
import { preflight, printPreflight } from "./preflight";
|
|
9
|
+
import { SECRETS_FILE, decryptSecrets } from "./secrets";
|
|
9
10
|
import { ensureD1, ensureQueue, findWrangler, whoami, wrangler } from "./wrangler";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -25,7 +26,9 @@ import { ensureD1, ensureQueue, findWrangler, whoami, wrangler } from "./wrangle
|
|
|
25
26
|
* secret-class key and every `unset` is removed from dist/ssr/wrangler.json
|
|
26
27
|
* before it is uploaded; the worker reads those from `wrangler secret`.
|
|
27
28
|
* 6. apply the committed migrations to the remote D1
|
|
28
|
-
* 7.
|
|
29
|
+
* 7. decrypt the repository's own secrets, if it keeps any
|
|
30
|
+
* 8. wrangler deploy, on exactly the config that was scrubbed, carrying
|
|
31
|
+
* those secrets up with the version
|
|
29
32
|
*/
|
|
30
33
|
|
|
31
34
|
export interface CloudflareOptions {
|
|
@@ -153,9 +156,28 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
|
|
|
153
156
|
const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
|
|
154
157
|
if (migrated.code !== 0) return fail(`applying migrations failed (exit ${migrated.code}); nothing was deployed.`);
|
|
155
158
|
|
|
156
|
-
|
|
159
|
+
/**
|
|
160
|
+
* 7. Secrets, from the repository, in the same operation as the deploy.
|
|
161
|
+
*
|
|
162
|
+
* `--secrets-file` applies ADDITIVELY: a secret this file does not name is
|
|
163
|
+
* left alone rather than deleted, so a shop that sets one by hand does not
|
|
164
|
+
* lose it the next time this runs. The plaintext exists only as a
|
|
165
|
+
* 0600 temp file for the length of the upload.
|
|
166
|
+
*/
|
|
167
|
+
const secretArgs: string[] = [];
|
|
168
|
+
let cleanupSecrets: (() => void) | undefined;
|
|
169
|
+
if (existsSync(join(project.root, SECRETS_FILE))) {
|
|
170
|
+
const decrypted = await decryptSecrets(project);
|
|
171
|
+
if ("error" in decrypted) return fail(`the repository's secrets could not be read: ${decrypted.error}`);
|
|
172
|
+
secretArgs.push("--secrets-file", decrypted.path);
|
|
173
|
+
cleanupSecrets = decrypted.cleanup;
|
|
174
|
+
console.log(`${color.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 8. deploy exactly what was scrubbed.
|
|
157
178
|
console.log("\n▸ wrangler deploy -c dist/ssr/wrangler.json");
|
|
158
|
-
const deployed = await wrangler(bin, ["deploy", "-c", join("dist", "ssr", "wrangler.json")], app, true);
|
|
179
|
+
const deployed = await wrangler(bin, ["deploy", "-c", join("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
|
|
180
|
+
cleanupSecrets?.();
|
|
159
181
|
if (deployed.code !== 0) return fail(`wrangler deploy failed (exit ${deployed.code}).`);
|
|
160
182
|
|
|
161
183
|
const domain = project.manifest.shop.domain;
|
package/src/deploy/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { findProject } from "../project";
|
|
|
4
4
|
import { captureVoid, runVoid } from "../void";
|
|
5
5
|
import { deployCloudflare } from "./cloudflare";
|
|
6
6
|
import { preflight, printPreflight } from "./preflight";
|
|
7
|
+
import { PRIVATE_KEY_VAR, SECRETS_FILE, secretsCommand } from "./secrets";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* `vc deploy` extends `void deploy`: vc's preflight first, then void's
|
|
@@ -99,3 +100,37 @@ export async function preflightHelp(): Promise<number> {
|
|
|
99
100
|
);
|
|
100
101
|
return 0;
|
|
101
102
|
}
|
|
103
|
+
|
|
104
|
+
/** `vc secrets` — what the repository declares, and whether it is encrypted. */
|
|
105
|
+
export async function secretsCliCommand(args: string[]): Promise<number> {
|
|
106
|
+
const project = await findProject();
|
|
107
|
+
if (!project) {
|
|
108
|
+
console.error("vc: no voidcommerce.json here.");
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
return secretsCommand(project, args);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function secretsHelp(): Promise<number> {
|
|
115
|
+
const width = 80;
|
|
116
|
+
console.log(
|
|
117
|
+
box("vc secrets", [
|
|
118
|
+
line(`Secrets that live in the repository, encrypted, in ${SECRETS_FILE}.`, width),
|
|
119
|
+
line("", width),
|
|
120
|
+
...row("vc secrets", "what this shop needs, and whether it is declared and encrypted", width, 2),
|
|
121
|
+
...row("vc secrets --init", `write ${SECRETS_FILE} with every required key as \`unset\``, width, 2),
|
|
122
|
+
...row(`dotenvx set KEY '…' -f ${SECRETS_FILE}`, "set one, without decrypting the file", width, 2),
|
|
123
|
+
...row(`dotenvx encrypt -f ${SECRETS_FILE}`, "encrypt anything still in the clear", width, 2),
|
|
124
|
+
line("", width),
|
|
125
|
+
line("The file is COMMITTED: values are ciphertext, key names are not, so a", width),
|
|
126
|
+
line("diff shows which secret changed without showing what it changed to.", width),
|
|
127
|
+
line(`The private key stays out — .env.keys locally, and ${PRIVATE_KEY_VAR}`, width),
|
|
128
|
+
line("as one build variable where the deploy runs.", width),
|
|
129
|
+
line("", width),
|
|
130
|
+
line("Tradeoff worth knowing: ciphertext in git is permanent, so a leaked key", width),
|
|
131
|
+
line("exposes rotated secrets too. `wrangler secret put` does not have that", width),
|
|
132
|
+
line("property, and stays available for anything you would rather keep out.", width),
|
|
133
|
+
], width),
|
|
134
|
+
);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
package/src/deploy/preflight.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { workerHosts } from "../manifest";
|
|
|
7
7
|
import type { Project } from "../project";
|
|
8
8
|
import { captureVoid } from "../void";
|
|
9
9
|
import { parseJsonc } from "./jsonc";
|
|
10
|
+
import { SECRETS_FILE, declaredSecretNames, plaintextSecretNames } from "./secrets";
|
|
10
11
|
import { findWrangler, secretNames } from "./wrangler";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -28,6 +29,8 @@ export interface Preflight {
|
|
|
28
29
|
remote: Set<string> | null;
|
|
29
30
|
missing: EnvKey[];
|
|
30
31
|
routeProblem: string | null;
|
|
32
|
+
/** Names in `.env.secrets` whose value was committed unencrypted. */
|
|
33
|
+
bareSecrets: string[];
|
|
31
34
|
ready: boolean;
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -86,12 +89,29 @@ export async function preflight(project: Project, source: SecretSource): Promise
|
|
|
86
89
|
}
|
|
87
90
|
for (const name of remote ?? []) present.set(name, "<secret>");
|
|
88
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A secret the REPOSITORY declares counts as present, because the deploy
|
|
94
|
+
* uploads it. The names are readable without the key — dotenvx encrypts
|
|
95
|
+
* values, not keys — so this works on a machine that cannot decrypt.
|
|
96
|
+
*/
|
|
97
|
+
for (const name of declaredSecretNames(project.root)) present.set(name, "<in the repository>");
|
|
98
|
+
|
|
89
99
|
const missing = allEnvKeys(project.manifest).filter((key) => {
|
|
90
100
|
const value = present.get(key.key);
|
|
91
101
|
return value === undefined || value === "" || value === UNSET;
|
|
92
102
|
});
|
|
93
103
|
const problem = routeProblem(project);
|
|
94
|
-
|
|
104
|
+
// A value committed in the clear is worse than a missing one: it is a
|
|
105
|
+
// leak that looks like it is working.
|
|
106
|
+
const bare = plaintextSecretNames(project.root);
|
|
107
|
+
return {
|
|
108
|
+
present,
|
|
109
|
+
remote,
|
|
110
|
+
missing,
|
|
111
|
+
routeProblem: problem,
|
|
112
|
+
bareSecrets: bare,
|
|
113
|
+
ready: missing.length === 0 && problem === null && bare.length === 0,
|
|
114
|
+
};
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
/** Print the report a person can act on. */
|
|
@@ -120,6 +140,12 @@ export function printPreflight(project: Project, result: Preflight, source: Secr
|
|
|
120
140
|
if (result.routeProblem) {
|
|
121
141
|
console.log(`${color.red("✗")} the worker's hostname\n ${result.routeProblem}\n`);
|
|
122
142
|
}
|
|
143
|
+
if (result.bareSecrets.length > 0) {
|
|
144
|
+
console.log(
|
|
145
|
+
`${color.red("✗")} committed in the clear in ${SECRETS_FILE}: ${result.bareSecrets.join(", ")}\n` +
|
|
146
|
+
` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` — and treat those values as burned.\n`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
123
149
|
for (const key of result.missing) {
|
|
124
150
|
console.log(`${color.red("✗")} ${key.key}`);
|
|
125
151
|
console.log(` ${key.breaks}`);
|
|
@@ -128,8 +154,17 @@ export function printPreflight(project: Project, result: Preflight, source: Secr
|
|
|
128
154
|
}
|
|
129
155
|
const secrets = result.missing.filter((key) => !key.plaintext);
|
|
130
156
|
if (secrets.length > 0) {
|
|
131
|
-
|
|
132
|
-
|
|
157
|
+
const inRepo = existsSync(join(project.root, SECRETS_FILE));
|
|
158
|
+
console.log(
|
|
159
|
+
inRepo
|
|
160
|
+
? `Put each in ${SECRETS_FILE}, which the deploy uploads:`
|
|
161
|
+
: source === "wrangler"
|
|
162
|
+
? "Set each secret on the worker (this also creates the draft worker on a first deploy):"
|
|
163
|
+
: "Set each secret on the project:",
|
|
164
|
+
);
|
|
165
|
+
for (const key of secrets) {
|
|
166
|
+
console.log(inRepo ? ` dotenvx set ${key.key} '…' -f ${SECRETS_FILE}` : ` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
|
|
167
|
+
}
|
|
133
168
|
}
|
|
134
169
|
const plain = result.missing.filter((key) => key.plaintext);
|
|
135
170
|
if (plain.length > 0) console.log(`Values safe to commit go in .env.production: ${plain.map((k) => k.key).join(", ")}`);
|
|
@@ -0,0 +1,244 @@
|
|
|
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 type { Project } from "../project";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Secrets that live in the repository, encrypted.
|
|
11
|
+
*
|
|
12
|
+
* ── The problem this solves ──────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* A shop's secrets were set by hand, one `wrangler secret put` at a time,
|
|
15
|
+
* on a machine that happened to have them. Nothing in the repository said
|
|
16
|
+
* what the values WERE, only what they were called, so the shop could not
|
|
17
|
+
* be rebuilt from a checkout and a new deploy target needed somebody to
|
|
18
|
+
* remember. That is the last manual step between "push" and "live".
|
|
19
|
+
*
|
|
20
|
+
* dotenvx closes it. `.env.secrets` holds ciphertext and a public key, and
|
|
21
|
+
* it is COMMITTED: the key names are readable, the values are not, and a
|
|
22
|
+
* diff shows when a secret changed without showing what it changed to. The
|
|
23
|
+
* private key is the one thing that stays out — a single value, set once
|
|
24
|
+
* where the deploy runs.
|
|
25
|
+
*
|
|
26
|
+
* ── Why the values still become worker SECRETS ───────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* The lazy version of this would decrypt at build time and let the values
|
|
29
|
+
* land in the worker's `vars`. That undoes the thing we built the scrub
|
|
30
|
+
* step for: `vars` are plaintext in the deployed configuration and readable
|
|
31
|
+
* by anyone with dashboard access. So the plaintext exists only inside the
|
|
32
|
+
* build, in a temp file, and goes up through `wrangler deploy
|
|
33
|
+
* --secrets-file`, which stores them as real secrets and — importantly —
|
|
34
|
+
* applies ADDITIVELY, so a secret this file does not name is left alone
|
|
35
|
+
* rather than deleted.
|
|
36
|
+
*
|
|
37
|
+
* ── The tradeoff, stated ─────────────────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* Ciphertext in git is permanent. If the private key ever leaks, every
|
|
40
|
+
* secret in the history is readable, including ones that were rotated —
|
|
41
|
+
* which is not true of `wrangler secret put`, where a rotation genuinely
|
|
42
|
+
* retires the old value. Rotating the dotenvx key re-encrypts the present,
|
|
43
|
+
* not the past. That is the price of a shop that rebuilds from a checkout,
|
|
44
|
+
* and it should be a decision rather than a surprise.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export const SECRETS_FILE = ".env.secrets";
|
|
48
|
+
export const PRIVATE_KEY_VAR = "DOTENV_PRIVATE_KEY_SECRETS";
|
|
49
|
+
|
|
50
|
+
/** dotenvx, from the project's own install rather than a global one. */
|
|
51
|
+
export function findDotenvx(from: string): string | null {
|
|
52
|
+
let dir = from;
|
|
53
|
+
for (;;) {
|
|
54
|
+
const local = join(dir, "node_modules", ".bin", "dotenvx");
|
|
55
|
+
if (existsSync(local)) return local;
|
|
56
|
+
const parent = dirname(dir);
|
|
57
|
+
if (parent === dir) break;
|
|
58
|
+
dir = parent;
|
|
59
|
+
}
|
|
60
|
+
return (process.env["PATH"] ?? "")
|
|
61
|
+
.split(delimiter)
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.some((entry) => existsSync(join(entry, "dotenvx")))
|
|
64
|
+
? "dotenvx"
|
|
65
|
+
: null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function run(cmd: string, args: string[], cwd: string, env: Record<string, string> = {}): Promise<{ code: number; out: string }> {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
|
|
71
|
+
let out = "";
|
|
72
|
+
child.stdout.on("data", (chunk) => {
|
|
73
|
+
out += chunk;
|
|
74
|
+
});
|
|
75
|
+
child.stderr.on("data", (chunk) => {
|
|
76
|
+
out += chunk;
|
|
77
|
+
});
|
|
78
|
+
child.on("error", (error) => resolve({ code: 1, out: String(error) }));
|
|
79
|
+
child.on("exit", (code) => resolve({ code: code ?? 1, out }));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The names a `.env.secrets` declares, read WITHOUT decrypting.
|
|
85
|
+
*
|
|
86
|
+
* dotenvx leaves keys in plaintext and encrypts only values, so preflight
|
|
87
|
+
* can say "all five are declared" on a machine that cannot read any of them.
|
|
88
|
+
*/
|
|
89
|
+
export function declaredSecretNames(root: string): Set<string> {
|
|
90
|
+
const path = join(root, SECRETS_FILE);
|
|
91
|
+
if (!existsSync(path)) return new Set();
|
|
92
|
+
const names = new Set<string>();
|
|
93
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
94
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
|
|
95
|
+
if (match && !match[1]!.startsWith("DOTENV_")) names.add(match[1]!);
|
|
96
|
+
}
|
|
97
|
+
return names;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Is a declared value actually encrypted, or was it committed in the clear? */
|
|
101
|
+
export function plaintextSecretNames(root: string): string[] {
|
|
102
|
+
const path = join(root, SECRETS_FILE);
|
|
103
|
+
if (!existsSync(path)) return [];
|
|
104
|
+
const bare: string[] = [];
|
|
105
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
106
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
107
|
+
if (!match || match[1]!.startsWith("DOTENV_")) continue;
|
|
108
|
+
const value = match[2]!.trim().replace(/^['"]|['"]$/g, "");
|
|
109
|
+
// `unset` is the documented placeholder, not a leak.
|
|
110
|
+
if (value && value !== "unset" && !value.startsWith("encrypted:")) bare.push(match[1]!);
|
|
111
|
+
}
|
|
112
|
+
return bare;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface DecryptedSecrets {
|
|
116
|
+
/** A temp file in `.env` format, for `wrangler deploy --secrets-file`. */
|
|
117
|
+
path: string;
|
|
118
|
+
names: string[];
|
|
119
|
+
cleanup: () => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Decrypt into a temp file the deploy can hand to wrangler.
|
|
124
|
+
*
|
|
125
|
+
* Written to disk rather than piped because that is what `--secrets-file`
|
|
126
|
+
* takes, and removed by `cleanup()` whatever happens next.
|
|
127
|
+
*/
|
|
128
|
+
export async function decryptSecrets(project: Project): Promise<DecryptedSecrets | { error: string }> {
|
|
129
|
+
const root = project.root;
|
|
130
|
+
if (!existsSync(join(root, SECRETS_FILE))) return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
|
|
131
|
+
|
|
132
|
+
const dotenvx = findDotenvx(root);
|
|
133
|
+
if (!dotenvx) return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
|
|
134
|
+
if (!process.env[PRIVATE_KEY_VAR] && !existsSync(join(root, ".env.keys"))) {
|
|
135
|
+
return {
|
|
136
|
+
error: `${PRIVATE_KEY_VAR} is not set and there is no .env.keys here.\n It is the one value that stays out of the repository; set it where the deploy runs.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root);
|
|
141
|
+
if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
|
|
142
|
+
|
|
143
|
+
// Keep only the shop's own keys: dotenvx echoes the public key back, and
|
|
144
|
+
// it has no business being a worker secret.
|
|
145
|
+
const lines = result.out
|
|
146
|
+
.split("\n")
|
|
147
|
+
.filter((line) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) && !line.trimStart().startsWith("DOTENV_"));
|
|
148
|
+
const names = lines.map((line) => line.slice(0, line.indexOf("=")).trim());
|
|
149
|
+
|
|
150
|
+
const path = join(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
|
|
151
|
+
writeFileSync(path, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
152
|
+
return {
|
|
153
|
+
path,
|
|
154
|
+
names,
|
|
155
|
+
cleanup: () => {
|
|
156
|
+
try {
|
|
157
|
+
writeFileSync(path, "", { mode: 0o600 });
|
|
158
|
+
// Overwrite before unlinking: the plaintext existed on disk and
|
|
159
|
+
// should stop existing there promptly.
|
|
160
|
+
require("node:fs").unlinkSync(path);
|
|
161
|
+
} catch {
|
|
162
|
+
// Already gone, or never written. Nothing to do.
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `vc secrets` — what the repository declares, and whether it is readable here. */
|
|
169
|
+
export async function secretsCommand(project: Project, args: string[]): Promise<number> {
|
|
170
|
+
const root = project.root;
|
|
171
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
172
|
+
const declared = declaredSecretNames(root);
|
|
173
|
+
const bare = plaintextSecretNames(root);
|
|
174
|
+
|
|
175
|
+
if (args.includes("--init")) return initSecrets(project);
|
|
176
|
+
|
|
177
|
+
if (!existsSync(join(root, SECRETS_FILE))) {
|
|
178
|
+
console.log(
|
|
179
|
+
`\n${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,\n` +
|
|
180
|
+
`which means the shop cannot be rebuilt from a checkout.\n\n` +
|
|
181
|
+
` vc secrets --init write one, with every required key as \`unset\`\n`,
|
|
182
|
+
);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log(`\n${SECRETS_FILE} — committed, encrypted, and read by \`vc deploy --cloudflare\`\n`);
|
|
187
|
+
for (const key of required) {
|
|
188
|
+
const state = declared.has(key.key) ? color.green("declared") : color.red("MISSING ");
|
|
189
|
+
console.log(` ${state} ${key.key}`);
|
|
190
|
+
}
|
|
191
|
+
const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
|
|
192
|
+
for (const name of extra) console.log(` ${color.dim("extra ")} ${name} ${color.dim("— not required by this shop")}`);
|
|
193
|
+
|
|
194
|
+
if (bare.length > 0) {
|
|
195
|
+
console.log(
|
|
196
|
+
`\n${color.red("✗")} ${bare.length} value${bare.length === 1 ? " is" : "s are"} committed IN THE CLEAR: ${bare.join(", ")}\n` +
|
|
197
|
+
` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` before committing again.\n`,
|
|
198
|
+
);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
const missing = required.filter((key) => !declared.has(key.key));
|
|
202
|
+
if (missing.length > 0) {
|
|
203
|
+
console.log(
|
|
204
|
+
`\n${color.red("✗")} ${missing.length} required secret${missing.length === 1 ? "" : "s"} not declared.\n` +
|
|
205
|
+
` dotenvx set ${missing[0]!.key} '…' -f ${SECRETS_FILE}\n`,
|
|
206
|
+
);
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
console.log(`\n${color.green("✓")} every secret this shop needs is declared and encrypted.\n`);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Write a `.env.secrets` with every required key as the `unset` sentinel. */
|
|
214
|
+
async function initSecrets(project: Project): Promise<number> {
|
|
215
|
+
const path = join(project.root, SECRETS_FILE);
|
|
216
|
+
if (existsSync(path)) {
|
|
217
|
+
console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
221
|
+
const body = [
|
|
222
|
+
"# Secrets, encrypted, and COMMITTED.",
|
|
223
|
+
"#",
|
|
224
|
+
"# Values are ciphertext; the key names are readable so a diff shows WHICH",
|
|
225
|
+
"# secret changed without showing what it changed to. `unset` is the",
|
|
226
|
+
"# documented placeholder and preflight refuses it.",
|
|
227
|
+
"#",
|
|
228
|
+
"# The private key stays out of the repository. It lives in .env.keys",
|
|
229
|
+
"# locally (gitignored) and as one build variable where the deploy runs.",
|
|
230
|
+
"",
|
|
231
|
+
...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""]),
|
|
232
|
+
].join("\n");
|
|
233
|
+
writeFileSync(path, body, { mode: 0o600 });
|
|
234
|
+
|
|
235
|
+
console.log(
|
|
236
|
+
`\n${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`\n\n` +
|
|
237
|
+
`Next:\n` +
|
|
238
|
+
` 1. put the real values in, then\n` +
|
|
239
|
+
` 2. ${color.cyan(`dotenvx encrypt -f ${SECRETS_FILE}`)}\n` +
|
|
240
|
+
` 3. commit ${SECRETS_FILE}; never commit .env.keys\n` +
|
|
241
|
+
` 4. set ${PRIVATE_KEY_VAR} where the deploy runs\n`,
|
|
242
|
+
);
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
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
|
}
|
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
|
@@ -326,7 +326,15 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
|
|
|
326
326
|
);
|
|
327
327
|
// `.void` and `dist` are the STOREFRONT's build artifacts at the root now;
|
|
328
328
|
// `.vc` holds the generated worker and its own.
|
|
329
|
-
|
|
329
|
+
// `.env.keys` is the ONE thing that must never be committed: it is the
|
|
330
|
+
// authority to decrypt everything in `.env.secrets`, which is.
|
|
331
|
+
await put(
|
|
332
|
+
root,
|
|
333
|
+
".gitignore",
|
|
334
|
+
"node_modules\n.vc\n.void\n.wrangler\n.env\n.env.local\n.env.keys\ndist\n*.tsbuildinfo\n.DS_Store\n",
|
|
335
|
+
result,
|
|
336
|
+
"own",
|
|
337
|
+
);
|
|
330
338
|
await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
|
|
331
339
|
await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
|
|
332
340
|
await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
|
package/src/generate/strict.ts
CHANGED
|
@@ -63,6 +63,9 @@ 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",
|
|
66
69
|
"@rolldown/plugin-babel",
|
|
67
70
|
"@tailwindcss/vite",
|
|
68
71
|
"@types/node",
|
package/src/generate/support.ts
CHANGED
|
@@ -182,7 +182,8 @@ export const emailNotifications = (emailer: Emailer): NotificationProvider => ({
|
|
|
182
182
|
* because the choice lives on the line, not in the catalogue.
|
|
183
183
|
*/
|
|
184
184
|
export function renderMintTs(): string {
|
|
185
|
-
return `import {
|
|
185
|
+
return `import { getCurrentAuthContext } from "@better-auth/core/context";
|
|
186
|
+
import { type MintProvider, recordedMint } from "@saastemly/better-commerce/plugins/nft";
|
|
186
187
|
|
|
187
188
|
/**
|
|
188
189
|
* Where a token comes from.
|
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
|
];
|