@khanglvm/llm-router 1.0.8 → 1.0.9

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.
@@ -0,0 +1,324 @@
1
+ export function hasNoDeployTargets(outputText = "") {
2
+ return /no deploy targets/i.test(String(outputText || ""));
3
+ }
4
+
5
+ export function parseTomlStringField(text, key) {
6
+ const pattern = new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']+)["']\\s*$`, "m");
7
+ const match = String(text || "").match(pattern);
8
+ return match?.[1] ? String(match[1]).trim() : "";
9
+ }
10
+
11
+ function topLevelTomlLineInfo(text = "") {
12
+ const lines = String(text || "").split(/\r?\n/g);
13
+ const info = [];
14
+ let currentSection = "";
15
+
16
+ for (let index = 0; index < lines.length; index += 1) {
17
+ const line = lines[index];
18
+ const trimmed = line.trim();
19
+ if (/^\s*\[.*\]\s*$/.test(line)) {
20
+ currentSection = trimmed;
21
+ }
22
+ info.push({
23
+ index,
24
+ line,
25
+ trimmed,
26
+ section: currentSection
27
+ });
28
+ }
29
+
30
+ return info;
31
+ }
32
+
33
+ export function hasWranglerDeployTargetConfigured(tomlText = "") {
34
+ const info = topLevelTomlLineInfo(tomlText);
35
+
36
+ const hasTopLevelWorkersDev = info.some((entry) =>
37
+ entry.section === "" && /^\s*workers_dev\s*=\s*true\s*$/i.test(entry.line)
38
+ );
39
+ if (hasTopLevelWorkersDev) return true;
40
+
41
+ const hasTopLevelRoute = info.some((entry) =>
42
+ entry.section === "" && /^\s*route\s*=\s*["'][^"']+["']\s*$/i.test(entry.line)
43
+ );
44
+ if (hasTopLevelRoute) return true;
45
+
46
+ const hasTopLevelRoutes = info.some((entry) =>
47
+ entry.section === "" && /^\s*routes\s*=\s*\[/i.test(entry.line)
48
+ );
49
+ if (hasTopLevelRoutes) return true;
50
+
51
+ return false;
52
+ }
53
+
54
+ function stripNonTopLevelRouteDeclarations(text = "") {
55
+ const lines = String(text || "").split(/\r?\n/g);
56
+ const output = [];
57
+ let currentSection = "";
58
+ let skippingRoutesArray = false;
59
+
60
+ for (const line of lines) {
61
+ const trimmed = line.trim();
62
+
63
+ if (/^\s*\[.*\]\s*$/.test(line)) {
64
+ currentSection = trimmed;
65
+ skippingRoutesArray = false;
66
+ output.push(line);
67
+ continue;
68
+ }
69
+
70
+ if (currentSection && /^\s*route\s*=/.test(line)) {
71
+ continue;
72
+ }
73
+
74
+ if (currentSection && /^\s*routes\s*=\s*\[/.test(line)) {
75
+ skippingRoutesArray = true;
76
+ if (line.includes("]")) {
77
+ skippingRoutesArray = false;
78
+ }
79
+ continue;
80
+ }
81
+
82
+ if (skippingRoutesArray) {
83
+ if (trimmed.includes("]")) {
84
+ skippingRoutesArray = false;
85
+ }
86
+ continue;
87
+ }
88
+
89
+ output.push(line);
90
+ }
91
+
92
+ return output.join("\n");
93
+ }
94
+
95
+ function insertTopLevelBlockBeforeFirstSection(text = "", block = "") {
96
+ const source = String(text || "");
97
+ const blockText = String(block || "").trim();
98
+ if (!blockText) return source;
99
+
100
+ const lines = source.split(/\r?\n/g);
101
+ const firstSectionIndex = lines.findIndex((line) => /^\s*\[.*\]\s*$/.test(line));
102
+ if (firstSectionIndex < 0) {
103
+ const prefix = source.trimEnd();
104
+ return `${prefix}${prefix ? "\n" : ""}${blockText}\n`;
105
+ }
106
+
107
+ const before = lines.slice(0, firstSectionIndex).join("\n").trimEnd();
108
+ const after = lines.slice(firstSectionIndex).join("\n").trimStart();
109
+ return `${before}${before ? "\n" : ""}${blockText}\n\n${after}\n`;
110
+ }
111
+
112
+ function upsertTomlBooleanField(text, key, value) {
113
+ const normalized = String(text || "");
114
+ const replacement = `${key} = ${value ? "true" : "false"}`;
115
+ if (new RegExp(`^\\s*${key}\\s*=`, "m").test(normalized)) {
116
+ return normalized.replace(new RegExp(`^\\s*${key}\\s*=.*$`, "m"), replacement);
117
+ }
118
+ return `${normalized.trimEnd()}\n${replacement}\n`;
119
+ }
120
+
121
+ function stripTopLevelRouteDeclarations(text = "") {
122
+ const lines = String(text || "").split(/\r?\n/g);
123
+ const output = [];
124
+ let currentSection = "";
125
+ let skippingRoutesArray = false;
126
+
127
+ for (const line of lines) {
128
+ const trimmed = line.trim();
129
+
130
+ if (/^\s*\[.*\]\s*$/.test(line)) {
131
+ currentSection = trimmed;
132
+ skippingRoutesArray = false;
133
+ output.push(line);
134
+ continue;
135
+ }
136
+
137
+ if (!currentSection && /^\s*route\s*=/.test(line)) {
138
+ continue;
139
+ }
140
+
141
+ if (!currentSection && /^\s*routes\s*=\s*\[/.test(line)) {
142
+ skippingRoutesArray = true;
143
+ if (line.includes("]")) {
144
+ skippingRoutesArray = false;
145
+ }
146
+ continue;
147
+ }
148
+
149
+ if (skippingRoutesArray) {
150
+ if (trimmed.includes("]")) {
151
+ skippingRoutesArray = false;
152
+ }
153
+ continue;
154
+ }
155
+
156
+ output.push(line);
157
+ }
158
+
159
+ return output.join("\n");
160
+ }
161
+
162
+ export function normalizeWranglerRoutePattern(value) {
163
+ const raw = String(value || "").trim();
164
+ if (!raw) return "";
165
+
166
+ let candidate = raw;
167
+ if (/^https?:\/\//i.test(candidate)) {
168
+ try {
169
+ const parsed = new URL(candidate);
170
+ candidate = `${parsed.hostname}${parsed.pathname || "/"}`;
171
+ } catch {
172
+ return "";
173
+ }
174
+ }
175
+
176
+ if (candidate.startsWith("/")) return "";
177
+ if (!candidate.includes("*")) {
178
+ if (candidate.endsWith("/")) candidate = `${candidate}*`;
179
+ else if (!candidate.includes("/")) candidate = `${candidate}/*`;
180
+ }
181
+
182
+ return candidate;
183
+ }
184
+
185
+ export function buildDefaultWranglerTomlForDeploy({
186
+ name = "llm-router-route",
187
+ main = "src/index.js",
188
+ compatibilityDate = "2024-01-01",
189
+ useWorkersDev = false,
190
+ routePattern = "",
191
+ zoneName = ""
192
+ } = {}) {
193
+ const lines = [
194
+ `name = "${String(name || "llm-router-route")}"`,
195
+ `main = "${String(main || "src/index.js")}"`,
196
+ `compatibility_date = "${String(compatibilityDate || "2024-01-01")}"`,
197
+ `workers_dev = ${useWorkersDev ? "true" : "false"}`
198
+ ];
199
+
200
+ const normalizedPattern = normalizeWranglerRoutePattern(routePattern);
201
+ const normalizedZone = String(zoneName || "").trim();
202
+ if (!useWorkersDev && normalizedPattern && normalizedZone) {
203
+ lines.push("routes = [");
204
+ lines.push(` { pattern = "${normalizedPattern}", zone_name = "${normalizedZone}" }`);
205
+ lines.push("]");
206
+ }
207
+
208
+ lines.push("preview_urls = false");
209
+ lines.push("");
210
+ lines.push("[vars]");
211
+ lines.push('ENVIRONMENT = "production"');
212
+ lines.push("");
213
+ return `${lines.join("\n")}`;
214
+ }
215
+
216
+ export function applyWranglerDeployTargetToToml(existingToml, {
217
+ useWorkersDev = false,
218
+ routePattern = "",
219
+ zoneName = "",
220
+ replaceExistingTarget = false
221
+ } = {}) {
222
+ let next = String(existingToml || "");
223
+ next = stripNonTopLevelRouteDeclarations(next);
224
+ if (replaceExistingTarget) {
225
+ next = stripTopLevelRouteDeclarations(next);
226
+ }
227
+ next = upsertTomlBooleanField(next, "workers_dev", useWorkersDev);
228
+
229
+ if (!useWorkersDev) {
230
+ const normalizedPattern = normalizeWranglerRoutePattern(routePattern);
231
+ const normalizedZone = String(zoneName || "").trim();
232
+ if (normalizedPattern && normalizedZone && (replaceExistingTarget || !hasWranglerDeployTargetConfigured(next))) {
233
+ const routeBlock = `routes = [\n { pattern = "${normalizedPattern}", zone_name = "${normalizedZone}" }\n]`;
234
+ next = insertTopLevelBlockBeforeFirstSection(next, routeBlock);
235
+ }
236
+ }
237
+
238
+ if (!/^\s*preview_urls\s*=/mi.test(next)) {
239
+ next = `${next.trimEnd()}\npreview_urls = false\n`;
240
+ }
241
+
242
+ return `${next.trimEnd()}\n`;
243
+ }
244
+
245
+ function normalizeHostname(value) {
246
+ return String(value || "")
247
+ .trim()
248
+ .toLowerCase()
249
+ .replace(/^https?:\/\//, "")
250
+ .replace(/\/.*$/, "")
251
+ .replace(/:\d+$/, "")
252
+ .replace(/\.$/, "");
253
+ }
254
+
255
+ export function extractHostnameFromRoutePattern(value) {
256
+ const route = String(value || "").trim();
257
+ if (!route) return "";
258
+
259
+ if (/^https?:\/\//i.test(route)) {
260
+ try {
261
+ return normalizeHostname(new URL(route).hostname);
262
+ } catch {
263
+ return "";
264
+ }
265
+ }
266
+
267
+ const left = route.split("/")[0] || "";
268
+ return normalizeHostname(left.replace(/\*+$/g, ""));
269
+ }
270
+
271
+ export function inferZoneNameFromHostname(hostname) {
272
+ const host = normalizeHostname(hostname);
273
+ if (!host || !host.includes(".")) return "";
274
+ const labels = host.split(".").filter(Boolean);
275
+ if (labels.length <= 2) return host;
276
+ return labels.slice(-2).join(".");
277
+ }
278
+
279
+ export function isHostnameUnderZone(hostname, zoneName) {
280
+ const host = normalizeHostname(hostname);
281
+ const zone = normalizeHostname(zoneName);
282
+ if (!host || !zone) return false;
283
+ return host === zone || host.endsWith(`.${zone}`);
284
+ }
285
+
286
+ export function suggestZoneNameForHostname(hostname, zones = []) {
287
+ const host = normalizeHostname(hostname);
288
+ if (!host) return "";
289
+
290
+ let best = "";
291
+ for (const zone of zones || []) {
292
+ const candidate = normalizeHostname(zone?.name || zone);
293
+ if (!candidate) continue;
294
+ if (host === candidate || host.endsWith(`.${candidate}`)) {
295
+ if (!best || candidate.length > best.length) {
296
+ best = candidate;
297
+ }
298
+ }
299
+ }
300
+ return best;
301
+ }
302
+
303
+ export function buildCloudflareDnsManualGuide({
304
+ hostname = "",
305
+ zoneName = "",
306
+ routePattern = ""
307
+ } = {}) {
308
+ const host = normalizeHostname(hostname || extractHostnameFromRoutePattern(routePattern));
309
+ const zone = normalizeHostname(zoneName || inferZoneNameFromHostname(host));
310
+ const subdomain = host && zone && host.endsWith(`.${zone}`)
311
+ ? host.slice(0, -(`.${zone}`).length)
312
+ : "";
313
+ const label = subdomain || "<subdomain>";
314
+
315
+ return [
316
+ "Custom domain checklist:",
317
+ `- Route target: ${routePattern || `${host || "<host>"}/*`} (zone: ${zone || "<zone>"})`,
318
+ `- DNS: create/update CNAME \`${label}\` -> \`@\` in zone \`${zone || "<zone>"}\``,
319
+ "- Proxy status must be ON (orange cloud / proxied)",
320
+ host ? `- Verify DNS: dig +short ${host} @1.1.1.1` : "- Verify DNS: dig +short <host> @1.1.1.1",
321
+ host ? `- Verify HTTP: curl -I https://${host}/anthropic` : "- Verify HTTP: curl -I https://<host>/anthropic",
322
+ "- Claude base URL must NOT include :8787 for Cloudflare Worker deployments"
323
+ ].join("\n");
324
+ }
package/src/index.js CHANGED
@@ -8,7 +8,9 @@ import { runtimeConfigFromEnv } from "./runtime/config.js";
8
8
 
9
9
  const workerFetch = createFetchHandler({
10
10
  getConfig: async (env) => runtimeConfigFromEnv(env),
11
- defaultStateStoreBackend: "memory"
11
+ defaultStateStoreBackend: "memory",
12
+ runtime: "worker",
13
+ workerSafeMode: true
12
14
  });
13
15
 
14
16
  export default {
@@ -0,0 +1,224 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { clearRuntimeState, getActiveRuntimeState } from "./instance-state.js";
3
+ import { startupStatus, stopStartup } from "./startup-manager.js";
4
+
5
+ export function parsePidList(text) {
6
+ const tokens = String(text || "")
7
+ .split(/[\s\r\n]+/)
8
+ .map((token) => token.trim())
9
+ .filter(Boolean);
10
+ return [...new Set(tokens
11
+ .filter((token) => /^\d+$/.test(token))
12
+ .map((token) => Number(token))
13
+ .filter((pid) => Number.isInteger(pid) && pid > 0))];
14
+ }
15
+
16
+ export function parseFuserPidList(text) {
17
+ const normalized = String(text || "")
18
+ .replace(/\b\d+\/tcp:\s*/gi, " ")
19
+ .trim();
20
+ if (!normalized) return [];
21
+ return parsePidList(normalized);
22
+ }
23
+
24
+ function listListeningPidsWithLsof(port, spawnSyncImpl) {
25
+ const result = spawnSyncImpl("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
26
+ encoding: "utf8"
27
+ });
28
+ if (result.error) {
29
+ return { ok: false, pids: [], tool: "lsof", error: result.error };
30
+ }
31
+
32
+ return {
33
+ ok: true,
34
+ pids: parsePidList(result.stdout),
35
+ tool: "lsof"
36
+ };
37
+ }
38
+
39
+ function listListeningPidsWithFuser(port, spawnSyncImpl) {
40
+ const result = spawnSyncImpl("fuser", ["-n", "tcp", String(port)], {
41
+ encoding: "utf8"
42
+ });
43
+ if (result.error) {
44
+ return { ok: false, pids: [], tool: "fuser", error: result.error };
45
+ }
46
+
47
+ return {
48
+ ok: true,
49
+ pids: parseFuserPidList(result.stdout),
50
+ tool: "fuser"
51
+ };
52
+ }
53
+
54
+ export function listListeningPids(port, deps = {}) {
55
+ const spawnSyncImpl = typeof deps.spawnSync === "function" ? deps.spawnSync : spawnSync;
56
+ const lsof = listListeningPidsWithLsof(port, spawnSyncImpl);
57
+ if (lsof.ok) return lsof;
58
+
59
+ const fuser = listListeningPidsWithFuser(port, spawnSyncImpl);
60
+ if (fuser.ok) return fuser;
61
+
62
+ return {
63
+ ok: false,
64
+ pids: [],
65
+ tool: "none",
66
+ error: lsof.error || fuser.error
67
+ };
68
+ }
69
+
70
+ function sleep(ms) {
71
+ return new Promise((resolve) => setTimeout(resolve, ms));
72
+ }
73
+
74
+ export async function waitForPortToRelease(port, timeoutMs = 4000, deps = {}) {
75
+ const listListeningPidsFn = typeof deps.listListeningPids === "function"
76
+ ? deps.listListeningPids
77
+ : (targetPort) => listListeningPids(targetPort, deps);
78
+ const sleepFn = typeof deps.sleep === "function" ? deps.sleep : sleep;
79
+
80
+ const startedAt = Date.now();
81
+ while (Date.now() - startedAt < timeoutMs) {
82
+ const probe = listListeningPidsFn(port);
83
+ if (!probe.ok) {
84
+ await sleepFn(150);
85
+ continue;
86
+ }
87
+ if (probe.pids.length === 0) {
88
+ return true;
89
+ }
90
+ await sleepFn(150);
91
+ }
92
+
93
+ const finalProbe = listListeningPidsFn(port);
94
+ if (!finalProbe.ok) return false;
95
+ return finalProbe.pids.length === 0;
96
+ }
97
+
98
+ export async function stopStartupManagedListener({ port, line, error }, deps = {}) {
99
+ const getActiveRuntimeStateFn = typeof deps.getActiveRuntimeState === "function"
100
+ ? deps.getActiveRuntimeState
101
+ : getActiveRuntimeState;
102
+ const startupStatusFn = typeof deps.startupStatus === "function"
103
+ ? deps.startupStatus
104
+ : startupStatus;
105
+ const stopStartupFn = typeof deps.stopStartup === "function"
106
+ ? deps.stopStartup
107
+ : stopStartup;
108
+ const clearRuntimeStateFn = typeof deps.clearRuntimeState === "function"
109
+ ? deps.clearRuntimeState
110
+ : clearRuntimeState;
111
+
112
+ let activeRuntimeState = null;
113
+ try {
114
+ activeRuntimeState = await getActiveRuntimeStateFn();
115
+ } catch {
116
+ activeRuntimeState = null;
117
+ }
118
+
119
+ let shouldStopStartup = false;
120
+ if (activeRuntimeState?.managedByStartup) {
121
+ shouldStopStartup = Number(activeRuntimeState.port) === Number(port);
122
+ } else if (!activeRuntimeState) {
123
+ try {
124
+ const status = await startupStatusFn();
125
+ shouldStopStartup = Boolean(status?.running);
126
+ } catch {
127
+ shouldStopStartup = false;
128
+ }
129
+ }
130
+
131
+ if (!shouldStopStartup) return { ok: true, attempted: false };
132
+
133
+ line(`Detected startup-managed llm-router on port ${port}. Stopping startup service before reclaim.`);
134
+ try {
135
+ await stopStartupFn();
136
+ await clearRuntimeStateFn();
137
+ return { ok: true, attempted: true };
138
+ } catch (startupError) {
139
+ error(`Failed stopping startup-managed service: ${startupError instanceof Error ? startupError.message : String(startupError)}`);
140
+ return {
141
+ ok: false,
142
+ attempted: true,
143
+ errorMessage: `Port ${port} is occupied by a startup-managed llm-router service and could not be stopped automatically. Stop it with 'llm-router stop' or 'llm-router config --operation=startup-uninstall' and retry.`
144
+ };
145
+ }
146
+ }
147
+
148
+ export async function reclaimPort({ port, line, error }, deps = {}) {
149
+ const selfPid = Number.isInteger(deps.selfPid) ? deps.selfPid : process.pid;
150
+ const stopStartupManagedListenerFn = typeof deps.stopStartupManagedListener === "function"
151
+ ? deps.stopStartupManagedListener
152
+ : (args) => stopStartupManagedListener(args, deps);
153
+ const listListeningPidsFn = typeof deps.listListeningPids === "function"
154
+ ? deps.listListeningPids
155
+ : (targetPort) => listListeningPids(targetPort, deps);
156
+ const waitForPortToReleaseFn = typeof deps.waitForPortToRelease === "function"
157
+ ? deps.waitForPortToRelease
158
+ : (targetPort, timeoutMs) => waitForPortToRelease(targetPort, timeoutMs, deps);
159
+ const killFn = typeof deps.kill === "function" ? deps.kill : process.kill.bind(process);
160
+
161
+ const startupStop = await stopStartupManagedListenerFn({ port, line, error });
162
+ if (!startupStop.ok) {
163
+ return {
164
+ ok: false,
165
+ errorMessage: startupStop.errorMessage
166
+ };
167
+ }
168
+
169
+ const probe = listListeningPidsFn(port);
170
+ if (!probe.ok) {
171
+ return {
172
+ ok: false,
173
+ errorMessage: `Port ${port} is in use but process lookup failed (${probe.error instanceof Error ? probe.error.message : String(probe.error || "unknown error")}).`
174
+ };
175
+ }
176
+
177
+ const targets = probe.pids.filter((pid) => pid !== selfPid);
178
+ if (targets.length === 0) {
179
+ return {
180
+ ok: false,
181
+ errorMessage: `Port ${port} is in use but no external listener PID was detected.`
182
+ };
183
+ }
184
+
185
+ line(`Port ${port} is already in use. Stopping existing listener(s): ${targets.join(", ")}.`);
186
+
187
+ for (const pid of targets) {
188
+ try {
189
+ killFn(pid, "SIGTERM");
190
+ } catch (killError) {
191
+ error(`Failed sending SIGTERM to pid ${pid}: ${killError instanceof Error ? killError.message : String(killError)}`);
192
+ }
193
+ }
194
+
195
+ let released = await waitForPortToReleaseFn(port, 3000);
196
+ if (!released) {
197
+ const remaining = listListeningPidsFn(port);
198
+ const remainingTargets = (remaining.pids || []).filter((pid) => pid !== selfPid);
199
+
200
+ if (remainingTargets.length > 0) {
201
+ line(`Port ${port} still busy. Force killing listener(s): ${remainingTargets.join(", ")}.`);
202
+ for (const pid of remainingTargets) {
203
+ try {
204
+ killFn(pid, "SIGKILL");
205
+ } catch (killError) {
206
+ error(`Failed sending SIGKILL to pid ${pid}: ${killError instanceof Error ? killError.message : String(killError)}`);
207
+ }
208
+ }
209
+ released = await waitForPortToReleaseFn(port, 2000);
210
+ }
211
+ }
212
+
213
+ if (!released) {
214
+ return {
215
+ ok: false,
216
+ errorMessage: `Failed to reclaim port ${port}; listener process is still running.`
217
+ };
218
+ }
219
+
220
+ return {
221
+ ok: true
222
+ };
223
+ }
224
+
@@ -1,9 +1,10 @@
1
1
  import path from "node:path";
2
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
- import { spawn, spawnSync } from "node:child_process";
3
+ import { spawn } from "node:child_process";
4
4
  import { configFileExists, getDefaultConfigPath, readConfigFile } from "./config-store.js";
5
5
  import { clearRuntimeState, writeRuntimeState } from "./instance-state.js";
6
6
  import { startLocalRouteServer } from "./local-server.js";
7
+ import { reclaimPort } from "./port-reclaim.js";
7
8
  import { configHasProvider, sanitizeConfigForDisplay } from "../runtime/config.js";
8
9
 
9
10
  function summarizeConfig(config, configPath) {
@@ -132,133 +133,6 @@ function spawnReplacementCli({ cliPath, startArgs }) {
132
133
  });
133
134
  }
134
135
 
135
- function parsePidList(text) {
136
- const matches = String(text || "").match(/\d+/g) || [];
137
- return [...new Set(matches
138
- .map((token) => Number(token))
139
- .filter((pid) => Number.isInteger(pid) && pid > 0))];
140
- }
141
-
142
- function listListeningPidsWithLsof(port) {
143
- const result = spawnSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
144
- encoding: "utf8"
145
- });
146
- if (result.error) {
147
- return { ok: false, pids: [], tool: "lsof", error: result.error };
148
- }
149
-
150
- return {
151
- ok: true,
152
- pids: parsePidList(result.stdout),
153
- tool: "lsof"
154
- };
155
- }
156
-
157
- function listListeningPidsWithFuser(port) {
158
- const result = spawnSync("fuser", ["-n", "tcp", String(port)], {
159
- encoding: "utf8"
160
- });
161
- if (result.error) {
162
- return { ok: false, pids: [], tool: "fuser", error: result.error };
163
- }
164
-
165
- return {
166
- ok: true,
167
- pids: parsePidList(`${result.stdout || ""}\n${result.stderr || ""}`),
168
- tool: "fuser"
169
- };
170
- }
171
-
172
- function listListeningPids(port) {
173
- const lsof = listListeningPidsWithLsof(port);
174
- if (lsof.ok) return lsof;
175
-
176
- const fuser = listListeningPidsWithFuser(port);
177
- if (fuser.ok) return fuser;
178
-
179
- return {
180
- ok: false,
181
- pids: [],
182
- tool: "none",
183
- error: lsof.error || fuser.error
184
- };
185
- }
186
-
187
- function sleep(ms) {
188
- return new Promise((resolve) => setTimeout(resolve, ms));
189
- }
190
-
191
- async function waitForPortToRelease(port, timeoutMs = 4000) {
192
- const startedAt = Date.now();
193
- while (Date.now() - startedAt < timeoutMs) {
194
- const probe = listListeningPids(port);
195
- if (!probe.ok || probe.pids.length === 0) {
196
- return true;
197
- }
198
- await sleep(150);
199
- }
200
-
201
- const finalProbe = listListeningPids(port);
202
- return !finalProbe.ok || finalProbe.pids.length === 0;
203
- }
204
-
205
- async function reclaimPort({ port, line, error }) {
206
- const probe = listListeningPids(port);
207
- if (!probe.ok) {
208
- return {
209
- ok: false,
210
- errorMessage: `Port ${port} is in use but process lookup failed (${probe.error instanceof Error ? probe.error.message : String(probe.error || "unknown error")}).`
211
- };
212
- }
213
-
214
- const targets = probe.pids.filter((pid) => pid !== process.pid);
215
- if (targets.length === 0) {
216
- return {
217
- ok: false,
218
- errorMessage: `Port ${port} is in use but no external listener PID was detected.`
219
- };
220
- }
221
-
222
- line(`Port ${port} is already in use. Stopping existing listener(s): ${targets.join(", ")}.`);
223
-
224
- for (const pid of targets) {
225
- try {
226
- process.kill(pid, "SIGTERM");
227
- } catch (killError) {
228
- error(`Failed sending SIGTERM to pid ${pid}: ${killError instanceof Error ? killError.message : String(killError)}`);
229
- }
230
- }
231
-
232
- let released = await waitForPortToRelease(port, 3000);
233
- if (!released) {
234
- const remaining = listListeningPids(port);
235
- const remainingTargets = (remaining.pids || []).filter((pid) => pid !== process.pid);
236
-
237
- if (remainingTargets.length > 0) {
238
- line(`Port ${port} still busy. Force killing listener(s): ${remainingTargets.join(", ")}.`);
239
- for (const pid of remainingTargets) {
240
- try {
241
- process.kill(pid, "SIGKILL");
242
- } catch (killError) {
243
- error(`Failed sending SIGKILL to pid ${pid}: ${killError instanceof Error ? killError.message : String(killError)}`);
244
- }
245
- }
246
- released = await waitForPortToRelease(port, 2000);
247
- }
248
- }
249
-
250
- if (!released) {
251
- return {
252
- ok: false,
253
- errorMessage: `Failed to reclaim port ${port}; listener process is still running.`
254
- };
255
- }
256
-
257
- return {
258
- ok: true
259
- };
260
- }
261
-
262
136
  export async function runStartCommand(options = {}) {
263
137
  const configPath = options.configPath || getDefaultConfigPath();
264
138
  const host = options.host || "127.0.0.1";