@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/src/cli/index.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  import type { StartOptions, Arch, Channel, ServiceName, NetworkSpecifier } from "../lib/types.ts";
7
7
  import { parseNetworkSpecifier } from "../lib/network.ts";
8
8
  import { expandForwardSpec } from "../lib/forward-spec.ts";
9
+ import { ADD_FLAGS, REMOVED_FLAGS, START_FLAGS, VALUE_FLAGS, unknownFlags, unknownFlagMessage, valuelessFlags, valuelessFlagMessage } from "./flags.ts";
10
+ import { QuickCHRError } from "../lib/types.ts";
11
+ import { CENTRS_EXEC_TIP, CENTRS_SEE_ALSO, tip, tipsForError } from "./tips.ts";
9
12
  import {
10
13
  MIN_PROVISION_VERSION,
11
14
  PROVISIONING_BOOT_ONLY_SUMMARY,
@@ -17,8 +20,24 @@ import {
17
20
  const args = process.argv.slice(2);
18
21
  const command = args[0];
19
22
 
20
- /** Parse --flag=value and --flag value pairs from args. */
21
- export function parseFlags(argv: string[]): { flags: Record<string, string | boolean | string[]>; positional: string[] } {
23
+ /** Stop a removed flag at the parser, naming what replaced it. Thrown rather than
24
+ * printed so it reaches `main()`'s handler like any other error, and so no command can
25
+ * reach a state where the flag was accepted and ignored (#164). */
26
+ function rejectRemovedFlag(key: string): void {
27
+ const replacement = REMOVED_FLAGS.get(key);
28
+ if (replacement === undefined) return;
29
+ throw new QuickCHRError("INVALID_ARGUMENT", `--${key} was removed. Use: ${replacement}`);
30
+ }
31
+
32
+ /** Parse --flag=value and --flag value pairs from args.
33
+ *
34
+ * Arity comes from `VALUE_FLAGS`, never from the shape of the next argument: a flag
35
+ * that is not in that table is boolean and leaves the following argument alone, so a
36
+ * positional cannot be eaten by a flag that has no use for it (#164). */
37
+ export function parseFlags(
38
+ argv: string[],
39
+ valueFlags: ReadonlySet<string> = VALUE_FLAGS,
40
+ ): { flags: Record<string, string | boolean | string[]>; positional: string[] } {
22
41
  const flags: Record<string, string | boolean | string[]> = {};
23
42
  const positional: string[] = [];
24
43
 
@@ -33,13 +52,16 @@ export function parseFlags(argv: string[]): { flags: Record<string, string | boo
33
52
  const eqIdx = arg.indexOf("=");
34
53
  if (eqIdx !== -1) {
35
54
  const key = arg.slice(2, eqIdx);
55
+ rejectRemovedFlag(key);
36
56
  flags[key] = arg.slice(eqIdx + 1);
37
57
  } else if (arg.startsWith("--no-")) {
58
+ rejectRemovedFlag(arg.slice(5));
38
59
  flags[arg.slice(5)] = false;
39
60
  } else {
40
61
  const key = arg.slice(2);
62
+ rejectRemovedFlag(key);
41
63
  const next = argv[i + 1];
42
- if (next && !next.startsWith("--")) {
64
+ if (valueFlags.has(key) && next !== undefined && !next.startsWith("--")) {
43
65
  // Check if this is a repeatable flag
44
66
  const existing = flags[key];
45
67
  if (Array.isArray(existing)) {
@@ -68,6 +90,15 @@ function flag(flags: Record<string, string | boolean | string[]>, key: string):
68
90
  return undefined;
69
91
  }
70
92
 
93
+ function cacheTargetFlag(flags: Record<string, string | boolean | string[]>, key: string): string | undefined {
94
+ if (!(key in flags)) return undefined;
95
+ const value = flags[key];
96
+ if (typeof value !== "string" || value.length === 0) {
97
+ throw new Error(`--${key} requires a value`);
98
+ }
99
+ return value;
100
+ }
101
+
71
102
  function flagBool(flags: Record<string, string | boolean | string[]>, key: string): boolean {
72
103
  return flags[key] === true || flags[key] === "true";
73
104
  }
@@ -83,20 +114,10 @@ function csvList(values: string[]): string[] {
83
114
  return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
84
115
  }
85
116
 
86
- /** Build networks array from --add-network, --no-network, and legacy vmnet flags. */
117
+ /** Build networks array from --add-network and --no-network. */
87
118
  function buildNetworks(flags: Record<string, string | boolean | string[]>): NetworkSpecifier[] | undefined {
88
119
  const specs: NetworkSpecifier[] = [];
89
120
 
90
- // Legacy flags first
91
- if (flag(flags, "vmnet-shared") !== undefined) {
92
- specs.push(parseNetworkSpecifier("shared"));
93
- }
94
- const bridgeIface = flag(flags, "vmnet-bridge");
95
- if (bridgeIface) {
96
- specs.push(parseNetworkSpecifier(`bridged:${bridgeIface}`));
97
- }
98
-
99
- // New --add-network flags
100
121
  for (const raw of flagList(flags, "add-network")) {
101
122
  specs.push(parseNetworkSpecifier(raw));
102
123
  }
@@ -111,6 +132,28 @@ function buildNetworks(flags: Record<string, string | boolean | string[]>): Netw
111
132
  return specs;
112
133
  }
113
134
 
135
+ /** Exit with a flag-list error when a machine-creating command is given a flag it does
136
+ * not know. An unrecognised flag is a typo, and a typo that downloads 43 MB and creates
137
+ * a machine is not a good default (#156). */
138
+ function rejectUnknownFlags(
139
+ flags: Record<string, string | boolean | string[]>,
140
+ known: readonly string[],
141
+ command: string,
142
+ ): void {
143
+ const unknown = unknownFlags(flags, known);
144
+ if (unknown.length > 0) {
145
+ console.error(unknownFlagMessage(unknown, command));
146
+ process.exit(1);
147
+ }
148
+ // A known flag given without its value is the same hazard wearing a valid name:
149
+ // `add --name --version 7.24.3` would otherwise create an auto-named machine.
150
+ const valueless = valuelessFlags(flags, known);
151
+ if (valueless.length > 0) {
152
+ console.error(valuelessFlagMessage(valueless, command));
153
+ process.exit(1);
154
+ }
155
+ }
156
+
114
157
  /** True when interactive prompts must be suppressed (e.g. LLM / CI / pipe). */
115
158
  function isNoPrompt(): boolean {
116
159
  return !!process.env.QUICKCHR_NO_PROMPT || !process.stdout.isTTY || !process.stdin.isTTY;
@@ -187,8 +230,30 @@ export async function resolveSecureLoginFlag(flags: Record<string, string | bool
187
230
  return resolveSetting("secure-login").value === true ? true : undefined;
188
231
  }
189
232
 
233
+ /** True when the user asked for help on this subcommand.
234
+ *
235
+ * `--help` used to reach subcommands as an ordinary argument: `add --help` generated a
236
+ * name and downloaded 43 MB, and `networks sockets create --help` persisted a socket
237
+ * called `--help` (#156). Handling it here — before the dispatch — means a new
238
+ * subcommand cannot miss the guard.
239
+ *
240
+ * Everything after a bare `--` is the user's payload (a RouterOS command for `exec`),
241
+ * so the scan stops there. */
242
+ function wantsCommandHelp(argv: string[]): boolean {
243
+ for (const arg of argv) {
244
+ if (arg === "--") return false;
245
+ if (arg === "--help" || arg === "-h") return true;
246
+ }
247
+ return false;
248
+ }
249
+
190
250
  async function main() {
191
251
  try {
252
+ // Help before side effects — for every subcommand, present and future (#156).
253
+ if (command !== undefined && !command.startsWith("-") && wantsCommandHelp(args.slice(1))) {
254
+ printHelp(command);
255
+ return;
256
+ }
192
257
  switch (command) {
193
258
  case "add":
194
259
  await cmdAdd(args.slice(1));
@@ -294,6 +359,7 @@ async function main() {
294
359
  if (err.installHint) {
295
360
  console.error(` Hint: ${err.installHint}`);
296
361
  }
362
+ for (const line of tipsForError(err.code, command)) tip(line);
297
363
  if (err.code === "PROVISIONING_VERSION_UNSUPPORTED") {
298
364
  console.error(` Why this happened: ${provisioningSupportSummary(MIN_PROVISION_VERSION)}`);
299
365
  console.error(` ${PROVISIONING_BOOT_ONLY_SUMMARY}`);
@@ -552,6 +618,7 @@ function printMachineListWithTip(
552
618
 
553
619
  async function cmdAdd(argv: string[]) {
554
620
  const { flags, positional } = parseFlags(argv);
621
+ rejectUnknownFlags(flags, ADD_FLAGS, "add");
555
622
  const { QuickCHR } = await import("../lib/quickchr.ts");
556
623
  const { bold, formatPorts, formatNetworks, dim } = await import("./format.ts");
557
624
  const { resolveSetting } = await import("../lib/settings.ts");
@@ -656,6 +723,7 @@ async function cmdExec(argv: string[]) {
656
723
  } else {
657
724
  console.error("Usage: quickchr exec <name> <command...>\n Run a RouterOS CLI command on a running instance.");
658
725
  }
726
+ tip(CENTRS_EXEC_TIP);
659
727
  process.exit(1);
660
728
  }
661
729
 
@@ -1196,6 +1264,7 @@ async function cmdSetup() {
1196
1264
  async function cmdStart(argv: string[]) {
1197
1265
  const { flags, positional } = parseFlags(argv);
1198
1266
  applyTimeoutExtraShortFlag(argv, flags, positional);
1267
+ rejectUnknownFlags(flags, START_FLAGS, "start");
1199
1268
  // Before the --all branch: both start paths must see the same override.
1200
1269
  await applyAccelFlag(flags);
1201
1270
 
@@ -1479,8 +1548,13 @@ async function cmdList(argv: string[] = []) {
1479
1548
 
1480
1549
  const { table, statusIcon, formatPorts, formatNetworks, dim } = await import("./format.ts");
1481
1550
  const machines = QuickCHR.list();
1551
+ // A machine whose state went bad keeps its place in the listing. It still holds a
1552
+ // disk image, and `list` is where anyone looks first — deferring it to `doctor`
1553
+ // assumes you already suspect trouble, which a quietly absent machine does not
1554
+ // suggest. The listing itself succeeded, so the exit code stays 0 (#165).
1555
+ const unreadable = QuickCHR.listUnreadable();
1482
1556
 
1483
- if (machines.length === 0) {
1557
+ if (machines.length === 0 && unreadable.length === 0) {
1484
1558
  if (asJson) {
1485
1559
  console.log("[]");
1486
1560
  } else {
@@ -1490,7 +1564,10 @@ async function cmdList(argv: string[] = []) {
1490
1564
  }
1491
1565
 
1492
1566
  if (asJson) {
1493
- console.log(JSON.stringify(machines, null, 2));
1567
+ console.log(JSON.stringify([
1568
+ ...machines,
1569
+ ...unreadable.map((u) => ({ name: u.name, status: "unreadable", error: u.error })),
1570
+ ], null, 2));
1494
1571
  return;
1495
1572
  }
1496
1573
 
@@ -1504,8 +1581,17 @@ async function cmdList(argv: string[] = []) {
1504
1581
  formatPorts(m.ports),
1505
1582
  m.pid ? String(m.pid) : dim("—"),
1506
1583
  ]);
1584
+ for (const u of unreadable) {
1585
+ rows.push([statusIcon("error"), u.name, dim("unreadable"), dim("—"), dim("—"), dim("—"), dim("—")]);
1586
+ }
1507
1587
 
1508
1588
  console.log(table(headers, rows));
1589
+
1590
+ // The row says which machine; this says what to do about it, so neither takes a
1591
+ // second command to learn.
1592
+ for (const u of unreadable) {
1593
+ console.log(`\n${statusIcon("error")} ${u.name}: machine.json unreadable — 'quickchr remove ${u.name}' to clear it`);
1594
+ }
1509
1595
  }
1510
1596
 
1511
1597
  function shellQuoteEnv(value: string): string {
@@ -1713,8 +1799,21 @@ async function cmdRemove(argv: string[]) {
1713
1799
  return;
1714
1800
  }
1715
1801
 
1716
- const instance = QuickCHR.get(name);
1802
+ // A machine.json that does not parse throws out of get(), because a lookup by name
1803
+ // must not answer "no such machine" about a directory that exists (#165). `remove`
1804
+ // is the command whose job is to clear that state, so here it routes into the
1805
+ // orphan path below instead of surfacing the error (#155).
1806
+ let instance: ReturnType<typeof QuickCHR.get> = null;
1807
+ try {
1808
+ instance = QuickCHR.get(name);
1809
+ } catch { /* unreadable state → orphan */ }
1717
1810
  if (!instance) {
1811
+ // A half-created directory is unremovable any other way, and `remove` naming it
1812
+ // in the "not found" list while refusing to delete it was the worst of both (#155).
1813
+ if (QuickCHR.removeOrphan(name)) {
1814
+ console.log(`${bold(name)} removed (leftover directory — no readable machine.json).`);
1815
+ return;
1816
+ }
1718
1817
  console.error(machineNotFoundMessage(name));
1719
1818
  process.exit(1);
1720
1819
  }
@@ -2053,6 +2152,7 @@ async function cmdNetworks(argv: string[]) {
2053
2152
  async function showNetworkOverview() {
2054
2153
  const { detectPlatform, detectPhysicalInterfaces } = await import("../lib/platform.ts");
2055
2154
  const { listNamedSockets } = await import("../lib/socket-registry.ts");
2155
+ const { describeSocketTransport } = await import("../lib/network.ts");
2056
2156
  const { statusIcon, bold, dim } = await import("./format.ts");
2057
2157
 
2058
2158
  const platform = await detectPlatform();
@@ -2126,7 +2226,8 @@ async function showNetworkOverview() {
2126
2226
  console.log(" Named Sockets:");
2127
2227
  for (const s of sockets) {
2128
2228
  const members = s.members.length > 0 ? dim(` members: ${s.members.join(", ")}`) : "";
2129
- console.log(` ${s.name.padEnd(16)}${s.mode} port:${s.port}${members}`);
2229
+ // Not `port:${s.port}` — a dgram link has no port and printed `port:undefined`.
2230
+ console.log(` ${s.name.padEnd(16)}${s.mode} ${describeSocketTransport(s)}${members}`);
2130
2231
  }
2131
2232
  } else {
2132
2233
  console.log(" Named Sockets:");
@@ -2153,8 +2254,11 @@ async function showInterfaces() {
2153
2254
  console.log(table(["Device", "Name", "Alias", "MAC"], rows));
2154
2255
  }
2155
2256
 
2257
+ const SOCKET_MODES = ["dgram", "listen-connect", "mcast"] as const;
2258
+
2156
2259
  async function handleSockets(argv: string[]) {
2157
- const { listNamedSockets, createNamedSocket, removeNamedSocket } = await import("../lib/socket-registry.ts");
2260
+ const { listNamedSockets, createNamedSocket, removeNamedSocket, defaultSocketMode } = await import("../lib/socket-registry.ts");
2261
+ const { describeSocketTransport } = await import("../lib/network.ts");
2158
2262
  const { bold, dim, table: fmtTable } = await import("./format.ts");
2159
2263
 
2160
2264
  const action = argv[0];
@@ -2168,22 +2272,73 @@ async function handleSockets(argv: string[]) {
2168
2272
  const rows = sockets.map((s) => [
2169
2273
  s.name,
2170
2274
  s.mode,
2171
- String(s.port),
2172
- s.mcastGroup ?? "",
2275
+ describeSocketTransport(s),
2173
2276
  s.members.join(", ") || dim("none"),
2174
2277
  ]);
2175
- console.log(fmtTable(["Name", "Mode", "Port", "Group", "Members"], rows));
2278
+ console.log(fmtTable(["Name", "Mode", "Transport", "Members"], rows));
2176
2279
  return;
2177
2280
  }
2178
2281
 
2179
2282
  if (action === "create") {
2180
- const name = argv[1];
2283
+ const { flags, positional } = parseFlags(argv.slice(1));
2284
+ const name = positional[0];
2181
2285
  if (!name) {
2182
- console.error("Usage: quickchr networks sockets create <name>");
2286
+ console.error("Usage: quickchr networks sockets create <name> [--mode dgram|listen-connect|mcast] [--port <n>] [--group <addr>]");
2183
2287
  process.exit(1);
2184
2288
  }
2185
- const entry = createNamedSocket(name);
2186
- console.log(`Created named socket: ${bold(entry.name)} (${entry.mode} port:${entry.port})`);
2289
+ // `parseFlags` stores `true` for a flag with no value and `flag()` turns that back
2290
+ // into `undefined`, so a bare `--mode` would silently fall through to the default
2291
+ // — the same shape as #156's valueless-flag bug.
2292
+ for (const key of ["mode", "port", "group"]) {
2293
+ if (typeof flags[key] === "boolean") {
2294
+ console.error(`Error: --${key} requires a value.`);
2295
+ process.exit(1);
2296
+ }
2297
+ }
2298
+ const mode = flag(flags, "mode");
2299
+ if (mode !== undefined && !(SOCKET_MODES as readonly string[]).includes(mode)) {
2300
+ console.error(`Error: unknown --mode "${mode}" — expected one of ${SOCKET_MODES.join(", ")}.`);
2301
+ process.exit(1);
2302
+ }
2303
+ const portRaw = flag(flags, "port");
2304
+ // Number(), not parseInt(): parseInt stops at the first non-digit, so "4000abc"
2305
+ // came through as 4000.
2306
+ const port = portRaw === undefined ? undefined : Number(portRaw);
2307
+ if (portRaw !== undefined && (!/^\d+$/.test(portRaw) || !Number.isInteger(port) || (port as number) < 1 || (port as number) > 65535)) {
2308
+ console.error(`Error: --port must be a number between 1 and 65535, got "${portRaw}".`);
2309
+ process.exit(1);
2310
+ }
2311
+ const group = flag(flags, "group");
2312
+ const effectiveMode = mode ?? defaultSocketMode();
2313
+ if (group !== undefined && effectiveMode !== "mcast") {
2314
+ console.error(`Error: --group only applies to --mode mcast (this socket is ${effectiveMode}).`);
2315
+ process.exit(1);
2316
+ }
2317
+ if (portRaw !== undefined && effectiveMode === "dgram") {
2318
+ console.error("Error: --port does not apply to --mode dgram — a unix datagram pair uses no port.");
2319
+ process.exit(1);
2320
+ }
2321
+ const entry = createNamedSocket(name, {
2322
+ mode: mode as (typeof SOCKET_MODES)[number] | undefined,
2323
+ port,
2324
+ mcastGroup: group,
2325
+ });
2326
+ console.log(`Created named socket: ${bold(entry.name)} (${entry.mode})`);
2327
+ console.log(` Transport: ${describeSocketTransport(entry)}`);
2328
+ if (entry.mode === "mcast") {
2329
+ // The caveat belongs at create time, not in a doc: mcast fails with no error
2330
+ // anywhere — interfaces up, addresses assigned, 100% loss. Three distinct
2331
+ // hazards, not one (#167): a macOS delivery bug, a sandbox permission block,
2332
+ // and — because quickchr passes no localaddr= — a group that rides the host's
2333
+ // default interface rather than loopback.
2334
+ console.log(dim(" Note: UDP multicast is the only N-way segment, and the only one that"));
2335
+ console.log(dim(" fails silently. It does not deliver on macOS, and it is refused"));
2336
+ console.log(dim(" in sandboxes that block unconnected UDP sends (some Linux CI)."));
2337
+ console.log(dim(` This group is not confined to loopback: ${entry.mcastGroup} rides the host's`));
2338
+ console.log(dim(" default multicast interface, so another machine on your LAN using"));
2339
+ console.log(dim(" the same group joins this segment. Use a unique group, or prefer"));
2340
+ console.log(dim(" 'dgram' for two machines."));
2341
+ }
2187
2342
  return;
2188
2343
  }
2189
2344
 
@@ -2217,6 +2372,21 @@ Usage:
2217
2372
  quickchr networks sockets create <n> Create a named socket
2218
2373
  quickchr networks sockets remove <n> Remove a named socket
2219
2374
 
2375
+ Socket create options:
2376
+ --mode <mode> dgram | listen-connect | mcast
2377
+ Default: dgram (POSIX), listen-connect (Windows)
2378
+ --port <n> TCP/UDP port for listen-connect and mcast
2379
+ --group <addr> Multicast group for --mode mcast (default 230.0.0.1)
2380
+
2381
+ Transports:
2382
+ dgram A pair of unix datagram sockets. No ports, no UDP syscalls,
2383
+ and either machine may start first. Two machines. POSIX only.
2384
+ listen-connect A TCP pair on loopback. Two machines; the first to join takes
2385
+ the listening end and keeps it, and must be started first
2386
+ thereafter. The Windows default.
2387
+ mcast UDP multicast — the only N-way segment, but broken on macOS
2388
+ and in UDP-blocked sandboxes, and silent when it fails.
2389
+
2220
2390
  Aliases: quickchr net`);
2221
2391
  }
2222
2392
 
@@ -2418,6 +2588,45 @@ async function cmdCache(argv: string[]) {
2418
2588
  };
2419
2589
 
2420
2590
  switch (sub) {
2591
+ case "add": {
2592
+ const { cacheAdd } = await import("../lib/cache-api.ts");
2593
+ const { resolveSetting } = await import("../lib/settings.ts");
2594
+ const version = cacheTargetFlag(flags, "version");
2595
+ const explicitChannel = cacheTargetFlag(flags, "channel") as Channel | undefined;
2596
+ const channel = version !== undefined || explicitChannel !== undefined
2597
+ ? explicitChannel
2598
+ : resolveSetting("default-channel").value as Channel;
2599
+ const arch = (cacheTargetFlag(flags, "arch") ?? resolveSetting("default-arch").value) as Arch | "auto";
2600
+ const asJson = flagBool(flags, "json");
2601
+ const quiet = { status() {}, debug() {}, warn() {} };
2602
+ const result = await cacheAdd({ version, channel, arch, logger: asJson ? quiet : undefined });
2603
+ if (asJson) {
2604
+ console.log(JSON.stringify(result));
2605
+ } else {
2606
+ console.log(`${result.cacheHit ? "Already cached" : "Cached"}: RouterOS ${result.version} (${result.arch})`);
2607
+ console.log(`Path: ${result.path}`);
2608
+ }
2609
+ break;
2610
+ }
2611
+ case "key": {
2612
+ const { cacheKey } = await import("../lib/cache-api.ts");
2613
+ const version = cacheTargetFlag(flags, "version");
2614
+ const explicitChannel = cacheTargetFlag(flags, "channel") as Channel | undefined;
2615
+ const { resolveSetting } = await import("../lib/settings.ts");
2616
+ const channel = version !== undefined || explicitChannel !== undefined
2617
+ ? explicitChannel
2618
+ : resolveSetting("default-channel").value as Channel;
2619
+ const arch = (cacheTargetFlag(flags, "arch") ?? resolveSetting("default-arch").value) as Arch | "auto";
2620
+ const result = await cacheKey({ version, channel, arch });
2621
+ if (flagBool(flags, "json")) {
2622
+ console.log(JSON.stringify(result));
2623
+ } else {
2624
+ console.log(`dir=${result.dir}`);
2625
+ console.log(`version=${result.version}`);
2626
+ console.log(`arch=${result.arch}`);
2627
+ }
2628
+ break;
2629
+ }
2421
2630
  case "list":
2422
2631
  case "ls": {
2423
2632
  const entries = listCacheEntries();
@@ -2695,6 +2904,7 @@ Commands:
2695
2904
  env <name> [--json] Print subprocess environment for a running machine
2696
2905
  console <name> Attach to serial console of a running instance
2697
2906
  exec <name> <command> Run a RouterOS CLI command on a running instance
2907
+ (raw /rest/execute — see 'quickchr exec --help' for centrs)
2698
2908
  remove [<name>|--all] Remove instance(s) and disk
2699
2909
  clean [<name>|--all] Reset instance disk to fresh image
2700
2910
  get <name> [group] Show machine config (license, device-mode, admin)
@@ -2703,7 +2913,7 @@ Commands:
2703
2913
  disk <name> Show disk details for an instance
2704
2914
  snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
2705
2915
  networks Network discovery & socket management
2706
- cache [list|prune|clear] Manage cached CHR images
2916
+ cache [add|key|list|prune|clear] Manage cached CHR images
2707
2917
  settings [print|get|set|reset] Manage quickchr's own global settings (quickchr.env)
2708
2918
  completions Manage shell completions (Tab completion)
2709
2919
  logs <name> Tail the QEMU log for an instance
@@ -2768,9 +2978,7 @@ Options:
2768
2978
  --device-mode <m> Set device-mode on first boot: rose|advanced|basic|home|auto|skip
2769
2979
  --add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
2770
2980
  socket::<name>, tap:<if>. Default: single user NIC.
2771
- --no-network Start with no NICs (headless)
2772
- --vmnet-shared [deprecated] Use --add-network shared
2773
- --vmnet-bridge <if> [deprecated] Use --add-network bridged:<if>`);
2981
+ --no-network Start with no NICs (headless)`);
2774
2982
  break;
2775
2983
  case "setup":
2776
2984
  console.log(`quickchr setup
@@ -2802,7 +3010,9 @@ Options:
2802
3010
  Examples:
2803
3011
  quickchr exec my-chr /system/resource/print
2804
3012
  quickchr exec my-chr ":put [:serialize to=json [/ip/address/print]]"
2805
- quickchr exec my-chr "/log/info message=hello"`);
3013
+ quickchr exec my-chr "/log/info message=hello"
3014
+
3015
+ ${CENTRS_SEE_ALSO}`);
2806
3016
  break;
2807
3017
  case "start":
2808
3018
  console.log(`quickchr start [<name>] [options]
@@ -2849,8 +3059,6 @@ Options:
2849
3059
  --add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
2850
3060
  socket::<name>, tap:<if>. Default: single user NIC.
2851
3061
  --no-network Start with no NICs (headless)
2852
- --vmnet-shared [deprecated] Use --add-network shared
2853
- --vmnet-bridge <if> [deprecated] Use --add-network bridged:<if>
2854
3062
  --install-all-packages Install all packages from all_packages.zip
2855
3063
  --license-level <l> Apply trial license: p1 (1 Gbps), p10 (10 Gbps), unlimited
2856
3064
  --license-account <a> MikroTik account email (or use MIKROTIK_WEB_ACCOUNT env var)
@@ -2875,6 +3083,7 @@ Options:
2875
3083
  Alias for 'quickchr list'. See 'quickchr help list'.`);
2876
3084
  break;
2877
3085
  case "list":
3086
+ case "ls":
2878
3087
  console.log(`quickchr list [<name>] [--json]
2879
3088
 
2880
3089
  List all CHR instances or show detailed info for one.
@@ -3028,6 +3237,14 @@ Credential resolution order (highest priority first):
3028
3237
  3. OS native secret store (macOS Keychain, Linux GNOME Keyring, Windows Credential Manager)
3029
3238
  4. ~/.config/quickchr/credentials.json (fallback)`);
3030
3239
  break;
3240
+ case "qga":
3241
+ console.log(`quickchr qga <name> <operation> [options]
3242
+
3243
+ Query the QEMU Guest Agent on a running CHR instance.
3244
+ QGA is x86-only — the arm64 CHR image does not start the guest agent.
3245
+
3246
+ Run 'quickchr qga <name>' with no operation to list every operation.`);
3247
+ break;
3031
3248
  case "clean":
3032
3249
  console.log(`quickchr clean [<name>] [--all]
3033
3250
 
@@ -3036,6 +3253,7 @@ Credential resolution order (highest priority first):
3036
3253
  --all Clean all instances.`);
3037
3254
  break;
3038
3255
  case "remove":
3256
+ case "rm":
3039
3257
  console.log(`quickchr remove [<name>] [--all]
3040
3258
 
3041
3259
  <name> Remove an instance (stops if running, deletes disk and state).
@@ -3057,11 +3275,16 @@ Options:
3057
3275
  showNetworksHelp();
3058
3276
  break;
3059
3277
  case "cache":
3060
- console.log(`quickchr cache <list|prune|clear> [options]
3278
+ console.log(`quickchr cache <add|key|list|prune|clear> [options]
3061
3279
 
3062
3280
  Manage cached CHR images in the data dir cache directory.
3063
3281
 
3064
3282
  Subcommands:
3283
+ add [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
3284
+ Resolve, download, and cache one CHR image without booting.
3285
+ key [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
3286
+ Print cache directory, resolved version, and architecture for CI.
3287
+ Without --json, output is GITHUB_OUTPUT-compatible key=value lines.
3065
3288
  list [--json] List cached images (basename, version, arch, size, age, in-use).
3066
3289
  prune [options] Evict cached images per policy. Never evicts in-use images.
3067
3290
  --older-than <ver> Evict images with version < <ver>.
@@ -3110,6 +3333,7 @@ Credentials (--add-user, MIKROTIK_WEB_ACCOUNT/PASSWORD) are never stored here.`)
3110
3333
  break;
3111
3334
  default:
3112
3335
  console.log(`No detailed help for '${command}'.`);
3336
+ console.log("Run 'quickchr help' for the command list.");
3113
3337
  }
3114
3338
  }
3115
3339
 
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Tips — short, dim, one-line pointers at the thing that would have gone better.
3
+ *
4
+ * Three rules, so tips stay useful instead of becoming chrome:
5
+ *
6
+ * 1. **stderr, always.** stdout is the command's result and has to stay parseable:
7
+ * `quickchr inspect --json | jq` must not have to filter advice out of its input.
8
+ * 2. **A tip names a next command or a better-suited tool.** It never restates what
9
+ * just happened, and it never appears where the command already said the thing.
10
+ * 3. **Suppressible.** `QUICKCHR_NO_TIPS=1` silences them. Not gated on TTY: the
11
+ * audience for "you probably wanted X" is agents and first-time users, both of
12
+ * which usually run without one.
13
+ */
14
+
15
+ /** Where quickchr stops and a validating RouterOS client starts.
16
+ *
17
+ * quickchr's `exec` is a thin `/rest/execute` pipe: it will happily send a command
18
+ * RouterOS rejects, and hand back the rejection. centrs validates a RouterOS-shaped
19
+ * command before running it and has a per-verb help system — the difference between
20
+ * one round trip and five. It takes a verb first (`retrieve` to read, `execute` to
21
+ * write, `explain` to analyze without running) and resolves the machine through
22
+ * `quickchr inspect`; see docs/centrs-interface.md. */
23
+ export const CENTRS_EXEC_TIP =
24
+ "centrs validates RouterOS commands before running them: centrs execute --quickchr <name> <command>";
25
+
26
+ /** The same pointer, as a help-text block. Help is where a reader is already asking
27
+ * "what should I use for this?", so it gets the fuller version. */
28
+ export const CENTRS_SEE_ALSO = `See also — centrs (@tikoci/centrs) validates a RouterOS-shaped command
29
+ before running it, and has per-verb help. 'quickchr exec' does not: it is a
30
+ raw /rest/execute pipe, and whatever you type is what RouterOS is asked to run.
31
+
32
+ centrs retrieve --quickchr <name> /system/resource Read state
33
+ centrs execute --quickchr <name> <command> Run a read/write command
34
+ centrs explain <command> Analyze without running it
35
+
36
+ centrs resolves the machine through 'quickchr inspect' — it never reads machine.json.
37
+ Install: bun add -g @tikoci/centrs`;
38
+
39
+ /** True when tips are switched off for this run. */
40
+ export function tipsSuppressed(): boolean {
41
+ const value = process.env.QUICKCHR_NO_TIPS;
42
+ return value !== undefined && value !== "" && value !== "0";
43
+ }
44
+
45
+ /** Print one tip line to stderr. No-op when tips are suppressed. */
46
+ export function tip(line: string): void {
47
+ if (tipsSuppressed()) return;
48
+ const dim = process.env.NO_COLOR ? (s: string) => s : (s: string) => `\x1b[2m${s}\x1b[0m`;
49
+ console.error(dim(`tip: ${line}`));
50
+ }
51
+
52
+ /** Tips that belong with a specific error code, printed by the CLI's error handler.
53
+ * `command` is the subcommand that failed, so a tip can stay scoped to where it helps. */
54
+ export function tipsForError(code: string, command?: string): string[] {
55
+ if (code === "EXEC_FAILED" && command === "exec") return [CENTRS_EXEC_TIP];
56
+ return [];
57
+ }
package/src/index.ts CHANGED
@@ -71,6 +71,16 @@ export type {
71
71
  export { QuickCHRError, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./lib/types.ts";
72
72
  export type { ErrorCode, HostInterface } from "./lib/types.ts";
73
73
 
74
+ // Cache utilities
75
+ export { cacheAdd, cacheKey, CACHE_VERSION_UNRESOLVED } from "./lib/cache-api.ts";
76
+ export type {
77
+ CacheAddOptions,
78
+ CacheAddResult,
79
+ CacheKeyResult,
80
+ CacheTargetOptions,
81
+ CacheVersionResolver,
82
+ } from "./lib/cache-api.ts";
83
+
74
84
  // Interface detection
75
85
  export { detectPhysicalInterfaces, resolveInterfaceAlias } from "./lib/platform.ts";
76
86