@saastemly/voidcommerce 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +269 -76
- package/dist/deploy/github.d.ts +85 -0
- package/dist/deploy/index.d.ts +3 -0
- package/dist/deploy/keys.d.ts +68 -25
- package/dist/deploy/link.d.ts +2 -0
- package/dist/deploy/secrets.d.ts +51 -8
- package/dist/generate/ci.d.ts +33 -18
- package/dist/generate/frontend.d.ts +1 -1
- package/dist/generate/index.d.ts +2 -0
- package/dist/index-2qt2yh3z.js +797 -0
- package/dist/{index-yzezvy5h.js → index-f1wds190.js} +322 -663
- package/dist/{index-pz6m2hkm.js → index-xx5p4b8d.js} +5 -4
- package/dist/index.js +12 -8
- package/dist/keys-qwvt324e.js +19 -0
- package/dist/manifest.d.ts +27 -5
- package/dist/manifest.js +3 -1
- package/package.json +1 -1
- package/src/cli.ts +14 -1
- package/src/deploy/cloudflare.ts +68 -4
- package/src/deploy/github.ts +198 -0
- package/src/deploy/index.ts +66 -18
- package/src/deploy/keys.ts +208 -144
- package/src/deploy/link.ts +185 -0
- package/src/deploy/secrets.ts +217 -35
- package/src/dotenvx-primitives.d.ts +4 -0
- package/src/generate/ci.ts +176 -121
- package/src/generate/frontend.ts +26 -4
- package/src/generate/index.ts +46 -8
- package/src/generate/support.ts +7 -5
- package/src/init.ts +2 -1
- package/src/manifest.ts +33 -8
- package/src/regenerate.ts +1 -0
- package/src/wizard.ts +7 -3
package/src/deploy/keys.ts
CHANGED
|
@@ -1,70 +1,68 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
6
|
-
import {
|
|
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
|
|
9
|
+
* The key that encrypts this repository's secrets.
|
|
10
10
|
*
|
|
11
|
-
* ──
|
|
11
|
+
* ── The realisation that shaped this ─────────────────────────────────────
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
* the
|
|
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
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
31
37
|
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
+
}
|
|
88
103
|
|
|
89
104
|
/**
|
|
90
|
-
*
|
|
105
|
+
* Make a key and give it to GitHub.
|
|
91
106
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
"
|
|
113
|
-
"
|
|
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
|
|
118
|
-
|
|
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
|
|
124
|
-
if (!
|
|
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
|
-
|
|
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` —
|
|
146
|
-
export async function keysCommand(
|
|
147
|
-
if (args.includes("--rotate")) return rotate(
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 (
|
|
154
|
-
console.error(
|
|
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
|
-
|
|
162
|
+
|
|
158
163
|
console.log(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
165
|
-
`\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-
|
|
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(
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
if (
|
|
194
|
-
console.error(
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
`\
|
|
206
|
-
`
|
|
207
|
-
|
|
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
|
+
}
|