@tikoci/quickchr 0.2.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.
@@ -0,0 +1,2625 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * quickchr CLI entry point — command router.
4
+ */
5
+
6
+ import type { StartOptions, Arch, Channel, ServiceName, NetworkSpecifier } from "../lib/types.ts";
7
+ import { parseNetworkSpecifier } from "../lib/network.ts";
8
+ import {
9
+ MIN_PROVISION_VERSION,
10
+ PROVISIONING_BOOT_ONLY_SUMMARY,
11
+ PROVISIONING_FEATURE_SUMMARY,
12
+ provisioningSupportHint,
13
+ provisioningSupportSummary,
14
+ } from "../lib/versions.ts";
15
+
16
+ const args = process.argv.slice(2);
17
+ const command = args[0];
18
+
19
+ /** Parse --flag=value and --flag value pairs from args. */
20
+ export function parseFlags(argv: string[]): { flags: Record<string, string | boolean | string[]>; positional: string[] } {
21
+ const flags: Record<string, string | boolean | string[]> = {};
22
+ const positional: string[] = [];
23
+
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const arg = argv[i];
26
+ if (arg === undefined) break;
27
+ if (arg === "--") {
28
+ positional.push(...argv.slice(i + 1));
29
+ break;
30
+ }
31
+ if (arg.startsWith("--")) {
32
+ const eqIdx = arg.indexOf("=");
33
+ if (eqIdx !== -1) {
34
+ const key = arg.slice(2, eqIdx);
35
+ flags[key] = arg.slice(eqIdx + 1);
36
+ } else if (arg.startsWith("--no-")) {
37
+ flags[arg.slice(5)] = false;
38
+ } else {
39
+ const key = arg.slice(2);
40
+ const next = argv[i + 1];
41
+ if (next && !next.startsWith("--")) {
42
+ // Check if this is a repeatable flag
43
+ const existing = flags[key];
44
+ if (Array.isArray(existing)) {
45
+ existing.push(next);
46
+ } else if (existing !== undefined && typeof existing === "string") {
47
+ flags[key] = [existing, next];
48
+ } else {
49
+ flags[key] = next;
50
+ }
51
+ i++;
52
+ } else {
53
+ flags[key] = true;
54
+ }
55
+ }
56
+ } else {
57
+ positional.push(arg);
58
+ }
59
+ }
60
+
61
+ return { flags, positional };
62
+ }
63
+
64
+ function flag(flags: Record<string, string | boolean | string[]>, key: string): string | undefined {
65
+ const val = flags[key];
66
+ if (typeof val === "string") return val;
67
+ return undefined;
68
+ }
69
+
70
+ function flagBool(flags: Record<string, string | boolean | string[]>, key: string): boolean {
71
+ return flags[key] === true || flags[key] === "true";
72
+ }
73
+
74
+ function flagList(flags: Record<string, string | boolean | string[]>, key: string): string[] {
75
+ const val = flags[key];
76
+ if (Array.isArray(val)) return val;
77
+ if (typeof val === "string") return [val];
78
+ return [];
79
+ }
80
+
81
+ function csvList(values: string[]): string[] {
82
+ return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
83
+ }
84
+
85
+ /** Build networks array from --add-network, --no-network, and legacy vmnet flags. */
86
+ function buildNetworks(flags: Record<string, string | boolean | string[]>): NetworkSpecifier[] | undefined {
87
+ const specs: NetworkSpecifier[] = [];
88
+
89
+ // Legacy flags first
90
+ if (flag(flags, "vmnet-shared") !== undefined) {
91
+ specs.push(parseNetworkSpecifier("shared"));
92
+ }
93
+ const bridgeIface = flag(flags, "vmnet-bridge");
94
+ if (bridgeIface) {
95
+ specs.push(parseNetworkSpecifier(`bridged:${bridgeIface}`));
96
+ }
97
+
98
+ // New --add-network flags
99
+ for (const raw of flagList(flags, "add-network")) {
100
+ specs.push(parseNetworkSpecifier(raw));
101
+ }
102
+
103
+ // --no-network → explicitly empty
104
+ if (flags.network === false) {
105
+ return [];
106
+ }
107
+
108
+ // Nothing specified → undefined (library decides defaults)
109
+ if (specs.length === 0) return undefined;
110
+ return specs;
111
+ }
112
+
113
+ /** True when interactive prompts must be suppressed (e.g. LLM / CI / pipe). */
114
+ function isNoPrompt(): boolean {
115
+ return !!process.env.QUICKCHR_NO_PROMPT || !process.stdout.isTTY || !process.stdin.isTTY;
116
+ }
117
+
118
+ async function main() {
119
+ try {
120
+ switch (command) {
121
+ case "add":
122
+ await cmdAdd(args.slice(1));
123
+ break;
124
+ case "start":
125
+ await cmdStart(args.slice(1));
126
+ break;
127
+ case "stop":
128
+ await cmdStop(args.slice(1));
129
+ break;
130
+ case "list":
131
+ case "ls":
132
+ case "status":
133
+ await cmdList(args.slice(1));
134
+ break;
135
+ case "remove":
136
+ case "rm":
137
+ await cmdRemove(args.slice(1));
138
+ break;
139
+ case "clean":
140
+ await cmdClean(args.slice(1));
141
+ break;
142
+ case "console":
143
+ await cmdConsole(args.slice(1));
144
+ break;
145
+ case "exec":
146
+ await cmdExec(args.slice(1));
147
+ break;
148
+ case "qga":
149
+ await cmdQga(args.slice(1));
150
+ break;
151
+ case "get":
152
+ await cmdGet(args.slice(1));
153
+ break;
154
+ case "license":
155
+ console.error("Note: 'quickchr license' is deprecated. Use 'quickchr set <name> --license' instead.");
156
+ await cmdLicense(args.slice(1));
157
+ break;
158
+ case "set":
159
+ await cmdSet(args.slice(1));
160
+ break;
161
+ case "disk":
162
+ await cmdDisk(args.slice(1));
163
+ break;
164
+ case "snapshot":
165
+ case "snap":
166
+ await cmdSnapshot(args.slice(1));
167
+ break;
168
+ case "setup":
169
+ await cmdSetup();
170
+ break;
171
+ case "completions":
172
+ await cmdCompletions(args.slice(1));
173
+ break;
174
+ case "doctor":
175
+ await cmdDoctor();
176
+ break;
177
+ case "logs":
178
+ await cmdLogs(args.slice(1));
179
+ break;
180
+ case "networks":
181
+ case "net":
182
+ await cmdNetworks(args.slice(1));
183
+ break;
184
+ case "version":
185
+ case "--version":
186
+ case "-v":
187
+ await cmdVersion();
188
+ break;
189
+ case "help":
190
+ case "--help":
191
+ case "-h":
192
+ printHelp(args[1]);
193
+ break;
194
+ case undefined:
195
+ // No command — interactive setup on TTY, help otherwise
196
+ if (isNoPrompt()) {
197
+ printHelp();
198
+ } else {
199
+ await cmdSetup();
200
+ }
201
+ break;
202
+ default:
203
+ console.error(`Unknown command: ${command}\nRun 'quickchr help' for usage.`);
204
+ process.exit(1);
205
+ }
206
+ } catch (e: unknown) {
207
+ if (e && typeof e === "object" && "code" in e && "message" in e) {
208
+ const err = e as { code: string; message: string; installHint?: string };
209
+ console.error(`Error [${err.code}]: ${err.message}`);
210
+ if (err.installHint) {
211
+ console.error(` Hint: ${err.installHint}`);
212
+ }
213
+ if (err.code === "PROVISIONING_VERSION_UNSUPPORTED") {
214
+ console.error(` Why this happened: ${provisioningSupportSummary(MIN_PROVISION_VERSION)}`);
215
+ console.error(` ${PROVISIONING_BOOT_ONLY_SUMMARY}`);
216
+ console.error(" Try this:");
217
+ console.error(` 1) ${provisioningSupportHint(MIN_PROVISION_VERSION)}`);
218
+ console.error(" 2) For older 7.x, run boot-only (no provisioning flags).");
219
+ }
220
+ process.exit(1);
221
+ }
222
+ throw e;
223
+ }
224
+ }
225
+
226
+ // --- Commands ---
227
+
228
+ /** Print QEMU keyboard shortcuts before foreground launch. Screen clears after QEMU starts. */
229
+ function printForegroundTips(hasProvisioning = false) {
230
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
231
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
232
+ const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
233
+ console.log();
234
+ if (hasProvisioning) {
235
+ console.log(bold(" Foreground mode — serial console will attach after provisioning"));
236
+ } else {
237
+ console.log(bold(" Foreground mode — QEMU serial console"));
238
+ }
239
+ console.log(` ${cyan("Ctrl-A X")} ${dim("exit QEMU and return to shell")}`);
240
+ console.log(` ${cyan("Ctrl-A C")} ${dim("toggle QEMU monitor (type 'quit' to force-stop)")}`);
241
+ console.log(` ${cyan("Ctrl-A H")} ${dim("list all QEMU serial shortcuts")}`);
242
+ console.log(` ${cyan("Ctrl-A S")} ${dim("send break signal to serial port")}`);
243
+ console.log();
244
+ if (!hasProvisioning) {
245
+ console.log(dim(" (screen clears when QEMU initializes)"));
246
+ console.log();
247
+ }
248
+ }
249
+
250
+ /** Attach stdin/stdout to a running background machine — replicates QEMU's Ctrl-A mux.
251
+ * serial.sock — RouterOS console (default mode)
252
+ * monitor.sock — QEMU monitor (Ctrl-A C to toggle, connected on demand) */
253
+ async function attachSerial(name: string, machineDir: string): Promise<void> {
254
+ const { join } = await import("node:path");
255
+ const { existsSync } = await import("node:fs");
256
+ const { connect } = await import("node:net");
257
+ const { bold, dim, cyan } = await import("./format.ts");
258
+
259
+ const serialSock = join(machineDir, "serial.sock");
260
+ const monitorSock = join(machineDir, "monitor.sock");
261
+
262
+ if (!existsSync(serialSock)) {
263
+ console.error(`\n "${name}" has no serial socket — is it running in background mode?`);
264
+ console.error(` Expected: ${dim(serialSock)}`);
265
+ process.exit(1);
266
+ }
267
+
268
+ if (!process.stdin.isTTY) {
269
+ console.error("Serial attach requires an interactive terminal.");
270
+ process.exit(1);
271
+ }
272
+
273
+ const hasMonitor = existsSync(monitorSock);
274
+
275
+ console.log();
276
+ console.log(bold(` Attached to: ${name}`));
277
+ console.log(` ${cyan("Ctrl-A X")} ${dim("detach and return to shell")}`);
278
+ if (hasMonitor) {
279
+ console.log(` ${cyan("Ctrl-A C")} ${dim("toggle QEMU monitor / serial console")}`);
280
+ }
281
+ console.log(` ${cyan("Ctrl-A H")} ${dim("show this help")}`);
282
+ console.log();
283
+ await Bun.sleep(600);
284
+
285
+ const serialConn = connect({ path: serialSock });
286
+ await new Promise<void>((resolve, reject) => {
287
+ serialConn.once("connect", resolve);
288
+ serialConn.once("error", reject);
289
+ }).catch((err: Error) => {
290
+ console.error(`\nFailed to connect to serial socket: ${err.message}`);
291
+ process.exit(1);
292
+ });
293
+
294
+ // Send CR to nudge RouterOS into re-printing its current prompt.
295
+ // Without this the socket delivers nothing until RouterOS receives input.
296
+ // RouterOS treats a bare CR at "Login:" as an empty entry → re-displays "Login: ".
297
+ serialConn.write(Buffer.from([0x0d]));
298
+
299
+ process.stdin.setRawMode(true);
300
+ process.stdin.resume();
301
+
302
+ // Yield one event-loop tick BEFORE attaching the data listener.
303
+ // Node.js/Bun streams discard data events that fire while no 'data' listener
304
+ // is registered (flowing mode + no listener = data lost). This cleanly drops
305
+ // any stale bytes buffered in stdin from a prior session that left raw mode
306
+ // enabled — those are what caused the spurious "\01" login in RouterOS logs.
307
+ await new Promise<void>((res) => setImmediate(res));
308
+
309
+ let mode: "serial" | "monitor" = "serial";
310
+ type Sock = ReturnType<typeof connect>;
311
+ let monitorConn: Sock | null = null;
312
+ let gotCtrlA = false;
313
+ let done = false;
314
+ let resolveExit!: () => void;
315
+ const exitPromise = new Promise<void>((res) => { resolveExit = res; });
316
+
317
+ const cleanup = () => {
318
+ if (done) return;
319
+ done = true;
320
+ serialConn.destroy();
321
+ if (monitorConn) monitorConn.destroy();
322
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
323
+ process.stdin.pause();
324
+ resolveExit();
325
+ };
326
+
327
+ // Always restore TTY on process exit — guards against SIGINT crash leaving raw mode
328
+ const sigHandler = () => cleanup();
329
+ process.once("SIGINT", sigHandler);
330
+ process.once("SIGTERM", sigHandler);
331
+
332
+ const printHelp = () => {
333
+ process.stdout.write(`\r\n ${cyan("Ctrl-A X")} detach\r\n`);
334
+ if (hasMonitor) process.stdout.write(` ${cyan("Ctrl-A C")} toggle monitor / serial console\r\n`);
335
+ process.stdout.write(` ${cyan("Ctrl-A H")} this help\r\n`);
336
+ };
337
+
338
+ /** Connect to monitor socket on demand (first Ctrl-A C press). */
339
+ const openMonitor = () => {
340
+ if (monitorConn && !monitorConn.destroyed) {
341
+ // Reconnect to existing live socket — just switch mode and nudge prompt
342
+ mode = "monitor";
343
+ process.stdout.write("\r\n\x1b[2m[QEMU monitor — Ctrl-A C to return to console]\x1b[0m\r\n");
344
+ monitorConn.write("\n");
345
+ return;
346
+ }
347
+ if (!existsSync(monitorSock)) {
348
+ process.stdout.write("\r\n\x1b[2m[monitor socket not found]\x1b[0m\r\n");
349
+ return;
350
+ }
351
+ const mc = connect({ path: monitorSock });
352
+ monitorConn = mc;
353
+ mc.on("data", (d: Buffer) => { if (mode === "monitor") process.stdout.write(d); });
354
+ mc.on("error", () => {
355
+ process.stdout.write("\r\n\x1b[2m[monitor unavailable]\x1b[0m\r\n");
356
+ if (mode === "monitor") mode = "serial";
357
+ monitorConn = null;
358
+ });
359
+ mc.on("close", () => {
360
+ if (mode === "monitor") {
361
+ // QEMU exited via 'quit' — serial socket will close next, triggering cleanup
362
+ process.stdout.write("\r\n\x1b[2m[QEMU exited]\x1b[0m\r\n");
363
+ }
364
+ monitorConn = null;
365
+ });
366
+ mc.once("connect", () => {
367
+ mode = "monitor";
368
+ process.stdout.write("\r\n\x1b[2m[QEMU monitor — Ctrl-A C to return to console]\x1b[0m\r\n");
369
+ // Don't nudge with "\n" here — the (qemu) banner arrives naturally on connect
370
+ });
371
+ };
372
+
373
+ serialConn.on("data", (d: Buffer) => { if (mode === "serial") process.stdout.write(d); });
374
+ serialConn.on("close", cleanup);
375
+ serialConn.on("error", cleanup);
376
+
377
+ const stdinHandler = (chunk: Buffer) => {
378
+ if (done) return;
379
+ for (let i = 0; i < chunk.length; i++) {
380
+ const byte = chunk[i] as number;
381
+ if (gotCtrlA) {
382
+ gotCtrlA = false;
383
+ // Match QEMU's mux_proc_byte exactly: unrecognised escapes are eaten (return 0).
384
+ // QEMU never forwards [0x01, unknown] — only listed keys trigger actions.
385
+ switch (byte) {
386
+ case 0x78: case 0x58: // x/X — detach
387
+ process.stdout.write("\r\n");
388
+ cleanup();
389
+ return;
390
+ case 0x68: case 0x48: // h/H — help
391
+ printHelp();
392
+ break;
393
+ case 0x63: case 0x43: // c/C — toggle monitor
394
+ if (!hasMonitor) {
395
+ process.stdout.write("\r\n\x1b[2m[monitor not available]\x1b[0m\r\n");
396
+ } else if (mode === "serial") {
397
+ openMonitor();
398
+ } else {
399
+ mode = "serial";
400
+ process.stdout.write("\r\n\x1b[2m[serial console]\x1b[0m\r\n");
401
+ }
402
+ break;
403
+ case 0x01: // Ctrl-A Ctrl-A — send literal 0x01 to active channel
404
+ if (mode === "serial" && !serialConn.destroyed) serialConn.write(Buffer.from([0x01]));
405
+ else if (monitorConn && !monitorConn.destroyed) monitorConn.write(Buffer.from([0x01]));
406
+ break;
407
+ // All other Ctrl-A + X: silently eat (matches QEMU mux_proc_byte behaviour)
408
+ default:
409
+ break;
410
+ }
411
+ } else if (byte === 0x01) {
412
+ gotCtrlA = true;
413
+ } else {
414
+ const dest = mode === "serial" ? serialConn : monitorConn;
415
+ if (dest && !dest.destroyed) dest.write(Buffer.from([byte]));
416
+ }
417
+ }
418
+ };
419
+
420
+ process.stdin.on("data", stdinHandler);
421
+ await exitPromise;
422
+ process.stdin.removeListener("data", stdinHandler);
423
+ process.removeListener("SIGINT", sigHandler);
424
+ process.removeListener("SIGTERM", sigHandler);
425
+ if (!done) cleanup();
426
+
427
+ console.log(`\n${bold(name)} console detached`);
428
+ console.log(` ${dim("Tip: resume session")} quickchr start ${name} --fg`);
429
+ console.log(` ${dim("Tip: run background")} quickchr start ${name}`);
430
+ }
431
+
432
+ /** Print a machine table with a tip line — used when a command gets no name argument. */
433
+ function printMachineListWithTip(
434
+ tipCommand: string,
435
+ machines: Array<{ name: string; status: string; version: string; arch: string }>,
436
+ hintFn?: (m: { name: string; status: string }) => string | undefined,
437
+ allowAll = true,
438
+ ) {
439
+ if (machines.length === 0) {
440
+ console.log("No matching instances.");
441
+ return;
442
+ }
443
+ const rows = machines.map((m) => ({
444
+ name: m.name,
445
+ status: m.status,
446
+ version: m.version,
447
+ arch: m.arch,
448
+ hint: hintFn?.(m),
449
+ }));
450
+ const [wName, wStatus, wVersion, wArch] = [
451
+ Math.max(4, ...rows.map((r) => r.name.length)),
452
+ Math.max(6, ...rows.map((r) => r.status.length)),
453
+ Math.max(7, ...rows.map((r) => r.version.length)),
454
+ Math.max(4, ...rows.map((r) => r.arch.length)),
455
+ ];
456
+ const pad = (s: string, n: number) => s + " ".repeat(Math.max(0, n - s.length));
457
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
458
+ console.log(dim(` ${pad("NAME", wName)} ${pad("STATUS", wStatus)} ${pad("VERSION", wVersion)} ${pad("ARCH", wArch)}`));
459
+ for (const r of rows) {
460
+ const extra = r.hint ? ` ${dim(r.hint)}` : "";
461
+ console.log(` ${pad(r.name, wName)} ${pad(r.status, wStatus)} ${pad(r.version, wVersion)} ${pad(r.arch, wArch)}${extra}`);
462
+ }
463
+ console.log();
464
+ console.log(allowAll
465
+ ? `${dim("tip:")} quickchr ${tipCommand} <name> or quickchr ${tipCommand} --all`
466
+ : `${dim("tip:")} quickchr ${tipCommand} <name>`);
467
+ }
468
+
469
+ async function cmdAdd(argv: string[]) {
470
+ const { flags, positional } = parseFlags(argv);
471
+ const { QuickCHR } = await import("../lib/quickchr.ts");
472
+ const { bold, formatPorts, formatNetworks, dim } = await import("./format.ts");
473
+
474
+ const opts: StartOptions = {
475
+ version: flag(flags, "version"),
476
+ channel: flag(flags, "channel") as Channel | undefined,
477
+ arch: flag(flags, "arch") as Arch | undefined,
478
+ name: flag(flags, "name") ?? positional[0],
479
+ cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
480
+ mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
481
+ packages: csvList(flagList(flags, "add-package")),
482
+ installAllPackages: flagBool(flags, "install-all-packages"),
483
+ portBase: flag(flags, "port-base") ? Number(flag(flags, "port-base")) : undefined,
484
+ excludePorts: [
485
+ ...(flags.winbox === false ? ["winbox" as ServiceName] : []),
486
+ ...(flags["api-ssl"] === false ? ["api-ssl" as ServiceName] : []),
487
+ ],
488
+ networks: buildNetworks(flags),
489
+ bootDiskFormat: (flag(flags, "boot-disk-format") as StartOptions["bootDiskFormat"] | undefined) ?? "qcow2",
490
+ bootSize: flag(flags, "boot-size"),
491
+ extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
492
+ };
493
+
494
+ const deviceModeValue = flag(flags, "device-mode");
495
+ const deviceModeEnable = csvList(flagList(flags, "device-mode-enable"));
496
+ const deviceModeDisable = csvList(flagList(flags, "device-mode-disable"));
497
+ if (deviceModeValue !== undefined || deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
498
+ opts.deviceMode = {
499
+ mode: deviceModeValue ?? "auto",
500
+ enable: deviceModeEnable.length > 0 ? deviceModeEnable : undefined,
501
+ disable: deviceModeDisable.length > 0 ? deviceModeDisable : undefined,
502
+ };
503
+ }
504
+
505
+ const userStr = flag(flags, "add-user");
506
+ if (userStr) {
507
+ const [uname = "", password] = userStr.split(":");
508
+ opts.user = { name: uname, password: password ?? "" };
509
+ }
510
+ opts.disableAdmin = flagBool(flags, "disable-admin");
511
+
512
+ // --secure-login / --no-secure-login
513
+ if (flags["secure-login"] === false) {
514
+ opts.secureLogin = false;
515
+ } else if (flagBool(flags, "secure-login")) {
516
+ opts.secureLogin = true;
517
+ }
518
+
519
+ const state = await QuickCHR.add(opts);
520
+
521
+ const { toChrPorts } = await import("../lib/network.ts");
522
+ const ports = toChrPorts(state.ports);
523
+ console.log(`${bold(state.name)} created`);
524
+ console.log(` Version: ${state.version} (${state.arch})`);
525
+ console.log(` CPU/Mem: ${state.cpu} vCPU${state.cpu > 1 ? "s" : ""}, ${state.mem} MB`);
526
+ console.log(` Network: ${formatNetworks(state.networks ?? [])}`);
527
+ console.log(` Ports: ${formatPorts(state.ports)}`);
528
+ console.log(` REST: http://127.0.0.1:${ports.http} ${dim("(after start)")}`);
529
+ console.log(` Dir: ${dim(state.machineDir)}`);
530
+ if (state.packages.length > 0) console.log(` Packages: ${state.packages.join(", ")} ${dim("(applied on first start)")}`);
531
+ if (state.deviceMode) console.log(` Device-mode: ${state.deviceMode.mode ?? "auto"} ${dim("(applied on first start)")}`);
532
+ console.log();
533
+ console.log(`${dim("tip:")} quickchr start ${state.name}`);
534
+ }
535
+
536
+ async function cmdConsole(argv: string[]) {
537
+ const { machineNotFoundMessage } = await import("./format.ts");
538
+ const { positional } = parseFlags(argv);
539
+ const name = positional[0];
540
+
541
+ if (!name) {
542
+ console.error("Usage: quickchr console <name>\n Attach to the serial console of a running machine.");
543
+ process.exit(1);
544
+ }
545
+
546
+ if (!process.stdin.isTTY) {
547
+ console.error("quickchr console requires an interactive terminal.");
548
+ process.exit(1);
549
+ }
550
+
551
+ const { QuickCHR } = await import("../lib/quickchr.ts");
552
+ const machine = QuickCHR.get(name);
553
+ if (!machine) {
554
+ console.error(machineNotFoundMessage(name));
555
+ process.exit(1);
556
+ }
557
+ if (machine.state.status !== "running") {
558
+ console.error(`Machine "${name}" is not running. Start it first: quickchr start ${name}`);
559
+ process.exit(1);
560
+ }
561
+ await attachSerial(name, machine.state.machineDir);
562
+ }
563
+
564
+ async function cmdExec(argv: string[]) {
565
+ const { machineNotFoundMessage } = await import("./format.ts");
566
+ const { flags, positional } = parseFlags(argv);
567
+ const name = positional[0];
568
+ const commandParts = positional.slice(1);
569
+
570
+ if (!name || commandParts.length === 0) {
571
+ const running = await getRunningMachines();
572
+ if (running.length > 0) {
573
+ printMachineListWithTip("exec", running, undefined, false);
574
+ console.log(`\nUsage: quickchr exec <name> <command...>`);
575
+ } else {
576
+ console.error("Usage: quickchr exec <name> <command...>\n Run a RouterOS CLI command on a running instance.");
577
+ }
578
+ process.exit(1);
579
+ }
580
+
581
+ const { QuickCHR } = await import("../lib/quickchr.ts");
582
+ const machine = QuickCHR.get(name);
583
+ if (!machine) {
584
+ console.error(machineNotFoundMessage(name));
585
+ process.exit(1);
586
+ }
587
+ if (machine.state.status !== "running") {
588
+ console.error(`Machine "${name}" is not running. Start it first: quickchr start ${name}`);
589
+ process.exit(1);
590
+ }
591
+
592
+ const command = commandParts.join(" ");
593
+ const via = flag(flags, "via") as "auto" | "rest" | undefined;
594
+ const user = flag(flags, "user");
595
+ const password = flag(flags, "password");
596
+ const timeout = flag(flags, "timeout") ? Number(flag(flags, "timeout")) * 1000 : undefined;
597
+
598
+ const result = await machine.exec(command, { via, user, password, timeout });
599
+ if (result.output) {
600
+ process.stdout.write(result.output);
601
+ // Ensure trailing newline for clean CLI output
602
+ if (!result.output.endsWith("\n")) {
603
+ process.stdout.write("\n");
604
+ }
605
+ }
606
+ }
607
+
608
+ async function cmdQga(argv: string[]) {
609
+ const { machineNotFoundMessage } = await import("./format.ts");
610
+ const { flags, positional } = parseFlags(argv);
611
+ const name = positional[0];
612
+ const operation = positional[1];
613
+
614
+ const USAGE = `Usage: quickchr qga <name> <operation> [options]
615
+
616
+ Operations:
617
+ ping Liveness check — confirm QGA is responding
618
+ info List supported QGA commands reported by the agent
619
+ osinfo OS version, kernel, architecture
620
+ hostname RouterOS identity name
621
+ time System time (nanoseconds since epoch)
622
+ timezone Timezone offset from UTC
623
+ networks Network interfaces with IP assignments
624
+ fsfreeze-status Filesystem freeze state (thawed|frozen)
625
+ fsfreeze-freeze Freeze filesystem for consistent snapshot
626
+ fsfreeze-thaw Thaw a frozen filesystem
627
+ shutdown Graceful VM shutdown (destructive)
628
+ file-write Write a file to RouterOS root (--path <name> --data <content>)
629
+ file-read Read a RouterOS root file (--path <name>)
630
+ exec Run RouterOS script via QGA (--script <cmd>)
631
+
632
+ QGA requires KVM on x86_64 — may not respond on macOS (HVF) or Windows. ARM64 CHR support is pending MikroTik implementation.
633
+
634
+ Options:
635
+ --timeout <seconds> Operation timeout (default: 10)`;
636
+
637
+ if (!name || !operation) {
638
+ const running = await getRunningMachines();
639
+ if (running.length > 0) {
640
+ printMachineListWithTip("qga", running.filter((m) => m.arch !== "arm64"), undefined, false);
641
+ }
642
+ console.log(`\n${USAGE}`);
643
+ process.exit(1);
644
+ }
645
+
646
+ const { QuickCHR } = await import("../lib/quickchr.ts");
647
+ const machine = QuickCHR.get(name);
648
+ if (!machine) {
649
+ console.error(machineNotFoundMessage(name));
650
+ process.exit(1);
651
+ }
652
+ if (machine.state.status !== "running") {
653
+ console.error(`Machine "${name}" is not running. Start it first: quickchr start ${name}`);
654
+ process.exit(1);
655
+ }
656
+
657
+ const timeoutMs = flag(flags, "timeout") ? Number(flag(flags, "timeout")) * 1000 : 10_000;
658
+ const {
659
+ qgaPing,
660
+ qgaInfo,
661
+ qgaGetOsInfo,
662
+ qgaGetHostName,
663
+ qgaGetTime,
664
+ qgaGetTimezone,
665
+ qgaGetNetworkInterfaces,
666
+ qgaFsFreezeStatus,
667
+ qgaFsFreezeFreeze,
668
+ qgaFsFreezeThaw,
669
+ qgaShutdown,
670
+ qgaFileWrite,
671
+ qgaFileRead,
672
+ qgaExec,
673
+ } = await import("../lib/qga.ts");
674
+ const { channelEndpoint } = await import("../lib/channels.ts");
675
+ const socketPath = channelEndpoint(machine.state.machineDir, "qga", machine.state.portBase);
676
+
677
+ if (machine.state.arch === "arm64") {
678
+ console.error(`Error [QGA_UNSUPPORTED]: QEMU Guest Agent is not yet functional on ARM64 CHR.`);
679
+ console.error(` ARM64 QGA support is pending MikroTik implementation — x86_64 machines work today.`);
680
+ process.exit(1);
681
+ }
682
+
683
+ // x86 on non-KVM platforms (macOS HVF, Windows): warn but proceed
684
+ if (process.platform !== "linux") {
685
+ const platform = process.platform === "darwin" ? "macOS (HVF)" : "Windows";
686
+ console.warn(`Warning [QGA_TIMEOUT]: QGA requires KVM — RouterOS guest agent only starts under KVM hypervisors.`);
687
+ console.warn(` On ${platform} it will likely time out. Attempting anyway.`);
688
+ }
689
+
690
+ switch (operation) {
691
+ case "ping": {
692
+ await qgaPing(socketPath, timeoutMs);
693
+ console.log("pong — QGA is responding");
694
+ break;
695
+ }
696
+ case "info": {
697
+ const commands = await qgaInfo(socketPath, timeoutMs);
698
+ const enabled = commands.filter((c) => c.enabled).map((c) => c.name).sort();
699
+ const disabled = commands.filter((c) => !c.enabled).map((c) => c.name).sort();
700
+ console.log(`Supported commands (${enabled.length} enabled):`);
701
+ for (const cmd of enabled) console.log(` ${cmd}`);
702
+ if (disabled.length > 0) {
703
+ console.log(`Disabled commands (${disabled.length}):`);
704
+ for (const cmd of disabled) console.log(` ${cmd}`);
705
+ }
706
+ break;
707
+ }
708
+ case "osinfo": {
709
+ const info = await qgaGetOsInfo(socketPath, timeoutMs);
710
+ console.log(`name: ${info.name}`);
711
+ console.log(`pretty-name: ${info.prettyName}`);
712
+ console.log(`id: ${info.id}`);
713
+ console.log(`kernel: ${info.kernelRelease}`);
714
+ console.log(`machine: ${info.machine}`);
715
+ break;
716
+ }
717
+ case "hostname": {
718
+ const hostname = await qgaGetHostName(socketPath, timeoutMs);
719
+ console.log(hostname);
720
+ break;
721
+ }
722
+ case "time": {
723
+ const ns = await qgaGetTime(socketPath, timeoutMs);
724
+ const ms = Math.floor(ns / 1_000_000);
725
+ const date = new Date(ms);
726
+ console.log(`${ns} ns (${date.toISOString()})`);
727
+ break;
728
+ }
729
+ case "timezone": {
730
+ const tz = await qgaGetTimezone(socketPath, timeoutMs);
731
+ const sign = tz.offset >= 0 ? "+" : "-";
732
+ const abs = Math.abs(tz.offset);
733
+ const h = String(Math.floor(abs / 3600)).padStart(2, "0");
734
+ const m = String(Math.floor((abs % 3600) / 60)).padStart(2, "0");
735
+ const display = tz.zone ? `${tz.zone} (UTC${sign}${h}:${m})` : `UTC${sign}${h}:${m}`;
736
+ console.log(display);
737
+ break;
738
+ }
739
+ case "networks": {
740
+ const ifaces = await qgaGetNetworkInterfaces(socketPath, timeoutMs);
741
+ for (const iface of ifaces) {
742
+ const mac = iface.mac ? ` mac: ${iface.mac}` : "";
743
+ console.log(`${iface.name}${mac}`);
744
+ for (const ip of iface.ipAddresses) {
745
+ console.log(` ${ip.type} ${ip.address}/${ip.prefix}`);
746
+ }
747
+ }
748
+ break;
749
+ }
750
+ case "fsfreeze-status": {
751
+ const status = await qgaFsFreezeStatus(socketPath, timeoutMs);
752
+ console.log(status);
753
+ break;
754
+ }
755
+ case "fsfreeze-freeze": {
756
+ const count = await qgaFsFreezeFreeze(socketPath, timeoutMs);
757
+ console.log(`${count} filesystem(s) frozen`);
758
+ break;
759
+ }
760
+ case "fsfreeze-thaw": {
761
+ const count = await qgaFsFreezeThaw(socketPath, timeoutMs);
762
+ console.log(`${count} filesystem(s) thawed`);
763
+ break;
764
+ }
765
+ case "shutdown": {
766
+ console.log(`Sending shutdown to "${name}"...`);
767
+ await qgaShutdown(socketPath, timeoutMs);
768
+ console.log(`Shutdown sent — machine will terminate shortly.`);
769
+ break;
770
+ }
771
+ case "file-write": {
772
+ const path = flag(flags, "path");
773
+ const data = flag(flags, "data");
774
+ if (!path || data === undefined) {
775
+ console.error(`Usage: quickchr qga ${name} file-write --path <filename> --data <content>`);
776
+ process.exit(1);
777
+ }
778
+ await qgaFileWrite(socketPath, path, data, timeoutMs);
779
+ console.log(`Written: ${path}`);
780
+ break;
781
+ }
782
+ case "file-read": {
783
+ const path = flag(flags, "path");
784
+ if (!path) {
785
+ console.error(`Usage: quickchr qga ${name} file-read --path <filename>`);
786
+ process.exit(1);
787
+ }
788
+ const content = await qgaFileRead(socketPath, path, timeoutMs);
789
+ process.stdout.write(content);
790
+ if (content && !content.endsWith("\n")) process.stdout.write("\n");
791
+ break;
792
+ }
793
+ case "exec": {
794
+ const script = flag(flags, "script");
795
+ if (!script) {
796
+ console.error(`Usage: quickchr qga ${name} exec --script <routeros-command>`);
797
+ process.exit(1);
798
+ }
799
+ const result = await qgaExec(socketPath, script, timeoutMs);
800
+ if (result.stdout) {
801
+ process.stdout.write(result.stdout);
802
+ if (!result.stdout.endsWith("\n")) process.stdout.write("\n");
803
+ }
804
+ if (result.stderr) process.stderr.write(result.stderr);
805
+ if (result.exitcode !== 0) process.exit(result.exitcode);
806
+ break;
807
+ }
808
+ default:
809
+ console.error(`Unknown QGA operation: ${operation}\n\n${USAGE}`);
810
+ process.exit(1);
811
+ }
812
+ }
813
+
814
+ /** Get running machines for list hints. */
815
+ async function getRunningMachines() {
816
+ const { QuickCHR } = await import("../lib/quickchr.ts");
817
+ return QuickCHR.list().filter((m) => m.status === "running");
818
+ }
819
+
820
+ async function cmdSetup() {
821
+ const { QuickCHR } = await import("../lib/quickchr.ts");
822
+ const { runWizard } = await import("./wizard.ts");
823
+
824
+ // Zero machines: jump straight into create flow with first-run intro
825
+ if (QuickCHR.list().length === 0) {
826
+ return runWizard({ firstRun: true });
827
+ }
828
+
829
+ const clack = await import("@clack/prompts");
830
+ clack.intro("quickchr setup");
831
+
832
+ // Main menu loops until cancel or create — Back from machine select returns here
833
+ while (true) {
834
+ const choice = await clack.select({
835
+ message: "What would you like to do?",
836
+ options: [
837
+ { value: "create", label: "Create a new machine" },
838
+ { value: "manage", label: "Manage machines (start / stop / remove / disks / snapshots)" },
839
+ { value: "networks", label: "Configure networks" },
840
+ ],
841
+ });
842
+ if (clack.isCancel(choice)) { clack.cancel("Cancelled."); return; }
843
+
844
+ if (choice === "create") {
845
+ clack.outro("");
846
+ return runWizard();
847
+ }
848
+
849
+ if (choice === "networks") {
850
+ const { detectPlatform, detectPhysicalInterfaces } = await import("../lib/platform.ts");
851
+ const platform = await detectPlatform();
852
+ const isMacOS = platform.os === "darwin";
853
+ const hasSocketVmnet = !!platform.socketVmnet;
854
+
855
+ clack.log.info("Network types available on this system:");
856
+ clack.log.step(" user — port forwarding (always available, default)");
857
+ if (isMacOS && hasSocketVmnet) {
858
+ clack.log.step(" shared — rootless shared network via socket_vmnet");
859
+ }
860
+ if (isMacOS) {
861
+ clack.log.step(" bridged:<iface> — bridge to a physical interface");
862
+ const ifaces = detectPhysicalInterfaces();
863
+ if (ifaces.length > 0) {
864
+ for (const i of ifaces) {
865
+ clack.log.step(` ${i.device} — ${i.name}${i.alias ? ` (${i.alias})` : ""}`);
866
+ }
867
+ }
868
+ }
869
+ clack.log.step(" socket::<name> — named L2 link between VMs");
870
+
871
+ clack.log.info("Use these with: quickchr add --add-network <spec>");
872
+ continue; // back to main menu
873
+ }
874
+
875
+ // Manage flow
876
+ const { statusIcon, bold, link, machineNotFoundMessage, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
877
+ const diskSummary = (m: ReturnType<typeof QuickCHR.list>[number]): string => {
878
+ const bootFormat = m.bootDiskFormat ?? (m.bootSize ? "qcow2" : "raw");
879
+ const boot = m.bootSize ? `${m.bootSize} (${bootFormat})` : `default (~128M, ${bootFormat})`;
880
+ const extraCount = m.extraDisks?.length ?? 0;
881
+ return extraCount > 0 ? `${boot} + ${extraCount} extra` : boot;
882
+ };
883
+
884
+ let machineList = QuickCHR.list();
885
+
886
+ // Machine selection loop — loops until user explicitly backs out
887
+ while (true) {
888
+ if (machineList.length === 0) {
889
+ clack.log.info("No machines. Create one with 'quickchr add'.");
890
+ break;
891
+ }
892
+
893
+ const selectedName = await clack.select({
894
+ message: "Select machine:",
895
+ options: [
896
+ ...machineList.map((m) => ({
897
+ value: m.name,
898
+ label: m.name,
899
+ hint: `${statusIcon(m.status)} ${m.version} (${m.arch}) • disk: ${diskSummary(m)}`,
900
+ })),
901
+ { value: "back", label: "← Back" },
902
+ ],
903
+ });
904
+ if (clack.isCancel(selectedName) || selectedName === "back") break;
905
+
906
+ // Capture disk layout once — disk format doesn't change between actions
907
+ const foundTarget = machineList.find((m) => m.name === selectedName);
908
+ if (!foundTarget) continue; // shouldn't happen; re-show machine select
909
+ let target = foundTarget;
910
+ const bootFormat = target.bootDiskFormat ?? (target.bootSize ? "qcow2" : "raw");
911
+ const canSnapshot = bootFormat === "qcow2";
912
+
913
+ clack.note(
914
+ `Boot disk: ${target.bootSize ? `${target.bootSize} (${bootFormat})` : `default (~128M, ${bootFormat})`}` +
915
+ (canSnapshot ? "" : " ⚠ no snapshots (raw disk)") + "\n" +
916
+ `Extra disks: ${target.extraDisks && target.extraDisks.length > 0 ? target.extraDisks.join(", ") : "none"}`,
917
+ `${target.name} disk layout`,
918
+ );
919
+
920
+ // Action loop — refreshes machine status on each iteration
921
+ while (true) {
922
+ machineList = QuickCHR.list();
923
+ const refreshed = machineList.find((m) => m.name === selectedName);
924
+ if (!refreshed) break; // machine removed externally
925
+ target = refreshed;
926
+
927
+ const isRunning = target.status === "running";
928
+
929
+ const runningActions = [
930
+ { value: "stop", label: "Stop" },
931
+ ...(canSnapshot ? [{ value: "snapshots", label: "Snapshots (save / load / delete / list)" }] : []),
932
+ { value: "remove", label: "Remove (stop first, then delete)" },
933
+ { value: "back", label: "← Back to machine list" },
934
+ ];
935
+ const stoppedActions = [
936
+ { value: "start", label: "Start" },
937
+ ...(canSnapshot ? [{ value: "snapshots", label: "Snapshots (list only — start for save / load / delete)" }] : []),
938
+ { value: "remove", label: "Remove" },
939
+ { value: "back", label: "← Back to machine list" },
940
+ ];
941
+ const actions = isRunning ? runningActions : stoppedActions;
942
+
943
+ const action = await clack.select({
944
+ message: `${bold(target.name)} — ${target.status}. Choose action:`,
945
+ options: actions,
946
+ });
947
+ if (clack.isCancel(action) || action === "back") break;
948
+
949
+ const instance = QuickCHR.get(target.name);
950
+ if (!instance) { clack.log.error(machineNotFoundMessage(target.name)); break; }
951
+
952
+ if (action === "start") {
953
+ const spinner = clack.spinner();
954
+ spinner.start(`Starting ${target.name}...`);
955
+ const started = await QuickCHR.start({ name: target.name, background: true });
956
+ spinner.stop(`${statusIcon("running")} ${bold(started.name)} started`);
957
+ const creds = await resolveDisplayCredentials(started.state);
958
+ clack.note(`REST: ${link(formatRestUrl(started.ports.http, creds.user, creds.password))}\nSSH: ${formatSshCommand(creds.user, started.sshPort)}`, "Instance details");
959
+ } else if (action === "stop") {
960
+ await instance.stop();
961
+ clack.log.success(`${bold(target.name)} stopped`);
962
+ } else if (action === "snapshots") {
963
+ const { formatSnapshotTable, formatDiskSize } = await import("../lib/disk.ts");
964
+ const MAX_WIZARD_SNAPSHOTS = 16;
965
+
966
+ // Pre-fetch snapshot list so unavailable actions (load/delete) are hidden
967
+ const snapSpinner = clack.spinner();
968
+ snapSpinner.start("Loading snapshots...");
969
+ let snaps = await instance.snapshot.list();
970
+ snapSpinner.stop(`${snaps.length} snapshot${snaps.length === 1 ? "" : "s"}`);
971
+
972
+ // Snapshot action loop — "back" returns to machine action menu
973
+ while (true) {
974
+ const hasSnaps = snaps.length > 0;
975
+ const snapshotOptions = isRunning
976
+ ? [
977
+ { value: "list", label: "List snapshots" },
978
+ { value: "save", label: "Save snapshot" },
979
+ ...(hasSnaps ? [{ value: "load", label: "Load (restore) snapshot" }] : []),
980
+ ...(hasSnaps ? [{ value: "delete", label: "Delete snapshot" }] : []),
981
+ { value: "back", label: "← Back" },
982
+ ]
983
+ : [
984
+ { value: "list", label: "List snapshots" },
985
+ { value: "back", label: "← Back (start machine for save / load / delete)" },
986
+ ];
987
+
988
+ const snapshotAction = await clack.select({
989
+ message: `${bold(target.name)} snapshots:`,
990
+ options: snapshotOptions,
991
+ });
992
+ if (clack.isCancel(snapshotAction) || snapshotAction === "back") break;
993
+
994
+ if (snapshotAction === "list") {
995
+ if (snaps.length === 0) {
996
+ clack.log.info("No snapshots yet. Use \"Save snapshot\" to create one.");
997
+ } else {
998
+ const displaySnaps = snaps.slice(0, MAX_WIZARD_SNAPSHOTS);
999
+ clack.note(formatSnapshotTable(displaySnaps), `${target.name} — ${snaps.length} snapshot${snaps.length === 1 ? "" : "s"}`);
1000
+ if (snaps.length > MAX_WIZARD_SNAPSHOTS) {
1001
+ clack.log.warn(`Showing ${MAX_WIZARD_SNAPSHOTS} of ${snaps.length}. Use 'quickchr snapshot ${target.name} list' for full output.`);
1002
+ }
1003
+ }
1004
+ snaps = await instance.snapshot.list();
1005
+ continue;
1006
+ }
1007
+
1008
+ if (snapshotAction === "save") {
1009
+ const defaultName = new Date().toISOString().replace(/:/g, "-").replace(/\..+$/, "Z");
1010
+ const snapName = await clack.text({
1011
+ message: "Snapshot name:",
1012
+ placeholder: defaultName,
1013
+ defaultValue: defaultName,
1014
+ validate: (v) => {
1015
+ if (v && !v.trim()) return "Snapshot name is required";
1016
+ if (v && /\s/.test(v)) return "Use a name without spaces";
1017
+ },
1018
+ });
1019
+ if (clack.isCancel(snapName)) continue;
1020
+
1021
+ const spinner = clack.spinner();
1022
+ spinner.start(`Saving snapshot ${snapName}...`);
1023
+ try {
1024
+ const snap = await instance.snapshot.save(snapName);
1025
+ spinner.stop(`Saved snapshot ${bold(snap.name)} (${formatDiskSize(snap.vmStateSize)})`);
1026
+ snaps = await instance.snapshot.list();
1027
+ } catch (e) {
1028
+ spinner.stop("Failed to save snapshot");
1029
+ clack.log.error(e instanceof Error ? e.message : String(e));
1030
+ }
1031
+ continue;
1032
+ }
1033
+
1034
+ if (snapshotAction === "load") {
1035
+ const displaySnaps = snaps.slice(0, MAX_WIZARD_SNAPSHOTS);
1036
+ const selectedSnap = await clack.select({
1037
+ message: "Select snapshot to load:",
1038
+ options: displaySnaps.map((s) => ({
1039
+ value: s.name,
1040
+ label: s.name,
1041
+ hint: `${formatDiskSize(s.vmStateSize)} — ${s.date.replace("T", " ").replace(/:\d{2}(?:\.\d+)?Z$/, "").replace("Z", "")}`,
1042
+ })),
1043
+ });
1044
+ if (clack.isCancel(selectedSnap)) continue;
1045
+
1046
+ const confirmed = await clack.confirm({
1047
+ message: `Load snapshot ${bold(selectedSnap)}? Current runtime state will be replaced.`,
1048
+ initialValue: false,
1049
+ });
1050
+ if (clack.isCancel(confirmed) || !confirmed) continue;
1051
+
1052
+ const spinner = clack.spinner();
1053
+ spinner.start(`Loading snapshot ${selectedSnap}...`);
1054
+ try {
1055
+ await instance.snapshot.load(selectedSnap);
1056
+ const booted = await instance.waitForBoot(120_000);
1057
+ spinner.stop(booted
1058
+ ? `Loaded snapshot ${bold(selectedSnap)}`
1059
+ : `Loaded snapshot ${bold(selectedSnap)} (boot readiness check timed out)`);
1060
+ } catch (e) {
1061
+ spinner.stop("Failed to load snapshot");
1062
+ clack.log.error(e instanceof Error ? e.message : String(e));
1063
+ }
1064
+ continue;
1065
+ }
1066
+
1067
+ if (snapshotAction === "delete") {
1068
+ const displaySnaps = snaps.slice(0, MAX_WIZARD_SNAPSHOTS);
1069
+ const selectedSnap = await clack.select({
1070
+ message: "Select snapshot to delete:",
1071
+ options: displaySnaps.map((s) => ({
1072
+ value: s.name,
1073
+ label: s.name,
1074
+ hint: `${formatDiskSize(s.vmStateSize)} — ${s.date.replace("T", " ").replace(/:\d{2}(?:\.\d+)?Z$/, "").replace("Z", "")}`,
1075
+ })),
1076
+ });
1077
+ if (clack.isCancel(selectedSnap)) continue;
1078
+
1079
+ const confirmed = await clack.confirm({
1080
+ message: `Delete snapshot ${bold(selectedSnap)}?`,
1081
+ initialValue: false,
1082
+ });
1083
+ if (clack.isCancel(confirmed) || !confirmed) continue;
1084
+
1085
+ try {
1086
+ await instance.snapshot.delete(selectedSnap);
1087
+ clack.log.success(`Deleted snapshot ${bold(selectedSnap)}`);
1088
+ snaps = await instance.snapshot.list();
1089
+ } catch (e) {
1090
+ clack.log.error(e instanceof Error ? e.message : String(e));
1091
+ }
1092
+ }
1093
+ }
1094
+ // snapshot loop exited via "← Back" → continue action loop
1095
+ } else if (action === "remove") {
1096
+ const confirmed = await clack.confirm({
1097
+ message: `Remove ${bold(target.name)} and its disk? This cannot be undone.`,
1098
+ initialValue: false,
1099
+ });
1100
+ if (!clack.isCancel(confirmed) && confirmed) {
1101
+ await instance.remove();
1102
+ clack.log.success(`${bold(target.name)} removed`);
1103
+ break; // machine is gone — exit action loop
1104
+ }
1105
+ // declined: stay in action loop
1106
+ }
1107
+ }
1108
+
1109
+ machineList = QuickCHR.list();
1110
+ }
1111
+ // machine select "← Back" → outer while continues → main menu re-shown
1112
+ }
1113
+ }
1114
+
1115
+ async function cmdStart(argv: string[]) {
1116
+ const { flags, positional } = parseFlags(argv);
1117
+
1118
+ // --all: start every stopped machine in background
1119
+ if (flagBool(flags, "all")) {
1120
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1121
+ const { statusIcon, link, bold, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
1122
+ const stopped = QuickCHR.list().filter((m) => m.status !== "running");
1123
+ if (stopped.length === 0) {
1124
+ console.log("No stopped instances.");
1125
+ return;
1126
+ }
1127
+ for (const m of stopped) {
1128
+ console.log(`Starting ${bold(m.name)}...`);
1129
+ const instance = await QuickCHR.start({ name: m.name, background: true });
1130
+ const creds = await resolveDisplayCredentials(instance.state);
1131
+ console.log(`${statusIcon("running")} ${bold(instance.name)} REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))} SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
1132
+ }
1133
+ return;
1134
+ }
1135
+
1136
+ // Determine if a specific target was requested via flags or positional
1137
+ const hasExplicitTarget =
1138
+ positional[0] !== undefined ||
1139
+ flag(flags, "name") !== undefined ||
1140
+ flag(flags, "version") !== undefined ||
1141
+ flag(flags, "channel") !== undefined;
1142
+
1143
+ // No target → list stopped machines with tip
1144
+ if (!hasExplicitTarget) {
1145
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1146
+ const stopped = QuickCHR.list().filter((m) => m.status !== "running");
1147
+ printMachineListWithTip("start", stopped);
1148
+ return;
1149
+ }
1150
+
1151
+ // Build start options from flags
1152
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1153
+ const { statusIcon, formatPorts, formatNetworks, link, bold, dim, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
1154
+
1155
+ const opts: StartOptions = {
1156
+ version: flag(flags, "version"),
1157
+ channel: flag(flags, "channel") as Channel | undefined,
1158
+ arch: flag(flags, "arch") as Arch | undefined,
1159
+ name: flag(flags, "name") ?? positional[0],
1160
+ cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
1161
+ mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
1162
+ packages: flagList(flags, "add-package"),
1163
+ installAllPackages: flagBool(flags, "install-all-packages"),
1164
+ portBase: flag(flags, "port-base") ? Number(flag(flags, "port-base")) : undefined,
1165
+ excludePorts: [
1166
+ ...(flags.winbox === false ? ["winbox" as ServiceName] : []),
1167
+ ...(flags["api-ssl"] === false ? ["api-ssl" as ServiceName] : []),
1168
+ ],
1169
+ networks: buildNetworks(flags),
1170
+ bootDiskFormat: (flag(flags, "boot-disk-format") as StartOptions["bootDiskFormat"] | undefined) ?? "qcow2",
1171
+ bootSize: flag(flags, "boot-size"),
1172
+ extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
1173
+ installDeps: flagBool(flags, "install-deps"),
1174
+ dryRun: flagBool(flags, "dry-run"),
1175
+ timeoutExtra: parseInt(flag(flags, "timeout-extra") ?? flag(flags, "T") ?? "0", 10) * 1000 || undefined,
1176
+ };
1177
+
1178
+ const deviceModeValue = flag(flags, "device-mode");
1179
+ const deviceModeEnable = csvList(flagList(flags, "device-mode-enable"));
1180
+ const deviceModeDisable = csvList(flagList(flags, "device-mode-disable"));
1181
+ const noDeviceMode = flags["device-mode"] === false;
1182
+ if (noDeviceMode) {
1183
+ if (deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
1184
+ console.warn("Warning: --device-mode-enable/--device-mode-disable ignored because --no-device-mode was set.");
1185
+ }
1186
+ } else if (deviceModeValue !== undefined || deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
1187
+ opts.deviceMode = {
1188
+ mode: deviceModeValue ?? "auto",
1189
+ enable: deviceModeEnable.length > 0 ? deviceModeEnable : undefined,
1190
+ disable: deviceModeDisable.length > 0 ? deviceModeDisable : undefined,
1191
+ };
1192
+ }
1193
+
1194
+ // --license-* flags (credentials from env if not supplied)
1195
+ const licenseLevel = flag(flags, "license-level");
1196
+ const licenseAccount = flag(flags, "license-account") ?? process.env.MIKROTIK_WEB_ACCOUNT;
1197
+ const licensePassword = flag(flags, "license-password") ?? process.env.MIKROTIK_WEB_PASSWORD;
1198
+ if (licenseLevel || licenseAccount) {
1199
+ if (!licenseAccount || !licensePassword) {
1200
+ console.error("Error: --license-level requires --license-account and --license-password (or MIKROTIK_WEB_ACCOUNT/MIKROTIK_WEB_PASSWORD env vars).");
1201
+ process.exit(1);
1202
+ }
1203
+ opts.license = {
1204
+ account: licenseAccount,
1205
+ password: licensePassword,
1206
+ level: licenseLevel as import("../lib/types.ts").LicenseLevel | undefined,
1207
+ };
1208
+ }
1209
+
1210
+ // --add-user admin:pass
1211
+ const userStr = flag(flags, "add-user");
1212
+ if (userStr) {
1213
+ const [name = "", password] = userStr.split(":");
1214
+ opts.user = { name, password: password ?? "" };
1215
+ }
1216
+
1217
+ opts.disableAdmin = flagBool(flags, "disable-admin");
1218
+
1219
+ // --secure-login / --no-secure-login
1220
+ if (flags["secure-login"] === false) {
1221
+ opts.secureLogin = false;
1222
+ } else if (flagBool(flags, "secure-login")) {
1223
+ opts.secureLogin = true;
1224
+ }
1225
+
1226
+ // Background default: true. Explicitly foreground only with --fg / --foreground / --no-background / --no-bg.
1227
+ const wantFg =
1228
+ flagBool(flags, "fg") ||
1229
+ flagBool(flags, "foreground") ||
1230
+ flags.background === false ||
1231
+ flags.bg === false;
1232
+ opts.background = !wantFg;
1233
+
1234
+ if (opts.dryRun) {
1235
+ console.log("Dry run — would start with options:", JSON.stringify(opts, null, 2));
1236
+ return;
1237
+ }
1238
+
1239
+ // --fg on already-running machine: attach to its serial console
1240
+ if (!opts.background && opts.name) {
1241
+ const runningMachine = QuickCHR.list().find((m) => m.name === opts.name && m.status === "running");
1242
+ if (runningMachine) {
1243
+ await attachSerial(runningMachine.name, runningMachine.machineDir);
1244
+ return;
1245
+ }
1246
+ }
1247
+
1248
+ if (!opts.background) {
1249
+ const wantsDeviceMode = !!opts.deviceMode && !["skip", "none", "off", "disabled"].includes((opts.deviceMode.mode ?? "auto").toLowerCase());
1250
+ const hasProv = !!(
1251
+ opts.installAllPackages ||
1252
+ (opts.packages?.length ?? 0) > 0 ||
1253
+ opts.user ||
1254
+ opts.disableAdmin ||
1255
+ opts.license ||
1256
+ wantsDeviceMode
1257
+ );
1258
+ printForegroundTips(hasProv);
1259
+ if (hasProv) {
1260
+ console.log("\x1b[2m (CHR will boot and configure itself before the console appears)\x1b[0m");
1261
+ console.log();
1262
+ }
1263
+ // Brief pause so user can read tips before QEMU clears the screen
1264
+ await Bun.sleep(1500);
1265
+ }
1266
+
1267
+ const instance = await QuickCHR.start(opts);
1268
+
1269
+ if (!opts.background) {
1270
+ console.log(`\n${bold(instance.name)} session ended`);
1271
+ console.log(` ${dim("Tip: resume session")} quickchr start ${instance.name} --fg`);
1272
+ console.log(` ${dim("Tip: run background")} quickchr start ${instance.name}`);
1273
+ } else {
1274
+ const creds = await resolveDisplayCredentials(instance.state);
1275
+ console.log(`${statusIcon("running")} ${bold(instance.name)} started`);
1276
+ console.log(` Version: ${instance.state.version} (${instance.state.arch})`);
1277
+ console.log(` Network: ${formatNetworks(instance.state.networks ?? [])}`);
1278
+ console.log(` Ports: ${formatPorts(instance.state.ports)}`);
1279
+ console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
1280
+ console.log(` SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
1281
+ console.log(` WinBox: 127.0.0.1:${instance.ports.winbox}`);
1282
+ if (creds.user !== "admin") {
1283
+ console.log(` Login: ${creds.user} / ${creds.password || "(no password)"}`);
1284
+ console.log(` Run: quickchr exec ${instance.name} /system/resource/print`);
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ async function cmdStop(argv: string[]) {
1290
+ const { flags, positional } = parseFlags(argv);
1291
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1292
+ const { statusIcon, bold, machineNotFoundMessage } = await import("./format.ts");
1293
+
1294
+ if (flagBool(flags, "all")) {
1295
+ const machines = QuickCHR.list().filter((m) => m.status === "running");
1296
+ if (machines.length === 0) {
1297
+ console.log("No running instances.");
1298
+ return;
1299
+ }
1300
+ for (const m of machines) {
1301
+ const instance = QuickCHR.get(m.name);
1302
+ if (instance) {
1303
+ await instance.stop();
1304
+ console.log(`${statusIcon("stopped")} ${bold(m.name)} stopped`);
1305
+ }
1306
+ }
1307
+ return;
1308
+ }
1309
+
1310
+ const name = positional[0] ?? flag(flags, "name");
1311
+
1312
+ if (!name) {
1313
+ const running = QuickCHR.list().filter((m) => m.status === "running");
1314
+ printMachineListWithTip("stop", running);
1315
+ return;
1316
+ }
1317
+
1318
+ const instance = QuickCHR.get(name);
1319
+ if (!instance) {
1320
+ console.error(machineNotFoundMessage(name));
1321
+ process.exit(1);
1322
+ }
1323
+ await instance.stop();
1324
+ console.log(`${statusIcon("stopped")} ${bold(name)} stopped`);
1325
+ }
1326
+
1327
+ async function cmdList(argv: string[] = []) {
1328
+ const { flags, positional } = parseFlags(argv);
1329
+ const asJson = flagBool(flags, "json");
1330
+ const name = positional[0];
1331
+
1332
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1333
+
1334
+ if (name) {
1335
+ const { statusIcon, bold, dim, link, formatPorts, formatNetworks, machineNotFoundMessage, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
1336
+
1337
+ const instance = QuickCHR.get(name);
1338
+ if (!instance) {
1339
+ console.error(machineNotFoundMessage(name));
1340
+ process.exit(1);
1341
+ }
1342
+
1343
+ const s = instance.state;
1344
+
1345
+ if (asJson) {
1346
+ const creds = s.status === "running" ? await resolveDisplayCredentials(s) : null;
1347
+ console.log(JSON.stringify({
1348
+ name: s.name,
1349
+ status: s.status,
1350
+ version: s.version,
1351
+ arch: s.arch,
1352
+ cpu: s.cpu,
1353
+ mem: s.mem,
1354
+ networks: s.networks,
1355
+ ports: s.ports,
1356
+ pid: s.pid ?? null,
1357
+ packages: s.packages,
1358
+ machineDir: s.machineDir,
1359
+ createdAt: s.createdAt,
1360
+ lastStartedAt: s.lastStartedAt ?? null,
1361
+ credentials: creds,
1362
+ }, null, 2));
1363
+ return;
1364
+ }
1365
+
1366
+ console.log(`\n${statusIcon(s.status)} ${bold(s.name)}`);
1367
+ console.log(` Version: ${s.version} (${s.arch})`);
1368
+ console.log(` CPU/Mem: ${s.cpu} vCPU${s.cpu > 1 ? "s" : ""}, ${s.mem} MB`);
1369
+ console.log(` Network: ${formatNetworks(s.networks)}`);
1370
+ console.log(` Ports: ${formatPorts(s.ports)}`);
1371
+ console.log(` Status: ${s.status}${s.pid ? ` (PID ${s.pid})` : ""}`);
1372
+ console.log(` Created: ${new Date(s.createdAt).toLocaleString()}`);
1373
+ if (s.lastStartedAt) console.log(` Started: ${new Date(s.lastStartedAt).toLocaleString()}`);
1374
+ if (s.packages.length > 0) console.log(` Packages: ${s.packages.join(", ")}`);
1375
+ console.log(` Dir: ${dim(s.machineDir)}`);
1376
+ console.log(` Logs: ${dim(`${s.machineDir}/qemu.log`)}`);
1377
+
1378
+ if (s.status === "running") {
1379
+ const creds = await resolveDisplayCredentials(s);
1380
+ console.log();
1381
+ console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
1382
+ console.log(` WinBox: 127.0.0.1:${instance.ports.winbox}`);
1383
+ console.log(` SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
1384
+ if (creds.user !== "admin") {
1385
+ console.log(` Login: ${creds.user} / ${creds.password || "(no password)"}`);
1386
+ }
1387
+ console.log(` Serial: ${dim(`${s.machineDir}/serial.sock`)}`);
1388
+ console.log();
1389
+ console.log(` ${dim("Tip:")} quickchr stop ${s.name}`);
1390
+ } else {
1391
+ console.log();
1392
+ console.log(` ${dim("Tip:")} quickchr start ${s.name} | quickchr remove ${s.name}`);
1393
+ }
1394
+ console.log();
1395
+ return;
1396
+ }
1397
+
1398
+ const { table, statusIcon, formatPorts, formatNetworks, dim } = await import("./format.ts");
1399
+ const machines = QuickCHR.list();
1400
+
1401
+ if (machines.length === 0) {
1402
+ if (asJson) {
1403
+ console.log("[]");
1404
+ } else {
1405
+ console.log("No instances. Run 'quickchr start' to create one.");
1406
+ }
1407
+ return;
1408
+ }
1409
+
1410
+ if (asJson) {
1411
+ console.log(JSON.stringify(machines, null, 2));
1412
+ return;
1413
+ }
1414
+
1415
+ const headers = ["", "Name", "Version", "Arch", "Network", "Ports", "PID"];
1416
+ const rows = machines.map((m) => [
1417
+ statusIcon(m.status),
1418
+ m.name,
1419
+ m.version,
1420
+ m.arch,
1421
+ formatNetworks(m.networks ?? []),
1422
+ formatPorts(m.ports),
1423
+ m.pid ? String(m.pid) : dim("—"),
1424
+ ]);
1425
+
1426
+ console.log(table(headers, rows));
1427
+ }
1428
+
1429
+
1430
+ async function cmdGet(argv: string[]) {
1431
+ const { flags, positional } = parseFlags(argv);
1432
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1433
+ const { bold, dim, machineNotFoundMessage } = await import("./format.ts");
1434
+
1435
+ const name = positional[0];
1436
+ const group = positional[1] as "license" | "device-mode" | "admin" | undefined;
1437
+ const asJson = flagBool(flags, "json");
1438
+
1439
+ if (!name) {
1440
+ console.error("Usage: quickchr get <name> [license|device-mode|admin] [--json]");
1441
+ process.exit(1);
1442
+ }
1443
+
1444
+ const validGroups = ["license", "device-mode", "admin"];
1445
+ if (group && !validGroups.includes(group)) {
1446
+ console.error(`Unknown property group: "${group}". Valid groups: ${validGroups.join(", ")}`);
1447
+ process.exit(1);
1448
+ }
1449
+
1450
+ const instance = QuickCHR.get(name);
1451
+ if (!instance) {
1452
+ console.error(machineNotFoundMessage(name));
1453
+ process.exit(1);
1454
+ }
1455
+
1456
+ const s = instance.state;
1457
+
1458
+ if (s.status !== "running") {
1459
+ // Offline — show what we know from persisted state
1460
+ const offlineData = {
1461
+ name: s.name,
1462
+ status: s.status,
1463
+ version: s.version,
1464
+ arch: s.arch,
1465
+ note: "machine is not running — live properties unavailable",
1466
+ };
1467
+ if (asJson) {
1468
+ console.log(JSON.stringify(offlineData, null, 2));
1469
+ } else {
1470
+ console.log(`\n${bold(s.name)} (${s.status})`);
1471
+ console.log(` Version: ${s.version} (${s.arch})`);
1472
+ console.log(` ${dim("Start the machine to query live properties.")}`);
1473
+ console.log();
1474
+ }
1475
+ return;
1476
+ }
1477
+
1478
+ const { resolveAuth } = await import("../lib/auth.ts");
1479
+ const auth = resolveAuth(s);
1480
+ const base = `http://127.0.0.1:${instance.ports.http}/rest`;
1481
+ const authHeader = auth.header;
1482
+
1483
+ async function fetchGroup(path: string): Promise<unknown> {
1484
+ try {
1485
+ const { restGet: rGet } = await import("../lib/rest.ts");
1486
+ const { status, body } = await rGet(`${base}${path}`, authHeader, 8_000);
1487
+ if (status >= 200 && status < 300) return JSON.parse(body);
1488
+ } catch { /* offline or auth error */ }
1489
+ return null;
1490
+ }
1491
+
1492
+ const results: Record<string, unknown> = {};
1493
+
1494
+ if (!group || group === "license") {
1495
+ const v = await fetchGroup("/system/license");
1496
+ if (v) results.license = v;
1497
+ }
1498
+ if (!group || group === "device-mode") {
1499
+ const v = await fetchGroup("/system/device-mode");
1500
+ if (v) results["device-mode"] = v;
1501
+ }
1502
+ if (!group || group === "admin") {
1503
+ // Show all users in group=full (admin-equivalent)
1504
+ const users = await fetchGroup("/user") as Array<Record<string, unknown>> | null;
1505
+ if (users) results.admin = users.filter((u) => u.group === "full");
1506
+ }
1507
+
1508
+ if (asJson) {
1509
+ console.log(JSON.stringify(results, null, 2));
1510
+ return;
1511
+ }
1512
+
1513
+ console.log(`\n${bold(s.name)} — live config`);
1514
+
1515
+ if (results.license) {
1516
+ const lic = results.license as Record<string, unknown>;
1517
+ console.log(`\n ${bold("License")}`);
1518
+ const level = lic.level ?? lic["upgradable-to"] ?? "free (1 Mbps)";
1519
+ console.log(` Level: ${level}`);
1520
+ if (lic["system-id"]) console.log(` Software ID: ${lic["system-id"]}`);
1521
+ if (lic.deadline) console.log(` Deadline: ${lic.deadline}`);
1522
+ }
1523
+
1524
+ if (results["device-mode"]) {
1525
+ const dm = results["device-mode"] as Record<string, unknown>;
1526
+ const mode = dm.mode ?? "(not set)";
1527
+ console.log(`\n ${bold("Device Mode")}`);
1528
+ console.log(` Mode: ${mode}`);
1529
+ const features = Object.entries(dm)
1530
+ .filter(([k, v]) => k !== "mode" && v === true)
1531
+ .map(([k]) => k);
1532
+ if (features.length > 0) console.log(` Enabled: ${features.join(", ")}`);
1533
+ }
1534
+
1535
+ if (results.admin) {
1536
+ const users = results.admin as Array<Record<string, unknown>>;
1537
+ console.log(`\n ${bold("Admin Users")} (group=full)`);
1538
+ for (const u of users) {
1539
+ const status = u.disabled === "true" || u.disabled === true ? dim(" (disabled)") : "";
1540
+ console.log(` ${u.name}${status}`);
1541
+ }
1542
+ }
1543
+
1544
+ console.log();
1545
+ }
1546
+
1547
+ async function cmdRemove(argv: string[]) {
1548
+ const { flags, positional } = parseFlags(argv);
1549
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1550
+ const { bold, statusIcon, machineNotFoundMessage } = await import("./format.ts");
1551
+
1552
+ // --all: remove every machine (warn about running ones)
1553
+ if (flagBool(flags, "all")) {
1554
+ const machines = QuickCHR.list();
1555
+ if (machines.length === 0) {
1556
+ console.log("No instances.");
1557
+ return;
1558
+ }
1559
+ for (const m of machines) {
1560
+ const instance = QuickCHR.get(m.name);
1561
+ if (instance) {
1562
+ await instance.remove();
1563
+ console.log(`${statusIcon("stopped")} ${bold(m.name)} removed`);
1564
+ }
1565
+ }
1566
+ return;
1567
+ }
1568
+
1569
+ const name = positional[0] ?? flag(flags, "name");
1570
+
1571
+ if (!name) {
1572
+ const machines = QuickCHR.list();
1573
+ printMachineListWithTip("remove", machines, (m) => m.status === "running" ? "(stop first)" : undefined);
1574
+ return;
1575
+ }
1576
+
1577
+ const instance = QuickCHR.get(name);
1578
+ if (!instance) {
1579
+ console.error(machineNotFoundMessage(name));
1580
+ process.exit(1);
1581
+ }
1582
+
1583
+ await instance.remove();
1584
+ console.log(`${bold(name)} removed.`);
1585
+ }
1586
+
1587
+ async function cmdClean(argv: string[]) {
1588
+ const { flags, positional } = parseFlags(argv);
1589
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1590
+ const { bold, statusIcon, machineNotFoundMessage } = await import("./format.ts");
1591
+
1592
+ // --all: clean every machine
1593
+ if (flagBool(flags, "all")) {
1594
+ const machines = QuickCHR.list();
1595
+ if (machines.length === 0) {
1596
+ console.log("No instances.");
1597
+ return;
1598
+ }
1599
+ for (const m of machines) {
1600
+ const instance = QuickCHR.get(m.name);
1601
+ if (instance) {
1602
+ await instance.clean();
1603
+ console.log(`${statusIcon("stopped")} ${bold(m.name)} reset to fresh image.`);
1604
+ }
1605
+ }
1606
+ return;
1607
+ }
1608
+
1609
+ const name = positional[0] ?? flag(flags, "name");
1610
+
1611
+ if (!name) {
1612
+ const machines = QuickCHR.list();
1613
+ printMachineListWithTip("clean", machines);
1614
+ return;
1615
+ }
1616
+
1617
+ const instance = QuickCHR.get(name);
1618
+ if (!instance) {
1619
+ console.error(machineNotFoundMessage(name));
1620
+ process.exit(1);
1621
+ }
1622
+
1623
+ await instance.clean();
1624
+ console.log(`${bold(name)} reset to fresh image.`);
1625
+ }
1626
+
1627
+ async function applyLicense(argv: string[]) {
1628
+ const { flags, positional } = parseFlags(argv);
1629
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1630
+ const { bold, machineNotFoundMessage } = await import("./format.ts");
1631
+
1632
+ const name = positional[0] ?? flag(flags, "name");
1633
+
1634
+ if (!name) {
1635
+ console.error("Usage: quickchr set <name> --license [--level=p1|p10|unlimited]");
1636
+ process.exit(1);
1637
+ }
1638
+
1639
+ const instance = QuickCHR.get(name);
1640
+ if (!instance) {
1641
+ console.error(machineNotFoundMessage(name));
1642
+ process.exit(1);
1643
+ }
1644
+ if (instance.state.status !== "running") {
1645
+ console.error(`Machine "${name}" is not running.`);
1646
+ process.exit(1);
1647
+ }
1648
+
1649
+ let account = flag(flags, "account") ?? process.env.MIKROTIK_WEB_ACCOUNT;
1650
+ let password = flag(flags, "password") ?? process.env.MIKROTIK_WEB_PASSWORD;
1651
+ const level = (flag(flags, "level") as import("../lib/types.ts").LicenseLevel | undefined) ?? "p1";
1652
+
1653
+ if (!account || !password) {
1654
+ const { getStoredCredentials, credentialStorageLabel } = await import("../lib/credentials.ts");
1655
+ const stored = await getStoredCredentials();
1656
+ if (stored) {
1657
+ account = stored.account;
1658
+ password = stored.password;
1659
+ console.log(`Using stored credentials for ${stored.account} (${credentialStorageLabel()})`);
1660
+ }
1661
+ }
1662
+
1663
+ if (!account || !password) {
1664
+ if (isNoPrompt()) {
1665
+ console.error("No credentials found. Set MIKROTIK_WEB_ACCOUNT and MIKROTIK_WEB_PASSWORD, or pass --account/--password.");
1666
+ process.exit(1);
1667
+ }
1668
+
1669
+ const clack = await import("@clack/prompts");
1670
+ clack.intro("quickchr license — credentials needed");
1671
+ const acct = await clack.text({ message: "MikroTik account (email):", validate: (v) => v.trim() ? undefined : "Required" });
1672
+ if (clack.isCancel(acct)) { clack.cancel("Cancelled."); return; }
1673
+ const pass = await clack.password({ message: "MikroTik password:" });
1674
+ if (clack.isCancel(pass)) { clack.cancel("Cancelled."); return; }
1675
+ account = acct;
1676
+ password = pass;
1677
+
1678
+ const save = await clack.confirm({ message: "Save credentials to system secret store?", initialValue: true });
1679
+ if (!clack.isCancel(save) && save) {
1680
+ const { saveCredentials, credentialStorageLabel } = await import("../lib/credentials.ts");
1681
+ await saveCredentials(account, password);
1682
+ clack.log.success(`Saved to ${credentialStorageLabel()}`);
1683
+ }
1684
+ clack.outro("");
1685
+ }
1686
+
1687
+ const before = instance.state.licenseLevel ?? "free";
1688
+ console.log(`Applying license level=${level} to ${bold(name)}...`);
1689
+ await instance.license({ account: account as string, password: password as string, level });
1690
+ console.log(`License applied: ${before} → ${level}`);
1691
+ }
1692
+
1693
+ async function cmdSet(argv: string[]) {
1694
+ const { flags, positional } = parseFlags(argv);
1695
+ const name = positional[0];
1696
+
1697
+ if (!name) {
1698
+ console.error("Usage: quickchr set <name> --license [--level=p1|p10|unlimited]");
1699
+ process.exit(1);
1700
+ }
1701
+
1702
+ const wantsLicense = flagBool(flags, "license");
1703
+
1704
+ if (!wantsLicense) {
1705
+ console.error("Nothing to set. Available flags: --license");
1706
+ process.exit(1);
1707
+ }
1708
+
1709
+ if (wantsLicense) {
1710
+ const subcmdArgs = [name, ...Object.entries(flags).flatMap(([k, v]) => v === true ? [`--${k}`] : [`--${k}=${v}`]), ...positional.slice(1)];
1711
+ await applyLicense(subcmdArgs);
1712
+ }
1713
+ }
1714
+
1715
+ async function cmdLicense(argv: string[]) {
1716
+ await applyLicense(argv);
1717
+ }
1718
+
1719
+
1720
+ async function cmdDisk(argv: string[]) {
1721
+ const { positional } = parseFlags(argv);
1722
+ const name = positional[0];
1723
+
1724
+ if (!name) {
1725
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1726
+ const machines = QuickCHR.list();
1727
+ printMachineListWithTip("disk", machines, undefined, false);
1728
+ return;
1729
+ }
1730
+
1731
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1732
+ const { bold, dim, machineNotFoundMessage } = await import("./format.ts");
1733
+ const { getDiskInfo } = await import("../lib/disk.ts");
1734
+ const { join } = await import("node:path");
1735
+ const { existsSync } = await import("node:fs");
1736
+ const { findQemuImg } = await import("../lib/platform.ts");
1737
+
1738
+ const machine = QuickCHR.get(name);
1739
+ if (!machine) {
1740
+ console.error(machineNotFoundMessage(name));
1741
+ process.exit(1);
1742
+ }
1743
+
1744
+ const state = machine.state;
1745
+ const qemuImg = findQemuImg();
1746
+
1747
+ const formatSize = (bytes: number) => {
1748
+ if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
1749
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1750
+ return `${(bytes / 1024).toFixed(1)} KB`;
1751
+ };
1752
+
1753
+ console.log(bold(`Disks for ${name}\n`));
1754
+
1755
+ // Boot disk
1756
+ const bootPath = state.bootDiskFormat === "qcow2"
1757
+ ? join(state.machineDir, "boot.qcow2")
1758
+ : join(state.machineDir, "disk.img");
1759
+ const bootFormat = state.bootDiskFormat ?? "raw";
1760
+
1761
+ if (qemuImg && existsSync(bootPath)) {
1762
+ try {
1763
+ const info = await getDiskInfo(bootPath);
1764
+ console.log(` boot ${bootFormat.padEnd(6)} virtual: ${formatSize(info.virtualSize).padEnd(10)} actual: ${formatSize(info.actualSize)}`);
1765
+ } catch {
1766
+ console.log(` boot ${bootFormat.padEnd(6)} ${bootPath}`);
1767
+ }
1768
+ } else {
1769
+ console.log(` boot ${bootFormat.padEnd(6)} ${bootPath}`);
1770
+ }
1771
+
1772
+ // Extra disks
1773
+ if (state.extraDisks && state.extraDisks.length > 0) {
1774
+ for (let i = 0; i < state.extraDisks.length; i++) {
1775
+ const diskPath = join(state.machineDir, `disk${i + 1}.qcow2`);
1776
+ const label = `disk${i + 1}`;
1777
+ if (qemuImg && existsSync(diskPath)) {
1778
+ try {
1779
+ const info = await getDiskInfo(diskPath);
1780
+ console.log(` ${label.padEnd(6)} qcow2 virtual: ${formatSize(info.virtualSize).padEnd(10)} actual: ${formatSize(info.actualSize)}`);
1781
+ } catch {
1782
+ console.log(` ${label.padEnd(6)} qcow2 ${diskPath}`);
1783
+ }
1784
+ } else {
1785
+ console.log(` ${label.padEnd(6)} qcow2 ${dim("(not created)")}`);
1786
+ }
1787
+ }
1788
+ }
1789
+
1790
+ if (!qemuImg) {
1791
+ console.log(dim(`\n (install qemu-img for disk size details)`));
1792
+ }
1793
+ }
1794
+
1795
+ async function cmdSnapshot(argv: string[]) {
1796
+ const { flags, positional } = parseFlags(argv);
1797
+ const name = positional[0];
1798
+ const subcmd = positional[1] ?? "list";
1799
+ const snapName = positional[2];
1800
+ const jsonOutput = flags.json === true;
1801
+
1802
+ if (!name || name === "help" || name === "--help" || name === "-h") {
1803
+ printCommandHelp("snapshot");
1804
+ return;
1805
+ }
1806
+
1807
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1808
+ const { bold, dim, machineNotFoundMessage } = await import("./format.ts");
1809
+ const { formatSnapshotTable, formatDiskSize } = await import("../lib/disk.ts");
1810
+
1811
+ const instance = QuickCHR.get(name);
1812
+ if (!instance) {
1813
+ console.error(machineNotFoundMessage(name));
1814
+ process.exit(1);
1815
+ }
1816
+
1817
+ const format = instance.state.bootDiskFormat ?? (instance.state.bootSize ? "qcow2" : "raw");
1818
+ if (format !== "qcow2") {
1819
+ console.error(`Snapshots require a qcow2 boot disk. "${name}" uses ${format}.`);
1820
+ console.error(dim("Hint: recreate with --boot-disk-format=qcow2 or --boot-size=<size>."));
1821
+ process.exit(1);
1822
+ }
1823
+
1824
+ switch (subcmd) {
1825
+ case "list":
1826
+ case "ls": {
1827
+ const snaps = await instance.snapshot.list();
1828
+ if (jsonOutput) {
1829
+ console.log(JSON.stringify(snaps, null, 2));
1830
+ } else if (snaps.length === 0) {
1831
+ console.log("No snapshots.");
1832
+ } else {
1833
+ console.log(bold(`Snapshots for ${name}\n`));
1834
+ console.log(formatSnapshotTable(snaps));
1835
+ }
1836
+ break;
1837
+ }
1838
+ case "save": {
1839
+ const snap = await instance.snapshot.save(snapName);
1840
+ if (jsonOutput) {
1841
+ console.log(JSON.stringify(snap, null, 2));
1842
+ } else {
1843
+ console.log(`Saved snapshot ${bold(snap.name)} (${formatDiskSize(snap.vmStateSize)})`);
1844
+ }
1845
+ break;
1846
+ }
1847
+ case "load": {
1848
+ if (!snapName) {
1849
+ const snaps = await instance.snapshot.list();
1850
+ if (snaps.length > 0 && snaps.length <= 5) {
1851
+ console.error(`Usage: quickchr snapshot ${name} load <snapshot-name>`);
1852
+ console.error(dim(` Available: ${snaps.map((s) => s.name).join(", ")}`));
1853
+ } else {
1854
+ console.error(`Usage: quickchr snapshot ${name} load <snapshot-name>`);
1855
+ if (snaps.length > 5) console.error(dim(` Run 'quickchr snapshot ${name} list' to see all snapshots.`));
1856
+ else console.error(dim(` No snapshots yet. Run: quickchr snapshot ${name} save`));
1857
+ }
1858
+ process.exit(1);
1859
+ }
1860
+ await instance.snapshot.load(snapName);
1861
+ if (!jsonOutput) console.log(`Loaded snapshot ${bold(snapName)}`);
1862
+ break;
1863
+ }
1864
+ case "delete":
1865
+ case "rm": {
1866
+ if (!snapName) {
1867
+ const snaps = await instance.snapshot.list();
1868
+ if (snaps.length > 0 && snaps.length <= 5) {
1869
+ console.error(`Usage: quickchr snapshot ${name} delete <snapshot-name>`);
1870
+ console.error(dim(` Available: ${snaps.map((s) => s.name).join(", ")}`));
1871
+ } else {
1872
+ console.error(`Usage: quickchr snapshot ${name} delete <snapshot-name>`);
1873
+ if (snaps.length > 5) console.error(dim(` Run 'quickchr snapshot ${name} list' to see all snapshots.`));
1874
+ }
1875
+ process.exit(1);
1876
+ }
1877
+ await instance.snapshot.delete(snapName);
1878
+ if (!jsonOutput) console.log(`Deleted snapshot ${bold(snapName)}`);
1879
+ break;
1880
+ }
1881
+ default:
1882
+ console.error(`Unknown snapshot subcommand: ${subcmd}`);
1883
+ printCommandHelp("snapshot");
1884
+ process.exit(1);
1885
+ }
1886
+ }
1887
+
1888
+ async function cmdNetworks(argv: string[]) {
1889
+ const subcmd = argv[0];
1890
+
1891
+ if (subcmd === "help" || subcmd === "--help" || subcmd === "-h") {
1892
+ showNetworksHelp();
1893
+ return;
1894
+ }
1895
+
1896
+ if (subcmd === "interfaces") {
1897
+ await showInterfaces();
1898
+ return;
1899
+ }
1900
+
1901
+ if (subcmd === "sockets") {
1902
+ await handleSockets(argv.slice(1));
1903
+ return;
1904
+ }
1905
+
1906
+ if (subcmd && subcmd !== "--help") {
1907
+ console.error(`Unknown networks subcommand: ${subcmd}\nRun 'quickchr networks help' for usage.`);
1908
+ process.exit(1);
1909
+ }
1910
+
1911
+ await showNetworkOverview();
1912
+ }
1913
+
1914
+ async function showNetworkOverview() {
1915
+ const { detectPlatform, detectPhysicalInterfaces } = await import("../lib/platform.ts");
1916
+ const { listNamedSockets } = await import("../lib/socket-registry.ts");
1917
+ const { statusIcon, bold, dim } = await import("./format.ts");
1918
+
1919
+ const platform = await detectPlatform();
1920
+ const interfaces = detectPhysicalInterfaces();
1921
+ const sockets = listNamedSockets();
1922
+ const vmnet = platform.socketVmnet;
1923
+
1924
+ const osLabel = platform.os === "darwin" ? "macOS" : platform.os === "linux" ? "Linux" : "Windows";
1925
+
1926
+ console.log(bold("Network Capabilities:\n"));
1927
+
1928
+ console.log(` Platform: ${osLabel} (${platform.os}/${platform.hostArch})`);
1929
+
1930
+ if (platform.os === "darwin") {
1931
+ if (vmnet) {
1932
+ console.log(` socket_vmnet: ${statusIcon("ok")} installed (${dim(vmnet.client)})`);
1933
+ if (vmnet.sharedSocket) {
1934
+ console.log(` Shared daemon: ${statusIcon("ok")} running (${dim(vmnet.sharedSocket)})`);
1935
+ } else {
1936
+ console.log(` Shared daemon: ${statusIcon("error")} not running`);
1937
+ }
1938
+ const bridgedEntries = Object.entries(vmnet.bridgedSockets);
1939
+ if (bridgedEntries.length > 0) {
1940
+ console.log(` Bridged daemons:`);
1941
+ for (const [iface, sock] of bridgedEntries) {
1942
+ console.log(` ${iface}: ${dim(sock)}`);
1943
+ }
1944
+ } else {
1945
+ console.log(" Bridged daemons: none");
1946
+ }
1947
+ } else {
1948
+ console.log(` socket_vmnet: ${statusIcon("error")} not installed`);
1949
+ console.log(` Hint: brew install socket_vmnet`);
1950
+ }
1951
+ } else {
1952
+ console.log(` socket_vmnet: ${dim("n/a (macOS only)")}`);
1953
+ }
1954
+
1955
+ console.log();
1956
+
1957
+ if (interfaces.length > 0) {
1958
+ console.log(" Physical Interfaces:");
1959
+ for (const iface of interfaces) {
1960
+ const aliasStr = iface.alias ? dim(` (alias: ${iface.alias})`) : "";
1961
+ console.log(` ${iface.device.padEnd(6)}${iface.name}${aliasStr}`);
1962
+ }
1963
+ } else {
1964
+ console.log(` Physical Interfaces: ${dim("none detected")}`);
1965
+ }
1966
+
1967
+ console.log();
1968
+ console.log(" Available Specifiers:");
1969
+ console.log(" user User-mode networking with port forwarding (default)");
1970
+ if (platform.os === "darwin") {
1971
+ if (vmnet?.sharedSocket) {
1972
+ console.log(" shared Shared network via socket_vmnet (rootless)");
1973
+ } else if (vmnet) {
1974
+ console.log(` shared ${dim("Shared daemon not running")}`);
1975
+ } else {
1976
+ console.log(` shared ${dim("Requires socket_vmnet")}`);
1977
+ }
1978
+ console.log(" bridged:<iface> Bridge to host interface (e.g., bridged:en0, bridged:wifi)");
1979
+ }
1980
+ console.log(" socket::<name> Named L2 link between CHR instances");
1981
+ console.log(" socket:listen:<port> QEMU socket listen");
1982
+ console.log(" socket:connect:<port> QEMU socket connect");
1983
+ console.log(" socket:mcast:<group>:<port> Multicast socket");
1984
+
1985
+ console.log();
1986
+ if (sockets.length > 0) {
1987
+ console.log(" Named Sockets:");
1988
+ for (const s of sockets) {
1989
+ const members = s.members.length > 0 ? dim(` members: ${s.members.join(", ")}`) : "";
1990
+ console.log(` ${s.name.padEnd(16)}${s.mode} port:${s.port}${members}`);
1991
+ }
1992
+ } else {
1993
+ console.log(" Named Sockets:");
1994
+ console.log(" (none)");
1995
+ }
1996
+ }
1997
+
1998
+ async function showInterfaces() {
1999
+ const { detectPhysicalInterfaces } = await import("../lib/platform.ts");
2000
+ const { table, dim } = await import("./format.ts");
2001
+
2002
+ const interfaces = detectPhysicalInterfaces();
2003
+ if (interfaces.length === 0) {
2004
+ console.log("No physical interfaces detected.");
2005
+ return;
2006
+ }
2007
+
2008
+ const rows = interfaces.map((iface) => [
2009
+ iface.device,
2010
+ iface.name,
2011
+ iface.alias ?? "",
2012
+ iface.mac ?? dim("n/a"),
2013
+ ]);
2014
+ console.log(table(["Device", "Name", "Alias", "MAC"], rows));
2015
+ }
2016
+
2017
+ async function handleSockets(argv: string[]) {
2018
+ const { listNamedSockets, createNamedSocket, removeNamedSocket } = await import("../lib/socket-registry.ts");
2019
+ const { bold, dim, table: fmtTable } = await import("./format.ts");
2020
+
2021
+ const action = argv[0];
2022
+
2023
+ if (!action) {
2024
+ const sockets = listNamedSockets();
2025
+ if (sockets.length === 0) {
2026
+ console.log("No named sockets.");
2027
+ return;
2028
+ }
2029
+ const rows = sockets.map((s) => [
2030
+ s.name,
2031
+ s.mode,
2032
+ String(s.port),
2033
+ s.mcastGroup ?? "",
2034
+ s.members.join(", ") || dim("none"),
2035
+ ]);
2036
+ console.log(fmtTable(["Name", "Mode", "Port", "Group", "Members"], rows));
2037
+ return;
2038
+ }
2039
+
2040
+ if (action === "create") {
2041
+ const name = argv[1];
2042
+ if (!name) {
2043
+ console.error("Usage: quickchr networks sockets create <name>");
2044
+ process.exit(1);
2045
+ }
2046
+ const entry = createNamedSocket(name);
2047
+ console.log(`Created named socket: ${bold(entry.name)} (${entry.mode} port:${entry.port})`);
2048
+ return;
2049
+ }
2050
+
2051
+ if (action === "remove") {
2052
+ const name = argv[1];
2053
+ if (!name) {
2054
+ console.error("Usage: quickchr networks sockets remove <name>");
2055
+ process.exit(1);
2056
+ }
2057
+ const removed = removeNamedSocket(name);
2058
+ if (removed) {
2059
+ console.log(`Removed named socket: ${bold(name)}`);
2060
+ } else {
2061
+ console.error(`Named socket "${name}" not found.`);
2062
+ process.exit(1);
2063
+ }
2064
+ return;
2065
+ }
2066
+
2067
+ console.error(`Unknown sockets subcommand: ${action}\nRun 'quickchr networks help' for usage.`);
2068
+ process.exit(1);
2069
+ }
2070
+
2071
+ function showNetworksHelp() {
2072
+ console.log(`quickchr networks — network discovery and socket management
2073
+
2074
+ Usage:
2075
+ quickchr networks Show network overview
2076
+ quickchr networks interfaces List physical interfaces
2077
+ quickchr networks sockets List named sockets
2078
+ quickchr networks sockets create <n> Create a named socket
2079
+ quickchr networks sockets remove <n> Remove a named socket
2080
+
2081
+ Aliases: quickchr net`);
2082
+ }
2083
+
2084
+ async function cmdCompletions(argv: string[]) {
2085
+ const { flags } = parseFlags(argv);
2086
+ const {
2087
+ installCompletions,
2088
+ uninstallCompletions,
2089
+ allCompletionStatuses,
2090
+ completionStatusFor,
2091
+ detectCurrentShell,
2092
+ shellBinary,
2093
+ listMachineNamesForCompletion,
2094
+ listRunningMachineNamesForCompletion,
2095
+ } = await import("../lib/completions.ts");
2096
+ type SupportedShell = import("../lib/completions.ts").SupportedShell;
2097
+
2098
+ // Hidden flags used by the completion scripts themselves
2099
+ if (flags.machines === true) {
2100
+ const names = listMachineNamesForCompletion();
2101
+ if (names.length > 0) console.log(names.join("\n"));
2102
+ return;
2103
+ }
2104
+ if (flags.running === true) {
2105
+ const names = listRunningMachineNamesForCompletion();
2106
+ if (names.length > 0) console.log(names.join("\n"));
2107
+ return;
2108
+ }
2109
+
2110
+ const shellInfo = detectCurrentShell();
2111
+ const binary = shellBinary(shellInfo);
2112
+
2113
+ if (flags.install === true) {
2114
+ const shellOverride = flag(flags, "shell") as SupportedShell | undefined;
2115
+ const targetShell: SupportedShell = shellOverride ?? (shellInfo.supported ? binary as SupportedShell : undefined) ?? "bash";
2116
+ const dryRun = flagBool(flags, "dry-run");
2117
+
2118
+ if (dryRun) {
2119
+ const { completionInstallPath } = await import("../lib/completions.ts");
2120
+ const loc = completionInstallPath(targetShell);
2121
+ console.log(`Would write: ${loc.file}`);
2122
+ if (loc.rcLine && loc.rcFile) {
2123
+ console.log(`Would append to ${loc.rcFile}:`);
2124
+ console.log(` ${loc.rcLine}`);
2125
+ }
2126
+ return;
2127
+ }
2128
+
2129
+ const result = installCompletions(targetShell, { dryRun: false });
2130
+ if (result.alreadyInstalled) {
2131
+ console.log(`Completions already installed (refreshed): ${result.file}`);
2132
+ } else {
2133
+ console.log(`Installed ${result.shell} completions: ${result.file}`);
2134
+ }
2135
+ if (result.rcLine && result.rcFile) {
2136
+ console.log(`Added to ${result.rcFile}:`);
2137
+ console.log(` ${result.rcLine}`);
2138
+ }
2139
+ console.log("Restart your shell or open a new terminal to activate completions.");
2140
+ return;
2141
+ }
2142
+
2143
+ if (flags.uninstall === true) {
2144
+ const shellOverride = flag(flags, "shell") as SupportedShell | undefined;
2145
+ const targetShell: SupportedShell = shellOverride ?? (shellInfo.supported ? binary as SupportedShell : undefined) ?? "bash";
2146
+
2147
+ const result = uninstallCompletions(targetShell);
2148
+ if (result.removed) {
2149
+ console.log(`Removed ${targetShell} completions: ${result.file}`);
2150
+ if (result.rcLine && result.rcFile) {
2151
+ console.log(`Removed line from ${result.rcFile}:`);
2152
+ console.log(` ${result.rcLine}`);
2153
+ }
2154
+ } else {
2155
+ console.log(`Completions were not installed for ${targetShell}.`);
2156
+ }
2157
+ return;
2158
+ }
2159
+
2160
+ // Default: show status for all shells
2161
+ const statuses = allCompletionStatuses();
2162
+ console.log("Shell completion status:\n");
2163
+ for (const s of statuses) {
2164
+ const mark = s.installed ? "✓" : "✗";
2165
+ const note = s.installed ? "installed" : "not installed";
2166
+ console.log(` ${mark} ${s.shell.padEnd(5)} ${note.padEnd(15)} ${s.path}`);
2167
+ }
2168
+
2169
+ const detected = completionStatusFor(shellInfo.supported ? binary as SupportedShell : "bash");
2170
+ console.log();
2171
+ if (!shellInfo.supported) {
2172
+ console.log(`Current shell (${binary}) is not supported for auto-install.`);
2173
+ console.log(`Supported: bash, zsh, fish`);
2174
+ } else if (!detected.installed) {
2175
+ console.log(`Run 'quickchr completions --install' to install for ${binary}.`);
2176
+ } else {
2177
+ console.log(`Completions active. Restart shell if recently installed.`);
2178
+ }
2179
+ }
2180
+
2181
+ async function cmdLogs(argv: string[]) {
2182
+ const { flags, positional } = parseFlags(argv);
2183
+ const name = positional[0];
2184
+
2185
+ if (!name) {
2186
+ console.error("Usage: quickchr logs <name> [--follow] [-n <lines>]");
2187
+ process.exit(1);
2188
+ }
2189
+
2190
+ const { QuickCHR } = await import("../lib/quickchr.ts");
2191
+ const { join } = await import("node:path");
2192
+ const { existsSync, readFileSync, statSync, openSync, readSync, closeSync } = await import("node:fs");
2193
+ const { machineNotFoundMessage } = await import("./format.ts");
2194
+
2195
+ const instance = QuickCHR.get(name);
2196
+ if (!instance) {
2197
+ console.error(machineNotFoundMessage(name));
2198
+ process.exit(1);
2199
+ }
2200
+
2201
+ const logPath = join(instance.state.machineDir, "qemu.log");
2202
+
2203
+ if (!existsSync(logPath)) {
2204
+ console.log("No log file yet.");
2205
+ return;
2206
+ }
2207
+
2208
+ let follow = flagBool(flags, "follow");
2209
+ let lines = flag(flags, "lines") ? Number(flag(flags, "lines")) : 50;
2210
+
2211
+ for (let i = 0; i < argv.length; i++) {
2212
+ if (argv[i] === "-f") follow = true;
2213
+ if (argv[i] === "-n" && argv[i + 1]) {
2214
+ lines = Number(argv[i + 1]);
2215
+ i++;
2216
+ }
2217
+ }
2218
+
2219
+ if (follow) {
2220
+ // Pure JS follow mode: print last N lines then poll for new content.
2221
+ const printLastLines = (n: number) => {
2222
+ const content = readFileSync(logPath, "utf-8");
2223
+ const allLines = content.split("\n");
2224
+ const slice = allLines.slice(-n);
2225
+ process.stdout.write(slice.join("\n"));
2226
+ if (slice.at(-1) !== "") process.stdout.write("\n");
2227
+ };
2228
+ printLastLines(lines);
2229
+ let fileSize = statSync(logPath).size;
2230
+ const interval = setInterval(() => {
2231
+ try {
2232
+ const newSize = statSync(logPath).size;
2233
+ if (newSize > fileSize) {
2234
+ const buf = new Uint8Array(newSize - fileSize);
2235
+ const fd = openSync(logPath, "r");
2236
+ readSync(fd, buf, 0, buf.length, fileSize);
2237
+ closeSync(fd);
2238
+ process.stdout.write(new TextDecoder().decode(buf));
2239
+ fileSize = newSize;
2240
+ }
2241
+ } catch { /* file may rotate or be removed */ }
2242
+ }, 250);
2243
+ process.on("SIGINT", () => { clearInterval(interval); process.exit(0); });
2244
+ await new Promise<void>(() => { /* keep alive until SIGINT */ });
2245
+ } else {
2246
+ const content = readFileSync(logPath, "utf-8");
2247
+ const allLines = content.split("\n");
2248
+ process.stdout.write(allLines.slice(-lines).join("\n") + "\n");
2249
+ }
2250
+ }
2251
+
2252
+ async function cmdDoctor() {
2253
+ const { QuickCHR } = await import("../lib/quickchr.ts");
2254
+ const { statusIcon, bold } = await import("./format.ts");
2255
+
2256
+ const result = await QuickCHR.doctor();
2257
+
2258
+ console.log(bold("quickchr doctor\n"));
2259
+ for (const check of result.checks) {
2260
+ console.log(`${statusIcon(check.status)} ${check.label}: ${check.detail}`);
2261
+ }
2262
+
2263
+ console.log();
2264
+ if (result.ok) {
2265
+ console.log("All checks passed.");
2266
+ } else {
2267
+ console.log("Some checks failed. Fix the errors above, then retry.");
2268
+ process.exit(1);
2269
+ }
2270
+ }
2271
+
2272
+ async function cmdVersion() {
2273
+ const pkg = await import("../../package.json");
2274
+ console.log(`quickchr ${pkg.version}`);
2275
+
2276
+ try {
2277
+ const { resolveAllVersions } = await import("../lib/versions.ts");
2278
+ const versions = await resolveAllVersions();
2279
+ console.log("\nLatest RouterOS versions:");
2280
+ for (const [channel, version] of Object.entries(versions)) {
2281
+ console.log(` ${channel.padEnd(12)} ${version}`);
2282
+ }
2283
+ } catch {
2284
+ // Offline — skip version lookup
2285
+ }
2286
+ }
2287
+
2288
+ // --- Help ---
2289
+
2290
+ function printHelp(command?: string) {
2291
+ if (command) {
2292
+ printCommandHelp(command);
2293
+ return;
2294
+ }
2295
+
2296
+ console.log(`quickchr — MikroTik CHR QEMU Manager
2297
+
2298
+ Usage:
2299
+ quickchr Interactive wizard (TTY only)
2300
+ quickchr <command> [options]
2301
+
2302
+ Commands:
2303
+ setup Interactive setup wizard (TTY only)
2304
+ add [options] Create a new CHR machine (without starting)
2305
+ start [<name>|options] Start or restart a CHR instance
2306
+ stop [<name>|--all] Stop instance(s) — print list if no name
2307
+ list [<name>] [--json] List all instances, or detail for one
2308
+ status Alias for list
2309
+ console <name> Attach to serial console of a running instance
2310
+ exec <name> <command> Run a RouterOS CLI command on a running instance
2311
+ remove [<name>|--all] Remove instance(s) and disk
2312
+ clean [<name>|--all] Reset instance disk to fresh image
2313
+ get <name> [group] Show machine config (license, device-mode, admin)
2314
+ set <name> --license Apply/renew CHR trial license
2315
+ license <name> Deprecated — use 'set <name> --license'
2316
+ disk <name> Show disk details for an instance
2317
+ snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
2318
+ networks Network discovery & socket management
2319
+ completions Manage shell completions (Tab completion)
2320
+ logs <name> Tail the QEMU log for an instance
2321
+ doctor Check prerequisites
2322
+ version Show version info
2323
+ help [command] Show help
2324
+
2325
+ Environment:
2326
+ MIKROTIK_WEB_ACCOUNT MikroTik.com account email (for license)
2327
+ MIKROTIK_WEB_PASSWORD MikroTik.com password (for license)
2328
+
2329
+ Provisioning support:
2330
+ ${provisioningSupportSummary(MIN_PROVISION_VERSION)}
2331
+ ${PROVISIONING_BOOT_ONLY_SUMMARY}
2332
+ Prefer --channel long-term when you plan to use ${PROVISIONING_FEATURE_SUMMARY}.
2333
+
2334
+ Run 'quickchr help <command>' for command-specific help.`);
2335
+ }
2336
+
2337
+ function printCommandHelp(command: string) {
2338
+ switch (command) {
2339
+ case "add":
2340
+ console.log(`quickchr add [options]
2341
+
2342
+ Create a new CHR machine without starting it.
2343
+ Use 'quickchr start <name>' to boot it afterwards.
2344
+
2345
+ Post-boot provisioning flags in this command (--add-package, --install-all-packages,
2346
+ --add-user, --disable-admin, managed login, and device-mode) are validated on
2347
+ RouterOS ${MIN_PROVISION_VERSION}+ only. Older 7.x remains boot-only. Disk and network
2348
+ options still work on older versions when the required host tools are installed.
2349
+
2350
+ Options:
2351
+ --name <name> Instance name (required)
2352
+ --version <ver> RouterOS version (e.g., 7.22.1)
2353
+ --channel <ch> Channel: stable, long-term, testing, development
2354
+ --arch <arch> Architecture: arm64, x86 (default: host native)
2355
+ --cpu <n> vCPU count (default: 1)
2356
+ --mem <mb> RAM in MB (default: 512)
2357
+ --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
2358
+ --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
2359
+ --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
2360
+ --add-package <pkg> Extra package to install on first boot (repeatable)
2361
+ --install-all-packages Install all packages on first boot
2362
+ --add-user <u:p> Create user on first boot (name:password)
2363
+ --disable-admin Disable the default admin account on first boot
2364
+ --no-secure-login Keep admin with no password (skip managed account)
2365
+ --port-base <port> Starting port number (default: auto-allocated from 9100)
2366
+ --no-winbox Exclude WinBox port mapping
2367
+ --no-api-ssl Exclude API-SSL port mapping
2368
+ --device-mode <m> Set device-mode on first boot: rose|advanced|basic|home|auto|skip
2369
+ --add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
2370
+ socket::<name>, tap:<if>. Default: single user NIC.
2371
+ --no-network Start with no NICs (headless)
2372
+ --vmnet-shared [deprecated] Use --add-network shared
2373
+ --vmnet-bridge <if> [deprecated] Use --add-network bridged:<if>`);
2374
+ break;
2375
+ case "setup":
2376
+ console.log(`quickchr setup
2377
+
2378
+ Launch the interactive wizard (requires a TTY).
2379
+ If machines already exist, shows a menu to manage them.`);
2380
+ break;
2381
+ case "console":
2382
+ console.log(`quickchr console <name>
2383
+
2384
+ Attach to the serial console of a running CHR instance.
2385
+ Requires a TTY. Exit with Ctrl-A X (QEMU monitor quit).`);
2386
+ break;
2387
+ case "exec":
2388
+ console.log(`quickchr exec <name> <command...> [options]
2389
+
2390
+ Run a RouterOS CLI command on a running CHR instance.
2391
+ Uses the REST /execute endpoint (no SSH needed).
2392
+
2393
+ <name> Name of a running CHR instance (required).
2394
+ <command...> RouterOS CLI command to execute.
2395
+
2396
+ Options:
2397
+ --via <transport> Transport: auto, rest, qga (default: auto) — qga requires KVM
2398
+ --user <user> Override username
2399
+ --password <pass> Override password
2400
+ --timeout <secs> Timeout in seconds (default: 30)
2401
+
2402
+ Examples:
2403
+ quickchr exec my-chr /system/resource/print
2404
+ quickchr exec my-chr ":put [:serialize to=json [/ip/address/print]]"
2405
+ quickchr exec my-chr "/log/info message=hello"`);
2406
+ break;
2407
+ case "start":
2408
+ console.log(`quickchr start [<name>] [options]
2409
+
2410
+ <name> Restart an existing stopped machine by name.
2411
+ Omit to see a list of stopped machines.
2412
+
2413
+ Creation flags below apply when using 'start' to create a new machine in one step.
2414
+ For a clearer create-then-boot flow, prefer 'quickchr add' followed by 'quickchr start <name>'.
2415
+
2416
+ Post-boot provisioning flags in this command (${PROVISIONING_FEATURE_SUMMARY}) are
2417
+ validated on RouterOS ${MIN_PROVISION_VERSION}+ only. Older 7.x remains boot-only.
2418
+ Disk and network options still work on older versions when the required host tools
2419
+ are installed.
2420
+
2421
+ Options:
2422
+ --all Start all stopped machines
2423
+ --bg / --background Run in background (default)
2424
+ --fg / --foreground Run in foreground — serial console on stdio
2425
+ --version <ver> RouterOS version (e.g., 7.22.1)
2426
+ --channel <ch> Channel: stable, long-term, testing, development
2427
+ --arch <arch> Architecture: arm64, x86 (default: host native)
2428
+ --name <name> Instance name
2429
+ --cpu <n> vCPU count (default: 1)
2430
+ --mem <mb> RAM in MB (default: 512)
2431
+ --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
2432
+ --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
2433
+ --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
2434
+ --add-package <pkg> Extra package to install (repeatable)
2435
+ --add-user <u:p> Create user with name:password
2436
+ --disable-admin Disable the default admin account
2437
+ --no-secure-login Keep admin with no password (skip managed account)
2438
+ --port-base <port> Starting port number (default: auto-allocated from 9100)
2439
+ --no-winbox Exclude WinBox port mapping
2440
+ --no-api-ssl Exclude API-SSL port mapping
2441
+ --add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
2442
+ socket::<name>, tap:<if>. Default: single user NIC.
2443
+ --no-network Start with no NICs (headless)
2444
+ --vmnet-shared [deprecated] Use --add-network shared
2445
+ --vmnet-bridge <if> [deprecated] Use --add-network bridged:<if>
2446
+ --install-all-packages Install all packages from all_packages.zip
2447
+ --license-level <l> Apply trial license: p1 (1 Gbps), p10 (10 Gbps), unlimited
2448
+ --license-account <a> MikroTik account email (or use MIKROTIK_WEB_ACCOUNT env var)
2449
+ --license-password <p> MikroTik password (or use MIKROTIK_WEB_PASSWORD env var)
2450
+ --device-mode <m> Configure device-mode: rose|advanced|basic|home|auto|skip
2451
+ --device-mode-enable <f> Set one or more device-mode flags to yes
2452
+ --device-mode-disable <f> Set one or more device-mode flags to no
2453
+ --no-device-mode Skip device-mode provisioning entirely
2454
+ --timeout-extra <s>, -T <s> Add extra seconds to the auto-computed boot timeout
2455
+ --dry-run Print what would run without starting`);
2456
+ break;
2457
+ case "stop":
2458
+ console.log(`quickchr stop [<name>] [--all]
2459
+
2460
+ <name> Stop a specific running instance.
2461
+ Omit to see a list of running instances.
2462
+ --all Stop all running instances.`);
2463
+ break;
2464
+ case "status":
2465
+ console.log(`quickchr status [<name>] [--json]
2466
+
2467
+ Alias for 'quickchr list'. See 'quickchr help list'.`);
2468
+ break;
2469
+ case "list":
2470
+ console.log(`quickchr list [<name>] [--json]
2471
+
2472
+ List all CHR instances or show detailed info for one.
2473
+
2474
+ <name> Show detailed status for a specific instance.
2475
+ Omit to list all instances in a table.
2476
+ --json Output JSON (array of all machines, or object for one).
2477
+
2478
+ 'quickchr status' is an alias for this command.`);
2479
+ break;
2480
+ case "doctor":
2481
+ console.log(`quickchr doctor
2482
+
2483
+ Check system prerequisites: QEMU binaries, firmware, acceleration,
2484
+ sshpass (for package upload), qemu-img (for disk operations), data directories,
2485
+ and cached images. Also reports the current shell and completion install status.`);
2486
+ break;
2487
+ case "logs":
2488
+ console.log(`quickchr logs <name> [options]
2489
+
2490
+ Tail the QEMU log for a CHR instance.
2491
+
2492
+ <name> Instance name (required).
2493
+
2494
+ Options:
2495
+ --follow, -f Follow log output (tail -f style).
2496
+ --lines=<N>, -n <N> Number of lines to show (default: 50).`);
2497
+ break;
2498
+ case "completions":
2499
+ console.log(`quickchr completions [options]
2500
+
2501
+ Manage shell Tab completions for quickchr.
2502
+
2503
+ Options:
2504
+ --install Install completions for the detected shell
2505
+ --install --shell <s> Install for a specific shell: bash, zsh, fish
2506
+ --uninstall Remove completions (and undo rc-file changes)
2507
+ --status Show install status for all shells
2508
+ --dry-run Show what --install would do without writing anything
2509
+
2510
+ Machine name completions are always up to date — the scripts call quickchr
2511
+ at completion time to list your machines.`);
2512
+ break;
2513
+ case "disk":
2514
+ console.log(`quickchr disk <name>
2515
+
2516
+ Show disk details (format, virtual size, actual size) for an instance.
2517
+
2518
+ <name> Show disks for a specific instance.
2519
+ Omit to list all instances.
2520
+
2521
+ Install qemu-img to include virtual/actual size details in the output.`);
2522
+ break;
2523
+ case "snapshot":
2524
+ case "snap":
2525
+ console.log(`quickchr snapshot <name> [list|save|load|delete] [snapshot-name] [--json]
2526
+
2527
+ Manage VM snapshots on a CHR instance's qcow2 boot disk.
2528
+
2529
+ <name> Instance name (required).
2530
+
2531
+ Subcommands:
2532
+ list List all snapshots (default if no subcommand given).
2533
+ save [snap-name] Save a snapshot. Name auto-generated (ISO date) if omitted.
2534
+ load <snap-name> Restore a snapshot. Machine must be running.
2535
+ delete <snap-name> Delete a snapshot. Machine must be running.
2536
+
2537
+ Options:
2538
+ --json Machine-readable JSON output.
2539
+
2540
+ Requires qcow2 boot disk (--boot-disk-format=qcow2 or --boot-size=<size>).
2541
+ Alias: quickchr snap`);
2542
+ break;
2543
+ case "get":
2544
+ console.log(`quickchr get <name> [license|device-mode|admin] [--json]
2545
+
2546
+ Query live machine configuration from a running CHR instance.
2547
+
2548
+ <name> Name of a running CHR instance (required).
2549
+ license Show license level, software ID, and deadline.
2550
+ device-mode Show device-mode settings and enabled features.
2551
+ admin Show users in the full (admin) group.
2552
+
2553
+ Options:
2554
+ --json Structured JSON output.
2555
+
2556
+ When no group is given, all properties are shown.
2557
+ The machine must be running to query live properties.`);
2558
+ break;
2559
+ case "set":
2560
+ console.log(`quickchr set <name> --license [options]
2561
+
2562
+ Set properties on a CHR instance.
2563
+
2564
+ <name> Name of a running CHR instance (required).
2565
+
2566
+ Flags:
2567
+ --license Apply or renew a CHR trial license.
2568
+
2569
+ License options (used with --license):
2570
+ --level <level> License level: p1 (1 Gbps), p10 (10 Gbps), unlimited (default: p1)
2571
+ --account <email> MikroTik.com account email
2572
+ --password <pass> MikroTik.com password
2573
+
2574
+ Credential resolution order:
2575
+ 1. --account / --password flags
2576
+ 2. MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD environment variables
2577
+ 3. OS native secret store (macOS Keychain / Linux Keyring)
2578
+ 4. ~/.config/quickchr/credentials.json (fallback)`);
2579
+ break;
2580
+ case "license":
2581
+ console.log(`quickchr license <name> [options]
2582
+
2583
+ Deprecated. Use 'quickchr set <name> --license' instead.
2584
+
2585
+ Apply or renew a CHR trial license via MikroTik.com.
2586
+ Free CHR runs at 1 Mbps — a trial license unlocks full speed.
2587
+ No reboot required. Takes effect immediately.
2588
+
2589
+ <name> Name of a running CHR instance (required).
2590
+
2591
+ Options:
2592
+ --level <level> License level: p1 (1 Gbps), p10 (10 Gbps), unlimited (default: p1)
2593
+ --account <email> MikroTik.com account email
2594
+ --password <pass> MikroTik.com password
2595
+
2596
+ Credential resolution order (highest priority first):
2597
+ 1. --account / --password flags
2598
+ 2. MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD environment variables
2599
+ 3. OS native secret store (macOS Keychain, Linux GNOME Keyring, Windows Credential Manager)
2600
+ 4. ~/.config/quickchr/credentials.json (fallback)`);
2601
+ break;
2602
+ case "clean":
2603
+ console.log(`quickchr clean [<name>] [--all]
2604
+
2605
+ <name> Reset an instance's disk to a fresh image (removes all data).
2606
+ Omit to see a list of instances.
2607
+ --all Clean all instances.`);
2608
+ break;
2609
+ case "remove":
2610
+ console.log(`quickchr remove [<name>] [--all]
2611
+
2612
+ <name> Remove an instance (stops if running, deletes disk and state).
2613
+ Omit to see a list of instances.
2614
+ --all Remove all instances.`);
2615
+ break;
2616
+ case "networks":
2617
+ case "net":
2618
+ showNetworksHelp();
2619
+ break;
2620
+ default:
2621
+ console.log(`No detailed help for '${command}'.`);
2622
+ }
2623
+ }
2624
+
2625
+ if (import.meta.main) main();