@aiwerk/mcp-bridge 1.0.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/bin/mcp-bridge.js +9 -0
  4. package/bin/mcp-bridge.ts +335 -0
  5. package/package.json +42 -0
  6. package/scripts/install-server.ps1 +300 -0
  7. package/scripts/install-server.sh +357 -0
  8. package/servers/apify/README.md +40 -0
  9. package/servers/apify/config.json +13 -0
  10. package/servers/apify/env_vars +1 -0
  11. package/servers/apify/install.ps1 +3 -0
  12. package/servers/apify/install.sh +4 -0
  13. package/servers/candidates.md +13 -0
  14. package/servers/github/README.md +40 -0
  15. package/servers/github/config.json +21 -0
  16. package/servers/github/env_vars +1 -0
  17. package/servers/github/install.ps1 +3 -0
  18. package/servers/github/install.sh +4 -0
  19. package/servers/google-maps/README.md +40 -0
  20. package/servers/google-maps/config.json +17 -0
  21. package/servers/google-maps/env_vars +1 -0
  22. package/servers/google-maps/install.ps1 +3 -0
  23. package/servers/google-maps/install.sh +4 -0
  24. package/servers/hetzner/README.md +41 -0
  25. package/servers/hetzner/config.json +16 -0
  26. package/servers/hetzner/env_vars +1 -0
  27. package/servers/hetzner/install.ps1 +3 -0
  28. package/servers/hetzner/install.sh +4 -0
  29. package/servers/hostinger/README.md +40 -0
  30. package/servers/hostinger/config.json +17 -0
  31. package/servers/hostinger/env_vars +1 -0
  32. package/servers/hostinger/install.ps1 +3 -0
  33. package/servers/hostinger/install.sh +4 -0
  34. package/servers/index.json +125 -0
  35. package/servers/linear/README.md +40 -0
  36. package/servers/linear/config.json +16 -0
  37. package/servers/linear/env_vars +1 -0
  38. package/servers/linear/install.ps1 +3 -0
  39. package/servers/linear/install.sh +4 -0
  40. package/servers/miro/README.md +40 -0
  41. package/servers/miro/config.json +19 -0
  42. package/servers/miro/env_vars +1 -0
  43. package/servers/miro/install.ps1 +3 -0
  44. package/servers/miro/install.sh +4 -0
  45. package/servers/notion/README.md +42 -0
  46. package/servers/notion/config.json +17 -0
  47. package/servers/notion/env_vars +1 -0
  48. package/servers/notion/install.ps1 +3 -0
  49. package/servers/notion/install.sh +4 -0
  50. package/servers/stripe/README.md +40 -0
  51. package/servers/stripe/config.json +19 -0
  52. package/servers/stripe/env_vars +1 -0
  53. package/servers/stripe/install.ps1 +3 -0
  54. package/servers/stripe/install.sh +4 -0
  55. package/servers/tavily/README.md +40 -0
  56. package/servers/tavily/config.json +17 -0
  57. package/servers/tavily/env_vars +1 -0
  58. package/servers/tavily/install.ps1 +3 -0
  59. package/servers/tavily/install.sh +4 -0
  60. package/servers/todoist/README.md +40 -0
  61. package/servers/todoist/config.json +17 -0
  62. package/servers/todoist/env_vars +1 -0
  63. package/servers/todoist/install.ps1 +3 -0
  64. package/servers/todoist/install.sh +4 -0
  65. package/servers/wise/README.md +41 -0
  66. package/servers/wise/config.json +16 -0
  67. package/servers/wise/env_vars +1 -0
  68. package/servers/wise/install.ps1 +3 -0
  69. package/servers/wise/install.sh +4 -0
  70. package/src/config.ts +168 -0
  71. package/src/index.ts +44 -0
  72. package/src/mcp-router.ts +366 -0
  73. package/src/protocol.ts +69 -0
  74. package/src/schema-convert.ts +178 -0
  75. package/src/standalone-server.ts +385 -0
  76. package/src/tool-naming.ts +51 -0
  77. package/src/transport-base.ts +199 -0
  78. package/src/transport-sse.ts +230 -0
  79. package/src/transport-stdio.ts +312 -0
  80. package/src/transport-streamable-http.ts +188 -0
  81. package/src/types.ts +88 -0
  82. package/src/update-checker.ts +155 -0
  83. package/tests/collision.test.ts +60 -0
  84. package/tests/env-resolve.test.ts +68 -0
  85. package/tests/mcp-router.test.ts +301 -0
  86. package/tests/schema-convert.test.ts +70 -0
  87. package/tests/transport-base.test.ts +214 -0
  88. package/tsconfig.json +15 -0
@@ -0,0 +1,230 @@
1
+ import { McpRequest, McpResponse, McpServerConfig, nextRequestId } from "./types.js";
2
+ import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transport-base.js";
3
+
4
+ export class SseTransport extends BaseTransport {
5
+ private endpointUrl: string | null = null;
6
+ private sseAbortController: AbortController | null = null;
7
+ private currentDataBuffer: string[] = [];
8
+
9
+ protected get transportName(): string { return "SSE"; }
10
+
11
+ async connect(): Promise<void> {
12
+ if (!this.config.url) {
13
+ throw new Error("SSE transport requires URL");
14
+ }
15
+
16
+ warnIfNonTlsRemoteUrl(this.config.url, this.logger);
17
+ // Validate that all header env vars resolve (fail fast)
18
+ resolveEnvRecord(this.config.headers || {}, "header");
19
+
20
+ this.sseAbortController = new AbortController();
21
+
22
+ const connectionTimeout = this.clientConfig.connectionTimeoutMs || 10000;
23
+ const streamReady = new Promise<void>((resolve, reject) => {
24
+ const timer = setTimeout(() => reject(new Error("SSE endpoint URL not received within timeout")), connectionTimeout);
25
+ this._onEndpointReceived = () => { clearTimeout(timer); resolve(); };
26
+ });
27
+
28
+ // Fire and forget the stream reader
29
+ this.startEventStream().catch((error) => {
30
+ if (error instanceof Error && error.name !== 'AbortError') {
31
+ this.logger.error("[mcp-bridge] SSE stream error:", error.message);
32
+ this.scheduleReconnect();
33
+ }
34
+ });
35
+
36
+ await streamReady;
37
+ this.connected = true;
38
+ this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
39
+ }
40
+
41
+ private _onEndpointReceived: (() => void) | null = null;
42
+
43
+ private async startEventStream(): Promise<void> {
44
+ if (!this.config.url) return;
45
+
46
+ const headers = resolveEnvRecord({
47
+ ...this.config.headers,
48
+ "Accept": "text/event-stream"
49
+ }, "header");
50
+
51
+ try {
52
+ const response = await fetch(this.config.url, {
53
+ method: "GET",
54
+ headers,
55
+ signal: this.sseAbortController?.signal
56
+ });
57
+
58
+ if (!response.ok) {
59
+ throw new Error(`SSE connection failed: HTTP ${response.status}`);
60
+ }
61
+
62
+ if (!response.body) {
63
+ throw new Error("No response body for SSE stream");
64
+ }
65
+
66
+ const reader = response.body.getReader();
67
+ const decoder = new TextDecoder();
68
+
69
+ let buffer = "";
70
+ let currentEvent = "";
71
+
72
+ while (true) {
73
+ const { done, value } = await reader.read();
74
+ if (done) break;
75
+
76
+ buffer += decoder.decode(value, { stream: true });
77
+ const lines = buffer.split('\n');
78
+ buffer = lines.pop() || "";
79
+
80
+ for (const line of lines) {
81
+ const trimmed = line.trim();
82
+ if (trimmed.startsWith("event: ")) {
83
+ currentEvent = trimmed.substring(7).trim();
84
+ } else if (trimmed === "") {
85
+ this.processEventLine(line, currentEvent);
86
+ currentEvent = "";
87
+ } else {
88
+ this.processEventLine(line, currentEvent);
89
+ }
90
+ }
91
+ }
92
+ } catch (error) {
93
+ if (error instanceof Error && error.name === 'AbortError') return;
94
+ this.logger.error("SSE stream error:", error);
95
+ this.scheduleReconnect();
96
+ }
97
+ }
98
+
99
+ private processEventLine(line: string, currentEvent: string = ""): void {
100
+ const trimmed = line.trim();
101
+ if (trimmed.startsWith("event: ")) return;
102
+
103
+ if (trimmed.startsWith("data: ")) {
104
+ this.currentDataBuffer.push(trimmed.substring(6));
105
+ return;
106
+ }
107
+
108
+ if (trimmed === "") {
109
+ if (this.currentDataBuffer.length === 0) return;
110
+
111
+ const data = this.currentDataBuffer.join("\n");
112
+ this.currentDataBuffer = [];
113
+
114
+ if (currentEvent === "endpoint") {
115
+ if (data.startsWith("/")) {
116
+ const base = new URL(this.config.url!);
117
+ this.endpointUrl = `${base.origin}${data}`;
118
+ } else if (data.startsWith("http")) {
119
+ if (!this.isSameOrigin(data)) {
120
+ this.logger.warn(`[mcp-bridge] Rejected SSE endpoint with mismatched origin: ${data}`);
121
+ return;
122
+ }
123
+ this.endpointUrl = data;
124
+ } else {
125
+ this.logger.warn(`[mcp-bridge] Rejected SSE endpoint with unsupported URL format: ${data}`);
126
+ return;
127
+ }
128
+ this.logger.info(`[mcp-bridge] SSE endpoint URL received: ${this.endpointUrl}`);
129
+ if (this._onEndpointReceived) {
130
+ this._onEndpointReceived();
131
+ this._onEndpointReceived = null;
132
+ }
133
+ return;
134
+ }
135
+
136
+ try {
137
+ const message = JSON.parse(data);
138
+ this.handleMessage(message);
139
+ } catch {
140
+ this.logger.debug("Failed to parse SSE data as JSON:", data);
141
+ }
142
+ }
143
+ }
144
+
145
+ async sendNotification(notification: any): Promise<void> {
146
+ if (!this.connected || !this.endpointUrl) {
147
+ throw new Error("SSE transport not connected or no endpoint URL");
148
+ }
149
+ const headers = resolveEnvRecord({
150
+ ...this.config.headers,
151
+ "Content-Type": "application/json"
152
+ }, "header");
153
+ const response = await fetch(this.endpointUrl!, {
154
+ method: "POST",
155
+ headers,
156
+ body: JSON.stringify(notification)
157
+ });
158
+ if (!response.ok) {
159
+ this.logger.warn(`[mcp-bridge] SSE notification got HTTP ${response.status}`);
160
+ }
161
+ }
162
+
163
+ async sendRequest(request: McpRequest): Promise<McpResponse> {
164
+ if (!this.connected || !this.endpointUrl) {
165
+ throw new Error("SSE transport not connected or no endpoint URL");
166
+ }
167
+
168
+ const id = nextRequestId();
169
+ const requestWithId = { ...request, id };
170
+
171
+ return new Promise((resolve, reject) => {
172
+ const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
173
+ const timeout = setTimeout(() => {
174
+ this.pendingRequests.delete(id);
175
+ reject(new Error(`Request timeout after ${requestTimeout}ms`));
176
+ }, requestTimeout);
177
+
178
+ this.pendingRequests.set(id, { resolve, reject, timeout });
179
+
180
+ const headers = resolveEnvRecord({
181
+ ...this.config.headers,
182
+ "Content-Type": "application/json"
183
+ }, "header");
184
+
185
+ // The response arrives via the SSE stream (handleMessage), not from this fetch.
186
+ // The fetch only confirms the server accepted the request (HTTP 200).
187
+ // If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
188
+ fetch(this.endpointUrl!, {
189
+ method: "POST",
190
+ headers,
191
+ body: JSON.stringify(requestWithId)
192
+ })
193
+ .then((response) => {
194
+ if (!response.ok) {
195
+ clearTimeout(timeout);
196
+ this.pendingRequests.delete(id);
197
+ reject(new Error(`HTTP ${response.status}`));
198
+ }
199
+ })
200
+ .catch((error) => {
201
+ clearTimeout(timeout);
202
+ this.pendingRequests.delete(id);
203
+ reject(error);
204
+ });
205
+ });
206
+ }
207
+
208
+ private isSameOrigin(url: string): boolean {
209
+ try {
210
+ if (!this.config.url) return false;
211
+ const incoming = new URL(url);
212
+ const base = new URL(this.config.url);
213
+ return incoming.origin === base.origin;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ async disconnect(): Promise<void> {
220
+ this.connected = false;
221
+ this.cleanupReconnectTimer();
222
+
223
+ if (this.sseAbortController) {
224
+ this.sseAbortController.abort();
225
+ this.sseAbortController = null;
226
+ }
227
+
228
+ this.rejectAllPending("Connection closed");
229
+ }
230
+ }
@@ -0,0 +1,312 @@
1
+ import { spawn, ChildProcess } from "child_process";
2
+ import { McpRequest, McpResponse, McpServerConfig, nextRequestId } from "./types.js";
3
+ import { BaseTransport, resolveEnvRecord, resolveArgs } from "./transport-base.js";
4
+
5
+ export class StdioTransport extends BaseTransport {
6
+ private process: ChildProcess | null = null;
7
+ private framingMode: "auto" | "lsp" | "newline" = "auto";
8
+ private stdoutBuffer = Buffer.alloc(0);
9
+ private isShuttingDown = false;
10
+
11
+ protected get transportName(): string { return "stdio"; }
12
+
13
+ async connect(): Promise<void> {
14
+ if (!this.config.command) {
15
+ throw new Error("Stdio transport requires command");
16
+ }
17
+
18
+ try {
19
+ this.isShuttingDown = false;
20
+ await this.startProcess();
21
+ this.connected = true;
22
+ this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
23
+ } catch (error) {
24
+ this.logger.error("Stdio transport connection failed:", error);
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ private async startProcess(): Promise<void> {
30
+ if (!this.config.command) return;
31
+
32
+ const env = { ...process.env, ...resolveEnvRecord(this.config.env || {}, "env key") };
33
+ const args = resolveArgs(this.config.args || [], env);
34
+
35
+ this.process = spawn(this.config.command, args, {
36
+ stdio: ["pipe", "pipe", "pipe"],
37
+ env
38
+ });
39
+
40
+ if (!this.process.stdin || !this.process.stdout || !this.process.stderr) {
41
+ throw new Error("Failed to create process pipes");
42
+ }
43
+
44
+ this.framingMode = this.config.framing || "auto";
45
+ this.stdoutBuffer = Buffer.alloc(0);
46
+ this.process.stdout.on("data", (data: Buffer) => {
47
+ this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, data]);
48
+ // Safety limit: prevent unbounded buffer growth from misbehaving servers
49
+ const MAX_BUFFER = 50 * 1024 * 1024; // 50MB
50
+ if (this.stdoutBuffer.length > MAX_BUFFER) {
51
+ this.logger.error(`[mcp-bridge] Stdio buffer exceeded ${MAX_BUFFER} bytes, killing process`);
52
+ this.process?.kill();
53
+ return;
54
+ }
55
+ this.processStdoutBuffer();
56
+ });
57
+
58
+ this.process.stderr.on("data", (data: Buffer) => {
59
+ this.logger.debug(`MCP server stderr: ${data.toString()}`);
60
+ });
61
+
62
+ this.process.on("exit", (code, signal) => {
63
+ this.logger.debug(`MCP server process exited: code=${code}, signal=${signal}`);
64
+ this.connected = false;
65
+ this.process = null;
66
+ this.rejectAllPending("Process exited");
67
+
68
+ if (!this.isShuttingDown) {
69
+ this.scheduleReconnect();
70
+ }
71
+ });
72
+
73
+ this.process.on("error", (error) => {
74
+ this.logger.error("MCP server process error:", error);
75
+ this.connected = false;
76
+ this.process = null;
77
+ this.rejectAllPending("Process error");
78
+
79
+ if (!this.isShuttingDown) {
80
+ this.scheduleReconnect();
81
+ }
82
+ });
83
+
84
+ const connectionTimeout = this.clientConfig.connectionTimeoutMs || 5000;
85
+ await new Promise<void>((resolve, reject) => {
86
+ let settled = false;
87
+ let timeout: NodeJS.Timeout;
88
+
89
+ const cleanup = () => {
90
+ this.process?.stdout?.off("data", onFirstData);
91
+ this.process?.off("error", onProcessError);
92
+ this.process?.off("exit", onProcessExit);
93
+ clearTimeout(timeout);
94
+ };
95
+
96
+ const settleResolve = () => {
97
+ if (settled) return;
98
+ settled = true;
99
+ cleanup();
100
+ resolve();
101
+ };
102
+
103
+ const settleReject = (error: Error) => {
104
+ if (settled) return;
105
+ settled = true;
106
+ cleanup();
107
+ reject(error);
108
+ };
109
+
110
+ const onFirstData = () => settleResolve();
111
+ const onProcessError = (error: Error) => settleReject(error);
112
+ const onProcessExit = () => settleReject(new Error("MCP server exited before stdout became ready"));
113
+
114
+ this.process!.stdout!.once("data", onFirstData);
115
+ this.process!.once("error", onProcessError);
116
+ this.process!.once("exit", onProcessExit);
117
+
118
+ timeout = setTimeout(() => {
119
+ this.logger.warn(`[mcp-bridge] Stdio startup stdout readiness timed out after ${connectionTimeout}ms; continuing`);
120
+ settleResolve();
121
+ }, connectionTimeout);
122
+ });
123
+ }
124
+
125
+ private writeMessage(message: any): void {
126
+ const json = JSON.stringify(message);
127
+ if (this.framingMode === "lsp") {
128
+ const body = Buffer.from(json, "utf8");
129
+ this.process!.stdin!.write(`Content-Length: ${body.length}\r\n\r\n`);
130
+ this.process!.stdin!.write(body);
131
+ } else {
132
+ this.process!.stdin!.write(json + '\n');
133
+ }
134
+ }
135
+
136
+ async sendNotification(notification: any): Promise<void> {
137
+ if (!this.connected || !this.process?.stdin) {
138
+ throw new Error("Stdio transport not connected");
139
+ }
140
+ this.writeMessage(notification);
141
+ }
142
+
143
+ async sendRequest(request: McpRequest): Promise<McpResponse> {
144
+ if (!this.connected || !this.process?.stdin) {
145
+ throw new Error("Stdio transport not connected");
146
+ }
147
+
148
+ const id = nextRequestId();
149
+ const requestWithId = { ...request, id };
150
+
151
+ return new Promise((resolve, reject) => {
152
+ const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
153
+ const timeout = setTimeout(() => {
154
+ this.pendingRequests.delete(id);
155
+ reject(new Error(`Request timeout after ${requestTimeout}ms`));
156
+ }, requestTimeout);
157
+
158
+ this.pendingRequests.set(id, { resolve, reject, timeout });
159
+
160
+ try {
161
+ this.writeMessage(requestWithId);
162
+ } catch (error) {
163
+ clearTimeout(timeout);
164
+ this.pendingRequests.delete(id);
165
+ reject(error);
166
+ }
167
+ });
168
+ }
169
+
170
+ private processStdoutBuffer(): void {
171
+ while (true) {
172
+ if (this.framingMode === "auto") {
173
+ const bufferText = this.stdoutBuffer.toString("utf8");
174
+ if (bufferText.includes("Content-Length:")) {
175
+ this.framingMode = "lsp";
176
+ } else if (this.stdoutBuffer.includes(0x0a)) {
177
+ this.framingMode = "newline";
178
+ } else {
179
+ return;
180
+ }
181
+ }
182
+
183
+ if (this.framingMode === "lsp") {
184
+ if (!this.parseLspMessageFromBuffer()) return;
185
+ continue;
186
+ }
187
+
188
+ if (!this.parseNewlineMessageFromBuffer()) return;
189
+ }
190
+ }
191
+
192
+ private parseNewlineMessageFromBuffer(): boolean {
193
+ const newlineIndex = this.stdoutBuffer.indexOf(0x0a);
194
+ if (newlineIndex === -1) return false;
195
+
196
+ const lineBuffer = this.stdoutBuffer.subarray(0, newlineIndex);
197
+ this.stdoutBuffer = this.stdoutBuffer.subarray(newlineIndex + 1);
198
+
199
+ const line = lineBuffer.toString("utf8").trim();
200
+ if (!line) return true;
201
+
202
+ try {
203
+ const message = JSON.parse(line);
204
+ this.handleMessage(message);
205
+ } catch {
206
+ this.logger.debug("Failed to parse stdout JSON:", line);
207
+ }
208
+ return true;
209
+ }
210
+
211
+ private parseLspMessageFromBuffer(): boolean {
212
+ const separator = Buffer.from("\r\n\r\n");
213
+ let headerEndIndex = this.stdoutBuffer.indexOf(separator);
214
+ let headerLength = separator.length;
215
+
216
+ if (headerEndIndex === -1) {
217
+ const altSeparator = Buffer.from("\n\n");
218
+ headerEndIndex = this.stdoutBuffer.indexOf(altSeparator);
219
+ headerLength = altSeparator.length;
220
+ }
221
+
222
+ if (headerEndIndex === -1) return false;
223
+
224
+ const headerText = this.stdoutBuffer.subarray(0, headerEndIndex).toString("utf8");
225
+ const contentLengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
226
+ if (!contentLengthMatch) {
227
+ this.logger.warn("[mcp-bridge] Missing Content-Length in LSP-framed stdout message; dropping malformed frame");
228
+ this.stdoutBuffer = this.stdoutBuffer.subarray(headerEndIndex + headerLength);
229
+ return true;
230
+ }
231
+
232
+ const contentLength = Number.parseInt(contentLengthMatch[1], 10);
233
+ const bodyStart = headerEndIndex + headerLength;
234
+ const bodyEnd = bodyStart + contentLength;
235
+
236
+ if (this.stdoutBuffer.length < bodyEnd) return false;
237
+
238
+ const body = this.stdoutBuffer.subarray(bodyStart, bodyEnd).toString("utf8");
239
+ this.stdoutBuffer = this.stdoutBuffer.subarray(bodyEnd);
240
+
241
+ try {
242
+ const message = JSON.parse(body);
243
+ this.handleMessage(message);
244
+ } catch {
245
+ this.logger.debug("Failed to parse LSP stdout JSON:", body);
246
+ }
247
+
248
+ return true;
249
+ }
250
+
251
+ async disconnect(): Promise<void> {
252
+ this.isShuttingDown = true;
253
+ this.connected = false;
254
+ this.cleanupReconnectTimer();
255
+
256
+ const activeProcess = this.process;
257
+ if (activeProcess) {
258
+ if (activeProcess.stdin && !activeProcess.stdin.destroyed) {
259
+ try {
260
+ this.writeMessage({ jsonrpc: "2.0", method: "close" });
261
+ } catch {
262
+ this.logger.debug("[mcp-bridge] Failed to send close notification during stdio disconnect");
263
+ }
264
+ }
265
+
266
+ await this.terminateProcessGracefully(activeProcess);
267
+ if (this.process === activeProcess) {
268
+ this.process = null;
269
+ }
270
+ }
271
+
272
+ this.rejectAllPending("Connection closed");
273
+ }
274
+
275
+ isConnected(): boolean {
276
+ return this.connected && this.process !== null;
277
+ }
278
+
279
+ private async terminateProcessGracefully(proc: ChildProcess): Promise<void> {
280
+ if (proc.exitCode !== null || proc.killed) return;
281
+
282
+ await new Promise<void>((resolve) => {
283
+ let done = false;
284
+ let forceKillTimer: NodeJS.Timeout | null = null;
285
+
286
+ const finish = () => {
287
+ if (done) return;
288
+ done = true;
289
+ if (forceKillTimer) clearTimeout(forceKillTimer);
290
+ proc.off("exit", onExit);
291
+ resolve();
292
+ };
293
+
294
+ const onExit = () => finish();
295
+ proc.once("exit", onExit);
296
+
297
+ try {
298
+ proc.kill("SIGINT");
299
+ } catch {
300
+ finish();
301
+ return;
302
+ }
303
+
304
+ forceKillTimer = setTimeout(() => {
305
+ if (proc.exitCode === null && !proc.killed) {
306
+ try { proc.kill("SIGTERM"); } catch { /* ignore */ }
307
+ }
308
+ setTimeout(finish, 200);
309
+ }, 2000);
310
+ });
311
+ }
312
+ }