@saastemly/voidcommerce 0.4.0 → 0.5.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,9 +1,19 @@
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-b9b4dawy.js";
1
13
  import {
2
14
  MANIFEST_FILE,
3
- envKeysOf,
4
15
  has,
5
16
  hasFrontend,
6
- isApex,
7
17
  isSingleApp,
8
18
  packagesOf,
9
19
  readManifest,
@@ -16,8 +26,7 @@ import {
16
26
  CHOICES
17
27
  } from "./index-844b3qn9.js";
18
28
  import {
19
- __require,
20
- __toESM
29
+ __require
21
30
  } from "./index-0v6na3yp.js";
22
31
 
23
32
  // src/generate/auth.ts
@@ -350,146 +359,6 @@ ${render("commerce").replace(/^/gm, "\t")}
350
359
  }
351
360
  var camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
352
361
 
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";
413
-
414
- /**
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.
419
- *
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.
425
- */
426
- export default defineEnv({
427
- ${lines.join(`
428
- `)}
429
- });
430
- `;
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
362
  // src/generate/frontend.ts
494
363
  var FRONTEND_OUT = "dist/client";
495
364
  var FRONTEND_BRANCH = "frontend-static";
@@ -1441,7 +1310,7 @@ import color from "picocolors";
1441
1310
  // package.json
1442
1311
  var package_default = {
1443
1312
  name: "@saastemly/voidcommerce",
1444
- version: "0.4.0",
1313
+ version: "0.5.0",
1445
1314
  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
1315
  type: "module",
1447
1316
  license: "MIT",
@@ -1639,7 +1508,7 @@ import { existsSync as existsSync5 } from "node:fs";
1639
1508
  import { dirname as dirname3, join as join5 } from "node:path";
1640
1509
 
1641
1510
  // 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";
1511
+ 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
1512
  import { existsSync as existsSync4, lstatSync } from "node:fs";
1644
1513
  import { dirname as dirname2, join as join4 } from "node:path";
1645
1514
 
@@ -1770,36 +1639,38 @@ async function distHelp() {
1770
1639
 
1771
1640
  // src/generate/ci.ts
1772
1641
  function renderDistWorkflow(manifest) {
1773
- const worker = manifest.shop.domain.split(".")[0];
1774
- return `name: Build the deployable app
1642
+ return `name: Deploy the shop
1775
1643
 
1776
1644
  # Generated by \`vc init\` from voidcommerce.json.
1777
1645
  #
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.
1646
+ # A push to main regenerates the Void app from the manifest, publishes it to
1647
+ # \`${DIST_BRANCH}\` as a standalone tree, and deploys it to Cloudflare.
1648
+ #
1649
+ # The credentials come from the repository, set once by \`vc link\`:
1650
+ #
1651
+ # secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
1652
+ # secrets.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE}
1653
+ # vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
1782
1654
  #
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.
1655
+ # Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
1656
+ # on your machine. See DEPLOY.md.
1787
1657
  on:
1788
1658
  push:
1789
1659
  branches: [main, master]
1790
1660
  workflow_dispatch:
1791
1661
 
1792
- # A later push wins: an older tree must never overwrite a newer one.
1662
+ # A later push wins: an older tree must never overwrite a newer one, and two
1663
+ # deploys must never race for the same worker.
1793
1664
  concurrency:
1794
- group: ${DIST_BRANCH}-\${{ github.repository }}
1665
+ group: deploy-\${{ github.repository }}
1795
1666
  cancel-in-progress: true
1796
1667
 
1797
- # contents: write is what lets GITHUB_TOKEN force-push the branch. Nothing else.
1798
1668
  permissions:
1799
1669
  contents: write
1800
1670
 
1801
1671
  jobs:
1802
- build:
1672
+ # ── Regenerate, and publish the standalone tree ────────────────────────
1673
+ dist:
1803
1674
  runs-on: ubuntu-latest
1804
1675
  steps:
1805
1676
  - uses: actions/checkout@v6
@@ -1840,6 +1711,50 @@ jobs:
1840
1711
  git add -A
1841
1712
  git commit -q -m "\${{ github.sha }} — ${manifest.shop.domain}"
1842
1713
  git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${DIST_BRANCH}
1714
+
1715
+ # ── Deploy it ──────────────────────────────────────────────────────────
1716
+ deploy:
1717
+ needs: dist
1718
+ runs-on: ubuntu-latest
1719
+ environment:
1720
+ name: production
1721
+ url: https://${manifest.shop.domain}
1722
+ env:
1723
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
1724
+ CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
1725
+ ${PRIVATE_KEY_VAR}: \${{ secrets.${PRIVATE_KEY_VAR} }}
1726
+ steps:
1727
+ - uses: actions/checkout@v6
1728
+ - uses: oven-sh/setup-bun@v2
1729
+
1730
+ # Checked before anything is built, so a repository that was never
1731
+ # linked says so in five seconds rather than four minutes.
1732
+ - name: Are the credentials here?
1733
+ run: |
1734
+ set -euo pipefail
1735
+ missing=""
1736
+ [ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
1737
+ [ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
1738
+ if [ -n "$missing" ]; then
1739
+ echo "::error::this repository has no$missing. Run 'vc link' once, from a checkout."
1740
+ exit 1
1741
+ fi
1742
+
1743
+ - run: bun install --frozen-lockfile
1744
+
1745
+ # One command: preflight, provision D1 and the queue if they are not
1746
+ # there, build, strip the values Void bakes into the worker's plaintext
1747
+ # vars, apply the committed migrations, and deploy with the decrypted
1748
+ # secrets attached to the version. Provisioning is idempotent — it looks
1749
+ # a resource up by name before creating it — so this is safe every run.
1750
+ - name: vc deploy --cloudflare --provision
1751
+ run: bunx vc deploy --cloudflare --provision
1752
+
1753
+ # An upsert, so it is safe on every deploy and costs one pass when
1754
+ # nothing in data/ has changed. A failure here does not un-deploy a
1755
+ # working shop, so it warns rather than failing the run.
1756
+ - name: Push the catalogue
1757
+ run: bunx vc import || echo "::warning::the catalogue did not import; the shop is up but its products may be stale"
1843
1758
  `;
1844
1759
  }
1845
1760
  function renderDeployReadme(manifest, zone2, hosts) {
@@ -1849,132 +1764,117 @@ function renderDeployReadme(manifest, zone2, hosts) {
1849
1764
  \`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
1850
1765
  regenerate with \`vc generate\`.
1851
1766
 
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
1767
+ ## The loop
1874
1768
 
1875
1769
  \`\`\`sh
1876
- vc deploy --cloudflare --provision
1770
+ git push
1877
1771
  \`\`\`
1878
1772
 
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.**
1773
+ That is the deploy. GitHub Actions regenerates the app from
1774
+ \`voidcommerce.json\`, publishes a standalone copy to \`${DIST_BRANCH}\`, creates the
1775
+ database and queue if they are not there, applies the migrations, and deploys
1776
+ the worker with this repository's secrets attached.
1883
1777
 
1884
- ### 3. The secrets
1778
+ You do not need wrangler on your machine, and you do not need to be logged
1779
+ into it. You do not need to open the Cloudflare dashboard after the one step
1780
+ below.
1885
1781
 
1886
- They live in the repository, encrypted:
1782
+ ## Once, before the first push
1887
1783
 
1888
1784
  \`\`\`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
1785
+ gh repo create --source=. --private --push # if there is no repo yet
1786
+ vc link
1892
1787
  \`\`\`
1893
1788
 
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.
1789
+ \`vc link\` does three things and stores all of them on the GitHub repository:
1901
1790
 
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.
1791
+ | what | where | why |
1792
+ |---|---|---|
1793
+ | \`${PRIVATE_KEY_VAR}\` | repository **secret** | opens \`${SECRETS_FILE}\`. Generated by \`vc link\`, never written to disk |
1794
+ | \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
1795
+ | \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
1907
1796
 
1908
- ### 4. An API token the build can use
1797
+ It will ask you to paste a Cloudflare API token. That is the only manual step
1798
+ in the whole setup, and it is worth saying exactly why it cannot be removed:
1909
1799
 
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.
1800
+ > **GitHub cannot mint a Cloudflare credential.** There is no OIDC or workload
1801
+ > identity federation between them the feature request has been open since
1802
+ > 2025 with no commitment, and Cloudflare's own CI guidance still says to store
1803
+ > a token in your CI provider's secrets. The Cloudflare GitHub App does not
1804
+ > help either: it grants *Cloudflare* access to your *repository*, not the
1805
+ > reverse. Something has to authorise creating a database in your account, and
1806
+ > only Cloudflare can issue that authorisation.
1914
1807
 
1915
- Create a token at **My Profile → API Tokens** with:
1808
+ Create the token at **My Profile → API Tokens → Create Token → Custom token**:
1916
1809
 
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 |
1810
+ | scope | permission | for |
1811
+ |---|---|---|
1812
+ | Account | Workers Scripts: Edit | deploying the worker |
1813
+ | Account | D1: Edit | creating the database, applying migrations |
1814
+ | Account | Queues: Edit | the order queue |
1815
+ | Account | Workers KV Storage: Edit | sessions and caches |
1816
+ | Account | Workers R2 Storage: Edit | product images |
1817
+ | Account | Account Settings: Read | confirming which account |
1818
+ | Zone | Workers Routes: Edit (${zone2}) | answering on your domain |
1819
+ | User | User Details: Read, Memberships: Read | wrangler asks at startup |
1926
1820
 
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.
1821
+ The token Cloudflare generates for its own Workers Builds will **not** do: it
1822
+ has no D1 and no Queues permission, so it cannot create this shop's database.
1929
1823
 
1930
- ### 5. Connect the repository
1824
+ ### The zone
1931
1825
 
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.
1826
+ \`${zone2}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
1827
+ creates in its own zone, so a domain hosted anywhere else cannot have one. The
1828
+ worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
1935
1829
 
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 |
1830
+ ## Secrets
1944
1831
 
1945
- The deploy command, on one line:
1832
+ They live in the repository, encrypted, in \`${SECRETS_FILE}\`:
1946
1833
 
1947
1834
  \`\`\`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
1835
+ vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
1836
+ vc secrets # what is set, what is missing
1949
1837
  \`\`\`
1950
1838
 
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.
1839
+ **This needs no credential.** dotenvx is asymmetric: encryption uses the public
1840
+ key committed at the top of the file, so anyone with a clone can set or rotate
1841
+ a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
1842
+ private key that lives in GitHub Actions.
1954
1843
 
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.
1844
+ A pre-commit hook refuses a commit that would put a value in the clear, because
1845
+ that cannot be undone by a later commit — the value stays in the history.
1957
1846
 
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.
1847
+ **Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
1848
+ leaks, every secret in the history is readable, including ones you rotated.
1849
+ \`wrangler secret put\` does not have that property, and stays available for
1850
+ anything you would rather never commit at all.
1961
1851
 
1962
- ### 6. The catalogue
1852
+ ## The catalogue
1963
1853
 
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.
1854
+ \`vc import\` pushes \`data/\` into the running shop, and the workflow runs it on
1855
+ every deploy. It is an upsert, so it costs one pass when nothing has changed.
1966
1856
 
1967
- ## Without Cloudflare's build
1857
+ ## Doing it by hand
1968
1858
 
1969
- The same thing by hand, from a checkout of either branch:
1859
+ The same thing, from a checkout, if CI is ever not the answer:
1970
1860
 
1971
1861
  \`\`\`sh
1972
- vc deploy --cloudflare
1862
+ export CLOUDFLARE_API_TOKEN=…
1863
+ export ${PRIVATE_KEY_VAR}=… # only if you kept a copy
1864
+ vc deploy --cloudflare --provision
1973
1865
  \`\`\`
1974
1866
 
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.
1867
+ which preflights, provisions, builds, strips the baked development values out
1868
+ of the worker's vars, applies the remote migrations, and deploys exactly what
1869
+ it verified.
1870
+
1871
+ ## If you would rather use Cloudflare's own build
1872
+
1873
+ Point Workers Builds at the \`${DIST_BRANCH}\` branch — it is a plain Void app with
1874
+ a committed lockfile. The Worker must be named \`${worker}\`, matching the \`name\`
1875
+ in its \`wrangler.jsonc\`. You will need to set \`${PRIVATE_KEY_VAR}\` as a
1876
+ build secret in the dashboard, and replace the auto-generated API token with
1877
+ one that has D1 and Queues. That is the path \`vc link\` exists to avoid.
1978
1878
  `;
1979
1879
  }
1980
1880
 
@@ -2313,6 +2213,13 @@ async function put(root, file, content, result, mode) {
2313
2213
  await writeFile2(path, content, "utf8");
2314
2214
  result.written.push(file);
2315
2215
  }
2216
+ async function retire(root, file, result) {
2217
+ const path = join4(root, file);
2218
+ if (!await exists(path))
2219
+ return;
2220
+ await rm2(path, { force: true });
2221
+ result.retired.push(file);
2222
+ }
2316
2223
  async function mergeJson(root, file, fallback, mutate, result) {
2317
2224
  const path = join4(root, file);
2318
2225
  const json = await exists(path) ? JSON.parse(await readFile2(path, "utf8")) : { ...fallback };
@@ -2336,7 +2243,7 @@ async function link(root, file, target, result) {
2336
2243
  }
2337
2244
  var sorted = (record) => Object.fromEntries(Object.entries(record).sort());
2338
2245
  async function generate(root, manifest) {
2339
- const result = { written: [], kept: [], packages: packagesOf(manifest) };
2246
+ const result = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
2340
2247
  await writeManifest(root, manifest);
2341
2248
  result.written.push(MANIFEST_FILE);
2342
2249
  switch (manifest.layout) {
@@ -2505,7 +2412,8 @@ dist
2505
2412
  # because the value stays in the history.
2506
2413
  bunx vc guard
2507
2414
  `, result, "regenerate");
2508
- await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
2415
+ await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
2416
+ await retire(root, ".github/workflows/void-dist.yml", result);
2509
2417
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
2510
2418
  await put(root, ".env", renderEnvLocal(manifest), result, "own");
2511
2419
  await put(root, "data/README.md", DATA_README, result, "own");
@@ -2869,346 +2777,16 @@ ${ran.out.trim()}`);
2869
2777
  }
2870
2778
 
2871
2779
  // 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
2780
  import { existsSync as existsSync7, readFileSync as readFileSync2 } from "node:fs";
2886
2781
  import { join as join7 } from "node:path";
2887
2782
  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
2783
  var UNSET = "unset";
3206
2784
  function productionEnv(appDir) {
3207
2785
  const out = new Map;
3208
- const path = join9(appDir, ".env.production");
3209
- if (!existsSync9(path))
2786
+ const path = join7(appDir, ".env.production");
2787
+ if (!existsSync7(path))
3210
2788
  return out;
3211
- for (const line2 of readFileSync4(path, "utf8").split(`
2789
+ for (const line2 of readFileSync2(path, "utf8").split(`
3212
2790
  `)) {
3213
2791
  const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line2);
3214
2792
  if (!match)
@@ -3233,12 +2811,12 @@ async function voidSecretNames(appDir) {
3233
2811
  return names;
3234
2812
  }
3235
2813
  function routeProblem(project) {
3236
- const path = join9(project.appDir, "wrangler.jsonc");
3237
- if (!existsSync9(path))
2814
+ const path = join7(project.appDir, "wrangler.jsonc");
2815
+ if (!existsSync7(path))
3238
2816
  return "wrangler.jsonc is missing";
3239
2817
  let routes = [];
3240
2818
  try {
3241
- routes = parseJsonc(readFileSync4(path, "utf8")).routes ?? [];
2819
+ routes = parseJsonc(readFileSync2(path, "utf8")).routes ?? [];
3242
2820
  } catch {
3243
2821
  return "wrangler.jsonc cannot be parsed";
3244
2822
  }
@@ -3278,7 +2856,7 @@ async function preflight(project, source) {
3278
2856
  function printPreflight(project, result, source) {
3279
2857
  const keys = allEnvKeys(project.manifest);
3280
2858
  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.
2859
+ console.log(color3.dim(source === "wrangler" ? `Could not read the worker's secrets — not deployed yet, or wrangler is not logged in.
3282
2860
  Anything not in .env.production is reported as missing.
3283
2861
  ` : `Could not read the project's secrets from Void — not logged in, or no linked project.
3284
2862
  Anything not in .env.production is reported as missing.
@@ -3288,37 +2866,37 @@ Anything not in .env.production is reported as missing.
3288
2866
  for (const key of keys) {
3289
2867
  const value = result.present.get(key.key);
3290
2868
  const set = value !== undefined && value !== "" && value !== UNSET;
3291
- console.log(` ${set ? color5.green("set ") : color5.dim("unset")} ${key.key}${key.plaintext ? color5.dim(" (plaintext)") : ""}`);
2869
+ console.log(` ${set ? color3.green("set ") : color3.dim("unset")} ${key.key}${key.plaintext ? color3.dim(" (plaintext)") : ""}`);
3292
2870
  }
3293
2871
  if (result.ready) {
3294
2872
  console.log(`
3295
- ${color5.green("Ready to go live.")}
2873
+ ${color3.green("Ready to go live.")}
3296
2874
  `);
3297
2875
  return;
3298
2876
  }
3299
2877
  console.log(`
3300
- ${color5.red("NOT ready to go live.")}
2878
+ ${color3.red("NOT ready to go live.")}
3301
2879
  `);
3302
2880
  if (result.routeProblem) {
3303
- console.log(`${color5.red("✗")} the worker's hostname
2881
+ console.log(`${color3.red("✗")} the worker's hostname
3304
2882
  ${result.routeProblem}
3305
2883
  `);
3306
2884
  }
3307
2885
  if (result.bareSecrets.length > 0) {
3308
- console.log(`${color5.red("✗")} committed in the clear in ${SECRETS_FILE}: ${result.bareSecrets.join(", ")}
2886
+ console.log(`${color3.red("✗")} committed in the clear in ${SECRETS_FILE}: ${result.bareSecrets.join(", ")}
3309
2887
  ` + ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` — and treat those values as burned.
3310
2888
  `);
3311
2889
  }
3312
2890
  for (const key of result.missing) {
3313
- console.log(`${color5.red("✗")} ${key.key}`);
2891
+ console.log(`${color3.red("✗")} ${key.key}`);
3314
2892
  console.log(` ${key.breaks}`);
3315
2893
  if (key.where)
3316
- console.log(` ${color5.dim(`from: ${key.where}`)}`);
2894
+ console.log(` ${color3.dim(`from: ${key.where}`)}`);
3317
2895
  console.log("");
3318
2896
  }
3319
2897
  const secrets = result.missing.filter((key) => !key.plaintext);
3320
2898
  if (secrets.length > 0) {
3321
- const inRepo = existsSync9(join9(project.root, SECRETS_FILE));
2899
+ const inRepo = existsSync7(join7(project.root, SECRETS_FILE));
3322
2900
  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
2901
  for (const key of secrets) {
3324
2902
  console.log(inRepo ? ` dotenvx set ${key.key} '…' -f ${SECRETS_FILE}` : ` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
@@ -3331,17 +2909,17 @@ ${color5.red("NOT ready to go live.")}
3331
2909
  }
3332
2910
 
3333
2911
  // 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";
2912
+ import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync } from "node:fs";
2913
+ import { dirname as dirname5, join as join8 } from "node:path";
2914
+ import color4 from "picocolors";
3337
2915
  var fail = (message) => {
3338
2916
  console.error(`
3339
- ${color6.red("✗")} ${message}
2917
+ ${color4.red("✗")} ${message}
3340
2918
  `);
3341
2919
  return 1;
3342
2920
  };
3343
2921
  function readConfig(path) {
3344
- return parseJsonc(readFileSync5(path, "utf8"));
2922
+ return parseJsonc(readFileSync3(path, "utf8"));
3345
2923
  }
3346
2924
  function findBuilder(from) {
3347
2925
  let dir = from;
@@ -3350,11 +2928,11 @@ function findBuilder(from) {
3350
2928
  ["vp", "vp build"],
3351
2929
  ["vite", "vite build"]
3352
2930
  ]) {
3353
- const path = join10(dir, "node_modules", ".bin", bin);
3354
- if (existsSync10(path))
2931
+ const path = join8(dir, "node_modules", ".bin", bin);
2932
+ if (existsSync8(path))
3355
2933
  return { cmd: path, label };
3356
2934
  }
3357
- const parent = dirname6(dir);
2935
+ const parent = dirname5(dir);
3358
2936
  if (parent === dir)
3359
2937
  return null;
3360
2938
  dir = parent;
@@ -3362,8 +2940,8 @@ function findBuilder(from) {
3362
2940
  }
3363
2941
  async function deployCloudflare(project, opts) {
3364
2942
  const app = project.appDir;
3365
- const configPath = join10(app, "wrangler.jsonc");
3366
- if (!existsSync10(configPath))
2943
+ const configPath = join8(app, "wrangler.jsonc");
2944
+ if (!existsSync8(configPath))
3367
2945
  return fail("wrangler.jsonc is missing — `vc generate` writes it.");
3368
2946
  const bin = findWrangler(app);
3369
2947
  if (!bin)
@@ -3376,16 +2954,16 @@ async function deployCloudflare(project, opts) {
3376
2954
  ${who.raw.trim().split(`
3377
2955
  `).slice(-4).join(`
3378
2956
  `)}`);
3379
- console.log(`${color6.green("✓")} wrangler is logged in`);
2957
+ console.log(`${color4.green("✓")} wrangler is logged in`);
3380
2958
  let config = readConfig(configPath);
3381
2959
  let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
3382
2960
  if (!accountId) {
3383
2961
  if (who.accounts.length === 1) {
3384
2962
  accountId = who.accounts[0].id;
3385
- writeFileSync2(configPath, upsertJsonc(readFileSync5(configPath, "utf8"), "account_id", accountId));
2963
+ writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "account_id", accountId));
3386
2964
  project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
3387
2965
  await writeManifest(project.root, project.manifest);
3388
- console.log(`${color6.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
2966
+ console.log(`${color4.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
3389
2967
  } else {
3390
2968
  return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
3391
2969
  ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
@@ -3397,7 +2975,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3397
2975
  if (!check.ready) {
3398
2976
  if (!opts.force)
3399
2977
  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.
2978
+ console.error(color4.yellow(`--force: deploying a shop that is NOT ready for customers.
3401
2979
  `));
3402
2980
  }
3403
2981
  const worker = config.name || project.manifest.shop.domain.split(".")[0];
@@ -3408,15 +2986,15 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3408
2986
  return fail("the D1 database is not provisioned. Run once with --provision to create it and record its id.");
3409
2987
  const db = await ensureD1(bin, app, `${worker}-db`);
3410
2988
  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]));
2989
+ writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "d1_databases", [entry]));
3412
2990
  project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
3413
2991
  await writeManifest(project.root, project.manifest);
3414
- console.log(`${color6.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
2992
+ console.log(`${color4.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
3415
2993
  config = readConfig(configPath);
3416
2994
  }
3417
2995
  if (opts.provision) {
3418
2996
  await ensureQueue(bin, app, "commerce");
3419
- console.log(`${color6.green("✓")} queue "commerce"`);
2997
+ console.log(`${color4.green("✓")} queue "commerce"`);
3420
2998
  }
3421
2999
  const builder = findBuilder(app);
3422
3000
  if (!builder)
@@ -3426,10 +3004,10 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3426
3004
  const built = await wrangler(builder.cmd, ["build"], app, true);
3427
3005
  if (built.code !== 0)
3428
3006
  return fail(`the build failed (exit ${built.code}).`);
3429
- const emittedPath = join10(app, "dist", "ssr", "wrangler.json");
3430
- if (!existsSync10(emittedPath))
3007
+ const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
3008
+ if (!existsSync8(emittedPath))
3431
3009
  return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
3432
- const emitted = JSON.parse(readFileSync5(emittedPath, "utf8"));
3010
+ const emitted = JSON.parse(readFileSync3(emittedPath, "utf8"));
3433
3011
  const secretKeys = new Set(allEnvKeys(project.manifest).filter((key) => !key.plaintext).map((key) => key.key));
3434
3012
  const scrubbed = [];
3435
3013
  for (const [key, value] of Object.entries(emitted.vars ?? {})) {
@@ -3441,8 +3019,8 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3441
3019
  const emittedD1 = emitted.d1_databases?.find((db) => db.binding === "DB");
3442
3020
  if (!emittedD1 || emittedD1.database_id === "local")
3443
3021
  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(", ")}` : ""}`);
3022
+ writeFileSync(emittedPath, JSON.stringify(emitted, null, 2));
3023
+ console.log(`${color4.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
3446
3024
  console.log(`
3447
3025
  ▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
3448
3026
  const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
@@ -3450,34 +3028,34 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3450
3028
  return fail(`applying migrations failed (exit ${migrated.code}); nothing was deployed.`);
3451
3029
  const secretArgs = [];
3452
3030
  let cleanupSecrets;
3453
- if (existsSync10(join10(project.root, SECRETS_FILE))) {
3031
+ if (existsSync8(join8(project.root, SECRETS_FILE))) {
3454
3032
  const decrypted = await decryptSecrets(project);
3455
3033
  if ("error" in decrypted)
3456
3034
  return fail(`the repository's secrets could not be read: ${decrypted.error}`);
3457
3035
  secretArgs.push("--secrets-file", decrypted.path);
3458
3036
  cleanupSecrets = decrypted.cleanup;
3459
- console.log(`${color6.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
3037
+ console.log(`${color4.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
3460
3038
  }
3461
3039
  console.log(`
3462
3040
  ▸ wrangler deploy -c dist/ssr/wrangler.json`);
3463
- const deployed = await wrangler(bin, ["deploy", "-c", join10("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
3041
+ const deployed = await wrangler(bin, ["deploy", "-c", join8("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
3464
3042
  cleanupSecrets?.();
3465
3043
  if (deployed.code !== 0)
3466
3044
  return fail(`wrangler deploy failed (exit ${deployed.code}).`);
3467
3045
  const domain = project.manifest.shop.domain;
3468
3046
  const url = `https://${workerHosts(project.manifest)[0]}`;
3469
3047
  console.log(`
3470
- ${color6.green("Live:")} ${url}`);
3048
+ ${color4.green("Live:")} ${url}`);
3471
3049
  if (hasFrontend(project.manifest.layout))
3472
- console.log(color6.dim("The storefront deploys itself from GitHub Actions on push."));
3050
+ console.log(color4.dim("The storefront deploys itself from GitHub Actions on push."));
3473
3051
  return 0;
3474
3052
  }
3475
3053
 
3476
3054
  // src/import.ts
3477
3055
  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";
3056
+ import { existsSync as existsSync9 } from "node:fs";
3057
+ import { join as join9 } from "node:path";
3058
+ import color5 from "picocolors";
3481
3059
  function describe(counts) {
3482
3060
  if (!counts)
3483
3061
  return "";
@@ -3516,7 +3094,7 @@ async function post(url, key, body) {
3516
3094
  return { ok: response.ok, status: response.status, body: { ...parsed, error: parsed.error ?? parsed.message } };
3517
3095
  }
3518
3096
  async function readJson(path) {
3519
- if (!existsSync11(path))
3097
+ if (!existsSync9(path))
3520
3098
  return null;
3521
3099
  try {
3522
3100
  return JSON.parse(await readFile3(path, "utf8"));
@@ -3544,41 +3122,41 @@ async function importCommand(args) {
3544
3122
  }
3545
3123
  const { url, how } = resolveTarget(project, args);
3546
3124
  if (!dry)
3547
- console.log(`${color7.dim("→")} ${url} ${color7.dim(`(${how})`)}
3125
+ console.log(`${color5.dim("→")} ${url} ${color5.dim(`(${how})`)}
3548
3126
  `);
3549
3127
  if (!dry) {
3550
3128
  let whoami2;
3551
3129
  try {
3552
3130
  whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
3553
3131
  } catch (error) {
3554
- console.error(`${color7.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
3132
+ console.error(`${color5.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
3555
3133
  ` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
3556
3134
  `);
3557
3135
  return 1;
3558
3136
  }
3559
3137
  if (!whoami2.ok) {
3560
- console.error(`${color7.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
3138
+ console.error(`${color5.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
3561
3139
  ` + ` Is SYSTEM_API_KEY the one this deployment was given?
3562
3140
  `);
3563
3141
  return 1;
3564
3142
  }
3565
- console.log(`${color7.green("✓")} authenticated as the system identity`);
3143
+ console.log(`${color5.green("✓")} authenticated as the system identity`);
3566
3144
  }
3567
3145
  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;
3146
+ const products = await readJson(join9(root, "data", "catalog.json"));
3147
+ const categories = await readJson(join9(root, "data", "categories.json"));
3148
+ const faqs = has(project.manifest, "faqs") ? await readJson(join9(root, "content", "faqs.json")) : null;
3149
+ const posts = has(project.manifest, "blogs") ? await readJson(join9(root, "content", "posts.json")) : null;
3572
3150
  if (!products && !faqs?.length && !posts?.length) {
3573
3151
  console.log(`
3574
- ${color7.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
3152
+ ${color5.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
3575
3153
  ` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
3576
3154
  `);
3577
3155
  return 0;
3578
3156
  }
3579
3157
  if (dry) {
3580
3158
  console.log(`
3581
- ${color7.dim("--dry-run: nothing was pushed.")}`);
3159
+ ${color5.dim("--dry-run: nothing was pushed.")}`);
3582
3160
  console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
3583
3161
  console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
3584
3162
  `);
@@ -3594,15 +3172,15 @@ ${color7.dim("--dry-run: nothing was pushed.")}`);
3594
3172
  });
3595
3173
  if (result.ok) {
3596
3174
  const report = result.body.report ?? {};
3597
- console.log(`${color7.green("✓")} products: ${describe(report.products)}`);
3175
+ console.log(`${color5.green("✓")} products: ${describe(report.products)}`);
3598
3176
  if (report.categories)
3599
- console.log(`${color7.green("✓")} categories: ${describe(report.categories)}`);
3177
+ console.log(`${color5.green("✓")} categories: ${describe(report.categories)}`);
3600
3178
  if (report.prices)
3601
- console.log(`${color7.green("✓")} prices: ${describe(report.prices)}`);
3179
+ console.log(`${color5.green("✓")} prices: ${describe(report.prices)}`);
3602
3180
  if (report.addons)
3603
- console.log(`${color7.green("✓")} addons: ${describe(report.addons)}`);
3181
+ console.log(`${color5.green("✓")} addons: ${describe(report.addons)}`);
3604
3182
  } else {
3605
- console.error(`${color7.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
3183
+ console.error(`${color5.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
3606
3184
  failed = true;
3607
3185
  }
3608
3186
  }
@@ -3614,20 +3192,20 @@ ${color7.dim("--dry-run: nothing was pushed.")}`);
3614
3192
  continue;
3615
3193
  const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
3616
3194
  if (result.ok) {
3617
- console.log(`${color7.green("✓")} ${label}: ${describe(result.body)}`);
3195
+ console.log(`${color5.green("✓")} ${label}: ${describe(result.body)}`);
3618
3196
  } else {
3619
- console.error(`${color7.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
3197
+ console.error(`${color5.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
3620
3198
  failed = true;
3621
3199
  }
3622
3200
  }
3623
3201
  if (failed) {
3624
3202
  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.
3203
+ ${color5.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
3626
3204
  `);
3627
3205
  return 1;
3628
3206
  }
3629
3207
  console.log(`
3630
- ${color7.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
3208
+ ${color5.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
3631
3209
  `);
3632
3210
  return 0;
3633
3211
  }
@@ -3648,4 +3226,4 @@ async function importHelp() {
3648
3226
  return 0;
3649
3227
  }
3650
3228
 
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 };
3229
+ 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 };