@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.
- package/dist/catalog.d.ts +13 -0
- package/dist/cli.js +239 -68
- package/dist/deploy/index.d.ts +9 -0
- package/dist/deploy/teardown.d.ts +62 -0
- package/dist/{index-07ppzmgt.js → index-2kqz80nx.js} +3 -3
- package/dist/{index-vpcnasre.js → index-t1ggktg8.js} +86 -8
- package/dist/index.js +2 -2
- package/dist/{keys-fbw9fdby.js → keys-1h4zpvd7.js} +1 -1
- package/package.json +1 -1
- package/src/catalog.ts +13 -0
- package/src/cli.ts +3 -0
- package/src/deploy/index.ts +64 -0
- package/src/deploy/secrets.ts +97 -5
- package/src/deploy/teardown.ts +169 -0
- package/src/generate/env.ts +10 -4
|
@@ -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
|
+
}
|
package/src/generate/env.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? "",
|