minovative-mind-cli 2.2.5 → 2.3.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/LICENSE.md +2 -0
- package/README.md +8 -8
- package/dist/commands/logout.js +1 -1
- package/dist/services/agent/slashCommands.js +53 -14
- package/dist/services/agent/toolLoop.js +4 -1
- package/dist/services/agent/types.d.ts +4 -0
- package/dist/services/agent-tools.js +55 -27
- package/dist/services/agent.d.ts +4 -0
- package/dist/services/agent.js +77 -60
- package/dist/services/ai.d.ts +1 -2
- package/dist/services/ai.js +21 -19
- package/dist/services/auth.d.ts +1 -1
- package/dist/services/auth.js +7 -28
- package/dist/services/chatHistoryService.d.ts +8 -0
- package/dist/services/contextAgent.js +18 -1
- package/dist/services/investigationComplexity.d.ts +1 -1
- package/dist/services/investigationComplexity.js +1 -1
- package/dist/services/orchestration/investigationAgent.d.ts +2 -2
- package/dist/services/orchestration/investigationAgent.js +25 -6
- package/dist/services/orchestration/orchestrator.d.ts +1 -1
- package/dist/services/orchestration/orchestrator.js +37 -18
- package/dist/services/orchestration/subAgent.d.ts +1 -1
- package/dist/services/orchestration/subAgent.js +23 -13
- package/dist/services/proxyClient.d.ts +16 -0
- package/dist/services/proxyClient.js +32 -0
- package/dist/utils/analysisRunner.js +2 -2
- package/dist/utils/config.d.ts +3 -3
- package/dist/utils/config.js +3 -3
- package/dist/utils/credentialStore.d.ts +30 -0
- package/dist/utils/credentialStore.js +540 -0
- package/dist/utils/projectStorage.js +70 -0
- package/dist/utils/systemPrompts.d.ts +3 -3
- package/dist/utils/systemPrompts.js +4 -4
- package/oclif.manifest.json +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { debugLog } from './logger.js';
|
|
7
|
+
// ─── Constants ───────────────────────────────────────────────────────
|
|
8
|
+
const SERVICE_NAME = 'minovative-mind-cli';
|
|
9
|
+
const ACCOUNT_NAME = 'default';
|
|
10
|
+
const KEYCHAIN_LABEL = 'Minovative Mind CLI';
|
|
11
|
+
/**
|
|
12
|
+
* Maximum time (ms) to wait for an OS keychain CLI call before falling back.
|
|
13
|
+
* Prevents indefinite hangs over SSH or headless sessions where the keychain
|
|
14
|
+
* may prompt for GUI interaction that will never arrive.
|
|
15
|
+
*/
|
|
16
|
+
const KEYCHAIN_TIMEOUT_MS = 3000;
|
|
17
|
+
/** Encrypted fallback file path. */
|
|
18
|
+
const ENCRYPTED_FILE = path.join(os.homedir(), '.minovative-mind-cli.enc');
|
|
19
|
+
/** Machine-specific salt used for AES key derivation in fallback mode. */
|
|
20
|
+
const SALT_FILE = path.join(os.homedir(), '.minovative-mind-cli-salt');
|
|
21
|
+
/** Legacy plaintext file path (for migration). */
|
|
22
|
+
export const LEGACY_CONFIG_FILE = path.join(os.homedir(), '.minovative-mind-cli.json');
|
|
23
|
+
// ─── Backend Detection ───────────────────────────────────────────────
|
|
24
|
+
let cachedBackend = null;
|
|
25
|
+
/**
|
|
26
|
+
* Determines the best available credential storage backend for the current OS.
|
|
27
|
+
* Results are cached after the first successful probe.
|
|
28
|
+
*/
|
|
29
|
+
async function detectBackend() {
|
|
30
|
+
if (cachedBackend)
|
|
31
|
+
return cachedBackend;
|
|
32
|
+
const platform = process.platform;
|
|
33
|
+
if (platform === 'darwin') {
|
|
34
|
+
if (await isCommandAvailable('security')) {
|
|
35
|
+
cachedBackend = 'keychain-macos';
|
|
36
|
+
debugLog('Credential store: using macOS Keychain');
|
|
37
|
+
return cachedBackend;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (platform === 'linux') {
|
|
41
|
+
if (await isCommandAvailable('secret-tool')) {
|
|
42
|
+
cachedBackend = 'libsecret-linux';
|
|
43
|
+
debugLog('Credential store: using libsecret (secret-tool)');
|
|
44
|
+
return cachedBackend;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (platform === 'win32') {
|
|
48
|
+
// DPAPI is always available on Windows via PowerShell
|
|
49
|
+
if (await isCommandAvailable('powershell')) {
|
|
50
|
+
cachedBackend = 'dpapi-windows';
|
|
51
|
+
debugLog('Credential store: using Windows DPAPI');
|
|
52
|
+
return cachedBackend;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
cachedBackend = 'encrypted-file';
|
|
56
|
+
debugLog('Credential store: using AES-256-GCM encrypted file fallback');
|
|
57
|
+
return cachedBackend;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Executes a command with a timeout. If the command doesn't complete within
|
|
61
|
+
* the timeout, the child process is killed and the promise rejects.
|
|
62
|
+
* This prevents hangs in headless/SSH sessions where OS keychain tools
|
|
63
|
+
* might wait for GUI prompts that will never arrive.
|
|
64
|
+
*/
|
|
65
|
+
async function execWithTimeout(command, args, timeoutMs = KEYCHAIN_TIMEOUT_MS) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const child = execFile(command, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
68
|
+
if (error) {
|
|
69
|
+
reject(error);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
resolve({ stdout: stdout, stderr: stderr });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
// Belt-and-suspenders: kill if the built-in timeout doesn't fire
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
child.kill('SIGTERM');
|
|
78
|
+
reject(new Error(`${command} timed out after ${timeoutMs}ms (possible GUI prompt in headless session)`));
|
|
79
|
+
}, timeoutMs + 500);
|
|
80
|
+
child.on('close', () => clearTimeout(timer));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Checks whether a given CLI command is available on PATH.
|
|
85
|
+
*/
|
|
86
|
+
async function isCommandAvailable(command) {
|
|
87
|
+
try {
|
|
88
|
+
const which = process.platform === 'win32' ? 'where' : 'which';
|
|
89
|
+
await execWithTimeout(which, [command], 2000);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// ─── macOS Keychain ──────────────────────────────────────────────────
|
|
97
|
+
async function macSave(json) {
|
|
98
|
+
try {
|
|
99
|
+
// -U flag updates the item if it already exists instead of erroring
|
|
100
|
+
await execWithTimeout('security', [
|
|
101
|
+
'add-generic-password',
|
|
102
|
+
'-a', ACCOUNT_NAME,
|
|
103
|
+
'-s', SERVICE_NAME,
|
|
104
|
+
'-l', KEYCHAIN_LABEL,
|
|
105
|
+
'-w', json,
|
|
106
|
+
'-U',
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
throw new Error(`macOS Keychain save failed: ${err.message}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function macLoad() {
|
|
114
|
+
try {
|
|
115
|
+
const { stdout } = await execWithTimeout('security', [
|
|
116
|
+
'find-generic-password',
|
|
117
|
+
'-a', ACCOUNT_NAME,
|
|
118
|
+
'-s', SERVICE_NAME,
|
|
119
|
+
'-w',
|
|
120
|
+
]);
|
|
121
|
+
return stdout.trim();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Item not found (exit code 44) or keychain locked
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function macClear() {
|
|
129
|
+
try {
|
|
130
|
+
await execWithTimeout('security', [
|
|
131
|
+
'delete-generic-password',
|
|
132
|
+
'-a', ACCOUNT_NAME,
|
|
133
|
+
'-s', SERVICE_NAME,
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Item not found — already cleared, ignore
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ─── Linux libsecret ────────────────────────────────────────────────
|
|
141
|
+
async function linuxSave(json) {
|
|
142
|
+
try {
|
|
143
|
+
// secret-tool reads the secret value from stdin
|
|
144
|
+
const child = execFile('secret-tool', [
|
|
145
|
+
'store',
|
|
146
|
+
'--label', KEYCHAIN_LABEL,
|
|
147
|
+
'service', SERVICE_NAME,
|
|
148
|
+
'account', ACCOUNT_NAME,
|
|
149
|
+
]);
|
|
150
|
+
if (child.stdin) {
|
|
151
|
+
child.stdin.write(json);
|
|
152
|
+
child.stdin.end();
|
|
153
|
+
}
|
|
154
|
+
await new Promise((resolve, reject) => {
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
child.kill('SIGTERM');
|
|
157
|
+
reject(new Error('secret-tool store timed out (possible missing DBus session)'));
|
|
158
|
+
}, KEYCHAIN_TIMEOUT_MS);
|
|
159
|
+
child.on('close', (code) => {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
if (code === 0)
|
|
162
|
+
resolve();
|
|
163
|
+
else
|
|
164
|
+
reject(new Error(`secret-tool store exited with code ${code}`));
|
|
165
|
+
});
|
|
166
|
+
child.on('error', (err) => {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
reject(err);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
throw new Error(`libsecret save failed: ${err.message}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function linuxLoad() {
|
|
177
|
+
try {
|
|
178
|
+
const { stdout } = await execWithTimeout('secret-tool', [
|
|
179
|
+
'lookup',
|
|
180
|
+
'service', SERVICE_NAME,
|
|
181
|
+
'account', ACCOUNT_NAME,
|
|
182
|
+
]);
|
|
183
|
+
return stdout.trim() || null;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function linuxClear() {
|
|
190
|
+
try {
|
|
191
|
+
await execWithTimeout('secret-tool', [
|
|
192
|
+
'clear',
|
|
193
|
+
'service', SERVICE_NAME,
|
|
194
|
+
'account', ACCOUNT_NAME,
|
|
195
|
+
]);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Already cleared
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// ─── Windows DPAPI ──────────────────────────────────────────────────
|
|
202
|
+
const WIN_CREDENTIAL_DIR = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), SERVICE_NAME);
|
|
203
|
+
const WIN_CREDENTIAL_FILE = path.join(WIN_CREDENTIAL_DIR, 'credentials.enc');
|
|
204
|
+
async function windowsSave(json) {
|
|
205
|
+
try {
|
|
206
|
+
fs.mkdirSync(WIN_CREDENTIAL_DIR, { recursive: true });
|
|
207
|
+
// Use PowerShell with DPAPI to encrypt. The output is a Base64 string.
|
|
208
|
+
const psScript = `
|
|
209
|
+
Add-Type -AssemblyName System.Security
|
|
210
|
+
$bytes = [System.Text.Encoding]::UTF8.GetBytes('${json.replace(/'/g, "''")}')
|
|
211
|
+
$encrypted = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
212
|
+
[Convert]::ToBase64String($encrypted)
|
|
213
|
+
`;
|
|
214
|
+
const { stdout } = await execWithTimeout('powershell', ['-NoProfile', '-NonInteractive', '-Command', psScript], 5000);
|
|
215
|
+
fs.writeFileSync(WIN_CREDENTIAL_FILE, stdout.trim(), 'utf-8');
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
throw new Error(`Windows DPAPI save failed: ${err.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function windowsLoad() {
|
|
222
|
+
try {
|
|
223
|
+
if (!fs.existsSync(WIN_CREDENTIAL_FILE))
|
|
224
|
+
return null;
|
|
225
|
+
const encrypted = fs.readFileSync(WIN_CREDENTIAL_FILE, 'utf-8').trim();
|
|
226
|
+
const psScript = `
|
|
227
|
+
Add-Type -AssemblyName System.Security
|
|
228
|
+
$encrypted = [Convert]::FromBase64String('${encrypted}')
|
|
229
|
+
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($encrypted, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
230
|
+
[System.Text.Encoding]::UTF8.GetString($bytes)
|
|
231
|
+
`;
|
|
232
|
+
const { stdout } = await execWithTimeout('powershell', ['-NoProfile', '-NonInteractive', '-Command', psScript], 5000);
|
|
233
|
+
return stdout.trim() || null;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function windowsClear() {
|
|
240
|
+
try {
|
|
241
|
+
if (fs.existsSync(WIN_CREDENTIAL_FILE)) {
|
|
242
|
+
fs.unlinkSync(WIN_CREDENTIAL_FILE);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Ignore
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// ─── AES-256-GCM Encrypted File Fallback ────────────────────────────
|
|
250
|
+
//
|
|
251
|
+
// Security model note: The encrypted file fallback is intentionally weaker
|
|
252
|
+
// than OS-native keychain storage. The AES key is derived from machine
|
|
253
|
+
// identifiers (hostname + username) which are not secret. An attacker with
|
|
254
|
+
// filesystem access to both the salt file and encrypted file, plus knowledge
|
|
255
|
+
// of the derivation scheme, could reconstruct the key. This is by design —
|
|
256
|
+
// the fallback targets environments (Docker, CI, headless servers) where OS
|
|
257
|
+
// keychains are unavailable. It prevents casual exposure (e.g., `cat` the
|
|
258
|
+
// file, dotfile backups) but is not equivalent to hardware-backed storage.
|
|
259
|
+
// For maximum security, use a desktop environment where OS keychain is available.
|
|
260
|
+
/**
|
|
261
|
+
* Derives a 256-bit AES key from machine-specific identifiers.
|
|
262
|
+
* The salt is generated once and persisted to prevent key drift.
|
|
263
|
+
*/
|
|
264
|
+
function deriveEncryptionKey() {
|
|
265
|
+
let salt;
|
|
266
|
+
if (fs.existsSync(SALT_FILE)) {
|
|
267
|
+
salt = Buffer.from(fs.readFileSync(SALT_FILE, 'utf-8'), 'hex');
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
salt = crypto.randomBytes(32);
|
|
271
|
+
fs.writeFileSync(SALT_FILE, salt.toString('hex'), 'utf-8');
|
|
272
|
+
setFilePermissions(SALT_FILE);
|
|
273
|
+
}
|
|
274
|
+
// Combine machine-specific identifiers for key derivation material
|
|
275
|
+
let username = 'unknown';
|
|
276
|
+
try {
|
|
277
|
+
username = os.userInfo().username;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// os.userInfo() can throw in some minimal Docker containers (ENOENT on /etc/passwd)
|
|
281
|
+
}
|
|
282
|
+
const machineId = `${os.hostname()}:${username}:${SERVICE_NAME}`;
|
|
283
|
+
return crypto.pbkdf2Sync(machineId, salt, 100_000, 32, 'sha512');
|
|
284
|
+
}
|
|
285
|
+
function encryptAES(plaintext) {
|
|
286
|
+
const key = deriveEncryptionKey();
|
|
287
|
+
const iv = crypto.randomBytes(16);
|
|
288
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
289
|
+
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
|
290
|
+
encrypted += cipher.final('hex');
|
|
291
|
+
const authTag = cipher.getAuthTag().toString('hex');
|
|
292
|
+
// Store as iv:authTag:ciphertext
|
|
293
|
+
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
|
|
294
|
+
}
|
|
295
|
+
function decryptAES(blob) {
|
|
296
|
+
const parts = blob.split(':');
|
|
297
|
+
if (parts.length !== 3)
|
|
298
|
+
throw new Error('Invalid encrypted credential format');
|
|
299
|
+
const [ivHex, authTagHex, ciphertext] = parts;
|
|
300
|
+
const key = deriveEncryptionKey();
|
|
301
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
302
|
+
const authTag = Buffer.from(authTagHex, 'hex');
|
|
303
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
304
|
+
decipher.setAuthTag(authTag);
|
|
305
|
+
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
|
306
|
+
decrypted += decipher.final('utf8');
|
|
307
|
+
return decrypted;
|
|
308
|
+
}
|
|
309
|
+
async function fileSave(json) {
|
|
310
|
+
const encrypted = encryptAES(json);
|
|
311
|
+
fs.writeFileSync(ENCRYPTED_FILE, encrypted, 'utf-8');
|
|
312
|
+
setFilePermissions(ENCRYPTED_FILE);
|
|
313
|
+
}
|
|
314
|
+
async function fileLoad() {
|
|
315
|
+
try {
|
|
316
|
+
if (!fs.existsSync(ENCRYPTED_FILE))
|
|
317
|
+
return null;
|
|
318
|
+
const blob = fs.readFileSync(ENCRYPTED_FILE, 'utf-8').trim();
|
|
319
|
+
return decryptAES(blob);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function fileClear() {
|
|
326
|
+
try {
|
|
327
|
+
if (fs.existsSync(ENCRYPTED_FILE))
|
|
328
|
+
fs.unlinkSync(ENCRYPTED_FILE);
|
|
329
|
+
if (fs.existsSync(SALT_FILE))
|
|
330
|
+
fs.unlinkSync(SALT_FILE);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// Ignore
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Sets restrictive file permissions (owner read/write only) on Unix systems.
|
|
338
|
+
*/
|
|
339
|
+
function setFilePermissions(filePath) {
|
|
340
|
+
if (process.platform !== 'win32') {
|
|
341
|
+
try {
|
|
342
|
+
fs.chmodSync(filePath, 0o600);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// Best effort
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// ─── Public API ─────────────────────────────────────────────────────
|
|
350
|
+
/**
|
|
351
|
+
* Persists authentication credentials to the most secure available store.
|
|
352
|
+
*
|
|
353
|
+
* Strategy (tried in order):
|
|
354
|
+
* 1. macOS Keychain (`security` CLI)
|
|
355
|
+
* 2. Linux libsecret (`secret-tool` CLI)
|
|
356
|
+
* 3. Windows DPAPI (PowerShell)
|
|
357
|
+
* 4. AES-256-GCM encrypted file with 0600 permissions
|
|
358
|
+
*/
|
|
359
|
+
export async function saveCredentials(data) {
|
|
360
|
+
const current = await loadCredentials();
|
|
361
|
+
const merged = { ...current, ...data };
|
|
362
|
+
const json = JSON.stringify(merged);
|
|
363
|
+
const backend = await detectBackend();
|
|
364
|
+
try {
|
|
365
|
+
switch (backend) {
|
|
366
|
+
case 'keychain-macos':
|
|
367
|
+
await macSave(json);
|
|
368
|
+
break;
|
|
369
|
+
case 'libsecret-linux':
|
|
370
|
+
await linuxSave(json);
|
|
371
|
+
break;
|
|
372
|
+
case 'dpapi-windows':
|
|
373
|
+
await windowsSave(json);
|
|
374
|
+
break;
|
|
375
|
+
case 'encrypted-file':
|
|
376
|
+
await fileSave(json);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
debugLog(`Primary credential save failed (${backend}): ${err.message}. Falling back to encrypted file.`);
|
|
382
|
+
// If the OS-native backend fails, fall back to encrypted file
|
|
383
|
+
if (backend !== 'encrypted-file') {
|
|
384
|
+
await fileSave(json);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
throw err;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Loads authentication credentials from the secure store.
|
|
393
|
+
* Returns an empty object if no credentials are found.
|
|
394
|
+
*
|
|
395
|
+
* On first run after upgrade, silently migrates any legacy plaintext
|
|
396
|
+
* `~/.minovative-mind-cli.json` into the secure store and deletes the old file.
|
|
397
|
+
*/
|
|
398
|
+
export async function loadCredentials() {
|
|
399
|
+
const backend = await detectBackend();
|
|
400
|
+
let json = null;
|
|
401
|
+
try {
|
|
402
|
+
switch (backend) {
|
|
403
|
+
case 'keychain-macos':
|
|
404
|
+
json = await macLoad();
|
|
405
|
+
break;
|
|
406
|
+
case 'libsecret-linux':
|
|
407
|
+
json = await linuxLoad();
|
|
408
|
+
break;
|
|
409
|
+
case 'dpapi-windows':
|
|
410
|
+
json = await windowsLoad();
|
|
411
|
+
break;
|
|
412
|
+
case 'encrypted-file':
|
|
413
|
+
json = await fileLoad();
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
debugLog(`Primary credential load failed (${backend}): ${err.message}`);
|
|
419
|
+
}
|
|
420
|
+
// If the primary backend yielded nothing or threw an error, check the fallback file.
|
|
421
|
+
// This handles the case where saveCredentials timed out on the keychain and wrote
|
|
422
|
+
// to the fallback file instead.
|
|
423
|
+
if (!json && backend !== 'encrypted-file') {
|
|
424
|
+
try {
|
|
425
|
+
json = await fileLoad();
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
debugLog(`Fallback credential load failed: ${err.message}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (json) {
|
|
432
|
+
try {
|
|
433
|
+
return JSON.parse(json);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
debugLog('Failed to parse stored credentials — returning empty');
|
|
437
|
+
return {};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// No credentials found in secure store — check for legacy plaintext file to migrate
|
|
441
|
+
const migrated = await migrateLegacyCredentials();
|
|
442
|
+
if (migrated)
|
|
443
|
+
return migrated;
|
|
444
|
+
return {};
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Removes all stored credentials from the secure store.
|
|
448
|
+
* Also cleans up any legacy plaintext file if it still exists.
|
|
449
|
+
*/
|
|
450
|
+
export async function clearCredentials() {
|
|
451
|
+
const backend = await detectBackend();
|
|
452
|
+
try {
|
|
453
|
+
switch (backend) {
|
|
454
|
+
case 'keychain-macos':
|
|
455
|
+
await macClear();
|
|
456
|
+
break;
|
|
457
|
+
case 'libsecret-linux':
|
|
458
|
+
await linuxClear();
|
|
459
|
+
break;
|
|
460
|
+
case 'dpapi-windows':
|
|
461
|
+
await windowsClear();
|
|
462
|
+
break;
|
|
463
|
+
case 'encrypted-file':
|
|
464
|
+
await fileClear();
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
debugLog(`Credential clear failed (${backend}): ${err.message}`);
|
|
470
|
+
}
|
|
471
|
+
// Always attempt to clear the fallback file, as a previous saveCredentials
|
|
472
|
+
// might have timed out on the OS keychain and written to the fallback instead.
|
|
473
|
+
if (backend !== 'encrypted-file') {
|
|
474
|
+
await fileClear();
|
|
475
|
+
}
|
|
476
|
+
// Always clean up legacy file on logout
|
|
477
|
+
removeLegacyFile();
|
|
478
|
+
}
|
|
479
|
+
// ─── Migration ──────────────────────────────────────────────────────
|
|
480
|
+
/**
|
|
481
|
+
* Silently migrates credentials from the legacy plaintext
|
|
482
|
+
* `~/.minovative-mind-cli.json` file into the secure store.
|
|
483
|
+
* Deletes the plaintext file after successful migration.
|
|
484
|
+
*/
|
|
485
|
+
async function migrateLegacyCredentials() {
|
|
486
|
+
try {
|
|
487
|
+
if (!fs.existsSync(LEGACY_CONFIG_FILE))
|
|
488
|
+
return null;
|
|
489
|
+
const raw = fs.readFileSync(LEGACY_CONFIG_FILE, 'utf-8');
|
|
490
|
+
const data = JSON.parse(raw);
|
|
491
|
+
if (!data.idToken && !data.refreshToken)
|
|
492
|
+
return null;
|
|
493
|
+
// Save to the new secure store
|
|
494
|
+
const json = JSON.stringify(data);
|
|
495
|
+
const backend = await detectBackend();
|
|
496
|
+
try {
|
|
497
|
+
switch (backend) {
|
|
498
|
+
case 'keychain-macos':
|
|
499
|
+
await macSave(json);
|
|
500
|
+
break;
|
|
501
|
+
case 'libsecret-linux':
|
|
502
|
+
await linuxSave(json);
|
|
503
|
+
break;
|
|
504
|
+
case 'dpapi-windows':
|
|
505
|
+
await windowsSave(json);
|
|
506
|
+
break;
|
|
507
|
+
case 'encrypted-file':
|
|
508
|
+
await fileSave(json);
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
debugLog(`Primary credential save failed during migration (${backend}): ${err.message}. Falling back to encrypted file.`);
|
|
514
|
+
if (backend !== 'encrypted-file') {
|
|
515
|
+
await fileSave(json);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
throw err;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Remove the legacy plaintext file
|
|
522
|
+
removeLegacyFile();
|
|
523
|
+
debugLog('Successfully migrated legacy plaintext credentials to secure storage');
|
|
524
|
+
return data;
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
debugLog(`Legacy credential migration failed: ${err.message}`);
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function removeLegacyFile() {
|
|
532
|
+
try {
|
|
533
|
+
if (fs.existsSync(LEGACY_CONFIG_FILE)) {
|
|
534
|
+
fs.unlinkSync(LEGACY_CONFIG_FILE);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
// Best effort
|
|
539
|
+
}
|
|
540
|
+
}
|
|
@@ -60,6 +60,76 @@ export function ensureIgnored(workspaceRoot) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
ensureMinovativeMindIgnore(workspaceRoot);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Creates a default .minovativemindignore file if it doesn't exist, pre-populated with
|
|
67
|
+
* sensible defaults for files that the AI should ignore but Git might track.
|
|
68
|
+
*/
|
|
69
|
+
function ensureMinovativeMindIgnore(workspaceRoot) {
|
|
70
|
+
const filePath = path.join(workspaceRoot, '.minovativemindignore');
|
|
71
|
+
if (!fs.existsSync(filePath)) {
|
|
72
|
+
const template = `# Minovative Mind CLI Ignore File
|
|
73
|
+
|
|
74
|
+
# This file works exactly like .gitignore, but applies ONLY to the Minovative Mind CLI AI agent.
|
|
75
|
+
|
|
76
|
+
# Use it to block the CLI AI agent from reading large/redundant files or any files/folders that you would like the AI agent not to read but still want tracked by Git.
|
|
77
|
+
|
|
78
|
+
# Lockfiles (Huge token wasters, AI should read package.json instead)
|
|
79
|
+
package-lock.json
|
|
80
|
+
yarn.lock
|
|
81
|
+
pnpm-lock.yaml
|
|
82
|
+
poetry.lock
|
|
83
|
+
Cargo.lock
|
|
84
|
+
|
|
85
|
+
# Massive Data Dumps
|
|
86
|
+
*.sql
|
|
87
|
+
*.sqlite
|
|
88
|
+
*.dump
|
|
89
|
+
*.csv
|
|
90
|
+
|
|
91
|
+
# Built/Minified Assets
|
|
92
|
+
*.min.js
|
|
93
|
+
*.bundle.js
|
|
94
|
+
*.map
|
|
95
|
+
|
|
96
|
+
# Media / Binary
|
|
97
|
+
*.png
|
|
98
|
+
*.jpg
|
|
99
|
+
*.jpeg
|
|
100
|
+
*.gif
|
|
101
|
+
*.mp4
|
|
102
|
+
*.mp3
|
|
103
|
+
*.wav
|
|
104
|
+
*.ico
|
|
105
|
+
*.svg
|
|
106
|
+
*.pdf
|
|
107
|
+
|
|
108
|
+
# Archives
|
|
109
|
+
*.zip
|
|
110
|
+
*.tar
|
|
111
|
+
*.tar.gz
|
|
112
|
+
*.rar
|
|
113
|
+
*.7z
|
|
114
|
+
|
|
115
|
+
# Compiled Binaries
|
|
116
|
+
*.exe
|
|
117
|
+
*.dll
|
|
118
|
+
*.so
|
|
119
|
+
*.dylib
|
|
120
|
+
*.class
|
|
121
|
+
*.pyc
|
|
122
|
+
|
|
123
|
+
# Logs
|
|
124
|
+
*.log
|
|
125
|
+
`;
|
|
126
|
+
try {
|
|
127
|
+
fs.writeFileSync(filePath, template, 'utf-8');
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
}
|
|
63
133
|
}
|
|
64
134
|
/**
|
|
65
135
|
* Reads a JSON file from the project's .minovativemind directory.
|