@saastemly/voidcommerce 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  runVoid,
22
22
  version,
23
23
  voidAppsIn
24
- } from "./index-y6qzjd9h.js";
24
+ } from "./index-mb1p1rh0.js";
25
25
  import {
26
26
  LOCAL_KEY_FILE,
27
27
  PRIVATE_KEY_VAR,
@@ -50,7 +50,7 @@ import {
50
50
  setVariable,
51
51
  variableNames,
52
52
  verifyCloudflareToken
53
- } from "./index-j5xjh6qp.js";
53
+ } from "./index-vhsq8jpy.js";
54
54
  import {
55
55
  LAYOUTS,
56
56
  oneOrigin,
@@ -23,7 +23,7 @@ export declare const FRONTEND_BRANCH = "frontend-static";
23
23
  export declare function renderFrontendApiTs(manifest: Manifest): string;
24
24
  export declare function renderFrontendEnvProduction(manifest: Manifest): string;
25
25
  /** The Vite config for a static Void site. Deliberately small: this is yours. */
26
- export declare function renderFrontendViteConfig(): string;
26
+ export declare function renderFrontendViteConfig(manifest: Manifest): string;
27
27
  /**
28
28
  * `target: node` because Void 0.10.13's Cloudflare prerender calls miniflare
29
29
  * with the single-worker shorthand that the miniflare its own dependencies
@@ -9,7 +9,7 @@ import {
9
9
  renderEnvLocal,
10
10
  renderEnvProduction,
11
11
  renderEnvTs
12
- } from "./index-j5xjh6qp.js";
12
+ } from "./index-vhsq8jpy.js";
13
13
  import {
14
14
  MANIFEST_FILE,
15
15
  has,
@@ -422,7 +422,33 @@ function renderFrontendEnvProduction(manifest) {
422
422
  VITE_API_ORIGIN=https://api.${manifest.shop.domain}
423
423
  `;
424
424
  }
425
- function renderFrontendViteConfig() {
425
+ function renderFrontendViteConfig(manifest) {
426
+ if (oneOrigin(manifest.layout)) {
427
+ return `import { defineConfig } from "vite";
428
+ import { voidPlugin } from "void";
429
+ import { voidReact } from "@void/react/plugin";
430
+
431
+ /**
432
+ * The storefront.
433
+ *
434
+ * In production one worker serves this page and the API it calls, so the
435
+ * client uses relative URLs and there is nothing to configure. In DEV they
436
+ * are two processes on two ports, and a relative /api call would hit this
437
+ * dev server instead of the worker — so it is proxied. Without this, local
438
+ * development is the only place the shop is broken.
439
+ *
440
+ * Start the worker first: it takes 5173, and this lands on the next port.
441
+ */
442
+ export default defineConfig({
443
+ plugins: [voidPlugin(), voidReact()],
444
+ server: {
445
+ proxy: {
446
+ "/api": { target: "http://127.0.0.1:5173", changeOrigin: false },
447
+ },
448
+ },
449
+ });
450
+ `;
451
+ }
426
452
  return `import { defineConfig } from "vite";
427
453
  import { voidPlugin } from "void";
428
454
  import { voidReact } from "@void/react/plugin";
@@ -1344,7 +1370,7 @@ import color from "picocolors";
1344
1370
  // package.json
1345
1371
  var package_default = {
1346
1372
  name: "@saastemly/voidcommerce",
1347
- version: "0.8.0",
1373
+ version: "0.10.0",
1348
1374
  description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
1349
1375
  type: "module",
1350
1376
  license: "MIT",
@@ -2293,6 +2319,39 @@ async function exists(path) {
2293
2319
  return false;
2294
2320
  }
2295
2321
  }
2322
+ async function putEnv(root, file, rendered, result) {
2323
+ const path = join4(root, file);
2324
+ if (!await exists(path)) {
2325
+ await mkdir2(dirname2(path), { recursive: true });
2326
+ await writeFile2(path, rendered, "utf8");
2327
+ result.written.push(file);
2328
+ return;
2329
+ }
2330
+ const read = (text) => {
2331
+ const out = new Map;
2332
+ for (const line2 of text.split(`
2333
+ `)) {
2334
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line2);
2335
+ if (match)
2336
+ out.set(match[1], match[2]);
2337
+ }
2338
+ return out;
2339
+ };
2340
+ const existing = read(await readFile2(path, "utf8"));
2341
+ const wanted = read(rendered);
2342
+ const merged = rendered.split(`
2343
+ `).map((line2) => {
2344
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line2);
2345
+ if (!match)
2346
+ return line2;
2347
+ const had = existing.get(match[1]);
2348
+ return had !== undefined && had !== "" && had !== "unset" ? `${match[1]}=${had}` : line2;
2349
+ }).join(`
2350
+ `);
2351
+ const dropped = [...existing.keys()].filter((key) => !wanted.has(key) && !key.startsWith("VITE_"));
2352
+ await writeFile2(path, merged, "utf8");
2353
+ result.written.push(dropped.length > 0 ? `${file} ${`(dropped ${dropped.join(", ")})`}` : file);
2354
+ }
2296
2355
  async function put(root, file, content, result, mode) {
2297
2356
  const path = join4(root, file);
2298
2357
  if (mode === "own" && await exists(path)) {
@@ -2364,7 +2423,7 @@ async function generateApi(root, dir, manifest, result, opts) {
2364
2423
  await put(root, at(".env.production"), renderEnvProduction(manifest), result, "regenerate");
2365
2424
  await put(root, at("lib/deploy/requirements.ts"), renderRequirementsTs(manifest), result, "regenerate");
2366
2425
  if (opts.env)
2367
- await put(root, at(".env"), renderEnvLocal(manifest), result, "own");
2426
+ await putEnv(root, at(".env"), renderEnvLocal(manifest), result);
2368
2427
  await put(root, at("lib/domain.ts"), renderDomainTs(manifest.layout), result, own);
2369
2428
  await put(root, at("lib/payment.ts"), renderPaymentTs(manifest), result, own);
2370
2429
  await put(root, at("lib/notifications.ts"), renderNotificationsTs(), result, own);
@@ -2424,7 +2483,7 @@ async function generateFrontend(root, dir, manifest, result) {
2424
2483
  const at = (file) => join4(dir, file);
2425
2484
  await put(root, at("lib/api.ts"), renderFrontendApiTs(manifest), result, "own");
2426
2485
  await put(root, at(".env.production"), renderFrontendEnvProduction(manifest), result, "regenerate");
2427
- await put(root, at("vite.config.ts"), renderFrontendViteConfig(), result, "own");
2486
+ await put(root, at("vite.config.ts"), renderFrontendViteConfig(manifest), result, "own");
2428
2487
  await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
2429
2488
  await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
2430
2489
  await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
@@ -2517,7 +2576,7 @@ bunx vc guard
2517
2576
  await put(root, ".github/workflows/deploy.yml", renderDistWorkflow(manifest), result, "regenerate");
2518
2577
  await retire(root, ".github/workflows/void-dist.yml", result);
2519
2578
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
2520
- await put(root, ".env", renderEnvLocal(manifest), result, "own");
2579
+ await putEnv(root, ".env", renderEnvLocal(manifest), result);
2521
2580
  await put(root, "data/README.md", DATA_README, result, "own");
2522
2581
  await put(root, "branding/README.md", BRANDING_README, result, "own");
2523
2582
  await put(root, "migrations/README.md", MIGRATIONS_README, result, "own");
@@ -3372,4 +3431,4 @@ async function importHelp() {
3372
3431
  return 0;
3373
3432
  }
3374
3433
 
3375
- export { renderAuthTs, FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, 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 };
3434
+ 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 };
@@ -2,6 +2,7 @@ import {
2
2
  envKeysOf,
3
3
  hasFrontend,
4
4
  isApex,
5
+ oneOrigin,
5
6
  zone
6
7
  } from "./index-tjy6yygc.js";
7
8
  import {
@@ -247,7 +248,7 @@ function renderEnvLocal(manifest) {
247
248
  "# Local development. Gitignored; .env.example documents every key.",
248
249
  "",
249
250
  ...keys.map((key) => `${key.key}=${local[key.key] ?? key.dev ?? "unset"}`),
250
- ...hasFrontend(manifest.layout) ? ["", "# The storefront, reaching the worker. Start the worker first.", "VITE_API_ORIGIN=http://localhost:5173"] : [],
251
+ ...hasFrontend(manifest.layout) && !oneOrigin(manifest.layout) ? ["", "# The storefront, reaching the worker. Start the worker first.", "VITE_API_ORIGIN=http://localhost:5173"] : [],
251
252
  ""
252
253
  ].join(`
253
254
  `);
@@ -384,7 +385,7 @@ async function decryptSecrets(project) {
384
385
  }
385
386
  const expected = committedPublicKey(root);
386
387
  if (expected) {
387
- const { publicKeyFor } = await import("./keys-3zjezaxk.js");
388
+ const { publicKeyFor } = await import("./keys-2q3qzntw.js");
388
389
  const derived = await publicKeyFor(privateKey);
389
390
  if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
390
391
  return {
@@ -432,7 +433,7 @@ async function secretsCommand(project, args) {
432
433
  if (args[0] === "set")
433
434
  return setSecretValue(project, args.slice(1));
434
435
  if (args.includes("--sync"))
435
- return syncSecrets(project);
436
+ return syncSecrets(project, args.includes("--prune"));
436
437
  if (!existsSync2(join2(root, SECRETS_FILE))) {
437
438
  console.log(`
438
439
  ${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
@@ -625,7 +626,7 @@ function upsertLine(path, name, value) {
625
626
  `)}${name}=${value}
626
627
  `);
627
628
  }
628
- async function syncSecrets(project) {
629
+ async function syncSecrets(project, prune) {
629
630
  const root = project.root;
630
631
  const path = join2(root, SECRETS_FILE);
631
632
  if (!existsSync2(path)) {
@@ -656,13 +657,39 @@ ${color.green("+")} ${missing.length} key${missing.length === 1 ? "" : "s"} adde
656
657
  ${color.green("✓")} every secret this shop needs is already declared.
657
658
  `);
658
659
  }
659
- if (extra.length > 0) {
660
+ if (extra.length > 0 && prune) {
661
+ const body = readFileSync(path, "utf8");
662
+ const kept = [];
663
+ const lines = body.split(`
664
+ `);
665
+ for (let i = 0;i < lines.length; i++) {
666
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(lines[i] ?? "");
667
+ if (match && extra.includes(match[1])) {
668
+ while (kept.length > 0 && (kept[kept.length - 1].startsWith("#") || kept[kept.length - 1].trim() === ""))
669
+ kept.pop();
670
+ if (lines[i + 1]?.trim() === "")
671
+ i++;
672
+ continue;
673
+ }
674
+ kept.push(lines[i]);
675
+ }
676
+ writeFileSync(path, `${kept.join(`
677
+ `).replace(/\n{3,}/g, `
678
+
679
+ `).replace(/\n*$/, "")}
680
+ `);
681
+ console.log(`${color.red("-")} ${extra.length} key${extra.length === 1 ? "" : "s"} removed from ${SECRETS_FILE}: ${extra.join(", ")}
682
+
683
+ ` + color.dim(` Their ciphertext is still in git history, so nothing is unrecoverable —
684
+ but nothing in this shop reads them any more.
685
+ `));
686
+ } else if (extra.length > 0) {
660
687
  console.log(`${color.yellow("!")} ${extra.length} declared key${extra.length === 1 ? " is" : "s are"} no longer required by this shop:
661
688
  ${extra.join(`
662
689
  `)}
663
690
 
664
- ` + color.dim(` Left alone — they cost nothing, and deleting a value you may still want
665
- is not this command's call. Remove them by hand when you are sure.
691
+ ` + color.dim(` Left alone: deleting a value you may still want is not this command's
692
+ call to make by default. \`vc secrets --sync --prune\` makes it.
666
693
  `));
667
694
  }
668
695
  return 0;
package/dist/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export { parseJsonc, upsertJsonc } from "./deploy/jsonc";
18
18
  export { parseWhoAmI, findWrangler } from "./deploy/wrangler";
19
19
  export { preflight, printPreflight, productionEnv, routeProblem } from "./deploy/preflight";
20
20
  export { deployCloudflare } from "./deploy/cloudflare";
21
- export { FRONTEND_BRANCH, renderFrontendApiTs, renderFrontendEnvProduction, renderFrontendWorkflow, renderStorefrontPage, } from "./generate/frontend";
21
+ export { FRONTEND_BRANCH, renderFrontendViteConfig, renderFrontendApiTs, renderFrontendEnvProduction, renderFrontendWorkflow, renderStorefrontPage, } from "./generate/frontend";
22
22
  export { distCommand, DIST_BRANCH, DIST_DIR } from "./dist";
23
23
  export { renderDistWorkflow, renderDeployReadme } from "./generate/ci";
24
24
  export { importCommand } from "./import";
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  renderDomainTs,
35
35
  renderFrontendApiTs,
36
36
  renderFrontendEnvProduction,
37
+ renderFrontendViteConfig,
37
38
  renderFrontendWorkflow,
38
39
  renderIndexServer,
39
40
  renderLayoutTsx,
@@ -51,7 +52,7 @@ import {
51
52
  routeProblem,
52
53
  strictDependencies,
53
54
  upsertJsonc
54
- } from "./index-y6qzjd9h.js";
55
+ } from "./index-mb1p1rh0.js";
55
56
  import {
56
57
  allEnvKeys,
57
58
  envSummary,
@@ -59,7 +60,7 @@ import {
59
60
  renderEnvLocal,
60
61
  renderEnvProduction,
61
62
  renderEnvTs
62
- } from "./index-j5xjh6qp.js";
63
+ } from "./index-vhsq8jpy.js";
63
64
  import {
64
65
  LAYOUTS,
65
66
  MANIFEST_FILE,
@@ -124,6 +125,7 @@ export {
124
125
  renderLayoutTsx,
125
126
  renderIndexServer,
126
127
  renderFrontendWorkflow,
128
+ renderFrontendViteConfig,
127
129
  renderFrontendEnvProduction,
128
130
  renderFrontendApiTs,
129
131
  renderEnvTs,
@@ -8,7 +8,7 @@ import {
8
8
  localPrivateKey,
9
9
  provisionKey,
10
10
  publicKeyFor
11
- } from "./index-j5xjh6qp.js";
11
+ } from "./index-vhsq8jpy.js";
12
12
  import"./index-tjy6yygc.js";
13
13
  import"./index-mnb7fz2t.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.8.0",
3
+ "version": "0.10.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",
@@ -250,7 +250,7 @@ export async function secretsCommand(project: Project, args: string[]): Promise<
250
250
 
251
251
  if (args.includes("--init")) return initSecrets(project);
252
252
  if (args[0] === "set") return setSecretValue(project, args.slice(1));
253
- if (args.includes("--sync")) return syncSecrets(project);
253
+ if (args.includes("--sync")) return syncSecrets(project, args.includes("--prune"));
254
254
 
255
255
  if (!existsSync(join(root, SECRETS_FILE))) {
256
256
  console.log(
@@ -482,7 +482,7 @@ function upsertLine(path: string, name: string, value: string): void {
482
482
  * claims — the value may still be wanted, and it is not this command's to
483
483
  * destroy. Extras are reported instead.
484
484
  */
485
- async function syncSecrets(project: Project): Promise<number> {
485
+ async function syncSecrets(project: Project, prune: boolean): Promise<number> {
486
486
  const root = project.root;
487
487
  const path = join(root, SECRETS_FILE);
488
488
  if (!existsSync(path)) {
@@ -505,11 +505,33 @@ async function syncSecrets(project: Project): Promise<number> {
505
505
  console.log(`\n${color.green("✓")} every secret this shop needs is already declared.\n`);
506
506
  }
507
507
 
508
- if (extra.length > 0) {
508
+ if (extra.length > 0 && prune) {
509
+ // Deleting a value is the operator's call, and --prune is them making
510
+ // it. The whole entry goes — its comment line too — so the file reads
511
+ // as though the key was never required.
512
+ const body = readFileSync(path, "utf8");
513
+ const kept: string[] = [];
514
+ const lines = body.split("\n");
515
+ for (let i = 0; i < lines.length; i++) {
516
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(lines[i] ?? "");
517
+ if (match && extra.includes(match[1]!)) {
518
+ // Drop the comment that introduces it, and the blank line after.
519
+ while (kept.length > 0 && (kept[kept.length - 1]!.startsWith("#") || kept[kept.length - 1]!.trim() === "")) kept.pop();
520
+ if (lines[i + 1]?.trim() === "") i++;
521
+ continue;
522
+ }
523
+ kept.push(lines[i]!);
524
+ }
525
+ writeFileSync(path, `${kept.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "")}\n`);
526
+ console.log(
527
+ `${color.red("-")} ${extra.length} key${extra.length === 1 ? "" : "s"} removed from ${SECRETS_FILE}: ${extra.join(", ")}\n\n` +
528
+ color.dim(` Their ciphertext is still in git history, so nothing is unrecoverable —\n but nothing in this shop reads them any more.\n`),
529
+ );
530
+ } else if (extra.length > 0) {
509
531
  console.log(
510
532
  `${color.yellow("!")} ${extra.length} declared key${extra.length === 1 ? " is" : "s are"} no longer required by this shop:\n` +
511
533
  ` ${extra.join("\n ")}\n\n` +
512
- color.dim(" Left alone — they cost nothing, and deleting a value you may still want\n is not this command's call. Remove them by hand when you are sure.\n"),
534
+ color.dim(` Left alone: deleting a value you may still want is not this command's\n call to make by default. \`vc secrets --sync --prune\` makes it.\n`),
513
535
  );
514
536
  }
515
537
  return 0;
@@ -1,5 +1,5 @@
1
1
  import { type EnvKey } from "../catalog";
2
- import { type Manifest, envKeysOf, has, hasFrontend, isApex, zone } from "../manifest";
2
+ import { type Manifest, envKeysOf, has, hasFrontend, isApex, zone , oneOrigin} from "../manifest";
3
3
 
4
4
  /**
5
5
  * `env.ts`, `.env.example` and `.env.production` — from the manifest.
@@ -123,7 +123,10 @@ export function renderEnvLocal(manifest: Manifest): string {
123
123
  // One file for both halves in a strict repo, where the storefront and
124
124
  // the worker share a root. The worker takes 5173 because it is started
125
125
  // first; the storefront lands on the next free port and calls back here.
126
- ...(hasFrontend(manifest.layout)
126
+ // Only a monorepo bakes the API's address in. Where one worker serves
127
+ // both, the client uses relative URLs and dev proxies /api instead —
128
+ // so a VITE_API_ORIGIN here would be read by nothing.
129
+ ...(hasFrontend(manifest.layout) && !oneOrigin(manifest.layout)
127
130
  ? ["", "# The storefront, reaching the worker. Start the worker first.", "VITE_API_ORIGIN=http://localhost:5173"]
128
131
  : []),
129
132
  "",
@@ -74,7 +74,33 @@ VITE_API_ORIGIN=https://api.${manifest.shop.domain}
74
74
  }
75
75
 
76
76
  /** The Vite config for a static Void site. Deliberately small: this is yours. */
77
- export function renderFrontendViteConfig(): string {
77
+ export function renderFrontendViteConfig(manifest: Manifest): string {
78
+ if (oneOrigin(manifest.layout)) {
79
+ return `import { defineConfig } from "vite";
80
+ import { voidPlugin } from "void";
81
+ import { voidReact } from "@void/react/plugin";
82
+
83
+ /**
84
+ * The storefront.
85
+ *
86
+ * In production one worker serves this page and the API it calls, so the
87
+ * client uses relative URLs and there is nothing to configure. In DEV they
88
+ * are two processes on two ports, and a relative /api call would hit this
89
+ * dev server instead of the worker — so it is proxied. Without this, local
90
+ * development is the only place the shop is broken.
91
+ *
92
+ * Start the worker first: it takes 5173, and this lands on the next port.
93
+ */
94
+ export default defineConfig({
95
+ plugins: [voidPlugin(), voidReact()],
96
+ server: {
97
+ proxy: {
98
+ "/api": { target: "http://127.0.0.1:5173", changeOrigin: false },
99
+ },
100
+ },
101
+ });
102
+ `;
103
+ }
78
104
  return `import { defineConfig } from "vite";
79
105
  import { voidPlugin } from "void";
80
106
  import { voidReact } from "@void/react/plugin";
@@ -89,6 +89,57 @@ async function exists(path: string): Promise<boolean> {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * Write a dotenv file that RECONCILES rather than freezes.
94
+ *
95
+ * `.env` used to be written once and never touched, so a shop that swapped a
96
+ * carrier or a payment rail kept the old keys forever and never gained the
97
+ * new ones. devprints ended up carrying five Adyen keys and a Resend key for
98
+ * a shop that uses neither, while missing two it needed. That is the same
99
+ * owned-file drift that shipped a `.gitignore` without `.env.keys` and an
100
+ * api.ts pointing at another shop's domain.
101
+ *
102
+ * So: the template decides which keys EXIST, and the file on disk decides
103
+ * what they are SET TO. A value a person put there is always kept; a key the
104
+ * manifest no longer declares goes, and is reported rather than vanishing.
105
+ */
106
+ async function putEnv(root: string, file: string, rendered: string, result: GenerateResult) {
107
+ const path = join(root, file);
108
+ if (!(await exists(path))) {
109
+ await mkdir(dirname(path), { recursive: true });
110
+ await writeFile(path, rendered, "utf8");
111
+ result.written.push(file);
112
+ return;
113
+ }
114
+
115
+ const read = (text: string): Map<string, string> => {
116
+ const out = new Map<string, string>();
117
+ for (const line of text.split("\n")) {
118
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
119
+ if (match) out.set(match[1]!, match[2]!);
120
+ }
121
+ return out;
122
+ };
123
+ const existing = read(await readFile(path, "utf8"));
124
+ const wanted = read(rendered);
125
+
126
+ const merged = rendered
127
+ .split("\n")
128
+ .map((line) => {
129
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
130
+ if (!match) return line;
131
+ const had = existing.get(match[1]!);
132
+ // A real value the operator set beats the template's placeholder.
133
+ // `unset` and empty are placeholders, not answers.
134
+ return had !== undefined && had !== "" && had !== "unset" ? `${match[1]}=${had}` : line;
135
+ })
136
+ .join("\n");
137
+
138
+ const dropped = [...existing.keys()].filter((key) => !wanted.has(key) && !key.startsWith("VITE_"));
139
+ await writeFile(path, merged, "utf8");
140
+ result.written.push(dropped.length > 0 ? `${file} ${`(dropped ${dropped.join(", ")})`}` : file);
141
+ }
142
+
92
143
  async function put(root: string, file: string, content: string, result: GenerateResult, mode: "regenerate" | "own") {
93
144
  const path = join(root, file);
94
145
  if (mode === "own" && (await exists(path))) {
@@ -182,7 +233,7 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
182
233
  await put(root, at(".env.production"), renderEnvProduction(manifest), result, "regenerate");
183
234
  await put(root, at("lib/deploy/requirements.ts"), renderRequirementsTs(manifest), result, "regenerate");
184
235
 
185
- if (opts.env) await put(root, at(".env"), renderEnvLocal(manifest), result, "own");
236
+ if (opts.env) await putEnv(root, at(".env"), renderEnvLocal(manifest), result);
186
237
  await put(root, at("lib/domain.ts"), renderDomainTs(manifest.layout), result, own);
187
238
  await put(root, at("lib/payment.ts"), renderPaymentTs(manifest), result, own);
188
239
  await put(root, at("lib/notifications.ts"), renderNotificationsTs(), result, own);
@@ -263,7 +314,7 @@ async function generateFrontend(root: string, dir: string, manifest: Manifest, r
263
314
 
264
315
  await put(root, at("lib/api.ts"), renderFrontendApiTs(manifest), result, "own");
265
316
  await put(root, at(".env.production"), renderFrontendEnvProduction(manifest), result, "regenerate");
266
- await put(root, at("vite.config.ts"), renderFrontendViteConfig(), result, "own");
317
+ await put(root, at("vite.config.ts"), renderFrontendViteConfig(manifest), result, "own");
267
318
  await put(root, at("void.json"), renderFrontendVoidJson(), result, "own");
268
319
  await put(root, at("tsconfig.json"), renderFrontendTsconfig(), result, "own");
269
320
  await put(root, at("pages/index.tsx"), renderStorefrontPage(manifest), result, "own");
@@ -395,7 +446,7 @@ bunx vc guard
395
446
  // build. Two workflows on the same push would deploy the shop twice.
396
447
  await retire(root, ".github/workflows/void-dist.yml", result);
397
448
  await put(root, "DEPLOY.md", renderDeployReadme(manifest, zone(manifest), workerHosts(manifest)), result, "regenerate");
398
- await put(root, ".env", renderEnvLocal(manifest), result, "own");
449
+ await putEnv(root, ".env", renderEnvLocal(manifest), result);
399
450
  await put(root, "data/README.md", DATA_README, result, "own");
400
451
  await put(root, "branding/README.md", BRANDING_README, result, "own");
401
452
  await put(root, "migrations/README.md", MIGRATIONS_README, result, "own");
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export { preflight, printPreflight, productionEnv, routeProblem } from "./deploy
27
27
  export { deployCloudflare } from "./deploy/cloudflare";
28
28
  export {
29
29
  FRONTEND_BRANCH,
30
+ renderFrontendViteConfig,
30
31
  renderFrontendApiTs,
31
32
  renderFrontendEnvProduction,
32
33
  renderFrontendWorkflow,