@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/cli.js +263 -72
- 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/index.d.ts +2 -0
- package/dist/{index-yzezvy5h.js → index-5m3t0zfc.js} +228 -650
- package/dist/index-b9b4dawy.js +797 -0
- package/dist/index.js +9 -7
- package/dist/keys-b91c72zp.js +19 -0
- package/package.json +1 -1
- package/src/cli.ts +14 -1
- 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 +168 -121
- package/src/generate/index.ts +22 -3
- package/src/init.ts +1 -0
- package/src/regenerate.ts +1 -0
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-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.
|
|
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 {
|
|
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
|
+
}
|
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
|
+
}
|