@saastemly/voidcommerce 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,797 @@
1
+ import {
2
+ envKeysOf,
3
+ hasFrontend,
4
+ isApex,
5
+ zone
6
+ } from "./index-pz6m2hkm.js";
7
+ import {
8
+ __require,
9
+ __toESM
10
+ } from "./index-0v6na3yp.js";
11
+
12
+ // src/deploy/keys.ts
13
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
14
+ import { join as join3 } from "node:path";
15
+ import color2 from "picocolors";
16
+
17
+ // src/deploy/github.ts
18
+ import { spawn } from "node:child_process";
19
+ import { delimiter, join } from "node:path";
20
+ import { existsSync } from "node:fs";
21
+ var REPO_SCOPE = "repo";
22
+ function findGh() {
23
+ for (const entry of (process.env["PATH"] ?? "").split(delimiter).filter(Boolean)) {
24
+ if (existsSync(join(entry, "gh")))
25
+ return join(entry, "gh");
26
+ }
27
+ return null;
28
+ }
29
+ function run(cmd, args, cwd, stdin) {
30
+ return new Promise((resolve) => {
31
+ const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
32
+ let out = "";
33
+ child.stdout?.on("data", (chunk) => {
34
+ out += chunk;
35
+ });
36
+ child.stderr?.on("data", (chunk) => {
37
+ out += chunk;
38
+ });
39
+ child.on("error", (error) => resolve({ code: 1, out: String(error) }));
40
+ child.on("exit", (code) => resolve({ code: code ?? 1, out }));
41
+ child.stdin?.end(stdin ?? "");
42
+ });
43
+ }
44
+ async function ghAuth(cwd) {
45
+ const gh = findGh();
46
+ if (!gh) {
47
+ return { ok: false, reason: "the GitHub CLI is not installed. https://cli.github.com — then `gh auth login`." };
48
+ }
49
+ const status = await run(gh, ["auth", "status"], cwd);
50
+ if (status.code !== 0) {
51
+ return { ok: false, reason: "gh is not logged in. Run `gh auth login`." };
52
+ }
53
+ const user = /Logged in to \S+ account (\S+)/.exec(status.out)?.[1];
54
+ const scopes = /Token scopes: (.+)/.exec(status.out)?.[1]?.split(",").map((scope) => scope.trim().replace(/^'|'$/g, ""));
55
+ if (scopes && !scopes.includes(REPO_SCOPE)) {
56
+ return {
57
+ ok: false,
58
+ user,
59
+ scopes,
60
+ reason: `gh is logged in as ${user ?? "you"} but its token lacks the \`${REPO_SCOPE}\` scope, which writing repository secrets needs.
61
+ Run \`gh auth refresh -s ${REPO_SCOPE}\`.`
62
+ };
63
+ }
64
+ return { ok: true, user, scopes };
65
+ }
66
+ async function repoSlug(cwd) {
67
+ const gh = findGh();
68
+ if (!gh)
69
+ return null;
70
+ const viewed = await run(gh, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], cwd);
71
+ if (viewed.code !== 0)
72
+ return null;
73
+ const slug = viewed.out.trim();
74
+ return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
75
+ }
76
+ async function setSecret(cwd, name, value) {
77
+ const gh = findGh();
78
+ if (!gh)
79
+ return { ok: false, error: "gh is not installed" };
80
+ const result = await run(gh, ["secret", "set", name], cwd, value);
81
+ return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split(`
82
+ `).slice(-2).join(" ") };
83
+ }
84
+ async function secretNames(cwd) {
85
+ const gh = findGh();
86
+ if (!gh)
87
+ return new Set;
88
+ const listed = await run(gh, ["secret", "list", "--json", "name", "-q", ".[].name"], cwd);
89
+ if (listed.code !== 0)
90
+ return new Set;
91
+ return new Set(listed.out.split(`
92
+ `).map((line) => line.trim()).filter(Boolean));
93
+ }
94
+ async function setVariable(cwd, name, value) {
95
+ const gh = findGh();
96
+ if (!gh)
97
+ return { ok: false, error: "gh is not installed" };
98
+ const result = await run(gh, ["variable", "set", name, "--body", value], cwd);
99
+ return result.code === 0 ? { ok: true } : { ok: false, error: result.out.trim().split(`
100
+ `).slice(-2).join(" ") };
101
+ }
102
+ async function variableNames(cwd) {
103
+ const gh = findGh();
104
+ if (!gh)
105
+ return new Set;
106
+ const listed = await run(gh, ["variable", "list", "--json", "name", "-q", ".[].name"], cwd);
107
+ if (listed.code !== 0)
108
+ return new Set;
109
+ return new Set(listed.out.split(`
110
+ `).map((line) => line.trim()).filter(Boolean));
111
+ }
112
+ async function verifyCloudflareToken(token) {
113
+ try {
114
+ const response = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
115
+ headers: { Authorization: `Bearer ${token}` }
116
+ });
117
+ const body = await response.json();
118
+ if (body.success && body.result?.status === "active")
119
+ return { ok: true, detail: "active" };
120
+ const message = body.errors?.[0]?.message ?? `HTTP ${response.status}`;
121
+ return { ok: false, detail: message };
122
+ } catch (error) {
123
+ return { ok: false, detail: `could not reach the Cloudflare API: ${String(error)}` };
124
+ }
125
+ }
126
+ async function cloudflareAccounts(token) {
127
+ try {
128
+ const response = await fetch("https://api.cloudflare.com/client/v4/accounts?per_page=50", {
129
+ headers: { Authorization: `Bearer ${token}` }
130
+ });
131
+ const body = await response.json();
132
+ return body.success && Array.isArray(body.result) ? body.result.map((a) => ({ id: a.id, name: a.name })) : [];
133
+ } catch {
134
+ return [];
135
+ }
136
+ }
137
+
138
+ // src/deploy/secrets.ts
139
+ import { existsSync as existsSync2, readFileSync, writeFileSync } from "node:fs";
140
+ import { spawn as spawn2 } from "node:child_process";
141
+ import { delimiter as delimiter2, dirname, join as join2 } from "node:path";
142
+ import { tmpdir } from "node:os";
143
+ import color from "picocolors";
144
+
145
+ // src/generate/env.ts
146
+ var BASE = [
147
+ {
148
+ key: "SHOP_DOMAIN",
149
+ breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
150
+ plaintext: true
151
+ },
152
+ {
153
+ key: "COMMERCE_CRON_SECRET",
154
+ breaks: "the scheduler cannot authenticate, so every background job stops",
155
+ where: "openssl rand -base64 32"
156
+ },
157
+ {
158
+ key: "COMMERCE_WEBHOOK_SECRET",
159
+ breaks: "payment webhooks cannot be verified, so no order ever becomes paid",
160
+ where: "the payment provider's dashboard"
161
+ },
162
+ {
163
+ key: "SHIPPING_DOMESTIC_MINOR",
164
+ breaks: "delivery is quoted at nothing, and every order loses the carrier cost",
165
+ plaintext: true,
166
+ dev: "4900",
167
+ where: "your carrier agreement, in minor units ex VAT"
168
+ },
169
+ {
170
+ key: "SHIPPING_FREE_FROM_MINOR",
171
+ breaks: "the free-delivery threshold is undefined",
172
+ plaintext: true,
173
+ dev: "100000"
174
+ }
175
+ ];
176
+ function allEnvKeys(manifest) {
177
+ const keys = [...BASE, ...envKeysOf(manifest)];
178
+ if (!isApex(manifest)) {
179
+ keys.splice(1, 0, {
180
+ key: "SHOP_ZONE",
181
+ breaks: "DNS records are written to the wrong zone, or to none",
182
+ plaintext: true
183
+ });
184
+ }
185
+ if (hasFrontend(manifest.layout) && manifest.shop.pagesHost) {
186
+ keys.splice(1, 0, {
187
+ key: "GITHUB_PAGES_HOST",
188
+ breaks: "the www record has nothing to point at",
189
+ plaintext: true
190
+ });
191
+ }
192
+ return keys;
193
+ }
194
+ var NUMERIC = new Set(["SHIPPING_DOMESTIC_MINOR", "SHIPPING_FREE_FROM_MINOR"]);
195
+ function renderEnvTs(manifest) {
196
+ const keys = allEnvKeys(manifest);
197
+ const lines = keys.map((key) => {
198
+ const helper = NUMERIC.has(key.key) ? "number()" : "string()";
199
+ const doc = [` /** ${key.breaks}${key.where ? ` — from: ${key.where}` : ""} */`];
200
+ return `${doc.join(`
201
+ `)}
202
+ ${key.key}: ${helper},`;
203
+ });
204
+ return `import { defineEnv, number, string } from "void/env";
205
+
206
+ /**
207
+ * Every env key the app reads. All of them are REQUIRED.
208
+ *
209
+ * Generated by \`vc init\` from voidcommerce.json — edit the manifest and
210
+ * regenerate rather than editing this by hand.
211
+ *
212
+ * Nothing is optional: an integration this shop supports is one the
213
+ * deployment sets up. No key has a default, because a default is compiled
214
+ * into the worker's vars and shadows the real secret. Locally, the literal
215
+ * value \`unset\` is the one documented way to say "I do not have this yet",
216
+ * and \`vc preflight\` refuses it.
217
+ */
218
+ export default defineEnv({
219
+ ${lines.join(`
220
+ `)}
221
+ });
222
+ `;
223
+ }
224
+ function renderEnvExample(manifest) {
225
+ const keys = allEnvKeys(manifest);
226
+ return [
227
+ "# Every key is required. Copy to .env for local development.",
228
+ "# `unset` is the one value that reads as absent — for a credential you do not have yet.",
229
+ "",
230
+ ...keys.map((key) => `${key.key}=${key.dev ?? (key.plaintext ? "" : "unset")}`),
231
+ ""
232
+ ].join(`
233
+ `);
234
+ }
235
+ function renderEnvLocal(manifest) {
236
+ const keys = allEnvKeys(manifest);
237
+ const local = {
238
+ SHOP_DOMAIN: `${manifest.shop.domain.split(".")[0]}.test`,
239
+ GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
240
+ SHOP_ZONE: zone(manifest),
241
+ COMMERCE_CRON_SECRET: "dev-cron-secret-not-for-production",
242
+ COMMERCE_WEBHOOK_SECRET: "whsec_dev_secret",
243
+ SYSTEM_API_KEY: "dev-system-key-0123456789abcdefghijklmnopqrstuvwxyz",
244
+ EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`
245
+ };
246
+ return [
247
+ "# Local development. Gitignored; .env.example documents every key.",
248
+ "",
249
+ ...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
+ ""
252
+ ].join(`
253
+ `);
254
+ }
255
+ function renderEnvProduction(manifest) {
256
+ const keys = allEnvKeys(manifest).filter((key) => key.plaintext);
257
+ const values = {
258
+ SHOP_DOMAIN: manifest.shop.domain,
259
+ GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
260
+ SHOP_ZONE: zone(manifest),
261
+ EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`,
262
+ ADYEN_ENVIRONMENT: "live",
263
+ BC_ENVIRONMENT: "production"
264
+ };
265
+ return [
266
+ "# Production values that are NOT secrets.",
267
+ "#",
268
+ "# On `void deploy --backend cloudflare` every .env* file is baked into the",
269
+ "# worker's vars as PLAINTEXT, so this holds only values safe to commit. Every",
270
+ "# credential is `wrangler secret put <NAME>` instead.",
271
+ "",
272
+ ...keys.map((key) => `${key.key}=${values[key.key] ?? key.dev ?? ""}`),
273
+ ""
274
+ ].join(`
275
+ `);
276
+ }
277
+ function envSummary(manifest) {
278
+ const keys = allEnvKeys(manifest);
279
+ return {
280
+ secrets: keys.filter((key) => !key.plaintext).map((key) => key.key),
281
+ plaintext: keys.filter((key) => key.plaintext).map((key) => key.key)
282
+ };
283
+ }
284
+
285
+ // src/deploy/secrets.ts
286
+ var SECRETS_FILE = ".env.secrets";
287
+ var PRIVATE_KEY_VAR = "DOTENV_PRIVATE_KEY_SECRETS";
288
+ function findDotenvx(from) {
289
+ let dir = from;
290
+ for (;; ) {
291
+ const local = join2(dir, "node_modules", ".bin", "dotenvx");
292
+ if (existsSync2(local))
293
+ return local;
294
+ const parent = dirname(dir);
295
+ if (parent === dir)
296
+ break;
297
+ dir = parent;
298
+ }
299
+ return (process.env["PATH"] ?? "").split(delimiter2).filter(Boolean).some((entry) => existsSync2(join2(entry, "dotenvx"))) ? "dotenvx" : null;
300
+ }
301
+ function run2(cmd, args, cwd, env = {}) {
302
+ return new Promise((resolve) => {
303
+ const child = spawn2(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
304
+ let out = "";
305
+ child.stdout.on("data", (chunk) => {
306
+ out += chunk;
307
+ });
308
+ child.stderr.on("data", (chunk) => {
309
+ out += chunk;
310
+ });
311
+ child.on("error", (error) => resolve({ code: 1, out: String(error) }));
312
+ child.on("exit", (code) => resolve({ code: code ?? 1, out }));
313
+ });
314
+ }
315
+ function committedPublicKeyInto(root, publicKey) {
316
+ const path = join2(root, SECRETS_FILE);
317
+ const line = `DOTENV_PUBLIC_KEY_SECRETS="${publicKey}"`;
318
+ if (!existsSync2(path)) {
319
+ writeFileSync(path, `${line}
320
+ `, { mode: 384 });
321
+ return;
322
+ }
323
+ const body = readFileSync(path, "utf8");
324
+ writeFileSync(path, /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=.*$/m.test(body) ? body.replace(/^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=.*$/m, line) : `${line}
325
+ ${body}`);
326
+ }
327
+ function declaredSecretNames(root) {
328
+ const path = join2(root, SECRETS_FILE);
329
+ if (!existsSync2(path))
330
+ return new Set;
331
+ const names = new Set;
332
+ for (const line of readFileSync(path, "utf8").split(`
333
+ `)) {
334
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
335
+ if (match && !match[1].startsWith("DOTENV_"))
336
+ names.add(match[1]);
337
+ }
338
+ return names;
339
+ }
340
+ function plaintextSecretNames(root) {
341
+ const path = join2(root, SECRETS_FILE);
342
+ if (!existsSync2(path))
343
+ return [];
344
+ const bare = [];
345
+ for (const line of readFileSync(path, "utf8").split(`
346
+ `)) {
347
+ const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
348
+ if (!match || match[1].startsWith("DOTENV_"))
349
+ continue;
350
+ const value = match[2].trim().replace(/^['"]|['"]$/g, "");
351
+ if (value && value !== "unset" && !value.startsWith("encrypted:"))
352
+ bare.push(match[1]);
353
+ }
354
+ return bare;
355
+ }
356
+ async function decryptSecrets(project) {
357
+ const root = project.root;
358
+ if (!existsSync2(join2(root, SECRETS_FILE)))
359
+ return { error: `${SECRETS_FILE} does not exist — \`vc secrets init\` writes one` };
360
+ const privateKey = process.env[PRIVATE_KEY_VAR];
361
+ if (!privateKey) {
362
+ return {
363
+ error: `${PRIVATE_KEY_VAR} is not set, so ${SECRETS_FILE} cannot be decrypted here.
364
+ ` + ` That is the normal state on a laptop: the key lives in GitHub Actions and
365
+ ` + ` only the deploy uses it. Push, and the workflow does this step.
366
+ ` + ` To deploy by hand anyway, export the key for one command.`
367
+ };
368
+ }
369
+ const expected = committedPublicKey(root);
370
+ if (expected) {
371
+ const { publicKeyFor } = await import("./keys-b91c72zp.js");
372
+ const derived = await publicKeyFor(privateKey);
373
+ if (derived && derived.toLowerCase() !== expected.toLowerCase()) {
374
+ return {
375
+ error: `${PRIVATE_KEY_VAR} does not open this repository.
376
+ it derives: ${derived.slice(0, 16)}…
377
+ ` + ` encrypted under: ${expected.slice(0, 16)}…
378
+ ` + ` It belongs to a different shop, or the repository has been re-keyed since.`
379
+ };
380
+ }
381
+ }
382
+ const dotenvx = findDotenvx(root);
383
+ if (!dotenvx)
384
+ return { error: "dotenvx is not installed. `bun add -d @dotenvx/dotenvx`" };
385
+ const result = await run2(dotenvx, ["decrypt", "-f", SECRETS_FILE, "--stdout"], root, {
386
+ [PRIVATE_KEY_VAR]: privateKey
387
+ });
388
+ if (result.code !== 0)
389
+ return { error: `dotenvx could not decrypt ${SECRETS_FILE}: ${result.out.trim().split(`
390
+ `).slice(-2).join(" ")}` };
391
+ const lines = result.out.split(`
392
+ `).filter((line) => /^\s*[A-Z][A-Z0-9_]*\s*=/.test(line) && !line.trimStart().startsWith("DOTENV_"));
393
+ const names = lines.map((line) => line.slice(0, line.indexOf("=")).trim());
394
+ const path = join2(tmpdir(), `vc-secrets-${process.pid}-${Date.now()}.env`);
395
+ writeFileSync(path, `${lines.join(`
396
+ `)}
397
+ `, { mode: 384 });
398
+ return {
399
+ path,
400
+ names,
401
+ cleanup: () => {
402
+ try {
403
+ writeFileSync(path, "", { mode: 384 });
404
+ __require("node:fs").unlinkSync(path);
405
+ } catch {}
406
+ }
407
+ };
408
+ }
409
+ async function secretsCommand(project, args) {
410
+ const root = project.root;
411
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
412
+ const declared = declaredSecretNames(root);
413
+ const bare = plaintextSecretNames(root);
414
+ if (args.includes("--init"))
415
+ return initSecrets(project);
416
+ if (args[0] === "set")
417
+ return setSecretValue(project, args.slice(1));
418
+ if (!existsSync2(join2(root, SECRETS_FILE))) {
419
+ console.log(`
420
+ ${color.yellow("No " + SECRETS_FILE + " yet.")} Secrets are set by hand with \`wrangler secret put\`,
421
+ which means the shop cannot be rebuilt from a checkout.
422
+
423
+ vc secrets --init write one, with every required key as \`unset\`
424
+ `);
425
+ return 1;
426
+ }
427
+ console.log(`
428
+ ${SECRETS_FILE} — committed, encrypted, and read by \`vc deploy --cloudflare\`
429
+ `);
430
+ for (const key of required) {
431
+ const state = declared.has(key.key) ? color.green("declared") : color.red("MISSING ");
432
+ console.log(` ${state} ${key.key}`);
433
+ }
434
+ const extra = [...declared].filter((name) => !required.some((key) => key.key === name));
435
+ for (const name of extra)
436
+ console.log(` ${color.dim("extra ")} ${name} ${color.dim("— not required by this shop")}`);
437
+ if (bare.length > 0) {
438
+ console.log(`
439
+ ${color.red("✗")} ${bare.length} value${bare.length === 1 ? " is" : "s are"} committed IN THE CLEAR: ${bare.join(", ")}
440
+ Run \`dotenvx encrypt -f ${SECRETS_FILE}\` before committing again.
441
+ `);
442
+ return 1;
443
+ }
444
+ const missing = required.filter((key) => !declared.has(key.key));
445
+ if (missing.length > 0) {
446
+ console.log(`
447
+ ${color.red("✗")} ${missing.length} required secret${missing.length === 1 ? "" : "s"} not declared.
448
+ dotenvx set ${missing[0].key} '…' -f ${SECRETS_FILE}
449
+ `);
450
+ return 1;
451
+ }
452
+ console.log(`
453
+ ${color.green("✓")} every secret this shop needs is declared and encrypted.
454
+ `);
455
+ return 0;
456
+ }
457
+ async function initSecrets(project) {
458
+ const path = join2(project.root, SECRETS_FILE);
459
+ if (existsSync2(path) && declaredSecretNames(project.root).size > 0) {
460
+ console.error(`vc: ${SECRETS_FILE} already declares secrets; not overwriting it.
461
+ \`vc secrets set KEY\` changes one.`);
462
+ return 1;
463
+ }
464
+ const existing = committedPublicKey(project.root);
465
+ const made = existing ? { ok: true, publicKey: existing } : await provisionKey(project);
466
+ if (!made.ok) {
467
+ console.error(`
468
+ vc: ${made.reason}
469
+ `);
470
+ return 1;
471
+ }
472
+ const required = allEnvKeys(project.manifest).filter((key) => !key.plaintext);
473
+ const body = [
474
+ "# Secrets, encrypted, and COMMITTED.",
475
+ "#",
476
+ "# Values are ciphertext; the key names are readable so a diff shows WHICH",
477
+ "# secret changed without showing what it changed to. `unset` is the",
478
+ "# documented placeholder and preflight refuses it.",
479
+ "#",
480
+ "# Setting a value needs NO credential: dotenvx encrypts with the public key",
481
+ "# below, which is right here in the repository. Only the deploy decrypts,",
482
+ "# and the private key for that lives in GitHub Actions.",
483
+ "#",
484
+ "# `vc secrets set KEY` is the way to change one — it reads the value from",
485
+ "# the terminal, so it never reaches a command line where `ps` would show it.",
486
+ "",
487
+ `DOTENV_PUBLIC_KEY_SECRETS="${made.publicKey}"`,
488
+ "",
489
+ ...required.flatMap((key) => [`# ${key.breaks}${key.where ? ` — from: ${key.where}` : ""}`, `${key.key}=unset`, ""])
490
+ ].join(`
491
+ `);
492
+ writeFileSync(path, body, { mode: 384 });
493
+ console.log(`
494
+ ${color.green("+")} ${SECRETS_FILE} — ${required.length} keys, all \`unset\`
495
+ ${existing ? color.dim(`= ${PRIVATE_KEY_VAR} was already on GitHub; reused it`) : `${color.green("+")} ${PRIVATE_KEY_VAR} stored on GitHub; the public key is in the file`}
496
+
497
+ Next:
498
+ ${required.map((key) => ` ${color.cyan(`vc secrets set ${key.key}`)}`).join(`
499
+ `)}
500
+
501
+ then commit ${SECRETS_FILE}. There is no key file to keep out of git.
502
+ `);
503
+ return 0;
504
+ }
505
+ async function setSecretValue(project, args) {
506
+ const root = project.root;
507
+ const name = args.find((arg) => !arg.startsWith("-"));
508
+ if (!name) {
509
+ console.error(`
510
+ vc: which secret? \`vc secrets set STRIPE_SECRET_KEY\`
511
+ `);
512
+ return 1;
513
+ }
514
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
515
+ console.error(`
516
+ vc: "${name}" is not a key name — they are SHOUTING_SNAKE_CASE.
517
+ `);
518
+ return 1;
519
+ }
520
+ if (!existsSync2(join2(root, SECRETS_FILE))) {
521
+ console.error(`
522
+ vc: no ${SECRETS_FILE} yet. \`vc secrets --init\` writes one.
523
+ `);
524
+ return 1;
525
+ }
526
+ const publicKey = committedPublicKey(root);
527
+ if (!publicKey) {
528
+ console.error(`
529
+ vc: ${SECRETS_FILE} has no DOTENV_PUBLIC_KEY_SECRETS, so there is nothing to encrypt to.
530
+ \`vc keys --init\` makes a key and puts the public half here.
531
+ `);
532
+ return 1;
533
+ }
534
+ const known = allEnvKeys(project.manifest).find((key) => key.key === name);
535
+ const value = await readValue(name, known?.breaks, known?.where);
536
+ if (value === null)
537
+ return 1;
538
+ const written = await encryptInto(root, publicKey, name, value);
539
+ if (!written.ok) {
540
+ console.error(`
541
+ vc: ${written.error}
542
+ `);
543
+ return 1;
544
+ }
545
+ const stillBare = plaintextSecretNames(root).includes(name);
546
+ if (stillBare) {
547
+ console.error(`
548
+ ${color.red("✗")} ${name} was written in the CLEAR — the public key did not encrypt it.
549
+ Do not commit. Check DOTENV_PUBLIC_KEY_SECRETS in ${SECRETS_FILE}.
550
+ `);
551
+ return 1;
552
+ }
553
+ console.log(`
554
+ ${color.green("✓")} ${name} encrypted into ${SECRETS_FILE}. Commit it.
555
+ `);
556
+ return 0;
557
+ }
558
+ async function readValue(name, breaks, where) {
559
+ if (!process.stdin.isTTY) {
560
+ const chunks = [];
561
+ for await (const chunk of process.stdin)
562
+ chunks.push(chunk);
563
+ const piped = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
564
+ if (!piped) {
565
+ console.error(`
566
+ vc: nothing on stdin to set ${name} to.
567
+ `);
568
+ return null;
569
+ }
570
+ return piped;
571
+ }
572
+ const p = await import("@clack/prompts");
573
+ if (breaks)
574
+ console.log(`
575
+ ${color.dim(breaks)}${where ? color.dim(` — from: ${where}`) : ""}`);
576
+ const answer = await p.password({
577
+ message: name,
578
+ validate: (input) => input && input.length > 0 ? undefined : "a secret with no value is not a secret"
579
+ });
580
+ if (p.isCancel(answer)) {
581
+ console.log(color.dim(`
582
+ cancelled; nothing changed.
583
+ `));
584
+ return null;
585
+ }
586
+ return String(answer);
587
+ }
588
+ async function encryptInto(root, publicKey, name, value) {
589
+ const { encrypt } = await import("./index-mpr7gm6k.js").then((m)=>__toESM(m.default,1));
590
+ let ciphertext;
591
+ try {
592
+ ciphertext = encrypt(publicKey, value);
593
+ } catch (error) {
594
+ return { ok: false, error: `could not encrypt to the key in ${SECRETS_FILE}: ${String(error)}` };
595
+ }
596
+ const looksWrong = !ciphertext.startsWith("encrypted:") || ciphertext === value || value.length >= 12 && ciphertext.includes(value);
597
+ if (looksWrong) {
598
+ return { ok: false, error: `refusing to write ${name} — the result does not look encrypted.` };
599
+ }
600
+ upsertLine(join2(root, SECRETS_FILE), name, ciphertext);
601
+ return { ok: true };
602
+ }
603
+ function upsertLine(path, name, value) {
604
+ const body = readFileSync(path, "utf8");
605
+ const pattern = new RegExp(`^\\s*${name}\\s*=.*$`, "m");
606
+ writeFileSync(path, pattern.test(body) ? body.replace(pattern, `${name}=${value}`) : `${body.replace(/\n*$/, `
607
+ `)}${name}=${value}
608
+ `);
609
+ }
610
+
611
+ // src/deploy/keys.ts
612
+ function committedPublicKey(root) {
613
+ const path = join3(root, SECRETS_FILE);
614
+ if (!existsSync3(path))
615
+ return null;
616
+ const match = /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=\s*["']?([0-9a-fA-F]+)["']?/m.exec(readFileSync2(path, "utf8"));
617
+ return match?.[1] ?? null;
618
+ }
619
+ async function generateKeypair() {
620
+ try {
621
+ const { keypair } = await import("./index-mpr7gm6k.js").then((m)=>__toESM(m.default,1));
622
+ return keypair();
623
+ } catch {
624
+ return null;
625
+ }
626
+ }
627
+ async function publicKeyFor(privateKey) {
628
+ try {
629
+ const { derive } = await import("./index-mpr7gm6k.js").then((m)=>__toESM(m.default,1));
630
+ return derive(privateKey);
631
+ } catch {
632
+ return null;
633
+ }
634
+ }
635
+ async function keyState(project) {
636
+ const publicKey = committedPublicKey(project.root);
637
+ const slug = await repoSlug(project.root);
638
+ const names = slug ? await secretNames(project.root) : new Set;
639
+ const localKey = process.env[PRIVATE_KEY_VAR] ?? null;
640
+ let mismatch;
641
+ if (localKey && publicKey) {
642
+ const derived = await publicKeyFor(localKey);
643
+ if (derived && derived.toLowerCase() !== publicKey.toLowerCase()) {
644
+ mismatch = `${PRIVATE_KEY_VAR} in your environment derives ${derived.slice(0, 16)}…, but ${SECRETS_FILE} was encrypted under ${publicKey.slice(0, 16)}…`;
645
+ }
646
+ }
647
+ return { publicKey, inGitHub: names.has(PRIVATE_KEY_VAR), slug, localKey, mismatch };
648
+ }
649
+ async function provisionKey(project) {
650
+ const auth = await ghAuth(project.root);
651
+ if (!auth.ok)
652
+ return { ok: false, reason: auth.reason ?? "gh is unavailable" };
653
+ const slug = await repoSlug(project.root);
654
+ if (!slug) {
655
+ return {
656
+ ok: false,
657
+ reason: `this checkout has no GitHub repository yet, and the key is stored on the repository.
658
+ Create one first:
659
+
660
+ gh repo create --source=. --private --push
661
+ `
662
+ };
663
+ }
664
+ const pair = await generateKeypair();
665
+ if (!pair)
666
+ return { ok: false, reason: "@dotenvx/dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`" };
667
+ const sent = await setSecret(project.root, PRIVATE_KEY_VAR, pair.privateKey);
668
+ if (!sent.ok)
669
+ return { ok: false, reason: `GitHub refused the secret: ${sent.error ?? "unknown error"}` };
670
+ return { ok: true, publicKey: pair.publicKey };
671
+ }
672
+ async function keysCommand(project, args) {
673
+ if (args.includes("--rotate"))
674
+ return rotate(project);
675
+ if (args.includes("--init"))
676
+ return initKey(project);
677
+ const state = await keyState(project);
678
+ console.log(`
679
+ Secrets are encrypted with a public key that is COMMITTED, and read with a`);
680
+ console.log(`private key that lives only in GitHub Actions.
681
+ `);
682
+ const hasFile = existsSync3(join3(project.root, SECRETS_FILE));
683
+ const publicNote = state.publicKey ? `${state.publicKey.slice(0, 20)}… in ${SECRETS_FILE}` : color2.dim(hasFile ? `${SECRETS_FILE} has no key yet — \`vc link\` makes one` : `no ${SECRETS_FILE} yet — \`vc secrets --init\``);
684
+ console.log(` ${state.publicKey ? color2.green("✓") : color2.dim("·")} public key ${publicNote}`);
685
+ console.log(` ${state.inGitHub ? color2.green("✓") : color2.red("✗")} private key ${state.inGitHub ? `${PRIVATE_KEY_VAR} is set on ${state.slug}` : state.slug ? color2.red(`not set on ${state.slug} — \`vc keys --init\``) : color2.dim("no GitHub repository yet")}`);
686
+ if (state.mismatch) {
687
+ console.error(`
688
+ ${color2.red("✗")} ${state.mismatch}
689
+ Unset it, or point it at the right repository.
690
+ `);
691
+ return 1;
692
+ }
693
+ console.log(color2.dim(`
694
+ Adding or changing a secret needs NO credential — encryption uses the public
695
+ ` + `key in the repository. Only the deploy decrypts, and only GitHub Actions can.
696
+ `));
697
+ if (!state.inGitHub && state.slug)
698
+ return 1;
699
+ return 0;
700
+ }
701
+ async function initKey(project) {
702
+ const state = await keyState(project);
703
+ if (state.inGitHub && state.publicKey) {
704
+ console.error(`
705
+ vc: ${project.root} already has a key: ${PRIVATE_KEY_VAR} on ${state.slug}, public ${state.publicKey.slice(0, 16)}….
706
+ ` + ` Replacing it makes every value in ${SECRETS_FILE} unreadable. \`vc keys --rotate\` is the deliberate way.
707
+ `);
708
+ return 1;
709
+ }
710
+ const made = await provisionKey(project);
711
+ if (!made.ok) {
712
+ console.error(`
713
+ vc: ${made.reason}
714
+ `);
715
+ return 1;
716
+ }
717
+ const fresh = !existsSync3(join3(project.root, SECRETS_FILE));
718
+ committedPublicKeyInto(project.root, made.publicKey);
719
+ console.log(`
720
+ ${color2.green("✓")} ${PRIVATE_KEY_VAR} set on ${state.slug}; public key in ${SECRETS_FILE}
721
+ ` + (fresh ? `
722
+ Next: ${color2.cyan("vc secrets --init")} fills in the keys this shop needs.
723
+ ` : `
724
+ `));
725
+ return 0;
726
+ }
727
+ async function rotate(project) {
728
+ const state = await keyState(project);
729
+ const old = state.localKey;
730
+ if (!old) {
731
+ console.error(`
732
+ vc: rotating needs the CURRENT private key, and GitHub cannot give it back —
733
+ ` + ` a secret is write-only once set, which is why it is a good place to keep one.
734
+
735
+ If you have a copy:
736
+ ${color2.cyan(`${PRIVATE_KEY_VAR}=<the old key> vc keys --rotate`)}
737
+
738
+ If you do not, re-key and enter the values again:
739
+ ${color2.cyan(`rm ${SECRETS_FILE} && vc keys --init && vc secrets --init`)}
740
+
741
+ ` + color2.dim(` That is less painful than it sounds. The reason to rotate an encryption key
742
+ ` + ` is that it may have leaked — and a key that may have leaked means the VALUES
743
+ ` + ` may have leaked. Those have to be replaced at Stripe and everywhere else
744
+ regardless, so re-entering them is not extra work. It is the work.
745
+ `));
746
+ return 1;
747
+ }
748
+ if (state.mismatch) {
749
+ console.error(`
750
+ vc: ${state.mismatch}
751
+ That key cannot read this repository, so it cannot rotate it either.
752
+ `);
753
+ return 1;
754
+ }
755
+ const dotenvx = findDotenvx(project.root);
756
+ if (!dotenvx) {
757
+ console.error("vc: dotenvx is not installed here. `bun add -d @dotenvx/dotenvx`");
758
+ return 1;
759
+ }
760
+ const decrypted = await run2(dotenvx, ["decrypt", "-f", SECRETS_FILE], project.root, { [PRIVATE_KEY_VAR]: old });
761
+ if (decrypted.code !== 0) {
762
+ console.error(`
763
+ vc: could not decrypt ${SECRETS_FILE}: ${decrypted.out.trim().split(`
764
+ `).slice(-2).join(" ")}
765
+ `);
766
+ return 1;
767
+ }
768
+ const made = await provisionKey(project);
769
+ if (!made.ok) {
770
+ console.error(`
771
+ vc: ${made.reason}
772
+
773
+ ` + color2.red(` ${SECRETS_FILE} IS NOW PLAINTEXT ON DISK and must not be committed.
774
+ `) + ` Re-encrypt under the old key to undo: ${color2.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)}
775
+ `);
776
+ return 1;
777
+ }
778
+ committedPublicKeyInto(project.root, made.publicKey);
779
+ const encrypted = await run2(dotenvx, ["encrypt", "-f", SECRETS_FILE], project.root);
780
+ if (encrypted.code !== 0) {
781
+ console.error(`
782
+ vc: re-encryption failed: ${encrypted.out.trim().split(`
783
+ `).slice(-2).join(" ")}
784
+ ` + color2.red(` ${SECRETS_FILE} IS PLAINTEXT ON DISK. Do not commit it.
785
+ `));
786
+ return 1;
787
+ }
788
+ console.log(`
789
+ ${color2.green("✓")} re-keyed under ${made.publicKey.slice(0, 20)}… and ${PRIVATE_KEY_VAR} replaced on ${state.slug}
790
+
791
+ Commit ${SECRETS_FILE}. The old key reads nothing in it any more.
792
+ ` + color2.yellow(` The old ciphertext is still in git history, and the old key still opens THAT.
793
+ `));
794
+ return 0;
795
+ }
796
+
797
+ export { allEnvKeys, renderEnvTs, renderEnvExample, renderEnvLocal, renderEnvProduction, envSummary, ghAuth, repoSlug, setSecret, secretNames, setVariable, variableNames, verifyCloudflareToken, cloudflareAccounts, committedPublicKey, generateKeypair, publicKeyFor, keyState, provisionKey, keysCommand, SECRETS_FILE, PRIVATE_KEY_VAR, committedPublicKeyInto, declaredSecretNames, plaintextSecretNames, decryptSecrets, secretsCommand, initSecrets };