@saastemly/voidcommerce 0.2.2 → 0.3.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 +34 -1
- package/dist/deploy/cloudflare.d.ts +3 -1
- package/dist/deploy/index.d.ts +3 -0
- package/dist/deploy/preflight.d.ts +2 -0
- package/dist/deploy/secrets.d.ts +68 -0
- package/dist/{index-sbchkpa6.js → index-84wq4qp8.js} +311 -75
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli.ts +2 -1
- package/src/deploy/cloudflare.ts +25 -3
- package/src/deploy/index.ts +35 -0
- package/src/deploy/preflight.ts +38 -3
- package/src/deploy/secrets.ts +244 -0
- package/src/dist.ts +3 -1
- package/src/generate/ci.ts +30 -5
- package/src/generate/index.ts +9 -1
- package/src/generate/strict.ts +3 -0
- package/src/help.ts +1 -0
|
@@ -840,6 +840,7 @@ function strictDependencies(manifest) {
|
|
|
840
840
|
const devDependencies = {};
|
|
841
841
|
for (const name of [
|
|
842
842
|
"@hono/node-server",
|
|
843
|
+
"@dotenvx/dotenvx",
|
|
843
844
|
"@rolldown/plugin-babel",
|
|
844
845
|
"@tailwindcss/vite",
|
|
845
846
|
"@types/node",
|
|
@@ -1436,7 +1437,7 @@ import color from "picocolors";
|
|
|
1436
1437
|
// package.json
|
|
1437
1438
|
var package_default = {
|
|
1438
1439
|
name: "@saastemly/voidcommerce",
|
|
1439
|
-
version: "0.
|
|
1440
|
+
version: "0.3.0",
|
|
1440
1441
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1441
1442
|
type: "module",
|
|
1442
1443
|
license: "MIT",
|
|
@@ -1492,6 +1493,7 @@ var EXTENDED_ROWS = [
|
|
|
1492
1493
|
["vc dev | build | preview", "the app's own script, run where the app is — api/ for a monorepo, .vc/app for strict"],
|
|
1493
1494
|
["vc dist", "the deployable app as a self-contained tree — what the void-dist branch carries"],
|
|
1494
1495
|
["vc import", "push data/ and content/ into the running shop, as the shop itself — an upsert, safe on every deploy"],
|
|
1496
|
+
["vc secrets", "the shop's secrets, encrypted and committed — one key where the deploy runs, instead of one wrangler secret put per value"],
|
|
1495
1497
|
["vc preflight", "is the shop ready to advertise on? every required key, and what breaks without it"],
|
|
1496
1498
|
["vc deploy", "preflight, then void deploy; or --cloudflare: wrangler, your own account, no Void login"]
|
|
1497
1499
|
];
|
|
@@ -1721,7 +1723,7 @@ async function distCommand(args) {
|
|
|
1721
1723
|
appPkg["patchedDependencies"] = rootPkg["patchedDependencies"];
|
|
1722
1724
|
await writeFile(appPkgPath, `${JSON.stringify(appPkg, null, 2)}
|
|
1723
1725
|
`, "utf8");
|
|
1724
|
-
for (const file of ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml"]) {
|
|
1726
|
+
for (const file of ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml", ".env.secrets"]) {
|
|
1725
1727
|
const source = join3(project.root, file);
|
|
1726
1728
|
if (existsSync3(source))
|
|
1727
1729
|
await cp(source, join3(target, file));
|
|
@@ -1876,13 +1878,27 @@ and \`voidcommerce.json\` so every later generate carries the real ids.
|
|
|
1876
1878
|
|
|
1877
1879
|
### 3. The secrets
|
|
1878
1880
|
|
|
1881
|
+
They live in the repository, encrypted:
|
|
1882
|
+
|
|
1879
1883
|
\`\`\`sh
|
|
1880
|
-
vc
|
|
1884
|
+
vc secrets --init # every required key, as \`unset\`
|
|
1885
|
+
# put the real values in, then
|
|
1886
|
+
bunx dotenvx encrypt -f .env.secrets # ciphertext; commit this
|
|
1881
1887
|
\`\`\`
|
|
1882
1888
|
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1889
|
+
\`.env.secrets\` is committed and \`.env.keys\` is not. The deploy decrypts it and
|
|
1890
|
+
hands the values to \`wrangler deploy --secrets-file\`, which stores them as
|
|
1891
|
+
real Worker secrets — not as plaintext \`vars\`, which anyone with dashboard
|
|
1892
|
+
access can read.
|
|
1893
|
+
|
|
1894
|
+
That turns one \`wrangler secret put\` per value into one build variable, set
|
|
1895
|
+
once in step 5. It also means the shop rebuilds from a checkout.
|
|
1896
|
+
|
|
1897
|
+
**Know the tradeoff.** Ciphertext in git is permanent: if the private key ever
|
|
1898
|
+
leaks, every secret in the history is readable, including ones you rotated.
|
|
1899
|
+
\`wrangler secret put\` does not have that property, and stays available for
|
|
1900
|
+
anything you would rather never commit. \`vc preflight\` counts a secret the
|
|
1901
|
+
repository declares as present, and refuses any value committed in the clear.
|
|
1886
1902
|
|
|
1887
1903
|
### 4. An API token the build can use
|
|
1888
1904
|
|
|
@@ -1917,9 +1933,20 @@ the \`wrangler.jsonc\` at the root directory, or the build fails.
|
|
|
1917
1933
|
| branch | \`${DIST_BRANCH}\` |
|
|
1918
1934
|
| root directory | \`/\` |
|
|
1919
1935
|
| build command | \`bun install && bunx void prepare && bunx vp build\` |
|
|
1920
|
-
| deploy command |
|
|
1936
|
+
| deploy command | see below |
|
|
1937
|
+
| build variable | \`DOTENV_PRIVATE_KEY_SECRETS\`, marked as a secret — the one value that is not in the repository |
|
|
1921
1938
|
| API token | the one from step 4 |
|
|
1922
1939
|
|
|
1940
|
+
The deploy command, on one line:
|
|
1941
|
+
|
|
1942
|
+
\`\`\`sh
|
|
1943
|
+
bunx dotenvx decrypt -f .env.secrets --stdout > .vc-secrets.env && bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/ssr/wrangler.json --secrets-file .vc-secrets.env
|
|
1944
|
+
\`\`\`
|
|
1945
|
+
|
|
1946
|
+
Plain \`sh\`, so it does not rely on process substitution. The decrypted file
|
|
1947
|
+
exists only inside the build sandbox, and \`--secrets-file\` applies additively:
|
|
1948
|
+
a secret it does not name is left alone rather than deleted.
|
|
1949
|
+
|
|
1923
1950
|
The migration command names the **binding** (\`DB\`), not the database, so it
|
|
1924
1951
|
still points at the right database if the name ever differs.
|
|
1925
1952
|
|
|
@@ -2459,6 +2486,7 @@ async function generateStrictRoot(root, manifest, result) {
|
|
|
2459
2486
|
.wrangler
|
|
2460
2487
|
.env
|
|
2461
2488
|
.env.local
|
|
2489
|
+
.env.keys
|
|
2462
2490
|
dist
|
|
2463
2491
|
*.tsbuildinfo
|
|
2464
2492
|
.DS_Store
|
|
@@ -2828,16 +2856,196 @@ ${ran.out.trim()}`);
|
|
|
2828
2856
|
}
|
|
2829
2857
|
|
|
2830
2858
|
// src/deploy/preflight.ts
|
|
2831
|
-
import { existsSync as
|
|
2832
|
-
import { join as
|
|
2859
|
+
import { existsSync as existsSync8, readFileSync as readFileSync3 } from "node:fs";
|
|
2860
|
+
import { join as join8 } from "node:path";
|
|
2861
|
+
import color4 from "picocolors";
|
|
2862
|
+
|
|
2863
|
+
// src/deploy/secrets.ts
|
|
2864
|
+
import { existsSync as existsSync7, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
2865
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
2866
|
+
import { delimiter as delimiter3, dirname as dirname5, join as join7 } from "node:path";
|
|
2867
|
+
import { tmpdir } from "node:os";
|
|
2833
2868
|
import color3 from "picocolors";
|
|
2869
|
+
var SECRETS_FILE = ".env.secrets";
|
|
2870
|
+
var PRIVATE_KEY_VAR = "DOTENV_PRIVATE_KEY_SECRETS";
|
|
2871
|
+
function findDotenvx(from) {
|
|
2872
|
+
let dir = from;
|
|
2873
|
+
for (;; ) {
|
|
2874
|
+
const local = join7(dir, "node_modules", ".bin", "dotenvx");
|
|
2875
|
+
if (existsSync7(local))
|
|
2876
|
+
return local;
|
|
2877
|
+
const parent = dirname5(dir);
|
|
2878
|
+
if (parent === dir)
|
|
2879
|
+
break;
|
|
2880
|
+
dir = parent;
|
|
2881
|
+
}
|
|
2882
|
+
return (process.env["PATH"] ?? "").split(delimiter3).filter(Boolean).some((entry) => existsSync7(join7(entry, "dotenvx"))) ? "dotenvx" : null;
|
|
2883
|
+
}
|
|
2884
|
+
function run(cmd, args, cwd, env = {}) {
|
|
2885
|
+
return new Promise((resolve) => {
|
|
2886
|
+
const child = spawn3(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
|
|
2887
|
+
let out = "";
|
|
2888
|
+
child.stdout.on("data", (chunk) => {
|
|
2889
|
+
out += chunk;
|
|
2890
|
+
});
|
|
2891
|
+
child.stderr.on("data", (chunk) => {
|
|
2892
|
+
out += chunk;
|
|
2893
|
+
});
|
|
2894
|
+
child.on("error", (error) => resolve({ code: 1, out: String(error) }));
|
|
2895
|
+
child.on("exit", (code) => resolve({ code: code ?? 1, out }));
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
function declaredSecretNames(root) {
|
|
2899
|
+
const path = join7(root, SECRETS_FILE);
|
|
2900
|
+
if (!existsSync7(path))
|
|
2901
|
+
return new Set;
|
|
2902
|
+
const names = new Set;
|
|
2903
|
+
for (const line2 of readFileSync2(path, "utf8").split(`
|
|
2904
|
+
`)) {
|
|
2905
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line2);
|
|
2906
|
+
if (match && !match[1].startsWith("DOTENV_"))
|
|
2907
|
+
names.add(match[1]);
|
|
2908
|
+
}
|
|
2909
|
+
return names;
|
|
2910
|
+
}
|
|
2911
|
+
function plaintextSecretNames(root) {
|
|
2912
|
+
const path = join7(root, SECRETS_FILE);
|
|
2913
|
+
if (!existsSync7(path))
|
|
2914
|
+
return [];
|
|
2915
|
+
const bare = [];
|
|
2916
|
+
for (const line2 of readFileSync2(path, "utf8").split(`
|
|
2917
|
+
`)) {
|
|
2918
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line2);
|
|
2919
|
+
if (!match || match[1].startsWith("DOTENV_"))
|
|
2920
|
+
continue;
|
|
2921
|
+
const value = match[2].trim().replace(/^['"]|['"]$/g, "");
|
|
2922
|
+
if (value && value !== "unset" && !value.startsWith("encrypted:"))
|
|
2923
|
+
bare.push(match[1]);
|
|
2924
|
+
}
|
|
2925
|
+
return bare;
|
|
2926
|
+
}
|
|
2927
|
+
async function decryptSecrets(project) {
|
|
2928
|
+
const root = project.root;
|
|
2929
|
+
if (!existsSync7(join7(root, SECRETS_FILE)))
|
|
2930
|
+
return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
|
|
2931
|
+
const dotenvx = findDotenvx(root);
|
|
2932
|
+
if (!dotenvx)
|
|
2933
|
+
return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
|
|
2934
|
+
if (!process.env[PRIVATE_KEY_VAR] && !existsSync7(join7(root, ".env.keys"))) {
|
|
2935
|
+
return {
|
|
2936
|
+
error: `${PRIVATE_KEY_VAR} is not set and there is no .env.keys here.
|
|
2937
|
+
It is the one value that stays out of the repository; set it where the deploy runs.`
|
|
2938
|
+
};
|
|
2939
|
+
}
|
|
2940
|
+
const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root);
|
|
2941
|
+
if (result.code !== 0)
|
|
2942
|
+
return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split(`
|
|
2943
|
+
`).slice(-2).join(" ")}` };
|
|
2944
|
+
const lines = result.out.split(`
|
|
2945
|
+
`).filter((line2) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line2) && !line2.trimStart().startsWith("DOTENV_"));
|
|
2946
|
+
const names = lines.map((line2) => line2.slice(0, line2.indexOf("=")).trim());
|
|
2947
|
+
const path = join7(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
|
|
2948
|
+
writeFileSync(path, `${lines.join(`
|
|
2949
|
+
`)}
|
|
2950
|
+
`, { mode: 384 });
|
|
2951
|
+
return {
|
|
2952
|
+
path,
|
|
2953
|
+
names,
|
|
2954
|
+
cleanup: () => {
|
|
2955
|
+
try {
|
|
2956
|
+
writeFileSync(path, "", { mode: 384 });
|
|
2957
|
+
__require("node:fs").unlinkSync(path);
|
|
2958
|
+
} catch {}
|
|
2959
|
+
}
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
async function secretsCommand(project, args) {
|
|
2963
|
+
const root = project.root;
|
|
2964
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
2965
|
+
const declared = declaredSecretNames(root);
|
|
2966
|
+
const bare = plaintextSecretNames(root);
|
|
2967
|
+
if (args.includes("--init"))
|
|
2968
|
+
return initSecrets(project);
|
|
2969
|
+
if (!existsSync7(join7(root, SECRETS_FILE))) {
|
|
2970
|
+
console.log(`
|
|
2971
|
+
${color3.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
|
|
2972
|
+
` + `which means the shop cannot be rebuilt from a checkout.
|
|
2973
|
+
|
|
2974
|
+
` + ` vc secrets --init write one, with every required key as \`unset\`
|
|
2975
|
+
`);
|
|
2976
|
+
return 1;
|
|
2977
|
+
}
|
|
2978
|
+
console.log(`
|
|
2979
|
+
${SECRETS_FILE} — committed, encrypted, and read by \`vc deploy --cloudflare\`
|
|
2980
|
+
`);
|
|
2981
|
+
for (const key of required) {
|
|
2982
|
+
const state = declared.has(key.key) ? color3.green("declared") : color3.red("MISSING ");
|
|
2983
|
+
console.log(` ${state} ${key.key}`);
|
|
2984
|
+
}
|
|
2985
|
+
const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
|
|
2986
|
+
for (const name of extra)
|
|
2987
|
+
console.log(` ${color3.dim("extra ")} ${name} ${color3.dim("— not required by this shop")}`);
|
|
2988
|
+
if (bare.length > 0) {
|
|
2989
|
+
console.log(`
|
|
2990
|
+
${color3.red("✗")} ${bare.length} value${bare.length === 1 ? " is" : "s are"} committed IN THE CLEAR: ${bare.join(", ")}
|
|
2991
|
+
` + ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` before committing again.
|
|
2992
|
+
`);
|
|
2993
|
+
return 1;
|
|
2994
|
+
}
|
|
2995
|
+
const missing = required.filter((key) => !declared.has(key.key));
|
|
2996
|
+
if (missing.length > 0) {
|
|
2997
|
+
console.log(`
|
|
2998
|
+
${color3.red("✗")} ${missing.length} required secret${missing.length === 1 ? "" : "s"} not declared.
|
|
2999
|
+
` + ` dotenvx set ${missing[0].key} '…' -f ${SECRETS_FILE}
|
|
3000
|
+
`);
|
|
3001
|
+
return 1;
|
|
3002
|
+
}
|
|
3003
|
+
console.log(`
|
|
3004
|
+
${color3.green("✓")} every secret this shop needs is declared and encrypted.
|
|
3005
|
+
`);
|
|
3006
|
+
return 0;
|
|
3007
|
+
}
|
|
3008
|
+
async function initSecrets(project) {
|
|
3009
|
+
const path = join7(project.root, SECRETS_FILE);
|
|
3010
|
+
if (existsSync7(path)) {
|
|
3011
|
+
console.error(`vc: ${SECRETS_FILE} already exists; not overwriting it.`);
|
|
3012
|
+
return 1;
|
|
3013
|
+
}
|
|
3014
|
+
const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
|
|
3015
|
+
const body = [
|
|
3016
|
+
"# Secrets, encrypted, and COMMITTED.",
|
|
3017
|
+
"#",
|
|
3018
|
+
"# Values are ciphertext; the key names are readable so a diff shows WHICH",
|
|
3019
|
+
"# secret changed without showing what it changed to. `unset` is the",
|
|
3020
|
+
"# documented placeholder and preflight refuses it.",
|
|
3021
|
+
"#",
|
|
3022
|
+
"# The private key stays out of the repository. It lives in .env.keys",
|
|
3023
|
+
"# locally (gitignored) and as one build variable where the deploy runs.",
|
|
3024
|
+
"",
|
|
3025
|
+
...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""])
|
|
3026
|
+
].join(`
|
|
3027
|
+
`);
|
|
3028
|
+
writeFileSync(path, body, { mode: 384 });
|
|
3029
|
+
console.log(`
|
|
3030
|
+
${color3.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`
|
|
3031
|
+
|
|
3032
|
+
` + `Next:
|
|
3033
|
+
` + ` 1. put the real values in, then
|
|
3034
|
+
` + ` 2. ${color3.cyan(`dotenvx encrypt -f ${SECRETS_FILE}`)}
|
|
3035
|
+
` + ` 3. commit ${SECRETS_FILE}; never commit .env.keys
|
|
3036
|
+
` + ` 4. set ${PRIVATE_KEY_VAR} where the deploy runs
|
|
3037
|
+
`);
|
|
3038
|
+
return 0;
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
// src/deploy/preflight.ts
|
|
2834
3042
|
var UNSET = "unset";
|
|
2835
3043
|
function productionEnv(appDir) {
|
|
2836
3044
|
const out = new Map;
|
|
2837
|
-
const path =
|
|
2838
|
-
if (!
|
|
3045
|
+
const path = join8(appDir, ".env.production");
|
|
3046
|
+
if (!existsSync8(path))
|
|
2839
3047
|
return out;
|
|
2840
|
-
for (const line2 of
|
|
3048
|
+
for (const line2 of readFileSync3(path, "utf8").split(`
|
|
2841
3049
|
`)) {
|
|
2842
3050
|
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line2);
|
|
2843
3051
|
if (!match)
|
|
@@ -2862,12 +3070,12 @@ async function voidSecretNames(appDir) {
|
|
|
2862
3070
|
return names;
|
|
2863
3071
|
}
|
|
2864
3072
|
function routeProblem(project) {
|
|
2865
|
-
const path =
|
|
2866
|
-
if (!
|
|
3073
|
+
const path = join8(project.appDir, "wrangler.jsonc");
|
|
3074
|
+
if (!existsSync8(path))
|
|
2867
3075
|
return "wrangler.jsonc is missing";
|
|
2868
3076
|
let routes = [];
|
|
2869
3077
|
try {
|
|
2870
|
-
routes = parseJsonc(
|
|
3078
|
+
routes = parseJsonc(readFileSync3(path, "utf8")).routes ?? [];
|
|
2871
3079
|
} catch {
|
|
2872
3080
|
return "wrangler.jsonc cannot be parsed";
|
|
2873
3081
|
}
|
|
@@ -2887,17 +3095,27 @@ async function preflight(project, source) {
|
|
|
2887
3095
|
}
|
|
2888
3096
|
for (const name of remote ?? [])
|
|
2889
3097
|
present.set(name, "<secret>");
|
|
3098
|
+
for (const name of declaredSecretNames(project.root))
|
|
3099
|
+
present.set(name, "<in the repository>");
|
|
2890
3100
|
const missing = allEnvKeys(project.manifest).filter((key) => {
|
|
2891
3101
|
const value = present.get(key.key);
|
|
2892
3102
|
return value === undefined || value === "" || value === UNSET;
|
|
2893
3103
|
});
|
|
2894
3104
|
const problem = routeProblem(project);
|
|
2895
|
-
|
|
3105
|
+
const bare = plaintextSecretNames(project.root);
|
|
3106
|
+
return {
|
|
3107
|
+
present,
|
|
3108
|
+
remote,
|
|
3109
|
+
missing,
|
|
3110
|
+
routeProblem: problem,
|
|
3111
|
+
bareSecrets: bare,
|
|
3112
|
+
ready: missing.length === 0 && problem === null && bare.length === 0
|
|
3113
|
+
};
|
|
2896
3114
|
}
|
|
2897
3115
|
function printPreflight(project, result, source) {
|
|
2898
3116
|
const keys = allEnvKeys(project.manifest);
|
|
2899
3117
|
if (result.remote === null) {
|
|
2900
|
-
console.log(
|
|
3118
|
+
console.log(color4.dim(source === "wrangler" ? `Could not read the worker's secrets — not deployed yet, or wrangler is not logged in.
|
|
2901
3119
|
Anything not in .env.production is reported as missing.
|
|
2902
3120
|
` : `Could not read the project's secrets from Void — not logged in, or no linked project.
|
|
2903
3121
|
Anything not in .env.production is reported as missing.
|
|
@@ -2907,34 +3125,41 @@ Anything not in .env.production is reported as missing.
|
|
|
2907
3125
|
for (const key of keys) {
|
|
2908
3126
|
const value = result.present.get(key.key);
|
|
2909
3127
|
const set = value !== undefined && value !== "" && value !== UNSET;
|
|
2910
|
-
console.log(` ${set ?
|
|
3128
|
+
console.log(` ${set ? color4.green("set ") : color4.dim("unset")} ${key.key}${key.plaintext ? color4.dim(" (plaintext)") : ""}`);
|
|
2911
3129
|
}
|
|
2912
3130
|
if (result.ready) {
|
|
2913
3131
|
console.log(`
|
|
2914
|
-
${
|
|
3132
|
+
${color4.green("Ready to go live.")}
|
|
2915
3133
|
`);
|
|
2916
3134
|
return;
|
|
2917
3135
|
}
|
|
2918
3136
|
console.log(`
|
|
2919
|
-
${
|
|
3137
|
+
${color4.red("NOT ready to go live.")}
|
|
2920
3138
|
`);
|
|
2921
3139
|
if (result.routeProblem) {
|
|
2922
|
-
console.log(`${
|
|
3140
|
+
console.log(`${color4.red("✗")} the worker's hostname
|
|
2923
3141
|
${result.routeProblem}
|
|
3142
|
+
`);
|
|
3143
|
+
}
|
|
3144
|
+
if (result.bareSecrets.length > 0) {
|
|
3145
|
+
console.log(`${color4.red("✗")} committed in the clear in ${SECRETS_FILE}: ${result.bareSecrets.join(", ")}
|
|
3146
|
+
` + ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` — and treat those values as burned.
|
|
2924
3147
|
`);
|
|
2925
3148
|
}
|
|
2926
3149
|
for (const key of result.missing) {
|
|
2927
|
-
console.log(`${
|
|
3150
|
+
console.log(`${color4.red("✗")} ${key.key}`);
|
|
2928
3151
|
console.log(` ${key.breaks}`);
|
|
2929
3152
|
if (key.where)
|
|
2930
|
-
console.log(` ${
|
|
3153
|
+
console.log(` ${color4.dim(`from: ${key.where}`)}`);
|
|
2931
3154
|
console.log("");
|
|
2932
3155
|
}
|
|
2933
3156
|
const secrets = result.missing.filter((key) => !key.plaintext);
|
|
2934
3157
|
if (secrets.length > 0) {
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
3158
|
+
const inRepo = existsSync8(join8(project.root, SECRETS_FILE));
|
|
3159
|
+
console.log(inRepo ? `Put each in ${SECRETS_FILE}, which the deploy uploads:` : source === "wrangler" ? "Set each secret on the worker (this also creates the draft worker on a first deploy):" : "Set each secret on the project:");
|
|
3160
|
+
for (const key of secrets) {
|
|
3161
|
+
console.log(inRepo ? ` dotenvx set ${key.key} '…' -f ${SECRETS_FILE}` : ` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
|
|
3162
|
+
}
|
|
2938
3163
|
}
|
|
2939
3164
|
const plain = result.missing.filter((key) => key.plaintext);
|
|
2940
3165
|
if (plain.length > 0)
|
|
@@ -2943,17 +3168,17 @@ ${color3.red("NOT ready to go live.")}
|
|
|
2943
3168
|
}
|
|
2944
3169
|
|
|
2945
3170
|
// src/deploy/cloudflare.ts
|
|
2946
|
-
import { existsSync as
|
|
2947
|
-
import { dirname as
|
|
2948
|
-
import
|
|
3171
|
+
import { existsSync as existsSync9, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
|
|
3172
|
+
import { dirname as dirname6, join as join9 } from "node:path";
|
|
3173
|
+
import color5 from "picocolors";
|
|
2949
3174
|
var fail = (message) => {
|
|
2950
3175
|
console.error(`
|
|
2951
|
-
${
|
|
3176
|
+
${color5.red("✗")} ${message}
|
|
2952
3177
|
`);
|
|
2953
3178
|
return 1;
|
|
2954
3179
|
};
|
|
2955
3180
|
function readConfig(path) {
|
|
2956
|
-
return parseJsonc(
|
|
3181
|
+
return parseJsonc(readFileSync4(path, "utf8"));
|
|
2957
3182
|
}
|
|
2958
3183
|
function findBuilder(from) {
|
|
2959
3184
|
let dir = from;
|
|
@@ -2962,11 +3187,11 @@ function findBuilder(from) {
|
|
|
2962
3187
|
["vp", "vp build"],
|
|
2963
3188
|
["vite", "vite build"]
|
|
2964
3189
|
]) {
|
|
2965
|
-
const path =
|
|
2966
|
-
if (
|
|
3190
|
+
const path = join9(dir, "node_modules", ".bin", bin);
|
|
3191
|
+
if (existsSync9(path))
|
|
2967
3192
|
return { cmd: path, label };
|
|
2968
3193
|
}
|
|
2969
|
-
const parent =
|
|
3194
|
+
const parent = dirname6(dir);
|
|
2970
3195
|
if (parent === dir)
|
|
2971
3196
|
return null;
|
|
2972
3197
|
dir = parent;
|
|
@@ -2974,8 +3199,8 @@ function findBuilder(from) {
|
|
|
2974
3199
|
}
|
|
2975
3200
|
async function deployCloudflare(project, opts) {
|
|
2976
3201
|
const app = project.appDir;
|
|
2977
|
-
const configPath =
|
|
2978
|
-
if (!
|
|
3202
|
+
const configPath = join9(app, "wrangler.jsonc");
|
|
3203
|
+
if (!existsSync9(configPath))
|
|
2979
3204
|
return fail("wrangler.jsonc is missing — `vc generate` writes it.");
|
|
2980
3205
|
const bin = findWrangler(app);
|
|
2981
3206
|
if (!bin)
|
|
@@ -2988,16 +3213,16 @@ async function deployCloudflare(project, opts) {
|
|
|
2988
3213
|
${who.raw.trim().split(`
|
|
2989
3214
|
`).slice(-4).join(`
|
|
2990
3215
|
`)}`);
|
|
2991
|
-
console.log(`${
|
|
3216
|
+
console.log(`${color5.green("✓")} wrangler is logged in`);
|
|
2992
3217
|
let config = readConfig(configPath);
|
|
2993
3218
|
let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
2994
3219
|
if (!accountId) {
|
|
2995
3220
|
if (who.accounts.length === 1) {
|
|
2996
3221
|
accountId = who.accounts[0].id;
|
|
2997
|
-
|
|
3222
|
+
writeFileSync2(configPath, upsertJsonc(readFileSync4(configPath, "utf8"), "account_id", accountId));
|
|
2998
3223
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
|
|
2999
3224
|
await writeManifest(project.root, project.manifest);
|
|
3000
|
-
console.log(`${
|
|
3225
|
+
console.log(`${color5.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
|
|
3001
3226
|
} else {
|
|
3002
3227
|
return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
|
|
3003
3228
|
${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
@@ -3009,7 +3234,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3009
3234
|
if (!check.ready) {
|
|
3010
3235
|
if (!opts.force)
|
|
3011
3236
|
return fail("refusing to deploy a shop that is not ready for customers. Fix the above, or pass --force for a deliberate partial deploy.");
|
|
3012
|
-
console.error(
|
|
3237
|
+
console.error(color5.yellow(`--force: deploying a shop that is NOT ready for customers.
|
|
3013
3238
|
`));
|
|
3014
3239
|
}
|
|
3015
3240
|
const worker = config.name || project.manifest.shop.domain.split(".")[0];
|
|
@@ -3020,15 +3245,15 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3020
3245
|
return fail("the D1 database is not provisioned. Run once with --provision to create it and record its id.");
|
|
3021
3246
|
const db = await ensureD1(bin, app, `${worker}-db`);
|
|
3022
3247
|
const entry = { binding: "DB", database_name: db.name, database_id: db.uuid, migrations_dir: "./db/migrations" };
|
|
3023
|
-
|
|
3248
|
+
writeFileSync2(configPath, upsertJsonc(readFileSync4(configPath, "utf8"), "d1_databases", [entry]));
|
|
3024
3249
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
|
|
3025
3250
|
await writeManifest(project.root, project.manifest);
|
|
3026
|
-
console.log(`${
|
|
3251
|
+
console.log(`${color5.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
|
|
3027
3252
|
config = readConfig(configPath);
|
|
3028
3253
|
}
|
|
3029
3254
|
if (opts.provision) {
|
|
3030
3255
|
await ensureQueue(bin, app, "commerce");
|
|
3031
|
-
console.log(`${
|
|
3256
|
+
console.log(`${color5.green("✓")} queue "commerce"`);
|
|
3032
3257
|
}
|
|
3033
3258
|
const builder = findBuilder(app);
|
|
3034
3259
|
if (!builder)
|
|
@@ -3038,10 +3263,10 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3038
3263
|
const built = await wrangler(builder.cmd, ["build"], app, true);
|
|
3039
3264
|
if (built.code !== 0)
|
|
3040
3265
|
return fail(`the build failed (exit ${built.code}).`);
|
|
3041
|
-
const emittedPath =
|
|
3042
|
-
if (!
|
|
3266
|
+
const emittedPath = join9(app, "dist", "ssr", "wrangler.json");
|
|
3267
|
+
if (!existsSync9(emittedPath))
|
|
3043
3268
|
return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
|
|
3044
|
-
const emitted = JSON.parse(
|
|
3269
|
+
const emitted = JSON.parse(readFileSync4(emittedPath, "utf8"));
|
|
3045
3270
|
const secretKeys = new Set(allEnvKeys(project.manifest).filter((key) => !key.plaintext).map((key) => key.key));
|
|
3046
3271
|
const scrubbed = [];
|
|
3047
3272
|
for (const [key, value] of Object.entries(emitted.vars ?? {})) {
|
|
@@ -3053,32 +3278,43 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3053
3278
|
const emittedD1 = emitted.d1_databases?.find((db) => db.binding === "DB");
|
|
3054
3279
|
if (!emittedD1 || emittedD1.database_id === "local")
|
|
3055
3280
|
return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
|
|
3056
|
-
|
|
3057
|
-
console.log(`${
|
|
3281
|
+
writeFileSync2(emittedPath, JSON.stringify(emitted, null, 2));
|
|
3282
|
+
console.log(`${color5.green("✓")} scrubbed ${scrubbed.length} baked value${scrubbed.length === 1 ? "" : "s"} from the worker's vars${scrubbed.length ? `: ${scrubbed.join(", ")}` : ""}`);
|
|
3058
3283
|
console.log(`
|
|
3059
3284
|
▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
|
|
3060
3285
|
const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
|
|
3061
3286
|
if (migrated.code !== 0)
|
|
3062
3287
|
return fail(`applying migrations failed (exit ${migrated.code}); nothing was deployed.`);
|
|
3288
|
+
const secretArgs = [];
|
|
3289
|
+
let cleanupSecrets;
|
|
3290
|
+
if (existsSync9(join9(project.root, SECRETS_FILE))) {
|
|
3291
|
+
const decrypted = await decryptSecrets(project);
|
|
3292
|
+
if ("error" in decrypted)
|
|
3293
|
+
return fail(`the repository's secrets could not be read: ${decrypted.error}`);
|
|
3294
|
+
secretArgs.push("--secrets-file", decrypted.path);
|
|
3295
|
+
cleanupSecrets = decrypted.cleanup;
|
|
3296
|
+
console.log(`${color5.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
|
|
3297
|
+
}
|
|
3063
3298
|
console.log(`
|
|
3064
3299
|
▸ wrangler deploy -c dist/ssr/wrangler.json`);
|
|
3065
|
-
const deployed = await wrangler(bin, ["deploy", "-c",
|
|
3300
|
+
const deployed = await wrangler(bin, ["deploy", "-c", join9("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
|
|
3301
|
+
cleanupSecrets?.();
|
|
3066
3302
|
if (deployed.code !== 0)
|
|
3067
3303
|
return fail(`wrangler deploy failed (exit ${deployed.code}).`);
|
|
3068
3304
|
const domain = project.manifest.shop.domain;
|
|
3069
3305
|
const url = `https://${workerHosts(project.manifest)[0]}`;
|
|
3070
3306
|
console.log(`
|
|
3071
|
-
${
|
|
3307
|
+
${color5.green("Live:")} ${url}`);
|
|
3072
3308
|
if (hasFrontend(project.manifest.layout))
|
|
3073
|
-
console.log(
|
|
3309
|
+
console.log(color5.dim("The storefront deploys itself from GitHub Actions on push."));
|
|
3074
3310
|
return 0;
|
|
3075
3311
|
}
|
|
3076
3312
|
|
|
3077
3313
|
// src/import.ts
|
|
3078
3314
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
3079
|
-
import { existsSync as
|
|
3080
|
-
import { join as
|
|
3081
|
-
import
|
|
3315
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
3316
|
+
import { join as join10 } from "node:path";
|
|
3317
|
+
import color6 from "picocolors";
|
|
3082
3318
|
function describe(counts) {
|
|
3083
3319
|
if (!counts)
|
|
3084
3320
|
return "";
|
|
@@ -3117,7 +3353,7 @@ async function post(url, key, body) {
|
|
|
3117
3353
|
return { ok: response.ok, status: response.status, body: { ...parsed, error: parsed.error ?? parsed.message } };
|
|
3118
3354
|
}
|
|
3119
3355
|
async function readJson(path) {
|
|
3120
|
-
if (!
|
|
3356
|
+
if (!existsSync10(path))
|
|
3121
3357
|
return null;
|
|
3122
3358
|
try {
|
|
3123
3359
|
return JSON.parse(await readFile3(path, "utf8"));
|
|
@@ -3145,41 +3381,41 @@ async function importCommand(args) {
|
|
|
3145
3381
|
}
|
|
3146
3382
|
const { url, how } = resolveTarget(project, args);
|
|
3147
3383
|
if (!dry)
|
|
3148
|
-
console.log(`${
|
|
3384
|
+
console.log(`${color6.dim("→")} ${url} ${color6.dim(`(${how})`)}
|
|
3149
3385
|
`);
|
|
3150
3386
|
if (!dry) {
|
|
3151
3387
|
let whoami2;
|
|
3152
3388
|
try {
|
|
3153
3389
|
whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
|
|
3154
3390
|
} catch (error) {
|
|
3155
|
-
console.error(`${
|
|
3391
|
+
console.error(`${color6.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
|
|
3156
3392
|
` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
|
|
3157
3393
|
`);
|
|
3158
3394
|
return 1;
|
|
3159
3395
|
}
|
|
3160
3396
|
if (!whoami2.ok) {
|
|
3161
|
-
console.error(`${
|
|
3397
|
+
console.error(`${color6.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
|
|
3162
3398
|
` + ` Is SYSTEM_API_KEY the one this deployment was given?
|
|
3163
3399
|
`);
|
|
3164
3400
|
return 1;
|
|
3165
3401
|
}
|
|
3166
|
-
console.log(`${
|
|
3402
|
+
console.log(`${color6.green("✓")} authenticated as the system identity`);
|
|
3167
3403
|
}
|
|
3168
3404
|
const root = project.root;
|
|
3169
|
-
const products = await readJson(
|
|
3170
|
-
const categories = await readJson(
|
|
3171
|
-
const faqs = has(project.manifest, "faqs") ? await readJson(
|
|
3172
|
-
const posts = has(project.manifest, "blogs") ? await readJson(
|
|
3405
|
+
const products = await readJson(join10(root, "data", "catalog.json"));
|
|
3406
|
+
const categories = await readJson(join10(root, "data", "categories.json"));
|
|
3407
|
+
const faqs = has(project.manifest, "faqs") ? await readJson(join10(root, "content", "faqs.json")) : null;
|
|
3408
|
+
const posts = has(project.manifest, "blogs") ? await readJson(join10(root, "content", "posts.json")) : null;
|
|
3173
3409
|
if (!products && !faqs?.length && !posts?.length) {
|
|
3174
3410
|
console.log(`
|
|
3175
|
-
${
|
|
3411
|
+
${color6.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
|
|
3176
3412
|
` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
|
|
3177
3413
|
`);
|
|
3178
3414
|
return 0;
|
|
3179
3415
|
}
|
|
3180
3416
|
if (dry) {
|
|
3181
3417
|
console.log(`
|
|
3182
|
-
${
|
|
3418
|
+
${color6.dim("--dry-run: nothing was pushed.")}`);
|
|
3183
3419
|
console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
|
|
3184
3420
|
console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
|
|
3185
3421
|
`);
|
|
@@ -3195,15 +3431,15 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3195
3431
|
});
|
|
3196
3432
|
if (result.ok) {
|
|
3197
3433
|
const report = result.body.report ?? {};
|
|
3198
|
-
console.log(`${
|
|
3434
|
+
console.log(`${color6.green("✓")} products: ${describe(report.products)}`);
|
|
3199
3435
|
if (report.categories)
|
|
3200
|
-
console.log(`${
|
|
3436
|
+
console.log(`${color6.green("✓")} categories: ${describe(report.categories)}`);
|
|
3201
3437
|
if (report.prices)
|
|
3202
|
-
console.log(`${
|
|
3438
|
+
console.log(`${color6.green("✓")} prices: ${describe(report.prices)}`);
|
|
3203
3439
|
if (report.addons)
|
|
3204
|
-
console.log(`${
|
|
3440
|
+
console.log(`${color6.green("✓")} addons: ${describe(report.addons)}`);
|
|
3205
3441
|
} else {
|
|
3206
|
-
console.error(`${
|
|
3442
|
+
console.error(`${color6.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3207
3443
|
failed = true;
|
|
3208
3444
|
}
|
|
3209
3445
|
}
|
|
@@ -3215,20 +3451,20 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3215
3451
|
continue;
|
|
3216
3452
|
const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
|
|
3217
3453
|
if (result.ok) {
|
|
3218
|
-
console.log(`${
|
|
3454
|
+
console.log(`${color6.green("✓")} ${label}: ${describe(result.body)}`);
|
|
3219
3455
|
} else {
|
|
3220
|
-
console.error(`${
|
|
3456
|
+
console.error(`${color6.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3221
3457
|
failed = true;
|
|
3222
3458
|
}
|
|
3223
3459
|
}
|
|
3224
3460
|
if (failed) {
|
|
3225
3461
|
console.error(`
|
|
3226
|
-
${
|
|
3462
|
+
${color6.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
|
|
3227
3463
|
`);
|
|
3228
3464
|
return 1;
|
|
3229
3465
|
}
|
|
3230
3466
|
console.log(`
|
|
3231
|
-
${
|
|
3467
|
+
${color6.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
|
|
3232
3468
|
`);
|
|
3233
3469
|
return 0;
|
|
3234
3470
|
}
|
|
@@ -3249,4 +3485,4 @@ async function importHelp() {
|
|
|
3249
3485
|
return 0;
|
|
3250
3486
|
}
|
|
3251
3487
|
|
|
3252
|
-
export { renderAuthTs, allEnvKeys, renderEnvTs, renderEnvExample, renderEnvLocal, renderEnvProduction, envSummary, FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, renderStorefrontPage, renderFrontendWorkflow, runVoid, runInherit, captureVoid, isVoidApp, voidAppsIn, STRICT_APP, VOID_VERSION, strictDependencies, renderVoidPatch, renderViteConfig, renderStrictTsconfig, renderVoidJson, renderDbSchema, renderDbSeed, renderLayoutTsx, renderAppCss, renderIndexServer, INDEX_PAGE, renderDashboardPage, CLIENT_ONLY, renderSignInPage, renderAuthClient, renderContentTs, renderCron, renderQueue, renderLiveStream, renderLiveRoute, renderStrictAppPackageJson, DATA_README, BRANDING_README, MIGRATIONS_README, finishStrict, line, row, box, fullHelp, initHelp, version, findProject, ensureGenerated, DIST_DIR, DIST_BRANCH, distCommand, distHelp, renderDistWorkflow, renderDeployReadme, renderRequirementsTs, renderDomainTs, generate, parseJsonc, upsertJsonc, findWrangler, parseWhoAmI, productionEnv, routeProblem, preflight, printPreflight, deployCloudflare, importCommand, importHelp };
|
|
3488
|
+
export { renderAuthTs, allEnvKeys, renderEnvTs, renderEnvExample, renderEnvLocal, renderEnvProduction, envSummary, FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, renderStorefrontPage, renderFrontendWorkflow, runVoid, runInherit, captureVoid, isVoidApp, voidAppsIn, STRICT_APP, VOID_VERSION, strictDependencies, renderVoidPatch, renderViteConfig, renderStrictTsconfig, renderVoidJson, renderDbSchema, renderDbSeed, renderLayoutTsx, renderAppCss, renderIndexServer, INDEX_PAGE, renderDashboardPage, CLIENT_ONLY, renderSignInPage, renderAuthClient, renderContentTs, renderCron, renderQueue, renderLiveStream, renderLiveRoute, renderStrictAppPackageJson, DATA_README, BRANDING_README, MIGRATIONS_README, finishStrict, line, row, box, fullHelp, initHelp, version, findProject, ensureGenerated, DIST_DIR, DIST_BRANCH, distCommand, distHelp, renderDistWorkflow, renderDeployReadme, renderRequirementsTs, renderDomainTs, generate, parseJsonc, upsertJsonc, findWrangler, parseWhoAmI, SECRETS_FILE, PRIVATE_KEY_VAR, secretsCommand, productionEnv, routeProblem, preflight, printPreflight, deployCloudflare, importCommand, importHelp };
|