@saastemly/voidcommerce 0.17.0 → 0.19.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,220 +0,0 @@
1
- import { rmSync } from "node:fs";
2
- import { join } from "node:path";
3
- import color from "picocolors";
4
- import { writeManifest } from "../manifest";
5
- import type { Project } from "../project";
6
- import { cloudflareAccounts, getVariable, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
7
- import { LOCAL_KEY_FILE, keyState, localPrivateKey, provisionKey, publicKeyFor } from "./keys";
8
- import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, declaredSecretNames, initSecrets } from "./secrets";
9
-
10
- /**
11
- * `vc link` — the one manual step, done once, from the terminal.
12
- *
13
- * ── What this is for ─────────────────────────────────────────────────────
14
- *
15
- * The goal is that publishing to GitHub is the whole deploy. Everything
16
- * else in voidcommerce gets there: the app is generated from the manifest,
17
- * the secrets are committed as ciphertext, the workflow builds and deploys.
18
- * One thing cannot be automated away, and it is worth being exact about
19
- * why.
20
- *
21
- * **GitHub cannot mint a Cloudflare credential.** There is no OIDC or
22
- * workload identity federation between them — the feature request has sat
23
- * unanswered since 2025, and Cloudflare's own CI guidance still says to
24
- * store an API token in your CI provider's secrets. The Cloudflare GitHub
25
- * App does not help: it grants Cloudflare access to the repository, not the
26
- * repository access to Cloudflare. Something has to authorise creating a
27
- * database in someone's account, and only Cloudflare can issue that.
28
- *
29
- * So exactly one token is typed, once. This command takes it, checks it
30
- * against the Cloudflare API before trusting it, and puts it in GitHub's
31
- * encrypted secrets — never on disk, never in a shell profile, never in
32
- * `~/.wrangler`. After that, `git push` is the deploy, forever.
33
- *
34
- * ── What it deliberately does not do ─────────────────────────────────────
35
- *
36
- * It does not create the token. That would need a token. The dashboard is
37
- * the only place a first one can come from, so this prints exactly which
38
- * permissions to tick and waits.
39
- */
40
-
41
- const TOKEN_SECRET = "CLOUDFLARE_API_TOKEN";
42
- const ACCOUNT_VAR = "CLOUDFLARE_ACCOUNT_ID";
43
-
44
- /** The scopes the deploy actually uses, and why each one is there. */
45
- const SCOPES: Array<[string, string]> = [
46
- ["Account · Workers Scripts: Edit", "deploy the worker"],
47
- ["Account · D1: Edit", "create the database and apply migrations"],
48
- ["Account · Queues: Edit", "create the order queue"],
49
- ["Account · Workers KV Storage: Edit", "sessions and caches"],
50
- ["Account · Workers R2 Storage: Edit", "product images"],
51
- ["Account · Account Settings: Read", "confirm which account this is"],
52
- ["Zone · Workers Routes: Edit", "answer on your domain"],
53
- ["Zone · Zone: Read", "confirm the domain's zone is on this account"],
54
- ["Zone · DNS: Read", "notice a hostname that already answers, before taking it over"],
55
- ["User · User Details: Read", "wrangler asks at startup"],
56
- ];
57
-
58
- export async function linkCommand(project: Project, args: string[]): Promise<number> {
59
- const p = await import("@clack/prompts");
60
- const root = project.root;
61
- const relink = args.includes("--force");
62
-
63
- p.intro(color.bgCyan(color.black(" vc link ")));
64
-
65
- // 1. gh, logged in, scoped.
66
- const auth = await ghAuth(root);
67
- if (!auth.ok) {
68
- p.cancel(auth.reason ?? "gh is unavailable");
69
- return 1;
70
- }
71
- const slug = await repoSlug(root);
72
- if (!slug) {
73
- p.cancel(
74
- "this checkout has no GitHub repository, and everything below is stored on one.\n\n" +
75
- ` ${color.cyan("gh repo create --source=. --private --push")}\n\n` +
76
- " then run `vc link` again.",
77
- );
78
- return 1;
79
- }
80
- p.log.success(`${color.green("✓")} ${slug}, as ${auth.user ?? "you"}`);
81
-
82
- // 2. The encryption key. Generated here, stored on the repository, never on disk.
83
- const keys = await keyState(project);
84
- if (keys.inGitHub && !relink) {
85
- p.log.info(`${PRIVATE_KEY_VAR} is already set — leaving it alone (--force replaces it, which orphans ${SECRETS_FILE})`);
86
- } else if (keys.inGitHub && relink) {
87
- p.log.warn(`--force: replacing ${PRIVATE_KEY_VAR} makes every value in ${SECRETS_FILE} unreadable. Use \`vc keys --rotate\` to re-encrypt instead.`);
88
- return 1;
89
- } else {
90
- // A key parked by `vc keys --init` before this repository existed is
91
- // ADOPTED, not replaced: it may already have encrypted the whole shop.
92
- const parked = localPrivateKey(root);
93
- if (parked) {
94
- const sent = await setVariable(root, PRIVATE_KEY_VAR, parked);
95
- if (!sent.ok) {
96
- p.cancel(`GitHub refused the variable: ${sent.error ?? "unknown error"}`);
97
- return 1;
98
- }
99
- const publicKey = await publicKeyFor(parked);
100
- if (publicKey) committedPublicKeyInto(root, publicKey);
101
-
102
- // READ IT BACK before deleting the only other copy. "The write
103
- // returned success" is not the same claim as "the key can be
104
- // recovered", and it is the second one this file is about to bet
105
- // the shop on. If it cannot be read, the local copy stays.
106
- const readBack = await getVariable(root, PRIVATE_KEY_VAR);
107
- if (readBack !== parked) {
108
- p.log.warn(
109
- `${color.yellow("!")} ${PRIVATE_KEY_VAR} was written to ${slug} but could not be read back,\n` +
110
- ` so ${LOCAL_KEY_FILE} has been LEFT IN PLACE. It is currently the only copy\n` +
111
- " of the key that opens this shop — do not delete it until `vc keys --restore` works.",
112
- );
113
- } else {
114
- rmSync(join(root, LOCAL_KEY_FILE), { force: true });
115
- p.log.success(
116
- `${color.green("✓")} the key moved to ${slug} and ${LOCAL_KEY_FILE} deleted — verified readable, so \`vc keys --restore\` can bring it back`,
117
- );
118
- }
119
- } else {
120
- const made = await provisionKey(project);
121
- if (!made.ok) {
122
- p.cancel(made.reason);
123
- return 1;
124
- }
125
- committedPublicKeyInto(root, made.publicKey);
126
- p.log.success(`${color.green("✓")} ${PRIVATE_KEY_VAR} generated and stored on GitHub; public half in ${SECRETS_FILE}`);
127
- }
128
- }
129
-
130
- // 3. The Cloudflare token — the one thing that cannot be derived.
131
- const existing = await secretNames(root);
132
- if (existing.has(TOKEN_SECRET) && !relink) {
133
- p.log.info(`${TOKEN_SECRET} is already set — pass --force to replace it`);
134
- } else {
135
- p.log.step(`A Cloudflare API token, from ${color.cyan("https://dash.cloudflare.com/profile/api-tokens")} → Create Token → Custom token`);
136
- console.log(SCOPES.map(([scope, why]) => ` ${scope.padEnd(38)} ${color.dim(why)}`).join("\n"));
137
- console.log(
138
- color.dim(
139
- `\n The token Cloudflare generates for its own Workers Builds will NOT do:\n` +
140
- ` it has no D1 and no Queues permission, so it cannot create the database.\n`,
141
- ),
142
- );
143
-
144
- const token = await p.password({
145
- message: "Paste it (nothing is written to disk)",
146
- validate: (input) => (input && input.length >= 20 ? undefined : "that is too short to be a Cloudflare token"),
147
- });
148
- if (p.isCancel(token)) {
149
- p.cancel("nothing was stored.");
150
- return 1;
151
- }
152
-
153
- const spinner = p.spinner();
154
- spinner.start("asking Cloudflare whether that token is real");
155
- const verified = await verifyCloudflareToken(String(token));
156
- if (!verified.ok) {
157
- spinner.stop(`${color.red("✗")} Cloudflare rejected it: ${verified.detail}`);
158
- p.cancel("nothing was stored.");
159
- return 1;
160
- }
161
- const accounts = await cloudflareAccounts(String(token));
162
- spinner.stop(`${color.green("✓")} the token is active and sees ${accounts.length} account${accounts.length === 1 ? "" : "s"}`);
163
-
164
- let accountId = project.manifest.cloudflare?.accountId ?? "";
165
- if (accounts.length === 1) {
166
- accountId = accounts[0]!.id;
167
- p.log.info(`account "${accounts[0]!.name}"`);
168
- } else if (accounts.length > 1) {
169
- const picked = await p.select({
170
- message: "Which account is this shop in?",
171
- options: accounts.map((account) => ({ value: account.id, label: account.name, hint: account.id })),
172
- });
173
- if (p.isCancel(picked)) {
174
- p.cancel("nothing was stored.");
175
- return 1;
176
- }
177
- accountId = String(picked);
178
- }
179
-
180
- const stored = await setSecret(root, TOKEN_SECRET, String(token));
181
- if (!stored.ok) {
182
- p.cancel(`GitHub refused the secret: ${stored.error ?? "unknown error"}`);
183
- return 1;
184
- }
185
- p.log.success(`${color.green("✓")} ${TOKEN_SECRET} stored on ${slug}`);
186
-
187
- if (accountId) {
188
- // A VARIABLE, not a secret: an account id is an identifier, and a
189
- // readable one is easier to debug in a workflow log.
190
- const varred = await setVariable(root, ACCOUNT_VAR, accountId);
191
- if (varred.ok) p.log.success(`${color.green("✓")} ${ACCOUNT_VAR} set as a repository variable`);
192
- project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
193
- await writeManifest(root, project.manifest);
194
- }
195
- }
196
-
197
- // 4. The shop's own secrets, so `vc link` leaves a repository that is
198
- // ready rather than one that is half-configured.
199
- if (declaredSecretNames(root).size === 0) {
200
- p.log.step(`writing ${SECRETS_FILE}`);
201
- await initSecrets(project);
202
- }
203
-
204
- // 5. What is left.
205
- const secrets = await secretNames(root);
206
- const vars = await variableNames(root);
207
- p.outro(
208
- [
209
- `${color.bold("Ready.")} From here, ${color.cyan("git push")} is the deploy.`,
210
- "",
211
- ` ${secrets.has(PRIVATE_KEY_VAR) ? color.green("✓") : color.red("✗")} ${PRIVATE_KEY_VAR} ${color.dim("reads the shop's secrets")}`,
212
- ` ${secrets.has(TOKEN_SECRET) ? color.green("✓") : color.red("✗")} ${TOKEN_SECRET} ${color.dim("deploys to Cloudflare")}`,
213
- ` ${vars.has(ACCOUNT_VAR) ? color.green("✓") : color.dim("·")} ${ACCOUNT_VAR} ${color.dim("which account")}`,
214
- "",
215
- ` Set the shop's own secrets with ${color.cyan("vc secrets set KEY")} — that needs no`,
216
- ` credential at all, because encryption uses the public key in the repository.`,
217
- ].join("\n"),
218
- );
219
- return 0;
220
- }