@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/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>` are deprecated aliases for
156
- `--add-network shared` / `--add-network bridged:<iface>`.
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>` reserves a port for a
269
- new tunnel.
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`, `tzspGatewayIp` | Platform-correct values for TZSP capture (`tshark -i …`) and RouterOS streaming target |
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
- tzspGatewayIp: string; // "10.0.2.2" — QEMU slirp host gateway
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.tzspGatewayIp` (`10.0.2.2`); see
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-listen:<port>` / `socket-connect:<port>` / `socket-mcast:<group>:<port>` | low-level QEMU socket netdevs | no | yes | no |
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` reserves a port and stores
827
- metadata in `<dataDir>/networks/lab-sw1.json`. Two machines using
828
- `--add-network socket::lab-sw1` form an L2 tunnel: the first to start
829
- listens on the port; the second connects.
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
- quickchr unregisters socket members on `stop`/`remove`/`clean` so port
832
- allocation can recycle.
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 tunnel between CHRs)
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 (`integration.yml`) sets this from its `routeros-target` dispatch input to test a release across platforms |
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.7",
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",
@@ -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
- const names = listMachineNames();
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
- if (s.type === "socket-named") return `socket::${s.name}`;
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
  }