@shunirr/cc-glm 0.1.0
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/README.md +199 -0
- package/dist/bin/cli.js +722 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/proxy/server.js +912 -0
- package/dist/proxy/server.js.map +1 -0
- package/package.json +55 -0
package/dist/bin/cli.js
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bin/cli.ts
|
|
4
|
+
import { spawn as spawn3 } from "child_process";
|
|
5
|
+
|
|
6
|
+
// src/config/loader.ts
|
|
7
|
+
import { readFile } from "fs/promises";
|
|
8
|
+
import { existsSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { parse as parseYaml } from "yaml";
|
|
11
|
+
var VALID_UPSTREAMS = /* @__PURE__ */ new Set(["anthropic", "zai"]);
|
|
12
|
+
var VALID_LOG_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "warn", "error"]);
|
|
13
|
+
var DEFAULTS = {
|
|
14
|
+
proxy: {
|
|
15
|
+
port: 8787,
|
|
16
|
+
host: "127.0.0.1"
|
|
17
|
+
},
|
|
18
|
+
upstream: {
|
|
19
|
+
anthropic: {
|
|
20
|
+
url: "https://api.anthropic.com"
|
|
21
|
+
},
|
|
22
|
+
zai: {
|
|
23
|
+
url: "https://api.z.ai/api/anthropic",
|
|
24
|
+
apiKey: ""
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
lifecycle: {
|
|
28
|
+
stopGraceSeconds: 8,
|
|
29
|
+
startWaitSeconds: 8,
|
|
30
|
+
stateDir: `${process.env.TMPDIR ?? "/tmp"}/claude-code-proxy`
|
|
31
|
+
},
|
|
32
|
+
logging: {
|
|
33
|
+
level: "info"
|
|
34
|
+
},
|
|
35
|
+
routing: {
|
|
36
|
+
rules: [],
|
|
37
|
+
default: "anthropic"
|
|
38
|
+
},
|
|
39
|
+
claude: { path: "" }
|
|
40
|
+
};
|
|
41
|
+
function expandEnvVars(str) {
|
|
42
|
+
if (typeof str !== "string") return str;
|
|
43
|
+
return str.replace(/\$\{([^}:]+)(:-([^}]*))?\}/g, (_, name, _defaultValue, defaultValue) => {
|
|
44
|
+
return process.env[name] ?? defaultValue ?? "";
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function getDefaultConfigPath() {
|
|
48
|
+
const home = process.env.HOME ?? "";
|
|
49
|
+
return join(home, ".config", "cc-glm", "config.yml");
|
|
50
|
+
}
|
|
51
|
+
async function loadConfig(filePath = getDefaultConfigPath()) {
|
|
52
|
+
let raw = {};
|
|
53
|
+
if (existsSync(filePath)) {
|
|
54
|
+
try {
|
|
55
|
+
const content = await readFile(filePath, "utf-8");
|
|
56
|
+
const parsed = parseYaml(content);
|
|
57
|
+
if (parsed && typeof parsed === "object") {
|
|
58
|
+
raw = parsed;
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new Error(`Failed to parse config file at ${filePath}: ${error}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return mergeAndValidateConfig(raw);
|
|
65
|
+
}
|
|
66
|
+
function mergeAndValidateConfig(raw) {
|
|
67
|
+
const config = {
|
|
68
|
+
proxy: mergeProxyConfig(raw.proxy),
|
|
69
|
+
upstream: mergeUpstreamConfig(raw.upstream),
|
|
70
|
+
lifecycle: mergeLifecycleConfig(raw.lifecycle),
|
|
71
|
+
logging: mergeLoggingConfig(raw.logging),
|
|
72
|
+
routing: mergeRoutingConfig(raw.routing),
|
|
73
|
+
signatureStore: mergeSignatureStoreConfig(raw.signature_store),
|
|
74
|
+
claude: mergeClaudeConfig(raw.claude)
|
|
75
|
+
};
|
|
76
|
+
validateConfig(config);
|
|
77
|
+
return config;
|
|
78
|
+
}
|
|
79
|
+
function mergeProxyConfig(raw) {
|
|
80
|
+
const rawPort = raw?.port;
|
|
81
|
+
let port;
|
|
82
|
+
if (typeof rawPort === "number" && Number.isFinite(rawPort) && !isNaN(rawPort)) {
|
|
83
|
+
port = rawPort;
|
|
84
|
+
} else {
|
|
85
|
+
port = DEFAULTS.proxy.port;
|
|
86
|
+
}
|
|
87
|
+
if (!Number.isInteger(port)) {
|
|
88
|
+
throw new Error(`Invalid port: ${port}. Must be an integer.`);
|
|
89
|
+
}
|
|
90
|
+
if (port < 1 || port > 65535) {
|
|
91
|
+
throw new Error(`Invalid port: ${port}. Must be between 1 and 65535.`);
|
|
92
|
+
}
|
|
93
|
+
const rawHost = raw?.host;
|
|
94
|
+
const host = typeof rawHost === "string" ? rawHost : DEFAULTS.proxy.host;
|
|
95
|
+
if (typeof host !== "string" || host.length === 0) {
|
|
96
|
+
throw new Error(`Invalid host: must be a non-empty string.`);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
host,
|
|
100
|
+
port
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function mergeUpstreamConfig(raw) {
|
|
104
|
+
const rawAnthropic = raw?.anthropic;
|
|
105
|
+
const rawZai = raw?.zai;
|
|
106
|
+
return {
|
|
107
|
+
anthropic: {
|
|
108
|
+
url: rawAnthropic?.url ? expandEnvVars(rawAnthropic.url) : DEFAULTS.upstream.anthropic.url
|
|
109
|
+
},
|
|
110
|
+
zai: {
|
|
111
|
+
url: rawZai?.url ? expandEnvVars(rawZai.url) : DEFAULTS.upstream.zai.url,
|
|
112
|
+
apiKey: expandEnvVars(rawZai?.apiKey ?? "") || process.env.ZAI_API_KEY || DEFAULTS.upstream.zai.apiKey
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function mergeLifecycleConfig(raw) {
|
|
117
|
+
const stateDir = raw?.stateDir ?? DEFAULTS.lifecycle.stateDir;
|
|
118
|
+
const expandedStateDir = expandEnvVars(typeof stateDir === "string" ? stateDir : DEFAULTS.lifecycle.stateDir);
|
|
119
|
+
const finalStateDir = expandedStateDir || `${process.env.TMPDIR || "/tmp"}/claude-code-proxy`;
|
|
120
|
+
if (typeof finalStateDir !== "string" || finalStateDir.length === 0) {
|
|
121
|
+
throw new Error(`Invalid stateDir: must be a non-empty string.`);
|
|
122
|
+
}
|
|
123
|
+
const rawStopGrace = raw?.stopGraceSeconds;
|
|
124
|
+
const rawStartWait = raw?.startWaitSeconds;
|
|
125
|
+
let stopGraceSeconds;
|
|
126
|
+
if (typeof rawStopGrace === "number" && Number.isFinite(rawStopGrace) && !isNaN(rawStopGrace)) {
|
|
127
|
+
stopGraceSeconds = rawStopGrace;
|
|
128
|
+
} else {
|
|
129
|
+
stopGraceSeconds = DEFAULTS.lifecycle.stopGraceSeconds;
|
|
130
|
+
}
|
|
131
|
+
if (!Number.isInteger(stopGraceSeconds)) {
|
|
132
|
+
throw new Error(`Invalid stopGraceSeconds: ${stopGraceSeconds}. Must be an integer.`);
|
|
133
|
+
}
|
|
134
|
+
if (stopGraceSeconds < 0 || stopGraceSeconds > 300) {
|
|
135
|
+
throw new Error(`Invalid stopGraceSeconds: ${stopGraceSeconds}. Must be between 0 and 300.`);
|
|
136
|
+
}
|
|
137
|
+
let startWaitSeconds;
|
|
138
|
+
if (typeof rawStartWait === "number" && Number.isFinite(rawStartWait) && !isNaN(rawStartWait)) {
|
|
139
|
+
startWaitSeconds = rawStartWait;
|
|
140
|
+
} else {
|
|
141
|
+
startWaitSeconds = DEFAULTS.lifecycle.startWaitSeconds;
|
|
142
|
+
}
|
|
143
|
+
if (!Number.isInteger(startWaitSeconds)) {
|
|
144
|
+
throw new Error(`Invalid startWaitSeconds: ${startWaitSeconds}. Must be an integer.`);
|
|
145
|
+
}
|
|
146
|
+
if (startWaitSeconds < 1 || startWaitSeconds > 60) {
|
|
147
|
+
throw new Error(`Invalid startWaitSeconds: ${startWaitSeconds}. Must be between 1 and 60.`);
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
stopGraceSeconds,
|
|
151
|
+
startWaitSeconds,
|
|
152
|
+
stateDir: finalStateDir
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function mergeLoggingConfig(raw) {
|
|
156
|
+
const level = raw?.level ?? DEFAULTS.logging.level;
|
|
157
|
+
if (!VALID_LOG_LEVELS.has(level)) {
|
|
158
|
+
throw new Error(`Invalid logging level: ${level}. Must be one of: ${Array.from(VALID_LOG_LEVELS).join(", ")}`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
level
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function mergeRoutingConfig(raw) {
|
|
165
|
+
const rules = raw?.rules ?? DEFAULTS.routing.rules;
|
|
166
|
+
const defaultUpstream = raw?.default ?? DEFAULTS.routing.default;
|
|
167
|
+
if (!Array.isArray(rules)) {
|
|
168
|
+
throw new Error(`Invalid routing.rules: must be an array, got ${typeof rules}`);
|
|
169
|
+
}
|
|
170
|
+
for (let i = 0; i < rules.length; i++) {
|
|
171
|
+
const rule = rules[i];
|
|
172
|
+
if (!rule || typeof rule !== "object") {
|
|
173
|
+
throw new Error(`Invalid routing rule at index ${i}: must be an object`);
|
|
174
|
+
}
|
|
175
|
+
if (typeof rule.match !== "string") {
|
|
176
|
+
throw new Error(`Invalid routing rule at index ${i}: match must be a string`);
|
|
177
|
+
}
|
|
178
|
+
if (typeof rule.upstream !== "string") {
|
|
179
|
+
throw new Error(`Invalid routing rule at index ${i}: upstream must be a string`);
|
|
180
|
+
}
|
|
181
|
+
if (!VALID_UPSTREAMS.has(rule.upstream)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Invalid routing rule at index ${i}: upstream "${rule.upstream}" is not valid. Must be one of: ${Array.from(VALID_UPSTREAMS).join(", ")}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (rule.model !== void 0 && typeof rule.model !== "string") {
|
|
187
|
+
throw new Error(`Invalid routing rule at index ${i}: model must be a string if provided`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!VALID_UPSTREAMS.has(defaultUpstream)) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Invalid routing.default: "${defaultUpstream}" is not valid. Must be one of: ${Array.from(VALID_UPSTREAMS).join(", ")}`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
rules,
|
|
197
|
+
default: defaultUpstream
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function mergeSignatureStoreConfig(raw) {
|
|
201
|
+
const DEFAULT_MAX_SIZE = 1e3;
|
|
202
|
+
const rawMaxSize = raw?.maxSize;
|
|
203
|
+
let maxSize;
|
|
204
|
+
if (typeof rawMaxSize === "number" && Number.isFinite(rawMaxSize) && !isNaN(rawMaxSize)) {
|
|
205
|
+
maxSize = rawMaxSize;
|
|
206
|
+
} else {
|
|
207
|
+
maxSize = DEFAULT_MAX_SIZE;
|
|
208
|
+
}
|
|
209
|
+
if (!Number.isInteger(maxSize)) {
|
|
210
|
+
throw new Error(`Invalid signatureStore.maxSize: ${maxSize}. Must be an integer.`);
|
|
211
|
+
}
|
|
212
|
+
if (maxSize < 1 || maxSize > 1e5) {
|
|
213
|
+
throw new Error(`Invalid signatureStore.maxSize: ${maxSize}. Must be between 1 and 100000.`);
|
|
214
|
+
}
|
|
215
|
+
return { maxSize };
|
|
216
|
+
}
|
|
217
|
+
function mergeClaudeConfig(raw) {
|
|
218
|
+
const path = raw?.path ?? DEFAULTS.claude.path;
|
|
219
|
+
if (typeof path !== "string") {
|
|
220
|
+
throw new Error(`Invalid claude.path: must be a string`);
|
|
221
|
+
}
|
|
222
|
+
return { path };
|
|
223
|
+
}
|
|
224
|
+
function validateConfig(config) {
|
|
225
|
+
try {
|
|
226
|
+
new URL(config.upstream.anthropic.url);
|
|
227
|
+
} catch {
|
|
228
|
+
throw new Error(`Invalid anthropic URL: ${config.upstream.anthropic.url}`);
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
new URL(config.upstream.zai.url);
|
|
232
|
+
} catch {
|
|
233
|
+
throw new Error(`Invalid zai URL: ${config.upstream.zai.url}`);
|
|
234
|
+
}
|
|
235
|
+
if (!config.upstream.zai.apiKey) {
|
|
236
|
+
console.warn("Warning: zai API key is not set. Requests to z.ai will fail without ZAI_API_KEY.");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/lifecycle/singleton.ts
|
|
241
|
+
import { spawn as spawn2 } from "child_process";
|
|
242
|
+
import { existsSync as existsSync3, openSync, closeSync } from "fs";
|
|
243
|
+
import { mkdir as mkdir2, rmdir } from "fs/promises";
|
|
244
|
+
import { dirname, join as join2 } from "path";
|
|
245
|
+
import { fileURLToPath } from "url";
|
|
246
|
+
|
|
247
|
+
// src/utils/process.ts
|
|
248
|
+
import { spawn } from "child_process";
|
|
249
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
250
|
+
import { mkdir, writeFile, unlink } from "fs/promises";
|
|
251
|
+
var MIN_VALID_PID = 1;
|
|
252
|
+
async function execCommand(command, args) {
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
const proc = spawn(command, args);
|
|
255
|
+
let stdout = "";
|
|
256
|
+
let stderr = "";
|
|
257
|
+
proc.stdout?.on("data", (data) => {
|
|
258
|
+
stdout += data.toString();
|
|
259
|
+
});
|
|
260
|
+
proc.stderr?.on("data", (data) => {
|
|
261
|
+
stderr += data.toString();
|
|
262
|
+
});
|
|
263
|
+
proc.on("close", (code) => {
|
|
264
|
+
if (code === 0) {
|
|
265
|
+
resolve(stdout.trim());
|
|
266
|
+
} else {
|
|
267
|
+
const error = new Error(`${command} failed with code ${code}: ${stderr}`);
|
|
268
|
+
error.code = code;
|
|
269
|
+
error.command = command;
|
|
270
|
+
reject(error);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
proc.on("error", (err) => {
|
|
274
|
+
const error = err;
|
|
275
|
+
if (error.code === "ENOENT") {
|
|
276
|
+
const enhancedError = new Error(`Command not found: ${command}`);
|
|
277
|
+
enhancedError.code = "ENOENT";
|
|
278
|
+
enhancedError.command = command;
|
|
279
|
+
reject(enhancedError);
|
|
280
|
+
} else {
|
|
281
|
+
reject(err);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function pidIsAlive(pid) {
|
|
287
|
+
if (!Number.isFinite(pid) || pid < MIN_VALID_PID) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
process.kill(pid, 0);
|
|
292
|
+
return true;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function isPortListening(port) {
|
|
298
|
+
try {
|
|
299
|
+
await execCommand("lsof", [`-nP`, `-iTCP:${port}`, `-sTCP:LISTEN`]);
|
|
300
|
+
return true;
|
|
301
|
+
} catch (err) {
|
|
302
|
+
const error = err;
|
|
303
|
+
if (error.code === "ENOENT" && error.command === "lsof") {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`lsof command is required but not found. Please install lsof or ensure it's in your PATH.`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function ensureStateDir(stateDir) {
|
|
312
|
+
if (!existsSync2(stateDir)) {
|
|
313
|
+
await mkdir(stateDir, { recursive: true });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function readPidFile(pidFile) {
|
|
317
|
+
try {
|
|
318
|
+
if (!existsSync2(pidFile)) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
const content = readFileSync(pidFile, "utf-8");
|
|
322
|
+
const pid = parseInt(content.trim(), 10);
|
|
323
|
+
if (isNaN(pid) || pid < MIN_VALID_PID) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return pid;
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function writePidFile(pidFile, pid) {
|
|
332
|
+
if (!Number.isFinite(pid) || pid < MIN_VALID_PID) {
|
|
333
|
+
throw new Error(`Invalid PID ${pid}: cannot write to PID file`);
|
|
334
|
+
}
|
|
335
|
+
await writeFile(pidFile, pid.toString(), "utf-8");
|
|
336
|
+
}
|
|
337
|
+
async function removePidFile(pidFile) {
|
|
338
|
+
try {
|
|
339
|
+
await unlink(pidFile);
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function sleep(ms) {
|
|
344
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/lifecycle/singleton.ts
|
|
348
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
349
|
+
var __dirname = dirname(__filename);
|
|
350
|
+
var SingletonProxy = class {
|
|
351
|
+
config;
|
|
352
|
+
pidFile;
|
|
353
|
+
lockDir;
|
|
354
|
+
logFile;
|
|
355
|
+
constructor(config) {
|
|
356
|
+
this.config = config;
|
|
357
|
+
const stateDir = config.lifecycle.stateDir;
|
|
358
|
+
this.pidFile = join2(stateDir, "proxy.pid");
|
|
359
|
+
this.lockDir = join2(stateDir, "lock");
|
|
360
|
+
this.logFile = join2(stateDir, "proxy.log");
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Start proxy if not already running
|
|
364
|
+
* Uses lock directory for atomic singleton behavior
|
|
365
|
+
*/
|
|
366
|
+
async start() {
|
|
367
|
+
await ensureStateDir(this.config.lifecycle.stateDir);
|
|
368
|
+
await this.recoverStaleLock();
|
|
369
|
+
if (await isPortListening(this.config.proxy.port)) {
|
|
370
|
+
const pid = readPidFile(this.pidFile);
|
|
371
|
+
if (pid && pid > 0 && pidIsAlive(pid) && await this.verifyPidOwnsPort(pid)) {
|
|
372
|
+
console.log(`Proxy already running on port ${this.config.proxy.port} (PID ${pid})`);
|
|
373
|
+
return;
|
|
374
|
+
} else {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Port ${this.config.proxy.port} is already in use by another process. Please stop the other process or configure a different port.`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const lockAcquired = await this.acquireLock();
|
|
381
|
+
if (!lockAcquired) {
|
|
382
|
+
await this.waitForReady();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
if (await isPortListening(this.config.proxy.port)) {
|
|
387
|
+
const pid = readPidFile(this.pidFile);
|
|
388
|
+
if (pid && pid > 0 && pidIsAlive(pid) && await this.verifyPidOwnsPort(pid)) {
|
|
389
|
+
return;
|
|
390
|
+
} else {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`Port ${this.config.proxy.port} is already in use by another process.`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
await this.startProxyProcess();
|
|
397
|
+
await this.waitForReady();
|
|
398
|
+
} finally {
|
|
399
|
+
await this.releaseLock();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Stop proxy if no Claude processes are running
|
|
404
|
+
*/
|
|
405
|
+
async stopIfNoClaude(hasClaude) {
|
|
406
|
+
const { stopGraceSeconds } = this.config.lifecycle;
|
|
407
|
+
for (let i = 0; i < stopGraceSeconds; i++) {
|
|
408
|
+
if (await hasClaude()) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
await sleep(1e3);
|
|
412
|
+
}
|
|
413
|
+
await this.stop();
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Stop the proxy process
|
|
417
|
+
* Verifies PID owns the target port before killing to avoid PID reuse issues
|
|
418
|
+
*/
|
|
419
|
+
async stop() {
|
|
420
|
+
const pid = readPidFile(this.pidFile);
|
|
421
|
+
if (pid && pid > 0) {
|
|
422
|
+
const isOurProcess = await this.verifyPidOwnsPort(pid);
|
|
423
|
+
if (isOurProcess) {
|
|
424
|
+
try {
|
|
425
|
+
process.kill(pid, "SIGTERM");
|
|
426
|
+
} catch (err) {
|
|
427
|
+
const error = err;
|
|
428
|
+
if ("code" in error && (error.code === "ESRCH" || error.code === "EPERM")) {
|
|
429
|
+
console.warn(`Failed to send SIGTERM to PID ${pid}: ${error.message}`);
|
|
430
|
+
} else {
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const deadline = Date.now() + 3e3;
|
|
435
|
+
while (Date.now() < deadline) {
|
|
436
|
+
if (!pidIsAlive(pid)) {
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
await sleep(100);
|
|
440
|
+
}
|
|
441
|
+
if (pidIsAlive(pid) && await this.verifyPidOwnsPort(pid)) {
|
|
442
|
+
try {
|
|
443
|
+
process.kill(pid, "SIGKILL");
|
|
444
|
+
} catch (err) {
|
|
445
|
+
const error = err;
|
|
446
|
+
if ("code" in error && (error.code === "ESRCH" || error.code === "EPERM")) {
|
|
447
|
+
console.warn(`Failed to send SIGKILL to PID ${pid}: ${error.message}`);
|
|
448
|
+
} else {
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
console.warn(`PID ${pid} does not own port ${this.config.proxy.port}, treating as stale`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
await removePidFile(this.pidFile);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Get the proxy base URL
|
|
461
|
+
*/
|
|
462
|
+
getBaseUrl() {
|
|
463
|
+
const { host, port } = this.config.proxy;
|
|
464
|
+
return `http://${host}:${port}`;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Try to acquire lock directory
|
|
468
|
+
*/
|
|
469
|
+
async acquireLock() {
|
|
470
|
+
try {
|
|
471
|
+
await mkdir2(this.lockDir, { recursive: false });
|
|
472
|
+
return true;
|
|
473
|
+
} catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Release lock directory
|
|
479
|
+
*/
|
|
480
|
+
async releaseLock() {
|
|
481
|
+
try {
|
|
482
|
+
await rmdir(this.lockDir);
|
|
483
|
+
} catch {
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Recover from a stale lock directory
|
|
488
|
+
* Handles cases where:
|
|
489
|
+
* - Lock exists but port is not listening and PID is dead
|
|
490
|
+
* - Lock exists but PID is alive but doesn't own the port (PID reuse)
|
|
491
|
+
*/
|
|
492
|
+
async recoverStaleLock() {
|
|
493
|
+
if (!existsSync3(this.lockDir)) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const portListening = await isPortListening(this.config.proxy.port);
|
|
497
|
+
const pid = readPidFile(this.pidFile);
|
|
498
|
+
const pidAlive = pid !== null && pid > 0 && pidIsAlive(pid);
|
|
499
|
+
let lockIsStale = false;
|
|
500
|
+
if (!portListening && !pidAlive) {
|
|
501
|
+
lockIsStale = true;
|
|
502
|
+
} else if (pidAlive && !portListening) {
|
|
503
|
+
const ownsPort = await this.verifyPidOwnsPort(pid);
|
|
504
|
+
if (!ownsPort) {
|
|
505
|
+
lockIsStale = true;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (lockIsStale) {
|
|
509
|
+
console.warn("Detected stale lock directory, recovering...");
|
|
510
|
+
try {
|
|
511
|
+
await rmdir(this.lockDir);
|
|
512
|
+
await removePidFile(this.pidFile);
|
|
513
|
+
console.warn("Stale lock recovered");
|
|
514
|
+
} catch (err) {
|
|
515
|
+
const error = err;
|
|
516
|
+
console.error(`Failed to recover stale lock: ${error.message}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Verify that a PID is actually listening on the target port
|
|
522
|
+
* This prevents killing a reused PID that doesn't belong to our proxy
|
|
523
|
+
*/
|
|
524
|
+
async verifyPidOwnsPort(pid) {
|
|
525
|
+
if (!await isPortListening(this.config.proxy.port)) {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
if (!pidIsAlive(pid)) {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const output = await execCommand("lsof", [
|
|
533
|
+
"-nP",
|
|
534
|
+
`-iTCP:${this.config.proxy.port}`,
|
|
535
|
+
"-sTCP:LISTEN",
|
|
536
|
+
"-p",
|
|
537
|
+
pid.toString()
|
|
538
|
+
]);
|
|
539
|
+
return output.includes(pid.toString());
|
|
540
|
+
} catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Wait for proxy to be ready (port listening)
|
|
546
|
+
*/
|
|
547
|
+
async waitForReady() {
|
|
548
|
+
const { startWaitSeconds } = this.config.lifecycle;
|
|
549
|
+
const deadline = Date.now() + startWaitSeconds * 1e3;
|
|
550
|
+
while (Date.now() < deadline) {
|
|
551
|
+
if (await isPortListening(this.config.proxy.port)) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
await sleep(100);
|
|
555
|
+
}
|
|
556
|
+
throw new Error(
|
|
557
|
+
`Proxy did not become ready at ${this.getBaseUrl()} (log: ${this.logFile})`
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Start the proxy server as a detached process
|
|
562
|
+
*/
|
|
563
|
+
async startProxyProcess() {
|
|
564
|
+
const serverPath = join2(__dirname, "..", "proxy", "server.js");
|
|
565
|
+
if (!existsSync3(serverPath)) {
|
|
566
|
+
throw new Error(`Proxy entry not found: ${serverPath}`);
|
|
567
|
+
}
|
|
568
|
+
const logFd = openSync(this.logFile, "a");
|
|
569
|
+
const proc = spawn2(process.execPath, [serverPath], {
|
|
570
|
+
detached: true,
|
|
571
|
+
stdio: ["ignore", logFd, logFd],
|
|
572
|
+
env: { ...process.env }
|
|
573
|
+
// Inherit all environment variables
|
|
574
|
+
});
|
|
575
|
+
const pid = proc.pid;
|
|
576
|
+
if (!pid || pid < 1) {
|
|
577
|
+
closeSync(logFd);
|
|
578
|
+
throw new Error("Failed to get valid PID from spawned process");
|
|
579
|
+
}
|
|
580
|
+
proc.unref();
|
|
581
|
+
await writePidFile(this.pidFile, pid);
|
|
582
|
+
closeSync(logFd);
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// src/lifecycle/tracker.ts
|
|
587
|
+
async function hasClaudeProcess() {
|
|
588
|
+
try {
|
|
589
|
+
const uid = process.getuid?.() ?? process.env.UID ?? "";
|
|
590
|
+
await execCommand("pgrep", [`-u`, uid.toString(), `-x`, `claude`]);
|
|
591
|
+
return true;
|
|
592
|
+
} catch {
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/utils/logger.ts
|
|
598
|
+
import chalk from "chalk";
|
|
599
|
+
var LOG_LEVEL_VALUES = {
|
|
600
|
+
debug: 0,
|
|
601
|
+
info: 1,
|
|
602
|
+
warn: 2,
|
|
603
|
+
error: 3
|
|
604
|
+
};
|
|
605
|
+
var Logger = class {
|
|
606
|
+
config;
|
|
607
|
+
constructor(config) {
|
|
608
|
+
this.config = config;
|
|
609
|
+
}
|
|
610
|
+
/** Update log level */
|
|
611
|
+
setLevel(level) {
|
|
612
|
+
this.config.level = level;
|
|
613
|
+
}
|
|
614
|
+
/** Check if a message should be logged */
|
|
615
|
+
shouldLog(level) {
|
|
616
|
+
return LOG_LEVEL_VALUES[level] >= LOG_LEVEL_VALUES[this.config.level];
|
|
617
|
+
}
|
|
618
|
+
/** Format log message */
|
|
619
|
+
format(level, message) {
|
|
620
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
621
|
+
const levelStr = level.toUpperCase().padEnd(5);
|
|
622
|
+
return `[${timestamp}] ${levelStr} ${message}`;
|
|
623
|
+
}
|
|
624
|
+
/** Log debug message */
|
|
625
|
+
debug(message) {
|
|
626
|
+
if (this.shouldLog("debug")) {
|
|
627
|
+
console.log(chalk.gray(this.format("debug", message)));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/** Log info message */
|
|
631
|
+
info(message) {
|
|
632
|
+
if (this.shouldLog("info")) {
|
|
633
|
+
console.log(chalk.white(this.format("info", message)));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/** Log warning message */
|
|
637
|
+
warn(message) {
|
|
638
|
+
if (this.shouldLog("warn")) {
|
|
639
|
+
console.log(chalk.yellow(this.format("warn", message)));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/** Log error message */
|
|
643
|
+
error(message) {
|
|
644
|
+
if (this.shouldLog("error")) {
|
|
645
|
+
console.error(chalk.red(this.format("error", message)));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// src/utils/claude.ts
|
|
651
|
+
import { existsSync as existsSync4 } from "fs";
|
|
652
|
+
async function resolveClaudePath(configPath) {
|
|
653
|
+
if (configPath) {
|
|
654
|
+
if (!existsSync4(configPath)) {
|
|
655
|
+
throw new Error(`Claude executable not found at: ${configPath}`);
|
|
656
|
+
}
|
|
657
|
+
return configPath;
|
|
658
|
+
}
|
|
659
|
+
const detector = process.platform === "win32" ? "where" : "which";
|
|
660
|
+
try {
|
|
661
|
+
return await execCommand(detector, ["claude"]);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
const error = err;
|
|
664
|
+
if (error.code === "ENOENT") {
|
|
665
|
+
throw new Error(
|
|
666
|
+
`Claude command not found. Install Claude Code CLI or set 'claude.path' in config.yml`
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/bin/cli.ts
|
|
674
|
+
async function main() {
|
|
675
|
+
const config = await loadConfig();
|
|
676
|
+
const logger = new Logger(config.logging);
|
|
677
|
+
let claudePath;
|
|
678
|
+
try {
|
|
679
|
+
claudePath = await resolveClaudePath(config.claude.path);
|
|
680
|
+
logger.debug(`Using claude: ${claudePath}`);
|
|
681
|
+
} catch (err) {
|
|
682
|
+
logger.error(`Failed to locate claude: ${err}`);
|
|
683
|
+
process.exit(1);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const proxy = new SingletonProxy(config);
|
|
687
|
+
logger.info("Starting proxy...");
|
|
688
|
+
await proxy.start();
|
|
689
|
+
logger.info(`Proxy ready at ${proxy.getBaseUrl()}`);
|
|
690
|
+
const baseUrl = proxy.getBaseUrl();
|
|
691
|
+
process.env.ANTHROPIC_BASE_URL = baseUrl;
|
|
692
|
+
const args = process.argv.slice(2);
|
|
693
|
+
logger.info(`Starting claude with args: ${args.join(" ") || "(no args)"}`);
|
|
694
|
+
const claude = spawn3(claudePath, args, {
|
|
695
|
+
stdio: "inherit",
|
|
696
|
+
env: { ...process.env, ANTHROPIC_BASE_URL: baseUrl }
|
|
697
|
+
});
|
|
698
|
+
const exitCode = await new Promise((resolve) => {
|
|
699
|
+
claude.on("close", (code) => {
|
|
700
|
+
resolve(code ?? 0);
|
|
701
|
+
});
|
|
702
|
+
claude.on("error", (err) => {
|
|
703
|
+
logger.error(`Failed to start claude: ${err.message}`);
|
|
704
|
+
resolve(1);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
logger.info(`Claude exited with code ${exitCode}`);
|
|
708
|
+
logger.info("Checking for other Claude processes...");
|
|
709
|
+
const hasClaude = await hasClaudeProcess();
|
|
710
|
+
if (!hasClaude) {
|
|
711
|
+
logger.info("No other Claude processes running, stopping proxy...");
|
|
712
|
+
await proxy.stopIfNoClaude(hasClaudeProcess);
|
|
713
|
+
} else {
|
|
714
|
+
logger.info("Other Claude processes still running, keeping proxy alive");
|
|
715
|
+
}
|
|
716
|
+
process.exit(exitCode);
|
|
717
|
+
}
|
|
718
|
+
main().catch((err) => {
|
|
719
|
+
console.error("Fatal error:", err);
|
|
720
|
+
process.exit(1);
|
|
721
|
+
});
|
|
722
|
+
//# sourceMappingURL=cli.js.map
|