@saastemly/voidcommerce 0.17.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.
@@ -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 privateKey = process.env[PRIVATE_KEY_VAR];
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
- ` + ` That is the normal state on a laptop: the key lives in GitHub Actions and
411
- ` + ` only the deploy uses it. Push, and the workflow does this step.
412
- ` + ` To deploy by hand anyway, export the key for one command.`
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-w65kbdp6.js");
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, ghAuth, repoSlug, setSecret, secretNames, setVariable, getVariable, 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 };
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-negctpws.js";
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.17.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
- # The credentials come from the repository, set once by \`vc link\`:
1721
+ # ONE thing is configured outside the repository:
1721
1722
  #
1722
- # secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
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
- # Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
1727
- # on your machine. See DEPLOY.md.
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
- CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
1819
- CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
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: Are the credentials here?
1833
+ - name: Is the key here?
1834
1834
  run: |
1835
1835
  set -euo pipefail
1836
- if [ -n "\${${PRIVATE_KEY_VAR}:-}" ]; then
1837
- echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
1838
- fi
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
 
@@ -1896,49 +1892,29 @@ You do not need wrangler on your machine, and you do not need to be logged
1896
1892
  into it. You do not need to open the Cloudflare dashboard after the one step
1897
1893
  below.
1898
1894
 
1899
- ## Once, before the first push
1895
+ ## Once, to start
1900
1896
 
1901
1897
  \`\`\`sh
1902
- gh repo create --source=. --private --push # if there is no repo yet
1903
- vc link
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
1904
1902
  \`\`\`
1905
1903
 
1906
- \`vc link\` does three things and stores all of them on the GitHub repository:
1907
-
1908
- | what | where | why |
1909
- |---|---|---|
1910
- | \`${PRIVATE_KEY_VAR}\` | repository **variable** | opens \`${SECRETS_FILE}\`. A variable, not a secret, so you can get it back — see below |
1911
- | \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
1912
- | \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
1913
-
1914
- It will ask you to paste a Cloudflare API token. That is the only manual step
1915
- in the whole setup, and it is worth saying exactly why it cannot be removed:
1916
-
1917
- > **GitHub cannot mint a Cloudflare credential.** There is no OIDC or workload
1918
- > identity federation between them the feature request has been open since
1919
- > 2025 with no commitment, and Cloudflare's own CI guidance still says to store
1920
- > a token in your CI provider's secrets. The Cloudflare GitHub App does not
1921
- > help either: it grants *Cloudflare* access to your *repository*, not the
1922
- > reverse. Something has to authorise creating a database in your account, and
1923
- > only Cloudflare can issue that authorisation.
1924
-
1925
- Create the token at **My Profile → API Tokens → Create Token → Custom token**:
1926
-
1927
- | scope | permission | for |
1928
- |---|---|---|
1929
- | Account | Workers Scripts: Edit | deploying the worker |
1930
- | Account | D1: Edit | creating the database, applying migrations |
1931
- | Account | Queues: Edit | the order queue |
1932
- | Account | Workers KV Storage: Edit | sessions and caches |
1933
- | Account | Workers R2 Storage: Edit | product images |
1934
- | Account | Account Settings: Read | confirming which account |
1935
- | Zone | Workers Routes: Edit (${zone2}) | answering on your domain |
1936
- | Zone | Zone: Read (${zone2}) | confirming the zone is on this account |
1937
- | Zone | DNS: Read (${zone2}) | noticing a hostname that already answers |
1938
- | User | User Details: Read, Memberships: Read | wrangler asks at startup |
1939
-
1940
- The token Cloudflare generates for its own Workers Builds will **not** do: it
1941
- 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**:
1942
1918
 
1943
1919
  ### The zone
1944
1920
 
@@ -3293,6 +3269,18 @@ async function deployCloudflare(project, opts) {
3293
3269
  const configPath = join8(app, "wrangler.jsonc");
3294
3270
  if (!existsSync8(configPath))
3295
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
+ }
3296
3284
  const bin = findWrangler(app);
3297
3285
  if (!bin)
3298
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-4jz1448c.js";
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-negctpws.js";
63
+ } from "./index-68m1eg3f.js";
64
64
  import {
65
65
  LAYOUTS,
66
66
  MANIFEST_FILE,
@@ -8,7 +8,7 @@ import {
8
8
  localPrivateKey,
9
9
  provisionKey,
10
10
  publicKeyFor
11
- } from "./index-negctpws.js";
11
+ } from "./index-68m1eg3f.js";
12
12
  import"./index-30y19qz5.js";
13
13
  import"./index-xyjhy6kp.js";
14
14
  import"./index-0v6na3yp.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.17.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,8 +4,6 @@ import {
4
4
  guardCommand,
5
5
  keysCliCommand,
6
6
  keysHelp,
7
- linkCliCommand,
8
- linkHelp,
9
7
  preflightCommand,
10
8
  preflightHelp,
11
9
  secretsCliCommand,
@@ -52,7 +50,6 @@ export const EXTENDED: Record<string, Extended> = {
52
50
  preflight: { run: preflightCommand, help: preflightHelp },
53
51
  secrets: { run: secretsCliCommand, help: secretsHelp },
54
52
  keys: { run: keysCliCommand, help: keysHelp },
55
- link: { run: linkCliCommand, help: linkHelp },
56
53
  // Called by the generated pre-commit hook; exit code is the interface.
57
54
  guard: { run: guardCommand, help: async () => guardCommand() },
58
55
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
@@ -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`.");
@@ -8,7 +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
11
 
13
12
  /**
14
13
  * `vc deploy` extends `void deploy`: vc's preflight first, then void's
@@ -307,37 +306,7 @@ export async function guardCommand(): Promise<number> {
307
306
  return 0;
308
307
  }
309
308
 
310
- /** `vc link` — GitHub holds the credentials; this is what puts them there. */
311
- export async function linkCliCommand(args: string[]): Promise<number> {
312
- const project = await findProject();
313
- if (!project) {
314
- console.error("vc: no voidcommerce.json here.");
315
- return 1;
316
- }
317
- return linkCommand(project, args);
318
- }
319
309
 
320
- export async function linkHelp(): Promise<number> {
321
- const width = 80;
322
- console.log(
323
- box("vc link", [
324
- line("Put this shop's credentials on its GitHub repository, once, so that", width),
325
- line("every deploy after this is a `git push`.", width),
326
- line("", width),
327
- ...row("vc link", "store the encryption key and the Cloudflare token on the repository", width, 2),
328
- ...row("vc link --force", "replace what is already there", width, 2),
329
- line("", width),
330
- line(color.bold("What it stores, and where"), width),
331
- ...row(PRIVATE_KEY_VAR, "a repository SECRET. A key already waiting locally is MOVED here and the local copy deleted", width, 2),
332
- ...row("CLOUDFLARE_API_TOKEN", "a repository SECRET, checked against the Cloudflare API before it is stored", width, 2),
333
- ...row("CLOUDFLARE_ACCOUNT_ID", "a repository VARIABLE — an identifier, not a credential", width, 2),
334
- line("", width),
335
- line(color.bold("Why one token still has to be typed"), width),
336
- line("GitHub cannot mint a Cloudflare credential. There is no OIDC federation", width),
337
- line("between them, and the Cloudflare GitHub App runs the other way: it grants", width),
338
- line("Cloudflare access to your repository, not your repository access to", width),
339
- line("Cloudflare. So it is typed once, here, and never stored on this machine.", width),
340
- ], width),
341
- );
342
- return 0;
343
- }
310
+
311
+
312
+
@@ -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
- const privateKey = process.env[PRIVATE_KEY_VAR];
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
  *
@@ -47,14 +47,13 @@ export function renderDistWorkflow(manifest: Manifest): string {
47
47
  # Cloudflare. The check runs FIRST and on its own: nothing is built, created
48
48
  # or replaced for a shop that is not ready.
49
49
  #
50
- # The credentials come from the repository, set once by \`vc link\`:
50
+ # ONE thing is configured outside the repository:
51
51
  #
52
- # secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
53
- # vars.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE} (a VARIABLE, so it can be read back)
54
- # vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
52
+ # vars.${PRIVATE_KEY_VAR} the key that opens ${SECRETS_FILE}
55
53
  #
56
- # Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
57
- # on your machine. See DEPLOY.md.
54
+ # Everything else including the Cloudflare token and account id — is an
55
+ # ordinary secret in that file, encrypted and committed, so it travels with
56
+ # the push. See DEPLOY.md.
58
57
  on:
59
58
  push:
60
59
  branches: [main, master]
@@ -145,8 +144,8 @@ jobs:
145
144
  name: production
146
145
  url: https://${manifest.shop.domain}
147
146
  env:
148
- CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
149
- CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
147
+ # The only thing this deploy is given. The Cloudflare credentials are
148
+ # read out of .env.secrets, which this key opens.
150
149
  ${PRIVATE_KEY_VAR}: \${{ vars.${PRIVATE_KEY_VAR} }}
151
150
  steps:
152
151
  - uses: actions/checkout@v6
@@ -160,19 +159,15 @@ jobs:
160
159
  # that is what makes a new laptop or a second person possible — but
161
160
  # GitHub only masks SECRETS in logs automatically. Registering it with
162
161
  # ::add-mask:: buys back the redaction without giving up recovery.
163
- - name: Are the credentials here?
162
+ - name: Is the key here?
164
163
  run: |
165
164
  set -euo pipefail
166
- if [ -n "\${${PRIVATE_KEY_VAR}:-}" ]; then
167
- echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
168
- fi
169
- missing=""
170
- [ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
171
- [ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
172
- if [ -n "$missing" ]; then
173
- echo "::error::this repository has no$missing. Run 'vc link' once, from a checkout."
165
+ if [ -z "\${${PRIVATE_KEY_VAR}:-}" ]; then
166
+ echo "::error::this repository has no ${PRIVATE_KEY_VAR} variable, so .env.secrets cannot be opened and nothing can deploy."
167
+ echo "Set it once, from a checkout: gh variable set ${PRIVATE_KEY_VAR}"
174
168
  exit 1
175
169
  fi
170
+ echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
176
171
 
177
172
  - run: bun install --frozen-lockfile
178
173
 
@@ -228,49 +223,29 @@ You do not need wrangler on your machine, and you do not need to be logged
228
223
  into it. You do not need to open the Cloudflare dashboard after the one step
229
224
  below.
230
225
 
231
- ## Once, before the first push
226
+ ## Once, to start
232
227
 
233
228
  \`\`\`sh
234
- gh repo create --source=. --private --push # if there is no repo yet
235
- vc link
229
+ gh repo create --source=. --private # or make it in the browser
230
+ vc secrets --init # writes the key onto the repository
231
+ vc secrets set CLOUDFLARE_API_TOKEN # and every other key it lists
232
+ git push -u origin main
236
233
  \`\`\`
237
234
 
238
- \`vc link\` does three things and stores all of them on the GitHub repository:
239
-
240
- | what | where | why |
241
- |---|---|---|
242
- | \`${PRIVATE_KEY_VAR}\` | repository **variable** | opens \`${SECRETS_FILE}\`. A variable, not a secret, so you can get it back — see below |
243
- | \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
244
- | \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
245
-
246
- It will ask you to paste a Cloudflare API token. That is the only manual step
247
- in the whole setup, and it is worth saying exactly why it cannot be removed:
248
-
249
- > **GitHub cannot mint a Cloudflare credential.** There is no OIDC or workload
250
- > identity federation between them the feature request has been open since
251
- > 2025 with no commitment, and Cloudflare's own CI guidance still says to store
252
- > a token in your CI provider's secrets. The Cloudflare GitHub App does not
253
- > help either: it grants *Cloudflare* access to your *repository*, not the
254
- > reverse. Something has to authorise creating a database in your account, and
255
- > only Cloudflare can issue that authorisation.
256
-
257
- Create the token at **My Profile → API Tokens → Create Token → Custom token**:
258
-
259
- | scope | permission | for |
260
- |---|---|---|
261
- | Account | Workers Scripts: Edit | deploying the worker |
262
- | Account | D1: Edit | creating the database, applying migrations |
263
- | Account | Queues: Edit | the order queue |
264
- | Account | Workers KV Storage: Edit | sessions and caches |
265
- | Account | Workers R2 Storage: Edit | product images |
266
- | Account | Account Settings: Read | confirming which account |
267
- | Zone | Workers Routes: Edit (${zone}) | answering on your domain |
268
- | Zone | Zone: Read (${zone}) | confirming the zone is on this account |
269
- | Zone | DNS: Read (${zone}) | noticing a hostname that already answers |
270
- | User | User Details: Read, Memberships: Read | wrangler asks at startup |
271
-
272
- The token Cloudflare generates for its own Workers Builds will **not** do: it
273
- has no D1 and no Queues permission, so it cannot create this shop's database.
235
+ That push is the deploy, and every push after it.
236
+
237
+ **Exactly one thing lives outside the repository**: \`${PRIVATE_KEY_VAR}\`, a
238
+ repository variable holding the key that opens \`${SECRETS_FILE}\`. It is set
239
+ for you the moment the key is made, as long as the repository already exists
240
+ which is why the repository comes first above.
241
+
242
+ Everything else, including the Cloudflare token and account id, is an
243
+ ordinary secret in \`${SECRETS_FILE}\`: encrypted, committed, and read by the
244
+ deploy out of what you pushed.
245
+
246
+ The token itself has to be fetched by a person, once, because there is no
247
+ OIDC between GitHub and Cloudflare and nothing else can issue one. Create it
248
+ at **My Profile API Tokens Create Token Custom token**:
274
249
 
275
250
  ### The zone
276
251
 
@@ -13,6 +13,28 @@ import { type Manifest, envKeysOf, has, hasFrontend, isApex, zone , oneOrigin} f
13
13
 
14
14
  /** Keys every shop has, whatever else was chosen. */
15
15
  const BASE: EnvKey[] = [
16
+ /**
17
+ * The Cloudflare credentials are ORDINARY SECRETS, deliberately.
18
+ *
19
+ * They used to be set on the GitHub repository by a `vc link` step, which
20
+ * existed only because they were treated as special. They are not: they
21
+ * are two more values this shop needs, they belong in `.env.secrets` with
22
+ * everything else, and they are set the same way — `vc secrets set`.
23
+ *
24
+ * That leaves exactly ONE thing that has to be configured outside the
25
+ * repository: the key that opens `.env.secrets`. Everything else, the
26
+ * deploy reads out of what was pushed.
27
+ */
28
+ {
29
+ key: "CLOUDFLARE_API_TOKEN",
30
+ breaks: "nothing deploys: no worker, no database, no migrations",
31
+ where: "dash.cloudflare.com/profile/api-tokens → Create Token → Custom token",
32
+ },
33
+ {
34
+ key: "CLOUDFLARE_ACCOUNT_ID",
35
+ breaks: "wrangler cannot tell which account to deploy into, and refuses rather than guessing",
36
+ where: "the Cloudflare dashboard sidebar, or `vc secrets set` it after the first provision",
37
+ },
16
38
  {
17
39
  key: "SHOP_DOMAIN",
18
40
  breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
@@ -1,2 +0,0 @@
1
- import type { Project } from "../project";
2
- export declare function linkCommand(project: Project, args: string[]): Promise<number>;