@fentz26/envcp 1.2.0-exp.bafb368 → 1.2.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.
Files changed (102) hide show
  1. package/README.md +21 -36
  2. package/VERSION +1 -1
  3. package/dist/adapters/base.d.ts +25 -2
  4. package/dist/adapters/base.d.ts.map +1 -1
  5. package/dist/adapters/base.js +111 -29
  6. package/dist/adapters/base.js.map +1 -1
  7. package/dist/adapters/gemini.d.ts +6 -4
  8. package/dist/adapters/gemini.d.ts.map +1 -1
  9. package/dist/adapters/gemini.js +43 -116
  10. package/dist/adapters/gemini.js.map +1 -1
  11. package/dist/adapters/openai.d.ts +6 -4
  12. package/dist/adapters/openai.d.ts.map +1 -1
  13. package/dist/adapters/openai.js +54 -131
  14. package/dist/adapters/openai.js.map +1 -1
  15. package/dist/adapters/rest.d.ts +1 -0
  16. package/dist/adapters/rest.d.ts.map +1 -1
  17. package/dist/adapters/rest.js +14 -6
  18. package/dist/adapters/rest.js.map +1 -1
  19. package/dist/cli/index.js +1463 -424
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/config/config-guard.d.ts.map +1 -1
  22. package/dist/config/config-guard.js +9 -4
  23. package/dist/config/config-guard.js.map +1 -1
  24. package/dist/config/config-hmac.d.ts.map +1 -1
  25. package/dist/config/config-hmac.js +7 -3
  26. package/dist/config/config-hmac.js.map +1 -1
  27. package/dist/config/manager.d.ts +12 -2
  28. package/dist/config/manager.d.ts.map +1 -1
  29. package/dist/config/manager.js +189 -105
  30. package/dist/config/manager.js.map +1 -1
  31. package/dist/mcp/server.d.ts.map +1 -1
  32. package/dist/mcp/server.js +2 -1
  33. package/dist/mcp/server.js.map +1 -1
  34. package/dist/server/unified.d.ts +1 -0
  35. package/dist/server/unified.d.ts.map +1 -1
  36. package/dist/server/unified.js +54 -30
  37. package/dist/server/unified.js.map +1 -1
  38. package/dist/service/config.d.ts.map +1 -1
  39. package/dist/service/config.js +5 -10
  40. package/dist/service/config.js.map +1 -1
  41. package/dist/service/generators.d.ts.map +1 -1
  42. package/dist/service/generators.js +6 -8
  43. package/dist/service/generators.js.map +1 -1
  44. package/dist/service/index.js +4 -3
  45. package/dist/service/index.js.map +1 -1
  46. package/dist/service/platform.js +2 -2
  47. package/dist/service/platform.js.map +1 -1
  48. package/dist/storage/index.d.ts +1 -1
  49. package/dist/storage/index.d.ts.map +1 -1
  50. package/dist/storage/index.js +35 -27
  51. package/dist/storage/index.js.map +1 -1
  52. package/dist/types.d.ts +265 -4
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/types.js +25 -1
  55. package/dist/types.js.map +1 -1
  56. package/dist/utils/crypto.d.ts +5 -3
  57. package/dist/utils/crypto.d.ts.map +1 -1
  58. package/dist/utils/crypto.js +7 -8
  59. package/dist/utils/crypto.js.map +1 -1
  60. package/dist/utils/fs.d.ts +11 -1
  61. package/dist/utils/fs.d.ts.map +1 -1
  62. package/dist/utils/fs.js +22 -13
  63. package/dist/utils/fs.js.map +1 -1
  64. package/dist/utils/hsm.d.ts +8 -8
  65. package/dist/utils/hsm.d.ts.map +1 -1
  66. package/dist/utils/hsm.js +13 -8
  67. package/dist/utils/hsm.js.map +1 -1
  68. package/dist/utils/http.d.ts +1 -1
  69. package/dist/utils/http.d.ts.map +1 -1
  70. package/dist/utils/http.js +9 -4
  71. package/dist/utils/http.js.map +1 -1
  72. package/dist/utils/keychain.js +4 -4
  73. package/dist/utils/keychain.js.map +1 -1
  74. package/dist/utils/lock.d.ts.map +1 -1
  75. package/dist/utils/lock.js +2 -1
  76. package/dist/utils/lock.js.map +1 -1
  77. package/dist/utils/lockout.d.ts +2 -2
  78. package/dist/utils/lockout.d.ts.map +1 -1
  79. package/dist/utils/lockout.js +3 -3
  80. package/dist/utils/lockout.js.map +1 -1
  81. package/dist/utils/prompt.d.ts +11 -0
  82. package/dist/utils/prompt.d.ts.map +1 -1
  83. package/dist/utils/prompt.js +242 -5
  84. package/dist/utils/prompt.js.map +1 -1
  85. package/dist/utils/secure-memory.d.ts.map +1 -1
  86. package/dist/utils/secure-memory.js +6 -3
  87. package/dist/utils/secure-memory.js.map +1 -1
  88. package/dist/utils/session.d.ts +3 -1
  89. package/dist/utils/session.d.ts.map +1 -1
  90. package/dist/utils/session.js +41 -19
  91. package/dist/utils/session.js.map +1 -1
  92. package/dist/utils/update-checker.d.ts +3 -0
  93. package/dist/utils/update-checker.d.ts.map +1 -1
  94. package/dist/utils/update-checker.js +51 -5
  95. package/dist/utils/update-checker.js.map +1 -1
  96. package/dist/vault/index.d.ts.map +1 -1
  97. package/dist/vault/index.js +5 -3
  98. package/dist/vault/index.js.map +1 -1
  99. package/dist/version.js +3 -3
  100. package/dist/version.js.map +1 -1
  101. package/package.json +2 -2
  102. package/scripts/sync-version.js +65 -23
package/dist/cli/index.js CHANGED
@@ -3,9 +3,9 @@ import chalk from 'chalk';
3
3
  import * as path from 'path';
4
4
  import * as os from 'os';
5
5
  import * as fs from 'fs/promises';
6
- import { promptPassword, promptInput, promptConfirm, promptList } from '../utils/prompt.js';
7
- import { ensureDir, pathExists } from '../utils/fs.js';
8
- import { loadConfig, initConfig, saveConfig, parseEnvFile, registerMcpConfig, isBlacklisted, canAccess } from '../config/manager.js';
6
+ import { promptPassword, promptInput, promptConfirm, promptMenu, promptTabbedMenu } from '../utils/prompt.js';
7
+ import { ensureDir, pathExists, findProjectRoot } from '../utils/fs.js';
8
+ import { loadConfig, loadScopedConfig, initConfig, saveConfig, saveScopedConfig, parseEnvFile, registerMcpConfig, isBlacklisted, canAccess } from '../config/manager.js';
9
9
  import { ConfigGuard } from '../config/config-guard.js';
10
10
  import { StorageManager, LogManager, resolveLogPath } from '../storage/index.js';
11
11
  import { VERSION } from '../version.js';
@@ -13,11 +13,13 @@ import { SessionManager } from '../utils/session.js';
13
13
  import { maskValue, validatePassword, encrypt, decrypt, generateRecoveryKey, createRecoveryData, recoverPassword } from '../utils/crypto.js';
14
14
  import { KeychainManager } from '../utils/keychain.js';
15
15
  import { HsmManager } from '../utils/hsm.js';
16
- import { checkForUpdate, formatUpdateMessage, logUpdateCheck } from '../utils/update-checker.js';
16
+ import { checkForUpdate, formatUpdateMessage, logUpdateCheck, fetchReleases, filterByChannel } from '../utils/update-checker.js';
17
+ import { spawnSync } from 'node:child_process';
17
18
  import { LockoutManager } from '../utils/lockout.js';
18
- import { initMemoryProtection } from '../utils/secure-memory.js';
19
+ import { initMemoryProtection, secureCompare } from '../utils/secure-memory.js';
19
20
  import { getGlobalVaultPath, getProjectVaultPath, resolveVaultPath, resolveSessionPath, setActiveVault, listVaults, initNamedVault, } from '../vault/index.js';
20
- import { findProjectRoot } from '../utils/fs.js';
21
+ import { loadServiceConfig, saveServiceConfig } from '../service/config.js';
22
+ import { installService, statusService, uninstallService } from '../service/index.js';
21
23
  initMemoryProtection();
22
24
  async function resolveCliContext(vaultOverride) {
23
25
  if (vaultOverride === 'global') {
@@ -33,359 +35,1054 @@ async function resolveCliContext(vaultOverride) {
33
35
  }
34
36
  return { projectPath, config };
35
37
  }
36
- async function withSession(fn, vaultOverride) {
37
- const { projectPath, config } = await resolveCliContext(vaultOverride);
38
- const vaultPath = vaultOverride
39
- ? vaultOverride === 'global'
40
- ? getGlobalVaultPath(config)
41
- : getProjectVaultPath(projectPath, config)
42
- : await resolveVaultPath(projectPath, config);
43
- if (config.encryption?.enabled === false) {
44
- const storage = new StorageManager(vaultPath, false);
45
- const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
46
- await logManager.init();
47
- await fn(storage, '', config, projectPath, logManager);
38
+ function buildSecuritySettings(securityChoice) {
39
+ const bruteForceProtection = {
40
+ enabled: true,
41
+ max_attempts: 5,
42
+ lockout_duration: 300,
43
+ progressive_delay: true,
44
+ max_delay: 60,
45
+ permanent_lockout_threshold: 50,
46
+ permanent_lockout_action: 'require_recovery_key',
47
+ notifications: {},
48
+ };
49
+ if (securityChoice === 'none') {
50
+ return {
51
+ encryption: { enabled: false },
52
+ storageEncrypted: false,
53
+ security: {
54
+ mode: 'recoverable',
55
+ recovery_file: '.envcp/.recovery',
56
+ brute_force_protection: bruteForceProtection,
57
+ },
58
+ };
59
+ }
60
+ return {
61
+ encryption: { enabled: true },
62
+ storageEncrypted: true,
63
+ security: {
64
+ mode: securityChoice,
65
+ recovery_file: '.envcp/.recovery',
66
+ brute_force_protection: bruteForceProtection,
67
+ },
68
+ };
69
+ }
70
+ function extractTransferVariables(data, invalidMessage) {
71
+ const variables = data.variables;
72
+ if (!variables || typeof variables !== 'object') {
73
+ console.log(chalk.red(invalidMessage));
74
+ return null;
75
+ }
76
+ return {
77
+ meta: data.meta,
78
+ variables: variables,
79
+ };
80
+ }
81
+ function logTransferInfo(title, meta, variables, labels) {
82
+ if (!meta) {
48
83
  return;
49
84
  }
50
- const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
51
- await sessionManager.init();
52
- // Initialize audit logging
53
- const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
54
- await logManager.init();
55
- let session = await sessionManager.load();
85
+ console.log(chalk.blue(title));
86
+ if (meta.project)
87
+ console.log(chalk.gray(` ${labels.project}: ${meta.project}`));
88
+ if (meta.timestamp)
89
+ console.log(chalk.gray(` ${labels.timestamp}: ${meta.timestamp}`));
90
+ console.log(chalk.gray(` Variables: ${meta.count || Object.keys(variables).length}`));
91
+ }
92
+ async function getAuthPassword(projectPath, config) {
56
93
  let password = '';
57
- if (!session) {
58
- const authMethod = config.auth?.method ?? 'password';
59
- // --- HSM-only or multi-factor auth ---
60
- if (authMethod === 'hsm' || authMethod === 'multi') {
61
- const hsm = HsmManager.fromConfig(config, projectPath);
62
- const hsmAvailable = await hsm.isAvailable();
63
- if (hsmAvailable) {
64
- try {
65
- const hsmSecret = await hsm.retrieveVaultPassword();
66
- if (authMethod === 'multi') {
67
- const factors = config.auth?.multi_factors ?? ['password', 'hsm'];
68
- let userPassword = '';
69
- if (factors.includes('password')) {
70
- userPassword = await promptPassword('Enter password (multi-factor):');
71
- }
72
- else if (factors.includes('keychain')) {
73
- const keychain = new KeychainManager(config.keychain?.service || 'envcp');
74
- const stored = await keychain.retrievePassword(projectPath);
75
- if (stored) {
76
- userPassword = stored;
77
- console.log(chalk.gray('Password retrieved from OS keychain (multi-factor)'));
78
- }
79
- }
80
- password = HsmManager.combineSecrets(hsmSecret, userPassword);
81
- console.log(chalk.gray(`Authenticated via ${hsm.backendName} + password`));
94
+ const authMethod = config.auth?.method ?? 'password';
95
+ if (authMethod === 'hsm' || authMethod === 'multi') {
96
+ const hsm = HsmManager.fromConfig(config, projectPath);
97
+ const hsmAvailable = await hsm.isAvailable();
98
+ if (hsmAvailable) {
99
+ try {
100
+ const hsmSecret = await hsm.retrieveVaultPassword();
101
+ if (authMethod === 'multi') {
102
+ const factors = config.auth?.multi_factors ?? ['password', 'hsm'];
103
+ let userPassword = '';
104
+ if (factors.includes('password')) {
105
+ userPassword = await promptPassword('Enter password (multi-factor):');
82
106
  }
83
- else {
84
- password = hsmSecret;
85
- console.log(chalk.gray(`Authenticated via ${hsm.backendName}`));
107
+ else if (factors.includes('keychain')) {
108
+ const keychain = new KeychainManager(config.keychain?.service || 'envcp');
109
+ const stored = await keychain.retrievePassword(projectPath);
110
+ if (stored) {
111
+ userPassword = stored;
112
+ console.log(chalk.gray('Password retrieved from OS keychain (multi-factor)'));
113
+ }
86
114
  }
115
+ password = HsmManager.combineSecrets(hsmSecret, userPassword);
116
+ console.log(chalk.gray(`Authenticated via ${hsm.backendName} + password`));
87
117
  }
88
- catch (err) {
89
- const msg = err instanceof Error ? err.message : String(err);
90
- const fallback = config.auth?.fallback ?? 'password';
91
- if (fallback === 'none') {
92
- console.log(chalk.red(`HSM authentication failed: ${msg}`));
93
- return;
94
- }
95
- console.log(chalk.yellow(`HSM unavailable: ${msg}`));
96
- console.log(chalk.gray('Falling back to password...'));
118
+ else {
119
+ password = hsmSecret;
120
+ console.log(chalk.gray(`Authenticated via ${hsm.backendName}`));
97
121
  }
98
122
  }
99
- else {
123
+ catch (err) {
124
+ const msg = err instanceof Error ? err.message : String(err);
100
125
  const fallback = config.auth?.fallback ?? 'password';
101
126
  if (fallback === 'none') {
102
- console.log(chalk.red(`HSM device (${hsm.backendName}) not found. No fallback configured.`));
103
- return;
127
+ console.log(chalk.red(`HSM authentication failed: ${msg}`));
128
+ return null;
104
129
  }
105
- console.log(chalk.yellow(`HSM device (${hsm.backendName}) not available. Falling back to password...`));
130
+ console.log(chalk.yellow(`HSM unavailable: ${msg}`));
131
+ console.log(chalk.gray('Falling back to password...'));
106
132
  }
107
133
  }
108
- // --- OS keychain ---
109
- if (!password && (authMethod === 'keychain' || config.keychain?.enabled)) {
110
- const keychain = new KeychainManager(config.keychain?.service || 'envcp');
111
- const stored = await keychain.retrievePassword(projectPath);
112
- if (stored) {
113
- password = stored;
114
- console.log(chalk.gray('Password retrieved from OS keychain'));
134
+ else {
135
+ const fallback = config.auth?.fallback ?? 'password';
136
+ if (fallback === 'none') {
137
+ console.log(chalk.red(`HSM device (${hsm.backendName}) not found. No fallback configured.`));
138
+ return null;
115
139
  }
140
+ console.log(chalk.yellow(`HSM device (${hsm.backendName}) not available. Falling back to password...`));
116
141
  }
117
- // --- Password prompt fallback ---
118
- if (!password) {
119
- password = await promptPassword('Enter password:');
120
- const { valid: passwordValid, warning: passwordWarning } = validatePassword(password, config.password || {});
121
- if (!passwordValid) {
122
- console.log(chalk.red("Invalid password"));
142
+ }
143
+ if (!password && (authMethod === 'keychain' || config.keychain?.enabled)) {
144
+ const keychain = new KeychainManager(config.keychain?.service || 'envcp');
145
+ const stored = await keychain.retrievePassword(projectPath);
146
+ if (stored) {
147
+ password = stored;
148
+ console.log(chalk.gray('Password retrieved from OS keychain'));
149
+ }
150
+ }
151
+ if (!password) {
152
+ password = await promptPassword('Enter password:');
153
+ const { valid: passwordValid, warning: passwordWarning } = validatePassword(password, config.password || {});
154
+ if (!passwordValid) {
155
+ console.log(chalk.red('Invalid password'));
156
+ return null;
157
+ }
158
+ if (passwordWarning) {
159
+ console.log(chalk.yellow('⚠ Weak password detected'));
160
+ }
161
+ }
162
+ return password;
163
+ }
164
+ async function withSession(fn, vaultOverride) {
165
+ const { projectPath, config } = await resolveCliContext(vaultOverride);
166
+ const vaultPath = vaultOverride
167
+ ? vaultOverride === 'global'
168
+ ? getGlobalVaultPath(config)
169
+ : getProjectVaultPath(projectPath, config)
170
+ : await resolveVaultPath(projectPath, config);
171
+ const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
172
+ await logManager.init();
173
+ if (config.encryption?.enabled === false) {
174
+ const storage = new StorageManager(vaultPath, false);
175
+ await fn(storage, '', config, projectPath, logManager);
176
+ return;
177
+ }
178
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
179
+ await sessionManager.init();
180
+ let password = '';
181
+ if (config.session?.enabled !== false) {
182
+ let session = await sessionManager.load();
183
+ if (!session) {
184
+ const authPassword = await getAuthPassword(projectPath, config);
185
+ if (!authPassword) {
123
186
  return;
124
187
  }
125
- if (passwordValid && passwordWarning) {
126
- console.log(chalk.yellow('⚠ Weak password detected'));
127
- }
188
+ password = authPassword;
189
+ session = await sessionManager.create(password);
128
190
  }
129
- session = await sessionManager.create(password);
191
+ password = sessionManager.getPassword() || password;
192
+ }
193
+ else {
194
+ const authPassword = await getAuthPassword(projectPath, config);
195
+ if (!authPassword) {
196
+ return;
197
+ }
198
+ password = authPassword;
199
+ await sessionManager.destroy();
130
200
  }
131
- password = sessionManager.getPassword() || password;
132
201
  const storage = new StorageManager(vaultPath, config.storage.encrypted);
133
202
  if (password)
134
203
  storage.setPassword(password);
135
204
  await fn(storage, password, config, projectPath, logManager);
136
205
  }
137
- const program = new Command();
138
- program
139
- .name('envcp')
140
- .description('Secure environment variable management for AI-assisted coding')
141
- .version(VERSION);
142
- program
143
- .command('init')
144
- .description('Initialize EnvCP in the current project')
145
- .option('-p, --project <name>', 'Project name')
146
- .option('--no-encrypt', 'Skip encryption (passwordless mode)')
147
- .option('--skip-env', 'Skip .env auto-import')
148
- .option('--skip-mcp', 'Skip MCP auto-registration')
149
- .option('--auth-method <method>', 'Authentication method: password | keychain | hsm | multi (default: password)')
150
- .option('--hsm-type <type>', 'HSM type for --auth-method hsm|multi: yubikey | gpg | pkcs11')
151
- .option('--key-id <id>', 'GPG key ID or PKCS#11 key label for HSM auth')
152
- .option('--pkcs11-lib <path>', 'Path to PKCS#11 shared library for --hsm-type pkcs11')
153
- .option('--global', 'Initialize a global vault at ~/.envcp/ (config + store live in $HOME)')
154
- .option('--force', 'Overwrite existing envcp.yaml (DESTRUCTIVE: existing vault may become inaccessible)')
155
- .action(async (options) => {
156
- const useGlobal = !!options.global;
157
- const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
158
- const projectPath = useGlobal ? home : process.cwd();
159
- const projectName = options.project || (useGlobal ? 'global' : path.basename(projectPath));
206
+ function isInteractiveCli() {
207
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
208
+ }
209
+ async function isProjectInitialized(projectPath = process.cwd()) {
210
+ return pathExists(path.join(projectPath, 'envcp.yaml'));
211
+ }
212
+ async function chooseInitMode() {
213
+ if (!isInteractiveCli()) {
214
+ return 'basic';
215
+ }
216
+ return promptMenu('Set up EnvCP', [
217
+ { label: 'Basic', value: 'basic', hint: '(quick, simple, low-tech)' },
218
+ { label: 'Advanced', value: 'advanced', hint: '(guided, more choices)' },
219
+ { label: 'Manual', value: 'manual', hint: '(full control)' },
220
+ ], 'basic');
221
+ }
222
+ async function chooseSecurityMode() {
223
+ if (!isInteractiveCli()) {
224
+ return 'recoverable';
225
+ }
226
+ return promptMenu('How would you like to secure your variables?', [
227
+ { label: 'No encryption', value: 'none', hint: '(fastest local setup)' },
228
+ { label: 'Encrypted with recovery key', value: 'recoverable', hint: '(recommended)' },
229
+ { label: 'Encrypted hard-lock', value: 'hard-lock', hint: '(max security)' },
230
+ ], 'recoverable');
231
+ }
232
+ async function chooseServerMode() {
233
+ if (!isInteractiveCli()) {
234
+ return 'auto';
235
+ }
236
+ return promptMenu('Background service mode', [
237
+ { label: 'Auto', value: 'auto', hint: '(recommended)' },
238
+ { label: 'REST', value: 'rest' },
239
+ { label: 'OpenAI', value: 'openai' },
240
+ { label: 'Gemini', value: 'gemini' },
241
+ { label: 'All', value: 'all' },
242
+ ], 'auto');
243
+ }
244
+ async function buildServiceSetup(projectPath, initMode) {
245
+ if (!isInteractiveCli()) {
246
+ return { enabled: false, startNow: false, config: null };
247
+ }
248
+ const wantsStartup = await promptConfirm(initMode === 'basic'
249
+ ? 'Start EnvCP automatically in the background?'
250
+ : 'Install background startup for EnvCP?', true);
251
+ if (!wantsStartup) {
252
+ return { enabled: false, startNow: false, config: null };
253
+ }
254
+ const serviceConfig = await loadServiceConfig();
255
+ serviceConfig.working_directory = projectPath;
256
+ serviceConfig.autostart = true;
257
+ if (initMode === 'manual') {
258
+ serviceConfig.server.mode = await chooseServerMode();
259
+ const hostInput = (await promptInput(`Service host [${serviceConfig.server.host}]:`)).trim();
260
+ if (hostInput) {
261
+ serviceConfig.server.host = hostInput;
262
+ }
263
+ const portInput = (await promptInput(`Service port [${serviceConfig.server.port}]:`)).trim();
264
+ if (portInput) {
265
+ const parsedPort = Number.parseInt(portInput, 10);
266
+ if (!Number.isNaN(parsedPort) && parsedPort > 0) {
267
+ serviceConfig.server.port = parsedPort;
268
+ }
269
+ }
270
+ const apiKey = (await promptInput('HTTP API key (leave blank for local-only use):')).trim();
271
+ serviceConfig.server.api_key = apiKey || undefined;
272
+ serviceConfig.restart_on_failure = await promptConfirm('Restart service if it crashes?', true);
273
+ }
274
+ const startNow = initMode === 'basic'
275
+ ? true
276
+ : await promptConfirm('Start the background service now?', true);
277
+ return { enabled: true, startNow, config: serviceConfig };
278
+ }
279
+ async function installStartupServiceIfNeeded(projectPath, serviceSetup) {
280
+ if (!serviceSetup.enabled || !serviceSetup.config) {
281
+ return;
282
+ }
283
+ await saveServiceConfig(serviceSetup.config);
284
+ const result = await installService({ workingDirectory: projectPath, now: serviceSetup.startNow });
285
+ if (result.ok) {
286
+ console.log(chalk.green('Background service configured.'));
287
+ console.log(chalk.gray(` ${result.message}`));
288
+ }
289
+ else {
290
+ console.log(chalk.yellow('Background service setup could not be completed automatically.'));
291
+ console.log(chalk.gray(` ${result.message}`));
292
+ }
293
+ }
294
+ async function summarizeServiceStatus() {
295
+ const serviceConfig = await loadServiceConfig();
296
+ const status = await statusService();
297
+ const active = status.ok ? 'running' : 'not running';
298
+ return `${active}, autostart ${serviceConfig.autostart ? 'on' : 'off'}`;
299
+ }
300
+ async function maybeImportDotEnv(projectPath, config, password, shouldImport) {
301
+ if (!shouldImport) {
302
+ return;
303
+ }
304
+ const envPath = path.join(projectPath, '.env');
305
+ if (!await pathExists(envPath)) {
306
+ return;
307
+ }
308
+ const envContent = await fs.readFile(envPath, 'utf8');
309
+ const vars = parseEnvFile(envContent);
310
+ const count = Object.keys(vars).length;
311
+ if (count === 0) {
312
+ return;
313
+ }
314
+ const storage = new StorageManager(path.join(projectPath, config.storage.path), config.storage.encrypted);
315
+ if (password) {
316
+ storage.setPassword(password);
317
+ }
318
+ const now = new Date().toISOString();
319
+ const existing = await storage.load();
320
+ for (const [name, value] of Object.entries(vars)) {
321
+ existing[name] = {
322
+ name,
323
+ value,
324
+ encrypted: config.storage.encrypted,
325
+ created: now,
326
+ updated: now,
327
+ sync_to_env: true,
328
+ protected: false,
329
+ };
330
+ }
331
+ await storage.save(existing);
332
+ if (password && config.session?.enabled !== false) {
333
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
334
+ await sessionManager.init();
335
+ await sessionManager.create(password);
336
+ }
337
+ console.log(chalk.green(` Imported ${count} variables from .env`));
338
+ console.log(chalk.gray(` Variables: ${Object.keys(vars).join(', ')}`));
339
+ }
340
+ async function runInitFlow(options = {}) {
341
+ const projectPath = process.cwd();
342
+ const projectName = options.project || path.basename(projectPath);
160
343
  const configPath = path.join(projectPath, 'envcp.yaml');
161
- if (await pathExists(configPath) && !options.force) {
162
- const vaultRelPath = path.join('.envcp', 'store.enc');
163
- console.log(chalk.yellow('EnvCP is already initialized here.'));
344
+ if (await pathExists(configPath)) {
345
+ console.log(chalk.yellow('EnvCP is already set up here.'));
164
346
  console.log(chalk.gray(` Config: ${configPath}`));
165
- if (await pathExists(path.join(projectPath, vaultRelPath))) {
166
- console.log(chalk.gray(` Vault: ${path.join(projectPath, vaultRelPath)}`));
167
- }
168
- console.log('');
169
- console.log(chalk.gray('Running init again would overwrite your config and may make'));
170
- console.log(chalk.gray('existing encrypted variables permanently inaccessible.'));
171
- console.log('');
172
- console.log(chalk.gray('To reconfigure: edit envcp.yaml directly'));
173
- console.log(chalk.gray('To force re-init (DESTRUCTIVE): envcp init --force'));
174
- process.exit(1);
347
+ console.log(chalk.gray('Run `envcp setup` to change project settings.'));
348
+ return;
175
349
  }
176
- console.log(chalk.blue(useGlobal ? 'Initializing EnvCP (global vault)...' : 'Initializing EnvCP...'));
350
+ console.log(chalk.blue('Initializing EnvCP...'));
177
351
  console.log('');
178
- const config = await initConfig(projectPath, projectName, { global: useGlobal });
179
- // Single security question (or skip if --no-encrypt)
352
+ const config = await initConfig(projectPath, projectName);
353
+ const initMode = await chooseInitMode();
354
+ const hasDotEnv = await pathExists(path.join(projectPath, '.env'));
180
355
  let securityChoice;
181
356
  if (options.encrypt === false) {
182
357
  securityChoice = 'none';
183
358
  }
359
+ else if (initMode === 'basic') {
360
+ securityChoice = isInteractiveCli() && !await promptConfirm('Protect variables with encryption?', true)
361
+ ? 'none'
362
+ : 'recoverable';
363
+ }
184
364
  else {
185
- securityChoice = await promptList('How would you like to secure your variables?', [
186
- { name: 'No encryption (fastest setup, for local dev)', value: 'none' },
187
- { name: 'Encrypted with recovery key (recommended)', value: 'recoverable' },
188
- { name: 'Encrypted hard-lock (max security, no recovery)', value: 'hard-lock' },
189
- ], 'recoverable');
365
+ securityChoice = await chooseSecurityMode();
366
+ }
367
+ const securitySettings = buildSecuritySettings(securityChoice);
368
+ config.encryption = securitySettings.encryption;
369
+ config.storage.encrypted = securitySettings.storageEncrypted;
370
+ config.security = securitySettings.security;
371
+ if (initMode === 'basic') {
372
+ config.session.enabled = false;
373
+ config.access.require_confirmation = false;
374
+ }
375
+ else if (initMode === 'advanced') {
376
+ config.session.enabled = securityChoice === 'none'
377
+ ? false
378
+ : (!isInteractiveCli() || await promptConfirm('Keep an unlocked session between commands?', true));
379
+ config.access.require_confirmation = !isInteractiveCli() || await promptConfirm('Ask before risky AI actions?', true);
190
380
  }
191
- // Apply security choice to config
192
- if (securityChoice === 'none') {
193
- config.encryption = { enabled: false };
194
- config.storage.encrypted = false;
195
- config.security = {
196
- mode: 'recoverable',
197
- recovery_file: '.envcp/.recovery',
198
- brute_force_protection: {
199
- enabled: true,
200
- max_attempts: 5,
201
- lockout_duration: 300,
202
- progressive_delay: true,
203
- max_delay: 60,
204
- permanent_lockout_threshold: 50,
205
- permanent_lockout_action: 'require_recovery_key',
206
- notifications: {}
381
+ else {
382
+ config.session.enabled = securityChoice === 'none'
383
+ ? false
384
+ : (!isInteractiveCli() || await promptConfirm('Enable sessions?', true));
385
+ config.access.require_confirmation = !isInteractiveCli() || await promptConfirm('Require confirmation for risky AI actions?', true);
386
+ }
387
+ let password = '';
388
+ if (securityChoice !== 'none') {
389
+ password = await promptPassword('Set encryption password:');
390
+ const confirmPwd = await promptPassword('Confirm password:');
391
+ if (!secureCompare(Buffer.from(password, 'utf8'), Buffer.from(confirmPwd, 'utf8'))) {
392
+ console.log(chalk.red('Passwords do not match. Aborting.'));
393
+ return;
394
+ }
395
+ }
396
+ await saveConfig(config, projectPath);
397
+ const shouldImportEnv = options.skipEnv
398
+ ? false
399
+ : hasDotEnv && (isInteractiveCli() ? await promptConfirm('Import variables from .env?', true) : false);
400
+ await maybeImportDotEnv(projectPath, config, password, shouldImportEnv);
401
+ const serviceSetup = await buildServiceSetup(projectPath, initMode);
402
+ await installStartupServiceIfNeeded(projectPath, serviceSetup);
403
+ if (securityChoice === 'recoverable' && password) {
404
+ const recoveryKey = generateRecoveryKey();
405
+ const recoveryData = await createRecoveryData(password, recoveryKey);
406
+ const recoveryPath = path.join(projectPath, config.security.recovery_file);
407
+ await fs.writeFile(recoveryPath, recoveryData, 'utf8');
408
+ console.log('');
409
+ console.log(chalk.yellow.bold(' RECOVERY KEY (save this somewhere safe!):'));
410
+ console.log(chalk.yellow.bold(` ${recoveryKey}`));
411
+ console.log(chalk.gray(' This key is shown ONCE. If you lose it, you cannot recover your password.'));
412
+ }
413
+ if (!options.skipMcp) {
414
+ const result = await registerMcpConfig(projectPath);
415
+ console.log('');
416
+ if (result.registered.length > 0) {
417
+ console.log(chalk.green(' MCP registered:'));
418
+ for (const name of result.registered) {
419
+ console.log(chalk.gray(` + ${name}`));
207
420
  }
421
+ }
422
+ if (result.registered.length === 0 && result.alreadyConfigured.length === 0) {
423
+ console.log(chalk.gray(' No AI tools detected for auto-registration'));
424
+ }
425
+ }
426
+ const authMethod = options.authMethod;
427
+ if (password && (authMethod === 'hsm' || authMethod === 'multi')) {
428
+ const hsmType = options.hsmType || 'yubikey';
429
+ config.hsm = {
430
+ ...config.hsm,
431
+ enabled: true,
432
+ type: hsmType,
433
+ key_id: options.keyId ?? config.hsm?.key_id,
434
+ pkcs11_lib: options.pkcs11Lib ?? config.hsm?.pkcs11_lib,
435
+ require_touch: config.hsm?.require_touch ?? true,
436
+ protected_key_path: config.hsm?.protected_key_path ?? '.envcp/.hsm-key',
437
+ };
438
+ config.auth = {
439
+ method: authMethod,
440
+ multi_factors: authMethod === 'multi' ? ['password', 'hsm'] : ['hsm'],
441
+ fallback: 'password',
442
+ };
443
+ await saveConfig(config, projectPath);
444
+ }
445
+ const modeLabel = securityChoice === 'none' ? 'no encryption' : securityChoice;
446
+ console.log(chalk.green('EnvCP initialized!'));
447
+ console.log(chalk.gray(` Project: ${config.project}`));
448
+ console.log(chalk.gray(` Mode: ${initMode}`));
449
+ console.log(chalk.gray(` Security: ${modeLabel}`));
450
+ console.log(chalk.gray(` Session: ${config.session.enabled ? 'on' : 'off'}`));
451
+ console.log('');
452
+ console.log(chalk.green('Done! Your AI tools can now use EnvCP.'));
453
+ }
454
+ async function runAddSecretFlow() {
455
+ const name = await promptInput('Secret name:');
456
+ if (!name.trim()) {
457
+ console.log(chalk.yellow('No secret name entered.'));
458
+ return;
459
+ }
460
+ await withSession(async (storage, _password, config) => {
461
+ const value = await promptPassword('Enter value:');
462
+ const tagsInput = await promptInput('Tags (comma-separated):');
463
+ const description = await promptInput('Description:');
464
+ const tags = tagsInput.split(',').map((t) => t.trim()).filter(Boolean);
465
+ const now = new Date().toISOString();
466
+ const variable = {
467
+ name: name.trim(),
468
+ value,
469
+ encrypted: config.storage.encrypted,
470
+ tags: tags.length > 0 ? tags : undefined,
471
+ description: description || undefined,
472
+ created: now,
473
+ updated: now,
474
+ sync_to_env: true,
475
+ protected: false,
208
476
  };
477
+ await storage.set(variable.name, variable);
478
+ console.log(chalk.green(`Variable '${variable.name}' added successfully`));
479
+ });
480
+ }
481
+ async function runConfigMenu() {
482
+ const projectPath = process.cwd();
483
+ const config = await loadConfig(projectPath);
484
+ const serviceConfig = await loadServiceConfig();
485
+ if (!isInteractiveCli()) {
486
+ console.log(chalk.blue('EnvCP config'));
487
+ console.log(chalk.gray(` Session: ${config.session.enabled ? 'on' : 'off'}`));
488
+ console.log(chalk.gray(` Sync: ${config.sync.enabled ? 'on' : 'off'}`));
489
+ console.log(chalk.gray(` Audit: ${config.audit.enabled ? 'on' : 'off'}`));
490
+ console.log(chalk.gray(` Startup: ${await summarizeServiceStatus()}`));
491
+ return;
492
+ }
493
+ while (true) {
494
+ const choice = await promptTabbedMenu('EnvCP config', [
495
+ {
496
+ label: 'General',
497
+ items: [
498
+ { label: 'Toggle session', value: 'general.session', hint: config.session.enabled ? '(on)' : '(off)' },
499
+ { label: 'Toggle sync to .env', value: 'general.sync', hint: config.sync.enabled ? '(on)' : '(off)' },
500
+ { label: 'Toggle startup service', value: 'general.startup', hint: serviceConfig.autostart ? '(on)' : '(off)' },
501
+ { label: 'Toggle masked values', value: 'general.mask', hint: config.access.mask_values ? '(on)' : '(off)' },
502
+ { label: 'Back', value: 'general.back' },
503
+ ],
504
+ },
505
+ {
506
+ label: 'Advanced',
507
+ items: [
508
+ { label: 'Toggle audit logging', value: 'advanced.audit', hint: config.audit.enabled ? '(on)' : '(off)' },
509
+ { label: 'Toggle AI confirmation', value: 'advanced.confirm', hint: config.access.require_confirmation ? '(on)' : '(off)' },
510
+ { label: 'Show startup status', value: 'advanced.startup-status', hint: '(service)' },
511
+ { label: 'Show config path', value: 'advanced.path', hint: '(envcp.yaml)' },
512
+ { label: 'Reload config guard', value: 'advanced.reload', hint: '(password required)' },
513
+ { label: 'Back', value: 'advanced.back' },
514
+ ],
515
+ },
516
+ ]);
517
+ if (choice.endsWith('.back')) {
518
+ return;
519
+ }
520
+ if (choice === 'general.session') {
521
+ config.session.enabled = !config.session.enabled;
522
+ await saveConfig(config, projectPath);
523
+ continue;
524
+ }
525
+ if (choice === 'general.sync') {
526
+ config.sync.enabled = !config.sync.enabled;
527
+ await saveConfig(config, projectPath);
528
+ continue;
529
+ }
530
+ if (choice === 'general.startup') {
531
+ serviceConfig.autostart = !serviceConfig.autostart;
532
+ serviceConfig.working_directory = projectPath;
533
+ await saveServiceConfig(serviceConfig);
534
+ const result = serviceConfig.autostart
535
+ ? await installService({ workingDirectory: projectPath })
536
+ : await uninstallService();
537
+ console.log(result.ok ? chalk.green(result.message) : chalk.yellow(result.message));
538
+ continue;
539
+ }
540
+ if (choice === 'general.mask') {
541
+ config.access.mask_values = !config.access.mask_values;
542
+ await saveConfig(config, projectPath);
543
+ continue;
544
+ }
545
+ if (choice === 'advanced.audit') {
546
+ config.audit.enabled = !config.audit.enabled;
547
+ await saveConfig(config, projectPath);
548
+ continue;
549
+ }
550
+ if (choice === 'advanced.confirm') {
551
+ config.access.require_confirmation = !config.access.require_confirmation;
552
+ await saveConfig(config, projectPath);
553
+ continue;
554
+ }
555
+ if (choice === 'advanced.startup-status') {
556
+ console.log(chalk.gray(await summarizeServiceStatus()));
557
+ await promptInput('Press Enter to continue');
558
+ continue;
559
+ }
560
+ if (choice === 'advanced.path') {
561
+ console.log(chalk.gray(path.join(projectPath, 'envcp.yaml')));
562
+ await promptInput('Press Enter to continue');
563
+ continue;
564
+ }
565
+ if (choice === 'advanced.reload') {
566
+ const configGuard = new ConfigGuard(projectPath);
567
+ const password = await promptPassword('Enter password to reload config:');
568
+ const result = await configGuard.reload(password);
569
+ console.log(result.success ? chalk.green('Config reloaded successfully') : chalk.red(result.error || 'Failed to reload config'));
570
+ }
571
+ }
572
+ }
573
+ function parseVariableRuleField(operation) {
574
+ switch (operation) {
575
+ case 'read':
576
+ return 'allow_ai_read';
577
+ case 'write':
578
+ return 'allow_ai_write';
579
+ case 'delete':
580
+ return 'allow_ai_delete';
581
+ case 'export':
582
+ return 'allow_ai_export';
583
+ case 'run':
584
+ case 'execute':
585
+ return 'allow_ai_execute';
586
+ case 'confirm':
587
+ case 'confirmation':
588
+ return 'require_confirmation';
589
+ default:
590
+ return null;
591
+ }
592
+ }
593
+ function parseDefaultRuleField(operation) {
594
+ switch (operation) {
595
+ case 'list':
596
+ case 'show':
597
+ case 'names':
598
+ case 'active-check':
599
+ case 'active_check':
600
+ return 'allow_ai_active_check';
601
+ default:
602
+ return parseVariableRuleField(operation);
603
+ }
604
+ }
605
+ function formatRuleSetting(value, enabledLabel = 'allow', disabledLabel = 'deny') {
606
+ return value ? enabledLabel : disabledLabel;
607
+ }
608
+ function formatVariableRuleLines(rule) {
609
+ const lines = [];
610
+ const fields = [
611
+ ['allow_ai_read', 'read', 'allow', 'deny'],
612
+ ['allow_ai_write', 'write', 'allow', 'deny'],
613
+ ['allow_ai_delete', 'delete', 'allow', 'deny'],
614
+ ['allow_ai_export', 'export', 'allow', 'deny'],
615
+ ['allow_ai_execute', 'run', 'allow', 'deny'],
616
+ ['require_confirmation', 'confirmation', 'on', 'off'],
617
+ ];
618
+ for (const [field, label, enabledLabel, disabledLabel] of fields) {
619
+ if (typeof rule[field] === 'boolean') {
620
+ lines.push(`${label}: ${formatRuleSetting(rule[field], enabledLabel, disabledLabel)}`);
621
+ }
622
+ }
623
+ if (rule.active_window) {
624
+ lines.push(`active: ${rule.active_window.start}-${rule.active_window.end}`);
625
+ }
626
+ return lines.length > 0 ? lines : ['inherit all defaults'];
627
+ }
628
+ function formatClientRuleLines(rule) {
629
+ const lines = [];
630
+ const fields = [
631
+ ['allow_ai_read', 'read', 'allow', 'deny'],
632
+ ['allow_ai_write', 'write', 'allow', 'deny'],
633
+ ['allow_ai_delete', 'delete', 'allow', 'deny'],
634
+ ['allow_ai_export', 'export', 'allow', 'deny'],
635
+ ['allow_ai_execute', 'run', 'allow', 'deny'],
636
+ ['allow_ai_active_check', 'list names', 'allow', 'deny'],
637
+ ['require_confirmation', 'confirmation', 'on', 'off'],
638
+ ];
639
+ for (const [field, label, enabledLabel, disabledLabel] of fields) {
640
+ if (typeof rule[field] === 'boolean') {
641
+ lines.push(`${label}: ${formatRuleSetting(rule[field], enabledLabel, disabledLabel)}`);
642
+ }
643
+ }
644
+ return lines.length > 0 ? lines : ['inherit global defaults'];
645
+ }
646
+ function describeClientId(clientId) {
647
+ switch (clientId) {
648
+ case 'openai':
649
+ return 'OpenAI-compatible';
650
+ case 'gemini':
651
+ return 'Gemini-compatible';
652
+ case 'mcp':
653
+ return 'MCP client';
654
+ case 'api':
655
+ return 'REST API client';
656
+ case 'cli':
657
+ return 'EnvCP CLI';
658
+ default:
659
+ return 'Custom client';
660
+ }
661
+ }
662
+ function formatClientLabel(clientId) {
663
+ return `${describeClientId(clientId)} (${clientId})`;
664
+ }
665
+ function getClientRule(config, clientId) {
666
+ return {
667
+ ...(config.access.client_rules?.[clientId] || {}),
668
+ variable_rules: {
669
+ ...(config.access.client_rules?.[clientId]?.variable_rules || {}),
670
+ },
671
+ };
672
+ }
673
+ function saveClientRule(config, clientId, rule) {
674
+ const nextRules = { ...(config.access.client_rules || {}) };
675
+ const hasDefaults = [
676
+ rule.allow_ai_read,
677
+ rule.allow_ai_write,
678
+ rule.allow_ai_delete,
679
+ rule.allow_ai_export,
680
+ rule.allow_ai_execute,
681
+ rule.allow_ai_active_check,
682
+ rule.require_confirmation,
683
+ ].some((value) => typeof value === 'boolean');
684
+ const hasVariables = Object.keys(rule.variable_rules || {}).length > 0;
685
+ if (!hasDefaults && !hasVariables) {
686
+ delete nextRules[clientId];
209
687
  }
210
688
  else {
211
- config.encryption = { enabled: true };
212
- config.storage.encrypted = true;
213
- config.security = {
214
- mode: securityChoice,
215
- recovery_file: '.envcp/.recovery',
216
- brute_force_protection: {
217
- enabled: true,
218
- max_attempts: 5,
219
- lockout_duration: 300,
220
- progressive_delay: true,
221
- max_delay: 60,
222
- permanent_lockout_threshold: 50,
223
- permanent_lockout_action: 'require_recovery_key',
224
- notifications: {},
225
- }
689
+ nextRules[clientId] = {
690
+ ...rule,
691
+ variable_rules: rule.variable_rules || {},
226
692
  };
227
693
  }
228
- // For encrypted modes: get password now
229
- let pwd = '';
230
- if (securityChoice !== 'none') {
231
- const password = await promptPassword('Set encryption password:');
232
- const confirmPwd = await promptPassword('Confirm password:');
233
- // eslint-disable-next-line security/detect-possible-timing-attacks -- comparing two user-typed confirm fields, not a secret-vs-known value
234
- if (password !== confirmPwd) {
235
- console.log(chalk.red('Passwords do not match. Aborting.'));
694
+ config.access.client_rules = nextRules;
695
+ }
696
+ function updateVariableRule(config, variableName, field, value, clientId) {
697
+ if (clientId) {
698
+ const clientRule = getClientRule(config, clientId);
699
+ const current = { ...(clientRule.variable_rules?.[variableName] || {}) };
700
+ if (value === undefined) {
701
+ delete current[field];
702
+ }
703
+ else {
704
+ current[field] = value;
705
+ }
706
+ const nextVariableRules = { ...(clientRule.variable_rules || {}) };
707
+ if (Object.keys(current).length === 0) {
708
+ delete nextVariableRules[variableName];
709
+ }
710
+ else {
711
+ nextVariableRules[variableName] = current;
712
+ }
713
+ clientRule.variable_rules = nextVariableRules;
714
+ saveClientRule(config, clientId, clientRule);
715
+ return;
716
+ }
717
+ const current = { ...(config.access.variable_rules?.[variableName] || {}) };
718
+ if (value === undefined) {
719
+ delete current[field];
720
+ }
721
+ else {
722
+ current[field] = value;
723
+ }
724
+ const nextRules = { ...(config.access.variable_rules || {}) };
725
+ if (Object.keys(current).length === 0) {
726
+ delete nextRules[variableName];
727
+ }
728
+ else {
729
+ nextRules[variableName] = current;
730
+ }
731
+ config.access.variable_rules = nextRules;
732
+ }
733
+ function setVariableRuleWindow(config, variableName, start, end, clientId) {
734
+ if (clientId) {
735
+ const clientRule = getClientRule(config, clientId);
736
+ const current = { ...(clientRule.variable_rules?.[variableName] || {}) };
737
+ if (!start || !end) {
738
+ delete current.active_window;
739
+ }
740
+ else {
741
+ current.active_window = { start, end };
742
+ }
743
+ const nextVariableRules = { ...(clientRule.variable_rules || {}) };
744
+ if (Object.keys(current).length === 0) {
745
+ delete nextVariableRules[variableName];
746
+ }
747
+ else {
748
+ nextVariableRules[variableName] = current;
749
+ }
750
+ clientRule.variable_rules = nextVariableRules;
751
+ saveClientRule(config, clientId, clientRule);
752
+ return;
753
+ }
754
+ const current = { ...(config.access.variable_rules?.[variableName] || {}) };
755
+ if (!start || !end) {
756
+ delete current.active_window;
757
+ }
758
+ else {
759
+ current.active_window = { start, end };
760
+ }
761
+ const nextRules = { ...(config.access.variable_rules || {}) };
762
+ if (Object.keys(current).length === 0) {
763
+ delete nextRules[variableName];
764
+ }
765
+ else {
766
+ nextRules[variableName] = current;
767
+ }
768
+ config.access.variable_rules = nextRules;
769
+ }
770
+ async function promptClientRuleTarget() {
771
+ const target = await promptMenu('Apply rule to', [
772
+ { label: 'Everyone', value: 'global' },
773
+ { label: 'One client / who', value: 'client' },
774
+ ], 'global');
775
+ if (target !== 'client') {
776
+ return undefined;
777
+ }
778
+ const clientId = (await promptInput('Client id (examples: mcp, openai, gemini, api, cursor):')).trim();
779
+ return clientId || undefined;
780
+ }
781
+ async function editVariableRule(config, clientId) {
782
+ const variableName = (await promptInput('Variable name:')).trim();
783
+ if (!variableName) {
784
+ return;
785
+ }
786
+ const field = await promptMenu(`Rule for ${variableName}`, [
787
+ { label: 'AI read', value: 'allow_ai_read' },
788
+ { label: 'AI write', value: 'allow_ai_write' },
789
+ { label: 'AI delete', value: 'allow_ai_delete' },
790
+ { label: 'AI export', value: 'allow_ai_export' },
791
+ { label: 'AI run', value: 'allow_ai_execute' },
792
+ { label: 'Require confirmation', value: 'require_confirmation' },
793
+ ]);
794
+ const value = await promptMenu(`Set ${field} for ${variableName}`, [
795
+ { label: 'Allow', value: 'allow' },
796
+ { label: 'Deny', value: 'deny' },
797
+ { label: 'Inherit default', value: 'inherit' },
798
+ ], 'inherit');
799
+ updateVariableRule(config, variableName, field, value === 'inherit' ? undefined : value === 'allow', clientId);
800
+ }
801
+ async function removeVariableRule(config, clientId) {
802
+ const variableName = (await promptInput('Variable rule to remove:')).trim();
803
+ if (!variableName) {
804
+ return;
805
+ }
806
+ if (clientId) {
807
+ const clientRule = getClientRule(config, clientId);
808
+ const nextVariableRules = { ...(clientRule.variable_rules || {}) };
809
+ delete nextVariableRules[variableName];
810
+ clientRule.variable_rules = nextVariableRules;
811
+ saveClientRule(config, clientId, clientRule);
812
+ return;
813
+ }
814
+ const nextRules = { ...(config.access.variable_rules || {}) };
815
+ delete nextRules[variableName];
816
+ config.access.variable_rules = nextRules;
817
+ }
818
+ async function editVariableRuleWindow(config, clientId) {
819
+ const variableName = (await promptInput('Variable name:')).trim();
820
+ if (!variableName) {
821
+ return;
822
+ }
823
+ const start = (await promptInput('Allowed from (HH:MM):')).trim();
824
+ const end = (await promptInput('Allowed until (HH:MM):')).trim();
825
+ setVariableRuleWindow(config, variableName, start, end, clientId);
826
+ }
827
+ function applyDefaultRule(config, field, value, clientId) {
828
+ if (clientId) {
829
+ const clientRule = getClientRule(config, clientId);
830
+ if (value === undefined) {
831
+ delete clientRule[field];
832
+ }
833
+ else {
834
+ clientRule[field] = value;
835
+ }
836
+ saveClientRule(config, clientId, clientRule);
837
+ return;
838
+ }
839
+ switch (field) {
840
+ case 'allow_ai_read':
841
+ config.access.allow_ai_read = value === true;
842
+ break;
843
+ case 'allow_ai_write':
844
+ config.access.allow_ai_write = value === true;
845
+ break;
846
+ case 'allow_ai_delete':
847
+ config.access.allow_ai_delete = value === true;
848
+ break;
849
+ case 'allow_ai_export':
850
+ config.access.allow_ai_export = value === true;
851
+ break;
852
+ case 'allow_ai_execute':
853
+ config.access.allow_ai_execute = value === true;
854
+ break;
855
+ case 'allow_ai_active_check':
856
+ config.access.allow_ai_active_check = value === true;
857
+ break;
858
+ case 'require_confirmation':
859
+ config.access.require_confirmation = value === true;
860
+ break;
861
+ }
862
+ }
863
+ function parseRuleScope(value, fallback) {
864
+ if (!value) {
865
+ return fallback;
866
+ }
867
+ if (value === 'project' || value === 'home' || value === 'merged') {
868
+ return value;
869
+ }
870
+ throw new Error(`Unknown scope '${value}'. Use project, home, or merged.`);
871
+ }
872
+ async function chooseEditableRuleScope() {
873
+ return promptMenu('Rule scope', [
874
+ { label: 'This project', value: 'project' },
875
+ { label: 'Home / whole computer', value: 'home' },
876
+ ], 'project');
877
+ }
878
+ function describeMergedDefaultOrigin(mergedValue, projectValue, homeValue) {
879
+ const sameAsProject = JSON.stringify(mergedValue) === JSON.stringify(projectValue);
880
+ const sameAsHome = JSON.stringify(mergedValue) === JSON.stringify(homeValue);
881
+ if (sameAsProject && sameAsHome) {
882
+ return 'default';
883
+ }
884
+ if (sameAsProject && !sameAsHome) {
885
+ return 'project';
886
+ }
887
+ if (!sameAsProject && sameAsHome) {
888
+ return 'home';
889
+ }
890
+ return 'merged';
891
+ }
892
+ function describeMergedVariableRuleOrigin(name, mergedRule, projectRules, homeRules) {
893
+ const projectRule = projectRules[name];
894
+ const homeRule = homeRules[name];
895
+ if (projectRule && homeRule) {
896
+ return JSON.stringify(mergedRule) === JSON.stringify(projectRule)
897
+ ? 'project overrides home'
898
+ : 'home + project';
899
+ }
900
+ if (projectRule) {
901
+ return 'project';
902
+ }
903
+ if (homeRule) {
904
+ return 'home';
905
+ }
906
+ return 'merged';
907
+ }
908
+ function describeMergedClientRuleOrigin(clientId, mergedRule, projectRules, homeRules) {
909
+ const projectRule = projectRules[clientId];
910
+ const homeRule = homeRules[clientId];
911
+ if (projectRule && homeRule) {
912
+ return JSON.stringify(mergedRule) === JSON.stringify(projectRule)
913
+ ? 'project overrides home'
914
+ : 'home + project';
915
+ }
916
+ if (projectRule) {
917
+ return 'project';
918
+ }
919
+ if (homeRule) {
920
+ return 'home';
921
+ }
922
+ return 'merged';
923
+ }
924
+ async function runRuleMenu() {
925
+ const projectPath = process.cwd();
926
+ let config = await loadScopedConfig(projectPath, 'merged');
927
+ if (!isInteractiveCli()) {
928
+ console.log(chalk.blue('EnvCP rules'));
929
+ console.log(chalk.gray(` AI read: ${config.access.allow_ai_read ? 'allow' : 'deny'}`));
930
+ console.log(chalk.gray(` AI write: ${config.access.allow_ai_write ? 'allow' : 'deny'}`));
931
+ console.log(chalk.gray(` AI delete: ${config.access.allow_ai_delete ? 'allow' : 'deny'}`));
932
+ console.log(chalk.gray(` AI run: ${config.access.allow_ai_execute ? 'allow' : 'deny'}`));
933
+ console.log(chalk.gray(` Variable rules: ${Object.keys(config.access.variable_rules || {}).length}`));
934
+ console.log(chalk.gray(` Who rules: ${Object.keys(config.access.client_rules || {}).length}`));
935
+ return;
936
+ }
937
+ while (true) {
938
+ const choice = await promptTabbedMenu('EnvCP rules', [
939
+ {
940
+ label: 'Defaults',
941
+ items: [
942
+ { label: 'Toggle AI read', value: 'default.read', hint: config.access.allow_ai_read ? '(allow)' : '(deny)' },
943
+ { label: 'Toggle AI write', value: 'default.write', hint: config.access.allow_ai_write ? '(allow)' : '(deny)' },
944
+ { label: 'Toggle AI delete', value: 'default.delete', hint: config.access.allow_ai_delete ? '(allow)' : '(deny)' },
945
+ { label: 'Toggle AI run', value: 'default.run', hint: config.access.allow_ai_execute ? '(allow)' : '(deny)' },
946
+ { label: 'Toggle AI list names', value: 'default.list', hint: config.access.allow_ai_active_check ? '(allow)' : '(deny)' },
947
+ { label: 'Edit one who default rule', value: 'default.client', hint: `(${Object.keys(config.access.client_rules || {}).length} saved)` },
948
+ { label: 'Back', value: 'default.back' },
949
+ ],
950
+ },
951
+ {
952
+ label: 'Variable',
953
+ items: [
954
+ { label: 'Edit one variable rule', value: 'variable.edit', hint: `(${Object.keys(config.access.variable_rules || {}).length} saved)` },
955
+ { label: 'Set active time window', value: 'variable.window', hint: '(HH:MM -> HH:MM)' },
956
+ { label: 'Clear active time window', value: 'variable.clear-window', hint: '(one variable)' },
957
+ { label: 'Remove one variable rule', value: 'variable.remove', hint: '(exact variable name)' },
958
+ { label: 'Show variable rule names', value: 'variable.list', hint: '(saved rules)' },
959
+ { label: 'Back', value: 'variable.back' },
960
+ ],
961
+ },
962
+ {
963
+ label: 'Who',
964
+ items: [
965
+ { label: 'Edit one who variable rule', value: 'who.edit', hint: `(${Object.keys(config.access.client_rules || {}).length} clients)` },
966
+ { label: 'Set who time window', value: 'who.window', hint: '(client + variable)' },
967
+ { label: 'Clear who time window', value: 'who.clear-window', hint: '(client + variable)' },
968
+ { label: 'Remove one who variable rule', value: 'who.remove', hint: '(client + variable)' },
969
+ { label: 'Show who names', value: 'who.list', hint: '(saved clients)' },
970
+ { label: 'Back', value: 'who.back' },
971
+ ],
972
+ },
973
+ ]);
974
+ if (choice.endsWith('.back')) {
236
975
  return;
237
976
  }
238
- pwd = password;
239
- }
240
- await saveConfig(config, projectPath, { global: useGlobal });
241
- const modeLabel = securityChoice === 'none' ? 'no encryption' : securityChoice;
242
- console.log(chalk.green('EnvCP initialized!'));
243
- console.log(chalk.gray(` Project: ${config.project}`));
244
- console.log(chalk.gray(` Security: ${modeLabel}`));
245
- if (securityChoice !== 'none') {
246
- console.log(chalk.gray(` Session timeout: ${config.session?.timeout_minutes || 30} minutes`));
247
- }
248
- // Auto-import .env
249
- if (!options.skipEnv) {
250
- const envPath = path.join(projectPath, '.env');
251
- if (await pathExists(envPath)) {
252
- const envContent = await fs.readFile(envPath, 'utf8');
253
- const vars = parseEnvFile(envContent);
254
- const count = Object.keys(vars).length;
255
- if (count > 0) {
256
- const storage = new StorageManager(path.join(projectPath, config.storage.path), config.storage.encrypted);
257
- if (pwd)
258
- storage.setPassword(pwd);
259
- const now = new Date().toISOString();
260
- const existing = await storage.load();
261
- for (const [name, value] of Object.entries(vars)) {
262
- existing[name] = {
263
- name, value,
264
- encrypted: config.storage.encrypted,
265
- created: now, updated: now,
266
- sync_to_env: true,
267
- protected: false,
268
- };
269
- }
270
- await storage.save(existing);
271
- // Create session for encrypted mode
272
- if (pwd) {
273
- const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
274
- await sessionManager.init();
275
- await sessionManager.create(pwd);
276
- }
277
- console.log(chalk.green(` Imported ${count} variables from .env`));
278
- console.log(chalk.gray(` Variables: ${Object.keys(vars).join(', ')}`));
977
+ if (choice === 'variable.list') {
978
+ config = await loadScopedConfig(projectPath, 'merged');
979
+ const names = Object.keys(config.access.variable_rules || {});
980
+ console.log(names.length > 0 ? chalk.gray(names.join(', ')) : chalk.gray('No variable-specific rules yet.'));
981
+ await promptInput('Press Enter to continue');
982
+ continue;
983
+ }
984
+ if (choice === 'who.list') {
985
+ config = await loadScopedConfig(projectPath, 'merged');
986
+ const names = Object.keys(config.access.client_rules || {});
987
+ console.log(names.length > 0 ? chalk.gray(names.join(', ')) : chalk.gray('No who-specific rules yet.'));
988
+ await promptInput('Press Enter to continue');
989
+ continue;
990
+ }
991
+ const scope = await chooseEditableRuleScope();
992
+ config = await loadScopedConfig(projectPath, scope);
993
+ if (choice === 'default.read') {
994
+ config.access.allow_ai_read = !config.access.allow_ai_read;
995
+ }
996
+ else if (choice === 'default.write') {
997
+ config.access.allow_ai_write = !config.access.allow_ai_write;
998
+ }
999
+ else if (choice === 'default.delete') {
1000
+ config.access.allow_ai_delete = !config.access.allow_ai_delete;
1001
+ }
1002
+ else if (choice === 'default.run') {
1003
+ config.access.allow_ai_execute = !config.access.allow_ai_execute;
1004
+ }
1005
+ else if (choice === 'default.list') {
1006
+ config.access.allow_ai_active_check = !config.access.allow_ai_active_check;
1007
+ }
1008
+ else if (choice === 'default.client') {
1009
+ const clientId = await promptClientRuleTarget();
1010
+ if (clientId) {
1011
+ const field = await promptMenu(`Default rule for ${clientId}`, [
1012
+ { label: 'AI read', value: 'allow_ai_read' },
1013
+ { label: 'AI write', value: 'allow_ai_write' },
1014
+ { label: 'AI delete', value: 'allow_ai_delete' },
1015
+ { label: 'AI export', value: 'allow_ai_export' },
1016
+ { label: 'AI run', value: 'allow_ai_execute' },
1017
+ { label: 'AI list names', value: 'allow_ai_active_check' },
1018
+ { label: 'Require confirmation', value: 'require_confirmation' },
1019
+ ]);
1020
+ const value = await promptMenu(`Set ${field} for ${clientId}`, [
1021
+ { label: 'Allow / on', value: 'allow' },
1022
+ { label: 'Deny / off', value: 'deny' },
1023
+ { label: 'Inherit global default', value: 'inherit' },
1024
+ ], 'inherit');
1025
+ applyDefaultRule(config, field, value === 'inherit' ? undefined : value === 'allow', clientId);
279
1026
  }
280
1027
  }
281
- }
282
- // Generate recovery key for encrypted recoverable mode
283
- if (securityChoice === 'recoverable' && pwd) {
284
- const recoveryKey = generateRecoveryKey();
285
- const recoveryData = await createRecoveryData(pwd, recoveryKey);
286
- const recoveryPath = path.join(projectPath, config.security.recovery_file);
287
- await fs.writeFile(recoveryPath, recoveryData, 'utf8');
288
- console.log('');
289
- console.log(chalk.yellow.bold(' RECOVERY KEY (save this somewhere safe!):'));
290
- console.log(chalk.yellow.bold(` ${recoveryKey}`));
291
- console.log(chalk.gray(' This key is shown ONCE. If you lose it, you cannot recover your password.'));
292
- }
293
- // Auto-register MCP in all detected tools
294
- if (!options.skipMcp) {
295
- const result = await registerMcpConfig(projectPath);
296
- console.log('');
297
- if (result.registered.length > 0) {
298
- console.log(chalk.green(' MCP registered:'));
299
- for (const name of result.registered) {
300
- console.log(chalk.gray(` + ${name}`));
301
- }
1028
+ else if (choice === 'variable.edit') {
1029
+ await editVariableRule(config);
302
1030
  }
303
- if (result.alreadyConfigured.length > 0) {
304
- for (const name of result.alreadyConfigured) {
305
- console.log(chalk.gray(` = ${name} (already configured)`));
306
- }
1031
+ else if (choice === 'variable.window') {
1032
+ await editVariableRuleWindow(config);
307
1033
  }
308
- if (result.manual.length > 0) {
309
- console.log(chalk.gray(' Manual setup needed:'));
310
- for (const name of result.manual) {
311
- console.log(chalk.gray(` ? ${name}`));
1034
+ else if (choice === 'variable.clear-window') {
1035
+ const variable = (await promptInput('Variable name:')).trim();
1036
+ if (variable) {
1037
+ setVariableRuleWindow(config, variable, undefined, undefined);
312
1038
  }
313
1039
  }
314
- if (result.registered.length === 0 && result.alreadyConfigured.length === 0) {
315
- console.log(chalk.gray(' No AI tools detected for auto-registration'));
1040
+ else if (choice === 'variable.remove') {
1041
+ await removeVariableRule(config);
316
1042
  }
317
- }
318
- // Setup HSM authentication if --auth-method hsm|multi provided
319
- const authMethod = options.authMethod;
320
- if (pwd && (authMethod === 'hsm' || authMethod === 'multi')) {
321
- const hsmType = options.hsmType || 'yubikey';
322
- config.hsm = {
323
- ...config.hsm,
324
- enabled: true,
325
- type: hsmType,
326
- key_id: options.keyId ?? config.hsm?.key_id,
327
- pkcs11_lib: options.pkcs11Lib ?? config.hsm?.pkcs11_lib,
328
- require_touch: config.hsm?.require_touch ?? true,
329
- protected_key_path: config.hsm?.protected_key_path ?? '.envcp/.hsm-key',
330
- };
331
- config.auth = {
332
- method: authMethod,
333
- multi_factors: authMethod === 'multi' ? ['password', 'hsm'] : ['hsm'],
334
- fallback: 'password',
335
- };
336
- const hsm = HsmManager.fromConfig(config, projectPath);
337
- if (await hsm.isAvailable()) {
338
- try {
339
- await hsm.protectVaultPassword(pwd);
340
- await saveConfig(config, projectPath, { global: useGlobal });
341
- console.log('');
342
- console.log(chalk.green(` Vault password protected by ${hsm.backendName}`));
343
- console.log(chalk.gray(` Future sessions will authenticate via hardware`));
1043
+ else if (choice === 'who.edit') {
1044
+ const clientId = await promptClientRuleTarget();
1045
+ if (clientId) {
1046
+ await editVariableRule(config, clientId);
344
1047
  }
345
- catch (err) {
346
- console.log(chalk.yellow(` HSM setup warning: ${err instanceof Error ? err.message : String(err)}`));
347
- console.log(chalk.gray(' Falling back to password authentication'));
1048
+ }
1049
+ else if (choice === 'who.window') {
1050
+ const clientId = await promptClientRuleTarget();
1051
+ if (clientId) {
1052
+ await editVariableRuleWindow(config, clientId);
348
1053
  }
349
1054
  }
350
- else {
351
- console.log(chalk.yellow(` HSM device (${hsm.backendName}) not available at init time.`));
352
- console.log(chalk.gray(' Run "envcp unlock --setup-hsm" later to enable hardware authentication.'));
353
- config.auth = { method: authMethod, multi_factors: ['password', 'hsm'], fallback: 'password' };
354
- await saveConfig(config, projectPath, { global: useGlobal });
1055
+ else if (choice === 'who.clear-window') {
1056
+ const clientId = await promptClientRuleTarget();
1057
+ if (clientId) {
1058
+ const variable = (await promptInput('Variable name:')).trim();
1059
+ if (variable) {
1060
+ setVariableRuleWindow(config, variable, undefined, undefined, clientId);
1061
+ }
1062
+ }
355
1063
  }
1064
+ else if (choice === 'who.remove') {
1065
+ const clientId = await promptClientRuleTarget();
1066
+ if (clientId) {
1067
+ await removeVariableRule(config, clientId);
1068
+ }
1069
+ }
1070
+ await saveScopedConfig(config, projectPath, scope);
356
1071
  }
357
- console.log('');
358
- console.log(chalk.green('Done! Your AI tools can now use EnvCP.'));
359
- });
360
- program
361
- .command('unlock')
362
- .description('Unlock EnvCP session with password')
363
- .option('-p, --password <password>', 'Password (will prompt if not provided)')
364
- .option('--recovery-key <key>', 'Recovery key to clear permanent lockout')
365
- .option('--save-to-keychain', 'Save password to OS keychain for auto-unlock')
366
- .option('--setup-hsm', 'Protect vault password with hardware security module')
367
- .option('--hsm-type <type>', 'HSM type: yubikey | gpg | pkcs11 (default: yubikey)')
368
- .option('--key-id <id>', 'GPG key ID or PKCS#11 key label')
369
- .option('--pkcs11-lib <path>', 'Path to PKCS#11 shared library (.so / .dll)')
370
- .option('--global', 'Unlock the global vault at ~/.envcp')
371
- .action(async (options) => {
1072
+ }
1073
+ async function runUnlockFlow(options = {}) {
372
1074
  const { projectPath, config } = await resolveCliContext(options.global ? 'global' : undefined);
373
- let password = options.password;
374
- if (!password) {
375
- password = await promptPassword('Enter password:');
376
- }
1075
+ const password = await promptPassword('Enter password:');
377
1076
  const { valid: passwordValid, warning: passwordWarning } = validatePassword(password, config.password || {});
378
1077
  if (!passwordValid) {
379
- // nosem: no tainted data flows to log
380
- console.log(chalk.red("Invalid password"));
1078
+ console.log(chalk.red('Invalid password'));
381
1079
  return;
382
1080
  }
383
- if (passwordValid && passwordWarning) {
1081
+ if (passwordWarning) {
384
1082
  console.log(chalk.yellow('⚠ Weak password detected'));
385
1083
  }
386
1084
  const sessionDir = path.dirname(resolveSessionPath(projectPath, config));
387
- let lockoutManager = new LockoutManager(path.join(sessionDir, '.lockout'));
388
- // Handle recovery key if provided
1085
+ const lockoutManager = new LockoutManager(path.join(sessionDir, '.lockout'));
389
1086
  if (options.recoveryKey) {
390
1087
  const recoveryPath = path.join(projectPath, config.security?.recovery_file || '.envcp/.recovery');
391
1088
  if (!await pathExists(recoveryPath)) {
@@ -395,10 +1092,8 @@ program
395
1092
  const recoveryData = await fs.readFile(recoveryPath, 'utf8');
396
1093
  try {
397
1094
  await recoverPassword(recoveryData, options.recoveryKey);
398
- // Recovery key is valid - clear permanent lockout
399
1095
  await lockoutManager.clearPermanentLockout();
400
1096
  console.log(chalk.green('Permanent lockout cleared.'));
401
- // Log recovery event
402
1097
  const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
403
1098
  await logManager.init();
404
1099
  await logManager.log({
@@ -411,17 +1106,15 @@ program
411
1106
  session_id: '',
412
1107
  client_id: 'cli',
413
1108
  client_type: 'terminal',
414
- ip: '127.0.0.1'
1109
+ ip: '127.0.0.1',
415
1110
  });
416
1111
  console.log(chalk.yellow('Note: You still need to enter the correct password.'));
417
- // Continue with password prompt
418
1112
  }
419
1113
  catch {
420
1114
  console.log(chalk.red('Invalid recovery key.'));
421
1115
  return;
422
1116
  }
423
1117
  }
424
- // Use new brute_force_protection config if available, fall back to session config
425
1118
  const bfpConfig = config.security?.brute_force_protection;
426
1119
  const lockoutThreshold = bfpConfig?.max_attempts ?? config.session?.lockout_threshold ?? 5;
427
1120
  const lockoutBaseSeconds = bfpConfig?.lockout_duration ?? config.session?.lockout_base_seconds ?? 60;
@@ -430,14 +1123,12 @@ program
430
1123
  const permanentThreshold = bfpConfig?.permanent_lockout_threshold ?? 0;
431
1124
  const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
432
1125
  await sessionManager.init();
433
- // Initialize audit logging
434
1126
  const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
435
1127
  await logManager.init();
436
1128
  const vaultPathForUnlock = await resolveVaultPath(projectPath, config);
437
1129
  const storage = new StorageManager(vaultPathForUnlock, config.storage.encrypted);
438
1130
  storage.setPassword(password);
439
1131
  const storeExists = await storage.exists();
440
- // Check lockout before attempting password verification on existing stores
441
1132
  if (storeExists) {
442
1133
  const lockoutStatus = await lockoutManager.check();
443
1134
  if (lockoutStatus.locked) {
@@ -455,11 +1146,9 @@ program
455
1146
  const recoveryData = await fs.readFile(recoveryPath, 'utf8');
456
1147
  try {
457
1148
  await recoverPassword(recoveryData, recoveryKey);
458
- // Recovery key is valid - clear permanent lockout
459
1149
  await lockoutManager.clearPermanentLockout();
460
1150
  console.log(chalk.green('Permanent lockout cleared. You can now attempt to unlock.'));
461
1151
  console.log(chalk.yellow('Note: You still need to enter the correct password.'));
462
- // Log recovery event
463
1152
  await logManager.log({
464
1153
  timestamp: new Date().toISOString(),
465
1154
  operation: 'unlock',
@@ -470,9 +1159,8 @@ program
470
1159
  session_id: '',
471
1160
  client_id: 'cli',
472
1161
  client_type: 'terminal',
473
- ip: '127.0.0.1'
1162
+ ip: '127.0.0.1',
474
1163
  });
475
- // Continue with password prompt
476
1164
  }
477
1165
  catch {
478
1166
  console.log(chalk.red('Invalid recovery key.'));
@@ -491,12 +1179,10 @@ program
491
1179
  }
492
1180
  if (!storeExists) {
493
1181
  const confirmPasswordValue = await promptPassword('Confirm password:');
494
- // eslint-disable-next-line security/detect-possible-timing-attacks -- comparing two user-typed confirm fields, not a secret-vs-known value
495
- if (confirmPasswordValue !== password) {
1182
+ if (!secureCompare(Buffer.from(confirmPasswordValue, 'utf8'), Buffer.from(password, 'utf8'))) {
496
1183
  console.log(chalk.red('Passwords do not match'));
497
1184
  return;
498
1185
  }
499
- // Generate recovery key for new stores in recoverable mode
500
1186
  if (config.security?.mode === 'recoverable') {
501
1187
  const recoveryPath = path.join(projectPath, config.security.recovery_file || '.envcp/.recovery');
502
1188
  if (!await pathExists(recoveryPath)) {
@@ -515,10 +1201,9 @@ program
515
1201
  try {
516
1202
  await storage.load();
517
1203
  }
518
- catch (error) {
1204
+ catch {
519
1205
  if (storeExists) {
520
1206
  const status = await lockoutManager.recordFailure(lockoutThreshold, lockoutBaseSeconds, progressiveDelay, maxDelay, permanentThreshold);
521
- // Log the failed attempt
522
1207
  await logManager.log({
523
1208
  timestamp: new Date().toISOString(),
524
1209
  operation: 'auth_failure',
@@ -529,12 +1214,11 @@ program
529
1214
  session_id: '',
530
1215
  client_id: 'cli',
531
1216
  client_type: 'terminal',
532
- ip: '127.0.0.1'
1217
+ ip: '127.0.0.1',
533
1218
  });
534
1219
  if (status.permanent_locked) {
535
1220
  console.log(chalk.red.bold('PERMANENT LOCKOUT TRIGGERED: Too many failed attempts.'));
536
1221
  console.log(chalk.red('Recovery key or administrator intervention required.'));
537
- // Log permanent lockout event
538
1222
  await logManager.log({
539
1223
  timestamp: new Date().toISOString(),
540
1224
  operation: 'permanent_lockout',
@@ -545,12 +1229,11 @@ program
545
1229
  session_id: '',
546
1230
  client_id: 'cli',
547
1231
  client_type: 'terminal',
548
- ip: '127.0.0.1'
1232
+ ip: '127.0.0.1',
549
1233
  });
550
1234
  }
551
1235
  else if (status.locked) {
552
1236
  console.log(chalk.red(`Invalid password. Too many failed attempts — locked out for ${status.remaining_seconds} second(s).`));
553
- // Log lockout event
554
1237
  await logManager.log({
555
1238
  timestamp: new Date().toISOString(),
556
1239
  operation: 'lockout_triggered',
@@ -561,13 +1244,12 @@ program
561
1244
  session_id: '',
562
1245
  client_id: 'cli',
563
1246
  client_type: 'terminal',
564
- ip: '127.0.0.1'
1247
+ ip: '127.0.0.1',
565
1248
  });
566
1249
  }
567
1250
  else {
568
1251
  const remaining = lockoutThreshold - status.attempts;
569
1252
  let message = `Invalid password. ${remaining} attempt(s) remaining before lockout.`;
570
- // Show progressive delay if applied
571
1253
  if (status.delay_seconds && status.delay_seconds > 0) {
572
1254
  message += ` (Delayed ${status.delay_seconds}s)`;
573
1255
  }
@@ -579,34 +1261,37 @@ program
579
1261
  }
580
1262
  return;
581
1263
  }
582
- // Successful unlock — clear any lockout state
583
1264
  await lockoutManager.reset();
584
- const session = await sessionManager.create(password);
585
- // Log successful unlock
586
- await logManager.log({
587
- timestamp: new Date().toISOString(),
588
- operation: 'unlock',
589
- variable: '',
590
- source: 'cli',
591
- success: true,
592
- message: 'Session unlocked successfully',
593
- session_id: session.id,
594
- client_id: 'cli',
595
- client_type: 'terminal',
596
- ip: '127.0.0.1'
597
- });
598
- console.log(chalk.green('Session unlocked!'));
599
- console.log(chalk.gray(` Session ID: ${session.id}`));
600
- console.log(chalk.gray(` Expires in: ${config.session?.timeout_minutes || 30} minutes`));
601
- const maxExt = config.session?.max_extensions || 5;
602
- console.log(chalk.gray(` Extensions remaining: ${maxExt - session.extensions}/${maxExt}`));
603
- // Save to keychain if requested
1265
+ if (config.session?.enabled === false) {
1266
+ await sessionManager.destroy();
1267
+ console.log(chalk.green('Password verified.'));
1268
+ console.log(chalk.gray('Session mode is off, so EnvCP will ask again next time.'));
1269
+ }
1270
+ else {
1271
+ const session = await sessionManager.create(password);
1272
+ await logManager.log({
1273
+ timestamp: new Date().toISOString(),
1274
+ operation: 'unlock',
1275
+ variable: '',
1276
+ source: 'cli',
1277
+ success: true,
1278
+ message: 'Session unlocked successfully',
1279
+ session_id: session.id,
1280
+ client_id: 'cli',
1281
+ client_type: 'terminal',
1282
+ ip: '127.0.0.1',
1283
+ });
1284
+ console.log(chalk.green('Session unlocked!'));
1285
+ console.log(chalk.gray(` Session ID: ${session.id}`));
1286
+ console.log(chalk.gray(` Expires in: ${config.session?.timeout_minutes || 30} minutes`));
1287
+ const maxExt = config.session?.max_extensions || 5;
1288
+ console.log(chalk.gray(` Extensions remaining: ${maxExt - session.extensions}/${maxExt}`));
1289
+ }
604
1290
  if (options.saveToKeychain) {
605
1291
  const keychain = new KeychainManager(config.keychain?.service || 'envcp');
606
1292
  if (await keychain.isAvailable()) {
607
1293
  const result = await keychain.storePassword(password, projectPath);
608
1294
  if (result.success) {
609
- // Enable keychain in config
610
1295
  config.keychain = { ...config.keychain, enabled: true };
611
1296
  await saveConfig(config, projectPath);
612
1297
  console.log(chalk.green(`Password saved to ${keychain.backendName}`));
@@ -620,7 +1305,6 @@ program
620
1305
  console.log(chalk.red(`OS keychain not available (${keychain.backendName})`));
621
1306
  }
622
1307
  }
623
- // Setup HSM if requested
624
1308
  if (options.setupHsm) {
625
1309
  const hsmType = options.hsmType || 'yubikey';
626
1310
  config.hsm = {
@@ -649,17 +1333,135 @@ program
649
1333
  console.log(chalk.red(`HSM setup failed: ${err instanceof Error ? err.message : String(err)}`));
650
1334
  }
651
1335
  }
1336
+ }
1337
+ async function runLockFlow(global = false) {
1338
+ const { projectPath, config } = await resolveCliContext(global ? 'global' : undefined);
1339
+ if (config.session?.enabled === false) {
1340
+ console.log(chalk.gray('Session mode is already off.'));
1341
+ return;
1342
+ }
1343
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
1344
+ await sessionManager.init();
1345
+ await sessionManager.destroy();
1346
+ console.log(chalk.green('Session locked'));
1347
+ }
1348
+ async function describeHomeState() {
1349
+ const config = await loadConfig(process.cwd());
1350
+ if (config.encryption?.enabled === false) {
1351
+ return { title: 'EnvCP (ready)', locked: false, sessionEnabled: false };
1352
+ }
1353
+ if (config.session?.enabled === false) {
1354
+ return { title: 'EnvCP (password each time)', locked: false, sessionEnabled: false };
1355
+ }
1356
+ const locked = !await pathExists(resolveSessionPath(process.cwd(), config));
1357
+ return {
1358
+ title: locked ? 'EnvCP (locked)' : 'EnvCP (session active)',
1359
+ locked,
1360
+ sessionEnabled: true,
1361
+ };
1362
+ }
1363
+ async function runInteractiveHome() {
1364
+ if (!await isProjectInitialized()) {
1365
+ console.log(chalk.yellow('EnvCP is not set up yet in this folder.'));
1366
+ await runInitFlow();
1367
+ return;
1368
+ }
1369
+ while (true) {
1370
+ const homeState = await describeHomeState();
1371
+ const choices = [
1372
+ ...(homeState.sessionEnabled
1373
+ ? [{ label: homeState.locked ? 'Unlock' : 'Lock', value: homeState.locked ? 'unlock' : 'lock' }]
1374
+ : []),
1375
+ { label: 'Add secret', value: 'add' },
1376
+ { label: 'Setup project', value: 'setup' },
1377
+ { label: 'Config', value: 'config' },
1378
+ { label: 'Rules', value: 'rules' },
1379
+ { label: 'Exit', value: 'exit' },
1380
+ ];
1381
+ const choice = await promptMenu(homeState.title, choices, choices[0].value);
1382
+ if (choice === 'unlock') {
1383
+ await runUnlockFlow();
1384
+ continue;
1385
+ }
1386
+ if (choice === 'lock') {
1387
+ await runLockFlow();
1388
+ continue;
1389
+ }
1390
+ if (choice === 'add') {
1391
+ await runAddSecretFlow();
1392
+ continue;
1393
+ }
1394
+ if (choice === 'setup') {
1395
+ await runConfigMenu();
1396
+ continue;
1397
+ }
1398
+ if (choice === 'config') {
1399
+ await runConfigMenu();
1400
+ continue;
1401
+ }
1402
+ if (choice === 'rules') {
1403
+ await runRuleMenu();
1404
+ continue;
1405
+ }
1406
+ if (choice === 'exit') {
1407
+ return;
1408
+ }
1409
+ }
1410
+ }
1411
+ const program = new Command();
1412
+ program
1413
+ .name('envcp')
1414
+ .description('Secure environment variable management for AI-assisted coding')
1415
+ .version(VERSION);
1416
+ program
1417
+ .command('init')
1418
+ .description('Set up EnvCP for first-time use')
1419
+ .option('-p, --project <name>', 'Project name')
1420
+ .option('--no-encrypt', 'Skip encryption (passwordless mode)')
1421
+ .option('--skip-env', 'Skip .env auto-import')
1422
+ .option('--skip-mcp', 'Skip MCP auto-registration')
1423
+ .option('--auth-method <method>', 'Authentication method: password | keychain | hsm | multi (default: password)')
1424
+ .option('--hsm-type <type>', 'HSM type for --auth-method hsm|multi: yubikey | gpg | pkcs11')
1425
+ .option('--key-id <id>', 'GPG key ID or PKCS#11 key label for HSM auth')
1426
+ .option('--pkcs11-lib <path>', 'Path to PKCS#11 shared library for --hsm-type pkcs11')
1427
+ .action(async (options) => {
1428
+ await runInitFlow(options);
1429
+ });
1430
+ program
1431
+ .command('setup')
1432
+ .description('Set up or reconfigure the current project')
1433
+ .action(async () => {
1434
+ if (!await isProjectInitialized()) {
1435
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
1436
+ if (path.resolve(process.cwd()) === path.resolve(home)) {
1437
+ console.log(chalk.yellow('envcp setup configures a project folder.'));
1438
+ console.log(chalk.gray('Open your project first, then run `envcp setup`.'));
1439
+ return;
1440
+ }
1441
+ await runInitFlow();
1442
+ return;
1443
+ }
1444
+ await runConfigMenu();
1445
+ });
1446
+ program
1447
+ .command('unlock')
1448
+ .description('Unlock EnvCP session with password')
1449
+ .option('--recovery-key <key>', 'Recovery key to clear permanent lockout')
1450
+ .option('--save-to-keychain', 'Save password to OS keychain for auto-unlock')
1451
+ .option('--setup-hsm', 'Protect vault password with hardware security module')
1452
+ .option('--hsm-type <type>', 'HSM type: yubikey | gpg | pkcs11 (default: yubikey)')
1453
+ .option('--key-id <id>', 'GPG key ID or PKCS#11 key label')
1454
+ .option('--pkcs11-lib <path>', 'Path to PKCS#11 shared library (.so / .dll)')
1455
+ .option('--global', 'Unlock the global vault at ~/.envcp')
1456
+ .action(async (options) => {
1457
+ await runUnlockFlow(options);
652
1458
  });
653
1459
  program
654
1460
  .command('lock')
655
1461
  .description('Lock EnvCP session')
656
1462
  .option('--global', 'Lock the global vault session at ~/.envcp/.session')
657
1463
  .action(async (options) => {
658
- const { projectPath, config } = await resolveCliContext(options.global ? 'global' : undefined);
659
- const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
660
- await sessionManager.init();
661
- await sessionManager.destroy();
662
- console.log(chalk.green('Session locked'));
1464
+ await runLockFlow(!!options.global);
663
1465
  });
664
1466
  program
665
1467
  .command('status')
@@ -667,6 +1469,16 @@ program
667
1469
  .option('--global', 'Check status of the global vault session at ~/.envcp/.session')
668
1470
  .action(async (options) => {
669
1471
  const { projectPath, config } = await resolveCliContext(options.global ? 'global' : undefined);
1472
+ if (config.encryption?.enabled === false) {
1473
+ console.log(chalk.green('Ready'));
1474
+ console.log(chalk.gray(' Encryption is off, so no unlock is needed.'));
1475
+ return;
1476
+ }
1477
+ if (config.session?.enabled === false) {
1478
+ console.log(chalk.yellow('Session mode is off'));
1479
+ console.log(chalk.gray(' EnvCP will ask for the password each time.'));
1480
+ return;
1481
+ }
670
1482
  const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
671
1483
  await sessionManager.init();
672
1484
  const password = await promptPassword('Enter password:');
@@ -683,9 +1495,13 @@ program
683
1495
  console.log(chalk.gray(` Remaining: ${remaining} minutes`));
684
1496
  console.log(chalk.gray(` Extensions remaining: ${maxExt - session.extensions}/${maxExt}`));
685
1497
  });
686
- program
1498
+ const configCommand = program
687
1499
  .command('config')
688
- .description('Config management commands')
1500
+ .description('Configure EnvCP settings')
1501
+ .action(async () => {
1502
+ await runConfigMenu();
1503
+ });
1504
+ configCommand
689
1505
  .command('reload')
690
1506
  .description('Reload config from envcp.yaml (requires password)')
691
1507
  .action(async () => {
@@ -695,12 +1511,190 @@ program
695
1511
  const result = await configGuard.reload(password);
696
1512
  if (result.success) {
697
1513
  console.log(chalk.green('Config reloaded successfully'));
698
- console.log(chalk.gray(' New config hash: ' + configGuard.getHash()?.substring(0, 16) + '...'));
1514
+ console.log(chalk.gray(` New config hash: ${configGuard.getHash()?.substring(0, 16)}...`));
699
1515
  }
700
1516
  else {
701
1517
  console.log(chalk.red(result.error || 'Failed to reload config'));
702
1518
  }
703
1519
  });
1520
+ const ruleCommand = program
1521
+ .command('rule')
1522
+ .description('Manage EnvCP AI access rules')
1523
+ .action(async () => {
1524
+ await runRuleMenu();
1525
+ });
1526
+ ruleCommand
1527
+ .command('list')
1528
+ .description('List default, variable-specific, and who-specific AI rules')
1529
+ .option('--scope <scope>', 'Rule scope: merged | project | home', 'merged')
1530
+ .action(async (options) => {
1531
+ const scope = parseRuleScope(options.scope, 'merged');
1532
+ const config = await loadScopedConfig(process.cwd(), scope);
1533
+ const projectConfig = scope === 'merged' ? await loadScopedConfig(process.cwd(), 'project') : null;
1534
+ const homeConfig = scope === 'merged' ? await loadScopedConfig(process.cwd(), 'home') : null;
1535
+ console.log(chalk.blue('EnvCP rules'));
1536
+ console.log(chalk.gray(` Scope: ${scope}`));
1537
+ const formatOrigin = (origin) => scope === 'merged' ? ` (${origin})` : '';
1538
+ console.log(chalk.gray(` Default read: ${config.access.allow_ai_read ? 'allow' : 'deny'}${formatOrigin(scope === 'merged' ? describeMergedDefaultOrigin(config.access.allow_ai_read, projectConfig.access.allow_ai_read, homeConfig.access.allow_ai_read) : scope)}`));
1539
+ console.log(chalk.gray(` Default write: ${config.access.allow_ai_write ? 'allow' : 'deny'}${formatOrigin(scope === 'merged' ? describeMergedDefaultOrigin(config.access.allow_ai_write, projectConfig.access.allow_ai_write, homeConfig.access.allow_ai_write) : scope)}`));
1540
+ console.log(chalk.gray(` Default delete: ${config.access.allow_ai_delete ? 'allow' : 'deny'}${formatOrigin(scope === 'merged' ? describeMergedDefaultOrigin(config.access.allow_ai_delete, projectConfig.access.allow_ai_delete, homeConfig.access.allow_ai_delete) : scope)}`));
1541
+ console.log(chalk.gray(` Default export: ${config.access.allow_ai_export ? 'allow' : 'deny'}${formatOrigin(scope === 'merged' ? describeMergedDefaultOrigin(config.access.allow_ai_export, projectConfig.access.allow_ai_export, homeConfig.access.allow_ai_export) : scope)}`));
1542
+ console.log(chalk.gray(` Default run: ${config.access.allow_ai_execute ? 'allow' : 'deny'}${formatOrigin(scope === 'merged' ? describeMergedDefaultOrigin(config.access.allow_ai_execute, projectConfig.access.allow_ai_execute, homeConfig.access.allow_ai_execute) : scope)}`));
1543
+ console.log(chalk.gray(` Default list names: ${config.access.allow_ai_active_check ? 'allow' : 'deny'}${formatOrigin(scope === 'merged' ? describeMergedDefaultOrigin(config.access.allow_ai_active_check, projectConfig.access.allow_ai_active_check, homeConfig.access.allow_ai_active_check) : scope)}`));
1544
+ console.log(chalk.gray(` Default confirmation: ${config.access.require_confirmation ? 'on' : 'off'}${formatOrigin(scope === 'merged' ? describeMergedDefaultOrigin(config.access.require_confirmation, projectConfig.access.require_confirmation, homeConfig.access.require_confirmation) : scope)}`));
1545
+ const variableRules = Object.entries(config.access.variable_rules || {});
1546
+ if (variableRules.length === 0) {
1547
+ console.log(chalk.gray(' Variable rules: none'));
1548
+ }
1549
+ else {
1550
+ console.log(chalk.gray(' Variable rules:'));
1551
+ for (const [name, rule] of variableRules) {
1552
+ const origin = scope === 'merged'
1553
+ ? describeMergedVariableRuleOrigin(name, rule, projectConfig.access.variable_rules || {}, homeConfig.access.variable_rules || {})
1554
+ : scope;
1555
+ console.log(chalk.gray(` ${name} [${origin}]`));
1556
+ for (const line of formatVariableRuleLines(rule)) {
1557
+ console.log(chalk.gray(` ${line}`));
1558
+ }
1559
+ }
1560
+ }
1561
+ const clientRules = Object.entries(config.access.client_rules || {});
1562
+ if (clientRules.length === 0) {
1563
+ console.log(chalk.gray(' Who rules: none'));
1564
+ return;
1565
+ }
1566
+ console.log(chalk.gray(' Who rules:'));
1567
+ for (const [clientId, rule] of clientRules) {
1568
+ const origin = scope === 'merged'
1569
+ ? describeMergedClientRuleOrigin(clientId, rule, projectConfig.access.client_rules || {}, homeConfig.access.client_rules || {})
1570
+ : scope;
1571
+ console.log(chalk.gray(` ${formatClientLabel(clientId)} [${origin}]`));
1572
+ for (const line of formatClientRuleLines(rule)) {
1573
+ console.log(chalk.gray(` ${line}`));
1574
+ }
1575
+ const variableRules = Object.entries(rule.variable_rules || {});
1576
+ if (variableRules.length > 0) {
1577
+ console.log(chalk.gray(' variable rules:'));
1578
+ for (const [name, variableRule] of variableRules) {
1579
+ console.log(chalk.gray(` ${name}`));
1580
+ for (const line of formatVariableRuleLines(variableRule)) {
1581
+ console.log(chalk.gray(` ${line}`));
1582
+ }
1583
+ }
1584
+ }
1585
+ }
1586
+ if (scope === 'merged') {
1587
+ console.log(chalk.gray(' Tip: use `envcp rule set-default ... --who <id>` or `envcp rule set-variable ... --who <id>` to edit one client.'));
1588
+ }
1589
+ });
1590
+ ruleCommand
1591
+ .command('set-default <operation> <mode>')
1592
+ .description('Set a default AI rule, e.g. read allow | list deny | confirm allow')
1593
+ .option('--scope <scope>', 'Rule scope: project | home', 'project')
1594
+ .option('--who <who>', 'Target one client id, e.g. mcp | openai | gemini | api | cursor')
1595
+ .action(async (operation, mode, options) => {
1596
+ const field = parseDefaultRuleField(operation);
1597
+ if (!field) {
1598
+ throw new Error(`Unknown rule operation '${operation}'`);
1599
+ }
1600
+ if (!['allow', 'deny', 'inherit'].includes(mode)) {
1601
+ throw new Error(`Rule mode must be 'allow', 'deny', or 'inherit', got '${mode}'`);
1602
+ }
1603
+ const scope = parseRuleScope(options.scope, 'project');
1604
+ if (scope === 'merged') {
1605
+ throw new Error('set-default cannot write to merged scope. Use project or home.');
1606
+ }
1607
+ if (mode === 'inherit' && !options.who) {
1608
+ throw new Error('inherit is only supported with --who. Global defaults must be allow or deny.');
1609
+ }
1610
+ const config = await loadScopedConfig(process.cwd(), scope);
1611
+ applyDefaultRule(config, field, mode === 'inherit' ? undefined : mode === 'allow', options.who);
1612
+ await saveScopedConfig(config, process.cwd(), scope);
1613
+ const target = options.who ? ` for ${options.who}` : '';
1614
+ console.log(chalk.green(`Default ${operation} rule set to ${mode}${target} in ${scope} scope`));
1615
+ });
1616
+ ruleCommand
1617
+ .command('set-variable <name> <operation> <mode>')
1618
+ .description('Set a variable-specific AI rule, e.g. OPENAI_API_KEY run deny')
1619
+ .option('--scope <scope>', 'Rule scope: project | home', 'project')
1620
+ .option('--who <who>', 'Target one client id, e.g. mcp | openai | gemini | api | cursor')
1621
+ .action(async (name, operation, mode, options) => {
1622
+ const field = parseVariableRuleField(operation);
1623
+ if (!field) {
1624
+ throw new Error(`Unknown rule operation '${operation}'`);
1625
+ }
1626
+ if (!['allow', 'deny', 'inherit'].includes(mode)) {
1627
+ throw new Error(`Rule mode must be 'allow', 'deny', or 'inherit', got '${mode}'`);
1628
+ }
1629
+ const scope = parseRuleScope(options.scope, 'project');
1630
+ if (scope === 'merged') {
1631
+ throw new Error('set-variable cannot write to merged scope. Use project or home.');
1632
+ }
1633
+ const config = await loadScopedConfig(process.cwd(), scope);
1634
+ updateVariableRule(config, name, field, mode === 'inherit' ? undefined : mode === 'allow', options.who);
1635
+ await saveScopedConfig(config, process.cwd(), scope);
1636
+ const target = options.who ? ` for ${options.who}` : '';
1637
+ console.log(chalk.green(`Variable rule for ${name} ${operation} set to ${mode}${target} in ${scope} scope`));
1638
+ });
1639
+ ruleCommand
1640
+ .command('set-window <name> <start> <end>')
1641
+ .description('Set an active time window for a variable rule, e.g. OPENAI_API_KEY 09:00 18:00')
1642
+ .option('--scope <scope>', 'Rule scope: project | home', 'project')
1643
+ .option('--who <who>', 'Target one client id, e.g. mcp | openai | gemini | api | cursor')
1644
+ .action(async (name, start, end, options) => {
1645
+ const scope = parseRuleScope(options.scope, 'project');
1646
+ if (scope === 'merged') {
1647
+ throw new Error('set-window cannot write to merged scope. Use project or home.');
1648
+ }
1649
+ const config = await loadScopedConfig(process.cwd(), scope);
1650
+ setVariableRuleWindow(config, name, start, end, options.who);
1651
+ await saveScopedConfig(config, process.cwd(), scope);
1652
+ const target = options.who ? ` for ${options.who}` : '';
1653
+ console.log(chalk.green(`Variable rule window for ${name} set to ${start}-${end}${target} in ${scope} scope`));
1654
+ });
1655
+ ruleCommand
1656
+ .command('clear-window <name>')
1657
+ .description('Clear the active time window for a variable rule')
1658
+ .option('--scope <scope>', 'Rule scope: project | home', 'project')
1659
+ .option('--who <who>', 'Target one client id, e.g. mcp | openai | gemini | api | cursor')
1660
+ .action(async (name, options) => {
1661
+ const scope = parseRuleScope(options.scope, 'project');
1662
+ if (scope === 'merged') {
1663
+ throw new Error('clear-window cannot write to merged scope. Use project or home.');
1664
+ }
1665
+ const config = await loadScopedConfig(process.cwd(), scope);
1666
+ setVariableRuleWindow(config, name, undefined, undefined, options.who);
1667
+ await saveScopedConfig(config, process.cwd(), scope);
1668
+ const target = options.who ? ` for ${options.who}` : '';
1669
+ console.log(chalk.green(`Cleared variable rule window for ${name}${target} in ${scope} scope`));
1670
+ });
1671
+ ruleCommand
1672
+ .command('remove-variable <name>')
1673
+ .description('Remove all variable-specific rules for one variable')
1674
+ .option('--scope <scope>', 'Rule scope: project | home', 'project')
1675
+ .option('--who <who>', 'Target one client id, e.g. mcp | openai | gemini | api | cursor')
1676
+ .action(async (name, options) => {
1677
+ const scope = parseRuleScope(options.scope, 'project');
1678
+ if (scope === 'merged') {
1679
+ throw new Error('remove-variable cannot write to merged scope. Use project or home.');
1680
+ }
1681
+ const config = await loadScopedConfig(process.cwd(), scope);
1682
+ if (options.who) {
1683
+ const clientRule = getClientRule(config, options.who);
1684
+ const nextVariableRules = { ...(clientRule.variable_rules || {}) };
1685
+ delete nextVariableRules[name];
1686
+ clientRule.variable_rules = nextVariableRules;
1687
+ saveClientRule(config, options.who, clientRule);
1688
+ }
1689
+ else {
1690
+ const nextRules = { ...(config.access.variable_rules || {}) };
1691
+ delete nextRules[name];
1692
+ config.access.variable_rules = nextRules;
1693
+ }
1694
+ await saveScopedConfig(config, process.cwd(), scope);
1695
+ const target = options.who ? ` for ${options.who}` : '';
1696
+ console.log(chalk.green(`Removed variable-specific rules for ${name}${target} in ${scope} scope`));
1697
+ });
704
1698
  program
705
1699
  .command('extend')
706
1700
  .description('Extend session timeout')
@@ -1003,13 +1997,13 @@ program
1003
1997
  continue;
1004
1998
  const excluded = config.sync.exclude?.some((pattern) => {
1005
1999
  // eslint-disable-next-line security/detect-non-literal-regexp -- glob pattern from config; metacharacters escaped above
1006
- const regex = new RegExp('^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
2000
+ const regex = new RegExp('^' + pattern.replaceAll(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*') + '$');
1007
2001
  return regex.test(name);
1008
2002
  });
1009
2003
  if (excluded)
1010
2004
  continue;
1011
2005
  const needsQuoting = /[\s#"'\\]/.test(variable.value);
1012
- const val = needsQuoting ? `"${variable.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : variable.value;
2006
+ const val = needsQuoting ? `"${variable.value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : variable.value;
1013
2007
  lines.push(`${name}=${val}`);
1014
2008
  }
1015
2009
  if (options.dryRun) {
@@ -1029,7 +2023,7 @@ program
1029
2023
  continue;
1030
2024
  const excluded = config.sync.exclude?.some((pattern) => {
1031
2025
  // eslint-disable-next-line security/detect-non-literal-regexp -- glob pattern from config; metacharacters escaped above
1032
- const regex = new RegExp('^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
2026
+ const regex = new RegExp('^' + pattern.replaceAll(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*') + '$');
1033
2027
  return regex.test(name);
1034
2028
  });
1035
2029
  if (excluded)
@@ -1073,7 +2067,6 @@ program
1073
2067
  program
1074
2068
  .command('serve')
1075
2069
  .description('Start EnvCP server')
1076
- .option('-p, --password <password>', 'Encryption password')
1077
2070
  .option('-m, --mode <mode>', 'Server mode: mcp, rest, openai, gemini, all, auto', 'auto')
1078
2071
  .option('--port <port>', 'HTTP port (for non-MCP modes)', '3456')
1079
2072
  .option('--host <host>', 'HTTP host', '127.0.0.1')
@@ -1113,10 +2106,10 @@ program
1113
2106
  const vaultPath = await resolveVaultPath(projectPath, config);
1114
2107
  const sessionPath = resolveSessionPath(projectPath, config);
1115
2108
  const mode = options.mode;
1116
- const port = parseInt(options.port, 10);
2109
+ const port = Number.parseInt(options.port, 10);
1117
2110
  const host = options.host;
1118
2111
  const apiKey = options.apiKey;
1119
- let password = options.password || '';
2112
+ let password = '';
1120
2113
  // Passwordless mode: skip all session/password logic
1121
2114
  if (config.encryption?.enabled === false) {
1122
2115
  if (mode === 'mcp') {
@@ -1134,7 +2127,7 @@ program
1134
2127
  if (!session && !password) {
1135
2128
  // MCP mode uses stdio — can't prompt interactively
1136
2129
  if (mode === 'mcp') {
1137
- process.stderr.write(`Error: No active session at ${sessionPath}. Run \`envcp unlock${forceGlobalMode ? ' --global' : ''}\` first, or use --password flag.\n`);
2130
+ process.stderr.write(`Error: No active session at ${sessionPath}. Run \`envcp unlock${forceGlobalMode ? ' --global' : ''}\` first.\n`);
1138
2131
  process.exit(1);
1139
2132
  }
1140
2133
  password = await promptPassword('Enter password:');
@@ -1258,7 +2251,7 @@ program
1258
2251
  default: {
1259
2252
  const lines = Object.entries(variables).map(([k, v]) => {
1260
2253
  const needsQuoting = /[\s#"'\\]/.test(v.value);
1261
- const val = needsQuoting ? `"${v.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : v.value;
2254
+ const val = needsQuoting ? `"${v.value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : v.value;
1262
2255
  return `${k}=${val}`;
1263
2256
  });
1264
2257
  output = lines.join('\n');
@@ -1289,20 +2282,12 @@ program
1289
2282
  console.log(chalk.red('Failed to decrypt. Wrong password or invalid file.'));
1290
2283
  return;
1291
2284
  }
1292
- const meta = importData.meta;
1293
- const variables = importData.variables;
1294
- if (!variables || typeof variables !== 'object') {
1295
- console.log(chalk.red('Invalid export format'));
2285
+ const transferData = extractTransferVariables(importData, 'Invalid export format');
2286
+ if (!transferData) {
1296
2287
  return;
1297
2288
  }
1298
- if (meta) {
1299
- console.log(chalk.blue('Import info:'));
1300
- if (meta.project)
1301
- console.log(chalk.gray(` From project: ${meta.project}`));
1302
- if (meta.timestamp)
1303
- console.log(chalk.gray(` Exported: ${meta.timestamp}`));
1304
- console.log(chalk.gray(` Variables: ${meta.count || Object.keys(variables).length}`));
1305
- }
2289
+ const { meta, variables } = transferData;
2290
+ logTransferInfo('Import info:', meta, variables, { project: 'From project', timestamp: 'Exported' });
1306
2291
  if (options.dryRun) {
1307
2292
  const current = await storage.load();
1308
2293
  const importNames = Object.keys(variables);
@@ -1367,7 +2352,7 @@ program
1367
2352
  console.log(chalk.yellow('No variables to backup'));
1368
2353
  return;
1369
2354
  }
1370
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
2355
+ const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
1371
2356
  const defaultPath = path.join(projectPath, `.envcp/backup-${timestamp}.enc`);
1372
2357
  const outputPath = options.output || defaultPath;
1373
2358
  const backupData = JSON.stringify({
@@ -1407,20 +2392,12 @@ program
1407
2392
  console.log(chalk.red('Failed to decrypt backup. Wrong password or corrupted file.'));
1408
2393
  return;
1409
2394
  }
1410
- const meta = backupData.meta;
1411
- const variables = backupData.variables;
1412
- if (!variables || typeof variables !== 'object') {
1413
- console.log(chalk.red('Invalid backup format'));
2395
+ const transferData = extractTransferVariables(backupData, 'Invalid backup format');
2396
+ if (!transferData) {
1414
2397
  return;
1415
2398
  }
1416
- if (meta) {
1417
- console.log(chalk.blue('Backup info:'));
1418
- if (meta.project)
1419
- console.log(chalk.gray(` Project: ${meta.project}`));
1420
- if (meta.timestamp)
1421
- console.log(chalk.gray(` Created: ${meta.timestamp}`));
1422
- console.log(chalk.gray(` Variables: ${meta.count || Object.keys(variables).length}`));
1423
- }
2399
+ const { meta, variables } = transferData;
2400
+ logTransferInfo('Backup info:', meta, variables, { project: 'Project', timestamp: 'Created' });
1424
2401
  const confirm = await promptConfirm(options.merge ? 'Merge backup into current store?' : 'Replace current store with backup?', false);
1425
2402
  if (!confirm) {
1426
2403
  console.log(chalk.yellow('Restore cancelled'));
@@ -1547,35 +2524,112 @@ program
1547
2524
  });
1548
2525
  program
1549
2526
  .command('update')
1550
- .description('Check for EnvCP updates')
1551
- .option('--check', 'Check for available updates')
2527
+ .description('Check for or install EnvCP updates')
2528
+ .option('-l, --latest', 'List and install latest stable versions')
2529
+ .option('-e, --experimental', 'List and install experimental versions')
2530
+ .option('-c, --canary', 'List and install canary versions')
2531
+ .option('--backup', 'Backup vault before installing')
1552
2532
  .action(async (options) => {
1553
2533
  const projectPath = process.cwd();
1554
- if (options.check || Object.keys(options).length === 0) {
2534
+ const home = os.homedir();
2535
+ const channel = options.latest ? 'latest' :
2536
+ options.experimental ? 'experimental' :
2537
+ options.canary ? 'canary' : null;
2538
+ if (!channel) {
2539
+ // Original behaviour: just check and notify
1555
2540
  console.log(chalk.blue('Checking for updates...'));
1556
2541
  try {
1557
2542
  const info = await checkForUpdate(projectPath);
1558
2543
  const message = formatUpdateMessage(info);
1559
2544
  await logUpdateCheck(projectPath, info);
1560
2545
  if (info.updateAvailable) {
1561
- if (info.critical) {
1562
- console.log(chalk.red.bold(message));
1563
- }
1564
- else {
1565
- console.log(chalk.yellow(message));
1566
- }
2546
+ console.log(info.critical ? chalk.red.bold(message) : chalk.yellow(message));
1567
2547
  }
1568
2548
  else {
1569
2549
  console.log(chalk.green(message));
1570
2550
  }
1571
2551
  }
1572
- catch (error) {
2552
+ catch {
1573
2553
  console.log(chalk.yellow('Could not check for updates (offline or rate-limited)'));
1574
- console.log(chalk.gray(` Current version: v${require('../../package.json').version}`));
1575
2554
  }
2555
+ return;
2556
+ }
2557
+ console.log(chalk.blue(`Fetching ${channel} releases...`));
2558
+ let releases;
2559
+ try {
2560
+ const all = await fetchReleases();
2561
+ releases = filterByChannel(all, channel).slice(0, 3);
2562
+ }
2563
+ catch {
2564
+ console.log(chalk.red('Could not fetch releases (offline or rate-limited)'));
2565
+ return;
2566
+ }
2567
+ if (releases.length === 0) {
2568
+ console.log(chalk.yellow(`No ${channel} releases found.`));
2569
+ return;
2570
+ }
2571
+ console.log(chalk.bold(`\n Available ${channel} versions:\n`));
2572
+ releases.forEach((r, i) => {
2573
+ console.log(chalk.cyan(` [${i + 1}] v${r.tag}`));
2574
+ });
2575
+ console.log(chalk.gray('\n [0] Cancel\n'));
2576
+ const answer = await promptInput('Pick a version (0 to cancel):');
2577
+ const picked = Number.parseInt(answer.trim(), 10);
2578
+ if (!picked || picked < 1 || picked > releases.length) {
2579
+ console.log(chalk.gray('Cancelled.'));
2580
+ return;
2581
+ }
2582
+ const chosen = releases[picked - 1];
2583
+ if (options.backup) {
2584
+ const backupPath = path.join(home, `.envcp.bak.${Date.now()}`);
2585
+ const vaultDir = path.join(home, '.envcp');
2586
+ const confirmed = await promptConfirm(`Backup ~/.envcp → ${backupPath}?`);
2587
+ if (confirmed) {
2588
+ try {
2589
+ await fs.cp(vaultDir, backupPath, { recursive: true });
2590
+ console.log(chalk.green(` ✓ Backed up to ${backupPath}`));
2591
+ }
2592
+ catch (e) {
2593
+ console.log(chalk.red(` Backup failed: ${e.message}`));
2594
+ return;
2595
+ }
2596
+ }
2597
+ }
2598
+ console.log(chalk.blue(`\nInstalling @fentz26/envcp@${chosen.tag}...`));
2599
+ const result = spawnSync('npm', ['install', '-g', `@fentz26/envcp@${chosen.tag}`], { stdio: 'inherit' });
2600
+ if (result.status === 0) {
2601
+ console.log(chalk.green(`\n ✓ Installed v${chosen.tag}`));
2602
+ }
2603
+ else {
2604
+ console.log(chalk.red('\n Installation failed.'));
1576
2605
  }
1577
2606
  });
1578
- program
2607
+ async function switchVaultContext(name) {
2608
+ const projectPath = process.cwd();
2609
+ if (name !== 'global' && name !== 'project') {
2610
+ const vaultDir = path.join(projectPath, '.envcp/vaults', name);
2611
+ if (!await pathExists(vaultDir)) {
2612
+ console.log(chalk.red(`Named vault "${name}" does not exist. Create it with: envcp vault --name ${name} init`));
2613
+ return;
2614
+ }
2615
+ }
2616
+ await setActiveVault(projectPath, name);
2617
+ console.log(chalk.green(`Switched to vault: ${name}`));
2618
+ }
2619
+ async function listVaultContexts() {
2620
+ const projectPath = process.cwd();
2621
+ const config = await loadConfig(projectPath);
2622
+ const vaults = await listVaults(projectPath, config);
2623
+ console.log('Available vaults:');
2624
+ for (const vault of vaults) {
2625
+ const active = vault.active ? chalk.green(' (active)') : '';
2626
+ const exists = await pathExists(vault.path);
2627
+ const status = exists ? '' : chalk.gray(' [not initialized]');
2628
+ console.log(` ${vault.name}${active}${status}`);
2629
+ console.log(chalk.gray(` ${vault.path}`));
2630
+ }
2631
+ }
2632
+ const vaultCommand = program
1579
2633
  .command('vault')
1580
2634
  .description('Manage vaults (global, project, or named)')
1581
2635
  .option('--global', 'Operate on the global vault')
@@ -1684,38 +2738,18 @@ program
1684
2738
  }
1685
2739
  }, vaultOverride);
1686
2740
  }));
1687
- program
1688
- .command('vault-switch')
2741
+ vaultCommand
2742
+ .addCommand(new Command('use')
1689
2743
  .description('Switch active vault context')
1690
2744
  .argument('<name>', 'Vault name (global, project, or named vault)')
1691
2745
  .action(async (name) => {
1692
- const projectPath = process.cwd();
1693
- if (name !== 'global' && name !== 'project') {
1694
- const vaultDir = path.join(projectPath, '.envcp/vaults', name);
1695
- if (!await pathExists(vaultDir)) {
1696
- console.log(chalk.red(`Named vault "${name}" does not exist. Create it with: envcp vault --name ${name} init`));
1697
- return;
1698
- }
1699
- }
1700
- await setActiveVault(projectPath, name);
1701
- console.log(chalk.green(`Switched to vault: ${name}`));
1702
- });
1703
- program
1704
- .command('vault-list')
2746
+ await switchVaultContext(name);
2747
+ }))
2748
+ .addCommand(new Command('contexts')
1705
2749
  .description('List all available vaults')
1706
2750
  .action(async () => {
1707
- const projectPath = process.cwd();
1708
- const config = await loadConfig(projectPath);
1709
- const vaults = await listVaults(projectPath, config);
1710
- console.log('Available vaults:');
1711
- for (const v of vaults) {
1712
- const active = v.active ? chalk.green(' (active)') : '';
1713
- const exists = await pathExists(v.path);
1714
- const status = exists ? '' : chalk.gray(' [not initialized]');
1715
- console.log(` ${v.name}${active}${status}`);
1716
- console.log(chalk.gray(` ${v.path}`));
1717
- }
1718
- });
2751
+ await listVaultContexts();
2752
+ }));
1719
2753
  program
1720
2754
  .command('keychain')
1721
2755
  .description('Manage OS keychain integration')
@@ -1845,7 +2879,7 @@ program
1845
2879
  .option('--source <source>', 'Filter by source (cli, mcp, api)')
1846
2880
  .option('--success', 'Show only successful operations')
1847
2881
  .option('--failure', 'Show only failed operations')
1848
- .option('--tail <n>', 'Show last N entries', parseInt)
2882
+ .option('--tail <n>', 'Show last N entries', (value) => Number.parseInt(value, 10))
1849
2883
  .option('--dates', 'List all available log dates')
1850
2884
  .option('--verify', 'Verify HMAC integrity of log entries')
1851
2885
  .action(async (options) => {
@@ -1962,5 +2996,10 @@ program
1962
2996
  console.log(chalk.gray('No log files to protect.'));
1963
2997
  }
1964
2998
  });
1965
- program.parse();
2999
+ if (process.argv.length <= 2 && isInteractiveCli()) {
3000
+ await runInteractiveHome();
3001
+ }
3002
+ else {
3003
+ await program.parseAsync(process.argv);
3004
+ }
1966
3005
  //# sourceMappingURL=index.js.map