@tikoci/quickchr 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +522 -6
- package/MANUAL.md +471 -59
- package/README.md +42 -8
- package/package.json +4 -2
- package/src/cli/flags.ts +208 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +660 -107
- package/src/cli/tips.ts +57 -0
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +12 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +34 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/images.ts +53 -22
- package/src/lib/lock.ts +497 -0
- package/src/lib/names.ts +96 -0
- package/src/lib/network.ts +199 -27
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +129 -7
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +93 -14
- package/src/lib/quickchr.ts +1108 -339
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +211 -8
- package/src/lib/types.ts +117 -17
- package/src/lib/versions.ts +54 -0
package/src/cli/index.ts
CHANGED
|
@@ -3,22 +3,43 @@
|
|
|
3
3
|
* quickchr CLI entry point — command router.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { StartOptions, Arch, Channel, ServiceName, NetworkSpecifier } from "../lib/types.ts";
|
|
6
|
+
import type { StartOptions, Arch, Channel, DeviceModeOptions, ServiceName, NetworkSpecifier } from "../lib/types.ts";
|
|
7
7
|
import { parseNetworkSpecifier } from "../lib/network.ts";
|
|
8
8
|
import { expandForwardSpec } from "../lib/forward-spec.ts";
|
|
9
|
+
import { ADD_FLAGS, REMOVED_FLAGS, SET_FLAGS, START_FLAGS, VALUE_FLAGS, unknownFlags, unknownFlagMessage, valuelessFlags, valuelessFlagMessage } from "./flags.ts";
|
|
10
|
+
import { QuickCHRError } from "../lib/types.ts";
|
|
11
|
+
import { CENTRS_EXEC_TIP, CENTRS_SEE_ALSO, tip, tipsForError } from "./tips.ts";
|
|
9
12
|
import {
|
|
13
|
+
isRouterOs6,
|
|
10
14
|
MIN_PROVISION_VERSION,
|
|
11
15
|
PROVISIONING_BOOT_ONLY_SUMMARY,
|
|
12
16
|
PROVISIONING_FEATURE_SUMMARY,
|
|
13
17
|
provisioningSupportHint,
|
|
14
18
|
provisioningSupportSummary,
|
|
19
|
+
ROUTEROS6_BOOT_ONLY,
|
|
15
20
|
} from "../lib/versions.ts";
|
|
16
21
|
|
|
17
22
|
const args = process.argv.slice(2);
|
|
18
23
|
const command = args[0];
|
|
19
24
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
25
|
+
/** Stop a removed flag at the parser, naming what replaced it. Thrown rather than
|
|
26
|
+
* printed so it reaches `main()`'s handler like any other error, and so no command can
|
|
27
|
+
* reach a state where the flag was accepted and ignored (#164). */
|
|
28
|
+
function rejectRemovedFlag(key: string): void {
|
|
29
|
+
const replacement = REMOVED_FLAGS.get(key);
|
|
30
|
+
if (replacement === undefined) return;
|
|
31
|
+
throw new QuickCHRError("INVALID_ARGUMENT", `--${key} was removed. Use: ${replacement}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse --flag=value and --flag value pairs from args.
|
|
35
|
+
*
|
|
36
|
+
* Arity comes from `VALUE_FLAGS`, never from the shape of the next argument: a flag
|
|
37
|
+
* that is not in that table is boolean and leaves the following argument alone, so a
|
|
38
|
+
* positional cannot be eaten by a flag that has no use for it (#164). */
|
|
39
|
+
export function parseFlags(
|
|
40
|
+
argv: string[],
|
|
41
|
+
valueFlags: ReadonlySet<string> = VALUE_FLAGS,
|
|
42
|
+
): { flags: Record<string, string | boolean | string[]>; positional: string[] } {
|
|
22
43
|
const flags: Record<string, string | boolean | string[]> = {};
|
|
23
44
|
const positional: string[] = [];
|
|
24
45
|
|
|
@@ -33,13 +54,16 @@ export function parseFlags(argv: string[]): { flags: Record<string, string | boo
|
|
|
33
54
|
const eqIdx = arg.indexOf("=");
|
|
34
55
|
if (eqIdx !== -1) {
|
|
35
56
|
const key = arg.slice(2, eqIdx);
|
|
57
|
+
rejectRemovedFlag(key);
|
|
36
58
|
flags[key] = arg.slice(eqIdx + 1);
|
|
37
59
|
} else if (arg.startsWith("--no-")) {
|
|
60
|
+
rejectRemovedFlag(arg.slice(5));
|
|
38
61
|
flags[arg.slice(5)] = false;
|
|
39
62
|
} else {
|
|
40
63
|
const key = arg.slice(2);
|
|
64
|
+
rejectRemovedFlag(key);
|
|
41
65
|
const next = argv[i + 1];
|
|
42
|
-
if (next && !next.startsWith("--")) {
|
|
66
|
+
if (valueFlags.has(key) && next !== undefined && !next.startsWith("--")) {
|
|
43
67
|
// Check if this is a repeatable flag
|
|
44
68
|
const existing = flags[key];
|
|
45
69
|
if (Array.isArray(existing)) {
|
|
@@ -68,10 +92,33 @@ function flag(flags: Record<string, string | boolean | string[]>, key: string):
|
|
|
68
92
|
return undefined;
|
|
69
93
|
}
|
|
70
94
|
|
|
95
|
+
function cacheTargetFlag(flags: Record<string, string | boolean | string[]>, key: string): string | undefined {
|
|
96
|
+
if (!(key in flags)) return undefined;
|
|
97
|
+
const value = flags[key];
|
|
98
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
99
|
+
throw new Error(`--${key} requires a value`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
71
104
|
function flagBool(flags: Record<string, string | boolean | string[]>, key: string): boolean {
|
|
72
105
|
return flags[key] === true || flags[key] === "true";
|
|
73
106
|
}
|
|
74
107
|
|
|
108
|
+
/** A boolean flag that keeps "not given" apart from `--no-<key>`: `true`, `false`, or
|
|
109
|
+
* `undefined` when the flag is absent.
|
|
110
|
+
*
|
|
111
|
+
* `flagBool()` collapses absent into `false`, and that is the wrong answer for any
|
|
112
|
+
* option `start()` merges with stored intent (`opts.x ?? existing.x`): `false ?? true`
|
|
113
|
+
* is `false`, so `quickchr add --install-all-packages` followed by a plain
|
|
114
|
+
* `quickchr start <name>` booted without the packages and said nothing (#185). Same
|
|
115
|
+
* rule as `--no-device-mode`: silence and refusal are different instructions, and only
|
|
116
|
+
* silence may fall through to what `add` recorded. */
|
|
117
|
+
export function flagOptionalBool(flags: Record<string, string | boolean | string[]>, key: string): boolean | undefined {
|
|
118
|
+
if (flags[key] === undefined) return undefined;
|
|
119
|
+
return flags[key] !== false && flags[key] !== "false";
|
|
120
|
+
}
|
|
121
|
+
|
|
75
122
|
function flagList(flags: Record<string, string | boolean | string[]>, key: string): string[] {
|
|
76
123
|
const val = flags[key];
|
|
77
124
|
if (Array.isArray(val)) return val;
|
|
@@ -83,20 +130,53 @@ function csvList(values: string[]): string[] {
|
|
|
83
130
|
return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
|
|
84
131
|
}
|
|
85
132
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
133
|
+
/** The device-mode request the `--device-mode*` flags describe, or `undefined` when
|
|
134
|
+
* they describe nothing.
|
|
135
|
+
*
|
|
136
|
+
* One reader for all three verbs that take these flags. `add` and `start` had
|
|
137
|
+
* separate copies and they had already drifted: only `start` honoured
|
|
138
|
+
* `--no-device-mode`, so `quickchr add --no-device-mode --device-mode-enable container`
|
|
139
|
+
* enabled container while the same flags on `start` skipped device-mode with a
|
|
140
|
+
* warning. Two spellings of one intent producing opposite results is the silent-drop
|
|
141
|
+
* failure in another costume (#176).
|
|
142
|
+
*
|
|
143
|
+
* `--no-device-mode` means *skip device-mode entirely*, so it wins over the feature
|
|
144
|
+
* lists and says so rather than quietly discarding them.
|
|
145
|
+
*
|
|
146
|
+
* It returns an explicit `{ mode: "skip" }` rather than `undefined`, because
|
|
147
|
+
* `undefined` means *the user said nothing* and the two are not the same instruction.
|
|
148
|
+
* `start()` resolves a silent request against stored intent — `opts.deviceMode ??
|
|
149
|
+
* existing.deviceMode` — so a machine created with `add --device-mode-enable
|
|
150
|
+
* container` and then started with `--no-device-mode` provisioned container anyway,
|
|
151
|
+
* power cycle included. Measured on CHR 7.24.4: 48 s and `container=yes`, from a flag
|
|
152
|
+
* whose documented job is to skip the step. A flag that is accepted and does nothing
|
|
153
|
+
* is the defect this whole area exists to remove (#176). */
|
|
154
|
+
export function deviceModeFromFlags(
|
|
155
|
+
flags: Record<string, string | boolean | string[]>,
|
|
156
|
+
): DeviceModeOptions | undefined {
|
|
157
|
+
const mode = flag(flags, "device-mode");
|
|
158
|
+
const enable = csvList(flagList(flags, "device-mode-enable"));
|
|
159
|
+
const disable = csvList(flagList(flags, "device-mode-disable"));
|
|
89
160
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (bridgeIface) {
|
|
96
|
-
specs.push(parseNetworkSpecifier(`bridged:${bridgeIface}`));
|
|
161
|
+
if (flags["device-mode"] === false) {
|
|
162
|
+
if (enable.length > 0 || disable.length > 0) {
|
|
163
|
+
console.warn("Warning: --device-mode-enable/--device-mode-disable ignored because --no-device-mode was set.");
|
|
164
|
+
}
|
|
165
|
+
return { mode: "skip" };
|
|
97
166
|
}
|
|
167
|
+
if (mode === undefined && enable.length === 0 && disable.length === 0) return undefined;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
mode: mode ?? "auto",
|
|
171
|
+
enable: enable.length > 0 ? enable : undefined,
|
|
172
|
+
disable: disable.length > 0 ? disable : undefined,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Build networks array from --add-network and --no-network. */
|
|
177
|
+
function buildNetworks(flags: Record<string, string | boolean | string[]>): NetworkSpecifier[] | undefined {
|
|
178
|
+
const specs: NetworkSpecifier[] = [];
|
|
98
179
|
|
|
99
|
-
// New --add-network flags
|
|
100
180
|
for (const raw of flagList(flags, "add-network")) {
|
|
101
181
|
specs.push(parseNetworkSpecifier(raw));
|
|
102
182
|
}
|
|
@@ -111,6 +191,28 @@ function buildNetworks(flags: Record<string, string | boolean | string[]>): Netw
|
|
|
111
191
|
return specs;
|
|
112
192
|
}
|
|
113
193
|
|
|
194
|
+
/** Exit with a flag-list error when a machine-creating command is given a flag it does
|
|
195
|
+
* not know. An unrecognised flag is a typo, and a typo that downloads 43 MB and creates
|
|
196
|
+
* a machine is not a good default (#156). */
|
|
197
|
+
function rejectUnknownFlags(
|
|
198
|
+
flags: Record<string, string | boolean | string[]>,
|
|
199
|
+
known: readonly string[],
|
|
200
|
+
command: string,
|
|
201
|
+
): void {
|
|
202
|
+
const unknown = unknownFlags(flags, known);
|
|
203
|
+
if (unknown.length > 0) {
|
|
204
|
+
console.error(unknownFlagMessage(unknown, command));
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
// A known flag given without its value is the same hazard wearing a valid name:
|
|
208
|
+
// `add --name --version 7.24.3` would otherwise create an auto-named machine.
|
|
209
|
+
const valueless = valuelessFlags(flags, known);
|
|
210
|
+
if (valueless.length > 0) {
|
|
211
|
+
console.error(valuelessFlagMessage(valueless, command));
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
114
216
|
/** True when interactive prompts must be suppressed (e.g. LLM / CI / pipe). */
|
|
115
217
|
function isNoPrompt(): boolean {
|
|
116
218
|
return !!process.env.QUICKCHR_NO_PROMPT || !process.stdout.isTTY || !process.stdin.isTTY;
|
|
@@ -179,16 +281,55 @@ export async function applyAccelFlag(flags: Record<string, string | boolean | st
|
|
|
179
281
|
|
|
180
282
|
/** Resolve --secure-login/--no-secure-login, falling back to the secure-login setting
|
|
181
283
|
* only when neither flag was passed. Shared by cmdAdd, cmdStart, and cmdStart's
|
|
182
|
-
* --all bulk-restart path.
|
|
183
|
-
|
|
284
|
+
* --all bulk-restart path.
|
|
285
|
+
*
|
|
286
|
+
* `applySetting` is **required**, and deliberately so. The setting is a *first-boot*
|
|
287
|
+
* default: carrying it into the start of a machine whose provisioning window has
|
|
288
|
+
* closed turns `quickchr start <name>` — and every such machine in `start --all` —
|
|
289
|
+
* into a request to provision a booted guest, which now refuses out loud (#176).
|
|
290
|
+
* Callers pass the target's own window state, not "does this machine exist": a
|
|
291
|
+
* machine that was `add`ed and never started is still on its first boot and keeps
|
|
292
|
+
* the setting. An explicit flag always wins over both.
|
|
293
|
+
*
|
|
294
|
+
* Making the argument required is the guard. An earlier pass left `cmdStart`'s
|
|
295
|
+
* single-target call on the defaulted form while believing it had been updated, so
|
|
296
|
+
* the restart regression stayed live behind a fix that had landed in `cmdAdd`
|
|
297
|
+
* instead. A missing argument is now a type error rather than a silent default. */
|
|
298
|
+
export async function resolveSecureLoginFlag(
|
|
299
|
+
flags: Record<string, string | boolean | string[]>,
|
|
300
|
+
applySetting: boolean,
|
|
301
|
+
): Promise<boolean | undefined> {
|
|
184
302
|
if (flags["secure-login"] === false) return false;
|
|
185
303
|
if (flagBool(flags, "secure-login")) return true;
|
|
304
|
+
if (!applySetting) return undefined;
|
|
186
305
|
const { resolveSetting } = await import("../lib/settings.ts");
|
|
187
306
|
return resolveSetting("secure-login").value === true ? true : undefined;
|
|
188
307
|
}
|
|
189
308
|
|
|
309
|
+
/** True when the user asked for help on this subcommand.
|
|
310
|
+
*
|
|
311
|
+
* `--help` used to reach subcommands as an ordinary argument: `add --help` generated a
|
|
312
|
+
* name and downloaded 43 MB, and `networks sockets create --help` persisted a socket
|
|
313
|
+
* called `--help` (#156). Handling it here — before the dispatch — means a new
|
|
314
|
+
* subcommand cannot miss the guard.
|
|
315
|
+
*
|
|
316
|
+
* Everything after a bare `--` is the user's payload (a RouterOS command for `exec`),
|
|
317
|
+
* so the scan stops there. */
|
|
318
|
+
function wantsCommandHelp(argv: string[]): boolean {
|
|
319
|
+
for (const arg of argv) {
|
|
320
|
+
if (arg === "--") return false;
|
|
321
|
+
if (arg === "--help" || arg === "-h") return true;
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
190
326
|
async function main() {
|
|
191
327
|
try {
|
|
328
|
+
// Help before side effects — for every subcommand, present and future (#156).
|
|
329
|
+
if (command !== undefined && !command.startsWith("-") && wantsCommandHelp(args.slice(1))) {
|
|
330
|
+
printHelp(command);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
192
333
|
switch (command) {
|
|
193
334
|
case "add":
|
|
194
335
|
await cmdAdd(args.slice(1));
|
|
@@ -199,6 +340,9 @@ async function main() {
|
|
|
199
340
|
case "stop":
|
|
200
341
|
await cmdStop(args.slice(1));
|
|
201
342
|
break;
|
|
343
|
+
case "shutdown":
|
|
344
|
+
await cmdShutdown(args.slice(1));
|
|
345
|
+
break;
|
|
202
346
|
case "list":
|
|
203
347
|
case "ls":
|
|
204
348
|
case "status":
|
|
@@ -294,7 +438,10 @@ async function main() {
|
|
|
294
438
|
if (err.installHint) {
|
|
295
439
|
console.error(` Hint: ${err.installHint}`);
|
|
296
440
|
}
|
|
297
|
-
|
|
441
|
+
for (const line of tipsForError(err.code, command)) tip(line);
|
|
442
|
+
// The 7.x baseline explanation; a RouterOS 6 refusal already says why, and
|
|
443
|
+
// "for older 7.x" would point a 6.x user at the wrong fix.
|
|
444
|
+
if (err.code === "PROVISIONING_VERSION_UNSUPPORTED" && !err.message.includes(ROUTEROS6_BOOT_ONLY)) {
|
|
298
445
|
console.error(` Why this happened: ${provisioningSupportSummary(MIN_PROVISION_VERSION)}`);
|
|
299
446
|
console.error(` ${PROVISIONING_BOOT_ONLY_SUMMARY}`);
|
|
300
447
|
console.error(" Try this:");
|
|
@@ -309,6 +456,12 @@ async function main() {
|
|
|
309
456
|
|
|
310
457
|
// --- Commands ---
|
|
311
458
|
|
|
459
|
+
/** RouterOS 6 in `add`/`start --help` (#182): what it is for, and what it is not. */
|
|
460
|
+
const ROUTEROS6_HELP = `RouterOS 6.x is a best-effort boot-only target (x86 only): quickchr downloads,
|
|
461
|
+
boots and tracks it, and reports its ports and login. It is there for testing v6→v7
|
|
462
|
+
migration tooling, not as supported RouterOS. Readiness is a native-API login, every
|
|
463
|
+
provisioning flag is refused, and REST-based commands (exec, get, set) say so.`;
|
|
464
|
+
|
|
312
465
|
/** Print QEMU keyboard shortcuts before foreground launch. Screen clears after QEMU starts. */
|
|
313
466
|
function printForegroundTips(hasProvisioning = false) {
|
|
314
467
|
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
|
@@ -552,6 +705,7 @@ function printMachineListWithTip(
|
|
|
552
705
|
|
|
553
706
|
async function cmdAdd(argv: string[]) {
|
|
554
707
|
const { flags, positional } = parseFlags(argv);
|
|
708
|
+
rejectUnknownFlags(flags, ADD_FLAGS, "add");
|
|
555
709
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
556
710
|
const { bold, formatPorts, formatNetworks, dim } = await import("./format.ts");
|
|
557
711
|
const { resolveSetting } = await import("../lib/settings.ts");
|
|
@@ -559,13 +713,15 @@ async function cmdAdd(argv: string[]) {
|
|
|
559
713
|
|
|
560
714
|
const opts: StartOptions = {
|
|
561
715
|
version: flag(flags, "version"),
|
|
562
|
-
|
|
716
|
+
// The default channel only stands in for a missing --version; applied alongside
|
|
717
|
+
// one, it reads to start()/add() as a caller passing both (#178).
|
|
718
|
+
channel: (flag(flags, "channel") as Channel | undefined) ?? (flag(flags, "version") ? undefined : resolveSetting("default-channel").value as Channel | undefined),
|
|
563
719
|
arch: (flag(flags, "arch") as Arch | "auto" | undefined) ?? (resolveSetting("default-arch").value as Arch | "auto" | undefined),
|
|
564
720
|
name: flag(flags, "name") ?? positional[0],
|
|
565
721
|
cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
|
|
566
722
|
mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
|
|
567
723
|
packages: csvList(flagList(flags, "add-package")),
|
|
568
|
-
installAllPackages:
|
|
724
|
+
installAllPackages: flagOptionalBool(flags, "install-all-packages"),
|
|
569
725
|
portBase: flag(flags, "port-base") ? Number(flag(flags, "port-base")) : undefined,
|
|
570
726
|
excludePorts: [
|
|
571
727
|
...(flags.winbox === false ? ["winbox" as ServiceName] : []),
|
|
@@ -578,24 +734,18 @@ async function cmdAdd(argv: string[]) {
|
|
|
578
734
|
extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
|
|
579
735
|
};
|
|
580
736
|
|
|
581
|
-
|
|
582
|
-
const deviceModeEnable = csvList(flagList(flags, "device-mode-enable"));
|
|
583
|
-
const deviceModeDisable = csvList(flagList(flags, "device-mode-disable"));
|
|
584
|
-
if (deviceModeValue !== undefined || deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
|
|
585
|
-
opts.deviceMode = {
|
|
586
|
-
mode: deviceModeValue ?? "auto",
|
|
587
|
-
enable: deviceModeEnable.length > 0 ? deviceModeEnable : undefined,
|
|
588
|
-
disable: deviceModeDisable.length > 0 ? deviceModeDisable : undefined,
|
|
589
|
-
};
|
|
590
|
-
}
|
|
737
|
+
opts.deviceMode = deviceModeFromFlags(flags);
|
|
591
738
|
|
|
592
739
|
const userStr = flag(flags, "add-user");
|
|
593
740
|
if (userStr) {
|
|
594
741
|
const [uname = "", password] = userStr.split(":");
|
|
595
742
|
opts.user = { name: uname, password: password ?? "" };
|
|
596
743
|
}
|
|
597
|
-
opts.disableAdmin =
|
|
598
|
-
|
|
744
|
+
opts.disableAdmin = flagOptionalBool(flags, "disable-admin");
|
|
745
|
+
// `add` always creates a machine, so its provisioning window is open by definition.
|
|
746
|
+
// A RouterOS 6 machine takes no provisioning at all, so the setting — a default, not
|
|
747
|
+
// a request — stays out of it; only an explicit --secure-login is refused.
|
|
748
|
+
opts.secureLogin = await resolveSecureLoginFlag(flags, !isRouterOs6(opts.version ?? ""));
|
|
599
749
|
|
|
600
750
|
const state = await QuickCHR.add(opts);
|
|
601
751
|
|
|
@@ -606,10 +756,26 @@ async function cmdAdd(argv: string[]) {
|
|
|
606
756
|
console.log(` CPU/Mem: ${state.cpu} vCPU${state.cpu > 1 ? "s" : ""}, ${state.mem} MB`);
|
|
607
757
|
console.log(` Network: ${formatNetworks(state.networks ?? [])}`);
|
|
608
758
|
console.log(` Ports: ${formatPorts(state.ports)}`);
|
|
609
|
-
|
|
759
|
+
if (isRouterOs6(state.version)) {
|
|
760
|
+
console.log(` API: 127.0.0.1:${ports.api} ${dim("(after start; RouterOS 6 is boot-only and has no REST)")}`);
|
|
761
|
+
} else {
|
|
762
|
+
console.log(` REST: http://127.0.0.1:${ports.http} ${dim("(after start)")}`);
|
|
763
|
+
}
|
|
610
764
|
console.log(` Dir: ${dim(state.machineDir)}`);
|
|
611
|
-
if (state.
|
|
612
|
-
if (state.
|
|
765
|
+
if (state.installAllPackages) console.log(` Packages: all extra packages ${dim("(applied on first start)")}`);
|
|
766
|
+
else if (state.packages.length > 0) console.log(` Packages: ${state.packages.join(", ")} ${dim("(applied on first start)")}`);
|
|
767
|
+
const login = [
|
|
768
|
+
...(state.user ? [`add user "${state.user.name}"`] : []),
|
|
769
|
+
...(state.secureLogin === true ? ["managed quickchr account"] : []),
|
|
770
|
+
...(state.disableAdmin ? ["disable admin"] : []),
|
|
771
|
+
];
|
|
772
|
+
if (login.length > 0) console.log(` Login: ${login.join(", ")} ${dim("(applied on first start)")}`);
|
|
773
|
+
if (state.deviceMode) {
|
|
774
|
+
const { shouldApplyDeviceMode: willApplyDm, resolveDeviceModeOptions: resolveDm } = await import("../lib/device-mode.ts");
|
|
775
|
+
console.log(willApplyDm(resolveDm(state.deviceMode))
|
|
776
|
+
? ` Device-mode: ${state.deviceMode.mode ?? "auto"} ${dim("(applied on first start)")}`
|
|
777
|
+
: ` Device-mode: ${dim("skipped (--no-device-mode)")}`);
|
|
778
|
+
}
|
|
613
779
|
console.log();
|
|
614
780
|
console.log(`${dim("tip:")} quickchr start ${state.name}`);
|
|
615
781
|
}
|
|
@@ -656,6 +822,7 @@ async function cmdExec(argv: string[]) {
|
|
|
656
822
|
} else {
|
|
657
823
|
console.error("Usage: quickchr exec <name> <command...>\n Run a RouterOS CLI command on a running instance.");
|
|
658
824
|
}
|
|
825
|
+
tip(CENTRS_EXEC_TIP);
|
|
659
826
|
process.exit(1);
|
|
660
827
|
}
|
|
661
828
|
|
|
@@ -1193,28 +1360,75 @@ async function cmdSetup() {
|
|
|
1193
1360
|
}
|
|
1194
1361
|
}
|
|
1195
1362
|
|
|
1363
|
+
/** Whether the `secure-login` *setting* reaches this start. The setting is a default,
|
|
1364
|
+
* not a request, so it stays out of every start that cannot provision: a closed
|
|
1365
|
+
* window, a `--no-wait` start (#159), and a RouterOS 6 machine (#182). Otherwise a
|
|
1366
|
+
* user with `secure-login=true` configured could never use either. An explicit
|
|
1367
|
+
* `--secure-login` is still passed through, and refused by name. */
|
|
1368
|
+
export function secureLoginSettingApplies(
|
|
1369
|
+
flags: Record<string, string | boolean | string[]>,
|
|
1370
|
+
windowOpen: boolean,
|
|
1371
|
+
version: string | undefined,
|
|
1372
|
+
): boolean {
|
|
1373
|
+
return windowOpen && flags.wait !== false && !isRouterOs6(version ?? "");
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** `--bg` never did what it reads as (#159): background is the default, and the flag
|
|
1377
|
+
* only ever chose where the serial console went. It stays accepted so existing
|
|
1378
|
+
* scripts keep working, and says once that it does nothing. */
|
|
1379
|
+
function warnDeprecatedBackgroundFlag(flags: Record<string, string | boolean | string[]>): void {
|
|
1380
|
+
const given = flags.bg === true ? "--bg" : flags.background === true ? "--background" : undefined;
|
|
1381
|
+
if (!given) return;
|
|
1382
|
+
console.error(`Warning: ${given} is deprecated and does nothing — background is already the default, and it never made start return early. Use --no-wait to return as soon as QEMU is running.`);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** After a `--no-wait` start the caller owns readiness; name the command that does it. */
|
|
1386
|
+
function printReadinessTip(name: string, version: string): void {
|
|
1387
|
+
// 6.x: native API, and no validation — centrs checks paths against the 7.x tree
|
|
1388
|
+
// and rejects /system/resource on 6.49 (checked 2026-10-21).
|
|
1389
|
+
const via = isRouterOs6(version) ? " --via native-api --no-validate" : "";
|
|
1390
|
+
tip(`wait for readiness with: centrs retrieve --quickchr ${name} /system/resource${via} --wait 5m`);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1196
1393
|
async function cmdStart(argv: string[]) {
|
|
1197
1394
|
const { flags, positional } = parseFlags(argv);
|
|
1198
1395
|
applyTimeoutExtraShortFlag(argv, flags, positional);
|
|
1396
|
+
rejectUnknownFlags(flags, START_FLAGS, "start");
|
|
1199
1397
|
// Before the --all branch: both start paths must see the same override.
|
|
1200
1398
|
await applyAccelFlag(flags);
|
|
1399
|
+
warnDeprecatedBackgroundFlag(flags);
|
|
1400
|
+
const wait = flags.wait === false ? false : undefined;
|
|
1201
1401
|
|
|
1202
1402
|
// --all: start every stopped machine in background
|
|
1203
1403
|
if (flagBool(flags, "all")) {
|
|
1404
|
+
// Batch starts only accept options this branch actually forwards. In particular,
|
|
1405
|
+
// a provisioning flag must never disappear on a --no-wait start.
|
|
1406
|
+
const batchFlags = new Set(["all", "wait", "bg", "background", "accel", "timeout-extra", "secure-login"]);
|
|
1407
|
+
const unsupported = Object.keys(flags).filter((key) => !batchFlags.has(key));
|
|
1408
|
+
if (unsupported.length > 0 || flags.bg === false || flags.background === false) {
|
|
1409
|
+
throw new QuickCHRError("INVALID_ARGUMENT", "start --all runs in background and accepts only --no-wait, --bg, --background, --accel, --timeout-extra, --secure-login and --no-secure-login. Start an individual machine to use other options.");
|
|
1410
|
+
}
|
|
1204
1411
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1205
1412
|
const { statusIcon, link, bold, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
|
|
1206
1413
|
const timeoutExtra = await resolveTimeoutExtraMs(flags);
|
|
1207
|
-
const secureLogin = await resolveSecureLoginFlag(flags);
|
|
1208
1414
|
const stopped = QuickCHR.list().filter((m) => m.status !== "running");
|
|
1209
1415
|
if (stopped.length === 0) {
|
|
1210
1416
|
console.log("No stopped instances.");
|
|
1211
1417
|
return;
|
|
1212
1418
|
}
|
|
1419
|
+
const { isProvisioningWindowOpen } = await import("../lib/provisioning-window.ts");
|
|
1213
1420
|
for (const m of stopped) {
|
|
1214
1421
|
console.log(`Starting ${bold(m.name)}...`);
|
|
1215
|
-
|
|
1422
|
+
// Per machine, not per invocation: a never-started machine in this batch is
|
|
1423
|
+
// still entitled to the secure-login setting on its first boot.
|
|
1424
|
+
const secureLogin = await resolveSecureLoginFlag(flags, secureLoginSettingApplies(flags, isProvisioningWindowOpen(m), m.version));
|
|
1425
|
+
const instance = await QuickCHR.start({ name: m.name, background: true, wait, timeoutExtra, secureLogin });
|
|
1216
1426
|
const creds = await resolveDisplayCredentials(instance.state);
|
|
1217
|
-
|
|
1427
|
+
const endpoint = isRouterOs6(instance.state.version)
|
|
1428
|
+
? `API: 127.0.0.1:${instance.ports.api}`
|
|
1429
|
+
: `REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`;
|
|
1430
|
+
console.log(`${statusIcon("running")} ${bold(instance.name)} ${endpoint} SSH: ${formatSshCommand(creds.user, instance.sshPort)}${wait === false ? " (not waited for)" : ""}`);
|
|
1431
|
+
if (wait === false) printReadinessTip(instance.name, instance.state.version);
|
|
1218
1432
|
}
|
|
1219
1433
|
return;
|
|
1220
1434
|
}
|
|
@@ -1241,13 +1455,15 @@ async function cmdStart(argv: string[]) {
|
|
|
1241
1455
|
|
|
1242
1456
|
const opts: StartOptions = {
|
|
1243
1457
|
version: flag(flags, "version"),
|
|
1244
|
-
|
|
1458
|
+
// The default channel only stands in for a missing --version; applied alongside
|
|
1459
|
+
// one, it reads to start()/add() as a caller passing both (#178).
|
|
1460
|
+
channel: (flag(flags, "channel") as Channel | undefined) ?? (flag(flags, "version") ? undefined : resolveSetting("default-channel").value as Channel | undefined),
|
|
1245
1461
|
arch: (flag(flags, "arch") as Arch | "auto" | undefined) ?? (resolveSetting("default-arch").value as Arch | "auto" | undefined),
|
|
1246
1462
|
name: flag(flags, "name") ?? positional[0],
|
|
1247
1463
|
cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
|
|
1248
1464
|
mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
|
|
1249
|
-
packages: flagList(flags, "add-package"),
|
|
1250
|
-
installAllPackages:
|
|
1465
|
+
packages: csvList(flagList(flags, "add-package")),
|
|
1466
|
+
installAllPackages: flagOptionalBool(flags, "install-all-packages"),
|
|
1251
1467
|
portBase: flag(flags, "port-base") ? Number(flag(flags, "port-base")) : undefined,
|
|
1252
1468
|
excludePorts: [
|
|
1253
1469
|
...(flags.winbox === false ? ["winbox" as ServiceName] : []),
|
|
@@ -1263,21 +1479,7 @@ async function cmdStart(argv: string[]) {
|
|
|
1263
1479
|
timeoutExtra: await resolveTimeoutExtraMs(flags),
|
|
1264
1480
|
};
|
|
1265
1481
|
|
|
1266
|
-
|
|
1267
|
-
const deviceModeEnable = csvList(flagList(flags, "device-mode-enable"));
|
|
1268
|
-
const deviceModeDisable = csvList(flagList(flags, "device-mode-disable"));
|
|
1269
|
-
const noDeviceMode = flags["device-mode"] === false;
|
|
1270
|
-
if (noDeviceMode) {
|
|
1271
|
-
if (deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
|
|
1272
|
-
console.warn("Warning: --device-mode-enable/--device-mode-disable ignored because --no-device-mode was set.");
|
|
1273
|
-
}
|
|
1274
|
-
} else if (deviceModeValue !== undefined || deviceModeEnable.length > 0 || deviceModeDisable.length > 0) {
|
|
1275
|
-
opts.deviceMode = {
|
|
1276
|
-
mode: deviceModeValue ?? "auto",
|
|
1277
|
-
enable: deviceModeEnable.length > 0 ? deviceModeEnable : undefined,
|
|
1278
|
-
disable: deviceModeDisable.length > 0 ? deviceModeDisable : undefined,
|
|
1279
|
-
};
|
|
1280
|
-
}
|
|
1482
|
+
opts.deviceMode = deviceModeFromFlags(flags);
|
|
1281
1483
|
|
|
1282
1484
|
// --license-* flags (credentials from env if not supplied)
|
|
1283
1485
|
const licenseLevel = flag(flags, "license-level");
|
|
@@ -1302,10 +1504,25 @@ async function cmdStart(argv: string[]) {
|
|
|
1302
1504
|
opts.user = { name, password: password ?? "" };
|
|
1303
1505
|
}
|
|
1304
1506
|
|
|
1305
|
-
opts.disableAdmin =
|
|
1306
|
-
|
|
1507
|
+
opts.disableAdmin = flagOptionalBool(flags, "disable-admin");
|
|
1508
|
+
{
|
|
1509
|
+
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1510
|
+
const { isProvisioningWindowOpen } = await import("../lib/provisioning-window.ts");
|
|
1511
|
+
const target = opts.name ? QuickCHR.get(opts.name) : null;
|
|
1512
|
+
// No machine yet, or one that was added and never booted: still a first boot,
|
|
1513
|
+
// so the setting applies. Only a closed window suppresses it — otherwise a
|
|
1514
|
+
// plain `quickchr start <booted-name>` would ask to provision a booted guest
|
|
1515
|
+
// and be refused.
|
|
1516
|
+
opts.secureLogin = await resolveSecureLoginFlag(flags, secureLoginSettingApplies(
|
|
1517
|
+
flags,
|
|
1518
|
+
!target || isProvisioningWindowOpen(target.state),
|
|
1519
|
+
target?.state.version ?? opts.version,
|
|
1520
|
+
));
|
|
1521
|
+
}
|
|
1522
|
+
opts.wait = wait;
|
|
1307
1523
|
|
|
1308
1524
|
// Background default: true. Explicitly foreground only with --fg / --foreground / --no-background / --no-bg.
|
|
1525
|
+
// `--bg` / `--background` are accepted and change nothing (see warnDeprecatedBackgroundFlag).
|
|
1309
1526
|
const wantFg =
|
|
1310
1527
|
flagBool(flags, "fg") ||
|
|
1311
1528
|
flagBool(flags, "foreground") ||
|
|
@@ -1318,8 +1535,9 @@ async function cmdStart(argv: string[]) {
|
|
|
1318
1535
|
return;
|
|
1319
1536
|
}
|
|
1320
1537
|
|
|
1321
|
-
// --fg on already-running machine: attach to its serial console
|
|
1322
|
-
|
|
1538
|
+
// --fg on already-running machine: attach to its serial console.
|
|
1539
|
+
// `--fg --no-wait` skips both foreground blocks so start() refuses the pair before any I/O.
|
|
1540
|
+
if (!opts.background && opts.wait !== false && opts.name) {
|
|
1323
1541
|
const runningMachine = QuickCHR.list().find((m) => m.name === opts.name && m.status === "running");
|
|
1324
1542
|
if (runningMachine) {
|
|
1325
1543
|
await attachSerial(runningMachine.name, runningMachine.machineDir);
|
|
@@ -1327,7 +1545,7 @@ async function cmdStart(argv: string[]) {
|
|
|
1327
1545
|
}
|
|
1328
1546
|
}
|
|
1329
1547
|
|
|
1330
|
-
if (!opts.background) {
|
|
1548
|
+
if (!opts.background && opts.wait !== false) {
|
|
1331
1549
|
const wantsDeviceMode = !!opts.deviceMode && !["skip", "none", "off", "disabled"].includes((opts.deviceMode.mode ?? "auto").toLowerCase());
|
|
1332
1550
|
const hasProv = !!(
|
|
1333
1551
|
opts.installAllPackages ||
|
|
@@ -1354,13 +1572,19 @@ async function cmdStart(argv: string[]) {
|
|
|
1354
1572
|
console.log(` ${dim("Tip: run background")} quickchr start ${instance.name}`);
|
|
1355
1573
|
} else {
|
|
1356
1574
|
const creds = await resolveDisplayCredentials(instance.state);
|
|
1357
|
-
|
|
1358
|
-
console.log(
|
|
1575
|
+
const v6 = isRouterOs6(instance.state.version);
|
|
1576
|
+
console.log(`${statusIcon("running")} ${bold(instance.name)} ${opts.wait === false ? "launched — not waiting for RouterOS (--no-wait)" : "started"}`);
|
|
1577
|
+
console.log(` Version: ${instance.state.version} (${instance.state.arch})${v6 ? ` ${dim("boot-only: no REST, no provisioning")}` : ""}`);
|
|
1359
1578
|
console.log(` Network: ${formatNetworks(instance.state.networks ?? [])}`);
|
|
1360
1579
|
console.log(` Ports: ${formatPorts(instance.state.ports)}`);
|
|
1361
|
-
|
|
1580
|
+
if (v6) {
|
|
1581
|
+
console.log(` API: 127.0.0.1:${instance.ports.api} ${dim(`(native API, user ${creds.user})`)}`);
|
|
1582
|
+
} else {
|
|
1583
|
+
console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
|
|
1584
|
+
}
|
|
1362
1585
|
console.log(` SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
|
|
1363
1586
|
console.log(` WinBox: 127.0.0.1:${instance.ports.winbox}`);
|
|
1587
|
+
if (opts.wait === false) printReadinessTip(instance.name, instance.state.version);
|
|
1364
1588
|
if (creds.user !== "admin") {
|
|
1365
1589
|
console.log(` Login: ${creds.user} / ${creds.password || "(no password)"}`);
|
|
1366
1590
|
console.log(` Run: quickchr exec ${instance.name} /system/resource/print`);
|
|
@@ -1368,6 +1592,32 @@ async function cmdStart(argv: string[]) {
|
|
|
1368
1592
|
}
|
|
1369
1593
|
}
|
|
1370
1594
|
|
|
1595
|
+
async function cmdShutdown(argv: string[]) {
|
|
1596
|
+
const { flags, positional } = parseFlags(argv);
|
|
1597
|
+
const invalid = unknownFlags(flags, ["timeout"]);
|
|
1598
|
+
if (invalid.length) throw new QuickCHRError("INVALID_ARGUMENT", "shutdown accepts only <name> and --timeout <seconds>");
|
|
1599
|
+
if (positional.length !== 1 || positional[0] === undefined) throw new QuickCHRError("INVALID_ARGUMENT", "Usage: quickchr shutdown <name> [--timeout <seconds>]");
|
|
1600
|
+
const raw = flags.timeout === undefined ? (process.env.QUICKCHR_SHUTDOWN_TIMEOUT ?? "10") : flag(flags, "timeout");
|
|
1601
|
+
if (raw === undefined || !/^[0-9]+$/.test(raw) || Number(raw) <= 0) {
|
|
1602
|
+
throw new QuickCHRError("INVALID_ARGUMENT", "shutdown timeout must be a positive integer number of seconds");
|
|
1603
|
+
}
|
|
1604
|
+
const { validateShutdownTimeout } = await import("../lib/shutdown.ts");
|
|
1605
|
+
const timeoutMs = validateShutdownTimeout(Number(raw) * 1000);
|
|
1606
|
+
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1607
|
+
const name = positional[0];
|
|
1608
|
+
const instance = QuickCHR.get(name);
|
|
1609
|
+
if (!instance) throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
|
|
1610
|
+
const result = await instance.shutdown({ timeoutMs });
|
|
1611
|
+
if (result.outcome === "forced") {
|
|
1612
|
+
console.log(`${name}: forced power cut — guest shutdown not confirmed`);
|
|
1613
|
+
if (result.requestError) console.error(`Shutdown request: ${result.requestError}`);
|
|
1614
|
+
} else if (result.outcome === "already-stopped") {
|
|
1615
|
+
console.log(`${name}: already stopped`);
|
|
1616
|
+
} else {
|
|
1617
|
+
console.log(`${name}: guest exited after shutdown request`);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1371
1621
|
async function cmdStop(argv: string[]) {
|
|
1372
1622
|
const { flags, positional } = parseFlags(argv);
|
|
1373
1623
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
@@ -1479,8 +1729,13 @@ async function cmdList(argv: string[] = []) {
|
|
|
1479
1729
|
|
|
1480
1730
|
const { table, statusIcon, formatPorts, formatNetworks, dim } = await import("./format.ts");
|
|
1481
1731
|
const machines = QuickCHR.list();
|
|
1732
|
+
// A machine whose state went bad keeps its place in the listing. It still holds a
|
|
1733
|
+
// disk image, and `list` is where anyone looks first — deferring it to `doctor`
|
|
1734
|
+
// assumes you already suspect trouble, which a quietly absent machine does not
|
|
1735
|
+
// suggest. The listing itself succeeded, so the exit code stays 0 (#165).
|
|
1736
|
+
const unreadable = QuickCHR.listUnreadable();
|
|
1482
1737
|
|
|
1483
|
-
if (machines.length === 0) {
|
|
1738
|
+
if (machines.length === 0 && unreadable.length === 0) {
|
|
1484
1739
|
if (asJson) {
|
|
1485
1740
|
console.log("[]");
|
|
1486
1741
|
} else {
|
|
@@ -1490,7 +1745,10 @@ async function cmdList(argv: string[] = []) {
|
|
|
1490
1745
|
}
|
|
1491
1746
|
|
|
1492
1747
|
if (asJson) {
|
|
1493
|
-
console.log(JSON.stringify(
|
|
1748
|
+
console.log(JSON.stringify([
|
|
1749
|
+
...machines,
|
|
1750
|
+
...unreadable.map((u) => ({ name: u.name, status: "unreadable", error: u.error })),
|
|
1751
|
+
], null, 2));
|
|
1494
1752
|
return;
|
|
1495
1753
|
}
|
|
1496
1754
|
|
|
@@ -1504,8 +1762,17 @@ async function cmdList(argv: string[] = []) {
|
|
|
1504
1762
|
formatPorts(m.ports),
|
|
1505
1763
|
m.pid ? String(m.pid) : dim("—"),
|
|
1506
1764
|
]);
|
|
1765
|
+
for (const u of unreadable) {
|
|
1766
|
+
rows.push([statusIcon("error"), u.name, dim("unreadable"), dim("—"), dim("—"), dim("—"), dim("—")]);
|
|
1767
|
+
}
|
|
1507
1768
|
|
|
1508
1769
|
console.log(table(headers, rows));
|
|
1770
|
+
|
|
1771
|
+
// The row says which machine; this says what to do about it, so neither takes a
|
|
1772
|
+
// second command to learn.
|
|
1773
|
+
for (const u of unreadable) {
|
|
1774
|
+
console.log(`\n${statusIcon("error")} ${u.name}: machine.json unreadable — 'quickchr remove ${u.name}' to clear it`);
|
|
1775
|
+
}
|
|
1509
1776
|
}
|
|
1510
1777
|
|
|
1511
1778
|
function shellQuoteEnv(value: string): string {
|
|
@@ -1614,6 +1881,11 @@ async function cmdGet(argv: string[]) {
|
|
|
1614
1881
|
return;
|
|
1615
1882
|
}
|
|
1616
1883
|
|
|
1884
|
+
// Every live group is a REST read. Refused here rather than left to fetchGroup(),
|
|
1885
|
+
// which swallows errors and would print an empty report for a v6 machine (#182).
|
|
1886
|
+
const { assertRestAvailable } = await import("../lib/versions.ts");
|
|
1887
|
+
assertRestAvailable(s, "'quickchr get' (live properties)");
|
|
1888
|
+
|
|
1617
1889
|
const { resolveAuth } = await import("../lib/auth.ts");
|
|
1618
1890
|
const auth = resolveAuth(s);
|
|
1619
1891
|
const base = `http://127.0.0.1:${instance.ports.http}/rest`;
|
|
@@ -1661,14 +1933,20 @@ async function cmdGet(argv: string[]) {
|
|
|
1661
1933
|
}
|
|
1662
1934
|
|
|
1663
1935
|
if (results["device-mode"]) {
|
|
1936
|
+
const { DEVICE_MODE_NON_FEATURE_KEYS, isDeviceModeFeatureEnabled } = await import("../lib/device-mode.ts");
|
|
1664
1937
|
const dm = results["device-mode"] as Record<string, unknown>;
|
|
1665
1938
|
const mode = dm.mode ?? "(not set)";
|
|
1666
1939
|
console.log(`\n ${bold("Device Mode")}`);
|
|
1667
1940
|
console.log(` Mode: ${mode}`);
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1941
|
+
// RouterOS reports these as strings ("true"/"false"), so the old `v === true`
|
|
1942
|
+
// test matched nothing and the feature lines never printed — `get` could not
|
|
1943
|
+
// read back what `set` had just written. Both lines are shown: after
|
|
1944
|
+
// `--device-mode-disable smb` the interesting answer is the one that is off.
|
|
1945
|
+
const features = Object.entries(dm).filter(([k]) => !DEVICE_MODE_NON_FEATURE_KEYS.has(k));
|
|
1946
|
+
const enabled = features.filter(([, v]) => isDeviceModeFeatureEnabled(v)).map(([k]) => k);
|
|
1947
|
+
const disabled = features.filter(([, v]) => !isDeviceModeFeatureEnabled(v)).map(([k]) => k);
|
|
1948
|
+
if (enabled.length > 0) console.log(` Enabled: ${enabled.join(", ")}`);
|
|
1949
|
+
if (disabled.length > 0) console.log(` Disabled: ${disabled.join(", ")}`);
|
|
1672
1950
|
}
|
|
1673
1951
|
|
|
1674
1952
|
if (results.admin) {
|
|
@@ -1713,8 +1991,21 @@ async function cmdRemove(argv: string[]) {
|
|
|
1713
1991
|
return;
|
|
1714
1992
|
}
|
|
1715
1993
|
|
|
1716
|
-
|
|
1994
|
+
// A machine.json that does not parse throws out of get(), because a lookup by name
|
|
1995
|
+
// must not answer "no such machine" about a directory that exists (#165). `remove`
|
|
1996
|
+
// is the command whose job is to clear that state, so here it routes into the
|
|
1997
|
+
// orphan path below instead of surfacing the error (#155).
|
|
1998
|
+
let instance: ReturnType<typeof QuickCHR.get> = null;
|
|
1999
|
+
try {
|
|
2000
|
+
instance = QuickCHR.get(name);
|
|
2001
|
+
} catch { /* unreadable state → orphan */ }
|
|
1717
2002
|
if (!instance) {
|
|
2003
|
+
// A half-created directory is unremovable any other way, and `remove` naming it
|
|
2004
|
+
// in the "not found" list while refusing to delete it was the worst of both (#155).
|
|
2005
|
+
if (QuickCHR.removeOrphan(name)) {
|
|
2006
|
+
console.log(`${bold(name)} removed (leftover directory — no readable machine.json).`);
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
1718
2009
|
console.error(machineNotFoundMessage(name));
|
|
1719
2010
|
process.exit(1);
|
|
1720
2011
|
}
|
|
@@ -1784,6 +2075,10 @@ async function applyLicense(argv: string[]) {
|
|
|
1784
2075
|
console.error(`Machine "${name}" is not running.`);
|
|
1785
2076
|
process.exit(1);
|
|
1786
2077
|
}
|
|
2078
|
+
// Before any credential prompt: instance.license() refuses RouterOS 6 too, but only
|
|
2079
|
+
// after the user has typed a MikroTik password for nothing.
|
|
2080
|
+
const { assertProvisioningSupportedVersion } = await import("../lib/versions.ts");
|
|
2081
|
+
assertProvisioningSupportedVersion(instance.state.version, "apply a license");
|
|
1787
2082
|
|
|
1788
2083
|
let account = flag(flags, "account") ?? process.env.MIKROTIK_WEB_ACCOUNT;
|
|
1789
2084
|
let password = flag(flags, "password") ?? process.env.MIKROTIK_WEB_PASSWORD;
|
|
@@ -1829,28 +2124,100 @@ async function applyLicense(argv: string[]) {
|
|
|
1829
2124
|
console.log(`License applied: ${before} → ${level}`);
|
|
1830
2125
|
}
|
|
1831
2126
|
|
|
2127
|
+
/** `set` applies a change to a machine that has already booted.
|
|
2128
|
+
*
|
|
2129
|
+
* It is the post-boot half of the provisioning flags, and deliberately narrow: only
|
|
2130
|
+
* the steps that are safe to run against a guest whose configuration quickchr no
|
|
2131
|
+
* longer knows get a home here (#176). `--license` is account-level. `--device-mode`
|
|
2132
|
+
* is a capability flag — it gates whether a feature *can* run and describes nothing
|
|
2133
|
+
* about how the guest is configured, so applying it later cannot clobber working
|
|
2134
|
+
* config; the config it was blocking could not have been applied while it was off.
|
|
2135
|
+
*
|
|
2136
|
+
* `--add-user`, `--disable-admin` and `--secure-login` are excluded on purpose. They
|
|
2137
|
+
* are working config rather than a capability, they collide with whatever has been
|
|
2138
|
+
* created since, and they are the steps that drift. Their route stays
|
|
2139
|
+
* `quickchr clean <name>` and a fresh provisioning run. */
|
|
1832
2140
|
async function cmdSet(argv: string[]) {
|
|
1833
2141
|
const { flags, positional } = parseFlags(argv);
|
|
1834
|
-
|
|
2142
|
+
// Positional first, `--name` as the fallback — the same order `applyLicense` uses,
|
|
2143
|
+
// which `set --license` delegates to. Without this `quickchr set --name lab
|
|
2144
|
+
// --license` printed a usage error while `quickchr license --name lab` worked.
|
|
2145
|
+
const name = positional[0] ?? flag(flags, "name");
|
|
1835
2146
|
|
|
2147
|
+
const usage = "Usage: quickchr set <name> [--license [--level=p1|p10|unlimited]] [--device-mode <mode>] [--device-mode-enable <features>] [--device-mode-disable <features>]";
|
|
1836
2148
|
if (!name) {
|
|
1837
|
-
console.error(
|
|
2149
|
+
console.error(usage);
|
|
2150
|
+
process.exit(1);
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
const valueless = valuelessFlags(flags, SET_FLAGS);
|
|
2154
|
+
if (valueless.length > 0) {
|
|
2155
|
+
console.error(valuelessFlagMessage(valueless, "set"));
|
|
2156
|
+
process.exit(1);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
if (flags["device-mode"] === false) {
|
|
2160
|
+
// `--no-device-mode` means "skip device-mode provisioning" on add/start. There is
|
|
2161
|
+
// nothing to skip in an imperative set, so honouring it would be accepting a flag
|
|
2162
|
+
// and doing nothing — the failure this whole area exists to stop.
|
|
2163
|
+
console.error("--no-device-mode is a provisioning flag for 'add'/'start' (skip the step); 'set' applies a change, so pass a mode or a feature list instead.");
|
|
1838
2164
|
process.exit(1);
|
|
1839
2165
|
}
|
|
1840
2166
|
|
|
1841
2167
|
const wantsLicense = flagBool(flags, "license");
|
|
2168
|
+
const deviceMode = deviceModeFromFlags(flags);
|
|
1842
2169
|
|
|
1843
|
-
if (!wantsLicense) {
|
|
1844
|
-
console.error("Nothing to set. Available flags: --license");
|
|
2170
|
+
if (!wantsLicense && !deviceMode) {
|
|
2171
|
+
console.error("Nothing to set. Available flags: --license, --device-mode, --device-mode-enable, --device-mode-disable");
|
|
1845
2172
|
process.exit(1);
|
|
1846
2173
|
}
|
|
1847
2174
|
|
|
2175
|
+
// Device-mode first: it power-cycles the guest, and a license applied beforehand
|
|
2176
|
+
// would be read back across a reboot it did not expect.
|
|
2177
|
+
if (deviceMode) await applyDeviceModeChange(name, deviceMode);
|
|
1848
2178
|
if (wantsLicense) {
|
|
1849
2179
|
const subcmdArgs = [name, ...Object.entries(flags).flatMap(([k, v]) => v === true ? [`--${k}`] : [`--${k}=${v}`]), ...positional.slice(1)];
|
|
1850
2180
|
await applyLicense(subcmdArgs);
|
|
1851
2181
|
}
|
|
1852
2182
|
}
|
|
1853
2183
|
|
|
2184
|
+
/** `set <name> --device-mode…` — the post-boot route the refusal names.
|
|
2185
|
+
*
|
|
2186
|
+
* Device-mode needs a **power cycle**: RouterOS will not apply it otherwise, and
|
|
2187
|
+
* `/system/device-mode/update` issued from inside the guest simply never returns,
|
|
2188
|
+
* because it is waiting for a power cycle it cannot perform on itself. quickchr owns
|
|
2189
|
+
* the power button, which is why this lives here and not in a tool that talks to a
|
|
2190
|
+
* router over the network. */
|
|
2191
|
+
async function applyDeviceModeChange(name: string, deviceMode: DeviceModeOptions) {
|
|
2192
|
+
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
2193
|
+
const { bold, dim, machineNotFoundMessage } = await import("./format.ts");
|
|
2194
|
+
const { formatDeviceModeSelection, resolveDeviceModeOptions } = await import("../lib/device-mode.ts");
|
|
2195
|
+
const { shellQuote } = await import("../lib/names.ts");
|
|
2196
|
+
|
|
2197
|
+
const instance = QuickCHR.get(name);
|
|
2198
|
+
if (!instance) {
|
|
2199
|
+
console.error(machineNotFoundMessage(name));
|
|
2200
|
+
process.exit(1);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// Before announcing a change that cannot happen (setDeviceMode() refuses it as well).
|
|
2204
|
+
const { assertProvisioningSupportedVersion } = await import("../lib/versions.ts");
|
|
2205
|
+
assertProvisioningSupportedVersion(instance.state.version, "set device-mode");
|
|
2206
|
+
|
|
2207
|
+
const selection = formatDeviceModeSelection(resolveDeviceModeOptions(deviceMode));
|
|
2208
|
+
console.log(`Setting device-mode on ${bold(name)}: ${selection}`);
|
|
2209
|
+
// Hedged on purpose: a request the guest already satisfies costs nothing, and
|
|
2210
|
+
// setDeviceMode() skips the power cycle in that case. Promising a restart before
|
|
2211
|
+
// reading the guest would make the quiet no-op look like a failure to restart.
|
|
2212
|
+
console.log(dim(" RouterOS only applies device-mode across a power cycle, so any setting that moves restarts the machine."));
|
|
2213
|
+
// setDeviceMode() checks the preconditions (running, user-mode NIC), prints the
|
|
2214
|
+
// settings that will actually move, and skips the power cycle when none would.
|
|
2215
|
+
await instance.setDeviceMode(deviceMode);
|
|
2216
|
+
// Quoted for the same reason the refusal routes are: a legacy machine name can
|
|
2217
|
+
// contain a space, and an unquoted read-back hint would name a different machine.
|
|
2218
|
+
console.log(`Device-mode set. Read it back with: quickchr get ${shellQuote(name)} device-mode`);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
1854
2221
|
async function cmdLicense(argv: string[]) {
|
|
1855
2222
|
await applyLicense(argv);
|
|
1856
2223
|
}
|
|
@@ -2053,6 +2420,7 @@ async function cmdNetworks(argv: string[]) {
|
|
|
2053
2420
|
async function showNetworkOverview() {
|
|
2054
2421
|
const { detectPlatform, detectPhysicalInterfaces } = await import("../lib/platform.ts");
|
|
2055
2422
|
const { listNamedSockets } = await import("../lib/socket-registry.ts");
|
|
2423
|
+
const { describeSocketTransport } = await import("../lib/network.ts");
|
|
2056
2424
|
const { statusIcon, bold, dim } = await import("./format.ts");
|
|
2057
2425
|
|
|
2058
2426
|
const platform = await detectPlatform();
|
|
@@ -2126,7 +2494,8 @@ async function showNetworkOverview() {
|
|
|
2126
2494
|
console.log(" Named Sockets:");
|
|
2127
2495
|
for (const s of sockets) {
|
|
2128
2496
|
const members = s.members.length > 0 ? dim(` members: ${s.members.join(", ")}`) : "";
|
|
2129
|
-
|
|
2497
|
+
// Not `port:${s.port}` — a dgram link has no port and printed `port:undefined`.
|
|
2498
|
+
console.log(` ${s.name.padEnd(16)}${s.mode} ${describeSocketTransport(s)}${members}`);
|
|
2130
2499
|
}
|
|
2131
2500
|
} else {
|
|
2132
2501
|
console.log(" Named Sockets:");
|
|
@@ -2153,8 +2522,11 @@ async function showInterfaces() {
|
|
|
2153
2522
|
console.log(table(["Device", "Name", "Alias", "MAC"], rows));
|
|
2154
2523
|
}
|
|
2155
2524
|
|
|
2525
|
+
const SOCKET_MODES = ["dgram", "listen-connect", "mcast"] as const;
|
|
2526
|
+
|
|
2156
2527
|
async function handleSockets(argv: string[]) {
|
|
2157
|
-
const { listNamedSockets, createNamedSocket, removeNamedSocket } = await import("../lib/socket-registry.ts");
|
|
2528
|
+
const { listNamedSockets, createNamedSocket, removeNamedSocket, defaultSocketMode } = await import("../lib/socket-registry.ts");
|
|
2529
|
+
const { describeSocketTransport } = await import("../lib/network.ts");
|
|
2158
2530
|
const { bold, dim, table: fmtTable } = await import("./format.ts");
|
|
2159
2531
|
|
|
2160
2532
|
const action = argv[0];
|
|
@@ -2168,22 +2540,73 @@ async function handleSockets(argv: string[]) {
|
|
|
2168
2540
|
const rows = sockets.map((s) => [
|
|
2169
2541
|
s.name,
|
|
2170
2542
|
s.mode,
|
|
2171
|
-
|
|
2172
|
-
s.mcastGroup ?? "",
|
|
2543
|
+
describeSocketTransport(s),
|
|
2173
2544
|
s.members.join(", ") || dim("none"),
|
|
2174
2545
|
]);
|
|
2175
|
-
console.log(fmtTable(["Name", "Mode", "
|
|
2546
|
+
console.log(fmtTable(["Name", "Mode", "Transport", "Members"], rows));
|
|
2176
2547
|
return;
|
|
2177
2548
|
}
|
|
2178
2549
|
|
|
2179
2550
|
if (action === "create") {
|
|
2180
|
-
const
|
|
2551
|
+
const { flags, positional } = parseFlags(argv.slice(1));
|
|
2552
|
+
const name = positional[0];
|
|
2181
2553
|
if (!name) {
|
|
2182
|
-
console.error("Usage: quickchr networks sockets create <name>");
|
|
2554
|
+
console.error("Usage: quickchr networks sockets create <name> [--mode dgram|listen-connect|mcast] [--port <n>] [--group <addr>]");
|
|
2555
|
+
process.exit(1);
|
|
2556
|
+
}
|
|
2557
|
+
// `parseFlags` stores `true` for a flag with no value and `flag()` turns that back
|
|
2558
|
+
// into `undefined`, so a bare `--mode` would silently fall through to the default
|
|
2559
|
+
// — the same shape as #156's valueless-flag bug.
|
|
2560
|
+
for (const key of ["mode", "port", "group"]) {
|
|
2561
|
+
if (typeof flags[key] === "boolean") {
|
|
2562
|
+
console.error(`Error: --${key} requires a value.`);
|
|
2563
|
+
process.exit(1);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
const mode = flag(flags, "mode");
|
|
2567
|
+
if (mode !== undefined && !(SOCKET_MODES as readonly string[]).includes(mode)) {
|
|
2568
|
+
console.error(`Error: unknown --mode "${mode}" — expected one of ${SOCKET_MODES.join(", ")}.`);
|
|
2569
|
+
process.exit(1);
|
|
2570
|
+
}
|
|
2571
|
+
const portRaw = flag(flags, "port");
|
|
2572
|
+
// Number(), not parseInt(): parseInt stops at the first non-digit, so "4000abc"
|
|
2573
|
+
// came through as 4000.
|
|
2574
|
+
const port = portRaw === undefined ? undefined : Number(portRaw);
|
|
2575
|
+
if (portRaw !== undefined && (!/^\d+$/.test(portRaw) || !Number.isInteger(port) || (port as number) < 1 || (port as number) > 65535)) {
|
|
2576
|
+
console.error(`Error: --port must be a number between 1 and 65535, got "${portRaw}".`);
|
|
2577
|
+
process.exit(1);
|
|
2578
|
+
}
|
|
2579
|
+
const group = flag(flags, "group");
|
|
2580
|
+
const effectiveMode = mode ?? defaultSocketMode();
|
|
2581
|
+
if (group !== undefined && effectiveMode !== "mcast") {
|
|
2582
|
+
console.error(`Error: --group only applies to --mode mcast (this socket is ${effectiveMode}).`);
|
|
2183
2583
|
process.exit(1);
|
|
2184
2584
|
}
|
|
2185
|
-
|
|
2186
|
-
|
|
2585
|
+
if (portRaw !== undefined && effectiveMode === "dgram") {
|
|
2586
|
+
console.error("Error: --port does not apply to --mode dgram — a unix datagram pair uses no port.");
|
|
2587
|
+
process.exit(1);
|
|
2588
|
+
}
|
|
2589
|
+
const entry = createNamedSocket(name, {
|
|
2590
|
+
mode: mode as (typeof SOCKET_MODES)[number] | undefined,
|
|
2591
|
+
port,
|
|
2592
|
+
mcastGroup: group,
|
|
2593
|
+
});
|
|
2594
|
+
console.log(`Created named socket: ${bold(entry.name)} (${entry.mode})`);
|
|
2595
|
+
console.log(` Transport: ${describeSocketTransport(entry)}`);
|
|
2596
|
+
if (entry.mode === "mcast") {
|
|
2597
|
+
// The caveat belongs at create time, not in a doc: mcast fails with no error
|
|
2598
|
+
// anywhere — interfaces up, addresses assigned, 100% loss. Three distinct
|
|
2599
|
+
// hazards, not one (#167): a macOS delivery bug, a sandbox permission block,
|
|
2600
|
+
// and — because quickchr passes no localaddr= — a group that rides the host's
|
|
2601
|
+
// default interface rather than loopback.
|
|
2602
|
+
console.log(dim(" Note: UDP multicast is the only N-way segment, and the only one that"));
|
|
2603
|
+
console.log(dim(" fails silently. It does not deliver on macOS, and it is refused"));
|
|
2604
|
+
console.log(dim(" in sandboxes that block unconnected UDP sends (some Linux CI)."));
|
|
2605
|
+
console.log(dim(` This group is not confined to loopback: ${entry.mcastGroup} rides the host's`));
|
|
2606
|
+
console.log(dim(" default multicast interface, so another machine on your LAN using"));
|
|
2607
|
+
console.log(dim(" the same group joins this segment. Use a unique group, or prefer"));
|
|
2608
|
+
console.log(dim(" 'dgram' for two machines."));
|
|
2609
|
+
}
|
|
2187
2610
|
return;
|
|
2188
2611
|
}
|
|
2189
2612
|
|
|
@@ -2217,6 +2640,21 @@ Usage:
|
|
|
2217
2640
|
quickchr networks sockets create <n> Create a named socket
|
|
2218
2641
|
quickchr networks sockets remove <n> Remove a named socket
|
|
2219
2642
|
|
|
2643
|
+
Socket create options:
|
|
2644
|
+
--mode <mode> dgram | listen-connect | mcast
|
|
2645
|
+
Default: dgram (POSIX), listen-connect (Windows)
|
|
2646
|
+
--port <n> TCP/UDP port for listen-connect and mcast
|
|
2647
|
+
--group <addr> Multicast group for --mode mcast (default 230.0.0.1)
|
|
2648
|
+
|
|
2649
|
+
Transports:
|
|
2650
|
+
dgram A pair of unix datagram sockets. No ports, no UDP syscalls,
|
|
2651
|
+
and either machine may start first. Two machines. POSIX only.
|
|
2652
|
+
listen-connect A TCP pair on loopback. Two machines; the first to join takes
|
|
2653
|
+
the listening end and keeps it, and must be started first
|
|
2654
|
+
thereafter. The Windows default.
|
|
2655
|
+
mcast UDP multicast — the only N-way segment, but broken on macOS
|
|
2656
|
+
and in UDP-blocked sandboxes, and silent when it fails.
|
|
2657
|
+
|
|
2220
2658
|
Aliases: quickchr net`);
|
|
2221
2659
|
}
|
|
2222
2660
|
|
|
@@ -2418,6 +2856,45 @@ async function cmdCache(argv: string[]) {
|
|
|
2418
2856
|
};
|
|
2419
2857
|
|
|
2420
2858
|
switch (sub) {
|
|
2859
|
+
case "add": {
|
|
2860
|
+
const { cacheAdd } = await import("../lib/cache-api.ts");
|
|
2861
|
+
const { resolveSetting } = await import("../lib/settings.ts");
|
|
2862
|
+
const version = cacheTargetFlag(flags, "version");
|
|
2863
|
+
const explicitChannel = cacheTargetFlag(flags, "channel") as Channel | undefined;
|
|
2864
|
+
const channel = version !== undefined || explicitChannel !== undefined
|
|
2865
|
+
? explicitChannel
|
|
2866
|
+
: resolveSetting("default-channel").value as Channel;
|
|
2867
|
+
const arch = (cacheTargetFlag(flags, "arch") ?? resolveSetting("default-arch").value) as Arch | "auto";
|
|
2868
|
+
const asJson = flagBool(flags, "json");
|
|
2869
|
+
const quiet = { status() {}, debug() {}, warn() {} };
|
|
2870
|
+
const result = await cacheAdd({ version, channel, arch, logger: asJson ? quiet : undefined });
|
|
2871
|
+
if (asJson) {
|
|
2872
|
+
console.log(JSON.stringify(result));
|
|
2873
|
+
} else {
|
|
2874
|
+
console.log(`${result.cacheHit ? "Already cached" : "Cached"}: RouterOS ${result.version} (${result.arch})`);
|
|
2875
|
+
console.log(`Path: ${result.path}`);
|
|
2876
|
+
}
|
|
2877
|
+
break;
|
|
2878
|
+
}
|
|
2879
|
+
case "key": {
|
|
2880
|
+
const { cacheKey } = await import("../lib/cache-api.ts");
|
|
2881
|
+
const version = cacheTargetFlag(flags, "version");
|
|
2882
|
+
const explicitChannel = cacheTargetFlag(flags, "channel") as Channel | undefined;
|
|
2883
|
+
const { resolveSetting } = await import("../lib/settings.ts");
|
|
2884
|
+
const channel = version !== undefined || explicitChannel !== undefined
|
|
2885
|
+
? explicitChannel
|
|
2886
|
+
: resolveSetting("default-channel").value as Channel;
|
|
2887
|
+
const arch = (cacheTargetFlag(flags, "arch") ?? resolveSetting("default-arch").value) as Arch | "auto";
|
|
2888
|
+
const result = await cacheKey({ version, channel, arch });
|
|
2889
|
+
if (flagBool(flags, "json")) {
|
|
2890
|
+
console.log(JSON.stringify(result));
|
|
2891
|
+
} else {
|
|
2892
|
+
console.log(`dir=${result.dir}`);
|
|
2893
|
+
console.log(`version=${result.version}`);
|
|
2894
|
+
console.log(`arch=${result.arch}`);
|
|
2895
|
+
}
|
|
2896
|
+
break;
|
|
2897
|
+
}
|
|
2421
2898
|
case "list":
|
|
2422
2899
|
case "ls": {
|
|
2423
2900
|
const entries = listCacheEntries();
|
|
@@ -2688,22 +3165,24 @@ Commands:
|
|
|
2688
3165
|
setup Interactive setup wizard (TTY only)
|
|
2689
3166
|
add [options] Create a new CHR machine (without starting)
|
|
2690
3167
|
start [<name>|options] Start or restart a CHR instance
|
|
2691
|
-
stop [<name>|--all]
|
|
3168
|
+
stop [<name>|--all] Power cut — print list if no name
|
|
3169
|
+
shutdown <name> Request guest shutdown, then bounded hard-stop fallback
|
|
2692
3170
|
list [<name>] [--json] List all instances, or detail for one
|
|
2693
3171
|
status Alias for list
|
|
2694
3172
|
inspect <name> [--json] Print running machine descriptor as JSON
|
|
2695
3173
|
env <name> [--json] Print subprocess environment for a running machine
|
|
2696
3174
|
console <name> Attach to serial console of a running instance
|
|
2697
3175
|
exec <name> <command> Run a RouterOS CLI command on a running instance
|
|
3176
|
+
(raw /rest/execute — see 'quickchr exec --help' for centrs)
|
|
2698
3177
|
remove [<name>|--all] Remove instance(s) and disk
|
|
2699
3178
|
clean [<name>|--all] Reset instance disk to fresh image
|
|
2700
|
-
get <name> [group]
|
|
2701
|
-
set <name>
|
|
3179
|
+
get <name> [group] Show machine config (license, device-mode, admin)
|
|
3180
|
+
set <name> [flags] Change a booted machine (license, device-mode)
|
|
2702
3181
|
license <name> Deprecated — use 'set <name> --license'
|
|
2703
3182
|
disk <name> Show disk details for an instance
|
|
2704
3183
|
snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
|
|
2705
3184
|
networks Network discovery & socket management
|
|
2706
|
-
cache [list|prune|clear] Manage cached CHR images
|
|
3185
|
+
cache [add|key|list|prune|clear] Manage cached CHR images
|
|
2707
3186
|
settings [print|get|set|reset] Manage quickchr's own global settings (quickchr.env)
|
|
2708
3187
|
completions Manage shell completions (Tab completion)
|
|
2709
3188
|
logs <name> Tail the QEMU log for an instance
|
|
@@ -2736,6 +3215,8 @@ Post-boot provisioning flags in this command (--add-package, --install-all-packa
|
|
|
2736
3215
|
RouterOS ${MIN_PROVISION_VERSION}+ only. Older 7.x remains boot-only. Disk and network
|
|
2737
3216
|
options still work on older versions when the required host tools are installed.
|
|
2738
3217
|
|
|
3218
|
+
${ROUTEROS6_HELP}
|
|
3219
|
+
|
|
2739
3220
|
Options:
|
|
2740
3221
|
--name <name> Instance name (required)
|
|
2741
3222
|
--version <ver> RouterOS version (e.g., 7.22.1)
|
|
@@ -2746,9 +3227,9 @@ Options:
|
|
|
2746
3227
|
--accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
|
|
2747
3228
|
Anything but 'auto' bypasses detection, including the
|
|
2748
3229
|
arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
3230
|
+
--boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
|
|
3231
|
+
--boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
|
|
3232
|
+
--add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
|
|
2752
3233
|
--add-package <pkg> Extra package to install on first boot (repeatable)
|
|
2753
3234
|
--forward <spec> Add an extra hostfwd port (repeatable). Spec:
|
|
2754
3235
|
name[:host[:guest]][/proto], or a port range
|
|
@@ -2766,11 +3247,18 @@ Options:
|
|
|
2766
3247
|
--no-winbox Exclude WinBox port mapping
|
|
2767
3248
|
--no-api-ssl Exclude API-SSL port mapping
|
|
2768
3249
|
--device-mode <m> Set device-mode on first boot: rose|advanced|basic|home|auto|skip
|
|
3250
|
+
--device-mode-enable <f> Set one or more device-mode flags to yes (e.g. container)
|
|
3251
|
+
--device-mode-disable <f> Set one or more device-mode flags to no
|
|
3252
|
+
--no-device-mode Skip device-mode provisioning entirely
|
|
2769
3253
|
--add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
|
|
2770
3254
|
socket::<name>, tap:<if>. Default: single user NIC.
|
|
2771
3255
|
--no-network Start with no NICs (headless)
|
|
2772
|
-
|
|
2773
|
-
|
|
3256
|
+
|
|
3257
|
+
Provisioning flags apply on the machine's first boot — whether they are given
|
|
3258
|
+
here or on that first 'start'. After the guest has booted, a flag the machine already
|
|
3259
|
+
has is accepted as a no-op; any other is refused rather than ignored, naming where the
|
|
3260
|
+
change can still be made (often 'quickchr set <name>'). 'quickchr clean <name>' also
|
|
3261
|
+
reopens the window, but resets the disk to factory and erases the guest's configuration.`);
|
|
2774
3262
|
break;
|
|
2775
3263
|
case "setup":
|
|
2776
3264
|
console.log(`quickchr setup
|
|
@@ -2802,7 +3290,9 @@ Options:
|
|
|
2802
3290
|
Examples:
|
|
2803
3291
|
quickchr exec my-chr /system/resource/print
|
|
2804
3292
|
quickchr exec my-chr ":put [:serialize to=json [/ip/address/print]]"
|
|
2805
|
-
quickchr exec my-chr "/log/info message=hello"
|
|
3293
|
+
quickchr exec my-chr "/log/info message=hello"
|
|
3294
|
+
|
|
3295
|
+
${CENTRS_SEE_ALSO}`);
|
|
2806
3296
|
break;
|
|
2807
3297
|
case "start":
|
|
2808
3298
|
console.log(`quickchr start [<name>] [options]
|
|
@@ -2818,10 +3308,23 @@ validated on RouterOS ${MIN_PROVISION_VERSION}+ only. Older 7.x remains boot-onl
|
|
|
2818
3308
|
Disk and network options still work on older versions when the required host tools
|
|
2819
3309
|
are installed.
|
|
2820
3310
|
|
|
3311
|
+
${ROUTEROS6_HELP}
|
|
3312
|
+
|
|
3313
|
+
By default 'start' returns once RouterOS is ready (REST answers; on 6.x, a native-API
|
|
3314
|
+
login). --no-wait returns as soon as QEMU is running and leaves readiness to you:
|
|
3315
|
+
centrs retrieve --quickchr <name> /system/resource --wait 5m
|
|
3316
|
+
QEMU runs detached either way. --no-wait is refused with --fg, and with provisioning
|
|
3317
|
+
flags, including ones 'add' recorded for the first boot — provisioning needs a booted
|
|
3318
|
+
guest.
|
|
3319
|
+
|
|
2821
3320
|
Options:
|
|
2822
|
-
--all Start all stopped machines
|
|
2823
|
-
|
|
3321
|
+
--all Start all stopped machines, in background. Accepts only
|
|
3322
|
+
--no-wait, --accel, --timeout-extra and --[no-]secure-login
|
|
3323
|
+
(plus the deprecated no-op --bg / --background); any other
|
|
3324
|
+
option is refused rather than ignored
|
|
3325
|
+
--no-wait Return once QEMU is running; do not wait for RouterOS (not with --fg)
|
|
2824
3326
|
--fg / --foreground Run in foreground — serial console on stdio
|
|
3327
|
+
--bg / --background Deprecated, does nothing: background is already the default
|
|
2825
3328
|
--version <ver> RouterOS version (e.g., 7.22.1)
|
|
2826
3329
|
--channel <ch> Channel: stable, long-term, testing, development
|
|
2827
3330
|
--arch <arch> Architecture: arm64, x86, auto (default: host native)
|
|
@@ -2831,9 +3334,9 @@ Options:
|
|
|
2831
3334
|
--accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
|
|
2832
3335
|
Anything but 'auto' bypasses detection, including the
|
|
2833
3336
|
arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
3337
|
+
--boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
|
|
3338
|
+
--boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
|
|
3339
|
+
--add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
|
|
2837
3340
|
--add-package <pkg> Extra package to install (repeatable)
|
|
2838
3341
|
--forward <spec> Add an extra hostfwd port (repeatable). Spec:
|
|
2839
3342
|
name[:host[:guest]][/proto], or a range
|
|
@@ -2849,8 +3352,6 @@ Options:
|
|
|
2849
3352
|
--add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
|
|
2850
3353
|
socket::<name>, tap:<if>. Default: single user NIC.
|
|
2851
3354
|
--no-network Start with no NICs (headless)
|
|
2852
|
-
--vmnet-shared [deprecated] Use --add-network shared
|
|
2853
|
-
--vmnet-bridge <if> [deprecated] Use --add-network bridged:<if>
|
|
2854
3355
|
--install-all-packages Install all packages from all_packages.zip
|
|
2855
3356
|
--license-level <l> Apply trial license: p1 (1 Gbps), p10 (10 Gbps), unlimited
|
|
2856
3357
|
--license-account <a> MikroTik account email (or use MIKROTIK_WEB_ACCOUNT env var)
|
|
@@ -2860,21 +3361,42 @@ Options:
|
|
|
2860
3361
|
--device-mode-disable <f> Set one or more device-mode flags to no
|
|
2861
3362
|
--no-device-mode Skip device-mode provisioning entirely
|
|
2862
3363
|
--timeout-extra <s>, -T <s> Add extra seconds to the auto-computed boot timeout
|
|
2863
|
-
--dry-run Print what would run without starting
|
|
3364
|
+
--dry-run Print what would run without starting
|
|
3365
|
+
|
|
3366
|
+
Provisioning flags (packages, login, device-mode, license) apply only on a machine's
|
|
3367
|
+
first boot. On a machine that has booted, a flag the machine already has (same
|
|
3368
|
+
packages, user, license, device-mode) is accepted as a no-op. Any other is refused,
|
|
3369
|
+
naming where the change can still be made — often 'quickchr set <name>'. Only reach for
|
|
3370
|
+
'quickchr clean <name>' if you mean it: it resets the disk to factory, erasing the
|
|
3371
|
+
guest's configuration, and reopens the first-boot window.`);
|
|
2864
3372
|
break;
|
|
2865
3373
|
case "stop":
|
|
2866
3374
|
console.log(`quickchr stop [<name>] [--all]
|
|
2867
3375
|
|
|
3376
|
+
Power cut: QEMU SIGTERM, then SIGKILL if needed. Pending guest changes may be lost.
|
|
3377
|
+
Use shutdown to request /system/shutdown first.
|
|
3378
|
+
|
|
2868
3379
|
<name> Stop a specific running instance.
|
|
2869
3380
|
Omit to see a list of running instances.
|
|
2870
3381
|
--all Stop all running instances.`);
|
|
2871
3382
|
break;
|
|
3383
|
+
case "shutdown":
|
|
3384
|
+
console.log(`quickchr shutdown <name> [--timeout <seconds>]
|
|
3385
|
+
|
|
3386
|
+
Request /system/shutdown over REST, then wait for QEMU to exit.
|
|
3387
|
+
If it is still running at the deadline, force a power cut and report it.
|
|
3388
|
+
--timeout <seconds> Positive integer seconds (default 10).
|
|
3389
|
+
Overrides QUICKCHR_SHUTDOWN_TIMEOUT.
|
|
3390
|
+
Lock wait and hard-stop escalation are separate bounded waits.
|
|
3391
|
+
Preserves disks and credentials. Use stop for immediate teardown.`);
|
|
3392
|
+
break;
|
|
2872
3393
|
case "status":
|
|
2873
3394
|
console.log(`quickchr status [<name>] [--json]
|
|
2874
3395
|
|
|
2875
3396
|
Alias for 'quickchr list'. See 'quickchr help list'.`);
|
|
2876
3397
|
break;
|
|
2877
3398
|
case "list":
|
|
3399
|
+
case "ls":
|
|
2878
3400
|
console.log(`quickchr list [<name>] [--json]
|
|
2879
3401
|
|
|
2880
3402
|
List all CHR instances or show detailed info for one.
|
|
@@ -2986,14 +3508,30 @@ When no group is given, all properties are shown.
|
|
|
2986
3508
|
The machine must be running to query live properties.`);
|
|
2987
3509
|
break;
|
|
2988
3510
|
case "set":
|
|
2989
|
-
console.log(`quickchr set <name> --license [options]
|
|
3511
|
+
console.log(`quickchr set <name> [--license | --device-mode...] [options]
|
|
2990
3512
|
|
|
2991
|
-
|
|
3513
|
+
Change a property on a machine that has already booted. This is the post-boot
|
|
3514
|
+
half of the provisioning flags: 'add'/'start' apply them at first boot, 'set'
|
|
3515
|
+
applies them now.
|
|
2992
3516
|
|
|
2993
3517
|
<name> Name of a running CHR instance (required).
|
|
2994
3518
|
|
|
2995
3519
|
Flags:
|
|
2996
3520
|
--license Apply or renew a CHR trial license.
|
|
3521
|
+
--device-mode <m> Set device-mode: rose|advanced|basic|home|auto
|
|
3522
|
+
--device-mode-enable <f> Set one or more device-mode flags to yes (e.g. container)
|
|
3523
|
+
--device-mode-disable <f> Set one or more device-mode flags to no
|
|
3524
|
+
|
|
3525
|
+
Device-mode notes:
|
|
3526
|
+
RouterOS only applies device-mode across a power cycle, so 'set' restarts the
|
|
3527
|
+
machine (roughly double a plain start). Enabling a feature without naming a
|
|
3528
|
+
mode resolves the mode to 'rose'; quickchr prints every setting that moves
|
|
3529
|
+
before it restarts. The machine must be running and have a user-mode NIC.
|
|
3530
|
+
Read it back with 'quickchr get <name> device-mode'.
|
|
3531
|
+
|
|
3532
|
+
Not available here: --add-user, --disable-admin and --secure-login. Those rewrite
|
|
3533
|
+
working config rather than unlock a capability, so their route stays
|
|
3534
|
+
'quickchr clean <name>' followed by the original add/start command.
|
|
2997
3535
|
|
|
2998
3536
|
License options (used with --license):
|
|
2999
3537
|
--level <level> License level: p1 (1 Gbps), p10 (10 Gbps), unlimited (default: p1)
|
|
@@ -3004,7 +3542,7 @@ Credential resolution order:
|
|
|
3004
3542
|
1. --account / --password flags
|
|
3005
3543
|
2. MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD environment variables
|
|
3006
3544
|
3. OS native secret store (macOS Keychain / Linux Keyring)
|
|
3007
|
-
4. ~/.config/quickchr/
|
|
3545
|
+
4. ~/.config/quickchr/com.quickchr.mikrotik-web.json (fallback)`);
|
|
3008
3546
|
break;
|
|
3009
3547
|
case "license":
|
|
3010
3548
|
console.log(`quickchr license <name> [options]
|
|
@@ -3026,7 +3564,15 @@ Credential resolution order (highest priority first):
|
|
|
3026
3564
|
1. --account / --password flags
|
|
3027
3565
|
2. MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD environment variables
|
|
3028
3566
|
3. OS native secret store (macOS Keychain, Linux GNOME Keyring, Windows Credential Manager)
|
|
3029
|
-
4. ~/.config/quickchr/
|
|
3567
|
+
4. ~/.config/quickchr/com.quickchr.mikrotik-web.json (fallback)`);
|
|
3568
|
+
break;
|
|
3569
|
+
case "qga":
|
|
3570
|
+
console.log(`quickchr qga <name> <operation> [options]
|
|
3571
|
+
|
|
3572
|
+
Query the QEMU Guest Agent on a running CHR instance.
|
|
3573
|
+
QGA is x86-only — the arm64 CHR image does not start the guest agent.
|
|
3574
|
+
|
|
3575
|
+
Run 'quickchr qga <name>' with no operation to list every operation.`);
|
|
3030
3576
|
break;
|
|
3031
3577
|
case "clean":
|
|
3032
3578
|
console.log(`quickchr clean [<name>] [--all]
|
|
@@ -3036,6 +3582,7 @@ Credential resolution order (highest priority first):
|
|
|
3036
3582
|
--all Clean all instances.`);
|
|
3037
3583
|
break;
|
|
3038
3584
|
case "remove":
|
|
3585
|
+
case "rm":
|
|
3039
3586
|
console.log(`quickchr remove [<name>] [--all]
|
|
3040
3587
|
|
|
3041
3588
|
<name> Remove an instance (stops if running, deletes disk and state).
|
|
@@ -3057,11 +3604,16 @@ Options:
|
|
|
3057
3604
|
showNetworksHelp();
|
|
3058
3605
|
break;
|
|
3059
3606
|
case "cache":
|
|
3060
|
-
console.log(`quickchr cache <list|prune|clear> [options]
|
|
3607
|
+
console.log(`quickchr cache <add|key|list|prune|clear> [options]
|
|
3061
3608
|
|
|
3062
3609
|
Manage cached CHR images in the data dir cache directory.
|
|
3063
3610
|
|
|
3064
3611
|
Subcommands:
|
|
3612
|
+
add [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
|
|
3613
|
+
Resolve, download, and cache one CHR image without booting.
|
|
3614
|
+
key [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
|
|
3615
|
+
Print cache directory, resolved version, and architecture for CI.
|
|
3616
|
+
Without --json, output is GITHUB_OUTPUT-compatible key=value lines.
|
|
3065
3617
|
list [--json] List cached images (basename, version, arch, size, age, in-use).
|
|
3066
3618
|
prune [options] Evict cached images per policy. Never evicts in-use images.
|
|
3067
3619
|
--older-than <ver> Evict images with version < <ver>.
|
|
@@ -3110,6 +3662,7 @@ Credentials (--add-user, MIKROTIK_WEB_ACCOUNT/PASSWORD) are never stored here.`)
|
|
|
3110
3662
|
break;
|
|
3111
3663
|
default:
|
|
3112
3664
|
console.log(`No detailed help for '${command}'.`);
|
|
3665
|
+
console.log("Run 'quickchr help' for the command list.");
|
|
3113
3666
|
}
|
|
3114
3667
|
}
|
|
3115
3668
|
|