@tikoci/centrs 0.1.7 → 0.1.8-next.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/retrieve/README.md +348 -4
  14. package/commands/retrieve/examples.md +422 -5
  15. package/commands/terminal/README.md +14 -4
  16. package/commands/terminal/examples.md +22 -3
  17. package/commands/transfer/README.md +55 -9
  18. package/commands/transfer/examples.md +106 -7
  19. package/docs/CLI.md +28 -20
  20. package/docs/CONSTITUTION.md +30 -10
  21. package/docs/MATRIX.md +38 -1
  22. package/docs/errors/README.md +3 -1
  23. package/docs/errors/input/invalid-query.md +36 -0
  24. package/docs/errors/input/local-destination.md +20 -0
  25. package/docs/errors/input/unsupported-query.md +19 -0
  26. package/docs/errors/internal/byte-string.md +13 -0
  27. package/docs/errors/settings/invalid-timeout.md +6 -0
  28. package/docs/errors/transport/stream-overflow.md +21 -0
  29. package/docs/errors/usage/removed-flag.md +20 -0
  30. package/docs/errors/validation/menu-unavailable.md +22 -0
  31. package/docs/errors/validation/not-followable.md +23 -0
  32. package/docs/errors/validation/package-missing.md +29 -0
  33. package/docs/errors/validation/unknown-path.md +16 -4
  34. package/docs/errors/wait/deadline-exceeded.md +14 -0
  35. package/docs/errors/wait/interrupted.md +10 -0
  36. package/package.json +14 -11
  37. package/src/absent-menu.ts +280 -0
  38. package/src/api-fanout.ts +22 -12
  39. package/src/api.ts +511 -186
  40. package/src/btest.ts +19 -17
  41. package/src/cli/api.ts +126 -34
  42. package/src/cli/execute.ts +18 -5
  43. package/src/cli/retrieve.ts +216 -22
  44. package/src/cli/terminal.ts +10 -5
  45. package/src/cli/transfer.ts +23 -8
  46. package/src/core/envelope.ts +14 -0
  47. package/src/core/error-catalog.ts +56 -1
  48. package/src/core/inspect.ts +43 -5
  49. package/src/core/query.ts +508 -0
  50. package/src/core/routeros-errors.ts +1 -1
  51. package/src/devices.ts +6 -9
  52. package/src/discover.ts +7 -8
  53. package/src/errors.ts +116 -7
  54. package/src/execute-fanout.ts +13 -8
  55. package/src/execute.ts +106 -65
  56. package/src/explain/catalog.ts +58 -10
  57. package/src/explain/menus.ts +23 -2
  58. package/src/explain.ts +6 -6
  59. package/src/index.ts +29 -1
  60. package/src/mcp/tools.ts +9 -14
  61. package/src/protocols/adapter.ts +224 -118
  62. package/src/protocols/btest-session.ts +3 -2
  63. package/src/protocols/endpoint.ts +10 -0
  64. package/src/protocols/mac-telnet-console.ts +6 -3
  65. package/src/protocols/native-api.ts +223 -70
  66. package/src/protocols/sftp.ts +44 -14
  67. package/src/protocols/ssh.ts +42 -14
  68. package/src/resolver/cdb.ts +28 -7
  69. package/src/resolver/index.ts +1 -0
  70. package/src/resolver/target.ts +53 -7
  71. package/src/retrieve-fanout.ts +51 -12
  72. package/src/retrieve-follow.ts +862 -0
  73. package/src/retrieve-sample.ts +244 -0
  74. package/src/retrieve-stream.ts +115 -0
  75. package/src/retrieve-wait.ts +286 -0
  76. package/src/retrieve.ts +727 -108
  77. package/src/settings.ts +15 -15
  78. package/src/transfer-fanout.ts +40 -15
  79. package/src/transfer.ts +234 -32
@@ -11,16 +11,23 @@ import {
11
11
  } from "../errors.ts";
12
12
  import {
13
13
  buildRetrieveErrorEnvelope,
14
+ buildRetrieveErrorEnvelopeFromResolved,
14
15
  buildRetrieveFanoutErrorEnvelope,
15
16
  describeCentrs,
16
17
  fanoutExitCode,
18
+ type ResolvedRetrieveRequest,
17
19
  type RetrieveOutputFormat,
18
20
  type RetrieveRequest,
19
21
  renderRetrieveEnvelope,
20
22
  renderRetrieveFanoutEnvelope,
23
+ renderRetrieveStreamLine,
21
24
  retrieve,
22
25
  retrieveFanout,
26
+ retrieveFollow,
23
27
  retrieveOutputFormats,
28
+ retrieveSample,
29
+ retrieveWait,
30
+ type ValidationTrace,
24
31
  } from "../index.ts";
25
32
  import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
26
33
  import {
@@ -50,7 +57,7 @@ import {
50
57
  export const retrieveCommand: CliCommandMetadata = {
51
58
  name: "retrieve",
52
59
  usage:
53
- "centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <target...> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
60
+ "centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <target...> <routeros-path> [flags] | centrs retrieve --host <host|url> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
54
61
  summary:
55
62
  "Read RouterOS values through the shared core using the selected protocol.",
56
63
  options: [
@@ -58,13 +65,14 @@ export const retrieveCommand: CliCommandMetadata = {
58
65
  flag: "--via",
59
66
  valueName: "<protocol>",
60
67
  description:
61
- "Pin the protocol selector. Defaults to `rest-api` for retrieve.",
68
+ "Pin the protocol selector. Defaults to `rest-api` for retrieve (`--sample` included), and to `native-api` under `--follow`.",
62
69
  },
63
70
  ...selectionCommandOptions,
64
71
  {
65
72
  flag: "--host",
66
73
  valueName: "<host|url>",
67
- description: "Override the resolved host or base URL for the target.",
74
+ description:
75
+ "Host or base URL to dial. With no <target> it is the target (no CDB lookup); with one it overrides the resolved host.",
68
76
  },
69
77
  {
70
78
  flag: "--port",
@@ -86,7 +94,7 @@ export const retrieveCommand: CliCommandMetadata = {
86
94
  flag: "--timeout",
87
95
  valueName: "<ms|5s>",
88
96
  description:
89
- "Operation timeout. REST currently rejects values above 60s.",
97
+ "Per-request timeout, separate from --wait's overall deadline. REST currently rejects values above 60s.",
90
98
  },
91
99
  {
92
100
  flag: "--attribute",
@@ -111,13 +119,59 @@ export const retrieveCommand: CliCommandMetadata = {
111
119
  flag: "--query / --filter",
112
120
  valueName: "<expr>",
113
121
  description:
114
- "RouterOS-side row filter (maps to `.query`). Not implemented yet — returns `validation/not-implemented`.",
122
+ "Row filter written as after `print where` (`disabled`, `mtu>=1500 and !dynamic`), run on the router as an API query; repeatable, AND-ed. No `~` or `in`.",
123
+ },
124
+ {
125
+ flag: "--follow",
126
+ description:
127
+ "Keep the menu's rows current over native-api `listen`: snapshot frames, a `synced` line, then a line per change, then a summary (NDJSON for json/yaml/ndjson). Single target, list menus whose print takes follow-only.",
128
+ },
129
+ {
130
+ flag: "--sample",
131
+ valueName: "<interval>",
132
+ description:
133
+ "Read the menu again every interval (with a unit, e.g. `5s`): one line per complete read, then a summary. With --wait, sets polling cadence and returns one final envelope. Sees counters and state listen never reports. Single target; rest-api or native-api.",
134
+ },
135
+ {
136
+ flag: "--wait",
137
+ valueName: "<deadline>",
138
+ description:
139
+ "Wait for a successful read or a condition until this overall deadline, with a unit (e.g. `2m`). Retries refused, closed, reset and timed-out connections and a down or unreachable host; other errors fail at once. One final envelope; single target.",
140
+ },
141
+ {
142
+ flag: "--until",
143
+ valueName: "<expr>",
144
+ description:
145
+ "With --wait: succeed when any selected row matches this --query grammar predicate; repeatable, AND-ed. Returns the matching rows. Already true succeeds on the initial read.",
146
+ },
147
+ {
148
+ flag: "--until-empty",
149
+ description:
150
+ "With --wait: succeed on a completed read with zero rows selected by --query. Silence and failed reads never satisfy it.",
151
+ },
152
+ {
153
+ flag: "--sweep",
154
+ valueName: "<duration>",
155
+ description:
156
+ "`--follow` only: how often a membership sweep finds removals RouterOS does not send, including silent exits from --query. Needs a unit; default `10s`; `0` turns it off.",
157
+ },
158
+ {
159
+ flag: "--count",
160
+ valueName: "<n>",
161
+ description:
162
+ "`--follow`: stop after N live changes (snapshot frames and `synced` do not count). `--sample`: stop after N samples.",
163
+ },
164
+ {
165
+ flag: "--duration",
166
+ valueName: "<duration>",
167
+ description:
168
+ "`--follow`/`--sample`: stop after this wall-clock window (e.g. `30s`), counted from the end of validation.",
115
169
  },
116
170
  {
117
171
  flag: "--format",
118
172
  valueName: `<${retrieveOutputFormats.join("|")}>`,
119
173
  description:
120
- "Output format for the CLI response. Defaults to text; use --json or --format json for the structured envelope.",
174
+ "Output format for the CLI response. Defaults to text; use --json or --format json for the structured envelope. `ndjson` prints each envelope as one compact line.",
121
175
  },
122
176
  {
123
177
  flag: "--json",
@@ -164,6 +218,8 @@ type RetrieveCliArgs = RetrieveRequest & {
164
218
 
165
219
  export async function runRetrieveCli(args: readonly string[]): Promise<number> {
166
220
  let request: RetrieveCliArgs | undefined;
221
+ let resolved: ResolvedRetrieveRequest | undefined;
222
+ const trace: ValidationTrace = {};
167
223
 
168
224
  try {
169
225
  request = parseRetrieveCliArgs(args);
@@ -177,6 +233,28 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
177
233
  // single-target envelope.
178
234
  const selectionFlags = request.selectionFlags ?? emptySelectionFlags();
179
235
  const targetPositionals = request.targetPositionals ?? [];
236
+ if (
237
+ request.wait !== undefined ||
238
+ request.follow ||
239
+ request.sample !== undefined
240
+ ) {
241
+ const mode =
242
+ request.wait !== undefined
243
+ ? "--wait"
244
+ : request.follow
245
+ ? "--follow"
246
+ : "--sample";
247
+ if (isFanoutMode(selectionFlags, targetPositionals.length)) {
248
+ throw new CentrsError({
249
+ code: "usage/fanout-not-supported",
250
+ summary: `\`retrieve ${mode}\` watches one router and cannot fan out across multiple targets.`,
251
+ remediation: `Watch a single router (no \`--group\`/\`--where\`/\`--all\`/\`--default\`/multiple positionals); run one \`${mode}\` per router to watch several.`,
252
+ });
253
+ }
254
+ return request.wait !== undefined
255
+ ? await runRetrieveWaitCli(request, args)
256
+ : await runRetrieveStreamCli(request, args);
257
+ }
180
258
  if (isFanoutMode(selectionFlags, targetPositionals.length)) {
181
259
  return await runRetrieveFanoutCli(
182
260
  request,
@@ -186,7 +264,12 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
186
264
  );
187
265
  }
188
266
 
189
- const envelope = await retrieve(request);
267
+ const envelope = await retrieve(request, Bun.env, {
268
+ trace,
269
+ onResolved: (value) => {
270
+ resolved = value;
271
+ },
272
+ });
190
273
  const resolvedFormat =
191
274
  (
192
275
  envelope.meta.operation as {
@@ -209,13 +292,17 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
209
292
  env: Bun.env,
210
293
  })
211
294
  : [];
212
- if (format === "json" || format === "yaml") {
295
+ if (format !== "text") {
213
296
  // When parsing failed before a request existed, build the error envelope
214
297
  // from an empty request rather than reconstructing positionals from raw
215
298
  // args — a credential value (e.g. the token after `--password` /
216
299
  // `--cdb-password`) would otherwise be echoed as `meta.target.input`.
300
+ // A failure after resolution keeps the resolved target, via and
301
+ // setting sources (#433).
217
302
  const envelope = withTips(
218
- buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
303
+ resolved
304
+ ? buildRetrieveErrorEnvelopeFromResolved(resolved, error, trace)
305
+ : buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
219
306
  tips,
220
307
  );
221
308
  console.error(
@@ -244,6 +331,61 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
244
331
  }
245
332
  }
246
333
 
334
+ /**
335
+ * Every `--follow`/`--sample` envelope goes to stdout, one per line, errors
336
+ * included; any failure sets exit 1, even after successful lines. Ctrl-C asks
337
+ * for a bounded stop that still ends with a summary.
338
+ */
339
+ async function runRetrieveStreamCli(
340
+ request: RetrieveCliArgs,
341
+ args: readonly string[],
342
+ ): Promise<number> {
343
+ const format = inferRequestedFormat(args, request);
344
+ const controller = new AbortController();
345
+ const onSigint = (): void => controller.abort();
346
+ process.on("SIGINT", onSigint);
347
+ let exitCode = 0;
348
+ try {
349
+ const stream = request.follow ? retrieveFollow : retrieveSample;
350
+ for await (const envelope of stream(request, Bun.env, {
351
+ signal: controller.signal,
352
+ })) {
353
+ // A config-file `CENTRS_FORMAT` is known only after resolution.
354
+ const resolvedFormat = envelope.meta.operation?.request.format ?? format;
355
+ console.log(renderRetrieveStreamLine(envelope, resolvedFormat));
356
+ if (!envelope.ok) exitCode = 1;
357
+ }
358
+ } finally {
359
+ process.off("SIGINT", onSigint);
360
+ }
361
+ return exitCode;
362
+ }
363
+
364
+ async function runRetrieveWaitCli(
365
+ request: RetrieveCliArgs,
366
+ args: readonly string[],
367
+ ): Promise<number> {
368
+ const controller = new AbortController();
369
+ const onSigint = (): void => controller.abort();
370
+ process.on("SIGINT", onSigint);
371
+ try {
372
+ const envelope = await retrieveWait(request, Bun.env, {
373
+ signal: controller.signal,
374
+ });
375
+ console.log(
376
+ renderRetrieveEnvelope(
377
+ envelope,
378
+ envelope.meta.operation?.request.format ??
379
+ inferRequestedFormat(args, request),
380
+ { verbose: request.verbose },
381
+ ),
382
+ );
383
+ return envelope.ok ? 0 : 1;
384
+ } finally {
385
+ process.off("SIGINT", onSigint);
386
+ }
387
+ }
388
+
247
389
  async function runRetrieveFanoutCli(
248
390
  request: RetrieveCliArgs,
249
391
  selectionFlags: SelectionFlags,
@@ -348,10 +490,11 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
348
490
  request.listAttributes = true;
349
491
  break;
350
492
  case "--filter":
351
- request.filter = expectValue(args, ++index, arg);
352
- break;
353
493
  case "--query":
354
- request.query = expectValue(args, ++index, arg);
494
+ request.query = [
495
+ ...[request.query ?? []].flat(),
496
+ expectValue(args, ++index, arg),
497
+ ];
355
498
  break;
356
499
  case "--max-results":
357
500
  request.maxResultsBytes = Number.parseInt(
@@ -377,6 +520,33 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
377
520
  case "--verbose":
378
521
  request.verbose = true;
379
522
  break;
523
+ case "--follow":
524
+ request.follow = true;
525
+ break;
526
+ case "--sample":
527
+ request.sample = expectValue(args, ++index, arg);
528
+ break;
529
+ case "--sweep":
530
+ request.sweep = expectValue(args, ++index, arg);
531
+ break;
532
+ case "--count":
533
+ request.count = parseCountFlag(expectValue(args, ++index, arg));
534
+ break;
535
+ case "--duration":
536
+ request.duration = expectValue(args, ++index, arg);
537
+ break;
538
+ case "--wait":
539
+ request.wait = expectValue(args, ++index, arg);
540
+ break;
541
+ case "--until":
542
+ request.until = [
543
+ ...[request.until ?? []].flat(),
544
+ expectValue(args, ++index, arg),
545
+ ];
546
+ break;
547
+ case "--until-empty":
548
+ request.untilEmpty = true;
549
+ break;
380
550
  default: {
381
551
  const consumed = consumeSelectionFlag(args, index, selectionFlags);
382
552
  if (consumed !== null) {
@@ -428,18 +598,28 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
428
598
 
429
599
  // A single `--quickchr <name>` is single-target mode: the machine name is the
430
600
  // target (resolved from the live descriptor inside the resolver); the lone
431
- // positional is the routeros-path.
432
- if (selectionFlags.quickchr.length === 1) {
433
- if (positional.length === 0 || path.length === 0) {
601
+ // positional is the routeros-path. The `--host` flag stands in for a missing
602
+ // `<router>` the same way (#428), with no CDB lookup; `CENTRS_HOST` never
603
+ // counts.
604
+ const quickchr = selectionFlags.quickchr[0];
605
+ const hostOnly =
606
+ quickchr === undefined &&
607
+ targetPositionals.length === 0 &&
608
+ request.host !== undefined;
609
+ if (quickchr !== undefined || hostOnly) {
610
+ if (path.length === 0) {
611
+ const flag = hostOnly ? "--host" : "--quickchr";
612
+ const example = hostOnly ? "--host 192.0.2.10" : "--quickchr lab";
434
613
  throw new CentrsError({
435
614
  code: "input/invalid-command",
436
- summary: "`centrs retrieve --quickchr` requires a <routeros-path>.",
437
- remediation:
438
- "Add the RouterOS menu path as the final positional, e.g. `centrs retrieve --quickchr lab /system/resource`.",
615
+ summary: `\`centrs retrieve ${flag}\` requires a <routeros-path>.`,
616
+ remediation: `Add the RouterOS menu path as the final positional, e.g. \`centrs retrieve ${example} /system/resource\`.`,
439
617
  context: { command: "retrieve", missingPath: true },
440
618
  });
441
619
  }
442
- request.quickchr = selectionFlags.quickchr[0];
620
+ if (quickchr !== undefined) {
621
+ request.quickchr = quickchr;
622
+ }
443
623
  return request;
444
624
  }
445
625
 
@@ -448,7 +628,7 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
448
628
  command: "retrieve",
449
629
  summary: "`centrs retrieve` requires a <target> and a <routeros-path>.",
450
630
  remediation:
451
- "Pass the router host/identity then the RouterOS path, e.g. `centrs retrieve 192.0.2.10 /system/resource`.",
631
+ "Pass the router host/identity then the RouterOS path, e.g. `centrs retrieve 192.0.2.10 /system/resource`, or name the host with `--host <host|url>`.",
452
632
  });
453
633
  }
454
634
  // Single-target: exactly one target positional (more would be fan-out mode).
@@ -456,6 +636,20 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
456
636
  return request;
457
637
  }
458
638
 
639
+ function parseCountFlag(value: string): number {
640
+ // The whole token: `parseInt` would accept `10abc` as 10.
641
+ if (!/^\d+$/.test(value.trim())) {
642
+ throw new CentrsError({
643
+ code: "settings/invalid-integer",
644
+ summary: `\`--count\` must be a positive integer. Received: ${value}`,
645
+ remediation:
646
+ "Pass how many live changes (`--follow`) or samples (`--sample`) to wait for, e.g. `--count 1`.",
647
+ context: { flag: "--count" },
648
+ });
649
+ }
650
+ return Number.parseInt(value, 10);
651
+ }
652
+
459
653
  function inferRequestedFormat(
460
654
  args: readonly string[],
461
655
  request?: RetrieveRequest,
@@ -471,8 +665,8 @@ function inferRequestedFormat(
471
665
  return args[formatIndex + 1] as RetrieveOutputFormat;
472
666
  }
473
667
  const envFormat = process.env["CENTRS_FORMAT"];
474
- if (envFormat === "json" || envFormat === "yaml" || envFormat === "text") {
475
- return envFormat;
668
+ if (retrieveOutputFormats.includes(envFormat as RetrieveOutputFormat)) {
669
+ return envFormat as RetrieveOutputFormat;
476
670
  }
477
671
  return "text";
478
672
  }
@@ -37,7 +37,8 @@ import { selectionFlagTokens } from "./selection.ts";
37
37
 
38
38
  export const terminalCommand: CliCommandMetadata = {
39
39
  name: "terminal",
40
- usage: "centrs terminal <router> [flags]",
40
+ usage:
41
+ "centrs terminal <router> [flags] | centrs terminal --host <host> [flags]",
41
42
  summary: "Open an interactive RouterOS console (ssh or mac-telnet).",
42
43
  options: [
43
44
  {
@@ -50,7 +51,7 @@ export const terminalCommand: CliCommandMetadata = {
50
51
  flag: "--host",
51
52
  valueName: "<host>",
52
53
  description:
53
- "Override the target host (ssh) / UDP delivery host (mac-telnet, default L2 broadcast).",
54
+ "ssh: the host to dial (the target when no <router> is given; no CDB lookup). mac-telnet: the UDP delivery host (default L2 broadcast).",
54
55
  },
55
56
  {
56
57
  flag: "--port",
@@ -299,16 +300,20 @@ export function parseTerminalCliArgs(args: readonly string[]): ParsedTerminal {
299
300
  if (help) {
300
301
  return { help: true, request: {} };
301
302
  }
303
+ // The `--host` flag stands in for a missing `<router>` (#428), with no CDB
304
+ // lookup; `CENTRS_HOST` never counts.
302
305
  const targetInput = positional[0];
303
- if (!targetInput) {
306
+ if (!targetInput && flags.host === undefined) {
304
307
  throw missingTargetError({
305
308
  command: "terminal",
306
309
  summary: "`centrs terminal` requires a <router> target.",
307
310
  remediation:
308
- "Pass the device MAC (or a CDB identity/ip) as the first argument; run `centrs terminal --help` for the shape.",
311
+ "Pass the device MAC (or a CDB identity/ip) as the first argument, or name an ssh host with `--host <host>`; run `centrs terminal --help` for the shape.",
309
312
  });
310
313
  }
311
- flags.targetInput = targetInput;
314
+ if (targetInput) {
315
+ flags.targetInput = targetInput;
316
+ }
312
317
  return { request: flags };
313
318
  }
314
319
 
@@ -18,7 +18,7 @@ import {
18
18
  type TransferOutputFormat,
19
19
  type TransferRequest,
20
20
  type TransferVerb,
21
- transfer,
21
+ transferEnvelope,
22
22
  transferFanout,
23
23
  transferOutputFormats,
24
24
  transferVerbs,
@@ -62,7 +62,7 @@ const VERB_ALIASES: Record<string, TransferVerb> = {
62
62
  export const transferCommand: CliCommandMetadata = {
63
63
  name: "transfer",
64
64
  usage:
65
- "centrs transfer <router> upload <local> [remote] | download <remote> [local] | list [path] | remove <remote> | mkdir <remote> | copy <src> <dst> [flags]",
65
+ "centrs transfer <router>|--host <host> upload <local> [remote] | download <remote> [local] | list [path] | remove <remote> | mkdir <remote> | copy <src> <dst> [flags]",
66
66
  summary:
67
67
  "Copy files to/from a RouterOS device and manage device files (rest/native/sftp).",
68
68
  options: [
@@ -85,7 +85,8 @@ export const transferCommand: CliCommandMetadata = {
85
85
  },
86
86
  {
87
87
  flag: "--force / --overwrite",
88
- description: "Replace an existing destination. Default refuses it.",
88
+ description:
89
+ "Skip the existing-destination check on the device for `upload`/`mkdir`/`copy` (`upload` then overwrites). Default refuses an existing target. `download` always replaces the local file.",
89
90
  },
90
91
  ...selectionCommandOptions,
91
92
  {
@@ -118,7 +119,8 @@ export const transferCommand: CliCommandMetadata = {
118
119
  {
119
120
  flag: "--host",
120
121
  valueName: "<host|url>",
121
- description: "Override the resolved host or base URL.",
122
+ description:
123
+ "Host or base URL to dial. With no <router> before the verb it is the target (no CDB lookup); with one it overrides the resolved host.",
122
124
  },
123
125
  {
124
126
  flag: "--port",
@@ -199,11 +201,18 @@ export async function runTransferCli(
199
201
  }
200
202
  request = parsed.request;
201
203
 
202
- const envelope = await transfer(request);
204
+ const envelope = await transferEnvelope(request);
203
205
  const format = resolvedFormat(request);
204
206
  const rendered = renderTransferEnvelope(envelope, format, {
205
207
  verbose: request.verbose,
206
208
  });
209
+ if (!envelope.ok) {
210
+ console.error(
211
+ // codeql[js/clear-text-logging] The envelope carries auth provenance only (`username`, `passwordProvided`, setting sources), never the password; see buildTransferErrorEnvelopeFromResolved.
212
+ format === "text" ? rendered + formatTipsText(envelope.tips) : rendered,
213
+ );
214
+ return 1;
215
+ }
207
216
  // A download to stdout (`local === "-"`) has already streamed the file bytes
208
217
  // to stdout inside `transfer()`; keep the structured envelope on stderr so it
209
218
  // cannot interleave with — and corrupt — the piped payload.
@@ -502,7 +511,7 @@ function assemblePositionals(
502
511
  } else {
503
512
  const verbIndex = positional.findIndex(isTransferVerbToken);
504
513
  if (verbIndex === -1) {
505
- if (positional.length === 0 && !hasSelector) {
514
+ if (positional.length === 0 && !hasSelector && flags.host === undefined) {
506
515
  throw missingTargetError({
507
516
  command: "transfer",
508
517
  summary:
@@ -536,7 +545,10 @@ function assemblePositionals(
536
545
  }
537
546
 
538
547
  // Single-target (not fan-out mode) needs exactly one resolved target: one
539
- // positional, or one `--quickchr <name>` (the named-live-provider).
548
+ // positional, one `--quickchr <name>` (the named-live-provider), or the
549
+ // `--host` flag with no positional before the verb (#428; no CDB lookup,
550
+ // `CENTRS_HOST` never counts). The `upload`/`download` aliases always read their first
551
+ // positional as the target, since a path count can't tell them apart.
540
552
  if (!isFanoutMode(selectionFlags, targetPositionals.length)) {
541
553
  if (selectionFlags.quickchr.length === 1) {
542
554
  request.quickchr = selectionFlags.quickchr[0];
@@ -544,11 +556,14 @@ function assemblePositionals(
544
556
  }
545
557
  const targetInput = targetPositionals[0];
546
558
  if (!targetInput) {
559
+ if (flags.host !== undefined && !fixedVerb) {
560
+ return { request, targetPositionals };
561
+ }
547
562
  throw missingTargetError({
548
563
  command: "transfer",
549
564
  summary: "`centrs transfer` requires a <router> target.",
550
565
  remediation:
551
- "Pass the router host/identity as the first argument; run `centrs transfer --help` for the command shape.",
566
+ "Pass the router host/identity as the first argument, or name the host with `--host <host>` (`transfer` verb form only); run `centrs transfer --help` for the command shape.",
552
567
  });
553
568
  }
554
569
  request.targetInput = targetInput;
@@ -159,6 +159,11 @@ export interface EnvelopeValidationMeta {
159
159
  enabled: boolean;
160
160
  /** Validator identity, e.g. `/console/inspect` or `:put [:parse]`. */
161
161
  source?: string;
162
+ /**
163
+ * The gate's verdict. Absent when the gate never produced one: a transport,
164
+ * auth or device error interrupted it, or a failure came before it ran.
165
+ * `stages[]` then says which stage was not reached and why (GH#435).
166
+ */
162
167
  result?: "passed" | "failed" | "skipped";
163
168
  /** Syntax gate status for commands that use RouterOS `:parse`. */
164
169
  syntax?: boolean;
@@ -176,6 +181,15 @@ export interface EnvelopeValidationMeta {
176
181
  stages?: readonly EnvelopeValidationStage[];
177
182
  }
178
183
 
184
+ /**
185
+ * The `reason` a stage carries when the gate never reached it: the error that
186
+ * stopped the run before that stage produced a verdict (GH#435). One wording
187
+ * across commands, so a consumer can match on the `not reached:` prefix.
188
+ */
189
+ export function notReachedReason(code: string): string {
190
+ return `not reached: ${code}`;
191
+ }
192
+
179
193
  export interface EnvelopeTimingMeta {
180
194
  startedAt?: string;
181
195
  durationMs?: number;
@@ -173,6 +173,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
173
173
  summary:
174
174
  "A file path contains a character the transport cannot quote (e.g. a quote or newline).",
175
175
  },
176
+ {
177
+ code: "input/invalid-query",
178
+ summary:
179
+ "A --query expression does not parse as a `print where` condition.",
180
+ },
176
181
  {
177
182
  code: "input/invalid-radius",
178
183
  summary:
@@ -186,6 +191,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
186
191
  code: "input/invalid-target",
187
192
  summary: "The target string is invalid for the requested operation.",
188
193
  },
194
+ {
195
+ code: "input/local-destination",
196
+ summary:
197
+ "A local download destination cannot be written (missing parent directory, a directory, or no permission).",
198
+ },
189
199
  {
190
200
  code: "input/local-file-not-found",
191
201
  summary: "A local file required for the transfer could not be read.",
@@ -198,8 +208,17 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
198
208
  code: "input/max-results-exceeded",
199
209
  summary: "Output exceeded the requested byte budget.",
200
210
  },
211
+ {
212
+ code: "input/unsupported-query",
213
+ summary:
214
+ "A --query uses something the RouterOS API query cannot run (`~`, `in`, a `$variable` or `[command]`).",
215
+ },
201
216
 
202
217
  // internal/*
218
+ {
219
+ code: "internal/byte-string",
220
+ summary: "A binary RouterOS word held a character that is not a byte.",
221
+ },
203
222
  {
204
223
  code: "internal/devices-failed",
205
224
  summary: "The devices command failed with an unexpected internal error.",
@@ -493,6 +512,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
493
512
  code: "transport/network",
494
513
  summary: "A network request to the device failed.",
495
514
  },
515
+ {
516
+ code: "transport/stream-overflow",
517
+ summary:
518
+ "More stream replies were waiting unread than the buffer allows, so centrs ended the stream rather than drop any.",
519
+ },
496
520
  {
497
521
  code: "transport/timeout",
498
522
  summary: "The transport request timed out.",
@@ -524,6 +548,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
524
548
  code: "usage/invalid-concurrency",
525
549
  summary: "The --concurrency value must be an integer >= 1.",
526
550
  },
551
+ {
552
+ code: "usage/removed-flag",
553
+ summary:
554
+ "A flag or request field was removed; the error names its replacement.",
555
+ },
527
556
  {
528
557
  code: "usage/stdin-ignored",
529
558
  summary:
@@ -558,14 +587,29 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
558
587
  },
559
588
 
560
589
  // validation/*
590
+ {
591
+ code: "validation/not-followable",
592
+ summary:
593
+ "The RouterOS menu cannot be followed: it is a single record, or its print has no follow-only.",
594
+ },
561
595
  {
562
596
  code: "validation/not-implemented",
563
597
  summary: "The requested validation feature is not implemented yet.",
564
598
  },
599
+ {
600
+ code: "validation/menu-unavailable",
601
+ summary:
602
+ "RouterOS 7 publishes this menu or command, but this device does not have it — usually hardware, build or version.",
603
+ },
565
604
  {
566
605
  code: "validation/option",
567
606
  summary: "An unknown option value was supplied.",
568
607
  },
608
+ {
609
+ code: "validation/package-missing",
610
+ summary:
611
+ "This device has no such menu or command; MikroTik publishes it in a package that is likely not installed.",
612
+ },
569
613
  {
570
614
  code: "validation/syntax",
571
615
  summary:
@@ -578,7 +622,18 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
578
622
  },
579
623
  {
580
624
  code: "validation/unknown-path",
581
- summary: "The RouterOS path does not expose the requested command.",
625
+ summary:
626
+ "The RouterOS path does not exist, or does not expose the requested command.",
627
+ },
628
+ {
629
+ code: "wait/deadline-exceeded",
630
+ summary:
631
+ "The wait deadline passed before readiness or its condition was established.",
632
+ },
633
+ {
634
+ code: "wait/interrupted",
635
+ summary:
636
+ "The wait was cancelled before readiness or its condition was established.",
582
637
  },
583
638
  ];
584
639