@saastemly/voidcommerce 0.2.1 → 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-xdsp97e4.js → index-84wq4qp8.js} +313 -76
- 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/generate/support.ts +2 -1
- 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
|
|
|
@@ -2139,7 +2166,8 @@ export const emailNotifications = (emailer: Emailer): NotificationProvider => ({
|
|
|
2139
2166
|
`;
|
|
2140
2167
|
}
|
|
2141
2168
|
function renderMintTs() {
|
|
2142
|
-
return `import {
|
|
2169
|
+
return `import { getCurrentAuthContext } from "@better-auth/core/context";
|
|
2170
|
+
import { type MintProvider, recordedMint } from "@saastemly/better-commerce/plugins/nft";
|
|
2143
2171
|
|
|
2144
2172
|
/**
|
|
2145
2173
|
* Where a token comes from.
|
|
@@ -2458,6 +2486,7 @@ async function generateStrictRoot(root, manifest, result) {
|
|
|
2458
2486
|
.wrangler
|
|
2459
2487
|
.env
|
|
2460
2488
|
.env.local
|
|
2489
|
+
.env.keys
|
|
2461
2490
|
dist
|
|
2462
2491
|
*.tsbuildinfo
|
|
2463
2492
|
.DS_Store
|
|
@@ -2827,16 +2856,196 @@ ${ran.out.trim()}`);
|
|
|
2827
2856
|
}
|
|
2828
2857
|
|
|
2829
2858
|
// src/deploy/preflight.ts
|
|
2830
|
-
import { existsSync as
|
|
2831
|
-
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";
|
|
2832
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
|
|
2833
3042
|
var UNSET = "unset";
|
|
2834
3043
|
function productionEnv(appDir) {
|
|
2835
3044
|
const out = new Map;
|
|
2836
|
-
const path =
|
|
2837
|
-
if (!
|
|
3045
|
+
const path = join8(appDir, ".env.production");
|
|
3046
|
+
if (!existsSync8(path))
|
|
2838
3047
|
return out;
|
|
2839
|
-
for (const line2 of
|
|
3048
|
+
for (const line2 of readFileSync3(path, "utf8").split(`
|
|
2840
3049
|
`)) {
|
|
2841
3050
|
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line2);
|
|
2842
3051
|
if (!match)
|
|
@@ -2861,12 +3070,12 @@ async function voidSecretNames(appDir) {
|
|
|
2861
3070
|
return names;
|
|
2862
3071
|
}
|
|
2863
3072
|
function routeProblem(project) {
|
|
2864
|
-
const path =
|
|
2865
|
-
if (!
|
|
3073
|
+
const path = join8(project.appDir, "wrangler.jsonc");
|
|
3074
|
+
if (!existsSync8(path))
|
|
2866
3075
|
return "wrangler.jsonc is missing";
|
|
2867
3076
|
let routes = [];
|
|
2868
3077
|
try {
|
|
2869
|
-
routes = parseJsonc(
|
|
3078
|
+
routes = parseJsonc(readFileSync3(path, "utf8")).routes ?? [];
|
|
2870
3079
|
} catch {
|
|
2871
3080
|
return "wrangler.jsonc cannot be parsed";
|
|
2872
3081
|
}
|
|
@@ -2886,17 +3095,27 @@ async function preflight(project, source) {
|
|
|
2886
3095
|
}
|
|
2887
3096
|
for (const name of remote ?? [])
|
|
2888
3097
|
present.set(name, "<secret>");
|
|
3098
|
+
for (const name of declaredSecretNames(project.root))
|
|
3099
|
+
present.set(name, "<in the repository>");
|
|
2889
3100
|
const missing = allEnvKeys(project.manifest).filter((key) => {
|
|
2890
3101
|
const value = present.get(key.key);
|
|
2891
3102
|
return value === undefined || value === "" || value === UNSET;
|
|
2892
3103
|
});
|
|
2893
3104
|
const problem = routeProblem(project);
|
|
2894
|
-
|
|
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
|
+
};
|
|
2895
3114
|
}
|
|
2896
3115
|
function printPreflight(project, result, source) {
|
|
2897
3116
|
const keys = allEnvKeys(project.manifest);
|
|
2898
3117
|
if (result.remote === null) {
|
|
2899
|
-
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.
|
|
2900
3119
|
Anything not in .env.production is reported as missing.
|
|
2901
3120
|
` : `Could not read the project's secrets from Void — not logged in, or no linked project.
|
|
2902
3121
|
Anything not in .env.production is reported as missing.
|
|
@@ -2906,34 +3125,41 @@ Anything not in .env.production is reported as missing.
|
|
|
2906
3125
|
for (const key of keys) {
|
|
2907
3126
|
const value = result.present.get(key.key);
|
|
2908
3127
|
const set = value !== undefined && value !== "" && value !== UNSET;
|
|
2909
|
-
console.log(` ${set ?
|
|
3128
|
+
console.log(` ${set ? color4.green("set ") : color4.dim("unset")} ${key.key}${key.plaintext ? color4.dim(" (plaintext)") : ""}`);
|
|
2910
3129
|
}
|
|
2911
3130
|
if (result.ready) {
|
|
2912
3131
|
console.log(`
|
|
2913
|
-
${
|
|
3132
|
+
${color4.green("Ready to go live.")}
|
|
2914
3133
|
`);
|
|
2915
3134
|
return;
|
|
2916
3135
|
}
|
|
2917
3136
|
console.log(`
|
|
2918
|
-
${
|
|
3137
|
+
${color4.red("NOT ready to go live.")}
|
|
2919
3138
|
`);
|
|
2920
3139
|
if (result.routeProblem) {
|
|
2921
|
-
console.log(`${
|
|
3140
|
+
console.log(`${color4.red("✗")} the worker's hostname
|
|
2922
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.
|
|
2923
3147
|
`);
|
|
2924
3148
|
}
|
|
2925
3149
|
for (const key of result.missing) {
|
|
2926
|
-
console.log(`${
|
|
3150
|
+
console.log(`${color4.red("✗")} ${key.key}`);
|
|
2927
3151
|
console.log(` ${key.breaks}`);
|
|
2928
3152
|
if (key.where)
|
|
2929
|
-
console.log(` ${
|
|
3153
|
+
console.log(` ${color4.dim(`from: ${key.where}`)}`);
|
|
2930
3154
|
console.log("");
|
|
2931
3155
|
}
|
|
2932
3156
|
const secrets = result.missing.filter((key) => !key.plaintext);
|
|
2933
3157
|
if (secrets.length > 0) {
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
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
|
+
}
|
|
2937
3163
|
}
|
|
2938
3164
|
const plain = result.missing.filter((key) => key.plaintext);
|
|
2939
3165
|
if (plain.length > 0)
|
|
@@ -2942,17 +3168,17 @@ ${color3.red("NOT ready to go live.")}
|
|
|
2942
3168
|
}
|
|
2943
3169
|
|
|
2944
3170
|
// src/deploy/cloudflare.ts
|
|
2945
|
-
import { existsSync as
|
|
2946
|
-
import { dirname as
|
|
2947
|
-
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";
|
|
2948
3174
|
var fail = (message) => {
|
|
2949
3175
|
console.error(`
|
|
2950
|
-
${
|
|
3176
|
+
${color5.red("✗")} ${message}
|
|
2951
3177
|
`);
|
|
2952
3178
|
return 1;
|
|
2953
3179
|
};
|
|
2954
3180
|
function readConfig(path) {
|
|
2955
|
-
return parseJsonc(
|
|
3181
|
+
return parseJsonc(readFileSync4(path, "utf8"));
|
|
2956
3182
|
}
|
|
2957
3183
|
function findBuilder(from) {
|
|
2958
3184
|
let dir = from;
|
|
@@ -2961,11 +3187,11 @@ function findBuilder(from) {
|
|
|
2961
3187
|
["vp", "vp build"],
|
|
2962
3188
|
["vite", "vite build"]
|
|
2963
3189
|
]) {
|
|
2964
|
-
const path =
|
|
2965
|
-
if (
|
|
3190
|
+
const path = join9(dir, "node_modules", ".bin", bin);
|
|
3191
|
+
if (existsSync9(path))
|
|
2966
3192
|
return { cmd: path, label };
|
|
2967
3193
|
}
|
|
2968
|
-
const parent =
|
|
3194
|
+
const parent = dirname6(dir);
|
|
2969
3195
|
if (parent === dir)
|
|
2970
3196
|
return null;
|
|
2971
3197
|
dir = parent;
|
|
@@ -2973,8 +3199,8 @@ function findBuilder(from) {
|
|
|
2973
3199
|
}
|
|
2974
3200
|
async function deployCloudflare(project, opts) {
|
|
2975
3201
|
const app = project.appDir;
|
|
2976
|
-
const configPath =
|
|
2977
|
-
if (!
|
|
3202
|
+
const configPath = join9(app, "wrangler.jsonc");
|
|
3203
|
+
if (!existsSync9(configPath))
|
|
2978
3204
|
return fail("wrangler.jsonc is missing — `vc generate` writes it.");
|
|
2979
3205
|
const bin = findWrangler(app);
|
|
2980
3206
|
if (!bin)
|
|
@@ -2987,16 +3213,16 @@ async function deployCloudflare(project, opts) {
|
|
|
2987
3213
|
${who.raw.trim().split(`
|
|
2988
3214
|
`).slice(-4).join(`
|
|
2989
3215
|
`)}`);
|
|
2990
|
-
console.log(`${
|
|
3216
|
+
console.log(`${color5.green("✓")} wrangler is logged in`);
|
|
2991
3217
|
let config = readConfig(configPath);
|
|
2992
3218
|
let accountId = config.account_id || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
|
|
2993
3219
|
if (!accountId) {
|
|
2994
3220
|
if (who.accounts.length === 1) {
|
|
2995
3221
|
accountId = who.accounts[0].id;
|
|
2996
|
-
|
|
3222
|
+
writeFileSync2(configPath, upsertJsonc(readFileSync4(configPath, "utf8"), "account_id", accountId));
|
|
2997
3223
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId };
|
|
2998
3224
|
await writeManifest(project.root, project.manifest);
|
|
2999
|
-
console.log(`${
|
|
3225
|
+
console.log(`${color5.green("✓")} pinned the account "${who.accounts[0].name}" in wrangler.jsonc`);
|
|
3000
3226
|
} else {
|
|
3001
3227
|
return fail(`the account is not pinned and wrangler sees ${who.accounts.length}. Set account_id in wrangler.jsonc to one of:
|
|
3002
3228
|
${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
@@ -3008,7 +3234,7 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3008
3234
|
if (!check.ready) {
|
|
3009
3235
|
if (!opts.force)
|
|
3010
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.");
|
|
3011
|
-
console.error(
|
|
3237
|
+
console.error(color5.yellow(`--force: deploying a shop that is NOT ready for customers.
|
|
3012
3238
|
`));
|
|
3013
3239
|
}
|
|
3014
3240
|
const worker = config.name || project.manifest.shop.domain.split(".")[0];
|
|
@@ -3019,15 +3245,15 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3019
3245
|
return fail("the D1 database is not provisioned. Run once with --provision to create it and record its id.");
|
|
3020
3246
|
const db = await ensureD1(bin, app, `${worker}-db`);
|
|
3021
3247
|
const entry = { binding: "DB", database_name: db.name, database_id: db.uuid, migrations_dir: "./db/migrations" };
|
|
3022
|
-
|
|
3248
|
+
writeFileSync2(configPath, upsertJsonc(readFileSync4(configPath, "utf8"), "d1_databases", [entry]));
|
|
3023
3249
|
project.manifest.cloudflare = { ...project.manifest.cloudflare, accountId, d1: { name: db.name, id: db.uuid } };
|
|
3024
3250
|
await writeManifest(project.root, project.manifest);
|
|
3025
|
-
console.log(`${
|
|
3251
|
+
console.log(`${color5.green("✓")} D1 "${db.name}" recorded in wrangler.jsonc and voidcommerce.json`);
|
|
3026
3252
|
config = readConfig(configPath);
|
|
3027
3253
|
}
|
|
3028
3254
|
if (opts.provision) {
|
|
3029
3255
|
await ensureQueue(bin, app, "commerce");
|
|
3030
|
-
console.log(`${
|
|
3256
|
+
console.log(`${color5.green("✓")} queue "commerce"`);
|
|
3031
3257
|
}
|
|
3032
3258
|
const builder = findBuilder(app);
|
|
3033
3259
|
if (!builder)
|
|
@@ -3037,10 +3263,10 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3037
3263
|
const built = await wrangler(builder.cmd, ["build"], app, true);
|
|
3038
3264
|
if (built.code !== 0)
|
|
3039
3265
|
return fail(`the build failed (exit ${built.code}).`);
|
|
3040
|
-
const emittedPath =
|
|
3041
|
-
if (!
|
|
3266
|
+
const emittedPath = join9(app, "dist", "ssr", "wrangler.json");
|
|
3267
|
+
if (!existsSync9(emittedPath))
|
|
3042
3268
|
return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
|
|
3043
|
-
const emitted = JSON.parse(
|
|
3269
|
+
const emitted = JSON.parse(readFileSync4(emittedPath, "utf8"));
|
|
3044
3270
|
const secretKeys = new Set(allEnvKeys(project.manifest).filter((key) => !key.plaintext).map((key) => key.key));
|
|
3045
3271
|
const scrubbed = [];
|
|
3046
3272
|
for (const [key, value] of Object.entries(emitted.vars ?? {})) {
|
|
@@ -3052,32 +3278,43 @@ ${who.accounts.map((a) => ` ${a.id} ${a.name}`).join(`
|
|
|
3052
3278
|
const emittedD1 = emitted.d1_databases?.find((db) => db.binding === "DB");
|
|
3053
3279
|
if (!emittedD1 || emittedD1.database_id === "local")
|
|
3054
3280
|
return fail("the emitted config still carries a placeholder D1 id; wrangler.jsonc's DB binding was not picked up by the build.");
|
|
3055
|
-
|
|
3056
|
-
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(", ")}` : ""}`);
|
|
3057
3283
|
console.log(`
|
|
3058
3284
|
▸ wrangler d1 migrations apply ${emittedD1.database_name} --remote`);
|
|
3059
3285
|
const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
|
|
3060
3286
|
if (migrated.code !== 0)
|
|
3061
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
|
+
}
|
|
3062
3298
|
console.log(`
|
|
3063
3299
|
▸ wrangler deploy -c dist/ssr/wrangler.json`);
|
|
3064
|
-
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?.();
|
|
3065
3302
|
if (deployed.code !== 0)
|
|
3066
3303
|
return fail(`wrangler deploy failed (exit ${deployed.code}).`);
|
|
3067
3304
|
const domain = project.manifest.shop.domain;
|
|
3068
3305
|
const url = `https://${workerHosts(project.manifest)[0]}`;
|
|
3069
3306
|
console.log(`
|
|
3070
|
-
${
|
|
3307
|
+
${color5.green("Live:")} ${url}`);
|
|
3071
3308
|
if (hasFrontend(project.manifest.layout))
|
|
3072
|
-
console.log(
|
|
3309
|
+
console.log(color5.dim("The storefront deploys itself from GitHub Actions on push."));
|
|
3073
3310
|
return 0;
|
|
3074
3311
|
}
|
|
3075
3312
|
|
|
3076
3313
|
// src/import.ts
|
|
3077
3314
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
3078
|
-
import { existsSync as
|
|
3079
|
-
import { join as
|
|
3080
|
-
import
|
|
3315
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
3316
|
+
import { join as join10 } from "node:path";
|
|
3317
|
+
import color6 from "picocolors";
|
|
3081
3318
|
function describe(counts) {
|
|
3082
3319
|
if (!counts)
|
|
3083
3320
|
return "";
|
|
@@ -3116,7 +3353,7 @@ async function post(url, key, body) {
|
|
|
3116
3353
|
return { ok: response.ok, status: response.status, body: { ...parsed, error: parsed.error ?? parsed.message } };
|
|
3117
3354
|
}
|
|
3118
3355
|
async function readJson(path) {
|
|
3119
|
-
if (!
|
|
3356
|
+
if (!existsSync10(path))
|
|
3120
3357
|
return null;
|
|
3121
3358
|
try {
|
|
3122
3359
|
return JSON.parse(await readFile3(path, "utf8"));
|
|
@@ -3144,41 +3381,41 @@ async function importCommand(args) {
|
|
|
3144
3381
|
}
|
|
3145
3382
|
const { url, how } = resolveTarget(project, args);
|
|
3146
3383
|
if (!dry)
|
|
3147
|
-
console.log(`${
|
|
3384
|
+
console.log(`${color6.dim("→")} ${url} ${color6.dim(`(${how})`)}
|
|
3148
3385
|
`);
|
|
3149
3386
|
if (!dry) {
|
|
3150
3387
|
let whoami2;
|
|
3151
3388
|
try {
|
|
3152
3389
|
whoami2 = await fetch(`${url}/api/auth/system/whoami`, { headers: { "x-system-key": key } });
|
|
3153
3390
|
} catch (error) {
|
|
3154
|
-
console.error(`${
|
|
3391
|
+
console.error(`${color6.red("✗")} could not reach ${url}: ${error instanceof Error ? error.message : String(error)}
|
|
3155
3392
|
` + ` Is the shop running? \`vc dev\` serves it locally; --local points here.
|
|
3156
3393
|
`);
|
|
3157
3394
|
return 1;
|
|
3158
3395
|
}
|
|
3159
3396
|
if (!whoami2.ok) {
|
|
3160
|
-
console.error(`${
|
|
3397
|
+
console.error(`${color6.red("✗")} the shop refused the system key (HTTP ${whoami2.status}).
|
|
3161
3398
|
` + ` Is SYSTEM_API_KEY the one this deployment was given?
|
|
3162
3399
|
`);
|
|
3163
3400
|
return 1;
|
|
3164
3401
|
}
|
|
3165
|
-
console.log(`${
|
|
3402
|
+
console.log(`${color6.green("✓")} authenticated as the system identity`);
|
|
3166
3403
|
}
|
|
3167
3404
|
const root = project.root;
|
|
3168
|
-
const products = await readJson(
|
|
3169
|
-
const categories = await readJson(
|
|
3170
|
-
const faqs = has(project.manifest, "faqs") ? await readJson(
|
|
3171
|
-
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;
|
|
3172
3409
|
if (!products && !faqs?.length && !posts?.length) {
|
|
3173
3410
|
console.log(`
|
|
3174
|
-
${
|
|
3411
|
+
${color6.yellow("Nothing to import.")} data/catalog.json is absent and the content files are empty.
|
|
3175
3412
|
` + `Products go in data/catalog.json as a JSON array; \`vc import --dry-run\` checks it without pushing.
|
|
3176
3413
|
`);
|
|
3177
3414
|
return 0;
|
|
3178
3415
|
}
|
|
3179
3416
|
if (dry) {
|
|
3180
3417
|
console.log(`
|
|
3181
|
-
${
|
|
3418
|
+
${color6.dim("--dry-run: nothing was pushed.")}`);
|
|
3182
3419
|
console.log(` ${products?.length ?? 0} products, ${categories?.length ?? 0} categories`);
|
|
3183
3420
|
console.log(` ${faqs?.length ?? 0} FAQ entries, ${posts?.length ?? 0} posts
|
|
3184
3421
|
`);
|
|
@@ -3194,15 +3431,15 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3194
3431
|
});
|
|
3195
3432
|
if (result.ok) {
|
|
3196
3433
|
const report = result.body.report ?? {};
|
|
3197
|
-
console.log(`${
|
|
3434
|
+
console.log(`${color6.green("✓")} products: ${describe(report.products)}`);
|
|
3198
3435
|
if (report.categories)
|
|
3199
|
-
console.log(`${
|
|
3436
|
+
console.log(`${color6.green("✓")} categories: ${describe(report.categories)}`);
|
|
3200
3437
|
if (report.prices)
|
|
3201
|
-
console.log(`${
|
|
3438
|
+
console.log(`${color6.green("✓")} prices: ${describe(report.prices)}`);
|
|
3202
3439
|
if (report.addons)
|
|
3203
|
-
console.log(`${
|
|
3440
|
+
console.log(`${color6.green("✓")} addons: ${describe(report.addons)}`);
|
|
3204
3441
|
} else {
|
|
3205
|
-
console.error(`${
|
|
3442
|
+
console.error(`${color6.red("✗")} catalogue: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3206
3443
|
failed = true;
|
|
3207
3444
|
}
|
|
3208
3445
|
}
|
|
@@ -3214,20 +3451,20 @@ ${color5.dim("--dry-run: nothing was pushed.")}`);
|
|
|
3214
3451
|
continue;
|
|
3215
3452
|
const result = await post(`${url}${path}`, key, { entries: rows, posts: rows });
|
|
3216
3453
|
if (result.ok) {
|
|
3217
|
-
console.log(`${
|
|
3454
|
+
console.log(`${color6.green("✓")} ${label}: ${describe(result.body)}`);
|
|
3218
3455
|
} else {
|
|
3219
|
-
console.error(`${
|
|
3456
|
+
console.error(`${color6.red("✗")} ${label}: ${result.body.error ?? `HTTP ${result.status}`}`);
|
|
3220
3457
|
failed = true;
|
|
3221
3458
|
}
|
|
3222
3459
|
}
|
|
3223
3460
|
if (failed) {
|
|
3224
3461
|
console.error(`
|
|
3225
|
-
${
|
|
3462
|
+
${color6.red("Some of it did not land.")} The import is an upsert, so fixing the cause and running it again is safe.
|
|
3226
3463
|
`);
|
|
3227
3464
|
return 1;
|
|
3228
3465
|
}
|
|
3229
3466
|
console.log(`
|
|
3230
|
-
${
|
|
3467
|
+
${color6.green("Imported.")} It is an upsert, so running it again costs one pass and changes nothing.
|
|
3231
3468
|
`);
|
|
3232
3469
|
return 0;
|
|
3233
3470
|
}
|
|
@@ -3248,4 +3485,4 @@ async function importHelp() {
|
|
|
3248
3485
|
return 0;
|
|
3249
3486
|
}
|
|
3250
3487
|
|
|
3251
|
-
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 };
|