@dotenc/cli 0.1.4 → 0.2.2

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.
@@ -4,10 +4,10 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { decrypt, encrypt } from "../helpers/crypto.js";
6
6
  export const debugCommand = async () => {
7
- const token = crypto.randomBytes(32).toString("base64");
7
+ const key = crypto.randomBytes(32).toString("base64");
8
8
  const encryptedFilePath = path.join(os.tmpdir(), "dotenc.enc");
9
- await encrypt(token, "Test", encryptedFilePath);
10
- const content = await decrypt(token, encryptedFilePath);
9
+ await encrypt(key, "Test", encryptedFilePath);
10
+ const content = await decrypt(key, encryptedFilePath);
11
11
  await fs.unlink(encryptedFilePath);
12
12
  if (content !== "Test") {
13
13
  throw new Error("Decrypted content is not equal to the original content");
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import { createHash } from "../helpers/createHash.js";
7
7
  import { decrypt, encrypt } from "../helpers/crypto.js";
8
8
  import { getDefaultEditor } from "../helpers/getDefaultEditor.js";
9
- import { getToken } from "../helpers/token.js";
9
+ import { getKey } from "../helpers/key.js";
10
10
  import { chooseEnvironmentPrompt } from "../prompts/chooseEnvironment.js";
11
11
  export const editCommand = async (environmentArg) => {
12
12
  let environment = environmentArg;
@@ -18,9 +18,9 @@ export const editCommand = async (environmentArg) => {
18
18
  console.error(`Environment file not found: ${environmentFilePath}`);
19
19
  return;
20
20
  }
21
- const token = await getToken(environment);
21
+ const key = await getKey(environment);
22
22
  const tempFilePath = path.join(os.tmpdir(), `.env.${environment}`);
23
- const content = await decrypt(token, environmentFilePath);
23
+ const content = await decrypt(key, environmentFilePath);
24
24
  await fs.writeFile(tempFilePath, content);
25
25
  const initialHash = createHash(content);
26
26
  const editor = await getDefaultEditor();
@@ -38,7 +38,7 @@ export const editCommand = async (environmentArg) => {
38
38
  console.log(`No changes were made to the environment file for "${environment}".`);
39
39
  }
40
40
  else {
41
- await encrypt(token, newContent, environmentFilePath);
41
+ await encrypt(key, newContent, environmentFilePath);
42
42
  console.log(`Encrypted environment file for "${environment}" and saved it to ${environmentFilePath}.`);
43
43
  }
44
44
  await fs.unlink(tempFilePath);
@@ -2,23 +2,23 @@ import crypto from "node:crypto";
2
2
  import { createEnvironment } from "../helpers/createEnvironment.js";
3
3
  import { createLocalEnvironment } from "../helpers/createLocalEnvironment.js";
4
4
  import { createProject } from "../helpers/createProject.js";
5
- import { addToken } from "../helpers/token.js";
5
+ import { addKey } from "../helpers/key.js";
6
6
  import { createEnvironmentPrompt } from "../prompts/createEnvironment.js";
7
7
  export const initCommand = async (environmentArg) => {
8
8
  // Generate a unique project ID
9
9
  const { projectId } = await createProject();
10
10
  // Setup local environment
11
11
  await createLocalEnvironment();
12
- // Generate a random token
13
- const token = crypto.randomBytes(32).toString("base64");
12
+ // Generate a random key
13
+ const key = crypto.randomBytes(32).toString("base64");
14
14
  // Prompt for the environment name
15
15
  let environment = environmentArg;
16
16
  if (!environment) {
17
17
  environment = await createEnvironmentPrompt("What should the environment be named?", "development");
18
18
  }
19
- await createEnvironment(environment, token);
20
- // Store the token
21
- await addToken(projectId, environment, token);
19
+ await createEnvironment(environment, key);
20
+ // Store the key
21
+ await addKey(projectId, environment, key);
22
22
  // Output success message
23
23
  console.log("Initialization complete!");
24
24
  console.log("Next steps:");
@@ -1,12 +1,12 @@
1
+ import { getKey } from "../../helpers/key.js";
1
2
  import { getProjectConfig } from "../../helpers/projectConfig.js";
2
- import { getToken } from "../../helpers/token.js";
3
- export const tokenExportCommand = async (environmentArg) => {
3
+ export const keyExportCommand = async (environmentArg) => {
4
4
  const environment = environmentArg;
5
5
  const { projectId } = await getProjectConfig();
6
6
  if (!projectId) {
7
7
  console.error('No project found. Run "dotenc init" to create one.');
8
8
  return;
9
9
  }
10
- const token = await getToken(environment);
11
- console.log(`Token for the ${environment} environment: ${token}`);
10
+ const key = await getKey(environment);
11
+ console.log(`Key for the ${environment} environment: ${key}`);
12
12
  };
@@ -1,12 +1,12 @@
1
+ import { addKey } from "../../helpers/key.js";
1
2
  import { getProjectConfig } from "../../helpers/projectConfig.js";
2
- import { addToken } from "../../helpers/token.js";
3
- export const tokenImportCommand = async (token, environmentArg) => {
3
+ export const keyImportCommand = async (key, environmentArg) => {
4
4
  const environment = environmentArg;
5
5
  const { projectId } = await getProjectConfig();
6
6
  if (!projectId) {
7
7
  console.error('No project found. Run "dotenc init" to create one.');
8
8
  return;
9
9
  }
10
- await addToken(projectId, environment, token);
11
- console.log(`Token imported to the ${environment} environment.`);
10
+ await addKey(projectId, environment, key);
11
+ console.log(`Key imported to the ${environment} environment.`);
12
12
  };
@@ -3,23 +3,30 @@ import { existsSync } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { decrypt } from "../helpers/crypto.js";
6
+ import { getKey } from "../helpers/key.js";
6
7
  import { parseEnv } from "../helpers/parseEnv.js";
7
- import { getToken } from "../helpers/token.js";
8
8
  export const runCommand = async (command, args, options) => {
9
9
  // Get the environment
10
- const environment = options.env || process.env.DOTENC_ENV;
11
- if (!environment) {
10
+ const environmentArg = options.env || process.env.DOTENC_ENV;
11
+ if (!environmentArg) {
12
12
  console.error('No environment provided. Use -e or set DOTENC_ENV to the environment you want to run the command in.\nTo start a new environment, use "dotenc init [environment]".');
13
13
  return;
14
14
  }
15
- const environmentFilePath = path.join(process.cwd(), `.env.${environment}.enc`);
16
- if (!existsSync(environmentFilePath)) {
17
- console.error(`Environment file not found: ${environmentFilePath}`);
18
- return;
19
- }
20
- const token = await getToken(environment);
21
- const content = await decrypt(token, environmentFilePath);
22
- const decryptedEnv = parseEnv(content);
15
+ const environments = environmentArg.split(",");
16
+ const decryptedEnvs = await Promise.all(environments.map(async (environment) => {
17
+ const environmentFilePath = path.join(process.cwd(), `.env.${environment}.enc`);
18
+ if (!existsSync(environmentFilePath)) {
19
+ console.error(`Environment file not found: ${environmentFilePath}`);
20
+ return;
21
+ }
22
+ const key = await getKey(environment);
23
+ const content = await decrypt(key, environmentFilePath);
24
+ const decryptedEnv = parseEnv(content);
25
+ return decryptedEnv;
26
+ }));
27
+ const decryptedEnv = decryptedEnvs.reduce((acc, env) => {
28
+ return { ...acc, ...env };
29
+ }, {});
23
30
  // Get the local environment
24
31
  let localEnv = {};
25
32
  const localEnvironmentFilePath = path.join(process.cwd(), ".env");
@@ -1,10 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { encrypt } from "./crypto.js";
4
- export const createEnvironment = async (name, token) => {
4
+ export const createEnvironment = async (name, key) => {
5
5
  const filePath = path.join(process.cwd(), `.env.${name}.enc`);
6
6
  if (existsSync(filePath)) {
7
7
  throw new Error(`Environment "${name}" already exists.`);
8
8
  }
9
- await encrypt(token, `# ${name} environment\n`, filePath);
9
+ await encrypt(key, `# ${name} environment\n`, filePath);
10
10
  };
@@ -6,19 +6,19 @@ const IV_LENGTH = 12; // 96 bits, recommended for GCM
6
6
  const AUTH_TAG_LENGTH = 16; // 128 bits, standard for GCM
7
7
  /**
8
8
  * Encrypts a file using AES-256-GCM.
9
- * @param {string} token - The encryption key (must be 32 bytes for AES-256).
9
+ * @param {string} key - The encryption key (must be 32 bytes for AES-256).
10
10
  * @param {string} input - The input string to encrypt.
11
11
  * @param {string} outputFile - Path to the output encrypted file.
12
12
  */
13
- export async function encrypt(token, input, outputFile) {
14
- const tokenBuffer = Buffer.from(token, "base64");
15
- if (tokenBuffer.length !== 32) {
16
- throw new Error("Token must be 32 bytes (256 bits) for AES-256-GCM.");
13
+ export async function encrypt(key, input, outputFile) {
14
+ const keyBuffer = Buffer.from(key, "base64");
15
+ if (keyBuffer.length !== 32) {
16
+ throw new Error("Key must be 32 bytes (256 bits) for AES-256-GCM.");
17
17
  }
18
18
  // Generate a random IV
19
19
  const iv = crypto.randomBytes(IV_LENGTH);
20
20
  // Create the cipher
21
- const cipher = crypto.createCipheriv(ALGORITHM, tokenBuffer, iv);
21
+ const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
22
22
  // Encrypt the data
23
23
  const encrypted = Buffer.concat([cipher.update(input), cipher.final()]);
24
24
  // Get the auth tag
@@ -30,13 +30,13 @@ export async function encrypt(token, input, outputFile) {
30
30
  }
31
31
  /**
32
32
  * Decrypts a file using AES-256-GCM.
33
- * @param {string} token - The decryption key (must be 32 bytes for AES-256).
33
+ * @param {string} key - The decryption key (must be 32 bytes for AES-256).
34
34
  * @param {string} inputFile - The input file to decrypt.
35
35
  */
36
- export async function decrypt(token, inputFile) {
37
- const tokenBuffer = Buffer.from(token, "base64");
38
- if (tokenBuffer.length !== 32) {
39
- throw new Error("Token must be 32 bytes (256 bits) for AES-256-GCM.");
36
+ export async function decrypt(key, inputFile) {
37
+ const keyBuffer = Buffer.from(key, "base64");
38
+ if (keyBuffer.length !== 32) {
39
+ throw new Error("Key must be 32 bytes (256 bits) for AES-256-GCM.");
40
40
  }
41
41
  // Read the encrypted file
42
42
  const encryptedData = await fs.readFile(inputFile);
@@ -48,7 +48,7 @@ export async function decrypt(token, inputFile) {
48
48
  const ciphertext = encryptedData.subarray(IV_LENGTH, encryptedData.length - AUTH_TAG_LENGTH);
49
49
  try {
50
50
  // Create the decipher
51
- const decipher = crypto.createDecipheriv(ALGORITHM, tokenBuffer, iv);
51
+ const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
52
52
  decipher.setAuthTag(authTag);
53
53
  // Decrypt the ciphertext
54
54
  const decrypted = Buffer.concat([
@@ -61,7 +61,7 @@ export async function decrypt(token, inputFile) {
61
61
  if (error instanceof Error &&
62
62
  error.message.includes("unable to authenticate")) {
63
63
  throw new Error("Failed to decrypt file. This could be because:\n" +
64
- "1. The encryption token may be incorrect\n" +
64
+ "1. The encryption key may be incorrect\n" +
65
65
  "2. The encrypted file may be corrupted\n" +
66
66
  "3. The encrypted file may have been tampered with");
67
67
  }
@@ -0,0 +1,39 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { getProjectConfig } from "./projectConfig.js";
6
+ export const getKey = async (environment) => {
7
+ if (process.env.DOTENC_KEY) {
8
+ return process.env.DOTENC_KEY;
9
+ }
10
+ const { projectId } = await getProjectConfig();
11
+ const keysFile = path.join(os.homedir(), ".dotenc", "keys.json");
12
+ if (existsSync(keysFile)) {
13
+ const keys = JSON.parse(await fs.readFile(keysFile, "utf-8"));
14
+ return keys[projectId][environment];
15
+ }
16
+ throw new Error("No key found. Please set the DOTENC_KEY environment variable or import the key using `dotenc import-key -e <environment> <key>`.");
17
+ };
18
+ /**
19
+ * Adds or updates a key for a specific project and environment.
20
+ */
21
+ export const addKey = async (projectId, environment, key) => {
22
+ const keysFile = path.join(os.homedir(), ".dotenc", "keys.json");
23
+ // Ensure the keys file exists
24
+ if (!existsSync(keysFile)) {
25
+ await fs.mkdir(path.dirname(keysFile), { recursive: true });
26
+ await fs.writeFile(keysFile, "{}", { mode: 0o600 }); // Create an empty JSON file with secure permissions
27
+ }
28
+ // Read the existing keys
29
+ const keys = JSON.parse(await fs.readFile(keysFile, "utf8"));
30
+ // Add or update the key
31
+ if (!keys[projectId]) {
32
+ keys[projectId] = {};
33
+ }
34
+ keys[projectId][environment] = key;
35
+ // Write the updated keys back to the file
36
+ await fs.writeFile(keysFile, JSON.stringify(keys, null, 2), {
37
+ mode: 0o600,
38
+ });
39
+ };
@@ -1,21 +1,87 @@
1
1
  /**
2
2
  * Parses a .env file content and returns an object of key-value pairs.
3
+ * Supports multiline values when they are properly quoted.
4
+ * Supports nested quotes (e.g., "value with 'quotes' inside" or 'value with "quotes" inside')
3
5
  */
4
6
  export const parseEnv = (content) => {
5
7
  const env = {};
6
- const lines = content.split("\n");
7
- for (const line of lines) {
8
- const trimmedLine = line.trim();
9
- // Skip empty lines and comments
10
- if (!trimmedLine || trimmedLine.startsWith("#")) {
8
+ let currentKey = "";
9
+ let currentValue = "";
10
+ let isInSingleQuotes = false;
11
+ let isInDoubleQuotes = false;
12
+ let isInComment = false;
13
+ let isInKey = true;
14
+ const commit = (trim) => {
15
+ if (currentKey.trim()) {
16
+ env[currentKey.trim()] = trim ? currentValue.trim() : currentValue;
17
+ }
18
+ currentKey = "";
19
+ currentValue = "";
20
+ isInSingleQuotes = false;
21
+ isInDoubleQuotes = false;
22
+ isInComment = false;
23
+ isInKey = true;
24
+ };
25
+ for (const char of content) {
26
+ // Handle single quoted content
27
+ if (isInSingleQuotes) {
28
+ if (char === "'") {
29
+ commit(false);
30
+ }
31
+ else {
32
+ currentValue += char;
33
+ }
34
+ continue;
35
+ }
36
+ // Handle double quoted content
37
+ if (isInDoubleQuotes) {
38
+ if (char === '"') {
39
+ commit(false);
40
+ }
41
+ else {
42
+ currentValue += char;
43
+ }
44
+ continue;
45
+ }
46
+ // Handle comments
47
+ if (isInComment) {
48
+ if (char === "\n") {
49
+ isInComment = false;
50
+ }
51
+ continue;
52
+ }
53
+ if (char === "#") {
54
+ isInComment = true;
11
55
  continue;
12
56
  }
13
- // Parse key-value pairs
14
- const [key, ...valueParts] = trimmedLine.split("=");
15
- const value = valueParts.join("=").trim();
16
- if (key) {
17
- env[key.trim()] = value.replace(/(^['"])|(['"]$)/g, ""); // Remove surrounding quotes
57
+ // Handle keys
58
+ if (isInKey) {
59
+ if (char === "=") {
60
+ isInKey = false;
61
+ }
62
+ else {
63
+ currentKey += char;
64
+ }
65
+ continue;
66
+ }
67
+ // Handle newlines
68
+ if (char === "\n") {
69
+ commit(true);
70
+ }
71
+ // Handle single quote opening
72
+ if (char === "'") {
73
+ isInSingleQuotes = true;
74
+ continue;
75
+ }
76
+ // Handle double quote opening
77
+ if (char === '"') {
78
+ isInDoubleQuotes = true;
79
+ continue;
18
80
  }
81
+ // Handle value
82
+ currentValue += char;
19
83
  }
84
+ // Handle EOF
85
+ commit(true);
20
86
  return env;
21
87
  };
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { parseEnv } from "./parseEnv.js";
3
+ describe("parseEnv", () => {
4
+ test("should parse a simple key-value pair", () => {
5
+ const env = parseEnv("FOO=bar");
6
+ expect(env.FOO).toBe("bar");
7
+ });
8
+ test("should parse a key-value pair with a space", () => {
9
+ const env = parseEnv("FOO = bar");
10
+ expect(env.FOO).toBe("bar");
11
+ });
12
+ test("should parse a complex env file", () => {
13
+ const env = parseEnv(`FOO="
14
+ bar"
15
+ BAR='baz
16
+ foo
17
+ 'BAZ=123
18
+
19
+ # Comment here "!#' foo
20
+
21
+ HELLO = WORLD
22
+ `);
23
+ expect(env.FOO).toBe("\nbar");
24
+ expect(env.BAR).toBe("baz\nfoo\n");
25
+ expect(env.BAZ).toBe("123");
26
+ expect(env.HELLO).toBe("WORLD");
27
+ });
28
+ });
package/dist/program.js CHANGED
@@ -6,9 +6,9 @@ import { configCommand } from "./commands/config.js";
6
6
  import { debugCommand } from "./commands/debug.js";
7
7
  import { editCommand } from "./commands/edit.js";
8
8
  import { initCommand } from "./commands/init.js";
9
+ import { keyExportCommand } from "./commands/key/export.js";
10
+ import { keyImportCommand } from "./commands/key/import.js";
9
11
  import { runCommand } from "./commands/run.js";
10
- import { tokenExportCommand } from "./commands/token/export.js";
11
- import { tokenImportCommand } from "./commands/token/import.js";
12
12
  const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = path.dirname(__filename);
14
14
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../package.json"), "utf8"));
@@ -30,15 +30,15 @@ program
30
30
  .addOption(new Option("-e, --environment <environment>", "the environment to run the command in"))
31
31
  .description("run a command in an environment")
32
32
  .action(runCommand);
33
- const token = program.command("token").description("Manage stored tokens");
34
- token
35
- .command("import <environment> <token>")
36
- .description("import a token for an environment")
37
- .action(tokenImportCommand);
38
- token
33
+ const key = program.command("key").description("Manage stored keys");
34
+ key
35
+ .command("import <environment> <key>")
36
+ .description("import a key for an environment")
37
+ .action(keyImportCommand);
38
+ key
39
39
  .command("export <environment>")
40
- .description("export a token from an environment")
41
- .action(tokenExportCommand);
40
+ .description("export a key from an environment")
41
+ .action(keyExportCommand);
42
42
  program
43
43
  .command("config <key> [value]")
44
44
  .addOption(new Option("-r, --remove", "remove a configuration key"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotenc/cli",
3
- "version": "0.1.4",
3
+ "version": "0.2.2",
4
4
  "description": "🔐 Secure, encrypted environment variables that live in your codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,10 +33,12 @@
33
33
  "@types/node": "^22.13.7",
34
34
  "tsc-alias": "^1.8.11",
35
35
  "tsx": "^4.19.3",
36
- "typescript": "^5.8.2"
36
+ "typescript": "^5.8.2",
37
+ "vitest": "^3.0.9"
37
38
  },
38
39
  "dependencies": {
39
40
  "@paralleldrive/cuid2": "^2.2.2",
41
+ "chalk": "^5.4.1",
40
42
  "commander": "^13.1.0",
41
43
  "inquirer": "^12.4.2",
42
44
  "zod": "^3.24.2"
@@ -44,6 +46,7 @@
44
46
  "scripts": {
45
47
  "dev": "tsx src/cli.ts",
46
48
  "start": "node dist/cli.js",
47
- "build": "tsc && tsc-alias"
49
+ "build": "tsc && tsc-alias",
50
+ "test": "vitest"
48
51
  }
49
52
  }
@@ -1,39 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import fs from "node:fs/promises";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { getProjectConfig } from "./projectConfig.js";
6
- export const getToken = async (environment) => {
7
- if (process.env.DOTENC_TOKEN) {
8
- return process.env.DOTENC_TOKEN;
9
- }
10
- const { projectId } = await getProjectConfig();
11
- const tokensFile = path.join(os.homedir(), ".dotenc", "tokens.json");
12
- if (existsSync(tokensFile)) {
13
- const tokens = JSON.parse(await fs.readFile(tokensFile, "utf-8"));
14
- return tokens[projectId][environment];
15
- }
16
- throw new Error("No token found. Please set the TOKEN environment variable or import the token using `dotenc import-token -e <environment> <token>`.");
17
- };
18
- /**
19
- * Adds or updates a token for a specific project and environment.
20
- */
21
- export const addToken = async (projectId, environment, token) => {
22
- const tokensFile = path.join(os.homedir(), ".dotenc", "tokens.json");
23
- // Ensure the tokens file exists
24
- if (!existsSync(tokensFile)) {
25
- await fs.mkdir(path.dirname(tokensFile), { recursive: true });
26
- await fs.writeFile(tokensFile, "{}", { mode: 0o600 }); // Create an empty JSON file with secure permissions
27
- }
28
- // Read the existing tokens
29
- const tokens = JSON.parse(await fs.readFile(tokensFile, "utf8"));
30
- // Add or update the token
31
- if (!tokens[projectId]) {
32
- tokens[projectId] = {};
33
- }
34
- tokens[projectId][environment] = token;
35
- // Write the updated tokens back to the file
36
- await fs.writeFile(tokensFile, JSON.stringify(tokens, null, 2), {
37
- mode: 0o600,
38
- });
39
- };