@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.
- package/DESIGN.md +193 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/package.json +61 -0
- package/src/cli/format.ts +154 -0
- package/src/cli/index.ts +2625 -0
- package/src/cli/wizard.ts +727 -0
- package/src/index.ts +124 -0
- package/src/lib/auth.ts +68 -0
- package/src/lib/channels.ts +222 -0
- package/src/lib/completions.ts +513 -0
- package/src/lib/console.ts +473 -0
- package/src/lib/credentials.ts +99 -0
- package/src/lib/device-mode.ts +283 -0
- package/src/lib/disk.ts +400 -0
- package/src/lib/exec.ts +101 -0
- package/src/lib/images.ts +167 -0
- package/src/lib/license.ts +292 -0
- package/src/lib/log.ts +41 -0
- package/src/lib/network.ts +635 -0
- package/src/lib/packages.ts +225 -0
- package/src/lib/password.ts +21 -0
- package/src/lib/platform.ts +458 -0
- package/src/lib/provision.ts +422 -0
- package/src/lib/qemu.ts +526 -0
- package/src/lib/qga.ts +667 -0
- package/src/lib/quickchr.ts +1871 -0
- package/src/lib/rest.ts +244 -0
- package/src/lib/secrets.ts +133 -0
- package/src/lib/socket-registry.ts +140 -0
- package/src/lib/state.ts +173 -0
- package/src/lib/storage.ts +163 -0
- package/src/lib/types.ts +690 -0
- package/src/lib/versions.ts +181 -0
- package/src/lib/zip.ts +44 -0
package/src/cli/index.ts
ADDED
|
@@ -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();
|