@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.
- package/dist/cli.js +240 -68
- package/dist/deploy/index.d.ts +9 -0
- package/dist/deploy/teardown.d.ts +62 -0
- package/dist/deploy/token-link.d.ts +15 -0
- package/dist/index-6e4eh08g.js +35 -0
- package/dist/{index-hsc97bf2.js → index-940vpkwa.js} +84 -2
- package/dist/{index-tk46w7yn.js → index-pghh8nnq.js} +18 -5
- package/dist/index.js +3 -2
- package/dist/{keys-knmqsqz1.js → keys-mewxnkwg.js} +1 -1
- package/dist/token-link-wxpp5xzp.js +11 -0
- package/package.json +1 -1
- package/src/cli.ts +3 -0
- package/src/deploy/index.ts +64 -0
- package/src/deploy/secrets.ts +97 -0
- package/src/deploy/teardown.ts +169 -0
- package/src/deploy/token-link.ts +76 -0
- package/src/generate/ci.ts +13 -2
|
@@ -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
|
+
}
|
package/src/generate/ci.ts
CHANGED
|
@@ -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.
|
|
248
|
-
|
|
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
|
|