@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/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/cli/wizard.ts
CHANGED
|
@@ -81,6 +81,11 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
81
81
|
if (!/^\d+\.\d+(\.\d+)?(beta\d+|rc\d+)?$/.test(v)) {
|
|
82
82
|
return "Invalid version format";
|
|
83
83
|
}
|
|
84
|
+
// RouterOS 6 is a CLI-only boot target (#182) — the wizard offers
|
|
85
|
+
// provisioning at every step, none of which 6.x can take.
|
|
86
|
+
if (/^6\./.test(v)) {
|
|
87
|
+
return "RouterOS 6 is boot-only and not offered here — use: quickchr add --version <6.x> --arch x86";
|
|
88
|
+
}
|
|
84
89
|
},
|
|
85
90
|
});
|
|
86
91
|
if (clack.isCancel(ver)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
@@ -557,7 +562,8 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
557
562
|
const background = await clack.select({
|
|
558
563
|
message: "Run mode:",
|
|
559
564
|
options: [
|
|
560
|
-
|
|
565
|
+
// Not "returns immediately": a background start waits for RouterOS to be ready (#159).
|
|
566
|
+
{ value: true, label: "Background", hint: "console logged; returns when RouterOS is ready" },
|
|
561
567
|
{ value: false, label: "Foreground", hint: "serial console on stdio" },
|
|
562
568
|
],
|
|
563
569
|
initialValue: true,
|
|
@@ -566,8 +572,10 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
566
572
|
|
|
567
573
|
// 11. Confirm
|
|
568
574
|
const opts: StartOptions = {
|
|
575
|
+
// The channel has already been resolved into `version` above; passing both reads
|
|
576
|
+
// to start() as a caller asking for two releases.
|
|
569
577
|
version,
|
|
570
|
-
channel,
|
|
578
|
+
channel: version ? undefined : channel,
|
|
571
579
|
arch,
|
|
572
580
|
name: name || undefined,
|
|
573
581
|
cpu: Number(cpu),
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,8 @@ export type {
|
|
|
64
64
|
ServiceEndpoint,
|
|
65
65
|
ServiceName,
|
|
66
66
|
SnapshotInfo,
|
|
67
|
+
ShutdownOptions,
|
|
68
|
+
ShutdownResult,
|
|
67
69
|
SshServiceEndpoint,
|
|
68
70
|
StartOptions,
|
|
69
71
|
} from "./lib/types.ts";
|
|
@@ -71,6 +73,16 @@ export type {
|
|
|
71
73
|
export { QuickCHRError, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./lib/types.ts";
|
|
72
74
|
export type { ErrorCode, HostInterface } from "./lib/types.ts";
|
|
73
75
|
|
|
76
|
+
// Cache utilities
|
|
77
|
+
export { cacheAdd, cacheKey, CACHE_VERSION_UNRESOLVED } from "./lib/cache-api.ts";
|
|
78
|
+
export type {
|
|
79
|
+
CacheAddOptions,
|
|
80
|
+
CacheAddResult,
|
|
81
|
+
CacheKeyResult,
|
|
82
|
+
CacheTargetOptions,
|
|
83
|
+
CacheVersionResolver,
|
|
84
|
+
} from "./lib/cache-api.ts";
|
|
85
|
+
|
|
74
86
|
// Interface detection
|
|
75
87
|
export { detectPhysicalInterfaces, resolveInterfaceAlias } from "./lib/platform.ts";
|
|
76
88
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native API (`/login` on guest port 8728) as a readiness signal.
|
|
3
|
+
*
|
|
4
|
+
* Built for RouterOS 6, which has no REST API, so the REST probe can never succeed
|
|
5
|
+
* against it — the field report behind #182 watched a 6.49 guest answer www, ssh and
|
|
6
|
+
* api from 15 s in, then get removed at 180 s for not answering `/rest`. It is written
|
|
7
|
+
* as a general alternative to the REST probe rather than a v6 special case: a 7.x
|
|
8
|
+
* guest with REST disabled is the same problem.
|
|
9
|
+
*
|
|
10
|
+
* Only the post-6.43 plain login is spoken (`=name=` + `=password=` in one sentence).
|
|
11
|
+
* Grounded against CHR 6.49.23 under HVF, 2026-10-21:
|
|
12
|
+
*
|
|
13
|
+
* - `admin` / empty password → `!done`, as one 7-byte reply.
|
|
14
|
+
* - wrong user or password → `!trap =message=invalid user name or password (6)`
|
|
15
|
+
* followed by `!done`, in the same read.
|
|
16
|
+
*
|
|
17
|
+
* Either answer means the API service is up, the way a REST 401 does — the probe is a
|
|
18
|
+
* readiness check, not a credential check.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { connect } from "node:net";
|
|
22
|
+
|
|
23
|
+
/** Encode one API word length (RouterOS API "length" prefix, 1–5 bytes). */
|
|
24
|
+
export function encodeApiLength(length: number): Uint8Array {
|
|
25
|
+
if (length < 0x80) return Uint8Array.of(length);
|
|
26
|
+
if (length < 0x4000) return Uint8Array.of((length >> 8) | 0x80, length & 0xff);
|
|
27
|
+
if (length < 0x200000) return Uint8Array.of((length >> 16) | 0xc0, (length >> 8) & 0xff, length & 0xff);
|
|
28
|
+
if (length < 0x10000000) {
|
|
29
|
+
return Uint8Array.of((length >>> 24) | 0xe0, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff);
|
|
30
|
+
}
|
|
31
|
+
return Uint8Array.of(0xf0, (length >>> 24) & 0xff, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A sentence is its words, each length-prefixed, then a zero-length word. */
|
|
35
|
+
export function encodeApiSentence(words: string[]): Uint8Array {
|
|
36
|
+
const encoder = new TextEncoder();
|
|
37
|
+
const parts: Uint8Array[] = [];
|
|
38
|
+
for (const word of words) {
|
|
39
|
+
const bytes = encoder.encode(word);
|
|
40
|
+
parts.push(encodeApiLength(bytes.length), bytes);
|
|
41
|
+
}
|
|
42
|
+
parts.push(Uint8Array.of(0));
|
|
43
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
44
|
+
let offset = 0;
|
|
45
|
+
for (const part of parts) {
|
|
46
|
+
out.set(part, offset);
|
|
47
|
+
offset += part.length;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Decode a length prefix at `offset`. `null` when the buffer ends inside it. */
|
|
53
|
+
function decodeApiLength(buf: Uint8Array, offset: number): { length: number; size: number } | null {
|
|
54
|
+
const first = buf[offset];
|
|
55
|
+
if (first === undefined) return null;
|
|
56
|
+
const size = (first & 0x80) === 0 ? 1
|
|
57
|
+
: (first & 0xc0) === 0x80 ? 2
|
|
58
|
+
: (first & 0xe0) === 0xc0 ? 3
|
|
59
|
+
: (first & 0xf0) === 0xe0 ? 4
|
|
60
|
+
: 5;
|
|
61
|
+
if (offset + size > buf.length) return null;
|
|
62
|
+
let length = size === 5 ? 0 : first & (0xff >> size);
|
|
63
|
+
for (let i = 1; i < size; i++) length = length * 256 + (buf[offset + i] ?? 0);
|
|
64
|
+
return { length, size };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every complete sentence in `buf`; a trailing partial sentence is left out. */
|
|
68
|
+
export function parseApiSentences(buf: Uint8Array): string[][] {
|
|
69
|
+
const decoder = new TextDecoder();
|
|
70
|
+
const sentences: string[][] = [];
|
|
71
|
+
let words: string[] = [];
|
|
72
|
+
let offset = 0;
|
|
73
|
+
while (offset < buf.length) {
|
|
74
|
+
const prefix = decodeApiLength(buf, offset);
|
|
75
|
+
if (!prefix || offset + prefix.size + prefix.length > buf.length) break;
|
|
76
|
+
offset += prefix.size;
|
|
77
|
+
if (prefix.length === 0) {
|
|
78
|
+
sentences.push(words);
|
|
79
|
+
words = [];
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
words.push(decoder.decode(buf.subarray(offset, offset + prefix.length)));
|
|
83
|
+
offset += prefix.length;
|
|
84
|
+
}
|
|
85
|
+
return sentences;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ApiLoginResult =
|
|
89
|
+
/** `!done` with no `!trap` — logged in. */
|
|
90
|
+
| { outcome: "done" }
|
|
91
|
+
/** `!trap` then `!done` — the service answered and refused the credentials. */
|
|
92
|
+
| { outcome: "trap"; message: string }
|
|
93
|
+
/** `!fatal` — the service answered and closed the session (e.g. too many sessions). */
|
|
94
|
+
| { outcome: "fatal"; message: string };
|
|
95
|
+
|
|
96
|
+
/** What the reply sentences say, once a `!done` or `!fatal` has arrived. `null` while
|
|
97
|
+
* the login is still unanswered. */
|
|
98
|
+
export function classifyLoginReply(sentences: string[][]): ApiLoginResult | null {
|
|
99
|
+
const messageOf = (sentence: string[]) =>
|
|
100
|
+
sentence.find((w) => w.startsWith("=message="))?.slice("=message=".length) ?? sentence.slice(1).join(" ");
|
|
101
|
+
const fatal = sentences.find((s) => s[0] === "!fatal");
|
|
102
|
+
if (fatal) return { outcome: "fatal", message: messageOf(fatal) };
|
|
103
|
+
if (!sentences.some((s) => s[0] === "!done")) return null;
|
|
104
|
+
const trap = sentences.find((s) => s[0] === "!trap");
|
|
105
|
+
return trap ? { outcome: "trap", message: messageOf(trap) } : { outcome: "done" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One `/login` round trip on `127.0.0.1:<port>`. Rejects on a socket error, on a close
|
|
109
|
+
* before the reply completes ("closed before reply"), and on the deadline
|
|
110
|
+
* ("apiLogin timeout") — the shapes `classifyProbeError()` sorts into outcomes. */
|
|
111
|
+
export function apiLogin(port: number, user: string, password: string, timeoutMs = 3000): Promise<ApiLoginResult> {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const socket = connect(port, "127.0.0.1");
|
|
114
|
+
let received = new Uint8Array(0);
|
|
115
|
+
let settled = false;
|
|
116
|
+
const finish = (fn: () => void) => {
|
|
117
|
+
if (settled) return;
|
|
118
|
+
settled = true;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
socket.destroy();
|
|
121
|
+
fn();
|
|
122
|
+
};
|
|
123
|
+
const timer = setTimeout(() => finish(() => reject(new Error("apiLogin timeout"))), timeoutMs);
|
|
124
|
+
socket.on("connect", () => {
|
|
125
|
+
socket.write(encodeApiSentence(["/login", `=name=${user}`, `=password=${password}`]));
|
|
126
|
+
});
|
|
127
|
+
socket.on("data", (chunk: Uint8Array) => {
|
|
128
|
+
const next = new Uint8Array(received.length + chunk.length);
|
|
129
|
+
next.set(received);
|
|
130
|
+
next.set(chunk, received.length);
|
|
131
|
+
received = next;
|
|
132
|
+
const result = classifyLoginReply(parseApiSentences(received));
|
|
133
|
+
if (result) finish(() => resolve(result));
|
|
134
|
+
});
|
|
135
|
+
socket.on("error", (err) => finish(() => reject(err)));
|
|
136
|
+
socket.on("close", () => finish(() => reject(new Error("closed before reply"))));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Public cache operations for CI consumers and the quickchr CLI. */
|
|
2
|
+
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ensureCachedImage, isUsableCachedImage } from "./images.ts";
|
|
5
|
+
import type { ProgressLogger } from "./log.ts";
|
|
6
|
+
import { getCacheDir } from "./state.ts";
|
|
7
|
+
import { ARCHES, CHANNELS, QuickCHRError, type Arch, type Channel } from "./types.ts";
|
|
8
|
+
import { chrImageBasename, isValidVersion, resolveVersion } from "./versions.ts";
|
|
9
|
+
|
|
10
|
+
export const CACHE_VERSION_UNRESOLVED = "unresolved";
|
|
11
|
+
const CACHE_VERSION_MAX_LENGTH = 32;
|
|
12
|
+
|
|
13
|
+
export interface CacheTargetOptions {
|
|
14
|
+
/** Resolve this RouterOS channel. Mutually exclusive with `version`. */
|
|
15
|
+
channel?: Channel;
|
|
16
|
+
/** Use this concrete RouterOS version without a network resolution request. */
|
|
17
|
+
version?: string;
|
|
18
|
+
/** Override the cache directory. Defaults to quickchr's platform-correct cache. */
|
|
19
|
+
cacheDir?: string;
|
|
20
|
+
/** CHR architecture. `auto` and omission select the Bun process architecture. */
|
|
21
|
+
arch?: Arch | "auto";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CacheKeyResult {
|
|
25
|
+
dir: string;
|
|
26
|
+
/** Concrete RouterOS version, or `unresolved` when channel resolution is offline. */
|
|
27
|
+
version: string;
|
|
28
|
+
arch: Arch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CacheAddOptions extends CacheTargetOptions {
|
|
32
|
+
logger?: ProgressLogger;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CacheAddResult extends CacheKeyResult {
|
|
36
|
+
path: string;
|
|
37
|
+
cacheHit: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type CacheVersionResolver = (channel: Channel) => Promise<string>;
|
|
41
|
+
|
|
42
|
+
function validateTarget(options: CacheTargetOptions): { channel?: Channel; version?: string } {
|
|
43
|
+
if (options.channel !== undefined && options.version !== undefined) {
|
|
44
|
+
throw new QuickCHRError("INVALID_VERSION", "Choose either a RouterOS channel or a concrete version, not both");
|
|
45
|
+
}
|
|
46
|
+
if (options.channel !== undefined && !(CHANNELS as readonly string[]).includes(options.channel)) {
|
|
47
|
+
throw new QuickCHRError(
|
|
48
|
+
"INVALID_VERSION",
|
|
49
|
+
`Invalid RouterOS channel "${options.channel}" — expected one of ${CHANNELS.join(", ")}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (options.version !== undefined && !isValidVersion(options.version)) {
|
|
53
|
+
throw new QuickCHRError("INVALID_VERSION", `Invalid RouterOS version: ${options.version}`);
|
|
54
|
+
}
|
|
55
|
+
return { channel: options.channel, version: options.version };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function concreteVersion(
|
|
59
|
+
options: CacheTargetOptions,
|
|
60
|
+
resolver: CacheVersionResolver,
|
|
61
|
+
): Promise<{ channel?: Channel; version: string }> {
|
|
62
|
+
const target = validateTarget(options);
|
|
63
|
+
if (target.version !== undefined) return { version: target.version };
|
|
64
|
+
const channel = target.channel ?? "stable";
|
|
65
|
+
const version = await resolver(channel);
|
|
66
|
+
if (!isValidVersion(version)) {
|
|
67
|
+
throw new QuickCHRError(
|
|
68
|
+
"INVALID_VERSION",
|
|
69
|
+
`Unexpected version "${version}" resolved for channel "${channel}"`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return { channel, version };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function concreteArch(arch: Arch | "auto" | undefined): Arch {
|
|
76
|
+
if (arch === undefined || arch === "auto") return process.arch === "arm64" ? "arm64" : "x86";
|
|
77
|
+
if (!(ARCHES as readonly string[]).includes(arch)) {
|
|
78
|
+
throw new QuickCHRError("INVALID_ARCH", `Invalid architecture "${arch}" — expected one of ${ARCHES.join(", ")}, auto`);
|
|
79
|
+
}
|
|
80
|
+
return arch;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Return quickchr's cache directory and the concrete version for a CI key.
|
|
85
|
+
*
|
|
86
|
+
* An explicit version is validated and returned without a network call. A
|
|
87
|
+
* channel resolution failure degrades to the stable `unresolved` sentinel so
|
|
88
|
+
* cache lookup cannot make an otherwise useful offline job fail.
|
|
89
|
+
*/
|
|
90
|
+
export async function cacheKey(
|
|
91
|
+
options: CacheTargetOptions = {},
|
|
92
|
+
resolver: CacheVersionResolver = resolveVersion,
|
|
93
|
+
): Promise<CacheKeyResult> {
|
|
94
|
+
const target = validateTarget(options);
|
|
95
|
+
const dir = options.cacheDir ?? getCacheDir();
|
|
96
|
+
const arch = concreteArch(options.arch);
|
|
97
|
+
if (target.version !== undefined) {
|
|
98
|
+
return {
|
|
99
|
+
dir,
|
|
100
|
+
version: target.version.length <= CACHE_VERSION_MAX_LENGTH ? target.version : CACHE_VERSION_UNRESOLVED,
|
|
101
|
+
arch,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const resolved = await concreteVersion(options, resolver);
|
|
106
|
+
return {
|
|
107
|
+
dir,
|
|
108
|
+
version: resolved.version.length <= CACHE_VERSION_MAX_LENGTH ? resolved.version : CACHE_VERSION_UNRESOLVED,
|
|
109
|
+
arch,
|
|
110
|
+
};
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof QuickCHRError && error.code === "DOWNLOAD_FAILED") {
|
|
113
|
+
return { dir, version: CACHE_VERSION_UNRESOLVED, arch };
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Resolve and cache one CHR image without creating or booting a machine. */
|
|
120
|
+
export async function cacheAdd(
|
|
121
|
+
options: CacheAddOptions = {},
|
|
122
|
+
resolver: CacheVersionResolver = resolveVersion,
|
|
123
|
+
): Promise<CacheAddResult> {
|
|
124
|
+
const resolved = await concreteVersion(options, resolver);
|
|
125
|
+
if (resolved.version.length > CACHE_VERSION_MAX_LENGTH) {
|
|
126
|
+
throw new QuickCHRError("INVALID_VERSION", `RouterOS version is too long: ${resolved.version}`);
|
|
127
|
+
}
|
|
128
|
+
const arch = concreteArch(options.arch);
|
|
129
|
+
const dir = options.cacheDir ?? getCacheDir();
|
|
130
|
+
const expectedPath = join(dir, `${chrImageBasename(resolved.version, arch)}.img`);
|
|
131
|
+
const cacheHit = await isUsableCachedImage(expectedPath);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const path = await ensureCachedImage(resolved.version, arch, dir, options.logger);
|
|
135
|
+
return { dir, version: resolved.version, arch, path, cacheHit };
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (resolved.channel && error instanceof QuickCHRError) {
|
|
138
|
+
throw new QuickCHRError(
|
|
139
|
+
error.code,
|
|
140
|
+
`Failed to cache channel "${resolved.channel}" -> ${resolved.version} (${arch}): ${error.message}`,
|
|
141
|
+
error.installHint,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/lib/completions.ts
CHANGED
|
@@ -328,7 +328,7 @@ _quickchr() {
|
|
|
328
328
|
cword=${D}COMP_CWORD
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor settings setup completions version help"
|
|
331
|
+
local commands="add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
|
|
332
332
|
|
|
333
333
|
if [[ ${D}cword -eq 1 ]]; then
|
|
334
334
|
COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
|
|
@@ -337,12 +337,12 @@ _quickchr() {
|
|
|
337
337
|
|
|
338
338
|
local cmd="${D}{words[1]}"
|
|
339
339
|
case "${D}cmd" in
|
|
340
|
-
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
|
|
340
|
+
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
|
|
341
341
|
local machines
|
|
342
342
|
machines=$(quickchr completions --machines 2>/dev/null)
|
|
343
343
|
COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
|
|
344
344
|
;;
|
|
345
|
-
stop)
|
|
345
|
+
stop|shutdown)
|
|
346
346
|
local machines
|
|
347
347
|
machines=$(quickchr completions --running 2>/dev/null)
|
|
348
348
|
[[ -z "${D}machines" ]] && machines=$(quickchr completions --machines 2>/dev/null)
|
|
@@ -352,6 +352,10 @@ _quickchr() {
|
|
|
352
352
|
local opts="--install --uninstall --status --dry-run --shell"
|
|
353
353
|
COMPREPLY=( $(compgen -W "${D}opts" -- "${D}cur") )
|
|
354
354
|
;;
|
|
355
|
+
cache)
|
|
356
|
+
local opts="add key list prune clear"
|
|
357
|
+
COMPREPLY=( $(compgen -W "${D}opts" -- "${D}cur") )
|
|
358
|
+
;;
|
|
355
359
|
help)
|
|
356
360
|
COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
|
|
357
361
|
;;
|
|
@@ -383,12 +387,15 @@ _quickchr() {
|
|
|
383
387
|
local commands=(
|
|
384
388
|
'add:Create a new CHR machine (without starting)'
|
|
385
389
|
'start:Start or restart a CHR instance'
|
|
386
|
-
'stop:
|
|
390
|
+
'stop:Power cut instance(s)'
|
|
391
|
+
'shutdown:Request guest shutdown'
|
|
387
392
|
'list:List all instances'
|
|
388
393
|
'ls:List all instances (alias)'
|
|
389
394
|
'status:Detailed status'
|
|
390
395
|
'inspect:Print running machine descriptor'
|
|
391
396
|
'env:Print subprocess environment'
|
|
397
|
+
'get:Show live machine config (license, device-mode, admin)'
|
|
398
|
+
'set:Change a booted machine (license, device-mode)'
|
|
392
399
|
'remove:Remove instance(s) and disk'
|
|
393
400
|
'rm:Remove instance(s) (alias)'
|
|
394
401
|
'clean:Reset instance disk to fresh image'
|
|
@@ -401,6 +408,7 @@ _quickchr() {
|
|
|
401
408
|
'snap:Manage snapshots (alias)'
|
|
402
409
|
'networks:Network discovery and socket management'
|
|
403
410
|
'net:Network discovery (alias)'
|
|
411
|
+
'cache:Prefetch and manage downloaded CHR images'
|
|
404
412
|
'doctor:Check prerequisites'
|
|
405
413
|
'settings:Manage global quickchr settings'
|
|
406
414
|
'setup:Interactive setup wizard'
|
|
@@ -413,12 +421,12 @@ _quickchr() {
|
|
|
413
421
|
args)
|
|
414
422
|
local cmd="${D}{words[2]}"
|
|
415
423
|
case ${D}cmd in
|
|
416
|
-
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
|
|
424
|
+
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
|
|
417
425
|
local machines
|
|
418
426
|
machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
|
|
419
427
|
_describe 'machine' machines
|
|
420
428
|
;;
|
|
421
|
-
stop)
|
|
429
|
+
stop|shutdown)
|
|
422
430
|
local machines
|
|
423
431
|
machines=(${D}{(f)"${D}(quickchr completions --running 2>/dev/null)"})
|
|
424
432
|
(( ${D}{#machines} == 0 )) && machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
|
|
@@ -434,8 +442,12 @@ _quickchr() {
|
|
|
434
442
|
)
|
|
435
443
|
_describe 'option' opts
|
|
436
444
|
;;
|
|
445
|
+
cache)
|
|
446
|
+
local subcommands=(add key list prune clear)
|
|
447
|
+
_describe 'cache subcommand' subcommands
|
|
448
|
+
;;
|
|
437
449
|
help)
|
|
438
|
-
local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor settings setup completions version)
|
|
450
|
+
local commands=(add start stop shutdown list status inspect env get set remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
|
|
439
451
|
_describe 'command' commands
|
|
440
452
|
;;
|
|
441
453
|
esac
|
|
@@ -451,7 +463,7 @@ function generateFishCompletion(): string {
|
|
|
451
463
|
return `# quickchr fish completion
|
|
452
464
|
# Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
|
|
453
465
|
|
|
454
|
-
set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor settings setup completions version help
|
|
466
|
+
set -l quickchr_commands add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
|
|
455
467
|
|
|
456
468
|
# Disable file completions globally for quickchr
|
|
457
469
|
complete -c quickchr -f
|
|
@@ -462,7 +474,9 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
462
474
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
463
475
|
-a "start" -d "Start or restart a CHR instance"
|
|
464
476
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
465
|
-
-a "stop" -d "
|
|
477
|
+
-a "stop" -d "Power cut instance(s)"
|
|
478
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
479
|
+
-a "shutdown" -d "Request guest shutdown"
|
|
466
480
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
467
481
|
-a "list ls" -d "List all instances"
|
|
468
482
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
@@ -482,6 +496,10 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
482
496
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
483
497
|
-a "qga" -d "Guest agent command"
|
|
484
498
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
499
|
+
-a "get" -d "Show live machine config (license, device-mode, admin)"
|
|
500
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \
|
|
501
|
+
-a "set" -d "Change a booted machine (license, device-mode)"
|
|
502
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \
|
|
485
503
|
-a "license" -d "Apply/renew CHR trial license"
|
|
486
504
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
487
505
|
-a "disk" -d "Show disk details"
|
|
@@ -489,6 +507,8 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
489
507
|
-a "snapshot snap" -d "Manage snapshots"
|
|
490
508
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
491
509
|
-a "networks net" -d "Network discovery and socket management"
|
|
510
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
511
|
+
-a "cache" -d "Prefetch and manage downloaded CHR images"
|
|
492
512
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
493
513
|
-a "doctor" -d "Check prerequisites"
|
|
494
514
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
@@ -503,12 +523,12 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
503
523
|
-a "help" -d "Show help"
|
|
504
524
|
|
|
505
525
|
# Machine name completions for commands that take a running machine
|
|
506
|
-
complete -c quickchr -n "__fish_seen_subcommand_from stop" \\
|
|
526
|
+
complete -c quickchr -n "__fish_seen_subcommand_from stop shutdown" \\
|
|
507
527
|
-a "(quickchr completions --running 2>/dev/null; or quickchr completions --machines 2>/dev/null)" \\
|
|
508
528
|
-d "machine"
|
|
509
529
|
|
|
510
530
|
# Machine name completions for commands that take any machine
|
|
511
|
-
complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env" \\
|
|
531
|
+
complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env get set" \\
|
|
512
532
|
-a "(quickchr completions --machines 2>/dev/null)" \\
|
|
513
533
|
-d "machine"
|
|
514
534
|
|
|
@@ -518,5 +538,8 @@ complete -c quickchr -n "__fish_seen_subcommand_from completions" -l uninstall -
|
|
|
518
538
|
complete -c quickchr -n "__fish_seen_subcommand_from completions" -l status -d "Show install status"
|
|
519
539
|
complete -c quickchr -n "__fish_seen_subcommand_from completions" -l dry-run -d "Show what would happen"
|
|
520
540
|
complete -c quickchr -n "__fish_seen_subcommand_from completions" -l shell -d "Specify shell" -a "bash zsh fish"
|
|
541
|
+
|
|
542
|
+
# cache subcommands
|
|
543
|
+
complete -c quickchr -n "__fish_seen_subcommand_from cache" -a "add key list prune clear" -d "cache subcommand"
|
|
521
544
|
`;
|
|
522
545
|
}
|