@saastemly/voidcommerce 0.18.0 → 0.19.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 +71 -411
- package/dist/deploy/index.d.ts +0 -6
- package/dist/deploy/secrets.d.ts +10 -0
- package/dist/{index-khzk6a9z.js → index-68m1eg3f.js} +29 -49
- package/dist/{index-3j6jtjmk.js → index-y4dk27kf.js} +45 -64
- package/dist/index.js +2 -2
- package/dist/{keys-fn6wbv40.js → keys-e6w5dcxx.js} +1 -1
- package/package.json +1 -1
- package/src/cli.ts +0 -6
- package/src/deploy/cloudflare.ts +23 -1
- package/src/deploy/index.ts +0 -71
- package/src/deploy/secrets.ts +27 -1
- package/src/generate/ci.ts +30 -62
- package/src/generate/env.ts +22 -0
- package/dist/deploy/link.d.ts +0 -2
- package/dist/deploy/publish.d.ts +0 -32
- package/src/deploy/link.ts +0 -220
- package/src/deploy/publish.ts +0 -145
package/dist/deploy/index.d.ts
CHANGED
|
@@ -43,9 +43,3 @@ export declare function keysHelp(): Promise<number>;
|
|
|
43
43
|
* Exit code is the whole interface — a hook cares about nothing else.
|
|
44
44
|
*/
|
|
45
45
|
export declare function guardCommand(): Promise<number>;
|
|
46
|
-
/** `vc link` — GitHub holds the credentials; this is what puts them there. */
|
|
47
|
-
export declare function linkCliCommand(args: string[]): Promise<number>;
|
|
48
|
-
export declare function linkHelp(): Promise<number>;
|
|
49
|
-
/** `vc publish` — create the repository, configure it, push. The push deploys. */
|
|
50
|
-
export declare function publishCliCommand(args: string[]): Promise<number>;
|
|
51
|
-
export declare function publishHelp(): Promise<number>;
|
package/dist/deploy/secrets.d.ts
CHANGED
|
@@ -121,6 +121,16 @@ export declare function initSecrets(project: Project): Promise<number>;
|
|
|
121
121
|
* command runs, and shells keep it in history besides.
|
|
122
122
|
*/
|
|
123
123
|
export declare function setSecretValue(project: Project, args: string[]): Promise<number>;
|
|
124
|
+
/**
|
|
125
|
+
* One value out of `.env.secrets`, by name.
|
|
126
|
+
*
|
|
127
|
+
* How the deploy gets its Cloudflare credentials without anyone having put
|
|
128
|
+
* them in the environment: they are ordinary secrets in the committed file,
|
|
129
|
+
* so the deploy reads them out of what was pushed. Returns null rather than
|
|
130
|
+
* throwing — a caller that cannot decrypt has an env var to fall back on,
|
|
131
|
+
* and a better error to give than this one.
|
|
132
|
+
*/
|
|
133
|
+
export declare function secretValue(project: Project, name: string): Promise<string | null>;
|
|
124
134
|
/**
|
|
125
135
|
* Encrypt one value into `.env.secrets`, in process.
|
|
126
136
|
*
|
|
@@ -77,24 +77,6 @@ async function repoSlug(cwd) {
|
|
|
77
77
|
const slug = viewed.out.trim();
|
|
78
78
|
return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
|
|
79
79
|
}
|
|
80
|
-
async function setSecret(cwd, name, value) {
|
|
81
|
-
const gh = findGh();
|
|
82
|
-
if (!gh)
|
|
83
|
-
return { ok: false, error: "gh is not installed" };
|
|
84
|
-
const result = await run(gh, ["secret", "set", name], cwd, value);
|
|
85
|
-
return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split(`
|
|
86
|
-
`).slice(-2).join(" ") };
|
|
87
|
-
}
|
|
88
|
-
async function secretNames(cwd) {
|
|
89
|
-
const gh = findGh();
|
|
90
|
-
if (!gh)
|
|
91
|
-
return new Set;
|
|
92
|
-
const listed = await run(gh, ["secret", "list", "--json", "name", "-q", ".[].name"], cwd);
|
|
93
|
-
if (listed.code !== 0)
|
|
94
|
-
return new Set;
|
|
95
|
-
return new Set(listed.out.split(`
|
|
96
|
-
`).map((line) => line.trim()).filter(Boolean));
|
|
97
|
-
}
|
|
98
80
|
async function setVariable(cwd, name, value) {
|
|
99
81
|
const gh = findGh();
|
|
100
82
|
if (!gh)
|
|
@@ -123,31 +105,6 @@ async function variableNames(cwd) {
|
|
|
123
105
|
return new Set(listed.out.split(`
|
|
124
106
|
`).map((line) => line.trim()).filter(Boolean));
|
|
125
107
|
}
|
|
126
|
-
async function verifyCloudflareToken(token) {
|
|
127
|
-
try {
|
|
128
|
-
const response = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
|
|
129
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
130
|
-
});
|
|
131
|
-
const body = await response.json();
|
|
132
|
-
if (body.success && body.result?.status === "active")
|
|
133
|
-
return { ok: true, detail: "active" };
|
|
134
|
-
const message = body.errors?.[0]?.message ?? `HTTP ${response.status}`;
|
|
135
|
-
return { ok: false, detail: message };
|
|
136
|
-
} catch (error) {
|
|
137
|
-
return { ok: false, detail: `could not reach the Cloudflare API: ${String(error)}` };
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
async function cloudflareAccounts(token) {
|
|
141
|
-
try {
|
|
142
|
-
const response = await fetch("https://api.cloudflare.com/client/v4/accounts?per_page=50", {
|
|
143
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
144
|
-
});
|
|
145
|
-
const body = await response.json();
|
|
146
|
-
return body.success && Array.isArray(body.result) ? body.result.map((a) => ({ id: a.id, name: a.name })) : [];
|
|
147
|
-
} catch {
|
|
148
|
-
return [];
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
108
|
|
|
152
109
|
// src/deploy/secrets.ts
|
|
153
110
|
import { existsSync as existsSync2, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -158,6 +115,16 @@ import color from "picocolors";
|
|
|
158
115
|
|
|
159
116
|
// src/generate/env.ts
|
|
160
117
|
var BASE = [
|
|
118
|
+
{
|
|
119
|
+
key: "CLOUDFLARE_API_TOKEN",
|
|
120
|
+
breaks: "nothing deploys: no worker, no database, no migrations",
|
|
121
|
+
where: "dash.cloudflare.com/profile/api-tokens → Create Token → Custom token"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
key: "CLOUDFLARE_ACCOUNT_ID",
|
|
125
|
+
breaks: "wrangler cannot tell which account to deploy into, and refuses rather than guessing",
|
|
126
|
+
where: "the Cloudflare dashboard sidebar, or `vc secrets set` it after the first provision"
|
|
127
|
+
},
|
|
161
128
|
{
|
|
162
129
|
key: "SHOP_DOMAIN",
|
|
163
130
|
breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
|
|
@@ -403,18 +370,19 @@ async function decryptSecrets(project) {
|
|
|
403
370
|
const root = project.root;
|
|
404
371
|
if (!existsSync2(join2(root, SECRETS_FILE)))
|
|
405
372
|
return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
|
|
406
|
-
const
|
|
373
|
+
const { localPrivateKey } = await import("./keys-e6w5dcxx.js");
|
|
374
|
+
const privateKey = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root) || "";
|
|
407
375
|
if (!privateKey) {
|
|
408
376
|
return {
|
|
409
377
|
error: `${PRIVATE_KEY_VAR} is not set, so ${SECRETS_FILE} cannot be decrypted here.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
378
|
+
That is the normal state on a laptop: the key lives in GitHub Actions and
|
|
379
|
+
only the deploy uses it. Push, and the workflow does this step.
|
|
380
|
+
To deploy by hand anyway, export the key for one command.`
|
|
413
381
|
};
|
|
414
382
|
}
|
|
415
383
|
const expected = committedPublicKey(root);
|
|
416
384
|
if (expected) {
|
|
417
|
-
const { publicKeyFor } = await import("./keys-
|
|
385
|
+
const { publicKeyFor } = await import("./keys-e6w5dcxx.js");
|
|
418
386
|
const derived = await publicKeyFor(privateKey);
|
|
419
387
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
420
388
|
return {
|
|
@@ -633,6 +601,18 @@ cancelled; nothing changed.
|
|
|
633
601
|
}
|
|
634
602
|
return String(answer);
|
|
635
603
|
}
|
|
604
|
+
async function secretValue(project, name) {
|
|
605
|
+
const decrypted = await decryptSecrets(project);
|
|
606
|
+
if ("error" in decrypted)
|
|
607
|
+
return null;
|
|
608
|
+
try {
|
|
609
|
+
const match = new RegExp(`^\\\\s*${name}\\\\s*=\\\\s*(.*)$`, "m").exec(readFileSync(decrypted.path, "utf8"));
|
|
610
|
+
const value = match?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
|
|
611
|
+
return value && value !== "unset" ? value : null;
|
|
612
|
+
} finally {
|
|
613
|
+
decrypted.cleanup();
|
|
614
|
+
}
|
|
615
|
+
}
|
|
636
616
|
async function encryptInto(root, publicKey, name, value) {
|
|
637
617
|
const { encrypt } = await import("./index-mpr7gm6k.js").then((m)=>__toESM(m.default,1));
|
|
638
618
|
let ciphertext;
|
|
@@ -995,4 +975,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
|
|
|
995
975
|
return 0;
|
|
996
976
|
}
|
|
997
977
|
|
|
998
|
-
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken,
|
|
978
|
+
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, apiToken, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, declaredSecretNames, unsetSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, secretValue, encryptInto };
|
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
renderEnvLocal,
|
|
12
12
|
renderEnvProduction,
|
|
13
13
|
renderEnvTs,
|
|
14
|
+
secretValue,
|
|
14
15
|
unsetSecretNames
|
|
15
|
-
} from "./index-
|
|
16
|
+
} from "./index-68m1eg3f.js";
|
|
16
17
|
import {
|
|
17
18
|
MANIFEST_FILE,
|
|
18
19
|
has,
|
|
@@ -1379,7 +1380,7 @@ import color from "picocolors";
|
|
|
1379
1380
|
// package.json
|
|
1380
1381
|
var package_default = {
|
|
1381
1382
|
name: "@saastemly/voidcommerce",
|
|
1382
|
-
version: "0.
|
|
1383
|
+
version: "0.19.0",
|
|
1383
1384
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1384
1385
|
type: "module",
|
|
1385
1386
|
license: "MIT",
|
|
@@ -1717,14 +1718,13 @@ function renderDistWorkflow(manifest) {
|
|
|
1717
1718
|
# Cloudflare. The check runs FIRST and on its own: nothing is built, created
|
|
1718
1719
|
# or replaced for a shop that is not ready.
|
|
1719
1720
|
#
|
|
1720
|
-
#
|
|
1721
|
+
# ONE thing is configured outside the repository:
|
|
1721
1722
|
#
|
|
1722
|
-
#
|
|
1723
|
-
# vars.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE} (a VARIABLE, so it can be read back)
|
|
1724
|
-
# vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
|
|
1723
|
+
# vars.${PRIVATE_KEY_VAR} the key that opens ${SECRETS_FILE}
|
|
1725
1724
|
#
|
|
1726
|
-
#
|
|
1727
|
-
#
|
|
1725
|
+
# Everything else — including the Cloudflare token and account id — is an
|
|
1726
|
+
# ordinary secret in that file, encrypted and committed, so it travels with
|
|
1727
|
+
# the push. See DEPLOY.md.
|
|
1728
1728
|
on:
|
|
1729
1729
|
push:
|
|
1730
1730
|
branches: [main, master]
|
|
@@ -1815,8 +1815,8 @@ jobs:
|
|
|
1815
1815
|
name: production
|
|
1816
1816
|
url: https://${manifest.shop.domain}
|
|
1817
1817
|
env:
|
|
1818
|
-
|
|
1819
|
-
|
|
1818
|
+
# The only thing this deploy is given. The Cloudflare credentials are
|
|
1819
|
+
# read out of .env.secrets, which this key opens.
|
|
1820
1820
|
${PRIVATE_KEY_VAR}: \${{ vars.${PRIVATE_KEY_VAR} }}
|
|
1821
1821
|
steps:
|
|
1822
1822
|
- uses: actions/checkout@v6
|
|
@@ -1830,19 +1830,15 @@ jobs:
|
|
|
1830
1830
|
# that is what makes a new laptop or a second person possible — but
|
|
1831
1831
|
# GitHub only masks SECRETS in logs automatically. Registering it with
|
|
1832
1832
|
# ::add-mask:: buys back the redaction without giving up recovery.
|
|
1833
|
-
- name:
|
|
1833
|
+
- name: Is the key here?
|
|
1834
1834
|
run: |
|
|
1835
1835
|
set -euo pipefail
|
|
1836
|
-
if [ -
|
|
1837
|
-
echo "::
|
|
1838
|
-
|
|
1839
|
-
missing=""
|
|
1840
|
-
[ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
|
|
1841
|
-
[ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
|
|
1842
|
-
if [ -n "$missing" ]; then
|
|
1843
|
-
echo "::error::this repository has no$missing. Run 'vc link' once, from a checkout."
|
|
1836
|
+
if [ -z "\${${PRIVATE_KEY_VAR}:-}" ]; then
|
|
1837
|
+
echo "::error::this repository has no ${PRIVATE_KEY_VAR} variable, so .env.secrets cannot be opened and nothing can deploy."
|
|
1838
|
+
echo "Set it once, from a checkout: gh variable set ${PRIVATE_KEY_VAR}"
|
|
1844
1839
|
exit 1
|
|
1845
1840
|
fi
|
|
1841
|
+
echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
|
|
1846
1842
|
|
|
1847
1843
|
- run: bun install --frozen-lockfile
|
|
1848
1844
|
|
|
@@ -1899,53 +1895,26 @@ below.
|
|
|
1899
1895
|
## Once, to start
|
|
1900
1896
|
|
|
1901
1897
|
\`\`\`sh
|
|
1902
|
-
|
|
1898
|
+
gh repo create --source=. --private # or make it in the browser
|
|
1899
|
+
vc secrets --init # writes the key onto the repository
|
|
1900
|
+
vc secrets set CLOUDFLARE_API_TOKEN # and every other key it lists
|
|
1901
|
+
git push -u origin main
|
|
1903
1902
|
\`\`\`
|
|
1904
1903
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
| \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
|
|
1920
|
-
|
|
1921
|
-
It will ask you to paste a Cloudflare API token. That is the only manual step
|
|
1922
|
-
in the whole setup, and it is worth saying exactly why it cannot be removed:
|
|
1923
|
-
|
|
1924
|
-
> **GitHub cannot mint a Cloudflare credential.** There is no OIDC or workload
|
|
1925
|
-
> identity federation between them — the feature request has been open since
|
|
1926
|
-
> 2025 with no commitment, and Cloudflare's own CI guidance still says to store
|
|
1927
|
-
> a token in your CI provider's secrets. The Cloudflare GitHub App does not
|
|
1928
|
-
> help either: it grants *Cloudflare* access to your *repository*, not the
|
|
1929
|
-
> reverse. Something has to authorise creating a database in your account, and
|
|
1930
|
-
> only Cloudflare can issue that authorisation.
|
|
1931
|
-
|
|
1932
|
-
Create the token at **My Profile → API Tokens → Create Token → Custom token**:
|
|
1933
|
-
|
|
1934
|
-
| scope | permission | for |
|
|
1935
|
-
|---|---|---|
|
|
1936
|
-
| Account | Workers Scripts: Edit | deploying the worker |
|
|
1937
|
-
| Account | D1: Edit | creating the database, applying migrations |
|
|
1938
|
-
| Account | Queues: Edit | the order queue |
|
|
1939
|
-
| Account | Workers KV Storage: Edit | sessions and caches |
|
|
1940
|
-
| Account | Workers R2 Storage: Edit | product images |
|
|
1941
|
-
| Account | Account Settings: Read | confirming which account |
|
|
1942
|
-
| Zone | Workers Routes: Edit (${zone2}) | answering on your domain |
|
|
1943
|
-
| Zone | Zone: Read (${zone2}) | confirming the zone is on this account |
|
|
1944
|
-
| Zone | DNS: Read (${zone2}) | noticing a hostname that already answers |
|
|
1945
|
-
| User | User Details: Read, Memberships: Read | wrangler asks at startup |
|
|
1946
|
-
|
|
1947
|
-
The token Cloudflare generates for its own Workers Builds will **not** do: it
|
|
1948
|
-
has no D1 and no Queues permission, so it cannot create this shop's database.
|
|
1904
|
+
That push is the deploy, and every push after it.
|
|
1905
|
+
|
|
1906
|
+
**Exactly one thing lives outside the repository**: \`${PRIVATE_KEY_VAR}\`, a
|
|
1907
|
+
repository variable holding the key that opens \`${SECRETS_FILE}\`. It is set
|
|
1908
|
+
for you the moment the key is made, as long as the repository already exists
|
|
1909
|
+
— which is why the repository comes first above.
|
|
1910
|
+
|
|
1911
|
+
Everything else, including the Cloudflare token and account id, is an
|
|
1912
|
+
ordinary secret in \`${SECRETS_FILE}\`: encrypted, committed, and read by the
|
|
1913
|
+
deploy out of what you pushed.
|
|
1914
|
+
|
|
1915
|
+
The token itself has to be fetched by a person, once, because there is no
|
|
1916
|
+
OIDC between GitHub and Cloudflare and nothing else can issue one. Create it
|
|
1917
|
+
at **My Profile → API Tokens → Create Token → Custom token**:
|
|
1949
1918
|
|
|
1950
1919
|
### The zone
|
|
1951
1920
|
|
|
@@ -3300,6 +3269,18 @@ async function deployCloudflare(project, opts) {
|
|
|
3300
3269
|
const configPath = join8(app, "wrangler.jsonc");
|
|
3301
3270
|
if (!existsSync8(configPath))
|
|
3302
3271
|
return fail("wrangler.jsonc is missing — `vc generate` writes it.");
|
|
3272
|
+
if (!process.env["CLOUDFLARE_API_TOKEN"]) {
|
|
3273
|
+
const fromRepo = await secretValue(project, "CLOUDFLARE_API_TOKEN");
|
|
3274
|
+
if (fromRepo) {
|
|
3275
|
+
process.env["CLOUDFLARE_API_TOKEN"] = fromRepo;
|
|
3276
|
+
console.log(`${color5.green("✓")} Cloudflare credentials read from ${SECRETS_FILE}`);
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
if (!process.env["CLOUDFLARE_ACCOUNT_ID"]) {
|
|
3280
|
+
const fromRepo = await secretValue(project, "CLOUDFLARE_ACCOUNT_ID");
|
|
3281
|
+
if (fromRepo)
|
|
3282
|
+
process.env["CLOUDFLARE_ACCOUNT_ID"] = fromRepo;
|
|
3283
|
+
}
|
|
3303
3284
|
const bin = findWrangler(app);
|
|
3304
3285
|
if (!bin)
|
|
3305
3286
|
return fail("wrangler is not installed. `bun add -d wrangler`, then `wrangler login`.");
|
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-y4dk27kf.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-68m1eg3f.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.19.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/cli.ts
CHANGED
|
@@ -4,10 +4,6 @@ import {
|
|
|
4
4
|
guardCommand,
|
|
5
5
|
keysCliCommand,
|
|
6
6
|
keysHelp,
|
|
7
|
-
linkCliCommand,
|
|
8
|
-
linkHelp,
|
|
9
|
-
publishCliCommand,
|
|
10
|
-
publishHelp,
|
|
11
7
|
preflightCommand,
|
|
12
8
|
preflightHelp,
|
|
13
9
|
secretsCliCommand,
|
|
@@ -54,8 +50,6 @@ export const EXTENDED: Record<string, Extended> = {
|
|
|
54
50
|
preflight: { run: preflightCommand, help: preflightHelp },
|
|
55
51
|
secrets: { run: secretsCliCommand, help: secretsHelp },
|
|
56
52
|
keys: { run: keysCliCommand, help: keysHelp },
|
|
57
|
-
link: { run: linkCliCommand, help: linkHelp },
|
|
58
|
-
publish: { run: publishCliCommand, help: publishHelp },
|
|
59
53
|
// Called by the generated pre-commit hook; exit code is the interface.
|
|
60
54
|
guard: { run: guardCommand, help: async () => guardCommand() },
|
|
61
55
|
dev: { run: appScript("dev"), help: appScriptHelp("dev") },
|
package/src/deploy/cloudflare.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { parseJsonc, upsertJsonc } from "./jsonc";
|
|
|
8
8
|
import { apiToken } from "./github";
|
|
9
9
|
import { preflight, printPreflight } from "./preflight";
|
|
10
10
|
import { checkSameZone, printZoneCheck } from "./zone";
|
|
11
|
-
import { SECRETS_FILE, decryptSecrets } from "./secrets";
|
|
11
|
+
import { SECRETS_FILE, decryptSecrets, secretValue } from "./secrets";
|
|
12
12
|
import { ensureD1, ensureQueue, findWrangler, whoami, wrangler } from "./wrangler";
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -77,6 +77,28 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
|
|
|
77
77
|
const configPath = join(app, "wrangler.jsonc");
|
|
78
78
|
if (!existsSync(configPath)) return fail("wrangler.jsonc is missing — `vc generate` writes it.");
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* 0. The Cloudflare credentials, out of the repository.
|
|
82
|
+
*
|
|
83
|
+
* They are ordinary secrets in `.env.secrets`, so a deploy needs nothing
|
|
84
|
+
* in its environment except the key that opens that file. This is what
|
|
85
|
+
* makes a push the whole deploy: everything else travelled with it.
|
|
86
|
+
*
|
|
87
|
+
* An environment variable still wins, for the person running this by hand
|
|
88
|
+
* against a different account.
|
|
89
|
+
*/
|
|
90
|
+
if (!process.env["CLOUDFLARE_API_TOKEN"]) {
|
|
91
|
+
const fromRepo = await secretValue(project, "CLOUDFLARE_API_TOKEN");
|
|
92
|
+
if (fromRepo) {
|
|
93
|
+
process.env["CLOUDFLARE_API_TOKEN"] = fromRepo;
|
|
94
|
+
console.log(`${color.green("✓")} Cloudflare credentials read from ${SECRETS_FILE}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!process.env["CLOUDFLARE_ACCOUNT_ID"]) {
|
|
98
|
+
const fromRepo = await secretValue(project, "CLOUDFLARE_ACCOUNT_ID");
|
|
99
|
+
if (fromRepo) process.env["CLOUDFLARE_ACCOUNT_ID"] = fromRepo;
|
|
100
|
+
}
|
|
101
|
+
|
|
80
102
|
// 1. wrangler, logged in, account pinned.
|
|
81
103
|
const bin = findWrangler(app);
|
|
82
104
|
if (!bin) return fail("wrangler is not installed. `bun add -d wrangler`, then `wrangler login`.");
|
package/src/deploy/index.ts
CHANGED
|
@@ -8,8 +8,6 @@ import { existsSync } from "node:fs";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { PRIVATE_KEY_VAR, SECRETS_FILE, encryptInto, plaintextSecretEntries, plaintextSecretNames, run, secretsCommand } from "./secrets";
|
|
10
10
|
import { LOCAL_KEY_FILE, committedPublicKey, ignoresKeyFile, keysCommand } from "./keys";
|
|
11
|
-
import { linkCommand } from "./link";
|
|
12
|
-
import { publishCommand } from "./publish";
|
|
13
11
|
|
|
14
12
|
/**
|
|
15
13
|
* `vc deploy` extends `void deploy`: vc's preflight first, then void's
|
|
@@ -308,76 +306,7 @@ export async function guardCommand(): Promise<number> {
|
|
|
308
306
|
return 0;
|
|
309
307
|
}
|
|
310
308
|
|
|
311
|
-
/** `vc link` — GitHub holds the credentials; this is what puts them there. */
|
|
312
|
-
export async function linkCliCommand(args: string[]): Promise<number> {
|
|
313
|
-
const project = await findProject();
|
|
314
|
-
if (!project) {
|
|
315
|
-
console.error("vc: no voidcommerce.json here.");
|
|
316
|
-
return 1;
|
|
317
|
-
}
|
|
318
|
-
return linkCommand(project, args);
|
|
319
|
-
}
|
|
320
309
|
|
|
321
|
-
export async function linkHelp(): Promise<number> {
|
|
322
|
-
const width = 80;
|
|
323
|
-
console.log(
|
|
324
|
-
box("vc link", [
|
|
325
|
-
line("Put this shop's credentials on its GitHub repository, once, so that", width),
|
|
326
|
-
line("every deploy after this is a `git push`.", width),
|
|
327
|
-
line("", width),
|
|
328
|
-
...row("vc link", "store the encryption key and the Cloudflare token on the repository", width, 2),
|
|
329
|
-
...row("vc link --force", "replace what is already there", width, 2),
|
|
330
|
-
line("", width),
|
|
331
|
-
line(color.bold("What it stores, and where"), width),
|
|
332
|
-
...row(PRIVATE_KEY_VAR, "a repository SECRET. A key already waiting locally is MOVED here and the local copy deleted", width, 2),
|
|
333
|
-
...row("CLOUDFLARE_API_TOKEN", "a repository SECRET, checked against the Cloudflare API before it is stored", width, 2),
|
|
334
|
-
...row("CLOUDFLARE_ACCOUNT_ID", "a repository VARIABLE — an identifier, not a credential", width, 2),
|
|
335
|
-
line("", width),
|
|
336
|
-
line(color.bold("Why one token still has to be typed"), width),
|
|
337
|
-
line("GitHub cannot mint a Cloudflare credential. There is no OIDC federation", width),
|
|
338
|
-
line("between them, and the Cloudflare GitHub App runs the other way: it grants", width),
|
|
339
|
-
line("Cloudflare access to your repository, not your repository access to", width),
|
|
340
|
-
line("Cloudflare. So it is typed once, here, and never stored on this machine.", width),
|
|
341
|
-
], width),
|
|
342
|
-
);
|
|
343
|
-
return 0;
|
|
344
|
-
}
|
|
345
310
|
|
|
346
311
|
|
|
347
|
-
/** `vc publish` — create the repository, configure it, push. The push deploys. */
|
|
348
|
-
export async function publishCliCommand(args: string[]): Promise<number> {
|
|
349
|
-
const project = await findProject();
|
|
350
|
-
if (!project) {
|
|
351
|
-
console.error("vc: no voidcommerce.json here.");
|
|
352
|
-
return 1;
|
|
353
|
-
}
|
|
354
|
-
return publishCommand(project, args);
|
|
355
|
-
}
|
|
356
312
|
|
|
357
|
-
export async function publishHelp(): Promise<number> {
|
|
358
|
-
const width = 80;
|
|
359
|
-
console.log(
|
|
360
|
-
box("vc publish", [
|
|
361
|
-
line("Create this shop's GitHub repository, give it what the deploy needs,", width),
|
|
362
|
-
line("and push. The push IS the deploy. Run once; after that, `git push`.", width),
|
|
363
|
-
line("", width),
|
|
364
|
-
...row("vc publish", "create the repository, set the credentials, push main", width, 2),
|
|
365
|
-
line("", width),
|
|
366
|
-
line(color.bold("What it does for you"), width),
|
|
367
|
-
...row("the encryption key", "vc already holds it — set with no input from you", width, 2),
|
|
368
|
-
...row("the account id", "read from voidcommerce.json", width, 2),
|
|
369
|
-
...row("the Cloudflare token", "the one thing it has to ask for", width, 2),
|
|
370
|
-
line("", width),
|
|
371
|
-
line(color.bold("Why the token cannot be automated away"), width),
|
|
372
|
-
line("There is no OIDC between GitHub and Cloudflare, so something must carry", width),
|
|
373
|
-
line("a token across, and only a person can fetch one from the dashboard. It", width),
|
|
374
|
-
line("is asked for once, checked against the Cloudflare API, and stored on the", width),
|
|
375
|
-
line("repository — never on this machine.", width),
|
|
376
|
-
line("", width),
|
|
377
|
-
line("The repository is created WITHOUT pushing, then configured, then pushed.", width),
|
|
378
|
-
line("Pushing first would start a deploy against a repository that has no key", width),
|
|
379
|
-
line("and no token: a red run, for no reason but the wrong order.", width),
|
|
380
|
-
], width),
|
|
381
|
-
);
|
|
382
|
-
return 0;
|
|
383
|
-
}
|
package/src/deploy/secrets.ts
CHANGED
|
@@ -202,7 +202,12 @@ export async function decryptSecrets(project: Project): Promise<DecryptedSecrets
|
|
|
202
202
|
// The private key comes from the environment, which in the normal case is
|
|
203
203
|
// GitHub Actions injecting the repository secret. A laptop has no reason
|
|
204
204
|
// to hold it, so a missing one is explained rather than treated as a fault.
|
|
205
|
-
|
|
205
|
+
// The environment first — that is CI, where the repository variable is
|
|
206
|
+
// injected — then a key parked locally by `vc keys --init` or restored by
|
|
207
|
+
// `vc keys --restore`. Reading only the environment made a local deploy
|
|
208
|
+
// fail while the key sat in a file two lines away.
|
|
209
|
+
const { localPrivateKey } = await import("./keys");
|
|
210
|
+
const privateKey = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root) || "";
|
|
206
211
|
if (!privateKey) {
|
|
207
212
|
return {
|
|
208
213
|
error:
|
|
@@ -443,6 +448,27 @@ async function readValue(name: string, breaks?: string, where?: string): Promise
|
|
|
443
448
|
}
|
|
444
449
|
|
|
445
450
|
|
|
451
|
+
/**
|
|
452
|
+
* One value out of `.env.secrets`, by name.
|
|
453
|
+
*
|
|
454
|
+
* How the deploy gets its Cloudflare credentials without anyone having put
|
|
455
|
+
* them in the environment: they are ordinary secrets in the committed file,
|
|
456
|
+
* so the deploy reads them out of what was pushed. Returns null rather than
|
|
457
|
+
* throwing — a caller that cannot decrypt has an env var to fall back on,
|
|
458
|
+
* and a better error to give than this one.
|
|
459
|
+
*/
|
|
460
|
+
export async function secretValue(project: Project, name: string): Promise<string | null> {
|
|
461
|
+
const decrypted = await decryptSecrets(project);
|
|
462
|
+
if ("error" in decrypted) return null;
|
|
463
|
+
try {
|
|
464
|
+
const match = new RegExp(`^\\\\s*${name}\\\\s*=\\\\s*(.*)$`, "m").exec(readFileSync(decrypted.path, "utf8"));
|
|
465
|
+
const value = match?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
|
|
466
|
+
return value && value !== "unset" ? value : null;
|
|
467
|
+
} finally {
|
|
468
|
+
decrypted.cleanup();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
446
472
|
/**
|
|
447
473
|
* Encrypt one value into `.env.secrets`, in process.
|
|
448
474
|
*
|