@uncensoredcode/openbridge 0.1.0 → 0.1.1
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 +1 -1
- package/packages/cli/dist/args.js +23 -2
- package/packages/server/dist/cli/run-bridge-server-cli.d.ts +81 -0
- package/packages/server/dist/cli/run-bridge-server-cli.js +961 -46
- package/packages/server/dist/client/bridge-api-client.d.ts +81 -1
- package/packages/server/dist/client/bridge-api-client.js +184 -1
- package/packages/server/dist/client/index.d.ts +39 -1
- package/packages/server/dist/client/index.js +20 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import crypto from "node:crypto";
|
|
2
|
-
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { chmod, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import process from "node:process";
|
|
5
6
|
import { createInterface } from "node:readline/promises";
|
|
@@ -10,17 +11,23 @@ import { clientModule } from "../client/index.js";
|
|
|
10
11
|
import { configModule } from "../config/index.js";
|
|
11
12
|
import { httpModule } from "../http/index.js";
|
|
12
13
|
import { securityModule } from "../security/index.js";
|
|
13
|
-
const { createBridgeChatCompletion, DEFAULT_BRIDGE_API_BASE_URL, streamBridgeChatCompletion } = clientModule;
|
|
14
|
+
const { createBridgeChatCompletion, createBridgeModel, createBridgeProvider, DEFAULT_BRIDGE_API_BASE_URL, deleteBridgeProvider, deleteBridgeProviderSessionPackage, deleteBridgeSession, getBridgeProvider, getBridgeProviderSessionPackage, getBridgeSession, listBridgeModels, listBridgeProviders, listBridgeSessions, putBridgeProviderSessionPackage, streamBridgeChatCompletion, updateBridgeProvider, checkBridgeHealth } = clientModule;
|
|
14
15
|
const { getBridgeServerStartupWarnings, loadBridgeServerConfig } = configModule;
|
|
15
16
|
const { clearLocalSessionVault, formatLiveProviderExtractionCanaryResult, runLiveProviderExtractionCanary } = bridgeModule;
|
|
16
17
|
const { sanitizeSensitiveText } = securityModule;
|
|
17
18
|
const { startBridgeApiServer } = httpModule;
|
|
19
|
+
const DEFAULT_LOG_TAIL_LINES = 50;
|
|
20
|
+
const DAEMON_READY_TIMEOUT_MS = 4_000;
|
|
21
|
+
const DAEMON_POLL_INTERVAL_MS = 100;
|
|
22
|
+
const STOP_TIMEOUT_MS = 5_000;
|
|
23
|
+
const LOG_FOLLOW_POLL_INTERVAL_MS = 250;
|
|
18
24
|
async function runBridgeServerCli(input) {
|
|
19
25
|
const stdout = input.stdout ?? process.stdout;
|
|
20
26
|
const stderr = input.stderr ?? process.stderr;
|
|
21
27
|
const stdin = input.stdin ?? process.stdin;
|
|
22
28
|
const startServer = input.startServer ?? startBridgeApiServer;
|
|
23
29
|
const runLiveCanary = input.runLiveCanary ?? runLiveProviderExtractionCanary;
|
|
30
|
+
const spawnDetachedServerProcess = input.spawnDetachedServerProcess ?? defaultSpawnDetachedServerProcess;
|
|
24
31
|
let command;
|
|
25
32
|
try {
|
|
26
33
|
command = parseBridgeServerCliArgs({
|
|
@@ -90,28 +97,200 @@ async function runBridgeServerCli(input) {
|
|
|
90
97
|
stdout.write(`Emptied session vault at ${sessionVaultPath}\n`);
|
|
91
98
|
return 0;
|
|
92
99
|
}
|
|
100
|
+
if (command.kind === "status") {
|
|
101
|
+
const status = await getServerStatus(command.config, input.fetchImpl);
|
|
102
|
+
writeJson(stdout, status);
|
|
103
|
+
return status.running && status.healthy !== false ? 0 : 1;
|
|
104
|
+
}
|
|
105
|
+
if (command.kind === "stop") {
|
|
106
|
+
const result = await stopServer(command.config);
|
|
107
|
+
stdout.write(`${result.message}\n`);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (command.kind === "logs") {
|
|
111
|
+
await printServerLogs(command.config, {
|
|
112
|
+
follow: command.follow,
|
|
113
|
+
lines: command.lines,
|
|
114
|
+
stdout
|
|
115
|
+
});
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (command.kind === "providers-list") {
|
|
119
|
+
writeJson(stdout, await listBridgeProviders({
|
|
120
|
+
baseUrl: command.baseUrl,
|
|
121
|
+
fetchImpl: input.fetchImpl
|
|
122
|
+
}));
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
if (command.kind === "providers-get") {
|
|
126
|
+
writeJson(stdout, await getBridgeProvider({
|
|
127
|
+
baseUrl: command.baseUrl,
|
|
128
|
+
id: command.id,
|
|
129
|
+
fetchImpl: input.fetchImpl
|
|
130
|
+
}));
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
if (command.kind === "providers-add") {
|
|
134
|
+
writeJson(stdout, await createBridgeProvider({
|
|
135
|
+
baseUrl: command.baseUrl,
|
|
136
|
+
id: command.id,
|
|
137
|
+
kind: command.providerKind,
|
|
138
|
+
label: command.label,
|
|
139
|
+
enabled: command.enabled,
|
|
140
|
+
config: command.config,
|
|
141
|
+
fetchImpl: input.fetchImpl
|
|
142
|
+
}));
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
if (command.kind === "providers-remove") {
|
|
146
|
+
writeJson(stdout, await deleteBridgeProvider({
|
|
147
|
+
baseUrl: command.baseUrl,
|
|
148
|
+
id: command.id,
|
|
149
|
+
fetchImpl: input.fetchImpl
|
|
150
|
+
}));
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
if (command.kind === "providers-enable" || command.kind === "providers-disable") {
|
|
154
|
+
writeJson(stdout, await updateBridgeProvider({
|
|
155
|
+
baseUrl: command.baseUrl,
|
|
156
|
+
id: command.id,
|
|
157
|
+
patch: {
|
|
158
|
+
enabled: command.kind === "providers-enable"
|
|
159
|
+
},
|
|
160
|
+
fetchImpl: input.fetchImpl
|
|
161
|
+
}));
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
if (command.kind === "providers-import-session") {
|
|
165
|
+
const sessionPackage = await readSessionPackageInput({
|
|
166
|
+
filePath: command.filePath,
|
|
167
|
+
stdin,
|
|
168
|
+
stderr
|
|
169
|
+
});
|
|
170
|
+
writeJson(stdout, await putBridgeProviderSessionPackage({
|
|
171
|
+
baseUrl: command.baseUrl,
|
|
172
|
+
id: command.id,
|
|
173
|
+
sessionPackage,
|
|
174
|
+
fetchImpl: input.fetchImpl
|
|
175
|
+
}));
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
if (command.kind === "providers-session-status") {
|
|
179
|
+
writeJson(stdout, await getBridgeProviderSessionPackage({
|
|
180
|
+
baseUrl: command.baseUrl,
|
|
181
|
+
id: command.id,
|
|
182
|
+
fetchImpl: input.fetchImpl
|
|
183
|
+
}));
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
if (command.kind === "providers-clear-session") {
|
|
187
|
+
writeJson(stdout, await deleteBridgeProviderSessionPackage({
|
|
188
|
+
baseUrl: command.baseUrl,
|
|
189
|
+
id: command.id,
|
|
190
|
+
fetchImpl: input.fetchImpl
|
|
191
|
+
}));
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
if (command.kind === "models-list") {
|
|
195
|
+
writeJson(stdout, await listBridgeModels({
|
|
196
|
+
baseUrl: command.baseUrl,
|
|
197
|
+
fetchImpl: input.fetchImpl
|
|
198
|
+
}));
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
if (command.kind === "models-add") {
|
|
202
|
+
writeJson(stdout, await createBridgeModel({
|
|
203
|
+
baseUrl: command.baseUrl,
|
|
204
|
+
provider: command.provider,
|
|
205
|
+
model: command.model,
|
|
206
|
+
fetchImpl: input.fetchImpl
|
|
207
|
+
}));
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
if (command.kind === "sessions-list") {
|
|
211
|
+
writeJson(stdout, await listBridgeSessions({
|
|
212
|
+
baseUrl: command.baseUrl,
|
|
213
|
+
fetchImpl: input.fetchImpl
|
|
214
|
+
}));
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
if (command.kind === "sessions-get") {
|
|
218
|
+
writeJson(stdout, await getBridgeSession({
|
|
219
|
+
baseUrl: command.baseUrl,
|
|
220
|
+
id: command.id,
|
|
221
|
+
fetchImpl: input.fetchImpl
|
|
222
|
+
}));
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
if (command.kind === "sessions-remove") {
|
|
226
|
+
writeJson(stdout, await deleteBridgeSession({
|
|
227
|
+
baseUrl: command.baseUrl,
|
|
228
|
+
id: command.id,
|
|
229
|
+
fetchImpl: input.fetchImpl
|
|
230
|
+
}));
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
93
233
|
for (const warning of getBridgeServerStartupWarnings(command.config)) {
|
|
94
234
|
stderr.write(`Warning: ${warning}\n`);
|
|
95
235
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
236
|
+
const preparedConfig = await ensureCliVaultKey(command.config, {
|
|
237
|
+
env: input.env ?? process.env,
|
|
238
|
+
stdin,
|
|
239
|
+
stdout,
|
|
240
|
+
stderr,
|
|
241
|
+
promptForVaultKey: input.promptForVaultKey
|
|
242
|
+
});
|
|
243
|
+
if (!command.foreground) {
|
|
244
|
+
const status = await getServerStatus(preparedConfig, input.fetchImpl);
|
|
245
|
+
if (status.running) {
|
|
246
|
+
throw new Error(`Bridge server is already running${status.pid ? ` (pid ${status.pid})` : ""}.`);
|
|
247
|
+
}
|
|
248
|
+
if (preparedConfig.port === 0) {
|
|
249
|
+
throw new Error("Detached start requires a fixed port. Use --foreground with --port 0.");
|
|
250
|
+
}
|
|
251
|
+
const processFiles = getServerProcessFiles(preparedConfig);
|
|
252
|
+
await ensureServerProcessDirectories(processFiles);
|
|
253
|
+
const daemonArgv = toForegroundStartArgv(input.argv);
|
|
254
|
+
const daemon = await spawnDetachedServerProcess({
|
|
255
|
+
argv: daemonArgv,
|
|
256
|
+
env: {
|
|
257
|
+
...(input.env ?? process.env)
|
|
258
|
+
},
|
|
259
|
+
logPath: processFiles.logPath,
|
|
260
|
+
cwd: process.cwd()
|
|
261
|
+
});
|
|
262
|
+
if (!Number.isInteger(daemon.pid) || daemon.pid <= 0) {
|
|
263
|
+
throw new Error("Failed to spawn detached bridge server process.");
|
|
264
|
+
}
|
|
265
|
+
const readyState = await waitForServerReady({
|
|
266
|
+
config: preparedConfig,
|
|
267
|
+
pid: daemon.pid,
|
|
268
|
+
statePath: processFiles.statePath,
|
|
269
|
+
fetchImpl: input.fetchImpl
|
|
270
|
+
});
|
|
271
|
+
const statusMessage = readyState === null
|
|
272
|
+
? [
|
|
273
|
+
`Bridge server started in background (pid ${daemon.pid}).`,
|
|
274
|
+
`Logs: ${processFiles.logPath}`,
|
|
275
|
+
`Startup is still pending; check status with "openbridge status".`
|
|
276
|
+
].join("\n")
|
|
277
|
+
: [
|
|
278
|
+
`Bridge server started in background (pid ${readyState.pid}).`,
|
|
279
|
+
`Base URL: ${readyState.baseUrl}`,
|
|
280
|
+
`Logs: ${readyState.logPath}`
|
|
281
|
+
].join("\n");
|
|
282
|
+
stdout.write(`${statusMessage}\n`);
|
|
283
|
+
return 0;
|
|
107
284
|
}
|
|
108
285
|
const server = await startServer({
|
|
109
|
-
config:
|
|
286
|
+
config: preparedConfig
|
|
110
287
|
});
|
|
288
|
+
const processState = await writeRunningServerState(preparedConfig, server);
|
|
289
|
+
registerForegroundServerCleanup(server, processState);
|
|
111
290
|
input.onServerStarted?.(server);
|
|
112
291
|
const address = server.address();
|
|
113
292
|
if (typeof address === "object" && address) {
|
|
114
|
-
stdout.write(`Bridge server listening on http://${address.address}:${address.port}\n`);
|
|
293
|
+
stdout.write(`Bridge server listening on http://${formatHostForUrl(address.address)}:${address.port}\n`);
|
|
115
294
|
}
|
|
116
295
|
return 0;
|
|
117
296
|
}
|
|
@@ -133,11 +312,28 @@ function parseBridgeServerCliArgs(input) {
|
|
|
133
312
|
onStart(options) {
|
|
134
313
|
parsedCommand = {
|
|
135
314
|
kind: "serve",
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
315
|
+
foreground: options.foreground === true,
|
|
316
|
+
config: loadCliServerConfig(env, options)
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
onStatus(options) {
|
|
320
|
+
parsedCommand = {
|
|
321
|
+
kind: "status",
|
|
322
|
+
config: loadCliServerConfig(env, options)
|
|
323
|
+
};
|
|
324
|
+
},
|
|
325
|
+
onStop(options) {
|
|
326
|
+
parsedCommand = {
|
|
327
|
+
kind: "stop",
|
|
328
|
+
config: loadCliServerConfig(env, options)
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
onLogs(options) {
|
|
332
|
+
parsedCommand = {
|
|
333
|
+
kind: "logs",
|
|
334
|
+
config: loadCliServerConfig(env, options),
|
|
335
|
+
follow: options.follow === true,
|
|
336
|
+
lines: readPositiveInteger(options.lines, "lines", DEFAULT_LOG_TAIL_LINES)
|
|
141
337
|
};
|
|
142
338
|
},
|
|
143
339
|
onChat(options) {
|
|
@@ -178,6 +374,107 @@ function parseBridgeServerCliArgs(input) {
|
|
|
178
374
|
sessionVaultPath: optionalNonEmptyString(options.sessionVaultPath, "session-vault-path") ?? undefined
|
|
179
375
|
})
|
|
180
376
|
};
|
|
377
|
+
},
|
|
378
|
+
onProvidersList(options) {
|
|
379
|
+
parsedCommand = {
|
|
380
|
+
kind: "providers-list",
|
|
381
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env)
|
|
382
|
+
};
|
|
383
|
+
},
|
|
384
|
+
onProvidersGet(id, options) {
|
|
385
|
+
parsedCommand = {
|
|
386
|
+
kind: "providers-get",
|
|
387
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
388
|
+
id: requireNonEmptyString(id, "id")
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
onProvidersAdd(options) {
|
|
392
|
+
parsedCommand = {
|
|
393
|
+
kind: "providers-add",
|
|
394
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
395
|
+
id: requireNonEmptyString(options.id, "id"),
|
|
396
|
+
providerKind: requireNonEmptyString(options.kind, "kind"),
|
|
397
|
+
label: requireNonEmptyString(options.label, "label"),
|
|
398
|
+
enabled: options.disable !== true,
|
|
399
|
+
config: options.config ? parseJsonObject(options.config, "config") : undefined
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
onProvidersRemove(id, options) {
|
|
403
|
+
parsedCommand = {
|
|
404
|
+
kind: "providers-remove",
|
|
405
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
406
|
+
id: requireNonEmptyString(id, "id")
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
onProvidersEnable(id, options) {
|
|
410
|
+
parsedCommand = {
|
|
411
|
+
kind: "providers-enable",
|
|
412
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
413
|
+
id: requireNonEmptyString(id, "id")
|
|
414
|
+
};
|
|
415
|
+
},
|
|
416
|
+
onProvidersDisable(id, options) {
|
|
417
|
+
parsedCommand = {
|
|
418
|
+
kind: "providers-disable",
|
|
419
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
420
|
+
id: requireNonEmptyString(id, "id")
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
onProvidersImportSession(id, options) {
|
|
424
|
+
parsedCommand = {
|
|
425
|
+
kind: "providers-import-session",
|
|
426
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
427
|
+
id: requireNonEmptyString(id, "id"),
|
|
428
|
+
filePath: optionalNonEmptyString(options.file, "file") ?? undefined
|
|
429
|
+
};
|
|
430
|
+
},
|
|
431
|
+
onProvidersSessionStatus(id, options) {
|
|
432
|
+
parsedCommand = {
|
|
433
|
+
kind: "providers-session-status",
|
|
434
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
435
|
+
id: requireNonEmptyString(id, "id")
|
|
436
|
+
};
|
|
437
|
+
},
|
|
438
|
+
onProvidersClearSession(id, options) {
|
|
439
|
+
parsedCommand = {
|
|
440
|
+
kind: "providers-clear-session",
|
|
441
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
442
|
+
id: requireNonEmptyString(id, "id")
|
|
443
|
+
};
|
|
444
|
+
},
|
|
445
|
+
onModelsList(options) {
|
|
446
|
+
parsedCommand = {
|
|
447
|
+
kind: "models-list",
|
|
448
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env)
|
|
449
|
+
};
|
|
450
|
+
},
|
|
451
|
+
onModelsAdd(options) {
|
|
452
|
+
parsedCommand = {
|
|
453
|
+
kind: "models-add",
|
|
454
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
455
|
+
provider: requireNonEmptyString(options.provider, "provider"),
|
|
456
|
+
model: requireNonEmptyString(options.model, "model")
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
onSessionsList(options) {
|
|
460
|
+
parsedCommand = {
|
|
461
|
+
kind: "sessions-list",
|
|
462
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env)
|
|
463
|
+
};
|
|
464
|
+
},
|
|
465
|
+
onSessionsGet(id, options) {
|
|
466
|
+
parsedCommand = {
|
|
467
|
+
kind: "sessions-get",
|
|
468
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
469
|
+
id: requireNonEmptyString(id, "id")
|
|
470
|
+
};
|
|
471
|
+
},
|
|
472
|
+
onSessionsRemove(id, options) {
|
|
473
|
+
parsedCommand = {
|
|
474
|
+
kind: "sessions-remove",
|
|
475
|
+
baseUrl: resolveBaseUrl(options.baseUrl, env),
|
|
476
|
+
id: requireNonEmptyString(id, "id")
|
|
477
|
+
};
|
|
181
478
|
}
|
|
182
479
|
});
|
|
183
480
|
const normalizedArgv = args.length === 0 || args[0]?.startsWith("--") ? ["start", ...args] : args;
|
|
@@ -197,31 +494,6 @@ function parseBridgeServerCliArgs(input) {
|
|
|
197
494
|
function getBridgeServerCliHelpText() {
|
|
198
495
|
return buildBridgeServerCliProgram(process.env).helpInformation();
|
|
199
496
|
}
|
|
200
|
-
function optionalNonEmptyString(value, key) {
|
|
201
|
-
if (value === undefined) {
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
const trimmed = value.trim();
|
|
205
|
-
if (!trimmed) {
|
|
206
|
-
throw new Error(`${key} must be a non-empty string.`);
|
|
207
|
-
}
|
|
208
|
-
return trimmed;
|
|
209
|
-
}
|
|
210
|
-
function requireNonEmptyString(value, key) {
|
|
211
|
-
const normalized = optionalNonEmptyString(value, key);
|
|
212
|
-
if (!normalized) {
|
|
213
|
-
throw new Error(`${key} is required.`);
|
|
214
|
-
}
|
|
215
|
-
return normalized;
|
|
216
|
-
}
|
|
217
|
-
function formatCliError(error) {
|
|
218
|
-
if (error instanceof ZodError) {
|
|
219
|
-
return sanitizeSensitiveText(error.issues
|
|
220
|
-
.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
|
|
221
|
-
.join("; "));
|
|
222
|
-
}
|
|
223
|
-
return sanitizeSensitiveText(error instanceof Error ? error.message : String(error));
|
|
224
|
-
}
|
|
225
497
|
function buildBridgeServerCliProgram(env, handlers = {}) {
|
|
226
498
|
const program = new Command();
|
|
227
499
|
program
|
|
@@ -235,13 +507,48 @@ function buildBridgeServerCliProgram(env, handlers = {}) {
|
|
|
235
507
|
});
|
|
236
508
|
program
|
|
237
509
|
.command("start")
|
|
238
|
-
.description("Start the standalone bridge HTTP server.")
|
|
510
|
+
.description("Start the standalone bridge HTTP server. Defaults to detached/background mode.")
|
|
239
511
|
.option("--host <host>", "Bind host", trimOrUndefined(env.BRIDGE_SERVER_HOST) ?? "127.0.0.1")
|
|
240
512
|
.option("--port <port>", "Bind port", trimOrUndefined(env.BRIDGE_SERVER_PORT))
|
|
513
|
+
.option("--state-root <path>", "State root for bridge artifacts")
|
|
514
|
+
.option("--runtime-root <path>", "Runtime root for workspace execution")
|
|
241
515
|
.option("--token <token>", "Optional local bridge auth token", trimOrUndefined(env.BRIDGE_AUTH_TOKEN))
|
|
516
|
+
.option("--foreground", "Keep the server attached to the current terminal")
|
|
242
517
|
.action((options) => {
|
|
243
518
|
handlers.onStart?.(options);
|
|
244
519
|
});
|
|
520
|
+
program
|
|
521
|
+
.command("status")
|
|
522
|
+
.description("Show the detached bridge server status.")
|
|
523
|
+
.option("--host <host>", "Expected bind host", trimOrUndefined(env.BRIDGE_SERVER_HOST))
|
|
524
|
+
.option("--port <port>", "Expected bind port", trimOrUndefined(env.BRIDGE_SERVER_PORT))
|
|
525
|
+
.option("--state-root <path>", "State root for bridge artifacts")
|
|
526
|
+
.option("--runtime-root <path>", "Runtime root for workspace execution")
|
|
527
|
+
.action((options) => {
|
|
528
|
+
handlers.onStatus?.(options);
|
|
529
|
+
});
|
|
530
|
+
program
|
|
531
|
+
.command("stop")
|
|
532
|
+
.description("Stop the detached bridge server.")
|
|
533
|
+
.option("--host <host>", "Expected bind host", trimOrUndefined(env.BRIDGE_SERVER_HOST))
|
|
534
|
+
.option("--port <port>", "Expected bind port", trimOrUndefined(env.BRIDGE_SERVER_PORT))
|
|
535
|
+
.option("--state-root <path>", "State root for bridge artifacts")
|
|
536
|
+
.option("--runtime-root <path>", "Runtime root for workspace execution")
|
|
537
|
+
.action((options) => {
|
|
538
|
+
handlers.onStop?.(options);
|
|
539
|
+
});
|
|
540
|
+
program
|
|
541
|
+
.command("logs")
|
|
542
|
+
.description("Print bridge server logs.")
|
|
543
|
+
.option("--host <host>", "Expected bind host", trimOrUndefined(env.BRIDGE_SERVER_HOST))
|
|
544
|
+
.option("--port <port>", "Expected bind port", trimOrUndefined(env.BRIDGE_SERVER_PORT))
|
|
545
|
+
.option("--state-root <path>", "State root for bridge artifacts")
|
|
546
|
+
.option("--runtime-root <path>", "Runtime root for workspace execution")
|
|
547
|
+
.option("--follow", "Follow log output")
|
|
548
|
+
.option("--lines <count>", "Number of trailing log lines to print", String(DEFAULT_LOG_TAIL_LINES))
|
|
549
|
+
.action((options) => {
|
|
550
|
+
handlers.onLogs?.(options);
|
|
551
|
+
});
|
|
245
552
|
program
|
|
246
553
|
.command("chat")
|
|
247
554
|
.description("Send a chat completion request through the standalone bridge HTTP API.")
|
|
@@ -283,8 +590,222 @@ function buildBridgeServerCliProgram(env, handlers = {}) {
|
|
|
283
590
|
: undefined
|
|
284
591
|
});
|
|
285
592
|
});
|
|
593
|
+
const providersCommand = program
|
|
594
|
+
.command("providers")
|
|
595
|
+
.description("Manage installed providers and provider session packages.");
|
|
596
|
+
providersCommand
|
|
597
|
+
.command("list")
|
|
598
|
+
.description("List providers.")
|
|
599
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
600
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
601
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
602
|
+
.action((options) => {
|
|
603
|
+
handlers.onProvidersList?.(options);
|
|
604
|
+
});
|
|
605
|
+
providersCommand
|
|
606
|
+
.command("get <id>")
|
|
607
|
+
.description("Get a provider by id.")
|
|
608
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
609
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
610
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
611
|
+
.action((id, options) => {
|
|
612
|
+
handlers.onProvidersGet?.(id, options);
|
|
613
|
+
});
|
|
614
|
+
providersCommand
|
|
615
|
+
.command("add")
|
|
616
|
+
.description("Create a provider.")
|
|
617
|
+
.requiredOption("--id <id>", "Provider id")
|
|
618
|
+
.requiredOption("--kind <kind>", "Provider kind")
|
|
619
|
+
.requiredOption("--label <label>", "Provider label")
|
|
620
|
+
.option("--disable", "Create the provider in a disabled state")
|
|
621
|
+
.option("--config <json>", "Provider config JSON object")
|
|
622
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
623
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
624
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
625
|
+
.action((options) => {
|
|
626
|
+
handlers.onProvidersAdd?.(options);
|
|
627
|
+
});
|
|
628
|
+
providersCommand
|
|
629
|
+
.command("remove <id>")
|
|
630
|
+
.description("Delete a provider.")
|
|
631
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
632
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
633
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
634
|
+
.action((id, options) => {
|
|
635
|
+
handlers.onProvidersRemove?.(id, options);
|
|
636
|
+
});
|
|
637
|
+
providersCommand
|
|
638
|
+
.command("enable <id>")
|
|
639
|
+
.description("Enable a provider.")
|
|
640
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
641
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
642
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
643
|
+
.action((id, options) => {
|
|
644
|
+
handlers.onProvidersEnable?.(id, options);
|
|
645
|
+
});
|
|
646
|
+
providersCommand
|
|
647
|
+
.command("disable <id>")
|
|
648
|
+
.description("Disable a provider.")
|
|
649
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
650
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
651
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
652
|
+
.action((id, options) => {
|
|
653
|
+
handlers.onProvidersDisable?.(id, options);
|
|
654
|
+
});
|
|
655
|
+
providersCommand
|
|
656
|
+
.command("import-session <id>")
|
|
657
|
+
.description("Install or replace a provider session package from stdin or a JSON file.")
|
|
658
|
+
.option("--file <path>", "Read the session package from a JSON file")
|
|
659
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
660
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
661
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
662
|
+
.action((id, options) => {
|
|
663
|
+
handlers.onProvidersImportSession?.(id, options);
|
|
664
|
+
});
|
|
665
|
+
providersCommand
|
|
666
|
+
.command("session-status <id>")
|
|
667
|
+
.description("Show the safe session-package status for a provider.")
|
|
668
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
669
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
670
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
671
|
+
.action((id, options) => {
|
|
672
|
+
handlers.onProvidersSessionStatus?.(id, options);
|
|
673
|
+
});
|
|
674
|
+
providersCommand
|
|
675
|
+
.command("clear-session <id>")
|
|
676
|
+
.description("Delete a provider session package.")
|
|
677
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
678
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
679
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
680
|
+
.action((id, options) => {
|
|
681
|
+
handlers.onProvidersClearSession?.(id, options);
|
|
682
|
+
});
|
|
683
|
+
const modelsCommand = program.command("models").description("Manage exposed bridge models.");
|
|
684
|
+
modelsCommand
|
|
685
|
+
.command("list")
|
|
686
|
+
.description("List models.")
|
|
687
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
688
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
689
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
690
|
+
.action((options) => {
|
|
691
|
+
handlers.onModelsList?.(options);
|
|
692
|
+
});
|
|
693
|
+
modelsCommand
|
|
694
|
+
.command("add")
|
|
695
|
+
.description("Add a model id to an existing provider.")
|
|
696
|
+
.requiredOption("--provider <id>", "Provider id")
|
|
697
|
+
.requiredOption("--model <id>", "Model id")
|
|
698
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
699
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
700
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
701
|
+
.action((options) => {
|
|
702
|
+
handlers.onModelsAdd?.(options);
|
|
703
|
+
});
|
|
704
|
+
const sessionsCommand = program.command("sessions").description("Manage bridge sessions.");
|
|
705
|
+
sessionsCommand
|
|
706
|
+
.command("list")
|
|
707
|
+
.description("List sessions.")
|
|
708
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
709
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
710
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
711
|
+
.action((options) => {
|
|
712
|
+
handlers.onSessionsList?.(options);
|
|
713
|
+
});
|
|
714
|
+
sessionsCommand
|
|
715
|
+
.command("get <id>")
|
|
716
|
+
.description("Get a session by id.")
|
|
717
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
718
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
719
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
720
|
+
.action((id, options) => {
|
|
721
|
+
handlers.onSessionsGet?.(id, options);
|
|
722
|
+
});
|
|
723
|
+
sessionsCommand
|
|
724
|
+
.command("remove <id>")
|
|
725
|
+
.description("Delete a session.")
|
|
726
|
+
.option("--base-url <url>", "Bridge base URL", trimOrUndefined(env.BRIDGE_API_BASE_URL) ??
|
|
727
|
+
trimOrUndefined(env.BRIDGE_SERVER_BASE_URL) ??
|
|
728
|
+
DEFAULT_BRIDGE_API_BASE_URL)
|
|
729
|
+
.action((id, options) => {
|
|
730
|
+
handlers.onSessionsRemove?.(id, options);
|
|
731
|
+
});
|
|
286
732
|
return program;
|
|
287
733
|
}
|
|
734
|
+
function loadCliServerConfig(env, options) {
|
|
735
|
+
return loadBridgeServerConfig(env, {
|
|
736
|
+
host: optionalNonEmptyString(options.host, "host") ?? undefined,
|
|
737
|
+
port: options.port,
|
|
738
|
+
authToken: "token" in options
|
|
739
|
+
? (optionalNonEmptyString(options.token, "token") ?? undefined)
|
|
740
|
+
: undefined,
|
|
741
|
+
stateRoot: optionalNonEmptyString(options.stateRoot, "state-root") ?? undefined,
|
|
742
|
+
runtimeRoot: optionalNonEmptyString(options.runtimeRoot, "runtime-root") ?? undefined
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
function resolveBaseUrl(value, env) {
|
|
746
|
+
return (optionalNonEmptyString(value, "base-url") ??
|
|
747
|
+
optionalNonEmptyString(env.BRIDGE_API_BASE_URL, "BRIDGE_API_BASE_URL") ??
|
|
748
|
+
optionalNonEmptyString(env.BRIDGE_SERVER_BASE_URL, "BRIDGE_SERVER_BASE_URL") ??
|
|
749
|
+
DEFAULT_BRIDGE_API_BASE_URL);
|
|
750
|
+
}
|
|
751
|
+
function optionalNonEmptyString(value, key) {
|
|
752
|
+
if (value === undefined) {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
const trimmed = value.trim();
|
|
756
|
+
if (!trimmed) {
|
|
757
|
+
throw new Error(`${key} must be a non-empty string.`);
|
|
758
|
+
}
|
|
759
|
+
return trimmed;
|
|
760
|
+
}
|
|
761
|
+
function requireNonEmptyString(value, key) {
|
|
762
|
+
const normalized = optionalNonEmptyString(value, key);
|
|
763
|
+
if (!normalized) {
|
|
764
|
+
throw new Error(`${key} is required.`);
|
|
765
|
+
}
|
|
766
|
+
return normalized;
|
|
767
|
+
}
|
|
768
|
+
function readPositiveInteger(value, key, fallback) {
|
|
769
|
+
if (value === undefined) {
|
|
770
|
+
return fallback;
|
|
771
|
+
}
|
|
772
|
+
const normalized = optionalNonEmptyString(value, key);
|
|
773
|
+
if (!normalized) {
|
|
774
|
+
return fallback;
|
|
775
|
+
}
|
|
776
|
+
if (!/^\d+$/.test(normalized)) {
|
|
777
|
+
throw new Error(`${key} must be a positive integer.`);
|
|
778
|
+
}
|
|
779
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
780
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
781
|
+
throw new Error(`${key} must be a positive integer.`);
|
|
782
|
+
}
|
|
783
|
+
return parsed;
|
|
784
|
+
}
|
|
785
|
+
function parseJsonObject(raw, key) {
|
|
786
|
+
let parsed;
|
|
787
|
+
try {
|
|
788
|
+
parsed = JSON.parse(raw);
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
throw new Error(`${key} must be valid JSON.`);
|
|
792
|
+
}
|
|
793
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
794
|
+
throw new Error(`${key} must be a JSON object.`);
|
|
795
|
+
}
|
|
796
|
+
return parsed;
|
|
797
|
+
}
|
|
798
|
+
function writeJson(stream, value) {
|
|
799
|
+
stream.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
800
|
+
}
|
|
801
|
+
function formatCliError(error) {
|
|
802
|
+
if (error instanceof ZodError) {
|
|
803
|
+
return sanitizeSensitiveText(error.issues
|
|
804
|
+
.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
|
|
805
|
+
.join("; "));
|
|
806
|
+
}
|
|
807
|
+
return sanitizeSensitiveText(error instanceof Error ? error.message : String(error));
|
|
808
|
+
}
|
|
288
809
|
function normalizeCommanderError(error) {
|
|
289
810
|
if (error instanceof Error && "code" in error) {
|
|
290
811
|
const code = String(error.code);
|
|
@@ -346,6 +867,389 @@ async function defaultPromptForVaultKey(input) {
|
|
|
346
867
|
readline.close();
|
|
347
868
|
}
|
|
348
869
|
}
|
|
870
|
+
async function readSessionPackageInput(input) {
|
|
871
|
+
const raw = input.filePath
|
|
872
|
+
? await readFile(path.resolve(input.filePath), "utf8")
|
|
873
|
+
: await readSessionPackageFromStdin(input.stdin, input.stderr);
|
|
874
|
+
return parseJsonObject(raw, "session package");
|
|
875
|
+
}
|
|
876
|
+
async function readSessionPackageFromStdin(stdin, stderr) {
|
|
877
|
+
if (stdin.isTTY) {
|
|
878
|
+
stderr.write("Paste the session package JSON, then press Ctrl-D to submit.\n");
|
|
879
|
+
}
|
|
880
|
+
return await readStreamToString(stdin);
|
|
881
|
+
}
|
|
882
|
+
function readStreamToString(stream) {
|
|
883
|
+
return new Promise((resolve, reject) => {
|
|
884
|
+
let data = "";
|
|
885
|
+
stream.setEncoding("utf8");
|
|
886
|
+
stream.on("data", (chunk) => {
|
|
887
|
+
data += chunk;
|
|
888
|
+
});
|
|
889
|
+
stream.once("end", () => resolve(data));
|
|
890
|
+
stream.once("error", reject);
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
function getServerProcessFiles(config) {
|
|
894
|
+
const runRoot = path.join(config.stateRoot, "run");
|
|
895
|
+
const logRoot = path.join(config.stateRoot, "logs");
|
|
896
|
+
return {
|
|
897
|
+
statePath: path.join(runRoot, "server-process.json"),
|
|
898
|
+
runRoot,
|
|
899
|
+
logPath: path.join(logRoot, "server.log"),
|
|
900
|
+
logRoot
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
async function ensureServerProcessDirectories(files) {
|
|
904
|
+
await mkdir(files.runRoot, {
|
|
905
|
+
recursive: true,
|
|
906
|
+
mode: 0o700
|
|
907
|
+
});
|
|
908
|
+
await mkdir(files.logRoot, {
|
|
909
|
+
recursive: true,
|
|
910
|
+
mode: 0o700
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
async function writeRunningServerState(config, server) {
|
|
914
|
+
const files = getServerProcessFiles(config);
|
|
915
|
+
await ensureServerProcessDirectories(files);
|
|
916
|
+
const address = server.address();
|
|
917
|
+
if (!address || typeof address === "string") {
|
|
918
|
+
throw new Error("Bridge server did not expose a network address.");
|
|
919
|
+
}
|
|
920
|
+
const state = {
|
|
921
|
+
pid: process.pid,
|
|
922
|
+
baseUrl: `http://${formatHostForUrl(address.address)}:${address.port}`,
|
|
923
|
+
host: address.address,
|
|
924
|
+
port: address.port,
|
|
925
|
+
logPath: files.logPath,
|
|
926
|
+
startedAt: new Date().toISOString(),
|
|
927
|
+
stateRoot: config.stateRoot
|
|
928
|
+
};
|
|
929
|
+
await writeServerProcessState(files.statePath, state);
|
|
930
|
+
return state;
|
|
931
|
+
}
|
|
932
|
+
async function writeServerProcessState(statePath, state) {
|
|
933
|
+
await mkdir(path.dirname(statePath), {
|
|
934
|
+
recursive: true,
|
|
935
|
+
mode: 0o700
|
|
936
|
+
});
|
|
937
|
+
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, {
|
|
938
|
+
encoding: "utf8",
|
|
939
|
+
mode: 0o600
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
async function readServerProcessState(statePath) {
|
|
943
|
+
const raw = await readOptionalFile(statePath);
|
|
944
|
+
if (!raw) {
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
let parsed;
|
|
948
|
+
try {
|
|
949
|
+
parsed = JSON.parse(raw);
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
const value = parsed;
|
|
958
|
+
if (!Number.isInteger(value.pid) ||
|
|
959
|
+
typeof value.baseUrl !== "string" ||
|
|
960
|
+
typeof value.host !== "string" ||
|
|
961
|
+
!Number.isInteger(value.port) ||
|
|
962
|
+
typeof value.logPath !== "string" ||
|
|
963
|
+
typeof value.startedAt !== "string" ||
|
|
964
|
+
typeof value.stateRoot !== "string") {
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
return value;
|
|
968
|
+
}
|
|
969
|
+
async function deleteServerProcessState(statePath) {
|
|
970
|
+
try {
|
|
971
|
+
await rm(statePath, {
|
|
972
|
+
force: true
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
catch (error) {
|
|
976
|
+
if (error.code !== "ENOENT") {
|
|
977
|
+
throw error;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function registerForegroundServerCleanup(server, state) {
|
|
982
|
+
if (typeof server.once !== "function" || typeof server.close !== "function") {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
let shuttingDown = false;
|
|
986
|
+
const statePath = path.join(state.stateRoot, "run", "server-process.json");
|
|
987
|
+
const cleanup = () => {
|
|
988
|
+
void deleteServerProcessState(statePath);
|
|
989
|
+
};
|
|
990
|
+
server.once("close", cleanup);
|
|
991
|
+
const handleSignal = (signal) => {
|
|
992
|
+
if (shuttingDown) {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
shuttingDown = true;
|
|
996
|
+
void closeServer(server)
|
|
997
|
+
.catch(() => { })
|
|
998
|
+
.finally(() => {
|
|
999
|
+
cleanup();
|
|
1000
|
+
process.exit(signal === "SIGINT" ? 130 : 0);
|
|
1001
|
+
});
|
|
1002
|
+
};
|
|
1003
|
+
process.once("SIGINT", () => {
|
|
1004
|
+
handleSignal("SIGINT");
|
|
1005
|
+
});
|
|
1006
|
+
process.once("SIGTERM", () => {
|
|
1007
|
+
handleSignal("SIGTERM");
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
async function getServerStatus(config, fetchImpl) {
|
|
1011
|
+
const files = getServerProcessFiles(config);
|
|
1012
|
+
const state = await readServerProcessState(files.statePath);
|
|
1013
|
+
const pid = state?.pid ?? null;
|
|
1014
|
+
const baseUrl = state?.baseUrl ?? `http://${formatHostForUrl(config.host)}:${config.port}`;
|
|
1015
|
+
const logPath = state?.logPath ?? files.logPath;
|
|
1016
|
+
const running = state ? isProcessRunning(state.pid) : false;
|
|
1017
|
+
let healthy = null;
|
|
1018
|
+
if (running) {
|
|
1019
|
+
try {
|
|
1020
|
+
await checkBridgeHealth({
|
|
1021
|
+
baseUrl,
|
|
1022
|
+
fetchImpl
|
|
1023
|
+
});
|
|
1024
|
+
healthy = true;
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
healthy = false;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
else if (state) {
|
|
1031
|
+
await deleteServerProcessState(files.statePath);
|
|
1032
|
+
}
|
|
1033
|
+
return {
|
|
1034
|
+
running,
|
|
1035
|
+
healthy,
|
|
1036
|
+
pid,
|
|
1037
|
+
baseUrl,
|
|
1038
|
+
logPath,
|
|
1039
|
+
startedAt: state?.startedAt ?? null
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
async function stopServer(config) {
|
|
1043
|
+
const files = getServerProcessFiles(config);
|
|
1044
|
+
const state = await readServerProcessState(files.statePath);
|
|
1045
|
+
if (!state || !isProcessRunning(state.pid)) {
|
|
1046
|
+
await deleteServerProcessState(files.statePath);
|
|
1047
|
+
return {
|
|
1048
|
+
stopped: false,
|
|
1049
|
+
message: "Bridge server is not running."
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
process.kill(state.pid, "SIGTERM");
|
|
1053
|
+
const terminated = await waitForProcessExit(state.pid, STOP_TIMEOUT_MS);
|
|
1054
|
+
if (!terminated) {
|
|
1055
|
+
process.kill(state.pid, "SIGKILL");
|
|
1056
|
+
const killed = await waitForProcessExit(state.pid, 1_000);
|
|
1057
|
+
if (!killed) {
|
|
1058
|
+
throw new Error(`Failed to stop bridge server pid ${state.pid}.`);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
await deleteServerProcessState(files.statePath);
|
|
1062
|
+
return {
|
|
1063
|
+
stopped: true,
|
|
1064
|
+
message: `Stopped bridge server pid ${state.pid}.`
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
async function printServerLogs(config, input) {
|
|
1068
|
+
const files = getServerProcessFiles(config);
|
|
1069
|
+
const content = await readOptionalRawFile(files.logPath);
|
|
1070
|
+
if (content) {
|
|
1071
|
+
const tail = tailLines(content, input.lines);
|
|
1072
|
+
if (tail) {
|
|
1073
|
+
input.stdout.write(tail);
|
|
1074
|
+
if (!tail.endsWith("\n")) {
|
|
1075
|
+
input.stdout.write("\n");
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
else if (!input.follow) {
|
|
1080
|
+
throw new Error(`Bridge server log file was not found at ${files.logPath}.`);
|
|
1081
|
+
}
|
|
1082
|
+
if (!input.follow) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
let position = await getFileSize(files.logPath);
|
|
1086
|
+
const stopSignal = createStopSignal();
|
|
1087
|
+
while (!stopSignal.stopped) {
|
|
1088
|
+
await delay(LOG_FOLLOW_POLL_INTERVAL_MS);
|
|
1089
|
+
const size = await getFileSize(files.logPath);
|
|
1090
|
+
if (size < position) {
|
|
1091
|
+
position = 0;
|
|
1092
|
+
}
|
|
1093
|
+
if (size === position) {
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
const chunk = await readFileSlice(files.logPath, position, size - position);
|
|
1097
|
+
if (chunk.length > 0) {
|
|
1098
|
+
input.stdout.write(chunk);
|
|
1099
|
+
}
|
|
1100
|
+
position = size;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
function createStopSignal() {
|
|
1104
|
+
const signal = {
|
|
1105
|
+
stopped: false
|
|
1106
|
+
};
|
|
1107
|
+
const stop = () => {
|
|
1108
|
+
signal.stopped = true;
|
|
1109
|
+
};
|
|
1110
|
+
process.once("SIGINT", stop);
|
|
1111
|
+
process.once("SIGTERM", stop);
|
|
1112
|
+
return signal;
|
|
1113
|
+
}
|
|
1114
|
+
function tailLines(text, lineCount) {
|
|
1115
|
+
if (!text) {
|
|
1116
|
+
return "";
|
|
1117
|
+
}
|
|
1118
|
+
const normalized = text.split(/\r?\n/);
|
|
1119
|
+
const hasTrailingNewline = text.endsWith("\n");
|
|
1120
|
+
const lines = hasTrailingNewline ? normalized.slice(0, -1) : normalized;
|
|
1121
|
+
const tail = lines.slice(-lineCount).join("\n");
|
|
1122
|
+
return tail.length === 0 ? "" : `${tail}${hasTrailingNewline ? "\n" : ""}`;
|
|
1123
|
+
}
|
|
1124
|
+
async function readFileSlice(filePath, offset, length) {
|
|
1125
|
+
const handle = await open(filePath, "r");
|
|
1126
|
+
try {
|
|
1127
|
+
const buffer = Buffer.alloc(length);
|
|
1128
|
+
const result = await handle.read(buffer, 0, length, offset);
|
|
1129
|
+
return buffer.subarray(0, result.bytesRead).toString("utf8");
|
|
1130
|
+
}
|
|
1131
|
+
finally {
|
|
1132
|
+
await handle.close();
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
async function getFileSize(filePath) {
|
|
1136
|
+
try {
|
|
1137
|
+
const info = await stat(filePath);
|
|
1138
|
+
return info.size;
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
if (error.code === "ENOENT") {
|
|
1142
|
+
return 0;
|
|
1143
|
+
}
|
|
1144
|
+
throw error;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
async function waitForServerReady(input) {
|
|
1148
|
+
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
|
|
1149
|
+
while (Date.now() < deadline) {
|
|
1150
|
+
const state = await readServerProcessState(input.statePath);
|
|
1151
|
+
if (state && state.pid === input.pid) {
|
|
1152
|
+
try {
|
|
1153
|
+
await checkBridgeHealth({
|
|
1154
|
+
baseUrl: state.baseUrl,
|
|
1155
|
+
fetchImpl: input.fetchImpl
|
|
1156
|
+
});
|
|
1157
|
+
return state;
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
// Ignore until timeout or process death.
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if (!isProcessRunning(input.pid)) {
|
|
1164
|
+
throw new Error(`Bridge server exited before becoming ready. Check logs at ${getServerProcessFiles(input.config).logPath}.`);
|
|
1165
|
+
}
|
|
1166
|
+
await delay(DAEMON_POLL_INTERVAL_MS);
|
|
1167
|
+
}
|
|
1168
|
+
return null;
|
|
1169
|
+
}
|
|
1170
|
+
async function defaultSpawnDetachedServerProcess(input) {
|
|
1171
|
+
const scriptPath = process.argv[1];
|
|
1172
|
+
if (!scriptPath) {
|
|
1173
|
+
throw new Error("Cannot determine the openbridge CLI entrypoint for detached start.");
|
|
1174
|
+
}
|
|
1175
|
+
await mkdir(path.dirname(input.logPath), {
|
|
1176
|
+
recursive: true,
|
|
1177
|
+
mode: 0o700
|
|
1178
|
+
});
|
|
1179
|
+
const logHandle = await open(input.logPath, "a", 0o600);
|
|
1180
|
+
try {
|
|
1181
|
+
const child = spawn(process.execPath, [scriptPath, ...input.argv], {
|
|
1182
|
+
cwd: input.cwd,
|
|
1183
|
+
env: input.env,
|
|
1184
|
+
detached: true,
|
|
1185
|
+
stdio: ["ignore", logHandle.fd, logHandle.fd]
|
|
1186
|
+
});
|
|
1187
|
+
child.unref();
|
|
1188
|
+
if (!child.pid) {
|
|
1189
|
+
throw new Error("Detached bridge server process did not expose a pid.");
|
|
1190
|
+
}
|
|
1191
|
+
return {
|
|
1192
|
+
pid: child.pid
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
finally {
|
|
1196
|
+
await logHandle.close();
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
function toForegroundStartArgv(argv) {
|
|
1200
|
+
const normalized = argv.length === 0 || argv[0]?.startsWith("--") ? ["start", ...argv] : [...argv];
|
|
1201
|
+
if (normalized[0] !== "start") {
|
|
1202
|
+
throw new Error("Detached launch is only supported for the start command.");
|
|
1203
|
+
}
|
|
1204
|
+
if (!normalized.includes("--foreground")) {
|
|
1205
|
+
normalized.push("--foreground");
|
|
1206
|
+
}
|
|
1207
|
+
return normalized;
|
|
1208
|
+
}
|
|
1209
|
+
function isProcessRunning(pid) {
|
|
1210
|
+
try {
|
|
1211
|
+
process.kill(pid, 0);
|
|
1212
|
+
return true;
|
|
1213
|
+
}
|
|
1214
|
+
catch (error) {
|
|
1215
|
+
if (error.code === "ESRCH") {
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
if (error.code === "EPERM") {
|
|
1219
|
+
return true;
|
|
1220
|
+
}
|
|
1221
|
+
throw error;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
1225
|
+
const deadline = Date.now() + timeoutMs;
|
|
1226
|
+
while (Date.now() < deadline) {
|
|
1227
|
+
if (!isProcessRunning(pid)) {
|
|
1228
|
+
return true;
|
|
1229
|
+
}
|
|
1230
|
+
await delay(100);
|
|
1231
|
+
}
|
|
1232
|
+
return !isProcessRunning(pid);
|
|
1233
|
+
}
|
|
1234
|
+
function delay(ms) {
|
|
1235
|
+
return new Promise((resolve) => {
|
|
1236
|
+
setTimeout(resolve, ms);
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
function formatHostForUrl(host) {
|
|
1240
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
1241
|
+
}
|
|
1242
|
+
function closeServer(server) {
|
|
1243
|
+
return new Promise((resolve, reject) => {
|
|
1244
|
+
server.close((error) => {
|
|
1245
|
+
if (error) {
|
|
1246
|
+
reject(error);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
resolve();
|
|
1250
|
+
});
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
349
1253
|
async function readOptionalFile(targetPath) {
|
|
350
1254
|
try {
|
|
351
1255
|
const value = (await readFile(targetPath, "utf8")).trim();
|
|
@@ -358,6 +1262,17 @@ async function readOptionalFile(targetPath) {
|
|
|
358
1262
|
throw error;
|
|
359
1263
|
}
|
|
360
1264
|
}
|
|
1265
|
+
async function readOptionalRawFile(targetPath) {
|
|
1266
|
+
try {
|
|
1267
|
+
return await readFile(targetPath, "utf8");
|
|
1268
|
+
}
|
|
1269
|
+
catch (error) {
|
|
1270
|
+
if (error.code === "ENOENT") {
|
|
1271
|
+
return null;
|
|
1272
|
+
}
|
|
1273
|
+
throw error;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
361
1276
|
function requireConfigPath(value, key) {
|
|
362
1277
|
if (!value) {
|
|
363
1278
|
throw new Error(`${key} is required.`);
|