@tikoci/quickchr 0.4.7 → 0.4.8
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 +366 -5
- package/MANUAL.md +199 -14
- package/README.md +29 -2
- package/package.json +4 -2
- package/src/cli/flags.ts +194 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +258 -34
- package/src/cli/tips.ts +57 -0
- package/src/index.ts +10 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +17 -3
- package/src/lib/images.ts +53 -22
- package/src/lib/names.ts +72 -0
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +39 -12
- package/src/lib/quickchr.ts +248 -40
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +29 -5
package/MANUAL.md
CHANGED
|
@@ -152,8 +152,9 @@ directory, downloads the RouterOS image (cached), and writes
|
|
|
152
152
|
| `--no-api-ssl` | false | Exclude API-SSL port |
|
|
153
153
|
| `--port-base <n>` | auto from 9100 | First port of the 10-port block |
|
|
154
154
|
|
|
155
|
-
`--vmnet-shared` and `--vmnet-bridge <iface>`
|
|
156
|
-
`--add-network shared`
|
|
155
|
+
`--vmnet-shared` and `--vmnet-bridge <iface>` have been removed. Use
|
|
156
|
+
`--add-network shared` and `--add-network bridged:<iface>`; either old spelling is
|
|
157
|
+
an error naming its replacement, not a silently ignored flag.
|
|
157
158
|
|
|
158
159
|
#### `start [name]`
|
|
159
160
|
|
|
@@ -219,6 +220,19 @@ Without a name: tabular summary of every machine. With a name: detailed
|
|
|
219
220
|
view including credentials and connection tips. `--json` for
|
|
220
221
|
machine-readable output.
|
|
221
222
|
|
|
223
|
+
A machine whose `machine.json` cannot be read — unreadable on disk, not valid JSON, or
|
|
224
|
+
valid JSON that is not usable machine state — still appears, as a row marked
|
|
225
|
+
`unreadable` followed by `quickchr remove <name>`. A `saveMachine()` interrupted by a
|
|
226
|
+
disk-full or a power loss is the usual cause. The listing exits 0: it succeeded, and the
|
|
227
|
+
broken entry is named rather than silently dropped. In `--json` that entry is
|
|
228
|
+
`{"name": …, "status": "unreadable", "error": …}` with no machine fields.
|
|
229
|
+
|
|
230
|
+
Naming that machine in a command that has to *use* its state — `list <name>`, `stop`,
|
|
231
|
+
`clean`, `env` — fails with `STATE_ERROR` naming the file, because a lookup you asked
|
|
232
|
+
for by name is not an enumeration. `quickchr remove <name>` is the exception, and has to
|
|
233
|
+
be: it is the command whose job is to clear that state, so it treats an unreadable
|
|
234
|
+
machine as a directory to delete rather than state to load.
|
|
235
|
+
|
|
222
236
|
#### `get <name> [license | device-mode | admin] [--json]`
|
|
223
237
|
|
|
224
238
|
Live REST query. Hits the running CHR and returns the requested group
|
|
@@ -265,8 +279,9 @@ Tail the per-machine `qemu.log`. `--follow` streams new output;
|
|
|
265
279
|
|
|
266
280
|
`networks` enumerates host interfaces and their alias resolution
|
|
267
281
|
(`wifi`, `ethernet`, `auto`). `networks sockets` lists registered
|
|
268
|
-
named L2 sockets; `networks sockets create <name>`
|
|
269
|
-
new
|
|
282
|
+
named L2 sockets and the transport each one uses; `networks sockets create <name>`
|
|
283
|
+
registers a new link, taking `--mode` to pick that transport (the default needs no
|
|
284
|
+
port at all).
|
|
270
285
|
|
|
271
286
|
#### `version [--json]`
|
|
272
287
|
|
|
@@ -392,6 +407,43 @@ shell completions if not yet configured.
|
|
|
392
407
|
|
|
393
408
|
Print or install shell completions. With no arg, detects current shell.
|
|
394
409
|
|
|
410
|
+
#### `cache [add|key|list|prune|clear]`
|
|
411
|
+
|
|
412
|
+
Manage downloaded CHR images. `cache add` resolves and downloads an image only;
|
|
413
|
+
it does not check QEMU, create a machine, or boot RouterOS. A cache hit is an
|
|
414
|
+
idempotent success.
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
quickchr cache add --channel stable --arch x86
|
|
418
|
+
quickchr cache add --version 7.24.4 --arch arm64 --json
|
|
419
|
+
quickchr cache key --channel stable --arch x86
|
|
420
|
+
# dir=/Users/me/.local/share/quickchr/cache
|
|
421
|
+
# version=7.24.4
|
|
422
|
+
# arch=x86
|
|
423
|
+
quickchr cache key --version 7.24.4 --arch x86 --json
|
|
424
|
+
# {"dir":".../quickchr/cache","version":"7.24.4","arch":"x86"}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
`--channel` and `--version` are mutually exclusive. When neither is supplied,
|
|
428
|
+
`default-channel` applies; both subcommands use `default-arch`. A pinned
|
|
429
|
+
version makes no network request in `cache key`. If channel resolution is
|
|
430
|
+
offline, `cache key` prints the stable `unresolved` sentinel and exits 0 so a CI
|
|
431
|
+
cache lookup cannot red the job; `cache add` fails because it cannot download an
|
|
432
|
+
unknown image. Without `--json`, `cache key` emits `key=value` lines that can be
|
|
433
|
+
appended directly to `$GITHUB_OUTPUT`.
|
|
434
|
+
|
|
435
|
+
```yaml
|
|
436
|
+
- id: chr-cache
|
|
437
|
+
run: quickchr cache key --channel stable --arch x86 >> "$GITHUB_OUTPUT"
|
|
438
|
+
- uses: actions/cache/restore@v5
|
|
439
|
+
with:
|
|
440
|
+
path: ${{ steps.chr-cache.outputs.dir }}
|
|
441
|
+
key: chr-${{ runner.os }}-${{ steps.chr-cache.outputs.version }}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
`--json` intentionally emits one JSON object for programmatic parsing; use the
|
|
445
|
+
default output form when writing step outputs directly.
|
|
446
|
+
|
|
395
447
|
#### `settings [print|get|set|reset] [key] [value] [--json]`
|
|
396
448
|
|
|
397
449
|
Manage quickchr's own global preferences — repeated flags/defaults that
|
|
@@ -449,6 +501,7 @@ mutate any machine's `machine.json`.
|
|
|
449
501
|
| `QUICKCHR_SERIAL_LOG` | `=1` tees the guest serial console to `<machineDir>/serial.log`. **Secret-bearing** — serial provisioning types the generated password in cleartext |
|
|
450
502
|
| `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `=1` lets a boot-timeout capture write to the guest (a mangle counting rule + host probes) to localize a silent packet drop. Skipped when `QUICKCHR_PRESERVE_ON_FAILURE=1` |
|
|
451
503
|
| `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
|
|
504
|
+
| `QUICKCHR_NO_TIPS` | `=1` suppresses the dim `tip:` lines quickchr prints on stderr |
|
|
452
505
|
| `NO_COLOR` | Disable ANSI styling |
|
|
453
506
|
|
|
454
507
|
`--json` is honored on `list`, `status`, `get`, `env`, `snapshot`, and a
|
|
@@ -456,6 +509,26 @@ few others; `inspect` is always JSON and accepts `--json` for parity. Check
|
|
|
456
509
|
`quickchr help <command>`. Errors are caught at the top level and exit `1`
|
|
457
510
|
with a `[code] message` line plus the install hint when present.
|
|
458
511
|
|
|
512
|
+
**Help.** `--help` / `-h` on any subcommand prints that subcommand's help, exits `0`,
|
|
513
|
+
and touches no state — including `quickchr add --help`, which is a machine-creating
|
|
514
|
+
command. Anything after a bare `--` is your payload and is never scanned for flags:
|
|
515
|
+
|
|
516
|
+
```bash
|
|
517
|
+
quickchr exec my-chr -- ":put [/system/resource/get version]"
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
**Unknown flags.** `add` and `start` reject a flag they do not know rather than
|
|
521
|
+
ignoring it, and suggest the closest match. The read-only commands still tolerate
|
|
522
|
+
stray arguments.
|
|
523
|
+
|
|
524
|
+
**Names.** Machine and named-socket names may contain letters, digits, `.`, `_`
|
|
525
|
+
and `-`, must start with a letter or digit, and are capped at 64 characters. Both
|
|
526
|
+
become a directory or file name under the data dir (§8).
|
|
527
|
+
|
|
528
|
+
**Tips.** quickchr prints an occasional one-line `tip:` on **stderr** pointing at a
|
|
529
|
+
better-suited command or tool. They never go to stdout, so piping `--json` output is
|
|
530
|
+
unaffected. `QUICKCHR_NO_TIPS=1` turns them off.
|
|
531
|
+
|
|
459
532
|
---
|
|
460
533
|
|
|
461
534
|
## 4. Library API reference
|
|
@@ -471,6 +544,9 @@ QuickCHR.start(opts?: StartOptions): Promise<ChrInstance>
|
|
|
471
544
|
QuickCHR.add(opts?: StartOptions): Promise<MachineState>
|
|
472
545
|
QuickCHR.list(): MachineState[]
|
|
473
546
|
QuickCHR.get(name: string): ChrInstance | null
|
|
547
|
+
QuickCHR.listUnreadable(): Array<{ name: string; error: string }>
|
|
548
|
+
QuickCHR.listOrphans(): string[]
|
|
549
|
+
QuickCHR.removeOrphan(name: string): boolean
|
|
474
550
|
QuickCHR.doctor(): Promise<DoctorResult>
|
|
475
551
|
QuickCHR.resolveVersion(channel: Channel): Promise<string>
|
|
476
552
|
```
|
|
@@ -489,6 +565,31 @@ process).
|
|
|
489
565
|
loads `machine.json`, refreshes PID liveness, and returns a runtime
|
|
490
566
|
handle. `list()` is `get()` for everything in `machines/`.
|
|
491
567
|
|
|
568
|
+
`listOrphans()` / `removeOrphan()` cover the gap between the two: a directory under
|
|
569
|
+
`machines/` with no readable `machine.json`, left by a create that a crash or a
|
|
570
|
+
SIGKILL interrupted. It still occupies the name, so `add()` refuses it, but no
|
|
571
|
+
machine answers to it.
|
|
572
|
+
|
|
573
|
+
How `get()` and `list()` behave depends on *why* the state is unreadable, and the
|
|
574
|
+
two differ — a targeted lookup fails, an enumeration does not:
|
|
575
|
+
|
|
576
|
+
| State | `get(name)` | `list()` |
|
|
577
|
+
|---|---|---|
|
|
578
|
+
| `machine.json` missing | `null` | omits it |
|
|
579
|
+
| `machine.json` present but unreadable (read error, invalid JSON, or valid JSON that is not machine state) | **throws** `STATE_ERROR`, naming the file and `quickchr remove <name>` | skips it; `listUnreadable()` names it, and `quickchr list` shows it as a row |
|
|
580
|
+
|
|
581
|
+
`listUnreadable()` returns `{ name, error }` for every machine in the second row — the
|
|
582
|
+
file is present but cannot be turned into state, whether because the read failed, the
|
|
583
|
+
JSON is invalid, or the parsed value is not a machine — so nothing is dropped without
|
|
584
|
+
being reported somewhere. A
|
|
585
|
+
directory with *no* `machine.json` is not in it; that is a create that never finished,
|
|
586
|
+
and `listOrphans()` covers it.
|
|
587
|
+
|
|
588
|
+
`listOrphans()` and `removeOrphan()` treat both the same — unreadable is unreadable —
|
|
589
|
+
so `quickchr remove <name>` clears either. `removeOrphan()` returns `false` for a real
|
|
590
|
+
machine (use `get(name)?.remove()` for those) and for a name that does not exist, and
|
|
591
|
+
throws `INVALID_NAME` for a name that is not a usable path segment.
|
|
592
|
+
|
|
492
593
|
### `ChrInstance`
|
|
493
594
|
|
|
494
595
|
**At a glance** — every method on a running instance:
|
|
@@ -496,7 +597,7 @@ handle. `list()` is `get()` for everything in `machines/`.
|
|
|
496
597
|
| Group | Member | Purpose |
|
|
497
598
|
|---|---|---|
|
|
498
599
|
| Identity | `name`, `state`, `ports`, `restUrl`, `sshPort`, `portBase` | Static metadata persisted in `machine.json` |
|
|
499
|
-
| Capture | `captureInterface`, `
|
|
600
|
+
| Capture | `captureInterface`, `hostGatewayIp` | Platform-correct values for TZSP capture (`tshark -i …`) and the guest→host address any UDP emitter targets |
|
|
500
601
|
| Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, teardown |
|
|
501
602
|
| Comms | `rest()`, `exec()`, `monitor()`, `serial()`, `qga()` | REST, RouterOS CLI, QEMU monitor, serial console, QGA (x86 only) |
|
|
502
603
|
| Provisioning | `license()`, `setDeviceMode()`, `availablePackages()`, `installPackage()` | Post-boot configuration |
|
|
@@ -513,7 +614,8 @@ interface ChrInstance {
|
|
|
513
614
|
sshPort: number;
|
|
514
615
|
portBase: number; // start of this instance's reserved port block
|
|
515
616
|
captureInterface: string; // "lo0" on macOS, "any" on Linux — for TZSP capture
|
|
516
|
-
|
|
617
|
+
hostGatewayIp: string; // "10.0.2.2" — the host, as seen from inside the guest
|
|
618
|
+
tzspGatewayIp: string; // deprecated alias for hostGatewayIp (#26)
|
|
517
619
|
|
|
518
620
|
waitForBoot(timeoutMs?: number): Promise<boolean>;
|
|
519
621
|
waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
|
|
@@ -643,7 +745,7 @@ const chr = await QuickCHR.start({
|
|
|
643
745
|
```
|
|
644
746
|
|
|
645
747
|
To **receive** UDP a guest sends, no forward is needed — bind an *unconnected*
|
|
646
|
-
host socket and have the guest send to `chr.
|
|
748
|
+
host socket and have the guest send to `chr.hostGatewayIp` (`10.0.2.2`); see
|
|
647
749
|
[`docs/networking-recipes.md`](docs/networking-recipes.md) §2 and
|
|
648
750
|
[`examples/udp-gateway/`](examples/udp-gateway/).
|
|
649
751
|
|
|
@@ -684,6 +786,24 @@ The entry also re-exports the lower-level version helpers for pre-flight checks:
|
|
|
684
786
|
`isProvisioningSupportedVersion` (validate a version against the provisioning floor
|
|
685
787
|
before calling `start()`).
|
|
686
788
|
|
|
789
|
+
### Cache helpers
|
|
790
|
+
|
|
791
|
+
`cacheKey()` and `cacheAdd()` are the library equivalents of the CLI cache
|
|
792
|
+
subcommands:
|
|
793
|
+
|
|
794
|
+
```ts
|
|
795
|
+
import { cacheAdd, cacheKey, CACHE_VERSION_UNRESOLVED } from "@tikoci/quickchr";
|
|
796
|
+
|
|
797
|
+
const { dir, version, arch } = await cacheKey({ channel: "stable", arch: "x86" });
|
|
798
|
+
if (version !== CACHE_VERSION_UNRESOLVED) {
|
|
799
|
+
await cacheAdd({ version, arch });
|
|
800
|
+
}
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
`cacheKey({ version })` validates the pin without resolving a channel.
|
|
804
|
+
`cacheAdd()` returns `{ dir, version, arch, path, cacheHit }`; it only downloads
|
|
805
|
+
and extracts the image and never creates or boots a machine.
|
|
806
|
+
|
|
687
807
|
---
|
|
688
808
|
|
|
689
809
|
## 5. Provisioning pipeline
|
|
@@ -810,7 +930,7 @@ Picking among the specifiers/forwards by *goal* — full guide with recipes in
|
|
|
810
930
|
|---|---|---|---|---|
|
|
811
931
|
| `user` | SLiRP user-mode NAT, hostfwd port mapping | no | no | via hostfwd ports only |
|
|
812
932
|
| `socket::<name>` | L2 socket pair between two VMs sharing the name | no | yes | no |
|
|
813
|
-
| `socket
|
|
933
|
+
| `socket:listen:<port>` / `socket:connect:<port>` / `socket:mcast:<group>:<port>` | low-level QEMU socket netdevs | no | yes | no |
|
|
814
934
|
| `shared` | NAT'd L3 with DHCP from the host | yes (or `socket_vmnet` rootless) | yes | yes |
|
|
815
935
|
| `bridged:<iface>` | L2 bridged onto host iface | yes (or `socket_vmnet` rootless) | yes | yes |
|
|
816
936
|
| `tap:<iface>` | pre-created TAP, no quickchr setup | depends | yes | yes |
|
|
@@ -821,15 +941,64 @@ Resolution chains:
|
|
|
821
941
|
- **`shared`** on macOS: socket_vmnet daemon → vmnet-shared (root-only QEMU). On Linux: pre-created TAP owned by the user, else root-created TAP+bridge.
|
|
822
942
|
- **`bridged:<iface>`** on macOS: socket_vmnet bridged → vmnet-bridged (root). On Linux: TAP attached to a bridge containing the named iface.
|
|
823
943
|
|
|
944
|
+
> The table lists the strings the **CLI** takes. The library's equivalents are the
|
|
945
|
+
> `NetworkSpecifier` type names, which are spelled with a dash rather than a colon:
|
|
946
|
+
> `socket:listen:5000` on the command line is `{ type: "socket-listen", port: 5000 }`
|
|
947
|
+
> in TypeScript.
|
|
948
|
+
|
|
824
949
|
### Named L2 sockets
|
|
825
950
|
|
|
826
|
-
`quickchr networks sockets create lab-sw1`
|
|
827
|
-
|
|
828
|
-
`--add-network socket::lab-sw1`
|
|
829
|
-
|
|
951
|
+
`quickchr networks sockets create lab-sw1` registers a link in
|
|
952
|
+
`<dataDir>/networks/lab-sw1.json`, and two machines using
|
|
953
|
+
`--add-network socket::lab-sw1` join it. A link carries a **transport**, fixed when
|
|
954
|
+
the name is created and printed by `start`, `networks sockets` and `create` itself —
|
|
955
|
+
you never have to open a file under the data dir to find out what your link is.
|
|
830
956
|
|
|
831
|
-
|
|
832
|
-
|
|
957
|
+
| `--mode` | Transport | Machines | Start order | Notes |
|
|
958
|
+
|---|---|---|---|---|
|
|
959
|
+
| `dgram` (default on macOS/Linux) | a pair of unix datagram sockets | 2 | either first | no host port, no UDP syscalls; needs QEMU 7.2+ |
|
|
960
|
+
| `listen-connect` (default on Windows) | TCP pair on loopback | 2 | the listening end first | the first machine to *join* takes the listening end and keeps it; a connector never retries, so start that machine first |
|
|
961
|
+
| `mcast` | UDP multicast on `230.0.0.1` | any number | any | the only N-way segment — **and the only one that fails silently** |
|
|
962
|
+
|
|
963
|
+
`mcast` is worth the warning it prints at create time. Three separate hazards, all with
|
|
964
|
+
the same signature — interfaces up, addresses assigned, 100% packet loss, nothing logged:
|
|
965
|
+
|
|
966
|
+
- **It does not deliver on macOS.** QEMU's multicast socket omits `SO_REUSEPORT`, which
|
|
967
|
+
macOS/BSD require before two sockets on one group both receive.
|
|
968
|
+
- **It is refused where unconnected UDP sends are blocked** — seccomp-filtered sandboxes
|
|
969
|
+
and some CI containers, including on Linux. The group still joins cleanly; only the
|
|
970
|
+
send is denied.
|
|
971
|
+
- **It is not confined to your machine.** quickchr does not pass `localaddr=`, so the
|
|
972
|
+
group rides the host's default multicast interface rather than loopback. Another
|
|
973
|
+
machine on your LAN using the same group joins your segment.
|
|
974
|
+
|
|
975
|
+
Use it when you genuinely need more than two machines on one segment, give it a group of
|
|
976
|
+
its own, and check the link before you trust it. For two machines, `dgram` is both
|
|
977
|
+
confined to the filesystem and able to refuse a bad configuration up front.
|
|
978
|
+
|
|
979
|
+
The two-machine modes hold their ends in named slots, so a machine that stops and
|
|
980
|
+
starts again comes back on the same end. That is what makes a restart safe, and it is
|
|
981
|
+
also why `listen-connect` has a start order at all: the roles are fixed when the two
|
|
982
|
+
machines first join, and from then on the listening end has to be running before the
|
|
983
|
+
connecting one starts, because QEMU's `connect=` fails silently and never retries.
|
|
984
|
+
`dgram` has no such constraint — either end may start first, every time. A third machine is refused, with a pointer
|
|
985
|
+
to `--mode mcast`:
|
|
986
|
+
|
|
987
|
+
```console
|
|
988
|
+
$ quickchr start --name chr3 --add-network socket::lab-sw1
|
|
989
|
+
Error [NETWORK_UNAVAILABLE]: Named socket "lab-sw1" is a dgram link and carries 2
|
|
990
|
+
machines; chr1 and chr2 already hold both ends. Stop one of them, or create an N-way
|
|
991
|
+
segment with 'quickchr networks sockets create <name> --mode mcast'.
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
quickchr unregisters socket members on `stop`/`remove`/`clean`, which frees the end
|
|
995
|
+
for another machine. `networks sockets remove` refuses a link that still has members —
|
|
996
|
+
removing it unlinks the endpoint sockets, which would kill a running link without
|
|
997
|
+
saying so.
|
|
998
|
+
|
|
999
|
+
Joining a link takes a registry lock, so `quickchr start a & quickchr start b &` is
|
|
1000
|
+
safe: without it both machines claim the same endpoint and are handed the same socket
|
|
1001
|
+
path.
|
|
833
1002
|
|
|
834
1003
|
For platform internals (vmnet quirks, TAP creation, bridged-mode
|
|
835
1004
|
restrictions, half-open SLiRP under TCG), see `docs/networking.md`.
|
|
@@ -1066,6 +1235,22 @@ derived Basic auth header. Do not commit or publicly log these values.
|
|
|
1066
1235
|
`quickchr exec <name> <command>` and `chr.exec(command, opts)` both
|
|
1067
1236
|
funnel through the same transport selector.
|
|
1068
1237
|
|
|
1238
|
+
> **`exec` does not validate your command.** It is a pipe: whatever you pass is what
|
|
1239
|
+
> RouterOS is asked to run, and a malformed command comes back as RouterOS's own
|
|
1240
|
+
> rejection. For anything beyond a one-liner, use
|
|
1241
|
+
> [centrs](https://github.com/tikoci/centrs), which checks a command against the
|
|
1242
|
+
> device's own command tree *before* running it and has per-path help:
|
|
1243
|
+
>
|
|
1244
|
+
> ```bash
|
|
1245
|
+
> centrs retrieve --quickchr my-chr /system/resource # read state
|
|
1246
|
+
> centrs execute --quickchr my-chr <command> # read/write command
|
|
1247
|
+
> centrs explain <command> # analyze without running
|
|
1248
|
+
> ```
|
|
1249
|
+
>
|
|
1250
|
+
> centrs resolves the machine's live connection facts through `quickchr inspect`
|
|
1251
|
+
> (see [`docs/centrs-interface.md`](./docs/centrs-interface.md)) — it never reads
|
|
1252
|
+
> `machine.json`, and neither should you.
|
|
1253
|
+
|
|
1069
1254
|
### Transports
|
|
1070
1255
|
|
|
1071
1256
|
| `--via` | Source | Notes |
|
package/README.md
CHANGED
|
@@ -141,6 +141,10 @@ quickchr settings set default-channel long-term
|
|
|
141
141
|
quickchr settings set secure-login true
|
|
142
142
|
quickchr settings print
|
|
143
143
|
|
|
144
|
+
# Resolve/cache an image without creating or booting a machine
|
|
145
|
+
quickchr cache key --channel stable
|
|
146
|
+
quickchr cache add --channel stable --arch x86
|
|
147
|
+
|
|
144
148
|
# Reset disk to fresh image
|
|
145
149
|
quickchr clean my-chr
|
|
146
150
|
|
|
@@ -247,11 +251,15 @@ By default each CHR gets a single user-mode NIC (SLIRP). Use `--add-network` to
|
|
|
247
251
|
quickchr start --name router1 --add-network user --add-network shared
|
|
248
252
|
quickchr start --name router2 --add-network user --add-network shared
|
|
249
253
|
|
|
250
|
-
# Named virtual socket (L2
|
|
254
|
+
# Named virtual socket (L2 link between two CHRs; either may start first)
|
|
251
255
|
quickchr networks sockets create lab-switch
|
|
252
256
|
quickchr start --name r1 --add-network socket::lab-switch
|
|
253
257
|
quickchr start --name r2 --add-network socket::lab-switch
|
|
254
258
|
|
|
259
|
+
# More than two machines on one segment needs UDP multicast, which does not work
|
|
260
|
+
# on macOS or where UDP is blocked — and fails silently when it does not
|
|
261
|
+
quickchr networks sockets create lab-segment --mode mcast
|
|
262
|
+
|
|
255
263
|
# Bridge to a physical interface
|
|
256
264
|
quickchr start --name gw --add-network user --add-network bridged:en0
|
|
257
265
|
```
|
|
@@ -377,6 +385,25 @@ await resolveActiveChannels();
|
|
|
377
385
|
`7.24` < `7.24.1`). The same map is available from the CLI via
|
|
378
386
|
`quickchr version --json`. See [MANUAL.md](./MANUAL.md) → *Version & channel helpers*.
|
|
379
387
|
|
|
388
|
+
### Cache API
|
|
389
|
+
|
|
390
|
+
CI can resolve cache identity and download an image without booting QEMU:
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
import { CACHE_VERSION_UNRESOLVED, cacheAdd, cacheKey } from "@tikoci/quickchr";
|
|
394
|
+
|
|
395
|
+
const identity = await cacheKey({ channel: "stable", arch: "x86" });
|
|
396
|
+
// { dir: "/home/runner/.local/share/quickchr/cache", version: "7.24.4", arch: "x86" }
|
|
397
|
+
|
|
398
|
+
if (identity.version !== CACHE_VERSION_UNRESOLVED) {
|
|
399
|
+
await cacheAdd({ version: identity.version, arch: "x86" });
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
An explicit version never performs channel resolution. Offline channel lookup
|
|
404
|
+
returns `version: "unresolved"` from `cacheKey`; `cacheAdd` remains strict and
|
|
405
|
+
fails when it cannot resolve or download the requested image.
|
|
406
|
+
|
|
380
407
|
### Use in Tests
|
|
381
408
|
|
|
382
409
|
```typescript
|
|
@@ -405,7 +432,7 @@ afterAll(async () => {
|
|
|
405
432
|
| `MIKROTIK_WEB_PASSWORD` | MikroTik.com password (for license renewal) |
|
|
406
433
|
| `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) |
|
|
407
434
|
| `QUICKCHR_INTEGRATION` | Set to `1` to run integration tests |
|
|
408
|
-
| `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
|
|
435
|
+
| `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 resolves each `routeros-target` once, then sets this to that exact pin across platforms |
|
|
409
436
|
|
|
410
437
|
The full list (including `QUICKCHR_DATA_DIR`, `QUICKCHR_NO_PROMPT`, `QUICKCHR_DEBUG`,
|
|
411
438
|
`NO_COLOR`) is in [MANUAL.md §11](./MANUAL.md#11-auth-secrets-environment-variables).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tikoci/quickchr",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
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",
|
|
@@ -21,14 +21,16 @@
|
|
|
21
21
|
"MANUAL.md"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
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
|
+
"check": "bun run lint:biome && bun run lint:typecheck && bun run lint:markdown && bun run lint:cspell && bun run lint:examples && bun run lint:specifiers && bun run lint:shell && bun run lint:bun-pin",
|
|
25
25
|
"lint": "biome check . && tsc --noEmit",
|
|
26
26
|
"lint:biome": "biome check .",
|
|
27
27
|
"lint:typecheck": "tsc --noEmit",
|
|
28
28
|
"lint:markdown": "bunx markdownlint-cli2 '**/*.md'",
|
|
29
29
|
"lint:cspell": "cspell lint . --no-progress",
|
|
30
30
|
"lint:examples": "bun run scripts/validate-examples.ts",
|
|
31
|
+
"lint:specifiers": "bun run scripts/validate-network-specifiers.ts",
|
|
31
32
|
"lint:shell": "sh scripts/lint-shell.sh",
|
|
33
|
+
"lint:bun-pin": "bun run scripts/check-bun-pin.ts",
|
|
32
34
|
"test": "bun test test/unit/",
|
|
33
35
|
"test:examples": "bun test examples/",
|
|
34
36
|
"smoke:examples": "QUICKCHR_INTEGRATION=1 EXAMPLES_SMOKE=1 bun test test/integration/examples-smoke.test.ts",
|
package/src/cli/flags.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// cspell:ignore hlep
|
|
2
|
+
/**
|
|
3
|
+
* Known-flag registry for the machine-creating commands.
|
|
4
|
+
*
|
|
5
|
+
* `parseFlags` accepts any `--flag`, and `add`/`start` read only the ones they know.
|
|
6
|
+
* An unrecognised flag was therefore inert: `quickchr add --hlep` created a machine and
|
|
7
|
+
* downloaded 43 MB, and `quickchr add --help` did the same (#156). A typo that creates
|
|
8
|
+
* state is not a good default, so these two commands check their flags first.
|
|
9
|
+
*
|
|
10
|
+
* Only `add` and `start` are strict. They are the commands that create machines and
|
|
11
|
+
* download images; the read-only commands can keep tolerating stray arguments.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Flag names as `parseFlags` reports them: no leading dashes, and `--no-x` arrives as `x`. */
|
|
15
|
+
const CREATE_FLAGS = [
|
|
16
|
+
"version", "channel", "arch", "name", "cpu", "mem", "accel",
|
|
17
|
+
"add-package", "install-all-packages",
|
|
18
|
+
"port-base", "forward", "winbox", "api-ssl",
|
|
19
|
+
"add-network", "network",
|
|
20
|
+
"boot-disk-format", "boot-size", "add-disk",
|
|
21
|
+
"device-mode", "device-mode-enable", "device-mode-disable",
|
|
22
|
+
"add-user", "disable-admin", "secure-login",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
/** `start` also boots, so it owns the run-time flags on top of the create set. */
|
|
26
|
+
const START_ONLY_FLAGS = [
|
|
27
|
+
"all", "dry-run", "background", "bg", "fg", "foreground",
|
|
28
|
+
"install-deps", "timeout-extra",
|
|
29
|
+
"license-level", "license-account", "license-password",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export const ADD_FLAGS: readonly string[] = CREATE_FLAGS;
|
|
33
|
+
export const START_FLAGS: readonly string[] = [...CREATE_FLAGS, ...START_ONLY_FLAGS];
|
|
34
|
+
|
|
35
|
+
/** Every flag that takes a value, across every command — `parseFlags`'s arity table.
|
|
36
|
+
*
|
|
37
|
+
* This is the parser's input, not only a validation list. Without it `parseFlags`
|
|
38
|
+
* guessed each flag's arity from the shape of the next argument — *if it does not start
|
|
39
|
+
* with `--`, consume it as the value* — which is one rule doing two jobs and getting one
|
|
40
|
+
* of them wrong: a boolean flag swallowed the following positional, so
|
|
41
|
+
* `quickchr start --vmnet-shared lab` set `vmnet-shared="lab"` and lost the machine name
|
|
42
|
+
* (#164). A flag absent from this set is boolean and never consumes an argument.
|
|
43
|
+
*
|
|
44
|
+
* It is also what makes a missing value visible. `parseFlags` stores `true` when a
|
|
45
|
+
* value-taking flag is followed by another flag or by nothing, and `flag()` turns a
|
|
46
|
+
* boolean back into `undefined` — so `quickchr add --name --version 7.24.3` used to
|
|
47
|
+
* reach `add()` with no name at all and quietly create an auto-named machine. Checking
|
|
48
|
+
* names alone does not catch that; the value has to be checked too.
|
|
49
|
+
*
|
|
50
|
+
* `--no-x` (which parses as `false`) is a deliberate negation, not a missing value,
|
|
51
|
+
* so only an explicit `true` is rejected.
|
|
52
|
+
*
|
|
53
|
+
* Both directions are asserted mechanically against the CLI source in
|
|
54
|
+
* `test/unit/cli-flag-arity.test.ts` — every name read as a value is here, and no name
|
|
55
|
+
* read as a boolean is — and the scan covers direct access (`flags["older-than"]`) as
|
|
56
|
+
* well as the helpers, because that is where the first gap was. A new flag cannot skip
|
|
57
|
+
* this table by being forgotten; the audit is the test's job, not a reviewer's. */
|
|
58
|
+
export const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
59
|
+
// add / start
|
|
60
|
+
"version", "channel", "arch", "name", "cpu", "mem", "accel",
|
|
61
|
+
"add-package", "port-base", "forward",
|
|
62
|
+
"add-network",
|
|
63
|
+
"boot-disk-format", "boot-size", "add-disk",
|
|
64
|
+
"device-mode", "device-mode-enable", "device-mode-disable",
|
|
65
|
+
"add-user", "timeout-extra",
|
|
66
|
+
"license-level", "license-account", "license-password",
|
|
67
|
+
// exec / qga / console
|
|
68
|
+
"via", "user", "password", "timeout", "path", "data", "script",
|
|
69
|
+
// set --license
|
|
70
|
+
"account", "level",
|
|
71
|
+
// networks sockets create
|
|
72
|
+
"mode", "port", "group",
|
|
73
|
+
// completions / logs
|
|
74
|
+
"shell", "lines",
|
|
75
|
+
// cache prune
|
|
76
|
+
"older-than", "max-age", "max-size",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/** Flags that quickchr used to accept, and what to write instead.
|
|
80
|
+
*
|
|
81
|
+
* `--vmnet-shared` / `--vmnet-bridge <iface>` were special cases for one network type
|
|
82
|
+
* that `--add-network` already expresses in general (#164). Removing them silently would
|
|
83
|
+
* repeat the defect they were removed for: a flag that is accepted and does nothing. A
|
|
84
|
+
* removed name stays known for as long as anyone might still type it, and says what
|
|
85
|
+
* replaced it. */
|
|
86
|
+
export const REMOVED_FLAGS: ReadonlyMap<string, string> = new Map([
|
|
87
|
+
["vmnet-shared", "--add-network shared"],
|
|
88
|
+
["vmnet-bridge", "--add-network bridged:<iface>"],
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/** Value-taking flags with a documented `--no-` spelling, where `false` is a real
|
|
92
|
+
* instruction rather than a value that went missing. `--no-device-mode` is the only
|
|
93
|
+
* one today; it is listed in `start --help`. Exported for the arity audit, which would
|
|
94
|
+
* otherwise read `flags["device-mode"] === false` as proof that it is a boolean. */
|
|
95
|
+
export const NEGATABLE_VALUE_FLAGS: ReadonlySet<string> = new Set(["device-mode"]);
|
|
96
|
+
|
|
97
|
+
/** Flags given without the value they require.
|
|
98
|
+
*
|
|
99
|
+
* Both spellings have to be caught. `--name` followed by another flag parses as
|
|
100
|
+
* `true`, and `--no-name` parses as `false`; either way `flag()` returns `undefined`
|
|
101
|
+
* and the machine gets an auto-generated name. Only a string (or a repeatable flag's
|
|
102
|
+
* string[]) is a value. */
|
|
103
|
+
export function valuelessFlags(
|
|
104
|
+
flags: Record<string, string | boolean | string[]>,
|
|
105
|
+
known: readonly string[],
|
|
106
|
+
): Array<{ name: string; negated: boolean }> {
|
|
107
|
+
const result: Array<{ name: string; negated: boolean }> = [];
|
|
108
|
+
for (const name of Object.keys(flags)) {
|
|
109
|
+
if (!known.includes(name) || !VALUE_FLAGS.has(name)) continue;
|
|
110
|
+
const value = flags[name];
|
|
111
|
+
if (typeof value === "string" || Array.isArray(value)) continue;
|
|
112
|
+
if (value === false && NEGATABLE_VALUE_FLAGS.has(name)) continue;
|
|
113
|
+
result.push({ name, negated: value === false });
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Human-readable complaint for `valuelessFlags()` output. Empty when nothing is missing. */
|
|
119
|
+
export function valuelessFlagMessage(
|
|
120
|
+
valueless: ReadonlyArray<{ name: string; negated: boolean }>,
|
|
121
|
+
command: string,
|
|
122
|
+
): string {
|
|
123
|
+
if (valueless.length === 0) return "";
|
|
124
|
+
const lines = valueless.map(({ name, negated }) =>
|
|
125
|
+
negated
|
|
126
|
+
? ` --no-${name} (not a supported negation — --${name} takes a value)`
|
|
127
|
+
: ` --${name} <value>`,
|
|
128
|
+
);
|
|
129
|
+
return [
|
|
130
|
+
`Error: flag${valueless.length > 1 ? "s" : ""} for 'quickchr ${command}' missing a value:`,
|
|
131
|
+
...lines,
|
|
132
|
+
`Run 'quickchr ${command} --help' for the full list.`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function editDistance(a: string, b: string): number {
|
|
137
|
+
// One rolling row — the suggestion only needs the distance, never the edit script.
|
|
138
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
139
|
+
for (let i = 1; i <= a.length; i++) {
|
|
140
|
+
const current = [i, ...Array<number>(b.length).fill(0)];
|
|
141
|
+
for (let j = 1; j <= b.length; j++) {
|
|
142
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
143
|
+
current[j] = Math.min(
|
|
144
|
+
(previous[j] ?? 0) + 1,
|
|
145
|
+
(current[j - 1] ?? 0) + 1,
|
|
146
|
+
(previous[j - 1] ?? 0) + cost,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
previous = current;
|
|
150
|
+
}
|
|
151
|
+
return previous[b.length] ?? 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The closest known flag to `name`, when one is close enough to be worth suggesting. */
|
|
155
|
+
export function suggestFlag(name: string, known: readonly string[]): string | undefined {
|
|
156
|
+
let best: string | undefined;
|
|
157
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
158
|
+
for (const candidate of known) {
|
|
159
|
+
const d = editDistance(name, candidate);
|
|
160
|
+
if (d < bestDistance) {
|
|
161
|
+
bestDistance = d;
|
|
162
|
+
best = candidate;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// 1/3 of the flag's length, so short flags need a near-exact match and long ones
|
|
166
|
+
// tolerate a transposition or two.
|
|
167
|
+
return bestDistance <= Math.max(1, Math.floor(name.length / 3)) ? best : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Every flag in `flags` that `known` does not list, with a suggestion where there is one. */
|
|
171
|
+
export function unknownFlags(
|
|
172
|
+
flags: Record<string, string | boolean | string[]>,
|
|
173
|
+
known: readonly string[],
|
|
174
|
+
): Array<{ name: string; suggestion?: string }> {
|
|
175
|
+
return Object.keys(flags)
|
|
176
|
+
.filter((name) => !known.includes(name))
|
|
177
|
+
.map((name) => ({ name, suggestion: suggestFlag(name, known) }));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Human-readable complaint for `unknownFlags()` output. Empty when nothing is unknown. */
|
|
181
|
+
export function unknownFlagMessage(
|
|
182
|
+
unknown: Array<{ name: string; suggestion?: string }>,
|
|
183
|
+
command: string,
|
|
184
|
+
): string {
|
|
185
|
+
if (unknown.length === 0) return "";
|
|
186
|
+
const lines = unknown.map(({ name, suggestion }) =>
|
|
187
|
+
suggestion ? ` --${name} (did you mean --${suggestion}?)` : ` --${name}`,
|
|
188
|
+
);
|
|
189
|
+
return [
|
|
190
|
+
`Error: unknown flag${unknown.length > 1 ? "s" : ""} for 'quickchr ${command}':`,
|
|
191
|
+
...lines,
|
|
192
|
+
`Run 'quickchr ${command} --help' for the full list.`,
|
|
193
|
+
].join("\n");
|
|
194
|
+
}
|
package/src/cli/format.ts
CHANGED
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
* CLI output formatting — tables, colors, status indicators.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { listMachineNames } from "../lib/state.ts";
|
|
5
|
+
import { listMachineNames, listOrphanMachineDirs } from "../lib/state.ts";
|
|
6
6
|
|
|
7
7
|
/** Format a "machine not found" error with helpful suggestions. */
|
|
8
8
|
export function machineNotFoundMessage(name: string): string {
|
|
9
|
-
|
|
9
|
+
// listMachineNames() is directory-based, so it also returns half-created machines.
|
|
10
|
+
// Offering one as "Available" is what made `remove` name a machine in the same
|
|
11
|
+
// breath as refusing to find it (#155) — so orphans get their own line.
|
|
12
|
+
const orphans = listOrphanMachineDirs();
|
|
13
|
+
const names = listMachineNames().filter((n) => !orphans.includes(n));
|
|
10
14
|
let msg = `Machine "${name}" not found.`;
|
|
15
|
+
if (orphans.includes(name)) {
|
|
16
|
+
msg = `Machine "${name}" has a leftover directory with no readable machine.json — a create that did not finish.`;
|
|
17
|
+
msg += `\n Clear it with: quickchr remove ${name}`;
|
|
18
|
+
return msg;
|
|
19
|
+
}
|
|
11
20
|
if (names.length === 0) {
|
|
12
21
|
msg += `\n No machines exist. Create one: quickchr start`;
|
|
13
22
|
} else if (names.length <= 5) {
|
|
@@ -15,6 +24,9 @@ export function machineNotFoundMessage(name: string): string {
|
|
|
15
24
|
} else {
|
|
16
25
|
msg += `\n Run 'quickchr list' to see available machines.`;
|
|
17
26
|
}
|
|
27
|
+
if (orphans.length > 0) {
|
|
28
|
+
msg += `\n Leftover directories (no readable machine.json): ${orphans.join(", ")} — clear with 'quickchr remove <name>'`;
|
|
29
|
+
}
|
|
18
30
|
return msg;
|
|
19
31
|
}
|
|
20
32
|
|
|
@@ -113,7 +125,12 @@ export function formatNetworks(networks: { specifier: unknown; id: string }[]):
|
|
|
113
125
|
if (s.type === "socket-listen") return `socket:listen:${s.port}`;
|
|
114
126
|
if (s.type === "socket-connect") return `socket:connect:${s.port}`;
|
|
115
127
|
if (s.type === "socket-mcast") return `socket:mcast:${s.group}:${s.port}`;
|
|
116
|
-
|
|
128
|
+
// `{type:"socket"}` is what `parseNetworkSpecifier` emits. This tested for
|
|
129
|
+
// "socket-named", a type name nothing ever produced, so the branch could not
|
|
130
|
+
// match and every named socket fell through to JSON.stringify — part of why
|
|
131
|
+
// the field report had to open machine.json to find out what its link was
|
|
132
|
+
// (#158).
|
|
133
|
+
if (s.type === "socket") return `socket::${s.name}`;
|
|
117
134
|
if (s.type === "tap") return `tap:${s.ifname}`;
|
|
118
135
|
return JSON.stringify(spec);
|
|
119
136
|
}
|