claude-connect 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/LICENSE +21 -0
- package/README.md +175 -0
- package/bin/claude-connect.js +40 -0
- package/package.json +43 -0
- package/src/data/catalog-store.js +482 -0
- package/src/gateway/constants.js +4 -0
- package/src/gateway/messages.js +362 -0
- package/src/gateway/server.js +481 -0
- package/src/gateway/state.js +104 -0
- package/src/index.js +61 -0
- package/src/lib/app-paths.js +269 -0
- package/src/lib/claude-settings.js +364 -0
- package/src/lib/oauth.js +485 -0
- package/src/lib/profile.js +104 -0
- package/src/lib/secrets.js +45 -0
- package/src/lib/terminal.js +350 -0
- package/src/lib/theme.js +44 -0
- package/src/wizard.js +887 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsPromises from 'node:fs/promises';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
buildAnthropicMessageFromOpenAI,
|
|
10
|
+
buildOpenAIRequestFromAnthropic,
|
|
11
|
+
estimateTokenCountFromAnthropicRequest,
|
|
12
|
+
writeAnthropicStreamFromMessage
|
|
13
|
+
} from './messages.js';
|
|
14
|
+
import { gatewayBasePath, gatewayBaseUrl, gatewayHost, gatewayPort } from './constants.js';
|
|
15
|
+
import { getGatewayStatus, readGatewayState, writeGatewayState, isProcessAlive } from './state.js';
|
|
16
|
+
import { resolveClaudeConnectPaths } from '../lib/app-paths.js';
|
|
17
|
+
import { readSwitchState } from '../lib/claude-settings.js';
|
|
18
|
+
import { readOAuthToken, refreshOAuthToken } from '../lib/oauth.js';
|
|
19
|
+
import { readProfileFile } from '../lib/profile.js';
|
|
20
|
+
import { readManagedTokenSecret } from '../lib/secrets.js';
|
|
21
|
+
|
|
22
|
+
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
23
|
+
const cliEntryPath = path.join(projectRoot, 'bin', 'claude-connect.js');
|
|
24
|
+
|
|
25
|
+
function isObject(value) {
|
|
26
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildErrorResponse(statusCode, message, type = 'api_error') {
|
|
30
|
+
return {
|
|
31
|
+
statusCode,
|
|
32
|
+
body: {
|
|
33
|
+
type: 'error',
|
|
34
|
+
error: {
|
|
35
|
+
type,
|
|
36
|
+
message
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sendJson(response, statusCode, payload) {
|
|
43
|
+
response.writeHead(statusCode, {
|
|
44
|
+
'content-type': 'application/json; charset=utf-8',
|
|
45
|
+
'cache-control': 'no-store'
|
|
46
|
+
});
|
|
47
|
+
response.end(`${JSON.stringify(payload, null, 2)}\n`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readJsonBody(request) {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let totalLength = 0;
|
|
53
|
+
|
|
54
|
+
for await (const chunk of request) {
|
|
55
|
+
totalLength += chunk.length;
|
|
56
|
+
|
|
57
|
+
if (totalLength > 10 * 1024 * 1024) {
|
|
58
|
+
throw new Error('La peticion excede el limite de 10 MB.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
chunks.push(chunk);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
65
|
+
return raw.length === 0 ? {} : JSON.parse(raw);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeOauthResourceUrl(resourceUrl) {
|
|
69
|
+
if (typeof resourceUrl !== 'string' || resourceUrl.trim().length === 0) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const normalized = resourceUrl.startsWith('http://') || resourceUrl.startsWith('https://')
|
|
74
|
+
? resourceUrl
|
|
75
|
+
: `https://${resourceUrl}`;
|
|
76
|
+
|
|
77
|
+
return normalized.replace(/\/$/, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildOauthApiBaseUrl(profile, tokenRecord) {
|
|
81
|
+
if (typeof profile?.auth?.oauth?.apiBaseUrl === 'string' && profile.auth.oauth.apiBaseUrl.length > 0) {
|
|
82
|
+
return profile.auth.oauth.apiBaseUrl;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const resourceUrl = normalizeOauthResourceUrl(tokenRecord?.token?.resource_url);
|
|
86
|
+
|
|
87
|
+
if (resourceUrl) {
|
|
88
|
+
return resourceUrl.endsWith('/v1') ? resourceUrl : `${resourceUrl}/v1`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return 'https://portal.qwen.ai/v1';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resolveGatewayContext() {
|
|
95
|
+
const switchState = await readSwitchState();
|
|
96
|
+
|
|
97
|
+
if (!switchState?.active || typeof switchState.profilePath !== 'string') {
|
|
98
|
+
throw new Error('Claude Connect no tiene un perfil activo en Claude Code.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const profile = await readProfileFile(switchState.profilePath);
|
|
102
|
+
const authMethod = profile?.auth?.method === 'api_key' ? 'token' : profile?.auth?.method;
|
|
103
|
+
|
|
104
|
+
if (authMethod === 'token') {
|
|
105
|
+
const envVar = profile?.auth?.envVar;
|
|
106
|
+
let token = typeof envVar === 'string' ? process.env[envVar] : '';
|
|
107
|
+
|
|
108
|
+
if ((!token || token.trim().length === 0) && typeof profile?.auth?.secretFile === 'string') {
|
|
109
|
+
const secret = await readManagedTokenSecret(profile.auth.secretFile);
|
|
110
|
+
token = typeof secret?.token === 'string' ? secret.token : '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
114
|
+
throw new Error(`Falta la variable de entorno ${envVar} y tampoco hay una API key guardada para este perfil.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
profile,
|
|
119
|
+
authMethod,
|
|
120
|
+
upstreamBaseUrl: profile.endpoint.baseUrl,
|
|
121
|
+
accessToken: token.trim()
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (authMethod === 'oauth') {
|
|
126
|
+
const tokenFile = profile?.auth?.oauth?.tokenFile;
|
|
127
|
+
|
|
128
|
+
if (typeof tokenFile !== 'string' || tokenFile.length === 0) {
|
|
129
|
+
throw new Error('El perfil OAuth no tiene un token local asociado.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let record = await readOAuthToken(tokenFile);
|
|
133
|
+
let accessToken = typeof record.token?.access_token === 'string' ? record.token.access_token : '';
|
|
134
|
+
|
|
135
|
+
if (!accessToken && typeof record.token?.refresh_token === 'string') {
|
|
136
|
+
record = await refreshOAuthToken({
|
|
137
|
+
filePath: tokenFile,
|
|
138
|
+
tokenUrl: profile.auth.oauth.tokenUrl,
|
|
139
|
+
clientId: profile.auth.oauth.clientId
|
|
140
|
+
});
|
|
141
|
+
accessToken = record.token.access_token;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
|
145
|
+
throw new Error('No se encontro un access_token valido para el perfil OAuth.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
profile,
|
|
150
|
+
authMethod,
|
|
151
|
+
upstreamBaseUrl: buildOauthApiBaseUrl(profile, record),
|
|
152
|
+
accessToken
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new Error(`Metodo de autenticacion no soportado por el gateway: ${authMethod}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function forwardChatCompletion({ openAiRequest, context, refreshOnUnauthorized = true }) {
|
|
160
|
+
const response = await fetch(`${context.upstreamBaseUrl.replace(/\/$/, '')}/chat/completions`, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: {
|
|
163
|
+
'content-type': 'application/json',
|
|
164
|
+
accept: 'application/json',
|
|
165
|
+
authorization: `Bearer ${context.accessToken}`,
|
|
166
|
+
'user-agent': 'claude-connect-gateway/0.1.0'
|
|
167
|
+
},
|
|
168
|
+
body: JSON.stringify(openAiRequest)
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const payload = await response.json().catch(() => ({}));
|
|
172
|
+
|
|
173
|
+
if (response.ok) {
|
|
174
|
+
return payload;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (response.status === 401 && refreshOnUnauthorized && context.authMethod === 'oauth') {
|
|
178
|
+
const refreshed = await refreshOAuthToken({
|
|
179
|
+
filePath: context.profile.auth.oauth.tokenFile,
|
|
180
|
+
tokenUrl: context.profile.auth.oauth.tokenUrl,
|
|
181
|
+
clientId: context.profile.auth.oauth.clientId
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return forwardChatCompletion({
|
|
185
|
+
openAiRequest,
|
|
186
|
+
context: {
|
|
187
|
+
...context,
|
|
188
|
+
accessToken: refreshed.token.access_token
|
|
189
|
+
},
|
|
190
|
+
refreshOnUnauthorized: false
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const message = payload?.error?.message || payload?.message || payload?.error || `HTTP ${response.status}`;
|
|
195
|
+
throw new Error(`Qwen devolvio un error: ${message}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildHealthPayload(context) {
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
service: 'claude-connect-gateway',
|
|
202
|
+
baseUrl: gatewayBaseUrl,
|
|
203
|
+
profileName: context.profile.profileName,
|
|
204
|
+
provider: context.profile.provider.id,
|
|
205
|
+
model: context.profile.model.id,
|
|
206
|
+
authMethod: context.authMethod,
|
|
207
|
+
upstreamBaseUrl: context.upstreamBaseUrl,
|
|
208
|
+
pid: process.pid
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function handleHealth(_request, response) {
|
|
213
|
+
const context = await resolveGatewayContext();
|
|
214
|
+
sendJson(response, 200, buildHealthPayload(context));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function handleModels(_request, response) {
|
|
218
|
+
const context = await resolveGatewayContext();
|
|
219
|
+
const model = context.profile.model;
|
|
220
|
+
|
|
221
|
+
sendJson(response, 200, {
|
|
222
|
+
data: [
|
|
223
|
+
{
|
|
224
|
+
type: 'model',
|
|
225
|
+
id: model.id,
|
|
226
|
+
display_name: model.name,
|
|
227
|
+
created_at: '2026-03-31'
|
|
228
|
+
}
|
|
229
|
+
],
|
|
230
|
+
first_id: model.id,
|
|
231
|
+
has_more: false,
|
|
232
|
+
last_id: model.id
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function handleCountTokens(request, response) {
|
|
237
|
+
const body = await readJsonBody(request);
|
|
238
|
+
sendJson(response, 200, {
|
|
239
|
+
input_tokens: estimateTokenCountFromAnthropicRequest(body)
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function handleMessages(request, response) {
|
|
244
|
+
const body = await readJsonBody(request);
|
|
245
|
+
const context = await resolveGatewayContext();
|
|
246
|
+
const openAiRequest = buildOpenAIRequestFromAnthropic({
|
|
247
|
+
body,
|
|
248
|
+
model: context.profile.model.id
|
|
249
|
+
});
|
|
250
|
+
const upstreamResponse = await forwardChatCompletion({
|
|
251
|
+
openAiRequest,
|
|
252
|
+
context
|
|
253
|
+
});
|
|
254
|
+
const anthropicMessage = buildAnthropicMessageFromOpenAI({
|
|
255
|
+
response: upstreamResponse,
|
|
256
|
+
requestedModel: context.profile.model.id
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (body.stream === true) {
|
|
260
|
+
response.writeHead(200, {
|
|
261
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
262
|
+
'cache-control': 'no-cache, no-transform',
|
|
263
|
+
connection: 'keep-alive',
|
|
264
|
+
'x-accel-buffering': 'no'
|
|
265
|
+
});
|
|
266
|
+
writeAnthropicStreamFromMessage(response, anthropicMessage);
|
|
267
|
+
response.end();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
sendJson(response, 200, anthropicMessage);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function routeRequest(request, response) {
|
|
275
|
+
const requestUrl = new URL(request.url || '/', gatewayBaseUrl);
|
|
276
|
+
|
|
277
|
+
if (request.method === 'GET' && requestUrl.pathname === `${gatewayBasePath}/health`) {
|
|
278
|
+
await handleHealth(request, response);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (request.method === 'GET' && requestUrl.pathname === `${gatewayBasePath}/v1/models`) {
|
|
283
|
+
await handleModels(request, response);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (request.method === 'POST' && requestUrl.pathname === `${gatewayBasePath}/v1/messages/count_tokens`) {
|
|
288
|
+
await handleCountTokens(request, response);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (request.method === 'POST' && requestUrl.pathname === `${gatewayBasePath}/v1/messages`) {
|
|
293
|
+
await handleMessages(request, response);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const error = buildErrorResponse(404, `Ruta no soportada: ${request.method} ${requestUrl.pathname}`, 'not_found_error');
|
|
298
|
+
sendJson(response, error.statusCode, error.body);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function serveGateway() {
|
|
302
|
+
const initialContext = await resolveGatewayContext();
|
|
303
|
+
const server = http.createServer(async (request, response) => {
|
|
304
|
+
try {
|
|
305
|
+
await routeRequest(request, response);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
308
|
+
const apiError = buildErrorResponse(500, message);
|
|
309
|
+
sendJson(response, apiError.statusCode, apiError.body);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
server.on('clientError', () => {
|
|
314
|
+
// Silence malformed local requests.
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const stop = async () => {
|
|
318
|
+
await writeGatewayState({
|
|
319
|
+
active: false,
|
|
320
|
+
pid: process.pid,
|
|
321
|
+
stoppedAt: new Date().toISOString()
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await new Promise((resolve) => server.close(resolve));
|
|
325
|
+
process.exit(0);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
process.on('SIGINT', () => {
|
|
329
|
+
void stop();
|
|
330
|
+
});
|
|
331
|
+
process.on('SIGTERM', () => {
|
|
332
|
+
void stop();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await new Promise((resolve, reject) => {
|
|
336
|
+
server.once('error', reject);
|
|
337
|
+
server.listen(gatewayPort, gatewayHost, resolve);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await writeGatewayState({
|
|
341
|
+
active: true,
|
|
342
|
+
pid: process.pid,
|
|
343
|
+
startedAt: new Date().toISOString(),
|
|
344
|
+
logPath: (await resolveClaudeConnectPaths()).gatewayLogPath,
|
|
345
|
+
profileName: initialContext.profile.profileName,
|
|
346
|
+
authMethod: initialContext.authMethod,
|
|
347
|
+
upstreamBaseUrl: initialContext.upstreamBaseUrl,
|
|
348
|
+
baseUrl: gatewayBaseUrl
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
console.log(`claude-connect gateway escuchando en ${gatewayBaseUrl}`);
|
|
352
|
+
return new Promise(() => {});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function startGatewayInBackground() {
|
|
356
|
+
const currentStatus = await getGatewayStatus();
|
|
357
|
+
const { gatewayLogPath } = await resolveClaudeConnectPaths();
|
|
358
|
+
|
|
359
|
+
if (currentStatus.active) {
|
|
360
|
+
return {
|
|
361
|
+
...currentStatus,
|
|
362
|
+
alreadyRunning: true
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await fsPromises.mkdir(path.dirname(gatewayLogPath), { recursive: true });
|
|
367
|
+
const outputFd = fs.openSync(gatewayLogPath, 'a');
|
|
368
|
+
const child = spawn(process.execPath, ['--no-warnings=ExperimentalWarning', cliEntryPath, 'gateway', 'serve'], {
|
|
369
|
+
cwd: projectRoot,
|
|
370
|
+
detached: true,
|
|
371
|
+
stdio: ['ignore', outputFd, outputFd]
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
child.unref();
|
|
375
|
+
fs.closeSync(outputFd);
|
|
376
|
+
|
|
377
|
+
await writeGatewayState({
|
|
378
|
+
active: false,
|
|
379
|
+
pid: child.pid,
|
|
380
|
+
startedAt: new Date().toISOString(),
|
|
381
|
+
logPath: gatewayLogPath,
|
|
382
|
+
baseUrl: gatewayBaseUrl
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
386
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
387
|
+
const status = await getGatewayStatus({ timeoutMs: 500 });
|
|
388
|
+
|
|
389
|
+
if (status.active) {
|
|
390
|
+
return {
|
|
391
|
+
...status,
|
|
392
|
+
alreadyRunning: false
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!isProcessAlive(child.pid)) {
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const adoptedStatus = await getGatewayStatus({ timeoutMs: 700 });
|
|
402
|
+
|
|
403
|
+
if (adoptedStatus.active) {
|
|
404
|
+
return {
|
|
405
|
+
...adoptedStatus,
|
|
406
|
+
alreadyRunning: true
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
await writeGatewayState({
|
|
411
|
+
active: false,
|
|
412
|
+
pid: child.pid,
|
|
413
|
+
startedAt: new Date().toISOString(),
|
|
414
|
+
logPath: gatewayLogPath,
|
|
415
|
+
baseUrl: gatewayBaseUrl,
|
|
416
|
+
lastError: 'El gateway no llego a responder en el tiempo esperado.'
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
throw new Error(`No pude iniciar el gateway local. Revisa el log: ${gatewayLogPath}`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function stopGateway() {
|
|
423
|
+
const { gatewayLogPath } = await resolveClaudeConnectPaths();
|
|
424
|
+
const state = await readGatewayState();
|
|
425
|
+
const pid = Number(state?.pid ?? 0);
|
|
426
|
+
|
|
427
|
+
if (!isProcessAlive(pid)) {
|
|
428
|
+
await writeGatewayState({
|
|
429
|
+
active: false,
|
|
430
|
+
stoppedAt: new Date().toISOString()
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
stopped: false,
|
|
435
|
+
pid: null,
|
|
436
|
+
logPath: gatewayLogPath
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (process.platform === 'win32') {
|
|
441
|
+
await new Promise((resolve, reject) => {
|
|
442
|
+
const child = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
|
443
|
+
stdio: 'ignore',
|
|
444
|
+
windowsHide: true
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
child.once('error', reject);
|
|
448
|
+
child.once('exit', (code) => {
|
|
449
|
+
if (code === 0 || code === 128) {
|
|
450
|
+
resolve();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
reject(new Error(`taskkill devolvio ${code}`));
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
} else {
|
|
458
|
+
process.kill(pid, 'SIGTERM');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
462
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
463
|
+
|
|
464
|
+
if (!isProcessAlive(pid)) {
|
|
465
|
+
await writeGatewayState({
|
|
466
|
+
active: false,
|
|
467
|
+
pid,
|
|
468
|
+
stoppedAt: new Date().toISOString(),
|
|
469
|
+
logPath: gatewayLogPath
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
stopped: true,
|
|
474
|
+
pid,
|
|
475
|
+
logPath: gatewayLogPath
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
throw new Error(`No pude detener el gateway local (pid ${pid}).`);
|
|
481
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { gatewayBaseUrl } from './constants.js';
|
|
3
|
+
import { resolveClaudeConnectPaths } from '../lib/app-paths.js';
|
|
4
|
+
|
|
5
|
+
function isObject(value) {
|
|
6
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function readJsonIfExists(filePath) {
|
|
10
|
+
try {
|
|
11
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeGatewayState(payload) {
|
|
23
|
+
const { gatewayDir, gatewayStatePath } = await resolveClaudeConnectPaths();
|
|
24
|
+
await fs.mkdir(gatewayDir, { recursive: true });
|
|
25
|
+
await fs.writeFile(gatewayStatePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function readGatewayState() {
|
|
29
|
+
const { gatewayStatePath } = await resolveClaudeConnectPaths();
|
|
30
|
+
const state = await readJsonIfExists(gatewayStatePath);
|
|
31
|
+
return isObject(state) ? state : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isProcessAlive(pid) {
|
|
35
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return true;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH') {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'EPERM') {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function probeGatewayHealth(timeoutMs) {
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(`${gatewayBaseUrl}/health`, {
|
|
58
|
+
headers: { accept: 'application/json' },
|
|
59
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return await response.json();
|
|
67
|
+
} catch (_error) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function getGatewayStatus({ timeoutMs = 700 } = {}) {
|
|
73
|
+
const { gatewayLogPath, gatewayStatePath } = await resolveClaudeConnectPaths();
|
|
74
|
+
const state = await readGatewayState();
|
|
75
|
+
const pid = Number(state?.pid ?? 0);
|
|
76
|
+
const processAlive = isProcessAlive(pid);
|
|
77
|
+
const health = await probeGatewayHealth(timeoutMs);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
active: Boolean(health),
|
|
81
|
+
pid: processAlive ? pid : null,
|
|
82
|
+
baseUrl: gatewayBaseUrl,
|
|
83
|
+
logPath: gatewayLogPath,
|
|
84
|
+
statePath: gatewayStatePath,
|
|
85
|
+
startedAt: typeof state?.startedAt === 'string' ? state.startedAt : null,
|
|
86
|
+
lastError: typeof state?.lastError === 'string' ? state.lastError : null,
|
|
87
|
+
profileName: typeof health?.profileName === 'string'
|
|
88
|
+
? health.profileName
|
|
89
|
+
: typeof state?.profileName === 'string'
|
|
90
|
+
? state.profileName
|
|
91
|
+
: null,
|
|
92
|
+
upstreamBaseUrl: typeof health?.upstreamBaseUrl === 'string'
|
|
93
|
+
? health.upstreamBaseUrl
|
|
94
|
+
: typeof state?.upstreamBaseUrl === 'string'
|
|
95
|
+
? state.upstreamBaseUrl
|
|
96
|
+
: null,
|
|
97
|
+
authMethod: typeof health?.authMethod === 'string'
|
|
98
|
+
? health.authMethod
|
|
99
|
+
: typeof state?.authMethod === 'string'
|
|
100
|
+
? state.authMethod
|
|
101
|
+
: null,
|
|
102
|
+
health
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getCatalogStore } from './data/catalog-store.js';
|
|
2
|
+
import { gatewayBaseUrl } from './gateway/constants.js';
|
|
3
|
+
import { getGatewayStatus } from './gateway/state.js';
|
|
4
|
+
import { serveGateway, startGatewayInBackground, stopGateway } from './gateway/server.js';
|
|
5
|
+
import { runWizard } from './wizard.js';
|
|
6
|
+
|
|
7
|
+
function printGatewayStatus(status) {
|
|
8
|
+
const lines = [
|
|
9
|
+
`active=${status.active ? 'yes' : 'no'}`,
|
|
10
|
+
`base_url=${status.baseUrl}`,
|
|
11
|
+
`pid=${status.pid ?? 'none'}`,
|
|
12
|
+
`profile=${status.profileName ?? 'none'}`,
|
|
13
|
+
`auth=${status.authMethod ?? 'none'}`,
|
|
14
|
+
`upstream=${status.upstreamBaseUrl ?? 'none'}`,
|
|
15
|
+
`log=${status.logPath}`
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function runGatewayCommand(args) {
|
|
22
|
+
const action = args[0] ?? 'status';
|
|
23
|
+
|
|
24
|
+
if (action === 'serve') {
|
|
25
|
+
await serveGateway();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (action === 'start') {
|
|
30
|
+
const status = await startGatewayInBackground();
|
|
31
|
+
process.stdout.write(`Gateway listo en ${gatewayBaseUrl} (pid ${status.pid})\n`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (action === 'stop') {
|
|
36
|
+
const result = await stopGateway();
|
|
37
|
+
process.stdout.write(
|
|
38
|
+
result.stopped
|
|
39
|
+
? `Gateway detenido (pid ${result.pid})\n`
|
|
40
|
+
: 'No habia un gateway en ejecucion.\n'
|
|
41
|
+
);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (action === 'status') {
|
|
46
|
+
printGatewayStatus(await getGatewayStatus());
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new Error(`Comando de gateway no soportado: ${action}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function run(argv = process.argv.slice(2)) {
|
|
54
|
+
if (argv[0] === 'gateway') {
|
|
55
|
+
await runGatewayCommand(argv.slice(1));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getCatalogStore();
|
|
60
|
+
await runWizard();
|
|
61
|
+
}
|