@tikoci/quickchr 0.4.8 → 0.5.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.
package/src/cli/wizard.ts CHANGED
@@ -81,6 +81,11 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
81
81
  if (!/^\d+\.\d+(\.\d+)?(beta\d+|rc\d+)?$/.test(v)) {
82
82
  return "Invalid version format";
83
83
  }
84
+ // RouterOS 6 is a CLI-only boot target (#182) — the wizard offers
85
+ // provisioning at every step, none of which 6.x can take.
86
+ if (/^6\./.test(v)) {
87
+ return "RouterOS 6 is boot-only and not offered here — use: quickchr add --version <6.x> --arch x86";
88
+ }
84
89
  },
85
90
  });
86
91
  if (clack.isCancel(ver)) { clack.cancel("Cancelled."); process.exit(0); }
@@ -557,7 +562,8 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
557
562
  const background = await clack.select({
558
563
  message: "Run mode:",
559
564
  options: [
560
- { value: true, label: "Background", hint: "returns immediately" },
565
+ // Not "returns immediately": a background start waits for RouterOS to be ready (#159).
566
+ { value: true, label: "Background", hint: "console logged; returns when RouterOS is ready" },
561
567
  { value: false, label: "Foreground", hint: "serial console on stdio" },
562
568
  ],
563
569
  initialValue: true,
@@ -566,8 +572,10 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
566
572
 
567
573
  // 11. Confirm
568
574
  const opts: StartOptions = {
575
+ // The channel has already been resolved into `version` above; passing both reads
576
+ // to start() as a caller asking for two releases.
569
577
  version,
570
- channel,
578
+ channel: version ? undefined : channel,
571
579
  arch,
572
580
  name: name || undefined,
573
581
  cpu: Number(cpu),
package/src/index.ts CHANGED
@@ -64,6 +64,8 @@ export type {
64
64
  ServiceEndpoint,
65
65
  ServiceName,
66
66
  SnapshotInfo,
67
+ ShutdownOptions,
68
+ ShutdownResult,
67
69
  SshServiceEndpoint,
68
70
  StartOptions,
69
71
  } from "./lib/types.ts";
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Native API (`/login` on guest port 8728) as a readiness signal.
3
+ *
4
+ * Built for RouterOS 6, which has no REST API, so the REST probe can never succeed
5
+ * against it — the field report behind #182 watched a 6.49 guest answer www, ssh and
6
+ * api from 15 s in, then get removed at 180 s for not answering `/rest`. It is written
7
+ * as a general alternative to the REST probe rather than a v6 special case: a 7.x
8
+ * guest with REST disabled is the same problem.
9
+ *
10
+ * Only the post-6.43 plain login is spoken (`=name=` + `=password=` in one sentence).
11
+ * Grounded against CHR 6.49.23 under HVF, 2026-10-21:
12
+ *
13
+ * - `admin` / empty password → `!done`, as one 7-byte reply.
14
+ * - wrong user or password → `!trap =message=invalid user name or password (6)`
15
+ * followed by `!done`, in the same read.
16
+ *
17
+ * Either answer means the API service is up, the way a REST 401 does — the probe is a
18
+ * readiness check, not a credential check.
19
+ */
20
+
21
+ import { connect } from "node:net";
22
+
23
+ /** Encode one API word length (RouterOS API "length" prefix, 1–5 bytes). */
24
+ export function encodeApiLength(length: number): Uint8Array {
25
+ if (length < 0x80) return Uint8Array.of(length);
26
+ if (length < 0x4000) return Uint8Array.of((length >> 8) | 0x80, length & 0xff);
27
+ if (length < 0x200000) return Uint8Array.of((length >> 16) | 0xc0, (length >> 8) & 0xff, length & 0xff);
28
+ if (length < 0x10000000) {
29
+ return Uint8Array.of((length >>> 24) | 0xe0, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff);
30
+ }
31
+ return Uint8Array.of(0xf0, (length >>> 24) & 0xff, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff);
32
+ }
33
+
34
+ /** A sentence is its words, each length-prefixed, then a zero-length word. */
35
+ export function encodeApiSentence(words: string[]): Uint8Array {
36
+ const encoder = new TextEncoder();
37
+ const parts: Uint8Array[] = [];
38
+ for (const word of words) {
39
+ const bytes = encoder.encode(word);
40
+ parts.push(encodeApiLength(bytes.length), bytes);
41
+ }
42
+ parts.push(Uint8Array.of(0));
43
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
44
+ let offset = 0;
45
+ for (const part of parts) {
46
+ out.set(part, offset);
47
+ offset += part.length;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** Decode a length prefix at `offset`. `null` when the buffer ends inside it. */
53
+ function decodeApiLength(buf: Uint8Array, offset: number): { length: number; size: number } | null {
54
+ const first = buf[offset];
55
+ if (first === undefined) return null;
56
+ const size = (first & 0x80) === 0 ? 1
57
+ : (first & 0xc0) === 0x80 ? 2
58
+ : (first & 0xe0) === 0xc0 ? 3
59
+ : (first & 0xf0) === 0xe0 ? 4
60
+ : 5;
61
+ if (offset + size > buf.length) return null;
62
+ let length = size === 5 ? 0 : first & (0xff >> size);
63
+ for (let i = 1; i < size; i++) length = length * 256 + (buf[offset + i] ?? 0);
64
+ return { length, size };
65
+ }
66
+
67
+ /** Every complete sentence in `buf`; a trailing partial sentence is left out. */
68
+ export function parseApiSentences(buf: Uint8Array): string[][] {
69
+ const decoder = new TextDecoder();
70
+ const sentences: string[][] = [];
71
+ let words: string[] = [];
72
+ let offset = 0;
73
+ while (offset < buf.length) {
74
+ const prefix = decodeApiLength(buf, offset);
75
+ if (!prefix || offset + prefix.size + prefix.length > buf.length) break;
76
+ offset += prefix.size;
77
+ if (prefix.length === 0) {
78
+ sentences.push(words);
79
+ words = [];
80
+ continue;
81
+ }
82
+ words.push(decoder.decode(buf.subarray(offset, offset + prefix.length)));
83
+ offset += prefix.length;
84
+ }
85
+ return sentences;
86
+ }
87
+
88
+ export type ApiLoginResult =
89
+ /** `!done` with no `!trap` — logged in. */
90
+ | { outcome: "done" }
91
+ /** `!trap` then `!done` — the service answered and refused the credentials. */
92
+ | { outcome: "trap"; message: string }
93
+ /** `!fatal` — the service answered and closed the session (e.g. too many sessions). */
94
+ | { outcome: "fatal"; message: string };
95
+
96
+ /** What the reply sentences say, once a `!done` or `!fatal` has arrived. `null` while
97
+ * the login is still unanswered. */
98
+ export function classifyLoginReply(sentences: string[][]): ApiLoginResult | null {
99
+ const messageOf = (sentence: string[]) =>
100
+ sentence.find((w) => w.startsWith("=message="))?.slice("=message=".length) ?? sentence.slice(1).join(" ");
101
+ const fatal = sentences.find((s) => s[0] === "!fatal");
102
+ if (fatal) return { outcome: "fatal", message: messageOf(fatal) };
103
+ if (!sentences.some((s) => s[0] === "!done")) return null;
104
+ const trap = sentences.find((s) => s[0] === "!trap");
105
+ return trap ? { outcome: "trap", message: messageOf(trap) } : { outcome: "done" };
106
+ }
107
+
108
+ /** One `/login` round trip on `127.0.0.1:<port>`. Rejects on a socket error, on a close
109
+ * before the reply completes ("closed before reply"), and on the deadline
110
+ * ("apiLogin timeout") — the shapes `classifyProbeError()` sorts into outcomes. */
111
+ export function apiLogin(port: number, user: string, password: string, timeoutMs = 3000): Promise<ApiLoginResult> {
112
+ return new Promise((resolve, reject) => {
113
+ const socket = connect(port, "127.0.0.1");
114
+ let received = new Uint8Array(0);
115
+ let settled = false;
116
+ const finish = (fn: () => void) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ clearTimeout(timer);
120
+ socket.destroy();
121
+ fn();
122
+ };
123
+ const timer = setTimeout(() => finish(() => reject(new Error("apiLogin timeout"))), timeoutMs);
124
+ socket.on("connect", () => {
125
+ socket.write(encodeApiSentence(["/login", `=name=${user}`, `=password=${password}`]));
126
+ });
127
+ socket.on("data", (chunk: Uint8Array) => {
128
+ const next = new Uint8Array(received.length + chunk.length);
129
+ next.set(received);
130
+ next.set(chunk, received.length);
131
+ received = next;
132
+ const result = classifyLoginReply(parseApiSentences(received));
133
+ if (result) finish(() => resolve(result));
134
+ });
135
+ socket.on("error", (err) => finish(() => reject(err)));
136
+ socket.on("close", () => finish(() => reject(new Error("closed before reply"))));
137
+ });
138
+ }
@@ -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 cache doctor settings setup completions version help"
331
+ local commands="add start stop shutdown list ls status inspect env get set 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") )
@@ -337,12 +337,12 @@ _quickchr() {
337
337
 
338
338
  local cmd="${D}{words[1]}"
339
339
  case "${D}cmd" in
340
- start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
340
+ start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
341
341
  local machines
342
342
  machines=$(quickchr completions --machines 2>/dev/null)
343
343
  COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
344
344
  ;;
345
- stop)
345
+ stop|shutdown)
346
346
  local machines
347
347
  machines=$(quickchr completions --running 2>/dev/null)
348
348
  [[ -z "${D}machines" ]] && machines=$(quickchr completions --machines 2>/dev/null)
@@ -387,12 +387,15 @@ _quickchr() {
387
387
  local commands=(
388
388
  'add:Create a new CHR machine (without starting)'
389
389
  'start:Start or restart a CHR instance'
390
- 'stop:Stop instance(s)'
390
+ 'stop:Power cut instance(s)'
391
+ 'shutdown:Request guest shutdown'
391
392
  'list:List all instances'
392
393
  'ls:List all instances (alias)'
393
394
  'status:Detailed status'
394
395
  'inspect:Print running machine descriptor'
395
396
  'env:Print subprocess environment'
397
+ 'get:Show live machine config (license, device-mode, admin)'
398
+ 'set:Change a booted machine (license, device-mode)'
396
399
  'remove:Remove instance(s) and disk'
397
400
  'rm:Remove instance(s) (alias)'
398
401
  'clean:Reset instance disk to fresh image'
@@ -418,12 +421,12 @@ _quickchr() {
418
421
  args)
419
422
  local cmd="${D}{words[2]}"
420
423
  case ${D}cmd in
421
- start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
424
+ start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
422
425
  local machines
423
426
  machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
424
427
  _describe 'machine' machines
425
428
  ;;
426
- stop)
429
+ stop|shutdown)
427
430
  local machines
428
431
  machines=(${D}{(f)"${D}(quickchr completions --running 2>/dev/null)"})
429
432
  (( ${D}{#machines} == 0 )) && machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
@@ -444,7 +447,7 @@ _quickchr() {
444
447
  _describe 'cache subcommand' subcommands
445
448
  ;;
446
449
  help)
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)
450
+ local commands=(add start stop shutdown list status inspect env get set remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
448
451
  _describe 'command' commands
449
452
  ;;
450
453
  esac
@@ -460,7 +463,7 @@ function generateFishCompletion(): string {
460
463
  return `# quickchr fish completion
461
464
  # Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
462
465
 
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
466
+ set -l quickchr_commands add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
464
467
 
465
468
  # Disable file completions globally for quickchr
466
469
  complete -c quickchr -f
@@ -471,7 +474,9 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
471
474
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
472
475
  -a "start" -d "Start or restart a CHR instance"
473
476
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
474
- -a "stop" -d "Stop instance(s)"
477
+ -a "stop" -d "Power cut instance(s)"
478
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
479
+ -a "shutdown" -d "Request guest shutdown"
475
480
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
476
481
  -a "list ls" -d "List all instances"
477
482
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
@@ -491,6 +496,10 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
491
496
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
492
497
  -a "qga" -d "Guest agent command"
493
498
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
499
+ -a "get" -d "Show live machine config (license, device-mode, admin)"
500
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \
501
+ -a "set" -d "Change a booted machine (license, device-mode)"
502
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \
494
503
  -a "license" -d "Apply/renew CHR trial license"
495
504
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
496
505
  -a "disk" -d "Show disk details"
@@ -514,12 +523,12 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
514
523
  -a "help" -d "Show help"
515
524
 
516
525
  # Machine name completions for commands that take a running machine
517
- complete -c quickchr -n "__fish_seen_subcommand_from stop" \\
526
+ complete -c quickchr -n "__fish_seen_subcommand_from stop shutdown" \\
518
527
  -a "(quickchr completions --running 2>/dev/null; or quickchr completions --machines 2>/dev/null)" \\
519
528
  -d "machine"
520
529
 
521
530
  # Machine name completions for commands that take any machine
522
- complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env" \\
531
+ complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env get set" \\
523
532
  -a "(quickchr completions --machines 2>/dev/null)" \\
524
533
  -d "machine"
525
534
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { DeviceModeOptions } from "./types.ts";
6
6
  import { QuickCHRError } from "./types.ts";
7
+ import { shellQuote } from "./names.ts";
7
8
  import { restGet, restPost } from "./rest.ts";
8
9
 
9
10
  export const KNOWN_DEVICE_MODES = ["home", "advanced", "basic", "rose"] as const;
@@ -63,6 +64,26 @@ function normalizeFeatureList(list: string[] | undefined): string[] {
63
64
  return [...new Set(values)];
64
65
  }
65
66
 
67
+ /** Keys `/system/device-mode` reports that are not feature flags. `mode` is the
68
+ * device-mode itself; the rest are status RouterOS keeps alongside it. */
69
+ export const DEVICE_MODE_NON_FEATURE_KEYS: ReadonlySet<string> = new Set([
70
+ "mode",
71
+ "allowed-versions",
72
+ "attempt-count",
73
+ "flagged",
74
+ "flagging-enabled",
75
+ ]);
76
+
77
+ /** Is this feature on, as RouterOS reports it?
78
+ *
79
+ * RouterOS answers `/system/device-mode` with **strings**: `"container": "true"`, not
80
+ * `true`. A reader that tests `value === true` therefore finds nothing enabled, ever —
81
+ * which is what `quickchr get <name> device-mode` did, printing a bare mode and no
82
+ * feature line however many were on. Normalize before deciding. */
83
+ export function isDeviceModeFeatureEnabled(value: unknown): boolean {
84
+ return toYesNo(value) === "yes";
85
+ }
86
+
66
87
  function toYesNo(value: unknown): string {
67
88
  if (typeof value === "boolean") return value ? "yes" : "no";
68
89
  if (typeof value === "number") return value > 0 ? "yes" : "no";
@@ -148,20 +169,61 @@ export function formatDeviceModeSelection(options: ResolvedDeviceModeOptions): s
148
169
  return parts.join(" ");
149
170
  }
150
171
 
151
- /** Wait until the device-mode REST endpoint is reachable with default admin auth. */
152
- export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number = 60_000): Promise<void> {
153
- const auth = `Basic ${btoa("admin:")}`;
172
+ /** Factory credentials — `admin` with no password, as a fresh CHR ships.
173
+ *
174
+ * The right auth for device-mode during *first-boot* provisioning, where it runs
175
+ * before any account has been created, and the wrong auth afterwards: a machine
176
+ * provisioned with `--disable-admin` answers 401. Post-boot callers resolve the
177
+ * machine's own credentials instead — see `applyDeviceMode`. */
178
+ export const FACTORY_AUTH_HEADER = `Basic ${btoa("admin:")}`;
179
+
180
+ /** Is this response body actually `/system/device-mode`, and not the post-boot race?
181
+ *
182
+ * RouterOS can answer a non-resource endpoint with resource-shaped or otherwise wrong
183
+ * data for a short window after boot — `provisioning.instructions.md` documents this
184
+ * for exactly this endpoint, and `readDeviceMode()` carries a guard for it. A 2xx is
185
+ * therefore not proof the endpoint is ready; a body with `mode` in it is. */
186
+ function isDeviceModeBody(body: string): boolean {
187
+ let data: unknown;
188
+ try { data = JSON.parse(body); } catch { return false; }
189
+ const record = Array.isArray(data) ? data[0] : data;
190
+ if (!record || typeof record !== "object") return false;
191
+ const keys = record as Record<string, unknown>;
192
+ if ("board-name" in keys || "architecture-name" in keys) return false;
193
+ return "mode" in keys;
194
+ }
195
+
196
+ /** Wait until the device-mode REST endpoint is reachable **and answering with
197
+ * device-mode data**.
198
+ *
199
+ * Returning on a bare 2xx was a race the next line then paid for: `readDeviceMode()`
200
+ * rejects a resource-shaped body outright, so a guest that came back mid-race failed
201
+ * the whole operation instead of being waited out for one more second. This is the
202
+ * poll-until-the-keys-are-there pattern `provisioning.instructions.md` prescribes for
203
+ * every non-resource endpoint, applied where the waiting already happens.
204
+ *
205
+ * `auth` defaults to factory admin, which is what a guest being provisioned for the
206
+ * first time has. A machine past that point needs its own credentials passed in. */
207
+ export async function waitForDeviceModeApi(
208
+ httpPort: number,
209
+ timeoutMs: number = 60_000,
210
+ auth: string = FACTORY_AUTH_HEADER,
211
+ ): Promise<void> {
154
212
  const deadline = Date.now() + timeoutMs;
213
+ let lastBody = "";
155
214
  while (Date.now() < deadline) {
156
215
  try {
157
- const { status } = await restGet(
216
+ const { status, body } = await restGet(
158
217
  `http://127.0.0.1:${httpPort}/rest/system/device-mode`,
159
218
  auth,
160
219
  3000,
161
220
  );
162
221
 
163
222
  if (status >= 200 && status < 300) {
164
- return;
223
+ if (isDeviceModeBody(body)) return;
224
+ // Answering, but not with device-mode yet — keep waiting rather than
225
+ // handing the caller a body it will throw on.
226
+ lastBody = body;
165
227
  }
166
228
 
167
229
  if (status === 401) {
@@ -176,7 +238,11 @@ export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number =
176
238
  await Bun.sleep(1000);
177
239
  }
178
240
 
179
- throw new QuickCHRError("BOOT_TIMEOUT", `Device-mode API did not become ready on port ${httpPort}`);
241
+ throw new QuickCHRError(
242
+ "BOOT_TIMEOUT",
243
+ `Device-mode API did not become ready on port ${httpPort}` +
244
+ (lastBody ? ` (last response was not device-mode data: ${lastBody.slice(0, 200)})` : ""),
245
+ );
180
246
  }
181
247
 
182
248
  /**
@@ -189,7 +255,11 @@ export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number =
189
255
  * Uses restPost (node:http + agent:false) to bypass Bun's connection pool.
190
256
  * The 300s timeout is a safety limit — the caller is expected to kill QEMU well before then.
191
257
  */
192
- export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceModeOptions): Promise<{ status: number; body: string }> {
258
+ export function startDeviceModeUpdate(
259
+ httpPort: number,
260
+ options: ResolvedDeviceModeOptions,
261
+ auth: string = FACTORY_AUTH_HEADER,
262
+ ): Promise<{ status: number; body: string }> {
193
263
  if (!shouldApplyDeviceMode(options)) {
194
264
  return Promise.resolve({ status: 200, body: "" });
195
265
  }
@@ -200,7 +270,6 @@ export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceM
200
270
  payload[name] = value;
201
271
  }
202
272
 
203
- const auth = `Basic ${btoa("admin:")}`;
204
273
  return restPost(
205
274
  `http://127.0.0.1:${httpPort}/rest/system/device-mode/update`,
206
275
  auth,
@@ -209,8 +278,10 @@ export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceM
209
278
  );
210
279
  }
211
280
 
212
- export async function readDeviceMode(httpPort: number): Promise<Record<string, string>> {
213
- const auth = `Basic ${btoa("admin:")}`;
281
+ export async function readDeviceMode(
282
+ httpPort: number,
283
+ auth: string = FACTORY_AUTH_HEADER,
284
+ ): Promise<Record<string, string>> {
214
285
  const { status, body } = await restGet(
215
286
  `http://127.0.0.1:${httpPort}/rest/system/device-mode`,
216
287
  auth,
@@ -281,3 +352,71 @@ export function verifyDeviceMode(
281
352
  actual,
282
353
  };
283
354
  }
355
+
356
+ /** The `quickchr set` flags that would reproduce this selection, shell-quoted.
357
+ *
358
+ * Rendered from the *resolved* options, not the raw ones, so the command it prints
359
+ * is the command that was actually going to run — `mode: "auto"` comes out as the
360
+ * `rose` it resolves to rather than as an alias the reader has to expand.
361
+ *
362
+ * Quoted because `resolveDeviceModeOptions()` deliberately passes unknown modes and
363
+ * features through for forward compatibility (it warns, RouterOS validates), so a
364
+ * value with a space or a `;` in it reaches this function intact. Unquoted, the
365
+ * advertised command would silently become a different command. Ordinary values are
366
+ * returned unquoted, so the common case still reads like something a person typed. */
367
+ export function formatDeviceModeFlags(options: ResolvedDeviceModeOptions): string {
368
+ if (options.skip) return "";
369
+ const parts: string[] = [];
370
+ if (options.mode) parts.push(`--device-mode ${shellQuote(options.mode)}`);
371
+ const enable = Object.entries(options.features).filter(([, v]) => v === "yes").map(([k]) => k);
372
+ const disable = Object.entries(options.features).filter(([, v]) => v === "no").map(([k]) => k);
373
+ if (enable.length > 0) parts.push(`--device-mode-enable ${shellQuote(enable.sort().join(","))}`);
374
+ if (disable.length > 0) parts.push(`--device-mode-disable ${shellQuote(disable.sort().join(","))}`);
375
+ return parts.join(" ");
376
+ }
377
+
378
+ /** What a device-mode update would change, read against the guest's current record.
379
+ *
380
+ * Only the differences — a change that costs a power cycle should say which settings
381
+ * it is buying, and `expected == actual` rows are noise. Empty means nothing would
382
+ * move, which is the caller's cue that no power cycle is needed. */
383
+ export function describeDeviceModeChange(
384
+ options: ResolvedDeviceModeOptions,
385
+ actual: Record<string, string>,
386
+ ): string[] {
387
+ // `verifyDeviceMode` stays the single judge of what counts as a mismatch — only the
388
+ // key is read back out of its message, and the values come from the maps it returns.
389
+ const { expected, mismatches } = verifyDeviceMode(options, actual);
390
+ const changed = new Set(mismatches.map((mismatch) => mismatch.split(":")[0]));
391
+ return Object.entries(expected)
392
+ .filter(([key]) => changed.has(key))
393
+ .map(([key, want]) => `${key}: ${actual[key] ?? "(unset)"} -> ${want}`);
394
+ }
395
+
396
+ /** Fold an applied device-mode selection into what `machine.json` already records.
397
+ *
398
+ * `setDeviceMode()` used to overwrite `state.deviceMode` with the request, so
399
+ * `set <name> --device-mode-enable container` on a machine provisioned with
400
+ * `ipsec=yes` left the record claiming ipsec had never been asked for. The guest
401
+ * still had it: device-mode updates are cumulative, and only the settings named in
402
+ * a request move. The record has to be cumulative too, or it describes a machine
403
+ * that does not exist — and `classifyProvisioningRequest()` reads it. */
404
+ export function mergeDeviceModeOptions(
405
+ current: DeviceModeOptions | undefined,
406
+ applied: ResolvedDeviceModeOptions,
407
+ ): DeviceModeOptions {
408
+ if (applied.skip) return current ?? {};
409
+ const enable = new Set(normalizeFeatureList(current?.enable));
410
+ const disable = new Set(normalizeFeatureList(current?.disable));
411
+ for (const [feature, value] of Object.entries(applied.features)) {
412
+ // A feature only ever sits on one side: the newest instruction for it wins.
413
+ enable.delete(feature);
414
+ disable.delete(feature);
415
+ (value === "yes" ? enable : disable).add(feature);
416
+ }
417
+ return {
418
+ mode: applied.mode ?? current?.mode,
419
+ enable: enable.size > 0 ? [...enable].sort() : undefined,
420
+ disable: disable.size > 0 ? [...disable].sort() : undefined,
421
+ };
422
+ }
@@ -54,18 +54,22 @@ export type BootProbeOutcome =
54
54
  | "reset"
55
55
  /** Connect or response exceeded the per-probe budget — socket hung. */
56
56
  | "probe-timeout"
57
- /** 401/403 — REST answered; auth layer is up. */
57
+ /** 401/403 from REST, or a login `!trap` from the native API — the service
58
+ * answered and refused the credentials; its auth layer is up. */
58
59
  | "unauthorized"
59
60
  /** 2xx but not the expected `board-name` singleton — REST still initializing. */
60
61
  | "wrong-body"
61
62
  /** Any other HTTP status. */
62
63
  | "http-error"
63
- /** 2xx with the expected body. */
64
+ /** 2xx with the expected body, or a native-API login `!done`. */
64
65
  | "ready"
65
66
  /** Anything unclassified — `detail` carries the raw text. */
66
67
  | "other";
67
68
 
68
69
  export interface BootProbeStats {
70
+ /** Which readiness probe ran — REST by default, the native-API login for a guest
71
+ * without REST (#182). Absent means REST. */
72
+ kind?: "rest" | "api";
69
73
  /** Total probe attempts made. */
70
74
  attempts: number;
71
75
  /** Attempt count per outcome. */
@@ -90,9 +94,11 @@ export function classifyProbeError(err: unknown): { outcome: BootProbeOutcome; d
90
94
  const message = err instanceof Error ? err.message : String(err);
91
95
  if (code === "ECONNREFUSED") return { outcome: "refused", detail: code };
92
96
  if (code === "ECONNRESET" || code === "EPIPE") return { outcome: "reset", detail: code };
93
- if (code === "ETIMEDOUT" || message.includes("restGet timeout")) {
97
+ if (code === "ETIMEDOUT" || message.includes("restGet timeout") || message.includes("apiLogin timeout")) {
94
98
  return { outcome: "probe-timeout", detail: code ?? "probe deadline" };
95
99
  }
100
+ // apiLogin(): slirp accepted, then the guest side closed before any reply.
101
+ if (message === "closed before reply") return { outcome: "reset", detail: message };
96
102
  return { outcome: "other", detail: code ? `${code}: ${message}` : message };
97
103
  }
98
104
 
@@ -123,9 +129,10 @@ export function summarizeBootProbe(stats: BootProbeStats): string {
123
129
  .sort((a, b) => b[1] - a[1])
124
130
  .map(([k, v]) => `${k}=${v}`)
125
131
  .join(" ");
132
+ const answer = stats.kind === "api" ? "API reply" : "HTTP response";
126
133
  const reachedHttp = stats.firstHttpAtMs === null
127
- ? "forwarded port never produced an HTTP response"
128
- : `first HTTP response at ${Math.round(stats.firstHttpAtMs / 1000)}s`;
134
+ ? `forwarded port never produced an ${answer}`
135
+ : `first ${answer} at ${Math.round(stats.firstHttpAtMs / 1000)}s`;
129
136
  const last = stats.last
130
137
  ? `last: ${stats.last.outcome}${stats.last.detail ? ` (${stats.last.detail})` : ""} at ${Math.round(stats.last.atMs / 1000)}s`
131
138
  : "";
@@ -673,7 +680,7 @@ export async function captureBootFailure(ctx: BootFailureContext): Promise<BootF
673
680
  lines.push(`Preceding credential transition: ${ctx.trigger.credentialTransition}`);
674
681
  }
675
682
  }
676
- if (ctx.probe) lines.push(`REST probe: ${summarizeBootProbe(ctx.probe)}`);
683
+ if (ctx.probe) lines.push(`${ctx.probe.kind === "api" ? "API login" : "REST"} probe: ${summarizeBootProbe(ctx.probe)}`);
677
684
  lines.push(`QEMU process: ${record.host.qemuProcess}`);
678
685
  if (record.hostfwd) lines.push(`hostfwd 127.0.0.1:${record.hostfwd.port}: ${record.hostfwd.tcpConnect}`);
679
686
  if (forwardProbe) lines.push(`slirp per-port: ${forwardProbe.summary}`);