@akshxy/envgit 0.1.0 → 0.3.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 ADDED
@@ -0,0 +1,128 @@
1
+ # envgit
2
+
3
+ Encrypted per-project environment variable manager. Store secrets safely alongside your code — encrypted at rest, never in plaintext.
4
+
5
+ ```
6
+ npm install -g @akshxy/envgit
7
+ ```
8
+
9
+ ## Why
10
+
11
+ `.env` files get committed by accident. `envgit` keeps your secrets encrypted in `.envgit/` so you can safely commit them to git and share them with your team — only people with the key can decrypt them.
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ # 1. Initialize in your project
17
+ envgit init
18
+
19
+ # 2. Set some variables
20
+ envgit set API_KEY=abc123 DB_PASS=secret
21
+
22
+ # 3. Commit the encrypted store (not the key!)
23
+ git add .envgit/
24
+ git commit -m "add encrypted env"
25
+
26
+ # 4. Share your key with teammates
27
+ envgit keygen --show
28
+ # → teammate runs: envgit keygen --set <key>
29
+
30
+ # 5. Write .env when you need it locally
31
+ envgit unpack dev
32
+ ```
33
+
34
+ ## Commands
35
+
36
+ ### Key management
37
+
38
+ | Command | Description |
39
+ |---------|-------------|
40
+ | `envgit init` | Initialize in current project, generates key |
41
+ | `envgit keygen` | Generate a new key and save it |
42
+ | `envgit keygen --show` | Print current key for sharing with teammates |
43
+ | `envgit keygen --set <key>` | Save a key received from a teammate |
44
+ | `envgit rotate-key` | Generate new key, re-encrypt all environments |
45
+ | `envgit verify` | Check all environments decrypt correctly |
46
+
47
+ ### Variables
48
+
49
+ | Command | Description |
50
+ |---------|-------------|
51
+ | `envgit set KEY=VALUE ...` | Set one or more variables |
52
+ | `envgit get KEY` | Print a single value |
53
+ | `envgit delete KEY` | Remove a variable |
54
+ | `envgit rename OLD NEW` | Rename a variable |
55
+ | `envgit list` | List all keys in active environment |
56
+ | `envgit list --show-values` | List keys and values |
57
+
58
+ ### Environments
59
+
60
+ | Command | Description |
61
+ |---------|-------------|
62
+ | `envgit envs` | List all environments with variable counts |
63
+ | `envgit add-env <name>` | Create a new environment |
64
+ | `envgit unpack <env>` | Decrypt env and write `.env` file |
65
+ | `envgit copy KEY --from dev --to prod` | Copy a variable between environments |
66
+ | `envgit diff dev prod` | Show differences between two environments |
67
+ | `envgit diff dev prod --show-values` | Include values in diff |
68
+
69
+ ### Export & run
70
+
71
+ | Command | Description |
72
+ |---------|-------------|
73
+ | `envgit export` | Print as `KEY=VALUE` lines (pipeable) |
74
+ | `envgit export --format json` | Print as JSON |
75
+ | `envgit export --format shell` | Print as `export KEY="VALUE"` (eval-able) |
76
+ | `envgit run -- node server.js` | Run a command with env vars injected |
77
+ | `envgit import --file .env.local` | Encrypt an existing `.env` file |
78
+
79
+ ### Status
80
+
81
+ | Command | Description |
82
+ |---------|-------------|
83
+ | `envgit status` | Show project, active env, key source, .env state |
84
+
85
+ ## All options support `--env <name>`
86
+
87
+ Most commands default to the active environment. Pass `--env` to target a specific one:
88
+
89
+ ```bash
90
+ envgit set API_KEY=prod-key --env prod
91
+ envgit list --env staging
92
+ envgit export --env prod --format json
93
+ ```
94
+
95
+ ## Team workflow
96
+
97
+ ```bash
98
+ # Developer A (project owner)
99
+ envgit init
100
+ envgit set DB_URL=postgres://... API_KEY=...
101
+ git add .envgit/ && git commit -m "encrypted env"
102
+ envgit keygen --show # share this key securely with teammates
103
+
104
+ # Developer B (teammate)
105
+ git clone <repo>
106
+ envgit keygen --set <key-from-teammate>
107
+ envgit unpack dev # writes .env
108
+ ```
109
+
110
+ ## Security
111
+
112
+ - **AES-256-GCM** — authenticated encryption, tamper-proof
113
+ - **32-byte random key** from OS cryptographic RNG
114
+ - **Fresh random IV** per encryption — same value encrypts differently each time
115
+ - **Key file locked to `0o600`** — unreadable by other users
116
+ - **Permission check on load** — errors if `.envgit.key` is world-readable
117
+ - **Key bytes zeroized** from memory immediately after use
118
+
119
+ The key (`~/.envgit.key`) is gitignored automatically. Never commit it.
120
+
121
+ ## Environment variable
122
+
123
+ Set `ENVGIT_KEY` instead of using a key file — useful in CI:
124
+
125
+ ```bash
126
+ export ENVGIT_KEY=$(cat .envgit.key)
127
+ envgit export --format shell | source /dev/stdin
128
+ ```
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@akshxy/envgit",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Encrypted per-project environment variable manager",
5
5
  "type": "module",
6
6
  "bin": {
7
- "envgit": "./bin/envgit.js"
7
+ "envgit": "bin/envgit.js"
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
- "src"
11
+ "src",
12
+ "README.md"
12
13
  ],
13
14
  "license": "MIT",
14
15
  "dependencies": {
@@ -1,6 +1,7 @@
1
1
  import { requireProjectRoot, loadKey } from '../keystore.js';
2
2
  import { loadConfig, saveConfig } from '../config.js';
3
3
  import { writeEncEnv } from '../enc.js';
4
+ import { ok, fatal } from '../ui.js';
4
5
 
5
6
  export async function addEnv(name) {
6
7
  const projectRoot = requireProjectRoot();
@@ -8,13 +9,12 @@ export async function addEnv(name) {
8
9
  const config = loadConfig(projectRoot);
9
10
 
10
11
  if (config.envs.includes(name)) {
11
- console.error(`Error: Environment '${name}' already exists.`);
12
- process.exit(1);
12
+ fatal(`Environment '${name}' already exists.`);
13
13
  }
14
14
 
15
15
  config.envs.push(name);
16
16
  saveConfig(projectRoot, config);
17
17
  writeEncEnv(projectRoot, name, key, {});
18
18
 
19
- console.log(`Added environment '${name}'.`);
19
+ ok(`Added environment '${name}'`);
20
20
  }
@@ -2,6 +2,7 @@ import { requireProjectRoot, loadKey } from '../keystore.js';
2
2
  import { resolveEnv } from '../config.js';
3
3
  import { readEncEnv } from '../enc.js';
4
4
  import { getCurrentEnv } from '../state.js';
5
+ import { fatal, label } from '../ui.js';
5
6
 
6
7
  export async function get(key, options) {
7
8
  const projectRoot = requireProjectRoot();
@@ -11,8 +12,7 @@ export async function get(key, options) {
11
12
  const vars = readEncEnv(projectRoot, envName, encKey);
12
13
 
13
14
  if (!(key in vars)) {
14
- console.error(`Error: Key '${key}' not found in [${envName}]`);
15
- process.exit(1);
15
+ fatal(`Key '${key}' not found in ${label(envName)}`);
16
16
  }
17
17
 
18
18
  console.log(vars[key]);
@@ -5,6 +5,7 @@ import { resolveEnv } from '../config.js';
5
5
  import { writeEncEnv } from '../enc.js';
6
6
  import { readEnvFile } from '../envfile.js';
7
7
  import { getCurrentEnv } from '../state.js';
8
+ import { ok, fatal, label, dim } from '../ui.js';
8
9
 
9
10
  export async function importEnv(options) {
10
11
  const projectRoot = requireProjectRoot();
@@ -13,15 +14,12 @@ export async function importEnv(options) {
13
14
 
14
15
  const filePath = join(projectRoot, options.file);
15
16
  if (!existsSync(filePath)) {
16
- console.error(`Error: File not found: ${options.file}`);
17
- process.exit(1);
17
+ fatal(`File not found: ${options.file}`);
18
18
  }
19
19
 
20
20
  const vars = readEnvFile(filePath);
21
21
  const count = Object.keys(vars).length;
22
22
 
23
23
  writeEncEnv(projectRoot, envName, key, vars);
24
- console.log(
25
- `Imported ${count} variable${count !== 1 ? 's' : ''} from ${options.file} into [${envName}]`
26
- );
24
+ ok(`Imported ${count} variable${count !== 1 ? 's' : ''} from ${dim(options.file)} into ${label(envName)}`);
27
25
  }
@@ -1,10 +1,11 @@
1
1
  import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
+ import { randomUUID } from 'crypto';
3
4
  import { generateKey } from '../crypto.js';
4
- import { saveKey } from '../keystore.js';
5
+ import { saveKey, globalKeyPath } from '../keystore.js';
5
6
  import { saveConfig, getEnvctlDir } from '../config.js';
6
7
  import { writeEncEnv } from '../enc.js';
7
- import { ok, warn, fatal, bold, dim, label } from '../ui.js';
8
+ import { ok, fatal, bold, dim, label } from '../ui.js';
8
9
 
9
10
  export async function init(options) {
10
11
  const projectRoot = process.cwd();
@@ -15,18 +16,21 @@ export async function init(options) {
15
16
  }
16
17
 
17
18
  const defaultEnv = options.env;
19
+ const keyId = randomUUID();
18
20
 
19
21
  mkdirSync(envgitDir, { recursive: true });
20
22
 
21
- const key = generateKey();
22
- saveKey(projectRoot, key);
23
-
23
+ // Save config first (saveKey needs key_id from config)
24
24
  saveConfig(projectRoot, {
25
25
  version: 1,
26
26
  default_env: defaultEnv,
27
27
  envs: [defaultEnv],
28
+ key_id: keyId,
28
29
  });
29
30
 
31
+ const key = generateKey();
32
+ const keyPath = saveKey(projectRoot, key, keyId);
33
+
30
34
  writeEncEnv(projectRoot, defaultEnv, key, {});
31
35
 
32
36
  updateGitignore(projectRoot);
@@ -35,16 +39,19 @@ export async function init(options) {
35
39
  console.log(bold('envgit initialized'));
36
40
  console.log('');
37
41
  ok(`Default environment: ${label(defaultEnv)}`);
38
- ok('Key saved to .envgit.key');
42
+ ok(`Key stored at ${dim(keyPath)}`);
39
43
  console.log('');
40
- console.log(dim('Keep .envgit.key secret and do not commit it.'));
41
44
  console.log(dim('Commit .envgit/ to share encrypted environments with your team.'));
45
+ console.log(dim('Your key never touches the repo — it lives only on your machine.'));
46
+ console.log('');
47
+ console.log(`Share your key with teammates: ${bold('envgit keygen --show')}`);
48
+ console.log(`Teammates save it with: ${bold('envgit keygen --set <key>')}`);
42
49
  console.log('');
43
50
  }
44
51
 
45
52
  function updateGitignore(projectRoot) {
46
53
  const gitignorePath = join(projectRoot, '.gitignore');
47
- const entries = ['.env', '.envgit.key'];
54
+ const entries = ['.env'];
48
55
 
49
56
  let existing = '';
50
57
  if (existsSync(gitignorePath)) {
@@ -1,21 +1,20 @@
1
1
  import { generateKey } from '../crypto.js';
2
- import { findProjectRoot, saveKey, loadKey } from '../keystore.js';
3
- import { ok, warn, bold, dim } from '../ui.js';
2
+ import { findProjectRoot, saveKey, loadKey, globalKeyPath } from '../keystore.js';
3
+ import { loadConfig } from '../config.js';
4
+ import { ok, warn, fatal, bold, dim } from '../ui.js';
4
5
 
5
6
  export async function keygen(options) {
6
7
  const projectRoot = findProjectRoot();
7
8
 
8
9
  if (options.show) {
9
10
  if (!projectRoot) {
10
- warn('No envgit project found — cannot show key.');
11
- process.exit(1);
11
+ fatal('No envgit project found — cannot show key.');
12
12
  }
13
13
  let key;
14
14
  try {
15
15
  key = loadKey(projectRoot);
16
16
  } catch (e) {
17
- warn(e.message);
18
- process.exit(1);
17
+ fatal(e.message);
19
18
  }
20
19
  const hint = key.slice(0, 8);
21
20
  console.log('');
@@ -23,8 +22,8 @@ export async function keygen(options) {
23
22
  console.log(` ${key}`);
24
23
  console.log('');
25
24
  console.log(dim(`Hint (first 8 chars): ${hint}`));
26
- console.log(dim('Share this key with teammates via a secure channel (not git).'));
27
- console.log(dim('They can save it with: envgit keygen --set <key>'));
25
+ console.log(dim('Share via a secure channel (not git, not chat).'));
26
+ console.log(dim('Teammate saves it with: envgit keygen --set <key>'));
28
27
  console.log('');
29
28
  return;
30
29
  }
@@ -33,16 +32,18 @@ export async function keygen(options) {
33
32
  const key = options.set;
34
33
  const decoded = Buffer.from(key, 'base64');
35
34
  if (decoded.length !== 32) {
36
- warn(`Invalid key — must decode to exactly 32 bytes (got ${decoded.length}). Generate one with: envgit keygen`);
37
- process.exit(1);
35
+ fatal(`Invalid key — must decode to exactly 32 bytes (got ${decoded.length}). Generate one with: envgit keygen`);
38
36
  }
39
37
  if (!projectRoot) {
40
- warn('No envgit project found. Run envgit init first.');
41
- process.exit(1);
38
+ fatal('No envgit project found. Run envgit init first, or clone a repo that uses envgit.');
42
39
  }
43
- saveKey(projectRoot, key);
44
- ok(`Key saved to .envgit.key`);
45
- console.log(dim(`Hint: ${key.slice(0, 8)}`));
40
+ const keyPath = saveKey(projectRoot, key);
41
+ ok(`Key saved for this project`);
42
+ console.log(dim(` Stored at: ${keyPath}`));
43
+ console.log(dim(` Hint: ${key.slice(0, 8)}`));
44
+ console.log('');
45
+ console.log(dim('Run `envgit verify` to confirm it works.'));
46
+ console.log('');
46
47
  return;
47
48
  }
48
49
 
@@ -51,8 +52,9 @@ export async function keygen(options) {
51
52
  const hint = key.slice(0, 8);
52
53
 
53
54
  if (projectRoot) {
54
- saveKey(projectRoot, key);
55
- ok('New key generated and saved to .envgit.key');
55
+ const keyPath = saveKey(projectRoot, key);
56
+ ok(`New key generated`);
57
+ console.log(dim(` Stored at: ${keyPath}`));
56
58
  } else {
57
59
  console.log('');
58
60
  console.log(bold('Generated key (no project found — not saved):'));
@@ -63,7 +65,7 @@ export async function keygen(options) {
63
65
  console.log(` ${key}`);
64
66
  console.log('');
65
67
  console.log(dim(`Hint (first 8 chars): ${hint}`));
66
- console.log(dim('Share this key with teammates via a secure channel (not git).'));
67
- console.log(dim('They can save it with: envgit keygen --set <key>'));
68
+ console.log(dim('Share via a secure channel (not git, not chat).'));
69
+ console.log(dim('Teammate saves it with: envgit keygen --set <key>'));
68
70
  console.log('');
69
71
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { requireProjectRoot } from '../keystore.js';
3
+ import { requireProjectRoot, globalKeyPath } from '../keystore.js';
4
4
  import { loadConfig } from '../config.js';
5
5
  import { getCurrentEnv } from '../state.js';
6
6
  import chalk from 'chalk';
@@ -11,11 +11,19 @@ export async function status() {
11
11
  const config = loadConfig(projectRoot);
12
12
  const current = getCurrentEnv(projectRoot);
13
13
 
14
- const keySource = process.env.ENVCTL_KEY
15
- ? 'ENVCTL_KEY env var'
16
- : existsSync(join(projectRoot, '.envgit.key'))
17
- ? '.envgit.key'
18
- : chalk.red('(not found)');
14
+ let keySource;
15
+ if (process.env.ENVGIT_KEY) {
16
+ keySource = chalk.green('ENVGIT_KEY') + dim(' (env var)');
17
+ } else if (config.key_id) {
18
+ const keyPath = globalKeyPath(config.key_id);
19
+ keySource = existsSync(keyPath)
20
+ ? chalk.green('~/.config/envgit/keys/') + dim(config.key_id.slice(0, 8) + '…')
21
+ : chalk.red('not found on this machine') + dim(' — run: envgit keygen --set <key>');
22
+ } else {
23
+ // Legacy
24
+ const legacyPath = join(projectRoot, '.envgit.key');
25
+ keySource = existsSync(legacyPath) ? dim('.envgit.key (legacy)') : chalk.red('(not found)');
26
+ }
19
27
 
20
28
  const dotenvExists = existsSync(join(projectRoot, '.env'));
21
29
 
package/src/keystore.js CHANGED
@@ -1,16 +1,25 @@
1
- import { readFileSync, writeFileSync, chmodSync, existsSync, statSync } from 'fs';
1
+ import { readFileSync, writeFileSync, chmodSync, existsSync, statSync, mkdirSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
+ import { homedir } from 'os';
4
+ import { loadConfig } from './config.js';
3
5
 
4
- const KEY_FILE = '.envgit.key';
5
6
  const ENV_VAR = 'ENVGIT_KEY';
6
7
  const SECURE_MODE = 0o600;
7
8
 
9
+ function globalKeysDir() {
10
+ return join(homedir(), '.config', 'envgit', 'keys');
11
+ }
12
+
13
+ export function globalKeyPath(keyId) {
14
+ return join(globalKeysDir(), `${keyId}.key`);
15
+ }
16
+
8
17
  export function findProjectRoot(startDir = process.cwd()) {
9
18
  let dir = startDir;
10
19
  while (true) {
11
20
  if (existsSync(join(dir, '.envgit'))) return dir;
12
21
  const parent = dirname(dir);
13
- if (parent === dir) return null; // reached filesystem root
22
+ if (parent === dir) return null;
14
23
  dir = parent;
15
24
  }
16
25
  }
@@ -26,27 +35,53 @@ export function requireProjectRoot() {
26
35
 
27
36
  export function loadKey(projectRoot) {
28
37
  if (process.env[ENV_VAR]) return process.env[ENV_VAR];
29
- const keyPath = join(projectRoot, KEY_FILE);
30
- if (existsSync(keyPath)) {
31
- const stat = statSync(keyPath);
32
- const mode = stat.mode & 0o777;
33
- if (mode & 0o077) {
34
- console.error(
35
- `Error: ${KEY_FILE} has insecure permissions (${mode.toString(8)}). Run: chmod 600 ${KEY_FILE}`
36
- );
37
- process.exit(1);
38
+
39
+ const config = loadConfig(projectRoot);
40
+
41
+ if (config.key_id) {
42
+ const keyPath = globalKeyPath(config.key_id);
43
+ if (existsSync(keyPath)) {
44
+ enforcePermissions(keyPath);
45
+ return readFileSync(keyPath, 'utf8').trim();
38
46
  }
39
- return readFileSync(keyPath, 'utf8').trim();
47
+ // Project found, but this machine doesn't have the key yet
48
+ console.error(
49
+ `No key found for this project. Ask your team for the key, then run:\n envgit keygen --set <key>`
50
+ );
51
+ process.exit(1);
40
52
  }
41
- throw new Error(
42
- `No encryption key found. Add it to ${KEY_FILE} or set the ENVGIT_KEY environment variable.`
53
+
54
+ // Legacy fallback: .envgit.key in project root
55
+ const legacyPath = join(projectRoot, '.envgit.key');
56
+ if (existsSync(legacyPath)) {
57
+ enforcePermissions(legacyPath);
58
+ return readFileSync(legacyPath, 'utf8').trim();
59
+ }
60
+
61
+ console.error(
62
+ `No key found for this project. Ask your team for the key, then run:\n envgit keygen --set <key>`
43
63
  );
64
+ process.exit(1);
44
65
  }
45
66
 
46
- export function saveKey(projectRoot, key) {
47
- const keyPath = join(projectRoot, KEY_FILE);
67
+ export function saveKey(projectRoot, key, keyId) {
68
+ const dir = globalKeysDir();
69
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
70
+
71
+ const id = keyId ?? loadConfig(projectRoot).key_id;
72
+ const keyPath = globalKeyPath(id);
48
73
  writeFileSync(keyPath, key + '\n', { mode: SECURE_MODE });
49
- // Explicitly tighten permissions in case the file already existed —
50
- // writeFileSync's mode option only applies when creating a new file.
51
74
  chmodSync(keyPath, SECURE_MODE);
75
+ return keyPath;
76
+ }
77
+
78
+ function enforcePermissions(keyPath) {
79
+ const stat = statSync(keyPath);
80
+ const mode = stat.mode & 0o777;
81
+ if (mode & 0o077) {
82
+ console.error(
83
+ `Error: key file has insecure permissions (${mode.toString(8)}). Run: chmod 600 ${keyPath}`
84
+ );
85
+ process.exit(1);
86
+ }
52
87
  }
@@ -1,26 +0,0 @@
1
- import { join } from 'path';
2
- import { existsSync } from 'fs';
3
- import { requireProjectRoot, loadKey } from '../keystore.js';
4
- import { getEncPath } from '../config.js';
5
- import { readEncEnv } from '../enc.js';
6
- import { writeEnvFile } from '../envfile.js';
7
-
8
- export async function switchEnv(envName) {
9
- const projectRoot = requireProjectRoot();
10
- const key = loadKey(projectRoot);
11
-
12
- const encPath = getEncPath(projectRoot, envName);
13
- if (!existsSync(encPath)) {
14
- console.error(
15
- `Error: Environment '${envName}' does not exist. Use 'envgit add-env ${envName}' to create it.`
16
- );
17
- process.exit(1);
18
- }
19
-
20
- const vars = readEncEnv(projectRoot, envName, key);
21
- const dotenvPath = join(projectRoot, '.env');
22
- writeEnvFile(dotenvPath, vars);
23
-
24
- const count = Object.keys(vars).length;
25
- console.log(`Switched to [${envName}] — wrote ${count} variable${count !== 1 ? 's' : ''} to .env`);
26
- }