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