@akshxy/envgit 0.5.2 → 0.6.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.
- package/README.md +14 -27
- package/bin/envgit.js +117 -98
- package/package.json +2 -1
- package/src/commands/add-env.js +4 -0
- package/src/commands/audit.js +4 -1
- package/src/commands/copy.js +15 -13
- package/src/commands/delete.js +11 -5
- package/src/commands/doctor.js +3 -6
- package/src/commands/envs.js +16 -10
- package/src/commands/fix.js +130 -0
- package/src/commands/get.js +6 -5
- package/src/commands/init.js +2 -2
- package/src/commands/join.js +1 -1
- package/src/commands/rename-key.js +8 -9
- package/src/commands/rotate-key.js +2 -5
- package/src/commands/scan.js +67 -7
- package/src/commands/set.js +13 -12
- package/src/commands/status.js +25 -13
- package/src/commands/unpack.js +19 -13
- package/src/commands/use.js +29 -0
- package/src/interactive.js +83 -0
- package/src/keystore.js +2 -2
- package/src/ui.js +30 -5
- package/src/commands/keygen.js +0 -71
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync, statSync, mkdirSync } from 'fs';
|
|
3
|
+
import { join, basename } from 'path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { findProjectRoot, globalKeyPath } from '../keystore.js';
|
|
6
|
+
import { loadConfig, saveConfig } from '../config.js';
|
|
7
|
+
import { bold, dim, ok, warn } from '../ui.js';
|
|
8
|
+
|
|
9
|
+
function fixed(msg) { console.log(chalk.green(` ✦ fixed ${msg}`)); }
|
|
10
|
+
function check(msg) { console.log(chalk.dim( ` ✓ ok ${msg}`)); }
|
|
11
|
+
function skipped(msg) { console.log(chalk.dim(` – skip ${msg}`)); }
|
|
12
|
+
|
|
13
|
+
export async function fix() {
|
|
14
|
+
const projectRoot = findProjectRoot();
|
|
15
|
+
if (!projectRoot) {
|
|
16
|
+
console.log('');
|
|
17
|
+
console.log(chalk.red(' No envgit project found. Run envgit init first.'));
|
|
18
|
+
console.log('');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let fixes = 0;
|
|
23
|
+
console.log('');
|
|
24
|
+
console.log(bold('Running envgit fix...'));
|
|
25
|
+
console.log('');
|
|
26
|
+
|
|
27
|
+
// ── 1. Ensure .envgit dir and state file exist ────────────────────────────
|
|
28
|
+
const envgitDir = join(projectRoot, '.envgit');
|
|
29
|
+
const currentFile = join(envgitDir, '.current');
|
|
30
|
+
mkdirSync(envgitDir, { recursive: true });
|
|
31
|
+
if (!existsSync(currentFile)) {
|
|
32
|
+
writeFileSync(currentFile, '', 'utf8');
|
|
33
|
+
fixed('.envgit/.current state file created');
|
|
34
|
+
fixes++;
|
|
35
|
+
} else {
|
|
36
|
+
check('.envgit/.current exists');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── 2. Config migration — add missing fields introduced in newer versions ──
|
|
40
|
+
let config;
|
|
41
|
+
try {
|
|
42
|
+
config = loadConfig(projectRoot);
|
|
43
|
+
let configChanged = false;
|
|
44
|
+
|
|
45
|
+
// v0.2+: default_env field
|
|
46
|
+
if (!config.default_env) {
|
|
47
|
+
config.default_env = config.envs?.[0] ?? 'dev';
|
|
48
|
+
configChanged = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// v0.3+: project name field
|
|
52
|
+
if (!config.project) {
|
|
53
|
+
config.project = basename(projectRoot);
|
|
54
|
+
configChanged = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (configChanged) {
|
|
58
|
+
saveConfig(projectRoot, config);
|
|
59
|
+
fixed('config.yml migrated to latest schema');
|
|
60
|
+
fixes++;
|
|
61
|
+
} else {
|
|
62
|
+
check('config.yml schema is current');
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
warn('Could not load config — skipping migration (run envgit init first)');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── 3. Key file permissions ───────────────────────────────────────────────
|
|
69
|
+
if (config?.key_id) {
|
|
70
|
+
const keyPath = globalKeyPath(config.key_id);
|
|
71
|
+
if (existsSync(keyPath)) {
|
|
72
|
+
const mode = statSync(keyPath).mode & 0o777;
|
|
73
|
+
if (mode & 0o077) {
|
|
74
|
+
chmodSync(keyPath, 0o600);
|
|
75
|
+
fixed(`key file permissions set to 600`);
|
|
76
|
+
fixes++;
|
|
77
|
+
} else {
|
|
78
|
+
check('key file permissions are 600');
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
skipped('key file not on this machine (normal for fresh clones)');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── 4. .gitignore — ensure .env and .envgit.key are ignored ──────────────
|
|
86
|
+
const gitignorePath = join(projectRoot, '.gitignore');
|
|
87
|
+
let gitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
|
|
88
|
+
const lines = gitignore.split('\n').map(l => l.trim());
|
|
89
|
+
const missing = [];
|
|
90
|
+
|
|
91
|
+
if (!lines.some(l => l === '.env' || l === '.env.*')) missing.push('.env');
|
|
92
|
+
if (!lines.some(l => l === '.envgit.key')) missing.push('.envgit.key');
|
|
93
|
+
|
|
94
|
+
if (missing.length > 0) {
|
|
95
|
+
gitignore = gitignore.trimEnd() + '\n\n# envgit\n' + missing.join('\n') + '\n';
|
|
96
|
+
writeFileSync(gitignorePath, gitignore, 'utf8');
|
|
97
|
+
fixed(`.gitignore — added: ${missing.join(', ')}`);
|
|
98
|
+
fixes++;
|
|
99
|
+
} else {
|
|
100
|
+
check('.gitignore has .env and .envgit.key');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 5. Remove .env from git tracking if accidentally staged ──────────────
|
|
104
|
+
try {
|
|
105
|
+
const tracked = execFileSync('git', ['ls-files', '.env'], {
|
|
106
|
+
cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'],
|
|
107
|
+
}).toString().trim();
|
|
108
|
+
|
|
109
|
+
if (tracked) {
|
|
110
|
+
execFileSync('git', ['rm', '--cached', '.env'], {
|
|
111
|
+
cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'],
|
|
112
|
+
});
|
|
113
|
+
fixed('.env removed from git tracking (file kept on disk)');
|
|
114
|
+
fixes++;
|
|
115
|
+
} else {
|
|
116
|
+
check('.env is not tracked by git');
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
skipped('not a git repo — skipping git tracking check');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Summary ───────────────────────────────────────────────────────────────
|
|
123
|
+
console.log('');
|
|
124
|
+
if (fixes === 0) {
|
|
125
|
+
console.log(chalk.green(bold(' Everything looks good. Nothing to fix.')));
|
|
126
|
+
} else {
|
|
127
|
+
console.log(chalk.green(bold(` Applied ${fixes} fix${fixes !== 1 ? 'es' : ''}. Run envgit doctor to verify.`)));
|
|
128
|
+
}
|
|
129
|
+
console.log('');
|
|
130
|
+
}
|
package/src/commands/get.js
CHANGED
|
@@ -2,7 +2,8 @@ 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
|
+
import { fatal, label, envLabel } from '../ui.js';
|
|
6
|
+
import { pickKey } from '../interactive.js';
|
|
6
7
|
|
|
7
8
|
export async function get(key, options) {
|
|
8
9
|
const projectRoot = requireProjectRoot();
|
|
@@ -11,9 +12,9 @@ export async function get(key, options) {
|
|
|
11
12
|
|
|
12
13
|
const vars = readEncEnv(projectRoot, envName, encKey);
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
fatal(`Key '${key}' not found in ${label(envName)}`);
|
|
16
|
-
}
|
|
15
|
+
const keyName = key ?? await pickKey(vars, `Key to get from [${envName}]`);
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
if (!(keyName in vars)) fatal(`Key '${keyName}' not found in ${envLabel(envName)}`);
|
|
18
|
+
|
|
19
|
+
console.log(vars[keyName]);
|
|
19
20
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -44,8 +44,8 @@ export async function init(options) {
|
|
|
44
44
|
console.log(dim('Commit .envgit/ to share encrypted environments with your team.'));
|
|
45
45
|
console.log(dim('Your key never touches the repo — it lives only on your machine.'));
|
|
46
46
|
console.log('');
|
|
47
|
-
console.log(`Share your key with teammates: ${bold('envgit
|
|
48
|
-
console.log(`Teammates
|
|
47
|
+
console.log(`Share your key with teammates: ${bold('envgit share')}`);
|
|
48
|
+
console.log(`Teammates receive it with: ${bold('envgit join <token> --code <passphrase>')}`);
|
|
49
49
|
console.log('');
|
|
50
50
|
}
|
|
51
51
|
|
package/src/commands/join.js
CHANGED
|
@@ -45,6 +45,6 @@ export async function join(token, options) {
|
|
|
45
45
|
console.log(dim(` Stored at: ${keyPath}`));
|
|
46
46
|
console.log('');
|
|
47
47
|
console.log(dim('Run `envgit verify` to confirm it works.'));
|
|
48
|
-
console.log(dim('Run `envgit unpack
|
|
48
|
+
console.log(dim('Run `envgit unpack` to write your .env file.'));
|
|
49
49
|
console.log('');
|
|
50
50
|
}
|
|
@@ -2,7 +2,8 @@ import { requireProjectRoot, loadKey } from '../keystore.js';
|
|
|
2
2
|
import { resolveEnv } from '../config.js';
|
|
3
3
|
import { readEncEnv, writeEncEnv } from '../enc.js';
|
|
4
4
|
import { getCurrentEnv } from '../state.js';
|
|
5
|
-
import { ok, fatal, label } from '../ui.js';
|
|
5
|
+
import { ok, fatal, label, envLabel } from '../ui.js';
|
|
6
|
+
import { pickKey, promptInput } from '../interactive.js';
|
|
6
7
|
|
|
7
8
|
export async function renameKey(oldName, newName, options) {
|
|
8
9
|
const projectRoot = requireProjectRoot();
|
|
@@ -11,19 +12,17 @@ export async function renameKey(oldName, newName, options) {
|
|
|
11
12
|
|
|
12
13
|
const vars = readEncEnv(projectRoot, envName, key);
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
15
|
+
const from = oldName ?? await pickKey(vars, `Key to rename in [${envName}]`);
|
|
16
|
+
const to = newName ?? await promptInput(`New name for ${from}`);
|
|
17
17
|
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
}
|
|
18
|
+
if (!(from in vars)) fatal(`Key '${from}' not found in ${envLabel(envName)}`);
|
|
19
|
+
if (to in vars) fatal(`Key '${to}' already exists in ${envLabel(envName)}`);
|
|
21
20
|
|
|
22
21
|
const ordered = {};
|
|
23
22
|
for (const [k, v] of Object.entries(vars)) {
|
|
24
|
-
ordered[k ===
|
|
23
|
+
ordered[k === from ? to : k] = v;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
writeEncEnv(projectRoot, envName, key, ordered);
|
|
28
|
-
ok(`Renamed ${
|
|
27
|
+
ok(`Renamed ${from} → ${to} in ${envLabel(envName)}`);
|
|
29
28
|
}
|
|
@@ -30,10 +30,7 @@ export async function rotateKey() {
|
|
|
30
30
|
console.log(dim(`Old hint: ${oldHint}`));
|
|
31
31
|
console.log(dim(`New hint: ${newHint}`));
|
|
32
32
|
console.log('');
|
|
33
|
-
|
|
34
|
-
console.log(
|
|
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>'));
|
|
33
|
+
warn('Old key is now invalid — teammates need the new one.');
|
|
34
|
+
console.log(dim('Run envgit share, then send teammates the join command.'));
|
|
38
35
|
console.log('');
|
|
39
36
|
}
|
package/src/commands/scan.js
CHANGED
|
@@ -21,28 +21,84 @@ const PATTERNS = [
|
|
|
21
21
|
{ name: 'Slack User Token', regex: /\bxoxp-[0-9]{10,13}-[0-9]{10,13}-[0-9a-zA-Z]{24}\b/ },
|
|
22
22
|
{ name: 'SendGrid API Key', regex: /\bSG\.[0-9a-zA-Z_-]{22}\.[0-9a-zA-Z_-]{43}\b/ },
|
|
23
23
|
{ name: 'Twilio API Key', regex: /\bSK[0-9a-fA-F]{32}\b/ },
|
|
24
|
-
|
|
24
|
+
// Only flag actual PEM blocks — not code/tests referencing the header string
|
|
25
|
+
{ name: 'Private Key Block', regex: /^-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
|
|
25
26
|
{ name: 'Hardcoded secret', regex: /(?:secret|password|passwd|api_?key|auth_?token)\s*[:=]\s*["']([^"'$`{]{8,})["']/i },
|
|
26
27
|
];
|
|
27
28
|
|
|
28
29
|
const SKIP_DIRS = new Set([
|
|
29
|
-
|
|
30
|
-
'
|
|
30
|
+
// JS/TS
|
|
31
|
+
'node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo',
|
|
32
|
+
'coverage', '.nyc_output',
|
|
33
|
+
// Python
|
|
34
|
+
'.venv', 'venv', 'env', '__pycache__', '.eggs', '*.egg-info',
|
|
35
|
+
'site-packages', '.pytest_cache', '.mypy_cache', '.ruff_cache',
|
|
36
|
+
// Ruby / Go / Rust
|
|
37
|
+
'vendor', 'target',
|
|
38
|
+
// VCS / tooling
|
|
39
|
+
'.git', '.envgit',
|
|
31
40
|
]);
|
|
32
41
|
|
|
33
42
|
const SKIP_EXTENSIONS = new Set([
|
|
43
|
+
// Binaries & media
|
|
34
44
|
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp',
|
|
35
45
|
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
36
46
|
'.mp4', '.mp3', '.wav', '.pdf',
|
|
37
|
-
'.zip', '.tar', '.gz', '.tgz',
|
|
47
|
+
'.zip', '.tar', '.gz', '.tgz', '.rar', '.7z',
|
|
48
|
+
// Compiled / generated
|
|
49
|
+
'.pyc', '.pyo', '.class', '.so', '.dylib', '.dll', '.exe',
|
|
38
50
|
'.map', '.lock', '.snap',
|
|
39
51
|
]);
|
|
40
52
|
|
|
41
53
|
const SKIP_FILES = new Set([
|
|
42
|
-
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
54
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'poetry.lock',
|
|
43
55
|
'.env.example', '.env.sample', '.env.template',
|
|
44
56
|
]);
|
|
45
57
|
|
|
58
|
+
function isPlaceholder(v) {
|
|
59
|
+
v = v.trim();
|
|
60
|
+
if (v.length < 8) return true;
|
|
61
|
+
|
|
62
|
+
// Template syntax: <YOUR_KEY>, ${VAR}, env(VAR)
|
|
63
|
+
if (/^<.+>$/.test(v)) return true;
|
|
64
|
+
if (/^\$\{?.+\}?$/.test(v)) return true;
|
|
65
|
+
if (/^env\(.+\)$/.test(v)) return true;
|
|
66
|
+
|
|
67
|
+
// ALL_CAPS — it's an env var name being used as a placeholder value
|
|
68
|
+
if (/^[A-Z][A-Z0-9_]{3,}$/.test(v)) return true;
|
|
69
|
+
|
|
70
|
+
// snake_case_with_multiple_parts — looks like a variable name, not a secret
|
|
71
|
+
// e.g. google_api_key, test_api_key, gemini_api_key, client_secret
|
|
72
|
+
if (/^[a-z][a-z0-9]*(_[a-z0-9]+){2,}$/.test(v)) return true;
|
|
73
|
+
|
|
74
|
+
// Stripe / other test-mode key prefixes
|
|
75
|
+
if (/^sk_test_|^pk_test_|^rk_test_/.test(v)) return true;
|
|
76
|
+
|
|
77
|
+
// Contains ellipsis, stars, hashes — clearly masked/truncated
|
|
78
|
+
if (/\.{2,}|\*{3,}|#{3,}/.test(v)) return true;
|
|
79
|
+
|
|
80
|
+
// Repeating character (aaaaaaa, 111111)
|
|
81
|
+
if (/^(.)\1{4,}$/.test(v)) return true;
|
|
82
|
+
|
|
83
|
+
// Common placeholder keywords anywhere in the value
|
|
84
|
+
if (/\b(your|my|sample|example|demo|fake|dummy|mock|placeholder|changeme|replace|insert|fill_?in|put_?here|goes_?here|api_?key_?here)\b/i.test(v)) return true;
|
|
85
|
+
|
|
86
|
+
// Starts with "test" or "TEST" followed by separator (test-key, TEST_TOKEN)
|
|
87
|
+
if (/^test[-_]/i.test(v)) return true;
|
|
88
|
+
|
|
89
|
+
// The value IS the field name (secret = "secret", api_key = "api_key", token = "token")
|
|
90
|
+
if (/^(api[_-]?key|auth[_-]?token|access[_-]?token|client[_-]?secret|bearer[_-]?token|x[_-]?api[_-]?key|private[_-]?key|secret[_-]?key)$/i.test(v)) return true;
|
|
91
|
+
|
|
92
|
+
// Common weak/example passwords
|
|
93
|
+
if (/^(password|passwd|p@ssw0rd|qwerty|admin|login|welcome|letmein|abc123|secret|monkey|dragon|master)/i.test(v)) return true;
|
|
94
|
+
|
|
95
|
+
// For Private Key Block pattern — only flag if it's an actual key block,
|
|
96
|
+
// not a string constant, comment, or code referencing the PEM header format
|
|
97
|
+
// (handled per-pattern in the caller)
|
|
98
|
+
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
46
102
|
// ── Shannon entropy ───────────────────────────────────────────────────────────
|
|
47
103
|
|
|
48
104
|
function entropy(str) {
|
|
@@ -93,7 +149,10 @@ export async function scan() {
|
|
|
93
149
|
|
|
94
150
|
// Known pattern matching
|
|
95
151
|
for (const { name, regex } of PATTERNS) {
|
|
96
|
-
|
|
152
|
+
const m = line.match(regex);
|
|
153
|
+
if (m) {
|
|
154
|
+
const captured = m[1] ?? m[0];
|
|
155
|
+
if (isPlaceholder(captured)) continue;
|
|
97
156
|
findings.push({ file: relPath, line: lineNum, type: name, snippet: line.trim(), how: 'pattern' });
|
|
98
157
|
break;
|
|
99
158
|
}
|
|
@@ -104,7 +163,8 @@ export async function scan() {
|
|
|
104
163
|
let match;
|
|
105
164
|
HIGH_ENTROPY_PATTERN.lastIndex = 0;
|
|
106
165
|
while ((match = HIGH_ENTROPY_PATTERN.exec(line)) !== null) {
|
|
107
|
-
|
|
166
|
+
const value = match[1];
|
|
167
|
+
if (!isPlaceholder(value) && entropy(value) >= ENTROPY_THRESHOLD && !alreadyCaught()) {
|
|
108
168
|
findings.push({ file: relPath, line: lineNum, type: 'High-entropy secret', snippet: line.trim(), how: 'entropy' });
|
|
109
169
|
}
|
|
110
170
|
}
|
package/src/commands/set.js
CHANGED
|
@@ -5,7 +5,8 @@ import { resolveEnv } from '../config.js';
|
|
|
5
5
|
import { readEncEnv, writeEncEnv } from '../enc.js';
|
|
6
6
|
import { readEnvFile } from '../envfile.js';
|
|
7
7
|
import { getCurrentEnv } from '../state.js';
|
|
8
|
-
import { ok, fatal, label } from '../ui.js';
|
|
8
|
+
import { ok, fatal, label, envLabel } from '../ui.js';
|
|
9
|
+
import { pickKey, promptValue } from '../interactive.js';
|
|
9
10
|
|
|
10
11
|
export async function set(assignments, options) {
|
|
11
12
|
const projectRoot = requireProjectRoot();
|
|
@@ -16,28 +17,28 @@ export async function set(assignments, options) {
|
|
|
16
17
|
|
|
17
18
|
if (options.file) {
|
|
18
19
|
const filePath = join(projectRoot, options.file);
|
|
19
|
-
if (!existsSync(filePath)) {
|
|
20
|
-
fatal(`File not found: ${options.file}`);
|
|
21
|
-
}
|
|
20
|
+
if (!existsSync(filePath)) fatal(`File not found: ${options.file}`);
|
|
22
21
|
const fileVars = readEnvFile(filePath);
|
|
23
22
|
const entries = Object.entries(fileVars);
|
|
24
|
-
if (entries.length === 0) {
|
|
25
|
-
fatal(`No variables found in ${options.file}`);
|
|
26
|
-
}
|
|
23
|
+
if (entries.length === 0) fatal(`No variables found in ${options.file}`);
|
|
27
24
|
for (const [k, v] of entries) {
|
|
28
25
|
vars[k] = v;
|
|
29
|
-
ok(`Set ${k} in ${
|
|
26
|
+
ok(`Set ${k} in ${envLabel(envName)}`);
|
|
30
27
|
}
|
|
28
|
+
} else if (assignments.length === 0) {
|
|
29
|
+
// ── Interactive mode ───────────────────────────────────────────────────
|
|
30
|
+
const keyName = await pickKey(vars, `Key to set in [${envName}]`, { allowNew: true });
|
|
31
|
+
const value = await promptValue(keyName, vars[keyName]);
|
|
32
|
+
vars[keyName] = value;
|
|
33
|
+
ok(`Set ${keyName} in ${envLabel(envName)}`);
|
|
31
34
|
} else {
|
|
32
35
|
for (const assignment of assignments) {
|
|
33
36
|
const eqIdx = assignment.indexOf('=');
|
|
34
|
-
if (eqIdx === -1) {
|
|
35
|
-
fatal(`Invalid assignment '${assignment}'. Expected KEY=VALUE format.`);
|
|
36
|
-
}
|
|
37
|
+
if (eqIdx === -1) fatal(`Invalid assignment '${assignment}'. Expected KEY=VALUE format.`);
|
|
37
38
|
const k = assignment.slice(0, eqIdx).trim();
|
|
38
39
|
const v = assignment.slice(eqIdx + 1);
|
|
39
40
|
vars[k] = v;
|
|
40
|
-
ok(`Set ${k} in ${
|
|
41
|
+
ok(`Set ${k} in ${envLabel(envName)}`);
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
package/src/commands/status.js
CHANGED
|
@@ -4,35 +4,47 @@ 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';
|
|
7
|
-
import { bold,
|
|
7
|
+
import { bold, dim, envLabel } from '../ui.js';
|
|
8
8
|
|
|
9
9
|
export async function status() {
|
|
10
10
|
const projectRoot = requireProjectRoot();
|
|
11
|
-
const config
|
|
12
|
-
const current
|
|
11
|
+
const config = loadConfig(projectRoot);
|
|
12
|
+
const current = getCurrentEnv(projectRoot);
|
|
13
13
|
|
|
14
|
+
// Key source
|
|
14
15
|
let keySource;
|
|
15
16
|
if (process.env.ENVGIT_KEY) {
|
|
16
17
|
keySource = chalk.green('ENVGIT_KEY') + dim(' (env var)');
|
|
17
18
|
} else if (config.key_id) {
|
|
18
19
|
const keyPath = globalKeyPath(config.key_id);
|
|
19
20
|
keySource = existsSync(keyPath)
|
|
20
|
-
? chalk.green('
|
|
21
|
-
: chalk.red('
|
|
21
|
+
? chalk.green('✓ key found') + dim(` ~/.config/envgit/keys/${config.key_id.slice(0, 8)}…`)
|
|
22
|
+
: chalk.red('✗ key missing') + dim(' — run: envgit join <token> --code <passphrase>');
|
|
22
23
|
} else {
|
|
23
|
-
// Legacy
|
|
24
24
|
const legacyPath = join(projectRoot, '.envgit.key');
|
|
25
|
-
keySource = existsSync(legacyPath) ? dim('.envgit.key (legacy)') : chalk.red('
|
|
25
|
+
keySource = existsSync(legacyPath) ? dim('.envgit.key (legacy)') : chalk.red('✗ not found');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const dotenvExists = existsSync(join(projectRoot, '.env'));
|
|
29
29
|
|
|
30
|
+
// Active env banner — front and center
|
|
31
|
+
const activeDisplay = current
|
|
32
|
+
? envLabel(current)
|
|
33
|
+
: chalk.dim('none');
|
|
34
|
+
|
|
35
|
+
console.log('');
|
|
36
|
+
console.log(` ${bold('Active env')} ${activeDisplay}${!current ? chalk.dim(' — run: envgit use <env>') : ''}`);
|
|
30
37
|
console.log('');
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
|
|
39
|
+
// All envs in a row, active one highlighted
|
|
40
|
+
const envRow = config.envs.map(e =>
|
|
41
|
+
e === current
|
|
42
|
+
? envLabel(e)
|
|
43
|
+
: chalk.dim(e)
|
|
44
|
+
).join(' ');
|
|
45
|
+
console.log(` ${bold('Environments')} ${envRow}`);
|
|
46
|
+
console.log(` ${bold('Key')} ${keySource}`);
|
|
47
|
+
console.log(` ${bold('.env')} ${dotenvExists ? chalk.green('present') : chalk.dim('missing')}${!dotenvExists && current ? chalk.dim(' — run: envgit unpack') : ''}`);
|
|
48
|
+
console.log(` ${bold('Project')} ${dim(projectRoot)}`);
|
|
37
49
|
console.log('');
|
|
38
50
|
}
|
package/src/commands/unpack.js
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import { requireProjectRoot, loadKey } from '../keystore.js';
|
|
4
|
-
import { getEncPath } from '../config.js';
|
|
4
|
+
import { loadConfig, getEncPath } from '../config.js';
|
|
5
5
|
import { readEncEnv } from '../enc.js';
|
|
6
6
|
import { writeEnvFile } from '../envfile.js';
|
|
7
|
-
import { setCurrentEnv } from '../state.js';
|
|
8
|
-
import { ok, fatal,
|
|
7
|
+
import { getCurrentEnv, setCurrentEnv } from '../state.js';
|
|
8
|
+
import { ok, fatal, dim, envLabel } from '../ui.js';
|
|
9
|
+
import { pickEnv } from '../interactive.js';
|
|
9
10
|
|
|
10
|
-
export async function unpack(
|
|
11
|
+
export async function unpack(envArg) {
|
|
11
12
|
const projectRoot = requireProjectRoot();
|
|
12
|
-
const key
|
|
13
|
+
const key = loadKey(projectRoot);
|
|
14
|
+
const config = loadConfig(projectRoot);
|
|
15
|
+
const current = getCurrentEnv(projectRoot);
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
// Priority: explicit arg → active env → interactive pick
|
|
18
|
+
const name = envArg
|
|
19
|
+
?? current
|
|
20
|
+
?? await pickEnv(config.envs, 'Which environment to unpack?');
|
|
21
|
+
|
|
22
|
+
const encPath = getEncPath(projectRoot, name);
|
|
15
23
|
if (!existsSync(encPath)) {
|
|
16
|
-
fatal(`Environment '${
|
|
24
|
+
fatal(`Environment '${name}' does not exist. Create it with: envgit add-env ${name}`);
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
const vars = readEncEnv(projectRoot,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
setCurrentEnv(projectRoot, envName);
|
|
27
|
+
const vars = readEncEnv(projectRoot, name, key);
|
|
28
|
+
writeEnvFile(join(projectRoot, '.env'), vars, { envName: name, projectRoot });
|
|
29
|
+
setCurrentEnv(projectRoot, name);
|
|
23
30
|
|
|
24
31
|
const count = Object.keys(vars).length;
|
|
25
|
-
|
|
26
|
-
ok(`Unpacked ${label(envName)} → .env ${dim(`(${count} variable${count !== 1 ? 's' : ''})`)}`);
|
|
32
|
+
ok(`Unpacked ${envLabel(name)} → .env ${dim(`(${count} var${count !== 1 ? 's' : ''})`)}`);
|
|
27
33
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { requireProjectRoot } from '../keystore.js';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
import { setCurrentEnv, getCurrentEnv } from '../state.js';
|
|
4
|
+
import { ok, fatal, envLabel, dim } from '../ui.js';
|
|
5
|
+
import { pickEnv } from '../interactive.js';
|
|
6
|
+
|
|
7
|
+
export async function use(envName) {
|
|
8
|
+
const projectRoot = requireProjectRoot();
|
|
9
|
+
const config = loadConfig(projectRoot);
|
|
10
|
+
const current = getCurrentEnv(projectRoot);
|
|
11
|
+
|
|
12
|
+
const name = envName ?? await pickEnv(config.envs, 'Switch to environment');
|
|
13
|
+
|
|
14
|
+
if (!config.envs.includes(name)) {
|
|
15
|
+
fatal(`Environment '${name}' does not exist. Create it with: envgit add-env ${name}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (name === current) {
|
|
19
|
+
ok(`Already on ${envLabel(name)}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setCurrentEnv(projectRoot, name);
|
|
24
|
+
|
|
25
|
+
console.log('');
|
|
26
|
+
ok(`Switched to ${envLabel(name)}`);
|
|
27
|
+
console.log(dim(` Run envgit unpack to write .env for this environment.`));
|
|
28
|
+
console.log('');
|
|
29
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { search, input, password, confirm } from '@inquirer/prompts';
|
|
2
|
+
|
|
3
|
+
// Fuzzy match — returns true if all chars of query appear in order in str
|
|
4
|
+
function fuzzy(str, query) {
|
|
5
|
+
if (!query) return true;
|
|
6
|
+
let si = 0;
|
|
7
|
+
const s = str.toLowerCase();
|
|
8
|
+
const q = query.toLowerCase();
|
|
9
|
+
for (let i = 0; i < q.length; i++) {
|
|
10
|
+
si = s.indexOf(q[i], si);
|
|
11
|
+
if (si === -1) return false;
|
|
12
|
+
si++;
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Interactive fuzzy key picker.
|
|
19
|
+
* @param {Record<string,string>} vars — existing key/value pairs
|
|
20
|
+
* @param {string} message
|
|
21
|
+
* @param {{ allowNew?: boolean }} opts
|
|
22
|
+
* @returns {Promise<string>} chosen key name
|
|
23
|
+
*/
|
|
24
|
+
export async function pickKey(vars, message = 'Select a key', { allowNew = false } = {}) {
|
|
25
|
+
const keys = Object.keys(vars).sort();
|
|
26
|
+
|
|
27
|
+
return search({
|
|
28
|
+
message,
|
|
29
|
+
source(term) {
|
|
30
|
+
const matches = keys.filter(k => fuzzy(k, term));
|
|
31
|
+
const choices = matches.map(k => ({ name: k, value: k }));
|
|
32
|
+
|
|
33
|
+
// If the typed term isn't an exact match and allowNew is set, offer to create it
|
|
34
|
+
if (allowNew && term && !keys.includes(term)) {
|
|
35
|
+
choices.unshift({ name: `+ create "${term}"`, value: term });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return choices;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Interactive fuzzy environment picker.
|
|
45
|
+
* @param {string[]} envs
|
|
46
|
+
* @param {string} message
|
|
47
|
+
* @returns {Promise<string>} chosen env name
|
|
48
|
+
*/
|
|
49
|
+
export async function pickEnv(envs, message = 'Select an environment') {
|
|
50
|
+
return search({
|
|
51
|
+
message,
|
|
52
|
+
source(term) {
|
|
53
|
+
return envs
|
|
54
|
+
.filter(e => fuzzy(e, term))
|
|
55
|
+
.map(e => ({ name: e, value: e }));
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Prompt for a secret value (masked by default).
|
|
62
|
+
*/
|
|
63
|
+
export async function promptValue(keyName, existing) {
|
|
64
|
+
const msg = existing !== undefined
|
|
65
|
+
? `New value for ${keyName} (current: ${'*'.repeat(Math.min(existing.length, 8))})`
|
|
66
|
+
: `Value for ${keyName}`;
|
|
67
|
+
|
|
68
|
+
return password({ message: msg, mask: false });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Prompt for a plain (visible) string.
|
|
73
|
+
*/
|
|
74
|
+
export async function promptInput(message, defaultValue) {
|
|
75
|
+
return input({ message, default: defaultValue });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Prompt for confirmation.
|
|
80
|
+
*/
|
|
81
|
+
export async function promptConfirm(message, defaultValue = false) {
|
|
82
|
+
return confirm({ message, default: defaultValue });
|
|
83
|
+
}
|
package/src/keystore.js
CHANGED
|
@@ -46,7 +46,7 @@ export function loadKey(projectRoot) {
|
|
|
46
46
|
}
|
|
47
47
|
// Project found, but this machine doesn't have the key yet
|
|
48
48
|
console.error(
|
|
49
|
-
`No key found for this project. Ask
|
|
49
|
+
`No key found for this project. Ask a teammate to run:\n envgit share\nthen run the join command they send you.`
|
|
50
50
|
);
|
|
51
51
|
process.exit(1);
|
|
52
52
|
}
|
|
@@ -59,7 +59,7 @@ export function loadKey(projectRoot) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
console.error(
|
|
62
|
-
`No key found for this project. Ask
|
|
62
|
+
`No key found for this project. Ask a teammate to run:\n envgit share\nthen run the join command they send you.`
|
|
63
63
|
);
|
|
64
64
|
process.exit(1);
|
|
65
65
|
}
|