@tikoci/quickchr 0.4.1 → 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/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.
@@ -331,6 +348,25 @@ console.log(chr.state.bootDiskFormat); // "qcow2"
331
348
  console.log(chr.state.extraDisks); // ["512M", "2G"]
332
349
  ```
333
350
 
351
+ ### Version & channel API
352
+
353
+ Pick which CHRs are worth booting (handy for CI matrices) without scraping CLI text:
354
+
355
+ ```typescript
356
+ import { resolveActiveChannels, resolveAllVersions } from "@tikoci/quickchr";
357
+
358
+ await resolveAllVersions();
359
+ // { stable: "7.23.1", "long-term": "7.21.4", testing: "7.23rc4", development: "7.24beta2" }
360
+
361
+ await resolveActiveChannels();
362
+ // ["stable", "long-term", "development"]
363
+ // released channels always, plus any pre-release at/ahead of stable
364
+ ```
365
+
366
+ `compareRouterOsVersion` orders pre-release suffixes (`7.24beta2` < `7.24rc1` <
367
+ `7.24` < `7.24.1`). The same map is available from the CLI via
368
+ `quickchr version --json`. See [MANUAL.md](./MANUAL.md) → *Version & channel helpers*.
369
+
334
370
  ### Use in Tests
335
371
 
336
372
  ```typescript
@@ -343,7 +379,7 @@ test("boot CHR and check version", async () => {
343
379
  chr = await QuickCHR.start({ channel: "stable" });
344
380
 
345
381
  const resource = await chr.rest("/system/resource");
346
- expect(resource["board-name"]).toBe("CHR");
382
+ expect(resource["board-name"]).toContain("CHR");
347
383
  }, 120_000);
348
384
 
349
385
  afterAll(async () => {
@@ -357,17 +393,34 @@ afterAll(async () => {
357
393
  |----------|-------------|
358
394
  | `MIKROTIK_WEB_ACCOUNT` | MikroTik.com account email (for license renewal) |
359
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) |
360
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).
361
402
 
362
403
  ## Documentation
363
404
 
364
405
  - **[MANUAL.md](./MANUAL.md)** — complete reference: every CLI command,
365
406
  every library API, provisioning, channels, networking, storage,
366
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.
367
415
  - **[DESIGN.md](./DESIGN.md)** — architecture, layers, design principles.
368
416
  - **[CONTRIBUTING.md](./CONTRIBUTING.md)** — dev setup and `bun run check`.
369
417
  - **[CHANGELOG.md](./CHANGELOG.md)** — release history.
370
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
+
371
424
  ## License
372
425
 
373
426
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.4.1",
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 { parseForwardSpec } from "../lib/forward-spec.ts";
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) {
@@ -179,7 +230,7 @@ async function main() {
179
230
  await cmdCompletions(args.slice(1));
180
231
  break;
181
232
  case "doctor":
182
- await cmdDoctor();
233
+ await cmdDoctor(args.slice(1));
183
234
  break;
184
235
  case "logs":
185
236
  await cmdLogs(args.slice(1));
@@ -191,10 +242,13 @@ 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":
197
- await cmdVersion();
251
+ await cmdVersion(args.slice(1));
198
252
  break;
199
253
  case "help":
200
254
  case "--help":
@@ -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").map(parseForwardSpec),
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").map(parseForwardSpec),
1237
+ extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
1185
1238
  installDeps: flagBool(flags, "install-deps"),
1186
1239
  dryRun: flagBool(flags, "dry-run"),
1187
- timeoutExtra: parseInt(flag(flags, "timeout-extra") ?? flag(flags, "T") ?? "0", 10) * 1000 || undefined,
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 = (s: string): number => {
2328
- const m = s.match(/^(\d+(?:\.\d+)?)([KMGT]?)i?B?$/i);
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,38 +2464,113 @@ async function cmdCache(argv: string[]) {
2429
2464
  }
2430
2465
  }
2431
2466
 
2432
- async function cmdDoctor() {
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
+
2543
+ async function cmdDoctor(argv: string[] = []) {
2544
+ const { flags } = parseFlags(argv);
2545
+ const asJson = flagBool(flags, "json");
2433
2546
  const { QuickCHR } = await import("../lib/quickchr.ts");
2434
- const { statusIcon, bold } = await import("./format.ts");
2435
2547
 
2436
2548
  const result = await QuickCHR.doctor();
2437
2549
 
2550
+ if (asJson) {
2551
+ // Best-effort: cache entries older than current long-term (offline → empty).
2552
+ const staleImages = await findStaleCacheImages();
2553
+ console.log(JSON.stringify({ ok: result.ok, checks: result.checks, staleImages }, null, 2));
2554
+ if (!result.ok) process.exit(1);
2555
+ return;
2556
+ }
2557
+
2558
+ const { statusIcon, bold } = await import("./format.ts");
2438
2559
  console.log(bold("quickchr doctor\n"));
2439
2560
  for (const check of result.checks) {
2440
2561
  console.log(`${statusIcon(check.status)} ${check.label}: ${check.detail}`);
2441
2562
  }
2442
-
2443
2563
  console.log();
2444
2564
 
2445
- // Best-effort: warn about cache entries older than current long-term.
2446
- try {
2447
- const { listCacheEntries } = await import("../lib/cache.ts");
2448
- const { resolveVersion, compareRouterOsVersion } = await import("../lib/versions.ts");
2449
- const entries = listCacheEntries();
2450
- if (entries.length > 0) {
2451
- const longTerm = await resolveVersion("long-term");
2452
- const stale = entries.filter((e) => {
2453
- if (e.version === "unknown" || e.inUse) return false;
2454
- try { return compareRouterOsVersion(e.version, longTerm) < 0; } catch { return false; }
2455
- });
2456
- if (stale.length > 0) {
2457
- console.log(`Note: ${stale.length} cached image(s) older than current long-term (${longTerm}):`);
2458
- for (const e of stale) console.log(` - ${e.basename} (${e.version})`);
2459
- console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
2460
- console.log();
2461
- }
2462
- }
2463
- } catch { /* offline or no cache — silent */ }
2565
+ // Best-effort, after the checks print so the network lookup never delays them.
2566
+ const staleImages = await findStaleCacheImages();
2567
+ if (staleImages.length > 0) {
2568
+ const longTerm = staleImages[0]?.olderThan;
2569
+ console.log(`Note: ${staleImages.length} cached image(s) older than current long-term (${longTerm}):`);
2570
+ for (const e of staleImages) console.log(` - ${e.basename} (${e.version})`);
2571
+ console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
2572
+ console.log();
2573
+ }
2464
2574
 
2465
2575
  if (result.ok) {
2466
2576
  console.log("All checks passed.");
@@ -2470,12 +2580,50 @@ async function cmdDoctor() {
2470
2580
  }
2471
2581
  }
2472
2582
 
2473
- async function cmdVersion() {
2583
+ interface StaleCacheImage {
2584
+ basename: string;
2585
+ version: string;
2586
+ /** The current long-term version this image is older than. */
2587
+ olderThan: string;
2588
+ }
2589
+
2590
+ /** Cached images older than the current long-term release. Empty when offline or no cache. */
2591
+ async function findStaleCacheImages(): Promise<StaleCacheImage[]> {
2592
+ try {
2593
+ const { listCacheEntries } = await import("../lib/cache.ts");
2594
+ const { resolveVersion, compareRouterOsVersion } = await import("../lib/versions.ts");
2595
+ const entries = listCacheEntries();
2596
+ if (entries.length === 0) return [];
2597
+ const longTerm = await resolveVersion("long-term");
2598
+ return entries
2599
+ .filter((e) => {
2600
+ if (e.version === "unknown" || e.inUse) return false;
2601
+ try { return compareRouterOsVersion(e.version, longTerm) < 0; } catch { return false; }
2602
+ })
2603
+ .map((e) => ({ basename: e.basename, version: e.version, olderThan: longTerm }));
2604
+ } catch {
2605
+ return []; // offline or no cache
2606
+ }
2607
+ }
2608
+
2609
+ async function cmdVersion(argv: string[] = []) {
2610
+ const { flags } = parseFlags(argv);
2611
+ const asJson = flagBool(flags, "json");
2612
+ const { resolveAllVersions } = await import("../lib/versions.ts");
2613
+
2614
+ if (asJson) {
2615
+ // Stable machine-readable contract: { channel: version }. Offline → {}.
2616
+ let versions: Record<string, string> = {};
2617
+ try {
2618
+ versions = await resolveAllVersions();
2619
+ } catch { /* offline — emit empty object */ }
2620
+ console.log(JSON.stringify(versions));
2621
+ return;
2622
+ }
2623
+
2474
2624
  const pkg = await import("../../package.json");
2475
2625
  console.log(`quickchr ${pkg.version}`);
2476
-
2477
2626
  try {
2478
- const { resolveAllVersions } = await import("../lib/versions.ts");
2479
2627
  const versions = await resolveAllVersions();
2480
2628
  console.log("\nLatest RouterOS versions:");
2481
2629
  for (const [channel, version] of Object.entries(versions)) {
@@ -2520,10 +2668,11 @@ Commands:
2520
2668
  snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
2521
2669
  networks Network discovery & socket management
2522
2670
  cache [list|prune|clear] Manage cached CHR images
2671
+ settings [print|get|set|reset] Manage quickchr's own global settings (quickchr.env)
2523
2672
  completions Manage shell completions (Tab completion)
2524
2673
  logs <name> Tail the QEMU log for an instance
2525
- doctor Check prerequisites
2526
- version Show version info
2674
+ doctor [--json] Check prerequisites
2675
+ version [--json] Show version info
2527
2676
  help [command] Show help
2528
2677
 
2529
2678
  Environment:
@@ -2563,10 +2712,13 @@ Options:
2563
2712
  --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
2564
2713
  --add-package <pkg> Extra package to install on first boot (repeatable)
2565
2714
  --forward <spec> Add an extra hostfwd port (repeatable). Spec:
2566
- name[:host[:guest]][/proto]. Examples:
2715
+ name[:host[:guest]][/proto], or a port range
2716
+ name:hostStart-hostEnd[:guestStart-guestEnd][/proto].
2717
+ Examples:
2567
2718
  --forward smb (host auto, guest 445/tcp from registry)
2568
2719
  --forward winbox:9300 (host pinned, guest from registry)
2569
2720
  --forward myapp:9200:7777/udp (fully explicit)
2721
+ --forward btest:9200-9210:2000-2010/udp (range, e.g. dynamic ports)
2570
2722
  --install-all-packages Install all packages on first boot
2571
2723
  --add-user <u:p> Create user on first boot (name:password)
2572
2724
  --disable-admin Disable the default admin account on first boot
@@ -2642,8 +2794,10 @@ Options:
2642
2794
  --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
2643
2795
  --add-package <pkg> Extra package to install (repeatable)
2644
2796
  --forward <spec> Add an extra hostfwd port (repeatable). Spec:
2645
- name[:host[:guest]][/proto]. Examples: smb,
2646
- winbox:9300, myapp:9200:7777/udp.
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.
2647
2801
  --add-user <u:p> Create user with name:password
2648
2802
  --disable-admin Disable the default admin account
2649
2803
  --no-secure-login Keep admin with no password (skip managed account)
@@ -2707,11 +2861,14 @@ Print environment variables for subprocesses that connect to a running CHR.
2707
2861
  --json Output the env map as JSON instead of KEY=value lines.`);
2708
2862
  break;
2709
2863
  case "doctor":
2710
- console.log(`quickchr doctor
2864
+ console.log(`quickchr doctor [--json]
2711
2865
 
2712
2866
  Check system prerequisites: QEMU binaries, firmware, acceleration,
2713
2867
  sshpass (for package upload), qemu-img (for disk operations), data directories,
2714
- and cached images. Also reports the current shell and completion install status.`);
2868
+ and cached images. Also reports the current shell and completion install status.
2869
+
2870
+ Options:
2871
+ --json Emit { ok, checks, staleImages } as JSON. Exit code still reflects ok.`);
2715
2872
  break;
2716
2873
  case "logs":
2717
2874
  console.log(`quickchr logs <name> [options]
@@ -2842,6 +2999,16 @@ Credential resolution order (highest priority first):
2842
2999
  Omit to see a list of instances.
2843
3000
  --all Remove all instances.`);
2844
3001
  break;
3002
+ case "version":
3003
+ console.log(`quickchr version [--json]
3004
+
3005
+ Show the quickchr version and the latest RouterOS version per channel.
3006
+
3007
+ Options:
3008
+ --json Emit the latest RouterOS versions as a { channel: version } object
3009
+ (stable, long-term, testing, development). Offline emits {}.
3010
+ Omits the quickchr version line — query package.json for that.`);
3011
+ break;
2845
3012
  case "networks":
2846
3013
  case "net":
2847
3014
  showNetworksHelp();
@@ -2862,7 +3029,39 @@ Subcommands:
2862
3029
 
2863
3030
  Duration: <N>(s|m|h|d|w) Size: <N>(K|M|G|T)[i][B] (case-insensitive, IEC binary).
2864
3031
 
2865
- Default auto-prune cap: 2 GiB (applied after each successful start).`);
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.`);
2866
3065
  break;
2867
3066
  default:
2868
3067
  console.log(`No detailed help for '${command}'.`);