@dotenc/cli 0.2.3 → 0.3.1

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.
@@ -1,3 +1,4 @@
1
+ import chalk from "chalk";
1
2
  import { execSync } from "node:child_process";
2
3
  import { existsSync } from "node:fs";
3
4
  import fs from "node:fs/promises";
@@ -13,12 +14,17 @@ export const editCommand = async (environmentArg) => {
13
14
  if (!environment) {
14
15
  environment = await chooseEnvironmentPrompt("What environment do you want to edit?");
15
16
  }
16
- const environmentFilePath = path.join(process.cwd(), `.env.${environment}.enc`);
17
+ const environmentFile = `.env.${environment}.enc`;
18
+ const environmentFilePath = path.join(process.cwd(), environmentFile);
17
19
  if (!existsSync(environmentFilePath)) {
18
20
  console.error(`Environment file not found: ${environmentFilePath}`);
19
21
  return;
20
22
  }
21
23
  const key = await getKey(environment);
24
+ if (!key) {
25
+ console.error(`\n${chalk.red("Error:")} no key found for the ${chalk.cyan(environment)} environment.`);
26
+ return;
27
+ }
22
28
  const tempFilePath = path.join(os.tmpdir(), `.env.${environment}`);
23
29
  const content = await decrypt(key, environmentFilePath);
24
30
  await fs.writeFile(tempFilePath, content);
@@ -29,17 +35,17 @@ export const editCommand = async (environmentArg) => {
29
35
  execSync(`${editor} ${tempFilePath}`, { stdio: "inherit" });
30
36
  }
31
37
  catch (error) {
32
- console.error(`Failed to open editor: ${editor}`);
38
+ console.error(`\nFailed to open editor: ${editor}`);
33
39
  return;
34
40
  }
35
41
  const newContent = await fs.readFile(tempFilePath, "utf-8");
36
42
  const finalHash = createHash(newContent);
37
43
  if (initialHash === finalHash) {
38
- console.log(`No changes were made to the environment file for "${environment}".`);
44
+ console.log(`\nNo changes were made to the ${chalk.cyan(environment)} environment.`);
39
45
  }
40
46
  else {
41
47
  await encrypt(key, newContent, environmentFilePath);
42
- console.log(`Encrypted environment file for "${environment}" and saved it to ${environmentFilePath}.`);
48
+ console.log(`\nEncrypted ${chalk.cyan(environment)} environment and saved it to ${chalk.gray(environmentFile)}.`);
43
49
  }
44
50
  await fs.unlink(tempFilePath);
45
51
  };
@@ -1,7 +1,10 @@
1
+ import chalk from "chalk";
1
2
  import crypto from "node:crypto";
2
3
  import { createEnvironment } from "../helpers/createEnvironment.js";
3
4
  import { createLocalEnvironment } from "../helpers/createLocalEnvironment.js";
4
5
  import { createProject } from "../helpers/createProject.js";
6
+ import { environmentExists } from "../helpers/environmentExists.js";
7
+ import { getEnvironmentNameSuggestion } from "../helpers/getEnvironmentNameSuggestion.js";
5
8
  import { addKey } from "../helpers/key.js";
6
9
  import { createEnvironmentPrompt } from "../prompts/createEnvironment.js";
7
10
  export const initCommand = async (environmentArg) => {
@@ -14,16 +17,28 @@ export const initCommand = async (environmentArg) => {
14
17
  // Prompt for the environment name
15
18
  let environment = environmentArg;
16
19
  if (!environment) {
17
- environment = await createEnvironmentPrompt("What should the environment be named?", "development");
20
+ environment = await createEnvironmentPrompt("What should the environment be named?", getEnvironmentNameSuggestion());
21
+ }
22
+ if (!environment) {
23
+ console.log(`${chalk.red("Error:")} no environment name provided`);
24
+ return;
25
+ }
26
+ if (environmentExists(environment)) {
27
+ console.log(`${chalk.red("Error:")} environment ${environment} already exists. To edit it, use ${chalk.gray(`dotenc edit ${environment}`)}`);
28
+ return;
18
29
  }
19
30
  await createEnvironment(environment, key);
20
31
  // Store the key
21
32
  await addKey(projectId, environment, key);
22
33
  // Output success message
23
- console.log("Initialization complete!");
24
- console.log("Next steps:");
25
- console.log(`1. Use "dotenc edit ${environment}" to securely edit your environment variables.`);
26
- console.log(`2. Use "dotenc run -e ${environment} <command> [args...]" or "DOTENC_ENV=${environment} dotenc run <command> [args...]" to run your application.`);
27
- console.log('3. Use "dotenc init [environment]" to initialize a new environment.');
28
- console.log("4. Use the git-ignored .env file for local development. It will have priority over any encrypted environment variables.");
34
+ console.log(`${chalk.green("✔")} Initialization complete!`);
35
+ console.log("\nSome useful tips:");
36
+ const editCommand = chalk.gray(`dotenc edit ${environment}`);
37
+ console.log(`\n- To securely edit your environment:\t${editCommand}`);
38
+ const runCommand = chalk.gray(`dotenc run -e ${environment} <command> [args...]`);
39
+ const runCommandWithEnv = chalk.gray(`DOTENC_ENV=${environment} dotenc run <command> [args...]`);
40
+ console.log(`- To run your application:\t\t${runCommand} or ${runCommandWithEnv}`);
41
+ const initCommand = chalk.gray("dotenc init [environment]");
42
+ console.log(`- To initialize a new environment:\t${initCommand}`);
43
+ console.log(`- Use the git-ignored ${chalk.gray(".env")} file for local development. It will have priority over any encrypted environments.`);
29
44
  };
@@ -1,12 +1,21 @@
1
+ import chalk from "chalk";
1
2
  import { getKey } from "../../helpers/key.js";
2
3
  import { getProjectConfig } from "../../helpers/projectConfig.js";
4
+ import { chooseEnvironmentPrompt } from "../../prompts/chooseEnvironment.js";
3
5
  export const keyExportCommand = async (environmentArg) => {
4
- const environment = environmentArg;
5
6
  const { projectId } = await getProjectConfig();
6
7
  if (!projectId) {
7
8
  console.error('No project found. Run "dotenc init" to create one.');
8
9
  return;
9
10
  }
11
+ let environment = environmentArg;
12
+ if (!environment) {
13
+ environment = await chooseEnvironmentPrompt("What environment do you want to export the key from?");
14
+ }
10
15
  const key = await getKey(environment);
11
- console.log(`Key for the ${environment} environment: ${key}`);
16
+ if (!key) {
17
+ console.error(`\nNo key found for the ${chalk.cyan(environment)} environment.`);
18
+ return;
19
+ }
20
+ console.log(`\nKey for the ${chalk.cyan(environment)} environment: ${chalk.gray(key)}`);
12
21
  };
@@ -1,12 +1,22 @@
1
+ import chalk from "chalk";
1
2
  import { addKey } from "../../helpers/key.js";
2
3
  import { getProjectConfig } from "../../helpers/projectConfig.js";
3
- export const keyImportCommand = async (key, environmentArg) => {
4
- const environment = environmentArg;
4
+ import { chooseEnvironmentPrompt } from "../../prompts/chooseEnvironment.js";
5
+ import { inputKeyPrompt } from "../../prompts/inputKey.js";
6
+ export const keyImportCommand = async (environmentArg, keyArg) => {
5
7
  const { projectId } = await getProjectConfig();
6
8
  if (!projectId) {
7
9
  console.error('No project found. Run "dotenc init" to create one.');
8
10
  return;
9
11
  }
12
+ let environment = environmentArg;
13
+ if (!environment) {
14
+ environment = await chooseEnvironmentPrompt("What environment do you want to import the key to?");
15
+ }
16
+ let key = keyArg;
17
+ if (!key) {
18
+ key = await inputKeyPrompt("Paste the key here:");
19
+ }
10
20
  await addKey(projectId, environment, key);
11
- console.log(`Key imported to the ${environment} environment.`);
21
+ console.log(`\nKey imported to the ${chalk.cyan(environment)} environment.`);
12
22
  };
@@ -0,0 +1,35 @@
1
+ import chalk from "chalk";
2
+ import crypto from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { decrypt, encrypt } from "../../helpers/crypto.js";
6
+ import { addKey, getKey } from "../../helpers/key.js";
7
+ import { getProjectConfig } from "../../helpers/projectConfig.js";
8
+ import { chooseEnvironmentPrompt } from "../../prompts/chooseEnvironment.js";
9
+ export const keyRotateCommand = async (environmentArg) => {
10
+ const { projectId } = await getProjectConfig();
11
+ if (!projectId) {
12
+ console.error('No project found. Run "dotenc init" to create one.');
13
+ return;
14
+ }
15
+ let environment = environmentArg;
16
+ if (!environment) {
17
+ environment = await chooseEnvironmentPrompt("What environment do you want to rotate the key for?");
18
+ }
19
+ const environmentFile = `.env.${environment}.enc`;
20
+ const environmentFilePath = path.join(process.cwd(), environmentFile);
21
+ if (!existsSync(environmentFilePath)) {
22
+ console.error(`Environment file not found: ${environmentFilePath}`);
23
+ return;
24
+ }
25
+ const key = await getKey(environment);
26
+ if (!key) {
27
+ console.error(`\nNo key found for the ${chalk.cyan(environment)} environment.`);
28
+ return;
29
+ }
30
+ const content = await decrypt(key, environmentFilePath);
31
+ const newKey = crypto.randomBytes(32).toString("base64");
32
+ await encrypt(newKey, content, environmentFilePath);
33
+ await addKey(projectId, environment, newKey);
34
+ console.log(`\nKey rotated for the ${chalk.cyan(environment)} environment.`);
35
+ };
@@ -13,13 +13,13 @@ export const runCommand = async (command, args, options) => {
13
13
  return;
14
14
  }
15
15
  const environments = environmentArg.split(",");
16
- const decryptedEnvs = await Promise.all(environments.map(async (environment) => {
16
+ const decryptedEnvs = await Promise.all(environments.map(async (environment, index) => {
17
17
  const environmentFilePath = path.join(process.cwd(), `.env.${environment}.enc`);
18
18
  if (!existsSync(environmentFilePath)) {
19
19
  console.error(`Environment file not found: ${environmentFilePath}`);
20
20
  return;
21
21
  }
22
- const key = await getKey(environment);
22
+ const key = await getKey(environment, index);
23
23
  const content = await decrypt(key, environmentFilePath);
24
24
  const decryptedEnv = parseEnv(content);
25
25
  return decryptedEnv;
@@ -0,0 +1,6 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ export const environmentExists = (environment) => {
4
+ const envPath = path.join(process.cwd(), `.env.${environment}.enc`);
5
+ return existsSync(envPath);
6
+ };
@@ -0,0 +1,5 @@
1
+ import { environmentExists } from "./environmentExists.js";
2
+ export const getEnvironmentNameSuggestion = () => {
3
+ const suggestions = ["development", "staging", "production", "test"].find((env) => !environmentExists(env)) ?? "";
4
+ return suggestions;
5
+ };
@@ -3,23 +3,23 @@ import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { getProjectConfig } from "./projectConfig.js";
6
- export const getKey = async (environment) => {
6
+ const keysFile = path.join(os.homedir(), ".dotenc", "keys.json");
7
+ export const getKey = async (environment, index = 0) => {
7
8
  if (process.env.DOTENC_KEY) {
8
- return process.env.DOTENC_KEY;
9
+ const keys = process.env.DOTENC_KEY.split(",");
10
+ return keys[index];
9
11
  }
10
12
  const { projectId } = await getProjectConfig();
11
- const keysFile = path.join(os.homedir(), ".dotenc", "keys.json");
12
13
  if (existsSync(keysFile)) {
13
14
  const keys = JSON.parse(await fs.readFile(keysFile, "utf-8"));
14
15
  return keys[projectId][environment];
15
16
  }
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
+ throw new Error("No key found. Please set the DOTENC_KEY environment variable or import the key using `dotenc key import <environment> <key>`.");
17
18
  };
18
19
  /**
19
20
  * Adds or updates a key for a specific project and environment.
20
21
  */
21
22
  export const addKey = async (projectId, environment, key) => {
22
- const keysFile = path.join(os.homedir(), ".dotenc", "keys.json");
23
23
  // Ensure the keys file exists
24
24
  if (!existsSync(keysFile)) {
25
25
  await fs.mkdir(path.dirname(keysFile), { recursive: true });
@@ -70,11 +70,13 @@ export const parseEnv = (content) => {
70
70
  }
71
71
  // Handle single quote opening
72
72
  if (char === "'") {
73
+ currentValue = "";
73
74
  isInSingleQuotes = true;
74
75
  continue;
75
76
  }
76
77
  // Handle double quote opening
77
78
  if (char === '"') {
79
+ currentValue = "";
78
80
  isInDoubleQuotes = true;
79
81
  continue;
80
82
  }
package/dist/program.js CHANGED
@@ -8,6 +8,7 @@ import { editCommand } from "./commands/edit.js";
8
8
  import { initCommand } from "./commands/init.js";
9
9
  import { keyExportCommand } from "./commands/key/export.js";
10
10
  import { keyImportCommand } from "./commands/key/import.js";
11
+ import { keyRotateCommand } from "./commands/key/rotate.js";
11
12
  import { runCommand } from "./commands/run.js";
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = path.dirname(__filename);
@@ -18,29 +19,43 @@ if (process.env.NODE_ENV !== "production") {
18
19
  program.command("debug").description("debug the CLI").action(debugCommand);
19
20
  }
20
21
  program
21
- .command("init [environment]")
22
+ .command("init")
23
+ .argument("[environment]", "the environment to initialize")
22
24
  .description("initialize a new environment")
23
25
  .action(initCommand);
24
26
  program
25
- .command("edit [environment]")
27
+ .command("edit")
28
+ .argument("[environment]", "the environment to edit")
26
29
  .description("edit an environment")
27
30
  .action(editCommand);
28
31
  program
29
- .command("run <command> [args...]")
30
- .addOption(new Option("-e, --environment <environment>", "the environment to run the command in"))
32
+ .command("run")
33
+ .argument("<command>", "the command to run")
34
+ .argument("[args...]", "the arguments to pass to the command")
35
+ .addOption(new Option("-e, --env <env1>[,env2[,...]]", "the environments to run the command in"))
31
36
  .description("run a command in an environment")
32
37
  .action(runCommand);
33
- const key = program.command("key").description("Manage stored keys");
38
+ const key = program.command("key").description("manage stored keys");
34
39
  key
35
- .command("import <environment> <key>")
40
+ .command("import")
41
+ .argument("[environment]", "the environment to import the key to")
42
+ .argument("[key]", "the key to import")
36
43
  .description("import a key for an environment")
37
44
  .action(keyImportCommand);
38
45
  key
39
- .command("export <environment>")
46
+ .command("export")
47
+ .argument("[environment]", "the environment to export the key from")
40
48
  .description("export a key from an environment")
41
49
  .action(keyExportCommand);
50
+ key
51
+ .command("rotate")
52
+ .argument("[environment]", "the environment to rotate the key for")
53
+ .description("rotate a key for an environment")
54
+ .action(keyRotateCommand);
42
55
  program
43
- .command("config <key> [value]")
56
+ .command("config")
57
+ .argument("<key>", "the key to get or set")
58
+ .argument("[value]", "the value to set the key to")
44
59
  .addOption(new Option("-r, --remove", "remove a configuration key"))
45
60
  .description("manage global configuration")
46
61
  .action(configCommand);
@@ -0,0 +1,13 @@
1
+ import inquirer from "inquirer";
2
+ export const inputKeyPrompt = async (message, defaultValue) => {
3
+ const result = await inquirer.prompt([
4
+ {
5
+ type: "password",
6
+ name: "key",
7
+ mask: "*",
8
+ message,
9
+ default: defaultValue,
10
+ },
11
+ ]);
12
+ return result.key;
13
+ };
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
5
+ import { editCommand } from "../commands/edit.js";
6
+ import { initCommand } from "../commands/init.js";
7
+ import { keyRotateCommand } from "../commands/key/rotate.js";
8
+ import { runCommand } from "../commands/run.js";
9
+ import { getKey } from "../helpers/key.js";
10
+ import { cleanupProjectKeys } from "./helpers/cleanupProjectKeys.js";
11
+ import { waitForFile } from "./helpers/waitForFile.js";
12
+ const localEnvFilePath = path.join(process.cwd(), ".env");
13
+ const encryptedEnvFilePath = path.join(process.cwd(), ".env.test.enc");
14
+ const projectFilePath = path.join(process.cwd(), "dotenc.json");
15
+ const outputFilePath = path.join(process.cwd(), "e2e.txt");
16
+ vi.mock("node:child_process", async (importOriginal) => {
17
+ const actual = await importOriginal();
18
+ return {
19
+ ...actual,
20
+ // Mock for the edit command
21
+ execSync: () => {
22
+ const tempFilePath = path.join(os.tmpdir(), ".env.test");
23
+ writeFileSync(tempFilePath, "DOTENC_HELLO=Hello, world!");
24
+ },
25
+ };
26
+ });
27
+ describe("e2e", () => {
28
+ beforeAll(() => {
29
+ vi.spyOn(console, "log").mockImplementation(() => { });
30
+ vi.spyOn(process, "exit").mockImplementation(() => ({}));
31
+ });
32
+ test("should initialize an environment", async () => {
33
+ await initCommand("test");
34
+ expect(existsSync(localEnvFilePath)).toBe(true);
35
+ expect(existsSync(encryptedEnvFilePath)).toBe(true);
36
+ expect(existsSync(projectFilePath)).toBe(true);
37
+ });
38
+ test("should edit an environment", async () => {
39
+ const initialContent = readFileSync(encryptedEnvFilePath, "utf-8");
40
+ await editCommand("test");
41
+ const editedContent = readFileSync(encryptedEnvFilePath, "utf-8");
42
+ expect(editedContent).not.toBe(initialContent);
43
+ });
44
+ test("should rotate a key", async () => {
45
+ const currentKey = await getKey("test");
46
+ await keyRotateCommand("test");
47
+ const rotatedKey = await getKey("test");
48
+ expect(rotatedKey).not.toBe(currentKey);
49
+ });
50
+ test("should run a command in an environment", async () => {
51
+ await runCommand("sh", [path.join(__dirname, "helpers", "e2e.sh")], {
52
+ env: "test",
53
+ });
54
+ const output = await waitForFile(outputFilePath);
55
+ expect(output).toBe("Hello, world!\n");
56
+ });
57
+ afterAll(async () => {
58
+ await cleanupProjectKeys();
59
+ unlinkSync(localEnvFilePath);
60
+ unlinkSync(encryptedEnvFilePath);
61
+ unlinkSync(projectFilePath);
62
+ unlinkSync(outputFilePath);
63
+ });
64
+ });
@@ -0,0 +1,13 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { getProjectConfig } from "../../helpers/projectConfig.js";
5
+ export const cleanupProjectKeys = async () => {
6
+ const { projectId } = await getProjectConfig();
7
+ const keysFile = path.join(os.homedir(), ".dotenc", "keys.json");
8
+ if (existsSync(keysFile)) {
9
+ const keys = JSON.parse(readFileSync(keysFile, "utf-8"));
10
+ delete keys[projectId];
11
+ writeFileSync(keysFile, JSON.stringify(keys, null, 2));
12
+ }
13
+ };
@@ -0,0 +1,15 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ export const waitForFile = (filePath, timeout = 5000) => new Promise((resolve, reject) => {
3
+ const startTime = Date.now();
4
+ const interval = setInterval(() => {
5
+ if (existsSync(filePath)) {
6
+ clearInterval(interval);
7
+ resolve(readFileSync(filePath, "utf-8"));
8
+ return;
9
+ }
10
+ if (Date.now() - startTime > timeout) {
11
+ clearInterval(interval);
12
+ reject(new Error(`Timeout waiting for file ${filePath}`));
13
+ }
14
+ }, 100);
15
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { parseEnv } from "./parseEnv.js";
2
+ import { parseEnv } from "../helpers/parseEnv.js";
3
3
  describe("parseEnv", () => {
4
4
  test("should parse a simple key-value pair", () => {
5
5
  const env = parseEnv("FOO=bar");
@@ -19,10 +19,12 @@ foo
19
19
  # Comment here "!#' foo
20
20
 
21
21
  HELLO = WORLD
22
+ DOTENC_HELLO = "Hello, world!"
22
23
  `);
23
24
  expect(env.FOO).toBe("\nbar");
24
25
  expect(env.BAR).toBe("baz\nfoo\n");
25
26
  expect(env.BAZ).toBe("123");
26
27
  expect(env.HELLO).toBe("WORLD");
28
+ expect(env.DOTENC_HELLO).toBe("Hello, world!");
27
29
  });
28
30
  });
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "@dotenc/cli",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "🔐 Secure, encrypted environment variables that live in your codebase",
5
+ "author": "Ivan Filho <i@ivanfilho.com>",
6
+ "license": "MIT",
5
7
  "type": "module",
6
8
  "bin": {
7
9
  "dotenc": "./dist/cli.js"
@@ -9,6 +11,29 @@
9
11
  "files": [
10
12
  "dist"
11
13
  ],
14
+ "devDependencies": {
15
+ "@biomejs/biome": "^1.9.4",
16
+ "@types/node": "^22.13.7",
17
+ "tsc-alias": "^1.8.11",
18
+ "tsx": "^4.19.3",
19
+ "typescript": "^5.8.2",
20
+ "vitest": "^3.0.9"
21
+ },
22
+ "dependencies": {
23
+ "@paralleldrive/cuid2": "^2.2.2",
24
+ "chalk": "^5.4.1",
25
+ "commander": "^13.1.0",
26
+ "inquirer": "^12.4.2",
27
+ "zod": "^3.24.2"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/ivanfilhoz/dotenc.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/ivanfilhoz/dotenc/issues"
35
+ },
36
+ "homepage": "https://github.com/ivanfilhoz/dotenc#readme",
12
37
  "keywords": [
13
38
  "environment",
14
39
  "variables",
@@ -26,23 +51,6 @@
26
51
  "encrypted",
27
52
  "codebase"
28
53
  ],
29
- "author": "Ivan Filho <i@ivanfilho.com>",
30
- "license": "MIT",
31
- "devDependencies": {
32
- "@biomejs/biome": "^1.9.4",
33
- "@types/node": "^22.13.7",
34
- "tsc-alias": "^1.8.11",
35
- "tsx": "^4.19.3",
36
- "typescript": "^5.8.2",
37
- "vitest": "^3.0.9"
38
- },
39
- "dependencies": {
40
- "@paralleldrive/cuid2": "^2.2.2",
41
- "chalk": "^5.4.1",
42
- "commander": "^13.1.0",
43
- "inquirer": "^12.4.2",
44
- "zod": "^3.24.2"
45
- },
46
54
  "scripts": {
47
55
  "dev": "tsx src/cli.ts",
48
56
  "start": "node dist/cli.js",