@tikoci/quickchr 0.4.7 → 0.5.0
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 +522 -6
- package/MANUAL.md +471 -59
- package/README.md +42 -8
- package/package.json +4 -2
- package/src/cli/flags.ts +208 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +660 -107
- package/src/cli/tips.ts +57 -0
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +12 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +34 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/images.ts +53 -22
- package/src/lib/lock.ts +497 -0
- package/src/lib/names.ts +96 -0
- package/src/lib/network.ts +199 -27
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +129 -7
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +93 -14
- package/src/lib/quickchr.ts +1108 -339
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +211 -8
- package/src/lib/types.ts +117 -17
- package/src/lib/versions.ts +54 -0
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
> **Experimental** — quickchr is under active development. Most testing has been on
|
|
4
4
|
> macOS (Apple Silicon + Intel), with CI on Linux (x86_64 + aarch64). Windows support
|
|
5
|
-
> is early-stage: unit tests pass on `windows-latest` CI,
|
|
6
|
-
>
|
|
7
|
-
>
|
|
5
|
+
> is early-stage: unit tests pass on `windows-latest` CI, and a weekly sweep boots a real
|
|
6
|
+
> CHR on Windows (TCG, anchor smoke subset only). Expect rough edges on Windows — bug
|
|
7
|
+
> reports welcome.
|
|
8
8
|
|
|
9
9
|
CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU.
|
|
10
10
|
|
|
@@ -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
|
|
|
@@ -154,6 +158,11 @@ quickchr remove my-chr
|
|
|
154
158
|
quickchr doctor
|
|
155
159
|
```
|
|
156
160
|
|
|
161
|
+
`quickchr stop <name>` terminates QEMU (a power cut). For an explicit guest
|
|
162
|
+
shutdown request, use `quickchr shutdown <name> [--timeout <seconds>]` (default
|
|
163
|
+
10 seconds, or `QUICKCHR_SHUTDOWN_TIMEOUT`). It waits for QEMU to exit and clearly
|
|
164
|
+
reports a forced power cut if the guest does not stop within the grace budget.
|
|
165
|
+
|
|
157
166
|
### Create / Start Options
|
|
158
167
|
|
|
159
168
|
| Flag | Description | Default |
|
|
@@ -168,8 +177,9 @@ quickchr doctor
|
|
|
168
177
|
| `--boot-size <size>` | Resize boot disk (e.g., 512M, 2G). Requires `qemu-img`. | |
|
|
169
178
|
| `--add-disk <size>` | Attach an extra blank qcow2 disk. Repeatable. Requires `qemu-img`. | |
|
|
170
179
|
| `--forward <spec>` | Add or pin a QEMU SLiRP hostfwd mapping. Repeatable. | |
|
|
171
|
-
| `--
|
|
180
|
+
| `--no-wait` | Return once QEMU is running; readiness is up to the caller. Not with provisioning flags or `--fg` | |
|
|
172
181
|
| `--fg` / `--foreground` | Run in foreground — serial console on stdio | |
|
|
182
|
+
| `--bg` / `--background` | **Deprecated, does nothing** — background is already the default. Warns; use `--no-wait` to return early | |
|
|
173
183
|
| `--add-package <pkg>` | Extra package to install (repeatable) | |
|
|
174
184
|
| `--install-all-packages` | Install all packages from `all_packages.zip` | |
|
|
175
185
|
| `--add-user <user:pass>` | Create a user after boot | |
|
|
@@ -200,7 +210,7 @@ quickchr separates **boot-only** machine management from **post-boot provisionin
|
|
|
200
210
|
|
|
201
211
|
| Feature | Minimum RouterOS | Notes |
|
|
202
212
|
|---------|------------------|-------|
|
|
203
|
-
| Boot / start / stop | Any RouterOS 7.x | Boot-only path; no post-boot RouterOS mutations |
|
|
213
|
+
| Boot / start / stop | Any RouterOS 7.x; RouterOS 6 on x86 (best-effort) | Boot-only path; no post-boot RouterOS mutations. 6.x has no REST: readiness is a native-API login, and provisioning is refused — see MANUAL "RouterOS 6 (boot-only)" |
|
|
204
214
|
| Disk resize / extra disks / disk inspection | Any RouterOS 7.x | Requires host `qemu-img`; QEMU-local only |
|
|
205
215
|
| Network attachment / port mappings | Any RouterOS 7.x | QEMU networking setup; not provisioning |
|
|
206
216
|
| Managed login / custom user / disable-admin | 7.20.8+ | Post-boot provisioning |
|
|
@@ -247,11 +257,15 @@ By default each CHR gets a single user-mode NIC (SLIRP). Use `--add-network` to
|
|
|
247
257
|
quickchr start --name router1 --add-network user --add-network shared
|
|
248
258
|
quickchr start --name router2 --add-network user --add-network shared
|
|
249
259
|
|
|
250
|
-
# Named virtual socket (L2
|
|
260
|
+
# Named virtual socket (L2 link between two CHRs; either may start first)
|
|
251
261
|
quickchr networks sockets create lab-switch
|
|
252
262
|
quickchr start --name r1 --add-network socket::lab-switch
|
|
253
263
|
quickchr start --name r2 --add-network socket::lab-switch
|
|
254
264
|
|
|
265
|
+
# More than two machines on one segment needs UDP multicast, which does not work
|
|
266
|
+
# on macOS or where UDP is blocked — and fails silently when it does not
|
|
267
|
+
quickchr networks sockets create lab-segment --mode mcast
|
|
268
|
+
|
|
255
269
|
# Bridge to a physical interface
|
|
256
270
|
quickchr start --name gw --add-network user --add-network bridged:en0
|
|
257
271
|
```
|
|
@@ -351,7 +365,8 @@ const result = await chr.exec("/system/resource/print");
|
|
|
351
365
|
console.log(result);
|
|
352
366
|
|
|
353
367
|
// Stop (or remove)
|
|
354
|
-
await chr.stop();
|
|
368
|
+
await chr.stop(); // power cut for normal teardown
|
|
369
|
+
// Alternatively, replace stop() above with: await chr.shutdown({ timeoutMs: 10_000 });
|
|
355
370
|
await chr.remove();
|
|
356
371
|
|
|
357
372
|
console.log(chr.state.bootDiskFormat); // "qcow2"
|
|
@@ -377,6 +392,25 @@ await resolveActiveChannels();
|
|
|
377
392
|
`7.24` < `7.24.1`). The same map is available from the CLI via
|
|
378
393
|
`quickchr version --json`. See [MANUAL.md](./MANUAL.md) → *Version & channel helpers*.
|
|
379
394
|
|
|
395
|
+
### Cache API
|
|
396
|
+
|
|
397
|
+
CI can resolve cache identity and download an image without booting QEMU:
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
import { CACHE_VERSION_UNRESOLVED, cacheAdd, cacheKey } from "@tikoci/quickchr";
|
|
401
|
+
|
|
402
|
+
const identity = await cacheKey({ channel: "stable", arch: "x86" });
|
|
403
|
+
// { dir: "/home/runner/.local/share/quickchr/cache", version: "7.24.4", arch: "x86" }
|
|
404
|
+
|
|
405
|
+
if (identity.version !== CACHE_VERSION_UNRESOLVED) {
|
|
406
|
+
await cacheAdd({ version: identity.version, arch: "x86" });
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
An explicit version never performs channel resolution. Offline channel lookup
|
|
411
|
+
returns `version: "unresolved"` from `cacheKey`; `cacheAdd` remains strict and
|
|
412
|
+
fails when it cannot resolve or download the requested image.
|
|
413
|
+
|
|
380
414
|
### Use in Tests
|
|
381
415
|
|
|
382
416
|
```typescript
|
|
@@ -405,7 +439,7 @@ afterAll(async () => {
|
|
|
405
439
|
| `MIKROTIK_WEB_PASSWORD` | MikroTik.com password (for license renewal) |
|
|
406
440
|
| `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
441
|
| `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
|
|
442
|
+
| `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
443
|
|
|
410
444
|
The full list (including `QUICKCHR_DATA_DIR`, `QUICKCHR_NO_PROMPT`, `QUICKCHR_DEBUG`,
|
|
411
445
|
`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.
|
|
3
|
+
"version": "0.5.0",
|
|
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,208 @@
|
|
|
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", "wait", "background", "bg", "fg", "foreground",
|
|
28
|
+
"install-deps", "timeout-extra",
|
|
29
|
+
"license-level", "license-account", "license-password",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
/** `set` applies a change to a machine that has already booted, so it takes the
|
|
33
|
+
* post-boot subset of the provisioning flags plus `--license`'s credential flags.
|
|
34
|
+
*
|
|
35
|
+
* It is listed here for the **valueless** check, not for strictness: `set` still
|
|
36
|
+
* tolerates an unknown flag like every other non-creating command. The check that
|
|
37
|
+
* matters is arity — `quickchr set lab --device-mode` with no value parses as a
|
|
38
|
+
* boolean and would otherwise reach "Nothing to set", which blames the user for the
|
|
39
|
+
* wrong thing. */
|
|
40
|
+
const SET_ONLY_FLAGS = [
|
|
41
|
+
"name", "license", "level", "account", "password",
|
|
42
|
+
"device-mode", "device-mode-enable", "device-mode-disable",
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
export const ADD_FLAGS: readonly string[] = CREATE_FLAGS;
|
|
46
|
+
export const START_FLAGS: readonly string[] = [...CREATE_FLAGS, ...START_ONLY_FLAGS];
|
|
47
|
+
export const SET_FLAGS: readonly string[] = SET_ONLY_FLAGS;
|
|
48
|
+
|
|
49
|
+
/** Every flag that takes a value, across every command — `parseFlags`'s arity table.
|
|
50
|
+
*
|
|
51
|
+
* This is the parser's input, not only a validation list. Without it `parseFlags`
|
|
52
|
+
* guessed each flag's arity from the shape of the next argument — *if it does not start
|
|
53
|
+
* with `--`, consume it as the value* — which is one rule doing two jobs and getting one
|
|
54
|
+
* of them wrong: a boolean flag swallowed the following positional, so
|
|
55
|
+
* `quickchr start --vmnet-shared lab` set `vmnet-shared="lab"` and lost the machine name
|
|
56
|
+
* (#164). A flag absent from this set is boolean and never consumes an argument.
|
|
57
|
+
*
|
|
58
|
+
* It is also what makes a missing value visible. `parseFlags` stores `true` when a
|
|
59
|
+
* value-taking flag is followed by another flag or by nothing, and `flag()` turns a
|
|
60
|
+
* boolean back into `undefined` — so `quickchr add --name --version 7.24.3` used to
|
|
61
|
+
* reach `add()` with no name at all and quietly create an auto-named machine. Checking
|
|
62
|
+
* names alone does not catch that; the value has to be checked too.
|
|
63
|
+
*
|
|
64
|
+
* `--no-x` (which parses as `false`) is a deliberate negation, not a missing value,
|
|
65
|
+
* so only an explicit `true` is rejected.
|
|
66
|
+
*
|
|
67
|
+
* Both directions are asserted mechanically against the CLI source in
|
|
68
|
+
* `test/unit/cli-flag-arity.test.ts` — every name read as a value is here, and no name
|
|
69
|
+
* read as a boolean is — and the scan covers direct access (`flags["older-than"]`) as
|
|
70
|
+
* well as the helpers, because that is where the first gap was. A new flag cannot skip
|
|
71
|
+
* this table by being forgotten; the audit is the test's job, not a reviewer's. */
|
|
72
|
+
export const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
73
|
+
// add / start
|
|
74
|
+
"version", "channel", "arch", "name", "cpu", "mem", "accel",
|
|
75
|
+
"add-package", "port-base", "forward",
|
|
76
|
+
"add-network",
|
|
77
|
+
"boot-disk-format", "boot-size", "add-disk",
|
|
78
|
+
"device-mode", "device-mode-enable", "device-mode-disable",
|
|
79
|
+
"add-user", "timeout-extra",
|
|
80
|
+
"license-level", "license-account", "license-password",
|
|
81
|
+
// exec / qga / console
|
|
82
|
+
"via", "user", "password", "timeout", "path", "data", "script",
|
|
83
|
+
// set --license
|
|
84
|
+
"account", "level",
|
|
85
|
+
// networks sockets create
|
|
86
|
+
"mode", "port", "group",
|
|
87
|
+
// completions / logs
|
|
88
|
+
"shell", "lines",
|
|
89
|
+
// cache prune
|
|
90
|
+
"older-than", "max-age", "max-size",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/** Flags that quickchr used to accept, and what to write instead.
|
|
94
|
+
*
|
|
95
|
+
* `--vmnet-shared` / `--vmnet-bridge <iface>` were special cases for one network type
|
|
96
|
+
* that `--add-network` already expresses in general (#164). Removing them silently would
|
|
97
|
+
* repeat the defect they were removed for: a flag that is accepted and does nothing. A
|
|
98
|
+
* removed name stays known for as long as anyone might still type it, and says what
|
|
99
|
+
* replaced it. */
|
|
100
|
+
export const REMOVED_FLAGS: ReadonlyMap<string, string> = new Map([
|
|
101
|
+
["vmnet-shared", "--add-network shared"],
|
|
102
|
+
["vmnet-bridge", "--add-network bridged:<iface>"],
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/** Value-taking flags with a documented `--no-` spelling, where `false` is a real
|
|
106
|
+
* instruction rather than a value that went missing. `--no-device-mode` is the only
|
|
107
|
+
* one today; it is listed in `start --help`. Exported for the arity audit, which would
|
|
108
|
+
* otherwise read `flags["device-mode"] === false` as proof that it is a boolean. */
|
|
109
|
+
export const NEGATABLE_VALUE_FLAGS: ReadonlySet<string> = new Set(["device-mode"]);
|
|
110
|
+
|
|
111
|
+
/** Flags given without the value they require.
|
|
112
|
+
*
|
|
113
|
+
* Both spellings have to be caught. `--name` followed by another flag parses as
|
|
114
|
+
* `true`, and `--no-name` parses as `false`; either way `flag()` returns `undefined`
|
|
115
|
+
* and the machine gets an auto-generated name. Only a string (or a repeatable flag's
|
|
116
|
+
* string[]) is a value. */
|
|
117
|
+
export function valuelessFlags(
|
|
118
|
+
flags: Record<string, string | boolean | string[]>,
|
|
119
|
+
known: readonly string[],
|
|
120
|
+
): Array<{ name: string; negated: boolean }> {
|
|
121
|
+
const result: Array<{ name: string; negated: boolean }> = [];
|
|
122
|
+
for (const name of Object.keys(flags)) {
|
|
123
|
+
if (!known.includes(name) || !VALUE_FLAGS.has(name)) continue;
|
|
124
|
+
const value = flags[name];
|
|
125
|
+
if (typeof value === "string" || Array.isArray(value)) continue;
|
|
126
|
+
if (value === false && NEGATABLE_VALUE_FLAGS.has(name)) continue;
|
|
127
|
+
result.push({ name, negated: value === false });
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Human-readable complaint for `valuelessFlags()` output. Empty when nothing is missing. */
|
|
133
|
+
export function valuelessFlagMessage(
|
|
134
|
+
valueless: ReadonlyArray<{ name: string; negated: boolean }>,
|
|
135
|
+
command: string,
|
|
136
|
+
): string {
|
|
137
|
+
if (valueless.length === 0) return "";
|
|
138
|
+
const lines = valueless.map(({ name, negated }) =>
|
|
139
|
+
negated
|
|
140
|
+
? ` --no-${name} (not a supported negation — --${name} takes a value)`
|
|
141
|
+
: ` --${name} <value>`,
|
|
142
|
+
);
|
|
143
|
+
return [
|
|
144
|
+
`Error: flag${valueless.length > 1 ? "s" : ""} for 'quickchr ${command}' missing a value:`,
|
|
145
|
+
...lines,
|
|
146
|
+
`Run 'quickchr ${command} --help' for the full list.`,
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function editDistance(a: string, b: string): number {
|
|
151
|
+
// One rolling row — the suggestion only needs the distance, never the edit script.
|
|
152
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
153
|
+
for (let i = 1; i <= a.length; i++) {
|
|
154
|
+
const current = [i, ...Array<number>(b.length).fill(0)];
|
|
155
|
+
for (let j = 1; j <= b.length; j++) {
|
|
156
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
157
|
+
current[j] = Math.min(
|
|
158
|
+
(previous[j] ?? 0) + 1,
|
|
159
|
+
(current[j - 1] ?? 0) + 1,
|
|
160
|
+
(previous[j - 1] ?? 0) + cost,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
previous = current;
|
|
164
|
+
}
|
|
165
|
+
return previous[b.length] ?? 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The closest known flag to `name`, when one is close enough to be worth suggesting. */
|
|
169
|
+
export function suggestFlag(name: string, known: readonly string[]): string | undefined {
|
|
170
|
+
let best: string | undefined;
|
|
171
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
172
|
+
for (const candidate of known) {
|
|
173
|
+
const d = editDistance(name, candidate);
|
|
174
|
+
if (d < bestDistance) {
|
|
175
|
+
bestDistance = d;
|
|
176
|
+
best = candidate;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// 1/3 of the flag's length, so short flags need a near-exact match and long ones
|
|
180
|
+
// tolerate a transposition or two.
|
|
181
|
+
return bestDistance <= Math.max(1, Math.floor(name.length / 3)) ? best : undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Every flag in `flags` that `known` does not list, with a suggestion where there is one. */
|
|
185
|
+
export function unknownFlags(
|
|
186
|
+
flags: Record<string, string | boolean | string[]>,
|
|
187
|
+
known: readonly string[],
|
|
188
|
+
): Array<{ name: string; suggestion?: string }> {
|
|
189
|
+
return Object.keys(flags)
|
|
190
|
+
.filter((name) => !known.includes(name))
|
|
191
|
+
.map((name) => ({ name, suggestion: suggestFlag(name, known) }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Human-readable complaint for `unknownFlags()` output. Empty when nothing is unknown. */
|
|
195
|
+
export function unknownFlagMessage(
|
|
196
|
+
unknown: Array<{ name: string; suggestion?: string }>,
|
|
197
|
+
command: string,
|
|
198
|
+
): string {
|
|
199
|
+
if (unknown.length === 0) return "";
|
|
200
|
+
const lines = unknown.map(({ name, suggestion }) =>
|
|
201
|
+
suggestion ? ` --${name} (did you mean --${suggestion}?)` : ` --${name}`,
|
|
202
|
+
);
|
|
203
|
+
return [
|
|
204
|
+
`Error: unknown flag${unknown.length > 1 ? "s" : ""} for 'quickchr ${command}':`,
|
|
205
|
+
...lines,
|
|
206
|
+
`Run 'quickchr ${command} --help' for the full list.`,
|
|
207
|
+
].join("\n");
|
|
208
|
+
}
|
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
|
}
|