@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/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
|
}
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2172
|
-
s.mcastGroup ?? "",
|
|
2275
|
+
describeSocketTransport(s),
|
|
2173
2276
|
s.members.join(", ") || dim("none"),
|
|
2174
2277
|
]);
|
|
2175
|
-
console.log(fmtTable(["Name", "Mode", "
|
|
2278
|
+
console.log(fmtTable(["Name", "Mode", "Transport", "Members"], rows));
|
|
2176
2279
|
return;
|
|
2177
2280
|
}
|
|
2178
2281
|
|
|
2179
2282
|
if (action === "create") {
|
|
2180
|
-
const
|
|
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
|
-
|
|
2186
|
-
|
|
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
|
|
package/src/cli/tips.ts
ADDED
|
@@ -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
|
|
|
@@ -108,6 +118,7 @@ export type { QgaCommandInfo } from "./lib/qga.ts";
|
|
|
108
118
|
|
|
109
119
|
// Console exec utilities
|
|
110
120
|
export { consoleExec, isConsoleReady, stripAnsi } from "./lib/console.ts";
|
|
121
|
+
export type { ConsoleExecResult } from "./lib/console.ts";
|
|
111
122
|
|
|
112
123
|
// Channel utilities
|
|
113
124
|
export { isQgaReady } from "./lib/channels.ts";
|