@tikoci/centrs 0.1.3 → 0.1.6

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 (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Capability/version pre-flight for centrs-generated RouterOS commands (#129).
3
+ *
4
+ * centrs sometimes generates a command that assumes a device capability the
5
+ * device may lack (e.g. `transfer mkdir` → `/file add type=directory`, absent
6
+ * on older RouterOS). Without a pre-flight that surfaces as a raw protocol
7
+ * error; with one it becomes a typed `routeros/version-unsupported` error
8
+ * naming the versions that do support the operation. (Distinct from
9
+ * `routeros/unsupported-capability`, which is a *protocol*-side gap — the
10
+ * transport cannot carry the operation at all.)
11
+ *
12
+ * Design:
13
+ * - A {@link CapabilityRequirement} carries the supported
14
+ * {@link RouterOsVersionRange} list as **data**. Ranges are inclusive-`min` /
15
+ * exclusive-`maxExclusive`, so a backported feature is a list of per-release-
16
+ * line minimums (e.g. `[{min:"7.21.4", maxExclusive:"7.22"}, {min:"7.22.2"}]`).
17
+ * The grounding for each requirement (CHR run, rosetta changelog/command-diff)
18
+ * lives at the requirement's definition site — this module only evaluates.
19
+ * - The device version comes from a {@link DeviceVersion}: a live read
20
+ * ({@link probeDeviceVersion}), the CDB record's stored `version=` comment
21
+ * fact ({@link versionFactFromComment} — stale-but-cheap, written by
22
+ * `discover --save`/`devices --check`), or the caller.
23
+ * - An **unknown** version never blocks (crash-early: the device's own error
24
+ * still surfaces through the RouterOS error mapping); only a version known to
25
+ * be outside every supported range fails, via {@link assertCapability}.
26
+ *
27
+ * Version ordering is suffix-aware: `7.23beta1 < 7.23beta2 < 7.23rc1 < 7.23 <
28
+ * 7.23.1` (RouterOS version format `MAJOR.MINOR[.PATCH][beta|rc N]`). The
29
+ * comparator tolerates trailing channel/date noise (`"7.21.4 (long-term) …"`),
30
+ * matching what `/system/resource` reports. Promoted from the integration
31
+ * harness's `routerOsAtLeast` (`test/integration/chr.ts`), which now delegates
32
+ * here.
33
+ */
34
+
35
+ import { CentrsError } from "../errors.ts";
36
+ import type { ProtocolAdapter } from "../protocols/adapter.ts";
37
+ import { parseRawCommentFacts } from "../resolver/comment-kv.ts";
38
+
39
+ /** Parsed RouterOS version, ordered by the tuple (major, minor, patch, stage, stageIteration). */
40
+ export interface RouterOsVersionParts {
41
+ major: number;
42
+ minor: number;
43
+ patch: number;
44
+ /** Prerelease stage rank: beta=0 < rc=1 < release=2. */
45
+ stageRank: 0 | 1 | 2;
46
+ /** Iteration within the stage (`beta2` → 2); 0 for a release. */
47
+ stageIteration: number;
48
+ }
49
+
50
+ /**
51
+ * Parse a RouterOS version out of a possibly-noisy string
52
+ * (`"7.21.4 (long-term) 2026-04-21"` → 7.21.4). Returns `undefined` when no
53
+ * `major.minor` shape is present.
54
+ */
55
+ export function parseRouterOsVersion(
56
+ raw: string,
57
+ ): RouterOsVersionParts | undefined {
58
+ // Bounded digit runs ({1,4}) keep this linear on adversarial input — an
59
+ // unbounded `\d+` here is a polynomial-ReDoS vector on strings of many
60
+ // repeated digits (CodeQL js/polynomial-redos). RouterOS version numbers
61
+ // never approach four digits per field, so the bound is free.
62
+ //
63
+ // The `(?<![\d.])` / `(?!\d)` token boundaries keep the (unanchored) match
64
+ // from latching onto a *fragment* of a larger number: without them
65
+ // `"12345.67"` matched `"2345.67"` and `"127.0.0.1"` matched `"127.0"`.
66
+ // They still allow legitimately noisy strings ("7.23.2 (stable)", "v7.23").
67
+ const match = raw.match(
68
+ /(?<![\d.])(\d{1,4})\.(\d{1,4})(?:\.(\d{1,4}))?(?:(beta|rc)(\d{1,4}))?(?![\d.])/i,
69
+ );
70
+ if (!match) {
71
+ return undefined;
72
+ }
73
+ const stage = match[4]?.toLowerCase();
74
+ return {
75
+ major: Number(match[1]),
76
+ minor: Number(match[2]),
77
+ patch: Number(match[3] ?? 0),
78
+ stageRank: stage === undefined ? 2 : stage === "rc" ? 1 : 0,
79
+ stageIteration: Number(match[5] ?? 0),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Compare two RouterOS version strings: negative when `a < b`, 0 when equal,
85
+ * positive when `a > b`. An unparseable version sorts below every parseable
86
+ * one (and equal to another unparseable one).
87
+ */
88
+ export function compareRouterOsVersion(a: string, b: string): number {
89
+ const left = parseRouterOsVersion(a);
90
+ const right = parseRouterOsVersion(b);
91
+ if (left === undefined || right === undefined) {
92
+ return (left === undefined ? 0 : 1) - (right === undefined ? 0 : 1);
93
+ }
94
+ return (
95
+ left.major - right.major ||
96
+ left.minor - right.minor ||
97
+ left.patch - right.patch ||
98
+ left.stageRank - right.stageRank ||
99
+ left.stageIteration - right.stageIteration
100
+ );
101
+ }
102
+
103
+ /** True when `running` ≥ `target`. Unparseable `running` is never ≥ a parseable target. */
104
+ export function routerOsVersionAtLeast(
105
+ running: string,
106
+ target: string,
107
+ ): boolean {
108
+ if (
109
+ parseRouterOsVersion(running) === undefined &&
110
+ parseRouterOsVersion(target) !== undefined
111
+ ) {
112
+ return false;
113
+ }
114
+ return compareRouterOsVersion(running, target) >= 0;
115
+ }
116
+
117
+ /**
118
+ * One contiguous supported window. `min` is inclusive; `maxExclusive` bounds a
119
+ * backport window (omit it for "this version and everything after").
120
+ */
121
+ export interface RouterOsVersionRange {
122
+ min: string;
123
+ maxExclusive?: string;
124
+ }
125
+
126
+ /**
127
+ * True when `version` falls inside `range`. A malformed range is a definition
128
+ * bug in a {@link CapabilityRequirement}, not a device condition, so it throws
129
+ * rather than failing open (an unparseable `min` would otherwise sort below
130
+ * every real version and mark everything supported).
131
+ */
132
+ export function versionInRange(
133
+ version: string,
134
+ range: RouterOsVersionRange,
135
+ ): boolean {
136
+ if (parseRouterOsVersion(range.min) === undefined) {
137
+ throw new TypeError(
138
+ `Invalid capability range min ${JSON.stringify(range.min)}; ` +
139
+ "set CapabilityRequirement.supported[].min to a RouterOS major.minor[.patch] version.",
140
+ );
141
+ }
142
+ if (
143
+ range.maxExclusive !== undefined &&
144
+ (parseRouterOsVersion(range.maxExclusive) === undefined ||
145
+ compareRouterOsVersion(range.min, range.maxExclusive) >= 0)
146
+ ) {
147
+ throw new TypeError(
148
+ `Invalid capability range maxExclusive ${JSON.stringify(range.maxExclusive)}; ` +
149
+ "set CapabilityRequirement.supported[].maxExclusive to a RouterOS version greater than min.",
150
+ );
151
+ }
152
+ if (!routerOsVersionAtLeast(version, range.min)) {
153
+ return false;
154
+ }
155
+ return (
156
+ range.maxExclusive === undefined ||
157
+ compareRouterOsVersion(version, range.maxExclusive) < 0
158
+ );
159
+ }
160
+
161
+ /**
162
+ * A capability a centrs-generated command depends on, with the version windows
163
+ * where RouterOS provides it. Define these next to the consumer (e.g. the
164
+ * transfer spec owns the `mkdir` requirement) and document the grounding
165
+ * evidence there.
166
+ */
167
+ export interface CapabilityRequirement {
168
+ /** Stable slug for error context, e.g. `file-add-directory`. */
169
+ capability: string;
170
+ /** Human phrase for the operation, e.g. "create directories with `/file add type=directory`". */
171
+ summary: string;
172
+ /** Windows where the capability exists; supported when ANY range matches. */
173
+ supported: readonly RouterOsVersionRange[];
174
+ /** Extra guidance appended to the typed error's remediation. */
175
+ remediation?: string;
176
+ }
177
+
178
+ /** Where a pre-flighted device version came from (error context + envelope provenance). */
179
+ export type DeviceVersionSource = "live" | "cdb-fact" | "caller";
180
+
181
+ /** A device version paired with its provenance. */
182
+ export interface DeviceVersion {
183
+ version: string;
184
+ source: DeviceVersionSource;
185
+ }
186
+
187
+ export type PreflightState = "supported" | "unsupported" | "unknown";
188
+
189
+ /** Outcome of a capability pre-flight. */
190
+ export interface PreflightVerdict {
191
+ state: PreflightState;
192
+ /** Echo of the checked device version, when one was available. */
193
+ device?: DeviceVersion;
194
+ }
195
+
196
+ /**
197
+ * Evaluate a requirement against a device version. `unknown` when no version
198
+ * is available or it does not parse — callers proceed on `unknown` (the device
199
+ * still reports its own error, mapped by the RouterOS error vocabulary).
200
+ */
201
+ export function checkCapability(
202
+ requirement: CapabilityRequirement,
203
+ device?: DeviceVersion,
204
+ ): PreflightVerdict {
205
+ if (
206
+ device === undefined ||
207
+ parseRouterOsVersion(device.version) === undefined
208
+ ) {
209
+ return { state: "unknown" };
210
+ }
211
+ const supported = requirement.supported.some((range) =>
212
+ versionInRange(device.version, range),
213
+ );
214
+ return { state: supported ? "supported" : "unsupported", device };
215
+ }
216
+
217
+ /** Render supported windows for humans: `7.21.4–7.22 (excl.), 7.22.2+`. */
218
+ export function renderSupportedRanges(
219
+ ranges: readonly RouterOsVersionRange[],
220
+ ): string {
221
+ return ranges
222
+ .map((range) =>
223
+ range.maxExclusive === undefined
224
+ ? `${range.min}+`
225
+ : `${range.min}–${range.maxExclusive} (excl.)`,
226
+ )
227
+ .join(", ");
228
+ }
229
+
230
+ /**
231
+ * Pre-flight gate: throw a typed `routeros/version-unsupported` error when
232
+ * the device version is known and outside every supported window; otherwise
233
+ * return the verdict (`unknown` passes — see {@link checkCapability}).
234
+ */
235
+ export function assertCapability(
236
+ requirement: CapabilityRequirement,
237
+ device?: DeviceVersion,
238
+ ): PreflightVerdict {
239
+ const verdict = checkCapability(requirement, device);
240
+ if (verdict.state !== "unsupported" || verdict.device === undefined) {
241
+ return verdict;
242
+ }
243
+ const supported = renderSupportedRanges(requirement.supported);
244
+ throw new CentrsError({
245
+ code: "routeros/version-unsupported",
246
+ summary: `RouterOS ${verdict.device.version} cannot ${requirement.summary}; it needs ${supported}.`,
247
+ remediation:
248
+ (requirement.remediation ? `${requirement.remediation} ` : "") +
249
+ (verdict.device.source === "cdb-fact"
250
+ ? "The version came from the stored CDB `version=` fact and may be stale; upgrade RouterOS, or refresh the fact if the device was already upgraded."
251
+ : "Upgrade RouterOS to a supported version."),
252
+ context: {
253
+ capability: requirement.capability,
254
+ version: verdict.device.version,
255
+ versionSource: verdict.device.source,
256
+ supported: requirement.supported.map((range) => ({ ...range })),
257
+ },
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Read a stored device version from a CDB record's comment kv-soup (`version=`
263
+ * — the derived fact `discover --save` and `devices --check` write). Stale by
264
+ * nature; provenance is `cdb-fact`.
265
+ */
266
+ export function versionFactFromComment(
267
+ comment: string,
268
+ ): DeviceVersion | undefined {
269
+ const version = parseRawCommentFacts(comment)["version"];
270
+ if (version === undefined || parseRouterOsVersion(version) === undefined) {
271
+ return undefined;
272
+ }
273
+ return { version, source: "cdb-fact" };
274
+ }
275
+
276
+ /**
277
+ * Live device-version probe over an already-built adapter. Structured
278
+ * transports read `/system/resource`; console transports (ssh/mac-telnet) run
279
+ * `:put [/system/resource/ get version]`. Transport/auth errors propagate —
280
+ * only a well-formed reply that simply lacks a version yields `undefined`.
281
+ */
282
+ export async function probeDeviceVersion(
283
+ adapter: ProtocolAdapter,
284
+ ): Promise<DeviceVersion | undefined> {
285
+ if (adapter.capabilities.retrieve) {
286
+ const resource = await adapter.getSingleton("/system/resource");
287
+ const version =
288
+ typeof resource === "object" && resource !== null
289
+ ? (resource as Record<string, unknown>)["version"]
290
+ : undefined;
291
+ if (typeof version === "string" && parseRouterOsVersion(version)) {
292
+ return { version, source: "live" };
293
+ }
294
+ return undefined;
295
+ }
296
+ const result = await adapter.execute({
297
+ path: "/system/resource",
298
+ command: "print",
299
+ script: ":put [/system/resource/ get version]",
300
+ });
301
+ const version = result.ret?.trim();
302
+ if (version && parseRouterOsVersion(version)) {
303
+ return { version, source: "live" };
304
+ }
305
+ return undefined;
306
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Quote RouterOS command text as one double-quoted string literal.
3
+ *
4
+ * `$` is load-bearing here: RouterOS expands variables inside double quotes,
5
+ * so leaving it bare changes the bytes seen by wrappers such as
6
+ * `:put [:parse "<input>"]` before the parser receives them.
7
+ * Control whitespace (`\t`/`\r`/`\n`) is escaped so the decoded string
8
+ * carries the caller's original bytes. This is defensive: RouterOS 7.20
9
+ * changelog says "replace TAB characters with spaces when editing scripts
10
+ * and added `tab-width` in `/console/settings`" — version-gated (absent
11
+ * before 7.20), width-configurable via `tab-width`, and scoped to the
12
+ * script editor, not grounded as string-literal expansion (see #276).
13
+ * Escaping preserves the caller's bytes whether the device expands or
14
+ * preserves a raw tab.
15
+ */
16
+ export function routerOsStringLiteral(value: string): string {
17
+ return `"${value
18
+ .replaceAll("\\", "\\\\")
19
+ .replaceAll('"', '\\"')
20
+ .replaceAll("$", "\\$")
21
+ .replaceAll("\t", "\\t")
22
+ .replaceAll("\r", "\\r")
23
+ .replaceAll("\n", "\\n")}"`;
24
+ }
@@ -0,0 +1,4 @@
1
+ import packageMetadata from "../../package.json" with { type: "json" };
2
+
3
+ /** Package version reported by every frontend and the root library surface. */
4
+ export const centrsVersion = packageMetadata.version;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Envelope YAML rendering — the `--format yaml` writer shared by every command.
3
+ *
4
+ * This is a **renderer, not a YAML library**: it serializes the envelope shapes
5
+ * centrs builds (scalars, arrays, plain objects) and quotes every string through
6
+ * `JSON.stringify`, which is valid YAML double-quoted scalar syntax. It does not
7
+ * parse YAML, emit anchors/aliases, or handle cycles.
8
+ *
9
+ * It lives in `src/core/` because it is **pure** — no Bun, filesystem, network,
10
+ * or CDB reach. `retrieve.ts` used to own it, which put every consumer of
11
+ * `toYaml` one import away from the transport stack; `explain`'s offline entry
12
+ * runs in a browser only because this function no longer drags that graph
13
+ * behind it (#312). Every importer now takes `toYaml` from here; `retrieve.ts`
14
+ * is one of them, and the function itself moved character for character.
15
+ */
16
+
17
+ /**
18
+ * Render `value` as YAML at `indent` spaces of nesting.
19
+ *
20
+ * `undefined` object entries are dropped (they have no YAML spelling and the
21
+ * envelope uses them for absent-optional); `undefined` in scalar position and
22
+ * `null` both render `null`. A non-plain object that is neither array nor
23
+ * scalar falls back to its JSON text as a quoted scalar rather than guessing.
24
+ */
25
+ export function toYaml(value: unknown, indent = 0): string {
26
+ const prefix = " ".repeat(indent);
27
+ if (value === null || value === undefined) {
28
+ return "null";
29
+ }
30
+ if (typeof value === "string") {
31
+ return yamlScalar(value);
32
+ }
33
+ if (typeof value === "number" || typeof value === "boolean") {
34
+ return String(value);
35
+ }
36
+ if (Array.isArray(value)) {
37
+ if (value.length === 0) {
38
+ return `${prefix}[]`;
39
+ }
40
+ return value
41
+ .map((item) => {
42
+ if (isScalar(item)) {
43
+ return `${prefix}- ${toYaml(item, indent + 2)}`;
44
+ }
45
+ return `${prefix}-\n${toYaml(item, indent + 2)}`;
46
+ })
47
+ .join("\n");
48
+ }
49
+ if (isPlainObject(value)) {
50
+ const entries = Object.entries(value).filter(
51
+ ([, entryValue]) => entryValue !== undefined,
52
+ );
53
+ if (entries.length === 0) {
54
+ return `${prefix}{}`;
55
+ }
56
+ return entries
57
+ .map(([key, entryValue]) => {
58
+ if (isScalar(entryValue)) {
59
+ return `${prefix}${key}: ${toYaml(entryValue, indent + 2)}`;
60
+ }
61
+ return `${prefix}${key}:\n${toYaml(entryValue, indent + 2)}`;
62
+ })
63
+ .join("\n");
64
+ }
65
+ return yamlScalar(JSON.stringify(value));
66
+ }
67
+
68
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value);
70
+ }
71
+
72
+ function isScalar(value: unknown): value is boolean | number | string | null {
73
+ return (
74
+ value === null ||
75
+ typeof value === "string" ||
76
+ typeof value === "number" ||
77
+ typeof value === "boolean"
78
+ );
79
+ }
80
+
81
+ function yamlScalar(value: string): string {
82
+ if (value.length === 0) {
83
+ return '""';
84
+ }
85
+ return JSON.stringify(value);
86
+ }
package/src/errors.ts CHANGED
@@ -13,6 +13,7 @@ export type CentrsErrorCode =
13
13
  | `input/${string}`
14
14
  | `internal/${string}`
15
15
  | `mndp/${string}`
16
+ | `quickchr/${string}`
16
17
  | `routeros/${string}`
17
18
  | `settings/${string}`
18
19
  | `target/${string}`
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Boundaries (see `commands/execute/README.md`, `docs/CONSTITUTION.md`), shared
11
11
  * with `api-fanout.ts` / `retrieve-fanout.ts`:
12
- * - The CDB is loaded ONCE (`expandCdbSelection`); a cdb member is resolved with
12
+ * - The CDB is loaded ONCE (`expandSelection`); a cdb member is resolved with
13
13
  * its pre-resolved record, so `resolveCdb`'s `__default__` synthetic fallback
14
14
  * never collides ad-hoc literals.
15
15
  * - A `--default` / `__default__` member is guarded from being dialed as the
@@ -26,6 +26,7 @@ import type {
26
26
  CommonSettingsMeta,
27
27
  FanoutData,
28
28
  FanoutSummary,
29
+ ValidationTrace,
29
30
  } from "./core/envelope.ts";
30
31
  import {
31
32
  buildFanoutEnvelope,
@@ -36,6 +37,7 @@ import {
36
37
  runFanout,
37
38
  summarizeFanout,
38
39
  } from "./core/fanout.ts";
40
+ import { toYaml } from "./core/yaml.ts";
39
41
  import { CentrsError, serializeCentrsError } from "./errors.ts";
40
42
  import {
41
43
  buildExecuteErrorEnvelopeFromResolved,
@@ -52,15 +54,14 @@ import {
52
54
  } from "./execute.ts";
53
55
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
54
56
  import {
55
- type CdbSelectionExpansion,
56
- type CdbSelectionMember,
57
- type CdbSelectionResolveInput,
58
- expandCdbSelection,
57
+ expandSelection,
59
58
  isDefaultRecordTarget,
60
59
  loadEnvFileDefaults,
60
+ type SelectionExpansion,
61
+ type SelectionMember,
62
+ type SelectionResolveInput,
61
63
  type TargetSelection,
62
64
  } from "./resolver/index.ts";
63
- import { toYaml } from "./retrieve.ts";
64
65
 
65
66
  /** Compact description of the selector that produced the fan-out. */
66
67
  export interface ExecuteSelectionSummary {
@@ -69,6 +70,8 @@ export interface ExecuteSelectionSummary {
69
70
  all: boolean;
70
71
  default: boolean;
71
72
  positionals: readonly string[];
73
+ /** quickchr machine names (`--quickchr`, repeatable; exclusive of the rest). */
74
+ quickchr?: readonly string[];
72
75
  }
73
76
 
74
77
  export interface ExecuteFanoutOperationMeta {
@@ -91,10 +94,10 @@ export type ExecuteFanoutErrorEnvelope =
91
94
  export interface ExecuteFanoutInternals {
92
95
  expand?: (
93
96
  selection: TargetSelection,
94
- input: CdbSelectionResolveInput,
97
+ input: SelectionResolveInput,
95
98
  env: Record<string, string | undefined>,
96
99
  config?: Record<string, string | undefined>,
97
- ) => Promise<CdbSelectionExpansion>;
100
+ ) => Promise<SelectionExpansion>;
98
101
  execute?: (
99
102
  resolved: ResolvedExecuteRequest,
100
103
  ) => Promise<ExecuteSuccessEnvelope>;
@@ -116,6 +119,7 @@ function summarizeSelection(
116
119
  all: selection.all,
117
120
  default: selection.default,
118
121
  positionals: selection.positionals,
122
+ quickchr: selection.quickchr,
119
123
  };
120
124
  }
121
125
 
@@ -124,7 +128,7 @@ function fanoutVia(
124
128
  request: ExecuteRequest,
125
129
  env: Record<string, string | undefined>,
126
130
  globalVia: RouterOsProtocol,
127
- expansion: CdbSelectionExpansion,
131
+ expansion: SelectionExpansion,
128
132
  ): RouterOsProtocol {
129
133
  // Deliberately CLI/env only, not `config` — see the identical note in
130
134
  // `retrieve-fanout.ts`'s `resolveFanoutConcurrencyProtocol`.
@@ -139,7 +143,7 @@ function fanoutVia(
139
143
  return anyNative ? "native-api" : globalVia;
140
144
  }
141
145
 
142
- function memberTargetMeta(member: CdbSelectionMember): {
146
+ function memberTargetMeta(member: SelectionMember): {
143
147
  input?: string;
144
148
  identity?: string;
145
149
  recordIndex?: number;
@@ -151,6 +155,9 @@ function memberTargetMeta(member: CdbSelectionMember): {
151
155
  recordIndex: member.recordIndex,
152
156
  };
153
157
  }
158
+ if (member.kind === "quickchr") {
159
+ return { input: member.name, identity: member.name };
160
+ }
154
161
  return { input: member.input };
155
162
  }
156
163
 
@@ -163,7 +170,7 @@ function memberTargetMeta(member: CdbSelectionMember): {
163
170
  */
164
171
  function relabelAdhocMember(
165
172
  envelope: ExecuteEnvelope,
166
- member: CdbSelectionMember,
173
+ member: SelectionMember,
167
174
  ): ExecuteEnvelope {
168
175
  if (member.kind !== "literal") {
169
176
  return envelope;
@@ -195,7 +202,7 @@ export async function executeFanout(
195
202
  const global = resolveExecuteGlobalContext(request, env, config);
196
203
  const selectionSummary = summarizeSelection(selection);
197
204
 
198
- const expand = internals.expand ?? expandCdbSelection;
205
+ const expand = internals.expand ?? expandSelection;
199
206
  const expansion = await expand(
200
207
  selection,
201
208
  {
@@ -227,10 +234,15 @@ export async function executeFanout(
227
234
  await assertFanoutWriteConfirmed(request, global.summary, expansion);
228
235
 
229
236
  const execute = internals.execute ?? runResolvedExecute;
237
+ // Per-target validation trace. `execute` and `onExecuteError` are separate
238
+ // callbacks that share only the resolved request, so the request object is
239
+ // the key: without it a per-target error envelope would reconstruct
240
+ // `meta.validation` instead of reporting the gate that actually ran (GH#354).
241
+ const traces = new WeakMap<ResolvedExecuteRequest, ValidationTrace>();
230
242
  const sleep = internals.sleep ?? defaultFanoutSleep;
231
243
 
232
244
  const targets = await runFanout<
233
- CdbSelectionMember,
245
+ SelectionMember,
234
246
  ResolvedExecuteRequest,
235
247
  ExecuteEnvelope
236
248
  >({
@@ -254,6 +266,15 @@ export async function executeFanout(
254
266
  { cdbResolution: member.resolution, config },
255
267
  );
256
268
  }
269
+ if (member.kind === "quickchr") {
270
+ // The live descriptor resolves here, per member — a stopped/unknown
271
+ // machine is an inner per-target failure, not an expansion error.
272
+ return resolveExecuteRequest(
273
+ { ...request, targetInput: undefined, quickchr: member.name },
274
+ env,
275
+ { config },
276
+ );
277
+ }
257
278
  return resolveExecuteRequest(
258
279
  { ...request, targetInput: member.input },
259
280
  env,
@@ -267,11 +288,19 @@ export async function executeFanout(
267
288
  warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
268
289
  summary: "Failed to resolve a fanout target.",
269
290
  }),
270
- execute: async (resolved, member) =>
271
- relabelAdhocMember(await execute(resolved), member),
291
+ execute: async (resolved, member) => {
292
+ const trace: ValidationTrace = {};
293
+ traces.set(resolved, trace);
294
+ return relabelAdhocMember(await execute(resolved, trace), member);
295
+ },
272
296
  onExecuteError: (resolved, member, error) =>
273
297
  relabelAdhocMember(
274
- buildExecuteErrorEnvelopeFromResolved(resolved, error),
298
+ buildExecuteErrorEnvelopeFromResolved(
299
+ resolved,
300
+ error,
301
+ undefined,
302
+ traces.get(resolved),
303
+ ),
275
304
  member,
276
305
  ),
277
306
  sleep,
@@ -292,7 +321,7 @@ export async function executeFanout(
292
321
  async function assertFanoutWriteConfirmed(
293
322
  request: ExecuteRequest,
294
323
  summary: ExecuteRequestSummary,
295
- expansion: CdbSelectionExpansion,
324
+ expansion: SelectionExpansion,
296
325
  ): Promise<void> {
297
326
  if (!summary.write || request.yes) {
298
327
  return;
@@ -323,7 +352,7 @@ interface ExecuteFanoutEnvelopeInput {
323
352
  requestSummary: ExecuteRequestSummary;
324
353
  settings: CommonSettingsMeta;
325
354
  via: RouterOsProtocol | null;
326
- warnings: CdbSelectionExpansion["warnings"];
355
+ warnings: SelectionExpansion["warnings"];
327
356
  targets: readonly ExecuteEnvelope[];
328
357
  }
329
358