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