@tikoci/quickchr 0.2.0 → 0.3.1

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.
@@ -14,7 +14,10 @@ export interface SocketEntry {
14
14
  mcastGroup?: string;
15
15
  port: number;
16
16
  createdAt: string;
17
+ /** Machines that currently reference this socket. */
17
18
  members: string[];
19
+ /** Auto-created by `start()` (true) vs explicit user/CLI creation (false). */
20
+ autoCreated: boolean;
18
21
  }
19
22
 
20
23
  const DEFAULT_START_PORT = 4000;
@@ -41,6 +44,19 @@ function socketPath(name: string): string {
41
44
  return join(getSocketRegistryDir(), `${name}.json`);
42
45
  }
43
46
 
47
+ /** Normalize an entry loaded from disk: defaults missing fields for backward compat. */
48
+ function normalizeEntry(raw: Partial<SocketEntry> & { name: string; mode: SocketEntry["mode"]; port: number; createdAt: string }): SocketEntry {
49
+ return {
50
+ name: raw.name,
51
+ mode: raw.mode,
52
+ mcastGroup: raw.mcastGroup,
53
+ port: raw.port,
54
+ createdAt: raw.createdAt,
55
+ members: Array.isArray(raw.members) ? [...raw.members] : [],
56
+ autoCreated: typeof raw.autoCreated === "boolean" ? raw.autoCreated : false,
57
+ };
58
+ }
59
+
44
60
  function saveEntry(entry: SocketEntry): void {
45
61
  writeFileSync(socketPath(entry.name), JSON.stringify(entry, null, "\t") + "\n");
46
62
  _cache.set(entry.name, entry);
@@ -54,7 +70,7 @@ function allocatePort(existing: SocketEntry[]): number {
54
70
 
55
71
  export function createNamedSocket(
56
72
  name: string,
57
- opts?: { mode?: "mcast" | "listen-connect"; port?: number; mcastGroup?: string },
73
+ opts?: { mode?: "mcast" | "listen-connect"; port?: number; mcastGroup?: string; autoCreated?: boolean },
58
74
  ): SocketEntry {
59
75
  if (_cache.has(name) || existsSync(socketPath(name))) {
60
76
  throw new QuickCHRError("STATE_ERROR", `Named socket "${name}" already exists`);
@@ -71,6 +87,7 @@ export function createNamedSocket(
71
87
  mcastGroup,
72
88
  createdAt: new Date().toISOString(),
73
89
  members: [],
90
+ autoCreated: opts?.autoCreated ?? false,
74
91
  };
75
92
 
76
93
  saveEntry(entry);
@@ -81,7 +98,8 @@ export function getNamedSocket(name: string): SocketEntry | undefined {
81
98
  const cached = _cache.get(name);
82
99
  if (cached) return cached;
83
100
  try {
84
- const entry = JSON.parse(readFileSync(socketPath(name), "utf-8")) as SocketEntry;
101
+ const raw = JSON.parse(readFileSync(socketPath(name), "utf-8"));
102
+ const entry = normalizeEntry(raw);
85
103
  _cache.set(name, entry);
86
104
  return entry;
87
105
  } catch {
@@ -98,7 +116,8 @@ export function listNamedSockets(): SocketEntry[] {
98
116
  const name = file.replace(/\.json$/, "");
99
117
  if (!merged.has(name)) {
100
118
  try {
101
- const entry = JSON.parse(readFileSync(join(dir, file), "utf-8")) as SocketEntry;
119
+ const raw = JSON.parse(readFileSync(join(dir, file), "utf-8"));
120
+ const entry = normalizeEntry(raw);
102
121
  merged.set(name, entry);
103
122
  _cache.set(name, entry);
104
123
  } catch {}
@@ -132,7 +151,7 @@ export function removeSocketMember(name: string, machineName: string): void {
132
151
  const entry = getNamedSocket(name);
133
152
  if (!entry) return;
134
153
  entry.members = entry.members.filter((m) => m !== machineName);
135
- if (entry.members.length === 0) {
154
+ if (entry.members.length === 0 && entry.autoCreated) {
136
155
  removeNamedSocket(name);
137
156
  } else {
138
157
  saveEntry(entry);
package/src/lib/types.ts CHANGED
@@ -195,12 +195,17 @@ export interface MachineState extends MachineConfig {
195
195
  * requires SCP upload + reboot cycle, typically 60–120s extra on native acceleration).
196
196
  */
197
197
  export interface StartOptions {
198
- /** RouterOS version to use (e.g. "7.22.1"). Mutually exclusive with `channel`. */
198
+ /** RouterOS version to use (e.g. "7.22.1"). Mutually exclusive with `channel`.
199
+ * As a convenience, a channel name (e.g. "long-term") is also accepted here and
200
+ * resolves the same as `channel: "long-term"` — but this emits a warning, since
201
+ * `channel` is the self-documenting field for that case. */
199
202
  version?: string;
200
203
  /** Release channel to resolve latest version from. Default: "stable". */
201
204
  channel?: Channel;
202
- /** Guest architecture. Default: matches host (arm64 on Apple Silicon, x86 on Intel/AMD). */
203
- arch?: Arch;
205
+ /** Guest architecture. Default: matches host (arm64 on Apple Silicon, x86 on Intel/AMD).
206
+ * Pass `"auto"` as an explicit synonym for the default — useful when an agent or
207
+ * caller wants to state the intent rather than omit the field. */
208
+ arch?: Arch | "auto";
204
209
  /** Instance name. Auto-generated from version+arch if omitted. Must not start with "-". */
205
210
  name?: string;
206
211
  /** Number of virtual CPUs. Default: 1. */
@@ -226,6 +231,11 @@ export interface StartOptions {
226
231
  * Enabling the managed login path is quickchr provisioning, so it is validated/tested
227
232
  * on RouterOS 7.20.8+ only. */
228
233
  secureLogin?: boolean;
234
+ /** Convenience alias: `noAuth: true` is equivalent to `secureLogin: false` —
235
+ * skip the managed `quickchr` user provisioning, leave admin password-less.
236
+ * Self-documenting alternative for callers who find `secureLogin: false` cryptic.
237
+ * When both are set, an explicit `secureLogin` value wins. */
238
+ noAuth?: boolean;
229
239
  /** Starting port number for this instance's port block. Auto-allocated if omitted. */
230
240
  portBase?: number;
231
241
  /** Services to exclude from port mappings (e.g. ["winbox", "api-ssl"]). */
@@ -304,12 +314,51 @@ export interface ChrInstance {
304
314
  ports: ChrPorts;
305
315
  restUrl: string;
306
316
  sshPort: number;
317
+ /** Port block base for this instance. Useful for picking L2 socket ports
318
+ * that won't collide with this instance's management ports.
319
+ * Each block occupies `PORTS_PER_BLOCK` (10) consecutive ports starting here.
320
+ * @example
321
+ * // Pick a socket port outside any running instance's block:
322
+ * const socketPort = Math.max(...instances.map(i => i.portBase)) + PORTS_PER_BLOCK;
323
+ */
324
+ portBase: number;
325
+ /** Platform-appropriate network interface for capturing TZSP traffic from this instance.
326
+ * `"lo0"` on macOS — QEMU user-mode (slirp) routes guest UDP through the host loopback.
327
+ * `"any"` on Linux — TZSP arrives on whichever interface the host routes it to.
328
+ * Pass directly to tshark: `tshark -i ${instance.captureInterface} -f "udp port 37008"`
329
+ * Only meaningful when the instance has a user-mode (`"user"`) network interface. */
330
+ captureInterface: string;
331
+ /** QEMU user-mode networking gateway IP — the host's address as seen from inside the VM.
332
+ * Use this as the RouterOS `/tool/sniffer` streaming-server target so TZSP traffic
333
+ * reaches the host. Always `"10.0.2.2"` for QEMU slirp user-mode networking.
334
+ * @example
335
+ * await instance.exec(`/tool/sniffer/set streaming-server=${instance.tzspGatewayIp}:37008`);
336
+ */
337
+ tzspGatewayIp: string;
307
338
 
308
339
  /** Check or wait for REST API readiness.
309
340
  * After `QuickCHR.start()` with provisioning options (installAllPackages, license, etc.),
310
341
  * the returned instance is already REST-reachable — this returns immediately.
311
342
  * Call this only when starting without provisioning, or as a safety check after a manual stop/restart. */
312
343
  waitForBoot(timeoutMs?: number): Promise<boolean>;
344
+ /** Poll a condition until it returns true or the timeout elapses.
345
+ * Retries every 2 s, swallowing errors from the condition function.
346
+ * Returns `true` when the condition passes, `false` on timeout.
347
+ *
348
+ * Useful for waiting on RouterOS state that isn't reflected in the boot readiness
349
+ * check — e.g. waiting for a package to become active, a daemon to enable, or a
350
+ * background script to complete.
351
+ *
352
+ * @param condition Async predicate: return `true` when the desired state is reached.
353
+ * @param timeoutMs Maximum wait in milliseconds (default: 30 000).
354
+ * @example
355
+ * // Wait for the Dude daemon to report enabled=yes
356
+ * const ready = await instance.waitFor(async () => {
357
+ * const r = await instance.exec("/dude/print");
358
+ * return r.output.includes("enabled: yes");
359
+ * }, 30_000);
360
+ */
361
+ waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
313
362
  stop(): Promise<void>;
314
363
  remove(): Promise<void>;
315
364
  clean(): Promise<void>;
@@ -329,7 +378,19 @@ export interface ChrInstance {
329
378
  qga(command: QgaCommand, args?: object): Promise<unknown>;
330
379
 
331
380
  rest(path: string, opts?: RequestInit): Promise<unknown>;
332
- /** Run a RouterOS CLI command against the instance. */
381
+ /** Run a RouterOS CLI command against the instance.
382
+ *
383
+ * **Single command per call.** RouterOS `/rest/execute` runs the input as one
384
+ * script statement; a multi-line `command` string with `\n`-separated commands
385
+ * may execute only the first line and silently ignore the rest depending on
386
+ * RouterOS version. To run several commands, call `exec()` multiple times, or
387
+ * wrap them in an explicit RouterOS script: `:do { /cmd1; /cmd2 }`.
388
+ *
389
+ * **Soft errors.** Some RouterOS commands return HTTP 200 with an error
390
+ * string in `output` (e.g. `/dude/agent/add` may resolve OK with
391
+ * `"doAdd Agent not implemented"`). `exec()` does not parse output for these
392
+ * patterns — inspect `result.output` if you need to assert success.
393
+ */
333
394
  exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
334
395
  /** Apply or renew a CHR trial license.
335
396
  * quickchr validates this provisioning flow on RouterOS 7.20.8+ only. */
@@ -343,6 +404,22 @@ export interface ChrInstance {
343
404
  * @param logger Optional progress logger for status/debug output. */
344
405
  setDeviceMode(options: DeviceModeOptions, logger?: ProgressLogger): Promise<void>;
345
406
 
407
+ /** Copy a local file to the CHR via SCP.
408
+ * Machine must be running. `remotePath` defaults to `/<basename>` (RouterOS flash root).
409
+ * Uses the resolved instance credentials.
410
+ * @example
411
+ * await instance.upload("./dude.db", "/dude/dude.db");
412
+ * await instance.upload("./config.rsc"); // → /config.rsc
413
+ */
414
+ upload(localPath: string, remotePath?: string): Promise<void>;
415
+
416
+ /** Copy a file from the CHR to the local filesystem via SCP.
417
+ * Machine must be running. Uses the resolved instance credentials.
418
+ * @example
419
+ * await instance.download("/dude/dude.db", "./dude-snapshot.db");
420
+ */
421
+ download(remotePath: string, localPath: string): Promise<void>;
422
+
346
423
  /** List extra packages available for this instance's version and arch.
347
424
  * Downloads and caches the all_packages ZIP on the first call. */
348
425
  availablePackages(): Promise<string[]>;
@@ -460,6 +537,7 @@ export type ErrorCode =
460
537
  | "SPAWN_FAILED"
461
538
  | "NETWORK_UNAVAILABLE"
462
539
  | "INVALID_NETWORK"
540
+ | "INVALID_FORWARD_SPEC"
463
541
  | "PROVISIONING_VERSION_UNSUPPORTED"
464
542
  | "INSUFFICIENT_DISK_SPACE"
465
543
  | "STATE_ERROR";