@akshxy/envgit 0.1.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/bin/envgit.js ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import { init } from '../src/commands/init.js';
4
+ import { set } from '../src/commands/set.js';
5
+ import { get } from '../src/commands/get.js';
6
+ import { unpack } from '../src/commands/unpack.js';
7
+ import { list } from '../src/commands/list.js';
8
+ import { importEnv } from '../src/commands/import.js';
9
+ import { addEnv } from '../src/commands/add-env.js';
10
+ import { status } from '../src/commands/status.js';
11
+ import { keygen } from '../src/commands/keygen.js';
12
+ import { deleteKey } from '../src/commands/delete.js';
13
+ import { copy } from '../src/commands/copy.js';
14
+ import { renameKey } from '../src/commands/rename-key.js';
15
+ import { diff } from '../src/commands/diff.js';
16
+ import { run } from '../src/commands/run.js';
17
+ import { envs } from '../src/commands/envs.js';
18
+ import { exportEnv } from '../src/commands/export.js';
19
+ import { verify } from '../src/commands/verify.js';
20
+ import { rotateKey } from '../src/commands/rotate-key.js';
21
+
22
+ program
23
+ .name('envgit')
24
+ .description('Encrypted per-project environment variable manager')
25
+ .version('0.1.0')
26
+ .enablePositionalOptions();
27
+
28
+ program
29
+ .command('init')
30
+ .description('Initialize envgit in the current project')
31
+ .option('--env <name>', 'default environment name', 'dev')
32
+ .action(init);
33
+
34
+ program
35
+ .command('status')
36
+ .description('Show project root, active env, key source, and .env state')
37
+ .action(status);
38
+
39
+ program
40
+ .command('set <assignments...>')
41
+ .description('Set one or more KEY=VALUE pairs (defaults to active env)')
42
+ .option('--env <name>', 'target environment')
43
+ .action(set);
44
+
45
+ program
46
+ .command('get <key>')
47
+ .description('Print a value by key (defaults to active env)')
48
+ .option('--env <name>', 'target environment')
49
+ .action(get);
50
+
51
+ program
52
+ .command('unpack <env>')
53
+ .alias('switch')
54
+ .alias('pull')
55
+ .description('Decrypt <env> and write a clean .env file, sets it as active')
56
+ .action(unpack);
57
+
58
+ program
59
+ .command('list')
60
+ .description('List keys in an environment (defaults to active env)')
61
+ .option('--env <name>', 'target environment')
62
+ .option('--show-values', 'print values alongside keys')
63
+ .action(list);
64
+
65
+ program
66
+ .command('import')
67
+ .description('Encrypt an existing .env file into an environment')
68
+ .option('--env <name>', 'target environment')
69
+ .option('--file <path>', 'source file to import', '.env')
70
+ .action(importEnv);
71
+
72
+ program
73
+ .command('add-env <name>')
74
+ .description('Add a new environment')
75
+ .action(addEnv);
76
+
77
+ program
78
+ .command('keygen')
79
+ .description('Generate or manage the encryption key')
80
+ .option('--show', 'print current key (for sharing with teammates)')
81
+ .option('--set <key>', 'save a received key to .envgit.key')
82
+ .action(keygen);
83
+
84
+ program
85
+ .command('delete <key>')
86
+ .description('Remove a key from the encrypted env')
87
+ .option('--env <name>', 'target environment')
88
+ .action(deleteKey);
89
+
90
+ program
91
+ .command('copy <key>')
92
+ .description('Copy a key\'s value between two environments')
93
+ .requiredOption('--from <env>', 'source environment')
94
+ .requiredOption('--to <env>', 'destination environment')
95
+ .action(copy);
96
+
97
+ program
98
+ .command('rename <old-key> <new-key>')
99
+ .description('Rename a key within an environment')
100
+ .option('--env <name>', 'target environment')
101
+ .action(renameKey);
102
+
103
+ program
104
+ .command('diff [env1] [env2]')
105
+ .description('Show differences between two environments')
106
+ .option('--show-values', 'reveal values in diff output')
107
+ .action(diff);
108
+
109
+ program
110
+ .command('run [args...]')
111
+ .description('Spawn a command with decrypted env vars injected')
112
+ .option('--env <name>', 'environment to use')
113
+ .allowUnknownOption()
114
+ .passThroughOptions()
115
+ .action(run);
116
+
117
+ program
118
+ .command('envs')
119
+ .description('List all environments with variable counts')
120
+ .action(envs);
121
+
122
+ program
123
+ .command('export')
124
+ .description('Print decrypted vars to stdout (dotenv, json, or shell format)')
125
+ .option('--env <name>', 'target environment')
126
+ .option('--format <fmt>', 'output format: dotenv, json, shell', 'dotenv')
127
+ .action(exportEnv);
128
+
129
+ program
130
+ .command('verify')
131
+ .description('Attempt to decrypt every .enc file with the current key')
132
+ .action(verify);
133
+
134
+ program
135
+ .command('rotate-key')
136
+ .description('Generate a new key and re-encrypt all environments')
137
+ .action(rotateKey);
138
+
139
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@akshxy/envgit",
3
+ "version": "0.1.0",
4
+ "description": "Encrypted per-project environment variable manager",
5
+ "type": "module",
6
+ "bin": {
7
+ "envgit": "./bin/envgit.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "license": "MIT",
14
+ "dependencies": {
15
+ "chalk": "^5.6.2",
16
+ "commander": "^12.0.0",
17
+ "js-yaml": "^4.1.0"
18
+ },
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ }
22
+ }
@@ -0,0 +1,20 @@
1
+ import { requireProjectRoot, loadKey } from '../keystore.js';
2
+ import { loadConfig, saveConfig } from '../config.js';
3
+ import { writeEncEnv } from '../enc.js';
4
+
5
+ export async function addEnv(name) {
6
+ const projectRoot = requireProjectRoot();
7
+ const key = loadKey(projectRoot);
8
+ const config = loadConfig(projectRoot);
9
+
10
+ if (config.envs.includes(name)) {
11
+ console.error(`Error: Environment '${name}' already exists.`);
12
+ process.exit(1);
13
+ }
14
+
15
+ config.envs.push(name);
16
+ saveConfig(projectRoot, config);
17
+ writeEncEnv(projectRoot, name, key, {});
18
+
19
+ console.log(`Added environment '${name}'.`);
20
+ }
@@ -0,0 +1,24 @@
1
+ import { requireProjectRoot, loadKey } from '../keystore.js';
2
+ import { readEncEnv, writeEncEnv } from '../enc.js';
3
+ import { ok, fatal, label } from '../ui.js';
4
+
5
+ export async function copy(keyName, options) {
6
+ if (!options.from || !options.to) {
7
+ fatal('Both --from and --to environments are required.');
8
+ }
9
+
10
+ const projectRoot = requireProjectRoot();
11
+ const key = loadKey(projectRoot);
12
+
13
+ const srcVars = readEncEnv(projectRoot, options.from, key);
14
+
15
+ if (!(keyName in srcVars)) {
16
+ fatal(`Key '${keyName}' not found in ${label(options.from)}`);
17
+ }
18
+
19
+ const dstVars = readEncEnv(projectRoot, options.to, key);
20
+ dstVars[keyName] = srcVars[keyName];
21
+ writeEncEnv(projectRoot, options.to, key, dstVars);
22
+
23
+ ok(`Copied ${keyName} from ${label(options.from)} → ${label(options.to)}`);
24
+ }
@@ -0,0 +1,21 @@
1
+ import { requireProjectRoot, loadKey } from '../keystore.js';
2
+ import { resolveEnv } from '../config.js';
3
+ import { readEncEnv, writeEncEnv } from '../enc.js';
4
+ import { getCurrentEnv } from '../state.js';
5
+ import { ok, fatal, label } from '../ui.js';
6
+
7
+ export async function deleteKey(keyName, options) {
8
+ const projectRoot = requireProjectRoot();
9
+ const key = loadKey(projectRoot);
10
+ const envName = resolveEnv(projectRoot, options.env, getCurrentEnv(projectRoot));
11
+
12
+ const vars = readEncEnv(projectRoot, envName, key);
13
+
14
+ if (!(keyName in vars)) {
15
+ fatal(`Key '${keyName}' not found in ${label(envName)}`);
16
+ }
17
+
18
+ delete vars[keyName];
19
+ writeEncEnv(projectRoot, envName, key, vars);
20
+ ok(`Deleted ${keyName} from ${label(envName)}`);
21
+ }
@@ -0,0 +1,87 @@
1
+ import chalk from 'chalk';
2
+ import { requireProjectRoot, loadKey } from '../keystore.js';
3
+ import { loadConfig } from '../config.js';
4
+ import { readEncEnv } from '../enc.js';
5
+ import { getCurrentEnv } from '../state.js';
6
+ import { fatal, label, dim } from '../ui.js';
7
+
8
+ export async function diff(env1Arg, env2Arg, options) {
9
+ const projectRoot = requireProjectRoot();
10
+ const key = loadKey(projectRoot);
11
+ const config = loadConfig(projectRoot);
12
+ const current = getCurrentEnv(projectRoot);
13
+
14
+ let env1, env2;
15
+ if (env1Arg && env2Arg) {
16
+ env1 = env1Arg;
17
+ env2 = env2Arg;
18
+ } else if (env1Arg) {
19
+ env1 = current || config.default_env;
20
+ env2 = env1Arg;
21
+ } else {
22
+ if (config.envs.length < 2) {
23
+ fatal('Need at least 2 environments to diff. Pass two env names.');
24
+ }
25
+ [env1, env2] = config.envs;
26
+ }
27
+
28
+ if (!config.envs.includes(env1)) fatal(`Environment '${env1}' does not exist.`);
29
+ if (!config.envs.includes(env2)) fatal(`Environment '${env2}' does not exist.`);
30
+
31
+ const vars1 = readEncEnv(projectRoot, env1, key);
32
+ const vars2 = readEncEnv(projectRoot, env2, key);
33
+
34
+ const allKeys = new Set([...Object.keys(vars1), ...Object.keys(vars2)]);
35
+
36
+ const onlyIn1 = [];
37
+ const onlyIn2 = [];
38
+ const changed = [];
39
+ let identical = 0;
40
+
41
+ for (const k of allKeys) {
42
+ const inEnv1 = k in vars1;
43
+ const inEnv2 = k in vars2;
44
+ if (inEnv1 && !inEnv2) {
45
+ onlyIn1.push(k);
46
+ } else if (!inEnv1 && inEnv2) {
47
+ onlyIn2.push(k);
48
+ } else if (vars1[k] !== vars2[k]) {
49
+ changed.push(k);
50
+ } else {
51
+ identical++;
52
+ }
53
+ }
54
+
55
+ console.log(`\nDiff ${label(env1)} ↔ ${label(env2)}\n`);
56
+
57
+ if (onlyIn1.length === 0 && onlyIn2.length === 0 && changed.length === 0) {
58
+ console.log(dim(` Environments are identical (${identical} var${identical !== 1 ? 's' : ''})`));
59
+ console.log('');
60
+ return;
61
+ }
62
+
63
+ for (const k of onlyIn1) {
64
+ const suffix = options.showValues ? ` = ${vars1[k]}` : '';
65
+ console.log(chalk.red(` - ${k}${suffix}`) + dim(` (only in ${env1})`));
66
+ }
67
+
68
+ for (const k of onlyIn2) {
69
+ const suffix = options.showValues ? ` = ${vars2[k]}` : '';
70
+ console.log(chalk.green(` + ${k}${suffix}`) + dim(` (only in ${env2})`));
71
+ }
72
+
73
+ for (const k of changed) {
74
+ if (options.showValues) {
75
+ console.log(chalk.yellow(` ~ ${k}`) + dim(` (changed)`));
76
+ console.log(chalk.dim(` ${env1}: ${vars1[k]}`));
77
+ console.log(chalk.dim(` ${env2}: ${vars2[k]}`));
78
+ } else {
79
+ console.log(chalk.yellow(` ~ ${k}`) + dim(` (changed)`));
80
+ }
81
+ }
82
+
83
+ if (identical > 0) {
84
+ console.log(dim(`\n ${identical} key${identical !== 1 ? 's' : ''} identical`));
85
+ }
86
+ console.log('');
87
+ }
@@ -0,0 +1,25 @@
1
+ import chalk from 'chalk';
2
+ import { requireProjectRoot, loadKey } from '../keystore.js';
3
+ import { loadConfig } from '../config.js';
4
+ import { readEncEnv } from '../enc.js';
5
+ import { getCurrentEnv } from '../state.js';
6
+ import { dim } from '../ui.js';
7
+
8
+ export async function envs() {
9
+ const projectRoot = requireProjectRoot();
10
+ const key = loadKey(projectRoot);
11
+ const config = loadConfig(projectRoot);
12
+ const current = getCurrentEnv(projectRoot);
13
+
14
+ console.log('');
15
+ for (const envName of config.envs) {
16
+ const isActive = envName === current;
17
+ const bullet = isActive ? chalk.green('●') : ' ';
18
+ const vars = readEncEnv(projectRoot, envName, key);
19
+ const count = Object.keys(vars).length;
20
+ const countStr = dim(`(${count} var${count !== 1 ? 's' : ''})`);
21
+ const activeSuffix = isActive ? chalk.cyan(' (active)') : '';
22
+ console.log(` ${bullet} ${isActive ? chalk.bold(envName) : envName} ${countStr}${activeSuffix}`);
23
+ }
24
+ console.log('');
25
+ }
@@ -0,0 +1,31 @@
1
+ import { requireProjectRoot, loadKey } from '../keystore.js';
2
+ import { resolveEnv } from '../config.js';
3
+ import { readEncEnv } from '../enc.js';
4
+ import { getCurrentEnv } from '../state.js';
5
+ import { fatal } from '../ui.js';
6
+
7
+ export async function exportEnv(options) {
8
+ const projectRoot = requireProjectRoot();
9
+ const key = loadKey(projectRoot);
10
+ const envName = resolveEnv(projectRoot, options.env, getCurrentEnv(projectRoot));
11
+
12
+ const vars = readEncEnv(projectRoot, envName, key);
13
+ const format = options.format || 'dotenv';
14
+
15
+ if (format === 'json') {
16
+ process.stdout.write(JSON.stringify(vars, null, 2) + '\n');
17
+ } else if (format === 'shell') {
18
+ for (const [k, v] of Object.entries(vars)) {
19
+ const escaped = v.replace(/"/g, '\\"');
20
+ process.stdout.write(`export ${k}="${escaped}"\n`);
21
+ }
22
+ } else if (format === 'dotenv') {
23
+ for (const [k, v] of Object.entries(vars)) {
24
+ const needsQuotes = /[\s"'\\#]/.test(v) || v === '';
25
+ const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
26
+ process.stdout.write(`${k}=${needsQuotes ? `"${escaped}"` : v}\n`);
27
+ }
28
+ } else {
29
+ fatal(`Unknown format '${format}'. Use: dotenv, json, shell`);
30
+ }
31
+ }
@@ -0,0 +1,19 @@
1
+ import { requireProjectRoot, loadKey } from '../keystore.js';
2
+ import { resolveEnv } from '../config.js';
3
+ import { readEncEnv } from '../enc.js';
4
+ import { getCurrentEnv } from '../state.js';
5
+
6
+ export async function get(key, options) {
7
+ const projectRoot = requireProjectRoot();
8
+ const encKey = loadKey(projectRoot);
9
+ const envName = resolveEnv(projectRoot, options.env, getCurrentEnv(projectRoot));
10
+
11
+ const vars = readEncEnv(projectRoot, envName, encKey);
12
+
13
+ if (!(key in vars)) {
14
+ console.error(`Error: Key '${key}' not found in [${envName}]`);
15
+ process.exit(1);
16
+ }
17
+
18
+ console.log(vars[key]);
19
+ }
@@ -0,0 +1,27 @@
1
+ import { join } from 'path';
2
+ import { existsSync } from 'fs';
3
+ import { requireProjectRoot, loadKey } from '../keystore.js';
4
+ import { resolveEnv } from '../config.js';
5
+ import { writeEncEnv } from '../enc.js';
6
+ import { readEnvFile } from '../envfile.js';
7
+ import { getCurrentEnv } from '../state.js';
8
+
9
+ export async function importEnv(options) {
10
+ const projectRoot = requireProjectRoot();
11
+ const key = loadKey(projectRoot);
12
+ const envName = resolveEnv(projectRoot, options.env, getCurrentEnv(projectRoot));
13
+
14
+ const filePath = join(projectRoot, options.file);
15
+ if (!existsSync(filePath)) {
16
+ console.error(`Error: File not found: ${options.file}`);
17
+ process.exit(1);
18
+ }
19
+
20
+ const vars = readEnvFile(filePath);
21
+ const count = Object.keys(vars).length;
22
+
23
+ writeEncEnv(projectRoot, envName, key, vars);
24
+ console.log(
25
+ `Imported ${count} variable${count !== 1 ? 's' : ''} from ${options.file} into [${envName}]`
26
+ );
27
+ }
@@ -0,0 +1,59 @@
1
+ import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { generateKey } from '../crypto.js';
4
+ import { saveKey } from '../keystore.js';
5
+ import { saveConfig, getEnvctlDir } from '../config.js';
6
+ import { writeEncEnv } from '../enc.js';
7
+ import { ok, warn, fatal, bold, dim, label } from '../ui.js';
8
+
9
+ export async function init(options) {
10
+ const projectRoot = process.cwd();
11
+ const envgitDir = getEnvctlDir(projectRoot);
12
+
13
+ if (existsSync(envgitDir)) {
14
+ fatal('envgit is already initialized in this directory.');
15
+ }
16
+
17
+ const defaultEnv = options.env;
18
+
19
+ mkdirSync(envgitDir, { recursive: true });
20
+
21
+ const key = generateKey();
22
+ saveKey(projectRoot, key);
23
+
24
+ saveConfig(projectRoot, {
25
+ version: 1,
26
+ default_env: defaultEnv,
27
+ envs: [defaultEnv],
28
+ });
29
+
30
+ writeEncEnv(projectRoot, defaultEnv, key, {});
31
+
32
+ updateGitignore(projectRoot);
33
+
34
+ console.log('');
35
+ console.log(bold('envgit initialized'));
36
+ console.log('');
37
+ ok(`Default environment: ${label(defaultEnv)}`);
38
+ ok('Key saved to .envgit.key');
39
+ console.log('');
40
+ console.log(dim('Keep .envgit.key secret and do not commit it.'));
41
+ console.log(dim('Commit .envgit/ to share encrypted environments with your team.'));
42
+ console.log('');
43
+ }
44
+
45
+ function updateGitignore(projectRoot) {
46
+ const gitignorePath = join(projectRoot, '.gitignore');
47
+ const entries = ['.env', '.envgit.key'];
48
+
49
+ let existing = '';
50
+ if (existsSync(gitignorePath)) {
51
+ existing = readFileSync(gitignorePath, 'utf8');
52
+ }
53
+
54
+ const toAdd = entries.filter((e) => !existing.split('\n').includes(e));
55
+ if (toAdd.length > 0) {
56
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
57
+ appendFileSync(gitignorePath, prefix + toAdd.join('\n') + '\n');
58
+ }
59
+ }
@@ -0,0 +1,69 @@
1
+ import { generateKey } from '../crypto.js';
2
+ import { findProjectRoot, saveKey, loadKey } from '../keystore.js';
3
+ import { ok, warn, bold, dim } from '../ui.js';
4
+
5
+ export async function keygen(options) {
6
+ const projectRoot = findProjectRoot();
7
+
8
+ if (options.show) {
9
+ if (!projectRoot) {
10
+ warn('No envgit project found — cannot show key.');
11
+ process.exit(1);
12
+ }
13
+ let key;
14
+ try {
15
+ key = loadKey(projectRoot);
16
+ } catch (e) {
17
+ warn(e.message);
18
+ process.exit(1);
19
+ }
20
+ const hint = key.slice(0, 8);
21
+ console.log('');
22
+ console.log(bold('Current key:'));
23
+ console.log(` ${key}`);
24
+ console.log('');
25
+ 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>'));
28
+ console.log('');
29
+ return;
30
+ }
31
+
32
+ if (options.set) {
33
+ const key = options.set;
34
+ const decoded = Buffer.from(key, 'base64');
35
+ 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);
38
+ }
39
+ if (!projectRoot) {
40
+ warn('No envgit project found. Run envgit init first.');
41
+ process.exit(1);
42
+ }
43
+ saveKey(projectRoot, key);
44
+ ok(`Key saved to .envgit.key`);
45
+ console.log(dim(`Hint: ${key.slice(0, 8)}`));
46
+ return;
47
+ }
48
+
49
+ // Generate new key
50
+ const key = generateKey();
51
+ const hint = key.slice(0, 8);
52
+
53
+ if (projectRoot) {
54
+ saveKey(projectRoot, key);
55
+ ok('New key generated and saved to .envgit.key');
56
+ } else {
57
+ console.log('');
58
+ console.log(bold('Generated key (no project found — not saved):'));
59
+ }
60
+
61
+ console.log('');
62
+ console.log(bold('Key:'));
63
+ console.log(` ${key}`);
64
+ console.log('');
65
+ 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('');
69
+ }
@@ -0,0 +1,36 @@
1
+ import { requireProjectRoot, loadKey } from '../keystore.js';
2
+ import { resolveEnv, loadConfig } from '../config.js';
3
+ import { readEncEnv } from '../enc.js';
4
+ import { getCurrentEnv } from '../state.js';
5
+ import { fatal, label, dim, bold } from '../ui.js';
6
+
7
+ export async function list(options) {
8
+ const projectRoot = requireProjectRoot();
9
+ const key = loadKey(projectRoot);
10
+ const config = loadConfig(projectRoot);
11
+ const envName = resolveEnv(projectRoot, options.env, getCurrentEnv(projectRoot));
12
+
13
+ if (!config.envs.includes(envName)) {
14
+ fatal(`Environment '${envName}' does not exist. Available: ${config.envs.join(', ')}`);
15
+ }
16
+
17
+ const vars = readEncEnv(projectRoot, envName, key);
18
+ const entries = Object.entries(vars);
19
+
20
+ console.log('');
21
+ if (entries.length === 0) {
22
+ console.log(`${label(envName)} is empty.`);
23
+ console.log('');
24
+ return;
25
+ }
26
+
27
+ console.log(label(envName));
28
+ for (const [k, v] of entries) {
29
+ if (options.showValues) {
30
+ console.log(` ${bold(k)}=${dim(v)}`);
31
+ } else {
32
+ console.log(` ${k}`);
33
+ }
34
+ }
35
+ console.log('');
36
+ }
@@ -0,0 +1,29 @@
1
+ import { requireProjectRoot, loadKey } from '../keystore.js';
2
+ import { resolveEnv } from '../config.js';
3
+ import { readEncEnv, writeEncEnv } from '../enc.js';
4
+ import { getCurrentEnv } from '../state.js';
5
+ import { ok, fatal, label } from '../ui.js';
6
+
7
+ export async function renameKey(oldName, newName, options) {
8
+ const projectRoot = requireProjectRoot();
9
+ const key = loadKey(projectRoot);
10
+ const envName = resolveEnv(projectRoot, options.env, getCurrentEnv(projectRoot));
11
+
12
+ const vars = readEncEnv(projectRoot, envName, key);
13
+
14
+ if (!(oldName in vars)) {
15
+ fatal(`Key '${oldName}' not found in ${label(envName)}`);
16
+ }
17
+
18
+ if (newName in vars) {
19
+ fatal(`Key '${newName}' already exists in ${label(envName)}`);
20
+ }
21
+
22
+ const ordered = {};
23
+ for (const [k, v] of Object.entries(vars)) {
24
+ ordered[k === oldName ? newName : k] = v;
25
+ }
26
+
27
+ writeEncEnv(projectRoot, envName, key, ordered);
28
+ ok(`Renamed ${oldName} → ${newName} in ${label(envName)}`);
29
+ }
@@ -0,0 +1,39 @@
1
+ import { requireProjectRoot, loadKey, saveKey } from '../keystore.js';
2
+ import { loadConfig } from '../config.js';
3
+ import { readEncEnv, writeEncEnv } from '../enc.js';
4
+ import { generateKey } from '../crypto.js';
5
+ import { ok, warn, bold, dim } from '../ui.js';
6
+
7
+ export async function rotateKey() {
8
+ const projectRoot = requireProjectRoot();
9
+ const oldKey = loadKey(projectRoot);
10
+ const config = loadConfig(projectRoot);
11
+
12
+ const oldHint = oldKey.slice(0, 8);
13
+
14
+ const newKey = generateKey();
15
+ const newHint = newKey.slice(0, 8);
16
+
17
+ console.log('');
18
+ console.log(bold('Rotating encryption key...'));
19
+ console.log('');
20
+
21
+ for (const envName of config.envs) {
22
+ const vars = readEncEnv(projectRoot, envName, oldKey);
23
+ writeEncEnv(projectRoot, envName, newKey, vars);
24
+ ok(`Re-encrypted ${envName}`);
25
+ }
26
+
27
+ saveKey(projectRoot, newKey);
28
+
29
+ console.log('');
30
+ console.log(dim(`Old hint: ${oldHint}`));
31
+ console.log(dim(`New hint: ${newHint}`));
32
+ console.log('');
33
+ console.log(bold('New key:'));
34
+ console.log(` ${newKey}`);
35
+ console.log('');
36
+ warn('Share the new key with your team! Old key is now invalid.');
37
+ console.log(dim('They can save it with: envgit keygen --set <key>'));
38
+ console.log('');
39
+ }
@@ -0,0 +1,36 @@
1
+ import { spawn } from 'child_process';
2
+ import { requireProjectRoot, loadKey } from '../keystore.js';
3
+ import { resolveEnv } from '../config.js';
4
+ import { readEncEnv } from '../enc.js';
5
+ import { getCurrentEnv } from '../state.js';
6
+ import { fatal, dim } from '../ui.js';
7
+
8
+ export async function run(args, options) {
9
+ if (!args || args.length === 0) {
10
+ fatal('No command specified. Usage: envgit run [--env <name>] -- <command> [args...]');
11
+ }
12
+
13
+ const projectRoot = requireProjectRoot();
14
+ const key = loadKey(projectRoot);
15
+ const envName = resolveEnv(projectRoot, options.env, getCurrentEnv(projectRoot));
16
+
17
+ const vars = readEncEnv(projectRoot, envName, key);
18
+
19
+ const env = { ...process.env, ...vars };
20
+
21
+ const [cmd, ...cmdArgs] = args;
22
+
23
+ const child = spawn(cmd, cmdArgs, {
24
+ env,
25
+ stdio: 'inherit',
26
+ shell: false,
27
+ });
28
+
29
+ child.on('error', (err) => {
30
+ fatal(`Failed to start command '${cmd}': ${err.message}`);
31
+ });
32
+
33
+ child.on('close', (code) => {
34
+ process.exit(code ?? 0);
35
+ });
36
+ }
@@ -0,0 +1,26 @@
1
+ import { requireProjectRoot, loadKey } from '../keystore.js';
2
+ import { resolveEnv } from '../config.js';
3
+ import { readEncEnv, writeEncEnv } from '../enc.js';
4
+ import { getCurrentEnv } from '../state.js';
5
+ import { ok, fatal, label } from '../ui.js';
6
+
7
+ export async function set(assignments, options) {
8
+ const projectRoot = requireProjectRoot();
9
+ const key = loadKey(projectRoot);
10
+ const envName = resolveEnv(projectRoot, options.env, getCurrentEnv(projectRoot));
11
+
12
+ const vars = readEncEnv(projectRoot, envName, key);
13
+
14
+ for (const assignment of assignments) {
15
+ const eqIdx = assignment.indexOf('=');
16
+ if (eqIdx === -1) {
17
+ fatal(`Invalid assignment '${assignment}'. Expected KEY=VALUE format.`);
18
+ }
19
+ const k = assignment.slice(0, eqIdx).trim();
20
+ const v = assignment.slice(eqIdx + 1);
21
+ vars[k] = v;
22
+ ok(`Set ${k} in ${label(envName)}`);
23
+ }
24
+
25
+ writeEncEnv(projectRoot, envName, key, vars);
26
+ }
@@ -0,0 +1,30 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { requireProjectRoot } from '../keystore.js';
4
+ import { loadConfig } from '../config.js';
5
+ import { getCurrentEnv } from '../state.js';
6
+ import chalk from 'chalk';
7
+ import { bold, label, dim } from '../ui.js';
8
+
9
+ export async function status() {
10
+ const projectRoot = requireProjectRoot();
11
+ const config = loadConfig(projectRoot);
12
+ const current = getCurrentEnv(projectRoot);
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)');
19
+
20
+ const dotenvExists = existsSync(join(projectRoot, '.env'));
21
+
22
+ console.log('');
23
+ console.log(`${bold('Project:')} ${dim(projectRoot)}`);
24
+ console.log(`${bold('Envs:')} ${config.envs.map((e) => (e === current ? chalk.cyan(e) : e)).join(', ')}`);
25
+ console.log(`${bold('Default:')} ${config.default_env}`);
26
+ console.log(`${bold('Active:')} ${current ? label(current) : dim('(none — run envgit unpack <env>)')}`);
27
+ console.log(`${bold('Key:')} ${keySource}`);
28
+ console.log(`${bold('.env:')} ${dotenvExists ? chalk.green('present') : chalk.yellow('missing')}`);
29
+ console.log('');
30
+ }
@@ -0,0 +1,26 @@
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
+ }
@@ -0,0 +1,27 @@
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
+ import { setCurrentEnv } from '../state.js';
8
+ import { ok, fatal, label, dim } from '../ui.js';
9
+
10
+ export async function unpack(envName) {
11
+ const projectRoot = requireProjectRoot();
12
+ const key = loadKey(projectRoot);
13
+
14
+ const encPath = getEncPath(projectRoot, envName);
15
+ if (!existsSync(encPath)) {
16
+ fatal(`Environment '${envName}' does not exist. Use 'envgit add-env ${envName}' to create it.`);
17
+ }
18
+
19
+ const vars = readEncEnv(projectRoot, envName, key);
20
+ const dotenvPath = join(projectRoot, '.env');
21
+ writeEnvFile(dotenvPath, vars, { envName, projectRoot });
22
+ setCurrentEnv(projectRoot, envName);
23
+
24
+ const count = Object.keys(vars).length;
25
+ console.log(dim(projectRoot));
26
+ ok(`Unpacked ${label(envName)} → .env ${dim(`(${count} variable${count !== 1 ? 's' : ''})`)}`);
27
+ }
@@ -0,0 +1,35 @@
1
+ import { requireProjectRoot, loadKey } from '../keystore.js';
2
+ import { loadConfig, getEncPath } from '../config.js';
3
+ import { readEncEnv } from '../enc.js';
4
+ import { ok, fail, bold, dim } from '../ui.js';
5
+
6
+ export async function verify() {
7
+ const projectRoot = requireProjectRoot();
8
+ const key = loadKey(projectRoot);
9
+ const config = loadConfig(projectRoot);
10
+
11
+ console.log('');
12
+ console.log(bold('Verifying all environments...'));
13
+ console.log('');
14
+
15
+ let allOk = true;
16
+ for (const envName of config.envs) {
17
+ try {
18
+ const vars = readEncEnv(projectRoot, envName, key);
19
+ const count = Object.keys(vars).length;
20
+ ok(`${envName} ${dim(`(${count} var${count !== 1 ? 's' : ''})`)}`);
21
+ } catch (e) {
22
+ fail(`${envName} — ${e.message}`);
23
+ allOk = false;
24
+ }
25
+ }
26
+
27
+ console.log('');
28
+ if (allOk) {
29
+ ok('All environments verified successfully.');
30
+ } else {
31
+ fail('Some environments failed to decrypt. Check your key.');
32
+ process.exit(1);
33
+ }
34
+ console.log('');
35
+ }
package/src/config.js ADDED
@@ -0,0 +1,35 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import yaml from 'js-yaml';
4
+
5
+ const ENVCTL_DIR = '.envgit';
6
+ const CONFIG_NAME = 'config.yml';
7
+
8
+ export function getEnvctlDir(projectRoot) {
9
+ return join(projectRoot, ENVCTL_DIR);
10
+ }
11
+
12
+ export function loadConfig(projectRoot) {
13
+ const configPath = join(projectRoot, ENVCTL_DIR, CONFIG_NAME);
14
+ if (!existsSync(configPath)) {
15
+ throw new Error("No envgit config found. Run 'envgit init' first.");
16
+ }
17
+ return yaml.load(readFileSync(configPath, 'utf8'));
18
+ }
19
+
20
+ export function saveConfig(projectRoot, config) {
21
+ const dir = getEnvctlDir(projectRoot);
22
+ mkdirSync(dir, { recursive: true });
23
+ writeFileSync(join(dir, CONFIG_NAME), yaml.dump(config), 'utf8');
24
+ }
25
+
26
+ export function getEncPath(projectRoot, envName) {
27
+ return join(projectRoot, ENVCTL_DIR, `${envName}.enc`);
28
+ }
29
+
30
+ export function resolveEnv(projectRoot, envOption, currentEnv) {
31
+ if (envOption) return envOption;
32
+ if (currentEnv) return currentEnv;
33
+ const config = loadConfig(projectRoot);
34
+ return config.default_env || 'dev';
35
+ }
package/src/crypto.js ADDED
@@ -0,0 +1,49 @@
1
+ import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
2
+
3
+ const ALGORITHM = 'aes-256-gcm';
4
+ const IV_LENGTH = 12; // 96-bit IV for GCM
5
+
6
+ export function generateKey() {
7
+ return randomBytes(32).toString('base64');
8
+ }
9
+
10
+ export function encrypt(plaintext, keyBase64) {
11
+ const key = Buffer.from(keyBase64, 'base64');
12
+ try {
13
+ const iv = randomBytes(IV_LENGTH);
14
+ const cipher = createCipheriv(ALGORITHM, key, iv);
15
+ const encrypted = Buffer.concat([
16
+ cipher.update(plaintext, 'utf8'),
17
+ cipher.final(),
18
+ ]);
19
+ const tag = cipher.getAuthTag();
20
+ const result = JSON.stringify({
21
+ v: 1,
22
+ iv: iv.toString('base64'),
23
+ tag: tag.toString('base64'),
24
+ data: encrypted.toString('base64'),
25
+ });
26
+ encrypted.fill(0);
27
+ return result;
28
+ } finally {
29
+ key.fill(0);
30
+ }
31
+ }
32
+
33
+ export function decrypt(ciphertext, keyBase64) {
34
+ const key = Buffer.from(keyBase64, 'base64');
35
+ try {
36
+ const { iv, tag, data } = JSON.parse(ciphertext);
37
+ const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'base64'));
38
+ decipher.setAuthTag(Buffer.from(tag, 'base64'));
39
+ const decrypted = Buffer.concat([
40
+ decipher.update(Buffer.from(data, 'base64')),
41
+ decipher.final(),
42
+ ]);
43
+ const result = decrypted.toString('utf8');
44
+ decrypted.fill(0);
45
+ return result;
46
+ } finally {
47
+ key.fill(0);
48
+ }
49
+ }
package/src/enc.js ADDED
@@ -0,0 +1,24 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
2
+ import { encrypt, decrypt } from './crypto.js';
3
+ import { getEncPath } from './config.js';
4
+ import { parseEnv, stringifyEnv } from './envfile.js';
5
+
6
+ export function readEncEnv(projectRoot, envName, key) {
7
+ const encPath = getEncPath(projectRoot, envName);
8
+ if (!existsSync(encPath)) return {};
9
+ const ciphertext = readFileSync(encPath, 'utf8').trim();
10
+ if (!ciphertext) return {};
11
+ try {
12
+ const plaintext = decrypt(ciphertext, key);
13
+ return parseEnv(plaintext);
14
+ } catch (e) {
15
+ throw new Error(`Failed to decrypt ${envName}.enc — wrong key? (${e.message})`);
16
+ }
17
+ }
18
+
19
+ export function writeEncEnv(projectRoot, envName, key, vars) {
20
+ const encPath = getEncPath(projectRoot, envName);
21
+ const plaintext = stringifyEnv(vars);
22
+ const ciphertext = encrypt(plaintext, key);
23
+ writeFileSync(encPath, ciphertext, 'utf8');
24
+ }
package/src/envfile.js ADDED
@@ -0,0 +1,48 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
2
+
3
+ export function parseEnv(content) {
4
+ const vars = {};
5
+ for (const line of content.split('\n')) {
6
+ const trimmed = line.trim();
7
+ if (!trimmed || trimmed.startsWith('#')) continue;
8
+ const eqIdx = trimmed.indexOf('=');
9
+ if (eqIdx === -1) continue;
10
+ const key = trimmed.slice(0, eqIdx).trim();
11
+ let value = trimmed.slice(eqIdx + 1).trim();
12
+ // Strip surrounding quotes
13
+ if (
14
+ (value.startsWith('"') && value.endsWith('"')) ||
15
+ (value.startsWith("'") && value.endsWith("'"))
16
+ ) {
17
+ value = value.slice(1, -1);
18
+ }
19
+ vars[key] = value;
20
+ }
21
+ return vars;
22
+ }
23
+
24
+ export function stringifyEnv(vars) {
25
+ const lines = Object.entries(vars).map(([k, v]) => {
26
+ const needsQuotes = /[\s"'\\#]/.test(v) || v === '';
27
+ const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
28
+ return `${k}=${needsQuotes ? `"${escaped}"` : v}`;
29
+ });
30
+ return lines.join('\n') + (lines.length ? '\n' : '');
31
+ }
32
+
33
+ export function readEnvFile(filePath) {
34
+ if (!existsSync(filePath)) return {};
35
+ return parseEnv(readFileSync(filePath, 'utf8'));
36
+ }
37
+
38
+ export function writeEnvFile(filePath, vars, { envName, projectRoot } = {}) {
39
+ let content = '';
40
+ if (envName) {
41
+ const projectName = projectRoot ? projectRoot.split('/').pop() : null;
42
+ content += `# Generated by envgit from [${envName}]`;
43
+ if (projectName) content += ` (${projectName})`;
44
+ content += '\n# Do not edit directly — use envgit set to update values\n\n';
45
+ }
46
+ content += stringifyEnv(vars);
47
+ writeFileSync(filePath, content, 'utf8');
48
+ }
@@ -0,0 +1,52 @@
1
+ import { readFileSync, writeFileSync, chmodSync, existsSync, statSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+
4
+ const KEY_FILE = '.envgit.key';
5
+ const ENV_VAR = 'ENVGIT_KEY';
6
+ const SECURE_MODE = 0o600;
7
+
8
+ export function findProjectRoot(startDir = process.cwd()) {
9
+ let dir = startDir;
10
+ while (true) {
11
+ if (existsSync(join(dir, '.envgit'))) return dir;
12
+ const parent = dirname(dir);
13
+ if (parent === dir) return null; // reached filesystem root
14
+ dir = parent;
15
+ }
16
+ }
17
+
18
+ export function requireProjectRoot() {
19
+ const root = findProjectRoot();
20
+ if (!root) {
21
+ console.error("Error: No envgit project found. Run 'envgit init' first.");
22
+ process.exit(1);
23
+ }
24
+ return root;
25
+ }
26
+
27
+ export function loadKey(projectRoot) {
28
+ 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
+ return readFileSync(keyPath, 'utf8').trim();
40
+ }
41
+ throw new Error(
42
+ `No encryption key found. Add it to ${KEY_FILE} or set the ENVGIT_KEY environment variable.`
43
+ );
44
+ }
45
+
46
+ export function saveKey(projectRoot, key) {
47
+ const keyPath = join(projectRoot, KEY_FILE);
48
+ 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
+ chmodSync(keyPath, SECURE_MODE);
52
+ }
package/src/state.js ADDED
@@ -0,0 +1,14 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const CURRENT_FILE = '.envgit/.current';
5
+
6
+ export function getCurrentEnv(projectRoot) {
7
+ const path = join(projectRoot, CURRENT_FILE);
8
+ if (!existsSync(path)) return null;
9
+ return readFileSync(path, 'utf8').trim() || null;
10
+ }
11
+
12
+ export function setCurrentEnv(projectRoot, envName) {
13
+ writeFileSync(join(projectRoot, CURRENT_FILE), envName + '\n', 'utf8');
14
+ }
package/src/ui.js ADDED
@@ -0,0 +1,9 @@
1
+ import chalk from 'chalk';
2
+
3
+ export const ok = (msg) => console.log(chalk.green(`✓ ${msg}`));
4
+ export const fail = (msg) => console.log(chalk.red(`✗ ${msg}`));
5
+ export const warn = (msg) => console.log(chalk.yellow(`⚠ ${msg}`));
6
+ export const fatal = (msg) => { console.error(chalk.red(`✗ ${msg}`)); process.exit(1); };
7
+ export const label = (text) => chalk.cyan(`[${text}]`);
8
+ export const dim = (text) => chalk.dim(text);
9
+ export const bold = (text) => chalk.bold(text);