@saastemly/voidcommerce 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,70 +1,68 @@
1
- import { hkdfSync } from "node:crypto";
2
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
  import color from "picocolors";
5
- import type { Manifest } from "../manifest";
6
- import { SECRETS_FILE } from "./secrets";
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
7
 
8
8
  /**
9
- * The key that encrypts the repository's secrets, DERIVED rather than stored.
9
+ * The key that encrypts this repository's secrets.
10
10
  *
11
- * ── What this buys, and what it costs ────────────────────────────────────
11
+ * ── The realisation that shaped this ─────────────────────────────────────
12
12
  *
13
- * Nothing has to be kept. There is no `.env.keys` to lose, leak, or forget to
14
- * gitignore a real failure that has already happened once here. Whoever can
15
- * deploy the worker can read its secrets, and nobody else can, which is the
16
- * property that was wanted.
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.
17
19
  *
18
- * The cost is severe and worth stating plainly, because it is invisible until
19
- * the day it is not:
20
+ * That collapses the problem. The private key is needed in exactly one
21
+ * place — wherever the deploy runs and nowhere else, ever.
20
22
  *
21
- * **Rotating the API token orphans every secret in the repository.** The
22
- * key changes with the token, and the ciphertext does not. Every
23
- * `encrypted:` value becomes permanently unreadable, and the only recovery
24
- * is to type them all in again.
23
+ * ── Why the key lives in GitHub ──────────────────────────────────────────
25
24
  *
26
- * **A second admin derives a different key.** Two people with two tokens
27
- * cannot read each other's values.
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.
28
30
  *
29
- * **The build derives from whichever token it was given**, so the token in
30
- * the build settings must be the one the values were encrypted under.
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.
31
37
  *
32
- * So this module's real job is not deriving a key — that is eight lines. It
33
- * is making those three failures LOUD. The committed `.env.secrets` carries
34
- * the public key, so the derived private key can always be checked against
35
- * what the repository was actually encrypted under, and a mismatch is caught
36
- * before anything is attempted rather than surfacing as a decryption failure
37
- * nobody can explain.
38
+ * ── Rotation, and why losing the key is survivable ───────────────────────
38
39
  *
39
- * @see https://developers.cloudflare.com/workers/wrangler/system-environment-variables/
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.
40
46
  */
41
47
 
42
- /**
43
- * Domain separation. A key derived for this purpose must not equal one
44
- * derived for any other, so the label is versioned: changing it is how a
45
- * future scheme migrates without colliding with this one.
46
- */
47
- const INFO = "voidcommerce/dotenvx/secrets/v1";
48
-
49
- /** Where the token comes from, in the order wrangler itself reads them. */
50
- export function apiToken(): string | null {
51
- return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
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;
52
54
  }
53
55
 
54
- /**
55
- * The private key this token and account derive.
56
- *
57
- * The account id is the salt rather than a constant, so the same token used
58
- * against two accounts does not produce one key — tokens are often scoped to
59
- * several accounts, and a shared key across them would be a wider blast
60
- * radius than anyone asked for.
61
- */
62
- export function derivePrivateKey(token: string, accountId: string): string {
63
- const bytes = hkdfSync("sha256", Buffer.from(token, "utf8"), Buffer.from(accountId, "utf8"), Buffer.from(INFO), 32);
64
- return Buffer.from(bytes).toString("hex");
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
+ }
65
64
  }
66
65
 
67
- /** The public key for a private one, via dotenvx's own primitive. */
68
66
  export async function publicKeyFor(privateKey: string): Promise<string | null> {
69
67
  try {
70
68
  const { derive } = await import("@dotenvx/primitives");
@@ -74,139 +72,205 @@ export async function publicKeyFor(privateKey: string): Promise<string | null> {
74
72
  }
75
73
  }
76
74
 
77
- /** The public key the repository was actually encrypted under. */
78
- export function committedPublicKey(root: string): string | null {
79
- const path = join(root, SECRETS_FILE);
80
- if (!existsSync(path)) return null;
81
- const match = /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=\s*["']?([0-9a-fA-F]+)["']?/m.exec(readFileSync(path, "utf8"));
82
- return match?.[1] ?? null;
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;
83
86
  }
84
87
 
85
- export type KeyState =
86
- | { ok: true; privateKey: string; publicKey: string; fresh: boolean }
87
- | { ok: false; reason: string };
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
+ }
88
103
 
89
104
  /**
90
- * The key for this repository, or a refusal that says exactly what is wrong.
105
+ * Make a key and give it to GitHub.
91
106
  *
92
- * `fresh` means the repository has no secrets yet, so there is nothing to
93
- * check the derivation against and any token is as good as another.
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.
94
110
  */
95
- export async function keyFor(manifest: Manifest, root: string): Promise<KeyState> {
96
- const token = apiToken();
97
- if (!token) {
98
- return {
99
- ok: false,
100
- reason:
101
- "CLOUDFLARE_API_TOKEN is not set.\n" +
102
- " The key is derived from it, so there is nothing to derive from. Note that\n" +
103
- " `wrangler login` does NOT set one — this scheme needs an API token, from\n" +
104
- " My Profile → API Tokens.",
105
- };
106
- }
107
- const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
108
- if (!accountId) {
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) {
109
117
  return {
110
118
  ok: false,
111
119
  reason:
112
- "the account is not pinned, and it salts the derivation.\n" +
113
- " Run `vc deploy --cloudflare --provision` once, or set CLOUDFLARE_ACCOUNT_ID.",
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",
114
123
  };
115
124
  }
116
125
 
117
- const privateKey = derivePrivateKey(token, accountId);
118
- const publicKey = await publicKeyFor(privateKey);
119
- if (!publicKey) {
120
- return { ok: false, reason: "@dotenvx/dotenvx is not installed here, so a key cannot be derived. `bun add -d @dotenvx/dotenvx`" };
121
- }
126
+ const pair = await generateKeypair();
127
+ if (!pair) return { ok: false, reason: "@dotenvx/dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`" };
122
128
 
123
- const committed = committedPublicKey(root);
124
- if (!committed) return { ok: true, privateKey, publicKey, fresh: true };
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"}` };
125
131
 
126
- if (committed.toLowerCase() !== publicKey.toLowerCase()) {
127
- return {
128
- ok: false,
129
- reason:
130
- `this token does not derive the key ${SECRETS_FILE} was encrypted under.\n\n` +
131
- ` encrypted under: ${committed.slice(0, 16)}…\n` +
132
- ` this token gives: ${publicKey.slice(0, 16)}…\n\n` +
133
- " That is one of three things, and all of them are the same fix:\n" +
134
- " · the token was rotated since the secrets were encrypted\n" +
135
- " · this is a different admin's token\n" +
136
- " · CLOUDFLARE_ACCOUNT_ID is not the account they were encrypted for\n\n" +
137
- ` If you still have the ORIGINAL token, \`vc keys rotate\` re-encrypts\n` +
138
- " everything under the new one. If you do not, the values are unrecoverable\n" +
139
- " and must be entered again.",
140
- };
141
- }
142
- return { ok: true, privateKey, publicKey, fresh: false };
132
+ return { ok: true, publicKey: pair.publicKey };
143
133
  }
144
134
 
145
- /** `vc keys` — what the derivation says, without printing anything secret. */
146
- export async function keysCommand(manifest: Manifest, root: string, args: string[]): Promise<number> {
147
- if (args.includes("--rotate")) return rotate(manifest, root);
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`);
148
143
 
149
- const state = await keyFor(manifest, root);
150
- console.log(`\nThe key is DERIVED from CLOUDFLARE_API_TOKEN, salted with the account id.`);
151
- console.log(color.dim("Nothing is stored, so nothing can leak — and nothing can be recovered.\n"));
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
+ );
152
157
 
153
- if (!state.ok) {
154
- console.error(`${color.red("✗")} ${state.reason}\n`);
158
+ if (state.mismatch) {
159
+ console.error(`\n${color.red("✗")} ${state.mismatch}\n Unset it, or point it at the right repository.\n`);
155
160
  return 1;
156
161
  }
157
- console.log(` derives public key ${state.publicKey.slice(0, 20)}…`);
162
+
158
163
  console.log(
159
- state.fresh
160
- ? ` ${color.dim(`${SECRETS_FILE} does not exist yet, so there is nothing to check against`)}`
161
- : ` ${color.green("✓")} matches what ${SECRETS_FILE} was encrypted under`,
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
+ ),
162
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);
163
197
  console.log(
164
- color.yellow(
165
- `\n! Rotating this API token makes every secret in ${SECRETS_FILE} unreadable.\n` +
166
- " Run `vc keys --rotate` with the NEW token exported and the old one in\n" +
167
- " CLOUDFLARE_API_TOKEN_OLD, BEFORE the old one stops working.\n",
168
- ),
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"),
169
200
  );
170
201
  return 0;
171
202
  }
172
203
 
173
204
  /**
174
- * Re-encrypt under a new token's key, while the old one still works.
175
- *
176
- * This is the escape hatch that makes a derived key survivable, and it only
177
- * exists in the window where both tokens are known. Outside that window there
178
- * is no recovery, which is why `vc keys` says so every time it runs.
205
+ * Re-key. Needs the old private key locally, and says so honestly when there
206
+ * is none.
179
207
  */
180
- async function rotate(manifest: Manifest, root: string): Promise<number> {
181
- const oldToken = process.env["CLOUDFLARE_API_TOKEN_OLD"];
182
- const newToken = apiToken();
183
- if (!oldToken || !newToken) {
208
+ async function rotate(project: Project): Promise<number> {
209
+ const state = await keyState(project);
210
+ const old = state.localKey;
211
+
212
+ if (!old) {
184
213
  console.error(
185
- "\nvc: rotation needs BOTH tokens:\n" +
186
- " CLOUDFLARE_API_TOKEN_OLD=<the one the secrets were encrypted under>\n" +
187
- " CLOUDFLARE_API_TOKEN=<the new one>\n\n" +
188
- " The old one is the only thing that can read the current values.\n",
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
+ ),
189
226
  );
190
227
  return 1;
191
228
  }
192
- const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
193
- if (!accountId) {
194
- console.error("vc: the account is not pinned, and it salts the derivation.");
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
+ );
195
256
  return 1;
196
257
  }
197
- const oldKey = derivePrivateKey(oldToken, accountId);
198
- const oldPublic = await publicKeyFor(oldKey);
199
- const committed = committedPublicKey(root);
200
- if (committed && oldPublic && committed.toLowerCase() !== oldPublic.toLowerCase()) {
201
- console.error(`\nvc: CLOUDFLARE_API_TOKEN_OLD does not derive the key ${SECRETS_FILE} was encrypted under either.\n`);
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
+ );
202
267
  return 1;
203
268
  }
269
+
204
270
  console.log(
205
- `\nRotation is a decrypt with the old key and an encrypt with the new one.\n` +
206
- `Run these two, in this order, from ${root}:\n\n` +
207
- ` ${color.cyan(`DOTENV_PRIVATE_KEY_SECRETS=<old> bunx dotenvx decrypt -f ${SECRETS_FILE}`)}\n` +
208
- ` ${color.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)} ${color.dim("# under the new derived key")}\n\n` +
209
- color.dim("vc does not run them for you: the middle state is your secrets in\nplaintext on disk, and that is a moment to be deliberate about.\n"),
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`),
210
274
  );
211
275
  return 0;
212
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
+ }