@saastemly/voidcommerce 0.19.1 → 0.21.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,169 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import color from "picocolors";
4
+ import { writeManifest, workerHosts } from "../manifest";
5
+ import type { Project } from "../project";
6
+ import { parseJsonc, upsertJsonc } from "./jsonc";
7
+ import { SECRETS_FILE, secretValue } from "./secrets";
8
+ import { findWrangler, wrangler } from "./wrangler";
9
+
10
+ /**
11
+ * `vc teardown --cloudflare` — undo a deploy, so the whole thing can be
12
+ * proved again from nothing.
13
+ *
14
+ * ── Why this exists ──────────────────────────────────────────────────────
15
+ *
16
+ * "Push and it goes live" is a claim, and a claim nobody can re-run is a
17
+ * story. Provisioning is the part most likely to break — it is the only
18
+ * step that creates rather than replaces, it runs once per shop, and it runs
19
+ * against an account whose state nobody controls. Being able to destroy and
20
+ * do it again is what turns the claim into something testable.
21
+ *
22
+ * ── This DELETES A PRODUCTION DATABASE ───────────────────────────────────
23
+ *
24
+ * D1 deletion is not recoverable: there is no undo and no snapshot unless
25
+ * one was taken. Every order, customer and address in it goes. So this
26
+ * refuses by default, and the confirmation is TYPING THE SHOP'S DOMAIN —
27
+ * not `y`, because `y` is muscle memory and a domain is not.
28
+ *
29
+ * It also refuses outright when the manifest's domain does not match what
30
+ * the wrangler config points at, since that mismatch is the signal that you
31
+ * are standing in a different shop than you think.
32
+ *
33
+ * ── Idempotent, deliberately ─────────────────────────────────────────────
34
+ *
35
+ * Deleting what is already gone is success, not failure. That is what makes
36
+ * it usable in a loop: teardown, publish, teardown again, without a human
37
+ * reading each result to decide whether the next step is safe.
38
+ */
39
+
40
+ export interface TeardownOptions {
41
+ /** Required. Without it this only ever prints what it would do. */
42
+ confirm: boolean;
43
+ /** Keep the D1 database — the one thing that holds data nobody can recreate. */
44
+ keepData: boolean;
45
+ dryRun: boolean;
46
+ }
47
+
48
+ interface WranglerConfig {
49
+ name?: string;
50
+ account_id?: string;
51
+ d1_databases?: Array<{ binding: string; database_name: string; database_id: string }>;
52
+ queues?: { producers?: Array<{ queue: string }>; consumers?: Array<{ queue: string }> };
53
+ }
54
+
55
+ const fail = (message: string): number => {
56
+ console.error(`\n${color.red("✗")} ${message}\n`);
57
+ return 1;
58
+ };
59
+
60
+ /** What a teardown would destroy, read from the config rather than guessed. */
61
+ export function teardownPlan(config: WranglerConfig, fallbackWorker: string): { worker: string; d1: string | null; queues: string[] } {
62
+ const queues = new Set<string>();
63
+ for (const producer of config.queues?.producers ?? []) if (producer.queue) queues.add(producer.queue);
64
+ for (const consumer of config.queues?.consumers ?? []) if (consumer.queue) queues.add(consumer.queue);
65
+ const d1 = config.d1_databases?.find((db) => db.binding === "DB");
66
+ return {
67
+ worker: config.name || fallbackWorker,
68
+ d1: d1?.database_name && d1.database_id !== "local" ? d1.database_name : null,
69
+ queues: [...queues],
70
+ };
71
+ }
72
+
73
+ export async function teardownCloudflare(project: Project, opts: TeardownOptions): Promise<number> {
74
+ const app = project.appDir;
75
+ const configPath = join(app, "wrangler.jsonc");
76
+ if (!existsSync(configPath)) return fail("wrangler.jsonc is missing — there is nothing here to tear down.");
77
+
78
+ // Credentials come from the repository, exactly as the deploy takes them.
79
+ if (!process.env["CLOUDFLARE_API_TOKEN"]) {
80
+ const token = await secretValue(project, "CLOUDFLARE_API_TOKEN");
81
+ if (token) process.env["CLOUDFLARE_API_TOKEN"] = token;
82
+ }
83
+ if (!process.env["CLOUDFLARE_ACCOUNT_ID"]) {
84
+ const account = await secretValue(project, "CLOUDFLARE_ACCOUNT_ID");
85
+ if (account) process.env["CLOUDFLARE_ACCOUNT_ID"] = account;
86
+ }
87
+
88
+ const bin = findWrangler(app);
89
+ if (!bin) return fail("wrangler is not installed. `bun add -d wrangler`.");
90
+
91
+ const config = parseJsonc<WranglerConfig>(readFileSync(configPath, "utf8"));
92
+ const plan = teardownPlan(config, project.manifest.shop.domain.split(".")[0]!);
93
+ const host = workerHosts(project.manifest)[0] ?? project.manifest.shop.domain;
94
+
95
+ console.log(`\n${color.bold("This will destroy, on Cloudflare:")}\n`);
96
+ console.log(` worker ${plan.worker} ${color.dim(`(and its custom domain ${host})`)}`);
97
+ console.log(
98
+ plan.d1
99
+ ? ` database ${plan.d1} ${opts.keepData ? color.green("KEPT — --keep-data") : color.red("DELETED, with every order in it")}`
100
+ : ` database ${color.dim("none recorded")}`,
101
+ );
102
+ console.log(plan.queues.length ? ` queues ${plan.queues.join(", ")}` : ` queues ${color.dim("none recorded")}`);
103
+ console.log("");
104
+
105
+ if (opts.dryRun) {
106
+ console.log(color.dim("--dry-run: nothing was touched.\n"));
107
+ return 0;
108
+ }
109
+
110
+ if (!opts.confirm) {
111
+ return fail(
112
+ `refusing to destroy anything without --confirm.\n` +
113
+ ` This is not reversible: D1 has no undo, and the orders go with it.\n\n` +
114
+ ` ${color.cyan(`vc teardown --cloudflare --confirm ${project.manifest.shop.domain}`)}`,
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Order matters. The worker goes FIRST: while it is live it serves
120
+ * customers, and a shop that answers with a database that no longer
121
+ * exists is worse than a shop that does not answer.
122
+ */
123
+ console.log(`▸ wrangler delete (${plan.worker})`);
124
+ const deleted = await wrangler(bin, ["delete", "--name", plan.worker, "--force"], app, true);
125
+ // "not found" is success here: teardown is idempotent by design.
126
+ if (deleted.code !== 0 && !/not found|does not exist|10007|10090/i.test(deleted.out)) {
127
+ return fail(`could not delete the worker:\n${deleted.out.trim().split("\n").slice(-3).join("\n")}`);
128
+ }
129
+ console.log(`${color.green("✓")} worker gone${/not found|does not exist/i.test(deleted.out) ? color.dim(" (it was already)") : ""}`);
130
+
131
+ for (const queue of plan.queues) {
132
+ const dropped = await wrangler(bin, ["queues", "delete", queue], app, true);
133
+ if (dropped.code !== 0 && !/not found|does not exist|11009|11018/i.test(dropped.out)) {
134
+ console.error(`${color.yellow("!")} queue "${queue}" could not be deleted: ${dropped.out.trim().split("\n").slice(-1)[0]}`);
135
+ } else {
136
+ console.log(`${color.green("✓")} queue "${queue}" gone`);
137
+ }
138
+ }
139
+
140
+ if (plan.d1 && !opts.keepData) {
141
+ const dropped = await wrangler(bin, ["d1", "delete", plan.d1, "--skip-confirmation"], app, true);
142
+ if (dropped.code !== 0 && !/not found|does not exist|7404/i.test(dropped.out)) {
143
+ return fail(`the worker and queues are gone but D1 "${plan.d1}" is not:\n${dropped.out.trim().split("\n").slice(-3).join("\n")}`);
144
+ }
145
+ console.log(`${color.green("✓")} database "${plan.d1}" gone`);
146
+ }
147
+
148
+ /**
149
+ * Forget the ids. A recorded database id that no longer exists is worse
150
+ * than none: `wrangler deploy` FAILS on a dangling `database_id` (error
151
+ * 10021) rather than creating a replacement, so leaving it behind would
152
+ * make the next deploy fail instead of provisioning cleanly.
153
+ */
154
+ if (!opts.keepData) {
155
+ writeFileSync(configPath, upsertJsonc(readFileSync(configPath, "utf8"), "d1_databases", []));
156
+ if (project.manifest.cloudflare?.d1) {
157
+ const { d1: _dropped, ...rest } = project.manifest.cloudflare;
158
+ project.manifest.cloudflare = rest;
159
+ await writeManifest(project.root, project.manifest);
160
+ }
161
+ console.log(`${color.green("✓")} the database id is out of wrangler.jsonc and the manifest, so the next deploy provisions afresh`);
162
+ }
163
+
164
+ console.log(
165
+ `\n${color.bold("Torn down.")} ${color.dim(`The shop's secrets are untouched in ${SECRETS_FILE} — this removed infrastructure, not configuration.`)}\n` +
166
+ ` Push again, or ${color.cyan("vc deploy --cloudflare --provision")}, and it rebuilds.\n`,
167
+ );
168
+ return 0;
169
+ }
@@ -0,0 +1,76 @@
1
+ import type { Manifest } from "../manifest";
2
+
3
+ /**
4
+ * A Cloudflare dashboard link that opens the token form already filled in.
5
+ *
6
+ * ── Why this is worth having ─────────────────────────────────────────────
7
+ *
8
+ * Fetching an API token is the one step of this whole system that a person
9
+ * has to do by hand, and it was the worst-designed one: a table of ten
10
+ * permissions to find and tick, in two different scopes, where a single
11
+ * missed row produces a token that authenticates fine and then fails
12
+ * halfway through a deploy with a permissions error nobody can place.
13
+ *
14
+ * Cloudflare's dashboard accepts `permissionGroupKeys` — a JSON array of
15
+ * `{key, type}` — and opens Create Token with exactly those selected. So the
16
+ * ten rows become one link, and the failure mode goes away.
17
+ *
18
+ * @see https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/
19
+ *
20
+ * ── On the keys below ────────────────────────────────────────────────────
21
+ *
22
+ * They are not guessable and a wrong one is silently dropped, which would
23
+ * produce a link that looks right and grants less than it claims. Every one
24
+ * was checked against the dashboard's own permission-group registry AND
25
+ * against links Cloudflare itself ships in its documentation — the Workers
26
+ * template, the DNS templates, the Prometheus link. None came from a
27
+ * third-party repository guessing at them, and several public repositories
28
+ * do guess: `account`, `user`, `workers`, `pages` match nothing at all.
29
+ */
30
+
31
+ /** `[label, key, type, why]` — the why is what a person reads when it fails. */
32
+ const PERMISSIONS: Array<[string, string, string, string]> = [
33
+ ["Workers Scripts: Edit", "workers_scripts", "edit", "deploy the worker"],
34
+ ["D1: Edit", "d1", "edit", "create the database and apply migrations"],
35
+ ["Queues: Edit", "queues", "edit", "create the order queue"],
36
+ ["Workers KV Storage: Edit", "workers_kv_storage", "edit", "sessions and caches"],
37
+ ["Workers R2 Storage: Edit", "workers_r2", "edit", "product images"],
38
+ ["Account Settings: Read", "account_settings", "read", "confirm which account this is"],
39
+ ["Zone: Read", "zone", "read", "confirm the domain's zone is on this account"],
40
+ ["DNS: Read", "dns", "read", "notice a hostname that already answers"],
41
+ ["Workers Routes: Edit", "workers_routes", "edit", "answer on your domain"],
42
+ ["User Details: Read", "user_details", "read", "wrangler asks at startup"],
43
+ ["Memberships: Read", "memberships", "read", "the same"],
44
+ ];
45
+
46
+ /**
47
+ * The link, for this shop.
48
+ *
49
+ * The USER-token form, deliberately: it is the one that accepts the two
50
+ * user-scoped permissions wrangler wants, and account-owned tokens are
51
+ * documented as not taking `accountId`/`zoneId` at all. `zoneId=all` because
52
+ * a shop's zone is not known until the domain is on Cloudflare, and picking
53
+ * the wrong one is a worse failure than picking from a list.
54
+ */
55
+ export function cloudflareTokenLink(manifest: Manifest): string {
56
+ const groups = PERMISSIONS.map(([, key, type]) => ({ key, type }));
57
+ const params = new URLSearchParams({
58
+ permissionGroupKeys: JSON.stringify(groups),
59
+ accountId: "*",
60
+ zoneId: "all",
61
+ // So a person with several tokens can tell later which shop this was for.
62
+ name: `${manifest.shop.domain} deploy`,
63
+ });
64
+ return `https://dash.cloudflare.com/profile/api-tokens?${params.toString()}`;
65
+ }
66
+
67
+ /** The same permissions as a table, for anyone who would rather tick them. */
68
+ export function cloudflareTokenTable(): string {
69
+ const width = Math.max(...PERMISSIONS.map(([label]) => label.length));
70
+ return PERMISSIONS.map(([label, , , why]) => ` ${label.padEnd(width)} ${why}`).join("\n");
71
+ }
72
+
73
+ /** Markdown, for DEPLOY.md. */
74
+ export function cloudflareTokenMarkdown(): string {
75
+ return ["| permission | for |", "|---|---|", ...PERMISSIONS.map(([label, , , why]) => `| ${label} | ${why} |`)].join("\n");
76
+ }
@@ -1,6 +1,7 @@
1
1
  import { DIST_BRANCH, DIST_DIR } from "../dist";
2
2
  import type { Manifest } from "../manifest";
3
3
  import { PRIVATE_KEY_VAR, SECRETS_FILE } from "../deploy/secrets";
4
+ import { cloudflareTokenMarkdown } from "../deploy/token-link";
4
5
 
5
6
  /**
6
7
  * The workflow that turns a push into a live shop.
@@ -190,6 +191,7 @@ jobs:
190
191
  /** What a person still has to do once, and why each thing cannot be done for them. */
191
192
  export function renderDeployReadme(manifest: Manifest, zone: string, hosts: string[]): string {
192
193
  const worker = manifest.shop.domain.split(".")[0];
194
+ const TOKEN_TABLE = cloudflareTokenMarkdown();
193
195
  return `# Going live
194
196
 
195
197
  \`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
@@ -244,8 +246,17 @@ ordinary secret in \`${SECRETS_FILE}\`: encrypted, committed, and read by the
244
246
  deploy out of what you pushed.
245
247
 
246
248
  The token itself has to be fetched by a person, once, because there is no
247
- OIDC between GitHub and Cloudflare and nothing else can issue one. Create it
248
- at **My Profile API Tokens → Create Token → Custom token**:
249
+ OIDC between GitHub and Cloudflare and nothing else can issue one. You do
250
+ not have to find the permissions yourself:
251
+
252
+ \`\`\`sh
253
+ vc secrets set CLOUDFLARE_API_TOKEN
254
+ \`\`\`
255
+
256
+ prints a Cloudflare link with all of them **already selected**, then takes
257
+ the token. It opens your own dashboard and the value is shown only to you.
258
+
259
+ ${TOKEN_TABLE}
249
260
 
250
261
  ### The zone
251
262