@saastemly/voidcommerce 0.20.0 → 0.22.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
+ }
@@ -29,11 +29,13 @@ const BASE: EnvKey[] = [
29
29
  key: "CLOUDFLARE_API_TOKEN",
30
30
  breaks: "nothing deploys: no worker, no database, no migrations",
31
31
  where: "dash.cloudflare.com/profile/api-tokens → Create Token → Custom token",
32
+ deployOnly: true,
32
33
  },
33
34
  {
34
35
  key: "CLOUDFLARE_ACCOUNT_ID",
35
36
  breaks: "wrangler cannot tell which account to deploy into, and refuses rather than guessing",
36
- where: "the Cloudflare dashboard sidebar, or `vc secrets set` it after the first provision",
37
+ where: "the Cloudflare dashboard sidebar, or read from the token on first deploy",
38
+ deployOnly: true,
37
39
  },
38
40
  {
39
41
  key: "SHOP_DOMAIN",
@@ -90,7 +92,9 @@ export function allEnvKeys(manifest: Manifest): EnvKey[] {
90
92
  const NUMERIC = new Set(["SHIPPING_DOMESTIC_MINOR", "SHIPPING_FREE_FROM_MINOR"]);
91
93
 
92
94
  export function renderEnvTs(manifest: Manifest): string {
93
- const keys = allEnvKeys(manifest);
95
+ // The worker validates what IT needs. A deploy credential here would make
96
+ // the shop refuse to boot without a token it never uses.
97
+ const keys = allEnvKeys(manifest).filter((key) => !key.deployOnly);
94
98
  const lines = keys.map((key) => {
95
99
  const helper = NUMERIC.has(key.key) ? "number()" : "string()";
96
100
  const doc = [` /** ${key.breaks}${key.where ? ` — from: ${key.where}` : ""} */`];
@@ -117,7 +121,7 @@ ${lines.join("\n")}
117
121
  }
118
122
 
119
123
  export function renderEnvExample(manifest: Manifest): string {
120
- const keys = allEnvKeys(manifest);
124
+ const keys = allEnvKeys(manifest).filter((key) => !key.deployOnly);
121
125
  return [
122
126
  "# Every key is required. Copy to .env for local development.",
123
127
  "# `unset` is the one value that reads as absent — for a credential you do not have yet.",
@@ -139,7 +143,9 @@ export function renderEnvExample(manifest: Manifest): string {
139
143
  export const MANIFEST_OWNED_ENV = new Set(["SHOP_DOMAIN", "SHOP_ZONE", "EMAIL_FROM", "GITHUB_PAGES_HOST"]);
140
144
 
141
145
  export function renderEnvLocal(manifest: Manifest): string {
142
- const keys = allEnvKeys(manifest);
146
+ // Not in .env either: wrangler READS .env, so a deploy-only key sitting
147
+ // there as `unset` becomes a literal account id — `accounts/unset/...`.
148
+ const keys = allEnvKeys(manifest).filter((key) => !key.deployOnly);
143
149
  const local: Record<string, string> = {
144
150
  SHOP_DOMAIN: `${manifest.shop.domain.split(".")[0]}.test`,
145
151
  GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",