@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 CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  SIGN_IN,
15
15
  TAX,
16
16
  UI
17
- } from "./index-mnb7fz2t.js";
17
+ } from "./index-7xwcv2cj.js";
18
18
  import"./index-0v6na3yp.js";
19
19
  export {
20
20
  UI,
package/dist/cli.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  runVoid,
22
22
  version,
23
23
  voidAppsIn
24
- } from "./index-mb1p1rh0.js";
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-vhsq8jpy.js";
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-tjy6yygc.js";
61
+ } from "./index-ym88atwf.js";
62
62
  import {
63
63
  GROUPS
64
- } from "./index-mnb7fz2t.js";
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) {
@@ -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;
@@ -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: "So the deploy writes the zone's records, not a person in a registrar's UI. The worker's own hostname is a Cloudflare custom domain and needs no record here; the mail sender's do.",
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: "the zone the shop's domain is in — the deploy writes the mail records so confirmations are not spam",
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-tjy6yygc.js";
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-2q3qzntw.js");
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-vhsq8jpy.js";
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-tjy6yygc.js";
27
+ } from "./index-ym88atwf.js";
26
28
  import {
27
29
  CHOICES
28
- } from "./index-mnb7fz2t.js";
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.10.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
- ${color4.red("✗")} ${message}
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(`${color4.green("✓")} wrangler is logged in`);
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(`${color4.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
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(color4.yellow(`--force: deploying a shop that is NOT ready for customers.
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(`${color4.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
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(`${color4.green("✓")} queue "commerce"`);
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} ${color4.dim("(the storefront)")}`);
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 ? color4.dim(" (the worker)") : ""}`);
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(`${color4.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
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(`${color4.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
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(`${color4.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
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
- ${color4.green("Live:")} ${url}`);
3328
+ ${color5.green("Live:")} ${url}`);
3236
3329
  if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
3237
- console.log(color4.dim("The storefront deploys itself from GitHub Actions on push."));
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 color5 from "picocolors";
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(`${color5.dim("→")} ${url} ${color5.dim(`(${how})`)}
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(`${color5.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(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(`${color5.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
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(`${color5.green("✓")} authenticated as the system identity`);
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
- ${color5.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
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
- ${color5.dim("--dry-run: nothing was pushed.")}`);
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(`${color5.green("✓")} products: ${describe(report.products)}`);
3473
+ console.log(`${color6.green("✓")} products: ${describe(report.products)}`);
3381
3474
  if (report.categories)
3382
- console.log(`${color5.green("✓")} categories: ${describe(report.categories)}`);
3475
+ console.log(`${color6.green("✓")} categories: ${describe(report.categories)}`);
3383
3476
  if (report.prices)
3384
- console.log(`${color5.green("✓")} prices: ${describe(report.prices)}`);
3477
+ console.log(`${color6.green("✓")} prices: ${describe(report.prices)}`);
3385
3478
  if (report.addons)
3386
- console.log(`${color5.green("✓")} addons: ${describe(report.addons)}`);
3479
+ console.log(`${color6.green("✓")} addons: ${describe(report.addons)}`);
3387
3480
  } else {
3388
- console.error(`${color5.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
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(`${color5.green("✓")} ${label}: ${describe(result.body)}`);
3493
+ console.log(`${color6.green("✓")} ${label}: ${describe(result.body)}`);
3401
3494
  } else {
3402
- console.error(`${color5.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
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
- ${color5.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
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
- ${color5.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
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
  }
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CHOICES,
3
3
  GROUPS
4
- } from "./index-mnb7fz2t.js";
4
+ } from "./index-7xwcv2cj.js";
5
5
 
6
6
  // src/manifest.ts
7
7
  import { readFile, writeFile } from "node:fs/promises";
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  routeProblem,
53
53
  strictDependencies,
54
54
  upsertJsonc
55
- } from "./index-mb1p1rh0.js";
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-vhsq8jpy.js";
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-tjy6yygc.js";
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-mnb7fz2t.js";
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-vhsq8jpy.js";
12
- import"./index-tjy6yygc.js";
13
- import"./index-mnb7fz2t.js";
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
@@ -18,8 +18,8 @@ import {
18
18
  writeManifest,
19
19
  zone,
20
20
  zoneOf
21
- } from "./index-tjy6yygc.js";
22
- import"./index-mnb7fz2t.js";
21
+ } from "./index-ym88atwf.js";
22
+ import"./index-7xwcv2cj.js";
23
23
  import"./index-0v6na3yp.js";
24
24
  export {
25
25
  zoneOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.10.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: "So the deploy writes the zone's records, not a person in a registrar's UI. The worker's own hostname is a Cloudflare custom domain and needs no record here; the mail sender's do.",
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: "the zone the shop's domain is in — the deploy writes the mail records so confirmations are not spam",
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
  ],
@@ -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");
@@ -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;
@@ -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
+ }
@@ -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
@@ -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> = {
@@ -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.