@tikoci/quickchr 0.4.2 → 0.4.4

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/src/cli/wizard.ts CHANGED
@@ -5,12 +5,43 @@
5
5
  import type { Arch, Channel, DeviceModeOptions, LicenseLevel, LicenseOptions, NetworkSpecifier, StartOptions } from "../lib/types.ts";
6
6
  import { CHANNELS, ARCHES, knownPackagesForArch } from "../lib/types.ts";
7
7
 
8
+ export type WizardUserChoice = "custom" | "managed" | "admin";
9
+
10
+ export interface WizardUserChoiceResult {
11
+ user?: { name: string; password: string };
12
+ disableAdmin: boolean;
13
+ secureLogin?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Maps the wizard's "CHR login setup" choice to StartOptions fields. Pulled out as a
18
+ * pure function (rather than inlined in runWizard) so it's unit-testable without
19
+ * mocking the entire @clack/prompts prompt sequence — "managed" must set both
20
+ * disableAdmin AND secureLogin, since provision() only auto-creates the replacement
21
+ * quickchr account when secureLogin === true (src/lib/provision.ts); disableAdmin
22
+ * alone would disable the default account with no replacement login provisioned.
23
+ */
24
+ export function resolveUserChoiceOptions(
25
+ userChoice: WizardUserChoice,
26
+ customUser?: { name: string; password: string },
27
+ ): WizardUserChoiceResult {
28
+ if (userChoice === "custom") {
29
+ return { user: customUser, disableAdmin: true };
30
+ }
31
+ if (userChoice === "managed") {
32
+ return { disableAdmin: true, secureLogin: true };
33
+ }
34
+ // "admin" — keep admin with no password
35
+ return { disableAdmin: false, secureLogin: false };
36
+ }
37
+
8
38
  /** Run the interactive wizard using @clack/prompts. */
9
39
  export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<void> {
10
40
  // Dynamic import — only loaded when wizard is actually used
11
41
  const clack = await import("@clack/prompts");
12
42
  const { QuickCHR } = await import("../lib/quickchr.ts");
13
43
  const { formatPorts, bold } = await import("./format.ts");
44
+ const { resolveSetting } = await import("../lib/settings.ts");
14
45
 
15
46
  clack.intro("quickchr — MikroTik CHR Manager");
16
47
  if (wizardOpts?.firstRun) {
@@ -31,6 +62,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
31
62
  let channel: Channel | undefined;
32
63
 
33
64
  if (versionSource === "channel") {
65
+ const defaultChannel = resolveSetting("default-channel").value as Channel;
34
66
  const ch = await clack.select({
35
67
  message: "Select channel:",
36
68
  options: CHANNELS.map((c) => ({
@@ -38,7 +70,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
38
70
  label: c,
39
71
  hint: c === "long-term" ? "recommended for provisioning" : undefined,
40
72
  })),
41
- initialValue: "long-term" as Channel,
73
+ initialValue: defaultChannel,
42
74
  });
43
75
  if (clack.isCancel(ch)) { clack.cancel("Cancelled."); process.exit(0); }
44
76
  channel = ch;
@@ -57,6 +89,8 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
57
89
 
58
90
  // 2. Architecture
59
91
  const hostArch = process.arch === "arm64" ? "arm64" : "x86";
92
+ const settingArch = resolveSetting("default-arch").value as Arch | "auto";
93
+ const archInitial: Arch = settingArch === "auto" ? hostArch : settingArch;
60
94
  const arch = await clack.select({
61
95
  message: "Architecture:",
62
96
  options: ARCHES.map((a) => ({
@@ -64,7 +98,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
64
98
  label: a,
65
99
  hint: a === hostArch ? "host native" : "emulated (slower)",
66
100
  })),
67
- initialValue: hostArch as Arch,
101
+ initialValue: archInitial,
68
102
  });
69
103
  if (clack.isCancel(arch)) { clack.cancel("Cancelled."); process.exit(0); }
70
104
 
@@ -304,7 +338,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
304
338
  } = await import("../lib/versions.ts");
305
339
  let resolvedVersion = version ?? await resolveVersion(channel ?? "long-term");
306
340
  version = resolvedVersion;
307
- let provisioningSupported = isProvisioningSupportedVersion(resolvedVersion);
341
+ const provisioningSupported = isProvisioningSupportedVersion(resolvedVersion);
308
342
 
309
343
  let installAllPackages = false;
310
344
  let packages: string[] = [];
@@ -346,7 +380,6 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
346
380
  channel = "long-term";
347
381
  resolvedVersion = await resolveVersion(channel);
348
382
  version = resolvedVersion;
349
- provisioningSupported = true;
350
383
  wantProvision = await askProvisionChoice();
351
384
  }
352
385
  }
@@ -428,6 +461,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
428
461
  }
429
462
 
430
463
  // 6c. User setup
464
+ const secureLoginSetting = resolveSetting("secure-login").value;
431
465
  const userChoice = await clack.select({
432
466
  message: "CHR login setup:",
433
467
  options: [
@@ -435,10 +469,11 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
435
469
  { value: "custom", label: "Custom user", hint: "you provide username + password" },
436
470
  { value: "admin", label: "Keep admin with no password", hint: "less secure" },
437
471
  ],
438
- initialValue: "managed",
472
+ initialValue: secureLoginSetting === false ? "admin" : "managed",
439
473
  });
440
474
  if (clack.isCancel(userChoice)) { clack.cancel("Cancelled."); process.exit(0); }
441
475
 
476
+ let customUser: { name: string; password: string } | undefined;
442
477
  if (userChoice === "custom") {
443
478
  const userName = await clack.text({
444
479
  message: "Username:",
@@ -449,14 +484,9 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
449
484
  const userPass = await clack.password({ message: "Password:" });
450
485
  if (clack.isCancel(userPass)) { clack.cancel("Cancelled."); process.exit(0); }
451
486
 
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;
487
+ customUser = { name: userName, password: userPass };
459
488
  }
489
+ ({ user, disableAdmin, secureLogin } = resolveUserChoiceOptions(userChoice as WizardUserChoice, customUser));
460
490
 
461
491
  // 6d. License
462
492
  const wantLicense = await clack.confirm({
package/src/index.ts CHANGED
@@ -32,6 +32,8 @@ export type {
32
32
  Channel,
33
33
  ChrInstance,
34
34
  ChrPorts,
35
+ CustomForward,
36
+ Descriptor,
35
37
  DeviceModeOptions,
36
38
  DoctorCheck,
37
39
  DoctorResult,
@@ -44,11 +46,11 @@ export type {
44
46
  LicenseLevel,
45
47
  LicenseOptions,
46
48
  MachineConfig,
47
- MachineDescriptor,
48
49
  MachineState,
49
50
  NetworkConfig,
50
51
  NetworkMode,
51
52
  NetworkSpecifier,
53
+ NetworkTopologyEntry,
52
54
  PackageManager,
53
55
  PlatformInfo,
54
56
  PortMapping,
@@ -59,12 +61,14 @@ export type {
59
61
  QgaNetworkIpAddress,
60
62
  QgaOsInfo,
61
63
  QgaTimezone,
64
+ ServiceEndpoint,
62
65
  ServiceName,
63
66
  SnapshotInfo,
67
+ SshServiceEndpoint,
64
68
  StartOptions,
65
69
  } from "./lib/types.ts";
66
70
 
67
- export { QuickCHRError } from "./lib/types.ts";
71
+ export { QuickCHRError, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./lib/types.ts";
68
72
  export type { ErrorCode, HostInterface } from "./lib/types.ts";
69
73
 
70
74
  // Interface detection
@@ -125,7 +129,7 @@ export { WELL_KNOWN_GUEST_PORTS, lookupGuestPort } from "./lib/guest-ports.ts";
125
129
  export type { GuestPortDef } from "./lib/guest-ports.ts";
126
130
 
127
131
  // Forward spec parsing
128
- export { parseForwardSpec } from "./lib/forward-spec.ts";
132
+ export { parseForwardSpec, expandForwardSpec, FORWARD_RANGE_MAX } from "./lib/forward-spec.ts";
129
133
 
130
134
  // Logging
131
135
  export { createLogger } from "./lib/log.ts";
package/src/lib/cache.ts CHANGED
@@ -11,10 +11,28 @@ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { getCacheDir, loadAllMachines } from "./state.ts";
13
13
  import { chrImageBasename, compareRouterOsVersion, isValidVersion } from "./versions.ts";
14
- import type { Arch } from "./types.ts";
14
+ import { QuickCHRError, type Arch } from "./types.ts";
15
15
 
16
16
  export const DEFAULT_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
17
17
 
18
+ /** Parse a human size string (NK/NM/NG/NT, IEC binary, case-insensitive) to bytes. */
19
+ export function parseSizeString(s: string): number {
20
+ const m = s.match(/^(\d+(?:\.\d+)?)([KMGT]?)i?B?$/i);
21
+ if (!m) throw new QuickCHRError("INVALID_SIZE_STRING", `Invalid size "${s}" (use NK/NM/NG/NT, e.g. 2G)`);
22
+ const n = Number.parseFloat(m[1] ?? "0");
23
+ const unit = (m[2] ?? "").toUpperCase();
24
+ const mult: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
25
+ return Math.floor(n * (mult[unit] ?? 1));
26
+ }
27
+
28
+ /** Format a byte count as a human size (IEC binary), matching cache list's display. */
29
+ export function formatSizeBytes(b: number): string {
30
+ if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GiB`;
31
+ if (b >= 1024 ** 2) return `${(b / 1024 ** 2).toFixed(1)} MiB`;
32
+ if (b >= 1024) return `${(b / 1024).toFixed(1)} KiB`;
33
+ return `${b} B`;
34
+ }
35
+
18
36
  export interface CacheEntry {
19
37
  path: string;
20
38
  basename: string;
@@ -54,7 +54,22 @@ export function channelFileExists(path: string): boolean {
54
54
  return existsSync(path);
55
55
  }
56
56
 
57
- /** Send a command to the QEMU monitor and return the response. */
57
+ /** Strip ANSI escapes and the monitor's per-character command echo, leaving
58
+ * just the response body. The echo hid real errors from callers: QEMU renders
59
+ * the typed command as incremental `cmd\x1b[K\x1b[D…` fragments before the
60
+ * first newline, so an `Error: …` response never appeared at string start and
61
+ * `savevm`/`loadvm` failures were silently swallowed (#31). */
62
+ export function cleanMonitorResponse(raw: string): string {
63
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are the point
64
+ const noAnsi = raw.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
65
+ const lines = noAnsi.split(/\r?\n/);
66
+ // Everything before the first newline is the echoed command; the response
67
+ // (possibly multi-line, possibly empty) follows.
68
+ return lines.slice(1).join("\n").trim();
69
+ }
70
+
71
+ /** Send a command to the QEMU monitor and return the cleaned response
72
+ * (command echo and ANSI control sequences stripped — see cleanMonitorResponse). */
58
73
  export async function monitorCommand(
59
74
  machineDir: string,
60
75
  command: string,
@@ -92,9 +107,9 @@ export async function monitorCommand(
92
107
  buffer = "";
93
108
  socket.write(command + "\n");
94
109
  } else if (sentCommand && buffer.includes("(qemu)")) {
95
- // Got response — strip the prompt
110
+ // Got response — strip the prompt, the command echo, and ANSI noise
96
111
  clearTimeout(timeout);
97
- const response = buffer.replace(/\(qemu\)\s*/g, "").trim();
112
+ const response = cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, ""));
98
113
  socket.destroy();
99
114
  resolve(response);
100
115
  }
@@ -105,7 +120,7 @@ export async function monitorCommand(
105
120
  socket.on("close", () => {
106
121
  clearTimeout(timeout);
107
122
  if (sentCommand) {
108
- resolve(buffer.replace(/\(qemu\)\s*/g, "").trim());
123
+ resolve(cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, "")));
109
124
  } else {
110
125
  reject(new QuickCHRError("MACHINE_STOPPED", "Monitor socket closed before command could be sent"));
111
126
  }
@@ -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 setup completions version help"
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"
332
332
 
333
333
  if [[ ${D}cword -eq 1 ]]; then
334
334
  COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
@@ -402,6 +402,7 @@ _quickchr() {
402
402
  'networks:Network discovery and socket management'
403
403
  'net:Network discovery (alias)'
404
404
  'doctor:Check prerequisites'
405
+ 'settings:Manage global quickchr settings'
405
406
  'setup:Interactive setup wizard'
406
407
  'completions:Manage shell completions'
407
408
  'version:Show version info'
@@ -434,7 +435,7 @@ _quickchr() {
434
435
  _describe 'option' opts
435
436
  ;;
436
437
  help)
437
- local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor setup completions version)
438
+ local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor settings setup completions version)
438
439
  _describe 'command' commands
439
440
  ;;
440
441
  esac
@@ -450,7 +451,7 @@ function generateFishCompletion(): string {
450
451
  return `# quickchr fish completion
451
452
  # Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
452
453
 
453
- 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 setup completions version help
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
454
455
 
455
456
  # Disable file completions globally for quickchr
456
457
  complete -c quickchr -f
@@ -490,6 +491,8 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
490
491
  -a "networks net" -d "Network discovery and socket management"
491
492
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
492
493
  -a "doctor" -d "Check prerequisites"
494
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
495
+ -a "settings" -d "Manage global quickchr settings"
493
496
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
494
497
  -a "setup" -d "Interactive setup wizard"
495
498
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
package/src/lib/disk.ts CHANGED
@@ -260,8 +260,11 @@ export function parseSnapshotList(monitorOutput: string): SnapshotInfo[] {
260
260
  // Match snapshot data lines: ID, TAG, VM SIZE, DATE (YYYY-MM-DD HH:MM:SS), VM CLOCK, ICOUNT
261
261
  // Example: "-- test 113 MiB 2026-04-14 00:47:19 0000:01:17.163 --"
262
262
  // Example: "1 snap1 113 MiB 2026-04-14 00:47:19 0000:01:17.163 --"
263
+ // VM CLOCK hour width varies by QEMU version (2-digit on 8.x runners,
264
+ // 4-digit on 11.x brew builds) and the ICOUNT column may be absent —
265
+ // accept both so list() doesn't silently return [] on older QEMU.
263
266
  const match = line.match(
264
- /^\s*(\S+)\s+(\S+)\s+(\d+(?:\.\d+)?\s*(?:[KMGT]i?B|bytes?))\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+(\d{4}:\d{2}:\d{2}\.\d+)\s+(\S+)/i,
267
+ /^\s*(\S+)\s+(\S+)\s+(\d+(?:\.\d+)?\s*(?:[KMGT]i?B|bytes?))\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+(\d{2,4}:\d{2}:\d{2}\.\d+)(?:\s+(\S+))?/i,
265
268
  );
266
269
  if (!match) continue;
267
270
 
@@ -274,7 +277,7 @@ export function parseSnapshotList(monitorOutput: string): SnapshotInfo[] {
274
277
  vmStateSize: parseVmSize(vmSizeStr),
275
278
  date: dateStr.replace(" ", "T") + "Z",
276
279
  vmClock: vmClock,
277
- icount: icountStr === "--" ? undefined : Number(icountStr),
280
+ icount: icountStr && icountStr !== "--" ? Number(icountStr) : undefined,
278
281
  });
279
282
  }
280
283
 
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Parse a CLI `--forward` spec into a `PortMapping`.
2
+ * Parse a CLI `--forward` spec into one or more `PortMapping`s.
3
3
  *
4
- * Grammar: `name[:host[:guest]][/proto]`
4
+ * Two grammars share this file:
5
+ *
6
+ * **Single port** — `parseForwardSpec`, grammar `name[:host[:guest]][/proto]`:
5
7
  *
6
8
  * - `name` — required. If it matches a key/alias in `WELL_KNOWN_GUEST_PORTS`,
7
9
  * missing `guest` and `proto` are filled from the registry. The original
@@ -14,7 +16,16 @@
14
16
  * - `proto` — optional. Defaults from registry, else `tcp`. Must be `tcp` or
15
17
  * `udp`.
16
18
  *
17
- * Throws `QuickCHRError("INVALID_FORWARD_SPEC", ...)` with the original spec
19
+ * **Port range** — `expandForwardSpec`, grammar
20
+ * `name:hostStart-hostEnd[:guestStart-guestEnd][/proto]`. Expands to one
21
+ * `PortMapping` per port (QEMU `hostfwd` has no native range — each port needs
22
+ * its own directive). Useful for L3 peer protocols with dynamic data ports
23
+ * (e.g. RouterOS bandwidth-test allocates UDP ports at runtime). See the rules
24
+ * on `expandForwardSpec` below. `expandForwardSpec` is the range-aware entry
25
+ * point and delegates to `parseForwardSpec` for non-range specs, so callers can
26
+ * always use `expandForwardSpec` (the CLI does).
27
+ *
28
+ * Both throw `QuickCHRError("INVALID_FORWARD_SPEC", ...)` with the original spec
18
29
  * embedded in the message on any parse error.
19
30
  */
20
31
 
@@ -90,3 +101,129 @@ export function parseForwardSpec(spec: string): PortMapping {
90
101
 
91
102
  return { name, host, guest, proto };
92
103
  }
104
+
105
+ /** Max ports a single range `--forward` may span, bounding the generated
106
+ * `hostfwd` string (one directive per port). */
107
+ export const FORWARD_RANGE_MAX = 64;
108
+
109
+ function parseRange(
110
+ spec: string,
111
+ raw: string,
112
+ label: string,
113
+ ): { start: number; end: number } {
114
+ const dash = raw.indexOf("-");
115
+ const startRaw = raw.slice(0, dash);
116
+ const endRaw = raw.slice(dash + 1);
117
+ const start = parsePort(spec, startRaw, `${label} range start`, false);
118
+ const end = parsePort(spec, endRaw, `${label} range end`, false);
119
+ if (end < start) {
120
+ fail(spec, `${label} range end ${end} is below start ${start}`);
121
+ }
122
+ return { start, end };
123
+ }
124
+
125
+ /**
126
+ * Range-aware front door for `--forward` specs. Returns a `PortMapping[]`.
127
+ *
128
+ * For a single-port spec (no `-` in the host/guest segment) this returns
129
+ * `[parseForwardSpec(spec)]` unchanged. For a range spec
130
+ * `name:hostStart-hostEnd[:guestStart-guestEnd][/proto]` it expands to one
131
+ * `PortMapping` per port, named `\`${name}-${hostPort}\`` so each is a distinct
132
+ * key in the machine's port map.
133
+ *
134
+ * Range rules:
135
+ * - **Host range is required** — ranges are not auto-allocated. The per-instance
136
+ * port block (10 ports) can't guarantee a contiguous run, so range host ports
137
+ * must be explicit. They are then collision-checked like any explicit
138
+ * `extraPorts` host (`validateExplicitExtraPorts`).
139
+ * - **Guest range is optional** — defaults to the same numbers as the host
140
+ * range. When given it must be the **same length** as the host range.
141
+ * - **`proto`** defaults from the `WELL_KNOWN_GUEST_PORTS` registry (by `name`),
142
+ * else `tcp`; an explicit `/proto` suffix wins.
143
+ * - Reversed ranges (end < start) and spans over {@link FORWARD_RANGE_MAX} ports
144
+ * throw `INVALID_FORWARD_SPEC`.
145
+ *
146
+ * @example
147
+ * expandForwardSpec("btest:9200-9202:2000-2002/udp");
148
+ * // → [{name:"btest-9200",host:9200,guest:2000,proto:"udp"}, … 9201/2001, 9202/2002]
149
+ * @example
150
+ * expandForwardSpec("smb"); // single port → [{name:"smb",host:0,guest:445,proto:"tcp"}]
151
+ */
152
+ export function expandForwardSpec(spec: string): PortMapping[] {
153
+ if (!spec?.trim()) {
154
+ throw new QuickCHRError("INVALID_FORWARD_SPEC", "Empty --forward spec");
155
+ }
156
+ const trimmed = spec.trim();
157
+
158
+ // Split off /proto suffix (so a range's "-" can't be confused with anything).
159
+ let body = trimmed;
160
+ let protoStr: string | undefined;
161
+ const slash = trimmed.lastIndexOf("/");
162
+ if (slash !== -1) {
163
+ body = trimmed.slice(0, slash);
164
+ protoStr = trimmed.slice(slash + 1).toLowerCase();
165
+ if (protoStr !== "tcp" && protoStr !== "udp") {
166
+ fail(spec, `protocol must be "tcp" or "udp", got "${protoStr}"`);
167
+ }
168
+ }
169
+
170
+ const parts = body.split(":");
171
+ const hasRange = Boolean(parts[1]?.includes("-") || parts[2]?.includes("-"));
172
+ if (!hasRange) {
173
+ return [parseForwardSpec(spec)];
174
+ }
175
+
176
+ if (parts.length > 3) {
177
+ fail(spec, `too many ":" segments (expected name:hostStart-hostEnd[:guestStart-guestEnd])`);
178
+ }
179
+ const name = parts[0]?.trim() ?? "";
180
+ if (!name) {
181
+ fail(spec, "name is required");
182
+ }
183
+
184
+ const hostSegment = parts[1];
185
+ if (!hostSegment?.includes("-")) {
186
+ fail(
187
+ spec,
188
+ "a range --forward requires an explicit host range (e.g. 9200-9210); ranges are not auto-allocated",
189
+ );
190
+ }
191
+ const hostRange = parseRange(spec, hostSegment, "host");
192
+
193
+ const guestSegment = parts[2];
194
+ let guestRange: { start: number; end: number };
195
+ if (guestSegment !== undefined && guestSegment !== "") {
196
+ if (!guestSegment.includes("-")) {
197
+ fail(
198
+ spec,
199
+ `guest segment "${guestSegment}" must be a range matching the host range (e.g. 2000-2010), not a single port`,
200
+ );
201
+ }
202
+ guestRange = parseRange(spec, guestSegment, "guest");
203
+ } else {
204
+ // guest range omitted → default to the host range's port numbers
205
+ guestRange = { start: hostRange.start, end: hostRange.end };
206
+ }
207
+
208
+ const hostCount = hostRange.end - hostRange.start + 1;
209
+ const guestCount = guestRange.end - guestRange.start + 1;
210
+ if (hostCount !== guestCount) {
211
+ fail(
212
+ spec,
213
+ `host range (${hostCount} ports) and guest range (${guestCount} ports) must be the same length`,
214
+ );
215
+ }
216
+ if (hostCount > FORWARD_RANGE_MAX) {
217
+ fail(spec, `range spans ${hostCount} ports, exceeding the ${FORWARD_RANGE_MAX}-port cap`);
218
+ }
219
+
220
+ const known = lookupGuestPort(name);
221
+ const proto: "tcp" | "udp" = (protoStr as "tcp" | "udp" | undefined) ?? known?.proto ?? "tcp";
222
+
223
+ const mappings: PortMapping[] = [];
224
+ for (let i = 0; i < hostCount; i++) {
225
+ const host = hostRange.start + i;
226
+ mappings.push({ name: `${name}-${host}`, host, guest: guestRange.start + i, proto });
227
+ }
228
+ return mappings;
229
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shared filesystem paths for quickchr's config-tier (XDG_CONFIG-like) files.
3
+ */
4
+
5
+ import { join } from "node:path";
6
+ import { homedir } from "node:os";
7
+
8
+ /** ~/.config/quickchr — used by secrets.ts (credential fallback files) and
9
+ * settings.ts (quickchr.env). Single source of truth so those modules never
10
+ * drift on the HOME/USERPROFILE fallback chain. */
11
+ export function quickchrConfigDir(): string {
12
+ return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), ".config", "quickchr");
13
+ }