@saastemly/voidcommerce 0.3.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,
@@ -11,11 +21,13 @@ import {
11
21
  workerHosts,
12
22
  writeManifest,
13
23
  zone
14
- } from "./index-zh3tmj08.js";
24
+ } from "./index-pz6m2hkm.js";
25
+ import {
26
+ CHOICES
27
+ } from "./index-844b3qn9.js";
15
28
  import {
16
- CHOICES,
17
29
  __require
18
- } from "./index-z4qazajn.js";
30
+ } from "./index-0v6na3yp.js";
19
31
 
20
32
  // src/generate/auth.ts
21
33
  var VAT = {
@@ -347,146 +359,6 @@ ${render("commerce").replace(/^/gm, "\t")}
347
359
  }
348
360
  var camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
349
361
 
350
- // src/generate/env.ts
351
- var BASE = [
352
- {
353
- key: "SHOP_DOMAIN",
354
- breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
355
- plaintext: true
356
- },
357
- {
358
- key: "COMMERCE_CRON_SECRET",
359
- breaks: "the scheduler cannot authenticate, so every background job stops",
360
- where: "openssl rand -base64 32"
361
- },
362
- {
363
- key: "COMMERCE_WEBHOOK_SECRET",
364
- breaks: "payment webhooks cannot be verified, so no order ever becomes paid",
365
- where: "the payment provider's dashboard"
366
- },
367
- {
368
- key: "SHIPPING_DOMESTIC_MINOR",
369
- breaks: "delivery is quoted at nothing, and every order loses the carrier cost",
370
- plaintext: true,
371
- dev: "4900",
372
- where: "your carrier agreement, in minor units ex VAT"
373
- },
374
- {
375
- key: "SHIPPING_FREE_FROM_MINOR",
376
- breaks: "the free-delivery threshold is undefined",
377
- plaintext: true,
378
- dev: "100000"
379
- }
380
- ];
381
- function allEnvKeys(manifest) {
382
- const keys = [...BASE, ...envKeysOf(manifest)];
383
- if (!isApex(manifest)) {
384
- keys.splice(1, 0, {
385
- key: "SHOP_ZONE",
386
- breaks: "DNS records are written to the wrong zone, or to none",
387
- plaintext: true
388
- });
389
- }
390
- if (hasFrontend(manifest.layout) && manifest.shop.pagesHost) {
391
- keys.splice(1, 0, {
392
- key: "GITHUB_PAGES_HOST",
393
- breaks: "the www record has nothing to point at",
394
- plaintext: true
395
- });
396
- }
397
- return keys;
398
- }
399
- var NUMERIC = new Set(["SHIPPING_DOMESTIC_MINOR", "SHIPPING_FREE_FROM_MINOR"]);
400
- function renderEnvTs(manifest) {
401
- const keys = allEnvKeys(manifest);
402
- const lines = keys.map((key) => {
403
- const helper = NUMERIC.has(key.key) ? "number()" : "string()";
404
- const doc = [` /** ${key.breaks}${key.where ? ` — from: ${key.where}` : ""} */`];
405
- return `${doc.join(`
406
- `)}
407
- ${key.key}: ${helper},`;
408
- });
409
- return `import { defineEnv, number, string } from "void/env";
410
-
411
- /**
412
- * Every env key the app reads. All of them are REQUIRED.
413
- *
414
- * Generated by \`vc init\` from voidcommerce.json — edit the manifest and
415
- * regenerate rather than editing this by hand.
416
- *
417
- * Nothing is optional: an integration this shop supports is one the
418
- * deployment sets up. No key has a default, because a default is compiled
419
- * into the worker's vars and shadows the real secret. Locally, the literal
420
- * value \`unset\` is the one documented way to say "I do not have this yet",
421
- * and \`vc preflight\` refuses it.
422
- */
423
- export default defineEnv({
424
- ${lines.join(`
425
- `)}
426
- });
427
- `;
428
- }
429
- function renderEnvExample(manifest) {
430
- const keys = allEnvKeys(manifest);
431
- return [
432
- "# Every key is required. Copy to .env for local development.",
433
- "# `unset` is the one value that reads as absent — for a credential you do not have yet.",
434
- "",
435
- ...keys.map((key) => `${key.key}=${key.dev ?? (key.plaintext ? "" : "unset")}`),
436
- ""
437
- ].join(`
438
- `);
439
- }
440
- function renderEnvLocal(manifest) {
441
- const keys = allEnvKeys(manifest);
442
- const local = {
443
- SHOP_DOMAIN: `${manifest.shop.domain.split(".")[0]}.test`,
444
- GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
445
- SHOP_ZONE: zone(manifest),
446
- COMMERCE_CRON_SECRET: "dev-cron-secret-not-for-production",
447
- COMMERCE_WEBHOOK_SECRET: "whsec_dev_secret",
448
- SYSTEM_API_KEY: "dev-system-key-0123456789abcdefghijklmnopqrstuvwxyz",
449
- EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`
450
- };
451
- return [
452
- "# Local development. Gitignored; .env.example documents every key.",
453
- "",
454
- ...keys.map((key) => `${key.key}=${local[key.key] ?? key.dev ?? "unset"}`),
455
- ...hasFrontend(manifest.layout) ? ["", "# The storefront, reaching the worker. Start the worker first.", "VITE_API_ORIGIN=http://localhost:5173"] : [],
456
- ""
457
- ].join(`
458
- `);
459
- }
460
- function renderEnvProduction(manifest) {
461
- const keys = allEnvKeys(manifest).filter((key) => key.plaintext);
462
- const values = {
463
- SHOP_DOMAIN: manifest.shop.domain,
464
- GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
465
- SHOP_ZONE: zone(manifest),
466
- EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`,
467
- ADYEN_ENVIRONMENT: "live",
468
- BC_ENVIRONMENT: "production"
469
- };
470
- return [
471
- "# Production values that are NOT secrets.",
472
- "#",
473
- "# On `void deploy --backend cloudflare` every .env* file is baked into the",
474
- "# worker's vars as PLAINTEXT, so this holds only values safe to commit. Every",
475
- "# credential is `wrangler secret put <NAME>` instead.",
476
- "",
477
- ...keys.map((key) => `${key.key}=${values[key.key] ?? key.dev ?? ""}`),
478
- ""
479
- ].join(`
480
- `);
481
- }
482
- function envSummary(manifest) {
483
- const keys = allEnvKeys(manifest);
484
- return {
485
- secrets: keys.filter((key) => !key.plaintext).map((key) => key.key),
486
- plaintext: keys.filter((key) => key.plaintext).map((key) => key.key)
487
- };
488
- }
489
-
490
362
  // src/generate/frontend.ts
491
363
  var FRONTEND_OUT = "dist/client";
492
364
  var FRONTEND_BRANCH = "frontend-static";
@@ -841,6 +713,7 @@ function strictDependencies(manifest) {
841
713
  for (const name of [
842
714
  "@hono/node-server",
843
715
  "@dotenvx/dotenvx",
716
+ "husky",
844
717
  "@rolldown/plugin-babel",
845
718
  "@tailwindcss/vite",
846
719
  "@types/node",
@@ -1437,7 +1310,7 @@ import color from "picocolors";
1437
1310
  // package.json
1438
1311
  var package_default = {
1439
1312
  name: "@saastemly/voidcommerce",
1440
- version: "0.3.0",
1313
+ version: "0.5.0",
1441
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`.",
1442
1315
  type: "module",
1443
1316
  license: "MIT",
@@ -1471,6 +1344,7 @@ var package_default = {
1471
1344
  },
1472
1345
  dependencies: {
1473
1346
  "@clack/prompts": "^0.11.0",
1347
+ "@dotenvx/primitives": "^2.2.0",
1474
1348
  picocolors: "^1.1.1"
1475
1349
  },
1476
1350
  devDependencies: {
@@ -1634,7 +1508,7 @@ import { existsSync as existsSync5 } from "node:fs";
1634
1508
  import { dirname as dirname3, join as join5 } from "node:path";
1635
1509
 
1636
1510
  // src/generate/index.ts
1637
- 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";
1638
1512
  import { existsSync as existsSync4, lstatSync } from "node:fs";
1639
1513
  import { dirname as dirname2, join as join4 } from "node:path";
1640
1514
 
@@ -1765,36 +1639,38 @@ async function distHelp() {
1765
1639
 
1766
1640
  // src/generate/ci.ts
1767
1641
  function renderDistWorkflow(manifest) {
1768
- const worker = manifest.shop.domain.split(".")[0];
1769
- return `name: Build the deployable app
1642
+ return `name: Deploy the shop
1770
1643
 
1771
1644
  # Generated by \`vc init\` from voidcommerce.json.
1772
1645
  #
1773
- # Every push to main regenerates the Void app from the manifest and force-pushes
1774
- # it to \`${DIST_BRANCH}\`. Cloudflare's Workers Builds watches THAT branch and
1775
- # deploys it, so a push here is a deploy — and no Cloudflare credential is
1776
- # 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\`:
1777
1650
  #
1778
- # The Cloudflare side is configured once, in the dashboard, for the Worker
1779
- # named \`${worker}\` (Settings → Builds). DEPLOY.md has the settings and the
1780
- # one non-obvious part: the build's API token needs D1:Edit, which the token
1781
- # Cloudflare generates for you does not have.
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)
1654
+ #
1655
+ # Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
1656
+ # on your machine. See DEPLOY.md.
1782
1657
  on:
1783
1658
  push:
1784
1659
  branches: [main, master]
1785
1660
  workflow_dispatch:
1786
1661
 
1787
- # 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.
1788
1664
  concurrency:
1789
- group: ${DIST_BRANCH}-\${{ github.repository }}
1665
+ group: deploy-\${{ github.repository }}
1790
1666
  cancel-in-progress: true
1791
1667
 
1792
- # contents: write is what lets GITHUB_TOKEN force-push the branch. Nothing else.
1793
1668
  permissions:
1794
1669
  contents: write
1795
1670
 
1796
1671
  jobs:
1797
- build:
1672
+ # ── Regenerate, and publish the standalone tree ────────────────────────
1673
+ dist:
1798
1674
  runs-on: ubuntu-latest
1799
1675
  steps:
1800
1676
  - uses: actions/checkout@v6
@@ -1835,6 +1711,50 @@ jobs:
1835
1711
  git add -A
1836
1712
  git commit -q -m "\${{ github.sha }} — ${manifest.shop.domain}"
1837
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"
1838
1758
  `;
1839
1759
  }
1840
1760
  function renderDeployReadme(manifest, zone2, hosts) {
@@ -1844,132 +1764,117 @@ function renderDeployReadme(manifest, zone2, hosts) {
1844
1764
  \`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
1845
1765
  regenerate with \`vc generate\`.
1846
1766
 
1847
- ## The loop, once it is set up
1848
-
1849
- Push to \`main\`. GitHub Actions regenerates the app from \`voidcommerce.json\`
1850
- and force-pushes it to \`${DIST_BRANCH}\`; Cloudflare's Workers Builds builds that
1851
- branch and deploys it. Nothing else runs.
1852
-
1853
- Your secrets survive every deploy — \`wrangler deploy\` never deletes a secret.
1854
- Plaintext \`vars\` are replaced from \`.env.production\` on each deploy, which is
1855
- why no credential is allowed in that file.
1856
-
1857
- ## Once, before the first push
1858
-
1859
- Each of these either creates something in your Cloudflare account or holds a
1860
- credential, so none of them can live in a repository.
1861
-
1862
- ### 1. The zone
1863
-
1864
- \`${zone2}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
1865
- creates in its own zone, so a domain hosted anywhere else cannot have one. The
1866
- worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
1867
-
1868
- ### 2. The database, the queue, and the account id
1767
+ ## The loop
1869
1768
 
1870
1769
  \`\`\`sh
1871
- vc deploy --cloudflare --provision
1770
+ git push
1872
1771
  \`\`\`
1873
1772
 
1874
- Idempotent: it creates nothing that already exists. It pins your account id,
1875
- creates the D1 database and the queue, and records both in \`wrangler.jsonc\`
1876
- and \`voidcommerce.json\` so every later generate carries the real ids.
1877
- **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.
1878
1777
 
1879
- ### 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.
1880
1781
 
1881
- They live in the repository, encrypted:
1782
+ ## Once, before the first push
1882
1783
 
1883
1784
  \`\`\`sh
1884
- vc secrets --init # every required key, as \`unset\`
1885
- # put the real values in, then
1886
- 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
1887
1787
  \`\`\`
1888
1788
 
1889
- \`.env.secrets\` is committed and \`.env.keys\` is not. The deploy decrypts it and
1890
- hands the values to \`wrangler deploy --secrets-file\`, which stores them as
1891
- real Worker secrets — not as plaintext \`vars\`, which anyone with dashboard
1892
- access can read.
1789
+ \`vc link\` does three things and stores all of them on the GitHub repository:
1893
1790
 
1894
- That turns one \`wrangler secret put\` per value into one build variable, set
1895
- once in step 5. It also means the shop rebuilds from a checkout.
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 |
1896
1796
 
1897
- **Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
1898
- leaks, every secret in the history is readable, including ones you rotated.
1899
- \`wrangler secret put\` does not have that property, and stays available for
1900
- anything you would rather never commit. \`vc preflight\` counts a secret the
1901
- repository declares as present, and refuses any value committed in the clear.
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:
1902
1799
 
1903
- ### 4. An API token the build can use
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.
1904
1807
 
1905
- This is the one non-obvious step. Cloudflare generates an API token for
1906
- Workers Builds automatically, and **that token has no D1 permission** — its
1907
- scopes are Workers Scripts, KV, R2, Workers Routes and account/user reads. The
1908
- deploy command below applies database migrations, so it needs more.
1808
+ Create the token at **My Profile API Tokens → Create Token → Custom token**:
1909
1809
 
1910
- Create a token at **My Profile → API Tokens** with:
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 |
1911
1820
 
1912
- | scope | permission |
1913
- |---|---|
1914
- | Account | Workers Scripts: Edit |
1915
- | Account | D1: Edit |
1916
- | Account | Workers KV Storage: Edit, Workers R2 Storage: Edit |
1917
- | Account | Queues: Edit |
1918
- | Account | Account Settings: Read |
1919
- | Zone | Workers Routes: Edit (${zone2}) |
1920
- | User | User Details: Read, Memberships: Read |
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.
1921
1823
 
1922
- Then select it in the build settings below. Use the same token for every
1923
- deploy of this Worker; permissions are whatever that token has.
1824
+ ### The zone
1924
1825
 
1925
- ### 5. Connect the repository
1926
-
1927
- In the Cloudflare dashboard, on the Worker named \`${worker}\`, under
1928
- **Settings → Builds → Connect**. The Worker's name must equal the \`name\` in
1929
- 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 ")}.
1930
1829
 
1931
- | setting | value |
1932
- |---|---|
1933
- | branch | \`${DIST_BRANCH}\` |
1934
- | root directory | \`/\` |
1935
- | build command | \`bun install && bunx void prepare && bunx vp build\` |
1936
- | deploy command | see below |
1937
- | build variable | \`DOTENV_PRIVATE_KEY_SECRETS\`, marked as a secret — the one value that is not in the repository |
1938
- | API token | the one from step 4 |
1830
+ ## Secrets
1939
1831
 
1940
- The deploy command, on one line:
1832
+ They live in the repository, encrypted, in \`${SECRETS_FILE}\`:
1941
1833
 
1942
1834
  \`\`\`sh
1943
- 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
1944
1837
  \`\`\`
1945
1838
 
1946
- Plain \`sh\`, so it does not rely on process substitution. The decrypted file
1947
- exists only inside the build sandbox, and \`--secrets-file\` applies additively:
1948
- 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.
1949
1843
 
1950
- The migration command names the **binding** (\`DB\`), not the database, so it
1951
- 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.
1952
1846
 
1953
- Cloudflare's build image has Bun; pin its version with a \`BUN_VERSION\` build
1954
- variable if you ever need to. The free plan allows 3,000 build minutes a month
1955
- 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.
1956
1851
 
1957
- ### 6. The catalogue
1852
+ ## The catalogue
1958
1853
 
1959
- \`vc import\` pushes \`data/\` into the running shop. It is an upsert, so it is
1960
- 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.
1961
1856
 
1962
- ## Without Cloudflare's build
1857
+ ## Doing it by hand
1963
1858
 
1964
- 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:
1965
1860
 
1966
1861
  \`\`\`sh
1967
- vc deploy --cloudflare
1862
+ export CLOUDFLARE_API_TOKEN=…
1863
+ export ${PRIVATE_KEY_VAR}=… # only if you kept a copy
1864
+ vc deploy --cloudflare --provision
1968
1865
  \`\`\`
1969
1866
 
1970
- which preflights, builds, strips the baked development values out of the
1971
- worker's vars, applies the remote migrations, and deploys exactly what it
1972
- 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.
1973
1878
  `;
1974
1879
  }
1975
1880
 
@@ -2308,6 +2213,13 @@ async function put(root, file, content, result, mode) {
2308
2213
  await writeFile2(path, content, "utf8");
2309
2214
  result.written.push(file);
2310
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
+ }
2311
2223
  async function mergeJson(root, file, fallback, mutate, result) {
2312
2224
  const path = join4(root, file);
2313
2225
  const json = await exists(path) ? JSON.parse(await readFile2(path, "utf8")) : { ...fallback };
@@ -2331,7 +2243,7 @@ async function link(root, file, target, result) {
2331
2243
  }
2332
2244
  var sorted = (record) => Object.fromEntries(Object.entries(record).sort());
2333
2245
  async function generate(root, manifest) {
2334
- const result = { written: [], kept: [], packages: packagesOf(manifest) };
2246
+ const result = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
2335
2247
  await writeManifest(root, manifest);
2336
2248
  result.written.push(MANIFEST_FILE);
2337
2249
  switch (manifest.layout) {
@@ -2478,6 +2390,8 @@ async function generateStrictRoot(root, manifest, result) {
2478
2390
  scripts["deploy"] ??= "vc deploy";
2479
2391
  scripts["import:catalog"] ??= "vc import";
2480
2392
  scripts["maildev"] ??= "maildev --smtp 1025 --web 1080";
2393
+ scripts["secrets"] ??= "vc secrets";
2394
+ scripts["prepare"] ??= "husky";
2481
2395
  pkg["scripts"] = scripts;
2482
2396
  }, result);
2483
2397
  await put(root, ".gitignore", `node_modules
@@ -2492,7 +2406,14 @@ dist
2492
2406
  .DS_Store
2493
2407
  `, result, "own");
2494
2408
  await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
2495
- await put(root, ".github/workflows/void-dist.yml", renderDistWorkflow(manifest), result, "regenerate");
2409
+ await put(root, ".husky/pre-commit", `#!/usr/bin/env sh
2410
+ # Generated by \`vc init\`. Refuses a commit that would put a secret in the
2411
+ # clear in .env.secrets — which cannot be undone by a later commit,
2412
+ # because the value stays in the history.
2413
+ bunx vc guard
2414
+ `, result, "regenerate");
2415
+ await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
2416
+ await retire(root, ".github/workflows/void-dist.yml", result);
2496
2417
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
2497
2418
  await put(root, ".env", renderEnvLocal(manifest), result, "own");
2498
2419
  await put(root, "data/README.md", DATA_README, result, "own");
@@ -2856,196 +2777,16 @@ ${ran.out.trim()}`);
2856
2777
  }
2857
2778
 
2858
2779
  // src/deploy/preflight.ts
2859
- import { existsSync as existsSync8, readFileSync as readFileSync3 } from "node:fs";
2860
- import { join as join8 } from "node:path";
2861
- import color4 from "picocolors";
2862
-
2863
- // src/deploy/secrets.ts
2864
- import { existsSync as existsSync7, readFileSync as readFileSync2, writeFileSync } from "node:fs";
2865
- import { spawn as spawn3 } from "node:child_process";
2866
- import { delimiter as delimiter3, dirname as dirname5, join as join7 } from "node:path";
2867
- import { tmpdir } from "node:os";
2780
+ import { existsSync as existsSync7, readFileSync as readFileSync2 } from "node:fs";
2781
+ import { join as join7 } from "node:path";
2868
2782
  import color3 from "picocolors";
2869
- var SECRETS_FILE = ".env.secrets";
2870
- var PRIVATE_KEY_VAR = "DOTENV_PRIVATE_KEY_SECRETS";
2871
- function findDotenvx(from) {
2872
- let dir = from;
2873
- for (;; ) {
2874
- const local = join7(dir, "node_modules", ".bin", "dotenvx");
2875
- if (existsSync7(local))
2876
- return local;
2877
- const parent = dirname5(dir);
2878
- if (parent === dir)
2879
- break;
2880
- dir = parent;
2881
- }
2882
- return (process.env["PATH"] ?? "").split(delimiter3).filter(Boolean).some((entry) => existsSync7(join7(entry, "dotenvx"))) ? "dotenvx" : null;
2883
- }
2884
- function run(cmd, args, cwd, env = {}) {
2885
- return new Promise((resolve) => {
2886
- const child = spawn3(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
2887
- let out = "";
2888
- child.stdout.on("data", (chunk) => {
2889
- out += chunk;
2890
- });
2891
- child.stderr.on("data", (chunk) => {
2892
- out += chunk;
2893
- });
2894
- child.on("error", (error) => resolve({ code: 1, out: String(error) }));
2895
- child.on("exit", (code) => resolve({ code: code ?? 1, out }));
2896
- });
2897
- }
2898
- function declaredSecretNames(root) {
2899
- const path = join7(root, SECRETS_FILE);
2900
- if (!existsSync7(path))
2901
- return new Set;
2902
- const names = new Set;
2903
- for (const line2 of readFileSync2(path, "utf8").split(`
2904
- `)) {
2905
- const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line2);
2906
- if (match && !match[1].startsWith("DOTENV_"))
2907
- names.add(match[1]);
2908
- }
2909
- return names;
2910
- }
2911
- function plaintextSecretNames(root) {
2912
- const path = join7(root, SECRETS_FILE);
2913
- if (!existsSync7(path))
2914
- return [];
2915
- const bare = [];
2916
- for (const line2 of readFileSync2(path, "utf8").split(`
2917
- `)) {
2918
- const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line2);
2919
- if (!match || match[1].startsWith("DOTENV_"))
2920
- continue;
2921
- const value = match[2].trim().replace(/^['"]|['"]$/g, "");
2922
- if (value && value !== "unset" && !value.startsWith("encrypted:"))
2923
- bare.push(match[1]);
2924
- }
2925
- return bare;
2926
- }
2927
- async function decryptSecrets(project) {
2928
- const root = project.root;
2929
- if (!existsSync7(join7(root, SECRETS_FILE)))
2930
- return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
2931
- const dotenvx = findDotenvx(root);
2932
- if (!dotenvx)
2933
- return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
2934
- if (!process.env[PRIVATE_KEY_VAR] && !existsSync7(join7(root, ".env.keys"))) {
2935
- return {
2936
- error: `${PRIVATE_KEY_VAR} is not set and there is no .env.keys here.
2937
- It is the one value that stays out of the repository; set it where the deploy runs.`
2938
- };
2939
- }
2940
- const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root);
2941
- if (result.code !== 0)
2942
- return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split(`
2943
- `).slice(-2).join(" ")}` };
2944
- const lines = result.out.split(`
2945
- `).filter((line2) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line2) && !line2.trimStart().startsWith("DOTENV_"));
2946
- const names = lines.map((line2) => line2.slice(0, line2.indexOf("=")).trim());
2947
- const path = join7(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
2948
- writeFileSync(path, `${lines.join(`
2949
- `)}
2950
- `, { mode: 384 });
2951
- return {
2952
- path,
2953
- names,
2954
- cleanup: () => {
2955
- try {
2956
- writeFileSync(path, "", { mode: 384 });
2957
- __require("node:fs").unlinkSync(path);
2958
- } catch {}
2959
- }
2960
- };
2961
- }
2962
- async function secretsCommand(project, args) {
2963
- const root = project.root;
2964
- const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
2965
- const declared = declaredSecretNames(root);
2966
- const bare = plaintextSecretNames(root);
2967
- if (args.includes("--init"))
2968
- return initSecrets(project);
2969
- if (!existsSync7(join7(root, SECRETS_FILE))) {
2970
- console.log(`
2971
- ${color3.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
2972
- ` + `which means the shop cannot be rebuilt from a checkout.
2973
-
2974
- ` + ` vc secrets --init write one, with every required key as \`unset\`
2975
- `);
2976
- return 1;
2977
- }
2978
- console.log(`
2979
- ${SECRETS_FILE} — committed, encrypted, and read by \`vc deploy --cloudflare\`
2980
- `);
2981
- for (const key of required) {
2982
- const state = declared.has(key.key) ? color3.green("declared") : color3.red("MISSING ");
2983
- console.log(` ${state} ${key.key}`);
2984
- }
2985
- const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
2986
- for (const name of extra)
2987
- console.log(` ${color3.dim("extra ")} ${name} ${color3.dim("— not required by this shop")}`);
2988
- if (bare.length > 0) {
2989
- console.log(`
2990
- ${color3.red("✗")} ${bare.length} value${bare.length === 1 ? " is" : "s are"} committed IN THE CLEAR: ${bare.join(", ")}
2991
- ` + ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` before committing again.
2992
- `);
2993
- return 1;
2994
- }
2995
- const missing = required.filter((key) => !declared.has(key.key));
2996
- if (missing.length > 0) {
2997
- console.log(`
2998
- ${color3.red("✗")} ${missing.length} required secret${missing.length === 1 ? "" : "s"} not declared.
2999
- ` + ` dotenvx set ${missing[0].key} '…' -f ${SECRETS_FILE}
3000
- `);
3001
- return 1;
3002
- }
3003
- console.log(`
3004
- ${color3.green("✓")} every secret this shop needs is declared and encrypted.
3005
- `);
3006
- return 0;
3007
- }
3008
- async function initSecrets(project) {
3009
- const path = join7(project.root, SECRETS_FILE);
3010
- if (existsSync7(path)) {
3011
- console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
3012
- return 1;
3013
- }
3014
- const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
3015
- const body = [
3016
- "# Secrets, encrypted, and COMMITTED.",
3017
- "#",
3018
- "# Values are ciphertext; the key names are readable so a diff shows WHICH",
3019
- "# secret changed without showing what it changed to. `unset` is the",
3020
- "# documented placeholder and preflight refuses it.",
3021
- "#",
3022
- "# The private key stays out of the repository. It lives in .env.keys",
3023
- "# locally (gitignored) and as one build variable where the deploy runs.",
3024
- "",
3025
- ...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""])
3026
- ].join(`
3027
- `);
3028
- writeFileSync(path, body, { mode: 384 });
3029
- console.log(`
3030
- ${color3.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`
3031
-
3032
- ` + `Next:
3033
- ` + ` 1. put the real values in, then
3034
- ` + ` 2. ${color3.cyan(`dotenvx encrypt -f ${SECRETS_FILE}`)}
3035
- ` + ` 3. commit ${SECRETS_FILE}; never commit .env.keys
3036
- ` + ` 4. set ${PRIVATE_KEY_VAR} where the deploy runs
3037
- `);
3038
- return 0;
3039
- }
3040
-
3041
- // src/deploy/preflight.ts
3042
2783
  var UNSET = "unset";
3043
2784
  function productionEnv(appDir) {
3044
2785
  const out = new Map;
3045
- const path = join8(appDir, ".env.production");
3046
- if (!existsSync8(path))
2786
+ const path = join7(appDir, ".env.production");
2787
+ if (!existsSync7(path))
3047
2788
  return out;
3048
- for (const line2 of readFileSync3(path, "utf8").split(`
2789
+ for (const line2 of readFileSync2(path, "utf8").split(`
3049
2790
  `)) {
3050
2791
  const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line2);
3051
2792
  if (!match)
@@ -3070,12 +2811,12 @@ async function voidSecretNames(appDir) {
3070
2811
  return names;
3071
2812
  }
3072
2813
  function routeProblem(project) {
3073
- const path = join8(project.appDir, "wrangler.jsonc");
3074
- if (!existsSync8(path))
2814
+ const path = join7(project.appDir, "wrangler.jsonc");
2815
+ if (!existsSync7(path))
3075
2816
  return "wrangler.jsonc is missing";
3076
2817
  let routes = [];
3077
2818
  try {
3078
- routes = parseJsonc(readFileSync3(path, "utf8")).routes ?? [];
2819
+ routes = parseJsonc(readFileSync2(path, "utf8")).routes ?? [];
3079
2820
  } catch {
3080
2821
  return "wrangler.jsonc cannot be parsed";
3081
2822
  }
@@ -3115,7 +2856,7 @@ async function preflight(project, source) {
3115
2856
  function printPreflight(project, result, source) {
3116
2857
  const keys = allEnvKeys(project.manifest);
3117
2858
  if (result.remote === null) {
3118
- console.log(color4.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.
3119
2860
  Anything not in .env.production is reported as missing.
3120
2861
  ` : `Could not read the project's secrets from Void — not logged in, or no linked project.
3121
2862
  Anything not in .env.production is reported as missing.
@@ -3125,37 +2866,37 @@ Anything not in .env.production is reported as missing.
3125
2866
  for (const key of keys) {
3126
2867
  const value = result.present.get(key.key);
3127
2868
  const set = value !== undefined && value !== "" && value !== UNSET;
3128
- console.log(` ${set ? color4.green("set ") : color4.dim("unset")} ${key.key}${key.plaintext ? color4.dim(" (plaintext)") : ""}`);
2869
+ console.log(` ${set ? color3.green("set ") : color3.dim("unset")} ${key.key}${key.plaintext ? color3.dim(" (plaintext)") : ""}`);
3129
2870
  }
3130
2871
  if (result.ready) {
3131
2872
  console.log(`
3132
- ${color4.green("Ready to go live.")}
2873
+ ${color3.green("Ready to go live.")}
3133
2874
  `);
3134
2875
  return;
3135
2876
  }
3136
2877
  console.log(`
3137
- ${color4.red("NOT ready to go live.")}
2878
+ ${color3.red("NOT ready to go live.")}
3138
2879
  `);
3139
2880
  if (result.routeProblem) {
3140
- console.log(`${color4.red("✗")} the worker's hostname
2881
+ console.log(`${color3.red("✗")} the worker's hostname
3141
2882
  ${result.routeProblem}
3142
2883
  `);
3143
2884
  }
3144
2885
  if (result.bareSecrets.length > 0) {
3145
- console.log(`${color4.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(", ")}
3146
2887
  ` + ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` — and treat those values as burned.
3147
2888
  `);
3148
2889
  }
3149
2890
  for (const key of result.missing) {
3150
- console.log(`${color4.red("✗")} ${key.key}`);
2891
+ console.log(`${color3.red("✗")} ${key.key}`);
3151
2892
  console.log(` ${key.breaks}`);
3152
2893
  if (key.where)
3153
- console.log(` ${color4.dim(`from: ${key.where}`)}`);
2894
+ console.log(` ${color3.dim(`from: ${key.where}`)}`);
3154
2895
  console.log("");
3155
2896
  }
3156
2897
  const secrets = result.missing.filter((key) => !key.plaintext);
3157
2898
  if (secrets.length > 0) {
3158
- const inRepo = existsSync8(join8(project.root, SECRETS_FILE));
2899
+ const inRepo = existsSync7(join7(project.root, SECRETS_FILE));
3159
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:");
3160
2901
  for (const key of secrets) {
3161
2902
  console.log(inRepo ? ` dotenvx set ${key.key} '…' -f ${SECRETS_FILE}` : ` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
@@ -3168,17 +2909,17 @@ ${color4.red("NOT ready to go live.")}
3168
2909
  }
3169
2910
 
3170
2911
  // src/deploy/cloudflare.ts
3171
- import { existsSync as existsSync9, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
3172
- import { dirname as dirname6, join as join9 } from "node:path";
3173
- import color5 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";
3174
2915
  var fail = (message) => {
3175
2916
  console.error(`
3176
- ${color5.red("✗")} ${message}
2917
+ ${color4.red("✗")} ${message}
3177
2918
  `);
3178
2919
  return 1;
3179
2920
  };
3180
2921
  function readConfig(path) {
3181
- return parseJsonc(readFileSync4(path, "utf8"));
2922
+ return parseJsonc(readFileSync3(path, "utf8"));
3182
2923
  }
3183
2924
  function findBuilder(from) {
3184
2925
  let dir = from;
@@ -3187,11 +2928,11 @@ function findBuilder(from) {
3187
2928
  ["vp", "vp build"],
3188
2929
  ["vite", "vite build"]
3189
2930
  ]) {
3190
- const path = join9(dir, "node_modules", ".bin", bin);
3191
- if (existsSync9(path))
2931
+ const path = join8(dir, "node_modules", ".bin", bin);
2932
+ if (existsSync8(path))
3192
2933
  return { cmd: path, label };
3193
2934
  }
3194
- const parent = dirname6(dir);
2935
+ const parent = dirname5(dir);
3195
2936
  if (parent === dir)
3196
2937
  return null;
3197
2938
  dir = parent;
@@ -3199,8 +2940,8 @@ function findBuilder(from) {
3199
2940
  }
3200
2941
  async function deployCloudflare(project, opts) {
3201
2942
  const app = project.appDir;
3202
- const configPath = join9(app, "wrangler.jsonc");
3203
- if (!existsSync9(configPath))
2943
+ const configPath = join8(app, "wrangler.jsonc");
2944
+ if (!existsSync8(configPath))
3204
2945
  return fail("wrangler.jsonc is missing — `vc generate` writes it.");
3205
2946
  const bin = findWrangler(app);
3206
2947
  if (!bin)
@@ -3213,16 +2954,16 @@ async function deployCloudflare(project, opts) {
3213
2954
  ${who.raw.trim().split(`
3214
2955
  `).slice(-4).join(`
3215
2956
  `)}`);
3216
- console.log(`${color5.green("✓")} wrangler is logged in`);
2957
+ console.log(`${color4.green("✓")} wrangler is logged in`);
3217
2958
  let config = readConfig(configPath);
3218
2959
  let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
3219
2960
  if (!accountId) {
3220
2961
  if (who.accounts.length === 1) {
3221
2962
  accountId = who.accounts[0].id;
3222
- writeFileSync2(configPath, upsertJsonc(readFileSync4(configPath, "utf8"), "account_id", accountId));
2963
+ writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "account_id", accountId));
3223
2964
  project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
3224
2965
  await writeManifest(project.root, project.manifest);
3225
- console.log(`${color5.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`);
3226
2967
  } else {
3227
2968
  return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
3228
2969
  ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
@@ -3234,7 +2975,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3234
2975
  if (!check.ready) {
3235
2976
  if (!opts.force)
3236
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.");
3237
- console.error(color5.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.
3238
2979
  `));
3239
2980
  }
3240
2981
  const worker = config.name || project.manifest.shop.domain.split(".")[0];
@@ -3245,15 +2986,15 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3245
2986
  return fail("the D1 database is not provisioned. Run once with --provision to create it and record its id.");
3246
2987
  const db = await ensureD1(bin, app, `${worker}-db`);
3247
2988
  const entry = { binding: "DB", database_name: db.name, database_id: db.uuid, migrations_dir: "./db/migrations" };
3248
- writeFileSync2(configPath, upsertJsonc(readFileSync4(configPath, "utf8"), "d1_databases", [entry]));
2989
+ writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "d1_databases", [entry]));
3249
2990
  project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
3250
2991
  await writeManifest(project.root, project.manifest);
3251
- console.log(`${color5.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`);
3252
2993
  config = readConfig(configPath);
3253
2994
  }
3254
2995
  if (opts.provision) {
3255
2996
  await ensureQueue(bin, app, "commerce");
3256
- console.log(`${color5.green("✓")} queue "commerce"`);
2997
+ console.log(`${color4.green("✓")} queue "commerce"`);
3257
2998
  }
3258
2999
  const builder = findBuilder(app);
3259
3000
  if (!builder)
@@ -3263,10 +3004,10 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3263
3004
  const built = await wrangler(builder.cmd, ["build"], app, true);
3264
3005
  if (built.code !== 0)
3265
3006
  return fail(`the build failed (exit ${built.code}).`);
3266
- const emittedPath = join9(app, "dist", "ssr", "wrangler.json");
3267
- if (!existsSync9(emittedPath))
3007
+ const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
3008
+ if (!existsSync8(emittedPath))
3268
3009
  return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
3269
- const emitted = JSON.parse(readFileSync4(emittedPath, "utf8"));
3010
+ const emitted = JSON.parse(readFileSync3(emittedPath, "utf8"));
3270
3011
  const secretKeys = new Set(allEnvKeys(project.manifest).filter((key) => !key.plaintext).map((key) => key.key));
3271
3012
  const scrubbed = [];
3272
3013
  for (const [key, value] of Object.entries(emitted.vars ?? {})) {
@@ -3278,8 +3019,8 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3278
3019
  const emittedD1 = emitted.d1_databases?.find((db) => db.binding === "DB");
3279
3020
  if (!emittedD1 || emittedD1.database_id === "local")
3280
3021
  return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
3281
- writeFileSync2(emittedPath, JSON.stringify(emitted, null, 2));
3282
- console.log(`${color5.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(", ")}` : ""}`);
3283
3024
  console.log(`
3284
3025
  ▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
3285
3026
  const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
@@ -3287,34 +3028,34 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
3287
3028
  return fail(`applying migrations failed (exit ${migrated.code}); nothing was deployed.`);
3288
3029
  const secretArgs = [];
3289
3030
  let cleanupSecrets;
3290
- if (existsSync9(join9(project.root, SECRETS_FILE))) {
3031
+ if (existsSync8(join8(project.root, SECRETS_FILE))) {
3291
3032
  const decrypted = await decryptSecrets(project);
3292
3033
  if ("error" in decrypted)
3293
3034
  return fail(`the repository's secrets could not be read: ${decrypted.error}`);
3294
3035
  secretArgs.push("--secrets-file", decrypted.path);
3295
3036
  cleanupSecrets = decrypted.cleanup;
3296
- console.log(`${color5.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`);
3297
3038
  }
3298
3039
  console.log(`
3299
3040
  ▸ wrangler deploy -c dist/ssr/wrangler.json`);
3300
- const deployed = await wrangler(bin, ["deploy", "-c", join9("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
3041
+ const deployed = await wrangler(bin, ["deploy", "-c", join8("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
3301
3042
  cleanupSecrets?.();
3302
3043
  if (deployed.code !== 0)
3303
3044
  return fail(`wrangler deploy failed (exit ${deployed.code}).`);
3304
3045
  const domain = project.manifest.shop.domain;
3305
3046
  const url = `https://${workerHosts(project.manifest)[0]}`;
3306
3047
  console.log(`
3307
- ${color5.green("Live:")} ${url}`);
3048
+ ${color4.green("Live:")} ${url}`);
3308
3049
  if (hasFrontend(project.manifest.layout))
3309
- console.log(color5.dim("The storefront deploys itself from GitHub Actions on push."));
3050
+ console.log(color4.dim("The storefront deploys itself from GitHub Actions on push."));
3310
3051
  return 0;
3311
3052
  }
3312
3053
 
3313
3054
  // src/import.ts
3314
3055
  import { readFile as readFile3 } from "node:fs/promises";
3315
- import { existsSync as existsSync10 } from "node:fs";
3316
- import { join as join10 } from "node:path";
3317
- import color6 from "picocolors";
3056
+ import { existsSync as existsSync9 } from "node:fs";
3057
+ import { join as join9 } from "node:path";
3058
+ import color5 from "picocolors";
3318
3059
  function describe(counts) {
3319
3060
  if (!counts)
3320
3061
  return "";
@@ -3353,7 +3094,7 @@ async function post(url, key, body) {
3353
3094
  return { ok: response.ok, status: response.status, body: { ...parsed, error: parsed.error ?? parsed.message } };
3354
3095
  }
3355
3096
  async function readJson(path) {
3356
- if (!existsSync10(path))
3097
+ if (!existsSync9(path))
3357
3098
  return null;
3358
3099
  try {
3359
3100
  return JSON.parse(await readFile3(path, "utf8"));
@@ -3381,41 +3122,41 @@ async function importCommand(args) {
3381
3122
  }
3382
3123
  const { url, how } = resolveTarget(project, args);
3383
3124
  if (!dry)
3384
- console.log(`${color6.dim("→")} ${url} ${color6.dim(`(${how})`)}
3125
+ console.log(`${color5.dim("→")} ${url} ${color5.dim(`(${how})`)}
3385
3126
  `);
3386
3127
  if (!dry) {
3387
3128
  let whoami2;
3388
3129
  try {
3389
3130
  whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
3390
3131
  } catch (error) {
3391
- console.error(`${color6.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)}
3392
3133
  ` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
3393
3134
  `);
3394
3135
  return 1;
3395
3136
  }
3396
3137
  if (!whoami2.ok) {
3397
- console.error(`${color6.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
3138
+ console.error(`${color5.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
3398
3139
  ` + ` Is SYSTEM_API_KEY the one this deployment was given?
3399
3140
  `);
3400
3141
  return 1;
3401
3142
  }
3402
- console.log(`${color6.green("✓")} authenticated as the system identity`);
3143
+ console.log(`${color5.green("✓")} authenticated as the system identity`);
3403
3144
  }
3404
3145
  const root = project.root;
3405
- const products = await readJson(join10(root, "data", "catalog.json"));
3406
- const categories = await readJson(join10(root, "data", "categories.json"));
3407
- const faqs = has(project.manifest, "faqs") ? await readJson(join10(root, "content", "faqs.json")) : null;
3408
- const posts = has(project.manifest, "blogs") ? await readJson(join10(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;
3409
3150
  if (!products && !faqs?.length && !posts?.length) {
3410
3151
  console.log(`
3411
- ${color6.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.
3412
3153
  ` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
3413
3154
  `);
3414
3155
  return 0;
3415
3156
  }
3416
3157
  if (dry) {
3417
3158
  console.log(`
3418
- ${color6.dim("--dry-run: nothing was pushed.")}`);
3159
+ ${color5.dim("--dry-run: nothing was pushed.")}`);
3419
3160
  console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
3420
3161
  console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
3421
3162
  `);
@@ -3431,15 +3172,15 @@ ${color6.dim("--dry-run: nothing was pushed.")}`);
3431
3172
  });
3432
3173
  if (result.ok) {
3433
3174
  const report = result.body.report ?? {};
3434
- console.log(`${color6.green("✓")} products: ${describe(report.products)}`);
3175
+ console.log(`${color5.green("✓")} products: ${describe(report.products)}`);
3435
3176
  if (report.categories)
3436
- console.log(`${color6.green("✓")} categories: ${describe(report.categories)}`);
3177
+ console.log(`${color5.green("✓")} categories: ${describe(report.categories)}`);
3437
3178
  if (report.prices)
3438
- console.log(`${color6.green("✓")} prices: ${describe(report.prices)}`);
3179
+ console.log(`${color5.green("✓")} prices: ${describe(report.prices)}`);
3439
3180
  if (report.addons)
3440
- console.log(`${color6.green("✓")} addons: ${describe(report.addons)}`);
3181
+ console.log(`${color5.green("✓")} addons: ${describe(report.addons)}`);
3441
3182
  } else {
3442
- console.error(`${color6.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
3183
+ console.error(`${color5.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
3443
3184
  failed = true;
3444
3185
  }
3445
3186
  }
@@ -3451,20 +3192,20 @@ ${color6.dim("--dry-run: nothing was pushed.")}`);
3451
3192
  continue;
3452
3193
  const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
3453
3194
  if (result.ok) {
3454
- console.log(`${color6.green("✓")} ${label}: ${describe(result.body)}`);
3195
+ console.log(`${color5.green("✓")} ${label}: ${describe(result.body)}`);
3455
3196
  } else {
3456
- console.error(`${color6.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
3197
+ console.error(`${color5.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
3457
3198
  failed = true;
3458
3199
  }
3459
3200
  }
3460
3201
  if (failed) {
3461
3202
  console.error(`
3462
- ${color6.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.
3463
3204
  `);
3464
3205
  return 1;
3465
3206
  }
3466
3207
  console.log(`
3467
- ${color6.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.
3468
3209
  `);
3469
3210
  return 0;
3470
3211
  }
@@ -3485,4 +3226,4 @@ async function importHelp() {
3485
3226
  return 0;
3486
3227
  }
3487
3228
 
3488
- 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, SECRETS_FILE, PRIVATE_KEY_VAR, 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 };