@tikoci/quickchr 0.2.0 → 0.3.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,92 @@
1
+ /**
2
+ * Parse a CLI `--forward` spec into a `PortMapping`.
3
+ *
4
+ * Grammar: `name[:host[:guest]][/proto]`
5
+ *
6
+ * - `name` — required. If it matches a key/alias in `WELL_KNOWN_GUEST_PORTS`,
7
+ * missing `guest` and `proto` are filled from the registry. The original
8
+ * spelling (alias or canonical) is preserved as `name` so users see the
9
+ * label they typed in `quickchr list` / state.
10
+ * - `host` — optional. When omitted (or 0), `buildPortMappings` auto-allocates
11
+ * a host port from the machine's port block.
12
+ * - `guest` — optional only when `name` resolves in the registry; otherwise
13
+ * required.
14
+ * - `proto` — optional. Defaults from registry, else `tcp`. Must be `tcp` or
15
+ * `udp`.
16
+ *
17
+ * Throws `QuickCHRError("INVALID_FORWARD_SPEC", ...)` with the original spec
18
+ * embedded in the message on any parse error.
19
+ */
20
+
21
+ import { lookupGuestPort } from "./guest-ports.ts";
22
+ import { type PortMapping, QuickCHRError } from "./types.ts";
23
+
24
+ function fail(spec: string, reason: string): never {
25
+ throw new QuickCHRError(
26
+ "INVALID_FORWARD_SPEC",
27
+ `Invalid --forward spec "${spec}": ${reason}`,
28
+ );
29
+ }
30
+
31
+ function parsePort(spec: string, raw: string, label: string, allowZero: boolean): number {
32
+ if (!/^\d+$/.test(raw)) {
33
+ fail(spec, `${label} port "${raw}" is not a number`);
34
+ }
35
+ const n = Number(raw);
36
+ const min = allowZero ? 0 : 1;
37
+ if (n < min || n > 65535) {
38
+ fail(spec, `${label} port ${n} out of range (${min}-65535)`);
39
+ }
40
+ return n;
41
+ }
42
+
43
+ export function parseForwardSpec(spec: string): PortMapping {
44
+ if (!spec?.trim()) {
45
+ throw new QuickCHRError(
46
+ "INVALID_FORWARD_SPEC",
47
+ "Empty --forward spec",
48
+ );
49
+ }
50
+ const trimmed = spec.trim();
51
+
52
+ // Split off /proto suffix
53
+ let body = trimmed;
54
+ let protoStr: string | undefined;
55
+ const slash = trimmed.lastIndexOf("/");
56
+ if (slash !== -1) {
57
+ body = trimmed.slice(0, slash);
58
+ protoStr = trimmed.slice(slash + 1).toLowerCase();
59
+ if (protoStr !== "tcp" && protoStr !== "udp") {
60
+ fail(spec, `protocol must be "tcp" or "udp", got "${protoStr}"`);
61
+ }
62
+ }
63
+
64
+ const parts = body.split(":");
65
+ if (parts.length > 3) {
66
+ fail(spec, `too many ":" segments (expected name[:host[:guest]])`);
67
+ }
68
+ const name = parts[0]?.trim() ?? "";
69
+ if (!name) {
70
+ fail(spec, "name is required");
71
+ }
72
+
73
+ const known = lookupGuestPort(name);
74
+
75
+ let host = 0;
76
+ if (parts.length >= 2 && parts[1] !== "" && parts[1] !== undefined) {
77
+ host = parsePort(spec, parts[1], "host", true);
78
+ }
79
+
80
+ let guest: number;
81
+ if (parts.length >= 3 && parts[2] !== "" && parts[2] !== undefined) {
82
+ guest = parsePort(spec, parts[2], "guest", false);
83
+ } else if (known) {
84
+ guest = known.guest;
85
+ } else {
86
+ fail(spec, `guest port required for unknown service "${name}"`);
87
+ }
88
+
89
+ const proto: "tcp" | "udp" = (protoStr as "tcp" | "udp" | undefined) ?? known?.proto ?? "tcp";
90
+
91
+ return { name, host, guest, proto };
92
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Well-known guest port registry.
3
+ *
4
+ * Maps a canonical service name (e.g. "smb", "winbox") to its guest-side port
5
+ * and protocol. Used to power short-form `--forward <name>` CLI flags so users
6
+ * don't have to remember exact port numbers.
7
+ *
8
+ * Pure data + lookup. No side effects, no imports from the rest of the codebase.
9
+ */
10
+
11
+ export interface GuestPortDef {
12
+ /** Canonical lowercase name, e.g. "smb". */
13
+ name: string;
14
+ /** Guest-side port number (1..65535). */
15
+ guest: number;
16
+ /** Transport protocol. */
17
+ proto: "tcp" | "udp";
18
+ /** Optional alternate names that resolve to this entry, e.g. ["cifs"] for "smb". */
19
+ aliases?: string[];
20
+ /** Brief, factual notes. May reference RouterOS package or subsystem. */
21
+ notes?: string;
22
+ }
23
+
24
+ export const WELL_KNOWN_GUEST_PORTS: readonly GuestPortDef[] = [
25
+ // --- RouterOS services (defaults from /ip/service) ---
26
+ { name: "ftp", guest: 21, proto: "tcp", notes: "RouterOS /ip/service ftp" },
27
+ { name: "ssh", guest: 22, proto: "tcp", notes: "RouterOS /ip/service ssh" },
28
+ { name: "telnet", guest: 23, proto: "tcp", notes: "RouterOS /ip/service telnet" },
29
+ { name: "http", guest: 80, proto: "tcp", aliases: ["www", "webfig"], notes: "RouterOS /ip/service www (WebFig + REST)" },
30
+ { name: "https", guest: 443, proto: "tcp", aliases: ["www-ssl", "webfig-ssl"], notes: "RouterOS /ip/service www-ssl (WebFig + REST over TLS)" },
31
+ { name: "snmp", guest: 161, proto: "udp", notes: "RouterOS /snmp" },
32
+ { name: "bandwidth-test", guest: 2000, proto: "tcp", aliases: ["btest"], notes: "RouterOS /tool/bandwidth-server" },
33
+ { name: "dude", guest: 2210, proto: "tcp", notes: "RouterOS dude package server (legacy)" },
34
+ { name: "dude-secure", guest: 2211, proto: "tcp", notes: "RouterOS dude package server (TLS)" },
35
+ { name: "upnp", guest: 2828, proto: "tcp", notes: "RouterOS /ip/upnp" },
36
+ { name: "winbox", guest: 8291, proto: "tcp", notes: "RouterOS /ip/service winbox (also used by Dude client and MikroTik mobile app)" },
37
+ { name: "api", guest: 8728, proto: "tcp", notes: "RouterOS /ip/service api (native API)" },
38
+ { name: "api-ssl", guest: 8729, proto: "tcp", notes: "RouterOS /ip/service api-ssl" },
39
+ { name: "socks", guest: 1080, proto: "tcp", notes: "RouterOS /ip/socks" },
40
+
41
+ // --- Common services often forwarded for container / userspace use ---
42
+ { name: "dns", guest: 53, proto: "udp", notes: "DNS resolver (UDP)" },
43
+ { name: "dns-tcp", guest: 53, proto: "tcp", notes: "DNS resolver (TCP, used for large responses and zone transfers)" },
44
+ { name: "ntp", guest: 123, proto: "udp", notes: "Network Time Protocol" },
45
+ { name: "smb", guest: 445, proto: "tcp", aliases: ["cifs"], notes: "SMB/CIFS file sharing" },
46
+ { name: "syslog", guest: 514, proto: "udp", notes: "Syslog (UDP)" },
47
+ { name: "mqtt", guest: 1883, proto: "tcp", notes: "MQTT broker (plaintext)" },
48
+ { name: "winrm", guest: 5985, proto: "tcp", notes: "Windows Remote Management (HTTP)" },
49
+ { name: "winrm-https", guest: 5986, proto: "tcp", notes: "Windows Remote Management (HTTPS)" },
50
+ { name: "http-alt", guest: 8080, proto: "tcp", notes: "Alternate HTTP, common for containerized web apps" },
51
+ { name: "https-alt", guest: 8443, proto: "tcp", notes: "Alternate HTTPS, common for containerized web apps" },
52
+ { name: "mqtts", guest: 8883, proto: "tcp", notes: "MQTT broker (TLS)" },
53
+ ];
54
+
55
+ /**
56
+ * Look up a guest port definition by canonical name or alias.
57
+ * Case-insensitive. Returns `undefined` if not found.
58
+ */
59
+ export function lookupGuestPort(nameOrAlias: string): GuestPortDef | undefined {
60
+ if (!nameOrAlias) return undefined;
61
+ const key = nameOrAlias.toLowerCase();
62
+ for (const entry of WELL_KNOWN_GUEST_PORTS) {
63
+ if (entry.name === key) return entry;
64
+ if (entry.aliases?.some((a) => a.toLowerCase() === key)) return entry;
65
+ }
66
+ return undefined;
67
+ }
@@ -8,6 +8,7 @@ import {
8
8
  DEFAULT_PORT_BASE,
9
9
  PORTS_PER_BLOCK,
10
10
  type ChrPorts,
11
+ type MachineState,
11
12
  type NetworkConfig,
12
13
  type NetworkMode,
13
14
  type NetworkSpecifier,
@@ -77,6 +78,78 @@ export function buildPortMappings(
77
78
  return mappings;
78
79
  }
79
80
 
81
+ /** Validate user-supplied `extraPorts` with explicit `host` values for collisions.
82
+ *
83
+ * Rejects three classes of conflict:
84
+ * 1. Explicit host equals one of this machine's auto-allocated SERVICE_PORTS
85
+ * slots (`portBase + offset`) for a non-excluded service.
86
+ * 2. Two explicit entries in the same `extraPorts` set request the same host.
87
+ * 3. Explicit host is already in use by another existing machine's port mapping.
88
+ *
89
+ * Auto-allocated extras (host falsy/0) are not validated here — the live
90
+ * `findAvailablePortBlock` probe handles those.
91
+ *
92
+ * Pass `ownMachineName` to skip an existing machine's mappings (e.g. when
93
+ * re-validating during a restart).
94
+ */
95
+ export function validateExplicitExtraPorts(
96
+ extraPorts: PortMapping[] | undefined,
97
+ portBase: number,
98
+ excludePorts: ServiceName[] = [],
99
+ existingMachines: MachineState[] = [],
100
+ ownMachineName?: string,
101
+ ): void {
102
+ if (!extraPorts || extraPorts.length === 0) return;
103
+
104
+ const serviceHosts = new Map<number, string>();
105
+ for (const [name, spec] of Object.entries(SERVICE_PORTS)) {
106
+ if (excludePorts.includes(name as ServiceName)) continue;
107
+ serviceHosts.set(portBase + spec.offset, name);
108
+ }
109
+
110
+ const otherHosts = new Map<number, { machine: string; service: string }>();
111
+ for (const m of existingMachines) {
112
+ if (ownMachineName && m.name === ownMachineName) continue;
113
+ for (const mapping of Object.values(m.ports ?? {})) {
114
+ otherHosts.set(mapping.host, { machine: m.name, service: mapping.name });
115
+ }
116
+ }
117
+
118
+ const seenInExtras = new Map<number, string>();
119
+
120
+ for (let i = 0; i < extraPorts.length; i++) {
121
+ const extra = extraPorts[i];
122
+ if (!extra?.host) continue;
123
+
124
+ const label = extra.name || `custom-${i}`;
125
+
126
+ const svc = serviceHosts.get(extra.host);
127
+ if (svc) {
128
+ throw new QuickCHRError(
129
+ "PORT_CONFLICT",
130
+ `Explicit extra port "${label}" host=${extra.host} collides with built-in service "${svc}" (portBase=${portBase}). Pick a different host port or exclude the service.`,
131
+ );
132
+ }
133
+
134
+ const dup = seenInExtras.get(extra.host);
135
+ if (dup) {
136
+ throw new QuickCHRError(
137
+ "PORT_CONFLICT",
138
+ `Explicit extra ports "${dup}" and "${label}" both request host=${extra.host}.`,
139
+ );
140
+ }
141
+ seenInExtras.set(extra.host, label);
142
+
143
+ const other = otherHosts.get(extra.host);
144
+ if (other) {
145
+ throw new QuickCHRError(
146
+ "PORT_CONFLICT",
147
+ `Explicit extra port "${label}" host=${extra.host} is already allocated to machine "${other.machine}" (service "${other.service}").`,
148
+ );
149
+ }
150
+ }
151
+ }
152
+
80
153
  /** Build QEMU hostfwd string from port mappings. */
81
154
  export function buildHostfwdString(ports: Record<string, PortMapping>): string {
82
155
  return Object.values(ports)
@@ -2,15 +2,15 @@
2
2
  * Extra package download and installation for CHR instances.
3
3
  */
4
4
 
5
- import { existsSync, readdirSync, unlinkSync, chmodSync } from "node:fs";
5
+ import { existsSync, readdirSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
- import { tmpdir } from "node:os";
8
7
  import type { Arch } from "./types.ts";
9
8
  import { QuickCHRError } from "./types.ts";
10
9
  import { packagesDownloadUrl } from "./versions.ts";
11
10
  import { getCacheDir, ensureDir } from "./state.ts";
12
11
  import { createLogger, type ProgressLogger } from "./log.ts";
13
12
  import { restPost } from "./rest.ts";
13
+ import { scpPush } from "./scp.ts";
14
14
  import { extractZip } from "./zip.ts";
15
15
 
16
16
  /** Download and extract the all-packages ZIP for a version/arch. Returns the extract dir. */
@@ -74,7 +74,7 @@ export function findPackageFile(
74
74
  return match ? join(extractDir, match) : undefined;
75
75
  }
76
76
 
77
- /** Upload packages to a running CHR via SCP. */
77
+ /** Upload packages to a running CHR via SCP. Uploads each file to flash root (`/<basename>`). */
78
78
  export async function uploadPackages(
79
79
  packagePaths: string[],
80
80
  sshPort: number,
@@ -82,58 +82,11 @@ export async function uploadPackages(
82
82
  password: string = "",
83
83
  logger?: ProgressLogger,
84
84
  ): Promise<void> {
85
- // Use SSH_ASKPASS to supply the password without requiring sshpass.
86
- // SSH_ASKPASS_REQUIRE=prefer (OpenSSH 8.4+) works even without a display.
87
- // SSH_ASKPASS: a small helper program that prints the password when invoked.
88
- // On Windows: .cmd script (OpenSSH Windows calls it as a process, not a shell).
89
- // On Unix: .sh script with execute bit set via chmodSync.
90
- const isWindows = process.platform === "win32";
91
- const ext = isWindows ? ".cmd" : ".sh";
92
- const askpassPath = join(tmpdir(), `quickchr-askpass-${process.pid}${ext}`);
93
- if (isWindows) {
94
- // Windows .cmd: OpenSSH reads stdout from the helper
95
- await Bun.write(askpassPath, `@echo off\r\necho ${password.replace(/[&|<>^%]/g, "^$&")}\r\n`);
96
- } else {
97
- await Bun.write(askpassPath, `#!/bin/sh\nprintf '%s' '${password.replace(/'/g, "'\\''")}'`);
98
- chmodSync(askpassPath, 0o755);
99
- }
100
-
101
- const scpEnv: Record<string, string> = {
102
- ...(process.env as Record<string, string>),
103
- DISPLAY: "",
104
- SSH_ASKPASS: askpassPath,
105
- SSH_ASKPASS_REQUIRE: "prefer",
106
- };
107
-
108
- try {
109
- const log = logger ?? createLogger();
110
- for (const pkgPath of packagePaths) {
111
- const filename = basename(pkgPath);
112
- log.status(`Uploading: ${filename}`);
113
-
114
- const scpArgs = [
115
- "scp", "-P", String(sshPort),
116
- "-o", "StrictHostKeyChecking=accept-new",
117
- "-o", "UserKnownHostsFile=/dev/null",
118
- pkgPath, `${user}@127.0.0.1:/`,
119
- ];
120
-
121
- const result = Bun.spawnSync(scpArgs, {
122
- stdout: "pipe",
123
- stderr: "pipe",
124
- env: scpEnv,
125
- });
126
-
127
- if (result.exitCode !== 0) {
128
- const stderr = new TextDecoder().decode(result.stderr);
129
- throw new QuickCHRError(
130
- "PROCESS_FAILED",
131
- `SCP upload failed for ${filename}: ${stderr}`,
132
- );
133
- }
134
- }
135
- } finally {
136
- try { unlinkSync(askpassPath); } catch { /* ignore */ }
85
+ const log = logger ?? createLogger();
86
+ for (const pkgPath of packagePaths) {
87
+ const filename = basename(pkgPath);
88
+ log.status(`Uploading: ${filename}`);
89
+ await scpPush(pkgPath, undefined, { sshPort, user, password });
137
90
  }
138
91
  }
139
92
 
@@ -27,11 +27,12 @@ import {
27
27
  assertProvisioningSupportedVersion,
28
28
  PROVISIONING_FEATURE_LABEL,
29
29
  } from "./versions.ts";
30
- import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, buildHostfwdString, hasUserModeNetwork } from "./network.ts";
30
+ import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts } from "./network.ts";
31
31
  import {
32
32
  getUsedPortBases,
33
33
  saveMachine,
34
34
  loadMachine,
35
+ loadAllMachines,
35
36
  removeMachine as removeState,
36
37
  getMachineDir,
37
38
  getMachinesDir,
@@ -43,13 +44,15 @@ import {
43
44
  getDataDir,
44
45
  } from "./state.ts";
45
46
  import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
47
+ import { autoPruneIfOverCap } from "./cache.ts";
46
48
  import { buildQemuArgs, spawnQemu, stopQemu, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
47
49
  import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
48
50
  import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
49
51
  import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
50
52
  import { provision } from "./provision.ts";
51
53
  import { renewLicense, getLicenseInfo } from "./license.ts";
52
- import { resolveAuth } from "./auth.ts";
54
+ import { resolveAuth, resolveCreds } from "./auth.ts";
55
+ import { scpPush, scpPull } from "./scp.ts";
53
56
  import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials } from "./credentials.ts";
54
57
  import { restExecute } from "./exec.ts";
55
58
  import { qgaExec } from "./qga.ts";
@@ -109,6 +112,16 @@ function hasProvisioningMutations(opts: {
109
112
  );
110
113
  }
111
114
 
115
+ /** Normalize convenience aliases on StartOptions:
116
+ * - `noAuth: true` → `secureLogin: false` (only when secureLogin is not explicitly set).
117
+ * Returns a shallow copy so the caller's input object is not mutated. */
118
+ function normalizeStartOptions(opts: StartOptions): StartOptions {
119
+ if (opts.noAuth === true && opts.secureLogin === undefined) {
120
+ return { ...opts, secureLogin: false };
121
+ }
122
+ return opts;
123
+ }
124
+
112
125
  function listProvisioningMutations(opts: {
113
126
  installAllPackages?: boolean;
114
127
  packages?: string[];
@@ -166,7 +179,7 @@ function registerSocketMembers(state: MachineState): void {
166
179
  for (const name of getSocketNamedNetworks(state)) {
167
180
  try {
168
181
  if (!getNamedSocket(name)) {
169
- createNamedSocket(name);
182
+ createNamedSocket(name, { autoCreated: true });
170
183
  }
171
184
  addSocketMember(name, state.name);
172
185
  } catch (e) {
@@ -218,6 +231,9 @@ function createInstance(state: MachineState): ChrInstance {
218
231
  ports,
219
232
  restUrl,
220
233
  sshPort: ports.ssh,
234
+ portBase: state.portBase,
235
+ captureInterface: process.platform === "darwin" ? "lo0" : "any",
236
+ tzspGatewayIp: "10.0.2.2",
221
237
 
222
238
  async waitForBoot(timeoutMs?: number): Promise<boolean> {
223
239
  // Use resolved credentials so waitForBoot can validate the response body
@@ -226,6 +242,19 @@ function createInstance(state: MachineState): ChrInstance {
226
242
  return waitForBoot(ports.http, timeoutMs, auth.header);
227
243
  },
228
244
 
245
+ async waitFor(condition: () => Promise<boolean>, timeoutMs = 30_000): Promise<boolean> {
246
+ const deadline = Date.now() + timeoutMs;
247
+ while (Date.now() < deadline) {
248
+ try {
249
+ if (await condition()) return true;
250
+ } catch { /* swallow — condition may throw before the state is ready */ }
251
+ const remaining = deadline - Date.now();
252
+ if (remaining <= 0) break;
253
+ await Bun.sleep(Math.min(2000, remaining));
254
+ }
255
+ return false;
256
+ },
257
+
229
258
  async stop(): Promise<void> {
230
259
  if (state.pid) {
231
260
  await stopQemu(state.pid);
@@ -514,6 +543,28 @@ function createInstance(state: MachineState): ChrInstance {
514
543
  return installed;
515
544
  },
516
545
 
546
+ async upload(localPath: string, remotePath?: string): Promise<void> {
547
+ if (!isMachineRunning(state)) {
548
+ throw new QuickCHRError(
549
+ "MACHINE_STOPPED",
550
+ `Machine "${state.name}" must be running to upload files.`,
551
+ );
552
+ }
553
+ const creds = resolveCreds(state);
554
+ await scpPush(localPath, remotePath, { sshPort: ports.ssh, ...creds });
555
+ },
556
+
557
+ async download(remotePath: string, localPath: string): Promise<void> {
558
+ if (!isMachineRunning(state)) {
559
+ throw new QuickCHRError(
560
+ "MACHINE_STOPPED",
561
+ `Machine "${state.name}" must be running to download files.`,
562
+ );
563
+ }
564
+ const creds = resolveCreds(state);
565
+ await scpPull(remotePath, localPath, { sshPort: ports.ssh, ...creds });
566
+ },
567
+
517
568
  async destroy(): Promise<void> {
518
569
  await this.stop();
519
570
  await this.remove();
@@ -690,6 +741,12 @@ function hostArchToChr(): Arch {
690
741
  return "x86";
691
742
  }
692
743
 
744
+ /** Resolve user-supplied arch (including the "auto" synonym) to a concrete Arch. */
745
+ function resolveArch(input: Arch | "auto" | undefined): Arch {
746
+ if (input === undefined || input === "auto") return hostArchToChr();
747
+ return input;
748
+ }
749
+
693
750
  async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
694
751
  const deadline = Date.now() + timeoutMs;
695
752
  while (Date.now() < deadline) {
@@ -937,6 +994,7 @@ export class QuickCHR {
937
994
  * and applied automatically on the first subsequent start(). Disk options (`bootSize`,
938
995
  * `extraDisks`) are materialized immediately and require `qemu-img` on the host. */
939
996
  static async add(opts: StartOptions = {}): Promise<MachineState> {
997
+ opts = normalizeStartOptions(opts);
940
998
  if (opts.name?.startsWith("-")) {
941
999
  throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
942
1000
  }
@@ -952,7 +1010,7 @@ export class QuickCHR {
952
1010
  version = await resolveVersion(channel);
953
1011
  }
954
1012
 
955
- const arch: Arch = opts.arch ?? hostArchToChr();
1013
+ const arch: Arch = resolveArch(opts.arch);
956
1014
  requireQemu(arch);
957
1015
  if (arch === "arm64") requireFirmware();
958
1016
  const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
@@ -987,6 +1045,7 @@ export class QuickCHR {
987
1045
 
988
1046
  const usedBases = getUsedPortBases();
989
1047
  const portBase = opts.portBase ?? await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
1048
+ validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, loadAllMachines(), name);
990
1049
  const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
991
1050
 
992
1051
  const machineDir = getMachineDir(name);
@@ -1017,6 +1076,7 @@ export class QuickCHR {
1017
1076
  deviceMode: opts.deviceMode,
1018
1077
  user: opts.user,
1019
1078
  disableAdmin: opts.disableAdmin,
1079
+ secureLogin: opts.secureLogin,
1020
1080
  portBase,
1021
1081
  excludePorts: opts.excludePorts ?? [],
1022
1082
  extraPorts: opts.extraPorts ?? [],
@@ -1044,6 +1104,7 @@ export class QuickCHR {
1044
1104
  * apply when creating a new machine and require `qemu-img` on the host.
1045
1105
  */
1046
1106
  static async start(opts: StartOptions = {}): Promise<ChrInstance> {
1107
+ opts = normalizeStartOptions(opts);
1047
1108
  // Validate name early (before any I/O) so callers get a fast, clear error
1048
1109
  if (opts.name?.startsWith("-")) {
1049
1110
  throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
@@ -1062,6 +1123,9 @@ export class QuickCHR {
1062
1123
  let version: string;
1063
1124
  if (opts.version) {
1064
1125
  if ((CHANNELS as string[]).includes(opts.version)) {
1126
+ logger.warn(
1127
+ `'${opts.version}' is a channel name, not a version — passed as 'version' but resolved as a channel. Use 'channel: "${opts.version}"' instead to make this explicit.`,
1128
+ );
1065
1129
  version = await resolveVersion(opts.version as Channel);
1066
1130
  } else if (!isValidVersion(opts.version)) {
1067
1131
  throw new QuickCHRError("INVALID_VERSION", `Invalid version: ${opts.version}`);
@@ -1094,7 +1158,7 @@ export class QuickCHR {
1094
1158
  }
1095
1159
 
1096
1160
  // Resolve architecture
1097
- const arch: Arch = opts.arch ?? hostArchToChr();
1161
+ const arch: Arch = resolveArch(opts.arch);
1098
1162
  const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
1099
1163
 
1100
1164
  // Check prerequisites (skip for dry-run — no QEMU needed)
@@ -1152,6 +1216,14 @@ export class QuickCHR {
1152
1216
  opts.extraPorts,
1153
1217
  );
1154
1218
 
1219
+ validateExplicitExtraPorts(
1220
+ opts.extraPorts,
1221
+ portBase,
1222
+ opts.excludePorts,
1223
+ loadAllMachines(),
1224
+ name,
1225
+ );
1226
+
1155
1227
  const ports = buildPortMappings(
1156
1228
  portBase,
1157
1229
  opts.excludePorts,
@@ -1173,6 +1245,7 @@ export class QuickCHR {
1173
1245
  deviceMode: requestedDeviceMode,
1174
1246
  user: opts.user,
1175
1247
  disableAdmin: opts.disableAdmin,
1248
+ secureLogin: opts.secureLogin,
1176
1249
  portBase,
1177
1250
  excludePorts: opts.excludePorts ?? [],
1178
1251
  extraPorts: opts.extraPorts ?? [],
@@ -1247,6 +1320,7 @@ export class QuickCHR {
1247
1320
  deviceMode: requestedDeviceMode,
1248
1321
  user: opts.user,
1249
1322
  disableAdmin: opts.disableAdmin,
1323
+ secureLogin: opts.secureLogin,
1250
1324
  portBase,
1251
1325
  excludePorts: opts.excludePorts ?? [],
1252
1326
  extraPorts: opts.extraPorts ?? [],
@@ -1584,6 +1658,10 @@ export class QuickCHR {
1584
1658
  await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
1585
1659
  }
1586
1660
 
1661
+ try {
1662
+ autoPruneIfOverCap({ logger: logger ? (msg) => logger.status(msg) : undefined, protectVersions: [state.version] });
1663
+ } catch { /* never propagate */ }
1664
+
1587
1665
  return instance;
1588
1666
  } finally {
1589
1667
  try { unlinkSync(lockPath); } catch { /* ignore */ }
package/src/lib/scp.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * SCP file transfer to/from a running CHR.
3
+ *
4
+ * Uses the same SSH_ASKPASS trick as {@link ./packages.ts uploadPackages} so
5
+ * no `sshpass` dependency is required. Both directions share the askpass
6
+ * lifecycle (written to tmpdir, cleaned up on exit).
7
+ */
8
+
9
+ import { chmodSync, unlinkSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { basename, join } from "node:path";
12
+
13
+ import { QuickCHRError } from "./types.ts";
14
+
15
+ export interface ScpOptions {
16
+ sshPort: number;
17
+ user: string;
18
+ password: string;
19
+ }
20
+
21
+ /** Copy a local file to the CHR. `remotePath` defaults to `/<basename>` (RouterOS flash root). */
22
+ export async function scpPush(
23
+ localPath: string,
24
+ remotePath: string | undefined,
25
+ opts: ScpOptions,
26
+ ): Promise<void> {
27
+ const remote = remotePath ?? `/${basename(localPath)}`;
28
+ await runScp([localPath, `${opts.user}@127.0.0.1:${remote}`], opts, `upload ${basename(localPath)}`);
29
+ }
30
+
31
+ /** Copy a file from the CHR to the local filesystem. */
32
+ export async function scpPull(
33
+ remotePath: string,
34
+ localPath: string,
35
+ opts: ScpOptions,
36
+ ): Promise<void> {
37
+ await runScp([`${opts.user}@127.0.0.1:${remotePath}`, localPath], opts, `download ${remotePath}`);
38
+ }
39
+
40
+ /** Shared plumbing: write askpass, invoke `scp`, clean up, translate exit code to QuickCHRError. */
41
+ async function runScp(
42
+ scpArgs: string[],
43
+ opts: ScpOptions,
44
+ label: string,
45
+ ): Promise<void> {
46
+ const { askpassPath, env } = await writeAskpass(opts.password);
47
+ try {
48
+ const args = [
49
+ "scp",
50
+ "-P", String(opts.sshPort),
51
+ "-o", "StrictHostKeyChecking=accept-new",
52
+ "-o", "UserKnownHostsFile=/dev/null",
53
+ ...scpArgs,
54
+ ];
55
+ const result = Bun.spawnSync(args, {
56
+ stdout: "pipe",
57
+ stderr: "pipe",
58
+ env,
59
+ });
60
+ if (result.exitCode !== 0) {
61
+ const stderr = new TextDecoder().decode(result.stderr);
62
+ throw new QuickCHRError(
63
+ "PROCESS_FAILED",
64
+ `SCP ${label} failed: ${stderr.trim() || `exit ${result.exitCode}`}`,
65
+ );
66
+ }
67
+ } finally {
68
+ try { unlinkSync(askpassPath); } catch { /* ignore */ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Write an SSH_ASKPASS helper for the given password.
74
+ *
75
+ * SSH_ASKPASS: a small program OpenSSH invokes to fetch a password when no
76
+ * TTY is available. `SSH_ASKPASS_REQUIRE=prefer` (OpenSSH 8.4+) makes it
77
+ * work without a display. On Windows we emit a `.cmd`; on Unix a `.sh`
78
+ * with the execute bit set.
79
+ */
80
+ async function writeAskpass(password: string): Promise<{ askpassPath: string; env: Record<string, string> }> {
81
+ const isWindows = process.platform === "win32";
82
+ const ext = isWindows ? ".cmd" : ".sh";
83
+ const askpassPath = join(tmpdir(), `quickchr-askpass-${process.pid}-${Date.now()}${ext}`);
84
+ if (isWindows) {
85
+ await Bun.write(askpassPath, `@echo off\r\necho ${password.replace(/[&|<>^%]/g, "^$&")}\r\n`);
86
+ } else {
87
+ await Bun.write(askpassPath, `#!/bin/sh\nprintf '%s' '${password.replace(/'/g, "'\\''")}'`);
88
+ chmodSync(askpassPath, 0o755);
89
+ }
90
+ const env: Record<string, string> = {
91
+ ...(process.env as Record<string, string>),
92
+ DISPLAY: "",
93
+ SSH_ASKPASS: askpassPath,
94
+ SSH_ASKPASS_REQUIRE: "prefer",
95
+ };
96
+ return { askpassPath, env };
97
+ }