recoder-code 2.4.7 ā 2.5.1
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/dist/src/commands/agents/create.d.ts +4 -0
- package/dist/src/commands/agents/create.js +175 -0
- package/dist/src/commands/agents/marketplace.d.ts +5 -0
- package/dist/src/commands/agents/marketplace.js +151 -0
- package/dist/src/commands/agents.js +49 -1
- package/dist/src/commands/configure.js +8 -0
- package/dist/src/commands/connect-cmd.js +8 -0
- package/dist/src/commands/hints.js +12 -0
- package/dist/src/commands/models/compare.d.ts +4 -0
- package/dist/src/commands/models/compare.js +92 -0
- package/dist/src/commands/models/select.d.ts +4 -0
- package/dist/src/commands/models/select.js +62 -0
- package/dist/src/commands/models-cmd.js +35 -0
- package/dist/src/commands/providers/config.d.ts +1 -1
- package/dist/src/commands/providers/config.js +37 -19
- package/dist/src/commands/providers/health.d.ts +4 -0
- package/dist/src/commands/providers/health.js +90 -0
- package/dist/src/commands/providers.js +20 -0
- package/dist/src/providers/local-detection.d.ts +6 -0
- package/dist/src/providers/local-detection.js +86 -0
- package/dist/src/providers/registry.js +2 -2
- package/dist/src/utils/secure-storage.d.ts +24 -0
- package/dist/src/utils/secure-storage.js +150 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -1
|
@@ -6,6 +6,17 @@ import * as fs from 'fs';
|
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import * as os from 'os';
|
|
8
8
|
import { getOllamaProvider, getOpenRouterProvider, getAnthropicProvider, getOpenAIProvider, getGroqProvider, parseModelId, } from '../providers/index.js';
|
|
9
|
+
import { selectModel } from './models/select.js';
|
|
10
|
+
import { compareModels } from './models/compare.js';
|
|
11
|
+
import { RecoderAuthService } from '../services/RecoderAuthService.js';
|
|
12
|
+
async function requireAuth() {
|
|
13
|
+
const authService = new RecoderAuthService();
|
|
14
|
+
const session = await authService.getSession();
|
|
15
|
+
if (!session) {
|
|
16
|
+
console.error('ā Please login first: recoder auth login');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
9
20
|
const CONFIG_DIR = path.join(os.homedir(), '.recoder-code');
|
|
10
21
|
const CUSTOM_MODELS_FILE = path.join(CONFIG_DIR, 'custom-models.json');
|
|
11
22
|
const DEFAULT_MODEL_FILE = path.join(CONFIG_DIR, 'default-model.json');
|
|
@@ -45,6 +56,7 @@ const listCommand = {
|
|
|
45
56
|
type: 'string',
|
|
46
57
|
}),
|
|
47
58
|
handler: async (argv) => {
|
|
59
|
+
await requireAuth();
|
|
48
60
|
console.log(chalk.bold.cyan('\nš¤ Available Models\n'));
|
|
49
61
|
const defaultModel = getDefaultModel();
|
|
50
62
|
if (defaultModel) {
|
|
@@ -120,6 +132,7 @@ const addCommand = {
|
|
|
120
132
|
.option('name', { describe: 'Display name', type: 'string' })
|
|
121
133
|
.option('provider', { describe: 'Provider', type: 'string' }),
|
|
122
134
|
handler: async (argv) => {
|
|
135
|
+
await requireAuth();
|
|
123
136
|
const parsed = parseModelId(argv.model);
|
|
124
137
|
const models = loadCustomModels();
|
|
125
138
|
if (models.some((m) => m.id === argv.model)) {
|
|
@@ -141,6 +154,7 @@ const removeCommand = {
|
|
|
141
154
|
describe: 'Remove a custom model',
|
|
142
155
|
builder: (yargs) => yargs.positional('model', { describe: 'Model ID', type: 'string', demandOption: true }),
|
|
143
156
|
handler: async (argv) => {
|
|
157
|
+
await requireAuth();
|
|
144
158
|
const models = loadCustomModels();
|
|
145
159
|
const filtered = models.filter((m) => m.id !== argv.model);
|
|
146
160
|
if (filtered.length === models.length) {
|
|
@@ -157,16 +171,37 @@ const setDefaultCommand = {
|
|
|
157
171
|
describe: 'Set default model',
|
|
158
172
|
builder: (yargs) => yargs.positional('model', { describe: 'Model ID', type: 'string', demandOption: true }),
|
|
159
173
|
handler: async (argv) => {
|
|
174
|
+
await requireAuth();
|
|
160
175
|
setDefaultModel(argv.model);
|
|
161
176
|
console.log(chalk.green(`\nā Default model set to ${argv.model}\n`));
|
|
162
177
|
process.exit(0);
|
|
163
178
|
},
|
|
164
179
|
};
|
|
180
|
+
const selectCommand = {
|
|
181
|
+
command: 'select',
|
|
182
|
+
describe: 'Interactive model selection with fuzzy search',
|
|
183
|
+
handler: async () => {
|
|
184
|
+
await requireAuth();
|
|
185
|
+
await selectModel();
|
|
186
|
+
process.exit(0);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const compareCommand = {
|
|
190
|
+
command: 'compare',
|
|
191
|
+
describe: 'Compare multiple models side-by-side',
|
|
192
|
+
handler: async () => {
|
|
193
|
+
await requireAuth();
|
|
194
|
+
await compareModels();
|
|
195
|
+
process.exit(0);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
165
198
|
export const modelsCommand = {
|
|
166
199
|
command: 'models',
|
|
167
200
|
describe: 'Manage AI models',
|
|
168
201
|
builder: (yargs) => yargs
|
|
169
202
|
.command(listCommand)
|
|
203
|
+
.command(selectCommand)
|
|
204
|
+
.command(compareCommand)
|
|
170
205
|
.command(addCommand)
|
|
171
206
|
.command(removeCommand)
|
|
172
207
|
.command(setDefaultCommand)
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Providers config command - Configure API keys
|
|
2
|
+
* Providers config command - Configure API keys with secure storage
|
|
3
3
|
*/
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import * as os from 'os';
|
|
8
8
|
import * as readline from 'readline';
|
|
9
|
+
import { SecureKeyStorage } from '../../utils/secure-storage.js';
|
|
9
10
|
const CONFIG_DIR = path.join(os.homedir(), '.recoder-code');
|
|
10
11
|
const ENV_FILE = path.join(CONFIG_DIR, '.env');
|
|
11
12
|
export async function configureProvider(provider) {
|
|
@@ -19,6 +20,13 @@ export async function configureProvider(provider) {
|
|
|
19
20
|
console.log();
|
|
20
21
|
console.log(chalk.cyan('Usage: recoder providers config <provider>'));
|
|
21
22
|
console.log();
|
|
23
|
+
// Show stored keys
|
|
24
|
+
const stored = await SecureKeyStorage.list();
|
|
25
|
+
if (stored.length > 0) {
|
|
26
|
+
console.log(chalk.green('Configured providers:'));
|
|
27
|
+
stored.forEach(p => console.log(chalk.gray(` ā ${p}`)));
|
|
28
|
+
console.log();
|
|
29
|
+
}
|
|
22
30
|
return;
|
|
23
31
|
}
|
|
24
32
|
const envVars = {
|
|
@@ -36,30 +44,40 @@ export async function configureProvider(provider) {
|
|
|
36
44
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
37
45
|
const question = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
38
46
|
console.log(chalk.bold.cyan(`\nāļø Configure ${provider}\n`));
|
|
47
|
+
console.log(chalk.gray('API key will be stored securely in keychain\n'));
|
|
39
48
|
const apiKey = await question(chalk.white(`Enter ${envVar}: `));
|
|
40
49
|
rl.close();
|
|
41
50
|
if (!apiKey.trim()) {
|
|
42
51
|
console.log(chalk.yellow('\nNo key provided, skipping.\n'));
|
|
43
52
|
return;
|
|
44
53
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
try {
|
|
55
|
+
// Store in secure keychain
|
|
56
|
+
await SecureKeyStorage.set(provider.toLowerCase(), apiKey.trim());
|
|
57
|
+
// Also save to env file for backward compatibility
|
|
58
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
59
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
let envContent = '';
|
|
62
|
+
if (fs.existsSync(ENV_FILE)) {
|
|
63
|
+
envContent = fs.readFileSync(ENV_FILE, 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
// Update or add the key
|
|
66
|
+
const regex = new RegExp(`^${envVar}=.*$`, 'm');
|
|
67
|
+
if (regex.test(envContent)) {
|
|
68
|
+
envContent = envContent.replace(regex, `${envVar}=${apiKey.trim()}`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
envContent += `\n${envVar}=${apiKey.trim()}`;
|
|
72
|
+
}
|
|
73
|
+
fs.writeFileSync(ENV_FILE, envContent.trim() + '\n');
|
|
74
|
+
console.log(chalk.green(`\nā API key stored securely`));
|
|
75
|
+
console.log(chalk.gray(` Keychain: ${process.platform === 'darwin' ? 'macOS Keychain' : 'Encrypted file'}`));
|
|
76
|
+
console.log(chalk.gray(` Fallback: ${ENV_FILE}`));
|
|
77
|
+
console.log();
|
|
57
78
|
}
|
|
58
|
-
|
|
59
|
-
|
|
79
|
+
catch (err) {
|
|
80
|
+
console.log(chalk.red(`\nā Error: ${err.message}`));
|
|
81
|
+
console.log();
|
|
60
82
|
}
|
|
61
|
-
fs.writeFileSync(ENV_FILE, envContent.trim() + '\n');
|
|
62
|
-
console.log(chalk.green(`\nā Saved to ${ENV_FILE}`));
|
|
63
|
-
console.log(chalk.gray(` Add to shell: export ${envVar}="${apiKey.trim()}"`));
|
|
64
|
-
console.log();
|
|
65
83
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider health monitoring
|
|
3
|
+
*/
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { getProviderRegistry } from '../../providers/registry.js';
|
|
6
|
+
async function checkProviderHealth(provider) {
|
|
7
|
+
const start = Date.now();
|
|
8
|
+
try {
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
11
|
+
let endpoint = provider.baseUrl;
|
|
12
|
+
if (provider.engine === 'openai') {
|
|
13
|
+
endpoint = `${provider.baseUrl}/models`;
|
|
14
|
+
}
|
|
15
|
+
else if (provider.engine === 'ollama') {
|
|
16
|
+
endpoint = `${provider.baseUrl}/api/tags`;
|
|
17
|
+
}
|
|
18
|
+
const response = await fetch(endpoint, {
|
|
19
|
+
method: 'GET',
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
...provider.headers,
|
|
23
|
+
},
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
});
|
|
26
|
+
clearTimeout(timeout);
|
|
27
|
+
const responseTime = Date.now() - start;
|
|
28
|
+
if (response.ok) {
|
|
29
|
+
return {
|
|
30
|
+
provider: provider.id,
|
|
31
|
+
name: provider.name,
|
|
32
|
+
status: 'online',
|
|
33
|
+
responseTime,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
return {
|
|
38
|
+
provider: provider.id,
|
|
39
|
+
name: provider.name,
|
|
40
|
+
status: 'error',
|
|
41
|
+
error: `HTTP ${response.status}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return {
|
|
47
|
+
provider: provider.id,
|
|
48
|
+
name: provider.name,
|
|
49
|
+
status: 'offline',
|
|
50
|
+
error: err.message || 'Connection failed',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function monitorProviders() {
|
|
55
|
+
console.log(chalk.bold.cyan('\nš„ Provider Health Monitor\n'));
|
|
56
|
+
console.log(chalk.gray('Checking all providers...\n'));
|
|
57
|
+
const registry = getProviderRegistry();
|
|
58
|
+
const providers = registry.getAllProviders();
|
|
59
|
+
const results = [];
|
|
60
|
+
for (const provider of providers) {
|
|
61
|
+
const status = await checkProviderHealth(provider);
|
|
62
|
+
results.push(status);
|
|
63
|
+
}
|
|
64
|
+
// Display results
|
|
65
|
+
console.log(chalk.bold('Status Report:'));
|
|
66
|
+
console.log(chalk.gray('ā'.repeat(60)));
|
|
67
|
+
const online = results.filter(r => r.status === 'online');
|
|
68
|
+
const offline = results.filter(r => r.status === 'offline');
|
|
69
|
+
const errors = results.filter(r => r.status === 'error');
|
|
70
|
+
online.forEach(r => {
|
|
71
|
+
console.log(chalk.green('ā') + ' ' +
|
|
72
|
+
chalk.bold(r.name) + ' ' +
|
|
73
|
+
chalk.gray(`(${r.responseTime}ms)`));
|
|
74
|
+
});
|
|
75
|
+
errors.forEach(r => {
|
|
76
|
+
console.log(chalk.yellow('ā ') + ' ' +
|
|
77
|
+
chalk.bold(r.name) + ' ' +
|
|
78
|
+
chalk.gray(`- ${r.error}`));
|
|
79
|
+
});
|
|
80
|
+
offline.forEach(r => {
|
|
81
|
+
console.log(chalk.red('ā') + ' ' +
|
|
82
|
+
chalk.bold(r.name) + ' ' +
|
|
83
|
+
chalk.gray(`- ${r.error}`));
|
|
84
|
+
});
|
|
85
|
+
console.log(chalk.gray('ā'.repeat(60)));
|
|
86
|
+
console.log(chalk.green(`${online.length} online`) + ' | ' +
|
|
87
|
+
chalk.yellow(`${errors.length} errors`) + ' | ' +
|
|
88
|
+
chalk.red(`${offline.length} offline`));
|
|
89
|
+
console.log();
|
|
90
|
+
}
|
|
@@ -6,6 +6,8 @@ import { listProviderModels } from './providers/models.js';
|
|
|
6
6
|
import { pullModel } from './providers/pull.js';
|
|
7
7
|
import { configureProvider } from './providers/config.js';
|
|
8
8
|
import { RecoderAuthService } from '../services/RecoderAuthService.js';
|
|
9
|
+
import { detectAndReport } from '../providers/local-detection.js';
|
|
10
|
+
import { monitorProviders } from './providers/health.js';
|
|
9
11
|
const listCommand = {
|
|
10
12
|
command: 'list',
|
|
11
13
|
describe: 'List all available providers',
|
|
@@ -20,6 +22,22 @@ const listCommand = {
|
|
|
20
22
|
process.exit(0);
|
|
21
23
|
},
|
|
22
24
|
};
|
|
25
|
+
const detectCommand = {
|
|
26
|
+
command: 'detect',
|
|
27
|
+
describe: 'Detect running local AI servers',
|
|
28
|
+
handler: async () => {
|
|
29
|
+
await detectAndReport();
|
|
30
|
+
process.exit(0);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const healthCommand = {
|
|
34
|
+
command: 'health',
|
|
35
|
+
describe: 'Monitor provider health status',
|
|
36
|
+
handler: async () => {
|
|
37
|
+
await monitorProviders();
|
|
38
|
+
process.exit(0);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
23
41
|
const modelsCommand = {
|
|
24
42
|
command: 'models [provider]',
|
|
25
43
|
describe: 'List models from all providers',
|
|
@@ -80,6 +98,8 @@ export const providersCommand = {
|
|
|
80
98
|
describe: 'Manage AI providers (Ollama, OpenRouter, etc.)',
|
|
81
99
|
builder: (yargs) => yargs
|
|
82
100
|
.command(listCommand)
|
|
101
|
+
.command(detectCommand)
|
|
102
|
+
.command(healthCommand)
|
|
83
103
|
.command(modelsCommand)
|
|
84
104
|
.command(pullCommand)
|
|
85
105
|
.command(configCommand)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Provider Detection - Auto-detect running local AI servers
|
|
3
|
+
*/
|
|
4
|
+
const LOCAL_PROVIDERS = [
|
|
5
|
+
{
|
|
6
|
+
id: 'lmstudio',
|
|
7
|
+
name: 'LM Studio',
|
|
8
|
+
port: 1234,
|
|
9
|
+
baseUrl: 'http://localhost:1234/v1',
|
|
10
|
+
engine: 'openai',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: 'ollama',
|
|
14
|
+
name: 'Ollama',
|
|
15
|
+
port: 11434,
|
|
16
|
+
baseUrl: 'http://localhost:11434',
|
|
17
|
+
engine: 'ollama',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'llamacpp',
|
|
21
|
+
name: 'llama.cpp',
|
|
22
|
+
port: 8080,
|
|
23
|
+
baseUrl: 'http://localhost:8080/v1',
|
|
24
|
+
engine: 'openai',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'vllm',
|
|
28
|
+
name: 'vLLM',
|
|
29
|
+
port: 8000,
|
|
30
|
+
baseUrl: 'http://localhost:8000/v1',
|
|
31
|
+
engine: 'openai',
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
async function checkServer(url) {
|
|
35
|
+
try {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timeout = setTimeout(() => controller.abort(), 1000);
|
|
38
|
+
const response = await fetch(url, {
|
|
39
|
+
method: 'GET',
|
|
40
|
+
signal: controller.signal,
|
|
41
|
+
});
|
|
42
|
+
clearTimeout(timeout);
|
|
43
|
+
return response.ok;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function detectLocalProviders() {
|
|
50
|
+
const detected = [];
|
|
51
|
+
for (const provider of LOCAL_PROVIDERS) {
|
|
52
|
+
const isRunning = await checkServer(provider.baseUrl);
|
|
53
|
+
if (isRunning) {
|
|
54
|
+
detected.push({
|
|
55
|
+
id: provider.id,
|
|
56
|
+
name: provider.name,
|
|
57
|
+
engine: provider.engine,
|
|
58
|
+
baseUrl: provider.baseUrl,
|
|
59
|
+
isLocal: true,
|
|
60
|
+
isEnabled: true,
|
|
61
|
+
isBuiltin: false,
|
|
62
|
+
supportsStreaming: true,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return detected;
|
|
67
|
+
}
|
|
68
|
+
export async function detectAndReport() {
|
|
69
|
+
console.log('š Detecting local AI servers...\n');
|
|
70
|
+
const detected = await detectLocalProviders();
|
|
71
|
+
if (detected.length === 0) {
|
|
72
|
+
console.log('ā No local AI servers detected');
|
|
73
|
+
console.log('\nš” Start one of these:');
|
|
74
|
+
LOCAL_PROVIDERS.forEach(p => {
|
|
75
|
+
console.log(` ⢠${p.name} (port ${p.port})`);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
console.log('ā
Detected local servers:\n');
|
|
80
|
+
detected.forEach(p => {
|
|
81
|
+
console.log(` ⢠${p.name} - ${p.baseUrl}`);
|
|
82
|
+
});
|
|
83
|
+
console.log('\nš” Use with: recoder --provider ' + detected[0].id);
|
|
84
|
+
}
|
|
85
|
+
console.log();
|
|
86
|
+
}
|
|
@@ -11,8 +11,8 @@ const PROVIDERS_FILE = path.join(CONFIG_DIR, 'providers.json');
|
|
|
11
11
|
const CUSTOM_PROVIDERS_DIR = path.join(CONFIG_DIR, 'custom_providers');
|
|
12
12
|
const DEFAULT_CONFIG = {
|
|
13
13
|
version: '1.0',
|
|
14
|
-
defaultProvider: '
|
|
15
|
-
defaultModel: 'claude-sonnet-4-20250514',
|
|
14
|
+
defaultProvider: 'openrouter',
|
|
15
|
+
defaultModel: 'anthropic/claude-sonnet-4-20250514',
|
|
16
16
|
customProviders: [],
|
|
17
17
|
lastUpdated: new Date().toISOString(),
|
|
18
18
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure keychain storage for API keys
|
|
3
|
+
* Uses macOS Keychain on macOS, encrypted file on other platforms
|
|
4
|
+
*/
|
|
5
|
+
export declare class SecureKeyStorage {
|
|
6
|
+
/**
|
|
7
|
+
* Store API key securely
|
|
8
|
+
*/
|
|
9
|
+
static set(provider: string, apiKey: string): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Retrieve API key
|
|
12
|
+
*/
|
|
13
|
+
static get(provider: string): Promise<string | null>;
|
|
14
|
+
/**
|
|
15
|
+
* Delete API key
|
|
16
|
+
*/
|
|
17
|
+
static delete(provider: string): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* List all stored providers
|
|
20
|
+
*/
|
|
21
|
+
static list(): Promise<string[]>;
|
|
22
|
+
private static loadEncryptedFile;
|
|
23
|
+
private static saveEncryptedFile;
|
|
24
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure keychain storage for API keys
|
|
3
|
+
* Uses macOS Keychain on macOS, encrypted file on other platforms
|
|
4
|
+
*/
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
const KEYCHAIN_SERVICE = 'xyz.recoder.cli';
|
|
11
|
+
const ENCRYPTED_FILE = path.join(os.homedir(), '.recoder-code', '.keys.enc');
|
|
12
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
13
|
+
function isMacOS() {
|
|
14
|
+
return process.platform === 'darwin';
|
|
15
|
+
}
|
|
16
|
+
function getMachineId() {
|
|
17
|
+
try {
|
|
18
|
+
if (isMacOS()) {
|
|
19
|
+
return execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', { encoding: 'utf-8' })
|
|
20
|
+
.split('=')[1]
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/"/g, '');
|
|
23
|
+
}
|
|
24
|
+
return os.hostname() + os.userInfo().username;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return os.hostname();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function getEncryptionKey() {
|
|
31
|
+
const machineId = getMachineId();
|
|
32
|
+
return crypto.scryptSync(machineId, 'recoder-salt', 32);
|
|
33
|
+
}
|
|
34
|
+
export class SecureKeyStorage {
|
|
35
|
+
/**
|
|
36
|
+
* Store API key securely
|
|
37
|
+
*/
|
|
38
|
+
static async set(provider, apiKey) {
|
|
39
|
+
if (isMacOS()) {
|
|
40
|
+
try {
|
|
41
|
+
// Try to delete existing first
|
|
42
|
+
try {
|
|
43
|
+
execSync(`security delete-generic-password -s "${KEYCHAIN_SERVICE}" -a "${provider}" 2>/dev/null`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Ignore if doesn't exist
|
|
47
|
+
}
|
|
48
|
+
// Add new key
|
|
49
|
+
execSync(`security add-generic-password -s "${KEYCHAIN_SERVICE}" -a "${provider}" -w "${apiKey}"`);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw new Error(`Failed to store key in keychain: ${err}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// Encrypted file storage for non-macOS
|
|
57
|
+
const keys = this.loadEncryptedFile();
|
|
58
|
+
keys[provider] = apiKey;
|
|
59
|
+
this.saveEncryptedFile(keys);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Retrieve API key
|
|
64
|
+
*/
|
|
65
|
+
static async get(provider) {
|
|
66
|
+
if (isMacOS()) {
|
|
67
|
+
try {
|
|
68
|
+
const result = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${provider}" -w`, { encoding: 'utf-8' });
|
|
69
|
+
return result.trim();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const keys = this.loadEncryptedFile();
|
|
77
|
+
return keys[provider] || null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Delete API key
|
|
82
|
+
*/
|
|
83
|
+
static async delete(provider) {
|
|
84
|
+
if (isMacOS()) {
|
|
85
|
+
try {
|
|
86
|
+
execSync(`security delete-generic-password -s "${KEYCHAIN_SERVICE}" -a "${provider}"`);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Ignore if doesn't exist
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const keys = this.loadEncryptedFile();
|
|
94
|
+
delete keys[provider];
|
|
95
|
+
this.saveEncryptedFile(keys);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* List all stored providers
|
|
100
|
+
*/
|
|
101
|
+
static async list() {
|
|
102
|
+
if (isMacOS()) {
|
|
103
|
+
try {
|
|
104
|
+
const result = execSync(`security dump-keychain | grep -A 1 "${KEYCHAIN_SERVICE}" | grep "acct" | cut -d'"' -f4`, { encoding: 'utf-8' });
|
|
105
|
+
return result.trim().split('\n').filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const keys = this.loadEncryptedFile();
|
|
113
|
+
return Object.keys(keys);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
static loadEncryptedFile() {
|
|
117
|
+
try {
|
|
118
|
+
if (!fs.existsSync(ENCRYPTED_FILE))
|
|
119
|
+
return {};
|
|
120
|
+
const encrypted = fs.readFileSync(ENCRYPTED_FILE, 'utf-8');
|
|
121
|
+
const [ivHex, authTagHex, encryptedData] = encrypted.split(':');
|
|
122
|
+
const key = getEncryptionKey();
|
|
123
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
124
|
+
const authTag = Buffer.from(authTagHex, 'hex');
|
|
125
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
126
|
+
decipher.setAuthTag(authTag);
|
|
127
|
+
let decrypted = decipher.update(encryptedData, 'hex', 'utf-8');
|
|
128
|
+
decrypted += decipher.final('utf-8');
|
|
129
|
+
return JSON.parse(decrypted);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
static saveEncryptedFile(keys) {
|
|
136
|
+
const dir = path.dirname(ENCRYPTED_FILE);
|
|
137
|
+
if (!fs.existsSync(dir)) {
|
|
138
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
139
|
+
}
|
|
140
|
+
const key = getEncryptionKey();
|
|
141
|
+
const iv = crypto.randomBytes(16);
|
|
142
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
143
|
+
let encrypted = cipher.update(JSON.stringify(keys), 'utf-8', 'hex');
|
|
144
|
+
encrypted += cipher.final('hex');
|
|
145
|
+
const authTag = cipher.getAuthTag();
|
|
146
|
+
const output = `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
147
|
+
fs.writeFileSync(ENCRYPTED_FILE, output, 'utf-8');
|
|
148
|
+
fs.chmodSync(ENCRYPTED_FILE, 0o600); // Owner read/write only
|
|
149
|
+
}
|
|
150
|
+
}
|