@saastemly/voidcommerce 0.4.0 → 0.6.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.
@@ -21,11 +21,12 @@ var LAYOUTS = [
21
21
  {
22
22
  id: "strict",
23
23
  label: "Strict (experimental)",
24
- hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app from the manifest and never hand-edited"
24
+ hint: "the root IS the storefront, sharing one package.json; the worker is generated under .vc/app and serves the storefront from its own assets — one origin, one deploy"
25
25
  }
26
26
  ];
27
27
  var isSingleApp = (layout) => layout === "app";
28
28
  var hasFrontend = (layout) => layout !== "app";
29
+ var oneOrigin = (layout) => layout !== "monorepo";
29
30
  var TWO_LABEL_SUFFIXES = new Set(["co", "com", "net", "org", "ac", "gov", "edu", "or", "ne", "in"]);
30
31
  function zoneOf(domain) {
31
32
  const labels = domain.trim().toLowerCase().replace(/\.$/, "").split(".");
@@ -42,7 +43,7 @@ function isApex(manifest) {
42
43
  }
43
44
  function workerHosts(manifest) {
44
45
  const { domain } = manifest.shop;
45
- if (!isSingleApp(manifest.layout))
46
+ if (!oneOrigin(manifest.layout))
46
47
  return [`api.${domain}`];
47
48
  return isApex(manifest) ? [domain, `www.${domain}`] : [domain];
48
49
  }
@@ -163,10 +164,10 @@ function validate(manifest) {
163
164
  if (dns.includes("simply")) {
164
165
  problems.push(`DNS at Simply.com cannot host ${workerHosts(manifest)[0]} — a Worker custom domain is created by Cloudflare, in a zone on Cloudflare`);
165
166
  }
166
- if (hasFrontend(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
167
+ if (!oneOrigin(manifest.layout) && manifest.shop.pagesHost && !/^[a-z0-9-]+\.github\.io$/i.test(manifest.shop.pagesHost)) {
167
168
  problems.push(`Pages host "${manifest.shop.pagesHost}" should be <owner>.github.io`);
168
169
  }
169
170
  return problems;
170
171
  }
171
172
 
172
- export { MANIFEST_FILE, LAYOUTS, isSingleApp, hasFrontend, zoneOf, zone, isApex, workerHosts, withRequired, has, chosenIn, envKeysOf, OWN_PACKAGES, specifier, packagesOf, readManifest, writeManifest, validate };
173
+ export { MANIFEST_FILE, LAYOUTS, isSingleApp, hasFrontend, oneOrigin, zoneOf, zone, isApex, workerHosts, withRequired, has, chosenIn, envKeysOf, OWN_PACKAGES, specifier, packagesOf, readManifest, writeManifest, validate };
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-f1wds190.js";
55
+ import {
56
+ allEnvKeys,
57
+ envSummary,
58
+ renderEnvExample,
59
+ renderEnvLocal,
60
+ renderEnvProduction,
61
+ renderEnvTs
62
+ } from "./index-2qt2yh3z.js";
61
63
  import {
62
64
  LAYOUTS,
63
65
  MANIFEST_FILE,
@@ -68,6 +70,7 @@ import {
68
70
  hasFrontend,
69
71
  isApex,
70
72
  isSingleApp,
73
+ oneOrigin,
71
74
  packagesOf,
72
75
  readManifest,
73
76
  specifier,
@@ -77,7 +80,7 @@ import {
77
80
  writeManifest,
78
81
  zone,
79
82
  zoneOf
80
- } from "./index-pz6m2hkm.js";
83
+ } from "./index-xx5p4b8d.js";
81
84
  import {
82
85
  AUTH_PLUGINS,
83
86
  CARRIERS,
@@ -145,6 +148,7 @@ export {
145
148
  parseWhoAmI,
146
149
  parseJsonc,
147
150
  packagesOf,
151
+ oneOrigin,
148
152
  isSingleApp,
149
153
  isApex,
150
154
  importCommand,
@@ -0,0 +1,19 @@
1
+ import {
2
+ committedPublicKey,
3
+ generateKeypair,
4
+ keyState,
5
+ keysCommand,
6
+ provisionKey,
7
+ publicKeyFor
8
+ } from "./index-2qt2yh3z.js";
9
+ import"./index-xx5p4b8d.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
+ };
@@ -24,9 +24,10 @@ export declare const MANIFEST_FILE = "voidcommerce.json";
24
24
  * strict EXPERIMENTAL. A monorepo turned inside out. The REPOSITORY
25
25
  * ROOT is the storefront — its package.json, its pages, yours to
26
26
  * edit — and the worker is generated underneath it at .vc/app
27
- * from the manifest, gitignored, never hand-edited. Hosted the
28
- * way a monorepo is: the worker on api.<domain>, the storefront
29
- * on <domain>.
27
+ * from the manifest, gitignored, never hand-edited. ONE worker
28
+ * serves both at <domain>: the storefront's prerendered tree is
29
+ * folded into the worker's assets at build time, so there is one
30
+ * origin, one deploy, and one certificate Cloudflare issues.
30
31
  *
31
32
  * The first question, because it decides where `void init` runs and what
32
33
  * the domain means. It cannot change after init: moving files is not a
@@ -47,8 +48,26 @@ export declare const LAYOUTS: Array<{
47
48
  * storefront on the domain itself.
48
49
  */
49
50
  export declare const isSingleApp: (layout: Layout) => boolean;
50
- /** Does this layout carry a storefront of its own, and so a second origin to trust? */
51
+ /** Does this layout carry a storefront of its own, in the repository? */
51
52
  export declare const hasFrontend: (layout: Layout) => boolean;
53
+ /**
54
+ * Does ONE worker serve both the storefront and the API?
55
+ *
56
+ * `app` always did — it generates its storefront into the worker. `strict`
57
+ * now does too: the root's prerendered tree is merged into the worker's own
58
+ * assets directory at build time, so there is one origin, one hostname, one
59
+ * deploy, and one certificate that Cloudflare issues itself.
60
+ *
61
+ * That deletes the whole GitHub Pages half — the A/AAAA records, the CNAME
62
+ * file, the base-path rewriting — and with it a trap worth naming: GitHub
63
+ * treats any Cloudflare IP as ineligible for a certificate, so a proxied
64
+ * record silently stops issuance AND renewal about ninety days later, while
65
+ * GitHub's own health check still reports green.
66
+ *
67
+ * `monorepo` keeps the split, because there the frontend is a separate app
68
+ * that a person may well want hosted separately.
69
+ */
70
+ export declare const oneOrigin: (layout: Layout) => boolean;
52
71
  /** The zone a hostname most likely belongs to: the registrable domain. */
53
72
  export declare function zoneOf(domain: string): string;
54
73
  /** The zone this shop's records go in. */
@@ -88,7 +107,10 @@ export interface Manifest {
88
107
  * default that is safe to assume.
89
108
  */
90
109
  taxRegistered?: boolean | undefined;
91
- /** `saastemly.github.io` — the monorepo's frontend lives on GitHub Pages; the www record points here. */
110
+ /**
111
+ * `saastemly.github.io` — only a MONOREPO has a storefront on GitHub
112
+ * Pages. In `app` and `strict` the worker serves it, so this is unused.
113
+ */
92
114
  pagesHost?: string | undefined;
93
115
  };
94
116
  /** Chosen ids from each group, keyed by group id. */
package/dist/manifest.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  hasFrontend,
9
9
  isApex,
10
10
  isSingleApp,
11
+ oneOrigin,
11
12
  packagesOf,
12
13
  readManifest,
13
14
  specifier,
@@ -17,7 +18,7 @@ import {
17
18
  writeManifest,
18
19
  zone,
19
20
  zoneOf
20
- } from "./index-pz6m2hkm.js";
21
+ } from "./index-xx5p4b8d.js";
21
22
  import"./index-844b3qn9.js";
22
23
  import"./index-0v6na3yp.js";
23
24
  export {
@@ -30,6 +31,7 @@ export {
30
31
  specifier,
31
32
  readManifest,
32
33
  packagesOf,
34
+ oneOrigin,
33
35
  isSingleApp,
34
36
  isApex,
35
37
  hasFrontend,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.4.0",
3
+ "version": "0.6.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") },
@@ -1,8 +1,8 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import color from "picocolors";
4
4
  import { allEnvKeys } from "../generate/env";
5
- import { hasFrontend, workerHosts, writeManifest } from "../manifest";
5
+ import { hasFrontend, isSingleApp, oneOrigin, workerHosts, writeManifest } from "../manifest";
6
6
  import type { Project } from "../project";
7
7
  import { parseJsonc, upsertJsonc } from "./jsonc";
8
8
  import { preflight, printPreflight } from "./preflight";
@@ -130,10 +130,46 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
130
130
  // 4. build.
131
131
  const builder = findBuilder(app);
132
132
  if (!builder) return fail("no build tool: neither vite-plus nor vite is installed.");
133
- console.log(`\n▸ ${builder.label}`);
133
+
134
+ // In strict the STOREFRONT is a second Void app, at the repository root,
135
+ // and it has to be built first: its prerendered tree is folded into the
136
+ // worker's assets below, and the worker's build is what emits the config
137
+ // that names that directory.
138
+ const merging = oneOrigin(project.manifest.layout) && !isSingleApp(project.manifest.layout);
139
+ if (merging) {
140
+ console.log(`\n▸ ${builder.label} ${color.dim("(the storefront)")}`);
141
+ const storefront = await wrangler(builder.cmd, ["build"], project.root, true);
142
+ if (storefront.code !== 0) return fail(`the storefront build failed (exit ${storefront.code}).`);
143
+ }
144
+
145
+ console.log(`\n▸ ${builder.label}${merging ? color.dim(" (the worker)") : ""}`);
134
146
  const built = await wrangler(builder.cmd, ["build"], app, true);
135
147
  if (built.code !== 0) return fail(`the build failed (exit ${built.code}).`);
136
148
 
149
+ // 4b. Fold the storefront into the worker's assets.
150
+ //
151
+ // Void's worker already serves static assets: it runs first for every
152
+ // request and calls `env.ASSETS.fetch` only when its own router 404s. So
153
+ // the storefront's prerendered files land in the same directory and are
154
+ // reached by exactly that fallback — no routing rules, no second origin.
155
+ //
156
+ // Filenames are content-hashed, so the two trees merge without collision.
157
+ // `.vite/manifest.json` is the one exception and is build metadata that
158
+ // should never be uploaded at all.
159
+ if (merging) {
160
+ const from = join(project.root, "dist", "client");
161
+ const into = join(app, "dist", "client");
162
+ if (!existsSync(from)) return fail(`the storefront build emitted no ${from} — is the root a Void app with output "static"?`);
163
+ if (!existsSync(into)) return fail(`the worker build emitted no ${into}; there is nothing to merge into.`);
164
+ const merged = mergeTree(from, into, new Set([".vite"]));
165
+ // The worker owns "/" only if it has a page for it. It must not, or the
166
+ // shop's front door is unreachable — so this is checked, not assumed.
167
+ if (!existsSync(join(into, "index.html"))) {
168
+ return fail("the storefront produced no index.html, so the shop has no front page.");
169
+ }
170
+ console.log(`${color.green("✓")} folded ${merged} storefront file${merged === 1 ? "" : "s"} into the worker's assets`);
171
+ }
172
+
137
173
  // 5. scrub.
138
174
  const emittedPath = join(app, "dist", "ssr", "wrangler.json");
139
175
  if (!existsSync(emittedPath)) return fail(`the build emitted no ${emittedPath} — is this a Void app on the Cloudflare target?`);
@@ -183,6 +219,34 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
183
219
  const domain = project.manifest.shop.domain;
184
220
  const url = `https://${workerHosts(project.manifest)[0]}`;
185
221
  console.log(`\n${color.green("Live:")} ${url}`);
186
- if (hasFrontend(project.manifest.layout)) console.log(color.dim("The storefront deploys itself from GitHub Actions on push."));
222
+ if (hasFrontend(project.manifest.layout) && !oneOrigin(project.manifest.layout)) {
223
+ console.log(color.dim("The storefront deploys itself from GitHub Actions on push."));
224
+ }
187
225
  return 0;
188
226
  }
227
+
228
+
229
+ /**
230
+ * Copy one built tree over another, skipping named directories.
231
+ *
232
+ * Deliberately additive rather than a replace: the worker's own client
233
+ * bundles are already in the target and must survive. Returns how many files
234
+ * were written, so the deploy can say so rather than claiming success
235
+ * silently.
236
+ */
237
+ function mergeTree(from: string, into: string, skip: Set<string>): number {
238
+ let count = 0;
239
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
240
+ if (skip.has(entry.name)) continue;
241
+ const source = join(from, entry.name);
242
+ const target = join(into, entry.name);
243
+ if (entry.isDirectory()) {
244
+ mkdirSync(target, { recursive: true });
245
+ count += mergeTree(source, target, skip);
246
+ } else {
247
+ copyFileSync(source, target);
248
+ count += 1;
249
+ }
250
+ }
251
+ return count;
252
+ }
@@ -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
+ }