@saastemly/voidcommerce 0.14.0 → 0.16.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 +23 -3
- package/dist/deploy/preflight.d.ts +10 -1
- package/dist/deploy/secrets.d.ts +10 -0
- package/dist/{index-paeg95y4.js → index-s8se0x2d.js} +17 -2
- package/dist/{index-jvsednjr.js → index-x70qxh3m.js} +64 -23
- package/dist/index.js +2 -2
- package/dist/{keys-nntc65pq.js → keys-6neyajsv.js} +1 -1
- package/package.json +1 -1
- package/src/deploy/index.ts +29 -1
- package/src/deploy/preflight.ts +20 -4
- package/src/deploy/secrets.ts +21 -0
- package/src/generate/ci.ts +34 -3
- package/src/generate/index.ts +41 -25
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
runVoid,
|
|
22
22
|
version,
|
|
23
23
|
voidAppsIn
|
|
24
|
-
} from "./index-
|
|
24
|
+
} from "./index-x70qxh3m.js";
|
|
25
25
|
import {
|
|
26
26
|
LOCAL_KEY_FILE,
|
|
27
27
|
PRIVATE_KEY_VAR,
|
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
setVariable,
|
|
51
51
|
variableNames,
|
|
52
52
|
verifyCloudflareToken
|
|
53
|
-
} from "./index-
|
|
53
|
+
} from "./index-s8se0x2d.js";
|
|
54
54
|
import {
|
|
55
55
|
LAYOUTS,
|
|
56
56
|
oneOrigin,
|
|
@@ -255,11 +255,31 @@ async function preflightCommand(args) {
|
|
|
255
255
|
console.error("vc: no voidcommerce.json here.");
|
|
256
256
|
return 1;
|
|
257
257
|
}
|
|
258
|
-
const source = args.includes("--cloudflare") ? "wrangler" : "void";
|
|
258
|
+
const source = args.includes("--repo") ? "repository" : args.includes("--cloudflare") ? "wrangler" : "void";
|
|
259
259
|
const result = await preflight(project, source);
|
|
260
260
|
printPreflight(project, result, source);
|
|
261
|
+
if (source !== "repository")
|
|
262
|
+
return result.ready ? 0 : 1;
|
|
263
|
+
const pending = await uncommittedMigrations(project.root);
|
|
264
|
+
if (pending.length > 0) {
|
|
265
|
+
console.error(`${color2.red("✗")} ${pending.length} migration file${pending.length === 1 ? " is" : "s are"} not committed:
|
|
266
|
+
` + ` ${pending.join(`
|
|
267
|
+
`)}
|
|
268
|
+
|
|
269
|
+
` + ` The deploy applies what is in the repository. Commit these, or the
|
|
270
|
+
` + ` schema the shop runs on is not the one you tested.
|
|
271
|
+
`);
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
261
274
|
return result.ready ? 0 : 1;
|
|
262
275
|
}
|
|
276
|
+
async function uncommittedMigrations(root) {
|
|
277
|
+
const status = await run("git", ["status", "--porcelain", "--", "migrations"], root);
|
|
278
|
+
if (status.code !== 0)
|
|
279
|
+
return [];
|
|
280
|
+
return status.out.split(`
|
|
281
|
+
`).map((line2) => line2.trim()).filter(Boolean).map((line2) => line2.replace(/^\S+\s+/, ""));
|
|
282
|
+
}
|
|
263
283
|
async function deployHelp() {
|
|
264
284
|
const captured = await captureVoid(["deploy", "--help"]);
|
|
265
285
|
const width = 80;
|
|
@@ -21,7 +21,16 @@ export interface Preflight {
|
|
|
21
21
|
bareSecrets: string[];
|
|
22
22
|
ready: boolean;
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Where a secret's presence is established.
|
|
26
|
+
*
|
|
27
|
+
* `repository` asks only what is committed, and is the right question before
|
|
28
|
+
* a PUSH: the deploy happens off this machine from what was pushed, so a
|
|
29
|
+
* check that needs local credentials is checking the wrong thing. It is also
|
|
30
|
+
* the only mode that works on a machine which has none — which, if config is
|
|
31
|
+
* properly in the environment, is every machine.
|
|
32
|
+
*/
|
|
33
|
+
export type SecretSource = "wrangler" | "void" | "repository";
|
|
25
34
|
/** Keys committed in .env.production — safe values only, by construction. */
|
|
26
35
|
export declare function productionEnv(appDir: string): Map<string, string>;
|
|
27
36
|
/** The worker's hostnames must be what the layout says. */
|
package/dist/deploy/secrets.d.ts
CHANGED
|
@@ -66,6 +66,16 @@ export declare function committedPublicKeyInto(root: string, publicKey: string):
|
|
|
66
66
|
* can say "all five are declared" on a machine that cannot read any of them.
|
|
67
67
|
*/
|
|
68
68
|
export declare function declaredSecretNames(root: string): Set<string>;
|
|
69
|
+
/**
|
|
70
|
+
* Declared names whose value is still the `unset` placeholder.
|
|
71
|
+
*
|
|
72
|
+
* Readable WITHOUT the key, because `unset` is plaintext — which is what
|
|
73
|
+
* lets a push-time gate tell "this shop has a Stripe key" from "this shop
|
|
74
|
+
* has a line that says STRIPE_SECRET_KEY". Preflight used to count the
|
|
75
|
+
* second as present, so a shop could pass every check and then fail to take
|
|
76
|
+
* money in production.
|
|
77
|
+
*/
|
|
78
|
+
export declare function unsetSecretNames(root: string): string[];
|
|
69
79
|
/** Is a declared value actually encrypted, or was it committed in the clear? */
|
|
70
80
|
export declare function plaintextSecretNames(root: string): string[];
|
|
71
81
|
/**
|
|
@@ -342,6 +342,21 @@ function declaredSecretNames(root) {
|
|
|
342
342
|
}
|
|
343
343
|
return names;
|
|
344
344
|
}
|
|
345
|
+
function unsetSecretNames(root) {
|
|
346
|
+
const path = join2(root, SECRETS_FILE);
|
|
347
|
+
if (!existsSync2(path))
|
|
348
|
+
return [];
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const line of readFileSync(path, "utf8").split(`
|
|
351
|
+
`)) {
|
|
352
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
353
|
+
if (!match || match[1].startsWith("DOTENV_"))
|
|
354
|
+
continue;
|
|
355
|
+
if (match[2].trim().replace(/^['"]|['"]$/g, "") === "unset")
|
|
356
|
+
out.push(match[1]);
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
345
360
|
function plaintextSecretNames(root) {
|
|
346
361
|
const path = join2(root, SECRETS_FILE);
|
|
347
362
|
if (!existsSync2(path))
|
|
@@ -389,7 +404,7 @@ async function decryptSecrets(project) {
|
|
|
389
404
|
}
|
|
390
405
|
const expected = committedPublicKey(root);
|
|
391
406
|
if (expected) {
|
|
392
|
-
const { publicKeyFor } = await import("./keys-
|
|
407
|
+
const { publicKeyFor } = await import("./keys-6neyajsv.js");
|
|
393
408
|
const derived = await publicKeyFor(privateKey);
|
|
394
409
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
395
410
|
return {
|
|
@@ -916,4 +931,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
|
|
|
916
931
|
return 0;
|
|
917
932
|
}
|
|
918
933
|
|
|
919
|
-
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
|
|
934
|
+
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, committedPublicKeyInto, declaredSecretNames, unsetSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, encryptInto };
|
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
renderEnvExample,
|
|
11
11
|
renderEnvLocal,
|
|
12
12
|
renderEnvProduction,
|
|
13
|
-
renderEnvTs
|
|
14
|
-
|
|
13
|
+
renderEnvTs,
|
|
14
|
+
unsetSecretNames
|
|
15
|
+
} from "./index-s8se0x2d.js";
|
|
15
16
|
import {
|
|
16
17
|
MANIFEST_FILE,
|
|
17
18
|
has,
|
|
@@ -1378,7 +1379,7 @@ import color from "picocolors";
|
|
|
1378
1379
|
// package.json
|
|
1379
1380
|
var package_default = {
|
|
1380
1381
|
name: "@saastemly/voidcommerce",
|
|
1381
|
-
version: "0.
|
|
1382
|
+
version: "0.16.0",
|
|
1382
1383
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1383
1384
|
type: "module",
|
|
1384
1385
|
license: "MIT",
|
|
@@ -1711,8 +1712,10 @@ function renderDistWorkflow(manifest) {
|
|
|
1711
1712
|
|
|
1712
1713
|
# Generated by \`vc init\` from voidcommerce.json.
|
|
1713
1714
|
#
|
|
1714
|
-
# A push to main regenerates the Void app from the manifest,
|
|
1715
|
-
# \`${DIST_BRANCH}\` as a standalone tree, and deploys it to
|
|
1715
|
+
# A push to main is checked, then regenerates the Void app from the manifest,
|
|
1716
|
+
# publishes it to \`${DIST_BRANCH}\` as a standalone tree, and deploys it to
|
|
1717
|
+
# Cloudflare. The check runs FIRST and on its own: nothing is built, created
|
|
1718
|
+
# or replaced for a shop that is not ready.
|
|
1716
1719
|
#
|
|
1717
1720
|
# The credentials come from the repository, set once by \`vc link\`:
|
|
1718
1721
|
#
|
|
@@ -1737,8 +1740,32 @@ permissions:
|
|
|
1737
1740
|
contents: write
|
|
1738
1741
|
|
|
1739
1742
|
jobs:
|
|
1743
|
+
# ── Is what was just pushed fit to deploy? ─────────────────────────────
|
|
1744
|
+
#
|
|
1745
|
+
# First, and on its own, because everything after it costs something: a
|
|
1746
|
+
# build, a database, a worker replaced. It answers the question the deploy
|
|
1747
|
+
# would ask, against exactly what was pushed, and needs NO CREDENTIALS to
|
|
1748
|
+
# do it — config lives in the repository, encrypted, so readiness is a
|
|
1749
|
+
# property of the commit rather than of whoever pushed it.
|
|
1750
|
+
#
|
|
1751
|
+
# This is the gate. It cannot be skipped with --no-verify, and it runs the
|
|
1752
|
+
# same way for every push and every person.
|
|
1753
|
+
preflight:
|
|
1754
|
+
runs-on: ubuntu-latest
|
|
1755
|
+
steps:
|
|
1756
|
+
- uses: actions/checkout@v6
|
|
1757
|
+
- uses: oven-sh/setup-bun@v2
|
|
1758
|
+
- run: bun install --frozen-lockfile
|
|
1759
|
+
|
|
1760
|
+
# Every required key actually SET rather than merely declared, nothing
|
|
1761
|
+
# committed in the clear, the hostnames the manifest says, and no
|
|
1762
|
+
# migration generated but left uncommitted.
|
|
1763
|
+
- name: vc preflight --repo
|
|
1764
|
+
run: bunx vc preflight --repo
|
|
1765
|
+
|
|
1740
1766
|
# ── Regenerate, and publish the standalone tree ────────────────────────
|
|
1741
1767
|
dist:
|
|
1768
|
+
needs: preflight
|
|
1742
1769
|
runs-on: ubuntu-latest
|
|
1743
1770
|
steps:
|
|
1744
1771
|
- uses: actions/checkout@v6
|
|
@@ -1838,7 +1865,12 @@ regenerate with \`vc generate\`.
|
|
|
1838
1865
|
git push
|
|
1839
1866
|
\`\`\`
|
|
1840
1867
|
|
|
1841
|
-
That is the deploy.
|
|
1868
|
+
That is the deploy. It is checked first: a \`preflight\` job runs before
|
|
1869
|
+
anything is built, and refuses a shop whose secrets are declared but unset,
|
|
1870
|
+
whose values were committed in the clear, or whose migrations are not all
|
|
1871
|
+
committed. It needs no credentials — readiness is a property of the commit,
|
|
1872
|
+
not of the machine that pushed it — so it fails the same way for everyone,
|
|
1873
|
+
and cannot be skipped. GitHub Actions regenerates the app from
|
|
1842
1874
|
\`voidcommerce.json\`, publishes a standalone copy to \`${DIST_BRANCH}\`, creates the
|
|
1843
1875
|
database and queue if they are not there, applies the migrations, and deploys
|
|
1844
1876
|
the worker with this repository's secrets attached.
|
|
@@ -2433,6 +2465,23 @@ async function link(root, file, target, result) {
|
|
|
2433
2465
|
}
|
|
2434
2466
|
}
|
|
2435
2467
|
var sorted = (record) => Object.fromEntries(Object.entries(record).sort());
|
|
2468
|
+
async function generateHooks(root, result) {
|
|
2469
|
+
await put(root, ".husky/pre-commit", `#!/usr/bin/env sh
|
|
2470
|
+
# Generated by \`vc init\`.
|
|
2471
|
+
#
|
|
2472
|
+
# Encrypts any value sitting in the clear in .env.secrets, and re-stages the
|
|
2473
|
+
# file so the COMMIT carries the ciphertext rather than what you staged.
|
|
2474
|
+
# Encryption needs only the public key in that file, so this needs no
|
|
2475
|
+
# credential and works on a fresh clone.
|
|
2476
|
+
#
|
|
2477
|
+
# It refuses only when it cannot fix the problem itself: no key yet
|
|
2478
|
+
# (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
|
|
2479
|
+
# committed in the clear cannot be un-committed — the value stays in the
|
|
2480
|
+
# history and has to be treated as burned.
|
|
2481
|
+
bunx vc guard
|
|
2482
|
+
`, result, "regenerate");
|
|
2483
|
+
await retire(root, ".husky/pre-push", result);
|
|
2484
|
+
}
|
|
2436
2485
|
async function generate(root, manifest) {
|
|
2437
2486
|
const result = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
|
|
2438
2487
|
await writeManifest(root, manifest);
|
|
@@ -2453,6 +2502,7 @@ async function generate(root, manifest) {
|
|
|
2453
2502
|
await generateStrictApp(root, STRICT_APP, manifest, result);
|
|
2454
2503
|
break;
|
|
2455
2504
|
}
|
|
2505
|
+
await generateHooks(root, result);
|
|
2456
2506
|
return result;
|
|
2457
2507
|
}
|
|
2458
2508
|
async function generateApi(root, dir, manifest, result, opts) {
|
|
@@ -2601,20 +2651,6 @@ dist
|
|
|
2601
2651
|
.DS_Store
|
|
2602
2652
|
`, result, "own");
|
|
2603
2653
|
await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
|
|
2604
|
-
await put(root, ".husky/pre-commit", `#!/usr/bin/env sh
|
|
2605
|
-
# Generated by \`vc init\`.
|
|
2606
|
-
#
|
|
2607
|
-
# Encrypts any value sitting in the clear in .env.secrets, and re-stages the
|
|
2608
|
-
# file so the COMMIT carries the ciphertext rather than what you staged.
|
|
2609
|
-
# Encryption needs only the public key in that file, so this needs no
|
|
2610
|
-
# credential and works on a fresh clone.
|
|
2611
|
-
#
|
|
2612
|
-
# It refuses only when it cannot fix the problem itself: no key yet
|
|
2613
|
-
# (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
|
|
2614
|
-
# committed in the clear cannot be un-committed — the value stays in the
|
|
2615
|
-
# history and has to be treated as burned.
|
|
2616
|
-
bunx vc guard
|
|
2617
|
-
`, result, "regenerate");
|
|
2618
2654
|
await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
|
|
2619
2655
|
await retire(root, ".github/workflows/void-dist.yml", result);
|
|
2620
2656
|
await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
|
|
@@ -3036,7 +3072,9 @@ function routeProblem(project) {
|
|
|
3036
3072
|
async function preflight(project, source) {
|
|
3037
3073
|
const present = productionEnv(project.appDir);
|
|
3038
3074
|
let remote = null;
|
|
3039
|
-
if (source === "
|
|
3075
|
+
if (source === "repository") {
|
|
3076
|
+
remote = null;
|
|
3077
|
+
} else if (source === "wrangler") {
|
|
3040
3078
|
const bin = findWrangler(project.appDir);
|
|
3041
3079
|
remote = bin ? await secretNames(bin, project.appDir) : null;
|
|
3042
3080
|
} else {
|
|
@@ -3044,8 +3082,11 @@ async function preflight(project, source) {
|
|
|
3044
3082
|
}
|
|
3045
3083
|
for (const name of remote ?? [])
|
|
3046
3084
|
present.set(name, "<secret>");
|
|
3047
|
-
|
|
3048
|
-
|
|
3085
|
+
const stillUnset = new Set(unsetSecretNames(project.root));
|
|
3086
|
+
for (const name of declaredSecretNames(project.root)) {
|
|
3087
|
+
if (!stillUnset.has(name))
|
|
3088
|
+
present.set(name, "<in the repository>");
|
|
3089
|
+
}
|
|
3049
3090
|
const missing = allEnvKeys(project.manifest).filter((key) => {
|
|
3050
3091
|
const value = present.get(key.key);
|
|
3051
3092
|
return value === undefined || value === "" || value === UNSET;
|
package/dist/index.js
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
routeProblem,
|
|
53
53
|
strictDependencies,
|
|
54
54
|
upsertJsonc
|
|
55
|
-
} from "./index-
|
|
55
|
+
} from "./index-x70qxh3m.js";
|
|
56
56
|
import {
|
|
57
57
|
allEnvKeys,
|
|
58
58
|
envSummary,
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
renderEnvLocal,
|
|
61
61
|
renderEnvProduction,
|
|
62
62
|
renderEnvTs
|
|
63
|
-
} from "./index-
|
|
63
|
+
} from "./index-s8se0x2d.js";
|
|
64
64
|
import {
|
|
65
65
|
LAYOUTS,
|
|
66
66
|
MANIFEST_FILE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/deploy/index.ts
CHANGED
|
@@ -61,12 +61,40 @@ export async function preflightCommand(args: string[]): Promise<number> {
|
|
|
61
61
|
console.error("vc: no voidcommerce.json here.");
|
|
62
62
|
return 1;
|
|
63
63
|
}
|
|
64
|
-
const source = args.includes("--cloudflare") ? "wrangler" : "void";
|
|
64
|
+
const source = args.includes("--repo") ? "repository" : args.includes("--cloudflare") ? "wrangler" : "void";
|
|
65
65
|
const result = await preflight(project, source);
|
|
66
66
|
printPreflight(project, result, source);
|
|
67
|
+
if (source !== "repository") return result.ready ? 0 : 1;
|
|
68
|
+
|
|
69
|
+
// A repository check is a check on what will be PUSHED, so it also asks
|
|
70
|
+
// the question CI would ask and cannot answer any earlier: is there a
|
|
71
|
+
// migration that was generated and never committed? Applied under a fresh
|
|
72
|
+
// name on the next run, against tables that already exist, it fails the
|
|
73
|
+
// deploy — and it fails it after the shop is already down for the update.
|
|
74
|
+
const pending = await uncommittedMigrations(project.root);
|
|
75
|
+
if (pending.length > 0) {
|
|
76
|
+
console.error(
|
|
77
|
+
`${color.red("✗")} ${pending.length} migration file${pending.length === 1 ? " is" : "s are"} not committed:\n` +
|
|
78
|
+
` ${pending.join("\n ")}\n\n` +
|
|
79
|
+
" The deploy applies what is in the repository. Commit these, or the\n" +
|
|
80
|
+
" schema the shop runs on is not the one you tested.\n",
|
|
81
|
+
);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
67
84
|
return result.ready ? 0 : 1;
|
|
68
85
|
}
|
|
69
86
|
|
|
87
|
+
/** Migration files git does not have — untracked or modified. */
|
|
88
|
+
async function uncommittedMigrations(root: string): Promise<string[]> {
|
|
89
|
+
const status = await run("git", ["status", "--porcelain", "--", "migrations"], root);
|
|
90
|
+
if (status.code !== 0) return [];
|
|
91
|
+
return status.out
|
|
92
|
+
.split("\n")
|
|
93
|
+
.map((line) => line.trim())
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.map((line) => line.replace(/^\S+\s+/, ""));
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
/** void's deploy help, then what vc adds. */
|
|
71
99
|
export async function deployHelp(): Promise<number> {
|
|
72
100
|
const captured = await captureVoid(["deploy", "--help"]);
|
package/src/deploy/preflight.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { workerHosts } from "../manifest";
|
|
|
7
7
|
import type { Project } from "../project";
|
|
8
8
|
import { captureVoid } from "../void";
|
|
9
9
|
import { parseJsonc } from "./jsonc";
|
|
10
|
-
import { SECRETS_FILE, declaredSecretNames, plaintextSecretNames } from "./secrets";
|
|
10
|
+
import { SECRETS_FILE, declaredSecretNames, plaintextSecretNames, unsetSecretNames } from "./secrets";
|
|
11
11
|
import { findWrangler, secretNames } from "./wrangler";
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -34,7 +34,16 @@ export interface Preflight {
|
|
|
34
34
|
ready: boolean;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Where a secret's presence is established.
|
|
39
|
+
*
|
|
40
|
+
* `repository` asks only what is committed, and is the right question before
|
|
41
|
+
* a PUSH: the deploy happens off this machine from what was pushed, so a
|
|
42
|
+
* check that needs local credentials is checking the wrong thing. It is also
|
|
43
|
+
* the only mode that works on a machine which has none — which, if config is
|
|
44
|
+
* properly in the environment, is every machine.
|
|
45
|
+
*/
|
|
46
|
+
export type SecretSource = "wrangler" | "void" | "repository";
|
|
38
47
|
|
|
39
48
|
/** Keys committed in .env.production — safe values only, by construction. */
|
|
40
49
|
export function productionEnv(appDir: string): Map<string, string> {
|
|
@@ -81,7 +90,9 @@ export function routeProblem(project: Project): string | null {
|
|
|
81
90
|
export async function preflight(project: Project, source: SecretSource): Promise<Preflight> {
|
|
82
91
|
const present = productionEnv(project.appDir);
|
|
83
92
|
let remote: Set<string> | null = null;
|
|
84
|
-
if (source === "
|
|
93
|
+
if (source === "repository") {
|
|
94
|
+
remote = null;
|
|
95
|
+
} else if (source === "wrangler") {
|
|
85
96
|
const bin = findWrangler(project.appDir);
|
|
86
97
|
remote = bin ? await secretNames(bin, project.appDir) : null;
|
|
87
98
|
} else {
|
|
@@ -94,7 +105,12 @@ export async function preflight(project: Project, source: SecretSource): Promise
|
|
|
94
105
|
* uploads it. The names are readable without the key — dotenvx encrypts
|
|
95
106
|
* values, not keys — so this works on a machine that cannot decrypt.
|
|
96
107
|
*/
|
|
97
|
-
|
|
108
|
+
const stillUnset = new Set(unsetSecretNames(project.root));
|
|
109
|
+
for (const name of declaredSecretNames(project.root)) {
|
|
110
|
+
// A name is not a value. `unset` is the documented placeholder, so a
|
|
111
|
+
// key carrying it is declared and absent at the same time.
|
|
112
|
+
if (!stillUnset.has(name)) present.set(name, "<in the repository>");
|
|
113
|
+
}
|
|
98
114
|
|
|
99
115
|
const missing = allEnvKeys(project.manifest).filter((key) => {
|
|
100
116
|
const value = present.get(key.key);
|
package/src/deploy/secrets.ts
CHANGED
|
@@ -126,6 +126,27 @@ export function declaredSecretNames(root: string): Set<string> {
|
|
|
126
126
|
return names;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Declared names whose value is still the `unset` placeholder.
|
|
131
|
+
*
|
|
132
|
+
* Readable WITHOUT the key, because `unset` is plaintext — which is what
|
|
133
|
+
* lets a push-time gate tell "this shop has a Stripe key" from "this shop
|
|
134
|
+
* has a line that says STRIPE_SECRET_KEY". Preflight used to count the
|
|
135
|
+
* second as present, so a shop could pass every check and then fail to take
|
|
136
|
+
* money in production.
|
|
137
|
+
*/
|
|
138
|
+
export function unsetSecretNames(root: string): string[] {
|
|
139
|
+
const path = join(root, SECRETS_FILE);
|
|
140
|
+
if (!existsSync(path)) return [];
|
|
141
|
+
const out: string[] = [];
|
|
142
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
143
|
+
const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
144
|
+
if (!match || match[1]!.startsWith("DOTENV_")) continue;
|
|
145
|
+
if (match[2]!.trim().replace(/^['"]|['"]$/g, "") === "unset") out.push(match[1]!);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
129
150
|
/** Is a declared value actually encrypted, or was it committed in the clear? */
|
|
130
151
|
export function plaintextSecretNames(root: string): string[] {
|
|
131
152
|
const path = join(root, SECRETS_FILE);
|
package/src/generate/ci.ts
CHANGED
|
@@ -42,8 +42,10 @@ export function renderDistWorkflow(manifest: Manifest): string {
|
|
|
42
42
|
|
|
43
43
|
# Generated by \`vc init\` from voidcommerce.json.
|
|
44
44
|
#
|
|
45
|
-
# A push to main regenerates the Void app from the manifest,
|
|
46
|
-
# \`${DIST_BRANCH}\` as a standalone tree, and deploys it to
|
|
45
|
+
# A push to main is checked, then regenerates the Void app from the manifest,
|
|
46
|
+
# publishes it to \`${DIST_BRANCH}\` as a standalone tree, and deploys it to
|
|
47
|
+
# Cloudflare. The check runs FIRST and on its own: nothing is built, created
|
|
48
|
+
# or replaced for a shop that is not ready.
|
|
47
49
|
#
|
|
48
50
|
# The credentials come from the repository, set once by \`vc link\`:
|
|
49
51
|
#
|
|
@@ -68,8 +70,32 @@ permissions:
|
|
|
68
70
|
contents: write
|
|
69
71
|
|
|
70
72
|
jobs:
|
|
73
|
+
# ── Is what was just pushed fit to deploy? ─────────────────────────────
|
|
74
|
+
#
|
|
75
|
+
# First, and on its own, because everything after it costs something: a
|
|
76
|
+
# build, a database, a worker replaced. It answers the question the deploy
|
|
77
|
+
# would ask, against exactly what was pushed, and needs NO CREDENTIALS to
|
|
78
|
+
# do it — config lives in the repository, encrypted, so readiness is a
|
|
79
|
+
# property of the commit rather than of whoever pushed it.
|
|
80
|
+
#
|
|
81
|
+
# This is the gate. It cannot be skipped with --no-verify, and it runs the
|
|
82
|
+
# same way for every push and every person.
|
|
83
|
+
preflight:
|
|
84
|
+
runs-on: ubuntu-latest
|
|
85
|
+
steps:
|
|
86
|
+
- uses: actions/checkout@v6
|
|
87
|
+
- uses: oven-sh/setup-bun@v2
|
|
88
|
+
- run: bun install --frozen-lockfile
|
|
89
|
+
|
|
90
|
+
# Every required key actually SET rather than merely declared, nothing
|
|
91
|
+
# committed in the clear, the hostnames the manifest says, and no
|
|
92
|
+
# migration generated but left uncommitted.
|
|
93
|
+
- name: vc preflight --repo
|
|
94
|
+
run: bunx vc preflight --repo
|
|
95
|
+
|
|
71
96
|
# ── Regenerate, and publish the standalone tree ────────────────────────
|
|
72
97
|
dist:
|
|
98
|
+
needs: preflight
|
|
73
99
|
runs-on: ubuntu-latest
|
|
74
100
|
steps:
|
|
75
101
|
- uses: actions/checkout@v6
|
|
@@ -171,7 +197,12 @@ regenerate with \`vc generate\`.
|
|
|
171
197
|
git push
|
|
172
198
|
\`\`\`
|
|
173
199
|
|
|
174
|
-
That is the deploy.
|
|
200
|
+
That is the deploy. It is checked first: a \`preflight\` job runs before
|
|
201
|
+
anything is built, and refuses a shop whose secrets are declared but unset,
|
|
202
|
+
whose values were committed in the clear, or whose migrations are not all
|
|
203
|
+
committed. It needs no credentials — readiness is a property of the commit,
|
|
204
|
+
not of the machine that pushed it — so it fails the same way for everyone,
|
|
205
|
+
and cannot be skipped. GitHub Actions regenerates the app from
|
|
175
206
|
\`voidcommerce.json\`, publishes a standalone copy to \`${DIST_BRANCH}\`, creates the
|
|
176
207
|
database and queue if they are not there, applies the migrations, and deploys
|
|
177
208
|
the worker with this repository's secrets attached.
|
package/src/generate/index.ts
CHANGED
|
@@ -195,6 +195,46 @@ async function link(root: string, file: string, target: string, result: Generate
|
|
|
195
195
|
|
|
196
196
|
const sorted = (record: Record<string, string>) => Object.fromEntries(Object.entries(record).sort());
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Git hooks, for every layout.
|
|
200
|
+
*
|
|
201
|
+
* They guard `.env.secrets`, which exists whatever the layout, so making
|
|
202
|
+
* them strict-only left an `app` shop able to commit a credential in the
|
|
203
|
+
* clear with nothing to stop it. Installed by husky on `bun install`, so a
|
|
204
|
+
* fresh clone is guarded without anyone remembering.
|
|
205
|
+
*/
|
|
206
|
+
async function generateHooks(root: string, result: GenerateResult) {
|
|
207
|
+
/**
|
|
208
|
+
* The hook lives in `.husky/`, which is COMMITTED, so the guard travels
|
|
209
|
+
* with the repository instead of being something each clone remembers to
|
|
210
|
+
* install. A secret committed in the clear cannot be un-committed.
|
|
211
|
+
*/
|
|
212
|
+
await put(
|
|
213
|
+
root,
|
|
214
|
+
".husky/pre-commit",
|
|
215
|
+
`#!/usr/bin/env sh
|
|
216
|
+
# Generated by \`vc init\`.
|
|
217
|
+
#
|
|
218
|
+
# Encrypts any value sitting in the clear in .env.secrets, and re-stages the
|
|
219
|
+
# file so the COMMIT carries the ciphertext rather than what you staged.
|
|
220
|
+
# Encryption needs only the public key in that file, so this needs no
|
|
221
|
+
# credential and works on a fresh clone.
|
|
222
|
+
#
|
|
223
|
+
# It refuses only when it cannot fix the problem itself: no key yet
|
|
224
|
+
# (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
|
|
225
|
+
# committed in the clear cannot be un-committed — the value stays in the
|
|
226
|
+
# history and has to be treated as burned.
|
|
227
|
+
bunx vc guard
|
|
228
|
+
`,
|
|
229
|
+
result,
|
|
230
|
+
"regenerate",
|
|
231
|
+
);
|
|
232
|
+
// The pre-PUSH gate lives in CI now: the deploy runs off-machine, so the
|
|
233
|
+
// authoritative check belongs there, where nobody can --no-verify past it.
|
|
234
|
+
// See renderDistWorkflow.
|
|
235
|
+
await retire(root, ".husky/pre-push", result);
|
|
236
|
+
}
|
|
237
|
+
|
|
198
238
|
export async function generate(root: string, manifest: Manifest): Promise<GenerateResult> {
|
|
199
239
|
const result: GenerateResult = { written: [], kept: [], retired: [], packages: packagesOf(manifest) };
|
|
200
240
|
|
|
@@ -218,6 +258,7 @@ export async function generate(root: string, manifest: Manifest): Promise<Genera
|
|
|
218
258
|
await generateStrictApp(root, STRICT_APP, manifest, result);
|
|
219
259
|
break;
|
|
220
260
|
}
|
|
261
|
+
await generateHooks(root, result);
|
|
221
262
|
return result;
|
|
222
263
|
}
|
|
223
264
|
|
|
@@ -424,31 +465,6 @@ async function generateStrictRoot(root: string, manifest: Manifest, result: Gene
|
|
|
424
465
|
"own",
|
|
425
466
|
);
|
|
426
467
|
await put(root, "patches/void@0.10.13.patch", renderVoidPatch(), result, "regenerate");
|
|
427
|
-
/**
|
|
428
|
-
* The hook lives in `.husky/`, which is COMMITTED, so the guard travels
|
|
429
|
-
* with the repository instead of being something each clone remembers to
|
|
430
|
-
* install. A secret committed in the clear cannot be un-committed.
|
|
431
|
-
*/
|
|
432
|
-
await put(
|
|
433
|
-
root,
|
|
434
|
-
".husky/pre-commit",
|
|
435
|
-
`#!/usr/bin/env sh
|
|
436
|
-
# Generated by \`vc init\`.
|
|
437
|
-
#
|
|
438
|
-
# Encrypts any value sitting in the clear in .env.secrets, and re-stages the
|
|
439
|
-
# file so the COMMIT carries the ciphertext rather than what you staged.
|
|
440
|
-
# Encryption needs only the public key in that file, so this needs no
|
|
441
|
-
# credential and works on a fresh clone.
|
|
442
|
-
#
|
|
443
|
-
# It refuses only when it cannot fix the problem itself: no key yet
|
|
444
|
-
# (\`vc keys --init\`), or a .env.keys that is not gitignored. A secret
|
|
445
|
-
# committed in the clear cannot be un-committed — the value stays in the
|
|
446
|
-
# history and has to be treated as burned.
|
|
447
|
-
bunx vc guard
|
|
448
|
-
`,
|
|
449
|
-
result,
|
|
450
|
-
"regenerate",
|
|
451
|
-
);
|
|
452
468
|
await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
|
|
453
469
|
// Renamed when GitHub Actions took over the deploy from Cloudflare's own
|
|
454
470
|
// build. Two workflows on the same push would deploy the shop twice.
|