@saastemly/voidcommerce 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +263 -72
- 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/index.d.ts +2 -0
- package/dist/{index-yzezvy5h.js → index-5m3t0zfc.js} +228 -650
- package/dist/index-b9b4dawy.js +797 -0
- package/dist/index.js +9 -7
- package/dist/keys-b91c72zp.js +19 -0
- package/package.json +1 -1
- package/src/cli.ts +14 -1
- 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 +168 -121
- package/src/generate/index.ts +22 -3
- package/src/init.ts +1 -0
- package/src/regenerate.ts +1 -0
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PRIVATE_KEY_VAR,
|
|
3
|
+
SECRETS_FILE,
|
|
4
|
+
allEnvKeys,
|
|
5
|
+
declaredSecretNames,
|
|
6
|
+
decryptSecrets,
|
|
7
|
+
plaintextSecretNames,
|
|
8
|
+
renderEnvExample,
|
|
9
|
+
renderEnvLocal,
|
|
10
|
+
renderEnvProduction,
|
|
11
|
+
renderEnvTs
|
|
12
|
+
} from "./index-b9b4dawy.js";
|
|
1
13
|
import {
|
|
2
14
|
MANIFEST_FILE,
|
|
3
|
-
envKeysOf,
|
|
4
15
|
has,
|
|
5
16
|
hasFrontend,
|
|
6
|
-
isApex,
|
|
7
17
|
isSingleApp,
|
|
8
18
|
packagesOf,
|
|
9
19
|
readManifest,
|
|
@@ -16,8 +26,7 @@ import {
|
|
|
16
26
|
CHOICES
|
|
17
27
|
} from "./index-844b3qn9.js";
|
|
18
28
|
import {
|
|
19
|
-
__require
|
|
20
|
-
__toESM
|
|
29
|
+
__require
|
|
21
30
|
} from "./index-0v6na3yp.js";
|
|
22
31
|
|
|
23
32
|
// src/generate/auth.ts
|
|
@@ -350,146 +359,6 @@ ${render("commerce").replace(/^/gm, "\t")}
|
|
|
350
359
|
}
|
|
351
360
|
var camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
352
361
|
|
|
353
|
-
// src/generate/env.ts
|
|
354
|
-
var BASE = [
|
|
355
|
-
{
|
|
356
|
-
key: "SHOP_DOMAIN",
|
|
357
|
-
breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
|
|
358
|
-
plaintext: true
|
|
359
|
-
},
|
|
360
|
-
{
|
|
361
|
-
key: "COMMERCE_CRON_SECRET",
|
|
362
|
-
breaks: "the scheduler cannot authenticate, so every background job stops",
|
|
363
|
-
where: "openssl rand -base64 32"
|
|
364
|
-
},
|
|
365
|
-
{
|
|
366
|
-
key: "COMMERCE_WEBHOOK_SECRET",
|
|
367
|
-
breaks: "payment webhooks cannot be verified, so no order ever becomes paid",
|
|
368
|
-
where: "the payment provider's dashboard"
|
|
369
|
-
},
|
|
370
|
-
{
|
|
371
|
-
key: "SHIPPING_DOMESTIC_MINOR",
|
|
372
|
-
breaks: "delivery is quoted at nothing, and every order loses the carrier cost",
|
|
373
|
-
plaintext: true,
|
|
374
|
-
dev: "4900",
|
|
375
|
-
where: "your carrier agreement, in minor units ex VAT"
|
|
376
|
-
},
|
|
377
|
-
{
|
|
378
|
-
key: "SHIPPING_FREE_FROM_MINOR",
|
|
379
|
-
breaks: "the free-delivery threshold is undefined",
|
|
380
|
-
plaintext: true,
|
|
381
|
-
dev: "100000"
|
|
382
|
-
}
|
|
383
|
-
];
|
|
384
|
-
function allEnvKeys(manifest) {
|
|
385
|
-
const keys = [...BASE, ...envKeysOf(manifest)];
|
|
386
|
-
if (!isApex(manifest)) {
|
|
387
|
-
keys.splice(1, 0, {
|
|
388
|
-
key: "SHOP_ZONE",
|
|
389
|
-
breaks: "DNS records are written to the wrong zone, or to none",
|
|
390
|
-
plaintext: true
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
if (hasFrontend(manifest.layout) && manifest.shop.pagesHost) {
|
|
394
|
-
keys.splice(1, 0, {
|
|
395
|
-
key: "GITHUB_PAGES_HOST",
|
|
396
|
-
breaks: "the www record has nothing to point at",
|
|
397
|
-
plaintext: true
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
return keys;
|
|
401
|
-
}
|
|
402
|
-
var NUMERIC = new Set(["SHIPPING_DOMESTIC_MINOR", "SHIPPING_FREE_FROM_MINOR"]);
|
|
403
|
-
function renderEnvTs(manifest) {
|
|
404
|
-
const keys = allEnvKeys(manifest);
|
|
405
|
-
const lines = keys.map((key) => {
|
|
406
|
-
const helper = NUMERIC.has(key.key) ? "number()" : "string()";
|
|
407
|
-
const doc = [` /** ${key.breaks}${key.where ? ` — from: ${key.where}` : ""} */`];
|
|
408
|
-
return `${doc.join(`
|
|
409
|
-
`)}
|
|
410
|
-
${key.key}: ${helper},`;
|
|
411
|
-
});
|
|
412
|
-
return `import { defineEnv, number, string } from "void/env";
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Every env key the app reads. All of them are REQUIRED.
|
|
416
|
-
*
|
|
417
|
-
* Generated by \`vc init\` from voidcommerce.json — edit the manifest and
|
|
418
|
-
* regenerate rather than editing this by hand.
|
|
419
|
-
*
|
|
420
|
-
* Nothing is optional: an integration this shop supports is one the
|
|
421
|
-
* deployment sets up. No key has a default, because a default is compiled
|
|
422
|
-
* into the worker's vars and shadows the real secret. Locally, the literal
|
|
423
|
-
* value \`unset\` is the one documented way to say "I do not have this yet",
|
|
424
|
-
* and \`vc preflight\` refuses it.
|
|
425
|
-
*/
|
|
426
|
-
export default defineEnv({
|
|
427
|
-
${lines.join(`
|
|
428
|
-
`)}
|
|
429
|
-
});
|
|
430
|
-
`;
|
|
431
|
-
}
|
|
432
|
-
function renderEnvExample(manifest) {
|
|
433
|
-
const keys = allEnvKeys(manifest);
|
|
434
|
-
return [
|
|
435
|
-
"# Every key is required. Copy to .env for local development.",
|
|
436
|
-
"# `unset` is the one value that reads as absent — for a credential you do not have yet.",
|
|
437
|
-
"",
|
|
438
|
-
...keys.map((key) => `${key.key}=${key.dev ?? (key.plaintext ? "" : "unset")}`),
|
|
439
|
-
""
|
|
440
|
-
].join(`
|
|
441
|
-
`);
|
|
442
|
-
}
|
|
443
|
-
function renderEnvLocal(manifest) {
|
|
444
|
-
const keys = allEnvKeys(manifest);
|
|
445
|
-
const local = {
|
|
446
|
-
SHOP_DOMAIN: `${manifest.shop.domain.split(".")[0]}.test`,
|
|
447
|
-
GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
|
|
448
|
-
SHOP_ZONE: zone(manifest),
|
|
449
|
-
COMMERCE_CRON_SECRET: "dev-cron-secret-not-for-production",
|
|
450
|
-
COMMERCE_WEBHOOK_SECRET: "whsec_dev_secret",
|
|
451
|
-
SYSTEM_API_KEY: "dev-system-key-0123456789abcdefghijklmnopqrstuvwxyz",
|
|
452
|
-
EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`
|
|
453
|
-
};
|
|
454
|
-
return [
|
|
455
|
-
"# Local development. Gitignored; .env.example documents every key.",
|
|
456
|
-
"",
|
|
457
|
-
...keys.map((key) => `${key.key}=${local[key.key] ?? key.dev ?? "unset"}`),
|
|
458
|
-
...hasFrontend(manifest.layout) ? ["", "# The storefront, reaching the worker. Start the worker first.", "VITE_API_ORIGIN=http://localhost:5173"] : [],
|
|
459
|
-
""
|
|
460
|
-
].join(`
|
|
461
|
-
`);
|
|
462
|
-
}
|
|
463
|
-
function renderEnvProduction(manifest) {
|
|
464
|
-
const keys = allEnvKeys(manifest).filter((key) => key.plaintext);
|
|
465
|
-
const values = {
|
|
466
|
-
SHOP_DOMAIN: manifest.shop.domain,
|
|
467
|
-
GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
|
|
468
|
-
SHOP_ZONE: zone(manifest),
|
|
469
|
-
EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`,
|
|
470
|
-
ADYEN_ENVIRONMENT: "live",
|
|
471
|
-
BC_ENVIRONMENT: "production"
|
|
472
|
-
};
|
|
473
|
-
return [
|
|
474
|
-
"# Production values that are NOT secrets.",
|
|
475
|
-
"#",
|
|
476
|
-
"# On `void deploy --backend cloudflare` every .env* file is baked into the",
|
|
477
|
-
"# worker's vars as PLAINTEXT, so this holds only values safe to commit. Every",
|
|
478
|
-
"# credential is `wrangler secret put <NAME>` instead.",
|
|
479
|
-
"",
|
|
480
|
-
...keys.map((key) => `${key.key}=${values[key.key] ?? key.dev ?? ""}`),
|
|
481
|
-
""
|
|
482
|
-
].join(`
|
|
483
|
-
`);
|
|
484
|
-
}
|
|
485
|
-
function envSummary(manifest) {
|
|
486
|
-
const keys = allEnvKeys(manifest);
|
|
487
|
-
return {
|
|
488
|
-
secrets: keys.filter((key) => !key.plaintext).map((key) => key.key),
|
|
489
|
-
plaintext: keys.filter((key) => key.plaintext).map((key) => key.key)
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
|
|
493
362
|
// src/generate/frontend.ts
|
|
494
363
|
var FRONTEND_OUT = "dist/client";
|
|
495
364
|
var FRONTEND_BRANCH = "frontend-static";
|
|
@@ -1441,7 +1310,7 @@ import color from "picocolors";
|
|
|
1441
1310
|
// package.json
|
|
1442
1311
|
var package_default = {
|
|
1443
1312
|
name: "@saastemly/voidcommerce",
|
|
1444
|
-
version: "0.
|
|
1313
|
+
version: "0.5.0",
|
|
1445
1314
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1446
1315
|
type: "module",
|
|
1447
1316
|
license: "MIT",
|
|
@@ -1639,7 +1508,7 @@ import { existsSync as existsSync5 } from "node:fs";
|
|
|
1639
1508
|
import { dirname as dirname3, join as join5 } from "node:path";
|
|
1640
1509
|
|
|
1641
1510
|
// src/generate/index.ts
|
|
1642
|
-
import { cp as cp2, mkdir as mkdir2, readFile as readFile2, readdir as readdir2, symlink, writeFile as writeFile2 } from "node:fs/promises";
|
|
1511
|
+
import { cp as cp2, mkdir as mkdir2, readFile as readFile2, readdir as readdir2, rm as rm2, symlink, writeFile as writeFile2 } from "node:fs/promises";
|
|
1643
1512
|
import { existsSync as existsSync4, lstatSync } from "node:fs";
|
|
1644
1513
|
import { dirname as dirname2, join as join4 } from "node:path";
|
|
1645
1514
|
|
|
@@ -1770,36 +1639,38 @@ async function distHelp() {
|
|
|
1770
1639
|
|
|
1771
1640
|
// src/generate/ci.ts
|
|
1772
1641
|
function renderDistWorkflow(manifest) {
|
|
1773
|
-
|
|
1774
|
-
return `name: Build the deployable app
|
|
1642
|
+
return `name: Deploy the shop
|
|
1775
1643
|
|
|
1776
1644
|
# Generated by \`vc init\` from voidcommerce.json.
|
|
1777
1645
|
#
|
|
1778
|
-
#
|
|
1779
|
-
#
|
|
1780
|
-
#
|
|
1781
|
-
#
|
|
1646
|
+
# A push to main regenerates the Void app from the manifest, publishes it to
|
|
1647
|
+
# \`${DIST_BRANCH}\` as a standalone tree, and deploys it to Cloudflare.
|
|
1648
|
+
#
|
|
1649
|
+
# The credentials come from the repository, set once by \`vc link\`:
|
|
1650
|
+
#
|
|
1651
|
+
# secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
|
|
1652
|
+
# secrets.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE}
|
|
1653
|
+
# vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
|
|
1782
1654
|
#
|
|
1783
|
-
#
|
|
1784
|
-
#
|
|
1785
|
-
# one non-obvious part: the build's API token needs D1:Edit, which the token
|
|
1786
|
-
# Cloudflare generates for you does not have.
|
|
1655
|
+
# Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
|
|
1656
|
+
# on your machine. See DEPLOY.md.
|
|
1787
1657
|
on:
|
|
1788
1658
|
push:
|
|
1789
1659
|
branches: [main, master]
|
|
1790
1660
|
workflow_dispatch:
|
|
1791
1661
|
|
|
1792
|
-
# A later push wins: an older tree must never overwrite a newer one
|
|
1662
|
+
# A later push wins: an older tree must never overwrite a newer one, and two
|
|
1663
|
+
# deploys must never race for the same worker.
|
|
1793
1664
|
concurrency:
|
|
1794
|
-
group:
|
|
1665
|
+
group: deploy-\${{ github.repository }}
|
|
1795
1666
|
cancel-in-progress: true
|
|
1796
1667
|
|
|
1797
|
-
# contents: write is what lets GITHUB_TOKEN force-push the branch. Nothing else.
|
|
1798
1668
|
permissions:
|
|
1799
1669
|
contents: write
|
|
1800
1670
|
|
|
1801
1671
|
jobs:
|
|
1802
|
-
|
|
1672
|
+
# ── Regenerate, and publish the standalone tree ────────────────────────
|
|
1673
|
+
dist:
|
|
1803
1674
|
runs-on: ubuntu-latest
|
|
1804
1675
|
steps:
|
|
1805
1676
|
- uses: actions/checkout@v6
|
|
@@ -1840,6 +1711,50 @@ jobs:
|
|
|
1840
1711
|
git add -A
|
|
1841
1712
|
git commit -q -m "\${{ github.sha }} — ${manifest.shop.domain}"
|
|
1842
1713
|
git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${DIST_BRANCH}
|
|
1714
|
+
|
|
1715
|
+
# ── Deploy it ──────────────────────────────────────────────────────────
|
|
1716
|
+
deploy:
|
|
1717
|
+
needs: dist
|
|
1718
|
+
runs-on: ubuntu-latest
|
|
1719
|
+
environment:
|
|
1720
|
+
name: production
|
|
1721
|
+
url: https://${manifest.shop.domain}
|
|
1722
|
+
env:
|
|
1723
|
+
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
1724
|
+
CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
|
1725
|
+
${PRIVATE_KEY_VAR}: \${{ secrets.${PRIVATE_KEY_VAR} }}
|
|
1726
|
+
steps:
|
|
1727
|
+
- uses: actions/checkout@v6
|
|
1728
|
+
- uses: oven-sh/setup-bun@v2
|
|
1729
|
+
|
|
1730
|
+
# Checked before anything is built, so a repository that was never
|
|
1731
|
+
# linked says so in five seconds rather than four minutes.
|
|
1732
|
+
- name: Are the credentials here?
|
|
1733
|
+
run: |
|
|
1734
|
+
set -euo pipefail
|
|
1735
|
+
missing=""
|
|
1736
|
+
[ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
|
|
1737
|
+
[ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
|
|
1738
|
+
if [ -n "$missing" ]; then
|
|
1739
|
+
echo "::error::this repository has no$missing. Run 'vc link' once, from a checkout."
|
|
1740
|
+
exit 1
|
|
1741
|
+
fi
|
|
1742
|
+
|
|
1743
|
+
- run: bun install --frozen-lockfile
|
|
1744
|
+
|
|
1745
|
+
# One command: preflight, provision D1 and the queue if they are not
|
|
1746
|
+
# there, build, strip the values Void bakes into the worker's plaintext
|
|
1747
|
+
# vars, apply the committed migrations, and deploy with the decrypted
|
|
1748
|
+
# secrets attached to the version. Provisioning is idempotent — it looks
|
|
1749
|
+
# a resource up by name before creating it — so this is safe every run.
|
|
1750
|
+
- name: vc deploy --cloudflare --provision
|
|
1751
|
+
run: bunx vc deploy --cloudflare --provision
|
|
1752
|
+
|
|
1753
|
+
# An upsert, so it is safe on every deploy and costs one pass when
|
|
1754
|
+
# nothing in data/ has changed. A failure here does not un-deploy a
|
|
1755
|
+
# working shop, so it warns rather than failing the run.
|
|
1756
|
+
- name: Push the catalogue
|
|
1757
|
+
run: bunx vc import || echo "::warning::the catalogue did not import; the shop is up but its products may be stale"
|
|
1843
1758
|
`;
|
|
1844
1759
|
}
|
|
1845
1760
|
function renderDeployReadme(manifest, zone2, hosts) {
|
|
@@ -1849,132 +1764,117 @@ function renderDeployReadme(manifest, zone2, hosts) {
|
|
|
1849
1764
|
\`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
|
|
1850
1765
|
regenerate with \`vc generate\`.
|
|
1851
1766
|
|
|
1852
|
-
## The loop
|
|
1853
|
-
|
|
1854
|
-
Push to \`main\`. GitHub Actions regenerates the app from \`voidcommerce.json\`
|
|
1855
|
-
and force-pushes it to \`${DIST_BRANCH}\`; Cloudflare's Workers Builds builds that
|
|
1856
|
-
branch and deploys it. Nothing else runs.
|
|
1857
|
-
|
|
1858
|
-
Your secrets survive every deploy — \`wrangler deploy\` never deletes a secret.
|
|
1859
|
-
Plaintext \`vars\` are replaced from \`.env.production\` on each deploy, which is
|
|
1860
|
-
why no credential is allowed in that file.
|
|
1861
|
-
|
|
1862
|
-
## Once, before the first push
|
|
1863
|
-
|
|
1864
|
-
Each of these either creates something in your Cloudflare account or holds a
|
|
1865
|
-
credential, so none of them can live in a repository.
|
|
1866
|
-
|
|
1867
|
-
### 1. The zone
|
|
1868
|
-
|
|
1869
|
-
\`${zone2}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
|
|
1870
|
-
creates in its own zone, so a domain hosted anywhere else cannot have one. The
|
|
1871
|
-
worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
|
|
1872
|
-
|
|
1873
|
-
### 2. The database, the queue, and the account id
|
|
1767
|
+
## The loop
|
|
1874
1768
|
|
|
1875
1769
|
\`\`\`sh
|
|
1876
|
-
|
|
1770
|
+
git push
|
|
1877
1771
|
\`\`\`
|
|
1878
1772
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
and
|
|
1882
|
-
|
|
1773
|
+
That is the deploy. GitHub Actions regenerates the app from
|
|
1774
|
+
\`voidcommerce.json\`, publishes a standalone copy to \`${DIST_BRANCH}\`, creates the
|
|
1775
|
+
database and queue if they are not there, applies the migrations, and deploys
|
|
1776
|
+
the worker with this repository's secrets attached.
|
|
1883
1777
|
|
|
1884
|
-
|
|
1778
|
+
You do not need wrangler on your machine, and you do not need to be logged
|
|
1779
|
+
into it. You do not need to open the Cloudflare dashboard after the one step
|
|
1780
|
+
below.
|
|
1885
1781
|
|
|
1886
|
-
|
|
1782
|
+
## Once, before the first push
|
|
1887
1783
|
|
|
1888
1784
|
\`\`\`sh
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
bunx dotenvx encrypt -f .env.secrets # ciphertext; commit this
|
|
1785
|
+
gh repo create --source=. --private --push # if there is no repo yet
|
|
1786
|
+
vc link
|
|
1892
1787
|
\`\`\`
|
|
1893
1788
|
|
|
1894
|
-
|
|
1895
|
-
hands the values to \`wrangler deploy --secrets-file\`, which stores them as
|
|
1896
|
-
real Worker secrets — not as plaintext \`vars\`, which anyone with dashboard
|
|
1897
|
-
access can read.
|
|
1898
|
-
|
|
1899
|
-
That turns one \`wrangler secret put\` per value into one build variable, set
|
|
1900
|
-
once in step 5. It also means the shop rebuilds from a checkout.
|
|
1789
|
+
\`vc link\` does three things and stores all of them on the GitHub repository:
|
|
1901
1790
|
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
\`
|
|
1905
|
-
|
|
1906
|
-
repository
|
|
1791
|
+
| what | where | why |
|
|
1792
|
+
|---|---|---|
|
|
1793
|
+
| \`${PRIVATE_KEY_VAR}\` | repository **secret** | opens \`${SECRETS_FILE}\`. Generated by \`vc link\`, never written to disk |
|
|
1794
|
+
| \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
|
|
1795
|
+
| \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
|
|
1907
1796
|
|
|
1908
|
-
|
|
1797
|
+
It will ask you to paste a Cloudflare API token. That is the only manual step
|
|
1798
|
+
in the whole setup, and it is worth saying exactly why it cannot be removed:
|
|
1909
1799
|
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1800
|
+
> **GitHub cannot mint a Cloudflare credential.** There is no OIDC or workload
|
|
1801
|
+
> identity federation between them — the feature request has been open since
|
|
1802
|
+
> 2025 with no commitment, and Cloudflare's own CI guidance still says to store
|
|
1803
|
+
> a token in your CI provider's secrets. The Cloudflare GitHub App does not
|
|
1804
|
+
> help either: it grants *Cloudflare* access to your *repository*, not the
|
|
1805
|
+
> reverse. Something has to authorise creating a database in your account, and
|
|
1806
|
+
> only Cloudflare can issue that authorisation.
|
|
1914
1807
|
|
|
1915
|
-
Create
|
|
1808
|
+
Create the token at **My Profile → API Tokens → Create Token → Custom token**:
|
|
1916
1809
|
|
|
1917
|
-
| scope | permission |
|
|
1918
|
-
|
|
1919
|
-
| Account | Workers Scripts: Edit |
|
|
1920
|
-
| Account | D1: Edit |
|
|
1921
|
-
| Account |
|
|
1922
|
-
| Account |
|
|
1923
|
-
| Account |
|
|
1924
|
-
|
|
|
1925
|
-
|
|
|
1810
|
+
| scope | permission | for |
|
|
1811
|
+
|---|---|---|
|
|
1812
|
+
| Account | Workers Scripts: Edit | deploying the worker |
|
|
1813
|
+
| Account | D1: Edit | creating the database, applying migrations |
|
|
1814
|
+
| Account | Queues: Edit | the order queue |
|
|
1815
|
+
| Account | Workers KV Storage: Edit | sessions and caches |
|
|
1816
|
+
| Account | Workers R2 Storage: Edit | product images |
|
|
1817
|
+
| Account | Account Settings: Read | confirming which account |
|
|
1818
|
+
| Zone | Workers Routes: Edit (${zone2}) | answering on your domain |
|
|
1819
|
+
| User | User Details: Read, Memberships: Read | wrangler asks at startup |
|
|
1926
1820
|
|
|
1927
|
-
|
|
1928
|
-
|
|
1821
|
+
The token Cloudflare generates for its own Workers Builds will **not** do: it
|
|
1822
|
+
has no D1 and no Queues permission, so it cannot create this shop's database.
|
|
1929
1823
|
|
|
1930
|
-
###
|
|
1824
|
+
### The zone
|
|
1931
1825
|
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1826
|
+
\`${zone2}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
|
|
1827
|
+
creates in its own zone, so a domain hosted anywhere else cannot have one. The
|
|
1828
|
+
worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
|
|
1935
1829
|
|
|
1936
|
-
|
|
1937
|
-
|---|---|
|
|
1938
|
-
| branch | \`${DIST_BRANCH}\` |
|
|
1939
|
-
| root directory | \`/\` |
|
|
1940
|
-
| build command | \`bun install && bunx void prepare && bunx vp build\` |
|
|
1941
|
-
| deploy command | see below |
|
|
1942
|
-
| build variable | \`DOTENV_PRIVATE_KEY_SECRETS\`, marked as a secret — the one value that is not in the repository |
|
|
1943
|
-
| API token | the one from step 4 |
|
|
1830
|
+
## Secrets
|
|
1944
1831
|
|
|
1945
|
-
|
|
1832
|
+
They live in the repository, encrypted, in \`${SECRETS_FILE}\`:
|
|
1946
1833
|
|
|
1947
1834
|
\`\`\`sh
|
|
1948
|
-
|
|
1835
|
+
vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
|
|
1836
|
+
vc secrets # what is set, what is missing
|
|
1949
1837
|
\`\`\`
|
|
1950
1838
|
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
a secret
|
|
1839
|
+
**This needs no credential.** dotenvx is asymmetric: encryption uses the public
|
|
1840
|
+
key committed at the top of the file, so anyone with a clone can set or rotate
|
|
1841
|
+
a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
|
|
1842
|
+
private key that lives in GitHub Actions.
|
|
1954
1843
|
|
|
1955
|
-
|
|
1956
|
-
|
|
1844
|
+
A pre-commit hook refuses a commit that would put a value in the clear, because
|
|
1845
|
+
that cannot be undone by a later commit — the value stays in the history.
|
|
1957
1846
|
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1847
|
+
**Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
|
|
1848
|
+
leaks, every secret in the history is readable, including ones you rotated.
|
|
1849
|
+
\`wrangler secret put\` does not have that property, and stays available for
|
|
1850
|
+
anything you would rather never commit at all.
|
|
1961
1851
|
|
|
1962
|
-
|
|
1852
|
+
## The catalogue
|
|
1963
1853
|
|
|
1964
|
-
\`vc import\` pushes \`data/\` into the running shop
|
|
1965
|
-
|
|
1854
|
+
\`vc import\` pushes \`data/\` into the running shop, and the workflow runs it on
|
|
1855
|
+
every deploy. It is an upsert, so it costs one pass when nothing has changed.
|
|
1966
1856
|
|
|
1967
|
-
##
|
|
1857
|
+
## Doing it by hand
|
|
1968
1858
|
|
|
1969
|
-
The same thing
|
|
1859
|
+
The same thing, from a checkout, if CI is ever not the answer:
|
|
1970
1860
|
|
|
1971
1861
|
\`\`\`sh
|
|
1972
|
-
|
|
1862
|
+
export CLOUDFLARE_API_TOKEN=…
|
|
1863
|
+
export ${PRIVATE_KEY_VAR}=… # only if you kept a copy
|
|
1864
|
+
vc deploy --cloudflare --provision
|
|
1973
1865
|
\`\`\`
|
|
1974
1866
|
|
|
1975
|
-
which preflights, builds, strips the baked development values out
|
|
1976
|
-
worker's vars, applies the remote migrations, and deploys exactly what
|
|
1977
|
-
verified.
|
|
1867
|
+
which preflights, provisions, builds, strips the baked development values out
|
|
1868
|
+
of the worker's vars, applies the remote migrations, and deploys exactly what
|
|
1869
|
+
it verified.
|
|
1870
|
+
|
|
1871
|
+
## If you would rather use Cloudflare's own build
|
|
1872
|
+
|
|
1873
|
+
Point Workers Builds at the \`${DIST_BRANCH}\` branch — it is a plain Void app with
|
|
1874
|
+
a committed lockfile. The Worker must be named \`${worker}\`, matching the \`name\`
|
|
1875
|
+
in its \`wrangler.jsonc\`. You will need to set \`${PRIVATE_KEY_VAR}\` as a
|
|
1876
|
+
build secret in the dashboard, and replace the auto-generated API token with
|
|
1877
|
+
one that has D1 and Queues. That is the path \`vc link\` exists to avoid.
|
|
1978
1878
|
`;
|
|
1979
1879
|
}
|
|
1980
1880
|
|
|
@@ -2313,6 +2213,13 @@ async function put(root, file, content, result, mode) {
|
|
|
2313
2213
|
await writeFile2(path, content, "utf8");
|
|
2314
2214
|
result.written.push(file);
|
|
2315
2215
|
}
|
|
2216
|
+
async function retire(root, file, result) {
|
|
2217
|
+
const path = join4(root, file);
|
|
2218
|
+
if (!await exists(path))
|
|
2219
|
+
return;
|
|
2220
|
+
await rm2(path, { force: true });
|
|
2221
|
+
result.retired.push(file);
|
|
2222
|
+
}
|
|
2316
2223
|
async function mergeJson(root, file, fallback, mutate, result) {
|
|
2317
2224
|
const path = join4(root, file);
|
|
2318
2225
|
const json = await exists(path) ? JSON.parse(await readFile2(path, "utf8")) : { ...fallback };
|
|
@@ -2336,7 +2243,7 @@ async function link(root, file, target, result) {
|
|
|
2336
2243
|
}
|
|
2337
2244
|
var sorted = (record) => Object.fromEntries(Object.entries(record).sort());
|
|
2338
2245
|
async function generate(root, manifest) {
|
|
2339
|
-
const result = { written: [], kept: [], packages: packagesOf(manifest) };
|
|
2246
|
+
const result = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
|
|
2340
2247
|
await writeManifest(root, manifest);
|
|
2341
2248
|
result.written.push(MANIFEST_FILE);
|
|
2342
2249
|
switch (manifest.layout) {
|
|
@@ -2505,7 +2412,8 @@ dist
|
|
|
2505
2412
|
# because the value stays in the history.
|
|
2506
2413
|
bunx vc guard
|
|
2507
2414
|
`, result, "regenerate");
|
|
2508
|
-
await put(root, ".github/workflows/
|
|
2415
|
+
await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
|
|
2416
|
+
await retire(root, ".github/workflows/void-dist.yml", result);
|
|
2509
2417
|
await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
|
|
2510
2418
|
await put(root, ".env", renderEnvLocal(manifest), result, "own");
|
|
2511
2419
|
await put(root, "data/README.md", DATA_README, result, "own");
|
|
@@ -2869,346 +2777,16 @@ ${ran.out.trim()}`);
|
|
|
2869
2777
|
}
|
|
2870
2778
|
|
|
2871
2779
|
// src/deploy/preflight.ts
|
|
2872
|
-
import { existsSync as existsSync9, readFileSync as readFileSync4 } from "node:fs";
|
|
2873
|
-
import { join as join9 } from "node:path";
|
|
2874
|
-
import color5 from "picocolors";
|
|
2875
|
-
|
|
2876
|
-
// src/deploy/secrets.ts
|
|
2877
|
-
import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
|
2878
|
-
import { spawn as spawn3 } from "node:child_process";
|
|
2879
|
-
import { delimiter as delimiter3, dirname as dirname5, join as join8 } from "node:path";
|
|
2880
|
-
import { tmpdir } from "node:os";
|
|
2881
|
-
import color4 from "picocolors";
|
|
2882
|
-
|
|
2883
|
-
// src/deploy/keys.ts
|
|
2884
|
-
import { hkdfSync } from "node:crypto";
|
|
2885
2780
|
import { existsSync as existsSync7, readFileSync as readFileSync2 } from "node:fs";
|
|
2886
2781
|
import { join as join7 } from "node:path";
|
|
2887
2782
|
import color3 from "picocolors";
|
|
2888
|
-
var INFO = "voidcommerce/dotenvx/secrets/v1";
|
|
2889
|
-
function apiToken() {
|
|
2890
|
-
return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
|
|
2891
|
-
}
|
|
2892
|
-
function derivePrivateKey(token, accountId) {
|
|
2893
|
-
const bytes = hkdfSync("sha256", Buffer.from(token, "utf8"), Buffer.from(accountId, "utf8"), Buffer.from(INFO), 32);
|
|
2894
|
-
return Buffer.from(bytes).toString("hex");
|
|
2895
|
-
}
|
|
2896
|
-
async function publicKeyFor(privateKey) {
|
|
2897
|
-
try {
|
|
2898
|
-
const { derive } = await import("./index-mpr7gm6k.js").then((m)=>__toESM(m.default,1));
|
|
2899
|
-
return derive(privateKey);
|
|
2900
|
-
} catch {
|
|
2901
|
-
return null;
|
|
2902
|
-
}
|
|
2903
|
-
}
|
|
2904
|
-
function committedPublicKey(root) {
|
|
2905
|
-
const path = join7(root, SECRETS_FILE);
|
|
2906
|
-
if (!existsSync7(path))
|
|
2907
|
-
return null;
|
|
2908
|
-
const match = /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=\s*["']?([0-9a-fA-F]+)["']?/m.exec(readFileSync2(path, "utf8"));
|
|
2909
|
-
return match?.[1] ?? null;
|
|
2910
|
-
}
|
|
2911
|
-
async function keyFor(manifest, root) {
|
|
2912
|
-
const token = apiToken();
|
|
2913
|
-
if (!token) {
|
|
2914
|
-
return {
|
|
2915
|
-
ok: false,
|
|
2916
|
-
reason: `CLOUDFLARE_API_TOKEN is not set.
|
|
2917
|
-
The key is derived from it, so there is nothing to derive from. Note that
|
|
2918
|
-
` + " `wrangler login` does NOT set one — this scheme needs an API token, from\n" + " My Profile → API Tokens."
|
|
2919
|
-
};
|
|
2920
|
-
}
|
|
2921
|
-
const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
2922
|
-
if (!accountId) {
|
|
2923
|
-
return {
|
|
2924
|
-
ok: false,
|
|
2925
|
-
reason: "the account is not pinned, and it salts the derivation.\n Run `vc deploy --cloudflare --provision` once, or set CLOUDFLARE_ACCOUNT_ID."
|
|
2926
|
-
};
|
|
2927
|
-
}
|
|
2928
|
-
const privateKey = derivePrivateKey(token, accountId);
|
|
2929
|
-
const publicKey = await publicKeyFor(privateKey);
|
|
2930
|
-
if (!publicKey) {
|
|
2931
|
-
return { ok: false, reason: "@dotenvx/dotenvx is not installed here, so a key cannot be derived. `bun add -d @dotenvx/dotenvx`" };
|
|
2932
|
-
}
|
|
2933
|
-
const committed = committedPublicKey(root);
|
|
2934
|
-
if (!committed)
|
|
2935
|
-
return { ok: true, privateKey, publicKey, fresh: true };
|
|
2936
|
-
if (committed.toLowerCase() !== publicKey.toLowerCase()) {
|
|
2937
|
-
return {
|
|
2938
|
-
ok: false,
|
|
2939
|
-
reason: `this token does not derive the key ${SECRETS_FILE} was encrypted under.
|
|
2940
|
-
|
|
2941
|
-
encrypted under: ${committed.slice(0, 16)}…
|
|
2942
|
-
` + ` this token gives: ${publicKey.slice(0, 16)}…
|
|
2943
|
-
|
|
2944
|
-
` + ` That is one of three things, and all of them are the same fix:
|
|
2945
|
-
` + ` · the token was rotated since the secrets were encrypted
|
|
2946
|
-
` + ` · this is a different admin's token
|
|
2947
|
-
` + ` · CLOUDFLARE_ACCOUNT_ID is not the account they were encrypted for
|
|
2948
|
-
|
|
2949
|
-
` + ` If you still have the ORIGINAL token, \`vc keys rotate\` re-encrypts
|
|
2950
|
-
everything under the new one. If you do not, the values are unrecoverable
|
|
2951
|
-
and must be entered again.`
|
|
2952
|
-
};
|
|
2953
|
-
}
|
|
2954
|
-
return { ok: true, privateKey, publicKey, fresh: false };
|
|
2955
|
-
}
|
|
2956
|
-
async function keysCommand(manifest, root, args) {
|
|
2957
|
-
if (args.includes("--rotate"))
|
|
2958
|
-
return rotate(manifest, root);
|
|
2959
|
-
const state = await keyFor(manifest, root);
|
|
2960
|
-
console.log(`
|
|
2961
|
-
The key is DERIVED from CLOUDFLARE_API_TOKEN, salted with the account id.`);
|
|
2962
|
-
console.log(color3.dim(`Nothing is stored, so nothing can leak — and nothing can be recovered.
|
|
2963
|
-
`));
|
|
2964
|
-
if (!state.ok) {
|
|
2965
|
-
console.error(`${color3.red("✗")} ${state.reason}
|
|
2966
|
-
`);
|
|
2967
|
-
return 1;
|
|
2968
|
-
}
|
|
2969
|
-
console.log(` derives public key ${state.publicKey.slice(0, 20)}…`);
|
|
2970
|
-
console.log(state.fresh ? ` ${color3.dim(`${SECRETS_FILE} does not exist yet, so there is nothing to check against`)}` : ` ${color3.green("✓")} matches what ${SECRETS_FILE} was encrypted under`);
|
|
2971
|
-
console.log(color3.yellow(`
|
|
2972
|
-
! Rotating this API token makes every secret in ${SECRETS_FILE} unreadable.
|
|
2973
|
-
Run \`vc keys --rotate\` with the NEW token exported and the old one in
|
|
2974
|
-
CLOUDFLARE_API_TOKEN_OLD, BEFORE the old one stops working.
|
|
2975
|
-
`));
|
|
2976
|
-
return 0;
|
|
2977
|
-
}
|
|
2978
|
-
async function rotate(manifest, root) {
|
|
2979
|
-
const oldToken = process.env["CLOUDFLARE_API_TOKEN_OLD"];
|
|
2980
|
-
const newToken = apiToken();
|
|
2981
|
-
if (!oldToken || !newToken) {
|
|
2982
|
-
console.error(`
|
|
2983
|
-
vc: rotation needs BOTH tokens:
|
|
2984
|
-
CLOUDFLARE_API_TOKEN_OLD=<the one the secrets were encrypted under>
|
|
2985
|
-
CLOUDFLARE_API_TOKEN=<the new one>
|
|
2986
|
-
|
|
2987
|
-
The old one is the only thing that can read the current values.
|
|
2988
|
-
`);
|
|
2989
|
-
return 1;
|
|
2990
|
-
}
|
|
2991
|
-
const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
2992
|
-
if (!accountId) {
|
|
2993
|
-
console.error("vc: the account is not pinned, and it salts the derivation.");
|
|
2994
|
-
return 1;
|
|
2995
|
-
}
|
|
2996
|
-
const oldKey = derivePrivateKey(oldToken, accountId);
|
|
2997
|
-
const oldPublic = await publicKeyFor(oldKey);
|
|
2998
|
-
const committed = committedPublicKey(root);
|
|
2999
|
-
if (committed && oldPublic && committed.toLowerCase() !== oldPublic.toLowerCase()) {
|
|
3000
|
-
console.error(`
|
|
3001
|
-
vc: CLOUDFLARE_API_TOKEN_OLD does not derive the key ${SECRETS_FILE} was encrypted under either.
|
|
3002
|
-
`);
|
|
3003
|
-
return 1;
|
|
3004
|
-
}
|
|
3005
|
-
console.log(`
|
|
3006
|
-
Rotation is a decrypt with the old key and an encrypt with the new one.
|
|
3007
|
-
Run these two, in this order, from ${root}:
|
|
3008
|
-
|
|
3009
|
-
${color3.cyan(`DOTENV_PRIVATE_KEY_SECRETS=<old> bunx dotenvx decrypt -f ${SECRETS_FILE}`)}
|
|
3010
|
-
${color3.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)} ${color3.dim("# under the new derived key")}
|
|
3011
|
-
|
|
3012
|
-
` + color3.dim(`vc does not run them for you: the middle state is your secrets in
|
|
3013
|
-
plaintext on disk, and that is a moment to be deliberate about.
|
|
3014
|
-
`));
|
|
3015
|
-
return 0;
|
|
3016
|
-
}
|
|
3017
|
-
|
|
3018
|
-
// src/deploy/secrets.ts
|
|
3019
|
-
var SECRETS_FILE = ".env.secrets";
|
|
3020
|
-
var PRIVATE_KEY_VAR = "DOTENV_PRIVATE_KEY_SECRETS";
|
|
3021
|
-
function findDotenvx(from) {
|
|
3022
|
-
let dir = from;
|
|
3023
|
-
for (;; ) {
|
|
3024
|
-
const local = join8(dir, "node_modules", ".bin", "dotenvx");
|
|
3025
|
-
if (existsSync8(local))
|
|
3026
|
-
return local;
|
|
3027
|
-
const parent = dirname5(dir);
|
|
3028
|
-
if (parent === dir)
|
|
3029
|
-
break;
|
|
3030
|
-
dir = parent;
|
|
3031
|
-
}
|
|
3032
|
-
return (process.env["PATH"] ?? "").split(delimiter3).filter(Boolean).some((entry) => existsSync8(join8(entry, "dotenvx"))) ? "dotenvx" : null;
|
|
3033
|
-
}
|
|
3034
|
-
function run(cmd, args, cwd, env = {}) {
|
|
3035
|
-
return new Promise((resolve) => {
|
|
3036
|
-
const child = spawn3(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
|
|
3037
|
-
let out = "";
|
|
3038
|
-
child.stdout.on("data", (chunk) => {
|
|
3039
|
-
out += chunk;
|
|
3040
|
-
});
|
|
3041
|
-
child.stderr.on("data", (chunk) => {
|
|
3042
|
-
out += chunk;
|
|
3043
|
-
});
|
|
3044
|
-
child.on("error", (error) => resolve({ code: 1, out: String(error) }));
|
|
3045
|
-
child.on("exit", (code) => resolve({ code: code ?? 1, out }));
|
|
3046
|
-
});
|
|
3047
|
-
}
|
|
3048
|
-
function declaredSecretNames(root) {
|
|
3049
|
-
const path = join8(root, SECRETS_FILE);
|
|
3050
|
-
if (!existsSync8(path))
|
|
3051
|
-
return new Set;
|
|
3052
|
-
const names = new Set;
|
|
3053
|
-
for (const line2 of readFileSync3(path, "utf8").split(`
|
|
3054
|
-
`)) {
|
|
3055
|
-
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line2);
|
|
3056
|
-
if (match && !match[1].startsWith("DOTENV_"))
|
|
3057
|
-
names.add(match[1]);
|
|
3058
|
-
}
|
|
3059
|
-
return names;
|
|
3060
|
-
}
|
|
3061
|
-
function plaintextSecretNames(root) {
|
|
3062
|
-
const path = join8(root, SECRETS_FILE);
|
|
3063
|
-
if (!existsSync8(path))
|
|
3064
|
-
return [];
|
|
3065
|
-
const bare = [];
|
|
3066
|
-
for (const line2 of readFileSync3(path, "utf8").split(`
|
|
3067
|
-
`)) {
|
|
3068
|
-
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line2);
|
|
3069
|
-
if (!match || match[1].startsWith("DOTENV_"))
|
|
3070
|
-
continue;
|
|
3071
|
-
const value = match[2].trim().replace(/^['"]|['"]$/g, "");
|
|
3072
|
-
if (value && value !== "unset" && !value.startsWith("encrypted:"))
|
|
3073
|
-
bare.push(match[1]);
|
|
3074
|
-
}
|
|
3075
|
-
return bare;
|
|
3076
|
-
}
|
|
3077
|
-
async function decryptSecrets(project) {
|
|
3078
|
-
const root = project.root;
|
|
3079
|
-
if (!existsSync8(join8(root, SECRETS_FILE)))
|
|
3080
|
-
return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
|
|
3081
|
-
const dotenvx = findDotenvx(root);
|
|
3082
|
-
if (!dotenvx)
|
|
3083
|
-
return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
|
|
3084
|
-
const key = await keyFor(project.manifest, root);
|
|
3085
|
-
if (!key.ok)
|
|
3086
|
-
return { error: key.reason };
|
|
3087
|
-
const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root, {
|
|
3088
|
-
[PRIVATE_KEY_VAR]: key.privateKey
|
|
3089
|
-
});
|
|
3090
|
-
if (result.code !== 0)
|
|
3091
|
-
return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split(`
|
|
3092
|
-
`).slice(-2).join(" ")}` };
|
|
3093
|
-
const lines = result.out.split(`
|
|
3094
|
-
`).filter((line2) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line2) && !line2.trimStart().startsWith("DOTENV_"));
|
|
3095
|
-
const names = lines.map((line2) => line2.slice(0, line2.indexOf("=")).trim());
|
|
3096
|
-
const path = join8(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
|
|
3097
|
-
writeFileSync(path, `${lines.join(`
|
|
3098
|
-
`)}
|
|
3099
|
-
`, { mode: 384 });
|
|
3100
|
-
return {
|
|
3101
|
-
path,
|
|
3102
|
-
names,
|
|
3103
|
-
cleanup: () => {
|
|
3104
|
-
try {
|
|
3105
|
-
writeFileSync(path, "", { mode: 384 });
|
|
3106
|
-
__require("node:fs").unlinkSync(path);
|
|
3107
|
-
} catch {}
|
|
3108
|
-
}
|
|
3109
|
-
};
|
|
3110
|
-
}
|
|
3111
|
-
async function secretsCommand(project, args) {
|
|
3112
|
-
const root = project.root;
|
|
3113
|
-
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
3114
|
-
const declared = declaredSecretNames(root);
|
|
3115
|
-
const bare = plaintextSecretNames(root);
|
|
3116
|
-
if (args.includes("--init"))
|
|
3117
|
-
return initSecrets(project);
|
|
3118
|
-
if (!existsSync8(join8(root, SECRETS_FILE))) {
|
|
3119
|
-
console.log(`
|
|
3120
|
-
${color4.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
|
|
3121
|
-
` + `which means the shop cannot be rebuilt from a checkout.
|
|
3122
|
-
|
|
3123
|
-
` + ` vc secrets --init write one, with every required key as \`unset\`
|
|
3124
|
-
`);
|
|
3125
|
-
return 1;
|
|
3126
|
-
}
|
|
3127
|
-
console.log(`
|
|
3128
|
-
${SECRETS_FILE} — committed, encrypted, and read by \`vc deploy --cloudflare\`
|
|
3129
|
-
`);
|
|
3130
|
-
for (const key of required) {
|
|
3131
|
-
const state = declared.has(key.key) ? color4.green("declared") : color4.red("MISSING ");
|
|
3132
|
-
console.log(` ${state} ${key.key}`);
|
|
3133
|
-
}
|
|
3134
|
-
const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
|
|
3135
|
-
for (const name of extra)
|
|
3136
|
-
console.log(` ${color4.dim("extra ")} ${name} ${color4.dim("— not required by this shop")}`);
|
|
3137
|
-
if (bare.length > 0) {
|
|
3138
|
-
console.log(`
|
|
3139
|
-
${color4.red("✗")} ${bare.length} value${bare.length === 1 ? " is" : "s are"} committed IN THE CLEAR: ${bare.join(", ")}
|
|
3140
|
-
` + ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` before committing again.
|
|
3141
|
-
`);
|
|
3142
|
-
return 1;
|
|
3143
|
-
}
|
|
3144
|
-
const missing = required.filter((key) => !declared.has(key.key));
|
|
3145
|
-
if (missing.length > 0) {
|
|
3146
|
-
console.log(`
|
|
3147
|
-
${color4.red("✗")} ${missing.length} required secret${missing.length === 1 ? "" : "s"} not declared.
|
|
3148
|
-
` + ` dotenvx set ${missing[0].key} '…' -f ${SECRETS_FILE}
|
|
3149
|
-
`);
|
|
3150
|
-
return 1;
|
|
3151
|
-
}
|
|
3152
|
-
console.log(`
|
|
3153
|
-
${color4.green("✓")} every secret this shop needs is declared and encrypted.
|
|
3154
|
-
`);
|
|
3155
|
-
return 0;
|
|
3156
|
-
}
|
|
3157
|
-
async function initSecrets(project) {
|
|
3158
|
-
const path = join8(project.root, SECRETS_FILE);
|
|
3159
|
-
if (existsSync8(path)) {
|
|
3160
|
-
console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
|
|
3161
|
-
return 1;
|
|
3162
|
-
}
|
|
3163
|
-
const key = await keyFor(project.manifest, project.root);
|
|
3164
|
-
if (!key.ok) {
|
|
3165
|
-
console.error(`
|
|
3166
|
-
vc: ${key.reason}
|
|
3167
|
-
`);
|
|
3168
|
-
return 1;
|
|
3169
|
-
}
|
|
3170
|
-
const required = allEnvKeys(project.manifest).filter((key2) => !key2.plaintext);
|
|
3171
|
-
const body = [
|
|
3172
|
-
"# Secrets, encrypted, and COMMITTED.",
|
|
3173
|
-
"#",
|
|
3174
|
-
"# Values are ciphertext; the key names are readable so a diff shows WHICH",
|
|
3175
|
-
"# secret changed without showing what it changed to. `unset` is the",
|
|
3176
|
-
"# documented placeholder and preflight refuses it.",
|
|
3177
|
-
"#",
|
|
3178
|
-
"# The private key is DERIVED from CLOUDFLARE_API_TOKEN, salted with the",
|
|
3179
|
-
"# account id — it is stored nowhere, so there is no .env.keys to leak.",
|
|
3180
|
-
"#",
|
|
3181
|
-
"# The cost of that: rotating the token makes every value below",
|
|
3182
|
-
"# permanently unreadable. `vc keys --rotate` re-encrypts while you still",
|
|
3183
|
-
"# have the old token; after that there is no recovery.",
|
|
3184
|
-
"",
|
|
3185
|
-
`DOTENV_PUBLIC_KEY_SECRETS="${key.publicKey}"`,
|
|
3186
|
-
"",
|
|
3187
|
-
...required.flatMap((key2) => [`# ${key2.breaks}${key2.where ? ` — from: ${key2.where}` : ""}`, `${key2.key}=unset`, ""])
|
|
3188
|
-
].join(`
|
|
3189
|
-
`);
|
|
3190
|
-
writeFileSync(path, body, { mode: 384 });
|
|
3191
|
-
console.log(`
|
|
3192
|
-
${color4.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`, under the key this token derives
|
|
3193
|
-
|
|
3194
|
-
` + `Next:
|
|
3195
|
-
` + ` 1. put the real values in, then
|
|
3196
|
-
` + ` 2. ${color4.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)}
|
|
3197
|
-
` + ` 3. commit it — there is no key file to keep out
|
|
3198
|
-
|
|
3199
|
-
` + color4.yellow(`! Rotating CLOUDFLARE_API_TOKEN makes these unreadable. \`vc keys\` explains.
|
|
3200
|
-
`));
|
|
3201
|
-
return 0;
|
|
3202
|
-
}
|
|
3203
|
-
|
|
3204
|
-
// src/deploy/preflight.ts
|
|
3205
2783
|
var UNSET = "unset";
|
|
3206
2784
|
function productionEnv(appDir) {
|
|
3207
2785
|
const out = new Map;
|
|
3208
|
-
const path =
|
|
3209
|
-
if (!
|
|
2786
|
+
const path = join7(appDir, ".env.production");
|
|
2787
|
+
if (!existsSync7(path))
|
|
3210
2788
|
return out;
|
|
3211
|
-
for (const line2 of
|
|
2789
|
+
for (const line2 of readFileSync2(path, "utf8").split(`
|
|
3212
2790
|
`)) {
|
|
3213
2791
|
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line2);
|
|
3214
2792
|
if (!match)
|
|
@@ -3233,12 +2811,12 @@ async function voidSecretNames(appDir) {
|
|
|
3233
2811
|
return names;
|
|
3234
2812
|
}
|
|
3235
2813
|
function routeProblem(project) {
|
|
3236
|
-
const path =
|
|
3237
|
-
if (!
|
|
2814
|
+
const path = join7(project.appDir, "wrangler.jsonc");
|
|
2815
|
+
if (!existsSync7(path))
|
|
3238
2816
|
return "wrangler.jsonc is missing";
|
|
3239
2817
|
let routes = [];
|
|
3240
2818
|
try {
|
|
3241
|
-
routes = parseJsonc(
|
|
2819
|
+
routes = parseJsonc(readFileSync2(path, "utf8")).routes ?? [];
|
|
3242
2820
|
} catch {
|
|
3243
2821
|
return "wrangler.jsonc cannot be parsed";
|
|
3244
2822
|
}
|
|
@@ -3278,7 +2856,7 @@ async function preflight(project, source) {
|
|
|
3278
2856
|
function printPreflight(project, result, source) {
|
|
3279
2857
|
const keys = allEnvKeys(project.manifest);
|
|
3280
2858
|
if (result.remote === null) {
|
|
3281
|
-
console.log(
|
|
2859
|
+
console.log(color3.dim(source === "wrangler" ? `Could not read the worker's secrets — not deployed yet, or wrangler is not logged in.
|
|
3282
2860
|
Anything not in .env.production is reported as missing.
|
|
3283
2861
|
` : `Could not read the project's secrets from Void — not logged in, or no linked project.
|
|
3284
2862
|
Anything not in .env.production is reported as missing.
|
|
@@ -3288,37 +2866,37 @@ Anything not in .env.production is reported as missing.
|
|
|
3288
2866
|
for (const key of keys) {
|
|
3289
2867
|
const value = result.present.get(key.key);
|
|
3290
2868
|
const set = value !== undefined && value !== "" && value !== UNSET;
|
|
3291
|
-
console.log(` ${set ?
|
|
2869
|
+
console.log(` ${set ? color3.green("set ") : color3.dim("unset")} ${key.key}${key.plaintext ? color3.dim(" (plaintext)") : ""}`);
|
|
3292
2870
|
}
|
|
3293
2871
|
if (result.ready) {
|
|
3294
2872
|
console.log(`
|
|
3295
|
-
${
|
|
2873
|
+
${color3.green("Ready to go live.")}
|
|
3296
2874
|
`);
|
|
3297
2875
|
return;
|
|
3298
2876
|
}
|
|
3299
2877
|
console.log(`
|
|
3300
|
-
${
|
|
2878
|
+
${color3.red("NOT ready to go live.")}
|
|
3301
2879
|
`);
|
|
3302
2880
|
if (result.routeProblem) {
|
|
3303
|
-
console.log(`${
|
|
2881
|
+
console.log(`${color3.red("✗")} the worker's hostname
|
|
3304
2882
|
${result.routeProblem}
|
|
3305
2883
|
`);
|
|
3306
2884
|
}
|
|
3307
2885
|
if (result.bareSecrets.length > 0) {
|
|
3308
|
-
console.log(`${
|
|
2886
|
+
console.log(`${color3.red("✗")} committed in the clear in ${SECRETS_FILE}: ${result.bareSecrets.join(", ")}
|
|
3309
2887
|
` + ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` — and treat those values as burned.
|
|
3310
2888
|
`);
|
|
3311
2889
|
}
|
|
3312
2890
|
for (const key of result.missing) {
|
|
3313
|
-
console.log(`${
|
|
2891
|
+
console.log(`${color3.red("✗")} ${key.key}`);
|
|
3314
2892
|
console.log(` ${key.breaks}`);
|
|
3315
2893
|
if (key.where)
|
|
3316
|
-
console.log(` ${
|
|
2894
|
+
console.log(` ${color3.dim(`from: ${key.where}`)}`);
|
|
3317
2895
|
console.log("");
|
|
3318
2896
|
}
|
|
3319
2897
|
const secrets = result.missing.filter((key) => !key.plaintext);
|
|
3320
2898
|
if (secrets.length > 0) {
|
|
3321
|
-
const inRepo =
|
|
2899
|
+
const inRepo = existsSync7(join7(project.root, SECRETS_FILE));
|
|
3322
2900
|
console.log(inRepo ? `Put each in ${SECRETS_FILE}, which the deploy uploads:` : source === "wrangler" ? "Set each secret on the worker (this also creates the draft worker on a first deploy):" : "Set each secret on the project:");
|
|
3323
2901
|
for (const key of secrets) {
|
|
3324
2902
|
console.log(inRepo ? ` dotenvx set ${key.key} '…' -f ${SECRETS_FILE}` : ` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
|
|
@@ -3331,17 +2909,17 @@ ${color5.red("NOT ready to go live.")}
|
|
|
3331
2909
|
}
|
|
3332
2910
|
|
|
3333
2911
|
// src/deploy/cloudflare.ts
|
|
3334
|
-
import { existsSync as
|
|
3335
|
-
import { dirname as
|
|
3336
|
-
import
|
|
2912
|
+
import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
|
2913
|
+
import { dirname as dirname5, join as join8 } from "node:path";
|
|
2914
|
+
import color4 from "picocolors";
|
|
3337
2915
|
var fail = (message) => {
|
|
3338
2916
|
console.error(`
|
|
3339
|
-
${
|
|
2917
|
+
${color4.red("✗")} ${message}
|
|
3340
2918
|
`);
|
|
3341
2919
|
return 1;
|
|
3342
2920
|
};
|
|
3343
2921
|
function readConfig(path) {
|
|
3344
|
-
return parseJsonc(
|
|
2922
|
+
return parseJsonc(readFileSync3(path, "utf8"));
|
|
3345
2923
|
}
|
|
3346
2924
|
function findBuilder(from) {
|
|
3347
2925
|
let dir = from;
|
|
@@ -3350,11 +2928,11 @@ function findBuilder(from) {
|
|
|
3350
2928
|
["vp", "vp build"],
|
|
3351
2929
|
["vite", "vite build"]
|
|
3352
2930
|
]) {
|
|
3353
|
-
const path =
|
|
3354
|
-
if (
|
|
2931
|
+
const path = join8(dir, "node_modules", ".bin", bin);
|
|
2932
|
+
if (existsSync8(path))
|
|
3355
2933
|
return { cmd: path, label };
|
|
3356
2934
|
}
|
|
3357
|
-
const parent =
|
|
2935
|
+
const parent = dirname5(dir);
|
|
3358
2936
|
if (parent === dir)
|
|
3359
2937
|
return null;
|
|
3360
2938
|
dir = parent;
|
|
@@ -3362,8 +2940,8 @@ function findBuilder(from) {
|
|
|
3362
2940
|
}
|
|
3363
2941
|
async function deployCloudflare(project, opts) {
|
|
3364
2942
|
const app = project.appDir;
|
|
3365
|
-
const configPath =
|
|
3366
|
-
if (!
|
|
2943
|
+
const configPath = join8(app, "wrangler.jsonc");
|
|
2944
|
+
if (!existsSync8(configPath))
|
|
3367
2945
|
return fail("wrangler.jsonc is missing — `vc generate` writes it.");
|
|
3368
2946
|
const bin = findWrangler(app);
|
|
3369
2947
|
if (!bin)
|
|
@@ -3376,16 +2954,16 @@ async function deployCloudflare(project, opts) {
|
|
|
3376
2954
|
${who.raw.trim().split(`
|
|
3377
2955
|
`).slice(-4).join(`
|
|
3378
2956
|
`)}`);
|
|
3379
|
-
console.log(`${
|
|
2957
|
+
console.log(`${color4.green("✓")} wrangler is logged in`);
|
|
3380
2958
|
let config = readConfig(configPath);
|
|
3381
2959
|
let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
3382
2960
|
if (!accountId) {
|
|
3383
2961
|
if (who.accounts.length === 1) {
|
|
3384
2962
|
accountId = who.accounts[0].id;
|
|
3385
|
-
|
|
2963
|
+
writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "account_id", accountId));
|
|
3386
2964
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
|
|
3387
2965
|
await writeManifest(project.root, project.manifest);
|
|
3388
|
-
console.log(`${
|
|
2966
|
+
console.log(`${color4.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
|
|
3389
2967
|
} else {
|
|
3390
2968
|
return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
|
|
3391
2969
|
${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
@@ -3397,7 +2975,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3397
2975
|
if (!check.ready) {
|
|
3398
2976
|
if (!opts.force)
|
|
3399
2977
|
return fail("refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force for a deliberate partial deploy.");
|
|
3400
|
-
console.error(
|
|
2978
|
+
console.error(color4.yellow(`--force: deploying a shop that is NOT ready for customers.
|
|
3401
2979
|
`));
|
|
3402
2980
|
}
|
|
3403
2981
|
const worker = config.name || project.manifest.shop.domain.split(".")[0];
|
|
@@ -3408,15 +2986,15 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3408
2986
|
return fail("the D1 database is not provisioned. Run once with --provision to create it and record its id.");
|
|
3409
2987
|
const db = await ensureD1(bin, app, `${worker}-db`);
|
|
3410
2988
|
const entry = { binding: "DB", database_name: db.name, database_id: db.uuid, migrations_dir: "./db/migrations" };
|
|
3411
|
-
|
|
2989
|
+
writeFileSync(configPath, upsertJsonc(readFileSync3(configPath, "utf8"), "d1_databases", [entry]));
|
|
3412
2990
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
|
|
3413
2991
|
await writeManifest(project.root, project.manifest);
|
|
3414
|
-
console.log(`${
|
|
2992
|
+
console.log(`${color4.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
|
|
3415
2993
|
config = readConfig(configPath);
|
|
3416
2994
|
}
|
|
3417
2995
|
if (opts.provision) {
|
|
3418
2996
|
await ensureQueue(bin, app, "commerce");
|
|
3419
|
-
console.log(`${
|
|
2997
|
+
console.log(`${color4.green("✓")} queue "commerce"`);
|
|
3420
2998
|
}
|
|
3421
2999
|
const builder = findBuilder(app);
|
|
3422
3000
|
if (!builder)
|
|
@@ -3426,10 +3004,10 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3426
3004
|
const built = await wrangler(builder.cmd, ["build"], app, true);
|
|
3427
3005
|
if (built.code !== 0)
|
|
3428
3006
|
return fail(`the build failed (exit ${built.code}).`);
|
|
3429
|
-
const emittedPath =
|
|
3430
|
-
if (!
|
|
3007
|
+
const emittedPath = join8(app, "dist", "ssr", "wrangler.json");
|
|
3008
|
+
if (!existsSync8(emittedPath))
|
|
3431
3009
|
return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
|
|
3432
|
-
const emitted = JSON.parse(
|
|
3010
|
+
const emitted = JSON.parse(readFileSync3(emittedPath, "utf8"));
|
|
3433
3011
|
const secretKeys = new Set(allEnvKeys(project.manifest).filter((key) => !key.plaintext).map((key) => key.key));
|
|
3434
3012
|
const scrubbed = [];
|
|
3435
3013
|
for (const [key, value] of Object.entries(emitted.vars ?? {})) {
|
|
@@ -3441,8 +3019,8 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3441
3019
|
const emittedD1 = emitted.d1_databases?.find((db) => db.binding === "DB");
|
|
3442
3020
|
if (!emittedD1 || emittedD1.database_id === "local")
|
|
3443
3021
|
return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
|
|
3444
|
-
|
|
3445
|
-
console.log(`${
|
|
3022
|
+
writeFileSync(emittedPath, JSON.stringify(emitted, null, 2));
|
|
3023
|
+
console.log(`${color4.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
|
|
3446
3024
|
console.log(`
|
|
3447
3025
|
▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
|
|
3448
3026
|
const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
|
|
@@ -3450,34 +3028,34 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3450
3028
|
return fail(`applying migrations failed (exit ${migrated.code}); nothing was deployed.`);
|
|
3451
3029
|
const secretArgs = [];
|
|
3452
3030
|
let cleanupSecrets;
|
|
3453
|
-
if (
|
|
3031
|
+
if (existsSync8(join8(project.root, SECRETS_FILE))) {
|
|
3454
3032
|
const decrypted = await decryptSecrets(project);
|
|
3455
3033
|
if ("error" in decrypted)
|
|
3456
3034
|
return fail(`the repository's secrets could not be read: ${decrypted.error}`);
|
|
3457
3035
|
secretArgs.push("--secrets-file", decrypted.path);
|
|
3458
3036
|
cleanupSecrets = decrypted.cleanup;
|
|
3459
|
-
console.log(`${
|
|
3037
|
+
console.log(`${color4.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
|
|
3460
3038
|
}
|
|
3461
3039
|
console.log(`
|
|
3462
3040
|
▸ wrangler deploy -c dist/ssr/wrangler.json`);
|
|
3463
|
-
const deployed = await wrangler(bin, ["deploy", "-c",
|
|
3041
|
+
const deployed = await wrangler(bin, ["deploy", "-c", join8("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
|
|
3464
3042
|
cleanupSecrets?.();
|
|
3465
3043
|
if (deployed.code !== 0)
|
|
3466
3044
|
return fail(`wrangler deploy failed (exit ${deployed.code}).`);
|
|
3467
3045
|
const domain = project.manifest.shop.domain;
|
|
3468
3046
|
const url = `https://${workerHosts(project.manifest)[0]}`;
|
|
3469
3047
|
console.log(`
|
|
3470
|
-
${
|
|
3048
|
+
${color4.green("Live:")} ${url}`);
|
|
3471
3049
|
if (hasFrontend(project.manifest.layout))
|
|
3472
|
-
console.log(
|
|
3050
|
+
console.log(color4.dim("The storefront deploys itself from GitHub Actions on push."));
|
|
3473
3051
|
return 0;
|
|
3474
3052
|
}
|
|
3475
3053
|
|
|
3476
3054
|
// src/import.ts
|
|
3477
3055
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
3478
|
-
import { existsSync as
|
|
3479
|
-
import { join as
|
|
3480
|
-
import
|
|
3056
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
3057
|
+
import { join as join9 } from "node:path";
|
|
3058
|
+
import color5 from "picocolors";
|
|
3481
3059
|
function describe(counts) {
|
|
3482
3060
|
if (!counts)
|
|
3483
3061
|
return "";
|
|
@@ -3516,7 +3094,7 @@ async function post(url, key, body) {
|
|
|
3516
3094
|
return { ok: response.ok, status: response.status, body: { ...parsed, error: parsed.error ?? parsed.message } };
|
|
3517
3095
|
}
|
|
3518
3096
|
async function readJson(path) {
|
|
3519
|
-
if (!
|
|
3097
|
+
if (!existsSync9(path))
|
|
3520
3098
|
return null;
|
|
3521
3099
|
try {
|
|
3522
3100
|
return JSON.parse(await readFile3(path, "utf8"));
|
|
@@ -3544,41 +3122,41 @@ async function importCommand(args) {
|
|
|
3544
3122
|
}
|
|
3545
3123
|
const { url, how } = resolveTarget(project, args);
|
|
3546
3124
|
if (!dry)
|
|
3547
|
-
console.log(`${
|
|
3125
|
+
console.log(`${color5.dim("→")} ${url} ${color5.dim(`(${how})`)}
|
|
3548
3126
|
`);
|
|
3549
3127
|
if (!dry) {
|
|
3550
3128
|
let whoami2;
|
|
3551
3129
|
try {
|
|
3552
3130
|
whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
|
|
3553
3131
|
} catch (error) {
|
|
3554
|
-
console.error(`${
|
|
3132
|
+
console.error(`${color5.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
|
|
3555
3133
|
` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
|
|
3556
3134
|
`);
|
|
3557
3135
|
return 1;
|
|
3558
3136
|
}
|
|
3559
3137
|
if (!whoami2.ok) {
|
|
3560
|
-
console.error(`${
|
|
3138
|
+
console.error(`${color5.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
|
|
3561
3139
|
` + ` Is SYSTEM_API_KEY the one this deployment was given?
|
|
3562
3140
|
`);
|
|
3563
3141
|
return 1;
|
|
3564
3142
|
}
|
|
3565
|
-
console.log(`${
|
|
3143
|
+
console.log(`${color5.green("✓")} authenticated as the system identity`);
|
|
3566
3144
|
}
|
|
3567
3145
|
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(
|
|
3146
|
+
const products = await readJson(join9(root, "data", "catalog.json"));
|
|
3147
|
+
const categories = await readJson(join9(root, "data", "categories.json"));
|
|
3148
|
+
const faqs = has(project.manifest, "faqs") ? await readJson(join9(root, "content", "faqs.json")) : null;
|
|
3149
|
+
const posts = has(project.manifest, "blogs") ? await readJson(join9(root, "content", "posts.json")) : null;
|
|
3572
3150
|
if (!products && !faqs?.length && !posts?.length) {
|
|
3573
3151
|
console.log(`
|
|
3574
|
-
${
|
|
3152
|
+
${color5.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
|
|
3575
3153
|
` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
|
|
3576
3154
|
`);
|
|
3577
3155
|
return 0;
|
|
3578
3156
|
}
|
|
3579
3157
|
if (dry) {
|
|
3580
3158
|
console.log(`
|
|
3581
|
-
${
|
|
3159
|
+
${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
3582
3160
|
console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
|
|
3583
3161
|
console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
|
|
3584
3162
|
`);
|
|
@@ -3594,15 +3172,15 @@ ${color7.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3594
3172
|
});
|
|
3595
3173
|
if (result.ok) {
|
|
3596
3174
|
const report = result.body.report ?? {};
|
|
3597
|
-
console.log(`${
|
|
3175
|
+
console.log(`${color5.green("✓")} products: ${describe(report.products)}`);
|
|
3598
3176
|
if (report.categories)
|
|
3599
|
-
console.log(`${
|
|
3177
|
+
console.log(`${color5.green("✓")} categories: ${describe(report.categories)}`);
|
|
3600
3178
|
if (report.prices)
|
|
3601
|
-
console.log(`${
|
|
3179
|
+
console.log(`${color5.green("✓")} prices: ${describe(report.prices)}`);
|
|
3602
3180
|
if (report.addons)
|
|
3603
|
-
console.log(`${
|
|
3181
|
+
console.log(`${color5.green("✓")} addons: ${describe(report.addons)}`);
|
|
3604
3182
|
} else {
|
|
3605
|
-
console.error(`${
|
|
3183
|
+
console.error(`${color5.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3606
3184
|
failed = true;
|
|
3607
3185
|
}
|
|
3608
3186
|
}
|
|
@@ -3614,20 +3192,20 @@ ${color7.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3614
3192
|
continue;
|
|
3615
3193
|
const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
|
|
3616
3194
|
if (result.ok) {
|
|
3617
|
-
console.log(`${
|
|
3195
|
+
console.log(`${color5.green("✓")} ${label}: ${describe(result.body)}`);
|
|
3618
3196
|
} else {
|
|
3619
|
-
console.error(`${
|
|
3197
|
+
console.error(`${color5.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3620
3198
|
failed = true;
|
|
3621
3199
|
}
|
|
3622
3200
|
}
|
|
3623
3201
|
if (failed) {
|
|
3624
3202
|
console.error(`
|
|
3625
|
-
${
|
|
3203
|
+
${color5.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
|
|
3626
3204
|
`);
|
|
3627
3205
|
return 1;
|
|
3628
3206
|
}
|
|
3629
3207
|
console.log(`
|
|
3630
|
-
${
|
|
3208
|
+
${color5.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
|
|
3631
3209
|
`);
|
|
3632
3210
|
return 0;
|
|
3633
3211
|
}
|
|
@@ -3648,4 +3226,4 @@ async function importHelp() {
|
|
|
3648
3226
|
return 0;
|
|
3649
3227
|
}
|
|
3650
3228
|
|
|
3651
|
-
export { renderAuthTs,
|
|
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 };
|