@liuzijian625/code-cli 1.0.6 → 1.0.8
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 +276 -48
- package/lib/config.js +64 -43
- package/lib/installer.js +42 -0
- 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/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.8",
|
|
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
|
},
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# code-cli 远程预设服务(Go 单二进制)
|
|
2
|
+
|
|
3
|
+
这个服务用于在一台公网服务器上集中维护加密后的 `presets.enc`,并提供网页在线预览/编辑;各台机器的 `code-cli` 每次从远程拉取 `presets.enc`,用同一套密码解密后应用配置。
|
|
4
|
+
|
|
5
|
+
## 构建
|
|
6
|
+
|
|
7
|
+
在有 Go 的机器上构建出单个可执行文件:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd remote-server
|
|
11
|
+
go build -o code-cli-remote .
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
把 `code-cli-remote` 拷贝到服务器即可部署(运行时不依赖其它文件,只有你指定的 `config` / `data` 会落盘)。
|
|
15
|
+
|
|
16
|
+
## 启动
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
./code-cli-remote -listen :8080
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
打开浏览器访问 `http://<服务器>:8080/`:
|
|
23
|
+
|
|
24
|
+
- 第一次打开:会提示你设置密码(同一套密码:网页登录 + presets 文件加密),并自动创建 `config` 和加密后的 `presets.enc`(默认写在可执行文件同目录)
|
|
25
|
+
- 之后:使用该密码登录即可预览/编辑/保存
|
|
26
|
+
|
|
27
|
+
页面会显示并提供“一键复制”远程拉取 URL:`http(s)://<服务器>:8080/presets.enc`
|
|
28
|
+
|
|
29
|
+
如需自定义文件路径:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
./code-cli-remote -listen :8080 -config /path/to/code-cli-remote.json -data /path/to/presets.enc
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## (可选)命令行初始化
|
|
36
|
+
|
|
37
|
+
如果你不想通过网页初始化,也可以用命令行初始化:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export CODECLI_REMOTE_PASSWORD='your-strong-password'
|
|
41
|
+
./code-cli-remote -init -config ./code-cli-remote.json -data ./presets.enc
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 配置到各台机器(code-cli)
|
|
45
|
+
|
|
46
|
+
在每台机器运行 `code-cli`:
|
|
47
|
+
|
|
48
|
+
- 进入 `远程配置`
|
|
49
|
+
- 设置远程 URL 为上面的 `.../presets.enc`
|
|
50
|
+
- 设置/保存密码(本机加密保存)
|
|
51
|
+
- 然后用 `应用远程配置` 来选择预设并应用
|
|
52
|
+
|
|
53
|
+
## 安全建议
|
|
54
|
+
|
|
55
|
+
- 强烈建议配合 HTTPS(例如用 Nginx/Caddy 反代到 `:8080`),避免密码在公网明文传输。
|
|
56
|
+
- `presets.enc` 本身是密文(即使被下载也需要密码解密),但仍建议使用强密码并限制服务器访问来源(防火墙/安全组)。
|