@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/transfer.ts
CHANGED
|
@@ -6,23 +6,29 @@
|
|
|
6
6
|
* `../cli.ts`. Mirrors the shape of `./retrieve.ts`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { fanoutExitCode } from "../core/fanout.ts";
|
|
9
10
|
import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
|
|
10
11
|
import {
|
|
11
12
|
buildTransferErrorEnvelope,
|
|
13
|
+
buildTransferFanoutErrorEnvelope,
|
|
12
14
|
CentrsError,
|
|
13
15
|
describeCentrs,
|
|
14
16
|
renderTransferEnvelope,
|
|
17
|
+
renderTransferFanoutEnvelope,
|
|
15
18
|
type TransferOutputFormat,
|
|
16
19
|
type TransferRequest,
|
|
17
20
|
type TransferVerb,
|
|
18
21
|
transfer,
|
|
22
|
+
transferFanout,
|
|
19
23
|
transferOutputFormats,
|
|
20
24
|
transferVerbs,
|
|
21
25
|
} from "../index.ts";
|
|
26
|
+
import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
|
|
22
27
|
import {
|
|
23
28
|
type CliCommandMetadata,
|
|
24
29
|
expectValue,
|
|
25
30
|
renderCommandHelp,
|
|
31
|
+
unknownFlagError,
|
|
26
32
|
} from "./common.ts";
|
|
27
33
|
import {
|
|
28
34
|
buildTargetSelectionTips,
|
|
@@ -32,6 +38,15 @@ import {
|
|
|
32
38
|
missingTargetError,
|
|
33
39
|
withTips,
|
|
34
40
|
} from "./missing-target.ts";
|
|
41
|
+
import {
|
|
42
|
+
assertQuickchrExclusive,
|
|
43
|
+
buildTargetSelection,
|
|
44
|
+
consumeSelectionFlag,
|
|
45
|
+
emptySelectionFlags,
|
|
46
|
+
isFanoutMode,
|
|
47
|
+
type SelectionFlags,
|
|
48
|
+
selectionCommandOptions,
|
|
49
|
+
} from "./selection.ts";
|
|
35
50
|
|
|
36
51
|
/** Sub-verb aliases, resolved to canonical verbs silently (commands/AGENTS.md). */
|
|
37
52
|
const VERB_ALIASES: Record<string, TransferVerb> = {
|
|
@@ -69,15 +84,26 @@ export const transferCommand: CliCommandMetadata = {
|
|
|
69
84
|
"Accept a self-signed TLS cert (https/api-ssl) or a new SSH host key. Default verifies.",
|
|
70
85
|
},
|
|
71
86
|
{
|
|
72
|
-
flag: "--force",
|
|
73
|
-
valueName: "(--overwrite)",
|
|
87
|
+
flag: "--force / --overwrite",
|
|
74
88
|
description: "Replace an existing destination. Default refuses it.",
|
|
75
89
|
},
|
|
90
|
+
...selectionCommandOptions,
|
|
91
|
+
{
|
|
92
|
+
flag: "--out-dir",
|
|
93
|
+
valueName: "<dir>",
|
|
94
|
+
description:
|
|
95
|
+
"`download` fan-out only: write one file per target into <dir>, named by CDB identity. Required when downloading across a selection.",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
flag: "--yes",
|
|
99
|
+
description:
|
|
100
|
+
"Confirm a mutating fan-out (upload/remove/mkdir/copy) across multiple routers in non-interactive runs.",
|
|
101
|
+
},
|
|
76
102
|
{
|
|
77
|
-
flag: "--verify",
|
|
103
|
+
flag: "--verify / --no-verify",
|
|
78
104
|
valueName: "<size|checksum|off>",
|
|
79
105
|
description:
|
|
80
|
-
"Post-transfer integrity check. Default size;
|
|
106
|
+
"Post-transfer integrity check. Default size; `--no-verify` skips.",
|
|
81
107
|
},
|
|
82
108
|
{
|
|
83
109
|
flag: "--type",
|
|
@@ -122,6 +148,7 @@ export const transferCommand: CliCommandMetadata = {
|
|
|
122
148
|
"Output format. Defaults to text; --json / --yaml shortcuts.",
|
|
123
149
|
},
|
|
124
150
|
{ flag: "--json", description: "Shortcut for --format json." },
|
|
151
|
+
{ flag: "--yaml", description: "Shortcut for --format yaml." },
|
|
125
152
|
{
|
|
126
153
|
flag: "--cdb-file",
|
|
127
154
|
valueName: "<path>",
|
|
@@ -159,6 +186,17 @@ export async function runTransferCli(
|
|
|
159
186
|
console.log(renderCommandHelp(describeCentrs(), transferCommand));
|
|
160
187
|
return 0;
|
|
161
188
|
}
|
|
189
|
+
|
|
190
|
+
// Fan-out mode (a selector flag, or >1 positional target before the verb)
|
|
191
|
+
// has its own envelope shape, single up-front write-confirm, and exit code.
|
|
192
|
+
if (isFanoutMode(parsed.selectionFlags, parsed.targetPositionals.length)) {
|
|
193
|
+
return await runTransferFanoutCli(
|
|
194
|
+
parsed.request,
|
|
195
|
+
parsed.selectionFlags,
|
|
196
|
+
parsed.targetPositionals,
|
|
197
|
+
args,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
162
200
|
request = parsed.request;
|
|
163
201
|
|
|
164
202
|
const envelope = await transfer(request);
|
|
@@ -196,12 +234,14 @@ export async function runTransferCli(
|
|
|
196
234
|
tips,
|
|
197
235
|
);
|
|
198
236
|
console.error(
|
|
237
|
+
// codeql[js/clear-text-logging] The transfer error path redacts password/cdbPassword before any renderer (redactTransferRequest), and buildTransferErrorEnvelope only emits targetInput.
|
|
199
238
|
renderTransferEnvelope(envelope, format, {
|
|
200
239
|
verbose: safeRequest?.verbose ?? false,
|
|
201
240
|
}),
|
|
202
241
|
);
|
|
203
242
|
} else {
|
|
204
243
|
console.error(
|
|
244
|
+
// codeql[js/clear-text-logging] Credentials are redacted from the request before rendering (redactTransferRequest) and never reach the error/stderr; same known false positive dismissed across all CLI commands.
|
|
205
245
|
formatCentrsErrorText(
|
|
206
246
|
asCentrsError(error, {
|
|
207
247
|
code: "input/invalid-command",
|
|
@@ -217,6 +257,46 @@ export async function runTransferCli(
|
|
|
217
257
|
}
|
|
218
258
|
}
|
|
219
259
|
|
|
260
|
+
async function runTransferFanoutCli(
|
|
261
|
+
request: TransferRequest,
|
|
262
|
+
selectionFlags: SelectionFlags,
|
|
263
|
+
targetPositionals: readonly string[],
|
|
264
|
+
args: readonly string[],
|
|
265
|
+
): Promise<number> {
|
|
266
|
+
const selection = buildTargetSelection(selectionFlags, targetPositionals);
|
|
267
|
+
const format = inferRequestedFormat(args, request);
|
|
268
|
+
try {
|
|
269
|
+
const envelope = await transferFanout(
|
|
270
|
+
{ ...request, stdinIsTty: process.stdin.isTTY },
|
|
271
|
+
selection,
|
|
272
|
+
Bun.env,
|
|
273
|
+
{},
|
|
274
|
+
{ concurrency: selectionFlags.concurrency, allowAdhoc: true },
|
|
275
|
+
);
|
|
276
|
+
console.log(
|
|
277
|
+
renderTransferFanoutEnvelope(envelope, format, {
|
|
278
|
+
verbose: request.verbose ?? false,
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
// Granular exit contract: 0 all-ok / 2 partial / 1 all-failed.
|
|
282
|
+
return fanoutExitCode(envelope);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
// Strip credentials before any renderer touches the request (stderr/CI logs):
|
|
285
|
+
// pass an explicitly redacted request, with no raw-request fallback path.
|
|
286
|
+
const envelope = buildTransferFanoutErrorEnvelope(
|
|
287
|
+
{ ...request, password: undefined, cdbPassword: undefined },
|
|
288
|
+
error,
|
|
289
|
+
);
|
|
290
|
+
console.error(
|
|
291
|
+
// codeql[js/clear-text-logging] No secret logged: request is redactTransferRequest'd (password/cdbPassword stripped) before the builder; fan-out error meta is {target:{},via,settings:{}}.
|
|
292
|
+
renderTransferFanoutEnvelope(envelope, format, {
|
|
293
|
+
verbose: request.verbose ?? false,
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
220
300
|
/**
|
|
221
301
|
* Strip credential fields before a failed request is handed to the error
|
|
222
302
|
* envelope/text renderers. Those outputs land on stderr (and CI logs), so the
|
|
@@ -237,13 +317,16 @@ function redactTransferRequest(
|
|
|
237
317
|
interface ParsedTransfer {
|
|
238
318
|
help?: boolean;
|
|
239
319
|
request: TransferRequest;
|
|
320
|
+
selectionFlags: SelectionFlags;
|
|
321
|
+
targetPositionals: readonly string[];
|
|
240
322
|
}
|
|
241
323
|
|
|
242
|
-
function parseTransferCliArgs(
|
|
324
|
+
export function parseTransferCliArgs(
|
|
243
325
|
args: readonly string[],
|
|
244
326
|
fixedVerb?: TransferVerb,
|
|
245
327
|
): ParsedTransfer {
|
|
246
328
|
const flags: Partial<TransferRequest> & { verbose?: boolean } = {};
|
|
329
|
+
const selectionFlags = emptySelectionFlags();
|
|
247
330
|
const positional: string[] = [];
|
|
248
331
|
let help = false;
|
|
249
332
|
// `--verify <mode>` and `--no-verify` both map to the single `verify` field,
|
|
@@ -326,28 +409,39 @@ function parseTransferCliArgs(
|
|
|
326
409
|
case "--no-validate":
|
|
327
410
|
flags.validate = false;
|
|
328
411
|
break;
|
|
412
|
+
case "--out-dir":
|
|
413
|
+
flags.outDir = expectValue(args, ++index, arg);
|
|
414
|
+
break;
|
|
415
|
+
case "--yes":
|
|
416
|
+
flags.yes = true;
|
|
417
|
+
break;
|
|
329
418
|
case "--verbose":
|
|
330
419
|
flags.verbose = true;
|
|
331
420
|
break;
|
|
332
|
-
default:
|
|
421
|
+
default: {
|
|
422
|
+
const consumed = consumeSelectionFlag(args, index, selectionFlags);
|
|
423
|
+
if (consumed !== null) {
|
|
424
|
+
index = consumed;
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
333
427
|
// A lone `-` is the stdin/stdout positional (upload from / download
|
|
334
428
|
// to a pipe), not a flag.
|
|
335
429
|
if (arg !== "-" && arg.startsWith("-")) {
|
|
336
|
-
throw
|
|
337
|
-
code: "input/invalid-command",
|
|
338
|
-
summary: `Unknown transfer flag: ${arg}`,
|
|
339
|
-
remediation:
|
|
340
|
-
"Remove the flag or run `centrs transfer --help` for the supported options.",
|
|
341
|
-
context: { flag: arg },
|
|
342
|
-
});
|
|
430
|
+
throw unknownFlagError("transfer", arg, transferCommand.options);
|
|
343
431
|
}
|
|
344
432
|
positional.push(arg);
|
|
345
433
|
break;
|
|
434
|
+
}
|
|
346
435
|
}
|
|
347
436
|
}
|
|
348
437
|
|
|
349
438
|
if (help) {
|
|
350
|
-
return {
|
|
439
|
+
return {
|
|
440
|
+
help: true,
|
|
441
|
+
request: { verb: fixedVerb ?? "list" },
|
|
442
|
+
selectionFlags,
|
|
443
|
+
targetPositionals: [],
|
|
444
|
+
};
|
|
351
445
|
}
|
|
352
446
|
|
|
353
447
|
if (sawNoVerify && explicitVerify !== undefined && explicitVerify !== "off") {
|
|
@@ -359,67 +453,116 @@ function parseTransferCliArgs(
|
|
|
359
453
|
});
|
|
360
454
|
}
|
|
361
455
|
|
|
362
|
-
const request = assemblePositionals(
|
|
363
|
-
|
|
456
|
+
const { request, targetPositionals } = assemblePositionals(
|
|
457
|
+
positional,
|
|
458
|
+
flags,
|
|
459
|
+
selectionFlags,
|
|
460
|
+
fixedVerb,
|
|
461
|
+
);
|
|
462
|
+
return { request, selectionFlags, targetPositionals };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Whether a positional token is a transfer verb keyword (canonical or alias). */
|
|
466
|
+
function isTransferVerbToken(token: string): boolean {
|
|
467
|
+
return transferVerbs.includes(token as TransferVerb) || token in VERB_ALIASES;
|
|
364
468
|
}
|
|
365
469
|
|
|
366
470
|
/**
|
|
367
|
-
* Map positionals to the per-verb request shape
|
|
368
|
-
*
|
|
369
|
-
*
|
|
471
|
+
* Map positionals to the per-verb request shape and split fan-out targets. The
|
|
472
|
+
* boundary is the **verb keyword**: positionals before it are targets, the verb +
|
|
473
|
+
* paths follow. The top-level `upload`/`download` alias fixes the verb, so its
|
|
474
|
+
* positionals are paths; with a selector present, positional fan-out targets are
|
|
475
|
+
* not expressible there (fan-out comes from the selector). See
|
|
476
|
+
* `docs/CONSTITUTION.md` (Target selection).
|
|
370
477
|
*/
|
|
371
478
|
function assemblePositionals(
|
|
372
479
|
positional: readonly string[],
|
|
373
480
|
flags: Partial<TransferRequest> & { verbose?: boolean },
|
|
481
|
+
selectionFlags: SelectionFlags,
|
|
374
482
|
fixedVerb?: TransferVerb,
|
|
375
|
-
): TransferRequest {
|
|
376
|
-
|
|
483
|
+
): { request: TransferRequest; targetPositionals: readonly string[] } {
|
|
484
|
+
// Any target-taking flag (CDB selector or `--quickchr`) claims the target
|
|
485
|
+
// slots, so every positional belongs to the verb/paths.
|
|
486
|
+
const hasSelector =
|
|
487
|
+
isFanoutMode(selectionFlags, 0) || selectionFlags.quickchr.length > 0;
|
|
377
488
|
let verb: TransferVerb;
|
|
489
|
+
let targetPositionals: readonly string[];
|
|
378
490
|
let rest: readonly string[];
|
|
379
491
|
|
|
380
492
|
if (fixedVerb) {
|
|
381
|
-
targetInput = positional[0];
|
|
382
493
|
verb = fixedVerb;
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
throw missingTargetError({
|
|
391
|
-
command: "transfer",
|
|
392
|
-
summary:
|
|
393
|
-
"`centrs transfer` requires a <router> target and a verb (upload|download|list|remove|mkdir|copy).",
|
|
394
|
-
remediation:
|
|
395
|
-
"Pass the target then a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
|
|
396
|
-
});
|
|
494
|
+
if (hasSelector) {
|
|
495
|
+
// alias + selector: targets come from the selector; positionals are paths.
|
|
496
|
+
targetPositionals = [];
|
|
497
|
+
rest = positional;
|
|
498
|
+
} else {
|
|
499
|
+
targetPositionals = positional[0] !== undefined ? [positional[0]] : [];
|
|
500
|
+
rest = positional.slice(1);
|
|
397
501
|
}
|
|
398
|
-
|
|
502
|
+
} else {
|
|
503
|
+
const verbIndex = positional.findIndex(isTransferVerbToken);
|
|
504
|
+
if (verbIndex === -1) {
|
|
505
|
+
if (positional.length === 0 && !hasSelector) {
|
|
506
|
+
throw missingTargetError({
|
|
507
|
+
command: "transfer",
|
|
508
|
+
summary:
|
|
509
|
+
"`centrs transfer` requires a <router> target and a verb (upload|download|list|remove|mkdir|copy).",
|
|
510
|
+
remediation:
|
|
511
|
+
"Pass the target then a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
|
|
512
|
+
});
|
|
513
|
+
}
|
|
399
514
|
throw new CentrsError({
|
|
400
515
|
code: "input/invalid-command",
|
|
401
516
|
summary:
|
|
402
|
-
"`centrs transfer` requires a verb
|
|
517
|
+
"`centrs transfer` requires a verb (upload|download|list|remove|mkdir|copy).",
|
|
403
518
|
remediation:
|
|
404
519
|
"Pass a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
|
|
405
|
-
context: {
|
|
520
|
+
context: { positionals: positional },
|
|
406
521
|
});
|
|
407
522
|
}
|
|
408
|
-
|
|
409
|
-
|
|
523
|
+
targetPositionals = positional.slice(0, verbIndex);
|
|
524
|
+
verb = canonicalVerb(positional[verbIndex] as string);
|
|
525
|
+
rest = positional.slice(verbIndex + 1);
|
|
410
526
|
}
|
|
411
527
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
528
|
+
const request: TransferRequest = { verb, ...flags };
|
|
529
|
+
applyVerbPaths(request, verb, rest);
|
|
530
|
+
assertQuickchrExclusive(selectionFlags, targetPositionals.length);
|
|
531
|
+
// Direct connection overrides conflict with `--quickchr` globally: reject at
|
|
532
|
+
// parse time so a repeated `--quickchr` gets one usage error (exit 1), never
|
|
533
|
+
// per-member failures. The resolver re-checks for library callers.
|
|
534
|
+
if (selectionFlags.quickchr.length > 0) {
|
|
535
|
+
assertNoQuickchrOverrideConflict(request, selectionFlags.quickchr[0] ?? "");
|
|
419
536
|
}
|
|
420
537
|
|
|
421
|
-
|
|
538
|
+
// Single-target (not fan-out mode) needs exactly one resolved target: one
|
|
539
|
+
// positional, or one `--quickchr <name>` (the named-live-provider).
|
|
540
|
+
if (!isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
541
|
+
if (selectionFlags.quickchr.length === 1) {
|
|
542
|
+
request.quickchr = selectionFlags.quickchr[0];
|
|
543
|
+
return { request, targetPositionals };
|
|
544
|
+
}
|
|
545
|
+
const targetInput = targetPositionals[0];
|
|
546
|
+
if (!targetInput) {
|
|
547
|
+
throw missingTargetError({
|
|
548
|
+
command: "transfer",
|
|
549
|
+
summary: "`centrs transfer` requires a <router> target.",
|
|
550
|
+
remediation:
|
|
551
|
+
"Pass the router host/identity as the first argument; run `centrs transfer --help` for the command shape.",
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
request.targetInput = targetInput;
|
|
555
|
+
}
|
|
422
556
|
|
|
557
|
+
return { request, targetPositionals };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Map the post-verb positionals onto the per-verb request fields. */
|
|
561
|
+
function applyVerbPaths(
|
|
562
|
+
request: TransferRequest,
|
|
563
|
+
verb: TransferVerb,
|
|
564
|
+
rest: readonly string[],
|
|
565
|
+
): void {
|
|
423
566
|
switch (verb) {
|
|
424
567
|
case "upload":
|
|
425
568
|
request.local = rest[0];
|
|
@@ -441,8 +584,6 @@ function assemblePositionals(
|
|
|
441
584
|
request.remoteDest = rest[1];
|
|
442
585
|
break;
|
|
443
586
|
}
|
|
444
|
-
|
|
445
|
-
return request;
|
|
446
587
|
}
|
|
447
588
|
|
|
448
589
|
function canonicalVerb(raw: string): TransferVerb {
|
package/src/cli.ts
CHANGED
|
@@ -2,27 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
import { apiCommand, runApiCli } from "./cli/api.ts";
|
|
4
4
|
import { btestCommand, runBtestCli } from "./cli/btest.ts";
|
|
5
|
+
import type { CliCommandMetadata } from "./cli/common.ts";
|
|
5
6
|
import { devicesCommand, runDevicesCli } from "./cli/devices.ts";
|
|
6
7
|
import { discoverCommand, runDiscoverCli } from "./cli/discover.ts";
|
|
7
8
|
import { executeCommand, runExecuteCli } from "./cli/execute.ts";
|
|
9
|
+
import { explainCliCommand, runExplainCli } from "./cli/explain.ts";
|
|
8
10
|
import { mcpCommand, runMcpCli } from "./cli/mcp.ts";
|
|
9
11
|
import { retrieveCommand, runRetrieveCli } from "./cli/retrieve.ts";
|
|
12
|
+
import { runSettingsCli, settingsCommand } from "./cli/settings.ts";
|
|
10
13
|
import { runTerminalCli, terminalCommand } from "./cli/terminal.ts";
|
|
11
14
|
import { runTransferCli, transferCommand } from "./cli/transfer.ts";
|
|
12
15
|
import { asCentrsError, formatCentrsErrorText } from "./errors.ts";
|
|
13
16
|
import { describeCentrs, plannedProtocols, plannedSurfaces } from "./index.ts";
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
/** Every CLI command's metadata, in help/docs order. `scripts/gen-cli-docs.ts` renders `docs/CLI.md` from this. */
|
|
19
|
+
export const cliCommands: ReadonlyArray<CliCommandMetadata> = [
|
|
16
20
|
retrieveCommand,
|
|
17
21
|
executeCommand,
|
|
18
22
|
apiCommand,
|
|
19
23
|
transferCommand,
|
|
20
24
|
terminalCommand,
|
|
25
|
+
explainCliCommand,
|
|
21
26
|
devicesCommand,
|
|
22
27
|
discoverCommand,
|
|
23
28
|
btestCommand,
|
|
24
29
|
mcpCommand,
|
|
25
|
-
|
|
30
|
+
settingsCommand,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const commandSummaries: ReadonlyArray<{ name: string; summary: string }> =
|
|
34
|
+
cliCommands.map((command) => ({
|
|
35
|
+
name: command.name,
|
|
36
|
+
summary: command.summary,
|
|
37
|
+
}));
|
|
26
38
|
|
|
27
39
|
export function renderCliHelp(): string {
|
|
28
40
|
return [
|
|
@@ -73,6 +85,9 @@ export async function runCli(
|
|
|
73
85
|
if (command === "download") {
|
|
74
86
|
return await runTransferCli(rest, { fixedVerb: "download" });
|
|
75
87
|
}
|
|
88
|
+
if (command === "explain") {
|
|
89
|
+
return await runExplainCli(rest);
|
|
90
|
+
}
|
|
76
91
|
if (command === "devices") {
|
|
77
92
|
return await runDevicesCli(rest);
|
|
78
93
|
}
|
|
@@ -85,6 +100,9 @@ export async function runCli(
|
|
|
85
100
|
if (command === "mcp") {
|
|
86
101
|
return await runMcpCli(rest);
|
|
87
102
|
}
|
|
103
|
+
if (command === "settings") {
|
|
104
|
+
return await runSettingsCli(rest);
|
|
105
|
+
}
|
|
88
106
|
// `stream` (and the `tail` shorthand) folded into `api --stream`.
|
|
89
107
|
if (command === "stream" || command === "tail") {
|
|
90
108
|
console.error(
|
|
@@ -104,6 +122,7 @@ export async function runCli(
|
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
console.error(
|
|
125
|
+
// codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
|
|
107
126
|
formatCentrsErrorText(
|
|
108
127
|
asCentrsError(new Error(`Unknown centrs command: ${command}`), {
|
|
109
128
|
code: "input/invalid-command",
|
|
@@ -118,6 +137,7 @@ export async function runCli(
|
|
|
118
137
|
// to a structured envelope, so reaching here means an unconverted throw
|
|
119
138
|
// escaped. Never let a raw stack trace reach the user.
|
|
120
139
|
console.error(
|
|
140
|
+
// codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
|
|
121
141
|
formatCentrsErrorText(
|
|
122
142
|
asCentrsError(error, {
|
|
123
143
|
code: "internal/unhandled",
|
package/src/core/envelope.ts
CHANGED
|
@@ -27,7 +27,9 @@ import type { RouterOsProtocol } from "../protocols/index.ts";
|
|
|
27
27
|
/**
|
|
28
28
|
* Where a resolved setting/identity field came from. Precedence (low → high)
|
|
29
29
|
* is config < comment-kv < env < cli; `cdb` and `arp` describe identity
|
|
30
|
-
* resolution rather than settings precedence
|
|
30
|
+
* resolution rather than settings precedence, and `provider` marks a field a
|
|
31
|
+
* named-live-provider supplied (e.g. `--quickchr`), which bypasses the ladder
|
|
32
|
+
* entirely (`docs/CONSTITUTION.md` → Resolution providers).
|
|
31
33
|
*/
|
|
32
34
|
export type SettingSourceKind =
|
|
33
35
|
| "default"
|
|
@@ -36,7 +38,8 @@ export type SettingSourceKind =
|
|
|
36
38
|
| "env"
|
|
37
39
|
| "cli"
|
|
38
40
|
| "cdb"
|
|
39
|
-
| "arp"
|
|
41
|
+
| "arp"
|
|
42
|
+
| "provider";
|
|
40
43
|
|
|
41
44
|
export interface SettingSource {
|
|
42
45
|
kind: SettingSourceKind;
|
|
@@ -53,6 +53,10 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
53
53
|
code: "cdb/empty-group",
|
|
54
54
|
summary: "The named CDB group contains no records.",
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
code: "cdb/empty-selection",
|
|
58
|
+
summary: "The target selection matched no CDB records.",
|
|
59
|
+
},
|
|
56
60
|
{
|
|
57
61
|
code: "cdb/invalid-option",
|
|
58
62
|
summary: "A comment kv-soup option carried an invalid value.",
|
|
@@ -129,10 +133,27 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
129
133
|
},
|
|
130
134
|
|
|
131
135
|
// input/*
|
|
136
|
+
{
|
|
137
|
+
code: "input/incomplete-gps",
|
|
138
|
+
summary: "lat/lon (or a --gps tuple) were not given as a complete pair.",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
code: "input/invalid-altitude",
|
|
142
|
+
summary: "The altitude is not a number, or altitude-type is not MSL/AGL.",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
code: "input/invalid-bbox",
|
|
146
|
+
summary:
|
|
147
|
+
"The --bbox is not four lat-first corners with south<=north and west<=east.",
|
|
148
|
+
},
|
|
132
149
|
{
|
|
133
150
|
code: "input/invalid-command",
|
|
134
151
|
summary: "The request shape was invalid (bad or missing arguments).",
|
|
135
152
|
},
|
|
153
|
+
{
|
|
154
|
+
code: "input/invalid-coordinate",
|
|
155
|
+
summary: "The latitude or longitude is not a number in its valid range.",
|
|
156
|
+
},
|
|
136
157
|
{
|
|
137
158
|
code: "input/invalid-match",
|
|
138
159
|
summary:
|
|
@@ -143,6 +164,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
143
164
|
summary:
|
|
144
165
|
"A file path contains a character the transport cannot quote (e.g. a quote or newline).",
|
|
145
166
|
},
|
|
167
|
+
{
|
|
168
|
+
code: "input/invalid-radius",
|
|
169
|
+
summary:
|
|
170
|
+
"The --near radius is not a number with an optional m/km/mi/ft suffix.",
|
|
171
|
+
},
|
|
146
172
|
{
|
|
147
173
|
code: "input/invalid-routeros-path",
|
|
148
174
|
summary: "The RouterOS path is malformed (it must be slash-prefixed).",
|
|
@@ -181,6 +207,10 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
181
207
|
code: "internal/native-api-length",
|
|
182
208
|
summary: "A native-API word length could not be encoded.",
|
|
183
209
|
},
|
|
210
|
+
{
|
|
211
|
+
code: "internal/settings-failed",
|
|
212
|
+
summary: "The settings command failed with an unexpected internal error.",
|
|
213
|
+
},
|
|
184
214
|
{
|
|
185
215
|
code: "internal/unhandled",
|
|
186
216
|
summary: "An unexpected internal error occurred (this is a centrs bug).",
|
|
@@ -204,6 +234,31 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
204
234
|
summary: "A received MNDP datagram could not be decoded.",
|
|
205
235
|
},
|
|
206
236
|
|
|
237
|
+
// quickchr/*
|
|
238
|
+
{
|
|
239
|
+
code: "quickchr/package-unavailable",
|
|
240
|
+
summary: "The optional `@tikoci/quickchr` package is not installed.",
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
code: "quickchr/unsupported",
|
|
244
|
+
summary:
|
|
245
|
+
"The installed `@tikoci/quickchr` is too old or speaks a newer descriptor version than centrs understands.",
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
code: "quickchr/machine-not-found",
|
|
249
|
+
summary: "No quickchr machine is registered under that name.",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
code: "quickchr/machine-stopped",
|
|
253
|
+
summary:
|
|
254
|
+
"The quickchr machine is not running, so it has no live connection facts.",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
code: "quickchr/unsupported-via",
|
|
258
|
+
summary:
|
|
259
|
+
"The chosen `--via` has no usable service on the quickchr machine (not forwarded, unavailable, or SSH without batch-capable auth).",
|
|
260
|
+
},
|
|
261
|
+
|
|
207
262
|
// routeros/*
|
|
208
263
|
{
|
|
209
264
|
code: "routeros/api-fatal",
|
|
@@ -293,8 +348,18 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
293
348
|
code: "routeros/unsupported-capability",
|
|
294
349
|
summary: "The chosen protocol does not support this command capability.",
|
|
295
350
|
},
|
|
351
|
+
{
|
|
352
|
+
code: "routeros/version-unsupported",
|
|
353
|
+
summary:
|
|
354
|
+
"The device's RouterOS version does not provide the operation this command needs.",
|
|
355
|
+
},
|
|
296
356
|
|
|
297
357
|
// settings/*
|
|
358
|
+
{
|
|
359
|
+
code: "settings/consequential-value",
|
|
360
|
+
summary:
|
|
361
|
+
"`settings set` accepted a syntactically valid value with a real, non-obvious consequence (e.g. a global --insecure default, or a transfer-via value gated elsewhere).",
|
|
362
|
+
},
|
|
298
363
|
{
|
|
299
364
|
code: "settings/invalid-boolean",
|
|
300
365
|
summary: "A boolean-like setting received a non-boolean value.",
|
|
@@ -315,6 +380,21 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
315
380
|
code: "settings/invalid-via",
|
|
316
381
|
summary: "An unsupported protocol identifier was supplied to --via.",
|
|
317
382
|
},
|
|
383
|
+
{
|
|
384
|
+
code: "settings/reserved-key",
|
|
385
|
+
summary:
|
|
386
|
+
"`settings set`/`reset` refused a credential-shaped or self-referential CENTRS_* key.",
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
code: "settings/skip-env-file-active",
|
|
390
|
+
summary:
|
|
391
|
+
"`settings print` ran under --skip-env-file; the shown centrs.env values would not apply to other commands run in the same environment.",
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
code: "settings/unknown-key",
|
|
395
|
+
summary:
|
|
396
|
+
"`settings get`/`set` received a token that is not a recognized CENTRS_*-shaped setting.",
|
|
397
|
+
},
|
|
318
398
|
{
|
|
319
399
|
code: "settings/unsafe-protocol-blocked",
|
|
320
400
|
summary:
|
|
@@ -385,6 +465,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
385
465
|
summary:
|
|
386
466
|
"Peer verification was disabled via --insecure (TLS / SSH host-key checks skipped).",
|
|
387
467
|
},
|
|
468
|
+
{
|
|
469
|
+
code: "transport/provider-trust",
|
|
470
|
+
summary:
|
|
471
|
+
"Peer verification is relaxed for a named-live-provider endpoint (a quickchr VM's self-signed TLS cert / ephemeral SSH host key), trusted by the provider's loopback-forward voucher.",
|
|
472
|
+
},
|
|
388
473
|
{
|
|
389
474
|
code: "transport/local-tool-missing",
|
|
390
475
|
summary:
|
|
@@ -425,6 +510,16 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
425
510
|
code: "usage/invalid-concurrency",
|
|
426
511
|
summary: "The --concurrency value must be an integer >= 1.",
|
|
427
512
|
},
|
|
513
|
+
{
|
|
514
|
+
code: "usage/stdin-ignored",
|
|
515
|
+
summary:
|
|
516
|
+
"stdin was redirected but the input came from the positional argument, so anything piped in was not analyzed.",
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
code: "usage/fanout-not-supported",
|
|
520
|
+
summary:
|
|
521
|
+
"The command or mode cannot fan out across multiple targets (e.g. terminal, --listen/--stream).",
|
|
522
|
+
},
|
|
428
523
|
{
|
|
429
524
|
code: "usage/invalid-method",
|
|
430
525
|
summary: "The -X/--method value is not a supported HTTP method.",
|