@saastemly/voidcommerce 0.11.0 → 0.12.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.js +1 -1
- package/dist/cli.js +6 -4
- package/dist/deploy/github.d.ts +2 -0
- package/dist/deploy/zone.d.ts +51 -0
- package/dist/{index-mnb7fz2t.js → index-7xwcv2cj.js} +9 -4
- package/dist/{index-62q6669q.js → index-a279r74m.js} +6 -3
- package/dist/{index-964eqtx0.js → index-g0p3kk0f.js} +123 -33
- package/dist/{index-tjy6yygc.js → index-ym88atwf.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/{keys-1cqepd69.js → keys-px5maaj1.js} +3 -3
- package/dist/manifest.js +2 -2
- package/package.json +1 -1
- package/src/catalog.ts +9 -4
- package/src/deploy/cloudflare.ts +20 -1
- package/src/deploy/github.ts +5 -0
- package/src/deploy/link.ts +2 -0
- package/src/deploy/zone.ts +147 -0
- package/src/generate/ci.ts +2 -0
package/dist/catalog.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
runVoid,
|
|
22
22
|
version,
|
|
23
23
|
voidAppsIn
|
|
24
|
-
} from "./index-
|
|
24
|
+
} from "./index-g0p3kk0f.js";
|
|
25
25
|
import {
|
|
26
26
|
LOCAL_KEY_FILE,
|
|
27
27
|
PRIVATE_KEY_VAR,
|
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
setVariable,
|
|
51
51
|
variableNames,
|
|
52
52
|
verifyCloudflareToken
|
|
53
|
-
} from "./index-
|
|
53
|
+
} from "./index-a279r74m.js";
|
|
54
54
|
import {
|
|
55
55
|
LAYOUTS,
|
|
56
56
|
oneOrigin,
|
|
@@ -58,10 +58,10 @@ import {
|
|
|
58
58
|
validate,
|
|
59
59
|
writeManifest,
|
|
60
60
|
zoneOf
|
|
61
|
-
} from "./index-
|
|
61
|
+
} from "./index-ym88atwf.js";
|
|
62
62
|
import {
|
|
63
63
|
GROUPS
|
|
64
|
-
} from "./index-
|
|
64
|
+
} from "./index-7xwcv2cj.js";
|
|
65
65
|
import {
|
|
66
66
|
__require
|
|
67
67
|
} from "./index-0v6na3yp.js";
|
|
@@ -85,6 +85,8 @@ var SCOPES = [
|
|
|
85
85
|
["Account · Workers R2 Storage: Edit", "product images"],
|
|
86
86
|
["Account · Account Settings: Read", "confirm which account this is"],
|
|
87
87
|
["Zone · Workers Routes: Edit", "answer on your domain"],
|
|
88
|
+
["Zone · Zone: Read", "confirm the domain's zone is on this account"],
|
|
89
|
+
["Zone · DNS: Read", "notice a hostname that already answers, before taking it over"],
|
|
88
90
|
["User · User Details: Read", "wrangler asks at startup"]
|
|
89
91
|
];
|
|
90
92
|
async function linkCommand(project, args) {
|
package/dist/deploy/github.d.ts
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
* @see https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/
|
|
31
31
|
* @see https://github.com/cloudflare/workers-sdk/discussions/11434
|
|
32
32
|
*/
|
|
33
|
+
/** The Cloudflare token, from wherever wrangler itself would read it. */
|
|
34
|
+
export declare function apiToken(): string | null;
|
|
33
35
|
export interface GhAuth {
|
|
34
36
|
/** Logged in, with a token that can write repository secrets. */
|
|
35
37
|
ok: boolean;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Same zone": the shop's DNS zone is on the SAME Cloudflare account as the
|
|
3
|
+
* worker — and checking that it really is.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this is worth a check rather than a sentence ─────────────────────
|
|
6
|
+
*
|
|
7
|
+
* Everything about the deploy rests on it. A Worker custom domain is a
|
|
8
|
+
* record Cloudflare writes in its own zone, so it works only when the zone
|
|
9
|
+
* is on the account the token belongs to. Cloudflare's own words: *"You
|
|
10
|
+
* cannot create a Custom Domain on a hostname with an existing CNAME DNS
|
|
11
|
+
* record or on a zone you do not own."* Until now that was a line in
|
|
12
|
+
* DEPLOY.md and nothing verified it, so getting it wrong surfaced as a
|
|
13
|
+
* wrangler error in the middle of a deploy that had already created a
|
|
14
|
+
* database.
|
|
15
|
+
*
|
|
16
|
+
* ── And the failure that is worse than an error ──────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* Wrangler resolves a conflicting hostname interactively — it asks whether
|
|
19
|
+
* to move the record onto this script. In CI there is nobody to ask, and it
|
|
20
|
+
* does not fail: its own source sets
|
|
21
|
+
*
|
|
22
|
+
* override_existing_origin = true
|
|
23
|
+
* override_existing_dns_record = true
|
|
24
|
+
*
|
|
25
|
+
* whenever stdout is not a TTY. So a hostname already serving something
|
|
26
|
+
* else — another worker, a real site — is taken over silently, by a push.
|
|
27
|
+
* That is not a thing to discover afterwards, so this refuses first.
|
|
28
|
+
*
|
|
29
|
+
* ── What it deliberately does not do ─────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* It writes nothing. `cloudflare-samezone` needs no DNS records and no
|
|
32
|
+
* second credential: the worker's hostname is a custom domain Cloudflare
|
|
33
|
+
* creates itself, the storefront rides in that same worker, and Cloudflare
|
|
34
|
+
* Email writes its own SPF, DKIM and DMARC when the domain is onboarded.
|
|
35
|
+
* The value here is the precondition, not the plumbing.
|
|
36
|
+
*/
|
|
37
|
+
export interface ZoneCheck {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
/** What to tell the operator. Empty when everything is as it should be. */
|
|
40
|
+
problems: string[];
|
|
41
|
+
notes: string[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Is `zone` on this account, and is `hostname` free to become a custom domain?
|
|
45
|
+
*
|
|
46
|
+
* `accountId` is optional: without it any zone the token can see counts,
|
|
47
|
+
* which is the right answer for a token scoped to one account anyway.
|
|
48
|
+
*/
|
|
49
|
+
export declare function checkSameZone(token: string, zone: string, hostname: string, accountId?: string): Promise<ZoneCheck>;
|
|
50
|
+
/** Print a check, in the shape the rest of the deploy uses. */
|
|
51
|
+
export declare function printZoneCheck(check: ZoneCheck, zone: string): void;
|
|
@@ -525,15 +525,20 @@ var EMAIL = {
|
|
|
525
525
|
var DNS = {
|
|
526
526
|
id: "dns",
|
|
527
527
|
title: "DNS",
|
|
528
|
-
intro: "
|
|
528
|
+
intro: "Where the shop's domain lives, and whether the deploy may touch it. The worker's own hostname is a Cloudflare custom domain and needs no record here — Cloudflare writes that one itself.",
|
|
529
529
|
kind: "select",
|
|
530
530
|
choices: [
|
|
531
|
+
{
|
|
532
|
+
id: "cloudflare-samezone",
|
|
533
|
+
label: "Cloudflare, same account",
|
|
534
|
+
hint: "the zone is on the same Cloudflare account as the worker — the deploy checks that before it changes anything, and needs no extra credential",
|
|
535
|
+
recommended: true
|
|
536
|
+
},
|
|
531
537
|
{
|
|
532
538
|
id: "cloudflare",
|
|
533
|
-
label: "Cloudflare",
|
|
534
|
-
hint: "
|
|
539
|
+
label: "Cloudflare, another account or a second zone",
|
|
540
|
+
hint: "a separate DNS token, for a zone the worker's own token cannot see — the deploy writes the mail records so confirmations are not spam",
|
|
535
541
|
packages: ["@saastemly/better-dns"],
|
|
536
|
-
recommended: true,
|
|
537
542
|
env: [
|
|
538
543
|
{ key: "CLOUDFLARE_DNS_TOKEN", breaks: "the storefront records are never written; the domain does not reach the shop", where: "a token with Zone:DNS:Edit and Zone:Zone:Read" }
|
|
539
544
|
]
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
isApex,
|
|
5
5
|
oneOrigin,
|
|
6
6
|
zone
|
|
7
|
-
} from "./index-
|
|
7
|
+
} from "./index-ym88atwf.js";
|
|
8
8
|
import {
|
|
9
9
|
__require,
|
|
10
10
|
__toESM
|
|
@@ -19,6 +19,9 @@ import color2 from "picocolors";
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { delimiter, join } from "node:path";
|
|
21
21
|
import { existsSync } from "node:fs";
|
|
22
|
+
function apiToken() {
|
|
23
|
+
return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
|
|
24
|
+
}
|
|
22
25
|
var REPO_SCOPE = "repo";
|
|
23
26
|
function findGh() {
|
|
24
27
|
for (const entry of (process.env["PATH"] ?? "").split(delimiter).filter(Boolean)) {
|
|
@@ -386,7 +389,7 @@ async function decryptSecrets(project) {
|
|
|
386
389
|
}
|
|
387
390
|
const expected = committedPublicKey(root);
|
|
388
391
|
if (expected) {
|
|
389
|
-
const { publicKeyFor } = await import("./keys-
|
|
392
|
+
const { publicKeyFor } = await import("./keys-px5maaj1.js");
|
|
390
393
|
const derived = await publicKeyFor(privateKey);
|
|
391
394
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
392
395
|
return {
|
|
@@ -913,4 +916,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
|
|
|
913
916
|
return 0;
|
|
914
917
|
}
|
|
915
918
|
|
|
916
|
-
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
|
|
919
|
+
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
PRIVATE_KEY_VAR,
|
|
4
4
|
SECRETS_FILE,
|
|
5
5
|
allEnvKeys,
|
|
6
|
+
apiToken,
|
|
6
7
|
declaredSecretNames,
|
|
7
8
|
decryptSecrets,
|
|
8
9
|
plaintextSecretNames,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
renderEnvLocal,
|
|
11
12
|
renderEnvProduction,
|
|
12
13
|
renderEnvTs
|
|
13
|
-
} from "./index-
|
|
14
|
+
} from "./index-a279r74m.js";
|
|
14
15
|
import {
|
|
15
16
|
MANIFEST_FILE,
|
|
16
17
|
has,
|
|
@@ -23,10 +24,10 @@ import {
|
|
|
23
24
|
workerHosts,
|
|
24
25
|
writeManifest,
|
|
25
26
|
zone
|
|
26
|
-
} from "./index-
|
|
27
|
+
} from "./index-ym88atwf.js";
|
|
27
28
|
import {
|
|
28
29
|
CHOICES
|
|
29
|
-
} from "./index-
|
|
30
|
+
} from "./index-7xwcv2cj.js";
|
|
30
31
|
import {
|
|
31
32
|
__require
|
|
32
33
|
} from "./index-0v6na3yp.js";
|
|
@@ -1371,7 +1372,7 @@ import color from "picocolors";
|
|
|
1371
1372
|
// package.json
|
|
1372
1373
|
var package_default = {
|
|
1373
1374
|
name: "@saastemly/voidcommerce",
|
|
1374
|
-
version: "0.
|
|
1375
|
+
version: "0.12.0",
|
|
1375
1376
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1376
1377
|
type: "module",
|
|
1377
1378
|
license: "MIT",
|
|
@@ -1885,6 +1886,8 @@ Create the token at **My Profile → API Tokens → Create Token → Custom toke
|
|
|
1885
1886
|
| Account | Workers R2 Storage: Edit | product images |
|
|
1886
1887
|
| Account | Account Settings: Read | confirming which account |
|
|
1887
1888
|
| Zone | Workers Routes: Edit (${zone2}) | answering on your domain |
|
|
1889
|
+
| Zone | Zone: Read (${zone2}) | confirming the zone is on this account |
|
|
1890
|
+
| Zone | DNS: Read (${zone2}) | noticing a hostname that already answers |
|
|
1888
1891
|
| User | User Details: Read, Memberships: Read | wrangler asks at startup |
|
|
1889
1892
|
|
|
1890
1893
|
The token Cloudflare generates for its own Workers Builds will **not** do: it
|
|
@@ -3080,10 +3083,84 @@ ${color3.red("NOT ready to go live.")}
|
|
|
3080
3083
|
// src/deploy/cloudflare.ts
|
|
3081
3084
|
import { copyFileSync, existsSync as existsSync8, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "node:fs";
|
|
3082
3085
|
import { dirname as dirname5, join as join8 } from "node:path";
|
|
3086
|
+
import color5 from "picocolors";
|
|
3087
|
+
|
|
3088
|
+
// src/deploy/zone.ts
|
|
3083
3089
|
import color4 from "picocolors";
|
|
3090
|
+
async function cf(token, path) {
|
|
3091
|
+
try {
|
|
3092
|
+
const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
3093
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
3094
|
+
});
|
|
3095
|
+
const body = await response.json();
|
|
3096
|
+
if (!body.success) {
|
|
3097
|
+
const first = body.errors?.[0];
|
|
3098
|
+
return { ok: false, error: first?.message ? `${first.message}${first.code ? ` (${first.code})` : ""}` : `HTTP ${response.status}` };
|
|
3099
|
+
}
|
|
3100
|
+
return { ok: true, result: body.result };
|
|
3101
|
+
} catch (error) {
|
|
3102
|
+
return { ok: false, error: `could not reach the Cloudflare API: ${String(error)}` };
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
async function checkSameZone(token, zone2, hostname, accountId) {
|
|
3106
|
+
const problems = [];
|
|
3107
|
+
const notes = [];
|
|
3108
|
+
const zones = await cf(token, `/zones?name=${encodeURIComponent(zone2)}`);
|
|
3109
|
+
if (!zones.ok) {
|
|
3110
|
+
return {
|
|
3111
|
+
ok: true,
|
|
3112
|
+
problems: [],
|
|
3113
|
+
notes: [`the zone could not be checked: ${zones.error}. Add ${color4.cyan("Zone → Zone: Read")} to the API token to have this verified.`]
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
const found = zones.result.find((entry) => entry.name.toLowerCase() === zone2.toLowerCase());
|
|
3117
|
+
if (!found) {
|
|
3118
|
+
problems.push(`the zone ${color4.bold(zone2)} is not on this Cloudflare account.
|
|
3119
|
+
` + ` A Worker custom domain is a record Cloudflare writes in its own zone, so a
|
|
3120
|
+
` + ` domain hosted anywhere else cannot have one. Move the zone to this account,
|
|
3121
|
+
` + " or point the shop at a domain that is already on it.");
|
|
3122
|
+
return { ok: false, problems, notes };
|
|
3123
|
+
}
|
|
3124
|
+
if (accountId && found.account?.id && found.account.id !== accountId) {
|
|
3125
|
+
problems.push(`${color4.bold(zone2)} is on Cloudflare, but under a different account (${found.account.name ?? found.account.id})
|
|
3126
|
+
` + " than the one this worker deploys to. The custom domain would be refused.");
|
|
3127
|
+
return { ok: false, problems, notes };
|
|
3128
|
+
}
|
|
3129
|
+
if (found.status !== "active") {
|
|
3130
|
+
notes.push(`${zone2} is on this account but its status is "${found.status}" — a custom domain needs an active zone.`);
|
|
3131
|
+
}
|
|
3132
|
+
const records = await cf(token, `/zones/${found.id}/dns_records?name.exact=${encodeURIComponent(hostname)}`);
|
|
3133
|
+
if (!records.ok) {
|
|
3134
|
+
notes.push(`existing records for ${hostname} could not be listed: ${records.error}. Add ${color4.cyan("Zone → DNS: Read")} to have this checked.`);
|
|
3135
|
+
return { ok: problems.length === 0, problems, notes };
|
|
3136
|
+
}
|
|
3137
|
+
const foreign = records.result.filter((record) => !(record.proxied && (record.content === "100::" || record.content === "192.0.2.1")));
|
|
3138
|
+
if (foreign.length > 0) {
|
|
3139
|
+
problems.push(`${color4.bold(hostname)} already has ${foreign.length} DNS record${foreign.length === 1 ? "" : "s"}:
|
|
3140
|
+
` + foreign.map((record) => ` ${record.type.padEnd(6)} ${record.content}`).join(`
|
|
3141
|
+
`) + `
|
|
3142
|
+
` + ` Deploying would take the hostname over. Wrangler asks about this at a
|
|
3143
|
+
` + ` terminal, but in CI it is not a prompt — it overrides the record silently.
|
|
3144
|
+
` + " Delete them first if the hostname really is meant to be this shop's.");
|
|
3145
|
+
}
|
|
3146
|
+
return { ok: problems.length === 0, problems, notes };
|
|
3147
|
+
}
|
|
3148
|
+
function printZoneCheck(check, zone2) {
|
|
3149
|
+
for (const note of check.notes)
|
|
3150
|
+
console.log(`${color4.yellow("!")} ${note}`);
|
|
3151
|
+
if (check.ok) {
|
|
3152
|
+
if (check.notes.length === 0)
|
|
3153
|
+
console.log(`${color4.green("✓")} ${zone2} is on this Cloudflare account, and the hostname is free`);
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
for (const problem of check.problems)
|
|
3157
|
+
console.error(`${color4.red("✗")} ${problem}`);
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
// src/deploy/cloudflare.ts
|
|
3084
3161
|
var fail = (message) => {
|
|
3085
3162
|
console.error(`
|
|
3086
|
-
${
|
|
3163
|
+
${color5.red("✗")} ${message}
|
|
3087
3164
|
`);
|
|
3088
3165
|
return 1;
|
|
3089
3166
|
};
|
|
@@ -3123,7 +3200,7 @@ async function deployCloudflare(project, opts) {
|
|
|
3123
3200
|
${who.raw.trim().split(`
|
|
3124
3201
|
`).slice(-4).join(`
|
|
3125
3202
|
`)}`);
|
|
3126
|
-
console.log(`${
|
|
3203
|
+
console.log(`${color5.green("✓")} wrangler is logged in`);
|
|
3127
3204
|
let config = readConfig(configPath);
|
|
3128
3205
|
let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
3129
3206
|
if (!accountId) {
|
|
@@ -3132,19 +3209,32 @@ ${who.raw.trim().split(`
|
|
|
3132
3209
|
writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "account_id", accountId));
|
|
3133
3210
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
|
|
3134
3211
|
await writeManifest(project.root, project.manifest);
|
|
3135
|
-
console.log(`${
|
|
3212
|
+
console.log(`${color5.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
|
|
3136
3213
|
} else {
|
|
3137
3214
|
return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
|
|
3138
3215
|
${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
3139
3216
|
`)}`);
|
|
3140
3217
|
}
|
|
3141
3218
|
}
|
|
3219
|
+
if (has(project.manifest, "cloudflare-samezone")) {
|
|
3220
|
+
const token = apiToken();
|
|
3221
|
+
if (token) {
|
|
3222
|
+
const shopZone = zone(project.manifest);
|
|
3223
|
+
const check2 = await checkSameZone(token, shopZone, workerHosts(project.manifest)[0], accountId);
|
|
3224
|
+
printZoneCheck(check2, shopZone);
|
|
3225
|
+
if (!check2.ok && !opts.force) {
|
|
3226
|
+
return fail("the zone is not ready for this shop. Fix the above, or pass --force to deploy anyway.");
|
|
3227
|
+
}
|
|
3228
|
+
} else {
|
|
3229
|
+
console.log(color5.dim("! CLOUDFLARE_API_TOKEN is not set here, so the zone could not be checked."));
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3142
3232
|
const check = await preflight(project, "wrangler");
|
|
3143
3233
|
printPreflight(project, check, "wrangler");
|
|
3144
3234
|
if (!check.ready) {
|
|
3145
3235
|
if (!opts.force)
|
|
3146
3236
|
return fail("refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force for a deliberate partial deploy.");
|
|
3147
|
-
console.error(
|
|
3237
|
+
console.error(color5.yellow(`--force: deploying a shop that is NOT ready for customers.
|
|
3148
3238
|
`));
|
|
3149
3239
|
}
|
|
3150
3240
|
const worker = config.name || project.manifest.shop.domain.split(".")[0];
|
|
@@ -3158,12 +3248,12 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3158
3248
|
writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "d1_databases", [entry]));
|
|
3159
3249
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
|
|
3160
3250
|
await writeManifest(project.root, project.manifest);
|
|
3161
|
-
console.log(`${
|
|
3251
|
+
console.log(`${color5.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
|
|
3162
3252
|
config = readConfig(configPath);
|
|
3163
3253
|
}
|
|
3164
3254
|
if (opts.provision) {
|
|
3165
3255
|
await ensureQueue(bin, app, "commerce");
|
|
3166
|
-
console.log(`${
|
|
3256
|
+
console.log(`${color5.green("✓")} queue "commerce"`);
|
|
3167
3257
|
}
|
|
3168
3258
|
const builder = findBuilder(app);
|
|
3169
3259
|
if (!builder)
|
|
@@ -3171,13 +3261,13 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3171
3261
|
const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
|
|
3172
3262
|
if (merging) {
|
|
3173
3263
|
console.log(`
|
|
3174
|
-
▸ ${builder.label} ${
|
|
3264
|
+
▸ ${builder.label} ${color5.dim("(the storefront)")}`);
|
|
3175
3265
|
const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
|
|
3176
3266
|
if (storefront.code !== 0)
|
|
3177
3267
|
return fail(`the storefront build failed (exit ${storefront.code}).`);
|
|
3178
3268
|
}
|
|
3179
3269
|
console.log(`
|
|
3180
|
-
▸ ${builder.label}${merging ?
|
|
3270
|
+
▸ ${builder.label}${merging ? color5.dim(" (the worker)") : ""}`);
|
|
3181
3271
|
const built = await wrangler(builder.cmd, ["build"], app, true);
|
|
3182
3272
|
if (built.code !== 0)
|
|
3183
3273
|
return fail(`the build failed (exit ${built.code}).`);
|
|
@@ -3192,7 +3282,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3192
3282
|
if (!existsSync8(join8(into, "index.html"))) {
|
|
3193
3283
|
return fail("the storefront produced no index.html, so the shop has no front page.");
|
|
3194
3284
|
}
|
|
3195
|
-
console.log(`${
|
|
3285
|
+
console.log(`${color5.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
|
|
3196
3286
|
}
|
|
3197
3287
|
const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
|
|
3198
3288
|
if (!existsSync8(emittedPath))
|
|
@@ -3210,7 +3300,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3210
3300
|
if (!emittedD1 || emittedD1.database_id === "local")
|
|
3211
3301
|
return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
|
|
3212
3302
|
writeFileSync(emittedPath, JSON.stringify(emitted, null, 2));
|
|
3213
|
-
console.log(`${
|
|
3303
|
+
console.log(`${color5.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
|
|
3214
3304
|
console.log(`
|
|
3215
3305
|
▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
|
|
3216
3306
|
const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
|
|
@@ -3224,7 +3314,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3224
3314
|
return fail(`the repository's secrets could not be read: ${decrypted.error}`);
|
|
3225
3315
|
secretArgs.push("--secrets-file", decrypted.path);
|
|
3226
3316
|
cleanupSecrets = decrypted.cleanup;
|
|
3227
|
-
console.log(`${
|
|
3317
|
+
console.log(`${color5.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
|
|
3228
3318
|
}
|
|
3229
3319
|
console.log(`
|
|
3230
3320
|
▸ wrangler deploy -c dist/ssr/wrangler.json`);
|
|
@@ -3235,9 +3325,9 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3235
3325
|
const domain = project.manifest.shop.domain;
|
|
3236
3326
|
const url = `https://${workerHosts(project.manifest)[0]}`;
|
|
3237
3327
|
console.log(`
|
|
3238
|
-
${
|
|
3328
|
+
${color5.green("Live:")} ${url}`);
|
|
3239
3329
|
if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
|
|
3240
|
-
console.log(
|
|
3330
|
+
console.log(color5.dim("The storefront deploys itself from GitHub Actions on push."));
|
|
3241
3331
|
}
|
|
3242
3332
|
return 0;
|
|
3243
3333
|
}
|
|
@@ -3263,7 +3353,7 @@ function mergeTree(from, into, skip) {
|
|
|
3263
3353
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
3264
3354
|
import { existsSync as existsSync9 } from "node:fs";
|
|
3265
3355
|
import { join as join9 } from "node:path";
|
|
3266
|
-
import
|
|
3356
|
+
import color6 from "picocolors";
|
|
3267
3357
|
function describe(counts) {
|
|
3268
3358
|
if (!counts)
|
|
3269
3359
|
return "";
|
|
@@ -3330,25 +3420,25 @@ async function importCommand(args) {
|
|
|
3330
3420
|
}
|
|
3331
3421
|
const { url, how } = resolveTarget(project, args);
|
|
3332
3422
|
if (!dry)
|
|
3333
|
-
console.log(`${
|
|
3423
|
+
console.log(`${color6.dim("→")} ${url} ${color6.dim(`(${how})`)}
|
|
3334
3424
|
`);
|
|
3335
3425
|
if (!dry) {
|
|
3336
3426
|
let whoami2;
|
|
3337
3427
|
try {
|
|
3338
3428
|
whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
|
|
3339
3429
|
} catch (error) {
|
|
3340
|
-
console.error(`${
|
|
3430
|
+
console.error(`${color6.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
|
|
3341
3431
|
` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
|
|
3342
3432
|
`);
|
|
3343
3433
|
return 1;
|
|
3344
3434
|
}
|
|
3345
3435
|
if (!whoami2.ok) {
|
|
3346
|
-
console.error(`${
|
|
3436
|
+
console.error(`${color6.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
|
|
3347
3437
|
` + ` Is SYSTEM_API_KEY the one this deployment was given?
|
|
3348
3438
|
`);
|
|
3349
3439
|
return 1;
|
|
3350
3440
|
}
|
|
3351
|
-
console.log(`${
|
|
3441
|
+
console.log(`${color6.green("✓")} authenticated as the system identity`);
|
|
3352
3442
|
}
|
|
3353
3443
|
const root = project.root;
|
|
3354
3444
|
const products = await readJson(join9(root, "data", "catalog.json"));
|
|
@@ -3357,14 +3447,14 @@ async function importCommand(args) {
|
|
|
3357
3447
|
const posts = has(project.manifest, "blogs") ? await readJson(join9(root, "content", "posts.json")) : null;
|
|
3358
3448
|
if (!products && !faqs?.length && !posts?.length) {
|
|
3359
3449
|
console.log(`
|
|
3360
|
-
${
|
|
3450
|
+
${color6.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
|
|
3361
3451
|
` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
|
|
3362
3452
|
`);
|
|
3363
3453
|
return 0;
|
|
3364
3454
|
}
|
|
3365
3455
|
if (dry) {
|
|
3366
3456
|
console.log(`
|
|
3367
|
-
${
|
|
3457
|
+
${color6.dim("--dry-run: nothing was pushed.")}`);
|
|
3368
3458
|
console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
|
|
3369
3459
|
console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
|
|
3370
3460
|
`);
|
|
@@ -3380,15 +3470,15 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3380
3470
|
});
|
|
3381
3471
|
if (result.ok) {
|
|
3382
3472
|
const report = result.body.report ?? {};
|
|
3383
|
-
console.log(`${
|
|
3473
|
+
console.log(`${color6.green("✓")} products: ${describe(report.products)}`);
|
|
3384
3474
|
if (report.categories)
|
|
3385
|
-
console.log(`${
|
|
3475
|
+
console.log(`${color6.green("✓")} categories: ${describe(report.categories)}`);
|
|
3386
3476
|
if (report.prices)
|
|
3387
|
-
console.log(`${
|
|
3477
|
+
console.log(`${color6.green("✓")} prices: ${describe(report.prices)}`);
|
|
3388
3478
|
if (report.addons)
|
|
3389
|
-
console.log(`${
|
|
3479
|
+
console.log(`${color6.green("✓")} addons: ${describe(report.addons)}`);
|
|
3390
3480
|
} else {
|
|
3391
|
-
console.error(`${
|
|
3481
|
+
console.error(`${color6.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3392
3482
|
failed = true;
|
|
3393
3483
|
}
|
|
3394
3484
|
}
|
|
@@ -3400,20 +3490,20 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3400
3490
|
continue;
|
|
3401
3491
|
const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
|
|
3402
3492
|
if (result.ok) {
|
|
3403
|
-
console.log(`${
|
|
3493
|
+
console.log(`${color6.green("✓")} ${label}: ${describe(result.body)}`);
|
|
3404
3494
|
} else {
|
|
3405
|
-
console.error(`${
|
|
3495
|
+
console.error(`${color6.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3406
3496
|
failed = true;
|
|
3407
3497
|
}
|
|
3408
3498
|
}
|
|
3409
3499
|
if (failed) {
|
|
3410
3500
|
console.error(`
|
|
3411
|
-
${
|
|
3501
|
+
${color6.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
|
|
3412
3502
|
`);
|
|
3413
3503
|
return 1;
|
|
3414
3504
|
}
|
|
3415
3505
|
console.log(`
|
|
3416
|
-
${
|
|
3506
|
+
${color6.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
|
|
3417
3507
|
`);
|
|
3418
3508
|
return 0;
|
|
3419
3509
|
}
|
package/dist/index.js
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
routeProblem,
|
|
53
53
|
strictDependencies,
|
|
54
54
|
upsertJsonc
|
|
55
|
-
} from "./index-
|
|
55
|
+
} from "./index-g0p3kk0f.js";
|
|
56
56
|
import {
|
|
57
57
|
allEnvKeys,
|
|
58
58
|
envSummary,
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
renderEnvLocal,
|
|
61
61
|
renderEnvProduction,
|
|
62
62
|
renderEnvTs
|
|
63
|
-
} from "./index-
|
|
63
|
+
} from "./index-a279r74m.js";
|
|
64
64
|
import {
|
|
65
65
|
LAYOUTS,
|
|
66
66
|
MANIFEST_FILE,
|
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
writeManifest,
|
|
82
82
|
zone,
|
|
83
83
|
zoneOf
|
|
84
|
-
} from "./index-
|
|
84
|
+
} from "./index-ym88atwf.js";
|
|
85
85
|
import {
|
|
86
86
|
AUTH_PLUGINS,
|
|
87
87
|
CARRIERS,
|
|
@@ -98,7 +98,7 @@ import {
|
|
|
98
98
|
SIGN_IN,
|
|
99
99
|
TAX,
|
|
100
100
|
UI
|
|
101
|
-
} from "./index-
|
|
101
|
+
} from "./index-7xwcv2cj.js";
|
|
102
102
|
import"./index-0v6na3yp.js";
|
|
103
103
|
export {
|
|
104
104
|
zoneOf,
|
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
localPrivateKey,
|
|
9
9
|
provisionKey,
|
|
10
10
|
publicKeyFor
|
|
11
|
-
} from "./index-
|
|
12
|
-
import"./index-
|
|
13
|
-
import"./index-
|
|
11
|
+
} from "./index-a279r74m.js";
|
|
12
|
+
import"./index-ym88atwf.js";
|
|
13
|
+
import"./index-7xwcv2cj.js";
|
|
14
14
|
import"./index-0v6na3yp.js";
|
|
15
15
|
export {
|
|
16
16
|
publicKeyFor,
|
package/dist/manifest.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/catalog.ts
CHANGED
|
@@ -605,15 +605,20 @@ export const EMAIL: Group = {
|
|
|
605
605
|
export const DNS: Group = {
|
|
606
606
|
id: "dns",
|
|
607
607
|
title: "DNS",
|
|
608
|
-
intro: "
|
|
608
|
+
intro: "Where the shop's domain lives, and whether the deploy may touch it. The worker's own hostname is a Cloudflare custom domain and needs no record here — Cloudflare writes that one itself.",
|
|
609
609
|
kind: "select",
|
|
610
610
|
choices: [
|
|
611
|
+
{
|
|
612
|
+
id: "cloudflare-samezone",
|
|
613
|
+
label: "Cloudflare, same account",
|
|
614
|
+
hint: "the zone is on the same Cloudflare account as the worker — the deploy checks that before it changes anything, and needs no extra credential",
|
|
615
|
+
recommended: true,
|
|
616
|
+
},
|
|
611
617
|
{
|
|
612
618
|
id: "cloudflare",
|
|
613
|
-
label: "Cloudflare",
|
|
614
|
-
hint: "
|
|
619
|
+
label: "Cloudflare, another account or a second zone",
|
|
620
|
+
hint: "a separate DNS token, for a zone the worker's own token cannot see — the deploy writes the mail records so confirmations are not spam",
|
|
615
621
|
packages: ["@saastemly/better-dns"],
|
|
616
|
-
recommended: true,
|
|
617
622
|
env: [
|
|
618
623
|
{ key: "CLOUDFLARE_DNS_TOKEN", breaks: "the storefront records are never written; the domain does not reach the shop", where: "a token with Zone:DNS:Edit and Zone:Zone:Read" },
|
|
619
624
|
],
|
package/src/deploy/cloudflare.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFi
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import color from "picocolors";
|
|
4
4
|
import { allEnvKeys } from "../generate/env";
|
|
5
|
-
import { hasFrontend, isSingleApp, oneOrigin, workerHosts, writeManifest } from "../manifest";
|
|
5
|
+
import { has, hasFrontend, isSingleApp, oneOrigin, workerHosts, writeManifest, zone } from "../manifest";
|
|
6
6
|
import type { Project } from "../project";
|
|
7
7
|
import { parseJsonc, upsertJsonc } from "./jsonc";
|
|
8
|
+
import { apiToken } from "./github";
|
|
8
9
|
import { preflight, printPreflight } from "./preflight";
|
|
10
|
+
import { checkSameZone, printZoneCheck } from "./zone";
|
|
9
11
|
import { SECRETS_FILE, decryptSecrets } from "./secrets";
|
|
10
12
|
import { ensureD1, ensureQueue, findWrangler, whoami, wrangler } from "./wrangler";
|
|
11
13
|
|
|
@@ -99,6 +101,23 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
|
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
// 1b. Same zone. Before anything is created, because the answer decides
|
|
105
|
+
// whether the hostname can work at all — and because a conflicting record
|
|
106
|
+
// would be taken over silently by the deploy below.
|
|
107
|
+
if (has(project.manifest, "cloudflare-samezone")) {
|
|
108
|
+
const token = apiToken();
|
|
109
|
+
if (token) {
|
|
110
|
+
const shopZone = zone(project.manifest);
|
|
111
|
+
const check = await checkSameZone(token, shopZone, workerHosts(project.manifest)[0]!, accountId);
|
|
112
|
+
printZoneCheck(check, shopZone);
|
|
113
|
+
if (!check.ok && !opts.force) {
|
|
114
|
+
return fail("the zone is not ready for this shop. Fix the above, or pass --force to deploy anyway.");
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
console.log(color.dim("! CLOUDFLARE_API_TOKEN is not set here, so the zone could not be checked."));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
102
121
|
// 2. preflight.
|
|
103
122
|
const check = await preflight(project, "wrangler");
|
|
104
123
|
printPreflight(project, check, "wrangler");
|
package/src/deploy/github.ts
CHANGED
|
@@ -35,6 +35,11 @@ import { existsSync } from "node:fs";
|
|
|
35
35
|
* @see https://github.com/cloudflare/workers-sdk/discussions/11434
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
/** The Cloudflare token, from wherever wrangler itself would read it. */
|
|
39
|
+
export function apiToken(): string | null {
|
|
40
|
+
return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
export interface GhAuth {
|
|
39
44
|
/** Logged in, with a token that can write repository secrets. */
|
|
40
45
|
ok: boolean;
|
package/src/deploy/link.ts
CHANGED
|
@@ -50,6 +50,8 @@ const SCOPES: Array<[string, string]> = [
|
|
|
50
50
|
["Account · Workers R2 Storage: Edit", "product images"],
|
|
51
51
|
["Account · Account Settings: Read", "confirm which account this is"],
|
|
52
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"],
|
|
53
55
|
["User · User Details: Read", "wrangler asks at startup"],
|
|
54
56
|
];
|
|
55
57
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import color from "picocolors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Same zone": the shop's DNS zone is on the SAME Cloudflare account as the
|
|
5
|
+
* worker — and checking that it really is.
|
|
6
|
+
*
|
|
7
|
+
* ── Why this is worth a check rather than a sentence ─────────────────────
|
|
8
|
+
*
|
|
9
|
+
* Everything about the deploy rests on it. A Worker custom domain is a
|
|
10
|
+
* record Cloudflare writes in its own zone, so it works only when the zone
|
|
11
|
+
* is on the account the token belongs to. Cloudflare's own words: *"You
|
|
12
|
+
* cannot create a Custom Domain on a hostname with an existing CNAME DNS
|
|
13
|
+
* record or on a zone you do not own."* Until now that was a line in
|
|
14
|
+
* DEPLOY.md and nothing verified it, so getting it wrong surfaced as a
|
|
15
|
+
* wrangler error in the middle of a deploy that had already created a
|
|
16
|
+
* database.
|
|
17
|
+
*
|
|
18
|
+
* ── And the failure that is worse than an error ──────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* Wrangler resolves a conflicting hostname interactively — it asks whether
|
|
21
|
+
* to move the record onto this script. In CI there is nobody to ask, and it
|
|
22
|
+
* does not fail: its own source sets
|
|
23
|
+
*
|
|
24
|
+
* override_existing_origin = true
|
|
25
|
+
* override_existing_dns_record = true
|
|
26
|
+
*
|
|
27
|
+
* whenever stdout is not a TTY. So a hostname already serving something
|
|
28
|
+
* else — another worker, a real site — is taken over silently, by a push.
|
|
29
|
+
* That is not a thing to discover afterwards, so this refuses first.
|
|
30
|
+
*
|
|
31
|
+
* ── What it deliberately does not do ─────────────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* It writes nothing. `cloudflare-samezone` needs no DNS records and no
|
|
34
|
+
* second credential: the worker's hostname is a custom domain Cloudflare
|
|
35
|
+
* creates itself, the storefront rides in that same worker, and Cloudflare
|
|
36
|
+
* Email writes its own SPF, DKIM and DMARC when the domain is onboarded.
|
|
37
|
+
* The value here is the precondition, not the plumbing.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export interface ZoneCheck {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
/** What to tell the operator. Empty when everything is as it should be. */
|
|
43
|
+
problems: string[];
|
|
44
|
+
notes: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CfZone {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
status: string;
|
|
51
|
+
account?: { id?: string; name?: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function cf<T>(token: string, path: string): Promise<{ ok: true; result: T } | { ok: false; error: string }> {
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
57
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
58
|
+
});
|
|
59
|
+
const body = (await response.json()) as { success?: boolean; result?: T; errors?: Array<{ message?: string; code?: number }> };
|
|
60
|
+
if (!body.success) {
|
|
61
|
+
const first = body.errors?.[0];
|
|
62
|
+
return { ok: false, error: first?.message ? `${first.message}${first.code ? ` (${first.code})` : ""}` : `HTTP ${response.status}` };
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, result: body.result as T };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { ok: false, error: `could not reach the Cloudflare API: ${String(error)}` };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Is `zone` on this account, and is `hostname` free to become a custom domain?
|
|
72
|
+
*
|
|
73
|
+
* `accountId` is optional: without it any zone the token can see counts,
|
|
74
|
+
* which is the right answer for a token scoped to one account anyway.
|
|
75
|
+
*/
|
|
76
|
+
export async function checkSameZone(token: string, zone: string, hostname: string, accountId?: string): Promise<ZoneCheck> {
|
|
77
|
+
const problems: string[] = [];
|
|
78
|
+
const notes: string[] = [];
|
|
79
|
+
|
|
80
|
+
const zones = await cf<CfZone[]>(token, `/zones?name=${encodeURIComponent(zone)}`);
|
|
81
|
+
if (!zones.ok) {
|
|
82
|
+
// A token without Zone:Read cannot answer the question. That is a gap
|
|
83
|
+
// in the check, not a failed shop, so it is reported and not fatal.
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
problems: [],
|
|
87
|
+
notes: [`the zone could not be checked: ${zones.error}. Add ${color.cyan("Zone → Zone: Read")} to the API token to have this verified.`],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const found = zones.result.find((entry) => entry.name.toLowerCase() === zone.toLowerCase());
|
|
92
|
+
if (!found) {
|
|
93
|
+
problems.push(
|
|
94
|
+
`the zone ${color.bold(zone)} is not on this Cloudflare account.\n` +
|
|
95
|
+
" A Worker custom domain is a record Cloudflare writes in its own zone, so a\n" +
|
|
96
|
+
" domain hosted anywhere else cannot have one. Move the zone to this account,\n" +
|
|
97
|
+
" or point the shop at a domain that is already on it.",
|
|
98
|
+
);
|
|
99
|
+
return { ok: false, problems, notes };
|
|
100
|
+
}
|
|
101
|
+
if (accountId && found.account?.id && found.account.id !== accountId) {
|
|
102
|
+
problems.push(
|
|
103
|
+
`${color.bold(zone)} is on Cloudflare, but under a different account (${found.account.name ?? found.account.id})\n` +
|
|
104
|
+
" than the one this worker deploys to. The custom domain would be refused.",
|
|
105
|
+
);
|
|
106
|
+
return { ok: false, problems, notes };
|
|
107
|
+
}
|
|
108
|
+
if (found.status !== "active") {
|
|
109
|
+
notes.push(`${zone} is on this account but its status is "${found.status}" — a custom domain needs an active zone.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The silent-takeover check. A record already answering for this hostname
|
|
113
|
+
// is somebody's, and in CI wrangler would move it without asking.
|
|
114
|
+
const records = await cf<Array<{ id: string; type: string; name: string; content: string; proxied?: boolean }>>(
|
|
115
|
+
token,
|
|
116
|
+
`/zones/${found.id}/dns_records?name.exact=${encodeURIComponent(hostname)}`,
|
|
117
|
+
);
|
|
118
|
+
if (!records.ok) {
|
|
119
|
+
notes.push(`existing records for ${hostname} could not be listed: ${records.error}. Add ${color.cyan("Zone → DNS: Read")} to have this checked.`);
|
|
120
|
+
return { ok: problems.length === 0, problems, notes };
|
|
121
|
+
}
|
|
122
|
+
// A Worker custom domain shows up as a proxied AAAA/A at 100:: — that is
|
|
123
|
+
// this shop's own record from a previous deploy, not a conflict.
|
|
124
|
+
const foreign = records.result.filter((record) => !(record.proxied && (record.content === "100::" || record.content === "192.0.2.1")));
|
|
125
|
+
if (foreign.length > 0) {
|
|
126
|
+
problems.push(
|
|
127
|
+
`${color.bold(hostname)} already has ${foreign.length} DNS record${foreign.length === 1 ? "" : "s"}:\n` +
|
|
128
|
+
foreign.map((record) => ` ${record.type.padEnd(6)} ${record.content}`).join("\n") +
|
|
129
|
+
"\n" +
|
|
130
|
+
" Deploying would take the hostname over. Wrangler asks about this at a\n" +
|
|
131
|
+
" terminal, but in CI it is not a prompt — it overrides the record silently.\n" +
|
|
132
|
+
" Delete them first if the hostname really is meant to be this shop's.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { ok: problems.length === 0, problems, notes };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Print a check, in the shape the rest of the deploy uses. */
|
|
140
|
+
export function printZoneCheck(check: ZoneCheck, zone: string): void {
|
|
141
|
+
for (const note of check.notes) console.log(`${color.yellow("!")} ${note}`);
|
|
142
|
+
if (check.ok) {
|
|
143
|
+
if (check.notes.length === 0) console.log(`${color.green("✓")} ${zone} is on this Cloudflare account, and the hostname is free`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const problem of check.problems) console.error(`${color.red("✗")} ${problem}`);
|
|
147
|
+
}
|
package/src/generate/ci.ts
CHANGED
|
@@ -225,6 +225,8 @@ Create the token at **My Profile → API Tokens → Create Token → Custom toke
|
|
|
225
225
|
| Account | Workers R2 Storage: Edit | product images |
|
|
226
226
|
| Account | Account Settings: Read | confirming which account |
|
|
227
227
|
| Zone | Workers Routes: Edit (${zone}) | answering on your domain |
|
|
228
|
+
| Zone | Zone: Read (${zone}) | confirming the zone is on this account |
|
|
229
|
+
| Zone | DNS: Read (${zone}) | noticing a hostname that already answers |
|
|
228
230
|
| User | User Details: Read, Memberships: Read | wrangler asks at startup |
|
|
229
231
|
|
|
230
232
|
The token Cloudflare generates for its own Workers Builds will **not** do: it
|