codexmate 0.0.5 → 0.0.6
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/.github/workflows/ci.yml +26 -0
- package/CHANGELOG.md +7 -0
- package/CHANGELOG.zh-CN.md +7 -0
- package/README.md +37 -29
- package/README.zh-CN.md +37 -29
- package/cli.js +1132 -293
- package/package.json +4 -3
- package/tests/e2e/recent-health.e2e.js +136 -135
- package/tests/e2e/run.js +63 -0
- package/web-ui.html +2151 -210
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
2
|
"name": "codexmate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Codex/Claude Code 配置与会话管理 CLI + Web 工具",
|
|
5
5
|
"bin": {
|
|
6
6
|
"codexmate": "./cli.js"
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@iarna/toml": "^2.2.5",
|
|
14
|
-
"json5": "^2.2.3"
|
|
14
|
+
"json5": "^2.2.3",
|
|
15
|
+
"zip-lib": "^1.2.1"
|
|
15
16
|
},
|
|
16
17
|
"engines": {
|
|
17
18
|
"node": ">=14"
|
|
@@ -1,135 +1,136 @@
|
|
|
1
|
-
const http = require('http');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const { spawn } = require('child_process');
|
|
6
|
-
|
|
7
|
-
const PORT = 3737;
|
|
8
|
-
const API_URL = `http://localhost:${PORT}/api`;
|
|
9
|
-
|
|
10
|
-
function delay(ms) {
|
|
11
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function request(action, params = {}) {
|
|
15
|
-
const payload = JSON.stringify({ action, params });
|
|
16
|
-
return new Promise((resolve, reject) => {
|
|
17
|
-
const req = http.request(API_URL, {
|
|
18
|
-
method: 'POST',
|
|
19
|
-
headers: {
|
|
20
|
-
'Content-Type': 'application/json',
|
|
21
|
-
'Content-Length': Buffer.byteLength(payload)
|
|
22
|
-
}
|
|
23
|
-
}, (res) => {
|
|
24
|
-
let data = '';
|
|
25
|
-
res.on('data', chunk => data += chunk);
|
|
26
|
-
res.on('end', () => {
|
|
27
|
-
try {
|
|
28
|
-
const json = JSON.parse(data || '{}');
|
|
29
|
-
resolve(json);
|
|
30
|
-
} catch (e) {
|
|
31
|
-
reject(e);
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
req.on('error', reject);
|
|
36
|
-
req.write(payload);
|
|
37
|
-
req.end();
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function waitForServer(timeoutMs = 12000) {
|
|
42
|
-
const start = Date.now();
|
|
43
|
-
while (Date.now() - start < timeoutMs) {
|
|
44
|
-
try {
|
|
45
|
-
await request('status');
|
|
46
|
-
return;
|
|
47
|
-
} catch (_) {
|
|
48
|
-
await delay(300);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
throw new Error('server not ready');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function escapeToml(value) {
|
|
55
|
-
return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function buildTemplate(provider, model, baseUrl, apiKey) {
|
|
59
|
-
return `model_provider = "${escapeToml(provider)}"
|
|
60
|
-
model = "${escapeToml(model)}"
|
|
61
|
-
|
|
62
|
-
[model_providers.${escapeToml(provider)}]
|
|
63
|
-
name = "${escapeToml(provider)}"
|
|
64
|
-
base_url = "${escapeToml(baseUrl)}"
|
|
65
|
-
wire_api = "responses"
|
|
66
|
-
preferred_auth_method = "${escapeToml(apiKey)}"
|
|
67
|
-
`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function assert(condition, message) {
|
|
71
|
-
if (!condition) {
|
|
72
|
-
throw new Error(`Assertion failed: ${message}`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async function run() {
|
|
77
|
-
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-e2e-'));
|
|
78
|
-
const env = {
|
|
79
|
-
...process.env,
|
|
80
|
-
USERPROFILE: tempHome,
|
|
81
|
-
HOME: tempHome,
|
|
82
|
-
CODEXMATE_NO_BROWSER: '1'
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const cliPath = path.join(__dirname, '..', '..', 'cli.js');
|
|
86
|
-
const child = spawn(process.execPath, [cliPath, 'start'], {
|
|
87
|
-
env,
|
|
88
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
await waitForServer();
|
|
93
|
-
|
|
94
|
-
await request('apply-config-template', { template: buildTemplate('alpha', 'm-alpha', 'https://example.com', 'sk-alpha') });
|
|
95
|
-
await request('apply-config-template', { template: buildTemplate('beta', 'm-beta', 'https://example.com', 'sk-beta') });
|
|
96
|
-
await request('apply-config-template', { template: buildTemplate('gamma', 'm-gamma', 'https://example.com', 'sk-gamma') });
|
|
97
|
-
await request('apply-config-template', { template: buildTemplate('delta', 'm-delta', 'https://example.com', 'sk-delta') });
|
|
98
|
-
|
|
99
|
-
const recent = await request('get-recent-configs');
|
|
100
|
-
assert(Array.isArray(recent.items), 'recent list should be array');
|
|
101
|
-
assert(recent.items.length === 3, 'recent list should keep 3 items');
|
|
102
|
-
assert(recent.items[0].provider === 'delta' && recent.items[0].model === 'm-delta', 'recent[0] should be delta');
|
|
103
|
-
assert(recent.items[1].provider === 'gamma' && recent.items[1].model === 'm-gamma', 'recent[1] should be gamma');
|
|
104
|
-
assert(recent.items[2].provider === 'beta' && recent.items[2].model === 'm-beta', 'recent[2] should be beta');
|
|
105
|
-
|
|
106
|
-
await request('apply-config-template', { template: buildTemplate('broken', 'm-broken', 'not-a-url', '') });
|
|
107
|
-
await request('delete-model', { model: 'm-broken' });
|
|
108
|
-
|
|
109
|
-
const health = await request('config-health-check');
|
|
110
|
-
assert(health && Array.isArray(health.issues), 'health check issues should be array');
|
|
111
|
-
const codes = new Set(health.issues.map(item => item.code));
|
|
112
|
-
assert(codes.has('base-url-invalid'), 'health check should flag invalid URL');
|
|
113
|
-
assert(codes.has('api-key-missing'), 'health check should flag missing key');
|
|
114
|
-
assert(codes.has('model-unavailable'), 'health check should flag missing model');
|
|
115
|
-
|
|
116
|
-
await request('apply-config-template', {
|
|
117
|
-
template: buildTemplate('local', 'm-local', `http://127.0.0.1:${PORT}`, 'sk-local')
|
|
118
|
-
});
|
|
119
|
-
const remoteHealth = await request('config-health-check', { remote: true, timeoutMs: 2000 });
|
|
120
|
-
assert(remoteHealth && Array.isArray(remoteHealth.issues), 'remote health issues should be array');
|
|
121
|
-
|
|
122
|
-
assert(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const PORT = 3737;
|
|
8
|
+
const API_URL = `http://localhost:${PORT}/api`;
|
|
9
|
+
|
|
10
|
+
function delay(ms) {
|
|
11
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function request(action, params = {}) {
|
|
15
|
+
const payload = JSON.stringify({ action, params });
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const req = http.request(API_URL, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: {
|
|
20
|
+
'Content-Type': 'application/json',
|
|
21
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
22
|
+
}
|
|
23
|
+
}, (res) => {
|
|
24
|
+
let data = '';
|
|
25
|
+
res.on('data', chunk => data += chunk);
|
|
26
|
+
res.on('end', () => {
|
|
27
|
+
try {
|
|
28
|
+
const json = JSON.parse(data || '{}');
|
|
29
|
+
resolve(json);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
reject(e);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
req.on('error', reject);
|
|
36
|
+
req.write(payload);
|
|
37
|
+
req.end();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function waitForServer(timeoutMs = 12000) {
|
|
42
|
+
const start = Date.now();
|
|
43
|
+
while (Date.now() - start < timeoutMs) {
|
|
44
|
+
try {
|
|
45
|
+
await request('status');
|
|
46
|
+
return;
|
|
47
|
+
} catch (_) {
|
|
48
|
+
await delay(300);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw new Error('server not ready');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function escapeToml(value) {
|
|
55
|
+
return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildTemplate(provider, model, baseUrl, apiKey) {
|
|
59
|
+
return `model_provider = "${escapeToml(provider)}"
|
|
60
|
+
model = "${escapeToml(model)}"
|
|
61
|
+
|
|
62
|
+
[model_providers.${escapeToml(provider)}]
|
|
63
|
+
name = "${escapeToml(provider)}"
|
|
64
|
+
base_url = "${escapeToml(baseUrl)}"
|
|
65
|
+
wire_api = "responses"
|
|
66
|
+
preferred_auth_method = "${escapeToml(apiKey)}"
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assert(condition, message) {
|
|
71
|
+
if (!condition) {
|
|
72
|
+
throw new Error(`Assertion failed: ${message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function run() {
|
|
77
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-e2e-'));
|
|
78
|
+
const env = {
|
|
79
|
+
...process.env,
|
|
80
|
+
USERPROFILE: tempHome,
|
|
81
|
+
HOME: tempHome,
|
|
82
|
+
CODEXMATE_NO_BROWSER: '1'
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const cliPath = path.join(__dirname, '..', '..', 'cli.js');
|
|
86
|
+
const child = spawn(process.execPath, [cliPath, 'start'], {
|
|
87
|
+
env,
|
|
88
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await waitForServer();
|
|
93
|
+
|
|
94
|
+
await request('apply-config-template', { template: buildTemplate('alpha', 'm-alpha', 'https://example.com', 'sk-alpha') });
|
|
95
|
+
await request('apply-config-template', { template: buildTemplate('beta', 'm-beta', 'https://example.com', 'sk-beta') });
|
|
96
|
+
await request('apply-config-template', { template: buildTemplate('gamma', 'm-gamma', 'https://example.com', 'sk-gamma') });
|
|
97
|
+
await request('apply-config-template', { template: buildTemplate('delta', 'm-delta', 'https://example.com', 'sk-delta') });
|
|
98
|
+
|
|
99
|
+
const recent = await request('get-recent-configs');
|
|
100
|
+
assert(Array.isArray(recent.items), 'recent list should be array');
|
|
101
|
+
assert(recent.items.length === 3, 'recent list should keep 3 items');
|
|
102
|
+
assert(recent.items[0].provider === 'delta' && recent.items[0].model === 'm-delta', 'recent[0] should be delta');
|
|
103
|
+
assert(recent.items[1].provider === 'gamma' && recent.items[1].model === 'm-gamma', 'recent[1] should be gamma');
|
|
104
|
+
assert(recent.items[2].provider === 'beta' && recent.items[2].model === 'm-beta', 'recent[2] should be beta');
|
|
105
|
+
|
|
106
|
+
await request('apply-config-template', { template: buildTemplate('broken', 'm-broken', 'not-a-url', '') });
|
|
107
|
+
await request('delete-model', { model: 'm-broken' });
|
|
108
|
+
|
|
109
|
+
const health = await request('config-health-check');
|
|
110
|
+
assert(health && Array.isArray(health.issues), 'health check issues should be array');
|
|
111
|
+
const codes = new Set(health.issues.map(item => item.code));
|
|
112
|
+
assert(codes.has('base-url-invalid'), 'health check should flag invalid URL');
|
|
113
|
+
assert(codes.has('api-key-missing'), 'health check should flag missing key');
|
|
114
|
+
assert(codes.has('model-unavailable'), 'health check should flag missing model');
|
|
115
|
+
|
|
116
|
+
await request('apply-config-template', {
|
|
117
|
+
template: buildTemplate('local', 'm-local', `http://127.0.0.1:${PORT}`, 'sk-local')
|
|
118
|
+
});
|
|
119
|
+
const remoteHealth = await request('config-health-check', { remote: true, timeoutMs: 2000 });
|
|
120
|
+
assert(remoteHealth && Array.isArray(remoteHealth.issues), 'remote health issues should be array');
|
|
121
|
+
assert(remoteHealth.ok === true, 'remote health should pass');
|
|
122
|
+
assert(remoteHealth.remote && remoteHealth.remote.type === 'speed-test', 'remote health should include speed-test info');
|
|
123
|
+
assert(typeof remoteHealth.remote.durationMs === 'number', 'remote health should include duration');
|
|
124
|
+
} finally {
|
|
125
|
+
if (child && !child.killed) {
|
|
126
|
+
child.kill('SIGINT');
|
|
127
|
+
}
|
|
128
|
+
await delay(300);
|
|
129
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
run().catch((err) => {
|
|
134
|
+
console.error(err);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
});
|
package/tests/e2e/run.js
CHANGED
|
@@ -178,6 +178,29 @@ async function main() {
|
|
|
178
178
|
const models = JSON.parse(fs.readFileSync(modelsPath, 'utf-8'));
|
|
179
179
|
assert(models.includes('e2e-model'), 'custom model not added');
|
|
180
180
|
|
|
181
|
+
const sessionsDir = path.join(tmpHome, '.codex', 'sessions');
|
|
182
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
183
|
+
const sessionId = 'e2e-session';
|
|
184
|
+
const sessionPath = path.join(sessionsDir, `${sessionId}.jsonl`);
|
|
185
|
+
const sessionRecords = [
|
|
186
|
+
{
|
|
187
|
+
type: 'session_meta',
|
|
188
|
+
payload: { id: sessionId, cwd: '/tmp/e2e' },
|
|
189
|
+
timestamp: '2025-01-01T00:00:00.000Z'
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
type: 'response_item',
|
|
193
|
+
payload: { type: 'message', role: 'user', content: 'hello' },
|
|
194
|
+
timestamp: '2025-01-01T00:00:01.000Z'
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
type: 'response_item',
|
|
198
|
+
payload: { type: 'message', role: 'assistant', content: 'world' },
|
|
199
|
+
timestamp: '2025-01-01T00:00:02.000Z'
|
|
200
|
+
}
|
|
201
|
+
];
|
|
202
|
+
fs.writeFileSync(sessionPath, sessionRecords.map(record => JSON.stringify(record)).join('\n') + '\n', 'utf-8');
|
|
203
|
+
|
|
181
204
|
const statusResult = runSync(node, [cliPath, 'status'], { env });
|
|
182
205
|
assert(statusResult.status === 0, 'status failed');
|
|
183
206
|
assert(statusResult.stdout.includes('提供商: e2e'), 'status provider not shown');
|
|
@@ -255,6 +278,46 @@ async function main() {
|
|
|
255
278
|
});
|
|
256
279
|
assert(apiModelsByUrlUnlimited.unlimited === true, 'api models-by-url unlimited missing');
|
|
257
280
|
|
|
281
|
+
const apiSessions = await postJson(port, {
|
|
282
|
+
action: 'list-sessions',
|
|
283
|
+
params: { source: 'codex', limit: 50, forceRefresh: true }
|
|
284
|
+
});
|
|
285
|
+
assert(Array.isArray(apiSessions.sessions), 'api sessions missing');
|
|
286
|
+
assert(apiSessions.sessions.some(item => item.sessionId === sessionId), 'api sessions missing codex entry');
|
|
287
|
+
|
|
288
|
+
const cloneResult = await postJson(port, {
|
|
289
|
+
action: 'clone-session',
|
|
290
|
+
params: { source: 'codex', sessionId }
|
|
291
|
+
});
|
|
292
|
+
assert(cloneResult.success === true, 'clone-session failed');
|
|
293
|
+
assert(cloneResult.sessionId && cloneResult.sessionId !== sessionId, 'clone-session id invalid');
|
|
294
|
+
assert(fs.existsSync(cloneResult.filePath), 'clone-session file missing');
|
|
295
|
+
|
|
296
|
+
const apiSessionsAfterClone = await postJson(port, {
|
|
297
|
+
action: 'list-sessions',
|
|
298
|
+
params: { source: 'codex', limit: 50, forceRefresh: true }
|
|
299
|
+
});
|
|
300
|
+
assert(Array.isArray(apiSessionsAfterClone.sessions), 'api sessions after clone missing');
|
|
301
|
+
assert(
|
|
302
|
+
apiSessionsAfterClone.sessions[0]
|
|
303
|
+
&& apiSessionsAfterClone.sessions[0].sessionId === cloneResult.sessionId,
|
|
304
|
+
'clone session not latest'
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const deleteResult = await postJson(port, {
|
|
308
|
+
action: 'delete-session',
|
|
309
|
+
params: { source: 'codex', sessionId }
|
|
310
|
+
});
|
|
311
|
+
assert(deleteResult.success === true, 'delete-session failed');
|
|
312
|
+
assert(!fs.existsSync(sessionPath), 'delete-session file still exists');
|
|
313
|
+
|
|
314
|
+
const apiSessionsAfterDelete = await postJson(port, {
|
|
315
|
+
action: 'list-sessions',
|
|
316
|
+
params: { source: 'codex', limit: 50, forceRefresh: true }
|
|
317
|
+
});
|
|
318
|
+
assert(!apiSessionsAfterDelete.sessions.some(item => item.sessionId === sessionId), 'deleted session still listed');
|
|
319
|
+
assert(apiSessionsAfterDelete.sessions.some(item => item.sessionId === cloneResult.sessionId), 'clone session missing after delete');
|
|
320
|
+
|
|
258
321
|
const speedResult = await postJson(port, { action: 'speed-test', params: { name: 'e2e2' } }, 4000);
|
|
259
322
|
assert(speedResult.ok === true, 'speed-test failed');
|
|
260
323
|
|