@saastemly/voidcommerce 0.4.0 → 0.6.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.
@@ -1,23 +1,33 @@
1
+ import {
2
+ PRIVATE_KEY_VAR,
3
+ SECRETS_FILE,
4
+ allEnvKeys,
5
+ declaredSecretNames,
6
+ decryptSecrets,
7
+ plaintextSecretNames,
8
+ renderEnvExample,
9
+ renderEnvLocal,
10
+ renderEnvProduction,
11
+ renderEnvTs
12
+ } from "./index-2qt2yh3z.js";
1
13
  import {
2
14
  MANIFEST_FILE,
3
- envKeysOf,
4
15
  has,
5
16
  hasFrontend,
6
- isApex,
7
17
  isSingleApp,
18
+ oneOrigin,
8
19
  packagesOf,
9
20
  readManifest,
10
21
  specifier,
11
22
  workerHosts,
12
23
  writeManifest,
13
24
  zone
14
- } from "./index-pz6m2hkm.js";
25
+ } from "./index-xx5p4b8d.js";
15
26
  import {
16
27
  CHOICES
17
28
  } from "./index-844b3qn9.js";
18
29
  import {
19
- __require,
20
- __toESM
30
+ __require
21
31
  } from "./index-0v6na3yp.js";
22
32
 
23
33
  // src/generate/auth.ts
@@ -350,150 +360,26 @@ ${render("commerce").replace(/^/gm, "\t")}
350
360
  }
351
361
  var camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
352
362
 
353
- // src/generate/env.ts
354
- var BASE = [
355
- {
356
- key: "SHOP_DOMAIN",
357
- breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
358
- plaintext: true
359
- },
360
- {
361
- key: "COMMERCE_CRON_SECRET",
362
- breaks: "the scheduler cannot authenticate, so every background job stops",
363
- where: "openssl rand -base64 32"
364
- },
365
- {
366
- key: "COMMERCE_WEBHOOK_SECRET",
367
- breaks: "payment webhooks cannot be verified, so no order ever becomes paid",
368
- where: "the payment provider's dashboard"
369
- },
370
- {
371
- key: "SHIPPING_DOMESTIC_MINOR",
372
- breaks: "delivery is quoted at nothing, and every order loses the carrier cost",
373
- plaintext: true,
374
- dev: "4900",
375
- where: "your carrier agreement, in minor units ex VAT"
376
- },
377
- {
378
- key: "SHIPPING_FREE_FROM_MINOR",
379
- breaks: "the free-delivery threshold is undefined",
380
- plaintext: true,
381
- dev: "100000"
382
- }
383
- ];
384
- function allEnvKeys(manifest) {
385
- const keys = [...BASE, ...envKeysOf(manifest)];
386
- if (!isApex(manifest)) {
387
- keys.splice(1, 0, {
388
- key: "SHOP_ZONE",
389
- breaks: "DNS records are written to the wrong zone, or to none",
390
- plaintext: true
391
- });
392
- }
393
- if (hasFrontend(manifest.layout) && manifest.shop.pagesHost) {
394
- keys.splice(1, 0, {
395
- key: "GITHUB_PAGES_HOST",
396
- breaks: "the www record has nothing to point at",
397
- plaintext: true
398
- });
399
- }
400
- return keys;
401
- }
402
- var NUMERIC = new Set(["SHIPPING_DOMESTIC_MINOR", "SHIPPING_FREE_FROM_MINOR"]);
403
- function renderEnvTs(manifest) {
404
- const keys = allEnvKeys(manifest);
405
- const lines = keys.map((key) => {
406
- const helper = NUMERIC.has(key.key) ? "number()" : "string()";
407
- const doc = [` /** ${key.breaks}${key.where ? ` — from: ${key.where}` : ""} */`];
408
- return `${doc.join(`
409
- `)}
410
- ${key.key}: ${helper},`;
411
- });
412
- return `import { defineEnv, number, string } from "void/env";
363
+ // src/generate/frontend.ts
364
+ var FRONTEND_OUT = "dist/client";
365
+ var FRONTEND_BRANCH = "frontend-static";
366
+ function renderFrontendApiTs(manifest) {
367
+ if (oneOrigin(manifest.layout)) {
368
+ return `import { createAuthClient } from "better-auth/react";
369
+ import { commerceClient } from "@saastemly/better-commerce/client";
413
370
 
414
371
  /**
415
- * Every env key the app reads. All of them are REQUIRED.
416
- *
417
- * Generated by \`vc init\` from voidcommerce.json — edit the manifest and
418
- * regenerate rather than editing this by hand.
372
+ * The one client the storefront talks through.
419
373
  *
420
- * Nothing is optional: an integration this shop supports is one the
421
- * deployment sets up. No key has a default, because a default is compiled
422
- * into the worker's vars and shadows the real secret. Locally, the literal
423
- * value \`unset\` is the one documented way to say "I do not have this yet",
424
- * and \`vc preflight\` refuses it.
374
+ * SAME ORIGIN. One worker serves this page and the API it calls, so there is
375
+ * no address to configure, nothing to bake at build time, and nothing that
376
+ * can point at the wrong place. Cookies are first-party, so no SameSite=None
377
+ * and no CORS preflight on any request.
425
378
  */
426
- export default defineEnv({
427
- ${lines.join(`
428
- `)}
429
- });
379
+ export const API_ORIGIN = "";
380
+ export const api = createAuthClient({ plugins: [commerceClient()] });
430
381
  `;
431
- }
432
- function renderEnvExample(manifest) {
433
- const keys = allEnvKeys(manifest);
434
- return [
435
- "# Every key is required. Copy to .env for local development.",
436
- "# `unset` is the one value that reads as absent — for a credential you do not have yet.",
437
- "",
438
- ...keys.map((key) => `${key.key}=${key.dev ?? (key.plaintext ? "" : "unset")}`),
439
- ""
440
- ].join(`
441
- `);
442
- }
443
- function renderEnvLocal(manifest) {
444
- const keys = allEnvKeys(manifest);
445
- const local = {
446
- SHOP_DOMAIN: `${manifest.shop.domain.split(".")[0]}.test`,
447
- GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
448
- SHOP_ZONE: zone(manifest),
449
- COMMERCE_CRON_SECRET: "dev-cron-secret-not-for-production",
450
- COMMERCE_WEBHOOK_SECRET: "whsec_dev_secret",
451
- SYSTEM_API_KEY: "dev-system-key-0123456789abcdefghijklmnopqrstuvwxyz",
452
- EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`
453
- };
454
- return [
455
- "# Local development. Gitignored; .env.example documents every key.",
456
- "",
457
- ...keys.map((key) => `${key.key}=${local[key.key] ?? key.dev ?? "unset"}`),
458
- ...hasFrontend(manifest.layout) ? ["", "# The storefront, reaching the worker. Start the worker first.", "VITE_API_ORIGIN=http://localhost:5173"] : [],
459
- ""
460
- ].join(`
461
- `);
462
- }
463
- function renderEnvProduction(manifest) {
464
- const keys = allEnvKeys(manifest).filter((key) => key.plaintext);
465
- const values = {
466
- SHOP_DOMAIN: manifest.shop.domain,
467
- GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
468
- SHOP_ZONE: zone(manifest),
469
- EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`,
470
- ADYEN_ENVIRONMENT: "live",
471
- BC_ENVIRONMENT: "production"
472
- };
473
- return [
474
- "# Production values that are NOT secrets.",
475
- "#",
476
- "# On `void deploy --backend cloudflare` every .env* file is baked into the",
477
- "# worker's vars as PLAINTEXT, so this holds only values safe to commit. Every",
478
- "# credential is `wrangler secret put <NAME>` instead.",
479
- "",
480
- ...keys.map((key) => `${key.key}=${values[key.key] ?? key.dev ?? ""}`),
481
- ""
482
- ].join(`
483
- `);
484
- }
485
- function envSummary(manifest) {
486
- const keys = allEnvKeys(manifest);
487
- return {
488
- secrets: keys.filter((key) => !key.plaintext).map((key) => key.key),
489
- plaintext: keys.filter((key) => key.plaintext).map((key) => key.key)
490
- };
491
- }
492
-
493
- // src/generate/frontend.ts
494
- var FRONTEND_OUT = "dist/client";
495
- var FRONTEND_BRANCH = "frontend-static";
496
- function renderFrontendApiTs(manifest) {
382
+ }
497
383
  return `/// <reference types="vite/client" />
498
384
  import { createAuthClient } from "better-auth/react";
499
385
  import { commerceClient } from "@saastemly/better-commerce/client";
@@ -514,6 +400,13 @@ export const api = createAuthClient({ baseURL: API_ORIGIN, plugins: [commerceCli
514
400
  `;
515
401
  }
516
402
  function renderFrontendEnvProduction(manifest) {
403
+ if (oneOrigin(manifest.layout)) {
404
+ return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
405
+ #
406
+ # The API is same-origin, so there is no VITE_API_ORIGIN: one worker serves
407
+ # this storefront and the endpoints it calls.
408
+ `;
409
+ }
517
410
  return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
518
411
  VITE_API_ORIGIN=https://api.${manifest.shop.domain}
519
412
  `;
@@ -637,9 +530,8 @@ function renderFrontendWorkflow(manifest, dir) {
637
530
  # Generated by \`vc init\` from voidcommerce.json.
638
531
  #
639
532
  # Builds the storefront and force-pushes the prerendered tree to
640
- # \`${FRONTEND_BRANCH}\`. Point GitHub Pages (or Cloudflare Pages) at that branch
641
- # and a push here is a live storefront. The worker deploys separately, from
642
- # \`void-dist\`.
533
+ # \`${FRONTEND_BRANCH}\`, which GitHub Pages serves. The worker deploys
534
+ # separately, from deploy.yml.
643
535
  on:
644
536
  push:
645
537
  branches: [main, master]
@@ -1441,7 +1333,7 @@ import color from "picocolors";
1441
1333
  // package.json
1442
1334
  var package_default = {
1443
1335
  name: "@saastemly/voidcommerce",
1444
- version: "0.4.0",
1336
+ version: "0.6.0",
1445
1337
  description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
1446
1338
  type: "module",
1447
1339
  license: "MIT",
@@ -1639,7 +1531,7 @@ import { existsSync as existsSync5 } from "node:fs";
1639
1531
  import { dirname as dirname3, join as join5 } from "node:path";
1640
1532
 
1641
1533
  // src/generate/index.ts
1642
- import { cp as cp2, mkdir as mkdir2, readFile as readFile2, readdir as readdir2, symlink, writeFile as writeFile2 } from "node:fs/promises";
1534
+ import { cp as cp2, mkdir as mkdir2, readFile as readFile2, readdir as readdir2, rm as rm2, symlink, writeFile as writeFile2 } from "node:fs/promises";
1643
1535
  import { existsSync as existsSync4, lstatSync } from "node:fs";
1644
1536
  import { dirname as dirname2, join as join4 } from "node:path";
1645
1537
 
@@ -1770,36 +1662,38 @@ async function distHelp() {
1770
1662
 
1771
1663
  // src/generate/ci.ts
1772
1664
  function renderDistWorkflow(manifest) {
1773
- const worker = manifest.shop.domain.split(".")[0];
1774
- return `name: Build the deployable app
1665
+ return `name: Deploy the shop
1775
1666
 
1776
1667
  # Generated by \`vc init\` from voidcommerce.json.
1777
1668
  #
1778
- # Every push to main regenerates the Void app from the manifest and force-pushes
1779
- # it to \`${DIST_BRANCH}\`. Cloudflare's Workers Builds watches THAT branch and
1780
- # deploys it, so a push here is a deploy — and no Cloudflare credential is
1781
- # stored in GitHub.
1669
+ # A push to main regenerates the Void app from the manifest, publishes it to
1670
+ # \`${DIST_BRANCH}\` as a standalone tree, and deploys it to Cloudflare.
1671
+ #
1672
+ # The credentials come from the repository, set once by \`vc link\`:
1782
1673
  #
1783
- # The Cloudflare side is configured once, in the dashboard, for the Worker
1784
- # named \`${worker}\` (Settings → Builds). DEPLOY.md has the settings and the
1785
- # one non-obvious part: the build's API token needs D1:Edit, which the token
1786
- # Cloudflare generates for you does not have.
1674
+ # secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
1675
+ # secrets.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE}
1676
+ # vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
1677
+ #
1678
+ # Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
1679
+ # on your machine. See DEPLOY.md.
1787
1680
  on:
1788
1681
  push:
1789
1682
  branches: [main, master]
1790
1683
  workflow_dispatch:
1791
1684
 
1792
- # A later push wins: an older tree must never overwrite a newer one.
1685
+ # A later push wins: an older tree must never overwrite a newer one, and two
1686
+ # deploys must never race for the same worker.
1793
1687
  concurrency:
1794
- group: ${DIST_BRANCH}-\${{ github.repository }}
1688
+ group: deploy-\${{ github.repository }}
1795
1689
  cancel-in-progress: true
1796
1690
 
1797
- # contents: write is what lets GITHUB_TOKEN force-push the branch. Nothing else.
1798
1691
  permissions:
1799
1692
  contents: write
1800
1693
 
1801
1694
  jobs:
1802
- build:
1695
+ # ── Regenerate, and publish the standalone tree ────────────────────────
1696
+ dist:
1803
1697
  runs-on: ubuntu-latest
1804
1698
  steps:
1805
1699
  - uses: actions/checkout@v6
@@ -1840,6 +1734,50 @@ jobs:
1840
1734
  git add -A
1841
1735
  git commit -q -m "\${{ github.sha }} — ${manifest.shop.domain}"
1842
1736
  git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${DIST_BRANCH}
1737
+
1738
+ # ── Deploy it ──────────────────────────────────────────────────────────
1739
+ deploy:
1740
+ needs: dist
1741
+ runs-on: ubuntu-latest
1742
+ environment:
1743
+ name: production
1744
+ url: https://${manifest.shop.domain}
1745
+ env:
1746
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
1747
+ CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
1748
+ ${PRIVATE_KEY_VAR}: \${{ secrets.${PRIVATE_KEY_VAR} }}
1749
+ steps:
1750
+ - uses: actions/checkout@v6
1751
+ - uses: oven-sh/setup-bun@v2
1752
+
1753
+ # Checked before anything is built, so a repository that was never
1754
+ # linked says so in five seconds rather than four minutes.
1755
+ - name: Are the credentials here?
1756
+ run: |
1757
+ set -euo pipefail
1758
+ missing=""
1759
+ [ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
1760
+ [ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
1761
+ if [ -n "$missing" ]; then
1762
+ echo "::error::this repository has no$missing. Run 'vc link' once, from a checkout."
1763
+ exit 1
1764
+ fi
1765
+
1766
+ - run: bun install --frozen-lockfile
1767
+
1768
+ # One command: preflight, provision D1 and the queue if they are not
1769
+ # there, build, strip the values Void bakes into the worker's plaintext
1770
+ # vars, apply the committed migrations, and deploy with the decrypted
1771
+ # secrets attached to the version. Provisioning is idempotent — it looks
1772
+ # a resource up by name before creating it — so this is safe every run.
1773
+ - name: vc deploy --cloudflare --provision
1774
+ run: bunx vc deploy --cloudflare --provision
1775
+
1776
+ # An upsert, so it is safe on every deploy and costs one pass when
1777
+ # nothing in data/ has changed. A failure here does not un-deploy a
1778
+ # working shop, so it warns rather than failing the run.
1779
+ - name: Push the catalogue
1780
+ run: bunx vc import || echo "::warning::the catalogue did not import; the shop is up but its products may be stale"
1843
1781
  `;
1844
1782
  }
1845
1783
  function renderDeployReadme(manifest, zone2, hosts) {
@@ -1849,132 +1787,125 @@ function renderDeployReadme(manifest, zone2, hosts) {
1849
1787
  \`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
1850
1788
  regenerate with \`vc generate\`.
1851
1789
 
1852
- ## The loop, once it is set up
1853
-
1854
- Push to \`main\`. GitHub Actions regenerates the app from \`voidcommerce.json\`
1855
- and force-pushes it to \`${DIST_BRANCH}\`; Cloudflare's Workers Builds builds that
1856
- branch and deploys it. Nothing else runs.
1857
-
1858
- Your secrets survive every deploy — \`wrangler deploy\` never deletes a secret.
1859
- Plaintext \`vars\` are replaced from \`.env.production\` on each deploy, which is
1860
- why no credential is allowed in that file.
1861
-
1862
- ## Once, before the first push
1863
-
1864
- Each of these either creates something in your Cloudflare account or holds a
1865
- credential, so none of them can live in a repository.
1866
-
1867
- ### 1. The zone
1868
-
1869
- \`${zone2}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
1870
- creates in its own zone, so a domain hosted anywhere else cannot have one. The
1871
- worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
1872
-
1873
- ### 2. The database, the queue, and the account id
1790
+ ## The loop
1874
1791
 
1875
1792
  \`\`\`sh
1876
- vc deploy --cloudflare --provision
1793
+ git push
1877
1794
  \`\`\`
1878
1795
 
1879
- Idempotent: it creates nothing that already exists. It pins your account id,
1880
- creates the D1 database and the queue, and records both in \`wrangler.jsonc\`
1881
- and \`voidcommerce.json\` so every later generate carries the real ids.
1882
- **Commit that change.**
1796
+ That is the deploy. GitHub Actions regenerates the app from
1797
+ \`voidcommerce.json\`, publishes a standalone copy to \`${DIST_BRANCH}\`, creates the
1798
+ database and queue if they are not there, applies the migrations, and deploys
1799
+ the worker with this repository's secrets attached.
1883
1800
 
1884
- ### 3. The secrets
1801
+ **One worker serves everything.** The storefront is prerendered and folded
1802
+ into the worker's own static assets, so \`${manifest.shop.domain}\` answers with
1803
+ the shop, the admin panel and the API from a single origin. Cloudflare creates
1804
+ the hostname's DNS record and certificate itself, because it is a Worker
1805
+ custom domain. There is nothing to configure in any dashboard, no second
1806
+ branch to keep in step, and no CORS — the storefront's requests are
1807
+ first-party.
1885
1808
 
1886
- They live in the repository, encrypted:
1809
+ You do not need wrangler on your machine, and you do not need to be logged
1810
+ into it. You do not need to open the Cloudflare dashboard after the one step
1811
+ below.
1812
+
1813
+ ## Once, before the first push
1887
1814
 
1888
1815
  \`\`\`sh
1889
- vc secrets --init # every required key, as \`unset\`
1890
- # put the real values in, then
1891
- bunx dotenvx encrypt -f .env.secrets # ciphertext; commit this
1816
+ gh repo create --source=. --private --push # if there is no repo yet
1817
+ vc link
1892
1818
  \`\`\`
1893
1819
 
1894
- \`.env.secrets\` is committed and \`.env.keys\` is not. The deploy decrypts it and
1895
- hands the values to \`wrangler deploy --secrets-file\`, which stores them as
1896
- real Worker secrets — not as plaintext \`vars\`, which anyone with dashboard
1897
- access can read.
1898
-
1899
- That turns one \`wrangler secret put\` per value into one build variable, set
1900
- once in step 5. It also means the shop rebuilds from a checkout.
1820
+ \`vc link\` does three things and stores all of them on the GitHub repository:
1901
1821
 
1902
- **Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
1903
- leaks, every secret in the history is readable, including ones you rotated.
1904
- \`wrangler secret put\` does not have that property, and stays available for
1905
- anything you would rather never commit. \`vc preflight\` counts a secret the
1906
- repository declares as present, and refuses any value committed in the clear.
1822
+ | what | where | why |
1823
+ |---|---|---|
1824
+ | \`${PRIVATE_KEY_VAR}\` | repository **secret** | opens \`${SECRETS_FILE}\`. Generated by \`vc link\`, never written to disk |
1825
+ | \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
1826
+ | \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
1907
1827
 
1908
- ### 4. An API token the build can use
1828
+ It will ask you to paste a Cloudflare API token. That is the only manual step
1829
+ in the whole setup, and it is worth saying exactly why it cannot be removed:
1909
1830
 
1910
- This is the one non-obvious step. Cloudflare generates an API token for
1911
- Workers Builds automatically, and **that token has no D1 permission** its
1912
- scopes are Workers Scripts, KV, R2, Workers Routes and account/user reads. The
1913
- deploy command below applies database migrations, so it needs more.
1831
+ > **GitHub cannot mint a Cloudflare credential.** There is no OIDC or workload
1832
+ > identity federation between them the feature request has been open since
1833
+ > 2025 with no commitment, and Cloudflare's own CI guidance still says to store
1834
+ > a token in your CI provider's secrets. The Cloudflare GitHub App does not
1835
+ > help either: it grants *Cloudflare* access to your *repository*, not the
1836
+ > reverse. Something has to authorise creating a database in your account, and
1837
+ > only Cloudflare can issue that authorisation.
1914
1838
 
1915
- Create a token at **My Profile → API Tokens** with:
1839
+ Create the token at **My Profile → API Tokens → Create Token → Custom token**:
1916
1840
 
1917
- | scope | permission |
1918
- |---|---|
1919
- | Account | Workers Scripts: Edit |
1920
- | Account | D1: Edit |
1921
- | Account | Workers KV Storage: Edit, Workers R2 Storage: Edit |
1922
- | Account | Queues: Edit |
1923
- | Account | Account Settings: Read |
1924
- | Zone | Workers Routes: Edit (${zone2}) |
1925
- | User | User Details: Read, Memberships: Read |
1841
+ | scope | permission | for |
1842
+ |---|---|---|
1843
+ | Account | Workers Scripts: Edit | deploying the worker |
1844
+ | Account | D1: Edit | creating the database, applying migrations |
1845
+ | Account | Queues: Edit | the order queue |
1846
+ | Account | Workers KV Storage: Edit | sessions and caches |
1847
+ | Account | Workers R2 Storage: Edit | product images |
1848
+ | Account | Account Settings: Read | confirming which account |
1849
+ | Zone | Workers Routes: Edit (${zone2}) | answering on your domain |
1850
+ | User | User Details: Read, Memberships: Read | wrangler asks at startup |
1926
1851
 
1927
- Then select it in the build settings below. Use the same token for every
1928
- deploy of this Worker; permissions are whatever that token has.
1852
+ The token Cloudflare generates for its own Workers Builds will **not** do: it
1853
+ has no D1 and no Queues permission, so it cannot create this shop's database.
1929
1854
 
1930
- ### 5. Connect the repository
1855
+ ### The zone
1931
1856
 
1932
- In the Cloudflare dashboard, on the Worker named \`${worker}\`, under
1933
- **Settings Builds Connect**. The Worker's name must equal the \`name\` in
1934
- the \`wrangler.jsonc\` at the root directory, or the build fails.
1857
+ \`${zone2}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
1858
+ creates in its own zone, so a domain hosted anywhere else cannot have one. The
1859
+ worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
1935
1860
 
1936
- | setting | value |
1937
- |---|---|
1938
- | branch | \`${DIST_BRANCH}\` |
1939
- | root directory | \`/\` |
1940
- | build command | \`bun install && bunx void prepare && bunx vp build\` |
1941
- | deploy command | see below |
1942
- | build variable | \`DOTENV_PRIVATE_KEY_SECRETS\`, marked as a secret — the one value that is not in the repository |
1943
- | API token | the one from step 4 |
1861
+ ## Secrets
1944
1862
 
1945
- The deploy command, on one line:
1863
+ They live in the repository, encrypted, in \`${SECRETS_FILE}\`:
1946
1864
 
1947
1865
  \`\`\`sh
1948
- bunx dotenvx decrypt -f .env.secrets --stdout > .vc-secrets.env && bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/ssr/wrangler.json --secrets-file .vc-secrets.env
1866
+ vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
1867
+ vc secrets # what is set, what is missing
1949
1868
  \`\`\`
1950
1869
 
1951
- Plain \`sh\`, so it does not rely on process substitution. The decrypted file
1952
- exists only inside the build sandbox, and \`--secrets-file\` applies additively:
1953
- a secret it does not name is left alone rather than deleted.
1870
+ **This needs no credential.** dotenvx is asymmetric: encryption uses the public
1871
+ key committed at the top of the file, so anyone with a clone can set or rotate
1872
+ a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
1873
+ private key that lives in GitHub Actions.
1954
1874
 
1955
- The migration command names the **binding** (\`DB\`), not the database, so it
1956
- still points at the right database if the name ever differs.
1875
+ A pre-commit hook refuses a commit that would put a value in the clear, because
1876
+ that cannot be undone by a later commit — the value stays in the history.
1957
1877
 
1958
- Cloudflare's build image has Bun; pin its version with a \`BUN_VERSION\` build
1959
- variable if you ever need to. The free plan allows 3,000 build minutes a month
1960
- and one build at a time.
1878
+ **Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
1879
+ leaks, every secret in the history is readable, including ones you rotated.
1880
+ \`wrangler secret put\` does not have that property, and stays available for
1881
+ anything you would rather never commit at all.
1961
1882
 
1962
- ### 6. The catalogue
1883
+ ## The catalogue
1963
1884
 
1964
- \`vc import\` pushes \`data/\` into the running shop. It is an upsert, so it is
1965
- safe on every deploy and costs one pass when nothing changed.
1885
+ \`vc import\` pushes \`data/\` into the running shop, and the workflow runs it on
1886
+ every deploy. It is an upsert, so it costs one pass when nothing has changed.
1966
1887
 
1967
- ## Without Cloudflare's build
1888
+ ## Doing it by hand
1968
1889
 
1969
- The same thing by hand, from a checkout of either branch:
1890
+ The same thing, from a checkout, if CI is ever not the answer:
1970
1891
 
1971
1892
  \`\`\`sh
1972
- vc deploy --cloudflare
1893
+ export CLOUDFLARE_API_TOKEN=…
1894
+ export ${PRIVATE_KEY_VAR}=… # only if you kept a copy
1895
+ vc deploy --cloudflare --provision
1973
1896
  \`\`\`
1974
1897
 
1975
- which preflights, builds, strips the baked development values out of the
1976
- worker's vars, applies the remote migrations, and deploys exactly what it
1977
- verified.
1898
+ which preflights, provisions, builds, strips the baked development values out
1899
+ of the worker's vars, applies the remote migrations, and deploys exactly what
1900
+ it verified.
1901
+
1902
+ ## If you would rather use Cloudflare's own build
1903
+
1904
+ Point Workers Builds at the \`${DIST_BRANCH}\` branch — it is a plain Void app with
1905
+ a committed lockfile. The Worker must be named \`${worker}\`, matching the \`name\`
1906
+ in its \`wrangler.jsonc\`. You will need to set \`${PRIVATE_KEY_VAR}\` as a
1907
+ build secret in the dashboard, and replace the auto-generated API token with
1908
+ one that has D1 and Queues. That is the path \`vc link\` exists to avoid.
1978
1909
  `;
1979
1910
  }
1980
1911
 
@@ -2014,7 +1945,7 @@ export function check(present: Map<string, string | undefined>): Requirement[] {
2014
1945
 
2015
1946
  // src/generate/support.ts
2016
1947
  function renderDomainTs(layout) {
2017
- const derive = layout === "app" ? ` // One app: the worker IS the site. No separate API host, and no other
1948
+ const derive = layout !== "monorepo" ? ` // One origin: the worker IS the site. No separate API host, and no other
2018
1949
  // origin to trust. It answers on www too, but only at a zone's apex —
2019
1950
  // www.shop.example.com is not a name anybody types.
2020
1951
  const hosts = apex ? [host, www] : [host];
@@ -2025,7 +1956,7 @@ function renderDomainTs(layout) {
2025
1956
  return `/**
2026
1957
  * The shop's domain, and everything derived from it.
2027
1958
  *
2028
- * One variable. \`SHOP_DOMAIN\` gives ${layout === "app" ? "the worker's hostnames" : `api.<domain> for the worker and the
1959
+ * One variable. \`SHOP_DOMAIN\` gives ${layout !== "monorepo" ? "the worker's hostnames" : `api.<domain> for the worker and the
2029
1960
  * storefront's origins`}, the app's public origin, and the
2030
1961
  * CORS and CSRF allow-list. They were four settings that had to agree and
2031
1962
  * nothing checked that they did.
@@ -2068,11 +1999,13 @@ export const GITHUB_PAGES_AAAA = ["2606:50c0:8000::153", "2606:50c0:8001::153",
2068
1999
  /**
2069
2000
  * The storefront's records. DNS-only, never proxied: GitHub issues the
2070
2001
  * certificate itself and validates by reaching the origin. The API hostname
2071
- * is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${layout === "app" ? `
2072
- * One app has no storefront elsewhere, so this is always empty.` : ""}
2002
+ * is NOT here — it is a Cloudflare custom domain in wrangler.jsonc.${layout !== "monorepo" ? `
2003
+ * One worker serves the storefront, so this is always empty.` : ""}
2073
2004
  */
2074
2005
  export function storefrontRecords(domain: ShopDomain, pagesHost: string) {
2075
- if (!pagesHost${layout === "app" ? " || domain.frontendOrigins.length === 0" : ""}) return [];
2006
+ // Empty whenever one worker serves the storefront: Cloudflare creates the
2007
+ // hostname's record and certificate itself for a custom domain.
2008
+ if (!pagesHost || domain.frontendOrigins.length === 0) return [];
2076
2009
  // The record's name is relative to the zone: "@" at the apex, otherwise the
2077
2010
  // labels in front of it.
2078
2011
  const name = domain.apex ? "@" : domain.host.slice(0, -(domain.zone.length + 1));
@@ -2313,6 +2246,13 @@ async function put(root, file, content, result, mode) {
2313
2246
  await writeFile2(path, content, "utf8");
2314
2247
  result.written.push(file);
2315
2248
  }
2249
+ async function retire(root, file, result) {
2250
+ const path = join4(root, file);
2251
+ if (!await exists(path))
2252
+ return;
2253
+ await rm2(path, { force: true });
2254
+ result.retired.push(file);
2255
+ }
2316
2256
  async function mergeJson(root, file, fallback, mutate, result) {
2317
2257
  const path = join4(root, file);
2318
2258
  const json = await exists(path) ? JSON.parse(await readFile2(path, "utf8")) : { ...fallback };
@@ -2336,7 +2276,7 @@ async function link(root, file, target, result) {
2336
2276
  }
2337
2277
  var sorted = (record) => Object.fromEntries(Object.entries(record).sort());
2338
2278
  async function generate(root, manifest) {
2339
- const result = { written: [], kept: [], packages: packagesOf(manifest) };
2279
+ const result = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
2340
2280
  await writeManifest(root, manifest);
2341
2281
  result.written.push(MANIFEST_FILE);
2342
2282
  switch (manifest.layout) {
@@ -2431,7 +2371,11 @@ async function generateFrontend(root, dir, manifest, result) {
2431
2371
  await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
2432
2372
  await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
2433
2373
  await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
2434
- await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
2374
+ if (oneOrigin(manifest.layout)) {
2375
+ await retire(root, ".github/workflows/frontend-static.yml", result);
2376
+ } else {
2377
+ await put(root, ".github/workflows/frontend-static.yml", renderFrontendWorkflow(manifest, dir), result, "regenerate");
2378
+ }
2435
2379
  if (dir === "")
2436
2380
  return;
2437
2381
  await mergeJson(root, at("package.json"), { name: `${manifest.shop.domain.split(".")[0]}-frontend`, type: "module", private: true }, (pkg) => {
@@ -2505,7 +2449,8 @@ dist
2505
2449
  # because the value stays in the history.
2506
2450
  bunx vc guard
2507
2451
  `, result, "regenerate");
2508
- await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
2452
+ await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
2453
+ await retire(root, ".github/workflows/void-dist.yml", result);
2509
2454
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
2510
2455
  await put(root, ".env", renderEnvLocal(manifest), result, "own");
2511
2456
  await put(root, "data/README.md", DATA_README, result, "own");
@@ -2532,8 +2477,13 @@ async function generateStrictApp(root, dir, manifest, result) {
2532
2477
  await put(root, at("db/seed.ts"), renderDbSeed(), result, gen);
2533
2478
  await put(root, at("pages/layout.tsx"), renderLayoutTsx(), result, gen);
2534
2479
  await put(root, at("pages/app.css"), renderAppCss(), result, gen);
2535
- await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
2536
- await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
2480
+ if (!oneOrigin(manifest.layout) || isSingleApp(manifest.layout)) {
2481
+ await put(root, at("pages/index.server.ts"), renderIndexServer(), result, gen);
2482
+ await put(root, at("pages/index.tsx"), INDEX_PAGE, result, gen);
2483
+ } else {
2484
+ await retire(root, at("pages/index.server.ts"), result);
2485
+ await retire(root, at("pages/index.tsx"), result);
2486
+ }
2537
2487
  await put(root, at("pages/api-dashboard/index.tsx"), renderDashboardPage(manifest), result, gen);
2538
2488
  await put(root, at("pages/api-dashboard/index.server.ts"), CLIENT_ONLY, result, gen);
2539
2489
  await put(root, at("pages/sign-in/index.tsx"), renderSignInPage(manifest), result, gen);
@@ -2869,346 +2819,16 @@ ${ran.out.trim()}`);
2869
2819
  }
2870
2820
 
2871
2821
  // src/deploy/preflight.ts
2872
- import { existsSync as existsSync9, readFileSync as readFileSync4 } from "node:fs";
2873
- import { join as join9 } from "node:path";
2874
- import color5 from "picocolors";
2875
-
2876
- // src/deploy/secrets.ts
2877
- import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync } from "node:fs";
2878
- import { spawn as spawn3 } from "node:child_process";
2879
- import { delimiter as delimiter3, dirname as dirname5, join as join8 } from "node:path";
2880
- import { tmpdir } from "node:os";
2881
- import color4 from "picocolors";
2882
-
2883
- // src/deploy/keys.ts
2884
- import { hkdfSync } from "node:crypto";
2885
2822
  import { existsSync as existsSync7, readFileSync as readFileSync2 } from "node:fs";
2886
2823
  import { join as join7 } from "node:path";
2887
2824
  import color3 from "picocolors";
2888
- var INFO = "voidcommerce/dotenvx/secrets/v1";
2889
- function apiToken() {
2890
- return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
2891
- }
2892
- function derivePrivateKey(token, accountId) {
2893
- const bytes = hkdfSync("sha256", Buffer.from(token, "utf8"), Buffer.from(accountId, "utf8"), Buffer.from(INFO), 32);
2894
- return Buffer.from(bytes).toString("hex");
2895
- }
2896
- async function publicKeyFor(privateKey) {
2897
- try {
2898
- const { derive } = await import("./index-mpr7gm6k.js").then((m)=>__toESM(m.default,1));
2899
- return derive(privateKey);
2900
- } catch {
2901
- return null;
2902
- }
2903
- }
2904
- function committedPublicKey(root) {
2905
- const path = join7(root, SECRETS_FILE);
2906
- if (!existsSync7(path))
2907
- return null;
2908
- const match = /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=\s*["']?([0-9a-fA-F]+)["']?/m.exec(readFileSync2(path, "utf8"));
2909
- return match?.[1] ?? null;
2910
- }
2911
- async function keyFor(manifest, root) {
2912
- const token = apiToken();
2913
- if (!token) {
2914
- return {
2915
- ok: false,
2916
- reason: `CLOUDFLARE_API_TOKEN is not set.
2917
- The key is derived from it, so there is nothing to derive from. Note that
2918
- ` + " `wrangler login` does NOT set one — this scheme needs an API token, from\n" + " My Profile → API Tokens."
2919
- };
2920
- }
2921
- const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
2922
- if (!accountId) {
2923
- return {
2924
- ok: false,
2925
- reason: "the account is not pinned, and it salts the derivation.\n Run `vc deploy --cloudflare --provision` once, or set CLOUDFLARE_ACCOUNT_ID."
2926
- };
2927
- }
2928
- const privateKey = derivePrivateKey(token, accountId);
2929
- const publicKey = await publicKeyFor(privateKey);
2930
- if (!publicKey) {
2931
- return { ok: false, reason: "@dotenvx/dotenvx is not installed here, so a key cannot be derived. `bun add -d @dotenvx/dotenvx`" };
2932
- }
2933
- const committed = committedPublicKey(root);
2934
- if (!committed)
2935
- return { ok: true, privateKey, publicKey, fresh: true };
2936
- if (committed.toLowerCase() !== publicKey.toLowerCase()) {
2937
- return {
2938
- ok: false,
2939
- reason: `this token does not derive the key ${SECRETS_FILE} was encrypted under.
2940
-
2941
- encrypted under: ${committed.slice(0, 16)}…
2942
- ` + ` this token gives: ${publicKey.slice(0, 16)}…
2943
-
2944
- ` + ` That is one of three things, and all of them are the same fix:
2945
- ` + ` · the token was rotated since the secrets were encrypted
2946
- ` + ` · this is a different admin's token
2947
- ` + ` · CLOUDFLARE_ACCOUNT_ID is not the account they were encrypted for
2948
-
2949
- ` + ` If you still have the ORIGINAL token, \`vc keys rotate\` re-encrypts
2950
- everything under the new one. If you do not, the values are unrecoverable
2951
- and must be entered again.`
2952
- };
2953
- }
2954
- return { ok: true, privateKey, publicKey, fresh: false };
2955
- }
2956
- async function keysCommand(manifest, root, args) {
2957
- if (args.includes("--rotate"))
2958
- return rotate(manifest, root);
2959
- const state = await keyFor(manifest, root);
2960
- console.log(`
2961
- The key is DERIVED from CLOUDFLARE_API_TOKEN, salted with the account id.`);
2962
- console.log(color3.dim(`Nothing is stored, so nothing can leak — and nothing can be recovered.
2963
- `));
2964
- if (!state.ok) {
2965
- console.error(`${color3.red("✗")} ${state.reason}
2966
- `);
2967
- return 1;
2968
- }
2969
- console.log(` derives public key ${state.publicKey.slice(0, 20)}…`);
2970
- console.log(state.fresh ? ` ${color3.dim(`${SECRETS_FILE} does not exist yet, so there is nothing to check against`)}` : ` ${color3.green("✓")} matches what ${SECRETS_FILE} was encrypted under`);
2971
- console.log(color3.yellow(`
2972
- ! Rotating this API token makes every secret in ${SECRETS_FILE} unreadable.
2973
- Run \`vc keys --rotate\` with the NEW token exported and the old one in
2974
- CLOUDFLARE_API_TOKEN_OLD, BEFORE the old one stops working.
2975
- `));
2976
- return 0;
2977
- }
2978
- async function rotate(manifest, root) {
2979
- const oldToken = process.env["CLOUDFLARE_API_TOKEN_OLD"];
2980
- const newToken = apiToken();
2981
- if (!oldToken || !newToken) {
2982
- console.error(`
2983
- vc: rotation needs BOTH tokens:
2984
- CLOUDFLARE_API_TOKEN_OLD=<the one the secrets were encrypted under>
2985
- CLOUDFLARE_API_TOKEN=<the new one>
2986
-
2987
- The old one is the only thing that can read the current values.
2988
- `);
2989
- return 1;
2990
- }
2991
- const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
2992
- if (!accountId) {
2993
- console.error("vc: the account is not pinned, and it salts the derivation.");
2994
- return 1;
2995
- }
2996
- const oldKey = derivePrivateKey(oldToken, accountId);
2997
- const oldPublic = await publicKeyFor(oldKey);
2998
- const committed = committedPublicKey(root);
2999
- if (committed && oldPublic && committed.toLowerCase() !== oldPublic.toLowerCase()) {
3000
- console.error(`
3001
- vc: CLOUDFLARE_API_TOKEN_OLD does not derive the key ${SECRETS_FILE} was encrypted under either.
3002
- `);
3003
- return 1;
3004
- }
3005
- console.log(`
3006
- Rotation is a decrypt with the old key and an encrypt with the new one.
3007
- Run these two, in this order, from ${root}:
3008
-
3009
- ${color3.cyan(`DOTENV_PRIVATE_KEY_SECRETS=<old> bunx dotenvx decrypt -f ${SECRETS_FILE}`)}
3010
- ${color3.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)} ${color3.dim("# under the new derived key")}
3011
-
3012
- ` + color3.dim(`vc does not run them for you: the middle state is your secrets in
3013
- plaintext on disk, and that is a moment to be deliberate about.
3014
- `));
3015
- return 0;
3016
- }
3017
-
3018
- // src/deploy/secrets.ts
3019
- var SECRETS_FILE = ".env.secrets";
3020
- var PRIVATE_KEY_VAR = "DOTENV_PRIVATE_KEY_SECRETS";
3021
- function findDotenvx(from) {
3022
- let dir = from;
3023
- for (;; ) {
3024
- const local = join8(dir, "node_modules", ".bin", "dotenvx");
3025
- if (existsSync8(local))
3026
- return local;
3027
- const parent = dirname5(dir);
3028
- if (parent === dir)
3029
- break;
3030
- dir = parent;
3031
- }
3032
- return (process.env["PATH"] ?? "").split(delimiter3).filter(Boolean).some((entry) => existsSync8(join8(entry, "dotenvx"))) ? "dotenvx" : null;
3033
- }
3034
- function run(cmd, args, cwd, env = {}) {
3035
- return new Promise((resolve) => {
3036
- const child = spawn3(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
3037
- let out = "";
3038
- child.stdout.on("data", (chunk) => {
3039
- out += chunk;
3040
- });
3041
- child.stderr.on("data", (chunk) => {
3042
- out += chunk;
3043
- });
3044
- child.on("error", (error) => resolve({ code: 1, out: String(error) }));
3045
- child.on("exit", (code) => resolve({ code: code ?? 1, out }));
3046
- });
3047
- }
3048
- function declaredSecretNames(root) {
3049
- const path = join8(root, SECRETS_FILE);
3050
- if (!existsSync8(path))
3051
- return new Set;
3052
- const names = new Set;
3053
- for (const line2 of readFileSync3(path, "utf8").split(`
3054
- `)) {
3055
- const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line2);
3056
- if (match && !match[1].startsWith("DOTENV_"))
3057
- names.add(match[1]);
3058
- }
3059
- return names;
3060
- }
3061
- function plaintextSecretNames(root) {
3062
- const path = join8(root, SECRETS_FILE);
3063
- if (!existsSync8(path))
3064
- return [];
3065
- const bare = [];
3066
- for (const line2 of readFileSync3(path, "utf8").split(`
3067
- `)) {
3068
- const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line2);
3069
- if (!match || match[1].startsWith("DOTENV_"))
3070
- continue;
3071
- const value = match[2].trim().replace(/^['"]|['"]$/g, "");
3072
- if (value && value !== "unset" && !value.startsWith("encrypted:"))
3073
- bare.push(match[1]);
3074
- }
3075
- return bare;
3076
- }
3077
- async function decryptSecrets(project) {
3078
- const root = project.root;
3079
- if (!existsSync8(join8(root, SECRETS_FILE)))
3080
- return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
3081
- const dotenvx = findDotenvx(root);
3082
- if (!dotenvx)
3083
- return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
3084
- const key = await keyFor(project.manifest, root);
3085
- if (!key.ok)
3086
- return { error: key.reason };
3087
- const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root, {
3088
- [PRIVATE_KEY_VAR]: key.privateKey
3089
- });
3090
- if (result.code !== 0)
3091
- return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split(`
3092
- `).slice(-2).join(" ")}` };
3093
- const lines = result.out.split(`
3094
- `).filter((line2) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line2) && !line2.trimStart().startsWith("DOTENV_"));
3095
- const names = lines.map((line2) => line2.slice(0, line2.indexOf("=")).trim());
3096
- const path = join8(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
3097
- writeFileSync(path, `${lines.join(`
3098
- `)}
3099
- `, { mode: 384 });
3100
- return {
3101
- path,
3102
- names,
3103
- cleanup: () => {
3104
- try {
3105
- writeFileSync(path, "", { mode: 384 });
3106
- __require("node:fs").unlinkSync(path);
3107
- } catch {}
3108
- }
3109
- };
3110
- }
3111
- async function secretsCommand(project, args) {
3112
- const root = project.root;
3113
- const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
3114
- const declared = declaredSecretNames(root);
3115
- const bare = plaintextSecretNames(root);
3116
- if (args.includes("--init"))
3117
- return initSecrets(project);
3118
- if (!existsSync8(join8(root, SECRETS_FILE))) {
3119
- console.log(`
3120
- ${color4.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
3121
- ` + `which means the shop cannot be rebuilt from a checkout.
3122
-
3123
- ` + ` vc secrets --init write one, with every required key as \`unset\`
3124
- `);
3125
- return 1;
3126
- }
3127
- console.log(`
3128
- ${SECRETS_FILE} — committed, encrypted, and read by \`vc deploy --cloudflare\`
3129
- `);
3130
- for (const key of required) {
3131
- const state = declared.has(key.key) ? color4.green("declared") : color4.red("MISSING ");
3132
- console.log(` ${state} ${key.key}`);
3133
- }
3134
- const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
3135
- for (const name of extra)
3136
- console.log(` ${color4.dim("extra ")} ${name} ${color4.dim("— not required by this shop")}`);
3137
- if (bare.length > 0) {
3138
- console.log(`
3139
- ${color4.red("✗")} ${bare.length} value${bare.length === 1 ? " is" : "s are"} committed IN THE CLEAR: ${bare.join(", ")}
3140
- ` + ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` before committing again.
3141
- `);
3142
- return 1;
3143
- }
3144
- const missing = required.filter((key) => !declared.has(key.key));
3145
- if (missing.length > 0) {
3146
- console.log(`
3147
- ${color4.red("✗")} ${missing.length} required secret${missing.length === 1 ? "" : "s"} not declared.
3148
- ` + ` dotenvx set ${missing[0].key} '…' -f ${SECRETS_FILE}
3149
- `);
3150
- return 1;
3151
- }
3152
- console.log(`
3153
- ${color4.green("✓")} every secret this shop needs is declared and encrypted.
3154
- `);
3155
- return 0;
3156
- }
3157
- async function initSecrets(project) {
3158
- const path = join8(project.root, SECRETS_FILE);
3159
- if (existsSync8(path)) {
3160
- console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
3161
- return 1;
3162
- }
3163
- const key = await keyFor(project.manifest, project.root);
3164
- if (!key.ok) {
3165
- console.error(`
3166
- vc: ${key.reason}
3167
- `);
3168
- return 1;
3169
- }
3170
- const required = allEnvKeys(project.manifest).filter((key2) => !key2.plaintext);
3171
- const body = [
3172
- "# Secrets, encrypted, and COMMITTED.",
3173
- "#",
3174
- "# Values are ciphertext; the key names are readable so a diff shows WHICH",
3175
- "# secret changed without showing what it changed to. `unset` is the",
3176
- "# documented placeholder and preflight refuses it.",
3177
- "#",
3178
- "# The private key is DERIVED from CLOUDFLARE_API_TOKEN, salted with the",
3179
- "# account id — it is stored nowhere, so there is no .env.keys to leak.",
3180
- "#",
3181
- "# The cost of that: rotating the token makes every value below",
3182
- "# permanently unreadable. `vc keys --rotate` re-encrypts while you still",
3183
- "# have the old token; after that there is no recovery.",
3184
- "",
3185
- `DOTENV_PUBLIC_KEY_SECRETS="${key.publicKey}"`,
3186
- "",
3187
- ...required.flatMap((key2) => [`# ${key2.breaks}${key2.where ? ` — from: ${key2.where}` : ""}`, `${key2.key}=unset`, ""])
3188
- ].join(`
3189
- `);
3190
- writeFileSync(path, body, { mode: 384 });
3191
- console.log(`
3192
- ${color4.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`, under the key this token derives
3193
-
3194
- ` + `Next:
3195
- ` + ` 1. put the real values in, then
3196
- ` + ` 2. ${color4.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)}
3197
- ` + ` 3. commit it — there is no key file to keep out
3198
-
3199
- ` + color4.yellow(`! Rotating CLOUDFLARE_API_TOKEN makes these unreadable. \`vc keys\` explains.
3200
- `));
3201
- return 0;
3202
- }
3203
-
3204
- // src/deploy/preflight.ts
3205
2825
  var UNSET = "unset";
3206
2826
  function productionEnv(appDir) {
3207
2827
  const out = new Map;
3208
- const path = join9(appDir, ".env.production");
3209
- if (!existsSync9(path))
2828
+ const path = join7(appDir, ".env.production");
2829
+ if (!existsSync7(path))
3210
2830
  return out;
3211
- for (const line2 of readFileSync4(path, "utf8").split(`
2831
+ for (const line2 of readFileSync2(path, "utf8").split(`
3212
2832
  `)) {
3213
2833
  const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line2);
3214
2834
  if (!match)
@@ -3233,12 +2853,12 @@ async function voidSecretNames(appDir) {
3233
2853
  return names;
3234
2854
  }
3235
2855
  function routeProblem(project) {
3236
- const path = join9(project.appDir, "wrangler.jsonc");
3237
- if (!existsSync9(path))
2856
+ const path = join7(project.appDir, "wrangler.jsonc");
2857
+ if (!existsSync7(path))
3238
2858
  return "wrangler.jsonc is missing";
3239
2859
  let routes = [];
3240
2860
  try {
3241
- routes = parseJsonc(readFileSync4(path, "utf8")).routes ?? [];
2861
+ routes = parseJsonc(readFileSync2(path, "utf8")).routes ?? [];
3242
2862
  } catch {
3243
2863
  return "wrangler.jsonc cannot be parsed";
3244
2864
  }
@@ -3278,7 +2898,7 @@ async function preflight(project, source) {
3278
2898
  function printPreflight(project, result, source) {
3279
2899
  const keys = allEnvKeys(project.manifest);
3280
2900
  if (result.remote === null) {
3281
- console.log(color5.dim(source === "wrangler" ? `Could not read the worker's secrets — not deployed yet, or wrangler is not logged in.
2901
+ console.log(color3.dim(source === "wrangler" ? `Could not read the worker's secrets — not deployed yet, or wrangler is not logged in.
3282
2902
  Anything not in .env.production is reported as missing.
3283
2903
  ` : `Could not read the project's secrets from Void — not logged in, or no linked project.
3284
2904
  Anything not in .env.production is reported as missing.
@@ -3288,37 +2908,37 @@ Anything not in .env.production is reported as missing.
3288
2908
  for (const key of keys) {
3289
2909
  const value = result.present.get(key.key);
3290
2910
  const set = value !== undefined && value !== "" && value !== UNSET;
3291
- console.log(` ${set ? color5.green("set ") : color5.dim("unset")} ${key.key}${key.plaintext ? color5.dim(" (plaintext)") : ""}`);
2911
+ console.log(` ${set ? color3.green("set ") : color3.dim("unset")} ${key.key}${key.plaintext ? color3.dim(" (plaintext)") : ""}`);
3292
2912
  }
3293
2913
  if (result.ready) {
3294
2914
  console.log(`
3295
- ${color5.green("Ready to go live.")}
2915
+ ${color3.green("Ready to go live.")}
3296
2916
  `);
3297
2917
  return;
3298
2918
  }
3299
2919
  console.log(`
3300
- ${color5.red("NOT ready to go live.")}
2920
+ ${color3.red("NOT ready to go live.")}
3301
2921
  `);
3302
2922
  if (result.routeProblem) {
3303
- console.log(`${color5.red("✗")} the worker's hostname
2923
+ console.log(`${color3.red("✗")} the worker's hostname
3304
2924
  ${result.routeProblem}
3305
2925
  `);
3306
2926
  }
3307
2927
  if (result.bareSecrets.length > 0) {
3308
- console.log(`${color5.red("✗")} committed in the clear in ${SECRETS_FILE}: ${result.bareSecrets.join(", ")}
2928
+ console.log(`${color3.red("✗")} committed in the clear in ${SECRETS_FILE}: ${result.bareSecrets.join(", ")}
3309
2929
  ` + ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` — and treat those values as burned.
3310
2930
  `);
3311
2931
  }
3312
2932
  for (const key of result.missing) {
3313
- console.log(`${color5.red("✗")} ${key.key}`);
2933
+ console.log(`${color3.red("✗")} ${key.key}`);
3314
2934
  console.log(` ${key.breaks}`);
3315
2935
  if (key.where)
3316
- console.log(` ${color5.dim(`from: ${key.where}`)}`);
2936
+ console.log(` ${color3.dim(`from: ${key.where}`)}`);
3317
2937
  console.log("");
3318
2938
  }
3319
2939
  const secrets = result.missing.filter((key) => !key.plaintext);
3320
2940
  if (secrets.length > 0) {
3321
- const inRepo = existsSync9(join9(project.root, SECRETS_FILE));
2941
+ const inRepo = existsSync7(join7(project.root, SECRETS_FILE));
3322
2942
  console.log(inRepo ? `Put each in ${SECRETS_FILE}, which the deploy uploads:` : source === "wrangler" ? "Set each secret on the worker (this also creates the draft worker on a first deploy):" : "Set each secret on the project:");
3323
2943
  for (const key of secrets) {
3324
2944
  console.log(inRepo ? ` dotenvx set ${key.key} '…' -f ${SECRETS_FILE}` : ` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
@@ -3331,17 +2951,17 @@ ${color5.red("NOT ready to go live.")}
3331
2951
  }
3332
2952
 
3333
2953
  // src/deploy/cloudflare.ts
3334
- import { existsSync as existsSync10, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
3335
- import { dirname as dirname6, join as join10 } from "node:path";
3336
- import color6 from "picocolors";
2954
+ import { copyFileSync, existsSync as existsSync8, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "node:fs";
2955
+ import { dirname as dirname5, join as join8 } from "node:path";
2956
+ import color4 from "picocolors";
3337
2957
  var fail = (message) => {
3338
2958
  console.error(`
3339
- ${color6.red("✗")} ${message}
2959
+ ${color4.red("✗")} ${message}
3340
2960
  `);
3341
2961
  return 1;
3342
2962
  };
3343
2963
  function readConfig(path) {
3344
- return parseJsonc(readFileSync5(path, "utf8"));
2964
+ return parseJsonc(readFileSync3(path, "utf8"));
3345
2965
  }
3346
2966
  function findBuilder(from) {
3347
2967
  let dir = from;
@@ -3350,11 +2970,11 @@ function findBuilder(from) {
3350
2970
  ["vp", "vp build"],
3351
2971
  ["vite", "vite build"]
3352
2972
  ]) {
3353
- const path = join10(dir, "node_modules", ".bin", bin);
3354
- if (existsSync10(path))
2973
+ const path = join8(dir, "node_modules", ".bin", bin);
2974
+ if (existsSync8(path))
3355
2975
  return { cmd: path, label };
3356
2976
  }
3357
- const parent = dirname6(dir);
2977
+ const parent = dirname5(dir);
3358
2978
  if (parent === dir)
3359
2979
  return null;
3360
2980
  dir = parent;
@@ -3362,8 +2982,8 @@ function findBuilder(from) {
3362
2982
  }
3363
2983
  async function deployCloudflare(project, opts) {
3364
2984
  const app = project.appDir;
3365
- const configPath = join10(app, "wrangler.jsonc");
3366
- if (!existsSync10(configPath))
2985
+ const configPath = join8(app, "wrangler.jsonc");
2986
+ if (!existsSync8(configPath))
3367
2987
  return fail("wrangler.jsonc is missing — `vc generate` writes it.");
3368
2988
  const bin = findWrangler(app);
3369
2989
  if (!bin)
@@ -3376,16 +2996,16 @@ async function deployCloudflare(project, opts) {
3376
2996
  ${who.raw.trim().split(`
3377
2997
  `).slice(-4).join(`
3378
2998
  `)}`);
3379
- console.log(`${color6.green("✓")} wrangler is logged in`);
2999
+ console.log(`${color4.green("✓")} wrangler is logged in`);
3380
3000
  let config = readConfig(configPath);
3381
3001
  let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
3382
3002
  if (!accountId) {
3383
3003
  if (who.accounts.length === 1) {
3384
3004
  accountId = who.accounts[0].id;
3385
- writeFileSync2(configPath, upsertJsonc(readFileSync5(configPath, "utf8"), "account_id", accountId));
3005
+ writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "account_id", accountId));
3386
3006
  project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
3387
3007
  await writeManifest(project.root, project.manifest);
3388
- console.log(`${color6.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
3008
+ console.log(`${color4.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
3389
3009
  } else {
3390
3010
  return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
3391
3011
  ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
@@ -3397,7 +3017,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3397
3017
  if (!check.ready) {
3398
3018
  if (!opts.force)
3399
3019
  return fail("refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force for a deliberate partial deploy.");
3400
- console.error(color6.yellow(`--force: deploying a shop that is NOT ready for customers.
3020
+ console.error(color4.yellow(`--force: deploying a shop that is NOT ready for customers.
3401
3021
  `));
3402
3022
  }
3403
3023
  const worker = config.name || project.manifest.shop.domain.split(".")[0];
@@ -3408,28 +3028,49 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3408
3028
  return fail("the D1 database is not provisioned. Run once with --provision to create it and record its id.");
3409
3029
  const db = await ensureD1(bin, app, `${worker}-db`);
3410
3030
  const entry = { binding: "DB", database_name: db.name, database_id: db.uuid, migrations_dir: "./db/migrations" };
3411
- writeFileSync2(configPath, upsertJsonc(readFileSync5(configPath, "utf8"), "d1_databases", [entry]));
3031
+ writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "d1_databases", [entry]));
3412
3032
  project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
3413
3033
  await writeManifest(project.root, project.manifest);
3414
- console.log(`${color6.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
3034
+ console.log(`${color4.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
3415
3035
  config = readConfig(configPath);
3416
3036
  }
3417
3037
  if (opts.provision) {
3418
3038
  await ensureQueue(bin, app, "commerce");
3419
- console.log(`${color6.green("✓")} queue "commerce"`);
3039
+ console.log(`${color4.green("✓")} queue "commerce"`);
3420
3040
  }
3421
3041
  const builder = findBuilder(app);
3422
3042
  if (!builder)
3423
3043
  return fail("no build tool: neither vite-plus nor vite is installed.");
3044
+ const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
3045
+ if (merging) {
3046
+ console.log(`
3047
+ ▸ ${builder.label} ${color4.dim("(the storefront)")}`);
3048
+ const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
3049
+ if (storefront.code !== 0)
3050
+ return fail(`the storefront build failed (exit ${storefront.code}).`);
3051
+ }
3424
3052
  console.log(`
3425
- ▸ ${builder.label}`);
3053
+ ▸ ${builder.label}${merging ? color4.dim(" (the worker)") : ""}`);
3426
3054
  const built = await wrangler(builder.cmd, ["build"], app, true);
3427
3055
  if (built.code !== 0)
3428
3056
  return fail(`the build failed (exit ${built.code}).`);
3429
- const emittedPath = join10(app, "dist", "ssr", "wrangler.json");
3430
- if (!existsSync10(emittedPath))
3057
+ if (merging) {
3058
+ const from = join8(project.root, "dist", "client");
3059
+ const into = join8(app, "dist", "client");
3060
+ if (!existsSync8(from))
3061
+ return fail(`the storefront build emitted no ${from} — is the root a Void app with output "static"?`);
3062
+ if (!existsSync8(into))
3063
+ return fail(`the worker build emitted no ${into}; there is nothing to merge into.`);
3064
+ const merged = mergeTree(from, into, new Set([".vite"]));
3065
+ if (!existsSync8(join8(into, "index.html"))) {
3066
+ return fail("the storefront produced no index.html, so the shop has no front page.");
3067
+ }
3068
+ console.log(`${color4.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
3069
+ }
3070
+ const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
3071
+ if (!existsSync8(emittedPath))
3431
3072
  return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
3432
- const emitted = JSON.parse(readFileSync5(emittedPath, "utf8"));
3073
+ const emitted = JSON.parse(readFileSync3(emittedPath, "utf8"));
3433
3074
  const secretKeys = new Set(allEnvKeys(project.manifest).filter((key) => !key.plaintext).map((key) => key.key));
3434
3075
  const scrubbed = [];
3435
3076
  for (const [key, value] of Object.entries(emitted.vars ?? {})) {
@@ -3441,8 +3082,8 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3441
3082
  const emittedD1 = emitted.d1_databases?.find((db) => db.binding === "DB");
3442
3083
  if (!emittedD1 || emittedD1.database_id === "local")
3443
3084
  return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
3444
- writeFileSync2(emittedPath, JSON.stringify(emitted, null, 2));
3445
- console.log(`${color6.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
3085
+ writeFileSync(emittedPath, JSON.stringify(emitted, null, 2));
3086
+ console.log(`${color4.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
3446
3087
  console.log(`
3447
3088
  ▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
3448
3089
  const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
@@ -3450,34 +3091,52 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3450
3091
  return fail(`applying migrations failed (exit ${migrated.code}); nothing was deployed.`);
3451
3092
  const secretArgs = [];
3452
3093
  let cleanupSecrets;
3453
- if (existsSync10(join10(project.root, SECRETS_FILE))) {
3094
+ if (existsSync8(join8(project.root, SECRETS_FILE))) {
3454
3095
  const decrypted = await decryptSecrets(project);
3455
3096
  if ("error" in decrypted)
3456
3097
  return fail(`the repository's secrets could not be read: ${decrypted.error}`);
3457
3098
  secretArgs.push("--secrets-file", decrypted.path);
3458
3099
  cleanupSecrets = decrypted.cleanup;
3459
- console.log(`${color6.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
3100
+ console.log(`${color4.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
3460
3101
  }
3461
3102
  console.log(`
3462
3103
  ▸ wrangler deploy -c dist/ssr/wrangler.json`);
3463
- const deployed = await wrangler(bin, ["deploy", "-c", join10("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
3104
+ const deployed = await wrangler(bin, ["deploy", "-c", join8("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
3464
3105
  cleanupSecrets?.();
3465
3106
  if (deployed.code !== 0)
3466
3107
  return fail(`wrangler deploy failed (exit ${deployed.code}).`);
3467
3108
  const domain = project.manifest.shop.domain;
3468
3109
  const url = `https://${workerHosts(project.manifest)[0]}`;
3469
3110
  console.log(`
3470
- ${color6.green("Live:")} ${url}`);
3471
- if (hasFrontend(project.manifest.layout))
3472
- console.log(color6.dim("The storefront deploys itself from GitHub Actions on push."));
3111
+ ${color4.green("Live:")} ${url}`);
3112
+ if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
3113
+ console.log(color4.dim("The storefront deploys itself from GitHub Actions on push."));
3114
+ }
3473
3115
  return 0;
3474
3116
  }
3117
+ function mergeTree(from, into, skip) {
3118
+ let count = 0;
3119
+ for (const entry of readdirSync2(from, { withFileTypes: true })) {
3120
+ if (skip.has(entry.name))
3121
+ continue;
3122
+ const source = join8(from, entry.name);
3123
+ const target = join8(into, entry.name);
3124
+ if (entry.isDirectory()) {
3125
+ mkdirSync(target, { recursive: true });
3126
+ count += mergeTree(source, target, skip);
3127
+ } else {
3128
+ copyFileSync(source, target);
3129
+ count += 1;
3130
+ }
3131
+ }
3132
+ return count;
3133
+ }
3475
3134
 
3476
3135
  // src/import.ts
3477
3136
  import { readFile as readFile3 } from "node:fs/promises";
3478
- import { existsSync as existsSync11 } from "node:fs";
3479
- import { join as join11 } from "node:path";
3480
- import color7 from "picocolors";
3137
+ import { existsSync as existsSync9 } from "node:fs";
3138
+ import { join as join9 } from "node:path";
3139
+ import color5 from "picocolors";
3481
3140
  function describe(counts) {
3482
3141
  if (!counts)
3483
3142
  return "";
@@ -3516,7 +3175,7 @@ async function post(url, key, body) {
3516
3175
  return { ok: response.ok, status: response.status, body: { ...parsed, error: parsed.error ?? parsed.message } };
3517
3176
  }
3518
3177
  async function readJson(path) {
3519
- if (!existsSync11(path))
3178
+ if (!existsSync9(path))
3520
3179
  return null;
3521
3180
  try {
3522
3181
  return JSON.parse(await readFile3(path, "utf8"));
@@ -3544,41 +3203,41 @@ async function importCommand(args) {
3544
3203
  }
3545
3204
  const { url, how } = resolveTarget(project, args);
3546
3205
  if (!dry)
3547
- console.log(`${color7.dim("→")} ${url} ${color7.dim(`(${how})`)}
3206
+ console.log(`${color5.dim("→")} ${url} ${color5.dim(`(${how})`)}
3548
3207
  `);
3549
3208
  if (!dry) {
3550
3209
  let whoami2;
3551
3210
  try {
3552
3211
  whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
3553
3212
  } catch (error) {
3554
- console.error(`${color7.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
3213
+ console.error(`${color5.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
3555
3214
  ` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
3556
3215
  `);
3557
3216
  return 1;
3558
3217
  }
3559
3218
  if (!whoami2.ok) {
3560
- console.error(`${color7.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
3219
+ console.error(`${color5.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
3561
3220
  ` + ` Is SYSTEM_API_KEY the one this deployment was given?
3562
3221
  `);
3563
3222
  return 1;
3564
3223
  }
3565
- console.log(`${color7.green("✓")} authenticated as the system identity`);
3224
+ console.log(`${color5.green("✓")} authenticated as the system identity`);
3566
3225
  }
3567
3226
  const root = project.root;
3568
- const products = await readJson(join11(root, "data", "catalog.json"));
3569
- const categories = await readJson(join11(root, "data", "categories.json"));
3570
- const faqs = has(project.manifest, "faqs") ? await readJson(join11(root, "content", "faqs.json")) : null;
3571
- const posts = has(project.manifest, "blogs") ? await readJson(join11(root, "content", "posts.json")) : null;
3227
+ const products = await readJson(join9(root, "data", "catalog.json"));
3228
+ const categories = await readJson(join9(root, "data", "categories.json"));
3229
+ const faqs = has(project.manifest, "faqs") ? await readJson(join9(root, "content", "faqs.json")) : null;
3230
+ const posts = has(project.manifest, "blogs") ? await readJson(join9(root, "content", "posts.json")) : null;
3572
3231
  if (!products && !faqs?.length && !posts?.length) {
3573
3232
  console.log(`
3574
- ${color7.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
3233
+ ${color5.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
3575
3234
  ` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
3576
3235
  `);
3577
3236
  return 0;
3578
3237
  }
3579
3238
  if (dry) {
3580
3239
  console.log(`
3581
- ${color7.dim("--dry-run: nothing was pushed.")}`);
3240
+ ${color5.dim("--dry-run: nothing was pushed.")}`);
3582
3241
  console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
3583
3242
  console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
3584
3243
  `);
@@ -3594,15 +3253,15 @@ ${color7.dim("--dry-run: nothing was pushed.")}`);
3594
3253
  });
3595
3254
  if (result.ok) {
3596
3255
  const report = result.body.report ?? {};
3597
- console.log(`${color7.green("✓")} products: ${describe(report.products)}`);
3256
+ console.log(`${color5.green("✓")} products: ${describe(report.products)}`);
3598
3257
  if (report.categories)
3599
- console.log(`${color7.green("✓")} categories: ${describe(report.categories)}`);
3258
+ console.log(`${color5.green("✓")} categories: ${describe(report.categories)}`);
3600
3259
  if (report.prices)
3601
- console.log(`${color7.green("✓")} prices: ${describe(report.prices)}`);
3260
+ console.log(`${color5.green("✓")} prices: ${describe(report.prices)}`);
3602
3261
  if (report.addons)
3603
- console.log(`${color7.green("✓")} addons: ${describe(report.addons)}`);
3262
+ console.log(`${color5.green("✓")} addons: ${describe(report.addons)}`);
3604
3263
  } else {
3605
- console.error(`${color7.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
3264
+ console.error(`${color5.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
3606
3265
  failed = true;
3607
3266
  }
3608
3267
  }
@@ -3614,20 +3273,20 @@ ${color7.dim("--dry-run: nothing was pushed.")}`);
3614
3273
  continue;
3615
3274
  const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
3616
3275
  if (result.ok) {
3617
- console.log(`${color7.green("✓")} ${label}: ${describe(result.body)}`);
3276
+ console.log(`${color5.green("✓")} ${label}: ${describe(result.body)}`);
3618
3277
  } else {
3619
- console.error(`${color7.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
3278
+ console.error(`${color5.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
3620
3279
  failed = true;
3621
3280
  }
3622
3281
  }
3623
3282
  if (failed) {
3624
3283
  console.error(`
3625
- ${color7.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
3284
+ ${color5.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
3626
3285
  `);
3627
3286
  return 1;
3628
3287
  }
3629
3288
  console.log(`
3630
- ${color7.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
3289
+ ${color5.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
3631
3290
  `);
3632
3291
  return 0;
3633
3292
  }
@@ -3648,4 +3307,4 @@ async function importHelp() {
3648
3307
  return 0;
3649
3308
  }
3650
3309
 
3651
- export { renderAuthTs, allEnvKeys, renderEnvTs, renderEnvExample, renderEnvLocal, renderEnvProduction, envSummary, FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, renderStorefrontPage, renderFrontendWorkflow, runVoid, runInherit, captureVoid, isVoidApp, voidAppsIn, STRICT_APP, VOID_VERSION, strictDependencies, renderVoidPatch, renderViteConfig, renderStrictTsconfig, renderVoidJson, renderDbSchema, renderDbSeed, renderLayoutTsx, renderAppCss, renderIndexServer, INDEX_PAGE, renderDashboardPage, CLIENT_ONLY, renderSignInPage, renderAuthClient, renderContentTs, renderCron, renderQueue, renderLiveStream, renderLiveRoute, renderStrictAppPackageJson, DATA_README, BRANDING_README, MIGRATIONS_README, finishStrict, line, row, box, fullHelp, initHelp, version, findProject, ensureGenerated, DIST_DIR, DIST_BRANCH, distCommand, distHelp, renderDistWorkflow, renderDeployReadme, renderRequirementsTs, renderDomainTs, generate, parseJsonc, upsertJsonc, findWrangler, parseWhoAmI, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, plaintextSecretNames, secretsCommand, productionEnv, routeProblem, preflight, printPreflight, deployCloudflare, importCommand, importHelp };
3310
+ export { renderAuthTs, FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, renderStorefrontPage, renderFrontendWorkflow, runVoid, runInherit, captureVoid, isVoidApp, voidAppsIn, STRICT_APP, VOID_VERSION, strictDependencies, renderVoidPatch, renderViteConfig, renderStrictTsconfig, renderVoidJson, renderDbSchema, renderDbSeed, renderLayoutTsx, renderAppCss, renderIndexServer, INDEX_PAGE, renderDashboardPage, CLIENT_ONLY, renderSignInPage, renderAuthClient, renderContentTs, renderCron, renderQueue, renderLiveStream, renderLiveRoute, renderStrictAppPackageJson, DATA_README, BRANDING_README, MIGRATIONS_README, finishStrict, line, row, box, fullHelp, initHelp, version, findProject, ensureGenerated, DIST_DIR, DIST_BRANCH, distCommand, distHelp, renderDistWorkflow, renderDeployReadme, renderRequirementsTs, renderDomainTs, generate, parseJsonc, upsertJsonc, findWrangler, parseWhoAmI, productionEnv, routeProblem, preflight, printPreflight, deployCloudflare, importCommand, importHelp };