@tikoci/quickchr 0.4.2 → 0.4.3
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 +161 -37
- package/MANUAL.md +1259 -0
- package/README.md +35 -1
- package/package.json +7 -4
- package/src/cli/index.ts +191 -42
- package/src/cli/wizard.ts +42 -12
- package/src/index.ts +1 -1
- package/src/lib/cache.ts +19 -1
- package/src/lib/channels.ts +19 -4
- package/src/lib/completions.ts +6 -3
- package/src/lib/disk.ts +5 -2
- package/src/lib/forward-spec.ts +140 -3
- package/src/lib/paths.ts +13 -0
- package/src/lib/provision.ts +25 -27
- package/src/lib/qemu.ts +52 -22
- package/src/lib/quickchr.ts +83 -16
- package/src/lib/secrets.ts +3 -7
- package/src/lib/settings.ts +373 -0
- package/src/lib/state.ts +42 -1
- package/src/lib/types.ts +90 -8
package/README.md
CHANGED
|
@@ -136,6 +136,11 @@ quickchr networks sockets
|
|
|
136
136
|
# Set up shell completions (bash/zsh/fish)
|
|
137
137
|
quickchr completions
|
|
138
138
|
|
|
139
|
+
# Persist a default so you stop repeating a flag on every add/start
|
|
140
|
+
quickchr settings set default-channel long-term
|
|
141
|
+
quickchr settings set secure-login true
|
|
142
|
+
quickchr settings print
|
|
143
|
+
|
|
139
144
|
# Reset disk to fresh image
|
|
140
145
|
quickchr clean my-chr
|
|
141
146
|
|
|
@@ -251,6 +256,12 @@ quickchr start --name r2 --add-network socket::lab-switch
|
|
|
251
256
|
quickchr start --name gw --add-network user --add-network bridged:en0
|
|
252
257
|
```
|
|
253
258
|
|
|
259
|
+
Not sure which mechanism fits your traffic (host→guest service, host receiving
|
|
260
|
+
guest-originated UDP, L2 broadcasts, VM↔VM links, real LAN)? See the
|
|
261
|
+
[networking recipes guide](./docs/networking-recipes.md) — a "traffic shape →
|
|
262
|
+
mechanism" decision table. The CLI flags map 1:1 to the library:
|
|
263
|
+
`--add-network` ↔ `StartOptions.networks`, `--forward` ↔ `StartOptions.extraPorts`.
|
|
264
|
+
|
|
254
265
|
### Port Layout
|
|
255
266
|
|
|
256
267
|
Each instance gets a block of 10 ports. With the default base of 9100, the first instance maps:
|
|
@@ -277,8 +288,14 @@ registry when it knows the guest port:
|
|
|
277
288
|
quickchr add lab --forward smb # auto host port → guest 445/tcp
|
|
278
289
|
quickchr add dude-lab --forward winbox:8291 # host 8291 → guest WinBox 8291/tcp
|
|
279
290
|
quickchr add app-lab --forward myapp:9200:7777/udp
|
|
291
|
+
quickchr add btest-lab --forward btest:9200-9210:2000-2010/udp # UDP port range
|
|
280
292
|
```
|
|
281
293
|
|
|
294
|
+
A **port range** (`name:hostStart-hostEnd[:guestStart-guestEnd][/proto]`) expands
|
|
295
|
+
to one `hostfwd` per port — handy for L3 peers with dynamic data ports. To
|
|
296
|
+
*receive* UDP a guest sends (no forward at all), see the gateway path in the
|
|
297
|
+
[networking recipes guide](./docs/networking-recipes.md).
|
|
298
|
+
|
|
282
299
|
Reusing a built-in service name such as `winbox` pins/replaces that service's
|
|
283
300
|
host port for the machine. Existing machines keep the mapping stored in
|
|
284
301
|
`machine.json`; recreate the machine to change fixed service ports cleanly.
|
|
@@ -362,7 +379,7 @@ test("boot CHR and check version", async () => {
|
|
|
362
379
|
chr = await QuickCHR.start({ channel: "stable" });
|
|
363
380
|
|
|
364
381
|
const resource = await chr.rest("/system/resource");
|
|
365
|
-
expect(resource["board-name"]).
|
|
382
|
+
expect(resource["board-name"]).toContain("CHR");
|
|
366
383
|
}, 120_000);
|
|
367
384
|
|
|
368
385
|
afterAll(async () => {
|
|
@@ -376,17 +393,34 @@ afterAll(async () => {
|
|
|
376
393
|
|----------|-------------|
|
|
377
394
|
| `MIKROTIK_WEB_ACCOUNT` | MikroTik.com account email (for license renewal) |
|
|
378
395
|
| `MIKROTIK_WEB_PASSWORD` | MikroTik.com password (for license renewal) |
|
|
396
|
+
| `QUICKCHR_DEFAULT_CHANNEL`, `QUICKCHR_DEFAULT_ARCH`, `QUICKCHR_CACHE_MAX_SIZE`, `QUICKCHR_TIMEOUT_EXTRA`, `QUICKCHR_SECURE_LOGIN` | Env-tier overrides for the 5 `quickchr settings` keys — see [MANUAL.md §3 `settings`](./MANUAL.md#3-cli-command-reference) |
|
|
379
397
|
| `QUICKCHR_INTEGRATION` | Set to `1` to run integration tests |
|
|
398
|
+
| `QUICKCHR_TEST_TARGET` | Integration tests only: RouterOS channel (`stable`/`long-term`/`testing`/`development`) or pinned version (e.g. `7.22.1`) to boot. Empty = `stable`. The Integration workflow (`integration.yml`) sets this from its `routeros-target` dispatch input to test a release across platforms |
|
|
399
|
+
|
|
400
|
+
The full list (including `QUICKCHR_DATA_DIR`, `QUICKCHR_NO_PROMPT`, `QUICKCHR_DEBUG`,
|
|
401
|
+
`NO_COLOR`) is in [MANUAL.md §11](./MANUAL.md#11-auth-secrets-environment-variables).
|
|
380
402
|
|
|
381
403
|
## Documentation
|
|
382
404
|
|
|
383
405
|
- **[MANUAL.md](./MANUAL.md)** — complete reference: every CLI command,
|
|
384
406
|
every library API, provisioning, channels, networking, storage,
|
|
385
407
|
errors. Source-checked.
|
|
408
|
+
- **[examples/](./examples/)** — runnable scripts (`bun run`): `quickstart/`
|
|
409
|
+
(boot + query), `grounding/` (apply config → read back — the one `bun:test`),
|
|
410
|
+
`dude/` (install a package), `harness/` (drive an external tool), `rollback/`,
|
|
411
|
+
`service-forward/`, `file-transfer/`, `device-mode/`, plus `mndp`/`udp-gateway`/
|
|
412
|
+
`version-matrix`. See [examples/COVERAGE.md](./examples/COVERAGE.md).
|
|
413
|
+
- **[docs/networking-recipes.md](./docs/networking-recipes.md)** — which
|
|
414
|
+
networking mechanism for which traffic shape.
|
|
386
415
|
- **[DESIGN.md](./DESIGN.md)** — architecture, layers, design principles.
|
|
387
416
|
- **[CONTRIBUTING.md](./CONTRIBUTING.md)** — dev setup and `bun run check`.
|
|
388
417
|
- **[CHANGELOG.md](./CHANGELOG.md)** — release history.
|
|
389
418
|
|
|
419
|
+
**Using quickchr with an AI agent?** The **`routeros-quickchr`** skill (in
|
|
420
|
+
[tikoci/routeros-skills](https://github.com/tikoci/routeros-skills)) teaches
|
|
421
|
+
agents the grounding workflow — boot a CHR, apply config, read it back — plus
|
|
422
|
+
the networking recipes and harness patterns.
|
|
423
|
+
|
|
390
424
|
## License
|
|
391
425
|
|
|
392
426
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tikoci/quickchr",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -17,21 +17,24 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"src",
|
|
19
19
|
"README.md",
|
|
20
|
-
"DESIGN.md"
|
|
20
|
+
"DESIGN.md",
|
|
21
|
+
"MANUAL.md"
|
|
21
22
|
],
|
|
22
23
|
"scripts": {
|
|
23
|
-
"check": "bun run lint:biome && bun run lint:typecheck && bun run lint:markdown && bun run lint:cspell",
|
|
24
|
+
"check": "bun run lint:biome && bun run lint:typecheck && bun run lint:markdown && bun run lint:cspell && bun run lint:examples && bun run lint:shell",
|
|
24
25
|
"lint": "biome check . && tsc --noEmit",
|
|
25
26
|
"lint:biome": "biome check .",
|
|
26
27
|
"lint:typecheck": "tsc --noEmit",
|
|
27
28
|
"lint:markdown": "bunx markdownlint-cli2 '**/*.md'",
|
|
28
29
|
"lint:cspell": "cspell lint . --no-progress",
|
|
30
|
+
"lint:examples": "bun run scripts/validate-examples.ts",
|
|
31
|
+
"lint:shell": "sh scripts/lint-shell.sh",
|
|
29
32
|
"test": "bun test test/unit/",
|
|
30
33
|
"test:examples": "bun test examples/",
|
|
34
|
+
"smoke:examples": "QUICKCHR_INTEGRATION=1 EXAMPLES_SMOKE=1 bun test test/integration/examples-smoke.test.ts",
|
|
31
35
|
"test:integration": "QUICKCHR_INTEGRATION=1 bun test test/integration/",
|
|
32
36
|
"test:all": "QUICKCHR_INTEGRATION=1 bun test test/unit/ test/integration/ examples/",
|
|
33
37
|
"dev": "bun run src/cli/index.ts",
|
|
34
|
-
"release": "bun scripts/release.ts",
|
|
35
38
|
"install:deps:ubuntu": "sudo apt-get --yes install qemu-system-x86 qemu-system-arm qemu-efi-aarch64 qemu-utils socat && curl -fsSL https://bun.sh/install | bash"
|
|
36
39
|
},
|
|
37
40
|
"devDependencies": {
|
package/src/cli/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { StartOptions, Arch, Channel, ServiceName, NetworkSpecifier } from "../lib/types.ts";
|
|
7
7
|
import { parseNetworkSpecifier } from "../lib/network.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { expandForwardSpec } from "../lib/forward-spec.ts";
|
|
9
9
|
import {
|
|
10
10
|
MIN_PROVISION_VERSION,
|
|
11
11
|
PROVISIONING_BOOT_ONLY_SUMMARY,
|
|
@@ -116,6 +116,57 @@ function isNoPrompt(): boolean {
|
|
|
116
116
|
return !!process.env.QUICKCHR_NO_PROMPT || !process.stdout.isTTY || !process.stdin.isTTY;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/** Merge the `-T` short-flag alias for `--timeout-extra` into `flags`, since parseFlags
|
|
120
|
+
* only recognizes `--` flags. Also consumes the `-T <value>` pair from `positional`
|
|
121
|
+
* so `quickchr start -T 15 lab` still targets `lab`. */
|
|
122
|
+
export function applyTimeoutExtraShortFlag(
|
|
123
|
+
argv: readonly string[],
|
|
124
|
+
flags: Record<string, string | boolean | string[]>,
|
|
125
|
+
positional?: string[],
|
|
126
|
+
): Record<string, string | boolean | string[]> {
|
|
127
|
+
const tIdx = argv.indexOf("-T");
|
|
128
|
+
if (tIdx !== -1) {
|
|
129
|
+
const value = argv[tIdx + 1];
|
|
130
|
+
if (flags["timeout-extra"] === undefined && value !== undefined) {
|
|
131
|
+
flags["timeout-extra"] = value;
|
|
132
|
+
}
|
|
133
|
+
if (positional) {
|
|
134
|
+
const pIdx = positional.indexOf("-T");
|
|
135
|
+
if (pIdx !== -1) {
|
|
136
|
+
positional.splice(pIdx, 1);
|
|
137
|
+
if (value !== undefined && positional[pIdx] === value) {
|
|
138
|
+
positional.splice(pIdx, 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return flags;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Resolve --timeout-extra into a millisecond boot-timeout addend. Validates an
|
|
147
|
+
* explicit flag with the same non-negative-integer rule the timeout-extra setting
|
|
148
|
+
* itself uses, falling back to the setting (env/file/default) only when the flag
|
|
149
|
+
* is absent. Shared by cmdStart's single-target and --all bulk-restart paths.
|
|
150
|
+
* Callers merge the `-T` short-flag alias into `flags["timeout-extra"]` before
|
|
151
|
+
* calling this — `-T` is single-dash and parseFlags only recognizes `--` flags
|
|
152
|
+
* (same reason cmdLogs scans argv directly for its own `-f`/`-n` aliases). */
|
|
153
|
+
export async function resolveTimeoutExtraMs(flags: Record<string, string | boolean | string[]>): Promise<number | undefined> {
|
|
154
|
+
const { resolveSetting, parseTimeoutExtraSeconds } = await import("../lib/settings.ts");
|
|
155
|
+
const flagRaw = flag(flags, "timeout-extra");
|
|
156
|
+
const seconds = flagRaw !== undefined ? parseTimeoutExtraSeconds(flagRaw) : (resolveSetting("timeout-extra").value as number | undefined);
|
|
157
|
+
return seconds !== undefined ? seconds * 1000 : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Resolve --secure-login/--no-secure-login, falling back to the secure-login setting
|
|
161
|
+
* only when neither flag was passed. Shared by cmdAdd, cmdStart, and cmdStart's
|
|
162
|
+
* --all bulk-restart path. */
|
|
163
|
+
export async function resolveSecureLoginFlag(flags: Record<string, string | boolean | string[]>): Promise<boolean | undefined> {
|
|
164
|
+
if (flags["secure-login"] === false) return false;
|
|
165
|
+
if (flagBool(flags, "secure-login")) return true;
|
|
166
|
+
const { resolveSetting } = await import("../lib/settings.ts");
|
|
167
|
+
return resolveSetting("secure-login").value === true ? true : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
119
170
|
async function main() {
|
|
120
171
|
try {
|
|
121
172
|
switch (command) {
|
|
@@ -191,6 +242,9 @@ async function main() {
|
|
|
191
242
|
case "cache":
|
|
192
243
|
await cmdCache(args.slice(1));
|
|
193
244
|
break;
|
|
245
|
+
case "settings":
|
|
246
|
+
await cmdSettings(args.slice(1));
|
|
247
|
+
break;
|
|
194
248
|
case "version":
|
|
195
249
|
case "--version":
|
|
196
250
|
case "-v":
|
|
@@ -480,11 +534,12 @@ async function cmdAdd(argv: string[]) {
|
|
|
480
534
|
const { flags, positional } = parseFlags(argv);
|
|
481
535
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
482
536
|
const { bold, formatPorts, formatNetworks, dim } = await import("./format.ts");
|
|
537
|
+
const { resolveSetting } = await import("../lib/settings.ts");
|
|
483
538
|
|
|
484
539
|
const opts: StartOptions = {
|
|
485
540
|
version: flag(flags, "version"),
|
|
486
|
-
channel: flag(flags, "channel") as Channel | undefined,
|
|
487
|
-
arch: flag(flags, "arch") as Arch | "auto" | undefined,
|
|
541
|
+
channel: (flag(flags, "channel") as Channel | undefined) ?? (resolveSetting("default-channel").value as Channel | undefined),
|
|
542
|
+
arch: (flag(flags, "arch") as Arch | "auto" | undefined) ?? (resolveSetting("default-arch").value as Arch | "auto" | undefined),
|
|
488
543
|
name: flag(flags, "name") ?? positional[0],
|
|
489
544
|
cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
|
|
490
545
|
mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
|
|
@@ -499,7 +554,7 @@ async function cmdAdd(argv: string[]) {
|
|
|
499
554
|
bootDiskFormat: (flag(flags, "boot-disk-format") as StartOptions["bootDiskFormat"] | undefined) ?? "qcow2",
|
|
500
555
|
bootSize: flag(flags, "boot-size"),
|
|
501
556
|
extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
|
|
502
|
-
extraPorts: flagList(flags, "forward").
|
|
557
|
+
extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
|
|
503
558
|
};
|
|
504
559
|
|
|
505
560
|
const deviceModeValue = flag(flags, "device-mode");
|
|
@@ -519,13 +574,7 @@ async function cmdAdd(argv: string[]) {
|
|
|
519
574
|
opts.user = { name: uname, password: password ?? "" };
|
|
520
575
|
}
|
|
521
576
|
opts.disableAdmin = flagBool(flags, "disable-admin");
|
|
522
|
-
|
|
523
|
-
// --secure-login / --no-secure-login
|
|
524
|
-
if (flags["secure-login"] === false) {
|
|
525
|
-
opts.secureLogin = false;
|
|
526
|
-
} else if (flagBool(flags, "secure-login")) {
|
|
527
|
-
opts.secureLogin = true;
|
|
528
|
-
}
|
|
577
|
+
opts.secureLogin = await resolveSecureLoginFlag(flags);
|
|
529
578
|
|
|
530
579
|
const state = await QuickCHR.add(opts);
|
|
531
580
|
|
|
@@ -1125,11 +1174,14 @@ async function cmdSetup() {
|
|
|
1125
1174
|
|
|
1126
1175
|
async function cmdStart(argv: string[]) {
|
|
1127
1176
|
const { flags, positional } = parseFlags(argv);
|
|
1177
|
+
applyTimeoutExtraShortFlag(argv, flags, positional);
|
|
1128
1178
|
|
|
1129
1179
|
// --all: start every stopped machine in background
|
|
1130
1180
|
if (flagBool(flags, "all")) {
|
|
1131
1181
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1132
1182
|
const { statusIcon, link, bold, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
|
|
1183
|
+
const timeoutExtra = await resolveTimeoutExtraMs(flags);
|
|
1184
|
+
const secureLogin = await resolveSecureLoginFlag(flags);
|
|
1133
1185
|
const stopped = QuickCHR.list().filter((m) => m.status !== "running");
|
|
1134
1186
|
if (stopped.length === 0) {
|
|
1135
1187
|
console.log("No stopped instances.");
|
|
@@ -1137,7 +1189,7 @@ async function cmdStart(argv: string[]) {
|
|
|
1137
1189
|
}
|
|
1138
1190
|
for (const m of stopped) {
|
|
1139
1191
|
console.log(`Starting ${bold(m.name)}...`);
|
|
1140
|
-
const instance = await QuickCHR.start({ name: m.name, background: true });
|
|
1192
|
+
const instance = await QuickCHR.start({ name: m.name, background: true, timeoutExtra, secureLogin });
|
|
1141
1193
|
const creds = await resolveDisplayCredentials(instance.state);
|
|
1142
1194
|
console.log(`${statusIcon("running")} ${bold(instance.name)} REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))} SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
|
|
1143
1195
|
}
|
|
@@ -1162,11 +1214,12 @@ async function cmdStart(argv: string[]) {
|
|
|
1162
1214
|
// Build start options from flags
|
|
1163
1215
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1164
1216
|
const { statusIcon, formatPorts, formatNetworks, link, bold, dim, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
|
|
1217
|
+
const { resolveSetting } = await import("../lib/settings.ts");
|
|
1165
1218
|
|
|
1166
1219
|
const opts: StartOptions = {
|
|
1167
1220
|
version: flag(flags, "version"),
|
|
1168
|
-
channel: flag(flags, "channel") as Channel | undefined,
|
|
1169
|
-
arch: flag(flags, "arch") as Arch | "auto" | undefined,
|
|
1221
|
+
channel: (flag(flags, "channel") as Channel | undefined) ?? (resolveSetting("default-channel").value as Channel | undefined),
|
|
1222
|
+
arch: (flag(flags, "arch") as Arch | "auto" | undefined) ?? (resolveSetting("default-arch").value as Arch | "auto" | undefined),
|
|
1170
1223
|
name: flag(flags, "name") ?? positional[0],
|
|
1171
1224
|
cpu: flag(flags, "cpu") ? Number(flag(flags, "cpu")) : undefined,
|
|
1172
1225
|
mem: flag(flags, "mem") ? Number(flag(flags, "mem")) : undefined,
|
|
@@ -1181,10 +1234,10 @@ async function cmdStart(argv: string[]) {
|
|
|
1181
1234
|
bootDiskFormat: (flag(flags, "boot-disk-format") as StartOptions["bootDiskFormat"] | undefined) ?? "qcow2",
|
|
1182
1235
|
bootSize: flag(flags, "boot-size"),
|
|
1183
1236
|
extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
|
|
1184
|
-
extraPorts: flagList(flags, "forward").
|
|
1237
|
+
extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
|
|
1185
1238
|
installDeps: flagBool(flags, "install-deps"),
|
|
1186
1239
|
dryRun: flagBool(flags, "dry-run"),
|
|
1187
|
-
timeoutExtra:
|
|
1240
|
+
timeoutExtra: await resolveTimeoutExtraMs(flags),
|
|
1188
1241
|
};
|
|
1189
1242
|
|
|
1190
1243
|
const deviceModeValue = flag(flags, "device-mode");
|
|
@@ -1227,13 +1280,7 @@ async function cmdStart(argv: string[]) {
|
|
|
1227
1280
|
}
|
|
1228
1281
|
|
|
1229
1282
|
opts.disableAdmin = flagBool(flags, "disable-admin");
|
|
1230
|
-
|
|
1231
|
-
// --secure-login / --no-secure-login
|
|
1232
|
-
if (flags["secure-login"] === false) {
|
|
1233
|
-
opts.secureLogin = false;
|
|
1234
|
-
} else if (flagBool(flags, "secure-login")) {
|
|
1235
|
-
opts.secureLogin = true;
|
|
1236
|
-
}
|
|
1283
|
+
opts.secureLogin = await resolveSecureLoginFlag(flags);
|
|
1237
1284
|
|
|
1238
1285
|
// Background default: true. Explicitly foreground only with --fg / --foreground / --no-background / --no-bg.
|
|
1239
1286
|
const wantFg =
|
|
@@ -2314,7 +2361,7 @@ async function cmdCache(argv: string[]) {
|
|
|
2314
2361
|
return;
|
|
2315
2362
|
}
|
|
2316
2363
|
|
|
2317
|
-
const { listCacheEntries, pruneCache } = await import("../lib/cache.ts");
|
|
2364
|
+
const { listCacheEntries, pruneCache, parseSizeString, formatSizeBytes } = await import("../lib/cache.ts");
|
|
2318
2365
|
|
|
2319
2366
|
const parseDuration = (s: string): number => {
|
|
2320
2367
|
const m = s.match(/^(\d+(?:\.\d+)?)([smhdw])$/);
|
|
@@ -2324,20 +2371,8 @@ async function cmdCache(argv: string[]) {
|
|
|
2324
2371
|
const mult: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
2325
2372
|
return Math.floor(n * (mult[unit ?? ""] ?? 0));
|
|
2326
2373
|
};
|
|
2327
|
-
const parseSize =
|
|
2328
|
-
|
|
2329
|
-
if (!m) throw new Error(`Invalid size "${s}" (use NK/NM/NG/NT, e.g. 2G)`);
|
|
2330
|
-
const n = Number.parseFloat(m[1] ?? "0");
|
|
2331
|
-
const unit = (m[2] ?? "").toUpperCase();
|
|
2332
|
-
const mult: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
|
|
2333
|
-
return Math.floor(n * (mult[unit] ?? 1));
|
|
2334
|
-
};
|
|
2335
|
-
const formatSize = (b: number): string => {
|
|
2336
|
-
if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GiB`;
|
|
2337
|
-
if (b >= 1024 ** 2) return `${(b / 1024 ** 2).toFixed(1)} MiB`;
|
|
2338
|
-
if (b >= 1024) return `${(b / 1024).toFixed(1)} KiB`;
|
|
2339
|
-
return `${b} B`;
|
|
2340
|
-
};
|
|
2374
|
+
const parseSize = parseSizeString;
|
|
2375
|
+
const formatSize = formatSizeBytes;
|
|
2341
2376
|
const formatAge = (ms: number): string => {
|
|
2342
2377
|
const s = Math.floor(ms / 1000);
|
|
2343
2378
|
if (s < 60) return `${s}s`;
|
|
@@ -2429,6 +2464,82 @@ async function cmdCache(argv: string[]) {
|
|
|
2429
2464
|
}
|
|
2430
2465
|
}
|
|
2431
2466
|
|
|
2467
|
+
async function cmdSettings(argv: string[]) {
|
|
2468
|
+
const { flags, positional } = parseFlags(argv);
|
|
2469
|
+
const sub = positional[0] ?? "print";
|
|
2470
|
+
|
|
2471
|
+
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
2472
|
+
printCommandHelp("settings");
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
const { settingsFilePath, settingsGet, settingsSet, settingsReset, settingsPrint } = await import("../lib/settings.ts");
|
|
2477
|
+
const { formatSizeBytes } = await import("../lib/cache.ts");
|
|
2478
|
+
const asJson = flags.json === true;
|
|
2479
|
+
|
|
2480
|
+
const displayValue = (key: string, value: unknown): string => {
|
|
2481
|
+
if (value === undefined) return "(unset)";
|
|
2482
|
+
if (key === "cache-max-size" && typeof value === "number") return formatSizeBytes(value);
|
|
2483
|
+
if (key === "timeout-extra" && typeof value === "number") return `${value}s`;
|
|
2484
|
+
return String(value);
|
|
2485
|
+
};
|
|
2486
|
+
|
|
2487
|
+
switch (sub) {
|
|
2488
|
+
case "print": {
|
|
2489
|
+
const key = positional[1];
|
|
2490
|
+
// All-keys print uses the tolerant settingsPrint() — a single malformed
|
|
2491
|
+
// env/file value must not crash the whole table (settingsGet, used for a
|
|
2492
|
+
// single named key below, is intentionally strict).
|
|
2493
|
+
const entries = key ? [settingsGet(key)] : settingsPrint();
|
|
2494
|
+
if (asJson) {
|
|
2495
|
+
console.log(JSON.stringify({ file: settingsFilePath(), settings: entries }, null, 2));
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
console.log(`Settings file: ${settingsFilePath()}\n`);
|
|
2499
|
+
for (const e of entries) {
|
|
2500
|
+
console.log(`${e.key.padEnd(16)} ${displayValue(e.key, e.value).padEnd(12)} [${e.source}]`);
|
|
2501
|
+
}
|
|
2502
|
+
break;
|
|
2503
|
+
}
|
|
2504
|
+
case "get": {
|
|
2505
|
+
const key = positional[1];
|
|
2506
|
+
if (!key) {
|
|
2507
|
+
console.error("Usage: quickchr settings get <key>");
|
|
2508
|
+
process.exit(1);
|
|
2509
|
+
}
|
|
2510
|
+
const entry = settingsGet(key);
|
|
2511
|
+
console.log(asJson ? JSON.stringify(entry, null, 2) : displayValue(entry.key, entry.value));
|
|
2512
|
+
break;
|
|
2513
|
+
}
|
|
2514
|
+
case "set": {
|
|
2515
|
+
const key = positional[1];
|
|
2516
|
+
const value = positional[2];
|
|
2517
|
+
if (!key || value === undefined) {
|
|
2518
|
+
console.error("Usage: quickchr settings set <key> <value>");
|
|
2519
|
+
process.exit(1);
|
|
2520
|
+
}
|
|
2521
|
+
const result = settingsSet(key, value);
|
|
2522
|
+
console.log(`${result.key}: ${displayValue(result.key, result.previous)} -> ${displayValue(result.key, result.value)}`);
|
|
2523
|
+
break;
|
|
2524
|
+
}
|
|
2525
|
+
case "reset": {
|
|
2526
|
+
const key = positional[1];
|
|
2527
|
+
const result = settingsReset(key);
|
|
2528
|
+
console.log(
|
|
2529
|
+
result.cleared.length === 0
|
|
2530
|
+
? key
|
|
2531
|
+
? `${key} was not set.`
|
|
2532
|
+
: "Nothing to clear."
|
|
2533
|
+
: `Cleared: ${result.cleared.join(", ")}`,
|
|
2534
|
+
);
|
|
2535
|
+
break;
|
|
2536
|
+
}
|
|
2537
|
+
default:
|
|
2538
|
+
console.error(`Unknown settings subcommand: ${sub}\nRun 'quickchr help settings' for usage.`);
|
|
2539
|
+
process.exit(1);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2432
2543
|
async function cmdDoctor(argv: string[] = []) {
|
|
2433
2544
|
const { flags } = parseFlags(argv);
|
|
2434
2545
|
const asJson = flagBool(flags, "json");
|
|
@@ -2557,6 +2668,7 @@ Commands:
|
|
|
2557
2668
|
snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
|
|
2558
2669
|
networks Network discovery & socket management
|
|
2559
2670
|
cache [list|prune|clear] Manage cached CHR images
|
|
2671
|
+
settings [print|get|set|reset] Manage quickchr's own global settings (quickchr.env)
|
|
2560
2672
|
completions Manage shell completions (Tab completion)
|
|
2561
2673
|
logs <name> Tail the QEMU log for an instance
|
|
2562
2674
|
doctor [--json] Check prerequisites
|
|
@@ -2600,10 +2712,13 @@ Options:
|
|
|
2600
2712
|
--add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
|
|
2601
2713
|
--add-package <pkg> Extra package to install on first boot (repeatable)
|
|
2602
2714
|
--forward <spec> Add an extra hostfwd port (repeatable). Spec:
|
|
2603
|
-
name[:host[:guest]][/proto]
|
|
2715
|
+
name[:host[:guest]][/proto], or a port range
|
|
2716
|
+
name:hostStart-hostEnd[:guestStart-guestEnd][/proto].
|
|
2717
|
+
Examples:
|
|
2604
2718
|
--forward smb (host auto, guest 445/tcp from registry)
|
|
2605
2719
|
--forward winbox:9300 (host pinned, guest from registry)
|
|
2606
2720
|
--forward myapp:9200:7777/udp (fully explicit)
|
|
2721
|
+
--forward btest:9200-9210:2000-2010/udp (range, e.g. dynamic ports)
|
|
2607
2722
|
--install-all-packages Install all packages on first boot
|
|
2608
2723
|
--add-user <u:p> Create user on first boot (name:password)
|
|
2609
2724
|
--disable-admin Disable the default admin account on first boot
|
|
@@ -2679,8 +2794,10 @@ Options:
|
|
|
2679
2794
|
--add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
|
|
2680
2795
|
--add-package <pkg> Extra package to install (repeatable)
|
|
2681
2796
|
--forward <spec> Add an extra hostfwd port (repeatable). Spec:
|
|
2682
|
-
name[:host[:guest]][/proto]
|
|
2683
|
-
|
|
2797
|
+
name[:host[:guest]][/proto], or a range
|
|
2798
|
+
name:hostStart-hostEnd[:guestStart-guestEnd][/proto].
|
|
2799
|
+
Examples: smb, winbox:9300, myapp:9200:7777/udp,
|
|
2800
|
+
btest:9200-9210:2000-2010/udp.
|
|
2684
2801
|
--add-user <u:p> Create user with name:password
|
|
2685
2802
|
--disable-admin Disable the default admin account
|
|
2686
2803
|
--no-secure-login Keep admin with no password (skip managed account)
|
|
@@ -2912,7 +3029,39 @@ Subcommands:
|
|
|
2912
3029
|
|
|
2913
3030
|
Duration: <N>(s|m|h|d|w) Size: <N>(K|M|G|T)[i][B] (case-insensitive, IEC binary).
|
|
2914
3031
|
|
|
2915
|
-
Default auto-prune cap: 2 GiB
|
|
3032
|
+
Default auto-prune cap: 2 GiB — override via 'quickchr settings set cache-max-size <size>'
|
|
3033
|
+
or the QUICKCHR_CACHE_MAX_SIZE env var (applied after each successful start).`);
|
|
3034
|
+
break;
|
|
3035
|
+
case "settings":
|
|
3036
|
+
console.log(`quickchr settings [print|get|set|reset] [key] [value] [--json]
|
|
3037
|
+
|
|
3038
|
+
Manage quickchr's own global preferences, stored in
|
|
3039
|
+
~/.config/quickchr/quickchr.env (dotenv-style: QUICKCHR_KEY=value lines).
|
|
3040
|
+
|
|
3041
|
+
Managed keys:
|
|
3042
|
+
default-channel Default --channel for 'add'/'start' and the setup wizard.
|
|
3043
|
+
stable|long-term|testing|development (default: stable)
|
|
3044
|
+
default-arch Default --arch. arm64|x86|auto (default: auto = host native)
|
|
3045
|
+
cache-max-size Auto-prune cap applied after each successful start.
|
|
3046
|
+
Size string (e.g. 2G, 512M). (default: 2G)
|
|
3047
|
+
timeout-extra Default for 'start's --timeout-extra/-T when omitted.
|
|
3048
|
+
Non-negative integer seconds. (default: 0)
|
|
3049
|
+
secure-login Default for 'add'/'start's --secure-login when neither
|
|
3050
|
+
--secure-login nor --no-secure-login is passed.
|
|
3051
|
+
true|false (default: false — 'settings print' shows this
|
|
3052
|
+
one as "(unset)" rather than "false"; the setup wizard
|
|
3053
|
+
needs to tell "not configured" apart from "explicitly
|
|
3054
|
+
false" to know whether to still recommend managed login)
|
|
3055
|
+
|
|
3056
|
+
Precedence (highest wins): CLI flag > QUICKCHR_<KEY> env var > quickchr.env > built-in default.
|
|
3057
|
+
|
|
3058
|
+
Subcommands:
|
|
3059
|
+
print [key] [--json] Show resolved value + source for one/all keys.
|
|
3060
|
+
get <key> [--json] Print one key's resolved value.
|
|
3061
|
+
set <key> <value> Validate and write a value to quickchr.env.
|
|
3062
|
+
reset [key] Delete one managed line, or all managed lines.
|
|
3063
|
+
|
|
3064
|
+
Credentials (--add-user, MIKROTIK_WEB_ACCOUNT/PASSWORD) are never stored here.`);
|
|
2916
3065
|
break;
|
|
2917
3066
|
default:
|
|
2918
3067
|
console.log(`No detailed help for '${command}'.`);
|
package/src/cli/wizard.ts
CHANGED
|
@@ -5,12 +5,43 @@
|
|
|
5
5
|
import type { Arch, Channel, DeviceModeOptions, LicenseLevel, LicenseOptions, NetworkSpecifier, StartOptions } from "../lib/types.ts";
|
|
6
6
|
import { CHANNELS, ARCHES, knownPackagesForArch } from "../lib/types.ts";
|
|
7
7
|
|
|
8
|
+
export type WizardUserChoice = "custom" | "managed" | "admin";
|
|
9
|
+
|
|
10
|
+
export interface WizardUserChoiceResult {
|
|
11
|
+
user?: { name: string; password: string };
|
|
12
|
+
disableAdmin: boolean;
|
|
13
|
+
secureLogin?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Maps the wizard's "CHR login setup" choice to StartOptions fields. Pulled out as a
|
|
18
|
+
* pure function (rather than inlined in runWizard) so it's unit-testable without
|
|
19
|
+
* mocking the entire @clack/prompts prompt sequence — "managed" must set both
|
|
20
|
+
* disableAdmin AND secureLogin, since provision() only auto-creates the replacement
|
|
21
|
+
* quickchr account when secureLogin === true (src/lib/provision.ts); disableAdmin
|
|
22
|
+
* alone would disable the default account with no replacement login provisioned.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveUserChoiceOptions(
|
|
25
|
+
userChoice: WizardUserChoice,
|
|
26
|
+
customUser?: { name: string; password: string },
|
|
27
|
+
): WizardUserChoiceResult {
|
|
28
|
+
if (userChoice === "custom") {
|
|
29
|
+
return { user: customUser, disableAdmin: true };
|
|
30
|
+
}
|
|
31
|
+
if (userChoice === "managed") {
|
|
32
|
+
return { disableAdmin: true, secureLogin: true };
|
|
33
|
+
}
|
|
34
|
+
// "admin" — keep admin with no password
|
|
35
|
+
return { disableAdmin: false, secureLogin: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
8
38
|
/** Run the interactive wizard using @clack/prompts. */
|
|
9
39
|
export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<void> {
|
|
10
40
|
// Dynamic import — only loaded when wizard is actually used
|
|
11
41
|
const clack = await import("@clack/prompts");
|
|
12
42
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
13
43
|
const { formatPorts, bold } = await import("./format.ts");
|
|
44
|
+
const { resolveSetting } = await import("../lib/settings.ts");
|
|
14
45
|
|
|
15
46
|
clack.intro("quickchr — MikroTik CHR Manager");
|
|
16
47
|
if (wizardOpts?.firstRun) {
|
|
@@ -31,6 +62,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
31
62
|
let channel: Channel | undefined;
|
|
32
63
|
|
|
33
64
|
if (versionSource === "channel") {
|
|
65
|
+
const defaultChannel = resolveSetting("default-channel").value as Channel;
|
|
34
66
|
const ch = await clack.select({
|
|
35
67
|
message: "Select channel:",
|
|
36
68
|
options: CHANNELS.map((c) => ({
|
|
@@ -38,7 +70,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
38
70
|
label: c,
|
|
39
71
|
hint: c === "long-term" ? "recommended for provisioning" : undefined,
|
|
40
72
|
})),
|
|
41
|
-
initialValue:
|
|
73
|
+
initialValue: defaultChannel,
|
|
42
74
|
});
|
|
43
75
|
if (clack.isCancel(ch)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
44
76
|
channel = ch;
|
|
@@ -57,6 +89,8 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
57
89
|
|
|
58
90
|
// 2. Architecture
|
|
59
91
|
const hostArch = process.arch === "arm64" ? "arm64" : "x86";
|
|
92
|
+
const settingArch = resolveSetting("default-arch").value as Arch | "auto";
|
|
93
|
+
const archInitial: Arch = settingArch === "auto" ? hostArch : settingArch;
|
|
60
94
|
const arch = await clack.select({
|
|
61
95
|
message: "Architecture:",
|
|
62
96
|
options: ARCHES.map((a) => ({
|
|
@@ -64,7 +98,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
64
98
|
label: a,
|
|
65
99
|
hint: a === hostArch ? "host native" : "emulated (slower)",
|
|
66
100
|
})),
|
|
67
|
-
initialValue:
|
|
101
|
+
initialValue: archInitial,
|
|
68
102
|
});
|
|
69
103
|
if (clack.isCancel(arch)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
70
104
|
|
|
@@ -304,7 +338,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
304
338
|
} = await import("../lib/versions.ts");
|
|
305
339
|
let resolvedVersion = version ?? await resolveVersion(channel ?? "long-term");
|
|
306
340
|
version = resolvedVersion;
|
|
307
|
-
|
|
341
|
+
const provisioningSupported = isProvisioningSupportedVersion(resolvedVersion);
|
|
308
342
|
|
|
309
343
|
let installAllPackages = false;
|
|
310
344
|
let packages: string[] = [];
|
|
@@ -346,7 +380,6 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
346
380
|
channel = "long-term";
|
|
347
381
|
resolvedVersion = await resolveVersion(channel);
|
|
348
382
|
version = resolvedVersion;
|
|
349
|
-
provisioningSupported = true;
|
|
350
383
|
wantProvision = await askProvisionChoice();
|
|
351
384
|
}
|
|
352
385
|
}
|
|
@@ -428,6 +461,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
428
461
|
}
|
|
429
462
|
|
|
430
463
|
// 6c. User setup
|
|
464
|
+
const secureLoginSetting = resolveSetting("secure-login").value;
|
|
431
465
|
const userChoice = await clack.select({
|
|
432
466
|
message: "CHR login setup:",
|
|
433
467
|
options: [
|
|
@@ -435,10 +469,11 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
435
469
|
{ value: "custom", label: "Custom user", hint: "you provide username + password" },
|
|
436
470
|
{ value: "admin", label: "Keep admin with no password", hint: "less secure" },
|
|
437
471
|
],
|
|
438
|
-
initialValue: "managed",
|
|
472
|
+
initialValue: secureLoginSetting === false ? "admin" : "managed",
|
|
439
473
|
});
|
|
440
474
|
if (clack.isCancel(userChoice)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
441
475
|
|
|
476
|
+
let customUser: { name: string; password: string } | undefined;
|
|
442
477
|
if (userChoice === "custom") {
|
|
443
478
|
const userName = await clack.text({
|
|
444
479
|
message: "Username:",
|
|
@@ -449,14 +484,9 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
449
484
|
const userPass = await clack.password({ message: "Password:" });
|
|
450
485
|
if (clack.isCancel(userPass)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
451
486
|
|
|
452
|
-
|
|
453
|
-
disableAdmin = true;
|
|
454
|
-
} else if (userChoice === "managed") {
|
|
455
|
-
disableAdmin = true;
|
|
456
|
-
} else {
|
|
457
|
-
// "admin" — keep admin with no password
|
|
458
|
-
secureLogin = false;
|
|
487
|
+
customUser = { name: userName, password: userPass };
|
|
459
488
|
}
|
|
489
|
+
({ user, disableAdmin, secureLogin } = resolveUserChoiceOptions(userChoice as WizardUserChoice, customUser));
|
|
460
490
|
|
|
461
491
|
// 6d. License
|
|
462
492
|
const wantLicense = await clack.confirm({
|
package/src/index.ts
CHANGED
|
@@ -125,7 +125,7 @@ export { WELL_KNOWN_GUEST_PORTS, lookupGuestPort } from "./lib/guest-ports.ts";
|
|
|
125
125
|
export type { GuestPortDef } from "./lib/guest-ports.ts";
|
|
126
126
|
|
|
127
127
|
// Forward spec parsing
|
|
128
|
-
export { parseForwardSpec } from "./lib/forward-spec.ts";
|
|
128
|
+
export { parseForwardSpec, expandForwardSpec, FORWARD_RANGE_MAX } from "./lib/forward-spec.ts";
|
|
129
129
|
|
|
130
130
|
// Logging
|
|
131
131
|
export { createLogger } from "./lib/log.ts";
|
package/src/lib/cache.ts
CHANGED
|
@@ -11,10 +11,28 @@ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { getCacheDir, loadAllMachines } from "./state.ts";
|
|
13
13
|
import { chrImageBasename, compareRouterOsVersion, isValidVersion } from "./versions.ts";
|
|
14
|
-
import type
|
|
14
|
+
import { QuickCHRError, type Arch } from "./types.ts";
|
|
15
15
|
|
|
16
16
|
export const DEFAULT_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
17
17
|
|
|
18
|
+
/** Parse a human size string (NK/NM/NG/NT, IEC binary, case-insensitive) to bytes. */
|
|
19
|
+
export function parseSizeString(s: string): number {
|
|
20
|
+
const m = s.match(/^(\d+(?:\.\d+)?)([KMGT]?)i?B?$/i);
|
|
21
|
+
if (!m) throw new QuickCHRError("INVALID_SIZE_STRING", `Invalid size "${s}" (use NK/NM/NG/NT, e.g. 2G)`);
|
|
22
|
+
const n = Number.parseFloat(m[1] ?? "0");
|
|
23
|
+
const unit = (m[2] ?? "").toUpperCase();
|
|
24
|
+
const mult: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
|
|
25
|
+
return Math.floor(n * (mult[unit] ?? 1));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Format a byte count as a human size (IEC binary), matching cache list's display. */
|
|
29
|
+
export function formatSizeBytes(b: number): string {
|
|
30
|
+
if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GiB`;
|
|
31
|
+
if (b >= 1024 ** 2) return `${(b / 1024 ** 2).toFixed(1)} MiB`;
|
|
32
|
+
if (b >= 1024) return `${(b / 1024).toFixed(1)} KiB`;
|
|
33
|
+
return `${b} B`;
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
export interface CacheEntry {
|
|
19
37
|
path: string;
|
|
20
38
|
basename: string;
|