@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 CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  version,
26
26
  voidAppsIn,
27
27
  wrangler
28
- } from "./index-51zee2vf.js";
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-t1ggktg8.js";
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-0v6na3yp.js";
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
- ` + ` This is not reversible: D1 has no undo, and the orders go with it.
136
+ This is not reversible: D1 has no undo, and the orders go with it.
125
137
 
126
- ` + ` ${color.cyan(`vc teardown --cloudflare --confirm ${project.manifest.shop.domain}`)}`);
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
- ` + ` Push again, or ${color.cyan("vc deploy --cloudflare --provision")}, and it rebuilds.
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-t1ggktg8.js";
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.22.1",
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 existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
15
- import { join as join3 } from "node:path";
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 as existsSync2, readFileSync, writeFileSync } from "node:fs";
111
- import { spawn as spawn2 } from "node:child_process";
112
- import { delimiter as delimiter2, dirname, join as join2 } from "node:path";
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 = join2(dir, "node_modules", ".bin", "dotenvx");
276
- if (existsSync2(local))
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(delimiter2).filter(Boolean).some((entry) => existsSync2(join2(entry, "dotenvx"))) ? "dotenvx" : null;
199
+ return (process.env["PATH"] ?? "").split(delimiter).filter(Boolean).some((entry) => existsSync(join(entry, "dotenvx"))) ? "dotenvx" : null;
284
200
  }
285
- function run2(cmd, args, cwd, env = {}) {
201
+ function run(cmd, args, cwd, env = {}) {
286
202
  return new Promise((resolve) => {
287
- const child = spawn2(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
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 = join2(root, SECRETS_FILE);
216
+ const path = join(root, SECRETS_FILE);
301
217
  const line = `DOTENV_PUBLIC_KEY_SECRETS="${publicKey}"`;
302
- if (!existsSync2(path)) {
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 = join2(root, SECRETS_FILE);
313
- if (!existsSync2(path))
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 = join2(root, SECRETS_FILE);
326
- if (!existsSync2(path))
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 = join2(root, SECRETS_FILE);
341
- if (!existsSync2(path))
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 = join2(root, SECRETS_FILE);
357
- if (!existsSync2(path))
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 (!existsSync2(join2(root, SECRETS_FILE)))
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-1h4zpvd7.js");
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-1h4zpvd7.js");
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 run2(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root, {
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 = join2(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
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 (!existsSync2(join2(root, SECRETS_FILE))) {
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 = join2(project.root, SECRETS_FILE);
484
- if (existsSync2(path) && declaredSecretNames(project.root).size > 0) {
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 (!existsSync2(join2(root, SECRETS_FILE))) {
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(join2(root, SECRETS_FILE), name, ciphertext);
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 = join2(root, SECRETS_FILE);
661
- if (!existsSync2(path)) {
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 = join2(root, SECRETS_FILE);
729
- if (!existsSync2(path)) {
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-1h4zpvd7.js");
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(join2(root, ".env.keys"), `${PRIVATE_KEY_VAR}="${pair.privateKey}"
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 = join3(root, LOCAL_KEY_FILE);
800
- if (!existsSync3(path))
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 = join3(root, SECRETS_FILE);
807
- if (!existsSync3(path))
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(join3(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${pair.privateKey}"
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 = join3(root, ".gitignore");
874
- if (!existsSync3(path))
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(join3(project.root, LOCAL_KEY_FILE), `${PRIVATE_KEY_VAR}="${key}"
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 = existsSync3(join3(project.root, SECRETS_FILE));
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 = !existsSync3(join3(project.root, SECRETS_FILE));
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 run2(dotenvx, ["decrypt", "-f", SECRETS_FILE], project.root, { [PRIVATE_KEY_VAR]: old });
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 run2(dotenvx, ["encrypt", "-f", SECRETS_FILE], project.root);
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, apiToken, LOCAL_KEY_FILE, localPrivateKey, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, ignoresKeyFile, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, run2 as run, declaredSecretNames, unsetSecretNames, plaintextSecretNames, plaintextSecretEntries, decryptSecrets, secretsCommand, secretValue, encryptInto };
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-51zee2vf.js";
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-t1ggktg8.js";
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-t1ggktg8.js";
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.22.1",
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",
@@ -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),
@@ -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
+ }