@liuzijian625/code-cli 1.0.5 → 1.0.7
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/bin/cli.js +236 -48
- package/lib/config.js +64 -40
- package/lib/remote.js +250 -0
- package/package.json +8 -1
- package/remote-server/README.md +56 -0
- package/remote-server/go.mod +3 -0
- package/remote-server/main.go +1436 -0
- package/.claude/settings.local.json +0 -10
package/bin/cli.js
CHANGED
|
@@ -1,43 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import inquirer from 'inquirer';
|
|
4
|
-
import chalk from 'chalk';
|
|
5
|
-
import { applyConfig, clearConfig } from '../lib/config.js';
|
|
6
|
-
import { addPreset, listPresets, deletePreset, getPresets } from '../lib/preset.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { applyConfig, clearConfig } from '../lib/config.js';
|
|
6
|
+
import { addPreset, listPresets, deletePreset, getPresets } from '../lib/preset.js';
|
|
7
|
+
import { clearRemotePassword, fetchRemotePresets, getRemoteSettings, setRemotePassword, setRemoteUrl } from '../lib/remote.js';
|
|
8
|
+
|
|
9
|
+
const TOOL_NAMES = { codex: 'Codex', claude: 'Claude Code', gemini: 'Gemini CLI' };
|
|
10
|
+
|
|
11
|
+
async function mainMenu() {
|
|
12
|
+
console.clear();
|
|
12
13
|
console.log(chalk.cyan.bold('\ncode-cli - AI CLI 配置管理工具\n'));
|
|
13
14
|
|
|
14
15
|
const { action } = await inquirer.prompt([{
|
|
15
16
|
type: 'list',
|
|
16
|
-
name: 'action',
|
|
17
|
-
message: '请选择操作:',
|
|
18
|
-
choices: [
|
|
19
|
-
{ name: '1. 应用配置', value: 'apply' },
|
|
20
|
-
{ name: '2. 管理预设', value: 'manage' },
|
|
21
|
-
{ name: '3. 删除配置', value: 'clear' },
|
|
22
|
-
{ name: '4.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
17
|
+
name: 'action',
|
|
18
|
+
message: '请选择操作:',
|
|
19
|
+
choices: [
|
|
20
|
+
{ name: '1. 应用配置', value: 'apply' },
|
|
21
|
+
{ name: '2. 管理预设', value: 'manage' },
|
|
22
|
+
{ name: '3. 删除配置', value: 'clear' },
|
|
23
|
+
{ name: '4. 远程配置', value: 'remote' },
|
|
24
|
+
{ name: '5. 退出', value: 'exit' }
|
|
25
|
+
]
|
|
26
|
+
}]);
|
|
27
|
+
|
|
28
|
+
switch (action) {
|
|
29
|
+
case 'apply':
|
|
30
|
+
await applyConfigMenu();
|
|
31
|
+
break;
|
|
32
|
+
case 'manage':
|
|
33
|
+
await managePresetMenu();
|
|
34
|
+
break;
|
|
35
|
+
case 'clear':
|
|
36
|
+
await clearConfigMenu();
|
|
37
|
+
break;
|
|
38
|
+
case 'remote':
|
|
39
|
+
await remoteConfigMenu();
|
|
40
|
+
break;
|
|
41
|
+
case 'exit':
|
|
42
|
+
console.log(chalk.green('再见!'));
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
41
46
|
|
|
42
47
|
async function selectTool(message, includeAll = false) {
|
|
43
48
|
const choices = [
|
|
@@ -195,20 +200,203 @@ async function deletePresetMenu() {
|
|
|
195
200
|
console.log(chalk.green(`\n预设 '${preset}' 已删除!`));
|
|
196
201
|
}
|
|
197
202
|
|
|
198
|
-
async function clearConfigMenu() {
|
|
199
|
-
const tool = await selectTool('选择要清除配置的工具:', true);
|
|
200
|
-
if (tool === 'back') return mainMenu();
|
|
201
|
-
|
|
202
|
-
await clearConfig(tool);
|
|
203
|
-
console.log(chalk.green(`\n${tool === 'all' ? '所有工具' : TOOL_NAMES[tool]} 的配置已清除`));
|
|
204
|
-
await pause();
|
|
205
|
-
return mainMenu();
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async function
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
203
|
+
async function clearConfigMenu() {
|
|
204
|
+
const tool = await selectTool('选择要清除配置的工具:', true);
|
|
205
|
+
if (tool === 'back') return mainMenu();
|
|
206
|
+
|
|
207
|
+
await clearConfig(tool);
|
|
208
|
+
console.log(chalk.green(`\n${tool === 'all' ? '所有工具' : TOOL_NAMES[tool]} 的配置已清除`));
|
|
209
|
+
await pause();
|
|
210
|
+
return mainMenu();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function ensureRemoteSettings() {
|
|
214
|
+
let { url, password } = getRemoteSettings();
|
|
215
|
+
|
|
216
|
+
if (!url) {
|
|
217
|
+
const ans = await inquirer.prompt([{
|
|
218
|
+
type: 'input',
|
|
219
|
+
name: 'url',
|
|
220
|
+
message: '请输入远程预设文件 URL:',
|
|
221
|
+
validate: (val) => val && val.trim() ? true : 'URL 不能为空'
|
|
222
|
+
}]);
|
|
223
|
+
url = ans.url.trim();
|
|
224
|
+
setRemoteUrl(url);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!password) {
|
|
228
|
+
const ans = await inquirer.prompt([{
|
|
229
|
+
type: 'password',
|
|
230
|
+
name: 'password',
|
|
231
|
+
message: '请输入远程密码:',
|
|
232
|
+
mask: '*',
|
|
233
|
+
validate: (val) => val && val.trim() ? true : '密码不能为空'
|
|
234
|
+
}, {
|
|
235
|
+
type: 'confirm',
|
|
236
|
+
name: 'remember',
|
|
237
|
+
message: '是否记住密码(仅保存到本机)?',
|
|
238
|
+
default: true
|
|
239
|
+
}]);
|
|
240
|
+
|
|
241
|
+
password = ans.password;
|
|
242
|
+
if (ans.remember) {
|
|
243
|
+
setRemotePassword(password);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { url, password };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function printPresetsSummary(presets) {
|
|
251
|
+
let hasAny = false;
|
|
252
|
+
for (const tool of ['codex', 'claude', 'gemini']) {
|
|
253
|
+
const list = presets[tool] || [];
|
|
254
|
+
if (list.length === 0) continue;
|
|
255
|
+
hasAny = true;
|
|
256
|
+
console.log(chalk.cyan(`\n${TOOL_NAMES[tool]} 预设:`));
|
|
257
|
+
list.forEach((p, i) => {
|
|
258
|
+
const maskedKey = p.key.length > 6 ? p.key.substring(0, 6) + '***' : '***';
|
|
259
|
+
console.log(` ${i + 1}. ${chalk.bold(p.name)}`);
|
|
260
|
+
console.log(` URL: ${p.url}`);
|
|
261
|
+
console.log(` Key: ${maskedKey}`);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!hasAny) {
|
|
266
|
+
console.log(chalk.yellow('\n远程暂无预设'));
|
|
267
|
+
}
|
|
268
|
+
console.log('');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function applyRemoteConfigMenu() {
|
|
272
|
+
const { url, password } = await ensureRemoteSettings();
|
|
273
|
+
let presets;
|
|
274
|
+
try {
|
|
275
|
+
presets = await fetchRemotePresets(url, password);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
278
|
+
console.log(chalk.red(`\n${message}`));
|
|
279
|
+
await pause();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const tool = await selectTool('选择要配置的工具 (远程):', true);
|
|
284
|
+
if (tool === 'back') return;
|
|
285
|
+
|
|
286
|
+
if (tool === 'all') {
|
|
287
|
+
for (const t of ['codex', 'claude', 'gemini']) {
|
|
288
|
+
const list = presets[t] || [];
|
|
289
|
+
if (list.length === 0) {
|
|
290
|
+
console.log(chalk.yellow(`\n${TOOL_NAMES[t]} 暂无远程预设,跳过`));
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const preset = await selectPreset(t, list);
|
|
294
|
+
if (preset) {
|
|
295
|
+
await applyConfig(t, preset);
|
|
296
|
+
console.log(chalk.green(`${TOOL_NAMES[t]} 配置已应用`));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
await pause();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const list = presets[tool] || [];
|
|
304
|
+
if (list.length === 0) {
|
|
305
|
+
console.log(chalk.yellow(`\n${TOOL_NAMES[tool]} 暂无远程预设`));
|
|
306
|
+
await pause();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const preset = await selectPreset(tool, list);
|
|
311
|
+
if (!preset) return;
|
|
312
|
+
|
|
313
|
+
await applyConfig(tool, preset);
|
|
314
|
+
console.log(chalk.green(`\n远程配置已应用到 ${TOOL_NAMES[tool]}`));
|
|
315
|
+
await pause();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function previewRemotePresetsMenu() {
|
|
319
|
+
const { url, password } = await ensureRemoteSettings();
|
|
320
|
+
try {
|
|
321
|
+
const presets = await fetchRemotePresets(url, password);
|
|
322
|
+
printPresetsSummary(presets);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
325
|
+
console.log(chalk.red(`\n${message}`));
|
|
326
|
+
}
|
|
327
|
+
await pause();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function remoteConfigMenu() {
|
|
331
|
+
const settings = getRemoteSettings();
|
|
332
|
+
console.clear();
|
|
333
|
+
console.log(chalk.cyan.bold('\n远程配置\n'));
|
|
334
|
+
console.log(`URL: ${settings.url ? chalk.green(settings.url) : chalk.yellow('(未设置)')}`);
|
|
335
|
+
console.log(`密码: ${settings.password ? chalk.green('(已保存)') : chalk.yellow('(未保存)')}`);
|
|
336
|
+
|
|
337
|
+
const { action } = await inquirer.prompt([{
|
|
338
|
+
type: 'list',
|
|
339
|
+
name: 'action',
|
|
340
|
+
message: '选择操作:',
|
|
341
|
+
choices: [
|
|
342
|
+
{ name: '1. 设置远程 URL', value: 'set_url' },
|
|
343
|
+
{ name: '2. 设置/更新密码', value: 'set_password' },
|
|
344
|
+
{ name: '3. 清除已保存密码', value: 'clear_password' },
|
|
345
|
+
{ name: '4. 预览远程预设', value: 'preview' },
|
|
346
|
+
{ name: '5. 应用远程配置', value: 'apply' },
|
|
347
|
+
{ name: '6. 返回', value: 'back' }
|
|
348
|
+
]
|
|
349
|
+
}]);
|
|
350
|
+
|
|
351
|
+
switch (action) {
|
|
352
|
+
case 'set_url': {
|
|
353
|
+
const ans = await inquirer.prompt([{
|
|
354
|
+
type: 'input',
|
|
355
|
+
name: 'url',
|
|
356
|
+
message: '请输入远程预设文件 URL:',
|
|
357
|
+
default: settings.url || '',
|
|
358
|
+
validate: (val) => val && val.trim() ? true : 'URL 不能为空'
|
|
359
|
+
}]);
|
|
360
|
+
setRemoteUrl(ans.url.trim());
|
|
361
|
+
console.log(chalk.green('\n远程 URL 已保存'));
|
|
362
|
+
await pause();
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case 'set_password': {
|
|
366
|
+
const ans = await inquirer.prompt([{
|
|
367
|
+
type: 'password',
|
|
368
|
+
name: 'password',
|
|
369
|
+
message: '请输入远程密码:',
|
|
370
|
+
mask: '*',
|
|
371
|
+
validate: (val) => val && val.trim() ? true : '密码不能为空'
|
|
372
|
+
}]);
|
|
373
|
+
setRemotePassword(ans.password);
|
|
374
|
+
console.log(chalk.green('\n远程密码已保存'));
|
|
375
|
+
await pause();
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
case 'clear_password':
|
|
379
|
+
clearRemotePassword();
|
|
380
|
+
console.log(chalk.green('\n已清除保存的密码'));
|
|
381
|
+
await pause();
|
|
382
|
+
break;
|
|
383
|
+
case 'preview':
|
|
384
|
+
await previewRemotePresetsMenu();
|
|
385
|
+
break;
|
|
386
|
+
case 'apply':
|
|
387
|
+
await applyRemoteConfigMenu();
|
|
388
|
+
break;
|
|
389
|
+
case 'back':
|
|
390
|
+
return mainMenu();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return remoteConfigMenu();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function pause() {
|
|
397
|
+
await inquirer.prompt([{
|
|
398
|
+
type: 'input',
|
|
399
|
+
name: 'continue',
|
|
212
400
|
message: '按回车继续...'
|
|
213
401
|
}]);
|
|
214
402
|
}
|
package/lib/config.js
CHANGED
|
@@ -5,13 +5,37 @@ import { parse, stringify } from '@iarna/toml';
|
|
|
5
5
|
import { exec } from 'child_process';
|
|
6
6
|
import { promisify } from 'util';
|
|
7
7
|
|
|
8
|
-
const execAsync = promisify(exec);
|
|
9
|
-
const isWindows = os.platform() === 'win32';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const isWindows = os.platform() === 'win32';
|
|
10
|
+
const CLAUDE_JSON_FILE = path.join(os.homedir(), '.claude.json');
|
|
11
|
+
|
|
12
|
+
function readJsonFileOrEmpty(filePath) {
|
|
13
|
+
if (!fs.existsSync(filePath)) return {};
|
|
14
|
+
try {
|
|
15
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
16
|
+
if (!raw.trim()) return {};
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
19
|
+
return parsed;
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeJsonFile(filePath, data) {
|
|
26
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureClaudeOnboardingComplete() {
|
|
30
|
+
const config = readJsonFileOrEmpty(CLAUDE_JSON_FILE);
|
|
31
|
+
config.hasCompletedOnboarding = true;
|
|
32
|
+
writeJsonFile(CLAUDE_JSON_FILE, config);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Codex 配置
|
|
36
|
+
async function applyCodex(preset) {
|
|
37
|
+
const codexDir = path.join(os.homedir(), '.codex');
|
|
38
|
+
const configFile = path.join(codexDir, 'config.toml');
|
|
15
39
|
const authFile = path.join(codexDir, 'auth.json');
|
|
16
40
|
|
|
17
41
|
if (!fs.existsSync(codexDir)) {
|
|
@@ -49,23 +73,22 @@ async function applyCodex(preset) {
|
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
// Claude Code 配置
|
|
52
|
-
async function applyClaude(preset) {
|
|
53
|
-
if (isWindows) {
|
|
54
|
-
// Windows: 修改 ~/.claude.json
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
config
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!fs.existsSync(claudeDir)) {
|
|
76
|
+
async function applyClaude(preset) {
|
|
77
|
+
if (isWindows) {
|
|
78
|
+
// Windows: 修改 ~/.claude.json
|
|
79
|
+
const config = readJsonFileOrEmpty(CLAUDE_JSON_FILE);
|
|
80
|
+
config.hasCompletedOnboarding = true;
|
|
81
|
+
if (!config.env || typeof config.env !== 'object' || Array.isArray(config.env)) config.env = {};
|
|
82
|
+
config.env.ANTHROPIC_BASE_URL = preset.url;
|
|
83
|
+
config.env.ANTHROPIC_AUTH_TOKEN = preset.key;
|
|
84
|
+
writeJsonFile(CLAUDE_JSON_FILE, config);
|
|
85
|
+
} else {
|
|
86
|
+
ensureClaudeOnboardingComplete();
|
|
87
|
+
// Linux/macOS: 修改 ~/.claude/settings.json
|
|
88
|
+
const claudeDir = path.join(os.homedir(), '.claude');
|
|
89
|
+
const settingsFile = path.join(claudeDir, 'settings.json');
|
|
90
|
+
|
|
91
|
+
if (!fs.existsSync(claudeDir)) {
|
|
69
92
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
70
93
|
}
|
|
71
94
|
|
|
@@ -128,22 +151,23 @@ async function clearCodex() {
|
|
|
128
151
|
}
|
|
129
152
|
}
|
|
130
153
|
|
|
131
|
-
async function clearClaude() {
|
|
132
|
-
if (isWindows) {
|
|
133
|
-
// Windows: 修改 ~/.claude.json
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
delete config.
|
|
138
|
-
delete config.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
} else {
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
154
|
+
async function clearClaude() {
|
|
155
|
+
if (isWindows) {
|
|
156
|
+
// Windows: 修改 ~/.claude.json
|
|
157
|
+
const config = readJsonFileOrEmpty(CLAUDE_JSON_FILE);
|
|
158
|
+
config.hasCompletedOnboarding = true;
|
|
159
|
+
if (config.env && typeof config.env === 'object' && !Array.isArray(config.env)) {
|
|
160
|
+
delete config.env.ANTHROPIC_BASE_URL;
|
|
161
|
+
delete config.env.ANTHROPIC_AUTH_TOKEN;
|
|
162
|
+
}
|
|
163
|
+
writeJsonFile(CLAUDE_JSON_FILE, config);
|
|
164
|
+
} else {
|
|
165
|
+
ensureClaudeOnboardingComplete();
|
|
166
|
+
const claudeDir = path.join(os.homedir(), '.claude');
|
|
167
|
+
const settingsFile = path.join(claudeDir, 'settings.json');
|
|
168
|
+
|
|
169
|
+
if (fs.existsSync(settingsFile)) {
|
|
170
|
+
let settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
|
147
171
|
if (settings.env) {
|
|
148
172
|
delete settings.env.ANTHROPIC_BASE_URL;
|
|
149
173
|
delete settings.env.ANTHROPIC_AUTH_TOKEN;
|
package/lib/remote.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = path.join(os.homedir(), '.code-cli');
|
|
7
|
+
const REMOTE_SETTINGS_FILE = path.join(CONFIG_DIR, 'remote.json');
|
|
8
|
+
const REMOTE_KEY_FILE = path.join(CONFIG_DIR, 'remote.key');
|
|
9
|
+
|
|
10
|
+
const TOOLS = ['codex', 'claude', 'gemini'];
|
|
11
|
+
const REMOTE_PRESETS_KDF_ITERATIONS = 200_000;
|
|
12
|
+
|
|
13
|
+
function ensureConfigDir() {
|
|
14
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
15
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function chmod600(filePath) {
|
|
20
|
+
if (os.platform() === 'win32') return;
|
|
21
|
+
try {
|
|
22
|
+
fs.chmodSync(filePath, 0o600);
|
|
23
|
+
} catch {
|
|
24
|
+
// ignore
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeJsonFileSecure(filePath, data) {
|
|
29
|
+
ensureConfigDir();
|
|
30
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
31
|
+
chmod600(filePath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readJsonFileOrEmpty(filePath) {
|
|
35
|
+
if (!fs.existsSync(filePath)) return {};
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
38
|
+
if (!raw.trim()) return {};
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
41
|
+
return parsed;
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getOrCreateLocalKey() {
|
|
48
|
+
ensureConfigDir();
|
|
49
|
+
|
|
50
|
+
if (fs.existsSync(REMOTE_KEY_FILE)) {
|
|
51
|
+
const raw = fs.readFileSync(REMOTE_KEY_FILE, 'utf-8').trim();
|
|
52
|
+
try {
|
|
53
|
+
const key = Buffer.from(raw, 'base64');
|
|
54
|
+
if (key.length === 32) return key;
|
|
55
|
+
} catch {
|
|
56
|
+
// fall through to re-create
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const key = crypto.randomBytes(32);
|
|
61
|
+
fs.writeFileSync(REMOTE_KEY_FILE, key.toString('base64'), { mode: 0o600 });
|
|
62
|
+
chmod600(REMOTE_KEY_FILE);
|
|
63
|
+
return key;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function encryptStringLocal(plaintext) {
|
|
67
|
+
const key = getOrCreateLocalKey();
|
|
68
|
+
const iv = crypto.randomBytes(12);
|
|
69
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
70
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
71
|
+
const tag = cipher.getAuthTag();
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
v: 1,
|
|
75
|
+
iv: iv.toString('base64'),
|
|
76
|
+
ct: ciphertext.toString('base64'),
|
|
77
|
+
tag: tag.toString('base64')
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function decryptStringLocal(encrypted) {
|
|
82
|
+
if (!encrypted || typeof encrypted !== 'object' || Array.isArray(encrypted)) {
|
|
83
|
+
throw new Error('invalid encrypted data');
|
|
84
|
+
}
|
|
85
|
+
if (encrypted.v !== 1) {
|
|
86
|
+
throw new Error('unsupported encrypted data version');
|
|
87
|
+
}
|
|
88
|
+
const key = getOrCreateLocalKey();
|
|
89
|
+
const iv = Buffer.from(encrypted.iv, 'base64');
|
|
90
|
+
const ct = Buffer.from(encrypted.ct, 'base64');
|
|
91
|
+
const tag = Buffer.from(encrypted.tag, 'base64');
|
|
92
|
+
|
|
93
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
94
|
+
decipher.setAuthTag(tag);
|
|
95
|
+
const plaintext = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
96
|
+
return plaintext.toString('utf8');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getRemoteSettings() {
|
|
100
|
+
const settings = readJsonFileOrEmpty(REMOTE_SETTINGS_FILE);
|
|
101
|
+
const url = typeof settings.url === 'string' && settings.url.trim() ? settings.url.trim() : undefined;
|
|
102
|
+
|
|
103
|
+
let password;
|
|
104
|
+
if (typeof settings.password === 'string' && settings.password) {
|
|
105
|
+
password = settings.password;
|
|
106
|
+
} else if (settings.password && typeof settings.password === 'object') {
|
|
107
|
+
try {
|
|
108
|
+
password = decryptStringLocal(settings.password);
|
|
109
|
+
} catch {
|
|
110
|
+
password = undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { url, password };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function setRemoteUrl(url) {
|
|
118
|
+
const settings = readJsonFileOrEmpty(REMOTE_SETTINGS_FILE);
|
|
119
|
+
settings.version = 1;
|
|
120
|
+
settings.url = url;
|
|
121
|
+
writeJsonFileSecure(REMOTE_SETTINGS_FILE, settings);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function setRemotePassword(password) {
|
|
125
|
+
const settings = readJsonFileOrEmpty(REMOTE_SETTINGS_FILE);
|
|
126
|
+
settings.version = 1;
|
|
127
|
+
settings.password = encryptStringLocal(password);
|
|
128
|
+
writeJsonFileSecure(REMOTE_SETTINGS_FILE, settings);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function clearRemotePassword() {
|
|
132
|
+
const settings = readJsonFileOrEmpty(REMOTE_SETTINGS_FILE);
|
|
133
|
+
delete settings.password;
|
|
134
|
+
writeJsonFileSecure(REMOTE_SETTINGS_FILE, settings);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizePresetsShape(data) {
|
|
138
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
139
|
+
throw new Error('预设文件格式错误:根节点必须是对象');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const normalized = {};
|
|
143
|
+
for (const tool of TOOLS) {
|
|
144
|
+
const list = data[tool];
|
|
145
|
+
if (list === undefined) {
|
|
146
|
+
normalized[tool] = [];
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(list)) {
|
|
150
|
+
throw new Error(`预设文件格式错误:${tool} 必须是数组`);
|
|
151
|
+
}
|
|
152
|
+
normalized[tool] = list.map((p, idx) => {
|
|
153
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) {
|
|
154
|
+
throw new Error(`预设文件格式错误:${tool}[${idx}] 必须是对象`);
|
|
155
|
+
}
|
|
156
|
+
const { name, url, key } = p;
|
|
157
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
158
|
+
throw new Error(`预设文件格式错误:${tool}[${idx}].name 必须是非空字符串`);
|
|
159
|
+
}
|
|
160
|
+
if (typeof url !== 'string' || !url.trim()) {
|
|
161
|
+
throw new Error(`预设文件格式错误:${tool}[${idx}].url 必须是非空字符串`);
|
|
162
|
+
}
|
|
163
|
+
if (typeof key !== 'string' || !key.trim()) {
|
|
164
|
+
throw new Error(`预设文件格式错误:${tool}[${idx}].key 必须是非空字符串`);
|
|
165
|
+
}
|
|
166
|
+
return { name: name.trim(), url: url.trim(), key: key.trim() };
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return normalized;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function looksLikeEncryptedPresetsJson(obj) {
|
|
173
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
|
|
174
|
+
if (obj.v !== 1 && obj.v !== 2) return false;
|
|
175
|
+
if (typeof obj.salt !== 'string' || typeof obj.iv !== 'string' || typeof obj.ct !== 'string' || typeof obj.tag !== 'string') return false;
|
|
176
|
+
if (obj.v === 2 && typeof obj.iterations !== 'number') return false;
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function deriveKeyFromPasswordV1(password, salt) {
|
|
181
|
+
return crypto.scryptSync(password, salt, 32);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function deriveKeyFromPasswordV2(password, salt, iterations) {
|
|
185
|
+
if (!Number.isFinite(iterations) || iterations <= 0) {
|
|
186
|
+
throw new Error('invalid iterations');
|
|
187
|
+
}
|
|
188
|
+
return crypto.pbkdf2Sync(password, salt, iterations, 32, 'sha256');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function decryptPresetsPayload(payloadText, password) {
|
|
192
|
+
const payload = JSON.parse(payloadText);
|
|
193
|
+
if (!looksLikeEncryptedPresetsJson(payload)) {
|
|
194
|
+
// 兼容:远程文件也可以是明文 presets.json
|
|
195
|
+
return normalizePresetsShape(payload);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const salt = Buffer.from(payload.salt, 'base64');
|
|
199
|
+
const iv = Buffer.from(payload.iv, 'base64');
|
|
200
|
+
const ct = Buffer.from(payload.ct, 'base64');
|
|
201
|
+
const tag = Buffer.from(payload.tag, 'base64');
|
|
202
|
+
|
|
203
|
+
const key = payload.v === 2
|
|
204
|
+
? deriveKeyFromPasswordV2(password, salt, payload.iterations)
|
|
205
|
+
: deriveKeyFromPasswordV1(password, salt);
|
|
206
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
207
|
+
decipher.setAuthTag(tag);
|
|
208
|
+
const plaintext = Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
209
|
+
const presets = JSON.parse(plaintext);
|
|
210
|
+
return normalizePresetsShape(presets);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function encryptPresetsToPayload(presets, password) {
|
|
214
|
+
const normalized = normalizePresetsShape(presets);
|
|
215
|
+
const salt = crypto.randomBytes(16);
|
|
216
|
+
const iv = crypto.randomBytes(12);
|
|
217
|
+
const key = deriveKeyFromPasswordV2(password, salt, REMOTE_PRESETS_KDF_ITERATIONS);
|
|
218
|
+
|
|
219
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
220
|
+
const plaintext = JSON.stringify(normalized);
|
|
221
|
+
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
222
|
+
const tag = cipher.getAuthTag();
|
|
223
|
+
|
|
224
|
+
const payload = {
|
|
225
|
+
v: 2,
|
|
226
|
+
kdf: 'pbkdf2-sha256',
|
|
227
|
+
iterations: REMOTE_PRESETS_KDF_ITERATIONS,
|
|
228
|
+
cipher: 'aes-256-gcm',
|
|
229
|
+
salt: salt.toString('base64'),
|
|
230
|
+
iv: iv.toString('base64'),
|
|
231
|
+
ct: ct.toString('base64'),
|
|
232
|
+
tag: tag.toString('base64')
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return JSON.stringify(payload, null, 2) + '\n';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function fetchRemotePresets(remoteUrl, password) {
|
|
239
|
+
const response = await fetch(remoteUrl, { redirect: 'follow' });
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
throw new Error(`远程请求失败:${response.status} ${response.statusText}`);
|
|
242
|
+
}
|
|
243
|
+
const text = await response.text();
|
|
244
|
+
try {
|
|
245
|
+
return decryptPresetsPayload(text, password);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
248
|
+
throw new Error(`远程预设解析/解密失败:${message}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liuzijian625/code-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "AI CLI 配置管理工具 - 管理 Codex、Claude Code、Gemini CLI 的配置",
|
|
5
5
|
"main": "bin/cli.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"code-cli": "bin/cli.js"
|
|
8
8
|
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"remote-server/main.go",
|
|
13
|
+
"remote-server/go.mod",
|
|
14
|
+
"remote-server/README.md"
|
|
15
|
+
],
|
|
9
16
|
"scripts": {
|
|
10
17
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
11
18
|
},
|