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