@calltelemetry/cucm-mcp 0.1.7 → 0.2.3

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.
@@ -2,398 +2,340 @@ import crypto from "node:crypto";
2
2
  import { Client } from "ssh2";
3
3
  import { defaultStateStore } from "./state.js";
4
4
  function extractCapFilePath(text) {
5
- if (!text)
6
- return undefined;
7
- // CUCM typically writes captures to /var/log/active/platform/cli/<name>.cap
8
- // Sometimes CLI output includes the final on-disk location.
9
- const re = /\/var\/log\/active\/platform\/cli\/[A-Za-z0-9._-]+\.cap/g;
10
- const matches = text.match(re);
11
- if (!matches || matches.length === 0)
12
- return undefined;
13
- return matches[matches.length - 1];
5
+ if (!text) return void 0;
6
+ const re = /\/var\/log\/active\/platform\/cli\/[A-Za-z0-9._-]+\.cap/g;
7
+ const matches = text.match(re);
8
+ if (!matches || matches.length === 0) return void 0;
9
+ return matches[matches.length - 1];
14
10
  }
15
11
  function looksLikeCucmPrompt(text) {
16
- const t = String(text || "");
17
- // CUCM CLI commonly ends commands by printing a prompt like:
18
- // admin:
19
- // Some environments might show other usernames.
20
- // We only look at the tail to avoid false positives.
21
- const tail = t.slice(-80);
22
- return /(?:^|\n)[A-Za-z0-9_-]+:\s*$/.test(tail);
12
+ const t = String(text || "");
13
+ const tail = t.slice(-80);
14
+ return /(?:^|\n)[A-Za-z0-9_-]+:\s*$/.test(tail);
23
15
  }
24
- export function resolveSshAuth(auth) {
25
- const username = auth?.username || process.env.CUCM_SSH_USERNAME;
26
- const password = auth?.password || process.env.CUCM_SSH_PASSWORD;
27
- if (!username || !password) {
28
- throw new Error("Missing SSH credentials (provide auth or set CUCM_SSH_USERNAME/CUCM_SSH_PASSWORD)");
29
- }
30
- return { username, password };
16
+ function resolveSshAuth(auth) {
17
+ const username = auth?.username || process.env.CUCM_SSH_USERNAME;
18
+ const password = auth?.password || process.env.CUCM_SSH_PASSWORD;
19
+ if (!username || !password) {
20
+ throw new Error("Missing SSH credentials (provide auth or set CUCM_SSH_USERNAME/CUCM_SSH_PASSWORD)");
21
+ }
22
+ return { username, password };
31
23
  }
32
- export function sanitizeFileBase(s) {
33
- // CUCM note: fname should not contain '.'
34
- // Keep it conservative.
35
- const cleaned = String(s || "")
36
- .trim()
37
- .replaceAll(".", "_")
38
- .replace(/[^A-Za-z0-9_-]+/g, "_")
39
- .replace(/^_+/, "")
40
- .replace(/_+$/, "");
41
- if (!cleaned)
42
- throw new Error("fileBase is required (after sanitization)");
43
- return cleaned;
24
+ function sanitizeFileBase(s) {
25
+ const cleaned = String(s || "").trim().replaceAll(".", "_").replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+/, "").replace(/_+$/, "");
26
+ if (!cleaned) throw new Error("fileBase is required (after sanitization)");
27
+ return cleaned;
44
28
  }
45
- export function buildCaptureCommand(opts) {
46
- const iface = String(opts.iface || "eth0").trim() || "eth0";
47
- const fileBase = sanitizeFileBase(opts.fileBase);
48
- const count = Number.isFinite(opts.count) ? Math.trunc(opts.count) : 100000;
49
- if (count <= 0)
50
- throw new Error("count must be > 0");
51
- const size = String(opts.size || "all").trim() || "all";
52
- const args = [
53
- "utils",
54
- "network",
55
- "capture",
56
- iface,
57
- "file",
58
- fileBase,
59
- "count",
60
- String(count),
61
- "size",
62
- size,
63
- ];
64
- if (opts.portFilter != null) {
65
- const p = Math.trunc(opts.portFilter);
66
- if (p < 1 || p > 65535)
67
- throw new Error("portFilter must be 1..65535");
68
- args.push("port", String(p));
69
- }
70
- if (opts.hostFilterIp) {
71
- const ip = String(opts.hostFilterIp).trim();
72
- if (!ip)
73
- throw new Error("hostFilterIp must be non-empty");
74
- args.push("host", "ip", ip);
75
- }
76
- return args.join(" ");
29
+ function buildCaptureCommand(opts) {
30
+ const iface = String(opts.iface || "eth0").trim() || "eth0";
31
+ const fileBase = sanitizeFileBase(opts.fileBase);
32
+ const count = Number.isFinite(opts.count) ? Math.trunc(opts.count) : 1e5;
33
+ if (count <= 0) throw new Error("count must be > 0");
34
+ const size = String(opts.size || "all").trim() || "all";
35
+ const args = [
36
+ "utils",
37
+ "network",
38
+ "capture",
39
+ iface,
40
+ "file",
41
+ fileBase,
42
+ "count",
43
+ String(count),
44
+ "size",
45
+ size
46
+ ];
47
+ if (opts.portFilter != null) {
48
+ const p = Math.trunc(opts.portFilter);
49
+ if (p < 1 || p > 65535) throw new Error("portFilter must be 1..65535");
50
+ args.push("port", String(p));
51
+ }
52
+ if (opts.hostFilterIp) {
53
+ const ip = String(opts.hostFilterIp).trim();
54
+ if (!ip) throw new Error("hostFilterIp must be non-empty");
55
+ args.push("host", "ip", ip);
56
+ }
57
+ return args.join(" ");
77
58
  }
78
- export function remoteCapturePath(fileBase) {
79
- const fb = sanitizeFileBase(fileBase);
80
- return `/var/log/active/platform/cli/${fb}.cap`;
59
+ function remoteCapturePath(fileBase) {
60
+ const fb = sanitizeFileBase(fileBase);
61
+ return `/var/log/active/platform/cli/${fb}.cap`;
81
62
  }
82
- export function remoteCaptureCandidates(fileBase, maxParts = 10) {
83
- const fb = sanitizeFileBase(fileBase);
84
- const base = `/var/log/active/platform/cli/${fb}.cap`;
85
- const out = [base];
86
- // Some CUCM/VOS versions roll packet capture files as .cap01, .cap02, ...
87
- for (let i = 1; i <= maxParts; i++) {
88
- const suffix = String(i).padStart(2, "0");
89
- out.push(`/var/log/active/platform/cli/${fb}.cap${suffix}`);
90
- }
91
- return out;
63
+ function remoteCaptureCandidates(fileBase, maxParts = 10) {
64
+ const fb = sanitizeFileBase(fileBase);
65
+ const base = `/var/log/active/platform/cli/${fb}.cap`;
66
+ const out = [base];
67
+ for (let i = 1; i <= maxParts; i++) {
68
+ const suffix = String(i).padStart(2, "0");
69
+ out.push(`/var/log/active/platform/cli/${fb}.cap${suffix}`);
70
+ }
71
+ return out;
92
72
  }
93
73
  function waitFor(channel, session, predicate, timeoutMs, timeoutMessage) {
94
- return new Promise((resolve, reject) => {
95
- if (predicate())
96
- return resolve();
97
- const onData = () => {
98
- if (predicate())
99
- cleanup(true);
100
- };
101
- let timer;
102
- const cleanup = (ok) => {
103
- channel.off("data", onData);
104
- channel.stderr.off("data", onData);
105
- if (timer)
106
- clearTimeout(timer);
107
- if (ok)
108
- resolve();
109
- else {
110
- const tail = (session.lastStdout || session.lastStderr || "").slice(-400);
111
- reject(new Error(`${timeoutMessage}. lastOutput=${JSON.stringify(tail)}`));
112
- }
113
- };
114
- channel.on("data", onData);
115
- channel.stderr.on("data", onData);
116
- timer = setTimeout(() => cleanup(false), Math.max(1000, timeoutMs));
117
- timer.unref?.();
118
- });
74
+ return new Promise((resolve, reject) => {
75
+ if (predicate()) return resolve();
76
+ const onData = () => {
77
+ if (predicate()) cleanup(true);
78
+ };
79
+ let timer;
80
+ const cleanup = (ok) => {
81
+ channel.off("data", onData);
82
+ channel.stderr.off("data", onData);
83
+ if (timer) clearTimeout(timer);
84
+ if (ok) resolve();
85
+ else {
86
+ const tail = (session.lastStdout || session.lastStderr || "").slice(-400);
87
+ reject(new Error(`${timeoutMessage}. lastOutput=${JSON.stringify(tail)}`));
88
+ }
89
+ };
90
+ channel.on("data", onData);
91
+ channel.stderr.on("data", onData);
92
+ timer = setTimeout(() => cleanup(false), Math.max(1e3, timeoutMs));
93
+ timer.unref?.();
94
+ });
119
95
  }
120
- export class PacketCaptureManager {
121
- active = new Map();
122
- state;
123
- constructor(opts) {
124
- this.state = opts?.state || defaultStateStore();
125
- }
126
- list() {
127
- return [...this.active.values()].map((a) => a.session);
128
- }
129
- async start(opts) {
130
- const iface = String(opts.iface || "eth0").trim() || "eth0";
131
- const fileBase = sanitizeFileBase(opts.fileBase || `cap_${Date.now()}`);
132
- const count = opts.count ?? 100000;
133
- const size = opts.size ?? "all";
134
- const cmd = buildCaptureCommand({ iface, fileBase, count, size, hostFilterIp: opts.hostFilterIp, portFilter: opts.portFilter });
135
- const id = crypto.randomUUID();
136
- const sshPort = opts.sshPort ?? (process.env.CUCM_SSH_PORT ? Number.parseInt(process.env.CUCM_SSH_PORT, 10) : 22);
137
- const auth = resolveSshAuth(opts.auth);
138
- const client = new Client();
139
- const startedAt = new Date().toISOString();
140
- const session = {
141
- id,
96
+ class PacketCaptureManager {
97
+ active = /* @__PURE__ */ new Map();
98
+ state;
99
+ constructor(opts) {
100
+ this.state = opts?.state || defaultStateStore();
101
+ }
102
+ list() {
103
+ return [...this.active.values()].map((a) => a.session);
104
+ }
105
+ async start(opts) {
106
+ const iface = String(opts.iface || "eth0").trim() || "eth0";
107
+ const fileBase = sanitizeFileBase(opts.fileBase || `cap_${Date.now()}`);
108
+ const count = opts.count ?? 1e5;
109
+ const size = opts.size ?? "all";
110
+ const cmd = buildCaptureCommand({ iface, fileBase, count, size, hostFilterIp: opts.hostFilterIp, portFilter: opts.portFilter });
111
+ const id = crypto.randomUUID();
112
+ const sshPort = opts.sshPort ?? (process.env.CUCM_SSH_PORT ? Number.parseInt(process.env.CUCM_SSH_PORT, 10) : 22);
113
+ const auth = resolveSshAuth(opts.auth);
114
+ const client = new Client();
115
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
116
+ const session = {
117
+ id,
118
+ host: opts.host,
119
+ startedAt,
120
+ iface,
121
+ fileBase,
122
+ remoteFilePath: remoteCapturePath(fileBase),
123
+ remoteFileCandidates: remoteCaptureCandidates(fileBase)
124
+ };
125
+ this.state.upsert({
126
+ ...session,
127
+ stoppedAt: session.stoppedAt
128
+ });
129
+ const startTimeoutMs = Math.max(2e3, Math.trunc(opts.startTimeoutMs ?? 3e4));
130
+ let connectTimer;
131
+ const connectTimeout = new Promise((_, reject) => {
132
+ connectTimer = setTimeout(() => reject(new Error(`SSH connect timed out after ${startTimeoutMs}ms`)), startTimeoutMs);
133
+ connectTimer.unref?.();
134
+ });
135
+ try {
136
+ await Promise.race([
137
+ new Promise((resolve, reject) => {
138
+ client.on("ready", () => resolve()).on("error", (e) => reject(e)).connect({
142
139
  host: opts.host,
143
- startedAt,
144
- iface,
145
- fileBase,
146
- remoteFilePath: remoteCapturePath(fileBase),
147
- remoteFileCandidates: remoteCaptureCandidates(fileBase),
148
- };
149
- // Persist early so we can recover/download even if the MCP process restarts.
150
- this.state.upsert({
151
- ...session,
152
- stoppedAt: session.stoppedAt,
153
- });
154
- await new Promise((resolve, reject) => {
155
- client
156
- .on("ready", () => resolve())
157
- .on("error", (e) => reject(e))
158
- .connect({
159
- host: opts.host,
160
- port: sshPort,
161
- username: auth.username,
162
- password: auth.password,
163
- readyTimeout: 15000,
164
- });
165
- });
166
- // CUCM SSH presents an interactive CLI shell. `exec()` is not reliably supported,
167
- // so we open a shell and type commands at the prompt.
168
- const channel = await new Promise((resolve, reject) => {
169
- client.shell({ term: "vt100", cols: 120, rows: 40 }, (err, ch) => {
170
- if (err)
171
- return reject(err);
172
- resolve(ch);
173
- });
174
- });
175
- channel.on("data", (buf) => {
176
- session.lastStdout = buf.toString("utf8").slice(-2000);
177
- this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
178
- });
179
- channel.stderr.on("data", (buf) => {
180
- session.lastStderr = buf.toString("utf8").slice(-2000);
181
- this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
182
- });
183
- channel.on("close", (code) => {
184
- session.exitCode = code;
185
- session.stoppedAt = session.stoppedAt || new Date().toISOString();
186
- // If the capture stopped unexpectedly, drop it from the active map.
187
- this.active.delete(id);
188
- this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
189
- try {
190
- client.end();
191
- client.destroy();
192
- }
193
- catch {
194
- // ignore
195
- }
196
- });
197
- // Wait for prompt before running capture command.
198
- // Some CUCM shells don't print a prompt until you send a newline.
199
- const promptTimeoutMs = Math.max(2000, Math.trunc(opts.startTimeoutMs ?? 30_000));
200
- try {
201
- try {
202
- channel.write("\n");
203
- }
204
- catch {
205
- // ignore
206
- }
207
- // Nudge a couple times if no prompt yet.
208
- void (async () => {
209
- const delays = [400, 1200, 2500];
210
- for (const d of delays) {
211
- await new Promise((r) => setTimeout(r, d));
212
- if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr))
213
- return;
214
- try {
215
- channel.write("\n");
216
- }
217
- catch {
218
- // ignore
219
- }
220
- }
221
- })();
222
- await waitFor(channel, session, () => looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr), promptTimeoutMs, "Timeout waiting for CUCM CLI prompt");
223
- channel.write(`${cmd}\n`);
224
- }
225
- catch (e) {
226
- try {
227
- channel.end();
228
- channel.close();
229
- }
230
- catch {
231
- // ignore
232
- }
233
- try {
234
- client.end();
235
- client.destroy();
236
- }
237
- catch {
238
- // ignore
239
- }
240
- throw e;
241
- }
242
- this.active.set(id, { session, client, channel });
243
- // Optional guard: stop after a duration even if packet count isn't reached.
244
- if (opts.maxDurationMs != null) {
245
- const dur = Math.max(250, Math.trunc(opts.maxDurationMs));
246
- const t = setTimeout(() => {
247
- void this.stop(id).catch(() => {
248
- // ignore
249
- });
250
- }, dur);
251
- t.unref?.();
140
+ port: sshPort,
141
+ username: auth.username,
142
+ password: auth.password,
143
+ readyTimeout: 15e3
144
+ });
145
+ }),
146
+ connectTimeout
147
+ ]);
148
+ } finally {
149
+ if (connectTimer) clearTimeout(connectTimer);
150
+ }
151
+ const channel = await new Promise((resolve, reject) => {
152
+ client.shell({ term: "vt100", cols: 120, rows: 40 }, (err, ch) => {
153
+ if (err) return reject(err);
154
+ resolve(ch);
155
+ });
156
+ });
157
+ channel.on("data", (buf) => {
158
+ session.lastStdout = buf.toString("utf8").slice(-2e3);
159
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
160
+ });
161
+ channel.stderr.on("data", (buf) => {
162
+ session.lastStderr = buf.toString("utf8").slice(-2e3);
163
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
164
+ });
165
+ channel.on("close", (code) => {
166
+ session.exitCode = code;
167
+ session.stoppedAt = session.stoppedAt || (/* @__PURE__ */ new Date()).toISOString();
168
+ this.active.delete(id);
169
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
170
+ try {
171
+ client.end();
172
+ client.destroy();
173
+ } catch {
174
+ }
175
+ });
176
+ const promptTimeoutMs = startTimeoutMs;
177
+ try {
178
+ try {
179
+ channel.write("\n");
180
+ } catch {
181
+ }
182
+ void (async () => {
183
+ const delays = [400, 1200, 2500];
184
+ for (const d of delays) {
185
+ await new Promise((r) => setTimeout(r, d));
186
+ if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) return;
187
+ try {
188
+ channel.write("\n");
189
+ } catch {
190
+ }
252
191
  }
253
- return session;
192
+ })();
193
+ await waitFor(
194
+ channel,
195
+ session,
196
+ () => looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr),
197
+ promptTimeoutMs,
198
+ "Timeout waiting for CUCM CLI prompt"
199
+ );
200
+ channel.write(`${cmd}
201
+ `);
202
+ } catch (e) {
203
+ try {
204
+ channel.end();
205
+ channel.close();
206
+ } catch {
207
+ }
208
+ try {
209
+ client.end();
210
+ client.destroy();
211
+ } catch {
212
+ }
213
+ throw e;
254
214
  }
255
- async stop(captureId, timeoutMs = 90_000) {
256
- const a = this.active.get(captureId);
257
- if (!a)
258
- throw new Error(`Unknown captureId: ${captureId}`);
259
- const { channel, client, session } = a;
260
- const done = new Promise((resolve) => {
261
- if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
262
- return resolve();
263
- }
264
- // CUCM CLI can keep the SSH channel open after the command finishes
265
- // (it returns to a prompt rather than exiting). Treat prompt as "stopped".
266
- const onData = () => {
267
- if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
268
- cleanup();
269
- resolve();
270
- }
271
- };
272
- const cleanup = () => {
273
- channel.off("data", onData);
274
- channel.stderr.off("data", onData);
275
- };
276
- const finish = () => {
277
- cleanup();
278
- resolve();
279
- };
280
- channel.once("exit", finish);
281
- channel.once("close", finish);
282
- channel.once("end", finish);
283
- channel.on("data", onData);
284
- channel.stderr.on("data", onData);
215
+ this.active.set(id, { session, client, channel });
216
+ if (opts.maxDurationMs != null) {
217
+ const dur = Math.max(250, Math.trunc(opts.maxDurationMs));
218
+ const t = setTimeout(() => {
219
+ void this.stop(id).catch(() => {
285
220
  });
286
- const sendInterrupt = () => {
287
- // Best-effort interrupt.
288
- try {
289
- if (typeof channel.signal === "function")
290
- channel.signal("INT");
291
- // Always also write Ctrl-C for PTY sessions.
292
- channel.write("\x03");
293
- }
294
- catch {
295
- try {
296
- channel.write("\x03");
297
- }
298
- catch {
299
- // ignore
300
- }
301
- }
302
- };
303
- // CUCM can take a while to flush/close captures (especially big buffers).
304
- // Also, some CLI flows require a second Ctrl-C or a newline to return to prompt.
305
- const resolvedTimeoutMs = Math.max(5000, timeoutMs || 0);
306
- const deadline = Date.now() + resolvedTimeoutMs;
307
- sendInterrupt();
308
- // Nudge again a couple times if it doesn't exit quickly.
309
- void (async () => {
310
- const delays = [750, 1500, 3000];
311
- for (const d of delays) {
312
- await new Promise((r) => setTimeout(r, d));
313
- if (Date.now() >= deadline)
314
- return;
315
- // If channel already closed, no-op.
316
- sendInterrupt();
317
- try {
318
- channel.write("\n");
319
- }
320
- catch {
321
- // ignore
322
- }
323
- }
324
- })();
325
- try {
326
- let timer;
327
- const timeout = new Promise((_, reject) => {
328
- timer = setTimeout(() => reject(new Error("Timeout waiting for capture to stop")), resolvedTimeoutMs);
329
- // Don't keep the process alive just because we're waiting.
330
- timer.unref?.();
331
- });
332
- try {
333
- await Promise.race([done, timeout]);
334
- }
335
- finally {
336
- if (timer)
337
- clearTimeout(timer);
338
- }
339
- }
340
- catch (e) {
341
- // Don't hard-fail: if the CLI doesn't emit a prompt/exit, we still want to:
342
- // - close SSH resources
343
- // - let the caller try DIME downloads (.cap, .cap01, etc)
344
- session.stopTimedOut = true;
345
- session.stoppedAt = session.stoppedAt || new Date().toISOString();
346
- this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
347
- try {
348
- channel.close();
349
- }
350
- catch {
351
- // ignore
352
- }
353
- }
354
- session.stoppedAt = new Date().toISOString();
355
- this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
356
- // If we're back at a CUCM prompt, try to close the session cleanly.
221
+ }, dur);
222
+ t.unref?.();
223
+ }
224
+ return session;
225
+ }
226
+ async stop(captureId, timeoutMs = 9e4) {
227
+ const a = this.active.get(captureId);
228
+ if (!a) throw new Error(`Unknown captureId: ${captureId}`);
229
+ const { channel, client, session } = a;
230
+ const done = new Promise((resolve) => {
231
+ if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
232
+ return resolve();
233
+ }
234
+ const onData = () => {
357
235
  if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
358
- try {
359
- channel.write("exit\n");
360
- }
361
- catch {
362
- // ignore
363
- }
364
- }
365
- // Ensure the channel is closed so the SSH client can terminate promptly.
366
- try {
367
- channel.end();
368
- }
369
- catch {
370
- // ignore
236
+ cleanup();
237
+ resolve();
371
238
  }
239
+ };
240
+ const cleanup = () => {
241
+ channel.off("data", onData);
242
+ channel.stderr.off("data", onData);
243
+ };
244
+ const finish = () => {
245
+ cleanup();
246
+ resolve();
247
+ };
248
+ channel.once("exit", finish);
249
+ channel.once("close", finish);
250
+ channel.once("end", finish);
251
+ channel.on("data", onData);
252
+ channel.stderr.on("data", onData);
253
+ });
254
+ const sendInterrupt = () => {
255
+ try {
256
+ if (typeof channel.signal === "function") channel.signal("INT");
257
+ channel.write("");
258
+ } catch {
372
259
  try {
373
- channel.close();
374
- }
375
- catch {
376
- // ignore
377
- }
378
- // Attempt to learn the actual remote file path from CLI output.
379
- // This helps when CUCM appends suffixes or reports a different on-disk location.
380
- const inferred = extractCapFilePath(session.lastStdout) || extractCapFilePath(session.lastStderr);
381
- if (inferred) {
382
- session.remoteFilePath = inferred;
383
- if (!session.remoteFileCandidates.includes(inferred)) {
384
- session.remoteFileCandidates = [inferred, ...session.remoteFileCandidates];
385
- }
260
+ channel.write("");
261
+ } catch {
386
262
  }
387
- this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
388
- // stop() implies cleanup
389
- this.active.delete(captureId);
263
+ }
264
+ };
265
+ const resolvedTimeoutMs = Math.max(5e3, timeoutMs || 0);
266
+ const deadline = Date.now() + resolvedTimeoutMs;
267
+ sendInterrupt();
268
+ void (async () => {
269
+ const delays = [750, 1500, 3e3];
270
+ for (const d of delays) {
271
+ await new Promise((r) => setTimeout(r, d));
272
+ if (Date.now() >= deadline) return;
273
+ sendInterrupt();
390
274
  try {
391
- client.end();
392
- client.destroy();
393
- }
394
- catch {
395
- // ignore
275
+ channel.write("\n");
276
+ } catch {
396
277
  }
397
- return session;
278
+ }
279
+ })();
280
+ try {
281
+ let timer;
282
+ const timeout = new Promise((_, reject) => {
283
+ timer = setTimeout(() => reject(new Error("Timeout waiting for capture to stop")), resolvedTimeoutMs);
284
+ timer.unref?.();
285
+ });
286
+ try {
287
+ await Promise.race([done, timeout]);
288
+ } finally {
289
+ if (timer) clearTimeout(timer);
290
+ }
291
+ } catch (e) {
292
+ session.stopTimedOut = true;
293
+ session.stoppedAt = session.stoppedAt || (/* @__PURE__ */ new Date()).toISOString();
294
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
295
+ try {
296
+ channel.close();
297
+ } catch {
298
+ }
299
+ }
300
+ session.stoppedAt = (/* @__PURE__ */ new Date()).toISOString();
301
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
302
+ if (looksLikeCucmPrompt(session.lastStdout) || looksLikeCucmPrompt(session.lastStderr)) {
303
+ try {
304
+ channel.write("exit\n");
305
+ } catch {
306
+ }
307
+ }
308
+ try {
309
+ channel.end();
310
+ } catch {
311
+ }
312
+ try {
313
+ channel.close();
314
+ } catch {
315
+ }
316
+ const inferred = extractCapFilePath(session.lastStdout) || extractCapFilePath(session.lastStderr);
317
+ if (inferred) {
318
+ session.remoteFilePath = inferred;
319
+ if (!session.remoteFileCandidates.includes(inferred)) {
320
+ session.remoteFileCandidates = [inferred, ...session.remoteFileCandidates];
321
+ }
322
+ }
323
+ this.state.upsert({ ...session, stoppedAt: session.stoppedAt });
324
+ this.active.delete(captureId);
325
+ try {
326
+ client.end();
327
+ client.destroy();
328
+ } catch {
398
329
  }
330
+ return session;
331
+ }
399
332
  }
333
+ export {
334
+ PacketCaptureManager,
335
+ buildCaptureCommand,
336
+ remoteCaptureCandidates,
337
+ remoteCapturePath,
338
+ resolveSshAuth,
339
+ sanitizeFileBase
340
+ };
341
+ //# sourceMappingURL=packetCapture.js.map