@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.
Files changed (134) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/GLOSSARY.txt +284 -0
  3. package/LICENSE +21 -0
  4. package/README.md +55 -11
  5. package/SECURITY.md +3 -3
  6. package/commands/AGENTS.md +7 -1
  7. package/commands/api/README.md +46 -27
  8. package/commands/api/examples.md +124 -6
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +376 -53
  11. package/commands/check/examples.md +390 -0
  12. package/commands/devices/README.md +177 -37
  13. package/commands/devices/examples.md +148 -0
  14. package/commands/discover/README.md +5 -11
  15. package/commands/execute/README.md +73 -26
  16. package/commands/execute/examples.md +131 -9
  17. package/commands/explain/README.md +1716 -0
  18. package/commands/explain/examples.md +606 -0
  19. package/commands/retrieve/README.md +55 -71
  20. package/commands/retrieve/examples.md +56 -9
  21. package/commands/settings/README.md +286 -22
  22. package/commands/settings/examples.md +408 -0
  23. package/commands/terminal/README.md +6 -0
  24. package/commands/transfer/README.md +57 -19
  25. package/commands/transfer/examples.md +95 -0
  26. package/docs/CLI.md +353 -0
  27. package/docs/CONSTITUTION.md +102 -26
  28. package/docs/MATRIX.md +106 -173
  29. package/docs/errors/README.md +5 -1
  30. package/docs/errors/cdb/empty-selection.md +19 -0
  31. package/docs/errors/input/incomplete-gps.md +9 -0
  32. package/docs/errors/input/invalid-altitude.md +9 -0
  33. package/docs/errors/input/invalid-bbox.md +9 -0
  34. package/docs/errors/input/invalid-coordinate.md +9 -0
  35. package/docs/errors/input/invalid-radius.md +9 -0
  36. package/docs/errors/internal/settings-failed.md +9 -0
  37. package/docs/errors/quickchr/machine-not-found.md +22 -0
  38. package/docs/errors/quickchr/machine-stopped.md +20 -0
  39. package/docs/errors/quickchr/package-unavailable.md +27 -0
  40. package/docs/errors/quickchr/unsupported-via.md +35 -0
  41. package/docs/errors/quickchr/unsupported.md +35 -0
  42. package/docs/errors/routeros/version-unsupported.md +26 -0
  43. package/docs/errors/settings/consequential-value.md +26 -0
  44. package/docs/errors/settings/invalid-boolean.md +21 -3
  45. package/docs/errors/settings/invalid-format.md +18 -3
  46. package/docs/errors/settings/invalid-integer.md +18 -3
  47. package/docs/errors/settings/invalid-timeout.md +20 -3
  48. package/docs/errors/settings/invalid-via.md +22 -3
  49. package/docs/errors/settings/reserved-key.md +29 -0
  50. package/docs/errors/settings/skip-env-file-active.md +25 -0
  51. package/docs/errors/settings/unknown-key.md +25 -0
  52. package/docs/errors/transport/provider-trust.md +29 -0
  53. package/docs/errors/usage/fanout-not-supported.md +16 -0
  54. package/docs/errors/usage/stdin-ignored.md +28 -0
  55. package/docs/errors/validation/unknown-attribute.md +37 -4
  56. package/package.json +49 -14
  57. package/src/api-fanout.ts +469 -0
  58. package/src/api.ts +171 -56
  59. package/src/cli/api.ts +156 -13
  60. package/src/cli/btest.ts +38 -4
  61. package/src/cli/common.ts +11 -0
  62. package/src/cli/devices.ts +235 -6
  63. package/src/cli/discover.ts +3 -1
  64. package/src/cli/execute.ts +129 -9
  65. package/src/cli/explain.ts +503 -0
  66. package/src/cli/mcp.ts +8 -1
  67. package/src/cli/retrieve.ts +167 -90
  68. package/src/cli/selection.ts +260 -0
  69. package/src/cli/settings.ts +286 -0
  70. package/src/cli/terminal.ts +22 -7
  71. package/src/cli/transfer.ts +192 -51
  72. package/src/cli.ts +22 -2
  73. package/src/core/envelope.ts +5 -2
  74. package/src/core/error-catalog.ts +95 -0
  75. package/src/core/fanout.ts +143 -2
  76. package/src/core/preflight.ts +306 -0
  77. package/src/core/routeros-string.ts +24 -0
  78. package/src/devices.ts +212 -20
  79. package/src/discover.ts +7 -2
  80. package/src/errors.ts +1 -0
  81. package/src/execute-fanout.ts +453 -0
  82. package/src/execute.ts +216 -68
  83. package/src/explain/arg-tokens.ts +114 -0
  84. package/src/explain/args.ts +1403 -0
  85. package/src/explain/blocks.ts +132 -0
  86. package/src/explain/brace-slots.ts +143 -0
  87. package/src/explain/brace-tokens.ts +38 -0
  88. package/src/explain/catalog.ts +1345 -0
  89. package/src/explain/coordinates.ts +329 -0
  90. package/src/explain/defects.ts +217 -0
  91. package/src/explain/is-known-menu.ts +17 -0
  92. package/src/explain/menus.ts +664 -0
  93. package/src/explain/operator-tokens.ts +310 -0
  94. package/src/explain/operators.ts +458 -0
  95. package/src/explain/path-tokens.ts +150 -0
  96. package/src/explain/pathresolve.ts +1324 -0
  97. package/src/explain/quoted-string.ts +241 -0
  98. package/src/explain/scope-brace.ts +308 -0
  99. package/src/explain/segment.ts +757 -0
  100. package/src/explain/string-tokens.ts +67 -0
  101. package/src/explain/symbol-values.ts +520 -0
  102. package/src/explain/symbols.ts +1597 -0
  103. package/src/explain/token-ranges.ts +72 -0
  104. package/src/explain/transport.ts +497 -0
  105. package/src/explain/value-tokens.ts +80 -0
  106. package/src/explain/values.ts +357 -0
  107. package/src/explain/verbs.ts +64 -0
  108. package/src/explain/verbsplit.ts +813 -0
  109. package/src/explain/write.ts +815 -0
  110. package/src/explain.ts +2152 -0
  111. package/src/index.ts +307 -1
  112. package/src/mcp/config.ts +12 -2
  113. package/src/mcp/tools.ts +137 -2
  114. package/src/protocols/adapter.ts +38 -18
  115. package/src/protocols/index.ts +11 -5
  116. package/src/protocols/mac-telnet-console.ts +61 -17
  117. package/src/resolver/cdb.ts +10 -2
  118. package/src/resolver/comment-kv.ts +58 -4
  119. package/src/resolver/config-file.ts +136 -0
  120. package/src/resolver/facts.ts +78 -0
  121. package/src/resolver/geo.ts +453 -0
  122. package/src/resolver/index.ts +65 -0
  123. package/src/resolver/mac.ts +12 -3
  124. package/src/resolver/quickchr-provider.ts +597 -0
  125. package/src/resolver/selection.ts +391 -0
  126. package/src/resolver/service-endpoint.ts +155 -0
  127. package/src/resolver/settings.ts +54 -5
  128. package/src/resolver/target.ts +21 -3
  129. package/src/retrieve-fanout.ts +239 -133
  130. package/src/retrieve.ts +120 -25
  131. package/src/settings.ts +1078 -0
  132. package/src/terminal.ts +11 -1
  133. package/src/transfer-fanout.ts +544 -0
  134. package/src/transfer.ts +133 -40
@@ -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; --no-verify skips.",
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 new CentrsError({
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 { help: true, request: { verb: fixedVerb ?? "list" } };
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(positional, flags, fixedVerb);
363
- return { request };
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. For the top-level alias the
368
- * verb is fixed and the first positional is `<router>`; for `centrs transfer`
369
- * the positionals are `<router> <verb> …`.
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
- let targetInput: string | undefined;
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
- rest = positional.slice(1);
384
- } else {
385
- targetInput = positional[0];
386
- const rawVerb = positional[1];
387
- // A missing target gets the tagged missing-target error (CDB picker tip);
388
- // a present target with no verb is a distinct "which verb?" usage error.
389
- if (!targetInput) {
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
- if (!rawVerb) {
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 after the <router> (upload|download|list|remove|mkdir|copy).",
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: { targetInput, verb: rawVerb },
520
+ context: { positionals: positional },
406
521
  });
407
522
  }
408
- verb = canonicalVerb(rawVerb);
409
- rest = positional.slice(2);
523
+ targetPositionals = positional.slice(0, verbIndex);
524
+ verb = canonicalVerb(positional[verbIndex] as string);
525
+ rest = positional.slice(verbIndex + 1);
410
526
  }
411
527
 
412
- if (!targetInput) {
413
- throw missingTargetError({
414
- command: "transfer",
415
- summary: "`centrs transfer` requires a <router> target.",
416
- remediation:
417
- "Pass the router host/identity as the first argument; run `centrs transfer --help` for the command shape.",
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
- const request: TransferRequest = { verb, targetInput, ...flags };
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
- const commandSummaries: ReadonlyArray<{ name: string; summary: string }> = [
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
- ].map((command) => ({ name: command.name, summary: command.summary }));
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",
@@ -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.",