@saastemly/voidcommerce 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ import type { Project } from "../project";
2
+ /**
3
+ * `vc teardown --cloudflare` — undo a deploy, so the whole thing can be
4
+ * proved again from nothing.
5
+ *
6
+ * ── Why this exists ──────────────────────────────────────────────────────
7
+ *
8
+ * "Push and it goes live" is a claim, and a claim nobody can re-run is a
9
+ * story. Provisioning is the part most likely to break — it is the only
10
+ * step that creates rather than replaces, it runs once per shop, and it runs
11
+ * against an account whose state nobody controls. Being able to destroy and
12
+ * do it again is what turns the claim into something testable.
13
+ *
14
+ * ── This DELETES A PRODUCTION DATABASE ───────────────────────────────────
15
+ *
16
+ * D1 deletion is not recoverable: there is no undo and no snapshot unless
17
+ * one was taken. Every order, customer and address in it goes. So this
18
+ * refuses by default, and the confirmation is TYPING THE SHOP'S DOMAIN —
19
+ * not `y`, because `y` is muscle memory and a domain is not.
20
+ *
21
+ * It also refuses outright when the manifest's domain does not match what
22
+ * the wrangler config points at, since that mismatch is the signal that you
23
+ * are standing in a different shop than you think.
24
+ *
25
+ * ── Idempotent, deliberately ─────────────────────────────────────────────
26
+ *
27
+ * Deleting what is already gone is success, not failure. That is what makes
28
+ * it usable in a loop: teardown, publish, teardown again, without a human
29
+ * reading each result to decide whether the next step is safe.
30
+ */
31
+ export interface TeardownOptions {
32
+ /** Required. Without it this only ever prints what it would do. */
33
+ confirm: boolean;
34
+ /** Keep the D1 database — the one thing that holds data nobody can recreate. */
35
+ keepData: boolean;
36
+ dryRun: boolean;
37
+ }
38
+ interface WranglerConfig {
39
+ name?: string;
40
+ account_id?: string;
41
+ d1_databases?: Array<{
42
+ binding: string;
43
+ database_name: string;
44
+ database_id: string;
45
+ }>;
46
+ queues?: {
47
+ producers?: Array<{
48
+ queue: string;
49
+ }>;
50
+ consumers?: Array<{
51
+ queue: string;
52
+ }>;
53
+ };
54
+ }
55
+ /** What a teardown would destroy, read from the config rather than guessed. */
56
+ export declare function teardownPlan(config: WranglerConfig, fallbackWorker: string): {
57
+ worker: string;
58
+ d1: string | null;
59
+ queues: string[];
60
+ };
61
+ export declare function teardownCloudflare(project: Project, opts: TeardownOptions): Promise<number>;
62
+ export {};
@@ -13,7 +13,7 @@ import {
13
13
  renderEnvTs,
14
14
  secretValue,
15
15
  unsetSecretNames
16
- } from "./index-vpcnasre.js";
16
+ } from "./index-t1ggktg8.js";
17
17
  import {
18
18
  MANIFEST_FILE,
19
19
  has,
@@ -1383,7 +1383,7 @@ import color from "picocolors";
1383
1383
  // package.json
1384
1384
  var package_default = {
1385
1385
  name: "@saastemly/voidcommerce",
1386
- version: "0.20.0",
1386
+ version: "0.22.0",
1387
1387
  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
1388
  type: "module",
1389
1389
  license: "MIT",
@@ -3630,4 +3630,4 @@ async function importHelp() {
3630
3630
  return 0;
3631
3631
  }
3632
3632
 
3633
- export { renderAuthTs, FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, renderFrontendViteConfig, renderStorefrontPage, renderFrontendWorkflow, runVoid, runInherit, captureVoid, isVoidApp, voidAppsIn, STRICT_APP, VOID_VERSION, strictDependencies, renderVoidPatch, renderViteConfig, renderStrictTsconfig, renderVoidJson, renderDbSchema, renderDbSeed, renderLayoutTsx, renderAppCss, renderIndexServer, INDEX_PAGE, renderDashboardPage, CLIENT_ONLY, renderSignInPage, renderAuthClient, renderContentTs, renderCron, renderQueue, renderLiveStream, renderLiveRoute, renderStrictAppPackageJson, DATA_README, BRANDING_README, MIGRATIONS_README, finishStrict, line, row, box, fullHelp, initHelp, version, findProject, ensureGenerated, DIST_DIR, DIST_BRANCH, distCommand, distHelp, renderDistWorkflow, renderDeployReadme, renderRequirementsTs, renderDomainTs, generate, parseJsonc, upsertJsonc, findWrangler, parseWhoAmI, productionEnv, routeProblem, preflight, printPreflight, deployCloudflare, importCommand, importHelp };
3633
+ export { renderAuthTs, FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, renderFrontendViteConfig, renderStorefrontPage, renderFrontendWorkflow, runVoid, runInherit, captureVoid, isVoidApp, voidAppsIn, STRICT_APP, VOID_VERSION, strictDependencies, renderVoidPatch, renderViteConfig, renderStrictTsconfig, renderVoidJson, renderDbSchema, renderDbSeed, renderLayoutTsx, renderAppCss, renderIndexServer, INDEX_PAGE, renderDashboardPage, CLIENT_ONLY, renderSignInPage, renderAuthClient, renderContentTs, renderCron, renderQueue, renderLiveStream, renderLiveRoute, renderStrictAppPackageJson, DATA_README, BRANDING_README, MIGRATIONS_README, finishStrict, line, row, box, fullHelp, initHelp, version, findProject, ensureGenerated, DIST_DIR, DIST_BRANCH, distCommand, distHelp, renderDistWorkflow, renderDeployReadme, renderRequirementsTs, renderDomainTs, generate, parseJsonc, upsertJsonc, findWrangler, wrangler, parseWhoAmI, productionEnv, routeProblem, preflight, printPreflight, deployCloudflare, importCommand, importHelp };
@@ -118,12 +118,14 @@ var BASE = [
118
118
  {
119
119
  key: "CLOUDFLARE_API_TOKEN",
120
120
  breaks: "nothing deploys: no worker, no database, no migrations",
121
- where: "dash.cloudflare.com/profile/api-tokens → Create Token → Custom token"
121
+ where: "dash.cloudflare.com/profile/api-tokens → Create Token → Custom token",
122
+ deployOnly: true
122
123
  },
123
124
  {
124
125
  key: "CLOUDFLARE_ACCOUNT_ID",
125
126
  breaks: "wrangler cannot tell which account to deploy into, and refuses rather than guessing",
126
- where: "the Cloudflare dashboard sidebar, or `vc secrets set` it after the first provision"
127
+ where: "the Cloudflare dashboard sidebar, or read from the token on first deploy",
128
+ deployOnly: true
127
129
  },
128
130
  {
129
131
  key: "SHOP_DOMAIN",
@@ -174,7 +176,7 @@ function allEnvKeys(manifest) {
174
176
  }
175
177
  var NUMERIC = new Set(["SHIPPING_DOMESTIC_MINOR", "SHIPPING_FREE_FROM_MINOR"]);
176
178
  function renderEnvTs(manifest) {
177
- const keys = allEnvKeys(manifest);
179
+ const keys = allEnvKeys(manifest).filter((key) => !key.deployOnly);
178
180
  const lines = keys.map((key) => {
179
181
  const helper = NUMERIC.has(key.key) ? "number()" : "string()";
180
182
  const doc = [` /** ${key.breaks}${key.where ? ` — from: ${key.where}` : ""} */`];
@@ -203,7 +205,7 @@ ${lines.join(`
203
205
  `;
204
206
  }
205
207
  function renderEnvExample(manifest) {
206
- const keys = allEnvKeys(manifest);
208
+ const keys = allEnvKeys(manifest).filter((key) => !key.deployOnly);
207
209
  return [
208
210
  "# Every key is required. Copy to .env for local development.",
209
211
  "# `unset` is the one value that reads as absent — for a credential you do not have yet.",
@@ -215,7 +217,7 @@ function renderEnvExample(manifest) {
215
217
  }
216
218
  var MANIFEST_OWNED_ENV = new Set(["SHOP_DOMAIN", "SHOP_ZONE", "EMAIL_FROM", "GITHUB_PAGES_HOST"]);
217
219
  function renderEnvLocal(manifest) {
218
- const keys = allEnvKeys(manifest);
220
+ const keys = allEnvKeys(manifest).filter((key) => !key.deployOnly);
219
221
  const local = {
220
222
  SHOP_DOMAIN: `${manifest.shop.domain.split(".")[0]}.test`,
221
223
  GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
@@ -370,7 +372,7 @@ async function decryptSecrets(project) {
370
372
  const root = project.root;
371
373
  if (!existsSync2(join2(root, SECRETS_FILE)))
372
374
  return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
373
- const { localPrivateKey } = await import("./keys-fbw9fdby.js");
375
+ const { localPrivateKey } = await import("./keys-1h4zpvd7.js");
374
376
  const privateKey = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root) || "";
375
377
  if (!privateKey) {
376
378
  return {
@@ -382,7 +384,7 @@ async function decryptSecrets(project) {
382
384
  }
383
385
  const expected = committedPublicKey(root);
384
386
  if (expected) {
385
- const { publicKeyFor } = await import("./keys-fbw9fdby.js");
387
+ const { publicKeyFor } = await import("./keys-1h4zpvd7.js");
386
388
  const derived = await publicKeyFor(privateKey);
387
389
  if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
388
390
  return {
@@ -402,8 +404,13 @@ async function decryptSecrets(project) {
402
404
  if (result.code !== 0)
403
405
  return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split(`
404
406
  `).slice(-2).join(" ")}` };
407
+ const deployOnly = new Set(allEnvKeys(project.manifest).filter((key) => key.deployOnly).map((key) => key.key));
405
408
  const lines = result.out.split(`
406
- `).filter((line) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) && !line.trimStart().startsWith("DOTENV_"));
409
+ `).filter((line) => {
410
+ if (!/^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) || line.trimStart().startsWith("DOTENV_"))
411
+ return false;
412
+ return !deployOnly.has(line.slice(0, line.indexOf("=")).trim());
413
+ });
407
414
  const names = lines.map((line) => line.slice(0, line.indexOf("=")).trim());
408
415
  const path = join2(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
409
416
  writeFileSync(path, `${lines.join(`
@@ -431,6 +438,8 @@ async function secretsCommand(project, args) {
431
438
  return setSecretValue(project, args.slice(1));
432
439
  if (args.includes("--sync"))
433
440
  return syncSecrets(project, args.includes("--prune"));
441
+ if (args.includes("--adopt"))
442
+ return adoptSecrets(project, args.includes("--confirm"));
434
443
  if (!existsSync2(join2(root, SECRETS_FILE))) {
435
444
  console.log(`
436
445
  ${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
@@ -714,6 +723,75 @@ ${color.green("✓")} every secret this shop needs is already declared.
714
723
  }
715
724
  return 0;
716
725
  }
726
+ async function adoptSecrets(project, confirmed) {
727
+ const root = project.root;
728
+ const path = join2(root, SECRETS_FILE);
729
+ if (!existsSync2(path)) {
730
+ console.error(`
731
+ vc: no ${SECRETS_FILE} here — \`vc secrets --init\` starts a fresh one.
732
+ `);
733
+ return 1;
734
+ }
735
+ const { localPrivateKey, publicKeyFor, generateKeypair } = await import("./keys-1h4zpvd7.js");
736
+ const current = committedPublicKey(root);
737
+ const mine = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root);
738
+ if (mine && current && (await publicKeyFor(mine))?.toLowerCase() === current.toLowerCase()) {
739
+ console.error(`
740
+ vc: you already hold the key to this shop, so this is not a fork.
741
+ Adopting would replace the key and blank every value you can currently read.
742
+ If that is really what you want, remove the key first.
743
+ `);
744
+ return 1;
745
+ }
746
+ const declared = [...declaredSecretNames(root)];
747
+ if (!confirmed) {
748
+ console.log(`
749
+ ${color.bold("Adopting this shop would:")}
750
+
751
+ ` + ` · make a NEW encryption key, replacing the one in ${SECRETS_FILE}
752
+ ` + ` · reset ${declared.length} value${declared.length === 1 ? "" : "s"} to \`unset\`
753
+
754
+ ` + ` The current values cannot be read here anyway — they belong to whoever
755
+ ` + ` set them. This makes that explicit rather than leaving their ciphertext
756
+ sitting in a shop you now own.
757
+
758
+ ${color.cyan("vc secrets --adopt --confirm")}
759
+ `);
760
+ return 1;
761
+ }
762
+ const pair = await generateKeypair();
763
+ if (!pair) {
764
+ console.error("vc: @dotenvx/dotenvx is not installed here.");
765
+ return 1;
766
+ }
767
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
768
+ const body = readFileSync(path, "utf8").split(`
769
+ `).map((line) => {
770
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
771
+ if (!match)
772
+ return line;
773
+ if (match[1] === "DOTENV_PUBLIC_KEY_SECRETS")
774
+ return `DOTENV_PUBLIC_KEY_SECRETS="${pair.publicKey}"`;
775
+ return `${match[1]}=unset`;
776
+ }).join(`
777
+ `);
778
+ writeFileSync(path, body);
779
+ writeFileSync(join2(root, ".env.keys"), `${PRIVATE_KEY_VAR}="${pair.privateKey}"
780
+ `, { mode: 384 });
781
+ console.log(`
782
+ ${color.green("✓")} adopted: a new key, and ${declared.length} value${declared.length === 1 ? "" : "s"} reset to \`unset\`
783
+ ${color.green("+")} .env.keys holds the new private key — gitignored, and yours
784
+
785
+ ` + `Now set what this shop needs:
786
+ ${required.slice(0, 4).map((key) => ` ${color.cyan(`vc secrets set ${key.key}`)}`).join(`
787
+ `)}
788
+ ` + (required.length > 4 ? ` ${color.dim(`…and ${required.length - 4} more — \`vc secrets\` lists them`)}
789
+ ` : "") + color.yellow(`
790
+ ! The previous owner's ciphertext is still in git history. It is unreadable
791
+ without their key, but a fresh repository is cleaner than a fork.
792
+ `));
793
+ return 0;
794
+ }
717
795
 
718
796
  // src/deploy/keys.ts
719
797
  var LOCAL_KEY_FILE = ".env.keys";
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  routeProblem,
53
53
  strictDependencies,
54
54
  upsertJsonc
55
- } from "./index-07ppzmgt.js";
55
+ } from "./index-2kqz80nx.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-vpcnasre.js";
63
+ } from "./index-t1ggktg8.js";
64
64
  import {
65
65
  LAYOUTS,
66
66
  MANIFEST_FILE,
@@ -8,7 +8,7 @@ import {
8
8
  localPrivateKey,
9
9
  provisionKey,
10
10
  publicKeyFor
11
- } from "./index-vpcnasre.js";
11
+ } from "./index-t1ggktg8.js";
12
12
  import"./index-30y19qz5.js";
13
13
  import"./index-xyjhy6kp.js";
14
14
  import"./index-0v6na3yp.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/catalog.ts CHANGED
@@ -25,6 +25,19 @@ export interface EnvKey {
25
25
  dev?: string | undefined;
26
26
  /** Where the real value comes from. */
27
27
  where?: string | undefined;
28
+ /**
29
+ * Needed to DEPLOY the shop, not to run it.
30
+ *
31
+ * The Cloudflare credentials are the case. They belong in `.env.secrets`
32
+ * so a push carries them, and preflight must insist on them — but the
33
+ * worker has no business seeing one. Left unmarked they ended up in three
34
+ * wrong places at once: `env.ts`, so the worker refused to boot without a
35
+ * deploy token; `.env`, where wrangler read `CLOUDFLARE_ACCOUNT_ID=unset`
36
+ * and addressed `accounts/unset/...`; and `--secrets-file`, which would
37
+ * have uploaded the deploy credential INTO the worker for any code
38
+ * running there to read.
39
+ */
40
+ deployOnly?: boolean | undefined;
28
41
  }
29
42
 
30
43
  export interface Choice {
package/src/cli.ts CHANGED
@@ -8,6 +8,8 @@ import {
8
8
  preflightHelp,
9
9
  secretsCliCommand,
10
10
  secretsHelp,
11
+ teardownCliCommand,
12
+ teardownHelp,
11
13
  } from "./deploy/index";
12
14
  import { distCommand, distHelp } from "./dist";
13
15
  import { importCommand, importHelp } from "./import";
@@ -50,6 +52,7 @@ export const EXTENDED: Record<string, Extended> = {
50
52
  preflight: { run: preflightCommand, help: preflightHelp },
51
53
  secrets: { run: secretsCliCommand, help: secretsHelp },
52
54
  keys: { run: keysCliCommand, help: keysHelp },
55
+ teardown: { run: teardownCliCommand, help: teardownHelp },
53
56
  // Called by the generated pre-commit hook; exit code is the interface.
54
57
  guard: { run: guardCommand, help: async () => guardCommand() },
55
58
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
@@ -8,6 +8,7 @@ import { existsSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { PRIVATE_KEY_VAR, SECRETS_FILE, encryptInto, plaintextSecretEntries, plaintextSecretNames, run, secretsCommand } from "./secrets";
10
10
  import { LOCAL_KEY_FILE, committedPublicKey, ignoresKeyFile, keysCommand } from "./keys";
11
+ import { teardownCloudflare } from "./teardown";
11
12
 
12
13
  /**
13
14
  * `vc deploy` extends `void deploy`: vc's preflight first, then void's
@@ -310,3 +311,66 @@ export async function guardCommand(): Promise<number> {
310
311
 
311
312
 
312
313
 
314
+
315
+
316
+ /**
317
+ * `vc teardown` — destroy this shop's Cloudflare infrastructure.
318
+ *
319
+ * The confirmation is the shop's DOMAIN, not `y`. A `y` is muscle memory;
320
+ * typing `devprints.saastemly.com` is not, and this deletes a database that
321
+ * has no undo.
322
+ */
323
+ export async function teardownCliCommand(args: string[]): Promise<number> {
324
+ const project = await findProject();
325
+ if (!project) {
326
+ console.error("vc: no voidcommerce.json here.");
327
+ return 1;
328
+ }
329
+ if (!args.includes("--cloudflare")) {
330
+ console.error("vc: teardown only knows how to undo a Cloudflare deploy. `vc teardown --cloudflare`.");
331
+ return 1;
332
+ }
333
+ const typed = args[args.indexOf("--confirm") + 1];
334
+ const confirmed = args.includes("--confirm") && typed === project.manifest.shop.domain;
335
+ if (args.includes("--confirm") && !confirmed) {
336
+ console.error(
337
+ `\nvc: --confirm needs this shop's domain, exactly:\n\n` +
338
+ ` vc teardown --cloudflare --confirm ${project.manifest.shop.domain}\n\n` +
339
+ ` You typed ${typed ? `"${typed}"` : "nothing"}. Naming the shop is the check — it is\n` +
340
+ " what stops this running in the wrong directory.\n",
341
+ );
342
+ return 1;
343
+ }
344
+ return teardownCloudflare(project, {
345
+ confirm: confirmed,
346
+ keepData: args.includes("--keep-data"),
347
+ dryRun: args.includes("--dry-run"),
348
+ });
349
+ }
350
+
351
+ export async function teardownHelp(): Promise<number> {
352
+ const width = 80;
353
+ console.log(
354
+ box("vc teardown", [
355
+ line("Destroy this shop's Cloudflare infrastructure, so the whole", width),
356
+ line("publish-to-live flow can be proved again from nothing.", width),
357
+ line("", width),
358
+ ...row("vc teardown --cloudflare --dry-run", "list what would be destroyed, touch nothing", width, 2),
359
+ ...row("vc teardown --cloudflare --confirm <domain>", "do it", width, 2),
360
+ ...row("--keep-data", "leave the D1 database alone", width, 2),
361
+ line("", width),
362
+ line(color.bold("Read this first"), width),
363
+ line("D1 deletion has NO UNDO. Every order, customer and address in the", width),
364
+ line("database goes with it, and there is no snapshot unless you took one.", width),
365
+ line("", width),
366
+ line("So --confirm takes the shop's DOMAIN, not `y`: a `y` is muscle memory,", width),
367
+ line("and typing the domain is what stops this running in the wrong shop.", width),
368
+ line("", width),
369
+ line("It is idempotent — deleting what is already gone is success — and it", width),
370
+ line("clears the recorded database id, because `wrangler deploy` FAILS on a", width),
371
+ line("dangling id rather than making a new database. Secrets are untouched:", width),
372
+ line("this removes infrastructure, not configuration.", width),
373
+ ], width),
374
+ );
375
+ return 0;
376
+ }
@@ -242,11 +242,21 @@ export async function decryptSecrets(project: Project): Promise<DecryptedSecrets
242
242
  });
243
243
  if (result.code !== 0) return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split("\n").slice(-2).join(" ")}` };
244
244
 
245
- // Keep only the shop's own keys: dotenvx echoes the public key back, and
246
- // it has no business being a worker secret.
247
- const lines = result.out
248
- .split("\n")
249
- .filter((line) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) && !line.trimStart().startsWith("DOTENV_"));
245
+ /**
246
+ * Keep only the secrets the WORKER needs.
247
+ *
248
+ * dotenvx echoes its own public key back, which has no business being a
249
+ * worker secret. And the deploy-only keys are the important exclusion:
250
+ * `--secrets-file` uploads what it is given, so leaving CLOUDFLARE_API_TOKEN
251
+ * in here would put the credential that can deploy, delete and re-create
252
+ * this shop INSIDE the shop, readable by any code running in the worker.
253
+ * A compromised dependency would have found it there.
254
+ */
255
+ const deployOnly = new Set(allEnvKeys(project.manifest).filter((key) => key.deployOnly).map((key) => key.key));
256
+ const lines = result.out.split("\n").filter((line) => {
257
+ if (!/^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) || line.trimStart().startsWith("DOTENV_")) return false;
258
+ return !deployOnly.has(line.slice(0, line.indexOf("=")).trim());
259
+ });
250
260
  const names = lines.map((line) => line.slice(0, line.indexOf("=")).trim());
251
261
 
252
262
  const path = join(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
@@ -277,6 +287,7 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
277
287
  if (args.includes("--init")) return initSecrets(project);
278
288
  if (args[0] === "set") return setSecretValue(project, args.slice(1));
279
289
  if (args.includes("--sync")) return syncSecrets(project, args.includes("--prune"));
290
+ if (args.includes("--adopt")) return adoptSecrets(project, args.includes("--confirm"));
280
291
 
281
292
  if (!existsSync(join(root, SECRETS_FILE))) {
282
293
  console.log(
@@ -598,3 +609,84 @@ async function syncSecrets(project: Project, prune: boolean): Promise<number> {
598
609
  }
599
610
  return 0;
600
611
  }
612
+
613
+
614
+ /**
615
+ * `vc secrets --adopt` — take this shop over as a different person.
616
+ *
617
+ * The fork case. Somebody clones a shop, and every value in `.env.secrets`
618
+ * belongs to whoever set it: their Stripe key, their Cloudflare token, their
619
+ * home address. They cannot read any of it — the private key is not theirs —
620
+ * but the ciphertext is right there, and the shop would deploy into somebody
621
+ * else's account if the key ever leaked.
622
+ *
623
+ * So this cuts the connection: a NEW keypair, every value back to `unset`,
624
+ * and the old public key replaced. What was encrypted to the old key stays
625
+ * in git history and is now unreadable by anyone here, which is the point —
626
+ * the new owner starts with a shop that has their secrets or none.
627
+ *
628
+ * It refuses when the current key IS readable here, because then this is not
629
+ * a fork, it is somebody about to destroy their own values by accident.
630
+ */
631
+ async function adoptSecrets(project: Project, confirmed: boolean): Promise<number> {
632
+ const root = project.root;
633
+ const path = join(root, SECRETS_FILE);
634
+ if (!existsSync(path)) {
635
+ console.error(`\nvc: no ${SECRETS_FILE} here — \`vc secrets --init\` starts a fresh one.\n`);
636
+ return 1;
637
+ }
638
+
639
+ const { localPrivateKey, publicKeyFor, generateKeypair } = await import("./keys");
640
+ const current = committedPublicKey(root);
641
+ const mine = process.env[PRIVATE_KEY_VAR] || localPrivateKey(root);
642
+ if (mine && current && (await publicKeyFor(mine))?.toLowerCase() === current.toLowerCase()) {
643
+ console.error(
644
+ `\nvc: you already hold the key to this shop, so this is not a fork.\n` +
645
+ ` Adopting would replace the key and blank every value you can currently read.\n` +
646
+ ` If that is really what you want, remove the key first.\n`,
647
+ );
648
+ return 1;
649
+ }
650
+
651
+ const declared = [...declaredSecretNames(root)];
652
+ if (!confirmed) {
653
+ console.log(
654
+ `\n${color.bold("Adopting this shop would:")}\n\n` +
655
+ ` · make a NEW encryption key, replacing the one in ${SECRETS_FILE}\n` +
656
+ ` · reset ${declared.length} value${declared.length === 1 ? "" : "s"} to \`unset\`\n\n` +
657
+ ` The current values cannot be read here anyway — they belong to whoever\n` +
658
+ ` set them. This makes that explicit rather than leaving their ciphertext\n` +
659
+ ` sitting in a shop you now own.\n\n` +
660
+ ` ${color.cyan("vc secrets --adopt --confirm")}\n`,
661
+ );
662
+ return 1;
663
+ }
664
+
665
+ const pair = await generateKeypair();
666
+ if (!pair) {
667
+ console.error("vc: @dotenvx/dotenvx is not installed here.");
668
+ return 1;
669
+ }
670
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
671
+ const body = readFileSync(path, "utf8")
672
+ .split("\n")
673
+ .map((line) => {
674
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
675
+ if (!match) return line;
676
+ if (match[1] === "DOTENV_PUBLIC_KEY_SECRETS") return `DOTENV_PUBLIC_KEY_SECRETS="${pair.publicKey}"`;
677
+ return `${match[1]}=unset`;
678
+ })
679
+ .join("\n");
680
+ writeFileSync(path, body);
681
+ writeFileSync(join(root, ".env.keys"), `${PRIVATE_KEY_VAR}="${pair.privateKey}"\n`, { mode: 0o600 });
682
+
683
+ console.log(
684
+ `\n${color.green("✓")} adopted: a new key, and ${declared.length} value${declared.length === 1 ? "" : "s"} reset to \`unset\`\n` +
685
+ `${color.green("+")} .env.keys holds the new private key — gitignored, and yours\n\n` +
686
+ `Now set what this shop needs:\n` +
687
+ `${required.slice(0, 4).map((key) => ` ${color.cyan(`vc secrets set ${key.key}`)}`).join("\n")}\n` +
688
+ (required.length > 4 ? ` ${color.dim(`…and ${required.length - 4} more — \`vc secrets\` lists them`)}\n` : "") +
689
+ color.yellow(`\n! The previous owner's ciphertext is still in git history. It is unreadable\n without their key, but a fresh repository is cleaner than a fork.\n`),
690
+ );
691
+ return 0;
692
+ }