@tikoci/centrs 0.1.2 → 0.1.3
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 +59 -0
- package/GLOSSARY.txt +71 -0
- package/README.md +24 -8
- package/SECURITY.md +3 -3
- package/commands/api/README.md +27 -9
- package/commands/api/examples.md +109 -6
- package/commands/check/README.md +371 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/execute/README.md +39 -4
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +36 -50
- package/commands/retrieve/examples.md +40 -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 +32 -2
- package/commands/transfer/examples.md +66 -0
- package/docs/CONSTITUTION.md +36 -11
- package/docs/MATRIX.md +68 -172
- package/docs/errors/README.md +2 -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/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/usage/fanout-not-supported.md +16 -0
- package/package.json +6 -6
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +91 -19
- package/src/cli/api.ts +135 -11
- package/src/cli/btest.ts +20 -2
- 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 +107 -6
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +135 -90
- package/src/cli/selection.ts +200 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +171 -47
- package/src/cli.ts +7 -0
- package/src/core/error-catalog.ts +55 -0
- package/src/core/fanout.ts +143 -2
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +120 -23
- package/src/index.ts +105 -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/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 +46 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/selection.ts +304 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +20 -2
- package/src/retrieve-fanout.ts +225 -133
- package/src/retrieve.ts +39 -7
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +527 -0
- package/src/transfer.ts +61 -11
package/src/cli/retrieve.ts
CHANGED
|
@@ -4,23 +4,29 @@
|
|
|
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";
|
|
20
25
|
import {
|
|
21
26
|
type CliCommandMetadata,
|
|
22
27
|
expectValue,
|
|
23
28
|
renderCommandHelp,
|
|
29
|
+
unknownFlagError,
|
|
24
30
|
} from "./common.ts";
|
|
25
31
|
import {
|
|
26
32
|
buildTargetSelectionTips,
|
|
@@ -30,11 +36,19 @@ import {
|
|
|
30
36
|
missingTargetError,
|
|
31
37
|
withTips,
|
|
32
38
|
} from "./missing-target.ts";
|
|
39
|
+
import {
|
|
40
|
+
buildTargetSelection,
|
|
41
|
+
consumeSelectionFlag,
|
|
42
|
+
emptySelectionFlags,
|
|
43
|
+
isFanoutMode,
|
|
44
|
+
type SelectionFlags,
|
|
45
|
+
selectionCommandOptions,
|
|
46
|
+
} from "./selection.ts";
|
|
33
47
|
|
|
34
48
|
export const retrieveCommand: CliCommandMetadata = {
|
|
35
49
|
name: "retrieve",
|
|
36
50
|
usage:
|
|
37
|
-
"centrs retrieve <target> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
|
|
51
|
+
"centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <target...> <routeros-path> [flags] | centrs retrieve --group <name> <routeros-path> [flags]",
|
|
38
52
|
summary:
|
|
39
53
|
"Read RouterOS values through the shared core using the selected protocol.",
|
|
40
54
|
options: [
|
|
@@ -44,18 +58,7 @@ export const retrieveCommand: CliCommandMetadata = {
|
|
|
44
58
|
description:
|
|
45
59
|
"Pin the protocol selector. Defaults to `rest-api` for retrieve.",
|
|
46
60
|
},
|
|
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
|
-
},
|
|
61
|
+
...selectionCommandOptions,
|
|
59
62
|
{
|
|
60
63
|
flag: "--host",
|
|
61
64
|
valueName: "<host|url>",
|
|
@@ -145,37 +148,50 @@ export const retrieveCommand: CliCommandMetadata = {
|
|
|
145
148
|
],
|
|
146
149
|
};
|
|
147
150
|
|
|
151
|
+
type RetrieveCliArgs = RetrieveRequest & {
|
|
152
|
+
help?: boolean;
|
|
153
|
+
selectionFlags?: SelectionFlags;
|
|
154
|
+
targetPositionals?: readonly string[];
|
|
155
|
+
};
|
|
156
|
+
|
|
148
157
|
export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
149
|
-
let request:
|
|
158
|
+
let request: RetrieveCliArgs | undefined;
|
|
150
159
|
|
|
151
160
|
try {
|
|
152
161
|
request = parseRetrieveCliArgs(args);
|
|
153
|
-
if (
|
|
162
|
+
if (request.help) {
|
|
154
163
|
console.log(renderCommandHelp(describeCentrs(), retrieveCommand));
|
|
155
164
|
return 0;
|
|
156
165
|
}
|
|
157
166
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
// Fan-out mode (a selector flag present, or >1 positional target) has its own
|
|
168
|
+
// envelope shape and granular exit code; a plain single-target call keeps the
|
|
169
|
+
// single-target envelope.
|
|
170
|
+
const selectionFlags = request.selectionFlags ?? emptySelectionFlags();
|
|
171
|
+
const targetPositionals = request.targetPositionals ?? [];
|
|
172
|
+
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
173
|
+
return await runRetrieveFanoutCli(
|
|
174
|
+
request,
|
|
175
|
+
selectionFlags,
|
|
176
|
+
targetPositionals,
|
|
177
|
+
args,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const envelope = await retrieve(request);
|
|
161
182
|
const resolvedFormat =
|
|
162
183
|
(
|
|
163
184
|
envelope.meta.operation as {
|
|
164
185
|
request?: { format?: RetrieveOutputFormat };
|
|
165
186
|
}
|
|
166
187
|
)?.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);
|
|
188
|
+
console.log(
|
|
189
|
+
renderRetrieveEnvelope(
|
|
190
|
+
envelope as Parameters<typeof renderRetrieveEnvelope>[0],
|
|
191
|
+
resolvedFormat,
|
|
192
|
+
{ verbose: request.verbose },
|
|
193
|
+
),
|
|
194
|
+
);
|
|
179
195
|
return 0;
|
|
180
196
|
} catch (error) {
|
|
181
197
|
const format = inferRequestedFormat(args, request);
|
|
@@ -186,31 +202,23 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
186
202
|
})
|
|
187
203
|
: [];
|
|
188
204
|
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
|
-
}
|
|
205
|
+
// When parsing failed before a request existed, build the error envelope
|
|
206
|
+
// from an empty request rather than reconstructing positionals from raw
|
|
207
|
+
// args — a credential value (e.g. the token after `--password` /
|
|
208
|
+
// `--cdb-password`) would otherwise be echoed as `meta.target.input`.
|
|
209
|
+
const envelope = withTips(
|
|
210
|
+
buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
|
|
211
|
+
tips,
|
|
212
|
+
);
|
|
213
|
+
console.error(
|
|
214
|
+
// 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.
|
|
215
|
+
renderRetrieveEnvelope(envelope, format, {
|
|
216
|
+
verbose: request?.verbose ?? false,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
212
219
|
} else {
|
|
213
220
|
console.error(
|
|
221
|
+
// codeql[js/clear-text-logging] Same false-positive pattern: password parsed from args never reaches error.message in the text-format catch path.
|
|
214
222
|
formatCentrsErrorText(
|
|
215
223
|
asCentrsError(error, {
|
|
216
224
|
code: "input/invalid-command",
|
|
@@ -228,12 +236,50 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
228
236
|
}
|
|
229
237
|
}
|
|
230
238
|
|
|
231
|
-
function
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
239
|
+
async function runRetrieveFanoutCli(
|
|
240
|
+
request: RetrieveCliArgs,
|
|
241
|
+
selectionFlags: SelectionFlags,
|
|
242
|
+
targetPositionals: readonly string[],
|
|
243
|
+
args: readonly string[],
|
|
244
|
+
): Promise<number> {
|
|
245
|
+
const selection = buildTargetSelection(selectionFlags, targetPositionals);
|
|
246
|
+
const format = inferRequestedFormat(args, request);
|
|
247
|
+
try {
|
|
248
|
+
const envelope = await retrieveFanout(
|
|
249
|
+
request,
|
|
250
|
+
selection,
|
|
251
|
+
Bun.env,
|
|
252
|
+
{},
|
|
253
|
+
{
|
|
254
|
+
concurrency: selectionFlags.concurrency,
|
|
255
|
+
allowAdhoc: true,
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
const resolvedFormat = envelope.meta.operation?.request.format ?? format;
|
|
259
|
+
console.log(
|
|
260
|
+
renderRetrieveFanoutEnvelope(envelope, resolvedFormat, {
|
|
261
|
+
verbose: request.verbose ?? false,
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
// Granular exit contract: 0 all-ok / 2 partial / 1 all-failed.
|
|
265
|
+
return fanoutExitCode(envelope);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
|
|
268
|
+
console.error(
|
|
269
|
+
// 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.
|
|
270
|
+
renderRetrieveFanoutEnvelope(envelope, format, {
|
|
271
|
+
verbose: request.verbose ?? false,
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
|
|
279
|
+
const request: RetrieveCliArgs = {
|
|
235
280
|
path: "",
|
|
236
281
|
};
|
|
282
|
+
const selectionFlags = emptySelectionFlags();
|
|
237
283
|
const positional: string[] = [];
|
|
238
284
|
|
|
239
285
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -314,15 +360,6 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
|
|
|
314
360
|
case "--resolve":
|
|
315
361
|
request.resolve = expectValue(args, ++index, arg);
|
|
316
362
|
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
363
|
case "--validate":
|
|
327
364
|
request.validate = true;
|
|
328
365
|
break;
|
|
@@ -332,35 +369,49 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
|
|
|
332
369
|
case "--verbose":
|
|
333
370
|
request.verbose = true;
|
|
334
371
|
break;
|
|
335
|
-
default:
|
|
372
|
+
default: {
|
|
373
|
+
const consumed = consumeSelectionFlag(args, index, selectionFlags);
|
|
374
|
+
if (consumed !== null) {
|
|
375
|
+
index = consumed;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
336
378
|
if (arg.startsWith("-")) {
|
|
337
|
-
throw
|
|
379
|
+
throw unknownFlagError("retrieve", arg, retrieveCommand.options);
|
|
338
380
|
}
|
|
339
381
|
positional.push(arg);
|
|
340
382
|
break;
|
|
383
|
+
}
|
|
341
384
|
}
|
|
342
385
|
}
|
|
343
386
|
|
|
387
|
+
request.selectionFlags = selectionFlags;
|
|
388
|
+
|
|
344
389
|
if (request.help) {
|
|
345
390
|
return request;
|
|
346
391
|
}
|
|
347
392
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
393
|
+
// api/retrieve positional boundary: the FINAL positional is the routeros-path;
|
|
394
|
+
// every preceding positional is a fan-out target.
|
|
395
|
+
const path = positional.at(-1) ?? "";
|
|
396
|
+
const targetPositionals = positional.slice(0, -1);
|
|
397
|
+
request.path = path;
|
|
398
|
+
request.targetPositionals = targetPositionals;
|
|
399
|
+
|
|
400
|
+
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
401
|
+
if (positional.length === 0 || path.length === 0) {
|
|
402
|
+
throw new CentrsError({
|
|
403
|
+
code: "input/invalid-command",
|
|
404
|
+
summary:
|
|
405
|
+
"`centrs retrieve` fan-out requires a <routeros-path> after the selectors/targets.",
|
|
406
|
+
remediation:
|
|
407
|
+
"Add the RouterOS menu path as the final positional, e.g. `centrs retrieve --group prod /system/resource`.",
|
|
408
|
+
context: { command: "retrieve", missingPath: true },
|
|
409
|
+
});
|
|
358
410
|
}
|
|
359
|
-
request.path = positional[0] ?? "";
|
|
360
411
|
return request;
|
|
361
412
|
}
|
|
362
413
|
|
|
363
|
-
if (positional.length === 0) {
|
|
414
|
+
if (positional.length === 0 || targetPositionals.length === 0) {
|
|
364
415
|
throw missingTargetError({
|
|
365
416
|
command: "retrieve",
|
|
366
417
|
summary: "`centrs retrieve` requires a <target> and a <routeros-path>.",
|
|
@@ -368,14 +419,8 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
|
|
|
368
419
|
"Pass the router host/identity then the RouterOS path, e.g. `centrs retrieve 192.0.2.10 /system/resource`.",
|
|
369
420
|
});
|
|
370
421
|
}
|
|
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] ?? "";
|
|
422
|
+
// Single-target: exactly one target positional (more would be fan-out mode).
|
|
423
|
+
request.targetInput = targetPositionals[0];
|
|
379
424
|
return request;
|
|
380
425
|
}
|
|
381
426
|
|
|
@@ -0,0 +1,200 @@
|
|
|
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: "--concurrency",
|
|
70
|
+
valueName: "<n>",
|
|
71
|
+
description:
|
|
72
|
+
"Max in-flight targets during fan-out (transport-aware default: rest-api 8, native-api 4).",
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** Mutable accumulator a command threads through its parse loop. */
|
|
77
|
+
export interface SelectionFlags {
|
|
78
|
+
groups: string[];
|
|
79
|
+
where: SelectionWhereClause[];
|
|
80
|
+
near?: NearPredicate;
|
|
81
|
+
bbox?: BboxPredicate;
|
|
82
|
+
all: boolean;
|
|
83
|
+
default: boolean;
|
|
84
|
+
concurrency?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function emptySelectionFlags(): SelectionFlags {
|
|
88
|
+
return { groups: [], where: [], all: false, default: false };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The selection flag tokens, for a command's `--help` table + unknown-flag suggestions. */
|
|
92
|
+
export const selectionFlagTokens = [
|
|
93
|
+
"--group",
|
|
94
|
+
"--where",
|
|
95
|
+
"--near",
|
|
96
|
+
"--bbox",
|
|
97
|
+
"--all",
|
|
98
|
+
"--default",
|
|
99
|
+
"--concurrency",
|
|
100
|
+
] as const;
|
|
101
|
+
|
|
102
|
+
function parseWhereClause(raw: string): SelectionWhereClause {
|
|
103
|
+
const eq = raw.indexOf("=");
|
|
104
|
+
if (eq <= 0) {
|
|
105
|
+
throw new CentrsError({
|
|
106
|
+
code: "input/invalid-command",
|
|
107
|
+
summary: `--where must be \`<attr>=<value>\`; received: ${raw}`,
|
|
108
|
+
remediation:
|
|
109
|
+
"Pass a device-class selector like `--where board=RB5009` (matches a CDB fact or core field).",
|
|
110
|
+
context: { where: raw },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return { key: raw.slice(0, eq), value: raw.slice(eq + 1) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Try to consume one selection flag at `args[index]`. Returns the new loop index
|
|
118
|
+
* when consumed (the caller sets its index to this and continues), or `null`
|
|
119
|
+
* when the arg is not a selection flag (the caller handles it). Mutates `acc`.
|
|
120
|
+
*/
|
|
121
|
+
export function consumeSelectionFlag(
|
|
122
|
+
args: readonly string[],
|
|
123
|
+
index: number,
|
|
124
|
+
acc: SelectionFlags,
|
|
125
|
+
): number | null {
|
|
126
|
+
const arg = args[index];
|
|
127
|
+
switch (arg) {
|
|
128
|
+
case "--all":
|
|
129
|
+
acc.all = true;
|
|
130
|
+
return index;
|
|
131
|
+
case "--default":
|
|
132
|
+
acc.default = true;
|
|
133
|
+
return index;
|
|
134
|
+
case "--group":
|
|
135
|
+
acc.groups.push(expectValue(args, index + 1, arg));
|
|
136
|
+
return index + 1;
|
|
137
|
+
case "--where":
|
|
138
|
+
acc.where.push(parseWhereClause(expectValue(args, index + 1, arg)));
|
|
139
|
+
return index + 1;
|
|
140
|
+
case "--near":
|
|
141
|
+
acc.near = parseNear(expectValue(args, index + 1, arg));
|
|
142
|
+
return index + 1;
|
|
143
|
+
case "--bbox":
|
|
144
|
+
acc.bbox = parseBbox(expectValue(args, index + 1, arg));
|
|
145
|
+
return index + 1;
|
|
146
|
+
case "--concurrency": {
|
|
147
|
+
const raw = expectValue(args, index + 1, arg);
|
|
148
|
+
const parsed = parseStrictInteger(raw);
|
|
149
|
+
if (parsed === undefined || parsed < 1) {
|
|
150
|
+
throw new CentrsError({
|
|
151
|
+
code: "usage/invalid-concurrency",
|
|
152
|
+
summary: `--concurrency must be an integer >= 1. Received: ${raw}`,
|
|
153
|
+
remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
|
|
154
|
+
context: { concurrency: raw },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
acc.concurrency = parsed;
|
|
158
|
+
return index + 1;
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Assemble a {@link TargetSelection} from parsed flags + the command's target positionals. */
|
|
166
|
+
export function buildTargetSelection(
|
|
167
|
+
flags: SelectionFlags,
|
|
168
|
+
positionals: readonly string[],
|
|
169
|
+
): TargetSelection {
|
|
170
|
+
return {
|
|
171
|
+
positionals,
|
|
172
|
+
groups: flags.groups,
|
|
173
|
+
all: flags.all,
|
|
174
|
+
default: flags.default,
|
|
175
|
+
where: flags.where,
|
|
176
|
+
near: flags.near,
|
|
177
|
+
bbox: flags.bbox,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Fan-out mode is keyed on INTENT, not resolved count: any selector flag, or more
|
|
183
|
+
* than one positional target. A plain single-positional call stays single-target
|
|
184
|
+
* (never `FanoutData`); a selector that resolves to one/zero members is still
|
|
185
|
+
* fan-out mode. See `docs/CONSTITUTION.md` (Target selection).
|
|
186
|
+
*/
|
|
187
|
+
export function isFanoutMode(
|
|
188
|
+
flags: SelectionFlags,
|
|
189
|
+
targetPositionalCount: number,
|
|
190
|
+
): boolean {
|
|
191
|
+
return (
|
|
192
|
+
flags.all ||
|
|
193
|
+
flags.default ||
|
|
194
|
+
flags.groups.length > 0 ||
|
|
195
|
+
flags.where.length > 0 ||
|
|
196
|
+
flags.near !== undefined ||
|
|
197
|
+
flags.bbox !== undefined ||
|
|
198
|
+
targetPositionalCount > 1
|
|
199
|
+
);
|
|
200
|
+
}
|