@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/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, but integration tests
6
- > (running a real CHR via QEMU for Windows) are not yet automated. Expect rough edges
7
- > on Windows — bug reports welcome.
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
- | `--bg` / `--background` | Run in background (default) | true |
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 tunnel between CHRs)
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 (`integration.yml`) sets this from its `routeros-target` dispatch input to test a release across platforms |
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.4.7",
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",
@@ -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
- 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
  }