@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.
- package/DESIGN.md +604 -3
- package/MANUAL.md +222 -19
- package/README.md +29 -2
- package/package.json +4 -2
- package/src/cli/flags.ts +194 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +258 -34
- package/src/cli/tips.ts +57 -0
- package/src/index.ts +11 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/completions.ts +17 -3
- package/src/lib/console.ts +206 -75
- package/src/lib/diagnostics.ts +740 -0
- package/src/lib/download.ts +433 -0
- package/src/lib/guest-snapshot.ts +474 -0
- package/src/lib/images.ts +56 -61
- package/src/lib/names.ts +72 -0
- package/src/lib/net.ts +59 -3
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +85 -17
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +62 -16
- package/src/lib/quickchr.ts +504 -64
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +44 -6
- package/src/lib/versions.ts +99 -22
|
@@ -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
|
+
}
|
package/src/lib/channels.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
package/src/lib/completions.ts
CHANGED
|
@@ -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
|
}
|
package/src/lib/console.ts
CHANGED
|
@@ -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
|
-
*
|
|
124
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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 =
|
|
137
|
-
): Promise<
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
*
|
|
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<
|
|
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
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|