@tikoci/centrs 0.1.2 → 0.1.4
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 +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -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/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- package/src/transfer.ts +133 -40
package/src/cli/retrieve.ts
CHANGED
|
@@ -4,23 +4,30 @@
|
|
|
4
4
|
* unchanged from the former monolithic `cli.ts`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
asCentrsError,
|
|
9
|
+
CentrsError,
|
|
10
|
+
formatCentrsErrorText,
|
|
11
|
+
} from "../errors.ts";
|
|
8
12
|
import {
|
|
9
13
|
buildRetrieveErrorEnvelope,
|
|
10
14
|
buildRetrieveFanoutErrorEnvelope,
|
|
11
15
|
describeCentrs,
|
|
16
|
+
fanoutExitCode,
|
|
12
17
|
type RetrieveOutputFormat,
|
|
13
18
|
type RetrieveRequest,
|
|
14
19
|
renderRetrieveEnvelope,
|
|
15
20
|
renderRetrieveFanoutEnvelope,
|
|
16
21
|
retrieve,
|
|
17
|
-
|
|
22
|
+
retrieveFanout,
|
|
18
23
|
retrieveOutputFormats,
|
|
19
24
|
} from "../index.ts";
|
|
25
|
+
import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
|
|
20
26
|
import {
|
|
21
27
|
type CliCommandMetadata,
|
|
22
28
|
expectValue,
|
|
23
29
|
renderCommandHelp,
|
|
30
|
+
unknownFlagError,
|
|
24
31
|
} from "./common.ts";
|
|
25
32
|
import {
|
|
26
33
|
buildTargetSelectionTips,
|
|
@@ -30,11 +37,20 @@ import {
|
|
|
30
37
|
missingTargetError,
|
|
31
38
|
withTips,
|
|
32
39
|
} from "./missing-target.ts";
|
|
40
|
+
import {
|
|
41
|
+
assertQuickchrExclusive,
|
|
42
|
+
buildTargetSelection,
|
|
43
|
+
consumeSelectionFlag,
|
|
44
|
+
emptySelectionFlags,
|
|
45
|
+
isFanoutMode,
|
|
46
|
+
type SelectionFlags,
|
|
47
|
+
selectionCommandOptions,
|
|
48
|
+
} from "./selection.ts";
|
|
33
49
|
|
|
34
50
|
export const retrieveCommand: CliCommandMetadata = {
|
|
35
51
|
name: "retrieve",
|
|
36
52
|
usage:
|
|
37
|
-
"centrs retrieve <target> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
|
|
53
|
+
"centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <target...> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
|
|
38
54
|
summary:
|
|
39
55
|
"Read RouterOS values through the shared core using the selected protocol.",
|
|
40
56
|
options: [
|
|
@@ -44,18 +60,7 @@ export const retrieveCommand: CliCommandMetadata = {
|
|
|
44
60
|
description:
|
|
45
61
|
"Pin the protocol selector. Defaults to `rest-api` for retrieve.",
|
|
46
62
|
},
|
|
47
|
-
|
|
48
|
-
flag: "--group",
|
|
49
|
-
valueName: "<name>",
|
|
50
|
-
description:
|
|
51
|
-
"Fan out over every CDB record in the group. Replaces the <target> positional.",
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
flag: "--concurrency",
|
|
55
|
-
valueName: "<n>",
|
|
56
|
-
description:
|
|
57
|
-
"Bounded worker-pool size for `--group` fanout (REST 8, native-api 4 by default).",
|
|
58
|
-
},
|
|
63
|
+
...selectionCommandOptions,
|
|
59
64
|
{
|
|
60
65
|
flag: "--host",
|
|
61
66
|
valueName: "<host|url>",
|
|
@@ -98,10 +103,16 @@ export const retrieveCommand: CliCommandMetadata = {
|
|
|
98
103
|
description: "Request the RouterOS detail/all-attributes shape.",
|
|
99
104
|
},
|
|
100
105
|
{
|
|
101
|
-
flag: "--list-attributes",
|
|
106
|
+
flag: "--list-attributes / --list",
|
|
102
107
|
description:
|
|
103
108
|
"List inspect-derived attributes without running the data call.",
|
|
104
109
|
},
|
|
110
|
+
{
|
|
111
|
+
flag: "--query / --filter",
|
|
112
|
+
valueName: "<expr>",
|
|
113
|
+
description:
|
|
114
|
+
"RouterOS-side row filter (maps to `.query`). Not implemented yet — returns `validation/not-implemented`.",
|
|
115
|
+
},
|
|
105
116
|
{
|
|
106
117
|
flag: "--format",
|
|
107
118
|
valueName: `<${retrieveOutputFormats.join("|")}>`,
|
|
@@ -145,37 +156,50 @@ export const retrieveCommand: CliCommandMetadata = {
|
|
|
145
156
|
],
|
|
146
157
|
};
|
|
147
158
|
|
|
159
|
+
type RetrieveCliArgs = RetrieveRequest & {
|
|
160
|
+
help?: boolean;
|
|
161
|
+
selectionFlags?: SelectionFlags;
|
|
162
|
+
targetPositionals?: readonly string[];
|
|
163
|
+
};
|
|
164
|
+
|
|
148
165
|
export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
149
|
-
let request:
|
|
166
|
+
let request: RetrieveCliArgs | undefined;
|
|
150
167
|
|
|
151
168
|
try {
|
|
152
169
|
request = parseRetrieveCliArgs(args);
|
|
153
|
-
if (
|
|
170
|
+
if (request.help) {
|
|
154
171
|
console.log(renderCommandHelp(describeCentrs(), retrieveCommand));
|
|
155
172
|
return 0;
|
|
156
173
|
}
|
|
157
174
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
175
|
+
// Fan-out mode (a selector flag present, or >1 positional target) has its own
|
|
176
|
+
// envelope shape and granular exit code; a plain single-target call keeps the
|
|
177
|
+
// single-target envelope.
|
|
178
|
+
const selectionFlags = request.selectionFlags ?? emptySelectionFlags();
|
|
179
|
+
const targetPositionals = request.targetPositionals ?? [];
|
|
180
|
+
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
181
|
+
return await runRetrieveFanoutCli(
|
|
182
|
+
request,
|
|
183
|
+
selectionFlags,
|
|
184
|
+
targetPositionals,
|
|
185
|
+
args,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const envelope = await retrieve(request);
|
|
161
190
|
const resolvedFormat =
|
|
162
191
|
(
|
|
163
192
|
envelope.meta.operation as {
|
|
164
193
|
request?: { format?: RetrieveOutputFormat };
|
|
165
194
|
}
|
|
166
195
|
)?.request?.format ?? "text";
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
envelope as Parameters<typeof renderRetrieveEnvelope>[0],
|
|
175
|
-
resolvedFormat,
|
|
176
|
-
{ verbose: request.verbose },
|
|
177
|
-
);
|
|
178
|
-
console.log(output);
|
|
196
|
+
console.log(
|
|
197
|
+
renderRetrieveEnvelope(
|
|
198
|
+
envelope as Parameters<typeof renderRetrieveEnvelope>[0],
|
|
199
|
+
resolvedFormat,
|
|
200
|
+
{ verbose: request.verbose },
|
|
201
|
+
),
|
|
202
|
+
);
|
|
179
203
|
return 0;
|
|
180
204
|
} catch (error) {
|
|
181
205
|
const format = inferRequestedFormat(args, request);
|
|
@@ -186,31 +210,23 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
186
210
|
})
|
|
187
211
|
: [];
|
|
188
212
|
if (format === "json" || format === "yaml") {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
|
|
204
|
-
tips,
|
|
205
|
-
);
|
|
206
|
-
console.error(
|
|
207
|
-
renderRetrieveEnvelope(envelope, format, {
|
|
208
|
-
verbose: request?.verbose ?? false,
|
|
209
|
-
}),
|
|
210
|
-
);
|
|
211
|
-
}
|
|
213
|
+
// When parsing failed before a request existed, build the error envelope
|
|
214
|
+
// from an empty request rather than reconstructing positionals from raw
|
|
215
|
+
// args — a credential value (e.g. the token after `--password` /
|
|
216
|
+
// `--cdb-password`) would otherwise be echoed as `meta.target.input`.
|
|
217
|
+
const envelope = withTips(
|
|
218
|
+
buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
|
|
219
|
+
tips,
|
|
220
|
+
);
|
|
221
|
+
console.error(
|
|
222
|
+
// codeql[js/clear-text-logging] Twin of the established redacted-request dismissals: error builders carry only targetInput (host)+via (protocol); the raw password never reaches this render.
|
|
223
|
+
renderRetrieveEnvelope(envelope, format, {
|
|
224
|
+
verbose: request?.verbose ?? false,
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
212
227
|
} else {
|
|
213
228
|
console.error(
|
|
229
|
+
// codeql[js/clear-text-logging] Same false-positive pattern: password parsed from args never reaches error.message in the text-format catch path.
|
|
214
230
|
formatCentrsErrorText(
|
|
215
231
|
asCentrsError(error, {
|
|
216
232
|
code: "input/invalid-command",
|
|
@@ -228,12 +244,50 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
228
244
|
}
|
|
229
245
|
}
|
|
230
246
|
|
|
231
|
-
function
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
247
|
+
async function runRetrieveFanoutCli(
|
|
248
|
+
request: RetrieveCliArgs,
|
|
249
|
+
selectionFlags: SelectionFlags,
|
|
250
|
+
targetPositionals: readonly string[],
|
|
251
|
+
args: readonly string[],
|
|
252
|
+
): Promise<number> {
|
|
253
|
+
const selection = buildTargetSelection(selectionFlags, targetPositionals);
|
|
254
|
+
const format = inferRequestedFormat(args, request);
|
|
255
|
+
try {
|
|
256
|
+
const envelope = await retrieveFanout(
|
|
257
|
+
request,
|
|
258
|
+
selection,
|
|
259
|
+
Bun.env,
|
|
260
|
+
{},
|
|
261
|
+
{
|
|
262
|
+
concurrency: selectionFlags.concurrency,
|
|
263
|
+
allowAdhoc: true,
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
const resolvedFormat = envelope.meta.operation?.request.format ?? format;
|
|
267
|
+
console.log(
|
|
268
|
+
renderRetrieveFanoutEnvelope(envelope, resolvedFormat, {
|
|
269
|
+
verbose: request.verbose ?? false,
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
// Granular exit contract: 0 all-ok / 2 partial / 1 all-failed.
|
|
273
|
+
return fanoutExitCode(envelope);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
|
|
276
|
+
console.error(
|
|
277
|
+
// codeql[js/clear-text-logging] Twin of #80/#81/#83. No secret logged: error builders carry only targetInput (host)+via (protocol); serializeCentrsError holds code/summary/remediation/context.
|
|
278
|
+
renderRetrieveFanoutEnvelope(envelope, format, {
|
|
279
|
+
verbose: request.verbose ?? false,
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
|
|
287
|
+
const request: RetrieveCliArgs = {
|
|
235
288
|
path: "",
|
|
236
289
|
};
|
|
290
|
+
const selectionFlags = emptySelectionFlags();
|
|
237
291
|
const positional: string[] = [];
|
|
238
292
|
|
|
239
293
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -314,15 +368,6 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
|
|
|
314
368
|
case "--resolve":
|
|
315
369
|
request.resolve = expectValue(args, ++index, arg);
|
|
316
370
|
break;
|
|
317
|
-
case "--group":
|
|
318
|
-
request.group = expectValue(args, ++index, arg);
|
|
319
|
-
break;
|
|
320
|
-
case "--concurrency":
|
|
321
|
-
request.concurrency = Number.parseInt(
|
|
322
|
-
expectValue(args, ++index, arg),
|
|
323
|
-
10,
|
|
324
|
-
);
|
|
325
|
-
break;
|
|
326
371
|
case "--validate":
|
|
327
372
|
request.validate = true;
|
|
328
373
|
break;
|
|
@@ -332,35 +377,73 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
|
|
|
332
377
|
case "--verbose":
|
|
333
378
|
request.verbose = true;
|
|
334
379
|
break;
|
|
335
|
-
default:
|
|
380
|
+
default: {
|
|
381
|
+
const consumed = consumeSelectionFlag(args, index, selectionFlags);
|
|
382
|
+
if (consumed !== null) {
|
|
383
|
+
index = consumed;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
336
386
|
if (arg.startsWith("-")) {
|
|
337
|
-
throw
|
|
387
|
+
throw unknownFlagError("retrieve", arg, retrieveCommand.options);
|
|
338
388
|
}
|
|
339
389
|
positional.push(arg);
|
|
340
390
|
break;
|
|
391
|
+
}
|
|
341
392
|
}
|
|
342
393
|
}
|
|
343
394
|
|
|
395
|
+
request.selectionFlags = selectionFlags;
|
|
396
|
+
|
|
344
397
|
if (request.help) {
|
|
345
398
|
return request;
|
|
346
399
|
}
|
|
347
400
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
401
|
+
// api/retrieve positional boundary: the FINAL positional is the routeros-path;
|
|
402
|
+
// every preceding positional is a fan-out target.
|
|
403
|
+
const path = positional.at(-1) ?? "";
|
|
404
|
+
const targetPositionals = positional.slice(0, -1);
|
|
405
|
+
request.path = path;
|
|
406
|
+
request.targetPositionals = targetPositionals;
|
|
407
|
+
assertQuickchrExclusive(selectionFlags, targetPositionals.length);
|
|
408
|
+
// Direct connection overrides conflict with `--quickchr` globally: reject at
|
|
409
|
+
// parse time so a repeated `--quickchr` gets one usage error (exit 1), never
|
|
410
|
+
// per-member failures. The resolver re-checks for library callers.
|
|
411
|
+
if (selectionFlags.quickchr.length > 0) {
|
|
412
|
+
assertNoQuickchrOverrideConflict(request, selectionFlags.quickchr[0] ?? "");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
416
|
+
if (positional.length === 0 || path.length === 0) {
|
|
417
|
+
throw new CentrsError({
|
|
418
|
+
code: "input/invalid-command",
|
|
419
|
+
summary:
|
|
420
|
+
"`centrs retrieve` fan-out requires a <routeros-path> after the selectors/targets.",
|
|
421
|
+
remediation:
|
|
422
|
+
"Add the RouterOS menu path as the final positional, e.g. `centrs retrieve --group prod /system/resource`.",
|
|
423
|
+
context: { command: "retrieve", missingPath: true },
|
|
424
|
+
});
|
|
353
425
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
426
|
+
return request;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// A single `--quickchr <name>` is single-target mode: the machine name is the
|
|
430
|
+
// target (resolved from the live descriptor inside the resolver); the lone
|
|
431
|
+
// positional is the routeros-path.
|
|
432
|
+
if (selectionFlags.quickchr.length === 1) {
|
|
433
|
+
if (positional.length === 0 || path.length === 0) {
|
|
434
|
+
throw new CentrsError({
|
|
435
|
+
code: "input/invalid-command",
|
|
436
|
+
summary: "`centrs retrieve --quickchr` requires a <routeros-path>.",
|
|
437
|
+
remediation:
|
|
438
|
+
"Add the RouterOS menu path as the final positional, e.g. `centrs retrieve --quickchr lab /system/resource`.",
|
|
439
|
+
context: { command: "retrieve", missingPath: true },
|
|
440
|
+
});
|
|
358
441
|
}
|
|
359
|
-
request.
|
|
442
|
+
request.quickchr = selectionFlags.quickchr[0];
|
|
360
443
|
return request;
|
|
361
444
|
}
|
|
362
445
|
|
|
363
|
-
if (positional.length === 0) {
|
|
446
|
+
if (positional.length === 0 || targetPositionals.length === 0) {
|
|
364
447
|
throw missingTargetError({
|
|
365
448
|
command: "retrieve",
|
|
366
449
|
summary: "`centrs retrieve` requires a <target> and a <routeros-path>.",
|
|
@@ -368,14 +451,8 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
|
|
|
368
451
|
"Pass the router host/identity then the RouterOS path, e.g. `centrs retrieve 192.0.2.10 /system/resource`.",
|
|
369
452
|
});
|
|
370
453
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
"`centrs retrieve` requires a <routeros-path> after the <target>.",
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
request.targetInput = positional[0];
|
|
378
|
-
request.path = positional[1] ?? "";
|
|
454
|
+
// Single-target: exactly one target positional (more would be fan-out mode).
|
|
455
|
+
request.targetInput = targetPositionals[0];
|
|
379
456
|
return request;
|
|
380
457
|
}
|
|
381
458
|
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared target-selection flag parser.
|
|
3
|
+
*
|
|
4
|
+
* The fan-out grammar (`--group` / `--where` / `--all` / `--default` /
|
|
5
|
+
* `--concurrency`) is parsed ONCE here so every command's hand-rolled `switch`
|
|
6
|
+
* loop delegates to it instead of re-implementing the grammar. This helper owns
|
|
7
|
+
* ONLY the flag selectors — it does NOT consume positionals, because a
|
|
8
|
+
* positional means a different thing per command (endpoint / path / command /
|
|
9
|
+
* transfer verb + paths). Each command splits its own positionals into
|
|
10
|
+
* `(targetPositionals, operationArgs)` and hands the target positionals to
|
|
11
|
+
* {@link buildTargetSelection}. See `docs/CONSTITUTION.md` (Target selection).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { CentrsError } from "../errors.ts";
|
|
15
|
+
import {
|
|
16
|
+
type BboxPredicate,
|
|
17
|
+
type NearPredicate,
|
|
18
|
+
parseBbox,
|
|
19
|
+
parseNear,
|
|
20
|
+
type SelectionWhereClause,
|
|
21
|
+
type TargetSelection,
|
|
22
|
+
} from "../resolver/index.ts";
|
|
23
|
+
import {
|
|
24
|
+
type CliCommandOption,
|
|
25
|
+
expectValue,
|
|
26
|
+
parseStrictInteger,
|
|
27
|
+
} from "./common.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The shared target-selection flags, as command-help rows. Spread into each
|
|
31
|
+
* fan-out command's `options` so `<command> --help` lists them and the
|
|
32
|
+
* "did you mean?" suggester knows them — one source, so a future command can't
|
|
33
|
+
* forget them.
|
|
34
|
+
*/
|
|
35
|
+
export const selectionCommandOptions: readonly CliCommandOption[] = [
|
|
36
|
+
{
|
|
37
|
+
flag: "--group",
|
|
38
|
+
valueName: "<name>",
|
|
39
|
+
description:
|
|
40
|
+
"Fan out across every CDB record in the group (repeatable; de-duped by record index).",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
flag: "--where",
|
|
44
|
+
valueName: "<attr>=<value>",
|
|
45
|
+
description:
|
|
46
|
+
"Device-class selector over CDB facts + core fields (repeatable, AND-combined).",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
flag: "--near",
|
|
50
|
+
valueName: "<lat>,<lon>,<radius>",
|
|
51
|
+
description:
|
|
52
|
+
"Geo selector: devices whose GPS is within radius (m/km/mi/ft; bare number = km). Lat-first.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
flag: "--bbox",
|
|
56
|
+
valueName: "<south>,<west>,<north>,<east>",
|
|
57
|
+
description:
|
|
58
|
+
"Geo selector: devices whose GPS is inside the lat-first bounding box.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
flag: "--all",
|
|
62
|
+
description: "Fan out across every CDB record (excludes `__default__`).",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
flag: "--default",
|
|
66
|
+
description: "Select the reserved `__default__` record.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
flag: "--quickchr",
|
|
70
|
+
valueName: "<name>",
|
|
71
|
+
description:
|
|
72
|
+
"Target a running quickchr-managed CHR VM by name (repeatable; fans out when repeated). Exclusive of positional targets and CDB selectors; conflicts with direct connection overrides (--host/--port/--username/--password, and --ssh-key where the command has it).",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
flag: "--concurrency",
|
|
76
|
+
valueName: "<n>",
|
|
77
|
+
description:
|
|
78
|
+
"Max in-flight targets during fan-out (transport-aware default: rest-api 8, native-api 4).",
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Mutable accumulator a command threads through its parse loop. */
|
|
83
|
+
export interface SelectionFlags {
|
|
84
|
+
groups: string[];
|
|
85
|
+
where: SelectionWhereClause[];
|
|
86
|
+
near?: NearPredicate;
|
|
87
|
+
bbox?: BboxPredicate;
|
|
88
|
+
all: boolean;
|
|
89
|
+
default: boolean;
|
|
90
|
+
/** Repeatable `--quickchr <name>` (named-live-provider; exclusive in v1). */
|
|
91
|
+
quickchr: string[];
|
|
92
|
+
concurrency?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function emptySelectionFlags(): SelectionFlags {
|
|
96
|
+
return { groups: [], where: [], all: false, default: false, quickchr: [] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The selection flag tokens, for a command's `--help` table + unknown-flag suggestions. */
|
|
100
|
+
export const selectionFlagTokens = [
|
|
101
|
+
"--group",
|
|
102
|
+
"--where",
|
|
103
|
+
"--near",
|
|
104
|
+
"--bbox",
|
|
105
|
+
"--all",
|
|
106
|
+
"--default",
|
|
107
|
+
"--quickchr",
|
|
108
|
+
"--concurrency",
|
|
109
|
+
] as const;
|
|
110
|
+
|
|
111
|
+
function parseWhereClause(raw: string): SelectionWhereClause {
|
|
112
|
+
const eq = raw.indexOf("=");
|
|
113
|
+
if (eq <= 0) {
|
|
114
|
+
throw new CentrsError({
|
|
115
|
+
code: "input/invalid-command",
|
|
116
|
+
summary: `--where must be \`<attr>=<value>\`; received: ${raw}`,
|
|
117
|
+
remediation:
|
|
118
|
+
"Pass a device-class selector like `--where board=RB5009` (matches a CDB fact or core field).",
|
|
119
|
+
context: { where: raw },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return { key: raw.slice(0, eq), value: raw.slice(eq + 1) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Try to consume one selection flag at `args[index]`. Returns the new loop index
|
|
127
|
+
* when consumed (the caller sets its index to this and continues), or `null`
|
|
128
|
+
* when the arg is not a selection flag (the caller handles it). Mutates `acc`.
|
|
129
|
+
*/
|
|
130
|
+
export function consumeSelectionFlag(
|
|
131
|
+
args: readonly string[],
|
|
132
|
+
index: number,
|
|
133
|
+
acc: SelectionFlags,
|
|
134
|
+
): number | null {
|
|
135
|
+
const arg = args[index];
|
|
136
|
+
switch (arg) {
|
|
137
|
+
case "--all":
|
|
138
|
+
acc.all = true;
|
|
139
|
+
return index;
|
|
140
|
+
case "--default":
|
|
141
|
+
acc.default = true;
|
|
142
|
+
return index;
|
|
143
|
+
case "--group":
|
|
144
|
+
acc.groups.push(expectValue(args, index + 1, arg));
|
|
145
|
+
return index + 1;
|
|
146
|
+
case "--where":
|
|
147
|
+
acc.where.push(parseWhereClause(expectValue(args, index + 1, arg)));
|
|
148
|
+
return index + 1;
|
|
149
|
+
case "--near":
|
|
150
|
+
acc.near = parseNear(expectValue(args, index + 1, arg));
|
|
151
|
+
return index + 1;
|
|
152
|
+
case "--bbox":
|
|
153
|
+
acc.bbox = parseBbox(expectValue(args, index + 1, arg));
|
|
154
|
+
return index + 1;
|
|
155
|
+
case "--quickchr":
|
|
156
|
+
acc.quickchr.push(expectValue(args, index + 1, arg));
|
|
157
|
+
return index + 1;
|
|
158
|
+
case "--concurrency": {
|
|
159
|
+
const raw = expectValue(args, index + 1, arg);
|
|
160
|
+
const parsed = parseStrictInteger(raw);
|
|
161
|
+
if (parsed === undefined || parsed < 1) {
|
|
162
|
+
throw new CentrsError({
|
|
163
|
+
code: "usage/invalid-concurrency",
|
|
164
|
+
summary: `--concurrency must be an integer >= 1. Received: ${raw}`,
|
|
165
|
+
remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
|
|
166
|
+
context: { concurrency: raw },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
acc.concurrency = parsed;
|
|
170
|
+
return index + 1;
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Assemble a {@link TargetSelection} from parsed flags + the command's target positionals. */
|
|
178
|
+
export function buildTargetSelection(
|
|
179
|
+
flags: SelectionFlags,
|
|
180
|
+
positionals: readonly string[],
|
|
181
|
+
): TargetSelection {
|
|
182
|
+
return {
|
|
183
|
+
positionals,
|
|
184
|
+
groups: flags.groups,
|
|
185
|
+
all: flags.all,
|
|
186
|
+
default: flags.default,
|
|
187
|
+
where: flags.where,
|
|
188
|
+
near: flags.near,
|
|
189
|
+
bbox: flags.bbox,
|
|
190
|
+
quickchr: flags.quickchr,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** True when any CDB selector flag is present (quickchr and positionals excluded). */
|
|
195
|
+
function hasCdbSelector(flags: SelectionFlags): boolean {
|
|
196
|
+
return (
|
|
197
|
+
flags.all ||
|
|
198
|
+
flags.default ||
|
|
199
|
+
flags.groups.length > 0 ||
|
|
200
|
+
flags.where.length > 0 ||
|
|
201
|
+
flags.near !== undefined ||
|
|
202
|
+
flags.bbox !== undefined
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `--quickchr` exclusivity (v1, `docs/CONSTITUTION.md` → Resolution providers):
|
|
208
|
+
* a command targets quickchr machines OR CDB/literal targets, never both.
|
|
209
|
+
* Commands call this after splitting positionals, in both single-target and
|
|
210
|
+
* fan-out mode; relaxing it later is additive.
|
|
211
|
+
*/
|
|
212
|
+
export function assertQuickchrExclusive(
|
|
213
|
+
flags: SelectionFlags,
|
|
214
|
+
targetPositionalCount: number,
|
|
215
|
+
): void {
|
|
216
|
+
if (flags.quickchr.length === 0) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (hasCdbSelector(flags) || targetPositionalCount > 0) {
|
|
220
|
+
throw new CentrsError({
|
|
221
|
+
code: "usage/conflicting-flags",
|
|
222
|
+
summary:
|
|
223
|
+
"`--quickchr` is exclusive: a command targets quickchr machines or CDB/literal targets, not both.",
|
|
224
|
+
remediation:
|
|
225
|
+
"Drop the positional target / CDB selector (or the `--quickchr` flag) so the selection has one target source.",
|
|
226
|
+
context: {
|
|
227
|
+
quickchr: [...flags.quickchr],
|
|
228
|
+
positionals: targetPositionalCount,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* True when any target-taking flag is present, so positionals are operation
|
|
236
|
+
* args, not targets. Superset of {@link isFanoutMode}'s flag term: a single
|
|
237
|
+
* `--quickchr` names the target (like a lone positional, still single-target
|
|
238
|
+
* mode) but already claims the positional slots.
|
|
239
|
+
*/
|
|
240
|
+
export function hasTargetSelector(flags: SelectionFlags): boolean {
|
|
241
|
+
return hasCdbSelector(flags) || flags.quickchr.length > 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Fan-out mode is keyed on INTENT, not resolved count: any selector flag, more
|
|
246
|
+
* than one positional target, or a repeated `--quickchr`. A plain
|
|
247
|
+
* single-positional call — and a single `--quickchr <name>` — stays
|
|
248
|
+
* single-target (never `FanoutData`); a selector that resolves to one/zero
|
|
249
|
+
* members is still fan-out mode. See `docs/CONSTITUTION.md` (Target selection).
|
|
250
|
+
*/
|
|
251
|
+
export function isFanoutMode(
|
|
252
|
+
flags: SelectionFlags,
|
|
253
|
+
targetPositionalCount: number,
|
|
254
|
+
): boolean {
|
|
255
|
+
return (
|
|
256
|
+
hasCdbSelector(flags) ||
|
|
257
|
+
flags.quickchr.length > 1 ||
|
|
258
|
+
targetPositionalCount > 1
|
|
259
|
+
);
|
|
260
|
+
}
|