@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.
- package/dist/cli.js +230 -81
- package/dist/deploy/github.d.ts +8 -0
- package/dist/deploy/index.d.ts +3 -0
- package/dist/deploy/keys.d.ts +22 -11
- package/dist/deploy/publish.d.ts +32 -0
- package/dist/{index-w0c96dxv.js → index-3j6jtjmk.js} +44 -10
- package/dist/{index-s8se0x2d.js → index-khzk6a9z.js} +69 -5
- package/dist/index.js +2 -2
- package/dist/{keys-6neyajsv.js → keys-fn6wbv40.js} +1 -1
- package/package.json +1 -1
- package/src/cli.ts +3 -0
- package/src/deploy/github.ts +16 -0
- package/src/deploy/index.ts +49 -4
- package/src/deploy/keys.ts +85 -15
- package/src/deploy/link.ts +21 -7
- package/src/deploy/publish.ts +145 -0
- package/src/generate/ci.ts +42 -8
package/dist/deploy/index.d.ts
CHANGED
|
@@ -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>;
|
package/dist/deploy/keys.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
* ──
|
|
29
|
+
* ── Why it is a VARIABLE and not a secret ────────────────────────────────
|
|
33
30
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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-
|
|
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.
|
|
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
|
-
#
|
|
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}: \${{
|
|
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,
|
|
1899
|
+
## Once, to start
|
|
1891
1900
|
|
|
1892
1901
|
\`\`\`sh
|
|
1893
|
-
|
|
1894
|
-
vc link
|
|
1902
|
+
vc publish
|
|
1895
1903
|
\`\`\`
|
|
1896
1904
|
|
|
1897
|
-
|
|
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 **
|
|
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}=… #
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
63
|
+
} from "./index-khzk6a9z.js";
|
|
64
64
|
import {
|
|
65
65
|
LAYOUTS,
|
|
66
66
|
MANIFEST_FILE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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") },
|
package/src/deploy/github.ts
CHANGED
|
@@ -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();
|
package/src/deploy/index.ts
CHANGED
|
@@ -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("
|
|
205
|
-
line("
|
|
206
|
-
line("
|
|
207
|
-
line("
|
|
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
|
+
}
|
package/src/deploy/keys.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
* ──
|
|
35
|
+
* ── Why it is a VARIABLE and not a secret ────────────────────────────────
|
|
39
36
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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
|
|
148
|
-
if (!sent.ok) return { ok: false, reason: `GitHub refused the
|
|
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`);
|