@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.
- package/CHANGELOG.md +348 -0
- package/GLOSSARY.txt +252 -0
- package/LICENSE +21 -0
- package/README.md +44 -12
- package/commands/AGENTS.md +14 -5
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +56 -26
- package/commands/api/examples.md +46 -1
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +77 -36
- package/commands/execute/examples.md +120 -10
- package/commands/explain/README.md +2756 -0
- package/commands/explain/examples.md +702 -0
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/terminal/examples.md +7 -1
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +138 -30
- package/docs/MATRIX.md +92 -18
- package/docs/errors/README.md +3 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +56 -10
- package/src/api-fanout.ts +46 -18
- package/src/api.ts +351 -121
- package/src/cli/api.ts +26 -7
- package/src/cli/btest.ts +19 -3
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/terminal.ts +1 -1
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +58 -2
- package/src/core/error-catalog.ts +42 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +47 -18
- package/src/execute.ts +339 -307
- package/src/explain/arg-tokens.ts +151 -0
- package/src/explain/args.ts +1749 -0
- package/src/explain/blocks.ts +175 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +39 -0
- package/src/explain/catalog.ts +1343 -0
- package/src/explain/coordinates.ts +488 -0
- package/src/explain/defects.ts +218 -0
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +668 -0
- package/src/explain/operator-tokens.ts +316 -0
- package/src/explain/operators.ts +465 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1569 -0
- package/src/explain/quoted-string.ts +331 -0
- package/src/explain/scope-brace.ts +461 -0
- package/src/explain/segment.ts +1165 -0
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +77 -0
- package/src/explain/symbol-values.ts +556 -0
- package/src/explain/symbols.ts +1617 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +514 -0
- package/src/explain/value-tokens.ts +83 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +955 -0
- package/src/explain/write.ts +856 -0
- package/src/explain.ts +2602 -0
- package/src/index.ts +225 -6
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +27 -13
- package/src/retrieve.ts +82 -77
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +43 -26
- 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
|
+
}
|
package/src/core/yaml.ts
ADDED
|
@@ -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
package/src/execute-fanout.ts
CHANGED
|
@@ -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 (`
|
|
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
|
-
|
|
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:
|
|
97
|
+
input: SelectionResolveInput,
|
|
95
98
|
env: Record<string, string | undefined>,
|
|
96
99
|
config?: Record<string, string | undefined>,
|
|
97
|
-
) => Promise<
|
|
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:
|
|
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:
|
|
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:
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
355
|
+
warnings: SelectionExpansion["warnings"];
|
|
327
356
|
targets: readonly ExecuteEnvelope[];
|
|
328
357
|
}
|
|
329
358
|
|