@tikoci/centrs 0.1.4 → 0.1.7

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 (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. package/src/transfer.ts +1 -1
package/src/index.ts CHANGED
@@ -1,5 +1,3 @@
1
- export const centrsVersion = "0.1.0";
2
-
3
1
  export {
4
2
  type ApiEnvelope,
5
3
  type ApiErrorEnvelope,
@@ -81,6 +79,7 @@ export {
81
79
  type EnvelopeMeta,
82
80
  type EnvelopeTargetMeta,
83
81
  type EnvelopeValidationMeta,
82
+ type EnvelopeValidationStage,
84
83
  type FanoutData,
85
84
  type FanoutSummary,
86
85
  normalizeWarnings,
@@ -110,6 +109,7 @@ export {
110
109
  type InspectChildItem,
111
110
  type InspectCompletionItem,
112
111
  type InspectRequestKind,
112
+ inspectArgumentNames,
113
113
  inspectChildren,
114
114
  inspectChildrenOrEmpty,
115
115
  inspectCompletions,
@@ -136,6 +136,7 @@ export {
136
136
  versionFactFromComment,
137
137
  versionInRange,
138
138
  } from "./core/preflight.ts";
139
+ export { centrsVersion } from "./core/version.ts";
139
140
  export {
140
141
  encodeMndpPacket,
141
142
  MNDP_BROADCAST_ADDRESS,
@@ -202,6 +203,7 @@ export {
202
203
  type DevicesCommand,
203
204
  type DevicesEnvelope,
204
205
  type DevicesGroupSummary,
206
+ type DevicesInitData,
205
207
  type DevicesListItem,
206
208
  type DevicesMutationData,
207
209
  type DevicesMutationEnvelope,
@@ -214,6 +216,7 @@ export {
214
216
  defaultCdbPath,
215
217
  devicesOutputFormats,
216
218
  editInteractiveOnlyError,
219
+ initCdb,
217
220
  type LoadCdbOptions,
218
221
  type LoadedCdb,
219
222
  listDevices,
@@ -305,13 +308,20 @@ export {
305
308
  // relative to the statement it was handed; the `Token` is that rebased into
306
309
  // document space by `explainCommand`. A consumer reading `explainCommand`'s
307
310
  // result wants the latter.
311
+ //
312
+ // `lexExplainArgumentTokens` is the skip-tolerant reading (#316) and is
313
+ // exported for the same reason `lexExplainValueAnchors` is: a consumer building
314
+ // its own token-level rule needs the reading a rule wants, not the one a
315
+ // renderer wants. Its `undecided` tokens carry no `value` and never become one.
308
316
  export {
309
317
  type Argument as ExplainArgument,
310
318
  type ArgumentKind as ExplainArgumentKind,
311
319
  type ArgumentReading as ExplainArgumentReading,
312
320
  type ArgumentsRead as ExplainArgumentsRead,
313
321
  type ArgumentsUnread as ExplainArgumentsUnread,
322
+ type ArgumentTokenReading as ExplainArgumentTokenReading,
314
323
  lexArguments as lexExplainArguments,
324
+ lexArgumentTokens as lexExplainArgumentTokens,
315
325
  lexValueAnchors as lexExplainValueAnchors,
316
326
  type ValueAnchor as ExplainValueAnchor,
317
327
  type ValueAnchorKind as ExplainValueAnchorKind,
@@ -478,6 +488,14 @@ export {
478
488
  explainOutputFormats,
479
489
  renderExplainEnvelope,
480
490
  } from "./explain.ts";
491
+ export {
492
+ assertOfflineSyntax,
493
+ isOfflineGateRejection,
494
+ OFFLINE_GATE_SOURCE,
495
+ type OfflineGateFinding,
496
+ type OfflineGateResult,
497
+ type ValidationStageName,
498
+ } from "./offline-gate.ts";
481
499
  export {
482
500
  type ApiVerb,
483
501
  createProtocolAdapter,
@@ -672,6 +690,7 @@ export {
672
690
  transferFanout,
673
691
  } from "./transfer-fanout.ts";
674
692
 
693
+ import { centrsVersion } from "./core/version.ts";
675
694
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
676
695
 
677
696
  export const plannedSurfaces = ["api", "cli", "tui", "mcp", "proxy"] as const;
package/src/mcp/server.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
12
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
13
+ import { centrsVersion } from "../core/version.ts";
13
14
  import { type CentrsMcpConfig, resolveMcpConfig } from "./config.ts";
14
15
  import {
15
16
  buildDevicesResource,
@@ -79,7 +80,7 @@ export function createCentrsMcpServer(
79
80
  config: CentrsMcpConfig = resolveMcpConfig(),
80
81
  ): McpServer {
81
82
  const server = new McpServer(
82
- { name: "centrs-mcp", version: "0.1.0" },
83
+ { name: "centrs-mcp", version: centrsVersion },
83
84
  { instructions: SERVER_INSTRUCTIONS },
84
85
  );
85
86
 
package/src/mcp/tools.ts CHANGED
@@ -352,7 +352,7 @@ export const executeInputShape = {
352
352
  .boolean()
353
353
  .optional()
354
354
  .describe(
355
- "Required true for write-shaped commands (add/set/remove). Reads ignore it.",
355
+ "Required true for write-shaped commands, including selector/positional and non-CRUD mutations. Reads ignore it.",
356
356
  ),
357
357
  via: z.string().optional().describe("Override transport."),
358
358
  timeout: z
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Stage 1 of the validation gate: the offline analyzer in front of the device
3
+ * preflight (GH#354).
4
+ *
5
+ * centrs used to run two validators that did not know about each other — the
6
+ * device preflight (`:put [:parse …]` plus `/console/inspect request=child`)
7
+ * that `execute` / `api` / `validate` share, and offline `explain`, which is
8
+ * byte-accurate and corpus-gated but took no part in validation at all. This
9
+ * module is the seam that joins them: every CLI-shaped input is analyzed here
10
+ * first, with no connection, and only then handed to the device stage.
11
+ *
12
+ * Three things that buys, in the order they matter:
13
+ *
14
+ * 1. A syntax fault is rejected with a **byte span** and **no round trip** — the
15
+ * protocol adapter is never even constructed.
16
+ * 2. The device stage narrows to the semantic half it is actually needed for
17
+ * (`:parse` is name-level only; `/console/inspect` owns the attribute set).
18
+ * 3. Where the device probe is unsafe or unavailable — the 7.12.2 class of
19
+ * problem in GH#343 — the gate degrades to a real gate instead of to nothing.
20
+ *
21
+ * **A clean offline pass is necessary, never sufficient.** `explain` publishes
22
+ * `runtimeAcceptance: "not-proven"` for a reason: `/console/inspect` accepts
23
+ * forms the runtime rejects, and the analyzer has no per-menu schema (decision 3
24
+ * — no offline schema snapshot). The device stage is not optional and this
25
+ * module never reports success on its behalf. `verdict: "warn"` is an
26
+ * ABSTENTION, not a rejection, and passes through for the same reason.
27
+ *
28
+ * `--validate=false` disables BOTH stages — one flag, one meaning. That was
29
+ * settled with the `--raw` precedence layer on GH#154 and is why callers gate
30
+ * this on the same resolved `validate` setting as the device stage; the escape
31
+ * hatch has to cover a stage-1 defect too (GH#355 is a live example of the
32
+ * analyzer being wrong in the other direction).
33
+ *
34
+ * The offending byte range rides `context.span`, never {@link CentrsError}'s
35
+ * `position`. `RouterOsErrorPosition` is documented as RouterOS's own
36
+ * authoritative 1-based byte column, "carried verbatim; never re-derived"
37
+ * (`errors.ts`) — synthesizing one from offline analysis would make a
38
+ * centrs-derived offset indistinguishable from a device-reported one.
39
+ */
40
+
41
+ import { CentrsError } from "./errors.ts";
42
+ import { type ExplainDiagnostic, explainCommand } from "./explain.ts";
43
+
44
+ /** Validator identity for `meta.validation` and error context. */
45
+ export const OFFLINE_GATE_SOURCE = "offline explain (canonicalizer)";
46
+
47
+ /** Which stage of the two-stage gate produced a verdict. */
48
+ export type ValidationStageName = "offline" | "device";
49
+
50
+ /** One rejected span, flattened out of {@link ExplainDiagnostic} for the error context. */
51
+ export interface OfflineGateFinding {
52
+ /** The `explain/<analyzer>/<slug>` diagnostic code. */
53
+ code: string;
54
+ message: string;
55
+ /** Byte offsets into the analyzed input, end-exclusive. */
56
+ start: number;
57
+ end: number;
58
+ }
59
+
60
+ /** Outcome of a stage-1 run that did not reject. */
61
+ export interface OfflineGateResult {
62
+ /**
63
+ * `pass` — the analyzer read the whole input. `warn` — it ABSTAINED on part
64
+ * of it (an unresolved menu, say). Both proceed to the device stage; only
65
+ * `fail` throws.
66
+ */
67
+ verdict: "pass" | "warn";
68
+ /** Warning-severity diagnostics, kept so a caller can surface the abstention. */
69
+ warnings: readonly OfflineGateFinding[];
70
+ }
71
+
72
+ function findingOf(diagnostic: ExplainDiagnostic): OfflineGateFinding {
73
+ return {
74
+ code: diagnostic.code,
75
+ message: diagnostic.message,
76
+ start: diagnostic.span.start,
77
+ end: diagnostic.span.end,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Run the offline stage over a RouterOS CLI string.
83
+ *
84
+ * Throws `validation/syntax` when the analyzer rejects the input; returns the
85
+ * verdict otherwise. Never opens a connection and never throws for any other
86
+ * reason — `explainCommand` itself is documented as total.
87
+ *
88
+ * `surface` names the caller in the error context (`execute`, `api /execute`) so
89
+ * a reader can tell which command's gate spoke; `via` is the resolved protocol
90
+ * when the caller has one, purely for context.
91
+ */
92
+ export function assertOfflineSyntax(
93
+ command: string,
94
+ options: { surface: string; via?: string },
95
+ ): OfflineGateResult {
96
+ const analysis = explainCommand(command);
97
+ const errors = analysis.diagnostics.filter(
98
+ (diagnostic) => diagnostic.severity === "error",
99
+ );
100
+ const warnings = analysis.diagnostics
101
+ .filter((diagnostic) => diagnostic.severity === "warning")
102
+ .map(findingOf);
103
+
104
+ if (analysis.verdict !== "fail") {
105
+ return { verdict: analysis.verdict === "warn" ? "warn" : "pass", warnings };
106
+ }
107
+
108
+ // A `fail` verdict always carries at least one error diagnostic, but read it
109
+ // defensively: an empty list would otherwise produce an error that says
110
+ // nothing about what was wrong, which is worse than a generic summary.
111
+ const findings = errors.map(findingOf);
112
+ const first = findings[0];
113
+ throw new CentrsError({
114
+ code: "validation/syntax",
115
+ summary: first
116
+ ? `RouterOS syntax rejected by offline analysis: ${first.message}`
117
+ : "RouterOS syntax rejected by offline analysis.",
118
+ remediation:
119
+ "Run `centrs explain '<command>'` to see the offending bytes in context, " +
120
+ "fix the syntax, then retry. `--validate=false` bypasses both validation " +
121
+ "stages if you are deliberately probing a RouterOS edge centrs reads wrong.",
122
+ context: {
123
+ command,
124
+ validationStage: "offline" satisfies ValidationStageName,
125
+ validationSource: OFFLINE_GATE_SOURCE,
126
+ surface: options.surface,
127
+ ...(options.via ? { via: options.via } : {}),
128
+ ...(first ? { span: { start: first.start, end: first.end } } : {}),
129
+ diagnostics: findings,
130
+ },
131
+ causeData: first?.code ?? "offline analysis rejected the input",
132
+ });
133
+ }
134
+
135
+ /**
136
+ * True when `error` is the rejection {@link assertOfflineSyntax} throws.
137
+ *
138
+ * Callers use this to build honest envelope metadata: on a stage-1 rejection the
139
+ * device stage did not run, and no connection was opened, so the meta must not
140
+ * claim `:parse` or `/console/inspect` failed.
141
+ */
142
+ export function isOfflineGateRejection(error: unknown): boolean {
143
+ return (
144
+ error instanceof CentrsError &&
145
+ error.code === "validation/syntax" &&
146
+ (error.context?.["validationStage"] as string | undefined) === "offline"
147
+ );
148
+ }
@@ -152,6 +152,14 @@ export interface ProtocolListenOptions {
152
152
  * after the follow is established, instead of a blind timer.
153
153
  */
154
154
  onListening?: () => void;
155
+ /**
156
+ * How long to wait for RouterOS to acknowledge `/cancel` after `signal`
157
+ * aborts before ending the listen locally. Native API defaults to the
158
+ * adapter's `timeoutMs`, the same bound as any other reply wait.
159
+ */
160
+ cancelGraceMs?: number;
161
+ /** Fired when that wait expires without the router's acknowledgement. */
162
+ onCancelUnacknowledged?: () => void;
155
163
  }
156
164
 
157
165
  /**
@@ -733,7 +741,10 @@ class NativeApiAdapter implements ProtocolAdapter {
733
741
  command.proplist = request.proplist;
734
742
  }
735
743
  const session = await this.connect();
736
- for await (const reply of session.listen(command, options)) {
744
+ for await (const reply of session.listen(command, {
745
+ ...options,
746
+ cancelGraceMs: options.cancelGraceMs ?? this.config.timeoutMs,
747
+ })) {
737
748
  // Each `!re` becomes a rest-style record (string values; `.dead` preserved).
738
749
  yield { ...reply.attributes };
739
750
  }
@@ -627,14 +627,16 @@ export function parseScriptFor(cli: string): string {
627
627
  * with RouterOS's position suffix. All 425 corpus rows of this shape are
628
628
  * the entire return; none appear embedded. Requiring both line-start and
629
629
  * the `(line N column M)` suffix is what separates a real verdict from
630
- * echoed body text.
630
+ * echoed body text. The pinned 7.23.5/7.24.2/7.25beta3 captures add no
631
+ * new heads beyond `syntax error`, `expected …` and `missing …`
632
+ * (`missing closing brace`, `missing value for where`; GH#375).
631
633
  */
632
634
  const WRAPPED_BAD_PARAMETER = /\(evl\s+bad parameter\s+(\S+)/i;
633
635
  const WRAPPED_BAD_COMMAND = /\(<%%\s*bad command name\b/i;
634
636
  const BARE_BAD_PARAMETER =
635
637
  /^[ \t]*bad parameter\s+(\S+)[^\n]*\(line \d+ column \d+\)/im;
636
638
  const BARE_SYNTAX =
637
- /^[ \t]*(?:syntax error|bad command name|expected [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
639
+ /^[ \t]*(?:syntax error|bad command name|(?:expected|missing) [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
638
640
 
639
641
  /**
640
642
  * Classify the printed value of `:put [:parse "<cli>"]`. Grounded on CHR 7.23.3
@@ -646,8 +648,8 @@ const BARE_SYNTAX =
646
648
  * RouterOS emits them (see the grammar note above): wrapped as
647
649
  * `(evl bad parameter <name> (line N column M) /path)` /
648
650
  * `(<%% bad command name <name> (line N column M) a;b;c)`, or bare and
649
- * line-initial as `expected … (line N column M)` / `syntax error (line N column
650
- * M)`. A single `:parse` covers both the syntax and the unknown-attribute
651
+ * line-initial as `expected … (line N column M)` / `missing … (line N column
652
+ * M)` / `syntax error (line N column M)`. A single `:parse` covers both the syntax and the unknown-attribute
651
653
  * (name-level) gate on every transport — REST and native API expose the same text
652
654
  * in the `ret` value (HTTP 200 / `as-string`), the console transports print it.
653
655
  *
@@ -635,10 +635,21 @@ export class NativeApiSession {
635
635
  * trap/`!done` are not awaited) and the generator returns.
636
636
  * A non-`interrupted` trap (e.g. a bad path) or `!fatal`/transport closure
637
637
  * throws. CHR-grounded (7.23.1): see `commands/api/AGENTS.md`.
638
+ *
639
+ * After an abort, `cancelGraceMs` bounds the wait for that acknowledgement: a
640
+ * peer that never answers `/cancel` would otherwise hold the generator open
641
+ * forever (#385). On expiry the generator ends without the router's
642
+ * `!done` and `onCancelUnacknowledged` fires, so the caller can close the
643
+ * session and report that the stop was local, not acknowledged.
638
644
  */
639
645
  async *listen(
640
646
  command: NativeApiCommand,
641
- options: { signal?: AbortSignal; onListening?: () => void } = {},
647
+ options: {
648
+ signal?: AbortSignal;
649
+ onListening?: () => void;
650
+ cancelGraceMs?: number;
651
+ onCancelUnacknowledged?: () => void;
652
+ } = {},
642
653
  ): AsyncGenerator<ApiReply, void, void> {
643
654
  if (this.closed) {
644
655
  throw (
@@ -702,10 +713,24 @@ export class NativeApiSession {
702
713
  // can act on a real barrier rather than a blind timer.
703
714
  options.onListening?.();
704
715
 
705
- const onAbort = (): void => this.cancel(tag);
716
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
717
+ const onAbort = (): void => {
718
+ this.cancel(tag);
719
+ if (options.cancelGraceMs === undefined) return;
720
+ graceTimer = setTimeout(() => {
721
+ if (ended) return;
722
+ // Stop routing this tag and drop anything queued: a peer that ignores
723
+ // `/cancel` but keeps sending frames must not keep the loop draining.
724
+ this.subscriptions.delete(tag);
725
+ queue.length = 0;
726
+ ended = true;
727
+ options.onCancelUnacknowledged?.();
728
+ signalWake();
729
+ }, options.cancelGraceMs);
730
+ };
706
731
  if (options.signal) {
707
732
  if (options.signal.aborted) {
708
- this.cancel(tag);
733
+ onAbort();
709
734
  } else {
710
735
  options.signal.addEventListener("abort", onAbort, { once: true });
711
736
  }
@@ -738,6 +763,7 @@ export class NativeApiSession {
738
763
  throw this.trapToError(command.command, trap);
739
764
  }
740
765
  } finally {
766
+ clearTimeout(graceTimer);
741
767
  options.signal?.removeEventListener("abort", onAbort);
742
768
  this.subscriptions.delete(tag);
743
769
  // Consumer broke out early (e.g. --count reached): tell the router to stop.
@@ -428,20 +428,40 @@ export function resolveAuth(
428
428
  }
429
429
 
430
430
  export function parseHostCandidate(value: string): URL {
431
- if (/^https?:\/\//i.test(value)) {
432
- const url = new URL(value);
433
- if (url.pathname !== "/" && url.pathname !== "") {
434
- throw new CentrsError({
435
- code: "input/invalid-target",
436
- summary: `Target URL must not include a path. Received: ${value}`,
437
- remediation:
438
- "Pass only the RouterOS host or base URL, then provide the RouterOS menu path as the second positional argument.",
439
- });
440
- }
441
- return url;
431
+ const hasScheme = /^https?:\/\//i.test(value);
432
+ let url: URL;
433
+ try {
434
+ url = new URL(hasScheme ? value : `http://${value}`);
435
+ } catch (cause) {
436
+ throw invalidTargetError(value, cause);
442
437
  }
438
+ if (hasScheme && url.pathname !== "/" && url.pathname !== "") {
439
+ throw new CentrsError({
440
+ code: "input/invalid-target",
441
+ summary: `Target URL must not include a path. Received: ${value}`,
442
+ remediation:
443
+ "Pass only the RouterOS host or base URL, then provide the RouterOS menu path as the second positional argument.",
444
+ });
445
+ }
446
+ return url;
447
+ }
443
448
 
444
- return new URL(`http://${value}`);
449
+ /**
450
+ * A `<router>` that is not a CDB handle and does not parse as a host (#381).
451
+ * `quickchr:NAME` is the likely guess for a quickchr VM, so it names the flag.
452
+ */
453
+ function invalidTargetError(value: string, cause: unknown): CentrsError {
454
+ const quickchrGuess = /^quickchr:(.+)$/i.exec(value)?.[1];
455
+ return new CentrsError({
456
+ code: "input/invalid-target",
457
+ summary: `Target is not a known device or a valid host: ${value}`,
458
+ remediation:
459
+ quickchrGuess !== undefined
460
+ ? `Select a quickchr VM with the flag: \`--quickchr ${quickchrGuess}\` (there is no \`quickchr:\` target prefix).`
461
+ : "Pass a CDB target or identity (see `centrs devices list`), an IP or hostname with optional `:port`, or an `http(s)://host[:port]` base URL.",
462
+ context: { target: value },
463
+ cause,
464
+ });
445
465
  }
446
466
 
447
467
  function readPort(parsedUrl: URL, scheme: "http" | "https"): number {
@@ -46,6 +46,7 @@ import {
46
46
  runFanout,
47
47
  summarizeFanout,
48
48
  } from "./core/fanout.ts";
49
+ import { toYaml } from "./core/yaml.ts";
49
50
  import { CentrsError, serializeCentrsError } from "./errors.ts";
50
51
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
51
52
  import {
@@ -71,7 +72,6 @@ import {
71
72
  resolveRetrieveGlobalContext,
72
73
  resolveRetrieveRequest,
73
74
  runResolvedRetrieve,
74
- toYaml,
75
75
  validateRetrieveRequestShape,
76
76
  } from "./retrieve.ts";
77
77
 
package/src/retrieve.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  } from "./core/envelope.ts";
9
9
  import {
10
10
  extractCompletionNames,
11
+ inspectArgumentNames,
11
12
  inspectChildren,
12
13
  inspectChildrenOrEmpty,
13
14
  inspectCompletions,
@@ -15,6 +16,7 @@ import {
15
16
  isCommandNode,
16
17
  pathTokens,
17
18
  } from "./core/inspect.ts";
19
+ import { toYaml } from "./core/yaml.ts";
18
20
  import { CentrsError, serializeCentrsError } from "./errors.ts";
19
21
  import {
20
22
  createProtocolAdapter,
@@ -123,6 +125,7 @@ export type RetrieveErrorEnvelope = CentrsErrorEnvelope<RetrieveOperationMeta>;
123
125
 
124
126
  interface RetrieveInspection {
125
127
  command: "get" | "print";
128
+ singleton: boolean;
126
129
  }
127
130
 
128
131
  export interface ResolvedRetrieveRequest {
@@ -239,7 +242,7 @@ export async function runResolvedRetrieve(
239
242
  }
240
243
  }
241
244
 
242
- const data = await executeRetrieve(resolved, backend);
245
+ const data = await executeRetrieve(resolved, backend, inspection);
243
246
  const envelope = buildSuccessEnvelope(
244
247
  resolved,
245
248
  {
@@ -998,9 +1001,18 @@ async function inspectRetrievePath(
998
1001
  });
999
1002
  }
1000
1003
 
1001
- const singleton = isKnownSingletonPath(resolved.path);
1004
+ // A singleton's `get` takes only `value-name`; a list menu's also takes
1005
+ // `number` (CHR 7.23.7: /tool/romon, /ip/dns, /system/identity vs
1006
+ // /ip/address). A hardcoded path list missed every other singleton and
1007
+ // validated its properties against `print`'s flags (issue #377).
1008
+ const singleton =
1009
+ supportsGet &&
1010
+ !(await inspectArgumentNames(backend, [...tokens, "get"])).includes(
1011
+ "number",
1012
+ );
1002
1013
  return {
1003
- command: singleton && supportsGet ? "get" : "print",
1014
+ command: singleton ? "get" : "print",
1015
+ singleton,
1004
1016
  };
1005
1017
  }
1006
1018
 
@@ -1039,8 +1051,9 @@ async function inspectAttributes(
1039
1051
  async function executeRetrieve(
1040
1052
  resolved: ResolvedRetrieveRequest,
1041
1053
  backend: ProtocolAdapter,
1054
+ inspection: RetrieveInspection | undefined,
1042
1055
  ): Promise<unknown> {
1043
- if (isKnownSingletonPath(resolved.path)) {
1056
+ if (inspection?.singleton ?? isKnownSingletonPath(resolved.path)) {
1044
1057
  const data = await backend.getSingleton(resolved.path);
1045
1058
  if (resolved.attributes.length > 0) {
1046
1059
  return projectSingletonAttributes(data, resolved.attributes);
@@ -1054,6 +1067,7 @@ async function executeRetrieve(
1054
1067
  });
1055
1068
  }
1056
1069
 
1070
+ /** Fallback for `--validate=false`, which skips the inspect that detects singletons. */
1057
1071
  function isKnownSingletonPath(path: string): boolean {
1058
1072
  return ["/system/resource", "/system/identity"].includes(
1059
1073
  path.replace(/\/$/, ""),
@@ -1192,62 +1206,3 @@ function exhaustiveOutputFormat(value: never): never {
1192
1206
  function isPlainObject(value: unknown): value is Record<string, unknown> {
1193
1207
  return typeof value === "object" && value !== null && !Array.isArray(value);
1194
1208
  }
1195
-
1196
- export function toYaml(value: unknown, indent = 0): string {
1197
- const prefix = " ".repeat(indent);
1198
- if (value === null || value === undefined) {
1199
- return "null";
1200
- }
1201
- if (typeof value === "string") {
1202
- return yamlScalar(value);
1203
- }
1204
- if (typeof value === "number" || typeof value === "boolean") {
1205
- return String(value);
1206
- }
1207
- if (Array.isArray(value)) {
1208
- if (value.length === 0) {
1209
- return `${prefix}[]`;
1210
- }
1211
- return value
1212
- .map((item) => {
1213
- if (isScalar(item)) {
1214
- return `${prefix}- ${toYaml(item, indent + 2)}`;
1215
- }
1216
- return `${prefix}-\n${toYaml(item, indent + 2)}`;
1217
- })
1218
- .join("\n");
1219
- }
1220
- if (isPlainObject(value)) {
1221
- const entries = Object.entries(value).filter(
1222
- ([, entryValue]) => entryValue !== undefined,
1223
- );
1224
- if (entries.length === 0) {
1225
- return `${prefix}{}`;
1226
- }
1227
- return entries
1228
- .map(([key, entryValue]) => {
1229
- if (isScalar(entryValue)) {
1230
- return `${prefix}${key}: ${toYaml(entryValue, indent + 2)}`;
1231
- }
1232
- return `${prefix}${key}:\n${toYaml(entryValue, indent + 2)}`;
1233
- })
1234
- .join("\n");
1235
- }
1236
- return yamlScalar(JSON.stringify(value));
1237
- }
1238
-
1239
- function isScalar(value: unknown): value is boolean | number | string | null {
1240
- return (
1241
- value === null ||
1242
- typeof value === "string" ||
1243
- typeof value === "number" ||
1244
- typeof value === "boolean"
1245
- );
1246
- }
1247
-
1248
- function yamlScalar(value: string): string {
1249
- if (value.length === 0) {
1250
- return '""';
1251
- }
1252
- return JSON.stringify(value);
1253
- }
package/src/settings.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  Warning,
25
25
  } from "./core/envelope.ts";
26
26
  import { buildTip } from "./core/envelope.ts";
27
+ import { toYaml } from "./core/yaml.ts";
27
28
  import { loadCdb, resolveDevicesSettings } from "./devices.ts";
28
29
  import { CentrsError, serializeCentrsError } from "./errors.ts";
29
30
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
@@ -37,7 +38,7 @@ import {
37
38
  readSettingsFileRaw,
38
39
  resolveOptionalIntegerSetting,
39
40
  } from "./resolver/index.ts";
40
- import { retrieveOutputFormats, toYaml } from "./retrieve.ts";
41
+ import { retrieveOutputFormats } from "./retrieve.ts";
41
42
 
42
43
  // ── Envelope shape ───────────────────────────────────────────────────────────
43
44
 
@@ -38,6 +38,7 @@ import {
38
38
  runFanout,
39
39
  summarizeFanout,
40
40
  } from "./core/fanout.ts";
41
+ import { toYaml } from "./core/yaml.ts";
41
42
  import { CentrsError, serializeCentrsError } from "./errors.ts";
42
43
  import { promptForWriteConfirmation } from "./execute.ts";
43
44
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
@@ -50,7 +51,6 @@ import {
50
51
  type SelectionResolveInput,
51
52
  type TargetSelection,
52
53
  } from "./resolver/index.ts";
53
- import { toYaml } from "./retrieve.ts";
54
54
  import {
55
55
  buildTransferErrorEnvelope,
56
56
  isTransferWriteVerb,
package/src/transfer.ts CHANGED
@@ -29,6 +29,7 @@ import type {
29
29
  EnvelopeValidationMeta,
30
30
  Warning,
31
31
  } from "./core/envelope.ts";
32
+ import { toYaml } from "./core/yaml.ts";
32
33
  import { CentrsError, serializeCentrsError } from "./errors.ts";
33
34
  import {
34
35
  createProtocolAdapter,
@@ -54,7 +55,6 @@ import {
54
55
  resolveTarget,
55
56
  toCoreSource,
56
57
  } from "./resolver/index.ts";
57
- import { toYaml } from "./retrieve.ts";
58
58
 
59
59
  export const transferOutputFormats = ["text", "json", "yaml"] as const;
60
60
  export type TransferOutputFormat = (typeof transferOutputFormats)[number];