@fentz26/envcp 1.2.0-exp.92feaad → 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 (61) hide show
  1. package/README.md +21 -36
  2. package/VERSION +1 -1
  3. package/dist/adapters/base.d.ts.map +1 -1
  4. package/dist/adapters/base.js +43 -23
  5. package/dist/adapters/base.js.map +1 -1
  6. package/dist/adapters/gemini.d.ts +5 -0
  7. package/dist/adapters/gemini.d.ts.map +1 -1
  8. package/dist/adapters/gemini.js +27 -9
  9. package/dist/adapters/gemini.js.map +1 -1
  10. package/dist/adapters/openai.d.ts +5 -0
  11. package/dist/adapters/openai.d.ts.map +1 -1
  12. package/dist/adapters/openai.js +36 -12
  13. package/dist/adapters/openai.js.map +1 -1
  14. package/dist/adapters/rest.d.ts +1 -0
  15. package/dist/adapters/rest.d.ts.map +1 -1
  16. package/dist/adapters/rest.js +7 -3
  17. package/dist/adapters/rest.js.map +1 -1
  18. package/dist/cli/index.js +1328 -336
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/config/config-hmac.d.ts.map +1 -1
  21. package/dist/config/config-hmac.js +5 -1
  22. package/dist/config/config-hmac.js.map +1 -1
  23. package/dist/config/manager.d.ts +12 -2
  24. package/dist/config/manager.d.ts.map +1 -1
  25. package/dist/config/manager.js +169 -87
  26. package/dist/config/manager.js.map +1 -1
  27. package/dist/server/unified.d.ts +1 -0
  28. package/dist/server/unified.d.ts.map +1 -1
  29. package/dist/server/unified.js +53 -29
  30. package/dist/server/unified.js.map +1 -1
  31. package/dist/service/config.d.ts.map +1 -1
  32. package/dist/service/config.js +1 -7
  33. package/dist/service/config.js.map +1 -1
  34. package/dist/types.d.ts +265 -4
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js +24 -0
  37. package/dist/types.js.map +1 -1
  38. package/dist/utils/crypto.d.ts +5 -3
  39. package/dist/utils/crypto.d.ts.map +1 -1
  40. package/dist/utils/crypto.js +5 -6
  41. package/dist/utils/crypto.js.map +1 -1
  42. package/dist/utils/fs.d.ts +11 -1
  43. package/dist/utils/fs.d.ts.map +1 -1
  44. package/dist/utils/fs.js +20 -11
  45. package/dist/utils/fs.js.map +1 -1
  46. package/dist/utils/hsm.d.ts.map +1 -1
  47. package/dist/utils/hsm.js +6 -2
  48. package/dist/utils/hsm.js.map +1 -1
  49. package/dist/utils/http.d.ts.map +1 -1
  50. package/dist/utils/http.js +8 -3
  51. package/dist/utils/http.js.map +1 -1
  52. package/dist/utils/prompt.d.ts +11 -0
  53. package/dist/utils/prompt.d.ts.map +1 -1
  54. package/dist/utils/prompt.js +239 -2
  55. package/dist/utils/prompt.js.map +1 -1
  56. package/dist/utils/session.d.ts +3 -1
  57. package/dist/utils/session.d.ts.map +1 -1
  58. package/dist/utils/session.js +38 -16
  59. package/dist/utils/session.js.map +1 -1
  60. package/package.json +2 -2
  61. package/scripts/sync-version.js +3 -3
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';
@@ -14,11 +14,12 @@ import { maskValue, validatePassword, encrypt, decrypt, generateRecoveryKey, cre
14
14
  import { KeychainManager } from '../utils/keychain.js';
15
15
  import { HsmManager } from '../utils/hsm.js';
16
16
  import { checkForUpdate, formatUpdateMessage, logUpdateCheck, fetchReleases, filterByChannel } from '../utils/update-checker.js';
17
- import { spawnSync } from 'child_process';
17
+ import { spawnSync } from 'node:child_process';
18
18
  import { LockoutManager } from '../utils/lockout.js';
19
- import { initMemoryProtection } from '../utils/secure-memory.js';
19
+ import { initMemoryProtection, secureCompare } from '../utils/secure-memory.js';
20
20
  import { getGlobalVaultPath, getProjectVaultPath, resolveVaultPath, resolveSessionPath, setActiveVault, listVaults, initNamedVault, } from '../vault/index.js';
21
- import { findProjectRoot } from '../utils/fs.js';
21
+ import { loadServiceConfig, saveServiceConfig } from '../service/config.js';
22
+ import { installService, statusService, uninstallService } from '../service/index.js';
22
23
  initMemoryProtection();
23
24
  async function resolveCliContext(vaultOverride) {
24
25
  if (vaultOverride === 'global') {
@@ -66,223 +67,342 @@ function buildSecuritySettings(securityChoice) {
66
67
  },
67
68
  };
68
69
  }
69
- async function withSession(fn, vaultOverride) {
70
- const { projectPath, config } = await resolveCliContext(vaultOverride);
71
- const vaultPath = vaultOverride
72
- ? vaultOverride === 'global'
73
- ? getGlobalVaultPath(config)
74
- : getProjectVaultPath(projectPath, config)
75
- : await resolveVaultPath(projectPath, config);
76
- if (config.encryption?.enabled === false) {
77
- const storage = new StorageManager(vaultPath, false);
78
- const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
79
- await logManager.init();
80
- await fn(storage, '', config, projectPath, logManager);
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) {
81
83
  return;
82
84
  }
83
- const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
84
- await sessionManager.init();
85
- // Initialize audit logging
86
- const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
87
- await logManager.init();
88
- 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) {
89
93
  let password = '';
90
- if (!session) {
91
- const authMethod = config.auth?.method ?? 'password';
92
- // --- HSM-only or multi-factor auth ---
93
- if (authMethod === 'hsm' || authMethod === 'multi') {
94
- const hsm = HsmManager.fromConfig(config, projectPath);
95
- const hsmAvailable = await hsm.isAvailable();
96
- if (hsmAvailable) {
97
- try {
98
- const hsmSecret = await hsm.retrieveVaultPassword();
99
- if (authMethod === 'multi') {
100
- const factors = config.auth?.multi_factors ?? ['password', 'hsm'];
101
- let userPassword = '';
102
- if (factors.includes('password')) {
103
- userPassword = await promptPassword('Enter password (multi-factor):');
104
- }
105
- else if (factors.includes('keychain')) {
106
- const keychain = new KeychainManager(config.keychain?.service || 'envcp');
107
- const stored = await keychain.retrievePassword(projectPath);
108
- if (stored) {
109
- userPassword = stored;
110
- console.log(chalk.gray('Password retrieved from OS keychain (multi-factor)'));
111
- }
112
- }
113
- password = HsmManager.combineSecrets(hsmSecret, userPassword);
114
- 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):');
115
106
  }
116
- else {
117
- password = hsmSecret;
118
- 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
+ }
119
114
  }
115
+ password = HsmManager.combineSecrets(hsmSecret, userPassword);
116
+ console.log(chalk.gray(`Authenticated via ${hsm.backendName} + password`));
120
117
  }
121
- catch (err) {
122
- const msg = err instanceof Error ? err.message : String(err);
123
- const fallback = config.auth?.fallback ?? 'password';
124
- if (fallback === 'none') {
125
- console.log(chalk.red(`HSM authentication failed: ${msg}`));
126
- return;
127
- }
128
- console.log(chalk.yellow(`HSM unavailable: ${msg}`));
129
- console.log(chalk.gray('Falling back to password...'));
118
+ else {
119
+ password = hsmSecret;
120
+ console.log(chalk.gray(`Authenticated via ${hsm.backendName}`));
130
121
  }
131
122
  }
132
- else {
123
+ catch (err) {
124
+ const msg = err instanceof Error ? err.message : String(err);
133
125
  const fallback = config.auth?.fallback ?? 'password';
134
126
  if (fallback === 'none') {
135
- console.log(chalk.red(`HSM device (${hsm.backendName}) not found. No fallback configured.`));
136
- return;
127
+ console.log(chalk.red(`HSM authentication failed: ${msg}`));
128
+ return null;
137
129
  }
138
- 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...'));
139
132
  }
140
133
  }
141
- // --- OS keychain ---
142
- if (!password && (authMethod === 'keychain' || config.keychain?.enabled)) {
143
- const keychain = new KeychainManager(config.keychain?.service || 'envcp');
144
- const stored = await keychain.retrievePassword(projectPath);
145
- if (stored) {
146
- password = stored;
147
- 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;
148
139
  }
140
+ console.log(chalk.yellow(`HSM device (${hsm.backendName}) not available. Falling back to password...`));
149
141
  }
150
- // --- Password prompt fallback ---
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"));
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) {
156
186
  return;
157
187
  }
158
- if (passwordValid && passwordWarning) {
159
- console.log(chalk.yellow('⚠ Weak password detected'));
160
- }
188
+ password = authPassword;
189
+ session = await sessionManager.create(password);
161
190
  }
162
- 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();
163
200
  }
164
- password = sessionManager.getPassword() || password;
165
201
  const storage = new StorageManager(vaultPath, config.storage.encrypted);
166
202
  if (password)
167
203
  storage.setPassword(password);
168
204
  await fn(storage, password, config, projectPath, logManager);
169
205
  }
170
- const program = new Command();
171
- program
172
- .name('envcp')
173
- .description('Secure environment variable management for AI-assisted coding')
174
- .version(VERSION);
175
- program
176
- .command('init')
177
- .description('Initialize EnvCP in the current project')
178
- .option('-p, --project <name>', 'Project name')
179
- .option('--no-encrypt', 'Skip encryption (passwordless mode)')
180
- .option('--skip-env', 'Skip .env auto-import')
181
- .option('--skip-mcp', 'Skip MCP auto-registration')
182
- .option('--auth-method <method>', 'Authentication method: password | keychain | hsm | multi (default: password)')
183
- .option('--hsm-type <type>', 'HSM type for --auth-method hsm|multi: yubikey | gpg | pkcs11')
184
- .option('--key-id <id>', 'GPG key ID or PKCS#11 key label for HSM auth')
185
- .option('--pkcs11-lib <path>', 'Path to PKCS#11 shared library for --hsm-type pkcs11')
186
- .option('--global', 'Initialize a global vault at ~/.envcp/ (config + store live in $HOME)')
187
- .option('--force', 'Overwrite existing envcp.yaml (DESTRUCTIVE: existing vault may become inaccessible)')
188
- .action(async (options) => {
189
- const useGlobal = !!options.global;
190
- const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
191
- const projectPath = useGlobal ? home : process.cwd();
192
- 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);
193
343
  const configPath = path.join(projectPath, 'envcp.yaml');
194
- if (await pathExists(configPath) && !options.force) {
195
- const vaultRelPath = path.join('.envcp', 'store.enc');
196
- 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.'));
197
346
  console.log(chalk.gray(` Config: ${configPath}`));
198
- if (await pathExists(path.join(projectPath, vaultRelPath))) {
199
- console.log(chalk.gray(` Vault: ${path.join(projectPath, vaultRelPath)}`));
200
- }
201
- console.log('');
202
- console.log(chalk.gray('Running init again would overwrite your config and may make'));
203
- console.log(chalk.gray('existing encrypted variables permanently inaccessible.'));
204
- console.log('');
205
- console.log(chalk.gray('To reconfigure: edit envcp.yaml directly'));
206
- console.log(chalk.gray('To force re-init (DESTRUCTIVE): envcp init --force'));
207
- process.exit(1);
347
+ console.log(chalk.gray('Run `envcp setup` to change project settings.'));
348
+ return;
208
349
  }
209
- console.log(chalk.blue(useGlobal ? 'Initializing EnvCP (global vault)...' : 'Initializing EnvCP...'));
350
+ console.log(chalk.blue('Initializing EnvCP...'));
210
351
  console.log('');
211
- const config = await initConfig(projectPath, projectName, { global: useGlobal });
212
- // 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'));
213
355
  let securityChoice;
214
356
  if (options.encrypt === false) {
215
357
  securityChoice = 'none';
216
358
  }
359
+ else if (initMode === 'basic') {
360
+ securityChoice = isInteractiveCli() && !await promptConfirm('Protect variables with encryption?', true)
361
+ ? 'none'
362
+ : 'recoverable';
363
+ }
217
364
  else {
218
- securityChoice = await promptList('How would you like to secure your variables?', [
219
- { name: 'No encryption (fastest setup, for local dev)', value: 'none' },
220
- { name: 'Encrypted with recovery key (recommended)', value: 'recoverable' },
221
- { name: 'Encrypted hard-lock (max security, no recovery)', value: 'hard-lock' },
222
- ], 'recoverable');
365
+ securityChoice = await chooseSecurityMode();
223
366
  }
224
367
  const securitySettings = buildSecuritySettings(securityChoice);
225
368
  config.encryption = securitySettings.encryption;
226
369
  config.storage.encrypted = securitySettings.storageEncrypted;
227
370
  config.security = securitySettings.security;
228
- // For encrypted modes: get password now
229
- let pwd = '';
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);
380
+ }
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 = '';
230
388
  if (securityChoice !== 'none') {
231
- const password = await promptPassword('Set encryption password:');
389
+ password = await promptPassword('Set encryption password:');
232
390
  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) {
391
+ if (!secureCompare(Buffer.from(password, 'utf8'), Buffer.from(confirmPwd, 'utf8'))) {
235
392
  console.log(chalk.red('Passwords do not match. Aborting.'));
236
393
  return;
237
394
  }
238
- pwd = password;
239
395
  }
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(', ')}`));
279
- }
280
- }
281
- }
282
- // Generate recovery key for encrypted recoverable mode
283
- if (securityChoice === 'recoverable' && pwd) {
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) {
284
404
  const recoveryKey = generateRecoveryKey();
285
- const recoveryData = await createRecoveryData(pwd, recoveryKey);
405
+ const recoveryData = await createRecoveryData(password, recoveryKey);
286
406
  const recoveryPath = path.join(projectPath, config.security.recovery_file);
287
407
  await fs.writeFile(recoveryPath, recoveryData, 'utf8');
288
408
  console.log('');
@@ -290,7 +410,6 @@ program
290
410
  console.log(chalk.yellow.bold(` ${recoveryKey}`));
291
411
  console.log(chalk.gray(' This key is shown ONCE. If you lose it, you cannot recover your password.'));
292
412
  }
293
- // Auto-register MCP in all detected tools
294
413
  if (!options.skipMcp) {
295
414
  const result = await registerMcpConfig(projectPath);
296
415
  console.log('');
@@ -300,24 +419,12 @@ program
300
419
  console.log(chalk.gray(` + ${name}`));
301
420
  }
302
421
  }
303
- if (result.alreadyConfigured.length > 0) {
304
- for (const name of result.alreadyConfigured) {
305
- console.log(chalk.gray(` = ${name} (already configured)`));
306
- }
307
- }
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}`));
312
- }
313
- }
314
422
  if (result.registered.length === 0 && result.alreadyConfigured.length === 0) {
315
423
  console.log(chalk.gray(' No AI tools detected for auto-registration'));
316
424
  }
317
425
  }
318
- // Setup HSM authentication if --auth-method hsm|multi provided
319
426
  const authMethod = options.authMethod;
320
- if (pwd && (authMethod === 'hsm' || authMethod === 'multi')) {
427
+ if (password && (authMethod === 'hsm' || authMethod === 'multi')) {
321
428
  const hsmType = options.hsmType || 'yubikey';
322
429
  config.hsm = {
323
430
  ...config.hsm,
@@ -333,55 +440,649 @@ program
333
440
  multi_factors: authMethod === 'multi' ? ['password', 'hsm'] : ['hsm'],
334
441
  fallback: 'password',
335
442
  };
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`));
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,
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];
687
+ }
688
+ else {
689
+ nextRules[clientId] = {
690
+ ...rule,
691
+ variable_rules: rule.variable_rules || {},
692
+ };
693
+ }
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')) {
975
+ return;
976
+ }
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);
344
1026
  }
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'));
1027
+ }
1028
+ else if (choice === 'variable.edit') {
1029
+ await editVariableRule(config);
1030
+ }
1031
+ else if (choice === 'variable.window') {
1032
+ await editVariableRuleWindow(config);
1033
+ }
1034
+ else if (choice === 'variable.clear-window') {
1035
+ const variable = (await promptInput('Variable name:')).trim();
1036
+ if (variable) {
1037
+ setVariableRuleWindow(config, variable, undefined, undefined);
348
1038
  }
349
1039
  }
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 });
1040
+ else if (choice === 'variable.remove') {
1041
+ await removeVariableRule(config);
1042
+ }
1043
+ else if (choice === 'who.edit') {
1044
+ const clientId = await promptClientRuleTarget();
1045
+ if (clientId) {
1046
+ await editVariableRule(config, clientId);
1047
+ }
1048
+ }
1049
+ else if (choice === 'who.window') {
1050
+ const clientId = await promptClientRuleTarget();
1051
+ if (clientId) {
1052
+ await editVariableRuleWindow(config, clientId);
1053
+ }
355
1054
  }
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
+ }
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('--recovery-key <key>', 'Recovery key to clear permanent lockout')
364
- .option('--save-to-keychain', 'Save password to OS keychain for auto-unlock')
365
- .option('--setup-hsm', 'Protect vault password with hardware security module')
366
- .option('--hsm-type <type>', 'HSM type: yubikey | gpg | pkcs11 (default: yubikey)')
367
- .option('--key-id <id>', 'GPG key ID or PKCS#11 key label')
368
- .option('--pkcs11-lib <path>', 'Path to PKCS#11 shared library (.so / .dll)')
369
- .option('--global', 'Unlock the global vault at ~/.envcp')
370
- .action(async (options) => {
1072
+ }
1073
+ async function runUnlockFlow(options = {}) {
371
1074
  const { projectPath, config } = await resolveCliContext(options.global ? 'global' : undefined);
372
1075
  const password = await promptPassword('Enter password:');
373
1076
  const { valid: passwordValid, warning: passwordWarning } = validatePassword(password, config.password || {});
374
1077
  if (!passwordValid) {
375
- // nosem: no tainted data flows to log
376
- console.log(chalk.red("Invalid password"));
1078
+ console.log(chalk.red('Invalid password'));
377
1079
  return;
378
1080
  }
379
- if (passwordValid && passwordWarning) {
1081
+ if (passwordWarning) {
380
1082
  console.log(chalk.yellow('⚠ Weak password detected'));
381
1083
  }
382
1084
  const sessionDir = path.dirname(resolveSessionPath(projectPath, config));
383
- let lockoutManager = new LockoutManager(path.join(sessionDir, '.lockout'));
384
- // Handle recovery key if provided
1085
+ const lockoutManager = new LockoutManager(path.join(sessionDir, '.lockout'));
385
1086
  if (options.recoveryKey) {
386
1087
  const recoveryPath = path.join(projectPath, config.security?.recovery_file || '.envcp/.recovery');
387
1088
  if (!await pathExists(recoveryPath)) {
@@ -391,10 +1092,8 @@ program
391
1092
  const recoveryData = await fs.readFile(recoveryPath, 'utf8');
392
1093
  try {
393
1094
  await recoverPassword(recoveryData, options.recoveryKey);
394
- // Recovery key is valid - clear permanent lockout
395
1095
  await lockoutManager.clearPermanentLockout();
396
1096
  console.log(chalk.green('Permanent lockout cleared.'));
397
- // Log recovery event
398
1097
  const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
399
1098
  await logManager.init();
400
1099
  await logManager.log({
@@ -407,17 +1106,15 @@ program
407
1106
  session_id: '',
408
1107
  client_id: 'cli',
409
1108
  client_type: 'terminal',
410
- ip: '127.0.0.1'
1109
+ ip: '127.0.0.1',
411
1110
  });
412
1111
  console.log(chalk.yellow('Note: You still need to enter the correct password.'));
413
- // Continue with password prompt
414
1112
  }
415
1113
  catch {
416
1114
  console.log(chalk.red('Invalid recovery key.'));
417
1115
  return;
418
1116
  }
419
1117
  }
420
- // Use new brute_force_protection config if available, fall back to session config
421
1118
  const bfpConfig = config.security?.brute_force_protection;
422
1119
  const lockoutThreshold = bfpConfig?.max_attempts ?? config.session?.lockout_threshold ?? 5;
423
1120
  const lockoutBaseSeconds = bfpConfig?.lockout_duration ?? config.session?.lockout_base_seconds ?? 60;
@@ -426,14 +1123,12 @@ program
426
1123
  const permanentThreshold = bfpConfig?.permanent_lockout_threshold ?? 0;
427
1124
  const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
428
1125
  await sessionManager.init();
429
- // Initialize audit logging
430
1126
  const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
431
1127
  await logManager.init();
432
1128
  const vaultPathForUnlock = await resolveVaultPath(projectPath, config);
433
1129
  const storage = new StorageManager(vaultPathForUnlock, config.storage.encrypted);
434
1130
  storage.setPassword(password);
435
1131
  const storeExists = await storage.exists();
436
- // Check lockout before attempting password verification on existing stores
437
1132
  if (storeExists) {
438
1133
  const lockoutStatus = await lockoutManager.check();
439
1134
  if (lockoutStatus.locked) {
@@ -451,11 +1146,9 @@ program
451
1146
  const recoveryData = await fs.readFile(recoveryPath, 'utf8');
452
1147
  try {
453
1148
  await recoverPassword(recoveryData, recoveryKey);
454
- // Recovery key is valid - clear permanent lockout
455
1149
  await lockoutManager.clearPermanentLockout();
456
1150
  console.log(chalk.green('Permanent lockout cleared. You can now attempt to unlock.'));
457
1151
  console.log(chalk.yellow('Note: You still need to enter the correct password.'));
458
- // Log recovery event
459
1152
  await logManager.log({
460
1153
  timestamp: new Date().toISOString(),
461
1154
  operation: 'unlock',
@@ -466,9 +1159,8 @@ program
466
1159
  session_id: '',
467
1160
  client_id: 'cli',
468
1161
  client_type: 'terminal',
469
- ip: '127.0.0.1'
1162
+ ip: '127.0.0.1',
470
1163
  });
471
- // Continue with password prompt
472
1164
  }
473
1165
  catch {
474
1166
  console.log(chalk.red('Invalid recovery key.'));
@@ -487,12 +1179,10 @@ program
487
1179
  }
488
1180
  if (!storeExists) {
489
1181
  const confirmPasswordValue = await promptPassword('Confirm password:');
490
- // eslint-disable-next-line security/detect-possible-timing-attacks -- comparing two user-typed confirm fields, not a secret-vs-known value
491
- if (confirmPasswordValue !== password) {
1182
+ if (!secureCompare(Buffer.from(confirmPasswordValue, 'utf8'), Buffer.from(password, 'utf8'))) {
492
1183
  console.log(chalk.red('Passwords do not match'));
493
1184
  return;
494
1185
  }
495
- // Generate recovery key for new stores in recoverable mode
496
1186
  if (config.security?.mode === 'recoverable') {
497
1187
  const recoveryPath = path.join(projectPath, config.security.recovery_file || '.envcp/.recovery');
498
1188
  if (!await pathExists(recoveryPath)) {
@@ -511,10 +1201,9 @@ program
511
1201
  try {
512
1202
  await storage.load();
513
1203
  }
514
- catch (error) {
1204
+ catch {
515
1205
  if (storeExists) {
516
1206
  const status = await lockoutManager.recordFailure(lockoutThreshold, lockoutBaseSeconds, progressiveDelay, maxDelay, permanentThreshold);
517
- // Log the failed attempt
518
1207
  await logManager.log({
519
1208
  timestamp: new Date().toISOString(),
520
1209
  operation: 'auth_failure',
@@ -525,12 +1214,11 @@ program
525
1214
  session_id: '',
526
1215
  client_id: 'cli',
527
1216
  client_type: 'terminal',
528
- ip: '127.0.0.1'
1217
+ ip: '127.0.0.1',
529
1218
  });
530
1219
  if (status.permanent_locked) {
531
1220
  console.log(chalk.red.bold('PERMANENT LOCKOUT TRIGGERED: Too many failed attempts.'));
532
1221
  console.log(chalk.red('Recovery key or administrator intervention required.'));
533
- // Log permanent lockout event
534
1222
  await logManager.log({
535
1223
  timestamp: new Date().toISOString(),
536
1224
  operation: 'permanent_lockout',
@@ -541,12 +1229,11 @@ program
541
1229
  session_id: '',
542
1230
  client_id: 'cli',
543
1231
  client_type: 'terminal',
544
- ip: '127.0.0.1'
1232
+ ip: '127.0.0.1',
545
1233
  });
546
1234
  }
547
1235
  else if (status.locked) {
548
1236
  console.log(chalk.red(`Invalid password. Too many failed attempts — locked out for ${status.remaining_seconds} second(s).`));
549
- // Log lockout event
550
1237
  await logManager.log({
551
1238
  timestamp: new Date().toISOString(),
552
1239
  operation: 'lockout_triggered',
@@ -557,13 +1244,12 @@ program
557
1244
  session_id: '',
558
1245
  client_id: 'cli',
559
1246
  client_type: 'terminal',
560
- ip: '127.0.0.1'
1247
+ ip: '127.0.0.1',
561
1248
  });
562
1249
  }
563
1250
  else {
564
1251
  const remaining = lockoutThreshold - status.attempts;
565
1252
  let message = `Invalid password. ${remaining} attempt(s) remaining before lockout.`;
566
- // Show progressive delay if applied
567
1253
  if (status.delay_seconds && status.delay_seconds > 0) {
568
1254
  message += ` (Delayed ${status.delay_seconds}s)`;
569
1255
  }
@@ -575,34 +1261,37 @@ program
575
1261
  }
576
1262
  return;
577
1263
  }
578
- // Successful unlock — clear any lockout state
579
1264
  await lockoutManager.reset();
580
- const session = await sessionManager.create(password);
581
- // Log successful unlock
582
- await logManager.log({
583
- timestamp: new Date().toISOString(),
584
- operation: 'unlock',
585
- variable: '',
586
- source: 'cli',
587
- success: true,
588
- message: 'Session unlocked successfully',
589
- session_id: session.id,
590
- client_id: 'cli',
591
- client_type: 'terminal',
592
- ip: '127.0.0.1'
593
- });
594
- console.log(chalk.green('Session unlocked!'));
595
- console.log(chalk.gray(` Session ID: ${session.id}`));
596
- console.log(chalk.gray(` Expires in: ${config.session?.timeout_minutes || 30} minutes`));
597
- const maxExt = config.session?.max_extensions || 5;
598
- console.log(chalk.gray(` Extensions remaining: ${maxExt - session.extensions}/${maxExt}`));
599
- // 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
+ }
600
1290
  if (options.saveToKeychain) {
601
1291
  const keychain = new KeychainManager(config.keychain?.service || 'envcp');
602
1292
  if (await keychain.isAvailable()) {
603
1293
  const result = await keychain.storePassword(password, projectPath);
604
1294
  if (result.success) {
605
- // Enable keychain in config
606
1295
  config.keychain = { ...config.keychain, enabled: true };
607
1296
  await saveConfig(config, projectPath);
608
1297
  console.log(chalk.green(`Password saved to ${keychain.backendName}`));
@@ -616,7 +1305,6 @@ program
616
1305
  console.log(chalk.red(`OS keychain not available (${keychain.backendName})`));
617
1306
  }
618
1307
  }
619
- // Setup HSM if requested
620
1308
  if (options.setupHsm) {
621
1309
  const hsmType = options.hsmType || 'yubikey';
622
1310
  config.hsm = {
@@ -645,17 +1333,135 @@ program
645
1333
  console.log(chalk.red(`HSM setup failed: ${err instanceof Error ? err.message : String(err)}`));
646
1334
  }
647
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);
648
1458
  });
649
1459
  program
650
1460
  .command('lock')
651
1461
  .description('Lock EnvCP session')
652
1462
  .option('--global', 'Lock the global vault session at ~/.envcp/.session')
653
1463
  .action(async (options) => {
654
- const { projectPath, config } = await resolveCliContext(options.global ? 'global' : undefined);
655
- const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
656
- await sessionManager.init();
657
- await sessionManager.destroy();
658
- console.log(chalk.green('Session locked'));
1464
+ await runLockFlow(!!options.global);
659
1465
  });
660
1466
  program
661
1467
  .command('status')
@@ -663,6 +1469,16 @@ program
663
1469
  .option('--global', 'Check status of the global vault session at ~/.envcp/.session')
664
1470
  .action(async (options) => {
665
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
+ }
666
1482
  const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
667
1483
  await sessionManager.init();
668
1484
  const password = await promptPassword('Enter password:');
@@ -679,9 +1495,13 @@ program
679
1495
  console.log(chalk.gray(` Remaining: ${remaining} minutes`));
680
1496
  console.log(chalk.gray(` Extensions remaining: ${maxExt - session.extensions}/${maxExt}`));
681
1497
  });
682
- program
1498
+ const configCommand = program
683
1499
  .command('config')
684
- .description('Config management commands')
1500
+ .description('Configure EnvCP settings')
1501
+ .action(async () => {
1502
+ await runConfigMenu();
1503
+ });
1504
+ configCommand
685
1505
  .command('reload')
686
1506
  .description('Reload config from envcp.yaml (requires password)')
687
1507
  .action(async () => {
@@ -691,12 +1511,190 @@ program
691
1511
  const result = await configGuard.reload(password);
692
1512
  if (result.success) {
693
1513
  console.log(chalk.green('Config reloaded successfully'));
694
- 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)}...`));
695
1515
  }
696
1516
  else {
697
1517
  console.log(chalk.red(result.error || 'Failed to reload config'));
698
1518
  }
699
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
+ });
700
1698
  program
701
1699
  .command('extend')
702
1700
  .description('Extend session timeout')
@@ -1108,7 +2106,7 @@ program
1108
2106
  const vaultPath = await resolveVaultPath(projectPath, config);
1109
2107
  const sessionPath = resolveSessionPath(projectPath, config);
1110
2108
  const mode = options.mode;
1111
- const port = parseInt(options.port, 10);
2109
+ const port = Number.parseInt(options.port, 10);
1112
2110
  const host = options.host;
1113
2111
  const apiKey = options.apiKey;
1114
2112
  let password = '';
@@ -1284,20 +2282,12 @@ program
1284
2282
  console.log(chalk.red('Failed to decrypt. Wrong password or invalid file.'));
1285
2283
  return;
1286
2284
  }
1287
- const meta = importData.meta;
1288
- const variables = importData.variables;
1289
- if (!variables || typeof variables !== 'object') {
1290
- console.log(chalk.red('Invalid export format'));
2285
+ const transferData = extractTransferVariables(importData, 'Invalid export format');
2286
+ if (!transferData) {
1291
2287
  return;
1292
2288
  }
1293
- if (meta) {
1294
- console.log(chalk.blue('Import info:'));
1295
- if (meta.project)
1296
- console.log(chalk.gray(` From project: ${meta.project}`));
1297
- if (meta.timestamp)
1298
- console.log(chalk.gray(` Exported: ${meta.timestamp}`));
1299
- console.log(chalk.gray(` Variables: ${meta.count || Object.keys(variables).length}`));
1300
- }
2289
+ const { meta, variables } = transferData;
2290
+ logTransferInfo('Import info:', meta, variables, { project: 'From project', timestamp: 'Exported' });
1301
2291
  if (options.dryRun) {
1302
2292
  const current = await storage.load();
1303
2293
  const importNames = Object.keys(variables);
@@ -1402,20 +2392,12 @@ program
1402
2392
  console.log(chalk.red('Failed to decrypt backup. Wrong password or corrupted file.'));
1403
2393
  return;
1404
2394
  }
1405
- const meta = backupData.meta;
1406
- const variables = backupData.variables;
1407
- if (!variables || typeof variables !== 'object') {
1408
- console.log(chalk.red('Invalid backup format'));
2395
+ const transferData = extractTransferVariables(backupData, 'Invalid backup format');
2396
+ if (!transferData) {
1409
2397
  return;
1410
2398
  }
1411
- if (meta) {
1412
- console.log(chalk.blue('Backup info:'));
1413
- if (meta.project)
1414
- console.log(chalk.gray(` Project: ${meta.project}`));
1415
- if (meta.timestamp)
1416
- console.log(chalk.gray(` Created: ${meta.timestamp}`));
1417
- console.log(chalk.gray(` Variables: ${meta.count || Object.keys(variables).length}`));
1418
- }
2399
+ const { meta, variables } = transferData;
2400
+ logTransferInfo('Backup info:', meta, variables, { project: 'Project', timestamp: 'Created' });
1419
2401
  const confirm = await promptConfirm(options.merge ? 'Merge backup into current store?' : 'Replace current store with backup?', false);
1420
2402
  if (!confirm) {
1421
2403
  console.log(chalk.yellow('Restore cancelled'));
@@ -1592,7 +2574,7 @@ program
1592
2574
  });
1593
2575
  console.log(chalk.gray('\n [0] Cancel\n'));
1594
2576
  const answer = await promptInput('Pick a version (0 to cancel):');
1595
- const picked = parseInt(answer.trim(), 10);
2577
+ const picked = Number.parseInt(answer.trim(), 10);
1596
2578
  if (!picked || picked < 1 || picked > releases.length) {
1597
2579
  console.log(chalk.gray('Cancelled.'));
1598
2580
  return;
@@ -1622,7 +2604,32 @@ program
1622
2604
  console.log(chalk.red('\n Installation failed.'));
1623
2605
  }
1624
2606
  });
1625
- 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
1626
2633
  .command('vault')
1627
2634
  .description('Manage vaults (global, project, or named)')
1628
2635
  .option('--global', 'Operate on the global vault')
@@ -1731,38 +2738,18 @@ program
1731
2738
  }
1732
2739
  }, vaultOverride);
1733
2740
  }));
1734
- program
1735
- .command('vault-switch')
2741
+ vaultCommand
2742
+ .addCommand(new Command('use')
1736
2743
  .description('Switch active vault context')
1737
2744
  .argument('<name>', 'Vault name (global, project, or named vault)')
1738
2745
  .action(async (name) => {
1739
- const projectPath = process.cwd();
1740
- if (name !== 'global' && name !== 'project') {
1741
- const vaultDir = path.join(projectPath, '.envcp/vaults', name);
1742
- if (!await pathExists(vaultDir)) {
1743
- console.log(chalk.red(`Named vault "${name}" does not exist. Create it with: envcp vault --name ${name} init`));
1744
- return;
1745
- }
1746
- }
1747
- await setActiveVault(projectPath, name);
1748
- console.log(chalk.green(`Switched to vault: ${name}`));
1749
- });
1750
- program
1751
- .command('vault-list')
2746
+ await switchVaultContext(name);
2747
+ }))
2748
+ .addCommand(new Command('contexts')
1752
2749
  .description('List all available vaults')
1753
2750
  .action(async () => {
1754
- const projectPath = process.cwd();
1755
- const config = await loadConfig(projectPath);
1756
- const vaults = await listVaults(projectPath, config);
1757
- console.log('Available vaults:');
1758
- for (const v of vaults) {
1759
- const active = v.active ? chalk.green(' (active)') : '';
1760
- const exists = await pathExists(v.path);
1761
- const status = exists ? '' : chalk.gray(' [not initialized]');
1762
- console.log(` ${v.name}${active}${status}`);
1763
- console.log(chalk.gray(` ${v.path}`));
1764
- }
1765
- });
2751
+ await listVaultContexts();
2752
+ }));
1766
2753
  program
1767
2754
  .command('keychain')
1768
2755
  .description('Manage OS keychain integration')
@@ -1892,7 +2879,7 @@ program
1892
2879
  .option('--source <source>', 'Filter by source (cli, mcp, api)')
1893
2880
  .option('--success', 'Show only successful operations')
1894
2881
  .option('--failure', 'Show only failed operations')
1895
- .option('--tail <n>', 'Show last N entries', parseInt)
2882
+ .option('--tail <n>', 'Show last N entries', (value) => Number.parseInt(value, 10))
1896
2883
  .option('--dates', 'List all available log dates')
1897
2884
  .option('--verify', 'Verify HMAC integrity of log entries')
1898
2885
  .action(async (options) => {
@@ -2009,5 +2996,10 @@ program
2009
2996
  console.log(chalk.gray('No log files to protect.'));
2010
2997
  }
2011
2998
  });
2012
- program.parse();
2999
+ if (process.argv.length <= 2 && isInteractiveCli()) {
3000
+ await runInteractiveHome();
3001
+ }
3002
+ else {
3003
+ await program.parseAsync(process.argv);
3004
+ }
2013
3005
  //# sourceMappingURL=index.js.map