@tikoci/quickchr 0.4.6 → 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 +604 -3
- package/MANUAL.md +222 -19
- 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 +11 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/completions.ts +17 -3
- package/src/lib/console.ts +206 -75
- package/src/lib/diagnostics.ts +740 -0
- package/src/lib/download.ts +433 -0
- package/src/lib/guest-snapshot.ts +474 -0
- package/src/lib/images.ts +56 -61
- package/src/lib/names.ts +72 -0
- package/src/lib/net.ts +59 -3
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +85 -17
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +62 -16
- package/src/lib/quickchr.ts +504 -64
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +44 -6
- package/src/lib/versions.ts +99 -22
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
|
|
|
@@ -199,9 +200,17 @@ credentials from the secret store.
|
|
|
199
200
|
#### `clean <name>`
|
|
200
201
|
|
|
201
202
|
Resets the machine to a fresh image: stops QEMU, re-copies the boot
|
|
202
|
-
image from cache, recreates extra disks per the saved config,
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
image from cache, recreates extra disks per the saved config, and clears
|
|
204
|
+
every credential the old disk held — the per-instance secret-store entry,
|
|
205
|
+
the provisioned `user`, the managed SSH keypair, and the `disableAdmin`
|
|
206
|
+
flag. The next `start` therefore logs in as factory `admin` with an empty
|
|
207
|
+
password. EFI vars are deliberately **kept** (they store boot order, not
|
|
208
|
+
guest state; wiping them costs an arm64 device rescan).
|
|
209
|
+
|
|
210
|
+
`clean` does not re-provision: the boot options in `machine.json`
|
|
211
|
+
(`packages`, `deviceMode`, `secureLogin`) are re-applied only on a machine
|
|
212
|
+
that has never started, so a cleaned machine comes back factory-fresh. Pass
|
|
213
|
+
the provisioning flags again, or `remove` and re-create, to get them back.
|
|
205
214
|
|
|
206
215
|
### Inspection
|
|
207
216
|
|
|
@@ -211,6 +220,19 @@ Without a name: tabular summary of every machine. With a name: detailed
|
|
|
211
220
|
view including credentials and connection tips. `--json` for
|
|
212
221
|
machine-readable output.
|
|
213
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
|
+
|
|
214
236
|
#### `get <name> [license | device-mode | admin] [--json]`
|
|
215
237
|
|
|
216
238
|
Live REST query. Hits the running CHR and returns the requested group
|
|
@@ -257,8 +279,9 @@ Tail the per-machine `qemu.log`. `--follow` streams new output;
|
|
|
257
279
|
|
|
258
280
|
`networks` enumerates host interfaces and their alias resolution
|
|
259
281
|
(`wifi`, `ethernet`, `auto`). `networks sockets` lists registered
|
|
260
|
-
named L2 sockets; `networks sockets create <name>`
|
|
261
|
-
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).
|
|
262
285
|
|
|
263
286
|
#### `version [--json]`
|
|
264
287
|
|
|
@@ -384,6 +407,43 @@ shell completions if not yet configured.
|
|
|
384
407
|
|
|
385
408
|
Print or install shell completions. With no arg, detects current shell.
|
|
386
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
|
+
|
|
387
447
|
#### `settings [print|get|set|reset] [key] [value] [--json]`
|
|
388
448
|
|
|
389
449
|
Manage quickchr's own global preferences — repeated flags/defaults that
|
|
@@ -437,7 +497,11 @@ mutate any machine's `machine.json`.
|
|
|
437
497
|
| `QUICKCHR_NO_PROMPT` | Force non-interactive mode (skips all wizards) |
|
|
438
498
|
| `QUICKCHR_DEBUG` | Emit `[debug]` lines from the progress logger |
|
|
439
499
|
| `QUICKCHR_INTEGRATION` | Required to run `test/integration/` |
|
|
500
|
+
| `QUICKCHR_PRESERVE_ON_FAILURE` | `=1` keeps the machine directory after a boot timeout instead of removing it (QEMU is still stopped) |
|
|
501
|
+
| `QUICKCHR_SERIAL_LOG` | `=1` tees the guest serial console to `<machineDir>/serial.log`. **Secret-bearing** — serial provisioning types the generated password in cleartext |
|
|
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` |
|
|
440
503
|
| `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
|
|
504
|
+
| `QUICKCHR_NO_TIPS` | `=1` suppresses the dim `tip:` lines quickchr prints on stderr |
|
|
441
505
|
| `NO_COLOR` | Disable ANSI styling |
|
|
442
506
|
|
|
443
507
|
`--json` is honored on `list`, `status`, `get`, `env`, `snapshot`, and a
|
|
@@ -445,6 +509,26 @@ few others; `inspect` is always JSON and accepts `--json` for parity. Check
|
|
|
445
509
|
`quickchr help <command>`. Errors are caught at the top level and exit `1`
|
|
446
510
|
with a `[code] message` line plus the install hint when present.
|
|
447
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
|
+
|
|
448
532
|
---
|
|
449
533
|
|
|
450
534
|
## 4. Library API reference
|
|
@@ -460,6 +544,9 @@ QuickCHR.start(opts?: StartOptions): Promise<ChrInstance>
|
|
|
460
544
|
QuickCHR.add(opts?: StartOptions): Promise<MachineState>
|
|
461
545
|
QuickCHR.list(): MachineState[]
|
|
462
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
|
|
463
550
|
QuickCHR.doctor(): Promise<DoctorResult>
|
|
464
551
|
QuickCHR.resolveVersion(channel: Channel): Promise<string>
|
|
465
552
|
```
|
|
@@ -478,6 +565,31 @@ process).
|
|
|
478
565
|
loads `machine.json`, refreshes PID liveness, and returns a runtime
|
|
479
566
|
handle. `list()` is `get()` for everything in `machines/`.
|
|
480
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
|
+
|
|
481
593
|
### `ChrInstance`
|
|
482
594
|
|
|
483
595
|
**At a glance** — every method on a running instance:
|
|
@@ -485,7 +597,7 @@ handle. `list()` is `get()` for everything in `machines/`.
|
|
|
485
597
|
| Group | Member | Purpose |
|
|
486
598
|
|---|---|---|
|
|
487
599
|
| Identity | `name`, `state`, `ports`, `restUrl`, `sshPort`, `portBase` | Static metadata persisted in `machine.json` |
|
|
488
|
-
| Capture | `captureInterface`, `
|
|
600
|
+
| Capture | `captureInterface`, `hostGatewayIp` | Platform-correct values for TZSP capture (`tshark -i …`) and the guest→host address any UDP emitter targets |
|
|
489
601
|
| Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, teardown |
|
|
490
602
|
| Comms | `rest()`, `exec()`, `monitor()`, `serial()`, `qga()` | REST, RouterOS CLI, QEMU monitor, serial console, QGA (x86 only) |
|
|
491
603
|
| Provisioning | `license()`, `setDeviceMode()`, `availablePackages()`, `installPackage()` | Post-boot configuration |
|
|
@@ -502,7 +614,8 @@ interface ChrInstance {
|
|
|
502
614
|
sshPort: number;
|
|
503
615
|
portBase: number; // start of this instance's reserved port block
|
|
504
616
|
captureInterface: string; // "lo0" on macOS, "any" on Linux — for TZSP capture
|
|
505
|
-
|
|
617
|
+
hostGatewayIp: string; // "10.0.2.2" — the host, as seen from inside the guest
|
|
618
|
+
tzspGatewayIp: string; // deprecated alias for hostGatewayIp (#26)
|
|
506
619
|
|
|
507
620
|
waitForBoot(timeoutMs?: number): Promise<boolean>;
|
|
508
621
|
waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
|
|
@@ -632,7 +745,7 @@ const chr = await QuickCHR.start({
|
|
|
632
745
|
```
|
|
633
746
|
|
|
634
747
|
To **receive** UDP a guest sends, no forward is needed — bind an *unconnected*
|
|
635
|
-
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
|
|
636
749
|
[`docs/networking-recipes.md`](docs/networking-recipes.md) §2 and
|
|
637
750
|
[`examples/udp-gateway/`](examples/udp-gateway/).
|
|
638
751
|
|
|
@@ -673,6 +786,24 @@ The entry also re-exports the lower-level version helpers for pre-flight checks:
|
|
|
673
786
|
`isProvisioningSupportedVersion` (validate a version against the provisioning floor
|
|
674
787
|
before calling `start()`).
|
|
675
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
|
+
|
|
676
807
|
---
|
|
677
808
|
|
|
678
809
|
## 5. Provisioning pipeline
|
|
@@ -799,7 +930,7 @@ Picking among the specifiers/forwards by *goal* — full guide with recipes in
|
|
|
799
930
|
|---|---|---|---|---|
|
|
800
931
|
| `user` | SLiRP user-mode NAT, hostfwd port mapping | no | no | via hostfwd ports only |
|
|
801
932
|
| `socket::<name>` | L2 socket pair between two VMs sharing the name | no | yes | no |
|
|
802
|
-
| `socket
|
|
933
|
+
| `socket:listen:<port>` / `socket:connect:<port>` / `socket:mcast:<group>:<port>` | low-level QEMU socket netdevs | no | yes | no |
|
|
803
934
|
| `shared` | NAT'd L3 with DHCP from the host | yes (or `socket_vmnet` rootless) | yes | yes |
|
|
804
935
|
| `bridged:<iface>` | L2 bridged onto host iface | yes (or `socket_vmnet` rootless) | yes | yes |
|
|
805
936
|
| `tap:<iface>` | pre-created TAP, no quickchr setup | depends | yes | yes |
|
|
@@ -810,15 +941,64 @@ Resolution chains:
|
|
|
810
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.
|
|
811
942
|
- **`bridged:<iface>`** on macOS: socket_vmnet bridged → vmnet-bridged (root). On Linux: TAP attached to a bridge containing the named iface.
|
|
812
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
|
+
|
|
813
949
|
### Named L2 sockets
|
|
814
950
|
|
|
815
|
-
`quickchr networks sockets create lab-sw1`
|
|
816
|
-
|
|
817
|
-
`--add-network socket::lab-sw1`
|
|
818
|
-
|
|
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.
|
|
819
956
|
|
|
820
|
-
|
|
821
|
-
|
|
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.
|
|
822
1002
|
|
|
823
1003
|
For platform internals (vmnet quirks, TAP creation, bridged-mode
|
|
824
1004
|
restrictions, half-open SLiRP under TCG), see `docs/networking.md`.
|
|
@@ -1016,6 +1196,9 @@ the 6 managed keys are all structural preferences, not secrets.
|
|
|
1016
1196
|
| `QUICKCHR_NO_PROMPT` | CLI dispatcher — forces non-interactive mode |
|
|
1017
1197
|
| `QUICKCHR_DEBUG` | progress logger — emits `[debug]` lines |
|
|
1018
1198
|
| `QUICKCHR_INTEGRATION` | `bun:test` gate for `test/integration/` |
|
|
1199
|
+
| `QUICKCHR_PRESERVE_ON_FAILURE` | `quickchr.ts` — skip `remove()` on `BOOT_TIMEOUT` so the machine dir survives for diagnosis |
|
|
1200
|
+
| `QUICKCHR_SERIAL_LOG` | `qemu.ts` — adds `logfile=` to the serial chardev (writes cleartext provisioning passwords) |
|
|
1201
|
+
| `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `guest-snapshot.ts` — allows the guest-mutating counting-rule probe during boot-failure capture |
|
|
1019
1202
|
| `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
|
|
1020
1203
|
| `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
|
|
1021
1204
|
| `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
|
|
@@ -1052,6 +1235,22 @@ derived Basic auth header. Do not commit or publicly log these values.
|
|
|
1052
1235
|
`quickchr exec <name> <command>` and `chr.exec(command, opts)` both
|
|
1053
1236
|
funnel through the same transport selector.
|
|
1054
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
|
+
|
|
1055
1254
|
### Transports
|
|
1056
1255
|
|
|
1057
1256
|
| `--via` | Source | Notes |
|
|
@@ -1162,8 +1361,12 @@ state deliberately) if a built-in service needs a different host port.
|
|
|
1162
1361
|
|
|
1163
1362
|
`quickchr clean <name>` re-copies the boot image from cache and
|
|
1164
1363
|
recreates extra disks per `machine.json`. Snapshots are gone (they live
|
|
1165
|
-
inside the qcow2 file).
|
|
1166
|
-
|
|
1364
|
+
inside the qcow2 file). Every credential the old disk held is wiped —
|
|
1365
|
+
stored instance credentials, `state.user`, the managed SSH keypair under
|
|
1366
|
+
`<machineDir>/ssh/`, and `disableAdmin` — because the accounts they named
|
|
1367
|
+
went with the disk. The machine comes back as a factory CHR: `admin` with
|
|
1368
|
+
an empty password, which is what `rest()`, `exec()`, and `inspect` resolve
|
|
1369
|
+
to afterwards. Nothing is re-provisioned automatically.
|
|
1167
1370
|
|
|
1168
1371
|
### Snapshots
|
|
1169
1372
|
|
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
|
+
}
|