dotenv-gad 1.4.1 → 1.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.
- package/README.md +51 -10
- package/dist/cli/commands/check.js +10 -2
- package/dist/cli/commands/decrypt.js +126 -0
- package/dist/cli/commands/docs.js +2 -2
- package/dist/cli/commands/encrypt.js +94 -0
- package/dist/cli/commands/fix.js +16 -12
- package/dist/cli/commands/init.js +17 -12
- package/dist/cli/commands/keygen.js +97 -0
- package/dist/cli/commands/rotate.js +160 -0
- package/dist/cli/commands/status.js +116 -0
- package/dist/cli/commands/sync.js +15 -17
- package/dist/cli/commands/utils.js +34 -28
- package/dist/cli/commands/verify.js +101 -0
- package/dist/cli/index.js +12 -0
- package/dist/client.cjs +32 -0
- package/dist/crypto.js +162 -0
- package/dist/errors.js +92 -4
- package/dist/index.cjs +787 -0
- package/dist/index.js +5 -4
- package/dist/types/cli/commands/check.d.ts +1 -1
- package/dist/types/cli/commands/decrypt.d.ts +2 -0
- package/dist/types/cli/commands/docs.d.ts +1 -1
- package/dist/types/cli/commands/encrypt.d.ts +2 -0
- package/dist/types/cli/commands/fix.d.ts +1 -1
- package/dist/types/cli/commands/init.d.ts +1 -1
- package/dist/types/cli/commands/keygen.d.ts +2 -0
- package/dist/types/cli/commands/rotate.d.ts +2 -0
- package/dist/types/cli/commands/status.d.ts +2 -0
- package/dist/types/cli/commands/sync.d.ts +1 -1
- package/dist/types/cli/commands/verify.d.ts +2 -0
- package/dist/types/crypto.d.ts +58 -0
- package/dist/types/errors.d.ts +35 -12
- package/dist/types/index.d.ts +7 -3
- package/dist/types/schema.d.ts +7 -0
- package/dist/types/utils.d.ts +19 -10
- package/dist/types/validator.d.ts +40 -0
- package/dist/utils.js +30 -12
- package/dist/validator.js +161 -18
- package/dist/vite-plugin.js +1 -1
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
# dotenv-gad
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/js/dotenv-gad)
|
|
4
|
+
[](https://github.com/kasimlyee/dotenv-gad/actions/workflows/ci.yml)
|
|
4
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
5
8
|
[](https://kasimlyee.github.io/dotenv-gad/)
|
|
9
|
+
[](https://www.npmjs.com/package/dotenv-gad)
|
|
6
10
|
|
|
7
11
|
**dotenv-gad** is an environment variable validation library that brings type safety, schema validation, and runtime checks to your Node.js and JavaScript applications. It works with any environment variable source — `.env` files, platform dashboards (Vercel, Railway, Docker), CI/CD pipelines, or `process.env` directly.
|
|
8
12
|
|
|
9
13
|
- Type-safe environment variables with full IntelliSense
|
|
10
14
|
- Schema validation (string, number, boolean, url, email, ip, port, json, array, object)
|
|
15
|
+
- **At-rest encryption** — transparent decrypt at runtime
|
|
11
16
|
- Schema composition for modular configs
|
|
12
17
|
- Automatic documentation and `.env.example` generation
|
|
13
18
|
- First-class TypeScript support
|
|
14
|
-
- CLI tooling (check, sync, types, init, fix, docs)
|
|
19
|
+
- CLI tooling (check, sync, types, init, fix, docs, keygen, encrypt, decrypt, rotate, verify, status)
|
|
15
20
|
- Sensitive value management and redaction
|
|
16
21
|
- Vite plugin with client-safe filtering and HMR
|
|
17
22
|
|
|
@@ -66,18 +71,25 @@ Full documentation is available at [kasimlyee.github.io/dotenv-gad](https://kasi
|
|
|
66
71
|
|
|
67
72
|
## CLI Commands
|
|
68
73
|
|
|
69
|
-
| Command
|
|
70
|
-
|
|
|
71
|
-
| `check`
|
|
72
|
-
| `sync`
|
|
73
|
-
| `types`
|
|
74
|
-
| `init`
|
|
75
|
-
| `fix`
|
|
76
|
-
| `docs`
|
|
74
|
+
| Command | Description |
|
|
75
|
+
| --------- | ------------------------------------------------ |
|
|
76
|
+
| `check` | Validate .env against schema |
|
|
77
|
+
| `sync` | Generate/update .env.example |
|
|
78
|
+
| `types` | Generate env.d.ts TypeScript types |
|
|
79
|
+
| `init` | Create starter schema |
|
|
80
|
+
| `fix` | Fix environment issues interactively |
|
|
81
|
+
| `docs` | Generate .env documentation |
|
|
82
|
+
| `keygen` | Generate an X25519 key pair for encryption |
|
|
83
|
+
| `encrypt` | Encrypt plaintext values for `encrypted:true` fields |
|
|
84
|
+
| `decrypt` | Print or write back decrypted values |
|
|
85
|
+
| `rotate` | Rotate keys: decrypt → new pair → re-encrypt |
|
|
86
|
+
| `status` | Show encryption status of each schema field |
|
|
87
|
+
| `verify` | Dry-run: confirm all encrypted values decrypt |
|
|
77
88
|
|
|
78
89
|
```bash
|
|
79
90
|
npx dotenv-gad check
|
|
80
|
-
npx dotenv-gad
|
|
91
|
+
npx dotenv-gad keygen
|
|
92
|
+
npx dotenv-gad encrypt
|
|
81
93
|
```
|
|
82
94
|
|
|
83
95
|
## Vite Plugin
|
|
@@ -174,6 +186,35 @@ const schema = composeSchema(baseSchema, dbSchema);
|
|
|
174
186
|
}
|
|
175
187
|
```
|
|
176
188
|
|
|
189
|
+
### At-rest Encryption
|
|
190
|
+
|
|
191
|
+
Store secrets as encrypted ciphertext in `.env` using asymmetric X25519 + ChaCha20-Poly1305. Only `.env.keys` (gitignored) can decrypt them.
|
|
192
|
+
|
|
193
|
+
**1. Mark fields as encrypted:**
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
export default defineSchema({
|
|
197
|
+
DATABASE_URL: { type: 'string', required: true, sensitive: true, encrypted: true },
|
|
198
|
+
API_SECRET: { type: 'string', required: true, sensitive: true, encrypted: true },
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**2. Generate keys and encrypt:**
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
npx dotenv-gad keygen # writes ENVGAD_PUBLIC_KEY to .env, creates .env.keys
|
|
206
|
+
npx dotenv-gad encrypt # replaces plaintext secrets with encrypted:v1:… tokens
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**3. `loadEnv` decrypts transparently at runtime:**
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const env = loadEnv(schema); // reads .env.keys, decrypts, validates — all in one step
|
|
213
|
+
console.log(env.DATABASE_URL); // "postgres://user:pass@host/db"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Your `.env` (with encrypted values and the public key) is now safe to commit. See the [Encryption guide](https://kasimlyee.github.io/dotenv-gad/guide/encryption) for key rotation, CI/CD setup, and security details.
|
|
217
|
+
|
|
177
218
|
### Grouping / Namespaced Envs
|
|
178
219
|
|
|
179
220
|
Group related variables into a single validated object:
|
|
@@ -2,13 +2,15 @@ import { Command } from "commander";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import { validateEnv } from "../../index.js";
|
|
5
|
-
import { EnvAggregateError } from "../../errors.js";
|
|
5
|
+
import { EnvAggregateError, EncryptionKeyMissingError } from "../../errors.js";
|
|
6
6
|
import { loadSchema } from "./utils.js";
|
|
7
|
-
export default function (
|
|
7
|
+
export default function (_program) {
|
|
8
8
|
return new Command("check")
|
|
9
9
|
.description("Validate .env against schema")
|
|
10
10
|
.option("--strict", "Fail on extra env vars not in schema")
|
|
11
11
|
.option("--fix", "Attempt to fix errors interactively")
|
|
12
|
+
.option("--keys <file>", "Path to .env.keys file for decrypting encrypted fields", ".env.keys")
|
|
13
|
+
.option("--allow-plaintext", "Warn instead of error when encrypted fields have plaintext values")
|
|
12
14
|
.action(async (option, command) => {
|
|
13
15
|
const rootOpts = command.parent.opts();
|
|
14
16
|
const spinner = ora("Validating environment...").start();
|
|
@@ -17,6 +19,8 @@ export default function (program) {
|
|
|
17
19
|
const env = validateEnv(schema, {
|
|
18
20
|
strict: option.strict,
|
|
19
21
|
path: rootOpts.env,
|
|
22
|
+
keysPath: option.keys,
|
|
23
|
+
allowPlaintext: option.allowPlaintext,
|
|
20
24
|
});
|
|
21
25
|
spinner.succeed(chalk.green("Environment validation passed!"));
|
|
22
26
|
console.log(chalk.dim(`Found ${Object.keys(env).length} valid variables`));
|
|
@@ -33,6 +37,10 @@ export default function (program) {
|
|
|
33
37
|
});
|
|
34
38
|
process.exit(1);
|
|
35
39
|
}
|
|
40
|
+
else if (error instanceof EncryptionKeyMissingError) {
|
|
41
|
+
console.error(chalk.red(`\n✗ ${error.message}`));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
36
44
|
else {
|
|
37
45
|
console.error(chalk.red("Unexpected error:"), error);
|
|
38
46
|
process.exit(2);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import dotenv from "dotenv";
|
|
7
|
+
import { decryptEnvValue, isEncryptedValue, loadPrivateKey } from "../../crypto.js";
|
|
8
|
+
import { EncryptionKeyMissingError } from "../../errors.js";
|
|
9
|
+
import { loadSchema } from "./utils.js";
|
|
10
|
+
async function confirm(question) {
|
|
11
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
12
|
+
try {
|
|
13
|
+
const answer = await rl.question(question);
|
|
14
|
+
return answer.trim().toLowerCase() === "y";
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
rl.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export default function (_program) {
|
|
21
|
+
return new Command("decrypt")
|
|
22
|
+
.description("Decrypt encrypted values and print them to stdout (or write back with --write)")
|
|
23
|
+
.option("--keys <file>", "Path to the .env.keys private key file", ".env.keys")
|
|
24
|
+
.option("--write", "Write decrypted values back to the .env file (requires confirmation)")
|
|
25
|
+
.action(async (opts, command) => {
|
|
26
|
+
const rootOpts = command.parent.opts();
|
|
27
|
+
const envPath = rootOpts.env ?? ".env";
|
|
28
|
+
const schemaPath = rootOpts.schema ?? "env.schema.ts";
|
|
29
|
+
const keysPath = opts.keys;
|
|
30
|
+
const spinner = ora("Loading schema…").start();
|
|
31
|
+
try {
|
|
32
|
+
const schema = await loadSchema(schemaPath);
|
|
33
|
+
const encryptedFields = Object.keys(schema).filter((k) => schema[k].encrypted === true);
|
|
34
|
+
if (encryptedFields.length === 0) {
|
|
35
|
+
spinner.info(chalk.dim("No fields with encrypted: true found in schema."));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
spinner.text = `Reading ${envPath}…`;
|
|
39
|
+
let envContent;
|
|
40
|
+
try {
|
|
41
|
+
envContent = readFileSync(envPath, "utf8");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
spinner.fail(chalk.red(`Cannot read ${envPath}`));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const parsed = dotenv.parse(envContent);
|
|
48
|
+
spinner.text = "Loading private key…";
|
|
49
|
+
const privateKeyHex = loadPrivateKey({ keysPath });
|
|
50
|
+
if (!privateKeyHex) {
|
|
51
|
+
spinner.fail(chalk.red("Private key not found.\n" +
|
|
52
|
+
` Checked: ${keysPath} and ENVGAD_PRIVATE_KEY env var\n` +
|
|
53
|
+
" Obtain .env.keys from your team or set ENVGAD_PRIVATE_KEY"));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
spinner.text = `Decrypting ${encryptedFields.length} field(s)…`;
|
|
57
|
+
const decrypted = {};
|
|
58
|
+
const errors = [];
|
|
59
|
+
for (const key of encryptedFields) {
|
|
60
|
+
const value = parsed[key];
|
|
61
|
+
if (value == null || value === "")
|
|
62
|
+
continue;
|
|
63
|
+
if (!isEncryptedValue(value)) {
|
|
64
|
+
console.warn(chalk.yellow(` ⚠ ${key}: value is not encrypted (skipping)`));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
decrypted[key] = decryptEnvValue(value, privateKeyHex, key);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
errors.push({
|
|
72
|
+
key,
|
|
73
|
+
message: err instanceof Error ? err.message : "Decryption failed",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
spinner.stop();
|
|
78
|
+
if (errors.length > 0) {
|
|
79
|
+
for (const { key, message } of errors) {
|
|
80
|
+
console.error(` ${chalk.red("✗")} ${chalk.bold(key)}: ${message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const decryptedKeys = Object.keys(decrypted);
|
|
84
|
+
if (decryptedKeys.length === 0) {
|
|
85
|
+
console.log(chalk.dim("No encrypted values found to decrypt."));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!opts.write) {
|
|
89
|
+
// Default: safe stdout output
|
|
90
|
+
console.log(chalk.bold("\n# Decrypted values (stdout only — not written to file):"));
|
|
91
|
+
for (const [key, value] of Object.entries(decrypted)) {
|
|
92
|
+
console.log(`${key}=${value}`);
|
|
93
|
+
}
|
|
94
|
+
console.log(chalk.dim(`\n${decryptedKeys.length} value(s) printed. ` +
|
|
95
|
+
"Use --write to update the .env file."));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// --write mode: confirm before overwriting
|
|
99
|
+
console.log(chalk.yellow(`\n⚠ About to write ${decryptedKeys.length} decrypted value(s) to ${envPath}.`) +
|
|
100
|
+
chalk.dim("\n This replaces encrypted values with plaintext.\n" +
|
|
101
|
+
" DO NOT commit these changes to git!\n"));
|
|
102
|
+
const ok = await confirm(" Continue? (y/N): ");
|
|
103
|
+
if (!ok) {
|
|
104
|
+
console.log(chalk.dim("\nAborted."));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const replaceRegexes = Object.fromEntries(Object.keys(decrypted).map((k) => [k, new RegExp(`^(${k}\\s*=).*$`, "m")]));
|
|
108
|
+
let updatedContent = envContent;
|
|
109
|
+
for (const [key, value] of Object.entries(decrypted)) {
|
|
110
|
+
updatedContent = updatedContent.replace(replaceRegexes[key], `$1${value}`);
|
|
111
|
+
}
|
|
112
|
+
writeFileSync(envPath, updatedContent);
|
|
113
|
+
console.log(`\n${chalk.green("✓")} ${decryptedKeys.length} value(s) written to ${chalk.bold(envPath)}\n` +
|
|
114
|
+
chalk.yellow("⚠ Remember: DO NOT commit these decrypted values!"));
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
spinner.stop();
|
|
118
|
+
if (error instanceof EncryptionKeyMissingError) {
|
|
119
|
+
console.error(chalk.red(`\n✗ ${error.message}`));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
console.error(chalk.red("\nUnexpected error:"), error);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { loadSchema } from "./utils.js";
|
|
3
3
|
import { writeFileSync } from "fs";
|
|
4
|
-
export default function (
|
|
4
|
+
export default function (_program) {
|
|
5
5
|
return new Command("docs")
|
|
6
6
|
.description("Generate Markdown documentation")
|
|
7
|
-
.action(async (
|
|
7
|
+
.action(async (_program, command) => {
|
|
8
8
|
const schema = await loadSchema(command.parent.opts().schema);
|
|
9
9
|
let md = `# Environment Variables\n\n`;
|
|
10
10
|
Object.entries(schema).forEach(([key, rule]) => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { readFileSync, writeFileSync, copyFileSync } from "node:fs";
|
|
5
|
+
import dotenv from "dotenv";
|
|
6
|
+
import { encryptEnvValue, isEncryptedValue } from "../../crypto.js";
|
|
7
|
+
import { EncryptionKeyMissingError } from "../../errors.js";
|
|
8
|
+
import { loadSchema } from "./utils.js";
|
|
9
|
+
export default function (_program) {
|
|
10
|
+
return new Command("encrypt")
|
|
11
|
+
.description("Encrypt plaintext values for fields marked encrypted: true in the schema")
|
|
12
|
+
.action(async (_opts, command) => {
|
|
13
|
+
const rootOpts = command.parent.opts();
|
|
14
|
+
const envPath = rootOpts.env ?? ".env";
|
|
15
|
+
const schemaPath = rootOpts.schema ?? "env.schema.ts";
|
|
16
|
+
const spinner = ora("Loading schema…").start();
|
|
17
|
+
try {
|
|
18
|
+
const schema = await loadSchema(schemaPath);
|
|
19
|
+
const encryptedFields = Object.keys(schema).filter((k) => schema[k].encrypted === true);
|
|
20
|
+
if (encryptedFields.length === 0) {
|
|
21
|
+
spinner.info(chalk.dim("No fields with encrypted: true found in schema. Nothing to do."));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
spinner.text = `Reading ${envPath}…`;
|
|
25
|
+
let envContent;
|
|
26
|
+
try {
|
|
27
|
+
envContent = readFileSync(envPath, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
spinner.fail(chalk.red(`Cannot read ${envPath}`));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const parsed = dotenv.parse(envContent);
|
|
34
|
+
const publicKeyHex = parsed.ENVGAD_PUBLIC_KEY;
|
|
35
|
+
if (!publicKeyHex) {
|
|
36
|
+
spinner.fail(chalk.red(`ENVGAD_PUBLIC_KEY not found in ${envPath}.\n` +
|
|
37
|
+
" Run: " +
|
|
38
|
+
chalk.cyan("npx dotenv-gad keygen")));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
spinner.text = `Encrypting ${encryptedFields.length} field(s)…`;
|
|
42
|
+
const keyRegex = (k) => new RegExp(`^(${k}\\s*=).*$`, "m");
|
|
43
|
+
const replaceRegexes = Object.fromEntries(encryptedFields.map((k) => [k, keyRegex(k)]));
|
|
44
|
+
let updatedContent = envContent;
|
|
45
|
+
let encryptedCount = 0;
|
|
46
|
+
const results = [];
|
|
47
|
+
for (const key of encryptedFields) {
|
|
48
|
+
const value = parsed[key];
|
|
49
|
+
if (value == null || value === "") {
|
|
50
|
+
results.push({ key, status: "missing" });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (isEncryptedValue(value)) {
|
|
54
|
+
results.push({ key, status: "skipped" });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const encrypted = encryptEnvValue(value, publicKeyHex, key);
|
|
58
|
+
updatedContent = updatedContent.replace(replaceRegexes[key], `$1${encrypted}`);
|
|
59
|
+
results.push({ key, status: "encrypted" });
|
|
60
|
+
encryptedCount++;
|
|
61
|
+
}
|
|
62
|
+
spinner.stop();
|
|
63
|
+
for (const { key, status } of results) {
|
|
64
|
+
if (status === "encrypted") {
|
|
65
|
+
console.log(` ${chalk.green("✓")} ${chalk.bold(key)}: encrypted`);
|
|
66
|
+
}
|
|
67
|
+
else if (status === "skipped") {
|
|
68
|
+
console.log(` ${chalk.dim("⊘")} ${chalk.dim(key + ": already encrypted (skipped)")}`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
console.log(` ${chalk.dim("⊘")} ${chalk.dim(key + ": not present in .env (skipped)")}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (encryptedCount > 0) {
|
|
75
|
+
copyFileSync(envPath, `${envPath}.bak`);
|
|
76
|
+
writeFileSync(envPath, updatedContent);
|
|
77
|
+
console.log(`\n${chalk.green("✓")} ${encryptedCount} field(s) encrypted in ${chalk.bold(envPath)}\n` +
|
|
78
|
+
chalk.dim(` Backup saved to ${envPath}.bak`));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.log(chalk.dim("\nNo fields needed encryption."));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
spinner.stop();
|
|
86
|
+
if (error instanceof EncryptionKeyMissingError) {
|
|
87
|
+
console.error(chalk.red(`\n✗ ${error.message}`));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
console.error(chalk.red("\nUnexpected error:"), error);
|
|
91
|
+
process.exit(2);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
-
import
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { loadSchema, applyFix } from "./utils.js";
|
|
5
5
|
import { validateEnv } from "../../index.js";
|
|
6
6
|
import { EnvAggregateError } from "../../errors.js";
|
|
7
|
-
|
|
7
|
+
async function confirm(question) {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9
|
+
try {
|
|
10
|
+
const answer = await rl.question(question);
|
|
11
|
+
return answer.trim().toLowerCase() === "y";
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
rl.close();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export default function (_program) {
|
|
8
18
|
return new Command("fix")
|
|
9
19
|
.description("Interactively fix environment issues")
|
|
10
|
-
.action(async (
|
|
20
|
+
.action(async (_options, command) => {
|
|
11
21
|
const rootOpts = command.parent.opts();
|
|
12
22
|
const schema = await loadSchema(rootOpts.schema);
|
|
13
23
|
const envPath = rootOpts.env || ".env";
|
|
@@ -26,19 +36,13 @@ export default function (program) {
|
|
|
26
36
|
console.log(chalk.dim(` ${e.rule.docs}`));
|
|
27
37
|
}
|
|
28
38
|
});
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
name: "confirmed",
|
|
32
|
-
message: `\nWould you like to fix these issues interactively?`,
|
|
33
|
-
default: true,
|
|
34
|
-
});
|
|
35
|
-
if (confirmed) {
|
|
36
|
-
// Convert errors array to issues object format expected by applyFix
|
|
39
|
+
const ok = await confirm(chalk.bold("\nWould you like to fix these issues interactively? (y/N): "));
|
|
40
|
+
if (ok) {
|
|
37
41
|
const issues = {};
|
|
38
42
|
error.errors.forEach((e) => {
|
|
39
43
|
issues[e.key] = { value: e.value, key: e.key };
|
|
40
44
|
});
|
|
41
|
-
await applyFix(issues, schema,
|
|
45
|
+
await applyFix(issues, schema, envPath);
|
|
42
46
|
}
|
|
43
47
|
else {
|
|
44
48
|
console.log(chalk.dim("\nFix cancelled."));
|
|
@@ -2,11 +2,18 @@ import { Command } from "commander";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import { writeFileSync, existsSync } from "fs";
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
async function confirm(question) {
|
|
7
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
8
|
+
try {
|
|
9
|
+
const answer = await rl.question(question);
|
|
10
|
+
return answer.trim().toLowerCase() === "y";
|
|
11
|
+
}
|
|
12
|
+
finally {
|
|
13
|
+
rl.close();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export default function (_program) {
|
|
10
17
|
return new Command("init")
|
|
11
18
|
.description("Initialize new schema file")
|
|
12
19
|
.option("--force", "Overwrite existing files")
|
|
@@ -15,14 +22,12 @@ export default function (program) {
|
|
|
15
22
|
const schemaPath = rootOpts.schema;
|
|
16
23
|
if (existsSync(schemaPath)) {
|
|
17
24
|
if (!options.force) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
message: "Schema file already exists. Overwrite?",
|
|
22
|
-
default: false,
|
|
23
|
-
});
|
|
24
|
-
if (!overwrite)
|
|
25
|
+
const ok = await confirm(chalk.yellow(`Schema file "${schemaPath}" already exists. Overwrite? (y/N): `));
|
|
26
|
+
if (!ok) {
|
|
27
|
+
console.log(chalk.dim("\nAborted."));
|
|
25
28
|
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
console.log();
|
|
26
31
|
}
|
|
27
32
|
}
|
|
28
33
|
const spinner = ora("Creating new schema...").start();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
6
|
+
import { generateKeyPair } from "../../crypto.js";
|
|
7
|
+
async function confirm(question) {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9
|
+
try {
|
|
10
|
+
const answer = await rl.question(question);
|
|
11
|
+
return answer.trim().toLowerCase() === "y";
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
rl.close();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export default function (program) {
|
|
18
|
+
return new Command("keygen")
|
|
19
|
+
.description("Generate an X25519 key pair for schema-based encryption")
|
|
20
|
+
.option("--keys <file>", "Path to write the private key file", ".env.keys")
|
|
21
|
+
.option("-f, --force", "Overwrite existing keys without confirmation")
|
|
22
|
+
.action(async (opts, command) => {
|
|
23
|
+
const rootOpts = command.parent.opts();
|
|
24
|
+
const envPath = rootOpts.env ?? ".env";
|
|
25
|
+
const keysPath = opts.keys;
|
|
26
|
+
// Warn and confirm if keys already exist (unless --force)
|
|
27
|
+
const envContent = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
28
|
+
const hasExistingPublicKey = /^ENVGAD_PUBLIC_KEY=/m.test(envContent);
|
|
29
|
+
const hasExistingKeysFile = existsSync(keysPath);
|
|
30
|
+
if ((hasExistingPublicKey || hasExistingKeysFile) && !opts.force) {
|
|
31
|
+
console.log(chalk.yellow("\n⚠ Existing keys detected:") +
|
|
32
|
+
(hasExistingPublicKey
|
|
33
|
+
? chalk.dim(`\n ENVGAD_PUBLIC_KEY found in ${envPath}`)
|
|
34
|
+
: "") +
|
|
35
|
+
(hasExistingKeysFile
|
|
36
|
+
? chalk.dim(`\n ${keysPath} already exists`)
|
|
37
|
+
: "") +
|
|
38
|
+
chalk.yellow("\n\n Generating new keys invalidates all existing encrypted values.\n" +
|
|
39
|
+
" Use 'npx dotenv-gad rotate' for safe key rotation.\n"));
|
|
40
|
+
const ok = await confirm(" Generate new keys anyway? (y/N): ");
|
|
41
|
+
if (!ok) {
|
|
42
|
+
console.log(chalk.dim("\nAborted."));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
console.log();
|
|
46
|
+
}
|
|
47
|
+
const spinner = ora("Generating X25519 key pair…").start();
|
|
48
|
+
const { publicKeyHex, privateKeyHex } = generateKeyPair();
|
|
49
|
+
// Update or append public key in .env
|
|
50
|
+
let newEnvContent;
|
|
51
|
+
if (hasExistingPublicKey) {
|
|
52
|
+
newEnvContent = envContent.replace(/^ENVGAD_PUBLIC_KEY=.*$/m, `ENVGAD_PUBLIC_KEY=${publicKeyHex}`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const sep = envContent.length > 0 && !envContent.endsWith("\n") ? "\n" : "";
|
|
56
|
+
newEnvContent =
|
|
57
|
+
envContent +
|
|
58
|
+
`${sep}\n# dotenv-gad encryption public key (safe to commit)\n` +
|
|
59
|
+
`ENVGAD_PUBLIC_KEY=${publicKeyHex}\n`;
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(envPath, newEnvContent);
|
|
62
|
+
// Write private key to .env.keys (restricted permissions on Unix)
|
|
63
|
+
writeFileSync(keysPath, "# KEEP THIS FILE SECRET — DO NOT COMMIT TO GIT\n" +
|
|
64
|
+
"# Share securely via 1Password, Vault, or a secure channel\n" +
|
|
65
|
+
`ENVGAD_PRIVATE_KEY=${privateKeyHex}\n`, { mode: 0o600 });
|
|
66
|
+
// Ensure .env.keys is listed in .gitignore
|
|
67
|
+
const gitignorePath = ".gitignore";
|
|
68
|
+
const gitignoreContent = existsSync(gitignorePath)
|
|
69
|
+
? readFileSync(gitignorePath, "utf8")
|
|
70
|
+
: "";
|
|
71
|
+
if (!gitignoreContent.includes(keysPath)) {
|
|
72
|
+
const sep = gitignoreContent.length > 0 && !gitignoreContent.endsWith("\n") ? "\n" : "";
|
|
73
|
+
appendFileSync(gitignorePath, `${sep}\n# dotenv-gad encryption keys\n${keysPath}\n`);
|
|
74
|
+
}
|
|
75
|
+
spinner.succeed(chalk.green("Key pair generated!"));
|
|
76
|
+
console.log("\n" +
|
|
77
|
+
chalk.bold("Public key") +
|
|
78
|
+
chalk.dim(` → ${envPath}`) +
|
|
79
|
+
`\n ${chalk.cyan(`ENVGAD_PUBLIC_KEY=${publicKeyHex}`)}\n` +
|
|
80
|
+
"\n" +
|
|
81
|
+
chalk.bold("Private key") +
|
|
82
|
+
chalk.dim(` → ${keysPath}`) +
|
|
83
|
+
`\n ${chalk.cyan(`ENVGAD_PRIVATE_KEY=${privateKeyHex}`)}\n` +
|
|
84
|
+
"\n" +
|
|
85
|
+
chalk.yellow("⚠ Remember:\n") +
|
|
86
|
+
chalk.dim(` • ${keysPath} is gitignored — never commit it\n`) +
|
|
87
|
+
chalk.dim(" • Share it securely with team members\n") +
|
|
88
|
+
chalk.dim(" • For CI/CD, set ENVGAD_PRIVATE_KEY as an environment secret\n") +
|
|
89
|
+
"\n" +
|
|
90
|
+
chalk.bold("Next steps:\n") +
|
|
91
|
+
chalk.dim(" 1. Add ") +
|
|
92
|
+
chalk.cyan("encrypted: true") +
|
|
93
|
+
chalk.dim(" to sensitive fields in your schema\n") +
|
|
94
|
+
chalk.dim(" 2. Run: ") +
|
|
95
|
+
chalk.cyan("npx dotenv-gad encrypt"));
|
|
96
|
+
});
|
|
97
|
+
}
|