@saastemly/voidcommerce 0.22.1 → 0.23.1
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 +91 -6
- package/dist/deploy/teardown.d.ts +8 -0
- package/dist/github-km752pz0.js +29 -0
- package/dist/{index-51zee2vf.js → index-3wftdrq5.js} +5 -3
- package/dist/{index-t1ggktg8.js → index-hvrbdp85.js} +56 -140
- package/dist/index-vj6akbny.js +135 -0
- package/dist/index.js +3 -2
- package/dist/{keys-1h4zpvd7.js → keys-adh7e74k.js} +2 -1
- package/dist/secrets-f2xvf5t3.js +38 -0
- package/package.json +1 -1
- package/src/deploy/index.ts +9 -0
- package/src/deploy/teardown.ts +105 -0
package/dist/cli.js
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
version,
|
|
26
26
|
voidAppsIn,
|
|
27
27
|
wrangler
|
|
28
|
-
} from "./index-
|
|
28
|
+
} from "./index-3wftdrq5.js";
|
|
29
29
|
import {
|
|
30
30
|
LOCAL_KEY_FILE,
|
|
31
31
|
PRIVATE_KEY_VAR,
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
run,
|
|
41
41
|
secretValue,
|
|
42
42
|
secretsCommand
|
|
43
|
-
} from "./index-
|
|
43
|
+
} from "./index-hvrbdp85.js";
|
|
44
44
|
import {
|
|
45
45
|
LAYOUTS,
|
|
46
46
|
oneOrigin,
|
|
@@ -54,7 +54,10 @@ import {
|
|
|
54
54
|
GROUPS
|
|
55
55
|
} from "./index-xyjhy6kp.js";
|
|
56
56
|
import"./index-6e4eh08g.js";
|
|
57
|
-
import"./index-
|
|
57
|
+
import"./index-vj6akbny.js";
|
|
58
|
+
import {
|
|
59
|
+
__require
|
|
60
|
+
} from "./index-0v6na3yp.js";
|
|
58
61
|
|
|
59
62
|
// src/deploy/index.ts
|
|
60
63
|
import color2 from "picocolors";
|
|
@@ -113,6 +116,15 @@ ${color.bold("This will destroy, on Cloudflare:")}
|
|
|
113
116
|
console.log(` worker ${plan.worker} ${color.dim(`(and its custom domain ${host})`)}`);
|
|
114
117
|
console.log(plan.d1 ? ` database ${plan.d1} ${opts.keepData ? color.green("KEPT — --keep-data") : color.red("DELETED, with every order in it")}` : ` database ${color.dim("none recorded")}`);
|
|
115
118
|
console.log(plan.queues.length ? ` queues ${plan.queues.join(", ")}` : ` queues ${color.dim("none recorded")}`);
|
|
119
|
+
if (opts.github) {
|
|
120
|
+
const { repoSlug } = await import("./github-km752pz0.js");
|
|
121
|
+
const slug = await repoSlug(project.root);
|
|
122
|
+
console.log(slug ? `
|
|
123
|
+
${color.bold("And on GitHub:")}
|
|
124
|
+
|
|
125
|
+
repository ${slug} ${color.red("DELETED — code, history, issues, and the encryption key variable")}` : `
|
|
126
|
+
repository ${color.dim("none — there is nothing to delete")}`);
|
|
127
|
+
}
|
|
116
128
|
console.log("");
|
|
117
129
|
if (opts.dryRun) {
|
|
118
130
|
console.log(color.dim(`--dry-run: nothing was touched.
|
|
@@ -121,9 +133,9 @@ ${color.bold("This will destroy, on Cloudflare:")}
|
|
|
121
133
|
}
|
|
122
134
|
if (!opts.confirm) {
|
|
123
135
|
return fail(`refusing to destroy anything without --confirm.
|
|
124
|
-
|
|
136
|
+
This is not reversible: D1 has no undo, and the orders go with it.
|
|
125
137
|
|
|
126
|
-
|
|
138
|
+
${color.cyan(`vc teardown --cloudflare --confirm ${project.manifest.shop.domain}`)}`);
|
|
127
139
|
}
|
|
128
140
|
console.log(`▸ wrangler delete (${plan.worker})`);
|
|
129
141
|
const deleted = await wrangler(bin, ["delete", "--name", plan.worker, "--force"], app);
|
|
@@ -162,12 +174,76 @@ ${dropped.out.trim().split(`
|
|
|
162
174
|
}
|
|
163
175
|
console.log(`${color.green("✓")} the database id is out of wrangler.jsonc and the manifest, so the next deploy provisions afresh`);
|
|
164
176
|
}
|
|
177
|
+
if (opts.github) {
|
|
178
|
+
const code = await teardownRepository(project, opts);
|
|
179
|
+
if (code !== 0)
|
|
180
|
+
return code;
|
|
181
|
+
}
|
|
165
182
|
console.log(`
|
|
166
183
|
${color.bold("Torn down.")} ${color.dim(`The shop's secrets are untouched in ${SECRETS_FILE} — this removed infrastructure, not configuration.`)}
|
|
167
|
-
|
|
184
|
+
Push again, or ${color.cyan("vc deploy --cloudflare --provision")}, and it rebuilds.
|
|
168
185
|
`);
|
|
169
186
|
return 0;
|
|
170
187
|
}
|
|
188
|
+
async function teardownRepository(project, opts) {
|
|
189
|
+
const root = project.root;
|
|
190
|
+
const { ghAuth, findGh, getVariable, repoSlug, run: run2 } = await import("./github-km752pz0.js");
|
|
191
|
+
const { LOCAL_KEY_FILE: LOCAL_KEY_FILE2, localPrivateKey, ignoresKeyFile: ignoresKeyFile2 } = await import("./keys-adh7e74k.js");
|
|
192
|
+
const { PRIVATE_KEY_VAR: PRIVATE_KEY_VAR2 } = await import("./secrets-f2xvf5t3.js");
|
|
193
|
+
const gh = findGh();
|
|
194
|
+
const auth = await ghAuth(root);
|
|
195
|
+
if (!gh || !auth.ok)
|
|
196
|
+
return fail(auth.reason ?? "the GitHub CLI is not installed.");
|
|
197
|
+
const slug = await repoSlug(root);
|
|
198
|
+
if (!slug) {
|
|
199
|
+
console.log(color.dim("! no GitHub repository here — nothing to delete."));
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
if (!localPrivateKey(root)) {
|
|
203
|
+
const remote = await getVariable(root, PRIVATE_KEY_VAR2);
|
|
204
|
+
if (remote) {
|
|
205
|
+
if (!ignoresKeyFile2(root)) {
|
|
206
|
+
return fail(`${LOCAL_KEY_FILE2} is not gitignored, so the key cannot be rescued here. Add it to .gitignore first.`);
|
|
207
|
+
}
|
|
208
|
+
writeFileSync(join(root, LOCAL_KEY_FILE2), `${PRIVATE_KEY_VAR2}="${remote}"
|
|
209
|
+
`, { mode: 384 });
|
|
210
|
+
console.log(`${color.green("✓")} key pulled back into ${LOCAL_KEY_FILE2} before the repository goes`);
|
|
211
|
+
} else if (declaredHasEncrypted(root)) {
|
|
212
|
+
return fail(`${slug} has no ${PRIVATE_KEY_VAR2} and there is no ${LOCAL_KEY_FILE2} here,
|
|
213
|
+
but ${SECRETS_FILE} holds encrypted values. Deleting the repository would
|
|
214
|
+
make them permanently unreadable. Find the key first, or clear the file.`);
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
console.log(color.dim(`= ${LOCAL_KEY_FILE2} is already here, so the key survives the deletion`));
|
|
218
|
+
}
|
|
219
|
+
console.log(`▸ gh repo delete ${slug}`);
|
|
220
|
+
const deleted = await run2(gh, ["repo", "delete", slug, "--yes"], root);
|
|
221
|
+
if (deleted.code !== 0) {
|
|
222
|
+
if (/delete_repo|scope/i.test(deleted.out)) {
|
|
223
|
+
return fail(`gh cannot delete repositories: its token has no \`delete_repo\` scope.
|
|
224
|
+
|
|
225
|
+
${color.cyan("gh auth refresh -s delete_repo")}
|
|
226
|
+
|
|
227
|
+
` + ` That scope is not granted by default, deliberately — it is the one that
|
|
228
|
+
` + " destroys work rather than resources.");
|
|
229
|
+
}
|
|
230
|
+
return fail(`could not delete ${slug}:
|
|
231
|
+
${deleted.out.trim().split(`
|
|
232
|
+
`).slice(-3).join(`
|
|
233
|
+
`)}`);
|
|
234
|
+
}
|
|
235
|
+
console.log(`${color.green("✓")} ${slug} deleted`);
|
|
236
|
+
const removed = await run2("git", ["remote", "remove", "origin"], root);
|
|
237
|
+
if (removed.code === 0)
|
|
238
|
+
console.log(`${color.green("✓")} origin removed, so the next publish starts from nothing`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
function declaredHasEncrypted(root) {
|
|
242
|
+
const path = join(root, SECRETS_FILE);
|
|
243
|
+
if (!existsSync(path))
|
|
244
|
+
return false;
|
|
245
|
+
return /^\s*[A-Z][A-Z0-9_]*\s*=\s*["']?encrypted:/m.test(readFileSync(path, "utf8"));
|
|
246
|
+
}
|
|
171
247
|
|
|
172
248
|
// src/deploy/index.ts
|
|
173
249
|
async function deployCommand(args) {
|
|
@@ -428,6 +504,7 @@ vc: --confirm needs this shop's domain, exactly:
|
|
|
428
504
|
return teardownCloudflare(project, {
|
|
429
505
|
confirm: confirmed,
|
|
430
506
|
keepData: args.includes("--keep-data"),
|
|
507
|
+
github: args.includes("--github"),
|
|
431
508
|
dryRun: args.includes("--dry-run")
|
|
432
509
|
});
|
|
433
510
|
}
|
|
@@ -440,6 +517,7 @@ async function teardownHelp() {
|
|
|
440
517
|
...row("vc teardown --cloudflare --dry-run", "list what would be destroyed, touch nothing", width, 2),
|
|
441
518
|
...row("vc teardown --cloudflare --confirm <domain>", "do it", width, 2),
|
|
442
519
|
...row("--keep-data", "leave the D1 database alone", width, 2),
|
|
520
|
+
...row("--github", "delete the GitHub repository too, so the next publish is from nothing", width, 2),
|
|
443
521
|
line("", width),
|
|
444
522
|
line(color2.bold("Read this first"), width),
|
|
445
523
|
line("D1 deletion has NO UNDO. Every order, customer and address in the", width),
|
|
@@ -448,6 +526,13 @@ async function teardownHelp() {
|
|
|
448
526
|
line("So --confirm takes the shop's DOMAIN, not `y`: a `y` is muscle memory,", width),
|
|
449
527
|
line("and typing the domain is what stops this running in the wrong shop.", width),
|
|
450
528
|
line("", width),
|
|
529
|
+
line("--github is separate and opt-in because the blast radius differs in", width),
|
|
530
|
+
line("kind: Cloudflare holds nothing that cannot be rebuilt from the", width),
|
|
531
|
+
line("repository, and the repository is what everything is rebuilt FROM. It", width),
|
|
532
|
+
line("also pulls the encryption key back onto disk BEFORE deleting, because", width),
|
|
533
|
+
line("that key lives as a repository variable — delete the repository with no", width),
|
|
534
|
+
line("local copy and every encrypted value is unreadable forever.", width),
|
|
535
|
+
line("", width),
|
|
451
536
|
line("It is idempotent — deleting what is already gone is success — and it", width),
|
|
452
537
|
line("clears the recorded database id, because `wrangler deploy` FAILS on a", width),
|
|
453
538
|
line("dangling id rather than making a new database. Secrets are untouched:", width),
|
|
@@ -33,6 +33,14 @@ export interface TeardownOptions {
|
|
|
33
33
|
confirm: boolean;
|
|
34
34
|
/** Keep the D1 database — the one thing that holds data nobody can recreate. */
|
|
35
35
|
keepData: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Delete the GitHub repository too, so `git publish from scratch` really
|
|
38
|
+
* is from scratch. Opt-in and separate from the Cloudflare teardown,
|
|
39
|
+
* because the blast radius is different in kind: Cloudflare holds nothing
|
|
40
|
+
* that cannot be rebuilt from the repository, and the repository is the
|
|
41
|
+
* thing everything is rebuilt FROM.
|
|
42
|
+
*/
|
|
43
|
+
github: boolean;
|
|
36
44
|
dryRun: boolean;
|
|
37
45
|
}
|
|
38
46
|
interface WranglerConfig {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
apiToken,
|
|
3
|
+
cloudflareAccounts,
|
|
4
|
+
findGh,
|
|
5
|
+
getVariable,
|
|
6
|
+
ghAuth,
|
|
7
|
+
repoSlug,
|
|
8
|
+
run,
|
|
9
|
+
secretNames,
|
|
10
|
+
setSecret,
|
|
11
|
+
setVariable,
|
|
12
|
+
variableNames,
|
|
13
|
+
verifyCloudflareToken
|
|
14
|
+
} from "./index-vj6akbny.js";
|
|
15
|
+
import"./index-0v6na3yp.js";
|
|
16
|
+
export {
|
|
17
|
+
verifyCloudflareToken,
|
|
18
|
+
variableNames,
|
|
19
|
+
setVariable,
|
|
20
|
+
setSecret,
|
|
21
|
+
secretNames,
|
|
22
|
+
run,
|
|
23
|
+
repoSlug,
|
|
24
|
+
ghAuth,
|
|
25
|
+
getVariable,
|
|
26
|
+
findGh,
|
|
27
|
+
cloudflareAccounts,
|
|
28
|
+
apiToken
|
|
29
|
+
};
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
PRIVATE_KEY_VAR,
|
|
4
4
|
SECRETS_FILE,
|
|
5
5
|
allEnvKeys,
|
|
6
|
-
apiToken,
|
|
7
6
|
declaredSecretNames,
|
|
8
7
|
decryptSecrets,
|
|
9
8
|
plaintextSecretNames,
|
|
@@ -13,7 +12,7 @@ import {
|
|
|
13
12
|
renderEnvTs,
|
|
14
13
|
secretValue,
|
|
15
14
|
unsetSecretNames
|
|
16
|
-
} from "./index-
|
|
15
|
+
} from "./index-hvrbdp85.js";
|
|
17
16
|
import {
|
|
18
17
|
MANIFEST_FILE,
|
|
19
18
|
has,
|
|
@@ -33,6 +32,9 @@ import {
|
|
|
33
32
|
import {
|
|
34
33
|
cloudflareTokenMarkdown
|
|
35
34
|
} from "./index-6e4eh08g.js";
|
|
35
|
+
import {
|
|
36
|
+
apiToken
|
|
37
|
+
} from "./index-vj6akbny.js";
|
|
36
38
|
import {
|
|
37
39
|
__require
|
|
38
40
|
} from "./index-0v6na3yp.js";
|
|
@@ -1383,7 +1385,7 @@ import color from "picocolors";
|
|
|
1383
1385
|
// package.json
|
|
1384
1386
|
var package_default = {
|
|
1385
1387
|
name: "@saastemly/voidcommerce",
|
|
1386
|
-
version: "0.
|
|
1388
|
+
version: "0.23.1",
|
|
1387
1389
|
description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
|
|
1388
1390
|
type: "module",
|
|
1389
1391
|
license: "MIT",
|
|
@@ -5,111 +5,27 @@ import {
|
|
|
5
5
|
oneOrigin,
|
|
6
6
|
zone
|
|
7
7
|
} from "./index-30y19qz5.js";
|
|
8
|
+
import {
|
|
9
|
+
getVariable,
|
|
10
|
+
ghAuth,
|
|
11
|
+
repoSlug,
|
|
12
|
+
setVariable,
|
|
13
|
+
variableNames
|
|
14
|
+
} from "./index-vj6akbny.js";
|
|
8
15
|
import {
|
|
9
16
|
__require,
|
|
10
17
|
__toESM
|
|
11
18
|
} from "./index-0v6na3yp.js";
|
|
12
19
|
|
|
13
20
|
// src/deploy/keys.ts
|
|
14
|
-
import { existsSync as
|
|
15
|
-
import { join as
|
|
21
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
22
|
+
import { join as join2 } from "node:path";
|
|
16
23
|
import color2 from "picocolors";
|
|
17
24
|
|
|
18
|
-
// src/deploy/github.ts
|
|
19
|
-
import { spawn } from "node:child_process";
|
|
20
|
-
import { delimiter, join } from "node:path";
|
|
21
|
-
import { existsSync } from "node:fs";
|
|
22
|
-
function apiToken() {
|
|
23
|
-
return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
|
|
24
|
-
}
|
|
25
|
-
var REPO_SCOPE = "repo";
|
|
26
|
-
function findGh() {
|
|
27
|
-
for (const entry of (process.env["PATH"] ?? "").split(delimiter).filter(Boolean)) {
|
|
28
|
-
if (existsSync(join(entry, "gh")))
|
|
29
|
-
return join(entry, "gh");
|
|
30
|
-
}
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
function run(cmd, args, cwd, stdin) {
|
|
34
|
-
return new Promise((resolve) => {
|
|
35
|
-
const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
36
|
-
let out = "";
|
|
37
|
-
child.stdout?.on("data", (chunk) => {
|
|
38
|
-
out += chunk;
|
|
39
|
-
});
|
|
40
|
-
child.stderr?.on("data", (chunk) => {
|
|
41
|
-
out += chunk;
|
|
42
|
-
});
|
|
43
|
-
child.on("error", (error) => resolve({ code: 1, out: String(error) }));
|
|
44
|
-
child.on("exit", (code) => resolve({ code: code ?? 1, out }));
|
|
45
|
-
child.stdin?.end(stdin ?? "");
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
async function ghAuth(cwd) {
|
|
49
|
-
const gh = findGh();
|
|
50
|
-
if (!gh) {
|
|
51
|
-
return { ok: false, reason: "the GitHub CLI is not installed. https://cli.github.com — then `gh auth login`." };
|
|
52
|
-
}
|
|
53
|
-
const status = await run(gh, ["auth", "status"], cwd);
|
|
54
|
-
if (status.code !== 0) {
|
|
55
|
-
return { ok: false, reason: "gh is not logged in. Run `gh auth login`." };
|
|
56
|
-
}
|
|
57
|
-
const user = /Logged in to \S+ account (\S+)/.exec(status.out)?.[1];
|
|
58
|
-
const scopes = /Token scopes: (.+)/.exec(status.out)?.[1]?.split(",").map((scope) => scope.trim().replace(/^'|'$/g, ""));
|
|
59
|
-
if (scopes && !scopes.includes(REPO_SCOPE)) {
|
|
60
|
-
return {
|
|
61
|
-
ok: false,
|
|
62
|
-
user,
|
|
63
|
-
scopes,
|
|
64
|
-
reason: `gh is logged in as ${user ?? "you"} but its token lacks the \`${REPO_SCOPE}\` scope, which writing repository secrets needs.
|
|
65
|
-
Run \`gh auth refresh -s ${REPO_SCOPE}\`.`
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
return { ok: true, user, scopes };
|
|
69
|
-
}
|
|
70
|
-
async function repoSlug(cwd) {
|
|
71
|
-
const gh = findGh();
|
|
72
|
-
if (!gh)
|
|
73
|
-
return null;
|
|
74
|
-
const viewed = await run(gh, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], cwd);
|
|
75
|
-
if (viewed.code !== 0)
|
|
76
|
-
return null;
|
|
77
|
-
const slug = viewed.out.trim();
|
|
78
|
-
return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
|
|
79
|
-
}
|
|
80
|
-
async function setVariable(cwd, name, value) {
|
|
81
|
-
const gh = findGh();
|
|
82
|
-
if (!gh)
|
|
83
|
-
return { ok: false, error: "gh is not installed" };
|
|
84
|
-
const result = await run(gh, ["variable", "set", name, "--body", value], cwd);
|
|
85
|
-
return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split(`
|
|
86
|
-
`).slice(-2).join(" ") };
|
|
87
|
-
}
|
|
88
|
-
async function getVariable(cwd, name) {
|
|
89
|
-
const gh = findGh();
|
|
90
|
-
if (!gh)
|
|
91
|
-
return null;
|
|
92
|
-
const got = await run(gh, ["variable", "get", name, "--json", "value", "-q", ".value"], cwd);
|
|
93
|
-
if (got.code !== 0)
|
|
94
|
-
return null;
|
|
95
|
-
const value = got.out.trim();
|
|
96
|
-
return value.length > 0 ? value : null;
|
|
97
|
-
}
|
|
98
|
-
async function variableNames(cwd) {
|
|
99
|
-
const gh = findGh();
|
|
100
|
-
if (!gh)
|
|
101
|
-
return new Set;
|
|
102
|
-
const listed = await run(gh, ["variable", "list", "--json", "name", "-q", ".[].name"], cwd);
|
|
103
|
-
if (listed.code !== 0)
|
|
104
|
-
return new Set;
|
|
105
|
-
return new Set(listed.out.split(`
|
|
106
|
-
`).map((line) => line.trim()).filter(Boolean));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
25
|
// src/deploy/secrets.ts
|
|
110
|
-
import { existsSync
|
|
111
|
-
import { spawn
|
|
112
|
-
import { delimiter
|
|
26
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import { delimiter, dirname, join } from "node:path";
|
|
113
29
|
import { tmpdir } from "node:os";
|
|
114
30
|
import color from "picocolors";
|
|
115
31
|
|
|
@@ -272,19 +188,19 @@ var PRIVATE_KEY_VAR = "DOTENV_PRIVATE_KEY_SECRETS";
|
|
|
272
188
|
function findDotenvx(from) {
|
|
273
189
|
let dir = from;
|
|
274
190
|
for (;; ) {
|
|
275
|
-
const local =
|
|
276
|
-
if (
|
|
191
|
+
const local = join(dir, "node_modules", ".bin", "dotenvx");
|
|
192
|
+
if (existsSync(local))
|
|
277
193
|
return local;
|
|
278
194
|
const parent = dirname(dir);
|
|
279
195
|
if (parent === dir)
|
|
280
196
|
break;
|
|
281
197
|
dir = parent;
|
|
282
198
|
}
|
|
283
|
-
return (process.env["PATH"] ?? "").split(
|
|
199
|
+
return (process.env["PATH"] ?? "").split(delimiter).filter(Boolean).some((entry) => existsSync(join(entry, "dotenvx"))) ? "dotenvx" : null;
|
|
284
200
|
}
|
|
285
|
-
function
|
|
201
|
+
function run(cmd, args, cwd, env = {}) {
|
|
286
202
|
return new Promise((resolve) => {
|
|
287
|
-
const child =
|
|
203
|
+
const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
|
|
288
204
|
let out = "";
|
|
289
205
|
child.stdout.on("data", (chunk) => {
|
|
290
206
|
out += chunk;
|
|
@@ -297,9 +213,9 @@ function run2(cmd, args, cwd, env = {}) {
|
|
|
297
213
|
});
|
|
298
214
|
}
|
|
299
215
|
function committedPublicKeyInto(root, publicKey) {
|
|
300
|
-
const path =
|
|
216
|
+
const path = join(root, SECRETS_FILE);
|
|
301
217
|
const line = `DOTENV_PUBLIC_KEY_SECRETS="${publicKey}"`;
|
|
302
|
-
if (!
|
|
218
|
+
if (!existsSync(path)) {
|
|
303
219
|
writeFileSync(path, `${line}
|
|
304
220
|
`, { mode: 384 });
|
|
305
221
|
return;
|
|
@@ -309,8 +225,8 @@ function committedPublicKeyInto(root, publicKey) {
|
|
|
309
225
|
${body}`);
|
|
310
226
|
}
|
|
311
227
|
function declaredSecretNames(root) {
|
|
312
|
-
const path =
|
|
313
|
-
if (!
|
|
228
|
+
const path = join(root, SECRETS_FILE);
|
|
229
|
+
if (!existsSync(path))
|
|
314
230
|
return new Set;
|
|
315
231
|
const names = new Set;
|
|
316
232
|
for (const line of readFileSync(path, "utf8").split(`
|
|
@@ -322,8 +238,8 @@ function declaredSecretNames(root) {
|
|
|
322
238
|
return names;
|
|
323
239
|
}
|
|
324
240
|
function unsetSecretNames(root) {
|
|
325
|
-
const path =
|
|
326
|
-
if (!
|
|
241
|
+
const path = join(root, SECRETS_FILE);
|
|
242
|
+
if (!existsSync(path))
|
|
327
243
|
return [];
|
|
328
244
|
const out = [];
|
|
329
245
|
for (const line of readFileSync(path, "utf8").split(`
|
|
@@ -337,8 +253,8 @@ function unsetSecretNames(root) {
|
|
|
337
253
|
return out;
|
|
338
254
|
}
|
|
339
255
|
function plaintextSecretNames(root) {
|
|
340
|
-
const path =
|
|
341
|
-
if (!
|
|
256
|
+
const path = join(root, SECRETS_FILE);
|
|
257
|
+
if (!existsSync(path))
|
|
342
258
|
return [];
|
|
343
259
|
const bare = [];
|
|
344
260
|
for (const line of readFileSync(path, "utf8").split(`
|
|
@@ -353,8 +269,8 @@ function plaintextSecretNames(root) {
|
|
|
353
269
|
return bare;
|
|
354
270
|
}
|
|
355
271
|
function plaintextSecretEntries(root) {
|
|
356
|
-
const path =
|
|
357
|
-
if (!
|
|
272
|
+
const path = join(root, SECRETS_FILE);
|
|
273
|
+
if (!existsSync(path))
|
|
358
274
|
return [];
|
|
359
275
|
const out = [];
|
|
360
276
|
for (const line of readFileSync(path, "utf8").split(`
|
|
@@ -370,9 +286,9 @@ function plaintextSecretEntries(root) {
|
|
|
370
286
|
}
|
|
371
287
|
async function decryptSecrets(project) {
|
|
372
288
|
const root = project.root;
|
|
373
|
-
if (!
|
|
289
|
+
if (!existsSync(join(root, SECRETS_FILE)))
|
|
374
290
|
return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
|
|
375
|
-
const { localPrivateKey } = await import("./keys-
|
|
291
|
+
const { localPrivateKey } = await import("./keys-adh7e74k.js");
|
|
376
292
|
const privateKey = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root) || "";
|
|
377
293
|
if (!privateKey) {
|
|
378
294
|
return {
|
|
@@ -384,7 +300,7 @@ async function decryptSecrets(project) {
|
|
|
384
300
|
}
|
|
385
301
|
const expected = committedPublicKey(root);
|
|
386
302
|
if (expected) {
|
|
387
|
-
const { publicKeyFor } = await import("./keys-
|
|
303
|
+
const { publicKeyFor } = await import("./keys-adh7e74k.js");
|
|
388
304
|
const derived = await publicKeyFor(privateKey);
|
|
389
305
|
if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
|
|
390
306
|
return {
|
|
@@ -398,7 +314,7 @@ async function decryptSecrets(project) {
|
|
|
398
314
|
const dotenvx = findDotenvx(root);
|
|
399
315
|
if (!dotenvx)
|
|
400
316
|
return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
|
|
401
|
-
const result = await
|
|
317
|
+
const result = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root, {
|
|
402
318
|
[PRIVATE_KEY_VAR]: privateKey
|
|
403
319
|
});
|
|
404
320
|
if (result.code !== 0)
|
|
@@ -412,7 +328,7 @@ async function decryptSecrets(project) {
|
|
|
412
328
|
return !deployOnly.has(line.slice(0, line.indexOf("=")).trim());
|
|
413
329
|
});
|
|
414
330
|
const names = lines.map((line) => line.slice(0, line.indexOf("=")).trim());
|
|
415
|
-
const path =
|
|
331
|
+
const path = join(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
|
|
416
332
|
writeFileSync(path, `${lines.join(`
|
|
417
333
|
`)}
|
|
418
334
|
`, { mode: 384 });
|
|
@@ -440,7 +356,7 @@ async function secretsCommand(project, args) {
|
|
|
440
356
|
return syncSecrets(project, args.includes("--prune"));
|
|
441
357
|
if (args.includes("--adopt"))
|
|
442
358
|
return adoptSecrets(project, args.includes("--confirm"));
|
|
443
|
-
if (!
|
|
359
|
+
if (!existsSync(join(root, SECRETS_FILE))) {
|
|
444
360
|
console.log(`
|
|
445
361
|
${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
|
|
446
362
|
which means the shop cannot be rebuilt from a checkout.
|
|
@@ -480,8 +396,8 @@ ${color.green("✓")} every secret this shop needs is declared and encrypted.
|
|
|
480
396
|
return 0;
|
|
481
397
|
}
|
|
482
398
|
async function initSecrets(project) {
|
|
483
|
-
const path =
|
|
484
|
-
if (
|
|
399
|
+
const path = join(project.root, SECRETS_FILE);
|
|
400
|
+
if (existsSync(path) && declaredSecretNames(project.root).size > 0) {
|
|
485
401
|
console.error(`vc: ${SECRETS_FILE} already declares secrets; not overwriting it.
|
|
486
402
|
\`vc secrets set KEY\` changes one.`);
|
|
487
403
|
return 1;
|
|
@@ -542,7 +458,7 @@ vc: "${name}" is not a key name — they are SHOUTING_SNAKE_CASE.
|
|
|
542
458
|
`);
|
|
543
459
|
return 1;
|
|
544
460
|
}
|
|
545
|
-
if (!
|
|
461
|
+
if (!existsSync(join(root, SECRETS_FILE))) {
|
|
546
462
|
console.error(`
|
|
547
463
|
vc: no ${SECRETS_FILE} yet. \`vc secrets --init\` writes one.
|
|
548
464
|
`);
|
|
@@ -645,7 +561,7 @@ async function encryptInto(root, publicKey, name, value) {
|
|
|
645
561
|
if (looksWrong) {
|
|
646
562
|
return { ok: false, error: `refusing to write ${name} — the result does not look encrypted.` };
|
|
647
563
|
}
|
|
648
|
-
upsertLine(
|
|
564
|
+
upsertLine(join(root, SECRETS_FILE), name, ciphertext);
|
|
649
565
|
return { ok: true };
|
|
650
566
|
}
|
|
651
567
|
function upsertLine(path, name, value) {
|
|
@@ -657,8 +573,8 @@ function upsertLine(path, name, value) {
|
|
|
657
573
|
}
|
|
658
574
|
async function syncSecrets(project, prune) {
|
|
659
575
|
const root = project.root;
|
|
660
|
-
const path =
|
|
661
|
-
if (!
|
|
576
|
+
const path = join(root, SECRETS_FILE);
|
|
577
|
+
if (!existsSync(path)) {
|
|
662
578
|
console.error(`
|
|
663
579
|
vc: no ${SECRETS_FILE} yet. \`vc secrets --init\` writes one.
|
|
664
580
|
`);
|
|
@@ -725,14 +641,14 @@ ${color.green("✓")} every secret this shop needs is already declared.
|
|
|
725
641
|
}
|
|
726
642
|
async function adoptSecrets(project, confirmed) {
|
|
727
643
|
const root = project.root;
|
|
728
|
-
const path =
|
|
729
|
-
if (!
|
|
644
|
+
const path = join(root, SECRETS_FILE);
|
|
645
|
+
if (!existsSync(path)) {
|
|
730
646
|
console.error(`
|
|
731
647
|
vc: no ${SECRETS_FILE} here — \`vc secrets --init\` starts a fresh one.
|
|
732
648
|
`);
|
|
733
649
|
return 1;
|
|
734
650
|
}
|
|
735
|
-
const { localPrivateKey, publicKeyFor, generateKeypair } = await import("./keys-
|
|
651
|
+
const { localPrivateKey, publicKeyFor, generateKeypair } = await import("./keys-adh7e74k.js");
|
|
736
652
|
const current = committedPublicKey(root);
|
|
737
653
|
const mine = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root);
|
|
738
654
|
if (mine && current && (await publicKeyFor(mine))?.toLowerCase() === current.toLowerCase()) {
|
|
@@ -776,7 +692,7 @@ ${color.bold("Adopting this shop would:")}
|
|
|
776
692
|
}).join(`
|
|
777
693
|
`);
|
|
778
694
|
writeFileSync(path, body);
|
|
779
|
-
writeFileSync(
|
|
695
|
+
writeFileSync(join(root, ".env.keys"), `${PRIVATE_KEY_VAR}="${pair.privateKey}"
|
|
780
696
|
`, { mode: 384 });
|
|
781
697
|
console.log(`
|
|
782
698
|
${color.green("✓")} adopted: a new key, and ${declared.length} value${declared.length === 1 ? "" : "s"} reset to \`unset\`
|
|
@@ -796,15 +712,15 @@ ${required.slice(0, 4).map((key) => ` ${color.cyan(`vc secrets set ${key.key}`)
|
|
|
796
712
|
// src/deploy/keys.ts
|
|
797
713
|
var LOCAL_KEY_FILE = ".env.keys";
|
|
798
714
|
function localPrivateKey(root) {
|
|
799
|
-
const path =
|
|
800
|
-
if (!
|
|
715
|
+
const path = join2(root, LOCAL_KEY_FILE);
|
|
716
|
+
if (!existsSync2(path))
|
|
801
717
|
return null;
|
|
802
718
|
const match = new RegExp(`^\\s*${PRIVATE_KEY_VAR}\\s*=\\s*["']?([0-9a-fA-F]+)["']?`, "m").exec(readFileSync2(path, "utf8"));
|
|
803
719
|
return match?.[1] ?? null;
|
|
804
720
|
}
|
|
805
721
|
function committedPublicKey(root) {
|
|
806
|
-
const path =
|
|
807
|
-
if (!
|
|
722
|
+
const path = join2(root, SECRETS_FILE);
|
|
723
|
+
if (!existsSync2(path))
|
|
808
724
|
return null;
|
|
809
725
|
const match = /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=\s*["']?([0-9a-fA-F]+)["']?/m.exec(readFileSync2(path, "utf8"));
|
|
810
726
|
return match?.[1] ?? null;
|
|
@@ -865,13 +781,13 @@ async function provisionKey(project) {
|
|
|
865
781
|
would publish the key that opens every secret in the repository.`
|
|
866
782
|
};
|
|
867
783
|
}
|
|
868
|
-
writeFileSync2(
|
|
784
|
+
writeFileSync2(join2(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${pair.privateKey}"
|
|
869
785
|
`, { mode: 384 });
|
|
870
786
|
return { ok: true, publicKey: pair.publicKey, parked: true };
|
|
871
787
|
}
|
|
872
788
|
function ignoresKeyFile(root) {
|
|
873
|
-
const path =
|
|
874
|
-
if (!
|
|
789
|
+
const path = join2(root, ".gitignore");
|
|
790
|
+
if (!existsSync2(path))
|
|
875
791
|
return false;
|
|
876
792
|
return readFileSync2(path, "utf8").split(`
|
|
877
793
|
`).map((line) => line.trim()).some((line) => line === LOCAL_KEY_FILE || line === `/${LOCAL_KEY_FILE}` || line === ".env.keys*" || line === ".env*");
|
|
@@ -920,7 +836,7 @@ vc: ${LOCAL_KEY_FILE} is not gitignored here, and this would write the key into
|
|
|
920
836
|
`);
|
|
921
837
|
return 1;
|
|
922
838
|
}
|
|
923
|
-
writeFileSync2(
|
|
839
|
+
writeFileSync2(join2(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${key}"
|
|
924
840
|
`, { mode: 384 });
|
|
925
841
|
console.log(`
|
|
926
842
|
${color2.green("✓")} key restored from ${slug} into ${LOCAL_KEY_FILE}
|
|
@@ -940,7 +856,7 @@ async function keysCommand(project, args) {
|
|
|
940
856
|
Secrets are encrypted with a public key that is COMMITTED, and read with a`);
|
|
941
857
|
console.log(`private key that lives only in GitHub Actions.
|
|
942
858
|
`);
|
|
943
|
-
const hasFile =
|
|
859
|
+
const hasFile = existsSync2(join2(project.root, SECRETS_FILE));
|
|
944
860
|
const publicNote = state.publicKey ? `${state.publicKey.slice(0, 20)}… in ${SECRETS_FILE}` : color2.dim(hasFile ? `${SECRETS_FILE} has no key yet — \`vc keys --init\` makes one` : `no ${SECRETS_FILE} yet — \`vc secrets --init\``);
|
|
945
861
|
console.log(` ${state.publicKey ? color2.green("✓") : color2.dim("·")} public key ${publicNote}`);
|
|
946
862
|
const parked = localPrivateKey(project.root) !== null;
|
|
@@ -976,7 +892,7 @@ vc: ${made.reason}
|
|
|
976
892
|
`);
|
|
977
893
|
return 1;
|
|
978
894
|
}
|
|
979
|
-
const fresh = !
|
|
895
|
+
const fresh = !existsSync2(join2(project.root, SECRETS_FILE));
|
|
980
896
|
committedPublicKeyInto(project.root, made.publicKey);
|
|
981
897
|
console.log(made.parked ? `
|
|
982
898
|
${color2.green("✓")} key made; public half in ${SECRETS_FILE}, private half waiting in ${LOCAL_KEY_FILE}
|
|
@@ -1027,7 +943,7 @@ vc: ${state.mismatch}
|
|
|
1027
943
|
console.error("vc: dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`");
|
|
1028
944
|
return 1;
|
|
1029
945
|
}
|
|
1030
|
-
const decrypted = await
|
|
946
|
+
const decrypted = await run(dotenvx, ["decrypt", "-f", SECRETS_FILE], project.root, { [PRIVATE_KEY_VAR]: old });
|
|
1031
947
|
if (decrypted.code !== 0) {
|
|
1032
948
|
console.error(`
|
|
1033
949
|
vc: could not decrypt ${SECRETS_FILE}: ${decrypted.out.trim().split(`
|
|
@@ -1046,7 +962,7 @@ vc: ${made.reason}
|
|
|
1046
962
|
return 1;
|
|
1047
963
|
}
|
|
1048
964
|
committedPublicKeyInto(project.root, made.publicKey);
|
|
1049
|
-
const encrypted = await
|
|
965
|
+
const encrypted = await run(dotenvx, ["encrypt", "-f", SECRETS_FILE], project.root);
|
|
1050
966
|
if (encrypted.code !== 0) {
|
|
1051
967
|
console.error(`
|
|
1052
968
|
vc: re-encryption failed: ${encrypted.out.trim().split(`
|
|
@@ -1064,4 +980,4 @@ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PR
|
|
|
1064
980
|
return 0;
|
|
1065
981
|
}
|
|
1066
982
|
|
|
1067
|
-
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary,
|
|
983
|
+
export { allEnvKeys, renderEnvTs, renderEnvExample, MANIFEST_OWNED_ENV, renderEnvLocal, renderEnvProduction, envSummary, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, findDotenvx, run, committedPublicKeyInto, declaredSecretNames, unsetSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, initSecrets, setSecretValue, secretValue, encryptInto };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/deploy/github.ts
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
function apiToken() {
|
|
6
|
+
return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
|
|
7
|
+
}
|
|
8
|
+
var REPO_SCOPE = "repo";
|
|
9
|
+
function findGh() {
|
|
10
|
+
for (const entry of (process.env["PATH"] ?? "").split(delimiter).filter(Boolean)) {
|
|
11
|
+
if (existsSync(join(entry, "gh")))
|
|
12
|
+
return join(entry, "gh");
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
function run(cmd, args, cwd, stdin) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
19
|
+
let out = "";
|
|
20
|
+
child.stdout?.on("data", (chunk) => {
|
|
21
|
+
out += chunk;
|
|
22
|
+
});
|
|
23
|
+
child.stderr?.on("data", (chunk) => {
|
|
24
|
+
out += chunk;
|
|
25
|
+
});
|
|
26
|
+
child.on("error", (error) => resolve({ code: 1, out: String(error) }));
|
|
27
|
+
child.on("exit", (code) => resolve({ code: code ?? 1, out }));
|
|
28
|
+
child.stdin?.end(stdin ?? "");
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function ghAuth(cwd) {
|
|
32
|
+
const gh = findGh();
|
|
33
|
+
if (!gh) {
|
|
34
|
+
return { ok: false, reason: "the GitHub CLI is not installed. https://cli.github.com — then `gh auth login`." };
|
|
35
|
+
}
|
|
36
|
+
const status = await run(gh, ["auth", "status"], cwd);
|
|
37
|
+
if (status.code !== 0) {
|
|
38
|
+
return { ok: false, reason: "gh is not logged in. Run `gh auth login`." };
|
|
39
|
+
}
|
|
40
|
+
const user = /Logged in to \S+ account (\S+)/.exec(status.out)?.[1];
|
|
41
|
+
const scopes = /Token scopes: (.+)/.exec(status.out)?.[1]?.split(",").map((scope) => scope.trim().replace(/^'|'$/g, ""));
|
|
42
|
+
if (scopes && !scopes.includes(REPO_SCOPE)) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
user,
|
|
46
|
+
scopes,
|
|
47
|
+
reason: `gh is logged in as ${user ?? "you"} but its token lacks the \`${REPO_SCOPE}\` scope, which writing repository secrets needs.
|
|
48
|
+
Run \`gh auth refresh -s ${REPO_SCOPE}\`.`
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, user, scopes };
|
|
52
|
+
}
|
|
53
|
+
async function repoSlug(cwd) {
|
|
54
|
+
const gh = findGh();
|
|
55
|
+
if (!gh)
|
|
56
|
+
return null;
|
|
57
|
+
const viewed = await run(gh, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], cwd);
|
|
58
|
+
if (viewed.code !== 0)
|
|
59
|
+
return null;
|
|
60
|
+
const slug = viewed.out.trim();
|
|
61
|
+
return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
|
|
62
|
+
}
|
|
63
|
+
async function setSecret(cwd, name, value) {
|
|
64
|
+
const gh = findGh();
|
|
65
|
+
if (!gh)
|
|
66
|
+
return { ok: false, error: "gh is not installed" };
|
|
67
|
+
const result = await run(gh, ["secret", "set", name], cwd, value);
|
|
68
|
+
return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split(`
|
|
69
|
+
`).slice(-2).join(" ") };
|
|
70
|
+
}
|
|
71
|
+
async function secretNames(cwd) {
|
|
72
|
+
const gh = findGh();
|
|
73
|
+
if (!gh)
|
|
74
|
+
return new Set;
|
|
75
|
+
const listed = await run(gh, ["secret", "list", "--json", "name", "-q", ".[].name"], cwd);
|
|
76
|
+
if (listed.code !== 0)
|
|
77
|
+
return new Set;
|
|
78
|
+
return new Set(listed.out.split(`
|
|
79
|
+
`).map((line) => line.trim()).filter(Boolean));
|
|
80
|
+
}
|
|
81
|
+
async function setVariable(cwd, name, value) {
|
|
82
|
+
const gh = findGh();
|
|
83
|
+
if (!gh)
|
|
84
|
+
return { ok: false, error: "gh is not installed" };
|
|
85
|
+
const result = await run(gh, ["variable", "set", name, "--body", value], cwd);
|
|
86
|
+
return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split(`
|
|
87
|
+
`).slice(-2).join(" ") };
|
|
88
|
+
}
|
|
89
|
+
async function getVariable(cwd, name) {
|
|
90
|
+
const gh = findGh();
|
|
91
|
+
if (!gh)
|
|
92
|
+
return null;
|
|
93
|
+
const got = await run(gh, ["variable", "get", name, "--json", "value", "-q", ".value"], cwd);
|
|
94
|
+
if (got.code !== 0)
|
|
95
|
+
return null;
|
|
96
|
+
const value = got.out.trim();
|
|
97
|
+
return value.length > 0 ? value : null;
|
|
98
|
+
}
|
|
99
|
+
async function variableNames(cwd) {
|
|
100
|
+
const gh = findGh();
|
|
101
|
+
if (!gh)
|
|
102
|
+
return new Set;
|
|
103
|
+
const listed = await run(gh, ["variable", "list", "--json", "name", "-q", ".[].name"], cwd);
|
|
104
|
+
if (listed.code !== 0)
|
|
105
|
+
return new Set;
|
|
106
|
+
return new Set(listed.out.split(`
|
|
107
|
+
`).map((line) => line.trim()).filter(Boolean));
|
|
108
|
+
}
|
|
109
|
+
async function verifyCloudflareToken(token) {
|
|
110
|
+
try {
|
|
111
|
+
const response = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
|
|
112
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
113
|
+
});
|
|
114
|
+
const body = await response.json();
|
|
115
|
+
if (body.success && body.result?.status === "active")
|
|
116
|
+
return { ok: true, detail: "active" };
|
|
117
|
+
const message = body.errors?.[0]?.message ?? `HTTP ${response.status}`;
|
|
118
|
+
return { ok: false, detail: message };
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return { ok: false, detail: `could not reach the Cloudflare API: ${String(error)}` };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function cloudflareAccounts(token) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch("https://api.cloudflare.com/client/v4/accounts?per_page=50", {
|
|
126
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
127
|
+
});
|
|
128
|
+
const body = await response.json();
|
|
129
|
+
return body.success && Array.isArray(body.result) ? body.result.map((a) => ({ id: a.id, name: a.name })) : [];
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export { apiToken, findGh, run, ghAuth, repoSlug, setSecret, secretNames, setVariable, getVariable, variableNames, verifyCloudflareToken, cloudflareAccounts };
|
package/dist/index.js
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
routeProblem,
|
|
53
53
|
strictDependencies,
|
|
54
54
|
upsertJsonc
|
|
55
|
-
} from "./index-
|
|
55
|
+
} from "./index-3wftdrq5.js";
|
|
56
56
|
import {
|
|
57
57
|
allEnvKeys,
|
|
58
58
|
envSummary,
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
renderEnvLocal,
|
|
61
61
|
renderEnvProduction,
|
|
62
62
|
renderEnvTs
|
|
63
|
-
} from "./index-
|
|
63
|
+
} from "./index-hvrbdp85.js";
|
|
64
64
|
import {
|
|
65
65
|
LAYOUTS,
|
|
66
66
|
MANIFEST_FILE,
|
|
@@ -100,6 +100,7 @@ import {
|
|
|
100
100
|
UI
|
|
101
101
|
} from "./index-xyjhy6kp.js";
|
|
102
102
|
import"./index-6e4eh08g.js";
|
|
103
|
+
import"./index-vj6akbny.js";
|
|
103
104
|
import"./index-0v6na3yp.js";
|
|
104
105
|
export {
|
|
105
106
|
zoneOf,
|
|
@@ -8,9 +8,10 @@ import {
|
|
|
8
8
|
localPrivateKey,
|
|
9
9
|
provisionKey,
|
|
10
10
|
publicKeyFor
|
|
11
|
-
} from "./index-
|
|
11
|
+
} from "./index-hvrbdp85.js";
|
|
12
12
|
import"./index-30y19qz5.js";
|
|
13
13
|
import"./index-xyjhy6kp.js";
|
|
14
|
+
import"./index-vj6akbny.js";
|
|
14
15
|
import"./index-0v6na3yp.js";
|
|
15
16
|
export {
|
|
16
17
|
publicKeyFor,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PRIVATE_KEY_VAR,
|
|
3
|
+
SECRETS_FILE,
|
|
4
|
+
committedPublicKeyInto,
|
|
5
|
+
declaredSecretNames,
|
|
6
|
+
decryptSecrets,
|
|
7
|
+
encryptInto,
|
|
8
|
+
findDotenvx,
|
|
9
|
+
initSecrets,
|
|
10
|
+
plaintextSecretEntries,
|
|
11
|
+
plaintextSecretNames,
|
|
12
|
+
run,
|
|
13
|
+
secretValue,
|
|
14
|
+
secretsCommand,
|
|
15
|
+
setSecretValue,
|
|
16
|
+
unsetSecretNames
|
|
17
|
+
} from "./index-hvrbdp85.js";
|
|
18
|
+
import"./index-30y19qz5.js";
|
|
19
|
+
import"./index-xyjhy6kp.js";
|
|
20
|
+
import"./index-vj6akbny.js";
|
|
21
|
+
import"./index-0v6na3yp.js";
|
|
22
|
+
export {
|
|
23
|
+
unsetSecretNames,
|
|
24
|
+
setSecretValue,
|
|
25
|
+
secretsCommand,
|
|
26
|
+
secretValue,
|
|
27
|
+
run,
|
|
28
|
+
plaintextSecretNames,
|
|
29
|
+
plaintextSecretEntries,
|
|
30
|
+
initSecrets,
|
|
31
|
+
findDotenvx,
|
|
32
|
+
encryptInto,
|
|
33
|
+
decryptSecrets,
|
|
34
|
+
declaredSecretNames,
|
|
35
|
+
committedPublicKeyInto,
|
|
36
|
+
SECRETS_FILE,
|
|
37
|
+
PRIVATE_KEY_VAR
|
|
38
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saastemly/voidcommerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
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/deploy/index.ts
CHANGED
|
@@ -344,6 +344,7 @@ export async function teardownCliCommand(args: string[]): Promise<number> {
|
|
|
344
344
|
return teardownCloudflare(project, {
|
|
345
345
|
confirm: confirmed,
|
|
346
346
|
keepData: args.includes("--keep-data"),
|
|
347
|
+
github: args.includes("--github"),
|
|
347
348
|
dryRun: args.includes("--dry-run"),
|
|
348
349
|
});
|
|
349
350
|
}
|
|
@@ -358,6 +359,7 @@ export async function teardownHelp(): Promise<number> {
|
|
|
358
359
|
...row("vc teardown --cloudflare --dry-run", "list what would be destroyed, touch nothing", width, 2),
|
|
359
360
|
...row("vc teardown --cloudflare --confirm <domain>", "do it", width, 2),
|
|
360
361
|
...row("--keep-data", "leave the D1 database alone", width, 2),
|
|
362
|
+
...row("--github", "delete the GitHub repository too, so the next publish is from nothing", width, 2),
|
|
361
363
|
line("", width),
|
|
362
364
|
line(color.bold("Read this first"), width),
|
|
363
365
|
line("D1 deletion has NO UNDO. Every order, customer and address in the", width),
|
|
@@ -366,6 +368,13 @@ export async function teardownHelp(): Promise<number> {
|
|
|
366
368
|
line("So --confirm takes the shop's DOMAIN, not `y`: a `y` is muscle memory,", width),
|
|
367
369
|
line("and typing the domain is what stops this running in the wrong shop.", width),
|
|
368
370
|
line("", width),
|
|
371
|
+
line("--github is separate and opt-in because the blast radius differs in", width),
|
|
372
|
+
line("kind: Cloudflare holds nothing that cannot be rebuilt from the", width),
|
|
373
|
+
line("repository, and the repository is what everything is rebuilt FROM. It", width),
|
|
374
|
+
line("also pulls the encryption key back onto disk BEFORE deleting, because", width),
|
|
375
|
+
line("that key lives as a repository variable — delete the repository with no", width),
|
|
376
|
+
line("local copy and every encrypted value is unreadable forever.", width),
|
|
377
|
+
line("", width),
|
|
369
378
|
line("It is idempotent — deleting what is already gone is success — and it", width),
|
|
370
379
|
line("clears the recorded database id, because `wrangler deploy` FAILS on a", width),
|
|
371
380
|
line("dangling id rather than making a new database. Secrets are untouched:", width),
|
package/src/deploy/teardown.ts
CHANGED
|
@@ -42,6 +42,14 @@ export interface TeardownOptions {
|
|
|
42
42
|
confirm: boolean;
|
|
43
43
|
/** Keep the D1 database — the one thing that holds data nobody can recreate. */
|
|
44
44
|
keepData: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Delete the GitHub repository too, so `git publish from scratch` really
|
|
47
|
+
* is from scratch. Opt-in and separate from the Cloudflare teardown,
|
|
48
|
+
* because the blast radius is different in kind: Cloudflare holds nothing
|
|
49
|
+
* that cannot be rebuilt from the repository, and the repository is the
|
|
50
|
+
* thing everything is rebuilt FROM.
|
|
51
|
+
*/
|
|
52
|
+
github: boolean;
|
|
45
53
|
dryRun: boolean;
|
|
46
54
|
}
|
|
47
55
|
|
|
@@ -100,6 +108,17 @@ export async function teardownCloudflare(project: Project, opts: TeardownOptions
|
|
|
100
108
|
: ` database ${color.dim("none recorded")}`,
|
|
101
109
|
);
|
|
102
110
|
console.log(plan.queues.length ? ` queues ${plan.queues.join(", ")}` : ` queues ${color.dim("none recorded")}`);
|
|
111
|
+
|
|
112
|
+
// A dry run has to show EVERYTHING it would do, or it is not a rehearsal.
|
|
113
|
+
if (opts.github) {
|
|
114
|
+
const { repoSlug } = await import("./github");
|
|
115
|
+
const slug = await repoSlug(project.root);
|
|
116
|
+
console.log(
|
|
117
|
+
slug
|
|
118
|
+
? `\n${color.bold("And on GitHub:")}\n\n repository ${slug} ${color.red("DELETED — code, history, issues, and the encryption key variable")}`
|
|
119
|
+
: `\n repository ${color.dim("none — there is nothing to delete")}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
103
122
|
console.log("");
|
|
104
123
|
|
|
105
124
|
if (opts.dryRun) {
|
|
@@ -167,9 +186,95 @@ export async function teardownCloudflare(project: Project, opts: TeardownOptions
|
|
|
167
186
|
console.log(`${color.green("✓")} the database id is out of wrangler.jsonc and the manifest, so the next deploy provisions afresh`);
|
|
168
187
|
}
|
|
169
188
|
|
|
189
|
+
if (opts.github) {
|
|
190
|
+
const code = await teardownRepository(project, opts);
|
|
191
|
+
if (code !== 0) return code;
|
|
192
|
+
}
|
|
193
|
+
|
|
170
194
|
console.log(
|
|
171
195
|
`\n${color.bold("Torn down.")} ${color.dim(`The shop's secrets are untouched in ${SECRETS_FILE} — this removed infrastructure, not configuration.`)}\n` +
|
|
172
196
|
` Push again, or ${color.cyan("vc deploy --cloudflare --provision")}, and it rebuilds.\n`,
|
|
173
197
|
);
|
|
174
198
|
return 0;
|
|
175
199
|
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Delete the GitHub repository — and rescue the key before doing it.
|
|
204
|
+
*
|
|
205
|
+
* ── The trap this exists to avoid ────────────────────────────────────────
|
|
206
|
+
*
|
|
207
|
+
* `DOTENV_PRIVATE_KEY_SECRETS` lives as a repository VARIABLE. Deleting the
|
|
208
|
+
* repository deletes it, and if the local `.env.keys` was already removed —
|
|
209
|
+
* which is the normal, recommended end state — then every value in
|
|
210
|
+
* `.env.secrets` becomes permanently unreadable the instant the repository
|
|
211
|
+
* goes. Including the Cloudflare token needed to tear Cloudflare down.
|
|
212
|
+
*
|
|
213
|
+
* A teardown that leaves you unable to rebuild is not a teardown, it is an
|
|
214
|
+
* accident. So the key is pulled back onto disk FIRST, and the repository is
|
|
215
|
+
* only deleted once it is safely there.
|
|
216
|
+
*/
|
|
217
|
+
async function teardownRepository(project: Project, opts: TeardownOptions): Promise<number> {
|
|
218
|
+
const root = project.root;
|
|
219
|
+
const { ghAuth, findGh, getVariable, repoSlug, run } = await import("./github");
|
|
220
|
+
const { LOCAL_KEY_FILE, localPrivateKey, ignoresKeyFile } = await import("./keys");
|
|
221
|
+
const { PRIVATE_KEY_VAR } = await import("./secrets");
|
|
222
|
+
|
|
223
|
+
const gh = findGh();
|
|
224
|
+
const auth = await ghAuth(root);
|
|
225
|
+
if (!gh || !auth.ok) return fail(auth.reason ?? "the GitHub CLI is not installed.");
|
|
226
|
+
|
|
227
|
+
const slug = await repoSlug(root);
|
|
228
|
+
if (!slug) {
|
|
229
|
+
console.log(color.dim("! no GitHub repository here — nothing to delete."));
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 1. Rescue the key, before anything is destroyed.
|
|
234
|
+
if (!localPrivateKey(root)) {
|
|
235
|
+
const remote = await getVariable(root, PRIVATE_KEY_VAR);
|
|
236
|
+
if (remote) {
|
|
237
|
+
if (!ignoresKeyFile(root)) {
|
|
238
|
+
return fail(`${LOCAL_KEY_FILE} is not gitignored, so the key cannot be rescued here. Add it to .gitignore first.`);
|
|
239
|
+
}
|
|
240
|
+
writeFileSync(join(root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${remote}"\n`, { mode: 0o600 });
|
|
241
|
+
console.log(`${color.green("✓")} key pulled back into ${LOCAL_KEY_FILE} before the repository goes`);
|
|
242
|
+
} else if (declaredHasEncrypted(root)) {
|
|
243
|
+
return fail(
|
|
244
|
+
`${slug} has no ${PRIVATE_KEY_VAR} and there is no ${LOCAL_KEY_FILE} here,\n` +
|
|
245
|
+
` but ${SECRETS_FILE} holds encrypted values. Deleting the repository would\n` +
|
|
246
|
+
" make them permanently unreadable. Find the key first, or clear the file.",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
console.log(color.dim(`= ${LOCAL_KEY_FILE} is already here, so the key survives the deletion`));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 2. Delete it.
|
|
254
|
+
console.log(`▸ gh repo delete ${slug}`);
|
|
255
|
+
const deleted = await run(gh, ["repo", "delete", slug, "--yes"], root);
|
|
256
|
+
if (deleted.code !== 0) {
|
|
257
|
+
if (/delete_repo|scope/i.test(deleted.out)) {
|
|
258
|
+
return fail(
|
|
259
|
+
`gh cannot delete repositories: its token has no \`delete_repo\` scope.\n\n` +
|
|
260
|
+
` ${color.cyan("gh auth refresh -s delete_repo")}\n\n` +
|
|
261
|
+
" That scope is not granted by default, deliberately — it is the one that\n" +
|
|
262
|
+
" destroys work rather than resources.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return fail(`could not delete ${slug}:\n${deleted.out.trim().split("\n").slice(-3).join("\n")}`);
|
|
266
|
+
}
|
|
267
|
+
console.log(`${color.green("✓")} ${slug} deleted`);
|
|
268
|
+
|
|
269
|
+
// 3. Drop the remote, so `gh repo create --source=.` works cleanly next time.
|
|
270
|
+
const removed = await run("git", ["remote", "remove", "origin"], root);
|
|
271
|
+
if (removed.code === 0) console.log(`${color.green("✓")} origin removed, so the next publish starts from nothing`);
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Does the secrets file hold anything that would be lost with the key? */
|
|
276
|
+
function declaredHasEncrypted(root: string): boolean {
|
|
277
|
+
const path = join(root, SECRETS_FILE);
|
|
278
|
+
if (!existsSync(path)) return false;
|
|
279
|
+
return /^\s*[A-Z][A-Z0-9_]*\s*=\s*["']?encrypted:/m.test(readFileSync(path, "utf8"));
|
|
280
|
+
}
|