@tikoci/centrs 0.1.6 → 0.1.8-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +342 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +10 -6
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +135 -42
- package/commands/api/examples.md +275 -19
- package/commands/devices/README.md +21 -4
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +60 -18
- package/commands/execute/examples.md +224 -0
- package/commands/explain/README.md +42 -5
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +442 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +29 -21
- package/docs/CONSTITUTION.md +41 -16
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +603 -194
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/devices.ts +16 -3
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +216 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +70 -1
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +65 -27
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +67 -1
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +148 -37
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +14 -9
- package/src/execute.ts +128 -116
- package/src/explain/catalog.ts +64 -16
- package/src/explain/menus.ts +27 -6
- package/src/explain.ts +6 -6
- package/src/index.ts +31 -1
- package/src/mcp/tools.ts +10 -15
- package/src/protocols/adapter.ts +235 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +12 -7
- package/src/protocols/native-api.ts +252 -73
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +84 -18
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +741 -109
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell a menu this device does not have apart from a real syntax error (GH#361).
|
|
3
|
+
*
|
|
4
|
+
* RouterOS's `:parse` reports an absent menu as if it were a syntax fault, in two
|
|
5
|
+
* spellings that both name the segment it could not resolve (CHR 7.23.7):
|
|
6
|
+
*
|
|
7
|
+
* /zerotier/print -> syntax error (line 1 column 10)
|
|
8
|
+
* /zerotier print -> (<%% bad command name zerotier (line 1 column 2) …)
|
|
9
|
+
*
|
|
10
|
+
* The slash spelling points at the `/` right AFTER the absent segment; the space
|
|
11
|
+
* spelling names the segment and points at its first byte. The column is a 1-based
|
|
12
|
+
* BYTE column on the given line (`:put "é"; /zerotier/print` -> column 21, not 20).
|
|
13
|
+
* Nesting moves the column but not the rule: `:put [/interface/w60g/get …]`,
|
|
14
|
+
* `/ip/address { /zerotier/print }` and a second line all point at the `/` after
|
|
15
|
+
* the absent segment.
|
|
16
|
+
*
|
|
17
|
+
* Once the segment is located, the path catalog says what it is. Both shipped
|
|
18
|
+
* tables are built from all-extra-packages builds, so a path they carry exists in
|
|
19
|
+
* SOME RouterOS 7 build, and its published gates explain why this one lacks it:
|
|
20
|
+
*
|
|
21
|
+
* The path can end in a command (`/system/license/output`, gated `nochr`), not
|
|
22
|
+
* only a menu; the summary uses the catalog's kind.
|
|
23
|
+
*
|
|
24
|
+
* - a package gate alone -> `validation/package-missing`
|
|
25
|
+
* - any other gate, or none -> `validation/menu-unavailable`
|
|
26
|
+
* - not in either table -> `validation/unknown-path`
|
|
27
|
+
*
|
|
28
|
+
* Anything this cannot locate exactly returns `undefined`, and the caller keeps
|
|
29
|
+
* its `validation/syntax`. Abstaining is always safe; guessing a path is not.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { CentrsError, type RouterOsErrorPosition } from "./errors.ts";
|
|
33
|
+
import {
|
|
34
|
+
type CatalogGate,
|
|
35
|
+
effectiveGates,
|
|
36
|
+
PATH_CATALOG,
|
|
37
|
+
} from "./explain/catalog.ts";
|
|
38
|
+
import { MENU_PATHS } from "./explain/menus.ts";
|
|
39
|
+
import { explainCommand } from "./explain.ts";
|
|
40
|
+
|
|
41
|
+
const NAME = /[a-z0-9-]/i;
|
|
42
|
+
const BAD_COMMAND_NAME = /bad command name\s+(\S+)\s+\(line \d+ column \d+\)/i;
|
|
43
|
+
const BARE_SYNTAX_ERROR = /^[ \t]*syntax error \(line \d+ column \d+\)/im;
|
|
44
|
+
/** What may precede the leading `/` of an absolute path run. */
|
|
45
|
+
const RUN_BOUNDARY = /[\s[{;(]/;
|
|
46
|
+
|
|
47
|
+
/** The located absent segment, as a path from the root. */
|
|
48
|
+
interface AbsentPath {
|
|
49
|
+
/** Lower-cased segments from the root through the absent one. */
|
|
50
|
+
segments: string[];
|
|
51
|
+
/** The segment RouterOS could not resolve, as written. */
|
|
52
|
+
segment: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function absentMenuError(
|
|
56
|
+
cli: string,
|
|
57
|
+
output: string,
|
|
58
|
+
position: RouterOsErrorPosition | undefined,
|
|
59
|
+
via: string,
|
|
60
|
+
): CentrsError | undefined {
|
|
61
|
+
if (position === undefined) return undefined;
|
|
62
|
+
const offset = offsetOf(cli, position);
|
|
63
|
+
if (offset === undefined) return undefined;
|
|
64
|
+
const absent = locateAbsentPath(cli, output, offset);
|
|
65
|
+
if (absent === undefined) return undefined;
|
|
66
|
+
|
|
67
|
+
const segments = expandAbbreviations(absent.segments);
|
|
68
|
+
const path = `/${segments.join("/")}`;
|
|
69
|
+
const context = {
|
|
70
|
+
command: cli,
|
|
71
|
+
path,
|
|
72
|
+
segment: absent.segment,
|
|
73
|
+
validationSource: `:put [:parse ...] over ${via}`,
|
|
74
|
+
detail: output,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (!isPublished(path)) {
|
|
78
|
+
return new CentrsError({
|
|
79
|
+
code: "validation/unknown-path",
|
|
80
|
+
summary: `RouterOS has no \`${path}\` menu or command, and centrs's path catalog has none.`,
|
|
81
|
+
remediation: `Check the spelling of \`${absent.segment}\`: the catalog covers every RouterOS 7.10–7.25 build with all extra packages. \`centrs explain '<command>'\` shows how the rest of the command reads.`,
|
|
82
|
+
context,
|
|
83
|
+
position,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const noun = PATH_CATALOG.get(path)?.kind === "command" ? "command" : "menu";
|
|
88
|
+
const gates = gatesOf(segments);
|
|
89
|
+
const packages = unique(gates.flatMap((gate) => gate.package ?? []));
|
|
90
|
+
const others = gates.filter(
|
|
91
|
+
(gate) => gate.conditions !== undefined || gate.syscap !== undefined,
|
|
92
|
+
);
|
|
93
|
+
if (packages.length > 0 && others.length === 0) {
|
|
94
|
+
const named = packages.map((name) => `\`${name}\``).join(", ");
|
|
95
|
+
return new CentrsError({
|
|
96
|
+
code: "validation/package-missing",
|
|
97
|
+
summary: `This device has no \`${path}\` ${noun}; MikroTik publishes it in package ${named}, which is likely not installed.`,
|
|
98
|
+
remediation: `The command is not the problem. Check the installed packages with \`/system/package/print\`; install ${named} (MikroTik's published name; the installable package can be named differently) and reboot, or run this only on devices that have it.`,
|
|
99
|
+
context: { ...context, packages, gates },
|
|
100
|
+
position,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return new CentrsError({
|
|
104
|
+
code: "validation/menu-unavailable",
|
|
105
|
+
summary: `This device has no \`${path}\` ${noun}, although RouterOS 7 publishes one.`,
|
|
106
|
+
remediation: `The command is not the problem. ${gateAdvice(gates)}`,
|
|
107
|
+
context: { ...context, gates },
|
|
108
|
+
position,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The UTF-16 index of RouterOS's 1-based line / byte column, or `undefined`. */
|
|
113
|
+
function offsetOf(
|
|
114
|
+
cli: string,
|
|
115
|
+
{ line, column }: RouterOsErrorPosition,
|
|
116
|
+
): number | undefined {
|
|
117
|
+
const lines = cli.split("\n");
|
|
118
|
+
const text = lines[line - 1];
|
|
119
|
+
if (text === undefined || column < 1) return undefined;
|
|
120
|
+
const bytes = new TextEncoder().encode(text);
|
|
121
|
+
if (column - 1 > bytes.length) return undefined;
|
|
122
|
+
const prefix = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
123
|
+
bytes.slice(0, column - 1),
|
|
124
|
+
);
|
|
125
|
+
let start = 0;
|
|
126
|
+
for (let index = 0; index < line - 1; index++)
|
|
127
|
+
start += (lines[index] as string).length + 1;
|
|
128
|
+
return start + prefix.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function locateAbsentPath(
|
|
132
|
+
cli: string,
|
|
133
|
+
output: string,
|
|
134
|
+
offset: number,
|
|
135
|
+
): AbsentPath | undefined {
|
|
136
|
+
const named = output.match(BAD_COMMAND_NAME);
|
|
137
|
+
if (named) {
|
|
138
|
+
const segment = named[1] as string;
|
|
139
|
+
if (cli.slice(offset, offset + segment.length) !== segment)
|
|
140
|
+
return undefined;
|
|
141
|
+
return cli[offset - 1] === "/"
|
|
142
|
+
? slashRun(cli, offset, offset + segment.length)
|
|
143
|
+
: spaceSpelling(cli, offset, segment);
|
|
144
|
+
}
|
|
145
|
+
if (!BARE_SYNTAX_ERROR.test(output) || cli[offset] !== "/") return undefined;
|
|
146
|
+
let start = offset;
|
|
147
|
+
while (start > 0 && NAME.test(cli[start - 1] as string)) start--;
|
|
148
|
+
if (start === offset || cli[start - 1] !== "/") return undefined;
|
|
149
|
+
return slashRun(cli, start, offset);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `/a/b/<segment>` — walk back over `name/` pairs to the run's leading `/`, which
|
|
154
|
+
* must sit at a statement boundary. A relative run (`../x`, `ip/x`) abstains.
|
|
155
|
+
*/
|
|
156
|
+
function slashRun(
|
|
157
|
+
cli: string,
|
|
158
|
+
start: number,
|
|
159
|
+
end: number,
|
|
160
|
+
): AbsentPath | undefined {
|
|
161
|
+
let runStart = start - 1;
|
|
162
|
+
while (runStart > 0 && cli[runStart] === "/") {
|
|
163
|
+
let nameStart = runStart;
|
|
164
|
+
while (nameStart > 0 && NAME.test(cli[nameStart - 1] as string))
|
|
165
|
+
nameStart--;
|
|
166
|
+
if (nameStart === runStart) break;
|
|
167
|
+
if (cli[nameStart - 1] !== "/") return undefined;
|
|
168
|
+
runStart = nameStart - 1;
|
|
169
|
+
}
|
|
170
|
+
if (runStart > 0 && !RUN_BOUNDARY.test(cli[runStart - 1] as string))
|
|
171
|
+
return undefined;
|
|
172
|
+
const segments = cli
|
|
173
|
+
.slice(runStart + 1, end)
|
|
174
|
+
.toLowerCase()
|
|
175
|
+
.split("/");
|
|
176
|
+
return { segments, segment: cli.slice(start, end) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `/ip nosuchmenu print` — the parent comes from the offline analyzer's reading
|
|
181
|
+
* of the statement that holds the segment. Menu-scope blocks and anything else
|
|
182
|
+
* the analyzer did not resolve abstain, and so does a segment that appears more
|
|
183
|
+
* than once (`/ip ip print`): which occurrence RouterOS rejected is not mapped.
|
|
184
|
+
*/
|
|
185
|
+
function spaceSpelling(
|
|
186
|
+
cli: string,
|
|
187
|
+
offset: number,
|
|
188
|
+
segment: string,
|
|
189
|
+
): AbsentPath | undefined {
|
|
190
|
+
const { structure } = explainCommand(cli);
|
|
191
|
+
const holders = [...structure.statements, ...structure.subcommands]
|
|
192
|
+
.filter(
|
|
193
|
+
(entry) =>
|
|
194
|
+
entry.resolution === "resolved" &&
|
|
195
|
+
entry.command !== undefined &&
|
|
196
|
+
entry.span.start <= offset &&
|
|
197
|
+
offset < entry.span.end,
|
|
198
|
+
)
|
|
199
|
+
.sort((a, b) => a.span.end - a.span.start - (b.span.end - b.span.start));
|
|
200
|
+
const command = holders[0]?.command;
|
|
201
|
+
if (command === undefined) return undefined;
|
|
202
|
+
const words = [
|
|
203
|
+
...command.path.split("/").filter(Boolean),
|
|
204
|
+
...("verb" in command ? [command.verb] : []),
|
|
205
|
+
].map((word) => word.toLowerCase());
|
|
206
|
+
const index = words.indexOf(segment.toLowerCase());
|
|
207
|
+
if (index < 0 || words.lastIndexOf(segment.toLowerCase()) !== index)
|
|
208
|
+
return undefined;
|
|
209
|
+
return { segments: words.slice(0, index + 1), segment };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** RouterOS accepts a unique prefix; resolve one against the catalog's children. */
|
|
213
|
+
function expandAbbreviations(segments: readonly string[]): string[] {
|
|
214
|
+
const out: string[] = [];
|
|
215
|
+
for (const segment of segments) {
|
|
216
|
+
const parent = out.length === 0 ? "" : `/${out.join("/")}`;
|
|
217
|
+
if (isPublished(`${parent}/${segment}`)) {
|
|
218
|
+
out.push(segment);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const children = new Set<string>();
|
|
222
|
+
for (const path of publishedPaths())
|
|
223
|
+
if (path.startsWith(`${parent}/${segment}`)) {
|
|
224
|
+
const child = path.slice(parent.length + 1).split("/")[0] as string;
|
|
225
|
+
children.add(child);
|
|
226
|
+
}
|
|
227
|
+
out.push(children.size === 1 ? ([...children][0] as string) : segment);
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function* publishedPaths(): Iterable<string> {
|
|
233
|
+
yield* PATH_CATALOG.keys();
|
|
234
|
+
yield* MENU_PATHS;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A path, or anything under it, is in one of the two shipped tables. */
|
|
238
|
+
function isPublished(path: string): boolean {
|
|
239
|
+
if (PATH_CATALOG.has(path) || MENU_PATHS.has(path)) return true;
|
|
240
|
+
for (const known of publishedPaths())
|
|
241
|
+
if (known.startsWith(`${path}/`)) return true;
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The path's own gates, root-first. A container row the publication left ungated
|
|
247
|
+
* (`/iot`) borrows the package every catalog row under it agrees on — all 62
|
|
248
|
+
* `/iot/*` rows say `iot` — and borrows nothing when they disagree.
|
|
249
|
+
*/
|
|
250
|
+
function gatesOf(segments: readonly string[]): CatalogGate[] {
|
|
251
|
+
const gates = effectiveGates(segments);
|
|
252
|
+
if (gates.some((gate) => gate.package !== undefined)) return gates;
|
|
253
|
+
const path = `/${segments.join("/")}`;
|
|
254
|
+
const below = [...PATH_CATALOG].filter(([known]) =>
|
|
255
|
+
known.startsWith(`${path}/`),
|
|
256
|
+
);
|
|
257
|
+
const packages = unique(below.map(([, entry]) => entry.package ?? ""));
|
|
258
|
+
if (below.length > 0 && packages.length === 1 && packages[0] !== "")
|
|
259
|
+
return [...gates, { path, package: packages[0] as string }];
|
|
260
|
+
return gates;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function gateAdvice(gates: readonly CatalogGate[]): string {
|
|
264
|
+
if (gates.length === 0)
|
|
265
|
+
return "MikroTik publishes no package or hardware requirement for it, so the usual cause is the RouterOS version: compare `/system/resource/print` with the version that introduced the menu.";
|
|
266
|
+
const parts = gates.map((gate) =>
|
|
267
|
+
[
|
|
268
|
+
gate.package && `package \`${gate.package}\``,
|
|
269
|
+
gate.syscap && `hardware capability \`${gate.syscap}\``,
|
|
270
|
+
gate.conditions && `build \`${gate.conditions}\``,
|
|
271
|
+
]
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.join(" and "),
|
|
274
|
+
);
|
|
275
|
+
return `MikroTik publishes it as needing ${unique(parts).join("; ")}. Check \`/system/package/print\` and the device model, or run this only on devices that have it.`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function unique(values: readonly string[]): string[] {
|
|
279
|
+
return [...new Set(values)];
|
|
280
|
+
}
|
package/src/api-fanout.ts
CHANGED
|
@@ -15,13 +15,14 @@
|
|
|
15
15
|
* with `target/unresolved`.
|
|
16
16
|
* - A mutating fan-out is confirmed ONCE up front (`--yes`), never per target;
|
|
17
17
|
* without `--yes` the error names the blast radius (how many routers).
|
|
18
|
-
* - `--
|
|
18
|
+
* - `--stream` and `--raw` are rejected in fan-out mode by the CLI
|
|
19
19
|
* before reaching here (single-session / envelope-stripping).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
23
|
type ApiEnvelope,
|
|
24
24
|
type ApiOperationMeta,
|
|
25
|
+
type ApiOutputFormat,
|
|
25
26
|
type ApiRequest,
|
|
26
27
|
type ApiRequestSummary,
|
|
27
28
|
type ApiSuccessEnvelope,
|
|
@@ -51,7 +52,12 @@ import {
|
|
|
51
52
|
summarizeFanout,
|
|
52
53
|
} from "./core/fanout.ts";
|
|
53
54
|
import { toYaml } from "./core/yaml.ts";
|
|
54
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
CentrsError,
|
|
57
|
+
formatCentrsErrorText,
|
|
58
|
+
serializeCentrsError,
|
|
59
|
+
terminalSafeLine,
|
|
60
|
+
} from "./errors.ts";
|
|
55
61
|
import { promptForWriteConfirmation } from "./execute.ts";
|
|
56
62
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
57
63
|
import {
|
|
@@ -428,26 +434,26 @@ export function buildApiFanoutErrorEnvelope(
|
|
|
428
434
|
}
|
|
429
435
|
|
|
430
436
|
/**
|
|
431
|
-
* Render an api fan-out envelope. `json`/`yaml` serialize the whole envelope
|
|
432
|
-
* `text` renders a summary line plus one line
|
|
437
|
+
* Render an api fan-out envelope. `json`/`yaml` serialize the whole envelope,
|
|
438
|
+
* `ndjson` as one compact line; `text` renders a summary line plus one line
|
|
439
|
+
* per target in record-index order.
|
|
433
440
|
*/
|
|
434
441
|
export function renderApiFanoutEnvelope(
|
|
435
442
|
envelope: ApiFanoutEnvelope | ApiFanoutErrorEnvelope,
|
|
436
|
-
format:
|
|
443
|
+
format: ApiOutputFormat,
|
|
437
444
|
options: { verbose?: boolean } = {},
|
|
438
445
|
): string {
|
|
439
446
|
if (format === "json") {
|
|
440
447
|
return JSON.stringify(envelope, null, 2);
|
|
441
448
|
}
|
|
449
|
+
if (format === "ndjson") {
|
|
450
|
+
return JSON.stringify(envelope);
|
|
451
|
+
}
|
|
442
452
|
if (format === "yaml") {
|
|
443
453
|
return toYaml(envelope);
|
|
444
454
|
}
|
|
445
455
|
if (!envelope.ok) {
|
|
446
|
-
|
|
447
|
-
if (envelope.error.remediation) {
|
|
448
|
-
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
449
|
-
}
|
|
450
|
-
return lines.join("\n");
|
|
456
|
+
return formatCentrsErrorText(envelope.error, options);
|
|
451
457
|
}
|
|
452
458
|
const operation = envelope.meta.operation;
|
|
453
459
|
const { total, ok, failed } = envelope.data.summary;
|
|
@@ -468,10 +474,14 @@ export function renderApiFanoutEnvelope(
|
|
|
468
474
|
);
|
|
469
475
|
} else {
|
|
470
476
|
lines.push(
|
|
471
|
-
|
|
477
|
+
terminalSafeLine(
|
|
478
|
+
` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
|
|
479
|
+
),
|
|
472
480
|
);
|
|
473
481
|
if (options.verbose && target.error.remediation) {
|
|
474
|
-
lines.push(
|
|
482
|
+
lines.push(
|
|
483
|
+
terminalSafeLine(` Fix: ${target.error.remediation}`),
|
|
484
|
+
);
|
|
475
485
|
}
|
|
476
486
|
}
|
|
477
487
|
}
|