codexmate 0.0.4 → 0.0.5

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/package.json CHANGED
@@ -1,12 +1,13 @@
1
- {
1
+ {
2
2
  "name": "codexmate",
3
- "version": "0.0.4",
4
- "description": "Codex 提供商管理 CLI 工具",
3
+ "version": "0.0.5",
4
+ "description": "Codex/Claude Code 配置与会话管理 CLI + Web 工具",
5
5
  "bin": {
6
6
  "codexmate": "./cli.js"
7
7
  },
8
8
  "scripts": {
9
- "start": "node cli.js"
9
+ "start": "node cli.js",
10
+ "test:e2e": "node tests/e2e/run.js"
10
11
  },
11
12
  "dependencies": {
12
13
  "@iarna/toml": "^2.2.5",
@@ -17,6 +18,12 @@
17
18
  },
18
19
  "keywords": [
19
20
  "codex",
21
+ "claude",
22
+ "claude-code",
23
+ "config",
24
+ "session",
25
+ "web-ui",
26
+ "provider",
20
27
  "ai",
21
28
  "llm",
22
29
  "cli"
@@ -24,4 +31,3 @@
24
31
  "author": "ymkiux",
25
32
  "license": "Apache-2.0"
26
33
  }
27
-
@@ -0,0 +1,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
+ const remoteCodes = new Set(remoteHealth.issues.map(item => item.code));
122
+ assert(remoteCodes.has('remote-models-parse'), 'remote health should flag models parse error');
123
+ } finally {
124
+ if (child && !child.killed) {
125
+ child.kill('SIGINT');
126
+ }
127
+ await delay(300);
128
+ fs.rmSync(tempHome, { recursive: true, force: true });
129
+ }
130
+ }
131
+
132
+ run().catch((err) => {
133
+ console.error(err);
134
+ process.exit(1);
135
+ });
@@ -0,0 +1,294 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const http = require('http');
5
+ const { spawnSync, spawn } = require('child_process');
6
+
7
+ function assert(condition, message) {
8
+ if (!condition) {
9
+ throw new Error(message);
10
+ }
11
+ }
12
+
13
+ function runSync(node, args, options = {}) {
14
+ const result = spawnSync(node, args, {
15
+ encoding: 'utf-8',
16
+ ...options
17
+ });
18
+ return result;
19
+ }
20
+
21
+ function runWithInput(node, args, input, options = {}) {
22
+ return new Promise((resolve) => {
23
+ const child = spawn(node, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'] });
24
+ let stdout = '';
25
+ let stderr = '';
26
+ child.stdout.on('data', chunk => stdout += chunk.toString());
27
+ child.stderr.on('data', chunk => stderr += chunk.toString());
28
+ child.on('close', (code) => resolve({ status: code, stdout, stderr }));
29
+ if (input) {
30
+ child.stdin.write(input);
31
+ }
32
+ child.stdin.end();
33
+ });
34
+ }
35
+ function postJson(port, payload, timeoutMs = 2000) {
36
+ return new Promise((resolve, reject) => {
37
+ const data = JSON.stringify(payload);
38
+ const req = http.request({
39
+ hostname: '127.0.0.1',
40
+ port,
41
+ path: '/api',
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'Content-Length': Buffer.byteLength(data)
46
+ }
47
+ }, (res) => {
48
+ let body = '';
49
+ res.setEncoding('utf-8');
50
+ res.on('data', chunk => body += chunk);
51
+ res.on('end', () => {
52
+ try {
53
+ resolve(JSON.parse(body || '{}'));
54
+ } catch (e) {
55
+ reject(new Error('Invalid JSON response'));
56
+ }
57
+ });
58
+ });
59
+
60
+ req.on('error', reject);
61
+ req.setTimeout(timeoutMs, () => {
62
+ req.destroy(new Error('Request timeout'));
63
+ });
64
+ req.write(data);
65
+ req.end();
66
+ });
67
+ }
68
+
69
+ async function waitForServer(port, retries = 20, delayMs = 200) {
70
+ let lastError;
71
+ for (let i = 0; i < retries; i++) {
72
+ try {
73
+ await postJson(port, { action: 'status' }, 1000);
74
+ return;
75
+ } catch (e) {
76
+ lastError = e;
77
+ await new Promise(resolve => setTimeout(resolve, delayMs));
78
+ }
79
+ }
80
+ throw lastError || new Error('Server not ready');
81
+ }
82
+
83
+ function startLocalServer(options = {}) {
84
+ const mode = options.mode || 'list';
85
+ const modelsPath = options.modelsPath || '/models';
86
+ return new Promise((resolve, reject) => {
87
+ const server = http.createServer((req, res) => {
88
+ if (req.url && req.url.startsWith(modelsPath)) {
89
+ if (mode === 'none') {
90
+ res.writeHead(404, { 'Content-Type': 'application/json' });
91
+ res.end(JSON.stringify({ error: 'not found' }));
92
+ return;
93
+ }
94
+ if (mode === 'html') {
95
+ res.writeHead(200, { 'Content-Type': 'text/html' });
96
+ res.end('<!doctype html><html><body>ok</body></html>');
97
+ return;
98
+ }
99
+ res.writeHead(200, { 'Content-Type': 'application/json' });
100
+ res.end(JSON.stringify({
101
+ data: [
102
+ { id: 'e2e2-model' },
103
+ { id: 'e2e2-model-2' }
104
+ ]
105
+ }));
106
+ return;
107
+ }
108
+ res.writeHead(200, { 'Content-Type': 'application/json' });
109
+ res.end(JSON.stringify({ ok: true }));
110
+ });
111
+ server.on('error', reject);
112
+ server.listen(0, '127.0.0.1', () => {
113
+ const address = server.address();
114
+ resolve({ server, port: address.port });
115
+ });
116
+ });
117
+ }
118
+
119
+ function closeServer(server) {
120
+ return new Promise((resolve) => {
121
+ if (!server) return resolve();
122
+ try {
123
+ server.close(() => resolve());
124
+ } catch (e) {
125
+ resolve();
126
+ }
127
+ });
128
+ }
129
+
130
+ async function main() {
131
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-e2e-'));
132
+ const env = {
133
+ ...process.env,
134
+ HOME: tmpHome,
135
+ USERPROFILE: tmpHome,
136
+ CODEXMATE_FORCE_RESET_EXISTING_CONFIG: '1'
137
+ };
138
+ const cliPath = path.resolve(__dirname, '../../cli.js');
139
+ const node = process.execPath;
140
+
141
+ let mockProvider;
142
+ let noModelsProvider;
143
+ let htmlModelsProvider;
144
+ try {
145
+ mockProvider = await startLocalServer({ mode: 'list', modelsPath: '/v1/models' });
146
+ noModelsProvider = await startLocalServer({ mode: 'none', modelsPath: '/v1/models' });
147
+ htmlModelsProvider = await startLocalServer({ mode: 'html', modelsPath: '/v1/models' });
148
+ const mockProviderUrl = `http://127.0.0.1:${mockProvider.port}`;
149
+ const noModelsUrl = `http://127.0.0.1:${noModelsProvider.port}`;
150
+ const htmlModelsUrl = `http://127.0.0.1:${htmlModelsProvider.port}`;
151
+
152
+ const setupInput = [
153
+ '2',
154
+ 'e2e',
155
+ mockProviderUrl,
156
+ 'sk-test',
157
+ 'e2e-model',
158
+ ''
159
+ ].join('\n');
160
+
161
+ const setupResult = await runWithInput(node, [cliPath, 'setup'], setupInput, { env });
162
+
163
+ assert(setupResult.status === 0, `setup failed: ${setupResult.stderr || setupResult.stdout}`);
164
+
165
+ const configPath = path.join(tmpHome, '.codex', 'config.toml');
166
+ assert(fs.existsSync(configPath), 'config.toml missing');
167
+ const configContent = fs.readFileSync(configPath, 'utf-8');
168
+ assert(/model_provider\s*=\s*"e2e"/.test(configContent), 'model_provider not set');
169
+ assert(/model\s*=\s*"e2e-model"/.test(configContent), 'model not set');
170
+ assert(/\[model_providers\.e2e\]/.test(configContent), 'provider block missing');
171
+ assert(/base_url\s*=\s*"http:\/\/127\.0\.0\.1:\d+"/.test(configContent), 'base_url missing');
172
+
173
+ const authPath = path.join(tmpHome, '.codex', 'auth.json');
174
+ const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
175
+ assert(auth.OPENAI_API_KEY === 'sk-test', 'auth api_key mismatch');
176
+
177
+ const modelsPath = path.join(tmpHome, '.codex', 'models.json');
178
+ const models = JSON.parse(fs.readFileSync(modelsPath, 'utf-8'));
179
+ assert(models.includes('e2e-model'), 'custom model not added');
180
+
181
+ const statusResult = runSync(node, [cliPath, 'status'], { env });
182
+ assert(statusResult.status === 0, 'status failed');
183
+ assert(statusResult.stdout.includes('提供商: e2e'), 'status provider not shown');
184
+ assert(statusResult.stdout.includes('模型: e2e-model'), 'status model not shown');
185
+
186
+ const listResult = runSync(node, [cliPath, 'list'], { env });
187
+ assert(listResult.status === 0, 'list failed');
188
+ assert(listResult.stdout.includes('e2e'), 'list missing provider');
189
+
190
+ const port = 18000 + Math.floor(Math.random() * 1000);
191
+ const webServer = spawn(node, [cliPath, 'start'], {
192
+ env: { ...env, CODEXMATE_PORT: String(port) },
193
+ stdio: ['ignore', 'pipe', 'pipe']
194
+ });
195
+ webServer.stdout.on('data', () => {});
196
+ webServer.stderr.on('data', () => {});
197
+
198
+ try {
199
+ await waitForServer(port);
200
+ const apiStatus = await postJson(port, { action: 'status' });
201
+ assert(apiStatus.provider === 'e2e', 'api status provider mismatch');
202
+
203
+ const apiList = await postJson(port, { action: 'list' });
204
+ assert(Array.isArray(apiList.providers), 'api list missing providers');
205
+ assert(apiList.providers.some(p => p.name === 'e2e'), 'api list missing provider');
206
+
207
+ const exportResult = await postJson(port, { action: 'export-config', params: { includeKeys: true } });
208
+ assert(exportResult.data, 'export-config missing data');
209
+ assert(exportResult.data.providers && exportResult.data.providers.e2e, 'export-config missing provider');
210
+ assert(exportResult.data.providers.e2e.apiKey === 'sk-test', 'export-config apiKey mismatch');
211
+
212
+ const importPayload = JSON.parse(JSON.stringify(exportResult.data));
213
+ importPayload.providers = {
214
+ ...importPayload.providers,
215
+ e2e2: { baseUrl: mockProviderUrl, apiKey: 'sk-e2e2' },
216
+ e2e3: { baseUrl: noModelsUrl, apiKey: 'sk-e2e3' },
217
+ e2e4: { baseUrl: htmlModelsUrl, apiKey: 'sk-e2e4' }
218
+ };
219
+ importPayload.models = Array.from(new Set([...(importPayload.models || []), 'e2e2-model']));
220
+ importPayload.currentProvider = 'e2e2';
221
+ importPayload.currentModel = 'e2e2-model';
222
+ importPayload.currentModels = { ...(importPayload.currentModels || {}), e2e2: 'e2e2-model' };
223
+
224
+ const importResult = await postJson(port, {
225
+ action: 'import-config',
226
+ params: {
227
+ payload: importPayload,
228
+ options: { overwriteProviders: true, applyCurrent: true, applyCurrentModels: true }
229
+ }
230
+ });
231
+ assert(importResult.success === true, 'import-config failed');
232
+
233
+ const apiStatusAfter = await postJson(port, { action: 'status' });
234
+ assert(apiStatusAfter.provider === 'e2e2', 'api status provider after import mismatch');
235
+ assert(apiStatusAfter.model === 'e2e2-model', 'api status model after import mismatch');
236
+
237
+ const apiModels = await postJson(port, { action: 'models', params: { provider: 'e2e2' } });
238
+ assert(Array.isArray(apiModels.models) && apiModels.models.includes('e2e2-model-2'), 'api models missing remote entry');
239
+
240
+ const apiModelsUnlimited = await postJson(port, { action: 'models', params: { provider: 'e2e3' } });
241
+ assert(apiModelsUnlimited.unlimited === true, 'api models unlimited missing');
242
+
243
+ const apiModelsHtml = await postJson(port, { action: 'models', params: { provider: 'e2e4' } });
244
+ assert(apiModelsHtml.unlimited === true, 'api models html unlimited missing');
245
+
246
+ const apiModelsByUrl = await postJson(port, {
247
+ action: 'models-by-url',
248
+ params: { baseUrl: mockProviderUrl, apiKey: 'sk-e2e2' }
249
+ });
250
+ assert(Array.isArray(apiModelsByUrl.models) && apiModelsByUrl.models.includes('e2e2-model'), 'api models-by-url missing remote entry');
251
+
252
+ const apiModelsByUrlUnlimited = await postJson(port, {
253
+ action: 'models-by-url',
254
+ params: { baseUrl: noModelsUrl }
255
+ });
256
+ assert(apiModelsByUrlUnlimited.unlimited === true, 'api models-by-url unlimited missing');
257
+
258
+ const speedResult = await postJson(port, { action: 'speed-test', params: { name: 'e2e2' } }, 4000);
259
+ assert(speedResult.ok === true, 'speed-test failed');
260
+
261
+ const switchResult = runSync(node, [cliPath, 'switch', 'e2e4'], { env });
262
+ assert(switchResult.status === 0, 'cli switch failed');
263
+
264
+ const cliModels = await runWithInput(node, [cliPath, 'models'], '', { env });
265
+ assert(cliModels.status === 0, 'cli models failed');
266
+ assert(cliModels.stdout.includes('视为不限'), 'cli models missing unlimited hint');
267
+ } finally {
268
+ webServer.kill('SIGINT');
269
+ await new Promise(resolve => webServer.on('exit', resolve));
270
+ }
271
+ } finally {
272
+ if (mockProvider) {
273
+ await closeServer(mockProvider.server);
274
+ }
275
+ if (noModelsProvider) {
276
+ await closeServer(noModelsProvider.server);
277
+ }
278
+ if (htmlModelsProvider) {
279
+ await closeServer(htmlModelsProvider.server);
280
+ }
281
+ try {
282
+ if (fs.rmSync) {
283
+ fs.rmSync(tmpHome, { recursive: true, force: true });
284
+ } else {
285
+ fs.rmdirSync(tmpHome, { recursive: true });
286
+ }
287
+ } catch (e) {}
288
+ }
289
+ }
290
+
291
+ main().catch((err) => {
292
+ console.error('E2E failed:', err.message || err);
293
+ process.exit(1);
294
+ });