@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.
- package/dist/cli.js +269 -76
- package/dist/deploy/github.d.ts +85 -0
- package/dist/deploy/index.d.ts +3 -0
- package/dist/deploy/keys.d.ts +68 -25
- package/dist/deploy/link.d.ts +2 -0
- package/dist/deploy/secrets.d.ts +51 -8
- package/dist/generate/ci.d.ts +33 -18
- package/dist/generate/frontend.d.ts +1 -1
- package/dist/generate/index.d.ts +2 -0
- package/dist/index-2qt2yh3z.js +797 -0
- package/dist/{index-yzezvy5h.js → index-f1wds190.js} +322 -663
- package/dist/{index-pz6m2hkm.js → index-xx5p4b8d.js} +5 -4
- package/dist/index.js +12 -8
- package/dist/keys-qwvt324e.js +19 -0
- package/dist/manifest.d.ts +27 -5
- package/dist/manifest.js +3 -1
- package/package.json +1 -1
- package/src/cli.ts +14 -1
- package/src/deploy/cloudflare.ts +68 -4
- package/src/deploy/github.ts +198 -0
- package/src/deploy/index.ts +66 -18
- package/src/deploy/keys.ts +208 -144
- package/src/deploy/link.ts +185 -0
- package/src/deploy/secrets.ts +217 -35
- package/src/dotenvx-primitives.d.ts +4 -0
- package/src/generate/ci.ts +176 -121
- package/src/generate/frontend.ts +26 -4
- package/src/generate/index.ts +46 -8
- package/src/generate/support.ts +7 -5
- package/src/init.ts +2 -1
- package/src/manifest.ts +33 -8
- package/src/regenerate.ts +1 -0
- package/src/wizard.ts +7 -3
|
@@ -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
|
|
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 (!
|
|
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 (
|
|
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-
|
|
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-
|
|
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
|
+
};
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
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-
|
|
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.
|
|
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 {
|
|
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") },
|
package/src/deploy/cloudflare.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
+
}
|
package/src/deploy/index.ts
CHANGED
|
@@ -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("
|
|
83
|
-
...row(
|
|
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}
|
|
123
|
-
...row(
|
|
124
|
-
...row(
|
|
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(
|
|
129
|
-
line("
|
|
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
|
|
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("
|
|
154
|
-
line("
|
|
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
|
-
|
|
157
|
-
|
|
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("
|
|
160
|
-
line("
|
|
161
|
-
line("
|
|
162
|
-
line("
|
|
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
|
+
}
|