@saastemly/voidcommerce 0.5.0 → 0.7.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-844b3qn9.js";
17
+ } from "./index-mnb7fz2t.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-5m3t0zfc.js";
24
+ } from "./index-85m0qkmg.js";
25
25
  import {
26
26
  PRIVATE_KEY_VAR,
27
27
  SECRETS_FILE,
@@ -42,17 +42,18 @@ import {
42
42
  setVariable,
43
43
  variableNames,
44
44
  verifyCloudflareToken
45
- } from "./index-b9b4dawy.js";
45
+ } from "./index-6w8rwtsh.js";
46
46
  import {
47
47
  LAYOUTS,
48
+ oneOrigin,
48
49
  readManifest,
49
50
  validate,
50
51
  writeManifest,
51
52
  zoneOf
52
- } from "./index-pz6m2hkm.js";
53
+ } from "./index-tjy6yygc.js";
53
54
  import {
54
55
  GROUPS
55
- } from "./index-844b3qn9.js";
56
+ } from "./index-mnb7fz2t.js";
56
57
  import {
57
58
  __require
58
59
  } from "./index-0v6na3yp.js";
@@ -436,6 +437,7 @@ ${color3.dim(`Included: ${required.map((choice) => choice.label).join(", ")}`)}`
436
437
  async function runWizard(existing, layout) {
437
438
  const monorepo = layout === "monorepo";
438
439
  const frontend = layout !== "app";
440
+ const onPages = !oneOrigin(layout);
439
441
  const shop = unwrap(await p.group({
440
442
  name: () => p.text({
441
443
  message: "Shop name",
@@ -492,7 +494,7 @@ async function runWizard(existing, layout) {
492
494
  initialValue: existing?.shop.locale ?? "",
493
495
  validate: (value) => /^[a-z]{2}(-[A-Z]{2})?$/.test(value.trim()) ? undefined : "like da, or en-GB."
494
496
  }),
495
- ...frontend ? {
497
+ ...onPages ? {
496
498
  pagesHost: () => p.text({
497
499
  message: `GitHub Pages host ${color3.dim("— where the storefront is published; leave blank if you serve the branch elsewhere")}`,
498
500
  placeholder: "northwind.github.io",
@@ -523,7 +525,7 @@ ${color3.dim("The generated storefront and panel. Strict has no custom code to h
523
525
  locale: shop.locale.trim(),
524
526
  taxRegistered: shop.taxRegistered === "yes",
525
527
  zone: shop.zone?.trim().toLowerCase() || undefined,
526
- pagesHost: frontend ? shop.pagesHost?.trim().toLowerCase() || undefined : undefined
528
+ pagesHost: onPages ? shop.pagesHost?.trim().toLowerCase() || undefined : undefined
527
529
  },
528
530
  chosen
529
531
  };
@@ -611,7 +613,7 @@ async function init(args) {
611
613
  } else {
612
614
  const parts = [
613
615
  ["api", "the D1 starter — this is the worker"],
614
- ["frontend", "Static Pagesthis is prerendered for GitHub Pages"]
616
+ ["frontend", "the storefrontprerendered and served from the worker's own assets"]
615
617
  ];
616
618
  for (const [dir, starter] of parts) {
617
619
  const target = join(root, dir);
@@ -1,2 +1,15 @@
1
1
  import { type Manifest } from "../manifest";
2
+ /**
3
+ * Carriers with a provider behind them, and whether each needs `lib/shipping.ts`.
4
+ *
5
+ * Exported because `generate/index.ts` has to know which shops get that file,
6
+ * and a second hand-maintained list of carrier ids WILL drift from this one —
7
+ * it already did: adding Royal Mail here left the file ungenerated, so the
8
+ * generated `auth.ts` imported a price table that did not exist.
9
+ *
10
+ * The token needs nothing: no origin, no tariff. Everything else needs one or
11
+ * the other, so the rule is simply "any carrier but the token".
12
+ */
13
+ export declare const WIRED_CARRIERS: readonly ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
14
+ export declare const needsShippingConfig: (id: string) => boolean;
2
15
  export declare function renderAuthTs(manifest: Manifest): string;
@@ -1,4 +1,4 @@
1
- import type { Manifest } from "../manifest";
1
+ import { type Manifest } from "../manifest";
2
2
  /**
3
3
  * The storefront: a static Void site that talks to the worker over HTTP.
4
4
  *
@@ -3,7 +3,7 @@ import {
3
3
  hasFrontend,
4
4
  isApex,
5
5
  zone
6
- } from "./index-pz6m2hkm.js";
6
+ } from "./index-tjy6yygc.js";
7
7
  import {
8
8
  __require,
9
9
  __toESM
@@ -368,7 +368,7 @@ async function decryptSecrets(project) {
368
368
  }
369
369
  const expected = committedPublicKey(root);
370
370
  if (expected) {
371
- const { publicKeyFor } = await import("./keys-b91c72zp.js");
371
+ const { publicKeyFor } = await import("./keys-92tcdjrc.js");
372
372
  const derived = await publicKeyFor(privateKey);
373
373
  if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
374
374
  return {
@@ -9,22 +9,23 @@ import {
9
9
  renderEnvLocal,
10
10
  renderEnvProduction,
11
11
  renderEnvTs
12
- } from "./index-b9b4dawy.js";
12
+ } from "./index-6w8rwtsh.js";
13
13
  import {
14
14
  MANIFEST_FILE,
15
15
  has,
16
16
  hasFrontend,
17
17
  isSingleApp,
18
+ oneOrigin,
18
19
  packagesOf,
19
20
  readManifest,
20
21
  specifier,
21
22
  workerHosts,
22
23
  writeManifest,
23
24
  zone
24
- } from "./index-pz6m2hkm.js";
25
+ } from "./index-tjy6yygc.js";
25
26
  import {
26
27
  CHOICES
27
- } from "./index-844b3qn9.js";
28
+ } from "./index-mnb7fz2t.js";
28
29
  import {
29
30
  __require
30
31
  } from "./index-0v6na3yp.js";
@@ -47,10 +48,17 @@ var VAT = {
47
48
  PL: { rate: 2300, name: "VAT" }
48
49
  };
49
50
  var social = (manifest, id, envPrefix) => has(manifest, id) ? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },` : null;
51
+ var WIRED_CARRIERS = ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"];
52
+ var needsShippingConfig = (id) => WIRED_CARRIERS.includes(id) && id !== "nft";
50
53
  function renderAuthTs(manifest) {
51
54
  const { shop } = manifest;
52
55
  const lines = [];
53
56
  const CARRIER_CALLS = {
57
+ "royal-mail": {
58
+ label: "Royal Mail",
59
+ call: `royalMailProvider({ apiKey: must(env, "CLICK_DROP_AUTH_KEY"), services: royalMailServices, currency: "${shop.currency}", carrierName: "Royal Mail" })`,
60
+ import: `import { royalMailProvider } from "@saastemly/better-commerce/providers/royal-mail";`
61
+ },
54
62
  "dhl-express": {
55
63
  label: "DHL Express",
56
64
  call: `dhlExpressProvider({ apiKey: must(env, "DHL_API_KEY"), apiSecret: must(env, "DHL_API_SECRET"), accountNumber: must(env, "DHL_ACCOUNT_NUMBER"), from: shipFrom })`,
@@ -85,8 +93,12 @@ import { mintProvider, destinationFor } from "./lib/mint.ts";`
85
93
  };
86
94
  const carriers = Object.keys(CARRIER_CALLS).filter((id) => has(manifest, id));
87
95
  const carrierImports = carriers.map((id) => CARRIER_CALLS[id].import);
88
- if (carriers.some((id) => id !== "nft"))
89
- carrierImports.push(`import { shipFrom } from "./lib/shipping.ts";`);
96
+ const shipping = [
97
+ ...carriers.some((id) => id !== "nft" && id !== "royal-mail") ? ["shipFrom"] : [],
98
+ ...carriers.includes("royal-mail") ? ["royalMailServices"] : []
99
+ ];
100
+ if (shipping.length > 0)
101
+ carrierImports.push(`import { ${shipping.join(", ")} } from "./lib/shipping.ts";`);
90
102
  const wantsNft = has(manifest, "nft");
91
103
  const carrierFulfillment = carriers.length ? `, deliveryFulfillment([${carriers.map((id) => CARRIER_CALLS[id].call).join(", ")}])` : "";
92
104
  const nftDefault = carriers.length ? `, defaultProvider: "delivery:${carriers.join("+")}"` : "";
@@ -363,6 +375,22 @@ var camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
363
375
  var FRONTEND_OUT = "dist/client";
364
376
  var FRONTEND_BRANCH = "frontend-static";
365
377
  function renderFrontendApiTs(manifest) {
378
+ if (oneOrigin(manifest.layout)) {
379
+ return `import { createAuthClient } from "better-auth/react";
380
+ import { commerceClient } from "@saastemly/better-commerce/client";
381
+
382
+ /**
383
+ * The one client the storefront talks through.
384
+ *
385
+ * SAME ORIGIN. One worker serves this page and the API it calls, so there is
386
+ * no address to configure, nothing to bake at build time, and nothing that
387
+ * can point at the wrong place. Cookies are first-party, so no SameSite=None
388
+ * and no CORS preflight on any request.
389
+ */
390
+ export const API_ORIGIN = "";
391
+ export const api = createAuthClient({ plugins: [commerceClient()] });
392
+ `;
393
+ }
366
394
  return `/// <reference types="vite/client" />
367
395
  import { createAuthClient } from "better-auth/react";
368
396
  import { commerceClient } from "@saastemly/better-commerce/client";
@@ -383,6 +411,13 @@ export const api = createAuthClient({ baseURL: API_ORIGIN, plugins: [commerceCli
383
411
  `;
384
412
  }
385
413
  function renderFrontendEnvProduction(manifest) {
414
+ if (oneOrigin(manifest.layout)) {
415
+ return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
416
+ #
417
+ # The API is same-origin, so there is no VITE_API_ORIGIN: one worker serves
418
+ # this storefront and the endpoints it calls.
419
+ `;
420
+ }
386
421
  return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
387
422
  VITE_API_ORIGIN=https://api.${manifest.shop.domain}
388
423
  `;
@@ -506,9 +541,8 @@ function renderFrontendWorkflow(manifest, dir) {
506
541
  # Generated by \`vc init\` from voidcommerce.json.
507
542
  #
508
543
  # Builds the storefront and force-pushes the prerendered tree to
509
- # \`${FRONTEND_BRANCH}\`. Point GitHub Pages (or Cloudflare Pages) at that branch
510
- # and a push here is a live storefront. The worker deploys separately, from
511
- # \`void-dist\`.
544
+ # \`${FRONTEND_BRANCH}\`, which GitHub Pages serves. The worker deploys
545
+ # separately, from deploy.yml.
512
546
  on:
513
547
  push:
514
548
  branches: [main, master]
@@ -1310,7 +1344,7 @@ import color from "picocolors";
1310
1344
  // package.json
1311
1345
  var package_default = {
1312
1346
  name: "@saastemly/voidcommerce",
1313
- version: "0.5.0",
1347
+ version: "0.7.0",
1314
1348
  description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
1315
1349
  type: "module",
1316
1350
  license: "MIT",
@@ -1775,6 +1809,14 @@ That is the deploy. GitHub Actions regenerates the app from
1775
1809
  database and queue if they are not there, applies the migrations, and deploys
1776
1810
  the worker with this repository's secrets attached.
1777
1811
 
1812
+ **One worker serves everything.** The storefront is prerendered and folded
1813
+ into the worker's own static assets, so \`${manifest.shop.domain}\` answers with
1814
+ the shop, the admin panel and the API from a single origin. Cloudflare creates
1815
+ the hostname's DNS record and certificate itself, because it is a Worker
1816
+ custom domain. There is nothing to configure in any dashboard, no second
1817
+ branch to keep in step, and no CORS — the storefront's requests are
1818
+ first-party.
1819
+
1778
1820
  You do not need wrangler on your machine, and you do not need to be logged
1779
1821
  into it. You do not need to open the Cloudflare dashboard after the one step
1780
1822
  below.
@@ -1914,7 +1956,7 @@ export function check(present: Map<string, string | undefined>): Requirement[] {
1914
1956
 
1915
1957
  // src/generate/support.ts
1916
1958
  function renderDomainTs(layout) {
1917
- const derive = layout === "app" ? ` // One app: the worker IS the site. No separate API host, and no other
1959
+ const derive = layout !== "monorepo" ? ` // One origin: the worker IS the site. No separate API host, and no other
1918
1960
  // origin to trust. It answers on www too, but only at a zone's apex —
1919
1961
  // www.shop.example.com is not a name anybody types.
1920
1962
  const hosts = apex ? [host, www] : [host];
@@ -1925,7 +1967,7 @@ function renderDomainTs(layout) {
1925
1967
  return `/**
1926
1968
  * The shop's domain, and everything derived from it.
1927
1969
  *
1928
- * One variable. \`SHOP_DOMAIN\` gives ${layout === "app" ? "the worker's hostnames" : `api.<domain> for the worker and the
1970
+ * One variable. \`SHOP_DOMAIN\` gives ${layout !== "monorepo" ? "the worker's hostnames" : `api.<domain> for the worker and the
1929
1971
  * storefront's origins`}, the app's public origin, and the
1930
1972
  * CORS and CSRF allow-list. They were four settings that had to agree and
1931
1973
  * nothing checked that they did.
@@ -1968,11 +2010,13 @@ export const GITHUB_PAGES_AAAA = ["2606:50c0:8000::153", "2606:50c0:8001::153",
1968
2010
  /**
1969
2011
  * The storefront's records. DNS-only, never proxied: GitHub issues the
1970
2012
  * certificate itself and validates by reaching the origin. The API hostname
1971
- * is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${layout === "app" ? `
1972
- * One app has no storefront elsewhere, so this is always empty.` : ""}
2013
+ * is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${layout !== "monorepo" ? `
2014
+ * One worker serves the storefront, so this is always empty.` : ""}
1973
2015
  */
1974
2016
  export function storefrontRecords(domain: ShopDomain, pagesHost: string) {
1975
- if (!pagesHost${layout === "app" ? " || domain.frontendOrigins.length === 0" : ""}) return [];
2017
+ // Empty whenever one worker serves the storefront: Cloudflare creates the
2018
+ // hostname's record and certificate itself for a custom domain.
2019
+ if (!pagesHost || domain.frontendOrigins.length === 0) return [];
1976
2020
  // The record's name is relative to the zone: "@" at the apex, otherwise the
1977
2021
  // labels in front of it.
1978
2022
  const name = domain.apex ? "@" : domain.host.slice(0, -(domain.zone.length + 1));
@@ -2148,13 +2192,18 @@ export async function destinationFor(orderId: string): Promise<string | null> {
2148
2192
  `;
2149
2193
  }
2150
2194
  function renderShippingTs(manifest) {
2151
- return `import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";
2152
-
2195
+ const royalMail = has(manifest, "royal-mail");
2196
+ const origin = ["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id));
2197
+ const imports = [
2198
+ ...origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : [],
2199
+ ...royalMail ? [`import type { RoyalMailService } from "@saastemly/better-commerce/providers/royal-mail";`] : []
2200
+ ];
2201
+ const originBlock = `
2153
2202
  /**
2154
2203
  * The address every parcel is quoted and shipped FROM.
2155
2204
  *
2156
- * TODO VERIFY: this is a placeholder. A carrier quotes against it, and DHL
2157
- * refuses a shipment whose origin has no \`name\` and \`phone\`.
2205
+ * TODO VERIFY: this is a placeholder. A carrier quotes against it, and most
2206
+ * refuse a shipment whose origin has no \`name\` and \`phone\`.
2158
2207
  */
2159
2208
  export const shipFrom: DeliveryAddress = {
2160
2209
  name: "${manifest.shop.name}",
@@ -2165,6 +2214,9 @@ export const shipFrom: DeliveryAddress = {
2165
2214
  phone: "TODO VERIFY: +44…",
2166
2215
  };
2167
2216
  `;
2217
+ return `${imports.join(`
2218
+ `)}
2219
+ ${origin ? originBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
2168
2220
  }
2169
2221
  function renderErpTs() {
2170
2222
  return `import { businessCentralProvider } from "@saastemly/better-commerce/providers/business-central";
@@ -2193,6 +2245,44 @@ export const erpFulfillment = (currency: string): FulfillmentProvider =>
2193
2245
  }).fulfillment();
2194
2246
  `;
2195
2247
  }
2248
+ var ROYAL_MAIL_SERVICES = () => `
2249
+ /**
2250
+ * What this shop charges for Royal Mail, by total parcel weight.
2251
+ *
2252
+ * TODO VERIFY: every \`amount\` below is a placeholder in pence. Replace them
2253
+ * with your own OBA rate card — Click & Drop → Settings → Shipping services
2254
+ * lists exactly which services your account has, and the codes here must be
2255
+ * among them. Service codes are account-specific: the published list is from
2256
+ * 2021 and Royal Mail warn that "available services are unique to your OBA
2257
+ * account and individual service agreements".
2258
+ *
2259
+ * The band whose \`maxGrams\` first covers the parcel wins, so order does not
2260
+ * matter. A parcel heavier than every band gets no rate at all rather than a
2261
+ * wrong one, and Click & Drop refuses anything over 30 kg.
2262
+ */
2263
+ export const royalMailServices: RoyalMailService[] = [
2264
+ {
2265
+ code: "TPN24",
2266
+ name: "Tracked 24",
2267
+ estimatedDays: 1,
2268
+ bands: [
2269
+ { maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
2270
+ { maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
2271
+ { maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
2272
+ ],
2273
+ },
2274
+ {
2275
+ code: "TPS48",
2276
+ name: "Tracked 48",
2277
+ estimatedDays: 2,
2278
+ bands: [
2279
+ { maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
2280
+ { maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
2281
+ { maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
2282
+ ],
2283
+ },
2284
+ ];
2285
+ `;
2196
2286
 
2197
2287
  // src/generate/index.ts
2198
2288
  async function exists(path) {
@@ -2281,7 +2371,7 @@ async function generateApi(root, dir, manifest, result, opts) {
2281
2371
  if (has(manifest, "nft")) {
2282
2372
  await put(root, at("lib/mint.ts"), renderMintTs(), result, own);
2283
2373
  }
2284
- if (["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id))) {
2374
+ if (WIRED_CARRIERS.some((id) => needsShippingConfig(id) && has(manifest, id))) {
2285
2375
  await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result, own);
2286
2376
  }
2287
2377
  if (has(manifest, "business-central")) {
@@ -2338,7 +2428,11 @@ async function generateFrontend(root, dir, manifest, result) {
2338
2428
  await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
2339
2429
  await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
2340
2430
  await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
2341
- await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
2431
+ if (oneOrigin(manifest.layout)) {
2432
+ await retire(root, ".github/workflows/frontend-static.yml", result);
2433
+ } else {
2434
+ await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
2435
+ }
2342
2436
  if (dir === "")
2343
2437
  return;
2344
2438
  await mergeJson(root, at("package.json"), { name: `${manifest.shop.domain.split(".")[0]}-frontend`, type: "module", private: true }, (pkg) => {
@@ -2440,8 +2534,13 @@ async function generateStrictApp(root, dir, manifest, result) {
2440
2534
  await put(root, at("db/seed.ts"), renderDbSeed(), result, gen);
2441
2535
  await put(root, at("pages/layout.tsx"), renderLayoutTsx(), result, gen);
2442
2536
  await put(root, at("pages/app.css"), renderAppCss(), result, gen);
2443
- await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
2444
- await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
2537
+ if (!oneOrigin(manifest.layout) || isSingleApp(manifest.layout)) {
2538
+ await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
2539
+ await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
2540
+ } else {
2541
+ await retire(root, at("pages/index.server.ts"), result);
2542
+ await retire(root, at("pages/index.tsx"), result);
2543
+ }
2445
2544
  await put(root, at("pages/api-dashboard/index.tsx"), renderDashboardPage(manifest), result, gen);
2446
2545
  await put(root, at("pages/api-dashboard/index.server.ts"), CLIENT_ONLY, result, gen);
2447
2546
  await put(root, at("pages/sign-in/index.tsx"), renderSignInPage(manifest), result, gen);
@@ -2909,7 +3008,7 @@ ${color3.red("NOT ready to go live.")}
2909
3008
  }
2910
3009
 
2911
3010
  // src/deploy/cloudflare.ts
2912
- import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync } from "node:fs";
3011
+ import { copyFileSync, existsSync as existsSync8, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "node:fs";
2913
3012
  import { dirname as dirname5, join as join8 } from "node:path";
2914
3013
  import color4 from "picocolors";
2915
3014
  var fail = (message) => {
@@ -2999,11 +3098,32 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
2999
3098
  const builder = findBuilder(app);
3000
3099
  if (!builder)
3001
3100
  return fail("no build tool: neither vite-plus nor vite is installed.");
3101
+ const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
3102
+ if (merging) {
3103
+ console.log(`
3104
+ ▸ ${builder.label} ${color4.dim("(the storefront)")}`);
3105
+ const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
3106
+ if (storefront.code !== 0)
3107
+ return fail(`the storefront build failed (exit ${storefront.code}).`);
3108
+ }
3002
3109
  console.log(`
3003
- ▸ ${builder.label}`);
3110
+ ▸ ${builder.label}${merging ? color4.dim(" (the worker)") : ""}`);
3004
3111
  const built = await wrangler(builder.cmd, ["build"], app, true);
3005
3112
  if (built.code !== 0)
3006
3113
  return fail(`the build failed (exit ${built.code}).`);
3114
+ if (merging) {
3115
+ const from = join8(project.root, "dist", "client");
3116
+ const into = join8(app, "dist", "client");
3117
+ if (!existsSync8(from))
3118
+ return fail(`the storefront build emitted no ${from} — is the root a Void app with output "static"?`);
3119
+ if (!existsSync8(into))
3120
+ return fail(`the worker build emitted no ${into}; there is nothing to merge into.`);
3121
+ const merged = mergeTree(from, into, new Set([".vite"]));
3122
+ if (!existsSync8(join8(into, "index.html"))) {
3123
+ return fail("the storefront produced no index.html, so the shop has no front page.");
3124
+ }
3125
+ console.log(`${color4.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
3126
+ }
3007
3127
  const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
3008
3128
  if (!existsSync8(emittedPath))
3009
3129
  return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
@@ -3046,10 +3166,28 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3046
3166
  const url = `https://${workerHosts(project.manifest)[0]}`;
3047
3167
  console.log(`
3048
3168
  ${color4.green("Live:")} ${url}`);
3049
- if (hasFrontend(project.manifest.layout))
3169
+ if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
3050
3170
  console.log(color4.dim("The storefront deploys itself from GitHub Actions on push."));
3171
+ }
3051
3172
  return 0;
3052
3173
  }
3174
+ function mergeTree(from, into, skip) {
3175
+ let count = 0;
3176
+ for (const entry of readdirSync2(from, { withFileTypes: true })) {
3177
+ if (skip.has(entry.name))
3178
+ continue;
3179
+ const source = join8(from, entry.name);
3180
+ const target = join8(into, entry.name);
3181
+ if (entry.isDirectory()) {
3182
+ mkdirSync(target, { recursive: true });
3183
+ count += mergeTree(source, target, skip);
3184
+ } else {
3185
+ copyFileSync(source, target);
3186
+ count += 1;
3187
+ }
3188
+ }
3189
+ return count;
3190
+ }
3053
3191
 
3054
3192
  // src/import.ts
3055
3193
  import { readFile as readFile3 } from "node:fs/promises";
@@ -446,7 +446,18 @@ var CARRIERS = {
446
446
  { id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" }, { key: "FEDEX_CLIENT_SECRET", breaks: "the same" }, { key: "FEDEX_ACCOUNT_NUMBER", breaks: "FedEx has no account to bill the transport to" }] },
447
447
  { id: "dhl-express", label: "DHL Express", hint: "international express, and the one worth having for a shop that posts abroad", env: [{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" }, { key: "DHL_API_SECRET", breaks: "the same" }, { key: "DHL_ACCOUNT_NUMBER", breaks: "DHL has no account to bill the transport to" }] },
448
448
  { id: "usps", label: "USPS", hint: "US domestic post — the cheapest way to ship a small parcel inside the US", env: [{ key: "USPS_CLIENT_ID", breaks: "USPS rates are not quoted" }, { key: "USPS_CLIENT_SECRET", breaks: "the same" }] },
449
- { id: "royal-mail", label: "Royal Mail", hint: "UK post, for a shop whose customers are mostly in Britain", env: [{ key: "ROYAL_MAIL_CLIENT_ID", breaks: "Royal Mail rates are not quoted" }] },
449
+ {
450
+ id: "royal-mail",
451
+ label: "Royal Mail (Click & Drop)",
452
+ hint: "UK post. Needs an OBA business account — Click & Drop generates labels through the API only for those. Prices come from your own tariff table, because no Royal Mail API quotes rates",
453
+ env: [
454
+ {
455
+ key: "CLICK_DROP_AUTH_KEY",
456
+ breaks: "no label and no tracking number: orders are created in Click & Drop but never despatched",
457
+ where: "Click & Drop → Settings → Integrations → Click & Drop API"
458
+ }
459
+ ]
460
+ },
450
461
  { id: "aramex", label: "Aramex", hint: "the carrier to reach the Gulf and wider Middle East reliably", env: [{ key: "ARAMEX_API_KEY", breaks: "Aramex rates are not quoted" }] },
451
462
  { id: "doordash-drive", label: "DoorDash Drive", hint: "same-day local delivery", env: [{ key: "DOORDASH_DEVELOPER_ID", breaks: "DoorDash is not offered" }] },
452
463
  { id: "stuart", label: "Stuart", hint: "same-day courier, Europe", env: [{ key: "STUART_CLIENT_ID", breaks: "Stuart is not offered" }] },
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CHOICES,
3
3
  GROUPS
4
- } from "./index-844b3qn9.js";
4
+ } from "./index-mnb7fz2t.js";
5
5
 
6
6
  // src/manifest.ts
7
7
  import { readFile, writeFile } from "node:fs/promises";
@@ -21,11 +21,12 @@ var LAYOUTS = [
21
21
  {
22
22
  id: "strict",
23
23
  label: "Strict (experimental)",
24
- hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app from the manifest and never hand-edited"
24
+ hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app and serves the storefront from its own assets — one origin, one deploy"
25
25
  }
26
26
  ];
27
27
  var isSingleApp = (layout) => layout === "app";
28
28
  var hasFrontend = (layout) => layout !== "app";
29
+ var oneOrigin = (layout) => layout !== "monorepo";
29
30
  var TWO_LABEL_SUFFIXES = new Set(["co", "com", "net", "org", "ac", "gov", "edu", "or", "ne", "in"]);
30
31
  function zoneOf(domain) {
31
32
  const labels = domain.trim().toLowerCase().replace(/\.$/, "").split(".");
@@ -42,7 +43,7 @@ function isApex(manifest) {
42
43
  }
43
44
  function workerHosts(manifest) {
44
45
  const { domain } = manifest.shop;
45
- if (!isSingleApp(manifest.layout))
46
+ if (!oneOrigin(manifest.layout))
46
47
  return [`api.${domain}`];
47
48
  return isApex(manifest) ? [domain, `www.${domain}`] : [domain];
48
49
  }
@@ -163,10 +164,10 @@ function validate(manifest) {
163
164
  if (dns.includes("simply")) {
164
165
  problems.push(`DNS at Simply.com cannot host ${workerHosts(manifest)[0]} — a Worker custom domain is created by Cloudflare, in a zone on Cloudflare`);
165
166
  }
166
- if (hasFrontend(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
167
+ if (!oneOrigin(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
167
168
  problems.push(`Pages host "${manifest.shop.pagesHost}" should be <owner>.github.io`);
168
169
  }
169
170
  return problems;
170
171
  }
171
172
 
172
- export { MANIFEST_FILE, LAYOUTS, isSingleApp, hasFrontend, zoneOf, zone, isApex, workerHosts, withRequired, has, chosenIn, envKeysOf, OWN_PACKAGES, specifier, packagesOf, readManifest, writeManifest, validate };
173
+ export { MANIFEST_FILE, LAYOUTS, isSingleApp, hasFrontend, oneOrigin, zoneOf, zone, isApex, workerHosts, withRequired, has, chosenIn, envKeysOf, OWN_PACKAGES, specifier, packagesOf, readManifest, writeManifest, validate };
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  routeProblem,
52
52
  strictDependencies,
53
53
  upsertJsonc
54
- } from "./index-5m3t0zfc.js";
54
+ } from "./index-85m0qkmg.js";
55
55
  import {
56
56
  allEnvKeys,
57
57
  envSummary,
@@ -59,7 +59,7 @@ import {
59
59
  renderEnvLocal,
60
60
  renderEnvProduction,
61
61
  renderEnvTs
62
- } from "./index-b9b4dawy.js";
62
+ } from "./index-6w8rwtsh.js";
63
63
  import {
64
64
  LAYOUTS,
65
65
  MANIFEST_FILE,
@@ -70,6 +70,7 @@ import {
70
70
  hasFrontend,
71
71
  isApex,
72
72
  isSingleApp,
73
+ oneOrigin,
73
74
  packagesOf,
74
75
  readManifest,
75
76
  specifier,
@@ -79,7 +80,7 @@ import {
79
80
  writeManifest,
80
81
  zone,
81
82
  zoneOf
82
- } from "./index-pz6m2hkm.js";
83
+ } from "./index-tjy6yygc.js";
83
84
  import {
84
85
  AUTH_PLUGINS,
85
86
  CARRIERS,
@@ -96,7 +97,7 @@ import {
96
97
  SIGN_IN,
97
98
  TAX,
98
99
  UI
99
- } from "./index-844b3qn9.js";
100
+ } from "./index-mnb7fz2t.js";
100
101
  import"./index-0v6na3yp.js";
101
102
  export {
102
103
  zoneOf,
@@ -147,6 +148,7 @@ export {
147
148
  parseWhoAmI,
148
149
  parseJsonc,
149
150
  packagesOf,
151
+ oneOrigin,
150
152
  isSingleApp,
151
153
  isApex,
152
154
  importCommand,
@@ -5,9 +5,9 @@ import {
5
5
  keysCommand,
6
6
  provisionKey,
7
7
  publicKeyFor
8
- } from "./index-b9b4dawy.js";
9
- import"./index-pz6m2hkm.js";
10
- import"./index-844b3qn9.js";
8
+ } from "./index-6w8rwtsh.js";
9
+ import"./index-tjy6yygc.js";
10
+ import"./index-mnb7fz2t.js";
11
11
  import"./index-0v6na3yp.js";
12
12
  export {
13
13
  publicKeyFor,
@@ -24,9 +24,10 @@ export declare const MANIFEST_FILE = "voidcommerce.json";
24
24
  * strict EXPERIMENTAL. A monorepo turned inside out. The REPOSITORY
25
25
  * ROOT is the storefront — its package.json, its pages, yours to
26
26
  * edit — and the worker is generated underneath it at .vc/app
27
- * from the manifest, gitignored, never hand-edited. Hosted the
28
- * way a monorepo is: the worker on api.<domain>, the storefront
29
- * on <domain>.
27
+ * from the manifest, gitignored, never hand-edited. ONE worker
28
+ * serves both at <domain>: the storefront's prerendered tree is
29
+ * folded into the worker's assets at build time, so there is one
30
+ * origin, one deploy, and one certificate Cloudflare issues.
30
31
  *
31
32
  * The first question, because it decides where `void init` runs and what
32
33
  * the domain means. It cannot change after init: moving files is not a
@@ -47,8 +48,26 @@ export declare const LAYOUTS: Array<{
47
48
  * storefront on the domain itself.
48
49
  */
49
50
  export declare const isSingleApp: (layout: Layout) => boolean;
50
- /** Does this layout carry a storefront of its own, and so a second origin to trust? */
51
+ /** Does this layout carry a storefront of its own, in the repository? */
51
52
  export declare const hasFrontend: (layout: Layout) => boolean;
53
+ /**
54
+ * Does ONE worker serve both the storefront and the API?
55
+ *
56
+ * `app` always did — it generates its storefront into the worker. `strict`
57
+ * now does too: the root's prerendered tree is merged into the worker's own
58
+ * assets directory at build time, so there is one origin, one hostname, one
59
+ * deploy, and one certificate that Cloudflare issues itself.
60
+ *
61
+ * That deletes the whole GitHub Pages half — the A/AAAA records, the CNAME
62
+ * file, the base-path rewriting — and with it a trap worth naming: GitHub
63
+ * treats any Cloudflare IP as ineligible for a certificate, so a proxied
64
+ * record silently stops issuance AND renewal about ninety days later, while
65
+ * GitHub's own health check still reports green.
66
+ *
67
+ * `monorepo` keeps the split, because there the frontend is a separate app
68
+ * that a person may well want hosted separately.
69
+ */
70
+ export declare const oneOrigin: (layout: Layout) => boolean;
52
71
  /** The zone a hostname most likely belongs to: the registrable domain. */
53
72
  export declare function zoneOf(domain: string): string;
54
73
  /** The zone this shop's records go in. */
@@ -88,7 +107,10 @@ export interface Manifest {
88
107
  * default that is safe to assume.
89
108
  */
90
109
  taxRegistered?: boolean | undefined;
91
- /** `saastemly.github.io` — the monorepo's frontend lives on GitHub Pages; the www record points here. */
110
+ /**
111
+ * `saastemly.github.io` — only a MONOREPO has a storefront on GitHub
112
+ * Pages. In `app` and `strict` the worker serves it, so this is unused.
113
+ */
92
114
  pagesHost?: string | undefined;
93
115
  };
94
116
  /** Chosen ids from each group, keyed by group id. */
package/dist/manifest.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  hasFrontend,
9
9
  isApex,
10
10
  isSingleApp,
11
+ oneOrigin,
11
12
  packagesOf,
12
13
  readManifest,
13
14
  specifier,
@@ -17,8 +18,8 @@ import {
17
18
  writeManifest,
18
19
  zone,
19
20
  zoneOf
20
- } from "./index-pz6m2hkm.js";
21
- import"./index-844b3qn9.js";
21
+ } from "./index-tjy6yygc.js";
22
+ import"./index-mnb7fz2t.js";
22
23
  import"./index-0v6na3yp.js";
23
24
  export {
24
25
  zoneOf,
@@ -30,6 +31,7 @@ export {
30
31
  specifier,
31
32
  readManifest,
32
33
  packagesOf,
34
+ oneOrigin,
33
35
  isSingleApp,
34
36
  isApex,
35
37
  hasFrontend,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.5.0",
3
+ "version": "0.7.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
@@ -519,7 +519,18 @@ export const CARRIERS: Group = {
519
519
  { id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" }, { key: "FEDEX_CLIENT_SECRET", breaks: "the same" }, { key: "FEDEX_ACCOUNT_NUMBER", breaks: "FedEx has no account to bill the transport to" }] },
520
520
  { id: "dhl-express", label: "DHL Express", hint: "international express, and the one worth having for a shop that posts abroad", env: [{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" }, { key: "DHL_API_SECRET", breaks: "the same" }, { key: "DHL_ACCOUNT_NUMBER", breaks: "DHL has no account to bill the transport to" }] },
521
521
  { id: "usps", label: "USPS", hint: "US domestic post — the cheapest way to ship a small parcel inside the US", env: [{ key: "USPS_CLIENT_ID", breaks: "USPS rates are not quoted" }, { key: "USPS_CLIENT_SECRET", breaks: "the same" }] },
522
- { id: "royal-mail", label: "Royal Mail", hint: "UK post, for a shop whose customers are mostly in Britain", env: [{ key: "ROYAL_MAIL_CLIENT_ID", breaks: "Royal Mail rates are not quoted" }] },
522
+ {
523
+ id: "royal-mail",
524
+ label: "Royal Mail (Click & Drop)",
525
+ hint: "UK post. Needs an OBA business account — Click & Drop generates labels through the API only for those. Prices come from your own tariff table, because no Royal Mail API quotes rates",
526
+ env: [
527
+ {
528
+ key: "CLICK_DROP_AUTH_KEY",
529
+ breaks: "no label and no tracking number: orders are created in Click & Drop but never despatched",
530
+ where: "Click & Drop → Settings → Integrations → Click & Drop API",
531
+ },
532
+ ],
533
+ },
523
534
  { id: "aramex", label: "Aramex", hint: "the carrier to reach the Gulf and wider Middle East reliably", env: [{ key: "ARAMEX_API_KEY", breaks: "Aramex rates are not quoted" }] },
524
535
  { id: "doordash-drive", label: "DoorDash Drive", hint: "same-day local delivery", env: [{ key: "DOORDASH_DEVELOPER_ID", breaks: "DoorDash is not offered" }] },
525
536
  { id: "stuart", label: "Stuart", hint: "same-day courier, Europe", env: [{ key: "STUART_CLIENT_ID", breaks: "Stuart is not offered" }] },
@@ -1,8 +1,8 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import color from "picocolors";
4
4
  import { allEnvKeys } from "../generate/env";
5
- import { hasFrontend, workerHosts, writeManifest } from "../manifest";
5
+ import { hasFrontend, isSingleApp, oneOrigin, workerHosts, writeManifest } from "../manifest";
6
6
  import type { Project } from "../project";
7
7
  import { parseJsonc, upsertJsonc } from "./jsonc";
8
8
  import { preflight, printPreflight } from "./preflight";
@@ -130,10 +130,46 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
130
130
  // 4. build.
131
131
  const builder = findBuilder(app);
132
132
  if (!builder) return fail("no build tool: neither vite-plus nor vite is installed.");
133
- console.log(`\n▸ ${builder.label}`);
133
+
134
+ // In strict the STOREFRONT is a second Void app, at the repository root,
135
+ // and it has to be built first: its prerendered tree is folded into the
136
+ // worker's assets below, and the worker's build is what emits the config
137
+ // that names that directory.
138
+ const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
139
+ if (merging) {
140
+ console.log(`\n▸ ${builder.label} ${color.dim("(the storefront)")}`);
141
+ const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
142
+ if (storefront.code !== 0) return fail(`the storefront build failed (exit ${storefront.code}).`);
143
+ }
144
+
145
+ console.log(`\n▸ ${builder.label}${merging ? color.dim(" (the worker)") : ""}`);
134
146
  const built = await wrangler(builder.cmd, ["build"], app, true);
135
147
  if (built.code !== 0) return fail(`the build failed (exit ${built.code}).`);
136
148
 
149
+ // 4b. Fold the storefront into the worker's assets.
150
+ //
151
+ // Void's worker already serves static assets: it runs first for every
152
+ // request and calls `env.ASSETS.fetch` only when its own router 404s. So
153
+ // the storefront's prerendered files land in the same directory and are
154
+ // reached by exactly that fallback — no routing rules, no second origin.
155
+ //
156
+ // Filenames are content-hashed, so the two trees merge without collision.
157
+ // `.vite/manifest.json` is the one exception and is build metadata that
158
+ // should never be uploaded at all.
159
+ if (merging) {
160
+ const from = join(project.root, "dist", "client");
161
+ const into = join(app, "dist", "client");
162
+ if (!existsSync(from)) return fail(`the storefront build emitted no ${from} — is the root a Void app with output "static"?`);
163
+ if (!existsSync(into)) return fail(`the worker build emitted no ${into}; there is nothing to merge into.`);
164
+ const merged = mergeTree(from, into, new Set([".vite"]));
165
+ // The worker owns "/" only if it has a page for it. It must not, or the
166
+ // shop's front door is unreachable — so this is checked, not assumed.
167
+ if (!existsSync(join(into, "index.html"))) {
168
+ return fail("the storefront produced no index.html, so the shop has no front page.");
169
+ }
170
+ console.log(`${color.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
171
+ }
172
+
137
173
  // 5. scrub.
138
174
  const emittedPath = join(app, "dist", "ssr", "wrangler.json");
139
175
  if (!existsSync(emittedPath)) return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
@@ -183,6 +219,34 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
183
219
  const domain = project.manifest.shop.domain;
184
220
  const url = `https://${workerHosts(project.manifest)[0]}`;
185
221
  console.log(`\n${color.green("Live:")} ${url}`);
186
- if (hasFrontend(project.manifest.layout)) console.log(color.dim("The storefront deploys itself from GitHub Actions on push."));
222
+ if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
223
+ console.log(color.dim("The storefront deploys itself from GitHub Actions on push."));
224
+ }
187
225
  return 0;
188
226
  }
227
+
228
+
229
+ /**
230
+ * Copy one built tree over another, skipping named directories.
231
+ *
232
+ * Deliberately additive rather than a replace: the worker's own client
233
+ * bundles are already in the target and must survive. Returns how many files
234
+ * were written, so the deploy can say so rather than claiming success
235
+ * silently.
236
+ */
237
+ function mergeTree(from: string, into: string, skip: Set<string>): number {
238
+ let count = 0;
239
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
240
+ if (skip.has(entry.name)) continue;
241
+ const source = join(from, entry.name);
242
+ const target = join(into, entry.name);
243
+ if (entry.isDirectory()) {
244
+ mkdirSync(target, { recursive: true });
245
+ count += mergeTree(source, target, skip);
246
+ } else {
247
+ copyFileSync(source, target);
248
+ count += 1;
249
+ }
250
+ }
251
+ return count;
252
+ }
@@ -49,6 +49,20 @@ const social = (manifest: Manifest, id: string, envPrefix: string) =>
49
49
  ? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },`
50
50
  : null;
51
51
 
52
+ /**
53
+ * Carriers with a provider behind them, and whether each needs `lib/shipping.ts`.
54
+ *
55
+ * Exported because `generate/index.ts` has to know which shops get that file,
56
+ * and a second hand-maintained list of carrier ids WILL drift from this one —
57
+ * it already did: adding Royal Mail here left the file ungenerated, so the
58
+ * generated `auth.ts` imported a price table that did not exist.
59
+ *
60
+ * The token needs nothing: no origin, no tariff. Everything else needs one or
61
+ * the other, so the rule is simply "any carrier but the token".
62
+ */
63
+ export const WIRED_CARRIERS = ["royal-mail", "dhl-express", "shippo", "easypost", "ups", "fedex", "nft"] as const;
64
+ export const needsShippingConfig = (id: string): boolean => WIRED_CARRIERS.includes(id as never) && id !== "nft";
65
+
52
66
  export function renderAuthTs(manifest: Manifest): string {
53
67
  const { shop } = manifest;
54
68
  const lines: Line[] = [];
@@ -68,6 +82,14 @@ export function renderAuthTs(manifest: Manifest): string {
68
82
  const CARRIER_CALLS: Record<string, { call: string; label: string; import: string }> = {
69
83
  // Every real carrier needs the ORIGIN it ships from, which is shop data
70
84
  // rather than a credential — so it lives in an owned lib/shipping.ts.
85
+ "royal-mail": {
86
+ label: "Royal Mail",
87
+ // No `from`: Click & Drop posts from the account's registered
88
+ // address, so an origin here would be ignored. `services` is the
89
+ // price table in lib/shipping.ts, because nothing quotes Royal Mail.
90
+ call: `royalMailProvider({ apiKey: must(env, "CLICK_DROP_AUTH_KEY"), services: royalMailServices, currency: "${shop.currency}", carrierName: "Royal Mail" })`,
91
+ import: `import { royalMailProvider } from "@saastemly/better-commerce/providers/royal-mail";`,
92
+ },
71
93
  "dhl-express": {
72
94
  label: "DHL Express",
73
95
  call: `dhlExpressProvider({ apiKey: must(env, "DHL_API_KEY"), apiSecret: must(env, "DHL_API_SECRET"), accountNumber: must(env, "DHL_ACCOUNT_NUMBER"), from: shipFrom })`,
@@ -101,8 +123,18 @@ export function renderAuthTs(manifest: Manifest): string {
101
123
  };
102
124
  const carriers = Object.keys(CARRIER_CALLS).filter((id) => has(manifest, id));
103
125
  const carrierImports = carriers.map((id) => CARRIER_CALLS[id]!.import);
104
- // The token needs no origin; every other carrier does.
105
- if (carriers.some((id) => id !== "nft")) carrierImports.push(`import { shipFrom } from "./lib/shipping.ts";`);
126
+ // One import from lib/shipping.ts, naming only what is actually used —
127
+ // an unused binding is a type error in a shop with `noUnusedLocals`.
128
+ //
129
+ // The token needs no origin. Royal Mail needs no origin either: Click &
130
+ // Drop posts from the address registered on the account, so a `from` here
131
+ // would be silently ignored. It needs the price table instead, because
132
+ // nothing quotes Royal Mail.
133
+ const shipping = [
134
+ ...(carriers.some((id) => id !== "nft" && id !== "royal-mail") ? ["shipFrom"] : []),
135
+ ...(carriers.includes("royal-mail") ? ["royalMailServices"] : []),
136
+ ];
137
+ if (shipping.length > 0) carrierImports.push(`import { ${shipping.join(", ")} } from "./lib/shipping.ts";`);
106
138
  const wantsNft = has(manifest, "nft");
107
139
  // One bridge for every carrier: which ships is decided by whose rate the
108
140
  // buyer paid for, so a token order mints and a parcel order posts.
@@ -176,6 +176,14 @@ That is the deploy. GitHub Actions regenerates the app from
176
176
  database and queue if they are not there, applies the migrations, and deploys
177
177
  the worker with this repository's secrets attached.
178
178
 
179
+ **One worker serves everything.** The storefront is prerendered and folded
180
+ into the worker's own static assets, so \`${manifest.shop.domain}\` answers with
181
+ the shop, the admin panel and the API from a single origin. Cloudflare creates
182
+ the hostname's DNS record and certificate itself, because it is a Worker
183
+ custom domain. There is nothing to configure in any dashboard, no second
184
+ branch to keep in step, and no CORS — the storefront's requests are
185
+ first-party.
186
+
179
187
  You do not need wrangler on your machine, and you do not need to be logged
180
188
  into it. You do not need to open the Cloudflare dashboard after the one step
181
189
  below.
@@ -1,4 +1,4 @@
1
- import type { Manifest } from "../manifest";
1
+ import { type Manifest, oneOrigin } from "../manifest";
2
2
 
3
3
  /**
4
4
  * The storefront: a static Void site that talks to the worker over HTTP.
@@ -24,6 +24,22 @@ export const FRONTEND_OUT = "dist/client";
24
24
  export const FRONTEND_BRANCH = "frontend-static";
25
25
 
26
26
  export function renderFrontendApiTs(manifest: Manifest): string {
27
+ if (oneOrigin(manifest.layout)) {
28
+ return `import { createAuthClient } from "better-auth/react";
29
+ import { commerceClient } from "@saastemly/better-commerce/client";
30
+
31
+ /**
32
+ * The one client the storefront talks through.
33
+ *
34
+ * SAME ORIGIN. One worker serves this page and the API it calls, so there is
35
+ * no address to configure, nothing to bake at build time, and nothing that
36
+ * can point at the wrong place. Cookies are first-party, so no SameSite=None
37
+ * and no CORS preflight on any request.
38
+ */
39
+ export const API_ORIGIN = "";
40
+ export const api = createAuthClient({ plugins: [commerceClient()] });
41
+ `;
42
+ }
27
43
  return `/// <reference types="vite/client" />
28
44
  import { createAuthClient } from "better-auth/react";
29
45
  import { commerceClient } from "@saastemly/better-commerce/client";
@@ -45,6 +61,13 @@ export const api = createAuthClient({ baseURL: API_ORIGIN, plugins: [commerceCli
45
61
  }
46
62
 
47
63
  export function renderFrontendEnvProduction(manifest: Manifest): string {
64
+ if (oneOrigin(manifest.layout)) {
65
+ return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
66
+ #
67
+ # The API is same-origin, so there is no VITE_API_ORIGIN: one worker serves
68
+ # this storefront and the endpoints it calls.
69
+ `;
70
+ }
48
71
  return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
49
72
  VITE_API_ORIGIN=https://api.${manifest.shop.domain}
50
73
  `;
@@ -183,9 +206,8 @@ export function renderFrontendWorkflow(manifest: Manifest, dir: string): string
183
206
  # Generated by \`vc init\` from voidcommerce.json.
184
207
  #
185
208
  # Builds the storefront and force-pushes the prerendered tree to
186
- # \`${FRONTEND_BRANCH}\`. Point GitHub Pages (or Cloudflare Pages) at that branch
187
- # and a push here is a live storefront. The worker deploys separately, from
188
- # \`void-dist\`.
209
+ # \`${FRONTEND_BRANCH}\`, which GitHub Pages serves. The worker deploys
210
+ # separately, from deploy.yml.
189
211
  on:
190
212
  push:
191
213
  branches: [main, master]
@@ -2,8 +2,8 @@ import { cp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/pr
2
2
  import { existsSync, lstatSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { CHOICES } from "../catalog";
5
- import { MANIFEST_FILE, type Manifest, has, isSingleApp, packagesOf, specifier, workerHosts, writeManifest, zone } from "../manifest";
6
- import { renderAuthTs } from "./auth";
5
+ import { MANIFEST_FILE, type Manifest, has, isSingleApp, oneOrigin, packagesOf, specifier, workerHosts, writeManifest, zone } from "../manifest";
6
+ import { renderAuthTs, WIRED_CARRIERS, needsShippingConfig } from "./auth";
7
7
  import {
8
8
  envSummary,
9
9
  renderEnvExample,
@@ -189,8 +189,10 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
189
189
  if (has(manifest, "nft")) {
190
190
  await put(root, at("lib/mint.ts"), renderMintTs(), result, own);
191
191
  }
192
- // Any carrier but the token quotes against an origin, which is shop data.
193
- if (["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id))) {
192
+ // Any carrier but the token needs shop data of its own — an origin to quote
193
+ // against, or a price table. The list lives with the carriers themselves so
194
+ // it cannot drift from them.
195
+ if (WIRED_CARRIERS.some((id) => needsShippingConfig(id) && has(manifest, id))) {
194
196
  await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result, own);
195
197
  }
196
198
  if (has(manifest, "business-central")) {
@@ -255,7 +257,7 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
255
257
  );
256
258
  }
257
259
 
258
- /** The static storefront on GitHub Pages. Only in a monorepo. */
260
+ /** The storefront: served by the worker in strict, by GitHub Pages in a monorepo. */
259
261
  async function generateFrontend(root: string, dir: string, manifest: Manifest, result: GenerateResult) {
260
262
  const at = (file: string) => join(dir, file);
261
263
 
@@ -265,7 +267,14 @@ async function generateFrontend(root: string, dir: string, manifest: Manifest, r
265
267
  await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
266
268
  await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
267
269
  await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
268
- await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
270
+ // A separate publishing workflow exists only when the storefront has a
271
+ // separate host. In strict the worker serves it, so the branch, the
272
+ // workflow, the CNAME file and the base-path rewriting all go away.
273
+ if (oneOrigin(manifest.layout)) {
274
+ await retire(root, ".github/workflows/frontend-static.yml", result);
275
+ } else {
276
+ await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
277
+ }
269
278
 
270
279
  // A monorepo's frontend is its own workspace; strict's shares the root's
271
280
  // one package.json, which generateStrictRoot has already written.
@@ -404,8 +413,20 @@ async function generateStrictApp(root: string, dir: string, manifest: Manifest,
404
413
  await put(root, at("db/seed.ts"), renderDbSeed(), result, gen);
405
414
  await put(root, at("pages/layout.tsx"), renderLayoutTsx(), result, gen);
406
415
  await put(root, at("pages/app.css"), renderAppCss(), result, gen);
407
- await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
408
- await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
416
+ // In strict the STOREFRONT owns "/": its prerendered index.html is merged
417
+ // into the worker's assets, and the worker reaches assets only by 404ing
418
+ // first. A worker index page here would shadow the shop's front door.
419
+ if (!oneOrigin(manifest.layout) || isSingleApp(manifest.layout)) {
420
+ await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
421
+ await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
422
+ } else {
423
+ // Deleted, not merely skipped. A shop regenerated from the older
424
+ // two-origin layout still has these on disk, and a leftover index page
425
+ // would answer "/" instead of 404ing through to the storefront — the
426
+ // shop would silently serve a blank redirect as its front door.
427
+ await retire(root, at("pages/index.server.ts"), result);
428
+ await retire(root, at("pages/index.tsx"), result);
429
+ }
409
430
  await put(root, at("pages/api-dashboard/index.tsx"), renderDashboardPage(manifest), result, gen);
410
431
  await put(root, at("pages/api-dashboard/index.server.ts"), CLIENT_ONLY, result, gen);
411
432
  await put(root, at("pages/sign-in/index.tsx"), renderSignInPage(manifest), result, gen);
@@ -11,8 +11,8 @@ import { type Layout, type Manifest, has } from "../manifest";
11
11
 
12
12
  export function renderDomainTs(layout: Layout): string {
13
13
  const derive =
14
- layout === "app"
15
- ? ` // One app: the worker IS the site. No separate API host, and no other
14
+ layout !== "monorepo"
15
+ ? ` // One origin: the worker IS the site. No separate API host, and no other
16
16
  // origin to trust. It answers on www too, but only at a zone's apex —
17
17
  // www.shop.example.com is not a name anybody types.
18
18
  const hosts = apex ? [host, www] : [host];
@@ -24,7 +24,7 @@ export function renderDomainTs(layout: Layout): string {
24
24
  return `/**
25
25
  * The shop's domain, and everything derived from it.
26
26
  *
27
- * One variable. \`SHOP_DOMAIN\` gives ${layout === "app" ? "the worker's hostnames" : "api.<domain> for the worker and the\n * storefront's origins"}, the app's public origin, and the
27
+ * One variable. \`SHOP_DOMAIN\` gives ${layout !== "monorepo" ? "the worker's hostnames" : "api.<domain> for the worker and the\n * storefront's origins"}, the app's public origin, and the
28
28
  * CORS and CSRF allow-list. They were four settings that had to agree and
29
29
  * nothing checked that they did.
30
30
  *
@@ -67,11 +67,13 @@ export const GITHUB_PAGES_AAAA = ["2606:50c0:8000::153", "2606:50c0:8001::153",
67
67
  * The storefront's records. DNS-only, never proxied: GitHub issues the
68
68
  * certificate itself and validates by reaching the origin. The API hostname
69
69
  * is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${
70
- layout === "app" ? "\n * One app has no storefront elsewhere, so this is always empty." : ""
70
+ layout !== "monorepo" ? "\n * One worker serves the storefront, so this is always empty." : ""
71
71
  }
72
72
  */
73
73
  export function storefrontRecords(domain: ShopDomain, pagesHost: string) {
74
- if (!pagesHost${layout === "app" ? " || domain.frontendOrigins.length === 0" : ""}) return [];
74
+ // Empty whenever one worker serves the storefront: Cloudflare creates the
75
+ // hostname's record and certificate itself for a custom domain.
76
+ if (!pagesHost || domain.frontendOrigins.length === 0) return [];
75
77
  // The record's name is relative to the zone: "@" at the apex, otherwise the
76
78
  // labels in front of it.
77
79
  const name = domain.apex ? "@" : domain.host.slice(0, -(domain.zone.length + 1));
@@ -272,13 +274,22 @@ export async function destinationFor(orderId: string): Promise<string | null> {
272
274
  * deploy, so both are marked rather than left blank.
273
275
  */
274
276
  export function renderShippingTs(manifest: Manifest): string {
275
- return `import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";
277
+ const royalMail = has(manifest, "royal-mail");
278
+ // Royal Mail posts from the address registered on the Click & Drop account,
279
+ // 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));
276
281
 
282
+ const imports = [
283
+ ...(origin ? [`import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";`] : []),
284
+ ...(royalMail ? [`import type { RoyalMailService } from "@saastemly/better-commerce/providers/royal-mail";`] : []),
285
+ ];
286
+
287
+ const originBlock = `
277
288
  /**
278
289
  * The address every parcel is quoted and shipped FROM.
279
290
  *
280
- * TODO VERIFY: this is a placeholder. A carrier quotes against it, and DHL
281
- * refuses a shipment whose origin has no \`name\` and \`phone\`.
291
+ * TODO VERIFY: this is a placeholder. A carrier quotes against it, and most
292
+ * refuse a shipment whose origin has no \`name\` and \`phone\`.
282
293
  */
283
294
  export const shipFrom: DeliveryAddress = {
284
295
  name: "${manifest.shop.name}",
@@ -289,6 +300,8 @@ export const shipFrom: DeliveryAddress = {
289
300
  phone: "TODO VERIFY: +44…",
290
301
  };
291
302
  `;
303
+
304
+ return `${imports.join("\n")}\n${origin ? originBlock : ""}${royalMail ? ROYAL_MAIL_SERVICES() : ""}`;
292
305
  }
293
306
 
294
307
  export function renderErpTs(): string {
@@ -318,3 +331,56 @@ export const erpFulfillment = (currency: string): FulfillmentProvider =>
318
331
  }).fulfillment();
319
332
  `;
320
333
  }
334
+
335
+
336
+ /**
337
+ * Royal Mail's price table — YOUR prices, because nothing will quote them.
338
+ *
339
+ * This is the one carrier here that cannot be asked what postage costs. No
340
+ * Royal Mail API quotes rates: not Click & Drop, not Shipping V3, not
341
+ * anything on the developer portal. Click & Drop imports orders and prints
342
+ * labels; postage is charged afterwards at whatever your OBA agreement says.
343
+ *
344
+ * So the shop quotes from this table, and the table has to match the rate
345
+ * card on your account or you will undercharge every order and find out at
346
+ * the invoice. The codes below are real Royal Mail service codes; the prices
347
+ * are NOT — they are placeholders, in pence.
348
+ */
349
+ const ROYAL_MAIL_SERVICES = (): string => `
350
+ /**
351
+ * What this shop charges for Royal Mail, by total parcel weight.
352
+ *
353
+ * TODO VERIFY: every \`amount\` below is a placeholder in pence. Replace them
354
+ * with your own OBA rate card — Click & Drop → Settings → Shipping services
355
+ * lists exactly which services your account has, and the codes here must be
356
+ * among them. Service codes are account-specific: the published list is from
357
+ * 2021 and Royal Mail warn that "available services are unique to your OBA
358
+ * account and individual service agreements".
359
+ *
360
+ * The band whose \`maxGrams\` first covers the parcel wins, so order does not
361
+ * matter. A parcel heavier than every band gets no rate at all rather than a
362
+ * wrong one, and Click & Drop refuses anything over 30 kg.
363
+ */
364
+ export const royalMailServices: RoyalMailService[] = [
365
+ {
366
+ code: "TPN24",
367
+ name: "Tracked 24",
368
+ estimatedDays: 1,
369
+ bands: [
370
+ { maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
371
+ { maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
372
+ { maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
373
+ ],
374
+ },
375
+ {
376
+ code: "TPS48",
377
+ name: "Tracked 48",
378
+ estimatedDays: 2,
379
+ bands: [
380
+ { maxGrams: 1000, amount: 0 /* TODO VERIFY */ },
381
+ { maxGrams: 2000, amount: 0 /* TODO VERIFY */ },
382
+ { maxGrams: 5000, amount: 0 /* TODO VERIFY */ },
383
+ ],
384
+ },
385
+ ];
386
+ `;
package/src/init.ts CHANGED
@@ -94,7 +94,7 @@ export async function init(args: string[]): Promise<number> {
94
94
  } else {
95
95
  const parts: Array<[dir: string, starter: string]> = [
96
96
  ["api", "the D1 starter — this is the worker"],
97
- ["frontend", "Static Pagesthis is prerendered for GitHub Pages"],
97
+ ["frontend", "the storefrontprerendered and served from the worker's own assets"],
98
98
  ];
99
99
  for (const [dir, starter] of parts) {
100
100
  const target = join(root, dir);
package/src/manifest.ts CHANGED
@@ -30,9 +30,10 @@ export const MANIFEST_FILE = "voidcommerce.json";
30
30
  * strict EXPERIMENTAL. A monorepo turned inside out. The REPOSITORY
31
31
  * ROOT is the storefront — its package.json, its pages, yours to
32
32
  * edit — and the worker is generated underneath it at .vc/app
33
- * from the manifest, gitignored, never hand-edited. Hosted the
34
- * way a monorepo is: the worker on api.<domain>, the storefront
35
- * on <domain>.
33
+ * from the manifest, gitignored, never hand-edited. ONE worker
34
+ * serves both at <domain>: the storefront's prerendered tree is
35
+ * folded into the worker's assets at build time, so there is one
36
+ * origin, one deploy, and one certificate Cloudflare issues.
36
37
  *
37
38
  * The first question, because it decides where `void init` runs and what
38
39
  * the domain means. It cannot change after init: moving files is not a
@@ -54,7 +55,7 @@ export const LAYOUTS: Array<{ id: Layout; label: string; hint: string }> = [
54
55
  {
55
56
  id: "strict",
56
57
  label: "Strict (experimental)",
57
- hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app from the manifest and never hand-edited",
58
+ hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app and serves the storefront from its own assets — one origin, one deploy",
58
59
  },
59
60
  ];
60
61
 
@@ -68,9 +69,28 @@ export const LAYOUTS: Array<{ id: Layout; label: string; hint: string }> = [
68
69
  */
69
70
  export const isSingleApp = (layout: Layout): boolean => layout === "app";
70
71
 
71
- /** Does this layout carry a storefront of its own, and so a second origin to trust? */
72
+ /** Does this layout carry a storefront of its own, in the repository? */
72
73
  export const hasFrontend = (layout: Layout): boolean => layout !== "app";
73
74
 
75
+ /**
76
+ * Does ONE worker serve both the storefront and the API?
77
+ *
78
+ * `app` always did — it generates its storefront into the worker. `strict`
79
+ * now does too: the root's prerendered tree is merged into the worker's own
80
+ * assets directory at build time, so there is one origin, one hostname, one
81
+ * deploy, and one certificate that Cloudflare issues itself.
82
+ *
83
+ * That deletes the whole GitHub Pages half — the A/AAAA records, the CNAME
84
+ * file, the base-path rewriting — and with it a trap worth naming: GitHub
85
+ * treats any Cloudflare IP as ineligible for a certificate, so a proxied
86
+ * record silently stops issuance AND renewal about ninety days later, while
87
+ * GitHub's own health check still reports green.
88
+ *
89
+ * `monorepo` keeps the split, because there the frontend is a separate app
90
+ * that a person may well want hosted separately.
91
+ */
92
+ export const oneOrigin = (layout: Layout): boolean => layout !== "monorepo";
93
+
74
94
  /**
75
95
  * Public suffixes whose second label is not a registrable name, so
76
96
  * `shop.example.co.uk` is a subdomain of `example.co.uk`, not of `co.uk`.
@@ -100,7 +120,9 @@ export function isApex(manifest: Manifest): boolean {
100
120
  /** Every hostname the worker answers on — the wrangler routes, in order. */
101
121
  export function workerHosts(manifest: Manifest): string[] {
102
122
  const { domain } = manifest.shop;
103
- if (!isSingleApp(manifest.layout)) return [`api.${domain}`];
123
+ // Only a monorepo puts the worker on its own hostname; everything else
124
+ // serves the shop and its API from one origin.
125
+ if (!oneOrigin(manifest.layout)) return [`api.${domain}`];
104
126
  return isApex(manifest) ? [domain, `www.${domain}`] : [domain];
105
127
  }
106
128
 
@@ -145,7 +167,10 @@ export interface Manifest {
145
167
  * default that is safe to assume.
146
168
  */
147
169
  taxRegistered?: boolean | undefined;
148
- /** `saastemly.github.io` — the monorepo's frontend lives on GitHub Pages; the www record points here. */
170
+ /**
171
+ * `saastemly.github.io` — only a MONOREPO has a storefront on GitHub
172
+ * Pages. In `app` and `strict` the worker serves it, so this is unused.
173
+ */
149
174
  pagesHost?: string | undefined;
150
175
  };
151
176
  /** Chosen ids from each group, keyed by group id. */
@@ -296,7 +321,7 @@ export function validate(manifest: Manifest): string[] {
296
321
  `DNS at Simply.com cannot host ${workerHosts(manifest)[0]} — a Worker custom domain is created by Cloudflare, in a zone on Cloudflare`,
297
322
  );
298
323
  }
299
- if (hasFrontend(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
324
+ if (!oneOrigin(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
300
325
  problems.push(`Pages host "${manifest.shop.pagesHost}" should be <owner>.github.io`);
301
326
  }
302
327
  return problems;
package/src/wizard.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import color from "picocolors";
3
3
  import { GROUPS, type Group } from "./catalog";
4
- import { LAYOUTS, type Layout, type Manifest, zoneOf } from "./manifest";
4
+ import { LAYOUTS, type Layout, type Manifest, zoneOf , oneOrigin} from "./manifest";
5
5
 
6
6
  /**
7
7
  * The forms.
@@ -98,6 +98,10 @@ async function askGroup(group: Group, previous: string[] | undefined, alsoRequir
98
98
  export async function runWizard(existing: Manifest | null, layout: Layout): Promise<Manifest> {
99
99
  const monorepo = layout === "monorepo";
100
100
  const frontend = layout !== "app";
101
+ // Only a monorepo publishes its storefront to GitHub Pages. In `strict`
102
+ // the worker serves it from its own assets, so there is no Pages host to
103
+ // ask for — and asking would imply a second origin that does not exist.
104
+ const onPages = !oneOrigin(layout);
101
105
 
102
106
  const shop = unwrap(
103
107
  await p.group(
@@ -171,7 +175,7 @@ export async function runWizard(existing: Manifest | null, layout: Layout): Prom
171
175
  initialValue: existing?.shop.locale ?? "",
172
176
  validate: (value) => (/^[a-z]{2}(-[A-Z]{2})?$/.test(value.trim()) ? undefined : "like da, or en-GB."),
173
177
  }),
174
- ...(frontend
178
+ ...(onPages
175
179
  ? {
176
180
  pagesHost: () =>
177
181
  p.text({
@@ -211,7 +215,7 @@ export async function runWizard(existing: Manifest | null, layout: Layout): Prom
211
215
  locale: shop.locale.trim(),
212
216
  taxRegistered: (shop as { taxRegistered?: string }).taxRegistered === "yes",
213
217
  zone: (shop as { zone?: string }).zone?.trim().toLowerCase() || undefined,
214
- pagesHost: frontend ? (shop as { pagesHost?: string }).pagesHost?.trim().toLowerCase() || undefined : undefined,
218
+ pagesHost: onPages ? (shop as { pagesHost?: string }).pagesHost?.trim().toLowerCase() || undefined : undefined,
215
219
  },
216
220
  chosen,
217
221
  };