@saastemly/voidcommerce 0.3.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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CHOICES,
3
3
  GROUPS
4
- } from "./index-z4qazajn.js";
4
+ } from "./index-844b3qn9.js";
5
5
 
6
6
  // src/manifest.ts
7
7
  import { readFile, writeFile } from "node:fs/promises";
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-84wq4qp8.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,
@@ -77,7 +79,7 @@ import {
77
79
  writeManifest,
78
80
  zone,
79
81
  zoneOf
80
- } from "./index-zh3tmj08.js";
82
+ } from "./index-pz6m2hkm.js";
81
83
  import {
82
84
  AUTH_PLUGINS,
83
85
  CARRIERS,
@@ -94,7 +96,8 @@ import {
94
96
  SIGN_IN,
95
97
  TAX,
96
98
  UI
97
- } from "./index-z4qazajn.js";
99
+ } from "./index-844b3qn9.js";
100
+ import"./index-0v6na3yp.js";
98
101
  export {
99
102
  zoneOf,
100
103
  zone,
@@ -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/dist/manifest.js CHANGED
@@ -17,8 +17,9 @@ import {
17
17
  writeManifest,
18
18
  zone,
19
19
  zoneOf
20
- } from "./index-zh3tmj08.js";
21
- import"./index-z4qazajn.js";
20
+ } from "./index-pz6m2hkm.js";
21
+ import"./index-844b3qn9.js";
22
+ import"./index-0v6na3yp.js";
22
23
  export {
23
24
  zoneOf,
24
25
  zone,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.3.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",
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@clack/prompts": "^0.11.0",
37
+ "@dotenvx/primitives": "^2.2.0",
37
38
  "picocolors": "^1.1.1"
38
39
  },
39
40
  "devDependencies": {
package/src/cli.ts CHANGED
@@ -1,4 +1,16 @@
1
- import { deployCommand, deployHelp, 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";
@@ -39,6 +51,10 @@ export const EXTENDED: Record<string, Extended> = {
39
51
  deploy: { run: deployCommand, help: deployHelp },
40
52
  preflight: { run: preflightCommand, help: preflightHelp },
41
53
  secrets: { run: secretsCliCommand, help: secretsHelp },
54
+ keys: { run: keysCliCommand, help: keysHelp },
55
+ link: { run: linkCliCommand, help: linkHelp },
56
+ // Called by the generated pre-commit hook; exit code is the interface.
57
+ guard: { run: guardCommand, help: async () => guardCommand() },
42
58
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
43
59
  build: { run: appScript("build"), help: appScriptHelp("build") },
44
60
  preview: { run: appScript("preview"), help: appScriptHelp("preview") },
@@ -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
+ }
@@ -4,7 +4,9 @@ import { findProject } from "../project";
4
4
  import { captureVoid, runVoid } from "../void";
5
5
  import { deployCloudflare } from "./cloudflare";
6
6
  import { preflight, printPreflight } from "./preflight";
7
- import { PRIVATE_KEY_VAR, SECRETS_FILE, secretsCommand } from "./secrets";
7
+ import { PRIVATE_KEY_VAR, SECRETS_FILE, plaintextSecretNames, secretsCommand } from "./secrets";
8
+ import { keysCommand } from "./keys";
9
+ import { linkCommand } from "./link";
8
10
 
9
11
  /**
10
12
  * `vc deploy` extends `void deploy`: vc's preflight first, then void's
@@ -78,8 +80,11 @@ export async function deployHelp(): Promise<number> {
78
80
  ...row("vc deploy --cloudflare --force", "deploy a shop preflight says is not ready — deliberately", width, 2),
79
81
  line("", width),
80
82
  line(color.bold("Needs, for --cloudflare"), width),
81
- ...row("wrangler login", "or CLOUDFLARE_API_TOKEN; the account is pinned for you when there is one", width, 2),
82
- ...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),
83
88
  ], width);
84
89
  if (captured) process.stdout.write(captured.out.replace(/\n*$/, "\n"));
85
90
  console.log(ours);
@@ -118,14 +123,16 @@ export async function secretsHelp(): Promise<number> {
118
123
  line(`Secrets that live in the repository, encrypted, in ${SECRETS_FILE}.`, width),
119
124
  line("", width),
120
125
  ...row("vc secrets", "what this shop needs, and whether it is declared and encrypted", width, 2),
121
- ...row("vc secrets --init", `write ${SECRETS_FILE} with every required key as \`unset\``, width, 2),
122
- ...row(`dotenvx set KEY '…' -f ${SECRETS_FILE}`, "set one, without decrypting the file", width, 2),
123
- ...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),
124
129
  line("", width),
125
130
  line("The file is COMMITTED: values are ciphertext, key names are not, so a", width),
126
131
  line("diff shows which secret changed without showing what it changed to.", width),
127
- line(`The private key stays out — .env.keys locally, and ${PRIVATE_KEY_VAR}`, width),
128
- 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),
129
136
  line("", width),
130
137
  line("Tradeoff worth knowing: ciphertext in git is permanent, so a leaked key", width),
131
138
  line("exposes rotated secrets too. `wrangler secret put` does not have that", width),
@@ -134,3 +141,97 @@ export async function secretsHelp(): Promise<number> {
134
141
  );
135
142
  return 0;
136
143
  }
144
+
145
+ /** `vc keys` — what the token derives, and the warning that goes with it. */
146
+ export async function keysCliCommand(args: string[]): Promise<number> {
147
+ const project = await findProject();
148
+ if (!project) {
149
+ console.error("vc: no voidcommerce.json here.");
150
+ return 1;
151
+ }
152
+ return keysCommand(project, args);
153
+ }
154
+
155
+ export async function keysHelp(): Promise<number> {
156
+ const width = 80;
157
+ console.log(
158
+ box("vc keys", [
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),
165
+ line("", width),
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),
170
+ line("", 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),
175
+ ], width),
176
+ );
177
+ return 0;
178
+ }
179
+
180
+ /**
181
+ * The pre-commit gate: refuse a commit that would put a secret in the clear.
182
+ *
183
+ * Exit code is the whole interface — a hook cares about nothing else.
184
+ */
185
+ export async function guardCommand(): Promise<number> {
186
+ const project = await findProject();
187
+ if (!project) return 0; // Not a shop; nothing to guard.
188
+ const bare = plaintextSecretNames(project.root);
189
+ if (bare.length === 0) return 0;
190
+ console.error(
191
+ `\n${color.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${
192
+ bare.length === 1 ? "is" : "are"
193
+ } not encrypted.\n\n` +
194
+ ` ${bare.join("\n ")}\n\n` +
195
+ ` bunx dotenvx encrypt -f ${SECRETS_FILE}\n\n` +
196
+ color.dim(" Committing a secret in the clear cannot be undone by a later commit;\n the value stays in the history and must be treated as burned.\n"),
197
+ );
198
+ return 1;
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
+ }