@saastemly/voidcommerce 0.16.1 → 0.18.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.
@@ -46,3 +46,6 @@ export declare function guardCommand(): Promise<number>;
46
46
  /** `vc link` — GitHub holds the credentials; this is what puts them there. */
47
47
  export declare function linkCliCommand(args: string[]): Promise<number>;
48
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>;
@@ -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.
@@ -0,0 +1,32 @@
1
+ import type { Project } from "../project";
2
+ /**
3
+ * `vc publish` — make the repository, give it what it needs, push.
4
+ *
5
+ * ── Why this exists ──────────────────────────────────────────────────────
6
+ *
7
+ * The goal was always "publishing to GitHub is the deploy". `vc link` sat in
8
+ * front of that as a separate step, and it is worth being exact about what
9
+ * it was actually for, because two of its three jobs never needed a person:
10
+ *
11
+ * DOTENV_PRIVATE_KEY_SECRETS vc already holds it — automatic
12
+ * CLOUDFLARE_ACCOUNT_ID it is in the manifest — automatic
13
+ * CLOUDFLARE_API_TOKEN exists only in Cloudflare's dashboard
14
+ *
15
+ * Only the third needs a human, and it cannot be removed: there is no OIDC
16
+ * or workload identity federation from GitHub to the Cloudflare API, so
17
+ * something has to carry a token across, and only a person can fetch one.
18
+ * Everything else is ceremony that a command can do.
19
+ *
20
+ * So this is the one command. It creates the repository, sets all three,
21
+ * and pushes — and the push is what deploys. After it, `git push` is the
22
+ * whole loop forever, and `vc` is only needed to read or change a secret.
23
+ *
24
+ * ── The order matters ────────────────────────────────────────────────────
25
+ *
26
+ * The repository is created WITHOUT pushing, the credentials go on, and only
27
+ * then does the push happen. `gh repo create --push` would put main there
28
+ * first, which starts a deploy against a repository that has no key and no
29
+ * token — a red run, an email, and a shop that did not deploy, for no
30
+ * reason other than doing two things in the wrong order.
31
+ */
32
+ export declare function publishCommand(project: Project, args: string[]): Promise<number>;
@@ -12,7 +12,7 @@ import {
12
12
  renderEnvProduction,
13
13
  renderEnvTs,
14
14
  unsetSecretNames
15
- } from "./index-s8se0x2d.js";
15
+ } from "./index-khzk6a9z.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.1",
1382
+ version: "0.18.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}"
@@ -1887,18 +1896,25 @@ You do not need wrangler on your machine, and you do not need to be logged
1887
1896
  into it. You do not need to open the Cloudflare dashboard after the one step
1888
1897
  below.
1889
1898
 
1890
- ## Once, before the first push
1899
+ ## Once, to start
1891
1900
 
1892
1901
  \`\`\`sh
1893
- gh repo create --source=. --private --push # if there is no repo yet
1894
- vc link
1902
+ vc publish
1895
1903
  \`\`\`
1896
1904
 
1897
- \`vc link\` does three things and stores all of them on the GitHub repository:
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:
1898
1914
 
1899
1915
  | what | where | why |
1900
1916
  |---|---|---|
1901
- | \`${PRIVATE_KEY_VAR}\` | repository **secret** | opens \`${SECRETS_FILE}\`. Generated by \`vc link\`, never written to disk |
1917
+ | \`${PRIVATE_KEY_VAR}\` | repository **variable** | opens \`${SECRETS_FILE}\`. A variable, not a secret, so you can get it back — see below |
1902
1918
  | \`CLOUDFLARE_API_TOKEN\` | repository **secret** | deploys, and creates D1 and the queue |
1903
1919
  | \`CLOUDFLARE_ACCOUNT_ID\` | repository **variable** | which account. An identifier, not a credential |
1904
1920
 
@@ -1946,6 +1962,24 @@ vc secrets set STRIPE_SECRET_KEY # prompts; never touches your shell history
1946
1962
  vc secrets # what is set, what is missing
1947
1963
  \`\`\`
1948
1964
 
1965
+ ### Getting the key back
1966
+
1967
+ \`\`\`sh
1968
+ vc keys --restore
1969
+ \`\`\`
1970
+
1971
+ \`${PRIVATE_KEY_VAR}\` is a repository **variable**, not a secret. GitHub
1972
+ secrets are write-only — \`gh secret\` has no read command — so a key kept
1973
+ there is gone the moment your local copy is, and with it every value in
1974
+ \`${SECRETS_FILE}\`. As a variable it can be read back, which is what makes a
1975
+ new laptop, a rebuild, or a second person possible at all.
1976
+
1977
+ **That means anyone who can read this repository can decrypt its secrets.**
1978
+ Keep it private. It is also the answer to sharing: a colleague who is a
1979
+ collaborator can run \`vc keys --restore\` and work; removing them removes
1980
+ their access. The deploy workflow masks the value with \`::add-mask::\` the
1981
+ moment it reads it, because GitHub does not mask variables on its own.
1982
+
1949
1983
  **This needs no credential.** dotenvx is asymmetric: encryption uses the public
1950
1984
  key committed at the top of the file, so anyone with a clone can set or rotate
1951
1985
  a secret. Nobody with a clone can read one. Only the deploy decrypts, with the
@@ -1970,7 +2004,7 @@ The same thing, from a checkout, if CI is ever not the answer:
1970
2004
 
1971
2005
  \`\`\`sh
1972
2006
  export CLOUDFLARE_API_TOKEN=…
1973
- export ${PRIVATE_KEY_VAR}=… # only if you kept a copy
2007
+ export ${PRIVATE_KEY_VAR}=… # or run \`vc keys --restore\` first
1974
2008
  vc deploy --cloudflare --provision
1975
2009
  \`\`\`
1976
2010
 
@@ -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-fn6wbv40.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, 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 };
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  routeProblem,
53
53
  strictDependencies,
54
54
  upsertJsonc
55
- } from "./index-w0c96dxv.js";
55
+ } from "./index-3j6jtjmk.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-khzk6a9z.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-khzk6a9z.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.1",
3
+ "version": "0.18.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
@@ -6,6 +6,8 @@ import {
6
6
  keysHelp,
7
7
  linkCliCommand,
8
8
  linkHelp,
9
+ publishCliCommand,
10
+ publishHelp,
9
11
  preflightCommand,
10
12
  preflightHelp,
11
13
  secretsCliCommand,
@@ -53,6 +55,7 @@ export const EXTENDED: Record<string, Extended> = {
53
55
  secrets: { run: secretsCliCommand, help: secretsHelp },
54
56
  keys: { run: keysCliCommand, help: keysHelp },
55
57
  link: { run: linkCliCommand, help: linkHelp },
58
+ publish: { run: publishCliCommand, help: publishHelp },
56
59
  // Called by the generated pre-commit hook; exit code is the interface.
57
60
  guard: { run: guardCommand, help: async () => guardCommand() },
58
61
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
@@ -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();
@@ -9,6 +9,7 @@ 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
11
  import { linkCommand } from "./link";
12
+ import { publishCommand } from "./publish";
12
13
 
13
14
  /**
14
15
  * `vc deploy` extends `void deploy`: vc's preflight first, then void's
@@ -201,10 +202,15 @@ export async function keysHelp(): Promise<number> {
201
202
  line("A contributor with only a clone can rotate the Stripe key and cannot read", width),
202
203
  line("the one already there. Only the deploy decrypts.", width),
203
204
  line("", width),
204
- line("GitHub will not hand a secret back once set, so re-keying without a local", width),
205
- line("copy means entering the values again. That is the right trade: a key worth", width),
206
- line("rotating is a key that may have leaked, and leaked values need replacing", width),
207
- line("at the source anyway.", width),
205
+ line("The key is a repository VARIABLE, not a secret. GitHub secrets are", width),
206
+ line("write-only `gh secret` has no read command so a key kept there is", width),
207
+ line("gone the moment your local copy is, and every encrypted value with it.", width),
208
+ line("As a variable it can be read back, which is what makes a new machine or", width),
209
+ line("a second person possible: `vc keys --restore`.", width),
210
+ line("", width),
211
+ line("So anyone who can READ the repository can decrypt its secrets. Keep it", width),
212
+ line("private. That access list is also how you share: a collaborator can", width),
213
+ line("restore the key, and removing them removes their access.", width),
208
214
  ], width),
209
215
  );
210
216
  return 0;
@@ -336,3 +342,42 @@ export async function linkHelp(): Promise<number> {
336
342
  );
337
343
  return 0;
338
344
  }
345
+
346
+
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
+
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
+ }
@@ -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`);