@saastemly/voidcommerce 0.4.0 → 0.5.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/index.js CHANGED
@@ -9,10 +9,8 @@ import {
9
9
  MIGRATIONS_README,
10
10
  STRICT_APP,
11
11
  VOID_VERSION,
12
- allEnvKeys,
13
12
  deployCloudflare,
14
13
  distCommand,
15
- envSummary,
16
14
  findProject,
17
15
  findWrangler,
18
16
  finishStrict,
@@ -34,10 +32,6 @@ import {
34
32
  renderDeployReadme,
35
33
  renderDistWorkflow,
36
34
  renderDomainTs,
37
- renderEnvExample,
38
- renderEnvLocal,
39
- renderEnvProduction,
40
- renderEnvTs,
41
35
  renderFrontendApiTs,
42
36
  renderFrontendEnvProduction,
43
37
  renderFrontendWorkflow,
@@ -57,7 +51,15 @@ import {
57
51
  routeProblem,
58
52
  strictDependencies,
59
53
  upsertJsonc
60
- } from "./index-yzezvy5h.js";
54
+ } from "./index-5m3t0zfc.js";
55
+ import {
56
+ allEnvKeys,
57
+ envSummary,
58
+ renderEnvExample,
59
+ renderEnvLocal,
60
+ renderEnvProduction,
61
+ renderEnvTs
62
+ } from "./index-b9b4dawy.js";
61
63
  import {
62
64
  LAYOUTS,
63
65
  MANIFEST_FILE,
@@ -0,0 +1,19 @@
1
+ import {
2
+ committedPublicKey,
3
+ generateKeypair,
4
+ keyState,
5
+ keysCommand,
6
+ provisionKey,
7
+ publicKeyFor
8
+ } from "./index-b9b4dawy.js";
9
+ import"./index-pz6m2hkm.js";
10
+ import"./index-844b3qn9.js";
11
+ import"./index-0v6na3yp.js";
12
+ export {
13
+ publicKeyFor,
14
+ provisionKey,
15
+ keysCommand,
16
+ keyState,
17
+ generateKeypair,
18
+ committedPublicKey
19
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.4.0",
3
+ "version": "0.5.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
@@ -1,4 +1,16 @@
1
- import { deployCommand, deployHelp, guardCommand, keysCliCommand, keysHelp, preflightCommand, preflightHelp, secretsCliCommand, secretsHelp } from "./deploy/index";
1
+ import {
2
+ deployCommand,
3
+ deployHelp,
4
+ guardCommand,
5
+ keysCliCommand,
6
+ keysHelp,
7
+ linkCliCommand,
8
+ linkHelp,
9
+ preflightCommand,
10
+ preflightHelp,
11
+ secretsCliCommand,
12
+ secretsHelp,
13
+ } from "./deploy/index";
2
14
  import { distCommand, distHelp } from "./dist";
3
15
  import { importCommand, importHelp } from "./import";
4
16
  import { fullHelp, initHelp, version } from "./help";
@@ -40,6 +52,7 @@ export const EXTENDED: Record<string, Extended> = {
40
52
  preflight: { run: preflightCommand, help: preflightHelp },
41
53
  secrets: { run: secretsCliCommand, help: secretsHelp },
42
54
  keys: { run: keysCliCommand, help: keysHelp },
55
+ link: { run: linkCliCommand, help: linkHelp },
43
56
  // Called by the generated pre-commit hook; exit code is the interface.
44
57
  guard: { run: guardCommand, help: async () => guardCommand() },
45
58
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
@@ -0,0 +1,198 @@
1
+ import { spawn } from "node:child_process";
2
+ import { delimiter, join } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+
5
+ /**
6
+ * GitHub as the root of trust.
7
+ *
8
+ * ── Why GitHub and not Cloudflare ────────────────────────────────────────
9
+ *
10
+ * The shop's credentials have to be reachable by whatever deploys it, and
11
+ * whatever deploys it has to be reachable by a push. The first design
12
+ * derived the encryption key from a Cloudflare API token, which put the
13
+ * bootstrap in the wrong order: the token authorises a worker that does not
14
+ * exist yet, and getting one means `wrangler login` — the exact step this is
15
+ * supposed to remove.
16
+ *
17
+ * GitHub is already there. A person who can push is logged into `gh`, and
18
+ * `gh` can write repository secrets. So GitHub holds the credentials, GitHub
19
+ * Actions is the thing that deploys, and the local machine holds nothing.
20
+ *
21
+ * ── What this cannot do, stated plainly ──────────────────────────────────
22
+ *
23
+ * It cannot mint a Cloudflare credential. There is no OIDC or workload
24
+ * identity federation from GitHub Actions to the Cloudflare API — the
25
+ * request has been open since 2025 with no Cloudflare commitment, and the
26
+ * official CI guidance is still "store an API token in your CI provider's
27
+ * secrets". The Cloudflare GitHub App runs one way: it grants Cloudflare
28
+ * access to the repository, not the repository access to Cloudflare.
29
+ *
30
+ * So exactly one long-lived Cloudflare token must exist, and this module's
31
+ * job is to make sure it exists in ONE place — GitHub's encrypted secrets —
32
+ * rather than on a laptop, in a shell profile, or in `~/.wrangler`.
33
+ *
34
+ * @see https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/
35
+ * @see https://github.com/cloudflare/workers-sdk/discussions/11434
36
+ */
37
+
38
+ export interface GhAuth {
39
+ /** Logged in, with a token that can write repository secrets. */
40
+ ok: boolean;
41
+ user?: string | undefined;
42
+ scopes?: string[] | undefined;
43
+ reason?: string | undefined;
44
+ }
45
+
46
+ /** The scope `gh secret set` needs on a repository. */
47
+ const REPO_SCOPE = "repo";
48
+
49
+ export function findGh(): string | null {
50
+ for (const entry of (process.env["PATH"] ?? "").split(delimiter).filter(Boolean)) {
51
+ if (existsSync(join(entry, "gh"))) return join(entry, "gh");
52
+ }
53
+ return null;
54
+ }
55
+
56
+ export function run(
57
+ cmd: string,
58
+ args: string[],
59
+ cwd: string,
60
+ stdin?: string,
61
+ ): Promise<{ code: number; out: string }> {
62
+ return new Promise((resolve) => {
63
+ const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
64
+ let out = "";
65
+ child.stdout?.on("data", (chunk) => {
66
+ out += chunk;
67
+ });
68
+ child.stderr?.on("data", (chunk) => {
69
+ out += chunk;
70
+ });
71
+ child.on("error", (error) => resolve({ code: 1, out: String(error) }));
72
+ child.on("exit", (code) => resolve({ code: code ?? 1, out }));
73
+ // Always closed: a `gh` subcommand that reads stdin and never sees EOF
74
+ // hangs forever, and one that does not read it does not mind.
75
+ child.stdin?.end(stdin ?? "");
76
+ });
77
+ }
78
+
79
+ /** Is `gh` here, logged in, and scoped to write secrets? */
80
+ export async function ghAuth(cwd: string): Promise<GhAuth> {
81
+ const gh = findGh();
82
+ if (!gh) {
83
+ return { ok: false, reason: "the GitHub CLI is not installed. https://cli.github.com — then `gh auth login`." };
84
+ }
85
+ const status = await run(gh, ["auth", "status"], cwd);
86
+ if (status.code !== 0) {
87
+ return { ok: false, reason: "gh is not logged in. Run `gh auth login`." };
88
+ }
89
+ const user = /Logged in to \S+ account (\S+)/.exec(status.out)?.[1];
90
+ const scopes = /Token scopes: (.+)/
91
+ .exec(status.out)?.[1]
92
+ ?.split(",")
93
+ .map((scope) => scope.trim().replace(/^'|'$/g, ""));
94
+ if (scopes && !scopes.includes(REPO_SCOPE)) {
95
+ return {
96
+ ok: false,
97
+ user,
98
+ scopes,
99
+ reason: `gh is logged in as ${user ?? "you"} but its token lacks the \`${REPO_SCOPE}\` scope, which writing repository secrets needs.\n Run \`gh auth refresh -s ${REPO_SCOPE}\`.`,
100
+ };
101
+ }
102
+ return { ok: true, user, scopes };
103
+ }
104
+
105
+ /** `owner/repo` for this checkout, or null when there is no GitHub remote yet. */
106
+ export async function repoSlug(cwd: string): Promise<string | null> {
107
+ const gh = findGh();
108
+ if (!gh) return null;
109
+ const viewed = await run(gh, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], cwd);
110
+ if (viewed.code !== 0) return null;
111
+ const slug = viewed.out.trim();
112
+ return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
113
+ }
114
+
115
+ /**
116
+ * Write a repository secret, with the value on STDIN.
117
+ *
118
+ * Never as an argument: an argument is visible in `ps` to every other
119
+ * process on the machine for as long as the call runs, and this is called
120
+ * with private keys.
121
+ */
122
+ export async function setSecret(cwd: string, name: string, value: string): Promise<{ ok: boolean; error?: string }> {
123
+ const gh = findGh();
124
+ if (!gh) return { ok: false, error: "gh is not installed" };
125
+ const result = await run(gh, ["secret", "set", name], cwd, value);
126
+ return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split("\n").slice(-2).join(" ") };
127
+ }
128
+
129
+ /** Repository secret NAMES. GitHub never gives a value back, by design. */
130
+ export async function secretNames(cwd: string): Promise<Set<string>> {
131
+ const gh = findGh();
132
+ if (!gh) return new Set();
133
+ const listed = await run(gh, ["secret", "list", "--json", "name", "-q", ".[].name"], cwd);
134
+ if (listed.code !== 0) return new Set();
135
+ return new Set(
136
+ listed.out
137
+ .split("\n")
138
+ .map((line) => line.trim())
139
+ .filter(Boolean),
140
+ );
141
+ }
142
+
143
+ /** A repository VARIABLE — readable, for things that are configuration rather than credentials. */
144
+ export async function setVariable(cwd: string, name: string, value: string): Promise<{ ok: boolean; error?: string }> {
145
+ const gh = findGh();
146
+ if (!gh) return { ok: false, error: "gh is not installed" };
147
+ const result = await run(gh, ["variable", "set", name, "--body", value], cwd);
148
+ return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split("\n").slice(-2).join(" ") };
149
+ }
150
+
151
+ export async function variableNames(cwd: string): Promise<Set<string>> {
152
+ const gh = findGh();
153
+ if (!gh) return new Set();
154
+ const listed = await run(gh, ["variable", "list", "--json", "name", "-q", ".[].name"], cwd);
155
+ if (listed.code !== 0) return new Set();
156
+ return new Set(
157
+ listed.out
158
+ .split("\n")
159
+ .map((line) => line.trim())
160
+ .filter(Boolean),
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Ask Cloudflare whether a token is real, before it is stored anywhere.
166
+ *
167
+ * A token that is wrong should be caught at the moment it is typed, by the
168
+ * person who has it, and not two minutes into a CI run by someone reading a
169
+ * log. This is the one Cloudflare call vc makes with the token in hand.
170
+ *
171
+ * @see https://developers.cloudflare.com/fundamentals/api/reference/verify-token/
172
+ */
173
+ export async function verifyCloudflareToken(token: string): Promise<{ ok: boolean; detail: string }> {
174
+ try {
175
+ const response = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
176
+ headers: { Authorization: `Bearer ${token}` },
177
+ });
178
+ const body = (await response.json()) as { success?: boolean; result?: { status?: string }; errors?: Array<{ message?: string }> };
179
+ if (body.success && body.result?.status === "active") return { ok: true, detail: "active" };
180
+ const message = body.errors?.[0]?.message ?? `HTTP ${response.status}`;
181
+ return { ok: false, detail: message };
182
+ } catch (error) {
183
+ return { ok: false, detail: `could not reach the Cloudflare API: ${String(error)}` };
184
+ }
185
+ }
186
+
187
+ /** The accounts a token can see, so the account id never has to be typed. */
188
+ export async function cloudflareAccounts(token: string): Promise<Array<{ id: string; name: string }>> {
189
+ try {
190
+ const response = await fetch("https://api.cloudflare.com/client/v4/accounts?per_page=50", {
191
+ headers: { Authorization: `Bearer ${token}` },
192
+ });
193
+ const body = (await response.json()) as { success?: boolean; result?: Array<{ id: string; name: string }> };
194
+ return body.success && Array.isArray(body.result) ? body.result.map((a) => ({ id: a.id, name: a.name })) : [];
195
+ } catch {
196
+ return [];
197
+ }
198
+ }
@@ -6,6 +6,7 @@ import { deployCloudflare } from "./cloudflare";
6
6
  import { preflight, printPreflight } from "./preflight";
7
7
  import { PRIVATE_KEY_VAR, SECRETS_FILE, plaintextSecretNames, secretsCommand } from "./secrets";
8
8
  import { keysCommand } from "./keys";
9
+ import { linkCommand } from "./link";
9
10
 
10
11
  /**
11
12
  * `vc deploy` extends `void deploy`: vc's preflight first, then void's
@@ -79,8 +80,11 @@ export async function deployHelp(): Promise<number> {
79
80
  ...row("vc deploy --cloudflare --force", "deploy a shop preflight says is not ready — deliberately", width, 2),
80
81
  line("", width),
81
82
  line(color.bold("Needs, for --cloudflare"), width),
82
- ...row("wrangler login", "or CLOUDFLARE_API_TOKEN; the account is pinned for you when there is one", width, 2),
83
- ...row("wrangler secret put <KEY>", "each secret, on the worker preflight lists them", width, 2),
83
+ ...row("CLOUDFLARE_API_TOKEN", "in the environment; the account is pinned for you when there is one", width, 2),
84
+ ...row(PRIVATE_KEY_VAR, "to decrypt the shop's own secrets", width, 2),
85
+ line("", width),
86
+ line("Normally neither is on your machine: `vc link` puts both on the GitHub", width),
87
+ line("repository and the workflow runs this command for you on every push.", width),
84
88
  ], width);
85
89
  if (captured) process.stdout.write(captured.out.replace(/\n*$/, "\n"));
86
90
  console.log(ours);
@@ -119,14 +123,16 @@ export async function secretsHelp(): Promise<number> {
119
123
  line(`Secrets that live in the repository, encrypted, in ${SECRETS_FILE}.`, width),
120
124
  line("", width),
121
125
  ...row("vc secrets", "what this shop needs, and whether it is declared and encrypted", width, 2),
122
- ...row("vc secrets --init", `write ${SECRETS_FILE} with every required key as \`unset\``, width, 2),
123
- ...row(`dotenvx set KEY '…' -f ${SECRETS_FILE}`, "set one, without decrypting the file", width, 2),
124
- ...row(`dotenvx encrypt -f ${SECRETS_FILE}`, "encrypt anything still in the clear", width, 2),
126
+ ...row("vc secrets --init", `write ${SECRETS_FILE} and make this shop's key`, width, 2),
127
+ ...row("vc secrets set KEY", "set one value prompts, so it never reaches your shell history", width, 2),
128
+ ...row("echo v | vc secrets set KEY", "the same, from a pipe, for scripts", width, 2),
125
129
  line("", width),
126
130
  line("The file is COMMITTED: values are ciphertext, key names are not, so a", width),
127
131
  line("diff shows which secret changed without showing what it changed to.", width),
128
- line(`The private key stays out — .env.keys locally, and ${PRIVATE_KEY_VAR}`, width),
129
- line("as one build variable where the deploy runs.", width),
132
+ line("", width),
133
+ line("Setting one needs NO credential. dotenvx is asymmetric, so encryption uses", width),
134
+ line(`the public key already in ${SECRETS_FILE}. The private key is a GitHub`, width),
135
+ line(`Actions secret, ${PRIVATE_KEY_VAR}, and only the deploy uses it.`, width),
130
136
  line("", width),
131
137
  line("Tradeoff worth knowing: ciphertext in git is permanent, so a leaked key", width),
132
138
  line("exposes rotated secrets too. `wrangler secret put` does not have that", width),
@@ -143,25 +149,29 @@ export async function keysCliCommand(args: string[]): Promise<number> {
143
149
  console.error("vc: no voidcommerce.json here.");
144
150
  return 1;
145
151
  }
146
- return keysCommand(project.manifest, project.root, args);
152
+ return keysCommand(project, args);
147
153
  }
148
154
 
149
155
  export async function keysHelp(): Promise<number> {
150
156
  const width = 80;
151
157
  console.log(
152
158
  box("vc keys", [
153
- line("The key that encrypts this repository's secrets is DERIVED from", width),
154
- line("CLOUDFLARE_API_TOKEN, salted with the account id. Nothing is stored.", width),
159
+ line("Secrets are encrypted with a PUBLIC key that is committed, and read with", width),
160
+ line("a PRIVATE key that lives only in GitHub Actions.", width),
161
+ line("", width),
162
+ ...row("vc keys", "where the key is, and what is missing", width, 2),
163
+ ...row("vc keys --init", "generate one and store it on the GitHub repository", width, 2),
164
+ ...row("vc keys --rotate", "re-key and re-encrypt, if you hold the current private key", width, 2),
155
165
  line("", width),
156
- ...row("vc keys", "what this token derives, and whether it matches the repository", width, 2),
157
- ...row("vc keys --rotate", "re-encrypt under a new token, while the old one still works", width, 2),
166
+ line("Because the encryption is asymmetric, SETTING a secret needs no credential", width),
167
+ line("at all: `vc secrets set KEY` encrypts with the public key in the repository.", width),
168
+ line("A contributor with only a clone can rotate the Stripe key and cannot read", width),
169
+ line("the one already there. Only the deploy decrypts.", width),
158
170
  line("", width),
159
- line("Whoever can deploy the worker can read its secrets, and nobody else can.", width),
160
- line("The price: rotating the token orphans every encrypted value, a second", width),
161
- line("admin's token derives a different key, and the build must use the same", width),
162
- line("token the values were encrypted under. `vc keys` checks the derived", width),
163
- line("public key against the committed one, so a mismatch is a clear refusal", width),
164
- line("rather than a decryption error nobody can place.", width),
171
+ line("GitHub will not hand a secret back once set, so re-keying without a local", width),
172
+ line("copy means entering the values again. That is the right trade: a key worth", width),
173
+ line("rotating is a key that may have leaked, and leaked values need replacing", width),
174
+ line("at the source anyway.", width),
165
175
  ], width),
166
176
  );
167
177
  return 0;
@@ -187,3 +197,41 @@ export async function guardCommand(): Promise<number> {
187
197
  );
188
198
  return 1;
189
199
  }
200
+
201
+
202
+ /** `vc link` — GitHub holds the credentials; this is what puts them there. */
203
+ export async function linkCliCommand(args: string[]): Promise<number> {
204
+ const project = await findProject();
205
+ if (!project) {
206
+ console.error("vc: no voidcommerce.json here.");
207
+ return 1;
208
+ }
209
+ return linkCommand(project, args);
210
+ }
211
+
212
+ export async function linkHelp(): Promise<number> {
213
+ const width = 80;
214
+ console.log(
215
+ box("vc link", [
216
+ line("Put this shop's credentials on its GitHub repository, once, so that", width),
217
+ line("every deploy after this is a `git push`.", width),
218
+ line("", width),
219
+ ...row("vc link", "generate the encryption key, take the Cloudflare token, store both", width, 2),
220
+ ...row("vc link --force", "replace what is already there", width, 2),
221
+ line("", width),
222
+ line(color.bold("What it stores, and where"), width),
223
+ ...row("DOTENV_PRIVATE_KEY_SECRETS", "generated here, never written to disk — a repository SECRET", width, 2),
224
+ ...row("CLOUDFLARE_API_TOKEN", "yours, checked against the Cloudflare API first — a repository SECRET", width, 2),
225
+ ...row("CLOUDFLARE_ACCOUNT_ID", "an identifier, not a credential — a repository VARIABLE", width, 2),
226
+ line("", width),
227
+ line(color.bold("Why one token still has to be typed"), width),
228
+ line("GitHub cannot mint a Cloudflare credential. There is no OIDC federation", width),
229
+ line("between them, and the Cloudflare GitHub App runs the other way: it grants", width),
230
+ line("Cloudflare access to your repository, not your repository access to", width),
231
+ line("Cloudflare. Something must authorise creating a database in your account,", width),
232
+ line("and only Cloudflare can issue that. So it is typed once, here, and never", width),
233
+ line("stored on this machine.", width),
234
+ ], width),
235
+ );
236
+ return 0;
237
+ }