dotenv-gad 1.4.2 → 1.6.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 +81 -11
- 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 +100 -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 +40 -28
- package/dist/cli/commands/verify.js +101 -0
- package/dist/cli/index.js +12 -0
- package/dist/crypto.js +165 -0
- package/dist/errors.js +92 -4
- package/dist/index.cjs +408 -26
- package/dist/index.js +6 -4
- package/dist/runtime.js +43 -0
- package/dist/schema-loader.js +12 -3
- 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 +8 -3
- package/dist/types/runtime.d.ts +29 -0
- package/dist/types/schema-loader.d.ts +5 -2
- package/dist/types/schema.d.ts +7 -0
- package/dist/types/utils.d.ts +21 -10
- package/dist/types/validator.d.ts +40 -0
- package/dist/utils.js +50 -13
- package/dist/validator.js +164 -19
- package/package.json +6 -4
|
@@ -0,0 +1,160 @@
|
|
|
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, copyFileSync } from "node:fs";
|
|
6
|
+
import dotenv from "dotenv";
|
|
7
|
+
import { generateKeyPair, encryptEnvValue, decryptEnvValue, isEncryptedValue, loadPrivateKey, } from "../../crypto.js";
|
|
8
|
+
import { loadSchema } from "./utils.js";
|
|
9
|
+
async function confirm(question) {
|
|
10
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
11
|
+
try {
|
|
12
|
+
const answer = await rl.question(question);
|
|
13
|
+
return answer.trim().toLowerCase() === "y";
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
rl.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export default function (_program) {
|
|
20
|
+
return new Command("rotate")
|
|
21
|
+
.description("Rotate encryption keys: decrypt all fields, generate a new key pair, re-encrypt")
|
|
22
|
+
.option("--keys <file>", "Path to the .env.keys file", ".env.keys")
|
|
23
|
+
.option("-f, --force", "Skip confirmation prompt")
|
|
24
|
+
.action(async (opts, command) => {
|
|
25
|
+
const rootOpts = command.parent.opts();
|
|
26
|
+
const envPath = rootOpts.env ?? ".env";
|
|
27
|
+
const schemaPath = rootOpts.schema ?? "env.schema.ts";
|
|
28
|
+
const keysPath = opts.keys;
|
|
29
|
+
const spinner = ora("Loading schema…").start();
|
|
30
|
+
try {
|
|
31
|
+
const schema = await loadSchema(schemaPath);
|
|
32
|
+
const encryptedFields = Object.keys(schema).filter((k) => schema[k].encrypted === true);
|
|
33
|
+
if (encryptedFields.length === 0) {
|
|
34
|
+
spinner.info(chalk.dim("No fields with encrypted: true in schema. Nothing to rotate."));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
spinner.text = `Reading ${envPath}…`;
|
|
38
|
+
let envContent;
|
|
39
|
+
try {
|
|
40
|
+
envContent = readFileSync(envPath, "utf8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
spinner.fail(chalk.red(`Cannot read ${envPath}`));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const parsed = dotenv.parse(envContent);
|
|
47
|
+
spinner.text = "Loading current private key…";
|
|
48
|
+
const oldPrivateKeyHex = loadPrivateKey({ keysPath });
|
|
49
|
+
if (!oldPrivateKeyHex) {
|
|
50
|
+
spinner.fail(chalk.red("Current private key not found.\n" +
|
|
51
|
+
` Checked: ${keysPath} and ENVGAD_PRIVATE_KEY env var\n` +
|
|
52
|
+
" Cannot rotate without the current private key."));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
// Identify which fields have encrypted values in the .env file
|
|
56
|
+
const toRotate = encryptedFields.filter((k) => parsed[k] && isEncryptedValue(parsed[k]));
|
|
57
|
+
spinner.stop();
|
|
58
|
+
if (toRotate.length === 0) {
|
|
59
|
+
console.log(chalk.dim("No encrypted values found in .env to rotate."));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
console.log(chalk.bold(`\nKey rotation will affect ${toRotate.length} field(s):\n`) +
|
|
63
|
+
toRotate.map((k) => chalk.dim(` • ${k}`)).join("\n") +
|
|
64
|
+
"\n\n" +
|
|
65
|
+
chalk.yellow("This operation will:\n") +
|
|
66
|
+
chalk.dim(" 1. Decrypt all encrypted values with the current private key\n") +
|
|
67
|
+
chalk.dim(" 2. Generate a new X25519 key pair\n") +
|
|
68
|
+
chalk.dim(" 3. Re-encrypt all values with the new public key\n") +
|
|
69
|
+
chalk.dim(" 4. Write the new public key to " + envPath + "\n") +
|
|
70
|
+
chalk.dim(" 5. Save the old private key as ENVGAD_PRIVATE_KEY_OLD in " + keysPath + "\n") +
|
|
71
|
+
chalk.dim(" 6. Write the new private key to " + keysPath + "\n"));
|
|
72
|
+
if (!opts.force) {
|
|
73
|
+
const ok = await confirm(" Proceed with key rotation? (y/N): ");
|
|
74
|
+
if (!ok) {
|
|
75
|
+
console.log(chalk.dim("\nAborted."));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
console.log();
|
|
79
|
+
}
|
|
80
|
+
const spinner2 = ora("Step 1/4: Decrypting current values…").start();
|
|
81
|
+
// Decrypt all encrypted values with the old key
|
|
82
|
+
const decrypted = {};
|
|
83
|
+
const decryptErrors = [];
|
|
84
|
+
for (const key of toRotate) {
|
|
85
|
+
try {
|
|
86
|
+
decrypted[key] = decryptEnvValue(parsed[key], oldPrivateKeyHex, key);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
decryptErrors.push(`${key}: ${err instanceof Error ? err.message : "decryption failed"}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (decryptErrors.length > 0) {
|
|
93
|
+
spinner2.fail(chalk.red("Decryption failed for some fields. Aborting rotation."));
|
|
94
|
+
for (const e of decryptErrors) {
|
|
95
|
+
console.error(chalk.red(` ✗ ${e}`));
|
|
96
|
+
}
|
|
97
|
+
console.log(chalk.dim("\nNo changes were written. Your .env and .env.keys are unchanged."));
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
spinner2.text = "Step 2/4: Generating new key pair…";
|
|
101
|
+
// Generate new key pair
|
|
102
|
+
const { publicKeyHex: newPublicKeyHex, privateKeyHex: newPrivateKeyHex } = generateKeyPair();
|
|
103
|
+
spinner2.text = "Step 3/4: Re-encrypting values with new key…";
|
|
104
|
+
// Re-encrypt all values with the new public key
|
|
105
|
+
const reEncrypted = {};
|
|
106
|
+
for (const key of toRotate) {
|
|
107
|
+
reEncrypted[key] = encryptEnvValue(decrypted[key], newPublicKeyHex, key);
|
|
108
|
+
}
|
|
109
|
+
spinner2.text = "Step 4/4: Writing updated files…";
|
|
110
|
+
// Update .env — replace all encrypted values and the public key
|
|
111
|
+
let updatedEnvContent = envContent;
|
|
112
|
+
// Update ENVGAD_PUBLIC_KEY
|
|
113
|
+
if (/^ENVGAD_PUBLIC_KEY=/m.test(updatedEnvContent)) {
|
|
114
|
+
updatedEnvContent = updatedEnvContent.replace(/^ENVGAD_PUBLIC_KEY=.*$/m, `ENVGAD_PUBLIC_KEY=${newPublicKeyHex}`);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const sep = updatedEnvContent.endsWith("\n") ? "" : "\n";
|
|
118
|
+
updatedEnvContent += `${sep}ENVGAD_PUBLIC_KEY=${newPublicKeyHex}\n`;
|
|
119
|
+
}
|
|
120
|
+
const replaceRegexes = Object.fromEntries(Object.keys(reEncrypted).map((k) => [k, new RegExp(`^(${k}\\s*=).*$`, "m")]));
|
|
121
|
+
for (const [key, value] of Object.entries(reEncrypted)) {
|
|
122
|
+
updatedEnvContent = updatedEnvContent.replace(replaceRegexes[key], `$1${value}`);
|
|
123
|
+
}
|
|
124
|
+
copyFileSync(envPath, `${envPath}.bak`);
|
|
125
|
+
writeFileSync(envPath, updatedEnvContent);
|
|
126
|
+
// Update .env.keys — keep old key as ENVGAD_PRIVATE_KEY_OLD
|
|
127
|
+
const keysFileContent = "# KEEP THIS FILE SECRET — DO NOT COMMIT TO GIT\n" +
|
|
128
|
+
"# Share securely via 1Password, Vault, or a secure channel\n" +
|
|
129
|
+
`ENVGAD_PRIVATE_KEY=${newPrivateKeyHex}\n` +
|
|
130
|
+
`\n# Previous key (kept for emergency access)\n` +
|
|
131
|
+
`ENVGAD_PRIVATE_KEY_OLD=${oldPrivateKeyHex}\n`;
|
|
132
|
+
// Back up old .env.keys before overwriting
|
|
133
|
+
if (existsSync(keysPath)) {
|
|
134
|
+
copyFileSync(keysPath, `${keysPath}.bak`);
|
|
135
|
+
}
|
|
136
|
+
writeFileSync(keysPath, keysFileContent, { mode: 0o600 });
|
|
137
|
+
spinner2.succeed(chalk.green("Key rotation complete!"));
|
|
138
|
+
console.log(`\n${chalk.green("✓")} ${toRotate.length} field(s) re-encrypted in ${chalk.bold(envPath)}\n` +
|
|
139
|
+
`${chalk.green("✓")} New public key written to ${chalk.bold(envPath)}\n` +
|
|
140
|
+
`${chalk.green("✓")} New private key written to ${chalk.bold(keysPath)}\n` +
|
|
141
|
+
`${chalk.dim(`✓ Previous private key preserved as ENVGAD_PRIVATE_KEY_OLD in ${keysPath}`)}\n` +
|
|
142
|
+
chalk.dim(`✓ Backup of old ${envPath} saved as ${envPath}.bak\n`) +
|
|
143
|
+
(existsSync(`${keysPath}.bak`)
|
|
144
|
+
? chalk.dim(`✓ Backup of old ${keysPath} saved as ${keysPath}.bak\n`)
|
|
145
|
+
: "") +
|
|
146
|
+
"\n" +
|
|
147
|
+
chalk.yellow("Next steps:\n") +
|
|
148
|
+
chalk.dim(" • Distribute the new .env.keys to team members\n") +
|
|
149
|
+
chalk.dim(" • Update ENVGAD_PRIVATE_KEY in CI/CD secrets\n") +
|
|
150
|
+
chalk.dim(" • Run: ") +
|
|
151
|
+
chalk.cyan("npx dotenv-gad verify") +
|
|
152
|
+
chalk.dim(" to confirm decryption works with the new key"));
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
spinner.stop();
|
|
156
|
+
console.error(chalk.red("Unexpected error:"), error);
|
|
157
|
+
process.exit(2);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import dotenv from "dotenv";
|
|
6
|
+
import { isEncryptedValue, loadPrivateKey } from "../../crypto.js";
|
|
7
|
+
import { loadSchema } from "./utils.js";
|
|
8
|
+
export default function (_program) {
|
|
9
|
+
return new Command("status")
|
|
10
|
+
.description("Show encryption status of all schema fields in the .env file")
|
|
11
|
+
.option("--keys <file>", "Path to .env.keys file", ".env.keys")
|
|
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 keysPath = opts.keys;
|
|
17
|
+
const spinner = ora("Loading schema…").start();
|
|
18
|
+
try {
|
|
19
|
+
const schema = await loadSchema(schemaPath);
|
|
20
|
+
let envContent = "";
|
|
21
|
+
if (existsSync(envPath)) {
|
|
22
|
+
envContent = readFileSync(envPath, "utf8");
|
|
23
|
+
}
|
|
24
|
+
const parsed = dotenv.parse(envContent);
|
|
25
|
+
// Check key availability
|
|
26
|
+
const hasPublicKey = Boolean(parsed.ENVGAD_PUBLIC_KEY);
|
|
27
|
+
const privateKeyHex = loadPrivateKey({ keysPath });
|
|
28
|
+
spinner.stop();
|
|
29
|
+
const schemaKeys = Object.keys(schema);
|
|
30
|
+
const encryptedSchemaKeys = new Set(schemaKeys.filter((k) => schema[k].encrypted));
|
|
31
|
+
console.log(chalk.bold("\nEnvironment Encryption Status"));
|
|
32
|
+
console.log(chalk.dim("─".repeat(52)));
|
|
33
|
+
let correctCount = 0;
|
|
34
|
+
let warningCount = 0;
|
|
35
|
+
for (const key of schemaKeys) {
|
|
36
|
+
const rule = schema[key];
|
|
37
|
+
const value = parsed[key];
|
|
38
|
+
const needsEncryption = rule.encrypted === true;
|
|
39
|
+
const valueIsEncrypted = value ? isEncryptedValue(value) : false;
|
|
40
|
+
const valueMissing = value == null || value === "";
|
|
41
|
+
let icon;
|
|
42
|
+
let label;
|
|
43
|
+
let note;
|
|
44
|
+
if (valueMissing) {
|
|
45
|
+
if (needsEncryption) {
|
|
46
|
+
icon = chalk.dim("○");
|
|
47
|
+
label = chalk.dim("missing");
|
|
48
|
+
note = chalk.dim("(encrypted when present)");
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
icon = chalk.dim("○");
|
|
52
|
+
label = chalk.dim("missing");
|
|
53
|
+
note = "";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (needsEncryption && valueIsEncrypted) {
|
|
57
|
+
icon = chalk.green("✓");
|
|
58
|
+
label = chalk.green("encrypted");
|
|
59
|
+
note = chalk.dim("(correct)");
|
|
60
|
+
correctCount++;
|
|
61
|
+
}
|
|
62
|
+
else if (needsEncryption && !valueIsEncrypted) {
|
|
63
|
+
icon = chalk.yellow("⚠");
|
|
64
|
+
label = chalk.yellow("plaintext");
|
|
65
|
+
note = chalk.yellow("(should be encrypted — run: npx dotenv-gad encrypt)");
|
|
66
|
+
warningCount++;
|
|
67
|
+
}
|
|
68
|
+
else if (!needsEncryption && valueIsEncrypted) {
|
|
69
|
+
icon = chalk.red("✗");
|
|
70
|
+
label = chalk.red("encrypted");
|
|
71
|
+
note = chalk.red("(schema lacks encrypted: true)");
|
|
72
|
+
warningCount++;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
icon = chalk.green("✓");
|
|
76
|
+
label = chalk.dim("plaintext");
|
|
77
|
+
note = chalk.dim("(no encryption required)");
|
|
78
|
+
correctCount++;
|
|
79
|
+
}
|
|
80
|
+
const keyDisplay = chalk.bold(key.padEnd(24));
|
|
81
|
+
console.log(` ${icon} ${keyDisplay} ${label} ${note}`);
|
|
82
|
+
}
|
|
83
|
+
console.log(chalk.dim("─".repeat(52)));
|
|
84
|
+
// Key availability summary
|
|
85
|
+
console.log();
|
|
86
|
+
if (hasPublicKey) {
|
|
87
|
+
console.log(` ${chalk.green("✓")} ${chalk.dim("Public key (ENVGAD_PUBLIC_KEY): present in " + envPath)}`);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
console.log(` ${chalk.yellow("⚠")} ${chalk.yellow("Public key (ENVGAD_PUBLIC_KEY): missing — run: npx dotenv-gad keygen")}`);
|
|
91
|
+
}
|
|
92
|
+
if (privateKeyHex) {
|
|
93
|
+
const src = existsSync(keysPath) ? keysPath : "ENVGAD_PRIVATE_KEY env var";
|
|
94
|
+
console.log(` ${chalk.green("✓")} ${chalk.dim("Private key (ENVGAD_PRIVATE_KEY): found in " + src)}`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
console.log(` ${chalk.dim("○")} ${chalk.dim("Private key (ENVGAD_PRIVATE_KEY): not found (needed for decryption only)")}`);
|
|
98
|
+
}
|
|
99
|
+
console.log();
|
|
100
|
+
const encryptedTotal = encryptedSchemaKeys.size;
|
|
101
|
+
if (warningCount > 0) {
|
|
102
|
+
console.log(chalk.yellow(` ${warningCount} issue(s) detected`) +
|
|
103
|
+
chalk.dim(`, ${correctCount} correct, ${encryptedTotal} field(s) require encryption`));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
console.log(chalk.green(` All ${schemaKeys.length} field(s) are correctly configured`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
spinner.stop();
|
|
112
|
+
console.error(chalk.red("Unexpected error:"), error);
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -3,7 +3,7 @@ import chalk from "chalk";
|
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import { writeFileSync } from "fs";
|
|
5
5
|
import { loadSchema } from "./utils.js";
|
|
6
|
-
export default function (
|
|
6
|
+
export default function (_program) {
|
|
7
7
|
return new Command("sync")
|
|
8
8
|
.description("Generate/update .env.example file")
|
|
9
9
|
.option("--output <file>", "Output file path", ".env.example")
|
|
@@ -15,39 +15,37 @@ export default function (program) {
|
|
|
15
15
|
if (!schema || typeof schema !== "object") {
|
|
16
16
|
throw new Error(`The schema loaded from "${rootOpts.schema}" is not valid.`);
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
const parts = ["# Auto-generated by dotenv-gad\n\n"];
|
|
19
19
|
Object.entries(schema).forEach(([key, rule]) => {
|
|
20
20
|
if (rule.sensitive)
|
|
21
21
|
return;
|
|
22
|
-
// If this is a grouped object with properties, emit grouped entries
|
|
23
22
|
const eff = rule;
|
|
23
|
+
// If this is a grouped object with properties, emit grouped entries
|
|
24
24
|
if (eff.type === "object" && eff.properties) {
|
|
25
25
|
const prefix = eff.envPrefix || `${key}_`;
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
parts.push(`# ${eff.docs || "No description available"}\n`);
|
|
27
|
+
parts.push(`# Group: ${key} (prefix=${prefix})\n`);
|
|
28
28
|
Object.entries(eff.properties).forEach(([prop, pRule]) => {
|
|
29
29
|
const pr = pRule;
|
|
30
30
|
if (pr.sensitive)
|
|
31
31
|
return;
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
parts.push(`# ${pr.docs || "No description available"}\n`);
|
|
33
|
+
parts.push(`# Type: ${pr.type}\n`);
|
|
34
34
|
if (pr.default !== undefined) {
|
|
35
|
-
|
|
35
|
+
parts.push(`# Default: ${JSON.stringify(pr.default)}\n`);
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
parts.push(`${prefix}${prop}=${pr.default ? JSON.stringify(pr.default) : ""}\n\n`);
|
|
38
38
|
});
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
41
|
+
parts.push(`# ${eff.docs || "No description available"}\n`);
|
|
42
|
+
parts.push(`# Type: ${eff.type}\n`);
|
|
43
|
+
if (eff.default !== undefined) {
|
|
44
|
+
parts.push(`# Default: ${JSON.stringify(eff.default)}\n`);
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
? JSON.stringify(rule.default)
|
|
48
|
-
: ""}\n\n`;
|
|
46
|
+
parts.push(`${key}=${eff.default ? JSON.stringify(eff.default) : ""}\n\n`);
|
|
49
47
|
});
|
|
50
|
-
writeFileSync(options.output,
|
|
48
|
+
writeFileSync(options.output, parts.join("").trimEnd());
|
|
51
49
|
spinner.succeed(chalk.green(`Generated ${options.output} successfully!`));
|
|
52
50
|
}
|
|
53
51
|
catch (error) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
2
3
|
import Chalk from "chalk";
|
|
3
|
-
import inquirer from "inquirer";
|
|
4
4
|
// Re-export from the standalone schema loader (no CLI-only deps)
|
|
5
5
|
export { loadSchema } from "../../schema-loader.js";
|
|
6
6
|
/**
|
|
@@ -16,37 +16,49 @@ export { loadSchema } from "../../schema-loader.js";
|
|
|
16
16
|
*/
|
|
17
17
|
export async function applyFix(issues, schema, envPath = ".env") {
|
|
18
18
|
const envLines = readFileSync(envPath, "utf-8").split("\n");
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
19
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
20
|
+
try {
|
|
21
|
+
for (const key in issues) {
|
|
22
|
+
if (!Object.prototype.hasOwnProperty.call(issues, key))
|
|
23
|
+
continue;
|
|
24
|
+
const rule = schema[key];
|
|
25
|
+
if (!rule) {
|
|
26
|
+
console.error(Chalk.red(`Error: Could not find rule for key ${key} in schema`));
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const defaultValue = rule.default !== undefined ? String(rule.default) : "";
|
|
30
|
+
const hint = defaultValue ? Chalk.dim(` [${defaultValue}]`) : "";
|
|
31
|
+
const prompt = `${Chalk.yellow(key)} (${rule.docs || "No description"})${hint}: `;
|
|
32
|
+
let input;
|
|
33
|
+
while (true) {
|
|
34
|
+
const answer = await rl.question(prompt);
|
|
35
|
+
input = answer.trim() || defaultValue;
|
|
34
36
|
if (rule.required && !input) {
|
|
35
|
-
|
|
37
|
+
console.log(Chalk.red(" Value is required"));
|
|
38
|
+
continue;
|
|
36
39
|
}
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
envLines.
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
// Sanitize: strip newlines and carriage returns to prevent .env injection
|
|
43
|
+
const sanitized = input.replace(/[\r\n]/g, "");
|
|
44
|
+
// Quote the value if it contains characters with special meaning in .env files
|
|
45
|
+
// (# starts a comment, leading/trailing spaces are stripped without quotes)
|
|
46
|
+
const needsQuoting = /[#"\\]/.test(sanitized) || sanitized !== sanitized.trim();
|
|
47
|
+
const envValue = needsQuoting
|
|
48
|
+
? `"${sanitized.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
49
|
+
: sanitized;
|
|
50
|
+
const lineIndex = envLines.findIndex((line) => line.startsWith(`${key}=`));
|
|
51
|
+
if (lineIndex >= 0) {
|
|
52
|
+
envLines[lineIndex] = `${key}=${envValue}`;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
envLines.push(`${key}=${envValue}`);
|
|
56
|
+
}
|
|
48
57
|
}
|
|
49
58
|
}
|
|
59
|
+
finally {
|
|
60
|
+
rl.close();
|
|
61
|
+
}
|
|
50
62
|
writeFileSync(envPath, envLines.join("\n"));
|
|
51
63
|
console.log(Chalk.green(`Updated ${envPath} successfully!`));
|
|
52
64
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import dotenv from "dotenv";
|
|
6
|
+
import { decryptEnvValue, isEncryptedValue, loadPrivateKey } from "../../crypto.js";
|
|
7
|
+
import { EncryptionKeyMissingError } from "../../errors.js";
|
|
8
|
+
import { loadSchema } from "./utils.js";
|
|
9
|
+
export default function (_program) {
|
|
10
|
+
return new Command("verify")
|
|
11
|
+
.description("Verify all encrypted fields can be decrypted with the current private key (no output)")
|
|
12
|
+
.option("--keys <file>", "Path to .env.keys file", ".env.keys")
|
|
13
|
+
.action(async (opts, command) => {
|
|
14
|
+
const rootOpts = command.parent.opts();
|
|
15
|
+
const envPath = rootOpts.env ?? ".env";
|
|
16
|
+
const schemaPath = rootOpts.schema ?? "env.schema.ts";
|
|
17
|
+
const keysPath = opts.keys;
|
|
18
|
+
const spinner = ora("Loading schema…").start();
|
|
19
|
+
try {
|
|
20
|
+
const schema = await loadSchema(schemaPath);
|
|
21
|
+
const encryptedFields = Object.keys(schema).filter((k) => schema[k].encrypted === true);
|
|
22
|
+
if (encryptedFields.length === 0) {
|
|
23
|
+
spinner.info(chalk.dim("No fields with encrypted: true in schema. Nothing to verify."));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
spinner.text = `Reading ${envPath}…`;
|
|
27
|
+
let envContent = "";
|
|
28
|
+
try {
|
|
29
|
+
envContent = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
spinner.fail(chalk.red(`Cannot read ${envPath}`));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const parsed = dotenv.parse(envContent);
|
|
36
|
+
spinner.text = "Loading private key…";
|
|
37
|
+
const privateKeyHex = loadPrivateKey({ keysPath });
|
|
38
|
+
if (!privateKeyHex) {
|
|
39
|
+
spinner.fail(chalk.red("Private key not found.\n" +
|
|
40
|
+
` Checked: ${keysPath} and ENVGAD_PRIVATE_KEY env var`));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
spinner.text = `Verifying ${encryptedFields.length} encrypted field(s)…`;
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const key of encryptedFields) {
|
|
46
|
+
const value = parsed[key];
|
|
47
|
+
if (value == null || value === "") {
|
|
48
|
+
// No value present — skip (required check is handled by validate)
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!isEncryptedValue(value)) {
|
|
52
|
+
results.push({ key, ok: false, reason: "value is plaintext (not encrypted)" });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
decryptEnvValue(value, privateKeyHex, key);
|
|
57
|
+
results.push({ key, ok: true });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
results.push({
|
|
61
|
+
key,
|
|
62
|
+
ok: false,
|
|
63
|
+
reason: err instanceof Error ? err.message : "Decryption failed",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
spinner.stop();
|
|
68
|
+
const failed = results.filter((r) => !r.ok);
|
|
69
|
+
const passed = results.filter((r) => r.ok);
|
|
70
|
+
for (const { key, ok, reason } of results) {
|
|
71
|
+
if (ok) {
|
|
72
|
+
console.log(` ${chalk.green("✓")} ${chalk.bold(key)}`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
console.log(` ${chalk.red("✗")} ${chalk.bold(key)}: ${chalk.red(reason ?? "failed")}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
console.log();
|
|
79
|
+
if (failed.length > 0) {
|
|
80
|
+
console.log(chalk.red(`✗ ${failed.length} field(s) failed verification`) +
|
|
81
|
+
chalk.dim(`, ${passed.length} passed`));
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
else if (passed.length === 0) {
|
|
85
|
+
console.log(chalk.dim("No encrypted values found in .env to verify."));
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
console.log(chalk.green(`✓ All ${passed.length} encrypted field(s) verified successfully`));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
spinner.stop();
|
|
93
|
+
if (error instanceof EncryptionKeyMissingError) {
|
|
94
|
+
console.error(chalk.red(`\n✗ ${error.message}`));
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
console.error(chalk.red("Unexpected error:"), error);
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -11,6 +11,12 @@ import typesCommand from "./commands/types.js";
|
|
|
11
11
|
import initCommand from "./commands/init.js";
|
|
12
12
|
import fixCommand from "./commands/fix.js";
|
|
13
13
|
import docsCommand from "./commands/docs.js";
|
|
14
|
+
import keygenCommand from "./commands/keygen.js";
|
|
15
|
+
import encryptCommand from "./commands/encrypt.js";
|
|
16
|
+
import decryptCommand from "./commands/decrypt.js";
|
|
17
|
+
import statusCommand from "./commands/status.js";
|
|
18
|
+
import verifyCommand from "./commands/verify.js";
|
|
19
|
+
import rotateCommand from "./commands/rotate.js";
|
|
14
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
21
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf-8"));
|
|
16
22
|
export function createCLI() {
|
|
@@ -31,6 +37,12 @@ export function createCLI() {
|
|
|
31
37
|
initCommand,
|
|
32
38
|
fixCommand,
|
|
33
39
|
docsCommand,
|
|
40
|
+
keygenCommand,
|
|
41
|
+
encryptCommand,
|
|
42
|
+
decryptCommand,
|
|
43
|
+
statusCommand,
|
|
44
|
+
verifyCommand,
|
|
45
|
+
rotateCommand,
|
|
34
46
|
];
|
|
35
47
|
commands.forEach((command) => {
|
|
36
48
|
const cmd = command(program);
|