@saastemly/voidcommerce 0.2.2 → 0.4.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/index.js CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  routeProblem,
58
58
  strictDependencies,
59
59
  upsertJsonc
60
- } from "./index-sbchkpa6.js";
60
+ } from "./index-yzezvy5h.js";
61
61
  import {
62
62
  LAYOUTS,
63
63
  MANIFEST_FILE,
@@ -77,7 +77,7 @@ import {
77
77
  writeManifest,
78
78
  zone,
79
79
  zoneOf
80
- } from "./index-zh3tmj08.js";
80
+ } from "./index-pz6m2hkm.js";
81
81
  import {
82
82
  AUTH_PLUGINS,
83
83
  CARRIERS,
@@ -94,7 +94,8 @@ import {
94
94
  SIGN_IN,
95
95
  TAX,
96
96
  UI
97
- } from "./index-z4qazajn.js";
97
+ } from "./index-844b3qn9.js";
98
+ import"./index-0v6na3yp.js";
98
99
  export {
99
100
  zoneOf,
100
101
  zone,
package/dist/manifest.js CHANGED
@@ -17,8 +17,9 @@ import {
17
17
  writeManifest,
18
18
  zone,
19
19
  zoneOf
20
- } from "./index-zh3tmj08.js";
21
- import"./index-z4qazajn.js";
20
+ } from "./index-pz6m2hkm.js";
21
+ import"./index-844b3qn9.js";
22
+ import"./index-0v6na3yp.js";
22
23
  export {
23
24
  zoneOf,
24
25
  zone,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.2.2",
3
+ "version": "0.4.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",
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@clack/prompts": "^0.11.0",
37
+ "@dotenvx/primitives": "^2.2.0",
37
38
  "picocolors": "^1.1.1"
38
39
  },
39
40
  "devDependencies": {
package/src/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { deployCommand, deployHelp, preflightCommand, preflightHelp } from "./deploy/index";
1
+ import { deployCommand, deployHelp, guardCommand, keysCliCommand, keysHelp, preflightCommand, preflightHelp, secretsCliCommand, secretsHelp } from "./deploy/index";
2
2
  import { distCommand, distHelp } from "./dist";
3
3
  import { importCommand, importHelp } from "./import";
4
4
  import { fullHelp, initHelp, version } from "./help";
@@ -38,6 +38,10 @@ export const EXTENDED: Record<string, Extended> = {
38
38
  import: { run: importCommand, help: importHelp },
39
39
  deploy: { run: deployCommand, help: deployHelp },
40
40
  preflight: { run: preflightCommand, help: preflightHelp },
41
+ secrets: { run: secretsCliCommand, help: secretsHelp },
42
+ keys: { run: keysCliCommand, help: keysHelp },
43
+ // Called by the generated pre-commit hook; exit code is the interface.
44
+ guard: { run: guardCommand, help: async () => guardCommand() },
41
45
  dev: { run: appScript("dev"), help: appScriptHelp("dev") },
42
46
  build: { run: appScript("build"), help: appScriptHelp("build") },
43
47
  preview: { run: appScript("preview"), help: appScriptHelp("preview") },
@@ -6,6 +6,7 @@ import { hasFrontend, workerHosts, writeManifest } from "../manifest";
6
6
  import type { Project } from "../project";
7
7
  import { parseJsonc, upsertJsonc } from "./jsonc";
8
8
  import { preflight, printPreflight } from "./preflight";
9
+ import { SECRETS_FILE, decryptSecrets } from "./secrets";
9
10
  import { ensureD1, ensureQueue, findWrangler, whoami, wrangler } from "./wrangler";
10
11
 
11
12
  /**
@@ -25,7 +26,9 @@ import { ensureD1, ensureQueue, findWrangler, whoami, wrangler } from "./wrangle
25
26
  * secret-class key and every `unset` is removed from dist/ssr/wrangler.json
26
27
  * before it is uploaded; the worker reads those from `wrangler secret`.
27
28
  * 6. apply the committed migrations to the remote D1
28
- * 7. wrangler deploy, on exactly the config that was scrubbed
29
+ * 7. decrypt the repository's own secrets, if it keeps any
30
+ * 8. wrangler deploy, on exactly the config that was scrubbed, carrying
31
+ * those secrets up with the version
29
32
  */
30
33
 
31
34
  export interface CloudflareOptions {
@@ -153,9 +156,28 @@ export async function deployCloudflare(project: Project, opts: CloudflareOptions
153
156
  const migrated = await wrangler(bin, ["d1", "migrations", "apply", emittedD1.database_name, "--remote"], app, true);
154
157
  if (migrated.code !== 0) return fail(`applying migrations failed (exit ${migrated.code}); nothing was deployed.`);
155
158
 
156
- // 7. deploy exactly what was scrubbed.
159
+ /**
160
+ * 7. Secrets, from the repository, in the same operation as the deploy.
161
+ *
162
+ * `--secrets-file` applies ADDITIVELY: a secret this file does not name is
163
+ * left alone rather than deleted, so a shop that sets one by hand does not
164
+ * lose it the next time this runs. The plaintext exists only as a
165
+ * 0600 temp file for the length of the upload.
166
+ */
167
+ const secretArgs: string[] = [];
168
+ let cleanupSecrets: (() => void) | undefined;
169
+ if (existsSync(join(project.root, SECRETS_FILE))) {
170
+ const decrypted = await decryptSecrets(project);
171
+ if ("error" in decrypted) return fail(`the repository's secrets could not be read: ${decrypted.error}`);
172
+ secretArgs.push("--secrets-file", decrypted.path);
173
+ cleanupSecrets = decrypted.cleanup;
174
+ console.log(`${color.green("✓")} ${decrypted.names.length} secrets from ${SECRETS_FILE}, uploaded with this version`);
175
+ }
176
+
177
+ // 8. deploy exactly what was scrubbed.
157
178
  console.log("\n▸ wrangler deploy -c dist/ssr/wrangler.json");
158
- const deployed = await wrangler(bin, ["deploy", "-c", join("dist", "ssr", "wrangler.json")], app, true);
179
+ const deployed = await wrangler(bin, ["deploy", "-c", join("dist", "ssr", "wrangler.json"), ...secretArgs], app, true);
180
+ cleanupSecrets?.();
159
181
  if (deployed.code !== 0) return fail(`wrangler deploy failed (exit ${deployed.code}).`);
160
182
 
161
183
  const domain = project.manifest.shop.domain;
@@ -4,6 +4,8 @@ import { findProject } from "../project";
4
4
  import { captureVoid, runVoid } from "../void";
5
5
  import { deployCloudflare } from "./cloudflare";
6
6
  import { preflight, printPreflight } from "./preflight";
7
+ import { PRIVATE_KEY_VAR, SECRETS_FILE, plaintextSecretNames, secretsCommand } from "./secrets";
8
+ import { keysCommand } from "./keys";
7
9
 
8
10
  /**
9
11
  * `vc deploy` extends `void deploy`: vc's preflight first, then void's
@@ -99,3 +101,89 @@ export async function preflightHelp(): Promise<number> {
99
101
  );
100
102
  return 0;
101
103
  }
104
+
105
+ /** `vc secrets` — what the repository declares, and whether it is encrypted. */
106
+ export async function secretsCliCommand(args: string[]): Promise<number> {
107
+ const project = await findProject();
108
+ if (!project) {
109
+ console.error("vc: no voidcommerce.json here.");
110
+ return 1;
111
+ }
112
+ return secretsCommand(project, args);
113
+ }
114
+
115
+ export async function secretsHelp(): Promise<number> {
116
+ const width = 80;
117
+ console.log(
118
+ box("vc secrets", [
119
+ line(`Secrets that live in the repository, encrypted, in ${SECRETS_FILE}.`, width),
120
+ line("", width),
121
+ ...row("vc secrets", "what this shop needs, and whether it is declared and encrypted", width, 2),
122
+ ...row("vc secrets --init", `write ${SECRETS_FILE} with every required key as \`unset\``, width, 2),
123
+ ...row(`dotenvx set KEY '…' -f ${SECRETS_FILE}`, "set one, without decrypting the file", width, 2),
124
+ ...row(`dotenvx encrypt -f ${SECRETS_FILE}`, "encrypt anything still in the clear", width, 2),
125
+ line("", width),
126
+ line("The file is COMMITTED: values are ciphertext, key names are not, so a", width),
127
+ line("diff shows which secret changed without showing what it changed to.", width),
128
+ line(`The private key stays out — .env.keys locally, and ${PRIVATE_KEY_VAR}`, width),
129
+ line("as one build variable where the deploy runs.", width),
130
+ line("", width),
131
+ line("Tradeoff worth knowing: ciphertext in git is permanent, so a leaked key", width),
132
+ line("exposes rotated secrets too. `wrangler secret put` does not have that", width),
133
+ line("property, and stays available for anything you would rather keep out.", width),
134
+ ], width),
135
+ );
136
+ return 0;
137
+ }
138
+
139
+ /** `vc keys` — what the token derives, and the warning that goes with it. */
140
+ export async function keysCliCommand(args: string[]): Promise<number> {
141
+ const project = await findProject();
142
+ if (!project) {
143
+ console.error("vc: no voidcommerce.json here.");
144
+ return 1;
145
+ }
146
+ return keysCommand(project.manifest, project.root, args);
147
+ }
148
+
149
+ export async function keysHelp(): Promise<number> {
150
+ const width = 80;
151
+ console.log(
152
+ box("vc keys", [
153
+ line("The key that encrypts this repository's secrets is DERIVED from", width),
154
+ line("CLOUDFLARE_API_TOKEN, salted with the account id. Nothing is stored.", width),
155
+ line("", width),
156
+ ...row("vc keys", "what this token derives, and whether it matches the repository", width, 2),
157
+ ...row("vc keys --rotate", "re-encrypt under a new token, while the old one still works", width, 2),
158
+ line("", width),
159
+ line("Whoever can deploy the worker can read its secrets, and nobody else can.", width),
160
+ line("The price: rotating the token orphans every encrypted value, a second", width),
161
+ line("admin's token derives a different key, and the build must use the same", width),
162
+ line("token the values were encrypted under. `vc keys` checks the derived", width),
163
+ line("public key against the committed one, so a mismatch is a clear refusal", width),
164
+ line("rather than a decryption error nobody can place.", width),
165
+ ], width),
166
+ );
167
+ return 0;
168
+ }
169
+
170
+ /**
171
+ * The pre-commit gate: refuse a commit that would put a secret in the clear.
172
+ *
173
+ * Exit code is the whole interface — a hook cares about nothing else.
174
+ */
175
+ export async function guardCommand(): Promise<number> {
176
+ const project = await findProject();
177
+ if (!project) return 0; // Not a shop; nothing to guard.
178
+ const bare = plaintextSecretNames(project.root);
179
+ if (bare.length === 0) return 0;
180
+ console.error(
181
+ `\n${color.red("✗ refusing the commit")}: ${bare.length} value${bare.length === 1 ? "" : "s"} in ${SECRETS_FILE} ${
182
+ bare.length === 1 ? "is" : "are"
183
+ } not encrypted.\n\n` +
184
+ ` ${bare.join("\n ")}\n\n` +
185
+ ` bunx dotenvx encrypt -f ${SECRETS_FILE}\n\n` +
186
+ color.dim(" Committing a secret in the clear cannot be undone by a later commit;\n the value stays in the history and must be treated as burned.\n"),
187
+ );
188
+ return 1;
189
+ }
@@ -0,0 +1,212 @@
1
+ import { hkdfSync } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import color from "picocolors";
5
+ import type { Manifest } from "../manifest";
6
+ import { SECRETS_FILE } from "./secrets";
7
+
8
+ /**
9
+ * The key that encrypts the repository's secrets, DERIVED rather than stored.
10
+ *
11
+ * ── What this buys, and what it costs ────────────────────────────────────
12
+ *
13
+ * Nothing has to be kept. There is no `.env.keys` to lose, leak, or forget to
14
+ * gitignore — a real failure that has already happened once here. Whoever can
15
+ * deploy the worker can read its secrets, and nobody else can, which is the
16
+ * property that was wanted.
17
+ *
18
+ * The cost is severe and worth stating plainly, because it is invisible until
19
+ * the day it is not:
20
+ *
21
+ * **Rotating the API token orphans every secret in the repository.** The
22
+ * key changes with the token, and the ciphertext does not. Every
23
+ * `encrypted:` value becomes permanently unreadable, and the only recovery
24
+ * is to type them all in again.
25
+ *
26
+ * **A second admin derives a different key.** Two people with two tokens
27
+ * cannot read each other's values.
28
+ *
29
+ * **The build derives from whichever token it was given**, so the token in
30
+ * the build settings must be the one the values were encrypted under.
31
+ *
32
+ * So this module's real job is not deriving a key — that is eight lines. It
33
+ * is making those three failures LOUD. The committed `.env.secrets` carries
34
+ * the public key, so the derived private key can always be checked against
35
+ * what the repository was actually encrypted under, and a mismatch is caught
36
+ * before anything is attempted rather than surfacing as a decryption failure
37
+ * nobody can explain.
38
+ *
39
+ * @see https://developers.cloudflare.com/workers/wrangler/system-environment-variables/
40
+ */
41
+
42
+ /**
43
+ * Domain separation. A key derived for this purpose must not equal one
44
+ * derived for any other, so the label is versioned: changing it is how a
45
+ * future scheme migrates without colliding with this one.
46
+ */
47
+ const INFO = "voidcommerce/dotenvx/secrets/v1";
48
+
49
+ /** Where the token comes from, in the order wrangler itself reads them. */
50
+ export function apiToken(): string | null {
51
+ return process.env["CLOUDFLARE_API_TOKEN"] || process.env["CF_API_TOKEN"] || null;
52
+ }
53
+
54
+ /**
55
+ * The private key this token and account derive.
56
+ *
57
+ * The account id is the salt rather than a constant, so the same token used
58
+ * against two accounts does not produce one key — tokens are often scoped to
59
+ * several accounts, and a shared key across them would be a wider blast
60
+ * radius than anyone asked for.
61
+ */
62
+ export function derivePrivateKey(token: string, accountId: string): string {
63
+ const bytes = hkdfSync("sha256", Buffer.from(token, "utf8"), Buffer.from(accountId, "utf8"), Buffer.from(INFO), 32);
64
+ return Buffer.from(bytes).toString("hex");
65
+ }
66
+
67
+ /** The public key for a private one, via dotenvx's own primitive. */
68
+ export async function publicKeyFor(privateKey: string): Promise<string | null> {
69
+ try {
70
+ const { derive } = await import("@dotenvx/primitives");
71
+ return derive(privateKey);
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /** The public key the repository was actually encrypted under. */
78
+ export function committedPublicKey(root: string): string | null {
79
+ const path = join(root, SECRETS_FILE);
80
+ if (!existsSync(path)) return null;
81
+ const match = /^\s*DOTENV_PUBLIC_KEY_SECRETS\s*=\s*["']?([0-9a-fA-F]+)["']?/m.exec(readFileSync(path, "utf8"));
82
+ return match?.[1] ?? null;
83
+ }
84
+
85
+ export type KeyState =
86
+ | { ok: true; privateKey: string; publicKey: string; fresh: boolean }
87
+ | { ok: false; reason: string };
88
+
89
+ /**
90
+ * The key for this repository, or a refusal that says exactly what is wrong.
91
+ *
92
+ * `fresh` means the repository has no secrets yet, so there is nothing to
93
+ * check the derivation against and any token is as good as another.
94
+ */
95
+ export async function keyFor(manifest: Manifest, root: string): Promise<KeyState> {
96
+ const token = apiToken();
97
+ if (!token) {
98
+ return {
99
+ ok: false,
100
+ reason:
101
+ "CLOUDFLARE_API_TOKEN is not set.\n" +
102
+ " The key is derived from it, so there is nothing to derive from. Note that\n" +
103
+ " `wrangler login` does NOT set one — this scheme needs an API token, from\n" +
104
+ " My Profile → API Tokens.",
105
+ };
106
+ }
107
+ const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
108
+ if (!accountId) {
109
+ return {
110
+ ok: false,
111
+ reason:
112
+ "the account is not pinned, and it salts the derivation.\n" +
113
+ " Run `vc deploy --cloudflare --provision` once, or set CLOUDFLARE_ACCOUNT_ID.",
114
+ };
115
+ }
116
+
117
+ const privateKey = derivePrivateKey(token, accountId);
118
+ const publicKey = await publicKeyFor(privateKey);
119
+ if (!publicKey) {
120
+ return { ok: false, reason: "@dotenvx/dotenvx is not installed here, so a key cannot be derived. `bun add -d @dotenvx/dotenvx`" };
121
+ }
122
+
123
+ const committed = committedPublicKey(root);
124
+ if (!committed) return { ok: true, privateKey, publicKey, fresh: true };
125
+
126
+ if (committed.toLowerCase() !== publicKey.toLowerCase()) {
127
+ return {
128
+ ok: false,
129
+ reason:
130
+ `this token does not derive the key ${SECRETS_FILE} was encrypted under.\n\n` +
131
+ ` encrypted under: ${committed.slice(0, 16)}…\n` +
132
+ ` this token gives: ${publicKey.slice(0, 16)}…\n\n` +
133
+ " That is one of three things, and all of them are the same fix:\n" +
134
+ " · the token was rotated since the secrets were encrypted\n" +
135
+ " · this is a different admin's token\n" +
136
+ " · CLOUDFLARE_ACCOUNT_ID is not the account they were encrypted for\n\n" +
137
+ ` If you still have the ORIGINAL token, \`vc keys rotate\` re-encrypts\n` +
138
+ " everything under the new one. If you do not, the values are unrecoverable\n" +
139
+ " and must be entered again.",
140
+ };
141
+ }
142
+ return { ok: true, privateKey, publicKey, fresh: false };
143
+ }
144
+
145
+ /** `vc keys` — what the derivation says, without printing anything secret. */
146
+ export async function keysCommand(manifest: Manifest, root: string, args: string[]): Promise<number> {
147
+ if (args.includes("--rotate")) return rotate(manifest, root);
148
+
149
+ const state = await keyFor(manifest, root);
150
+ console.log(`\nThe key is DERIVED from CLOUDFLARE_API_TOKEN, salted with the account id.`);
151
+ console.log(color.dim("Nothing is stored, so nothing can leak — and nothing can be recovered.\n"));
152
+
153
+ if (!state.ok) {
154
+ console.error(`${color.red("✗")} ${state.reason}\n`);
155
+ return 1;
156
+ }
157
+ console.log(` derives public key ${state.publicKey.slice(0, 20)}…`);
158
+ console.log(
159
+ state.fresh
160
+ ? ` ${color.dim(`${SECRETS_FILE} does not exist yet, so there is nothing to check against`)}`
161
+ : ` ${color.green("✓")} matches what ${SECRETS_FILE} was encrypted under`,
162
+ );
163
+ console.log(
164
+ color.yellow(
165
+ `\n! Rotating this API token makes every secret in ${SECRETS_FILE} unreadable.\n` +
166
+ " Run `vc keys --rotate` with the NEW token exported and the old one in\n" +
167
+ " CLOUDFLARE_API_TOKEN_OLD, BEFORE the old one stops working.\n",
168
+ ),
169
+ );
170
+ return 0;
171
+ }
172
+
173
+ /**
174
+ * Re-encrypt under a new token's key, while the old one still works.
175
+ *
176
+ * This is the escape hatch that makes a derived key survivable, and it only
177
+ * exists in the window where both tokens are known. Outside that window there
178
+ * is no recovery, which is why `vc keys` says so every time it runs.
179
+ */
180
+ async function rotate(manifest: Manifest, root: string): Promise<number> {
181
+ const oldToken = process.env["CLOUDFLARE_API_TOKEN_OLD"];
182
+ const newToken = apiToken();
183
+ if (!oldToken || !newToken) {
184
+ console.error(
185
+ "\nvc: rotation needs BOTH tokens:\n" +
186
+ " CLOUDFLARE_API_TOKEN_OLD=<the one the secrets were encrypted under>\n" +
187
+ " CLOUDFLARE_API_TOKEN=<the new one>\n\n" +
188
+ " The old one is the only thing that can read the current values.\n",
189
+ );
190
+ return 1;
191
+ }
192
+ const accountId = manifest.cloudflare?.accountId || process.env["CLOUDFLARE_ACCOUNT_ID"] || "";
193
+ if (!accountId) {
194
+ console.error("vc: the account is not pinned, and it salts the derivation.");
195
+ return 1;
196
+ }
197
+ const oldKey = derivePrivateKey(oldToken, accountId);
198
+ const oldPublic = await publicKeyFor(oldKey);
199
+ const committed = committedPublicKey(root);
200
+ if (committed && oldPublic && committed.toLowerCase() !== oldPublic.toLowerCase()) {
201
+ console.error(`\nvc: CLOUDFLARE_API_TOKEN_OLD does not derive the key ${SECRETS_FILE} was encrypted under either.\n`);
202
+ return 1;
203
+ }
204
+ console.log(
205
+ `\nRotation is a decrypt with the old key and an encrypt with the new one.\n` +
206
+ `Run these two, in this order, from ${root}:\n\n` +
207
+ ` ${color.cyan(`DOTENV_PRIVATE_KEY_SECRETS=<old> bunx dotenvx decrypt -f ${SECRETS_FILE}`)}\n` +
208
+ ` ${color.cyan(`bunx dotenvx encrypt -f ${SECRETS_FILE}`)} ${color.dim("# under the new derived key")}\n\n` +
209
+ color.dim("vc does not run them for you: the middle state is your secrets in\nplaintext on disk, and that is a moment to be deliberate about.\n"),
210
+ );
211
+ return 0;
212
+ }
@@ -7,6 +7,7 @@ import { workerHosts } from "../manifest";
7
7
  import type { Project } from "../project";
8
8
  import { captureVoid } from "../void";
9
9
  import { parseJsonc } from "./jsonc";
10
+ import { SECRETS_FILE, declaredSecretNames, plaintextSecretNames } from "./secrets";
10
11
  import { findWrangler, secretNames } from "./wrangler";
11
12
 
12
13
  /**
@@ -28,6 +29,8 @@ export interface Preflight {
28
29
  remote: Set<string> | null;
29
30
  missing: EnvKey[];
30
31
  routeProblem: string | null;
32
+ /** Names in `.env.secrets` whose value was committed unencrypted. */
33
+ bareSecrets: string[];
31
34
  ready: boolean;
32
35
  }
33
36
 
@@ -86,12 +89,29 @@ export async function preflight(project: Project, source: SecretSource): Promise
86
89
  }
87
90
  for (const name of remote ?? []) present.set(name, "<secret>");
88
91
 
92
+ /**
93
+ * A secret the REPOSITORY declares counts as present, because the deploy
94
+ * uploads it. The names are readable without the key — dotenvx encrypts
95
+ * values, not keys — so this works on a machine that cannot decrypt.
96
+ */
97
+ for (const name of declaredSecretNames(project.root)) present.set(name, "<in the repository>");
98
+
89
99
  const missing = allEnvKeys(project.manifest).filter((key) => {
90
100
  const value = present.get(key.key);
91
101
  return value === undefined || value === "" || value === UNSET;
92
102
  });
93
103
  const problem = routeProblem(project);
94
- return { present, remote, missing, routeProblem: problem, ready: missing.length === 0 && problem === null };
104
+ // A value committed in the clear is worse than a missing one: it is a
105
+ // leak that looks like it is working.
106
+ const bare = plaintextSecretNames(project.root);
107
+ return {
108
+ present,
109
+ remote,
110
+ missing,
111
+ routeProblem: problem,
112
+ bareSecrets: bare,
113
+ ready: missing.length === 0 && problem === null && bare.length === 0,
114
+ };
95
115
  }
96
116
 
97
117
  /** Print the report a person can act on. */
@@ -120,6 +140,12 @@ export function printPreflight(project: Project, result: Preflight, source: Secr
120
140
  if (result.routeProblem) {
121
141
  console.log(`${color.red("✗")} the worker's hostname\n ${result.routeProblem}\n`);
122
142
  }
143
+ if (result.bareSecrets.length > 0) {
144
+ console.log(
145
+ `${color.red("✗")} committed in the clear in ${SECRETS_FILE}: ${result.bareSecrets.join(", ")}\n` +
146
+ ` Run \`dotenvx encrypt -f ${SECRETS_FILE}\` — and treat those values as burned.\n`,
147
+ );
148
+ }
123
149
  for (const key of result.missing) {
124
150
  console.log(`${color.red("✗")} ${key.key}`);
125
151
  console.log(` ${key.breaks}`);
@@ -128,8 +154,17 @@ export function printPreflight(project: Project, result: Preflight, source: Secr
128
154
  }
129
155
  const secrets = result.missing.filter((key) => !key.plaintext);
130
156
  if (secrets.length > 0) {
131
- console.log(source === "wrangler" ? "Set each secret on the worker (this also creates the draft worker on a first deploy):" : "Set each secret on the project:");
132
- for (const key of secrets) console.log(` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
157
+ const inRepo = existsSync(join(project.root, SECRETS_FILE));
158
+ console.log(
159
+ inRepo
160
+ ? `Put each in ${SECRETS_FILE}, which the deploy uploads:`
161
+ : source === "wrangler"
162
+ ? "Set each secret on the worker (this also creates the draft worker on a first deploy):"
163
+ : "Set each secret on the project:",
164
+ );
165
+ for (const key of secrets) {
166
+ console.log(inRepo ? ` dotenvx set ${key.key} '…' -f ${SECRETS_FILE}` : ` ${source === "wrangler" ? "wrangler secret put" : "void secret put"} ${key.key}`);
167
+ }
133
168
  }
134
169
  const plain = result.missing.filter((key) => key.plaintext);
135
170
  if (plain.length > 0) console.log(`Values safe to commit go in .env.production: ${plain.map((k) => k.key).join(", ")}`);