@saastemly/voidcommerce 0.16.0 → 0.17.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 CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  runVoid,
22
22
  version,
23
23
  voidAppsIn
24
- } from "./index-x70qxh3m.js";
24
+ } from "./index-4jz1448c.js";
25
25
  import {
26
26
  LOCAL_KEY_FILE,
27
27
  PRIVATE_KEY_VAR,
@@ -32,6 +32,7 @@ import {
32
32
  declaredSecretNames,
33
33
  encryptInto,
34
34
  envSummary,
35
+ getVariable,
35
36
  ghAuth,
36
37
  ignoresKeyFile,
37
38
  initSecrets,
@@ -50,7 +51,7 @@ import {
50
51
  setVariable,
51
52
  variableNames,
52
53
  verifyCloudflareToken
53
- } from "./index-s8se0x2d.js";
54
+ } from "./index-negctpws.js";
54
55
  import {
55
56
  LAYOUTS,
56
57
  oneOrigin,
@@ -118,16 +119,23 @@ async function linkCommand(project, args) {
118
119
  } else {
119
120
  const parked = localPrivateKey(root);
120
121
  if (parked) {
121
- const sent = await setSecret(root, PRIVATE_KEY_VAR, parked);
122
+ const sent = await setVariable(root, PRIVATE_KEY_VAR, parked);
122
123
  if (!sent.ok) {
123
- p.cancel(`GitHub refused the secret: ${sent.error ?? "unknown error"}`);
124
+ p.cancel(`GitHub refused the variable: ${sent.error ?? "unknown error"}`);
124
125
  return 1;
125
126
  }
126
127
  const publicKey = await publicKeyFor(parked);
127
128
  if (publicKey)
128
129
  committedPublicKeyInto(root, publicKey);
129
- rmSync(join(root, LOCAL_KEY_FILE), { force: true });
130
- p.log.success(`${color.green("✓")} the key waiting in ${LOCAL_KEY_FILE} moved to GitHub, and the file deleted`);
130
+ const readBack = await getVariable(root, PRIVATE_KEY_VAR);
131
+ if (readBack !== parked) {
132
+ p.log.warn(`${color.yellow("!")} ${PRIVATE_KEY_VAR} was written to ${slug} but could not be read back,
133
+ so ${LOCAL_KEY_FILE} has been LEFT IN PLACE. It is currently the only copy
134
+ ` + " of the key that opens this shop — do not delete it until `vc keys --restore` works.");
135
+ } else {
136
+ rmSync(join(root, LOCAL_KEY_FILE), { force: true });
137
+ p.log.success(`${color.green("✓")} the key moved to ${slug} and ${LOCAL_KEY_FILE} deleted — verified readable, so \`vc keys --restore\` can bring it back`);
138
+ }
131
139
  } else {
132
140
  const made = await provisionKey(project);
133
141
  if (!made.ok) {
@@ -372,10 +380,15 @@ async function keysHelp() {
372
380
  line("A contributor with only a clone can rotate the Stripe key and cannot read", width),
373
381
  line("the one already there. Only the deploy decrypts.", width),
374
382
  line("", width),
375
- line("GitHub will not hand a secret back once set, so re-keying without a local", width),
376
- line("copy means entering the values again. That is the right trade: a key worth", width),
377
- line("rotating is a key that may have leaked, and leaked values need replacing", width),
378
- line("at the source anyway.", width)
383
+ line("The key is a repository VARIABLE, not a secret. GitHub secrets are", width),
384
+ line("write-only `gh secret` has no read command so a key kept there is", width),
385
+ line("gone the moment your local copy is, and every encrypted value with it.", width),
386
+ line("As a variable it can be read back, which is what makes a new machine or", width),
387
+ line("a second person possible: `vc keys --restore`.", width),
388
+ line("", width),
389
+ line("So anyone who can READ the repository can decrypt its secrets. Keep it", width),
390
+ line("private. That access list is also how you share: a collaborator can", width),
391
+ line("restore the key, and removing them removes their access.", width)
379
392
  ], width));
380
393
  return 0;
381
394
  }
@@ -66,6 +66,14 @@ export declare function setVariable(cwd: string, name: string, value: string): P
66
66
  ok: boolean;
67
67
  error?: string;
68
68
  }>;
69
+ /**
70
+ * A variable's VALUE — the thing a secret can never give back.
71
+ *
72
+ * `gh secret` has list, set and delete and no read, by design: a GitHub
73
+ * secret is write-only forever, even to its owner. A variable is not, and
74
+ * that single difference is why the encryption key lives in one.
75
+ */
76
+ export declare function getVariable(cwd: string, name: string): Promise<string | null>;
69
77
  export declare function variableNames(cwd: string): Promise<Set<string>>;
70
78
  /**
71
79
  * Ask Cloudflare whether a token is real, before it is stored anywhere.
@@ -24,19 +24,30 @@ import type { Project } from "../project";
24
24
  *
25
25
  * So the key is generated at random and handed to GitHub, which is where
26
26
  * the deploy runs and the one place a person who can push is already
27
- * authenticated. It is never written to disk. `gh secret set` takes it on
28
- * stdin, GitHub encrypts it, and no API can read it back — which is the
29
- * property that makes it a good home and, unavoidably, the property that
30
- * makes rotation a re-key rather than a re-encrypt.
27
+ * authenticated.
31
28
  *
32
- * ── Rotation, and why losing the key is survivable ───────────────────────
29
+ * ── Why it is a VARIABLE and not a secret ────────────────────────────────
33
30
  *
34
- * Nothing can read a GitHub secret back, so re-encrypting the existing
35
- * ciphertext needs a local copy of the old key. Usually there is none, and
36
- * that is fine: the only reason to rotate an encryption key is that it may
37
- * have leaked, and a key that may have leaked means the VALUES may have
38
- * leaked. Those must be replaced at Stripe and everywhere else regardless.
39
- * Re-entering them is not extra work it is the work.
31
+ * `gh secret` has list, set and delete and NO read: a GitHub secret is
32
+ * write-only forever, even to the person who set it. Keeping the key there
33
+ * built a one-way door once the local `.env.keys` was deleted the key
34
+ * existed nowhere a human could reach, so nobody could ever decrypt again,
35
+ * including the owner of the shop. CI could deploy, and that was the whole
36
+ * of it. No reading a value back to check it, no re-keying, no second
37
+ * machine, no colleague.
38
+ *
39
+ * A repository VARIABLE can be read back (`gh variable get`), so the key is
40
+ * recoverable by anyone with access to the repository — the owner, plus
41
+ * whoever they invite. That access list IS the answer to "a key several
42
+ * people can share": adding a colleague is adding a collaborator, and
43
+ * removing their access removes their ability to decrypt.
44
+ *
45
+ * The cost, stated plainly. A variable is shown in the repository's settings
46
+ * in the clear, and unlike a secret it is NOT masked in workflow logs
47
+ * automatically. So the generated workflow masks it with `::add-mask::` the
48
+ * moment it reads it, and the repository must be private. This is the right
49
+ * trade only because the alternative — a key nobody can ever read — is not a
50
+ * working system, it is a shop with one deploy left in it.
40
51
  */
41
52
  /**
42
53
  * Where a private key waits before the repository exists.
@@ -12,7 +12,7 @@ import {
12
12
  renderEnvProduction,
13
13
  renderEnvTs,
14
14
  unsetSecretNames
15
- } from "./index-s8se0x2d.js";
15
+ } from "./index-negctpws.js";
16
16
  import {
17
17
  MANIFEST_FILE,
18
18
  has,
@@ -1379,7 +1379,7 @@ import color from "picocolors";
1379
1379
  // package.json
1380
1380
  var package_default = {
1381
1381
  name: "@saastemly/voidcommerce",
1382
- version: "0.16.0",
1382
+ version: "0.17.0",
1383
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`.",
1384
1384
  type: "module",
1385
1385
  license: "MIT",
@@ -1720,7 +1720,7 @@ function renderDistWorkflow(manifest) {
1720
1720
  # The credentials come from the repository, set once by \`vc link\`:
1721
1721
  #
1722
1722
  # secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
1723
- # secrets.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE}
1723
+ # vars.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE} (a VARIABLE, so it can be read back)
1724
1724
  # vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
1725
1725
  #
1726
1726
  # Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
@@ -1817,16 +1817,25 @@ jobs:
1817
1817
  env:
1818
1818
  CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
1819
1819
  CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
1820
- ${PRIVATE_KEY_VAR}: \${{ secrets.${PRIVATE_KEY_VAR} }}
1820
+ ${PRIVATE_KEY_VAR}: \${{ vars.${PRIVATE_KEY_VAR} }}
1821
1821
  steps:
1822
1822
  - uses: actions/checkout@v6
1823
1823
  - uses: oven-sh/setup-bun@v2
1824
1824
 
1825
1825
  # Checked before anything is built, so a repository that was never
1826
1826
  # linked says so in five seconds rather than four minutes.
1827
+ #
1828
+ # And ${PRIVATE_KEY_VAR} is masked here, first thing. It is a
1829
+ # VARIABLE rather than a secret so that a human can read it back —
1830
+ # that is what makes a new laptop or a second person possible — but
1831
+ # GitHub only masks SECRETS in logs automatically. Registering it with
1832
+ # ::add-mask:: buys back the redaction without giving up recovery.
1827
1833
  - name: Are the credentials here?
1828
1834
  run: |
1829
1835
  set -euo pipefail
1836
+ if [ -n "\${${PRIVATE_KEY_VAR}:-}" ]; then
1837
+ echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
1838
+ fi
1830
1839
  missing=""
1831
1840
  [ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
1832
1841
  [ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
@@ -1898,7 +1907,7 @@ vc link
1898
1907
 
1899
1908
  | what | where | why |
1900
1909
  |---|---|---|
1901
- | \`${PRIVATE_KEY_VAR}\` | repository **secret** | opens \`${SECRETS_FILE}\`. Generated by \`vc link\`, never written to disk |
1910
+ | \`${PRIVATE_KEY_VAR}\` | repository **variable** | opens \`${SECRETS_FILE}\`. A variable, not a secret, so you can get it back — see below |
1902
1911
  | \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
1903
1912
  | \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
1904
1913
 
@@ -1946,6 +1955,24 @@ vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
1946
1955
  vc secrets # what is set, what is missing
1947
1956
  \`\`\`
1948
1957
 
1958
+ ### Getting the key back
1959
+
1960
+ \`\`\`sh
1961
+ vc keys --restore
1962
+ \`\`\`
1963
+
1964
+ \`${PRIVATE_KEY_VAR}\` is a repository **variable**, not a secret. GitHub
1965
+ secrets are write-only — \`gh secret\` has no read command — so a key kept
1966
+ there is gone the moment your local copy is, and with it every value in
1967
+ \`${SECRETS_FILE}\`. As a variable it can be read back, which is what makes a
1968
+ new laptop, a rebuild, or a second person possible at all.
1969
+
1970
+ **That means anyone who can read this repository can decrypt its secrets.**
1971
+ Keep it private. It is also the answer to sharing: a colleague who is a
1972
+ collaborator can run \`vc keys --restore\` and work; removing them removes
1973
+ their access. The deploy workflow masks the value with \`::add-mask::\` the
1974
+ moment it reads it, because GitHub does not mask variables on its own.
1975
+
1949
1976
  **This needs no credential.** dotenvx is asymmetric: encryption uses the public
1950
1977
  key committed at the top of the file, so anyone with a clone can set or rotate
1951
1978
  a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
@@ -1970,7 +1997,7 @@ The same thing, from a checkout, if CI is ever not the answer:
1970
1997
 
1971
1998
  \`\`\`sh
1972
1999
  export CLOUDFLARE_API_TOKEN=…
1973
- export ${PRIVATE_KEY_VAR}=… # only if you kept a copy
2000
+ export ${PRIVATE_KEY_VAR}=… # or run \`vc keys --restore\` first
1974
2001
  vc deploy --cloudflare --provision
1975
2002
  \`\`\`
1976
2003
 
@@ -103,6 +103,16 @@ async function setVariable(cwd, name, value) {
103
103
  return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split(`
104
104
  `).slice(-2).join(" ") };
105
105
  }
106
+ async function getVariable(cwd, name) {
107
+ const gh = findGh();
108
+ if (!gh)
109
+ return null;
110
+ const got = await run(gh, ["variable", "get", name, "--json", "value", "-q", ".value"], cwd);
111
+ if (got.code !== 0)
112
+ return null;
113
+ const value = got.out.trim();
114
+ return value.length > 0 ? value : null;
115
+ }
106
116
  async function variableNames(cwd) {
107
117
  const gh = findGh();
108
118
  if (!gh)
@@ -404,7 +414,7 @@ async function decryptSecrets(project) {
404
414
  }
405
415
  const expected = committedPublicKey(root);
406
416
  if (expected) {
407
- const { publicKeyFor } = await import("./keys-6neyajsv.js");
417
+ const { publicKeyFor } = await import("./keys-w65kbdp6.js");
408
418
  const derived = await publicKeyFor(privateKey);
409
419
  if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
410
420
  return {
@@ -749,7 +759,7 @@ async function publicKeyFor(privateKey) {
749
759
  async function keyState(project) {
750
760
  const publicKey = committedPublicKey(project.root);
751
761
  const slug = await repoSlug(project.root);
752
- const names = slug ? await secretNames(project.root) : new Set;
762
+ const names = slug ? await variableNames(project.root) : new Set;
753
763
  const localKey = process.env[PRIVATE_KEY_VAR] ?? localPrivateKey(project.root);
754
764
  let mismatch;
755
765
  if (localKey && publicKey) {
@@ -773,9 +783,9 @@ async function provisionKey(project) {
773
783
  const auth = await ghAuth(project.root);
774
784
  const slug = auth.ok ? await repoSlug(project.root) : null;
775
785
  if (slug) {
776
- const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
786
+ const sent = await setVariable(project.root, PRIVATE_KEY_VAR, pair.privateKey);
777
787
  if (!sent.ok)
778
- return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
788
+ return { ok: false, reason: `GitHub refused the variable: ${sent.error ?? "unknown error"}` };
779
789
  return { ok: true, publicKey: pair.publicKey, parked: false };
780
790
  }
781
791
  if (!ignoresKeyFile(project.root)) {
@@ -797,11 +807,65 @@ function ignoresKeyFile(root) {
797
807
  return readFileSync2(path, "utf8").split(`
798
808
  `).map((line) => line.trim()).some((line) => line === LOCAL_KEY_FILE || line === `/${LOCAL_KEY_FILE}` || line === ".env.keys*" || line === ".env*");
799
809
  }
810
+ async function restoreKey(project) {
811
+ const auth = await ghAuth(project.root);
812
+ if (!auth.ok) {
813
+ console.error(`
814
+ vc: ${auth.reason}
815
+ `);
816
+ return 1;
817
+ }
818
+ const slug = await repoSlug(project.root);
819
+ if (!slug) {
820
+ console.error(`
821
+ vc: this checkout has no GitHub repository, so there is nowhere to restore from.
822
+ `);
823
+ return 1;
824
+ }
825
+ const key = await getVariable(project.root, PRIVATE_KEY_VAR);
826
+ if (!key) {
827
+ console.error(`
828
+ vc: ${slug} has no ${PRIVATE_KEY_VAR} variable.
829
+ Either this shop was never linked, or the key is held somewhere else.
830
+ \`vc keys\` shows what this repository has.
831
+ `);
832
+ return 1;
833
+ }
834
+ const committed = committedPublicKey(project.root);
835
+ const derived = await publicKeyFor(key);
836
+ if (committed && derived && derived.toLowerCase() !== committed.toLowerCase()) {
837
+ console.error(`
838
+ vc: the key on ${slug} does not open this repository.
839
+ it derives: ${derived.slice(0, 16)}…
840
+ ` + ` ${SECRETS_FILE} needs: ${committed.slice(0, 16)}…
841
+
842
+ ` + ` Nothing was written. The repository has been re-keyed since that
843
+ variable was set, or the two belong to different shops.
844
+ `);
845
+ return 1;
846
+ }
847
+ if (!ignoresKeyFile(project.root)) {
848
+ console.error(`
849
+ vc: ${LOCAL_KEY_FILE} is not gitignored here, and this would write the key into it.
850
+ Add it to .gitignore first.
851
+ `);
852
+ return 1;
853
+ }
854
+ writeFileSync2(join3(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${key}"
855
+ `, { mode: 384 });
856
+ console.log(`
857
+ ${color2.green("✓")} key restored from ${slug} into ${LOCAL_KEY_FILE}
858
+ ` + color2.dim(` It opens ${SECRETS_FILE}. The file is gitignored; delete it when you are done.
859
+ `));
860
+ return 0;
861
+ }
800
862
  async function keysCommand(project, args) {
801
863
  if (args.includes("--rotate"))
802
864
  return rotate(project);
803
865
  if (args.includes("--init"))
804
866
  return initKey(project);
867
+ if (args.includes("--restore"))
868
+ return restoreKey(project);
805
869
  const state = await keyState(project);
806
870
  console.log(`
807
871
  Secrets are encrypted with a public key that is COMMITTED, and read with a`);
@@ -931,4 +995,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
931
995
  return 0;
932
996
  }
933
997
 
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 };
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 };
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  routeProblem,
53
53
  strictDependencies,
54
54
  upsertJsonc
55
- } from "./index-x70qxh3m.js";
55
+ } from "./index-4jz1448c.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-s8se0x2d.js";
63
+ } from "./index-negctpws.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-s8se0x2d.js";
11
+ } from "./index-negctpws.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.16.0",
3
+ "version": "0.17.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",
@@ -153,6 +153,22 @@ export async function setVariable(cwd: string, name: string, value: string): Pro
153
153
  return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split("\n").slice(-2).join(" ") };
154
154
  }
155
155
 
156
+ /**
157
+ * A variable's VALUE — the thing a secret can never give back.
158
+ *
159
+ * `gh secret` has list, set and delete and no read, by design: a GitHub
160
+ * secret is write-only forever, even to its owner. A variable is not, and
161
+ * that single difference is why the encryption key lives in one.
162
+ */
163
+ export async function getVariable(cwd: string, name: string): Promise<string | null> {
164
+ const gh = findGh();
165
+ if (!gh) return null;
166
+ const got = await run(gh, ["variable", "get", name, "--json", "value", "-q", ".value"], cwd);
167
+ if (got.code !== 0) return null;
168
+ const value = got.out.trim();
169
+ return value.length > 0 ? value : null;
170
+ }
171
+
156
172
  export async function variableNames(cwd: string): Promise<Set<string>> {
157
173
  const gh = findGh();
158
174
  if (!gh) return new Set();
@@ -66,11 +66,14 @@ export async function preflightCommand(args: string[]): Promise<number> {
66
66
  printPreflight(project, result, source);
67
67
  if (source !== "repository") return result.ready ? 0 : 1;
68
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.
69
+ // Is there a migration that was generated and never committed?
70
+ //
71
+ // This one has teeth only LOCALLY. A CI checkout is by definition clean,
72
+ // so `git status` there reports nothing and this passes vacuously the
73
+ // job that actually catches it in CI is the one that runs AFTER `vc dist`
74
+ // regenerates, because that is the moment a new migration can appear.
75
+ // Both are kept: they catch the same mistake at the two different moments
76
+ // it can be made.
74
77
  const pending = await uncommittedMigrations(project.root);
75
78
  if (pending.length > 0) {
76
79
  console.error(
@@ -198,10 +201,15 @@ export async function keysHelp(): Promise<number> {
198
201
  line("A contributor with only a clone can rotate the Stripe key and cannot read", width),
199
202
  line("the one already there. Only the deploy decrypts.", width),
200
203
  line("", width),
201
- line("GitHub will not hand a secret back once set, so re-keying without a local", width),
202
- line("copy means entering the values again. That is the right trade: a key worth", width),
203
- line("rotating is a key that may have leaked, and leaked values need replacing", width),
204
- line("at the source anyway.", width),
204
+ line("The key is a repository VARIABLE, not a secret. GitHub secrets are", width),
205
+ line("write-only `gh secret` has no read command so a key kept there is", width),
206
+ line("gone the moment your local copy is, and every encrypted value with it.", width),
207
+ line("As a variable it can be read back, which is what makes a new machine or", width),
208
+ line("a second person possible: `vc keys --restore`.", width),
209
+ line("", width),
210
+ line("So anyone who can READ the repository can decrypt its secrets. Keep it", width),
211
+ line("private. That access list is also how you share: a collaborator can", width),
212
+ line("restore the key, and removing them removes their access.", width),
205
213
  ], width),
206
214
  );
207
215
  return 0;
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import color from "picocolors";
4
4
  import type { Project } from "../project";
5
- import { ghAuth, repoSlug, secretNames, setSecret } from "./github";
5
+ import { getVariable, ghAuth, repoSlug, setVariable, variableNames } from "./github";
6
6
  import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, findDotenvx, run } from "./secrets";
7
7
 
8
8
  /**
@@ -30,19 +30,30 @@ import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, findDotenvx, run
30
30
  *
31
31
  * So the key is generated at random and handed to GitHub, which is where
32
32
  * the deploy runs and the one place a person who can push is already
33
- * authenticated. It is never written to disk. `gh secret set` takes it on
34
- * stdin, GitHub encrypts it, and no API can read it back — which is the
35
- * property that makes it a good home and, unavoidably, the property that
36
- * makes rotation a re-key rather than a re-encrypt.
33
+ * authenticated.
37
34
  *
38
- * ── Rotation, and why losing the key is survivable ───────────────────────
35
+ * ── Why it is a VARIABLE and not a secret ────────────────────────────────
39
36
  *
40
- * Nothing can read a GitHub secret back, so re-encrypting the existing
41
- * ciphertext needs a local copy of the old key. Usually there is none, and
42
- * that is fine: the only reason to rotate an encryption key is that it may
43
- * have leaked, and a key that may have leaked means the VALUES may have
44
- * leaked. Those must be replaced at Stripe and everywhere else regardless.
45
- * Re-entering them is not extra work it is the work.
37
+ * `gh secret` has list, set and delete and NO read: a GitHub secret is
38
+ * write-only forever, even to the person who set it. Keeping the key there
39
+ * built a one-way door once the local `.env.keys` was deleted the key
40
+ * existed nowhere a human could reach, so nobody could ever decrypt again,
41
+ * including the owner of the shop. CI could deploy, and that was the whole
42
+ * of it. No reading a value back to check it, no re-keying, no second
43
+ * machine, no colleague.
44
+ *
45
+ * A repository VARIABLE can be read back (`gh variable get`), so the key is
46
+ * recoverable by anyone with access to the repository — the owner, plus
47
+ * whoever they invite. That access list IS the answer to "a key several
48
+ * people can share": adding a colleague is adding a collaborator, and
49
+ * removing their access removes their ability to decrypt.
50
+ *
51
+ * The cost, stated plainly. A variable is shown in the repository's settings
52
+ * in the clear, and unlike a secret it is NOT masked in workflow logs
53
+ * automatically. So the generated workflow masks it with `::add-mask::` the
54
+ * moment it reads it, and the repository must be private. This is the right
55
+ * trade only because the alternative — a key nobody can ever read — is not a
56
+ * working system, it is a shop with one deploy left in it.
46
57
  */
47
58
 
48
59
  /**
@@ -108,7 +119,7 @@ export interface KeyState {
108
119
  export async function keyState(project: Project): Promise<KeyState> {
109
120
  const publicKey = committedPublicKey(project.root);
110
121
  const slug = await repoSlug(project.root);
111
- const names = slug ? await secretNames(project.root) : new Set<string>();
122
+ const names = slug ? await variableNames(project.root) : new Set<string>();
112
123
  const localKey = process.env[PRIVATE_KEY_VAR] ?? localPrivateKey(project.root);
113
124
 
114
125
  let mismatch: string | undefined;
@@ -144,8 +155,8 @@ export async function provisionKey(project: Project): Promise<{ ok: true; public
144
155
  const slug = auth.ok ? await repoSlug(project.root) : null;
145
156
 
146
157
  if (slug) {
147
- const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
148
- if (!sent.ok) return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
158
+ const sent = await setVariable(project.root, PRIVATE_KEY_VAR, pair.privateKey);
159
+ if (!sent.ok) return { ok: false, reason: `GitHub refused the variable: ${sent.error ?? "unknown error"}` };
149
160
  return { ok: true, publicKey: pair.publicKey, parked: false };
150
161
  }
151
162
 
@@ -175,10 +186,69 @@ export function ignoresKeyFile(root: string): boolean {
175
186
  .some((line) => line === LOCAL_KEY_FILE || line === `/${LOCAL_KEY_FILE}` || line === ".env.keys*" || line === ".env*");
176
187
  }
177
188
 
189
+ /**
190
+ * Bring the key back onto a machine that does not have it.
191
+ *
192
+ * This is the command that makes the whole arrangement survivable. A new
193
+ * laptop, a colleague joining, or simply wanting to read a value back: all
194
+ * of them were impossible while the key lived somewhere write-only. Here it
195
+ * is one command, and the only credential involved is being logged into
196
+ * `gh` as somebody the repository lets in.
197
+ */
198
+ async function restoreKey(project: Project): Promise<number> {
199
+ const auth = await ghAuth(project.root);
200
+ if (!auth.ok) {
201
+ console.error(`\nvc: ${auth.reason}\n`);
202
+ return 1;
203
+ }
204
+ const slug = await repoSlug(project.root);
205
+ if (!slug) {
206
+ console.error("\nvc: this checkout has no GitHub repository, so there is nowhere to restore from.\n");
207
+ return 1;
208
+ }
209
+
210
+ const key = await getVariable(project.root, PRIVATE_KEY_VAR);
211
+ if (!key) {
212
+ console.error(
213
+ `\nvc: ${slug} has no ${PRIVATE_KEY_VAR} variable.\n` +
214
+ ` Either this shop was never linked, or the key is held somewhere else.\n` +
215
+ ` \`vc keys\` shows what this repository has.\n`,
216
+ );
217
+ return 1;
218
+ }
219
+
220
+ // Check it opens THIS repository before writing it down. A key that
221
+ // belongs to another shop would decrypt nothing and confuse everything.
222
+ const committed = committedPublicKey(project.root);
223
+ const derived = await publicKeyFor(key);
224
+ if (committed && derived && derived.toLowerCase() !== committed.toLowerCase()) {
225
+ console.error(
226
+ `\nvc: the key on ${slug} does not open this repository.\n` +
227
+ ` it derives: ${derived.slice(0, 16)}…\n` +
228
+ ` ${SECRETS_FILE} needs: ${committed.slice(0, 16)}…\n\n` +
229
+ " Nothing was written. The repository has been re-keyed since that\n" +
230
+ " variable was set, or the two belong to different shops.\n",
231
+ );
232
+ return 1;
233
+ }
234
+
235
+ if (!ignoresKeyFile(project.root)) {
236
+ console.error(`\nvc: ${LOCAL_KEY_FILE} is not gitignored here, and this would write the key into it.\n Add it to .gitignore first.\n`);
237
+ return 1;
238
+ }
239
+ writeFileSync(join(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${key}"\n`, { mode: 0o600 });
240
+ console.log(
241
+ `\n${color.green("✓")} key restored from ${slug} into ${LOCAL_KEY_FILE}\n` +
242
+ color.dim(` It opens ${SECRETS_FILE}. The file is gitignored; delete it when you are done.\n`),
243
+ );
244
+ return 0;
245
+ }
246
+
178
247
  /** `vc keys` — where the key is, and what is missing. */
179
248
  export async function keysCommand(project: Project, args: string[]): Promise<number> {
180
249
  if (args.includes("--rotate")) return rotate(project);
181
250
  if (args.includes("--init")) return initKey(project);
251
+ if (args.includes("--restore")) return restoreKey(project);
182
252
 
183
253
  const state = await keyState(project);
184
254
  console.log(`\nSecrets are encrypted with a public key that is COMMITTED, and read with a`);
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import color from "picocolors";
4
4
  import { writeManifest } from "../manifest";
5
5
  import type { Project } from "../project";
6
- import { cloudflareAccounts, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
6
+ import { cloudflareAccounts, getVariable, ghAuth, repoSlug, secretNames, setSecret, setVariable, variableNames, verifyCloudflareToken } from "./github";
7
7
  import { LOCAL_KEY_FILE, keyState, localPrivateKey, provisionKey, publicKeyFor } from "./keys";
8
8
  import { PRIVATE_KEY_VAR, SECRETS_FILE, committedPublicKeyInto, declaredSecretNames, initSecrets } from "./secrets";
9
9
 
@@ -91,17 +91,31 @@ export async function linkCommand(project: Project, args: string[]): Promise<num
91
91
  // ADOPTED, not replaced: it may already have encrypted the whole shop.
92
92
  const parked = localPrivateKey(root);
93
93
  if (parked) {
94
- const sent = await setSecret(root, PRIVATE_KEY_VAR, parked);
94
+ const sent = await setVariable(root, PRIVATE_KEY_VAR, parked);
95
95
  if (!sent.ok) {
96
- p.cancel(`GitHub refused the secret: ${sent.error ?? "unknown error"}`);
96
+ p.cancel(`GitHub refused the variable: ${sent.error ?? "unknown error"}`);
97
97
  return 1;
98
98
  }
99
99
  const publicKey = await publicKeyFor(parked);
100
100
  if (publicKey) committedPublicKeyInto(root, publicKey);
101
- // Only now, once GitHub has it: deleting first would lose the key
102
- // outright if the upload failed.
103
- rmSync(join(root, LOCAL_KEY_FILE), { force: true });
104
- p.log.success(`${color.green("✓")} the key waiting in ${LOCAL_KEY_FILE} moved to GitHub, and the file deleted`);
101
+
102
+ // READ IT BACK before deleting the only other copy. "The write
103
+ // returned success" is not the same claim as "the key can be
104
+ // recovered", and it is the second one this file is about to bet
105
+ // the shop on. If it cannot be read, the local copy stays.
106
+ const readBack = await getVariable(root, PRIVATE_KEY_VAR);
107
+ if (readBack !== parked) {
108
+ p.log.warn(
109
+ `${color.yellow("!")} ${PRIVATE_KEY_VAR} was written to ${slug} but could not be read back,\n` +
110
+ ` so ${LOCAL_KEY_FILE} has been LEFT IN PLACE. It is currently the only copy\n` +
111
+ " of the key that opens this shop — do not delete it until `vc keys --restore` works.",
112
+ );
113
+ } else {
114
+ rmSync(join(root, LOCAL_KEY_FILE), { force: true });
115
+ p.log.success(
116
+ `${color.green("✓")} the key moved to ${slug} and ${LOCAL_KEY_FILE} deleted — verified readable, so \`vc keys --restore\` can bring it back`,
117
+ );
118
+ }
105
119
  } else {
106
120
  const made = await provisionKey(project);
107
121
  if (!made.ok) {
@@ -50,7 +50,7 @@ export function renderDistWorkflow(manifest: Manifest): string {
50
50
  # The credentials come from the repository, set once by \`vc link\`:
51
51
  #
52
52
  # secrets.CLOUDFLARE_API_TOKEN deploys, and creates D1 + the queue
53
- # secrets.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE}
53
+ # vars.${PRIVATE_KEY_VAR} opens ${SECRETS_FILE} (a VARIABLE, so it can be read back)
54
54
  # vars.CLOUDFLARE_ACCOUNT_ID which account (an id, not a credential)
55
55
  #
56
56
  # Nothing here needs a Cloudflare dashboard visit and nothing needs wrangler
@@ -147,16 +147,25 @@ jobs:
147
147
  env:
148
148
  CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
149
149
  CLOUDFLARE_ACCOUNT_ID: \${{ vars.CLOUDFLARE_ACCOUNT_ID }}
150
- ${PRIVATE_KEY_VAR}: \${{ secrets.${PRIVATE_KEY_VAR} }}
150
+ ${PRIVATE_KEY_VAR}: \${{ vars.${PRIVATE_KEY_VAR} }}
151
151
  steps:
152
152
  - uses: actions/checkout@v6
153
153
  - uses: oven-sh/setup-bun@v2
154
154
 
155
155
  # Checked before anything is built, so a repository that was never
156
156
  # linked says so in five seconds rather than four minutes.
157
+ #
158
+ # And ${PRIVATE_KEY_VAR} is masked here, first thing. It is a
159
+ # VARIABLE rather than a secret so that a human can read it back —
160
+ # that is what makes a new laptop or a second person possible — but
161
+ # GitHub only masks SECRETS in logs automatically. Registering it with
162
+ # ::add-mask:: buys back the redaction without giving up recovery.
157
163
  - name: Are the credentials here?
158
164
  run: |
159
165
  set -euo pipefail
166
+ if [ -n "\${${PRIVATE_KEY_VAR}:-}" ]; then
167
+ echo "::add-mask::\${${PRIVATE_KEY_VAR}}"
168
+ fi
160
169
  missing=""
161
170
  [ -n "\${CLOUDFLARE_API_TOKEN:-}" ] || missing="$missing CLOUDFLARE_API_TOKEN"
162
171
  [ -n "\${${PRIVATE_KEY_VAR}:-}" ] || missing="$missing ${PRIVATE_KEY_VAR}"
@@ -230,7 +239,7 @@ vc link
230
239
 
231
240
  | what | where | why |
232
241
  |---|---|---|
233
- | \`${PRIVATE_KEY_VAR}\` | repository **secret** | opens \`${SECRETS_FILE}\`. Generated by \`vc link\`, never written to disk |
242
+ | \`${PRIVATE_KEY_VAR}\` | repository **variable** | opens \`${SECRETS_FILE}\`. A variable, not a secret, so you can get it back — see below |
234
243
  | \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
235
244
  | \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
236
245
 
@@ -278,6 +287,24 @@ vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
278
287
  vc secrets # what is set, what is missing
279
288
  \`\`\`
280
289
 
290
+ ### Getting the key back
291
+
292
+ \`\`\`sh
293
+ vc keys --restore
294
+ \`\`\`
295
+
296
+ \`${PRIVATE_KEY_VAR}\` is a repository **variable**, not a secret. GitHub
297
+ secrets are write-only — \`gh secret\` has no read command — so a key kept
298
+ there is gone the moment your local copy is, and with it every value in
299
+ \`${SECRETS_FILE}\`. As a variable it can be read back, which is what makes a
300
+ new laptop, a rebuild, or a second person possible at all.
301
+
302
+ **That means anyone who can read this repository can decrypt its secrets.**
303
+ Keep it private. It is also the answer to sharing: a colleague who is a
304
+ collaborator can run \`vc keys --restore\` and work; removing them removes
305
+ their access. The deploy workflow masks the value with \`::add-mask::\` the
306
+ moment it reads it, because GitHub does not mask variables on its own.
307
+
281
308
  **This needs no credential.** dotenvx is asymmetric: encryption uses the public
282
309
  key committed at the top of the file, so anyone with a clone can set or rotate
283
310
  a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
@@ -302,7 +329,7 @@ The same thing, from a checkout, if CI is ever not the answer:
302
329
 
303
330
  \`\`\`sh
304
331
  export CLOUDFLARE_API_TOKEN=…
305
- export ${PRIVATE_KEY_VAR}=… # only if you kept a copy
332
+ export ${PRIVATE_KEY_VAR}=… # or run \`vc keys --restore\` first
306
333
  vc deploy --cloudflare --provision
307
334
  \`\`\`
308
335