@tikoci/quickchr 0.4.6 → 0.4.8

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,146 @@
1
+ /** Public cache operations for CI consumers and the quickchr CLI. */
2
+
3
+ import { join } from "node:path";
4
+ import { ensureCachedImage, isUsableCachedImage } from "./images.ts";
5
+ import type { ProgressLogger } from "./log.ts";
6
+ import { getCacheDir } from "./state.ts";
7
+ import { ARCHES, CHANNELS, QuickCHRError, type Arch, type Channel } from "./types.ts";
8
+ import { chrImageBasename, isValidVersion, resolveVersion } from "./versions.ts";
9
+
10
+ export const CACHE_VERSION_UNRESOLVED = "unresolved";
11
+ const CACHE_VERSION_MAX_LENGTH = 32;
12
+
13
+ export interface CacheTargetOptions {
14
+ /** Resolve this RouterOS channel. Mutually exclusive with `version`. */
15
+ channel?: Channel;
16
+ /** Use this concrete RouterOS version without a network resolution request. */
17
+ version?: string;
18
+ /** Override the cache directory. Defaults to quickchr's platform-correct cache. */
19
+ cacheDir?: string;
20
+ /** CHR architecture. `auto` and omission select the Bun process architecture. */
21
+ arch?: Arch | "auto";
22
+ }
23
+
24
+ export interface CacheKeyResult {
25
+ dir: string;
26
+ /** Concrete RouterOS version, or `unresolved` when channel resolution is offline. */
27
+ version: string;
28
+ arch: Arch;
29
+ }
30
+
31
+ export interface CacheAddOptions extends CacheTargetOptions {
32
+ logger?: ProgressLogger;
33
+ }
34
+
35
+ export interface CacheAddResult extends CacheKeyResult {
36
+ path: string;
37
+ cacheHit: boolean;
38
+ }
39
+
40
+ export type CacheVersionResolver = (channel: Channel) => Promise<string>;
41
+
42
+ function validateTarget(options: CacheTargetOptions): { channel?: Channel; version?: string } {
43
+ if (options.channel !== undefined && options.version !== undefined) {
44
+ throw new QuickCHRError("INVALID_VERSION", "Choose either a RouterOS channel or a concrete version, not both");
45
+ }
46
+ if (options.channel !== undefined && !(CHANNELS as readonly string[]).includes(options.channel)) {
47
+ throw new QuickCHRError(
48
+ "INVALID_VERSION",
49
+ `Invalid RouterOS channel "${options.channel}" — expected one of ${CHANNELS.join(", ")}`,
50
+ );
51
+ }
52
+ if (options.version !== undefined && !isValidVersion(options.version)) {
53
+ throw new QuickCHRError("INVALID_VERSION", `Invalid RouterOS version: ${options.version}`);
54
+ }
55
+ return { channel: options.channel, version: options.version };
56
+ }
57
+
58
+ async function concreteVersion(
59
+ options: CacheTargetOptions,
60
+ resolver: CacheVersionResolver,
61
+ ): Promise<{ channel?: Channel; version: string }> {
62
+ const target = validateTarget(options);
63
+ if (target.version !== undefined) return { version: target.version };
64
+ const channel = target.channel ?? "stable";
65
+ const version = await resolver(channel);
66
+ if (!isValidVersion(version)) {
67
+ throw new QuickCHRError(
68
+ "INVALID_VERSION",
69
+ `Unexpected version "${version}" resolved for channel "${channel}"`,
70
+ );
71
+ }
72
+ return { channel, version };
73
+ }
74
+
75
+ function concreteArch(arch: Arch | "auto" | undefined): Arch {
76
+ if (arch === undefined || arch === "auto") return process.arch === "arm64" ? "arm64" : "x86";
77
+ if (!(ARCHES as readonly string[]).includes(arch)) {
78
+ throw new QuickCHRError("INVALID_ARCH", `Invalid architecture "${arch}" — expected one of ${ARCHES.join(", ")}, auto`);
79
+ }
80
+ return arch;
81
+ }
82
+
83
+ /**
84
+ * Return quickchr's cache directory and the concrete version for a CI key.
85
+ *
86
+ * An explicit version is validated and returned without a network call. A
87
+ * channel resolution failure degrades to the stable `unresolved` sentinel so
88
+ * cache lookup cannot make an otherwise useful offline job fail.
89
+ */
90
+ export async function cacheKey(
91
+ options: CacheTargetOptions = {},
92
+ resolver: CacheVersionResolver = resolveVersion,
93
+ ): Promise<CacheKeyResult> {
94
+ const target = validateTarget(options);
95
+ const dir = options.cacheDir ?? getCacheDir();
96
+ const arch = concreteArch(options.arch);
97
+ if (target.version !== undefined) {
98
+ return {
99
+ dir,
100
+ version: target.version.length <= CACHE_VERSION_MAX_LENGTH ? target.version : CACHE_VERSION_UNRESOLVED,
101
+ arch,
102
+ };
103
+ }
104
+ try {
105
+ const resolved = await concreteVersion(options, resolver);
106
+ return {
107
+ dir,
108
+ version: resolved.version.length <= CACHE_VERSION_MAX_LENGTH ? resolved.version : CACHE_VERSION_UNRESOLVED,
109
+ arch,
110
+ };
111
+ } catch (error) {
112
+ if (error instanceof QuickCHRError && error.code === "DOWNLOAD_FAILED") {
113
+ return { dir, version: CACHE_VERSION_UNRESOLVED, arch };
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ /** Resolve and cache one CHR image without creating or booting a machine. */
120
+ export async function cacheAdd(
121
+ options: CacheAddOptions = {},
122
+ resolver: CacheVersionResolver = resolveVersion,
123
+ ): Promise<CacheAddResult> {
124
+ const resolved = await concreteVersion(options, resolver);
125
+ if (resolved.version.length > CACHE_VERSION_MAX_LENGTH) {
126
+ throw new QuickCHRError("INVALID_VERSION", `RouterOS version is too long: ${resolved.version}`);
127
+ }
128
+ const arch = concreteArch(options.arch);
129
+ const dir = options.cacheDir ?? getCacheDir();
130
+ const expectedPath = join(dir, `${chrImageBasename(resolved.version, arch)}.img`);
131
+ const cacheHit = await isUsableCachedImage(expectedPath);
132
+
133
+ try {
134
+ const path = await ensureCachedImage(resolved.version, arch, dir, options.logger);
135
+ return { dir, version: resolved.version, arch, path, cacheHit };
136
+ } catch (error) {
137
+ if (resolved.channel && error instanceof QuickCHRError) {
138
+ throw new QuickCHRError(
139
+ error.code,
140
+ `Failed to cache channel "${resolved.channel}" -> ${resolved.version} (${arch}): ${error.message}`,
141
+ error.installHint,
142
+ );
143
+ }
144
+ throw error;
145
+ }
146
+ }
@@ -15,6 +15,7 @@ import type { Arch, ChannelTcpEndpoint, QgaCommand } from "./types.ts";
15
15
  import { QuickCHRError } from "./types.ts";
16
16
  import { qgaProbe, qgaRawCommand } from "./qga.ts";
17
17
  import { qgaKvmWarning } from "./platform.ts";
18
+ import { newMonitorPhaseTimings, summarizeMonitorPhases } from "./diagnostics.ts";
18
19
 
19
20
  /**
20
21
  * Resolve the IPC path for a QEMU channel (monitor, serial, qga).
@@ -87,9 +88,19 @@ export async function monitorCommand(
87
88
  const endpoint = channelEndpoint(machineDir, "monitor", portBase);
88
89
 
89
90
  return new Promise((resolve, reject) => {
91
+ // Per-phase timings so a timeout says *where* the round-trip stalled rather
92
+ // than just that it did (#80). Only read on the timeout path — a successful
93
+ // command pays nothing but a few Date.now() calls.
94
+ const started = Date.now();
95
+ const phases = newMonitorPhaseTimings();
96
+ const since = () => Date.now() - started;
97
+
90
98
  const timeout = setTimeout(() => {
91
99
  socket.destroy();
92
- reject(new QuickCHRError("BOOT_TIMEOUT", "Monitor command timed out"));
100
+ reject(new QuickCHRError(
101
+ "BOOT_TIMEOUT",
102
+ `Monitor command timed out after ${timeoutMs}ms: "${command}" — ${summarizeMonitorPhases(phases)}`,
103
+ ));
93
104
  }, timeoutMs);
94
105
 
95
106
  let buffer = "";
@@ -99,13 +110,20 @@ export async function monitorCommand(
99
110
  ? connect({ path: endpoint })
100
111
  : connect(endpoint.port, endpoint.host);
101
112
 
113
+ socket.on("connect", () => { phases.connectedAtMs = since(); });
114
+
102
115
  socket.on("data", (data) => {
116
+ phases.bytesReceived += data.length;
117
+ phases.firstByteAtMs ??= since();
118
+ if (sentCommand) phases.responseFirstByteAtMs ??= since();
103
119
  buffer += data.toString();
104
120
  // Wait for the (qemu) prompt before sending command
105
121
  if (!sentCommand && buffer.includes("(qemu)")) {
106
122
  sentCommand = true;
123
+ phases.promptAtMs = since();
107
124
  buffer = "";
108
125
  socket.write(command + "\n");
126
+ phases.commandWrittenAtMs = since();
109
127
  } else if (sentCommand && buffer.includes("(qemu)")) {
110
128
  // Got response — strip the prompt, the command echo, and ANSI noise
111
129
  clearTimeout(timeout);
@@ -122,7 +140,10 @@ export async function monitorCommand(
122
140
  if (sentCommand) {
123
141
  resolve(cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, "")));
124
142
  } else {
125
- reject(new QuickCHRError("MACHINE_STOPPED", "Monitor socket closed before command could be sent"));
143
+ reject(new QuickCHRError(
144
+ "MACHINE_STOPPED",
145
+ `Monitor socket closed before command could be sent — ${summarizeMonitorPhases(phases)}`,
146
+ ));
126
147
  }
127
148
  });
128
149
 
@@ -328,7 +328,7 @@ _quickchr() {
328
328
  cword=${D}COMP_CWORD
329
329
  }
330
330
 
331
- local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor settings setup completions version help"
331
+ local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
332
332
 
333
333
  if [[ ${D}cword -eq 1 ]]; then
334
334
  COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
@@ -352,6 +352,10 @@ _quickchr() {
352
352
  local opts="--install --uninstall --status --dry-run --shell"
353
353
  COMPREPLY=( $(compgen -W "${D}opts" -- "${D}cur") )
354
354
  ;;
355
+ cache)
356
+ local opts="add key list prune clear"
357
+ COMPREPLY=( $(compgen -W "${D}opts" -- "${D}cur") )
358
+ ;;
355
359
  help)
356
360
  COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
357
361
  ;;
@@ -401,6 +405,7 @@ _quickchr() {
401
405
  'snap:Manage snapshots (alias)'
402
406
  'networks:Network discovery and socket management'
403
407
  'net:Network discovery (alias)'
408
+ 'cache:Prefetch and manage downloaded CHR images'
404
409
  'doctor:Check prerequisites'
405
410
  'settings:Manage global quickchr settings'
406
411
  'setup:Interactive setup wizard'
@@ -434,8 +439,12 @@ _quickchr() {
434
439
  )
435
440
  _describe 'option' opts
436
441
  ;;
442
+ cache)
443
+ local subcommands=(add key list prune clear)
444
+ _describe 'cache subcommand' subcommands
445
+ ;;
437
446
  help)
438
- local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor settings setup completions version)
447
+ local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
439
448
  _describe 'command' commands
440
449
  ;;
441
450
  esac
@@ -451,7 +460,7 @@ function generateFishCompletion(): string {
451
460
  return `# quickchr fish completion
452
461
  # Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
453
462
 
454
- set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor settings setup completions version help
463
+ set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
455
464
 
456
465
  # Disable file completions globally for quickchr
457
466
  complete -c quickchr -f
@@ -489,6 +498,8 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
489
498
  -a "snapshot snap" -d "Manage snapshots"
490
499
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
491
500
  -a "networks net" -d "Network discovery and socket management"
501
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
502
+ -a "cache" -d "Prefetch and manage downloaded CHR images"
492
503
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
493
504
  -a "doctor" -d "Check prerequisites"
494
505
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
@@ -518,5 +529,8 @@ complete -c quickchr -n "__fish_seen_subcommand_from completions" -l uninstall -
518
529
  complete -c quickchr -n "__fish_seen_subcommand_from completions" -l status -d "Show install status"
519
530
  complete -c quickchr -n "__fish_seen_subcommand_from completions" -l dry-run -d "Show what would happen"
520
531
  complete -c quickchr -n "__fish_seen_subcommand_from completions" -l shell -d "Specify shell" -a "bash zsh fish"
532
+
533
+ # cache subcommands
534
+ complete -c quickchr -n "__fish_seen_subcommand_from cache" -a "add key list prune clear" -d "cache subcommand"
521
535
  `;
522
536
  }
@@ -27,6 +27,31 @@ const DEFAULT_TIMEOUT_MS = 30_000;
27
27
  /** Interval for polling the buffer in waitFor(). */
28
28
  const POLL_INTERVAL_MS = 250;
29
29
 
30
+ /**
31
+ * What one *fresh* serial login costs, for callers that must budget for it.
32
+ *
33
+ * A login is not a round-trip — it is dominated by RouterOS, not by us.
34
+ * Measured 2026-07-31 on 7.23.2 / x86 / HVF with a raw socket and timestamped
35
+ * receives (tikoci/quickchr#69, bite B10 of #110):
36
+ *
37
+ * banner → `Login:` 10–25 ms
38
+ * username → `Password:` 54–56 ms
39
+ * password → license `[Y/n]:` **10,184 ms** ← the whole cost
40
+ * full fresh `consoleExec()` 11,364 / 11,360 ms
41
+ * `consoleExec()` on an already-open session 306 ms
42
+ *
43
+ * So a budget sized on the ~0.3 s round-trip figure is ~40× short of what the
44
+ * first call needs, and a caller that divides such a budget across credential
45
+ * candidates can never complete even one. That is exactly what made
46
+ * `captureGuestSnapshot()` report `consoleReachable: false` against provably
47
+ * healthy guests. Anything that logs in must budget from this constant, not
48
+ * from the round-trip cost.
49
+ *
50
+ * 15 s is the measured 11.4 s plus ~30% margin; TCG and cross-arch guests are
51
+ * slower still, which is why callers stack it rather than treat it as a cap.
52
+ */
53
+ export const CONSOLE_LOGIN_COST_MS = 15_000;
54
+
30
55
  /** Pre-prompt pattern: the "] > " suffix is version-proof since it
31
56
  * covers [admin@MikroTik] > , [admin@CHR] > , and any custom identity. */
32
57
  const PROMPT_PATTERN = "] > ";
@@ -120,50 +145,130 @@ async function waitFor(
120
145
  }
121
146
 
122
147
  /**
123
- * Wait for the LAST occurrence of `pattern` at or after `searchFrom` in the
124
- * buffer, determined by buffer stability.
148
+ * Ceiling on the quiet period the fallback prompt terminator waits for.
149
+ *
150
+ * This is no longer the normal way a command reply ends — {@link makeSentinel}
151
+ * is — so it is sized for safety rather than speed, and does not need to scale
152
+ * with the accelerator. The old flat 150 ms *was* the normal path, and that is
153
+ * exactly what let a prompt **redraw** terminate a slow large reply (#109): the
154
+ * two biggest guest-snapshot queries came back empty on a loaded TCG guest while
155
+ * every small one succeeded.
156
+ */
157
+ const FALLBACK_PROMPT_STABLE_MS = 2_000;
158
+
159
+ /** Floor, so a very small `timeoutMs` cannot reduce the window to nothing. */
160
+ const MIN_PROMPT_STABLE_MS = 250;
161
+
162
+ /**
163
+ * The quiet period to require before the fallback prompt may terminate a reply.
164
+ *
165
+ * Derived from the caller's budget rather than fixed: a caller passing less than
166
+ * ~2 s would otherwise never reach the fallback at all, and an unframed but
167
+ * *complete* reply would surface as a `BOOT_TIMEOUT` instead of `framed: false`.
168
+ * A small fraction leaves the rest of the budget for the reply itself. The
169
+ * window is only a secondary guard now — {@link isTrailingPrompt} already
170
+ * rejects a redraw structurally, whatever the timing.
171
+ */
172
+ function promptStableWindow(timeoutMs: number): number {
173
+ return Math.min(FALLBACK_PROMPT_STABLE_MS, Math.max(MIN_PROMPT_STABLE_MS, Math.floor(timeoutMs / 8)));
174
+ }
175
+
176
+ /** True when `pattern` at `idx` is the last meaningful thing in the buffer.
177
+ *
178
+ * The redraw that broke #109 is "prompt + repainted command", so the redraw
179
+ * prompt always has the command text after it. Requiring the prompt to sit at
180
+ * the end of the buffer therefore rejects a redraw outright, independently of
181
+ * any timing window. Trailing ANSI and whitespace are ignored — RouterOS paints
182
+ * the prompt with color codes and terminal queries around it. */
183
+ function isTrailingPrompt(buffer: string, idx: number, pattern: string): boolean {
184
+ return stripAnsi(buffer.slice(idx + pattern.length)).trim() === "";
185
+ }
186
+
187
+ /** How a command's reply ended. */
188
+ type ReplyEnd =
189
+ /** The sentinel arrived — the reply is complete by construction. */
190
+ | { kind: "sentinel"; index: number }
191
+ /** No sentinel; a trailing, stable prompt terminated it instead. */
192
+ | { kind: "prompt"; index: number };
193
+
194
+ /**
195
+ * Build the end-of-reply sentinel.
196
+ *
197
+ * The marker is assembled **in the guest** from three string literals, so the
198
+ * echoed command line contains the expression but never the contiguous marker
199
+ * text. That is what makes "the marker appeared" mean "the payload is complete"
200
+ * rather than "the command was echoed".
201
+ *
202
+ * It is sent as a **separate console line**, not chained with `;`. Measured on
203
+ * 7.21.5: `/bogus print; :put ("QC" . "END" . "e1")` prints the syntax error and
204
+ * **never runs the second statement**, and the same holds for a runtime error
205
+ * (`/ip/address add address=nonsense; …`). A `;` chain would therefore lose the
206
+ * terminator on exactly the replies that carry an error message. Two `\r`-
207
+ * terminated lines are two separate console inputs, so the second one still runs.
208
+ */
209
+ function makeSentinel(): { command: string; marker: string; nonce: string } {
210
+ const nonce = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
211
+ return {
212
+ command: `:put ("QCHR" . "${nonce}" . "END")`,
213
+ marker: `QCHR${nonce}END`,
214
+ nonce,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Wait for a command's reply to end — by sentinel, or failing that by a prompt
220
+ * that is both trailing and stable.
221
+ *
222
+ * Both terminators are watched in **one** loop on purpose. Waiting out the
223
+ * sentinel first and only then starting a prompt wait would spend the entire
224
+ * budget before the fallback got a chance, turning every unframed reply into a
225
+ * timeout instead of a slower success.
125
226
  *
126
- * RouterOS's VT100 terminal redraws the prompt+command line during execution,
127
- * so the first "] > " after the command is just the redraw, not the output prompt.
128
- * We wait until no new data arrives for `stableMs` milliseconds, then return
129
- * the position of the LAST occurrence of `pattern`.
227
+ * Returns null if neither terminator arrives in time.
130
228
  */
131
- async function waitForFinalPrompt(
229
+ async function waitForReplyEnd(
132
230
  session: ConsoleSessionState,
231
+ marker: string,
133
232
  pattern: string,
134
233
  searchFrom: number,
135
234
  timeoutMs: number,
136
- stableMs = 150,
137
- ): Promise<number> {
235
+ stableMs = promptStableWindow(timeoutMs),
236
+ ): Promise<ReplyEnd | null> {
138
237
  const FAST_POLL = 50;
139
238
  const deadline = Date.now() + timeoutMs;
140
239
  let lastGrowthAt = Date.now();
141
240
  let lastLen = session.buffer.length;
142
- let lastPromptAt = -1;
143
241
 
144
- while (Date.now() < deadline) {
242
+ const trailingPrompt = (): number => {
243
+ const candidate = session.buffer.lastIndexOf(pattern);
244
+ if (candidate < searchFrom) return -1;
245
+ return isTrailingPrompt(session.buffer, candidate, pattern) ? candidate : -1;
246
+ };
247
+
248
+ for (;;) {
249
+ const sentinelAt = session.buffer.indexOf(marker, searchFrom);
250
+ if (sentinelAt >= 0) {
251
+ session.matchOffset = sentinelAt + marker.length;
252
+ return { kind: "sentinel", index: sentinelAt };
253
+ }
254
+
145
255
  const currentLen = session.buffer.length;
146
256
  if (currentLen > lastLen) {
147
257
  lastLen = currentLen;
148
258
  lastGrowthAt = Date.now();
149
- const candidate = session.buffer.lastIndexOf(pattern);
150
- if (candidate >= searchFrom) lastPromptAt = candidate;
151
- }
152
-
153
- if (lastPromptAt >= 0 && Date.now() - lastGrowthAt >= stableMs) {
154
- session.matchOffset = lastPromptAt + pattern.length;
155
- return lastPromptAt;
156
259
  }
157
260
 
158
- if (session.streamDone) {
159
- if (lastPromptAt >= 0)
160
- session.matchOffset = lastPromptAt + pattern.length;
161
- return lastPromptAt;
261
+ const promptAt = trailingPrompt();
262
+ const quiet = Date.now() - lastGrowthAt >= stableMs;
263
+ if (promptAt >= 0 && (quiet || session.streamDone)) {
264
+ session.matchOffset = promptAt + pattern.length;
265
+ return { kind: "prompt", index: promptAt };
162
266
  }
267
+ if (session.streamDone) return null;
268
+ if (Date.now() >= deadline) return null;
163
269
 
164
270
  await Bun.sleep(FAST_POLL);
165
271
  }
166
- return -1;
167
272
  }
168
273
 
169
274
  /**
@@ -315,12 +420,70 @@ async function handlePostLogin(
315
420
  return false;
316
421
  }
317
422
 
423
+ /**
424
+ * Reduce the raw post-command console text to just the command's output.
425
+ *
426
+ * Measured shape of a RouterOS 7.21.5 serial reply (x86/HVF, QEMU 11.0.3):
427
+ *
428
+ * ```text
429
+ * <cmd>\r[admin@MikroTik] > <cmd>\r\n echo — ONE physical line, bare-\r redraws
430
+ * \r<output>\r\n output, wrapped at the terminal width
431
+ * \r\r\r\r[admin@MikroTik] > \r[admin@MikroTik] > <sentinel echo>\r\n
432
+ * \rQCHR…END\r\n the sentinel; the caller cuts before it
433
+ * ```
434
+ *
435
+ * Two structural rules do the work, and neither relies on matching the command
436
+ * text throughout — a long command is **horizontally scrolled** in the redraw
437
+ * (`<ss=nonsense; :put (…)`), so its opening characters are not present there:
438
+ *
439
+ * - the sentinel echo carries this call's `nonce`, and shares a physical line
440
+ * with the prompt redraw ahead of it, so dropping nonce-bearing lines removes
441
+ * both at once;
442
+ * - the command echo is the first physical line, and is dropped only when it
443
+ * actually looks like one (it carries a prompt redraw, or starts with the
444
+ * command) — so a reply with no echo cannot lose its first output line.
445
+ */
446
+ export function extractOutput(raw: string, command: string, nonce?: string): string {
447
+ const lines = stripAnsi(raw)
448
+ .split(/\r?\n/)
449
+ .filter((l) => nonce === undefined || !l.includes(nonce));
450
+
451
+ const cmdPrefix = command.trim().slice(0, 24);
452
+ const first = lines[0];
453
+ let startIdx =
454
+ first !== undefined &&
455
+ (first.includes(PROMPT_PATTERN) || (cmdPrefix !== "" && first.trimStart().startsWith(cmdPrefix)))
456
+ ? 1
457
+ : 0;
458
+ while (startIdx < lines.length && (lines[startIdx] ?? "").trim() === "") startIdx++;
459
+
460
+ // Trailing prompt fragments and blank lines are the redraw after the reply.
461
+ let endIdx = lines.length;
462
+ for (let i = lines.length - 1; i >= startIdx; i--) {
463
+ const trimmed = (lines[i] ?? "").trim();
464
+ if (trimmed === "" || trimmed.endsWith(PROMPT_PATTERN.trim())) endIdx = i;
465
+ else break;
466
+ }
467
+
468
+ return lines.slice(startIdx, endIdx).join("\n").trim();
469
+ }
470
+
471
+ /** Result of one console command.
472
+ *
473
+ * `framed` says the reply was delimited by the end-of-reply sentinel — i.e. the
474
+ * reader saw the whole reply. When it is false the reply was terminated by the
475
+ * fallback prompt heuristic instead, so an empty `output` means "could not frame
476
+ * the reply", not "the guest printed nothing" (#109). */
477
+ export interface ConsoleExecResult {
478
+ output: string;
479
+ framed: boolean;
480
+ }
481
+
318
482
  /**
319
483
  * Execute a RouterOS CLI command over the serial console.
320
484
  *
321
- * Connects to serial socket, ensures the session is logged in,
322
- * sends the command, and captures output between the command echo
323
- * and the next prompt.
485
+ * Connects to serial socket, ensures the session is logged in, sends the command
486
+ * followed by an end-of-reply sentinel, and captures the output between them.
324
487
  *
325
488
  * @param machineDir Machine directory containing serial.sock
326
489
  * @param command RouterOS CLI command to execute
@@ -335,7 +498,7 @@ export async function consoleExec(
335
498
  password: string = "",
336
499
  timeoutMs: number = DEFAULT_TIMEOUT_MS,
337
500
  portBase?: number,
338
- ): Promise<{ output: string }> {
501
+ ): Promise<ConsoleExecResult> {
339
502
  const session = openSession(machineDir, portBase);
340
503
 
341
504
  try {
@@ -367,62 +530,30 @@ export async function consoleExec(
367
530
  // pre-buffered "] > " from ANSI codes or OSC title sequences RouterOS emits.
368
531
  session.matchOffset = Math.max(session.matchOffset, preCommandOffset);
369
532
 
370
- // Send command
371
- write(session, `${command}\r`);
533
+ // Send the command and the end-of-reply sentinel as two console lines in one
534
+ // write. The sentinel is what terminates the reply; the prompt is only a
535
+ // fallback (see makeSentinel / waitForReplyEnd).
536
+ const sentinel = makeSentinel();
537
+ write(session, `${command}\r${sentinel.command}\r`);
372
538
 
373
- // Wait for the FINAL prompt after the command completes.
374
- // RouterOS VT100 redraws the prompt+command line during execution, so the
375
- // first "] > " is a redraw, not the output prompt. waitForFinalPrompt uses
376
- // buffer stability (no new data for 150ms) to identify the last prompt.
377
- const promptFound = await waitForFinalPrompt(session, PROMPT_PATTERN, preCommandOffset, timeoutMs);
378
- if (promptFound < 0) {
539
+ const end = await waitForReplyEnd(session, sentinel.marker, PROMPT_PATTERN, preCommandOffset, timeoutMs);
540
+ if (end === null) {
379
541
  throw new QuickCHRError(
380
542
  "BOOT_TIMEOUT",
381
- `Console exec: no prompt after command (timed out after ${timeoutMs}ms)`,
543
+ `Console exec: no end-of-reply sentinel and no prompt after command (timed out after ${timeoutMs}ms)`,
382
544
  );
383
545
  }
546
+ const framed = end.kind === "sentinel";
384
547
 
385
- // Extract output: everything between our command and the next prompt.
386
- // promptFound points at "] > " — back up to the newline before the prompt
387
- // line so we don't include the "[admin@...] > " prefix in output.
388
- const lastNewline = session.buffer.lastIndexOf("\n", promptFound);
389
- const sliceEnd = lastNewline >= preCommandOffset ? lastNewline : promptFound;
390
- const rawOutput = session.buffer.slice(preCommandOffset, sliceEnd);
391
-
392
- // Clean up: strip ANSI, strip the command echo line, trim
393
- const cleaned = stripAnsi(rawOutput);
394
- const lines = cleaned.split(/\r?\n/);
395
-
396
- // First non-empty line(s) are the command echo — skip them.
397
- // The echo repeats back what we typed.
398
- let startIdx = 0;
399
- for (let i = 0; i < lines.length; i++) {
400
- const line = lines[i];
401
- if (!line) continue;
402
- const trimmed = line.trim();
403
- // Skip echo lines that contain our command text
404
- if (trimmed.includes(command.trim()) || trimmed === "") {
405
- startIdx = i + 1;
406
- } else {
407
- break;
408
- }
409
- }
548
+ // Cut back to the newline that starts the terminator's own line, so neither
549
+ // the marker nor the "[admin@…] > " prefix lands in the output.
550
+ const lineStart = session.buffer.lastIndexOf("\n", end.index);
551
+ const sliceEnd = lineStart >= preCommandOffset ? lineStart : end.index;
410
552
 
411
- // Also strip trailing prompt fragments and empty lines
412
- let endIdx = lines.length;
413
- for (let i = lines.length - 1; i >= startIdx; i--) {
414
- const line = lines[i];
415
- if (!line) continue;
416
- const trimmed = line.trim();
417
- if (trimmed === "" || trimmed.endsWith(PROMPT_PATTERN.trim())) {
418
- endIdx = i;
419
- } else {
420
- break;
421
- }
422
- }
553
+ const rawOutput = session.buffer.slice(preCommandOffset, sliceEnd);
423
554
 
424
- const output = lines.slice(startIdx, endIdx).join("\n").trim();
425
- return { output };
555
+ const output = extractOutput(rawOutput, command, sentinel.nonce);
556
+ return { output, framed };
426
557
  } finally {
427
558
  session.socket.destroy();
428
559
  }