@akshxy/envgit 0.5.2 → 0.6.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.
@@ -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
+ }
@@ -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
- if (!(key in vars)) {
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
- console.log(vars[key]);
17
+ if (!(keyName in vars)) fatal(`Key '${keyName}' not found in ${envLabel(envName)}`);
18
+
19
+ console.log(vars[keyName]);
19
20
  }
@@ -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 keygen --show')}`);
48
- console.log(`Teammates save it with: ${bold('envgit keygen --set <key>')}`);
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
 
@@ -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 dev` to write your .env file.'));
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
- if (!(oldName in vars)) {
15
- fatal(`Key '${oldName}' not found in ${label(envName)}`);
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 (newName in vars) {
19
- fatal(`Key '${newName}' already exists in ${label(envName)}`);
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 === oldName ? newName : k] = v;
23
+ ordered[k === from ? to : k] = v;
25
24
  }
26
25
 
27
26
  writeEncEnv(projectRoot, envName, key, ordered);
28
- ok(`Renamed ${oldName} → ${newName} in ${label(envName)}`);
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
- 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>'));
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
  }
@@ -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
- { name: 'Private Key Block', regex: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
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
- 'node_modules', '.git', '.envgit', 'dist', 'build', 'out',
30
- '.next', '.nuxt', 'coverage', '.nyc_output', 'vendor', '.turbo',
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
- if (regex.test(line)) {
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
- if (entropy(match[1]) >= ENTROPY_THRESHOLD && !alreadyCaught()) {
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
  }
@@ -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 ${label(envName)}`);
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 ${label(envName)}`);
41
+ ok(`Set ${k} in ${envLabel(envName)}`);
41
42
  }
42
43
  }
43
44
 
@@ -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, label, dim } from '../ui.js';
7
+ import { bold, dim, envLabel } from '../ui.js';
8
8
 
9
9
  export async function status() {
10
10
  const projectRoot = requireProjectRoot();
11
- const config = loadConfig(projectRoot);
12
- const current = getCurrentEnv(projectRoot);
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('~/.config/envgit/keys/') + dim(config.key_id.slice(0, 8) + '…')
21
- : chalk.red('not found on this machine') + dim(' — run: envgit keygen --set <key>');
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('(not found)');
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
- console.log(`${bold('Project:')} ${dim(projectRoot)}`);
32
- console.log(`${bold('Envs:')} ${config.envs.map((e) => (e === current ? chalk.cyan(e) : e)).join(', ')}`);
33
- console.log(`${bold('Default:')} ${config.default_env}`);
34
- console.log(`${bold('Active:')} ${current ? label(current) : dim('(none — run envgit unpack <env>)')}`);
35
- console.log(`${bold('Key:')} ${keySource}`);
36
- console.log(`${bold('.env:')} ${dotenvExists ? chalk.green('present') : chalk.yellow('missing')}`);
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
  }
@@ -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, label, dim } from '../ui.js';
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(envName) {
11
+ export async function unpack(envArg) {
11
12
  const projectRoot = requireProjectRoot();
12
- const key = loadKey(projectRoot);
13
+ const key = loadKey(projectRoot);
14
+ const config = loadConfig(projectRoot);
15
+ const current = getCurrentEnv(projectRoot);
13
16
 
14
- const encPath = getEncPath(projectRoot, envName);
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 '${envName}' does not exist. Use 'envgit add-env ${envName}' to create it.`);
24
+ fatal(`Environment '${name}' does not exist. Create it with: envgit add-env ${name}`);
17
25
  }
18
26
 
19
- const vars = readEncEnv(projectRoot, envName, key);
20
- const dotenvPath = join(projectRoot, '.env');
21
- writeEnvFile(dotenvPath, vars, { envName, projectRoot });
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
- console.log(dim(projectRoot));
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 your team for the key, then run:\n envgit keygen --set <key>`
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 your team for the key, then run:\n envgit keygen --set <key>`
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
  }