@tikoci/quickchr 0.2.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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * RouterOS version resolution and download URL generation.
3
+ */
4
+
5
+ import type { Arch, Channel } from "./types.ts";
6
+ import { QuickCHRError } from "./types.ts";
7
+
8
+ const UPGRADE_BASE = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
9
+ const DOWNLOAD_BASE = "https://download.mikrotik.com/routeros";
10
+ export const MIN_PROVISION_VERSION = "7.20.8";
11
+ export const PROVISIONING_FEATURE_LABEL = "post-boot provisioning";
12
+ export const PROVISIONING_FEATURE_SUMMARY = "packages, login/user changes, license, and device-mode";
13
+ export const PROVISIONING_BOOT_ONLY_SUMMARY = "Boot-only features such as disks, networks, and other QEMU-local setup remain available on older 7.x releases.";
14
+
15
+ export interface RouterOsCompatibilityRow {
16
+ feature: string;
17
+ minimumVersion: string;
18
+ notes: string;
19
+ }
20
+
21
+ export const ROUTEROS_COMPATIBILITY_MATRIX: RouterOsCompatibilityRow[] = [
22
+ {
23
+ feature: "Boot / start / stop",
24
+ minimumVersion: "any RouterOS 7.x",
25
+ notes: "Boot-only path. QEMU-local only; no RouterOS mutations required.",
26
+ },
27
+ {
28
+ feature: "Disk resize / extra disks / disk inspection",
29
+ minimumVersion: "any RouterOS 7.x",
30
+ notes: "Host-side qemu-img features. Requires qemu-img, not RouterOS provisioning support.",
31
+ },
32
+ {
33
+ feature: "Network attachment / port mappings",
34
+ minimumVersion: "any RouterOS 7.x",
35
+ notes: "QEMU networking setup before or during boot.",
36
+ },
37
+ {
38
+ feature: "Managed login, custom user, disable-admin",
39
+ minimumVersion: MIN_PROVISION_VERSION,
40
+ notes: "Mutates RouterOS after boot; handled as quickchr provisioning.",
41
+ },
42
+ {
43
+ feature: "Package install / install-all-packages",
44
+ minimumVersion: MIN_PROVISION_VERSION,
45
+ notes: "Uploads packages and reboots after boot-time provisioning starts.",
46
+ },
47
+ {
48
+ feature: "License apply / renew",
49
+ minimumVersion: MIN_PROVISION_VERSION,
50
+ notes: "RouterOS license workflow varies by version; quickchr validates this path on the baseline and newer.",
51
+ },
52
+ {
53
+ feature: "Device-mode changes",
54
+ minimumVersion: MIN_PROVISION_VERSION,
55
+ notes: "Explicitly unsupported below the baseline even if older RouterOS builds expose some attributes.",
56
+ },
57
+ ];
58
+
59
+ export function provisioningSupportSummary(minimumVersion = MIN_PROVISION_VERSION): string {
60
+ return `quickchr validates ${PROVISIONING_FEATURE_LABEL} on RouterOS ${minimumVersion}+ only; older 7.x remains boot-only.`;
61
+ }
62
+
63
+ export function provisioningSupportHint(minimumVersion = MIN_PROVISION_VERSION): string {
64
+ return `Use --channel long-term or --version ${minimumVersion}+ for ${PROVISIONING_FEATURE_SUMMARY}, or keep the older version without provisioning options.`;
65
+ }
66
+
67
+ /** Fetch the latest version for a given channel from MikroTik's upgrade server. */
68
+ export async function resolveVersion(channel: Channel): Promise<string> {
69
+ const url = `${UPGRADE_BASE}.${channel}`;
70
+
71
+ const response = await fetch(url);
72
+ if (!response.ok) {
73
+ throw new QuickCHRError(
74
+ "DOWNLOAD_FAILED",
75
+ `Failed to fetch version for channel "${channel}": HTTP ${response.status}`,
76
+ );
77
+ }
78
+
79
+ const text = await response.text();
80
+ // Response format: "7.22.1 1774276515" (version + unix timestamp)
81
+ const version = text.trim().split(/\s+/)[0]?.trim();
82
+ if (!version || !isValidVersion(version)) {
83
+ throw new QuickCHRError(
84
+ "INVALID_VERSION",
85
+ `Unexpected version format for channel "${channel}": "${text.trim()}"`,
86
+ );
87
+ }
88
+
89
+ return version;
90
+ }
91
+
92
+ /** Fetch latest versions for all channels in parallel. */
93
+ export async function resolveAllVersions(): Promise<Record<Channel, string>> {
94
+ const channels: Channel[] = ["stable", "long-term", "testing", "development"];
95
+ const results = await Promise.all(
96
+ channels.map(async (ch) => [ch, await resolveVersion(ch)] as const),
97
+ );
98
+ return Object.fromEntries(results) as Record<Channel, string>;
99
+ }
100
+
101
+ /** Validate a version string matches RouterOS format: MAJOR.MINOR[.PATCH][betaN|rcN] */
102
+ export function isValidVersion(version: string): boolean {
103
+ return /^\d+\.\d+(\.\d+)?(beta\d+|rc\d+)?$/.test(version);
104
+ }
105
+
106
+ /** Parse a RouterOS version into numeric parts. beta/rc suffixes are ignored for ordering. */
107
+ export function parseVersionParts(version: string): [number, number, number] {
108
+ const match = version.match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:beta\d+|rc\d+)?$/);
109
+ if (!match) {
110
+ throw new QuickCHRError("INVALID_VERSION", `Invalid version: ${version}`);
111
+ }
112
+
113
+ const major = Number.parseInt(match[1] ?? "0", 10);
114
+ const minor = Number.parseInt(match[2] ?? "0", 10);
115
+ const patch = Number.parseInt(match[3] ?? "0", 10);
116
+ return [major, minor, patch];
117
+ }
118
+
119
+ /** Compare two RouterOS versions semantically. */
120
+ export function compareRouterOsVersion(left: string, right: string): number {
121
+ const [leftMajor, leftMinor, leftPatch] = parseVersionParts(left);
122
+ const [rightMajor, rightMinor, rightPatch] = parseVersionParts(right);
123
+
124
+ if (leftMajor !== rightMajor) return leftMajor - rightMajor;
125
+ if (leftMinor !== rightMinor) return leftMinor - rightMinor;
126
+ return leftPatch - rightPatch;
127
+ }
128
+
129
+ /** True if RouterOS version is supported for quickchr provisioning mutations. */
130
+ export function isProvisioningSupportedVersion(
131
+ version: string,
132
+ minimumVersion = MIN_PROVISION_VERSION,
133
+ ): boolean {
134
+ return compareRouterOsVersion(version, minimumVersion) >= 0;
135
+ }
136
+
137
+ /** Guard provisioning operations to reduce version-dependent failure modes. */
138
+ export function assertProvisioningSupportedVersion(
139
+ version: string,
140
+ operation: string,
141
+ minimumVersion = MIN_PROVISION_VERSION,
142
+ ): void {
143
+ if (isProvisioningSupportedVersion(version, minimumVersion)) return;
144
+
145
+ throw new QuickCHRError(
146
+ "PROVISIONING_VERSION_UNSUPPORTED",
147
+ `Cannot ${operation} on RouterOS ${version}. ${provisioningSupportSummary(minimumVersion)} ${PROVISIONING_BOOT_ONLY_SUMMARY}`,
148
+ provisioningSupportHint(minimumVersion),
149
+ );
150
+ }
151
+
152
+ /** Build the MikroTik download URL for a CHR .img.zip. */
153
+ export function chrDownloadUrl(version: string, arch: Arch = "x86"): string {
154
+ const suffix = arch === "arm64" ? "-arm64" : "";
155
+ return `${DOWNLOAD_BASE}/${version}/chr-${version}${suffix}.img.zip`;
156
+ }
157
+
158
+ /** Build the download URL for the all-packages zip. */
159
+ export function packagesDownloadUrl(version: string, arch: Arch): string {
160
+ const mtArch = arch === "arm64" ? "arm64" : "x86";
161
+ return `${DOWNLOAD_BASE}/${version}/all_packages-${mtArch}-${version}.zip`;
162
+ }
163
+
164
+ /** Build the expected image filename (without extension). */
165
+ export function chrImageBasename(version: string, arch: Arch): string {
166
+ return arch === "arm64" ? `chr-${version}-arm64` : `chr-${version}`;
167
+ }
168
+
169
+ /** Generate a default machine name from version + arch, with incrementing suffix. */
170
+ export function generateMachineName(
171
+ version: string,
172
+ arch: Arch,
173
+ existingNames: string[],
174
+ ): string {
175
+ const base = `${version}-${arch}`;
176
+ let n = 1;
177
+ while (existingNames.includes(`${base}-${n}`)) {
178
+ n++;
179
+ }
180
+ return `${base}-${n}`;
181
+ }
package/src/lib/zip.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * ZIP extraction using fflate — no external unzip binary required.
3
+ */
4
+
5
+ import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { unzipSync } from "fflate";
8
+ import { QuickCHRError } from "./types.ts";
9
+
10
+ /**
11
+ * Extract all files from a ZIP archive to a destination directory.
12
+ * Creates the destination and any intermediate directories as needed.
13
+ * Throws QuickCHRError("PROCESS_FAILED") on read or extraction failure.
14
+ */
15
+ export function extractZip(zipPath: string, destDir: string): void {
16
+ let data: Uint8Array;
17
+ try {
18
+ data = new Uint8Array(readFileSync(zipPath));
19
+ } catch (e) {
20
+ throw new QuickCHRError(
21
+ "PROCESS_FAILED",
22
+ `Cannot read ZIP ${zipPath}: ${e instanceof Error ? e.message : String(e)}`,
23
+ );
24
+ }
25
+
26
+ let files: ReturnType<typeof unzipSync>;
27
+ try {
28
+ files = unzipSync(data);
29
+ } catch (e) {
30
+ throw new QuickCHRError(
31
+ "PROCESS_FAILED",
32
+ `ZIP extraction failed: ${e instanceof Error ? e.message : String(e)}`,
33
+ );
34
+ }
35
+
36
+ mkdirSync(destDir, { recursive: true });
37
+
38
+ for (const [name, fileData] of Object.entries(files)) {
39
+ if (name.endsWith("/")) continue; // skip directory entries
40
+ const destPath = join(destDir, name);
41
+ mkdirSync(dirname(destPath), { recursive: true });
42
+ writeFileSync(destPath, fileData);
43
+ }
44
+ }