@saastemly/voidcommerce 0.3.0 → 0.5.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.
@@ -0,0 +1,276 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import color from "picocolors";
4
+ import type { Project } from "../project";
5
+ import { ghAuth, repoSlug, secretNames, setSecret } from "./github";
6
+ import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, findDotenvx, run } from "./secrets";
7
+
8
+ /**
9
+ * The key that encrypts this repository's secrets.
10
+ *
11
+ * ── The realisation that shaped this ─────────────────────────────────────
12
+ *
13
+ * dotenvx is ASYMMETRIC. Encryption takes the public key; only decryption
14
+ * takes the private one. The public key is committed at the top of
15
+ * `.env.secrets`, so ADDING OR CHANGING A SECRET NEEDS NO CREDENTIAL AT
16
+ * ALL — not a Cloudflare token, not a wrangler login, not even this file.
17
+ * Anyone who can clone the repository can set a secret in it; nobody who
18
+ * can clone it can read one.
19
+ *
20
+ * That collapses the problem. The private key is needed in exactly one
21
+ * place — wherever the deploy runs — and nowhere else, ever.
22
+ *
23
+ * ── Why the key lives in GitHub ──────────────────────────────────────────
24
+ *
25
+ * The first design derived it from a Cloudflare API token, which put the
26
+ * bootstrap in the wrong order: the token authorises a worker that does not
27
+ * exist yet, and obtaining one means `wrangler login`. It also made every
28
+ * secret unreadable the day the token rotated, and gave two admins two
29
+ * different keys.
30
+ *
31
+ * So the key is generated at random and handed to GitHub, which is where
32
+ * the deploy runs and the one place a person who can push is already
33
+ * authenticated. It is never written to disk. `gh secret set` takes it on
34
+ * stdin, GitHub encrypts it, and no API can read it back — which is the
35
+ * property that makes it a good home and, unavoidably, the property that
36
+ * makes rotation a re-key rather than a re-encrypt.
37
+ *
38
+ * ── Rotation, and why losing the key is survivable ───────────────────────
39
+ *
40
+ * Nothing can read a GitHub secret back, so re-encrypting the existing
41
+ * ciphertext needs a local copy of the old key. Usually there is none, and
42
+ * that is fine: the only reason to rotate an encryption key is that it may
43
+ * have leaked, and a key that may have leaked means the VALUES may have
44
+ * leaked. Those must be replaced at Stripe and everywhere else regardless.
45
+ * Re-entering them is not extra work — it is the work.
46
+ */
47
+
48
+ /** The public key the repository was encrypted under, from the committed file. */
49
+ export function committedPublicKey(root: string): string | null {
50
+ const path = join(root, SECRETS_FILE);
51
+ if (!existsSync(path)) return null;
52
+ const match = /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=\s*["']?([0-9a-fA-F]+)["']?/m.exec(readFileSync(path, "utf8"));
53
+ return match?.[1] ?? null;
54
+ }
55
+
56
+ /** A fresh secp256k1 pair. The private half must reach GitHub and then be forgotten. */
57
+ export async function generateKeypair(): Promise<{ publicKey: string; privateKey: string } | null> {
58
+ try {
59
+ const { keypair } = await import("@dotenvx/primitives");
60
+ return keypair();
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ export async function publicKeyFor(privateKey: string): Promise<string | null> {
67
+ try {
68
+ const { derive } = await import("@dotenvx/primitives");
69
+ return derive(privateKey);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export interface KeyState {
76
+ /** The public key in `.env.secrets`, if the file exists. */
77
+ publicKey: string | null;
78
+ /** Does GitHub hold a private key for this repository? Names only — values never come back. */
79
+ inGitHub: boolean;
80
+ /** `owner/repo`, or null when there is no GitHub remote yet. */
81
+ slug: string | null;
82
+ /** A local override, for deploying by hand without CI. */
83
+ localKey: string | null;
84
+ /** Set when the local override does not match the committed public key. */
85
+ mismatch?: string | undefined;
86
+ }
87
+
88
+ export async function keyState(project: Project): Promise<KeyState> {
89
+ const publicKey = committedPublicKey(project.root);
90
+ const slug = await repoSlug(project.root);
91
+ const names = slug ? await secretNames(project.root) : new Set<string>();
92
+ const localKey = process.env[PRIVATE_KEY_VAR] ?? null;
93
+
94
+ let mismatch: string | undefined;
95
+ if (localKey && publicKey) {
96
+ const derived = await publicKeyFor(localKey);
97
+ if (derived && derived.toLowerCase() !== publicKey.toLowerCase()) {
98
+ mismatch = `${PRIVATE_KEY_VAR} in your environment derives ${derived.slice(0, 16)}…, but ${SECRETS_FILE} was encrypted under ${publicKey.slice(0, 16)}…`;
99
+ }
100
+ }
101
+ return { publicKey, inGitHub: names.has(PRIVATE_KEY_VAR), slug, localKey, mismatch };
102
+ }
103
+
104
+ /**
105
+ * Make a key and give it to GitHub.
106
+ *
107
+ * Returns the PUBLIC key for the caller to write into `.env.secrets`. The
108
+ * private key is deliberately not returned and not logged: it exists as a
109
+ * local variable for the length of one `gh` call and then goes out of scope.
110
+ */
111
+ export async function provisionKey(project: Project): Promise<{ ok: true; publicKey: string } | { ok: false; reason: string }> {
112
+ const auth = await ghAuth(project.root);
113
+ if (!auth.ok) return { ok: false, reason: auth.reason ?? "gh is unavailable" };
114
+
115
+ const slug = await repoSlug(project.root);
116
+ if (!slug) {
117
+ return {
118
+ ok: false,
119
+ reason:
120
+ "this checkout has no GitHub repository yet, and the key is stored on the repository.\n" +
121
+ " Create one first:\n\n" +
122
+ " gh repo create --source=. --private --push\n",
123
+ };
124
+ }
125
+
126
+ const pair = await generateKeypair();
127
+ if (!pair) return { ok: false, reason: "@dotenvx/dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`" };
128
+
129
+ const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
130
+ if (!sent.ok) return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
131
+
132
+ return { ok: true, publicKey: pair.publicKey };
133
+ }
134
+
135
+ /** `vc keys` — where the key is, and what is missing. */
136
+ export async function keysCommand(project: Project, args: string[]): Promise<number> {
137
+ if (args.includes("--rotate")) return rotate(project);
138
+ if (args.includes("--init")) return initKey(project);
139
+
140
+ const state = await keyState(project);
141
+ console.log(`\nSecrets are encrypted with a public key that is COMMITTED, and read with a`);
142
+ console.log(`private key that lives only in GitHub Actions.\n`);
143
+
144
+ // "no file" and "a file with no key in it" are different problems with
145
+ // different fixes, and saying the wrong one sends a person to the wrong
146
+ // command.
147
+ const hasFile = existsSync(join(project.root, SECRETS_FILE));
148
+ const publicNote = state.publicKey
149
+ ? `${state.publicKey.slice(0, 20)}… in ${SECRETS_FILE}`
150
+ : color.dim(hasFile ? `${SECRETS_FILE} has no key yet — \`vc link\` makes one` : `no ${SECRETS_FILE} yet — \`vc secrets --init\``);
151
+ console.log(` ${state.publicKey ? color.green("✓") : color.dim("·")} public key ${publicNote}`);
152
+ console.log(
153
+ ` ${state.inGitHub ? color.green("✓") : color.red("✗")} private key ${
154
+ state.inGitHub ? `${PRIVATE_KEY_VAR} is set on ${state.slug}` : state.slug ? color.red(`not set on ${state.slug} — \`vc keys --init\``) : color.dim("no GitHub repository yet")
155
+ }`,
156
+ );
157
+
158
+ if (state.mismatch) {
159
+ console.error(`\n${color.red("✗")} ${state.mismatch}\n Unset it, or point it at the right repository.\n`);
160
+ return 1;
161
+ }
162
+
163
+ console.log(
164
+ color.dim(
165
+ `\nAdding or changing a secret needs NO credential — encryption uses the public\n` +
166
+ `key in the repository. Only the deploy decrypts, and only GitHub Actions can.\n`,
167
+ ),
168
+ );
169
+
170
+ if (!state.inGitHub && state.slug) return 1;
171
+ return 0;
172
+ }
173
+
174
+ /** Generate a key, store it on the repository, and put the public half in the file. */
175
+ async function initKey(project: Project): Promise<number> {
176
+ const state = await keyState(project);
177
+ if (state.inGitHub && state.publicKey) {
178
+ console.error(
179
+ `\nvc: ${project.root} already has a key: ${PRIVATE_KEY_VAR} on ${state.slug}, public ${state.publicKey.slice(0, 16)}….\n` +
180
+ ` Replacing it makes every value in ${SECRETS_FILE} unreadable. \`vc keys --rotate\` is the deliberate way.\n`,
181
+ );
182
+ return 1;
183
+ }
184
+
185
+ const made = await provisionKey(project);
186
+ if (!made.ok) {
187
+ console.error(`\nvc: ${made.reason}\n`);
188
+ return 1;
189
+ }
190
+
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
+ const fresh = !existsSync(join(project.root, SECRETS_FILE));
196
+ committedPublicKeyInto(project.root, made.publicKey);
197
+ console.log(
198
+ `\n${color.green("✓")} ${PRIVATE_KEY_VAR} set on ${state.slug}; public key in ${SECRETS_FILE}\n` +
199
+ (fresh ? `\n Next: ${color.cyan("vc secrets --init")} fills in the keys this shop needs.\n` : "\n"),
200
+ );
201
+ return 0;
202
+ }
203
+
204
+ /**
205
+ * Re-key. Needs the old private key locally, and says so honestly when there
206
+ * is none.
207
+ */
208
+ async function rotate(project: Project): Promise<number> {
209
+ const state = await keyState(project);
210
+ const old = state.localKey;
211
+
212
+ if (!old) {
213
+ console.error(
214
+ `\nvc: rotating needs the CURRENT private key, and GitHub cannot give it back —\n` +
215
+ ` a secret is write-only once set, which is why it is a good place to keep one.\n\n` +
216
+ ` If you have a copy:\n` +
217
+ ` ${color.cyan(`${PRIVATE_KEY_VAR}=<the old key> vc keys --rotate`)}\n\n` +
218
+ ` If you do not, re-key and enter the values again:\n` +
219
+ ` ${color.cyan(`rm ${SECRETS_FILE} && vc keys --init && vc secrets --init`)}\n\n` +
220
+ color.dim(
221
+ ` That is less painful than it sounds. The reason to rotate an encryption key\n` +
222
+ ` is that it may have leaked — and a key that may have leaked means the VALUES\n` +
223
+ ` may have leaked. Those have to be replaced at Stripe and everywhere else\n` +
224
+ ` regardless, so re-entering them is not extra work. It is the work.\n`,
225
+ ),
226
+ );
227
+ return 1;
228
+ }
229
+
230
+ if (state.mismatch) {
231
+ console.error(`\nvc: ${state.mismatch}\n That key cannot read this repository, so it cannot rotate it either.\n`);
232
+ return 1;
233
+ }
234
+
235
+ const dotenvx = findDotenvx(project.root);
236
+ if (!dotenvx) {
237
+ console.error("vc: dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`");
238
+ return 1;
239
+ }
240
+
241
+ // Decrypt in place with the old key, then hand the file a new public key
242
+ // and re-encrypt. The window where plaintext is on disk is this function.
243
+ const decrypted = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE], project.root, { [PRIVATE_KEY_VAR]: old });
244
+ if (decrypted.code !== 0) {
245
+ console.error(`\nvc: could not decrypt ${SECRETS_FILE}: ${decrypted.out.trim().split("\n").slice(-2).join(" ")}\n`);
246
+ return 1;
247
+ }
248
+
249
+ const made = await provisionKey(project);
250
+ if (!made.ok) {
251
+ console.error(
252
+ `\nvc: ${made.reason}\n\n` +
253
+ color.red(` ${SECRETS_FILE} IS NOW PLAINTEXT ON DISK and must not be committed.\n`) +
254
+ ` Re-encrypt under the old key to undo: ${color.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)}\n`,
255
+ );
256
+ return 1;
257
+ }
258
+
259
+ committedPublicKeyInto(project.root, made.publicKey);
260
+
261
+ const encrypted = await run(dotenvx, ["encrypt", "-f", SECRETS_FILE], project.root);
262
+ if (encrypted.code !== 0) {
263
+ console.error(
264
+ `\nvc: re-encryption failed: ${encrypted.out.trim().split("\n").slice(-2).join(" ")}\n` +
265
+ color.red(` ${SECRETS_FILE} IS PLAINTEXT ON DISK. Do not commit it.\n`),
266
+ );
267
+ return 1;
268
+ }
269
+
270
+ console.log(
271
+ `\n${color.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PRIVATE_KEY_VAR} replaced on ${state.slug}\n\n` +
272
+ ` Commit ${SECRETS_FILE}. The old key reads nothing in it any more.\n` +
273
+ color.yellow(` The old ciphertext is still in git history, and the old key still opens THAT.\n`),
274
+ );
275
+ return 0;
276
+ }
@@ -0,0 +1,185 @@
1
+ import color from "picocolors";
2
+ import { writeManifest } from "../manifest";
3
+ import type { Project } from "../project";
4
+ import { cloudflareAccounts, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
5
+ import { keyState, provisionKey } from "./keys";
6
+ import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, declaredSecretNames, initSecrets } from "./secrets";
7
+
8
+ /**
9
+ * `vc link` — the one manual step, done once, from the terminal.
10
+ *
11
+ * ── What this is for ─────────────────────────────────────────────────────
12
+ *
13
+ * The goal is that publishing to GitHub is the whole deploy. Everything
14
+ * else in voidcommerce gets there: the app is generated from the manifest,
15
+ * the secrets are committed as ciphertext, the workflow builds and deploys.
16
+ * One thing cannot be automated away, and it is worth being exact about
17
+ * why.
18
+ *
19
+ * **GitHub cannot mint a Cloudflare credential.** There is no OIDC or
20
+ * workload identity federation between them — the feature request has sat
21
+ * unanswered since 2025, and Cloudflare's own CI guidance still says to
22
+ * store an API token in your CI provider's secrets. The Cloudflare GitHub
23
+ * App does not help: it grants Cloudflare access to the repository, not the
24
+ * repository access to Cloudflare. Something has to authorise creating a
25
+ * database in someone's account, and only Cloudflare can issue that.
26
+ *
27
+ * So exactly one token is typed, once. This command takes it, checks it
28
+ * against the Cloudflare API before trusting it, and puts it in GitHub's
29
+ * encrypted secrets — never on disk, never in a shell profile, never in
30
+ * `~/.wrangler`. After that, `git push` is the deploy, forever.
31
+ *
32
+ * ── What it deliberately does not do ─────────────────────────────────────
33
+ *
34
+ * It does not create the token. That would need a token. The dashboard is
35
+ * the only place a first one can come from, so this prints exactly which
36
+ * permissions to tick and waits.
37
+ */
38
+
39
+ const TOKEN_SECRET = "CLOUDFLARE_API_TOKEN";
40
+ const ACCOUNT_VAR = "CLOUDFLARE_ACCOUNT_ID";
41
+
42
+ /** The scopes the deploy actually uses, and why each one is there. */
43
+ const SCOPES: Array<[string, string]> = [
44
+ ["Account · Workers Scripts: Edit", "deploy the worker"],
45
+ ["Account · D1: Edit", "create the database and apply migrations"],
46
+ ["Account · Queues: Edit", "create the order queue"],
47
+ ["Account · Workers KV Storage: Edit", "sessions and caches"],
48
+ ["Account · Workers R2 Storage: Edit", "product images"],
49
+ ["Account · Account Settings: Read", "confirm which account this is"],
50
+ ["Zone · Workers Routes: Edit", "answer on your domain"],
51
+ ["User · User Details: Read", "wrangler asks at startup"],
52
+ ];
53
+
54
+ export async function linkCommand(project: Project, args: string[]): Promise<number> {
55
+ const p = await import("@clack/prompts");
56
+ const root = project.root;
57
+ const relink = args.includes("--force");
58
+
59
+ p.intro(color.bgCyan(color.black(" vc link ")));
60
+
61
+ // 1. gh, logged in, scoped.
62
+ const auth = await ghAuth(root);
63
+ if (!auth.ok) {
64
+ p.cancel(auth.reason ?? "gh is unavailable");
65
+ return 1;
66
+ }
67
+ const slug = await repoSlug(root);
68
+ if (!slug) {
69
+ p.cancel(
70
+ "this checkout has no GitHub repository, and everything below is stored on one.\n\n" +
71
+ ` ${color.cyan("gh repo create --source=. --private --push")}\n\n` +
72
+ " then run `vc link` again.",
73
+ );
74
+ return 1;
75
+ }
76
+ p.log.success(`${color.green("✓")} ${slug}, as ${auth.user ?? "you"}`);
77
+
78
+ // 2. The encryption key. Generated here, stored on the repository, never on disk.
79
+ const keys = await keyState(project);
80
+ if (keys.inGitHub && !relink) {
81
+ p.log.info(`${PRIVATE_KEY_VAR} is already set — leaving it alone (--force replaces it, which orphans ${SECRETS_FILE})`);
82
+ } else if (keys.inGitHub && relink) {
83
+ p.log.warn(`--force: replacing ${PRIVATE_KEY_VAR} makes every value in ${SECRETS_FILE} unreadable. Use \`vc keys --rotate\` to re-encrypt instead.`);
84
+ return 1;
85
+ } else {
86
+ const made = await provisionKey(project);
87
+ if (!made.ok) {
88
+ p.cancel(made.reason);
89
+ return 1;
90
+ }
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
+ }
94
+
95
+ // 3. The Cloudflare token — the one thing that cannot be derived.
96
+ const existing = await secretNames(root);
97
+ if (existing.has(TOKEN_SECRET) && !relink) {
98
+ p.log.info(`${TOKEN_SECRET} is already set — pass --force to replace it`);
99
+ } else {
100
+ p.log.step(`A Cloudflare API token, from ${color.cyan("https://dash.cloudflare.com/profile/api-tokens")} → Create Token → Custom token`);
101
+ console.log(SCOPES.map(([scope, why]) => ` ${scope.padEnd(38)} ${color.dim(why)}`).join("\n"));
102
+ console.log(
103
+ color.dim(
104
+ `\n The token Cloudflare generates for its own Workers Builds will NOT do:\n` +
105
+ ` it has no D1 and no Queues permission, so it cannot create the database.\n`,
106
+ ),
107
+ );
108
+
109
+ const token = await p.password({
110
+ message: "Paste it (nothing is written to disk)",
111
+ validate: (input) => (input && input.length >= 20 ? undefined : "that is too short to be a Cloudflare token"),
112
+ });
113
+ if (p.isCancel(token)) {
114
+ p.cancel("nothing was stored.");
115
+ return 1;
116
+ }
117
+
118
+ const spinner = p.spinner();
119
+ spinner.start("asking Cloudflare whether that token is real");
120
+ const verified = await verifyCloudflareToken(String(token));
121
+ if (!verified.ok) {
122
+ spinner.stop(`${color.red("✗")} Cloudflare rejected it: ${verified.detail}`);
123
+ p.cancel("nothing was stored.");
124
+ return 1;
125
+ }
126
+ const accounts = await cloudflareAccounts(String(token));
127
+ spinner.stop(`${color.green("✓")} the token is active and sees ${accounts.length} account${accounts.length === 1 ? "" : "s"}`);
128
+
129
+ let accountId = project.manifest.cloudflare?.accountId ?? "";
130
+ if (accounts.length === 1) {
131
+ accountId = accounts[0]!.id;
132
+ p.log.info(`account "${accounts[0]!.name}"`);
133
+ } else if (accounts.length > 1) {
134
+ const picked = await p.select({
135
+ message: "Which account is this shop in?",
136
+ options: accounts.map((account) => ({ value: account.id, label: account.name, hint: account.id })),
137
+ });
138
+ if (p.isCancel(picked)) {
139
+ p.cancel("nothing was stored.");
140
+ return 1;
141
+ }
142
+ accountId = String(picked);
143
+ }
144
+
145
+ const stored = await setSecret(root, TOKEN_SECRET, String(token));
146
+ if (!stored.ok) {
147
+ p.cancel(`GitHub refused the secret: ${stored.error ?? "unknown error"}`);
148
+ return 1;
149
+ }
150
+ p.log.success(`${color.green("✓")} ${TOKEN_SECRET} stored on ${slug}`);
151
+
152
+ if (accountId) {
153
+ // A VARIABLE, not a secret: an account id is an identifier, and a
154
+ // readable one is easier to debug in a workflow log.
155
+ const varred = await setVariable(root, ACCOUNT_VAR, accountId);
156
+ if (varred.ok) p.log.success(`${color.green("✓")} ${ACCOUNT_VAR} set as a repository variable`);
157
+ project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
158
+ await writeManifest(root, project.manifest);
159
+ }
160
+ }
161
+
162
+ // 4. The shop's own secrets, so `vc link` leaves a repository that is
163
+ // ready rather than one that is half-configured.
164
+ if (declaredSecretNames(root).size === 0) {
165
+ p.log.step(`writing ${SECRETS_FILE}`);
166
+ await initSecrets(project);
167
+ }
168
+
169
+ // 5. What is left.
170
+ const secrets = await secretNames(root);
171
+ const vars = await variableNames(root);
172
+ p.outro(
173
+ [
174
+ `${color.bold("Ready.")} From here, ${color.cyan("git push")} is the deploy.`,
175
+ "",
176
+ ` ${secrets.has(PRIVATE_KEY_VAR) ? color.green("✓") : color.red("✗")} ${PRIVATE_KEY_VAR} ${color.dim("reads the shop's secrets")}`,
177
+ ` ${secrets.has(TOKEN_SECRET) ? color.green("✓") : color.red("✗")} ${TOKEN_SECRET} ${color.dim("deploys to Cloudflare")}`,
178
+ ` ${vars.has(ACCOUNT_VAR) ? color.green("✓") : color.dim("·")} ${ACCOUNT_VAR} ${color.dim("which account")}`,
179
+ "",
180
+ ` Set the shop's own secrets with ${color.cyan("vc secrets set KEY")} — that needs no`,
181
+ ` credential at all, because encryption uses the public key in the repository.`,
182
+ ].join("\n"),
183
+ );
184
+ return 0;
185
+ }