@saastemly/voidcommerce 0.10.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/generate/env.d.ts +10 -0
- package/dist/{index-mnb7fz2t.js → index-7xwcv2cj.js} +9 -4
- package/dist/{index-vhsq8jpy.js → index-a279r74m.js} +7 -3
- package/dist/{index-mb1p1rh0.js → index-g0p3kk0f.js} +126 -33
- package/dist/{index-tjy6yygc.js → index-ym88atwf.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/{keys-2q3qzntw.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/src/generate/env.ts +11 -0
- package/src/generate/index.ts +4 -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;
|
package/dist/generate/env.d.ts
CHANGED
|
@@ -3,6 +3,16 @@ import { type Manifest, has } from "../manifest";
|
|
|
3
3
|
export declare function allEnvKeys(manifest: Manifest): EnvKey[];
|
|
4
4
|
export declare function renderEnvTs(manifest: Manifest): string;
|
|
5
5
|
export declare function renderEnvExample(manifest: Manifest): string;
|
|
6
|
+
/**
|
|
7
|
+
* Keys whose value is the MANIFEST'S to decide, not the operator's.
|
|
8
|
+
*
|
|
9
|
+
* These are the shop's identity. Preserving whatever was already on disk is
|
|
10
|
+
* right for a credential and wrong for these: a shop copied from another one
|
|
11
|
+
* kept `SHOP_DOMAIN=tshirt.test` and an EMAIL_FROM at the wrong domain, which
|
|
12
|
+
* is how a storefront ends up quietly addressing the wrong business. If you
|
|
13
|
+
* want a different domain, change the manifest — that is what it is for.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MANIFEST_OWNED_ENV: Set<string>;
|
|
6
16
|
export declare function renderEnvLocal(manifest: Manifest): string;
|
|
7
17
|
export declare function renderEnvProduction(manifest: Manifest): string;
|
|
8
18
|
/** So the generator can say what it decided. */
|
|
@@ -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)) {
|
|
@@ -233,6 +236,7 @@ function renderEnvExample(manifest) {
|
|
|
233
236
|
].join(`
|
|
234
237
|
`);
|
|
235
238
|
}
|
|
239
|
+
var MANIFEST_OWNED_ENV = new Set(["SHOP_DOMAIN", "SHOP_ZONE", "EMAIL_FROM", "GITHUB_PAGES_HOST"]);
|
|
236
240
|
function renderEnvLocal(manifest) {
|
|
237
241
|
const keys = allEnvKeys(manifest);
|
|
238
242
|
const local = {
|
|
@@ -385,7 +389,7 @@ async function decryptSecrets(project) {
|
|
|
385
389
|
}
|
|
386
390
|
const expected = committedPublicKey(root);
|
|
387
391
|
if (expected) {
|
|
388
|
-
const { publicKeyFor } = await import("./keys-
|
|
392
|
+
const { publicKeyFor } = await import("./keys-px5maaj1.js");
|
|
389
393
|
const derived = await publicKeyFor(privateKey);
|
|
390
394
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
391
395
|
return {
|
|
@@ -912,4 +916,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
|
|
|
912
916
|
return 0;
|
|
913
917
|
}
|
|
914
918
|
|
|
915
|
-
export { allEnvKeys, renderEnvTs, renderEnvExample, 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 };
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
MANIFEST_OWNED_ENV,
|
|
2
3
|
PRIVATE_KEY_VAR,
|
|
3
4
|
SECRETS_FILE,
|
|
4
5
|
allEnvKeys,
|
|
6
|
+
apiToken,
|
|
5
7
|
declaredSecretNames,
|
|
6
8
|
decryptSecrets,
|
|
7
9
|
plaintextSecretNames,
|
|
@@ -9,7 +11,7 @@ import {
|
|
|
9
11
|
renderEnvLocal,
|
|
10
12
|
renderEnvProduction,
|
|
11
13
|
renderEnvTs
|
|
12
|
-
} from "./index-
|
|
14
|
+
} from "./index-a279r74m.js";
|
|
13
15
|
import {
|
|
14
16
|
MANIFEST_FILE,
|
|
15
17
|
has,
|
|
@@ -22,10 +24,10 @@ import {
|
|
|
22
24
|
workerHosts,
|
|
23
25
|
writeManifest,
|
|
24
26
|
zone
|
|
25
|
-
} from "./index-
|
|
27
|
+
} from "./index-ym88atwf.js";
|
|
26
28
|
import {
|
|
27
29
|
CHOICES
|
|
28
|
-
} from "./index-
|
|
30
|
+
} from "./index-7xwcv2cj.js";
|
|
29
31
|
import {
|
|
30
32
|
__require
|
|
31
33
|
} from "./index-0v6na3yp.js";
|
|
@@ -1370,7 +1372,7 @@ import color from "picocolors";
|
|
|
1370
1372
|
// package.json
|
|
1371
1373
|
var package_default = {
|
|
1372
1374
|
name: "@saastemly/voidcommerce",
|
|
1373
|
-
version: "0.
|
|
1375
|
+
version: "0.12.0",
|
|
1374
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`.",
|
|
1375
1377
|
type: "module",
|
|
1376
1378
|
license: "MIT",
|
|
@@ -1884,6 +1886,8 @@ Create the token at **My Profile → API Tokens → Create Token → Custom toke
|
|
|
1884
1886
|
| Account | Workers R2 Storage: Edit | product images |
|
|
1885
1887
|
| Account | Account Settings: Read | confirming which account |
|
|
1886
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 |
|
|
1887
1891
|
| User | User Details: Read, Memberships: Read | wrangler asks at startup |
|
|
1888
1892
|
|
|
1889
1893
|
The token Cloudflare generates for its own Workers Builds will **not** do: it
|
|
@@ -2344,6 +2348,8 @@ async function putEnv(root, file, rendered, result) {
|
|
|
2344
2348
|
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line2);
|
|
2345
2349
|
if (!match)
|
|
2346
2350
|
return line2;
|
|
2351
|
+
if (MANIFEST_OWNED_ENV.has(match[1]))
|
|
2352
|
+
return line2;
|
|
2347
2353
|
const had = existing.get(match[1]);
|
|
2348
2354
|
return had !== undefined && had !== "" && had !== "unset" ? `${match[1]}=${had}` : line2;
|
|
2349
2355
|
}).join(`
|
|
@@ -3077,10 +3083,84 @@ ${color3.red("NOT ready to go live.")}
|
|
|
3077
3083
|
// src/deploy/cloudflare.ts
|
|
3078
3084
|
import { copyFileSync, existsSync as existsSync8, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "node:fs";
|
|
3079
3085
|
import { dirname as dirname5, join as join8 } from "node:path";
|
|
3086
|
+
import color5 from "picocolors";
|
|
3087
|
+
|
|
3088
|
+
// src/deploy/zone.ts
|
|
3080
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
|
|
3081
3161
|
var fail = (message) => {
|
|
3082
3162
|
console.error(`
|
|
3083
|
-
${
|
|
3163
|
+
${color5.red("✗")} ${message}
|
|
3084
3164
|
`);
|
|
3085
3165
|
return 1;
|
|
3086
3166
|
};
|
|
@@ -3120,7 +3200,7 @@ async function deployCloudflare(project, opts) {
|
|
|
3120
3200
|
${who.raw.trim().split(`
|
|
3121
3201
|
`).slice(-4).join(`
|
|
3122
3202
|
`)}`);
|
|
3123
|
-
console.log(`${
|
|
3203
|
+
console.log(`${color5.green("✓")} wrangler is logged in`);
|
|
3124
3204
|
let config = readConfig(configPath);
|
|
3125
3205
|
let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
3126
3206
|
if (!accountId) {
|
|
@@ -3129,19 +3209,32 @@ ${who.raw.trim().split(`
|
|
|
3129
3209
|
writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "account_id", accountId));
|
|
3130
3210
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
|
|
3131
3211
|
await writeManifest(project.root, project.manifest);
|
|
3132
|
-
console.log(`${
|
|
3212
|
+
console.log(`${color5.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
|
|
3133
3213
|
} else {
|
|
3134
3214
|
return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
|
|
3135
3215
|
${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
3136
3216
|
`)}`);
|
|
3137
3217
|
}
|
|
3138
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
|
+
}
|
|
3139
3232
|
const check = await preflight(project, "wrangler");
|
|
3140
3233
|
printPreflight(project, check, "wrangler");
|
|
3141
3234
|
if (!check.ready) {
|
|
3142
3235
|
if (!opts.force)
|
|
3143
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.");
|
|
3144
|
-
console.error(
|
|
3237
|
+
console.error(color5.yellow(`--force: deploying a shop that is NOT ready for customers.
|
|
3145
3238
|
`));
|
|
3146
3239
|
}
|
|
3147
3240
|
const worker = config.name || project.manifest.shop.domain.split(".")[0];
|
|
@@ -3155,12 +3248,12 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3155
3248
|
writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "d1_databases", [entry]));
|
|
3156
3249
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
|
|
3157
3250
|
await writeManifest(project.root, project.manifest);
|
|
3158
|
-
console.log(`${
|
|
3251
|
+
console.log(`${color5.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
|
|
3159
3252
|
config = readConfig(configPath);
|
|
3160
3253
|
}
|
|
3161
3254
|
if (opts.provision) {
|
|
3162
3255
|
await ensureQueue(bin, app, "commerce");
|
|
3163
|
-
console.log(`${
|
|
3256
|
+
console.log(`${color5.green("✓")} queue "commerce"`);
|
|
3164
3257
|
}
|
|
3165
3258
|
const builder = findBuilder(app);
|
|
3166
3259
|
if (!builder)
|
|
@@ -3168,13 +3261,13 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3168
3261
|
const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
|
|
3169
3262
|
if (merging) {
|
|
3170
3263
|
console.log(`
|
|
3171
|
-
▸ ${builder.label} ${
|
|
3264
|
+
▸ ${builder.label} ${color5.dim("(the storefront)")}`);
|
|
3172
3265
|
const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
|
|
3173
3266
|
if (storefront.code !== 0)
|
|
3174
3267
|
return fail(`the storefront build failed (exit ${storefront.code}).`);
|
|
3175
3268
|
}
|
|
3176
3269
|
console.log(`
|
|
3177
|
-
▸ ${builder.label}${merging ?
|
|
3270
|
+
▸ ${builder.label}${merging ? color5.dim(" (the worker)") : ""}`);
|
|
3178
3271
|
const built = await wrangler(builder.cmd, ["build"], app, true);
|
|
3179
3272
|
if (built.code !== 0)
|
|
3180
3273
|
return fail(`the build failed (exit ${built.code}).`);
|
|
@@ -3189,7 +3282,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3189
3282
|
if (!existsSync8(join8(into, "index.html"))) {
|
|
3190
3283
|
return fail("the storefront produced no index.html, so the shop has no front page.");
|
|
3191
3284
|
}
|
|
3192
|
-
console.log(`${
|
|
3285
|
+
console.log(`${color5.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
|
|
3193
3286
|
}
|
|
3194
3287
|
const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
|
|
3195
3288
|
if (!existsSync8(emittedPath))
|
|
@@ -3207,7 +3300,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3207
3300
|
if (!emittedD1 || emittedD1.database_id === "local")
|
|
3208
3301
|
return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
|
|
3209
3302
|
writeFileSync(emittedPath, JSON.stringify(emitted, null, 2));
|
|
3210
|
-
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(", ")}` : ""}`);
|
|
3211
3304
|
console.log(`
|
|
3212
3305
|
▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
|
|
3213
3306
|
const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
|
|
@@ -3221,7 +3314,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3221
3314
|
return fail(`the repository's secrets could not be read: ${decrypted.error}`);
|
|
3222
3315
|
secretArgs.push("--secrets-file", decrypted.path);
|
|
3223
3316
|
cleanupSecrets = decrypted.cleanup;
|
|
3224
|
-
console.log(`${
|
|
3317
|
+
console.log(`${color5.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
|
|
3225
3318
|
}
|
|
3226
3319
|
console.log(`
|
|
3227
3320
|
▸ wrangler deploy -c dist/ssr/wrangler.json`);
|
|
@@ -3232,9 +3325,9 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3232
3325
|
const domain = project.manifest.shop.domain;
|
|
3233
3326
|
const url = `https://${workerHosts(project.manifest)[0]}`;
|
|
3234
3327
|
console.log(`
|
|
3235
|
-
${
|
|
3328
|
+
${color5.green("Live:")} ${url}`);
|
|
3236
3329
|
if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
|
|
3237
|
-
console.log(
|
|
3330
|
+
console.log(color5.dim("The storefront deploys itself from GitHub Actions on push."));
|
|
3238
3331
|
}
|
|
3239
3332
|
return 0;
|
|
3240
3333
|
}
|
|
@@ -3260,7 +3353,7 @@ function mergeTree(from, into, skip) {
|
|
|
3260
3353
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
3261
3354
|
import { existsSync as existsSync9 } from "node:fs";
|
|
3262
3355
|
import { join as join9 } from "node:path";
|
|
3263
|
-
import
|
|
3356
|
+
import color6 from "picocolors";
|
|
3264
3357
|
function describe(counts) {
|
|
3265
3358
|
if (!counts)
|
|
3266
3359
|
return "";
|
|
@@ -3327,25 +3420,25 @@ async function importCommand(args) {
|
|
|
3327
3420
|
}
|
|
3328
3421
|
const { url, how } = resolveTarget(project, args);
|
|
3329
3422
|
if (!dry)
|
|
3330
|
-
console.log(`${
|
|
3423
|
+
console.log(`${color6.dim("→")} ${url} ${color6.dim(`(${how})`)}
|
|
3331
3424
|
`);
|
|
3332
3425
|
if (!dry) {
|
|
3333
3426
|
let whoami2;
|
|
3334
3427
|
try {
|
|
3335
3428
|
whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
|
|
3336
3429
|
} catch (error) {
|
|
3337
|
-
console.error(`${
|
|
3430
|
+
console.error(`${color6.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
|
|
3338
3431
|
` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
|
|
3339
3432
|
`);
|
|
3340
3433
|
return 1;
|
|
3341
3434
|
}
|
|
3342
3435
|
if (!whoami2.ok) {
|
|
3343
|
-
console.error(`${
|
|
3436
|
+
console.error(`${color6.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
|
|
3344
3437
|
` + ` Is SYSTEM_API_KEY the one this deployment was given?
|
|
3345
3438
|
`);
|
|
3346
3439
|
return 1;
|
|
3347
3440
|
}
|
|
3348
|
-
console.log(`${
|
|
3441
|
+
console.log(`${color6.green("✓")} authenticated as the system identity`);
|
|
3349
3442
|
}
|
|
3350
3443
|
const root = project.root;
|
|
3351
3444
|
const products = await readJson(join9(root, "data", "catalog.json"));
|
|
@@ -3354,14 +3447,14 @@ async function importCommand(args) {
|
|
|
3354
3447
|
const posts = has(project.manifest, "blogs") ? await readJson(join9(root, "content", "posts.json")) : null;
|
|
3355
3448
|
if (!products && !faqs?.length && !posts?.length) {
|
|
3356
3449
|
console.log(`
|
|
3357
|
-
${
|
|
3450
|
+
${color6.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
|
|
3358
3451
|
` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
|
|
3359
3452
|
`);
|
|
3360
3453
|
return 0;
|
|
3361
3454
|
}
|
|
3362
3455
|
if (dry) {
|
|
3363
3456
|
console.log(`
|
|
3364
|
-
${
|
|
3457
|
+
${color6.dim("--dry-run: nothing was pushed.")}`);
|
|
3365
3458
|
console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
|
|
3366
3459
|
console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
|
|
3367
3460
|
`);
|
|
@@ -3377,15 +3470,15 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3377
3470
|
});
|
|
3378
3471
|
if (result.ok) {
|
|
3379
3472
|
const report = result.body.report ?? {};
|
|
3380
|
-
console.log(`${
|
|
3473
|
+
console.log(`${color6.green("✓")} products: ${describe(report.products)}`);
|
|
3381
3474
|
if (report.categories)
|
|
3382
|
-
console.log(`${
|
|
3475
|
+
console.log(`${color6.green("✓")} categories: ${describe(report.categories)}`);
|
|
3383
3476
|
if (report.prices)
|
|
3384
|
-
console.log(`${
|
|
3477
|
+
console.log(`${color6.green("✓")} prices: ${describe(report.prices)}`);
|
|
3385
3478
|
if (report.addons)
|
|
3386
|
-
console.log(`${
|
|
3479
|
+
console.log(`${color6.green("✓")} addons: ${describe(report.addons)}`);
|
|
3387
3480
|
} else {
|
|
3388
|
-
console.error(`${
|
|
3481
|
+
console.error(`${color6.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3389
3482
|
failed = true;
|
|
3390
3483
|
}
|
|
3391
3484
|
}
|
|
@@ -3397,20 +3490,20 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3397
3490
|
continue;
|
|
3398
3491
|
const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
|
|
3399
3492
|
if (result.ok) {
|
|
3400
|
-
console.log(`${
|
|
3493
|
+
console.log(`${color6.green("✓")} ${label}: ${describe(result.body)}`);
|
|
3401
3494
|
} else {
|
|
3402
|
-
console.error(`${
|
|
3495
|
+
console.error(`${color6.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3403
3496
|
failed = true;
|
|
3404
3497
|
}
|
|
3405
3498
|
}
|
|
3406
3499
|
if (failed) {
|
|
3407
3500
|
console.error(`
|
|
3408
|
-
${
|
|
3501
|
+
${color6.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
|
|
3409
3502
|
`);
|
|
3410
3503
|
return 1;
|
|
3411
3504
|
}
|
|
3412
3505
|
console.log(`
|
|
3413
|
-
${
|
|
3506
|
+
${color6.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
|
|
3414
3507
|
`);
|
|
3415
3508
|
return 0;
|
|
3416
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
|
package/src/generate/env.ts
CHANGED
|
@@ -105,6 +105,17 @@ export function renderEnvExample(manifest: Manifest): string {
|
|
|
105
105
|
].join("\n");
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Keys whose value is the MANIFEST'S to decide, not the operator's.
|
|
110
|
+
*
|
|
111
|
+
* These are the shop's identity. Preserving whatever was already on disk is
|
|
112
|
+
* right for a credential and wrong for these: a shop copied from another one
|
|
113
|
+
* kept `SHOP_DOMAIN=tshirt.test` and an EMAIL_FROM at the wrong domain, which
|
|
114
|
+
* is how a storefront ends up quietly addressing the wrong business. If you
|
|
115
|
+
* want a different domain, change the manifest — that is what it is for.
|
|
116
|
+
*/
|
|
117
|
+
export const MANIFEST_OWNED_ENV = new Set(["SHOP_DOMAIN", "SHOP_ZONE", "EMAIL_FROM", "GITHUB_PAGES_HOST"]);
|
|
118
|
+
|
|
108
119
|
export function renderEnvLocal(manifest: Manifest): string {
|
|
109
120
|
const keys = allEnvKeys(manifest);
|
|
110
121
|
const local: Record<string, string> = {
|
package/src/generate/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
envSummary,
|
|
9
9
|
renderEnvExample,
|
|
10
10
|
renderEnvLocal,
|
|
11
|
+
MANIFEST_OWNED_ENV,
|
|
11
12
|
renderEnvProduction,
|
|
12
13
|
renderEnvTs,
|
|
13
14
|
} from "./env";
|
|
@@ -128,6 +129,9 @@ async function putEnv(root: string, file: string, rendered: string, result: Gene
|
|
|
128
129
|
.map((line) => {
|
|
129
130
|
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
130
131
|
if (!match) return line;
|
|
132
|
+
// The shop's identity is the manifest's to decide; a value carried
|
|
133
|
+
// over from a shop this one was copied from is not a preference.
|
|
134
|
+
if (MANIFEST_OWNED_ENV.has(match[1]!)) return line;
|
|
131
135
|
const had = existing.get(match[1]!);
|
|
132
136
|
// A real value the operator set beats the template's placeholder.
|
|
133
137
|
// `unset` and empty are placeholders, not answers.
|