@saastemly/voidcommerce 0.11.0 → 0.13.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-0je92d88.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-964eqtx0.js";
24
+ } from "./index-ze4z3n5q.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-62q6669q.js";
53
+ } from "./index-cxse24q6.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-727eybzt.js";
62
62
  import {
63
63
  GROUPS
64
- } from "./index-mnb7fz2t.js";
64
+ } from "./index-0je92d88.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;
@@ -10,6 +10,6 @@ import { type Manifest } from "../manifest";
10
10
  * The token needs nothing: no origin, no tariff. Everything else needs one or
11
11
  * the other, so the rule is simply "any carrier but the token".
12
12
  */
13
- export declare const WIRED_CARRIERS: readonly ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
13
+ export declare const WIRED_CARRIERS: readonly ["parcel2go", "royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
14
14
  export declare const needsShippingConfig: (id: string) => boolean;
15
15
  export declare function renderAuthTs(manifest: Manifest): string;
@@ -440,6 +440,16 @@ var CARRIERS = {
440
440
  intro: "Live rates at checkout, quoted per carrier. Flat-rate shipping needs none of these.",
441
441
  kind: "multiselect",
442
442
  choices: [
443
+ {
444
+ id: "parcel2go",
445
+ label: "Parcel2Go — a courier collects from your door",
446
+ hint: "the only one here a private individual can use end to end: live prices, a collection booked by API, and label-free services where the driver brings it. No business account, no volume minimum, no monthly fee",
447
+ recommended: true,
448
+ env: [
449
+ { key: "PARCEL2GO_CLIENT_ID", breaks: "no rates are quoted and no collection is ever booked", where: "parcel2go.com/myaccount/api → Client Credentials" },
450
+ { key: "PARCEL2GO_CLIENT_SECRET", breaks: "the same — and Parcel2Go will not show it twice, so keep it when you make it", where: "you choose this value when creating the credentials" }
451
+ ]
452
+ },
443
453
  { id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [{ key: "SHIPPO_API_TOKEN", breaks: "calculated rates fall back to flat" }] },
444
454
  { id: "easypost", label: "EasyPost", hint: "the same, US-leaning", env: [{ key: "EASYPOST_API_KEY", breaks: "calculated rates fall back to flat" }] },
445
455
  { id: "ups", label: "UPS", hint: "your own UPS account, for negotiated rates an aggregator cannot see", env: [{ key: "UPS_CLIENT_ID", breaks: "UPS rates are not quoted" }, { key: "UPS_CLIENT_SECRET", breaks: "the same" }, { key: "UPS_ACCOUNT_NUMBER", breaks: "UPS has no account to bill the transport to" }] },
@@ -525,15 +535,20 @@ var EMAIL = {
525
535
  var DNS = {
526
536
  id: "dns",
527
537
  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.",
538
+ 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
539
  kind: "select",
530
540
  choices: [
541
+ {
542
+ id: "cloudflare-samezone",
543
+ label: "Cloudflare, same account",
544
+ 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",
545
+ recommended: true
546
+ },
531
547
  {
532
548
  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",
549
+ label: "Cloudflare, another account or a second zone",
550
+ 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
551
  packages: ["@saastemly/better-dns"],
536
- recommended: true,
537
552
  env: [
538
553
  { 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
554
  ]
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CHOICES,
3
3
  GROUPS
4
- } from "./index-mnb7fz2t.js";
4
+ } from "./index-0je92d88.js";
5
5
 
6
6
  // src/manifest.ts
7
7
  import { readFile, writeFile } from "node:fs/promises";
@@ -4,7 +4,7 @@ import {
4
4
  isApex,
5
5
  oneOrigin,
6
6
  zone
7
- } from "./index-tjy6yygc.js";
7
+ } from "./index-727eybzt.js";
8
8
  import {
9
9
  __require,
10
10
  __toESM
@@ -19,6 +19,9 @@ import color2 from "picocolors";
19
19
  import { spawn } from "node:child_process";
20
20
  import { delimiter, join } from "node:path";
21
21
  import { existsSync } from "node:fs";
22
+ function apiToken() {
23
+ return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
24
+ }
22
25
  var REPO_SCOPE = "repo";
23
26
  function findGh() {
24
27
  for (const entry of (process.env["PATH"] ?? "").split(delimiter).filter(Boolean)) {
@@ -386,7 +389,7 @@ async function decryptSecrets(project) {
386
389
  }
387
390
  const expected = committedPublicKey(root);
388
391
  if (expected) {
389
- const { publicKeyFor } = await import("./keys-1cqepd69.js");
392
+ const { publicKeyFor } = await import("./keys-9efk9rdz.js");
390
393
  const derived = await publicKeyFor(privateKey);
391
394
  if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
392
395
  return {
@@ -913,4 +916,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
913
916
  return 0;
914
917
  }
915
918
 
916
- export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
919
+ export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
@@ -3,6 +3,7 @@ import {
3
3
  PRIVATE_KEY_VAR,
4
4
  SECRETS_FILE,
5
5
  allEnvKeys,
6
+ apiToken,
6
7
  declaredSecretNames,
7
8
  decryptSecrets,
8
9
  plaintextSecretNames,
@@ -10,7 +11,7 @@ import {
10
11
  renderEnvLocal,
11
12
  renderEnvProduction,
12
13
  renderEnvTs
13
- } from "./index-62q6669q.js";
14
+ } from "./index-cxse24q6.js";
14
15
  import {
15
16
  MANIFEST_FILE,
16
17
  has,
@@ -23,10 +24,10 @@ import {
23
24
  workerHosts,
24
25
  writeManifest,
25
26
  zone
26
- } from "./index-tjy6yygc.js";
27
+ } from "./index-727eybzt.js";
27
28
  import {
28
29
  CHOICES
29
- } from "./index-mnb7fz2t.js";
30
+ } from "./index-0je92d88.js";
30
31
  import {
31
32
  __require
32
33
  } from "./index-0v6na3yp.js";
@@ -49,12 +50,17 @@ var VAT = {
49
50
  PL: { rate: 2300, name: "VAT" }
50
51
  };
51
52
  var social = (manifest, id, envPrefix) => has(manifest, id) ? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },` : null;
52
- var WIRED_CARRIERS = ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
53
+ var WIRED_CARRIERS = ["parcel2go", "royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
53
54
  var needsShippingConfig = (id) => WIRED_CARRIERS.includes(id) && id !== "nft";
54
55
  function renderAuthTs(manifest) {
55
56
  const { shop } = manifest;
56
57
  const lines = [];
57
58
  const CARRIER_CALLS = {
59
+ parcel2go: {
60
+ label: "Parcel2Go",
61
+ call: `parcel2goProvider({ clientId: must(env, "PARCEL2GO_CLIENT_ID"), clientSecret: must(env, "PARCEL2GO_CLIENT_SECRET"), from: shipFrom, customer: parcel2goCustomer, currency: "${shop.currency}" })`,
62
+ import: `import { parcel2goProvider } from "@saastemly/better-commerce/providers/parcel2go";`
63
+ },
58
64
  "royal-mail": {
59
65
  label: "Royal Mail",
60
66
  call: `royalMailProvider({ apiKey: must(env, "CLICK_DROP_AUTH_KEY"), services: royalMailServices, currency: "${shop.currency}", carrierName: "Royal Mail" })`,
@@ -96,7 +102,8 @@ import { mintProvider, destinationFor } from "./lib/mint.ts";`
96
102
  const carrierImports = carriers.map((id) => CARRIER_CALLS[id].import);
97
103
  const shipping = [
98
104
  ...carriers.some((id) => id !== "nft" && id !== "royal-mail") ? ["shipFrom"] : [],
99
- ...carriers.includes("royal-mail") ? ["royalMailServices"] : []
105
+ ...carriers.includes("royal-mail") ? ["royalMailServices"] : [],
106
+ ...carriers.includes("parcel2go") ? ["parcel2goCustomer"] : []
100
107
  ];
101
108
  if (shipping.length > 0)
102
109
  carrierImports.push(`import { ${shipping.join(", ")} } from "./lib/shipping.ts";`);
@@ -1371,7 +1378,7 @@ import color from "picocolors";
1371
1378
  // package.json
1372
1379
  var package_default = {
1373
1380
  name: "@saastemly/voidcommerce",
1374
- version: "0.11.0",
1381
+ version: "0.13.0",
1375
1382
  description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
1376
1383
  type: "module",
1377
1384
  license: "MIT",
@@ -1885,6 +1892,8 @@ Create the token at **My Profile → API Tokens → Create Token → Custom toke
1885
1892
  | Account | Workers R2 Storage: Edit | product images |
1886
1893
  | Account | Account Settings: Read | confirming which account |
1887
1894
  | Zone | Workers Routes: Edit (${zone2}) | answering on your domain |
1895
+ | Zone | Zone: Read (${zone2}) | confirming the zone is on this account |
1896
+ | Zone | DNS: Read (${zone2}) | noticing a hostname that already answers |
1888
1897
  | User | User Details: Read, Memberships: Read | wrangler asks at startup |
1889
1898
 
1890
1899
  The token Cloudflare generates for its own Workers Builds will **not** do: it
@@ -2220,7 +2229,8 @@ export async function destinationFor(orderId: string): Promise<string | null> {
2220
2229
  }
2221
2230
  function renderShippingTs(manifest) {
2222
2231
  const royalMail = has(manifest, "royal-mail");
2223
- const origin = ["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
2232
+ const parcel2go = has(manifest, "parcel2go");
2233
+ const origin = ["parcel2go", "dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
2224
2234
  const imports = [
2225
2235
  ...origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : [],
2226
2236
  ...royalMail ? [`import type { RoyalMailService } from "@saastemly/better-commerce/providers/royal-mail";`] : []
@@ -2240,10 +2250,26 @@ export const shipFrom: DeliveryAddress = {
2240
2250
  country: "${manifest.shop.country}",
2241
2251
  phone: "TODO VERIFY: +44…",
2242
2252
  };
2253
+ `;
2254
+ const parcel2goBlock = `
2255
+ /**
2256
+ * Who the Parcel2Go account belongs to.
2257
+ *
2258
+ * Every order carries it: Parcel2Go books a courier to come to a real person
2259
+ * at a real door, and needs to know whose door. This is the ACCOUNT holder,
2260
+ * not the buyer — the buyer's details ride on the parcel.
2261
+ *
2262
+ * TODO VERIFY: these are placeholders.
2263
+ */
2264
+ export const parcel2goCustomer = {
2265
+ email: "TODO VERIFY: the email on your Parcel2Go account",
2266
+ forename: "TODO VERIFY",
2267
+ surname: "TODO VERIFY",
2268
+ };
2243
2269
  `;
2244
2270
  return `${imports.join(`
2245
2271
  `)}
2246
- ${origin ? originBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
2272
+ ${origin ? originBlock : ""}${parcel2go ? parcel2goBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
2247
2273
  }
2248
2274
  function renderErpTs() {
2249
2275
  return `import { businessCentralProvider } from "@saastemly/better-commerce/providers/business-central";
@@ -3080,10 +3106,84 @@ ${color3.red("NOT ready to go live.")}
3080
3106
  // src/deploy/cloudflare.ts
3081
3107
  import { copyFileSync, existsSync as existsSync8, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "node:fs";
3082
3108
  import { dirname as dirname5, join as join8 } from "node:path";
3109
+ import color5 from "picocolors";
3110
+
3111
+ // src/deploy/zone.ts
3083
3112
  import color4 from "picocolors";
3113
+ async function cf(token, path) {
3114
+ try {
3115
+ const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
3116
+ headers: { Authorization: `Bearer ${token}` }
3117
+ });
3118
+ const body = await response.json();
3119
+ if (!body.success) {
3120
+ const first = body.errors?.[0];
3121
+ return { ok: false, error: first?.message ? `${first.message}${first.code ? ` (${first.code})` : ""}` : `HTTP ${response.status}` };
3122
+ }
3123
+ return { ok: true, result: body.result };
3124
+ } catch (error) {
3125
+ return { ok: false, error: `could not reach the Cloudflare API: ${String(error)}` };
3126
+ }
3127
+ }
3128
+ async function checkSameZone(token, zone2, hostname, accountId) {
3129
+ const problems = [];
3130
+ const notes = [];
3131
+ const zones = await cf(token, `/zones?name=${encodeURIComponent(zone2)}`);
3132
+ if (!zones.ok) {
3133
+ return {
3134
+ ok: true,
3135
+ problems: [],
3136
+ notes: [`the zone could not be checked: ${zones.error}. Add ${color4.cyan("Zone → Zone: Read")} to the API token to have this verified.`]
3137
+ };
3138
+ }
3139
+ const found = zones.result.find((entry) => entry.name.toLowerCase() === zone2.toLowerCase());
3140
+ if (!found) {
3141
+ problems.push(`the zone ${color4.bold(zone2)} is not on this Cloudflare account.
3142
+ ` + ` A Worker custom domain is a record Cloudflare writes in its own zone, so a
3143
+ ` + ` domain hosted anywhere else cannot have one. Move the zone to this account,
3144
+ ` + " or point the shop at a domain that is already on it.");
3145
+ return { ok: false, problems, notes };
3146
+ }
3147
+ if (accountId && found.account?.id && found.account.id !== accountId) {
3148
+ problems.push(`${color4.bold(zone2)} is on Cloudflare, but under a different account (${found.account.name ?? found.account.id})
3149
+ ` + " than the one this worker deploys to. The custom domain would be refused.");
3150
+ return { ok: false, problems, notes };
3151
+ }
3152
+ if (found.status !== "active") {
3153
+ notes.push(`${zone2} is on this account but its status is "${found.status}" — a custom domain needs an active zone.`);
3154
+ }
3155
+ const records = await cf(token, `/zones/${found.id}/dns_records?name.exact=${encodeURIComponent(hostname)}`);
3156
+ if (!records.ok) {
3157
+ notes.push(`existing records for ${hostname} could not be listed: ${records.error}. Add ${color4.cyan("Zone → DNS: Read")} to have this checked.`);
3158
+ return { ok: problems.length === 0, problems, notes };
3159
+ }
3160
+ const foreign = records.result.filter((record) => !(record.proxied && (record.content === "100::" || record.content === "192.0.2.1")));
3161
+ if (foreign.length > 0) {
3162
+ problems.push(`${color4.bold(hostname)} already has ${foreign.length} DNS record${foreign.length === 1 ? "" : "s"}:
3163
+ ` + foreign.map((record) => ` ${record.type.padEnd(6)} ${record.content}`).join(`
3164
+ `) + `
3165
+ ` + ` Deploying would take the hostname over. Wrangler asks about this at a
3166
+ ` + ` terminal, but in CI it is not a prompt — it overrides the record silently.
3167
+ ` + " Delete them first if the hostname really is meant to be this shop's.");
3168
+ }
3169
+ return { ok: problems.length === 0, problems, notes };
3170
+ }
3171
+ function printZoneCheck(check, zone2) {
3172
+ for (const note of check.notes)
3173
+ console.log(`${color4.yellow("!")} ${note}`);
3174
+ if (check.ok) {
3175
+ if (check.notes.length === 0)
3176
+ console.log(`${color4.green("✓")} ${zone2} is on this Cloudflare account, and the hostname is free`);
3177
+ return;
3178
+ }
3179
+ for (const problem of check.problems)
3180
+ console.error(`${color4.red("✗")} ${problem}`);
3181
+ }
3182
+
3183
+ // src/deploy/cloudflare.ts
3084
3184
  var fail = (message) => {
3085
3185
  console.error(`
3086
- ${color4.red("✗")} ${message}
3186
+ ${color5.red("✗")} ${message}
3087
3187
  `);
3088
3188
  return 1;
3089
3189
  };
@@ -3123,7 +3223,7 @@ async function deployCloudflare(project, opts) {
3123
3223
  ${who.raw.trim().split(`
3124
3224
  `).slice(-4).join(`
3125
3225
  `)}`);
3126
- console.log(`${color4.green("✓")} wrangler is logged in`);
3226
+ console.log(`${color5.green("✓")} wrangler is logged in`);
3127
3227
  let config = readConfig(configPath);
3128
3228
  let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
3129
3229
  if (!accountId) {
@@ -3132,19 +3232,32 @@ ${who.raw.trim().split(`
3132
3232
  writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "account_id", accountId));
3133
3233
  project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
3134
3234
  await writeManifest(project.root, project.manifest);
3135
- console.log(`${color4.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
3235
+ console.log(`${color5.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
3136
3236
  } else {
3137
3237
  return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
3138
3238
  ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3139
3239
  `)}`);
3140
3240
  }
3141
3241
  }
3242
+ if (has(project.manifest, "cloudflare-samezone")) {
3243
+ const token = apiToken();
3244
+ if (token) {
3245
+ const shopZone = zone(project.manifest);
3246
+ const check2 = await checkSameZone(token, shopZone, workerHosts(project.manifest)[0], accountId);
3247
+ printZoneCheck(check2, shopZone);
3248
+ if (!check2.ok && !opts.force) {
3249
+ return fail("the zone is not ready for this shop. Fix the above, or pass --force to deploy anyway.");
3250
+ }
3251
+ } else {
3252
+ console.log(color5.dim("! CLOUDFLARE_API_TOKEN is not set here, so the zone could not be checked."));
3253
+ }
3254
+ }
3142
3255
  const check = await preflight(project, "wrangler");
3143
3256
  printPreflight(project, check, "wrangler");
3144
3257
  if (!check.ready) {
3145
3258
  if (!opts.force)
3146
3259
  return fail("refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force for a deliberate partial deploy.");
3147
- console.error(color4.yellow(`--force: deploying a shop that is NOT ready for customers.
3260
+ console.error(color5.yellow(`--force: deploying a shop that is NOT ready for customers.
3148
3261
  `));
3149
3262
  }
3150
3263
  const worker = config.name || project.manifest.shop.domain.split(".")[0];
@@ -3158,12 +3271,12 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3158
3271
  writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "d1_databases", [entry]));
3159
3272
  project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
3160
3273
  await writeManifest(project.root, project.manifest);
3161
- console.log(`${color4.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
3274
+ console.log(`${color5.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
3162
3275
  config = readConfig(configPath);
3163
3276
  }
3164
3277
  if (opts.provision) {
3165
3278
  await ensureQueue(bin, app, "commerce");
3166
- console.log(`${color4.green("✓")} queue "commerce"`);
3279
+ console.log(`${color5.green("✓")} queue "commerce"`);
3167
3280
  }
3168
3281
  const builder = findBuilder(app);
3169
3282
  if (!builder)
@@ -3171,13 +3284,13 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3171
3284
  const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
3172
3285
  if (merging) {
3173
3286
  console.log(`
3174
- ▸ ${builder.label} ${color4.dim("(the storefront)")}`);
3287
+ ▸ ${builder.label} ${color5.dim("(the storefront)")}`);
3175
3288
  const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
3176
3289
  if (storefront.code !== 0)
3177
3290
  return fail(`the storefront build failed (exit ${storefront.code}).`);
3178
3291
  }
3179
3292
  console.log(`
3180
- ▸ ${builder.label}${merging ? color4.dim(" (the worker)") : ""}`);
3293
+ ▸ ${builder.label}${merging ? color5.dim(" (the worker)") : ""}`);
3181
3294
  const built = await wrangler(builder.cmd, ["build"], app, true);
3182
3295
  if (built.code !== 0)
3183
3296
  return fail(`the build failed (exit ${built.code}).`);
@@ -3192,7 +3305,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3192
3305
  if (!existsSync8(join8(into, "index.html"))) {
3193
3306
  return fail("the storefront produced no index.html, so the shop has no front page.");
3194
3307
  }
3195
- console.log(`${color4.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
3308
+ console.log(`${color5.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
3196
3309
  }
3197
3310
  const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
3198
3311
  if (!existsSync8(emittedPath))
@@ -3210,7 +3323,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3210
3323
  if (!emittedD1 || emittedD1.database_id === "local")
3211
3324
  return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
3212
3325
  writeFileSync(emittedPath, JSON.stringify(emitted, null, 2));
3213
- console.log(`${color4.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
3326
+ console.log(`${color5.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
3214
3327
  console.log(`
3215
3328
  ▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
3216
3329
  const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
@@ -3224,7 +3337,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3224
3337
  return fail(`the repository's secrets could not be read: ${decrypted.error}`);
3225
3338
  secretArgs.push("--secrets-file", decrypted.path);
3226
3339
  cleanupSecrets = decrypted.cleanup;
3227
- console.log(`${color4.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
3340
+ console.log(`${color5.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
3228
3341
  }
3229
3342
  console.log(`
3230
3343
  ▸ wrangler deploy -c dist/ssr/wrangler.json`);
@@ -3235,9 +3348,9 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3235
3348
  const domain = project.manifest.shop.domain;
3236
3349
  const url = `https://${workerHosts(project.manifest)[0]}`;
3237
3350
  console.log(`
3238
- ${color4.green("Live:")} ${url}`);
3351
+ ${color5.green("Live:")} ${url}`);
3239
3352
  if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
3240
- console.log(color4.dim("The storefront deploys itself from GitHub Actions on push."));
3353
+ console.log(color5.dim("The storefront deploys itself from GitHub Actions on push."));
3241
3354
  }
3242
3355
  return 0;
3243
3356
  }
@@ -3263,7 +3376,7 @@ function mergeTree(from, into, skip) {
3263
3376
  import { readFile as readFile3 } from "node:fs/promises";
3264
3377
  import { existsSync as existsSync9 } from "node:fs";
3265
3378
  import { join as join9 } from "node:path";
3266
- import color5 from "picocolors";
3379
+ import color6 from "picocolors";
3267
3380
  function describe(counts) {
3268
3381
  if (!counts)
3269
3382
  return "";
@@ -3330,25 +3443,25 @@ async function importCommand(args) {
3330
3443
  }
3331
3444
  const { url, how } = resolveTarget(project, args);
3332
3445
  if (!dry)
3333
- console.log(`${color5.dim("→")} ${url} ${color5.dim(`(${how})`)}
3446
+ console.log(`${color6.dim("→")} ${url} ${color6.dim(`(${how})`)}
3334
3447
  `);
3335
3448
  if (!dry) {
3336
3449
  let whoami2;
3337
3450
  try {
3338
3451
  whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
3339
3452
  } catch (error) {
3340
- console.error(`${color5.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
3453
+ console.error(`${color6.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
3341
3454
  ` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
3342
3455
  `);
3343
3456
  return 1;
3344
3457
  }
3345
3458
  if (!whoami2.ok) {
3346
- console.error(`${color5.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
3459
+ console.error(`${color6.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
3347
3460
  ` + ` Is SYSTEM_API_KEY the one this deployment was given?
3348
3461
  `);
3349
3462
  return 1;
3350
3463
  }
3351
- console.log(`${color5.green("✓")} authenticated as the system identity`);
3464
+ console.log(`${color6.green("✓")} authenticated as the system identity`);
3352
3465
  }
3353
3466
  const root = project.root;
3354
3467
  const products = await readJson(join9(root, "data", "catalog.json"));
@@ -3357,14 +3470,14 @@ async function importCommand(args) {
3357
3470
  const posts = has(project.manifest, "blogs") ? await readJson(join9(root, "content", "posts.json")) : null;
3358
3471
  if (!products && !faqs?.length && !posts?.length) {
3359
3472
  console.log(`
3360
- ${color5.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
3473
+ ${color6.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
3361
3474
  ` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
3362
3475
  `);
3363
3476
  return 0;
3364
3477
  }
3365
3478
  if (dry) {
3366
3479
  console.log(`
3367
- ${color5.dim("--dry-run: nothing was pushed.")}`);
3480
+ ${color6.dim("--dry-run: nothing was pushed.")}`);
3368
3481
  console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
3369
3482
  console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
3370
3483
  `);
@@ -3380,15 +3493,15 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
3380
3493
  });
3381
3494
  if (result.ok) {
3382
3495
  const report = result.body.report ?? {};
3383
- console.log(`${color5.green("✓")} products: ${describe(report.products)}`);
3496
+ console.log(`${color6.green("✓")} products: ${describe(report.products)}`);
3384
3497
  if (report.categories)
3385
- console.log(`${color5.green("✓")} categories: ${describe(report.categories)}`);
3498
+ console.log(`${color6.green("✓")} categories: ${describe(report.categories)}`);
3386
3499
  if (report.prices)
3387
- console.log(`${color5.green("✓")} prices: ${describe(report.prices)}`);
3500
+ console.log(`${color6.green("✓")} prices: ${describe(report.prices)}`);
3388
3501
  if (report.addons)
3389
- console.log(`${color5.green("✓")} addons: ${describe(report.addons)}`);
3502
+ console.log(`${color6.green("✓")} addons: ${describe(report.addons)}`);
3390
3503
  } else {
3391
- console.error(`${color5.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
3504
+ console.error(`${color6.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
3392
3505
  failed = true;
3393
3506
  }
3394
3507
  }
@@ -3400,20 +3513,20 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
3400
3513
  continue;
3401
3514
  const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
3402
3515
  if (result.ok) {
3403
- console.log(`${color5.green("✓")} ${label}: ${describe(result.body)}`);
3516
+ console.log(`${color6.green("✓")} ${label}: ${describe(result.body)}`);
3404
3517
  } else {
3405
- console.error(`${color5.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
3518
+ console.error(`${color6.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
3406
3519
  failed = true;
3407
3520
  }
3408
3521
  }
3409
3522
  if (failed) {
3410
3523
  console.error(`
3411
- ${color5.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
3524
+ ${color6.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
3412
3525
  `);
3413
3526
  return 1;
3414
3527
  }
3415
3528
  console.log(`
3416
- ${color5.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
3529
+ ${color6.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
3417
3530
  `);
3418
3531
  return 0;
3419
3532
  }
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  routeProblem,
53
53
  strictDependencies,
54
54
  upsertJsonc
55
- } from "./index-964eqtx0.js";
55
+ } from "./index-ze4z3n5q.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-62q6669q.js";
63
+ } from "./index-cxse24q6.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-727eybzt.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-0je92d88.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-62q6669q.js";
12
- import"./index-tjy6yygc.js";
13
- import"./index-mnb7fz2t.js";
11
+ } from "./index-cxse24q6.js";
12
+ import"./index-727eybzt.js";
13
+ import"./index-0je92d88.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-727eybzt.js";
22
+ import"./index-0je92d88.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.11.0",
3
+ "version": "0.13.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
@@ -513,6 +513,16 @@ export const CARRIERS: Group = {
513
513
  intro: "Live rates at checkout, quoted per carrier. Flat-rate shipping needs none of these.",
514
514
  kind: "multiselect",
515
515
  choices: [
516
+ {
517
+ id: "parcel2go",
518
+ label: "Parcel2Go — a courier collects from your door",
519
+ hint: "the only one here a private individual can use end to end: live prices, a collection booked by API, and label-free services where the driver brings it. No business account, no volume minimum, no monthly fee",
520
+ recommended: true,
521
+ env: [
522
+ { key: "PARCEL2GO_CLIENT_ID", breaks: "no rates are quoted and no collection is ever booked", where: "parcel2go.com/myaccount/api → Client Credentials" },
523
+ { key: "PARCEL2GO_CLIENT_SECRET", breaks: "the same — and Parcel2Go will not show it twice, so keep it when you make it", where: "you choose this value when creating the credentials" },
524
+ ],
525
+ },
516
526
  { id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [{ key: "SHIPPO_API_TOKEN", breaks: "calculated rates fall back to flat" }] },
517
527
  { id: "easypost", label: "EasyPost", hint: "the same, US-leaning", env: [{ key: "EASYPOST_API_KEY", breaks: "calculated rates fall back to flat" }] },
518
528
  { id: "ups", label: "UPS", hint: "your own UPS account, for negotiated rates an aggregator cannot see", env: [{ key: "UPS_CLIENT_ID", breaks: "UPS rates are not quoted" }, { key: "UPS_CLIENT_SECRET", breaks: "the same" }, { key: "UPS_ACCOUNT_NUMBER", breaks: "UPS has no account to bill the transport to" }] },
@@ -605,15 +615,20 @@ export const EMAIL: Group = {
605
615
  export const DNS: Group = {
606
616
  id: "dns",
607
617
  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.",
618
+ 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
619
  kind: "select",
610
620
  choices: [
621
+ {
622
+ id: "cloudflare-samezone",
623
+ label: "Cloudflare, same account",
624
+ 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",
625
+ recommended: true,
626
+ },
611
627
  {
612
628
  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",
629
+ label: "Cloudflare, another account or a second zone",
630
+ 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
631
  packages: ["@saastemly/better-dns"],
616
- recommended: true,
617
632
  env: [
618
633
  { 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
634
  ],
@@ -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
+ }
@@ -60,7 +60,7 @@ const social = (manifest: Manifest, id: string, envPrefix: string) =>
60
60
  * The token needs nothing: no origin, no tariff. Everything else needs one or
61
61
  * the other, so the rule is simply "any carrier but the token".
62
62
  */
63
- export const WIRED_CARRIERS = ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"] as const;
63
+ export const WIRED_CARRIERS = ["parcel2go", "royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"] as const;
64
64
  export const needsShippingConfig = (id: string): boolean => WIRED_CARRIERS.includes(id as never) && id !== "nft";
65
65
 
66
66
  export function renderAuthTs(manifest: Manifest): string {
@@ -82,6 +82,13 @@ export function renderAuthTs(manifest: Manifest): string {
82
82
  const CARRIER_CALLS: Record<string, { call: string; label: string; import: string }> = {
83
83
  // Every real carrier needs the ORIGIN it ships from, which is shop data
84
84
  // rather than a credential — so it lives in an owned lib/shipping.ts.
85
+ parcel2go: {
86
+ label: "Parcel2Go",
87
+ // `from` is the collection address: Parcel2Go requires one on every
88
+ // order, because a courier is being sent to it.
89
+ call: `parcel2goProvider({ clientId: must(env, "PARCEL2GO_CLIENT_ID"), clientSecret: must(env, "PARCEL2GO_CLIENT_SECRET"), from: shipFrom, customer: parcel2goCustomer, currency: "${shop.currency}" })`,
90
+ import: `import { parcel2goProvider } from "@saastemly/better-commerce/providers/parcel2go";`,
91
+ },
85
92
  "royal-mail": {
86
93
  label: "Royal Mail",
87
94
  // No `from`: Click & Drop posts from the account's registered
@@ -133,6 +140,7 @@ export function renderAuthTs(manifest: Manifest): string {
133
140
  const shipping = [
134
141
  ...(carriers.some((id) => id !== "nft" && id !== "royal-mail") ? ["shipFrom"] : []),
135
142
  ...(carriers.includes("royal-mail") ? ["royalMailServices"] : []),
143
+ ...(carriers.includes("parcel2go") ? ["parcel2goCustomer"] : []),
136
144
  ];
137
145
  if (shipping.length > 0) carrierImports.push(`import { ${shipping.join(", ")} } from "./lib/shipping.ts";`);
138
146
  const wantsNft = has(manifest, "nft");
@@ -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
@@ -275,9 +275,10 @@ export async function destinationFor(orderId: string): Promise<string | null> {
275
275
  */
276
276
  export function renderShippingTs(manifest: Manifest): string {
277
277
  const royalMail = has(manifest, "royal-mail");
278
+ const parcel2go = has(manifest, "parcel2go");
278
279
  // Royal Mail posts from the address registered on the Click & Drop account,
279
280
  // so an origin here would be ignored. Only carriers that QUOTE need one.
280
- const origin = ["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
281
+ const origin = ["parcel2go", "dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
281
282
 
282
283
  const imports = [
283
284
  ...(origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : []),
@@ -301,7 +302,23 @@ export const shipFrom: DeliveryAddress = {
301
302
  };
302
303
  `;
303
304
 
304
- return `${imports.join("\n")}\n${origin ? originBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
305
+ const parcel2goBlock = `
306
+ /**
307
+ * Who the Parcel2Go account belongs to.
308
+ *
309
+ * Every order carries it: Parcel2Go books a courier to come to a real person
310
+ * at a real door, and needs to know whose door. This is the ACCOUNT holder,
311
+ * not the buyer — the buyer's details ride on the parcel.
312
+ *
313
+ * TODO VERIFY: these are placeholders.
314
+ */
315
+ export const parcel2goCustomer = {
316
+ email: "TODO VERIFY: the email on your Parcel2Go account",
317
+ forename: "TODO VERIFY",
318
+ surname: "TODO VERIFY",
319
+ };
320
+ `;
321
+ return `${imports.join("\n")}\n${origin ? originBlock : ""}${parcel2go ? parcel2goBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
305
322
  }
306
323
 
307
324
  export function renderErpTs(): string {