@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.
- package/DESIGN.md +193 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/package.json +61 -0
- package/src/cli/format.ts +154 -0
- package/src/cli/index.ts +2625 -0
- package/src/cli/wizard.ts +727 -0
- package/src/index.ts +124 -0
- package/src/lib/auth.ts +68 -0
- package/src/lib/channels.ts +222 -0
- package/src/lib/completions.ts +513 -0
- package/src/lib/console.ts +473 -0
- package/src/lib/credentials.ts +99 -0
- package/src/lib/device-mode.ts +283 -0
- package/src/lib/disk.ts +400 -0
- package/src/lib/exec.ts +101 -0
- package/src/lib/images.ts +167 -0
- package/src/lib/license.ts +292 -0
- package/src/lib/log.ts +41 -0
- package/src/lib/network.ts +635 -0
- package/src/lib/packages.ts +225 -0
- package/src/lib/password.ts +21 -0
- package/src/lib/platform.ts +458 -0
- package/src/lib/provision.ts +422 -0
- package/src/lib/qemu.ts +526 -0
- package/src/lib/qga.ts +667 -0
- package/src/lib/quickchr.ts +1871 -0
- package/src/lib/rest.ts +244 -0
- package/src/lib/secrets.ts +133 -0
- package/src/lib/socket-registry.ts +140 -0
- package/src/lib/state.ts +173 -0
- package/src/lib/storage.ts +163 -0
- package/src/lib/types.ts +690 -0
- package/src/lib/versions.ts +181 -0
- package/src/lib/zip.ts +44 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive wizard for quickchr — walks user through starting a CHR.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Arch, Channel, DeviceModeOptions, LicenseLevel, LicenseOptions, NetworkSpecifier, StartOptions } from "../lib/types.ts";
|
|
6
|
+
import { CHANNELS, ARCHES, knownPackagesForArch } from "../lib/types.ts";
|
|
7
|
+
|
|
8
|
+
/** Run the interactive wizard using @clack/prompts. */
|
|
9
|
+
export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<void> {
|
|
10
|
+
// Dynamic import — only loaded when wizard is actually used
|
|
11
|
+
const clack = await import("@clack/prompts");
|
|
12
|
+
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
13
|
+
const { formatPorts, bold } = await import("./format.ts");
|
|
14
|
+
|
|
15
|
+
clack.intro("quickchr — MikroTik CHR Manager");
|
|
16
|
+
if (wizardOpts?.firstRun) {
|
|
17
|
+
clack.log.info("No machines found — let's create your first CHR instance.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 1. Version source: channel or direct version
|
|
21
|
+
const versionSource = await clack.select({
|
|
22
|
+
message: "How do you want to pick the RouterOS version?",
|
|
23
|
+
options: [
|
|
24
|
+
{ value: "channel", label: "By channel (stable, long-term, testing, development)" },
|
|
25
|
+
{ value: "version", label: "Enter a specific version number" },
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
if (clack.isCancel(versionSource)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
29
|
+
|
|
30
|
+
let version: string | undefined;
|
|
31
|
+
let channel: Channel | undefined;
|
|
32
|
+
|
|
33
|
+
if (versionSource === "channel") {
|
|
34
|
+
const ch = await clack.select({
|
|
35
|
+
message: "Select channel:",
|
|
36
|
+
options: CHANNELS.map((c) => ({
|
|
37
|
+
value: c,
|
|
38
|
+
label: c,
|
|
39
|
+
hint: c === "long-term" ? "recommended for provisioning" : undefined,
|
|
40
|
+
})),
|
|
41
|
+
initialValue: "long-term" as Channel,
|
|
42
|
+
});
|
|
43
|
+
if (clack.isCancel(ch)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
44
|
+
channel = ch;
|
|
45
|
+
} else {
|
|
46
|
+
const ver = await clack.text({
|
|
47
|
+
message: "Enter RouterOS version (e.g., 7.22.1):",
|
|
48
|
+
validate: (v) => {
|
|
49
|
+
if (!/^\d+\.\d+(\.\d+)?(beta\d+|rc\d+)?$/.test(v)) {
|
|
50
|
+
return "Invalid version format";
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
if (clack.isCancel(ver)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
55
|
+
version = ver;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. Architecture
|
|
59
|
+
const hostArch = process.arch === "arm64" ? "arm64" : "x86";
|
|
60
|
+
const arch = await clack.select({
|
|
61
|
+
message: "Architecture:",
|
|
62
|
+
options: ARCHES.map((a) => ({
|
|
63
|
+
value: a,
|
|
64
|
+
label: a,
|
|
65
|
+
hint: a === hostArch ? "host native" : "emulated (slower)",
|
|
66
|
+
})),
|
|
67
|
+
initialValue: hostArch as Arch,
|
|
68
|
+
});
|
|
69
|
+
if (clack.isCancel(arch)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
70
|
+
|
|
71
|
+
// 3. Name
|
|
72
|
+
const name = await clack.text({
|
|
73
|
+
message: "Instance name (leave empty for auto):",
|
|
74
|
+
placeholder: "auto-generated",
|
|
75
|
+
defaultValue: "",
|
|
76
|
+
});
|
|
77
|
+
if (clack.isCancel(name)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
78
|
+
|
|
79
|
+
// 4. Resources — detect host capabilities and offer sensible choices
|
|
80
|
+
const os = await import("node:os");
|
|
81
|
+
const hostCpus = os.cpus().length;
|
|
82
|
+
const hostMemMb = Math.round(os.totalmem() / 1024 / 1024);
|
|
83
|
+
|
|
84
|
+
const cpuOptions: Array<{ value: string; label: string; hint?: string }> = [];
|
|
85
|
+
cpuOptions.push({ value: "1", label: "1 CPU", hint: "recommended for most use" });
|
|
86
|
+
if (hostCpus >= 2) cpuOptions.push({ value: "2", label: "2 CPUs" });
|
|
87
|
+
if (hostCpus >= 4) cpuOptions.push({ value: "4", label: "4 CPUs" });
|
|
88
|
+
if (hostCpus >= 8) cpuOptions.push({ value: "8", label: "8 CPUs" });
|
|
89
|
+
if (hostCpus >= 16) cpuOptions.push({ value: "16", label: "16 CPUs" });
|
|
90
|
+
|
|
91
|
+
const cpu = await clack.select({
|
|
92
|
+
message: `CPU cores (host: ${hostCpus}):`,
|
|
93
|
+
options: cpuOptions,
|
|
94
|
+
initialValue: "1",
|
|
95
|
+
});
|
|
96
|
+
if (clack.isCancel(cpu)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
97
|
+
|
|
98
|
+
const memOptions: Array<{ value: string; label: string; hint?: string }> = [];
|
|
99
|
+
memOptions.push({ value: "256", label: "256 MB", hint: "recommended minimum" });
|
|
100
|
+
if (hostMemMb >= 1024) memOptions.push({ value: "512", label: "512 MB" });
|
|
101
|
+
if (hostMemMb >= 2048) memOptions.push({ value: "1024", label: "1 GB" });
|
|
102
|
+
if (hostMemMb >= 4096) memOptions.push({ value: "2048", label: "2 GB" });
|
|
103
|
+
if (hostMemMb >= 8192) memOptions.push({ value: "4096", label: "4 GB" });
|
|
104
|
+
|
|
105
|
+
const mem = await clack.select({
|
|
106
|
+
message: `Memory (host: ${(hostMemMb / 1024).toFixed(0)} GB):`,
|
|
107
|
+
options: memOptions,
|
|
108
|
+
initialValue: "256",
|
|
109
|
+
});
|
|
110
|
+
if (clack.isCancel(mem)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
111
|
+
|
|
112
|
+
// 4b. Disk configuration — requires qemu-img; skip gracefully if absent
|
|
113
|
+
let bootSize: string | undefined;
|
|
114
|
+
let bootDiskFormat: "qcow2" | "raw" = "qcow2";
|
|
115
|
+
const extraDisks: string[] = [];
|
|
116
|
+
|
|
117
|
+
const { findQemuImg, getQemuInstallHint } = await import("../lib/platform.ts");
|
|
118
|
+
const { isValidDiskSize } = await import("../lib/disk.ts");
|
|
119
|
+
const qemuImg = findQemuImg();
|
|
120
|
+
|
|
121
|
+
if (!qemuImg) {
|
|
122
|
+
clack.log.warn(`Disk features (boot resize, extra disks) require qemu-img. Run 'quickchr doctor' to confirm setup. ${getQemuInstallHint()}`);
|
|
123
|
+
} else {
|
|
124
|
+
const diskFormatChoice = await clack.select({
|
|
125
|
+
message: "Boot disk format:",
|
|
126
|
+
options: [
|
|
127
|
+
{ value: "qcow2", label: "qcow2 (recommended)", hint: "supports snapshots and resize" },
|
|
128
|
+
{ value: "raw", label: "raw", hint: "no resize support" },
|
|
129
|
+
],
|
|
130
|
+
initialValue: "qcow2",
|
|
131
|
+
});
|
|
132
|
+
if (clack.isCancel(diskFormatChoice)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
133
|
+
bootDiskFormat = diskFormatChoice as "qcow2" | "raw";
|
|
134
|
+
|
|
135
|
+
if (bootDiskFormat === "qcow2") {
|
|
136
|
+
const wantBootResize = await clack.confirm({
|
|
137
|
+
message: "Resize the boot disk? (default is ~128 MB)",
|
|
138
|
+
initialValue: false,
|
|
139
|
+
});
|
|
140
|
+
if (clack.isCancel(wantBootResize)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
141
|
+
|
|
142
|
+
if (wantBootResize) {
|
|
143
|
+
const size = await clack.text({
|
|
144
|
+
message: "Boot disk size (e.g., 512M, 1G, 2G):",
|
|
145
|
+
validate: (v) => {
|
|
146
|
+
if (!isValidDiskSize(v)) return "Size like 512M, 1G, or 2048";
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
if (clack.isCancel(size)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
150
|
+
bootSize = size.trim();
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
clack.log.info("Raw boot disk selected — boot resize is disabled.");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const wantExtraDisks = await clack.confirm({
|
|
157
|
+
message: "Add extra blank disks?",
|
|
158
|
+
initialValue: false,
|
|
159
|
+
});
|
|
160
|
+
if (clack.isCancel(wantExtraDisks)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
161
|
+
|
|
162
|
+
if (wantExtraDisks) {
|
|
163
|
+
let addMore = true;
|
|
164
|
+
while (addMore) {
|
|
165
|
+
const diskSize = await clack.text({
|
|
166
|
+
message: `Extra disk ${extraDisks.length + 1} size (e.g., 64M, 512M, 1G):`,
|
|
167
|
+
validate: (v) => {
|
|
168
|
+
if (!isValidDiskSize(v)) return "Size like 64M, 512M, or 1G";
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
if (clack.isCancel(diskSize)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
172
|
+
extraDisks.push(diskSize.trim());
|
|
173
|
+
|
|
174
|
+
const another = await clack.confirm({
|
|
175
|
+
message: "Add another disk?",
|
|
176
|
+
initialValue: false,
|
|
177
|
+
});
|
|
178
|
+
if (clack.isCancel(another)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
179
|
+
addMore = another;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── 5. Networking ────────────────────────────────────────────────────
|
|
185
|
+
// User/SLiRP is always included (localhost port forwarding for REST, SSH, etc.).
|
|
186
|
+
// The wizard only asks about *additional* networks. To remove the user-mode
|
|
187
|
+
// interface, use `quickchr start --no-network --add-network <spec>`.
|
|
188
|
+
const networks: NetworkSpecifier[] = ["user"];
|
|
189
|
+
const { detectPlatform, detectPhysicalInterfaces, isSocketVmnetDaemonRunning } = await import("../lib/platform.ts");
|
|
190
|
+
const { parseNetworkSpecifier } = await import("../lib/network.ts");
|
|
191
|
+
const platform = await detectPlatform();
|
|
192
|
+
const isMacOS = platform.os === "darwin";
|
|
193
|
+
const hasSocketVmnet = !!platform.socketVmnet;
|
|
194
|
+
|
|
195
|
+
// Only offer additional networks if the platform has options beyond user-mode
|
|
196
|
+
const hasNetworkOptions = isMacOS || process.platform === "linux";
|
|
197
|
+
if (hasNetworkOptions) {
|
|
198
|
+
const defaultYes = isMacOS && hasSocketVmnet;
|
|
199
|
+
const wantExtra = await clack.confirm({
|
|
200
|
+
message: "Add a real network? (the CHR already has localhost port forwarding for REST/SSH/WinBox)",
|
|
201
|
+
initialValue: defaultYes,
|
|
202
|
+
});
|
|
203
|
+
if (clack.isCancel(wantExtra)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
204
|
+
|
|
205
|
+
if (wantExtra) {
|
|
206
|
+
let addMore = true;
|
|
207
|
+
while (addMore) {
|
|
208
|
+
const netOptions: Array<{ value: string; label: string; hint?: string }> = [];
|
|
209
|
+
if (isMacOS && hasSocketVmnet) {
|
|
210
|
+
netOptions.push({ value: "shared", label: "shared", hint: "routable IP via socket_vmnet (pingable from host)" });
|
|
211
|
+
}
|
|
212
|
+
if (isMacOS) {
|
|
213
|
+
netOptions.push({ value: "bridged", label: "bridged", hint: "bridge to a physical interface (e.g. Wi-Fi)" });
|
|
214
|
+
}
|
|
215
|
+
netOptions.push({ value: "socket", label: "socket", hint: "named L2 link between VMs" });
|
|
216
|
+
|
|
217
|
+
const netType = await clack.select({
|
|
218
|
+
message: "Network mode:",
|
|
219
|
+
options: netOptions,
|
|
220
|
+
});
|
|
221
|
+
if (clack.isCancel(netType)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
222
|
+
|
|
223
|
+
if (netType === "shared") {
|
|
224
|
+
if (networks.includes("shared")) {
|
|
225
|
+
clack.log.warn("Only one shared network is supported.");
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Block until socket_vmnet daemon is running — don't proceed with a known-bad config
|
|
229
|
+
let daemonOk = false;
|
|
230
|
+
while (!daemonOk) {
|
|
231
|
+
const socketPath = platform.socketVmnet?.sharedSocket;
|
|
232
|
+
if (socketPath && isSocketVmnetDaemonRunning(socketPath)) {
|
|
233
|
+
daemonOk = true;
|
|
234
|
+
} else {
|
|
235
|
+
clack.log.warn("socket_vmnet daemon is not running.");
|
|
236
|
+
const retry = await clack.select({
|
|
237
|
+
message: "What would you like to do?",
|
|
238
|
+
options: [
|
|
239
|
+
{ value: "retry", label: "Check again", hint: "after running: sudo brew services start socket_vmnet" },
|
|
240
|
+
{ value: "skip", label: "Skip shared network", hint: "continue without it" },
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
if (clack.isCancel(retry)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
244
|
+
if (retry === "skip") break;
|
|
245
|
+
// Refresh platform detection for retry
|
|
246
|
+
const refreshed = await detectPlatform();
|
|
247
|
+
Object.assign(platform, refreshed);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (daemonOk) {
|
|
251
|
+
networks.push("shared");
|
|
252
|
+
}
|
|
253
|
+
} else if (netType === "bridged") {
|
|
254
|
+
const ifaces = detectPhysicalInterfaces();
|
|
255
|
+
if (ifaces.length === 0) {
|
|
256
|
+
clack.log.warn("No physical interfaces detected. Enter device name manually.");
|
|
257
|
+
const manualIface = await clack.text({
|
|
258
|
+
message: "Interface device name (e.g. en0):",
|
|
259
|
+
validate: (v) => { if (!v.trim()) return "Required"; },
|
|
260
|
+
});
|
|
261
|
+
if (clack.isCancel(manualIface)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
262
|
+
networks.push(parseNetworkSpecifier(`bridged:${manualIface}`));
|
|
263
|
+
} else {
|
|
264
|
+
const ifaceChoice = await clack.select({
|
|
265
|
+
message: "Bridge to which interface?",
|
|
266
|
+
options: ifaces.map((i) => ({
|
|
267
|
+
value: i.device,
|
|
268
|
+
label: `${i.device} — ${i.name}`,
|
|
269
|
+
hint: i.alias ?? undefined,
|
|
270
|
+
})),
|
|
271
|
+
});
|
|
272
|
+
if (clack.isCancel(ifaceChoice)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
273
|
+
networks.push(parseNetworkSpecifier(`bridged:${ifaceChoice}`));
|
|
274
|
+
}
|
|
275
|
+
} else if (netType === "socket") {
|
|
276
|
+
const socketName = await clack.text({
|
|
277
|
+
message: "Socket link name (VMs sharing a name get L2 connectivity):",
|
|
278
|
+
validate: (v) => { if (!v.trim()) return "Required"; },
|
|
279
|
+
});
|
|
280
|
+
if (clack.isCancel(socketName)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
281
|
+
networks.push(parseNetworkSpecifier(`socket::${socketName}`));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const another = await clack.confirm({
|
|
285
|
+
message: "Add another network?",
|
|
286
|
+
initialValue: false,
|
|
287
|
+
});
|
|
288
|
+
if (clack.isCancel(another)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
289
|
+
addMore = another;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── 6. Provisioning gate ─────────────────────────────────────────────
|
|
295
|
+
// Everything after this point (packages, device-mode, login, license)
|
|
296
|
+
// requires booting the CHR and connecting via REST API.
|
|
297
|
+
const {
|
|
298
|
+
resolveVersion,
|
|
299
|
+
MIN_PROVISION_VERSION,
|
|
300
|
+
isProvisioningSupportedVersion,
|
|
301
|
+
PROVISIONING_BOOT_ONLY_SUMMARY,
|
|
302
|
+
provisioningSupportSummary,
|
|
303
|
+
provisioningSupportHint,
|
|
304
|
+
} = await import("../lib/versions.ts");
|
|
305
|
+
let resolvedVersion = version ?? await resolveVersion(channel ?? "long-term");
|
|
306
|
+
version = resolvedVersion;
|
|
307
|
+
let provisioningSupported = isProvisioningSupportedVersion(resolvedVersion);
|
|
308
|
+
|
|
309
|
+
let installAllPackages = false;
|
|
310
|
+
let packages: string[] = [];
|
|
311
|
+
let deviceMode: DeviceModeOptions | undefined;
|
|
312
|
+
let user: { name: string; password: string } | undefined;
|
|
313
|
+
let disableAdmin = false;
|
|
314
|
+
let secureLogin: boolean | undefined;
|
|
315
|
+
let license: LicenseOptions | undefined;
|
|
316
|
+
let wantProvision = false;
|
|
317
|
+
const askProvisionChoice = async (): Promise<boolean> => {
|
|
318
|
+
const provisionChoice = await clack.confirm({
|
|
319
|
+
message: "Configure the router after boot? (packages, login, device-mode, license)",
|
|
320
|
+
initialValue: true,
|
|
321
|
+
});
|
|
322
|
+
if (clack.isCancel(provisionChoice)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
323
|
+
return provisionChoice;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
if (provisioningSupported) {
|
|
327
|
+
wantProvision = await askProvisionChoice();
|
|
328
|
+
} else {
|
|
329
|
+
clack.log.warn(`RouterOS ${resolvedVersion}: ${provisioningSupportSummary(MIN_PROVISION_VERSION)}`);
|
|
330
|
+
clack.log.info(PROVISIONING_BOOT_ONLY_SUMMARY);
|
|
331
|
+
clack.log.info(provisioningSupportHint(MIN_PROVISION_VERSION));
|
|
332
|
+
const unsupportedAction = await clack.select({
|
|
333
|
+
message: "How do you want to proceed?",
|
|
334
|
+
options: [
|
|
335
|
+
{ value: "switch", label: "Use the long-term channel instead", hint: "recommended to enable provisioning" },
|
|
336
|
+
{ value: "boot-only", label: "Keep this version in boot-only mode", hint: "no packages/login/device-mode/license changes" },
|
|
337
|
+
{ value: "cancel", label: "Cancel" },
|
|
338
|
+
],
|
|
339
|
+
initialValue: "switch",
|
|
340
|
+
});
|
|
341
|
+
if (clack.isCancel(unsupportedAction) || unsupportedAction === "cancel") {
|
|
342
|
+
clack.cancel("Cancelled.");
|
|
343
|
+
process.exit(0);
|
|
344
|
+
}
|
|
345
|
+
if (unsupportedAction === "switch") {
|
|
346
|
+
channel = "long-term";
|
|
347
|
+
resolvedVersion = await resolveVersion(channel);
|
|
348
|
+
version = resolvedVersion;
|
|
349
|
+
provisioningSupported = true;
|
|
350
|
+
wantProvision = await askProvisionChoice();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (wantProvision) {
|
|
355
|
+
// 6a. Extra packages
|
|
356
|
+
const pkgChoice = await clack.select({
|
|
357
|
+
message: "Install extra packages?",
|
|
358
|
+
options: [
|
|
359
|
+
{ value: "none", label: "No extra packages" },
|
|
360
|
+
{ value: "all", label: "All packages", hint: "installs everything available" },
|
|
361
|
+
{ value: "custom", label: "Choose packages" },
|
|
362
|
+
],
|
|
363
|
+
initialValue: "none",
|
|
364
|
+
});
|
|
365
|
+
if (clack.isCancel(pkgChoice)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
366
|
+
|
|
367
|
+
if (pkgChoice === "all") {
|
|
368
|
+
installAllPackages = true;
|
|
369
|
+
} else if (pkgChoice === "custom") {
|
|
370
|
+
const pkgOptions = knownPackagesForArch(arch as Arch).map((p) => ({ value: p, label: p }));
|
|
371
|
+
const pkgSelection = await clack.multiselect({
|
|
372
|
+
message: "Select packages (space to toggle, enter to confirm):",
|
|
373
|
+
options: pkgOptions,
|
|
374
|
+
required: false,
|
|
375
|
+
});
|
|
376
|
+
if (clack.isCancel(pkgSelection)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
377
|
+
packages = pkgSelection as string[];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 6b. Device mode
|
|
381
|
+
const wantDeviceMode = await clack.confirm({
|
|
382
|
+
message: "Configure device-mode? (needed for containers and some restricted features)",
|
|
383
|
+
initialValue: false,
|
|
384
|
+
});
|
|
385
|
+
if (clack.isCancel(wantDeviceMode)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
386
|
+
|
|
387
|
+
if (wantDeviceMode) {
|
|
388
|
+
const modeChoice = await clack.select({
|
|
389
|
+
message: "Device-mode profile:",
|
|
390
|
+
options: [
|
|
391
|
+
{ value: "rose", label: "rose", hint: "includes container; recommended for most users" },
|
|
392
|
+
{ value: "advanced", label: "advanced", hint: "CHR default — no container" },
|
|
393
|
+
{ value: "basic", label: "basic" },
|
|
394
|
+
{ value: "home", label: "home" },
|
|
395
|
+
],
|
|
396
|
+
initialValue: "rose",
|
|
397
|
+
});
|
|
398
|
+
if (clack.isCancel(modeChoice)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
399
|
+
|
|
400
|
+
deviceMode = { mode: modeChoice as string };
|
|
401
|
+
|
|
402
|
+
// All features that are disabled regardless of mode.
|
|
403
|
+
const extraFeatures = modeChoice === "rose"
|
|
404
|
+
? [
|
|
405
|
+
{ value: "traffic-gen", label: "traffic-gen", hint: "/tool traffic-gen" },
|
|
406
|
+
{ value: "routerboard", label: "routerboard", hint: "/system routerboard settings" },
|
|
407
|
+
{ value: "install-any-version", label: "install-any-version", hint: "allow downgrade/sidegrade" },
|
|
408
|
+
{ value: "partitions", label: "partitions", hint: "/partitions support" },
|
|
409
|
+
]
|
|
410
|
+
: [
|
|
411
|
+
{ value: "container", label: "container", hint: "/container — required for running containers" },
|
|
412
|
+
{ value: "traffic-gen", label: "traffic-gen", hint: "/tool traffic-gen" },
|
|
413
|
+
{ value: "routerboard", label: "routerboard", hint: "/system routerboard settings" },
|
|
414
|
+
{ value: "install-any-version", label: "install-any-version", hint: "allow downgrade/sidegrade" },
|
|
415
|
+
{ value: "partitions", label: "partitions", hint: "/partitions support" },
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
const enableExtras = await clack.multiselect({
|
|
419
|
+
message: `Enable extra features beyond ${modeChoice} mode? (space to toggle, enter to confirm)`,
|
|
420
|
+
options: extraFeatures,
|
|
421
|
+
required: false,
|
|
422
|
+
});
|
|
423
|
+
if (clack.isCancel(enableExtras)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
424
|
+
|
|
425
|
+
if ((enableExtras as string[]).length > 0) {
|
|
426
|
+
deviceMode.enable = enableExtras as string[];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// 6c. User setup
|
|
431
|
+
const userChoice = await clack.select({
|
|
432
|
+
message: "CHR login setup:",
|
|
433
|
+
options: [
|
|
434
|
+
{ value: "managed", label: "quickchr managed login", hint: "auto-generated password, disables admin (recommended)" },
|
|
435
|
+
{ value: "custom", label: "Custom user", hint: "you provide username + password" },
|
|
436
|
+
{ value: "admin", label: "Keep admin with no password", hint: "less secure" },
|
|
437
|
+
],
|
|
438
|
+
initialValue: "managed",
|
|
439
|
+
});
|
|
440
|
+
if (clack.isCancel(userChoice)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
441
|
+
|
|
442
|
+
if (userChoice === "custom") {
|
|
443
|
+
const userName = await clack.text({
|
|
444
|
+
message: "Username:",
|
|
445
|
+
validate: (v) => { if (!v.trim()) return "Required"; },
|
|
446
|
+
});
|
|
447
|
+
if (clack.isCancel(userName)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
448
|
+
|
|
449
|
+
const userPass = await clack.password({ message: "Password:" });
|
|
450
|
+
if (clack.isCancel(userPass)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
451
|
+
|
|
452
|
+
user = { name: userName, password: userPass };
|
|
453
|
+
disableAdmin = true;
|
|
454
|
+
} else if (userChoice === "managed") {
|
|
455
|
+
disableAdmin = true;
|
|
456
|
+
} else {
|
|
457
|
+
// "admin" — keep admin with no password
|
|
458
|
+
secureLogin = false;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 6d. License
|
|
462
|
+
const wantLicense = await clack.confirm({
|
|
463
|
+
message: "Apply a CHR trial license? (unlocks speed above 1 Mbps)",
|
|
464
|
+
initialValue: false,
|
|
465
|
+
});
|
|
466
|
+
if (clack.isCancel(wantLicense)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
467
|
+
|
|
468
|
+
if (wantLicense) {
|
|
469
|
+
const { getStoredCredentials, saveCredentials, credentialStorageLabel } = await import("../lib/credentials.ts");
|
|
470
|
+
const stored = await getStoredCredentials();
|
|
471
|
+
|
|
472
|
+
let licAccount = "";
|
|
473
|
+
let licPassword = "";
|
|
474
|
+
|
|
475
|
+
if (stored) {
|
|
476
|
+
const useStored = await clack.confirm({
|
|
477
|
+
message: `Use saved credentials for ${stored.account} (${credentialStorageLabel()})?`,
|
|
478
|
+
initialValue: true,
|
|
479
|
+
});
|
|
480
|
+
if (clack.isCancel(useStored)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
481
|
+
|
|
482
|
+
if (useStored) {
|
|
483
|
+
licAccount = stored.account;
|
|
484
|
+
licPassword = stored.password;
|
|
485
|
+
} else {
|
|
486
|
+
const acct = await clack.text({ message: "MikroTik account (email):", validate: (v) => v.trim() ? undefined : "Required" });
|
|
487
|
+
if (clack.isCancel(acct)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
488
|
+
const pass = await clack.password({ message: "MikroTik password:" });
|
|
489
|
+
if (clack.isCancel(pass)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
490
|
+
licAccount = acct;
|
|
491
|
+
licPassword = pass;
|
|
492
|
+
const saveCreds = await clack.confirm({ message: "Save these credentials?", initialValue: true });
|
|
493
|
+
if (!clack.isCancel(saveCreds) && saveCreds) {
|
|
494
|
+
await saveCredentials(licAccount, licPassword);
|
|
495
|
+
clack.log.success(`Saved to ${credentialStorageLabel()}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
} else {
|
|
499
|
+
const acct = await clack.text({ message: "MikroTik account (email):", validate: (v) => v.trim() ? undefined : "Required" });
|
|
500
|
+
if (clack.isCancel(acct)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
501
|
+
const pass = await clack.password({ message: "MikroTik password:" });
|
|
502
|
+
if (clack.isCancel(pass)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
503
|
+
licAccount = acct;
|
|
504
|
+
licPassword = pass;
|
|
505
|
+
const saveCreds = await clack.confirm({ message: "Save these credentials?", initialValue: true });
|
|
506
|
+
if (!clack.isCancel(saveCreds) && saveCreds) {
|
|
507
|
+
await saveCredentials(licAccount, licPassword);
|
|
508
|
+
clack.log.success(`Saved to ${credentialStorageLabel()}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const licLevel = await clack.select({
|
|
513
|
+
message: "License level:",
|
|
514
|
+
options: [
|
|
515
|
+
{ value: "p1", label: "p1 — 1 Gbps", hint: "trial, 60 days" },
|
|
516
|
+
{ value: "p10", label: "p10 — 10 Gbps", hint: "trial, 60 days" },
|
|
517
|
+
{ value: "unlimited", label: "unlimited — no cap", hint: "trial, 60 days" },
|
|
518
|
+
] as Array<{ value: LicenseLevel; label: string; hint: string }>,
|
|
519
|
+
initialValue: "p1" as LicenseLevel,
|
|
520
|
+
});
|
|
521
|
+
if (clack.isCancel(licLevel)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
522
|
+
license = { account: licAccount, password: licPassword, level: licLevel };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// 10. Background/foreground
|
|
527
|
+
const background = await clack.select({
|
|
528
|
+
message: "Run mode:",
|
|
529
|
+
options: [
|
|
530
|
+
{ value: true, label: "Background", hint: "returns immediately" },
|
|
531
|
+
{ value: false, label: "Foreground", hint: "serial console on stdio" },
|
|
532
|
+
],
|
|
533
|
+
initialValue: true,
|
|
534
|
+
});
|
|
535
|
+
if (clack.isCancel(background)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
536
|
+
|
|
537
|
+
// 11. Confirm
|
|
538
|
+
const opts: StartOptions = {
|
|
539
|
+
version,
|
|
540
|
+
channel,
|
|
541
|
+
arch,
|
|
542
|
+
name: name || undefined,
|
|
543
|
+
cpu: Number(cpu),
|
|
544
|
+
mem: Number(mem),
|
|
545
|
+
bootSize,
|
|
546
|
+
bootDiskFormat,
|
|
547
|
+
extraDisks: extraDisks.length > 0 ? extraDisks : undefined,
|
|
548
|
+
packages,
|
|
549
|
+
installAllPackages,
|
|
550
|
+
deviceMode,
|
|
551
|
+
user,
|
|
552
|
+
disableAdmin,
|
|
553
|
+
secureLogin,
|
|
554
|
+
license,
|
|
555
|
+
networks: networks.length > 0 ? networks : undefined,
|
|
556
|
+
background: background as boolean,
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const pkgSummary = installAllPackages ? "all packages" : packages.length > 0 ? `pkgs: ${packages.join(", ")}` : "";
|
|
560
|
+
const netSummary = networks.map((n) => typeof n === "string" ? n : n.type).join(", ");
|
|
561
|
+
|
|
562
|
+
// Build a readable multi-line summary for the confirmation prompt
|
|
563
|
+
const summaryParts = [`${resolvedVersion} / ${arch}`];
|
|
564
|
+
if (netSummary) summaryParts.push(`networks: ${netSummary}`);
|
|
565
|
+
if (pkgSummary) summaryParts.push(pkgSummary);
|
|
566
|
+
if (deviceMode) {
|
|
567
|
+
const features = deviceMode.enable?.length ? ` +${deviceMode.enable.join(",")}` : "";
|
|
568
|
+
summaryParts.push(`device-mode: ${deviceMode.mode}${features}`);
|
|
569
|
+
}
|
|
570
|
+
if (license) summaryParts.push(`license: ${license.level}`);
|
|
571
|
+
if (user) summaryParts.push(`login: ${user.name}`);
|
|
572
|
+
|
|
573
|
+
const confirm = await clack.confirm({
|
|
574
|
+
message: `Start CHR? (${summaryParts.join(" · ")})`,
|
|
575
|
+
});
|
|
576
|
+
if (clack.isCancel(confirm) || !confirm) { clack.cancel("Cancelled."); process.exit(0); }
|
|
577
|
+
|
|
578
|
+
// Pre-warm image cache BEFORE starting any spinner so that download progress
|
|
579
|
+
// prints cleanly without interleaving with spinner escape codes.
|
|
580
|
+
const { ensureCachedImage } = await import("../lib/images.ts");
|
|
581
|
+
opts.version = resolvedVersion; // pin so QuickCHR.start doesn't resolve again
|
|
582
|
+
|
|
583
|
+
// Route pre-warm messages through clack so they render inside the prompt flow
|
|
584
|
+
// (prevents "Using cached image:" from appearing as raw text outside the │ lines).
|
|
585
|
+
const prewarmLogger = {
|
|
586
|
+
status: (msg: string) => clack.log.step(msg.trim()),
|
|
587
|
+
debug: () => {},
|
|
588
|
+
warn: (msg: string) => clack.log.warn(msg),
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
await ensureCachedImage(resolvedVersion, arch as Arch, undefined, prewarmLogger);
|
|
593
|
+
} catch (e: unknown) {
|
|
594
|
+
clack.log.error(e instanceof Error ? e.message : String(e));
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Foreground: skip spinner — QEMU takes over the terminal
|
|
599
|
+
if (opts.background === false) {
|
|
600
|
+
const b = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
|
601
|
+
const d = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
602
|
+
const c = (s: string) => `\x1b[36m${s}\x1b[0m`;
|
|
603
|
+
|
|
604
|
+
const hasProvisioning = !!(
|
|
605
|
+
opts.installAllPackages ||
|
|
606
|
+
(opts.packages && opts.packages.length > 0) ||
|
|
607
|
+
opts.user ||
|
|
608
|
+
opts.disableAdmin ||
|
|
609
|
+
opts.license ||
|
|
610
|
+
opts.deviceMode
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
console.log();
|
|
614
|
+
if (hasProvisioning) {
|
|
615
|
+
console.log(b(" Foreground mode — serial console will attach after provisioning"));
|
|
616
|
+
} else {
|
|
617
|
+
console.log(b(" Foreground mode — QEMU serial console"));
|
|
618
|
+
}
|
|
619
|
+
console.log(` ${c("Ctrl-A X")} ${d("exit QEMU and return to shell")}`);
|
|
620
|
+
console.log(` ${c("Ctrl-A C")} ${d("toggle QEMU monitor (type 'quit' to force-stop)")}`);
|
|
621
|
+
console.log(` ${c("Ctrl-A H")} ${d("list all QEMU serial shortcuts")}`);
|
|
622
|
+
console.log();
|
|
623
|
+
if (hasProvisioning) {
|
|
624
|
+
clack.log.info("Provisioning in progress — CHR will boot, configure, then hand over the console...");
|
|
625
|
+
}
|
|
626
|
+
// Brief pause so user can read tips before QEMU clears the screen
|
|
627
|
+
await Bun.sleep(2000);
|
|
628
|
+
try {
|
|
629
|
+
const instance = await QuickCHR.start(opts);
|
|
630
|
+
console.log(`\n${b(instance.name)} session ended`);
|
|
631
|
+
console.log(` ${d("Tip: resume session")} quickchr start ${instance.name} --fg`);
|
|
632
|
+
console.log(` ${d("Tip: run background")} quickchr start ${instance.name}`);
|
|
633
|
+
} catch (e: unknown) {
|
|
634
|
+
if (e instanceof Error) clack.log.error(e.message);
|
|
635
|
+
process.exit(1);
|
|
636
|
+
}
|
|
637
|
+
clack.outro("Done!");
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Background: spin while CHR boots
|
|
642
|
+
const spinner = clack.spinner();
|
|
643
|
+
const spinMsg = installAllPackages ? "Booting CHR and installing all packages..." : "Booting CHR...";
|
|
644
|
+
spinner.start(spinMsg);
|
|
645
|
+
const bootStartTime = Date.now();
|
|
646
|
+
// Show elapsed seconds so the user can see progress during the boot wait.
|
|
647
|
+
const elapsedTimer = setInterval(() => {
|
|
648
|
+
const elapsed = Math.floor((Date.now() - bootStartTime) / 1000);
|
|
649
|
+
spinner.message(`${spinMsg} (${elapsed}s)`);
|
|
650
|
+
}, 3000);
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
let cachePrewarmed = false;
|
|
654
|
+
opts.onProgress = (msg: string) => {
|
|
655
|
+
if (cachePrewarmed && msg.startsWith("Using cached image:")) return;
|
|
656
|
+
spinner.message(msg);
|
|
657
|
+
};
|
|
658
|
+
cachePrewarmed = true;
|
|
659
|
+
const instance = await QuickCHR.start(opts);
|
|
660
|
+
clearInterval(elapsedTimer);
|
|
661
|
+
spinner.stop(`${bold(instance.name)} started`);
|
|
662
|
+
|
|
663
|
+
const { hasUserModeNetwork } = await import("../lib/network.ts");
|
|
664
|
+
const userMode = hasUserModeNetwork(instance.state.networks);
|
|
665
|
+
|
|
666
|
+
const details = [
|
|
667
|
+
`Version: ${instance.state.version} (${instance.state.arch})`,
|
|
668
|
+
];
|
|
669
|
+
|
|
670
|
+
// Credentials — show username/password so user can actually log in
|
|
671
|
+
const creds = instance.state.user;
|
|
672
|
+
const loginUser = creds?.name ?? "admin";
|
|
673
|
+
const loginPass = creds?.password && creds.password !== "(stored in secrets)" ? creds.password : "";
|
|
674
|
+
|
|
675
|
+
if (userMode) {
|
|
676
|
+
const authPrefix = loginPass ? `${loginUser}:${loginPass}@` : `${loginUser}@`;
|
|
677
|
+
details.push(
|
|
678
|
+
`Ports: ${formatPorts(instance.state.ports)}`,
|
|
679
|
+
`REST: http://${authPrefix}127.0.0.1:${instance.ports.http}`,
|
|
680
|
+
`SSH: ssh ${loginUser}@127.0.0.1 -p ${instance.sshPort}`,
|
|
681
|
+
`WinBox: 127.0.0.1:${instance.ports.winbox}`,
|
|
682
|
+
);
|
|
683
|
+
} else {
|
|
684
|
+
details.push("Network: shared/bridged — VM has a DHCP address (no localhost port forwarding)");
|
|
685
|
+
details.push("Tip: Check IP via RouterOS console: /ip/dhcp-client/print");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (creds?.name) {
|
|
689
|
+
details.push("");
|
|
690
|
+
details.push(`Login: ${creds.name} / ${loginPass || "(no password)"}`);
|
|
691
|
+
details.push(`Run: quickchr exec ${instance.name} /system/resource/print`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
clack.note(details.join("\n"), "Instance details");
|
|
695
|
+
|
|
696
|
+
// Offer to install shell completions if not already installed
|
|
697
|
+
const { detectCurrentShell, shellBinary, completionStatusFor, installCompletions } = await import("../lib/completions.ts");
|
|
698
|
+
const shellInfo = detectCurrentShell();
|
|
699
|
+
const binary = shellBinary(shellInfo);
|
|
700
|
+
if (shellInfo.supported) {
|
|
701
|
+
const compStatus = completionStatusFor(binary as import("../lib/completions.ts").SupportedShell);
|
|
702
|
+
if (!compStatus.installed) {
|
|
703
|
+
const wantCompletions = await clack.confirm({
|
|
704
|
+
message: `Install shell completions? (adds Tab completion for quickchr commands and machine names)`,
|
|
705
|
+
initialValue: true,
|
|
706
|
+
});
|
|
707
|
+
if (!clack.isCancel(wantCompletions) && wantCompletions) {
|
|
708
|
+
const result = installCompletions(binary as import("../lib/completions.ts").SupportedShell);
|
|
709
|
+
clack.log.success(`Installed ${binary} completions: ${result.file}`);
|
|
710
|
+
if (result.rcLine && result.rcFile) {
|
|
711
|
+
clack.log.info(`Added to ${result.rcFile}: ${result.rcLine}`);
|
|
712
|
+
}
|
|
713
|
+
clack.log.info("Restart your shell or open a new terminal to activate.");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
clack.outro("Done!");
|
|
719
|
+
} catch (e: unknown) {
|
|
720
|
+
clearInterval(elapsedTimer);
|
|
721
|
+
spinner.stop("Failed", 1);
|
|
722
|
+
if (e instanceof Error) {
|
|
723
|
+
clack.log.error(e.message);
|
|
724
|
+
}
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
}
|