@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.
@@ -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>;
@@ -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 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-fn6wbv40.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, findGh, run, 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 run1, 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-khzk6a9z.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.18.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
 
@@ -1899,53 +1895,26 @@ below.
1899
1895
  ## Once, to start
1900
1896
 
1901
1897
  \`\`\`sh
1902
- vc publish
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
- One command: it creates the repository, gives it what the deploy needs, and
1906
- pushes — and the push is the deploy. After it, \`git push\` is the whole loop.
1907
-
1908
- Two of the three things it sets need nothing from you. The encryption key vc
1909
- already holds; the account id is in \`voidcommerce.json\`. It asks only for a
1910
- Cloudflare API token, because there is no OIDC between GitHub and Cloudflare
1911
- and only a person can fetch one from the dashboard.
1912
-
1913
- It stores three things on the GitHub repository:
1914
-
1915
- | what | where | why |
1916
- |---|---|---|
1917
- | \`${PRIVATE_KEY_VAR}\` | repository **variable** | opens \`${SECRETS_FILE}\`. A variable, not a secret, so you can get it back — see below |
1918
- | \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
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-3j6jtjmk.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-khzk6a9z.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-khzk6a9z.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.18.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") },
@@ -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,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
- }
@@ -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
  *