@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/GLOSSARY.txt +71 -0
  3. package/README.md +24 -8
  4. package/SECURITY.md +3 -3
  5. package/commands/api/README.md +27 -9
  6. package/commands/api/examples.md +109 -6
  7. package/commands/check/README.md +371 -53
  8. package/commands/check/examples.md +390 -0
  9. package/commands/devices/README.md +177 -37
  10. package/commands/devices/examples.md +148 -0
  11. package/commands/execute/README.md +39 -4
  12. package/commands/execute/examples.md +90 -6
  13. package/commands/retrieve/README.md +36 -50
  14. package/commands/retrieve/examples.md +40 -9
  15. package/commands/settings/README.md +286 -22
  16. package/commands/settings/examples.md +408 -0
  17. package/commands/terminal/README.md +6 -0
  18. package/commands/transfer/README.md +32 -2
  19. package/commands/transfer/examples.md +66 -0
  20. package/docs/CONSTITUTION.md +36 -11
  21. package/docs/MATRIX.md +68 -172
  22. package/docs/errors/README.md +2 -1
  23. package/docs/errors/cdb/empty-selection.md +19 -0
  24. package/docs/errors/input/incomplete-gps.md +9 -0
  25. package/docs/errors/input/invalid-altitude.md +9 -0
  26. package/docs/errors/input/invalid-bbox.md +9 -0
  27. package/docs/errors/input/invalid-coordinate.md +9 -0
  28. package/docs/errors/input/invalid-radius.md +9 -0
  29. package/docs/errors/internal/settings-failed.md +9 -0
  30. package/docs/errors/settings/consequential-value.md +26 -0
  31. package/docs/errors/settings/invalid-boolean.md +21 -3
  32. package/docs/errors/settings/invalid-format.md +18 -3
  33. package/docs/errors/settings/invalid-integer.md +18 -3
  34. package/docs/errors/settings/invalid-timeout.md +20 -3
  35. package/docs/errors/settings/invalid-via.md +22 -3
  36. package/docs/errors/settings/reserved-key.md +29 -0
  37. package/docs/errors/settings/skip-env-file-active.md +25 -0
  38. package/docs/errors/settings/unknown-key.md +25 -0
  39. package/docs/errors/usage/fanout-not-supported.md +16 -0
  40. package/package.json +6 -6
  41. package/src/api-fanout.ts +454 -0
  42. package/src/api.ts +91 -19
  43. package/src/cli/api.ts +135 -11
  44. package/src/cli/btest.ts +20 -2
  45. package/src/cli/common.ts +11 -0
  46. package/src/cli/devices.ts +235 -6
  47. package/src/cli/discover.ts +3 -1
  48. package/src/cli/execute.ts +107 -6
  49. package/src/cli/mcp.ts +8 -1
  50. package/src/cli/retrieve.ts +135 -90
  51. package/src/cli/selection.ts +200 -0
  52. package/src/cli/settings.ts +286 -0
  53. package/src/cli/terminal.ts +22 -7
  54. package/src/cli/transfer.ts +171 -47
  55. package/src/cli.ts +7 -0
  56. package/src/core/error-catalog.ts +55 -0
  57. package/src/core/fanout.ts +143 -2
  58. package/src/devices.ts +212 -20
  59. package/src/discover.ts +7 -2
  60. package/src/execute-fanout.ts +438 -0
  61. package/src/execute.ts +120 -23
  62. package/src/index.ts +105 -1
  63. package/src/mcp/config.ts +12 -2
  64. package/src/mcp/tools.ts +137 -2
  65. package/src/protocols/adapter.ts +38 -18
  66. package/src/protocols/index.ts +11 -5
  67. package/src/resolver/cdb.ts +10 -2
  68. package/src/resolver/comment-kv.ts +58 -4
  69. package/src/resolver/config-file.ts +136 -0
  70. package/src/resolver/facts.ts +78 -0
  71. package/src/resolver/geo.ts +453 -0
  72. package/src/resolver/index.ts +46 -0
  73. package/src/resolver/mac.ts +12 -3
  74. package/src/resolver/selection.ts +304 -0
  75. package/src/resolver/settings.ts +54 -5
  76. package/src/resolver/target.ts +20 -2
  77. package/src/retrieve-fanout.ts +225 -133
  78. package/src/retrieve.ts +39 -7
  79. package/src/settings.ts +1078 -0
  80. package/src/terminal.ts +11 -1
  81. package/src/transfer-fanout.ts +527 -0
  82. package/src/transfer.ts +61 -11
@@ -6,16 +6,20 @@
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";
@@ -23,6 +27,7 @@ import {
23
27
  type CliCommandMetadata,
24
28
  expectValue,
25
29
  renderCommandHelp,
30
+ unknownFlagError,
26
31
  } from "./common.ts";
27
32
  import {
28
33
  buildTargetSelectionTips,
@@ -32,6 +37,14 @@ import {
32
37
  missingTargetError,
33
38
  withTips,
34
39
  } from "./missing-target.ts";
40
+ import {
41
+ buildTargetSelection,
42
+ consumeSelectionFlag,
43
+ emptySelectionFlags,
44
+ isFanoutMode,
45
+ type SelectionFlags,
46
+ selectionCommandOptions,
47
+ } from "./selection.ts";
35
48
 
36
49
  /** Sub-verb aliases, resolved to canonical verbs silently (commands/AGENTS.md). */
37
50
  const VERB_ALIASES: Record<string, TransferVerb> = {
@@ -73,6 +86,18 @@ export const transferCommand: CliCommandMetadata = {
73
86
  valueName: "(--overwrite)",
74
87
  description: "Replace an existing destination. Default refuses it.",
75
88
  },
89
+ ...selectionCommandOptions,
90
+ {
91
+ flag: "--out-dir",
92
+ valueName: "<dir>",
93
+ description:
94
+ "`download` fan-out only: write one file per target into <dir>, named by CDB identity. Required when downloading across a selection.",
95
+ },
96
+ {
97
+ flag: "--yes",
98
+ description:
99
+ "Confirm a mutating fan-out (upload/remove/mkdir/copy) across multiple routers in non-interactive runs.",
100
+ },
76
101
  {
77
102
  flag: "--verify",
78
103
  valueName: "<size|checksum|off>",
@@ -159,6 +184,17 @@ export async function runTransferCli(
159
184
  console.log(renderCommandHelp(describeCentrs(), transferCommand));
160
185
  return 0;
161
186
  }
187
+
188
+ // Fan-out mode (a selector flag, or >1 positional target before the verb)
189
+ // has its own envelope shape, single up-front write-confirm, and exit code.
190
+ if (isFanoutMode(parsed.selectionFlags, parsed.targetPositionals.length)) {
191
+ return await runTransferFanoutCli(
192
+ parsed.request,
193
+ parsed.selectionFlags,
194
+ parsed.targetPositionals,
195
+ args,
196
+ );
197
+ }
162
198
  request = parsed.request;
163
199
 
164
200
  const envelope = await transfer(request);
@@ -196,12 +232,14 @@ export async function runTransferCli(
196
232
  tips,
197
233
  );
198
234
  console.error(
235
+ // codeql[js/clear-text-logging] The transfer error path redacts password/cdbPassword before any renderer (redactTransferRequest), and buildTransferErrorEnvelope only emits targetInput.
199
236
  renderTransferEnvelope(envelope, format, {
200
237
  verbose: safeRequest?.verbose ?? false,
201
238
  }),
202
239
  );
203
240
  } else {
204
241
  console.error(
242
+ // 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
243
  formatCentrsErrorText(
206
244
  asCentrsError(error, {
207
245
  code: "input/invalid-command",
@@ -217,6 +255,46 @@ export async function runTransferCli(
217
255
  }
218
256
  }
219
257
 
258
+ async function runTransferFanoutCli(
259
+ request: TransferRequest,
260
+ selectionFlags: SelectionFlags,
261
+ targetPositionals: readonly string[],
262
+ args: readonly string[],
263
+ ): Promise<number> {
264
+ const selection = buildTargetSelection(selectionFlags, targetPositionals);
265
+ const format = inferRequestedFormat(args, request);
266
+ try {
267
+ const envelope = await transferFanout(
268
+ { ...request, stdinIsTty: process.stdin.isTTY },
269
+ selection,
270
+ Bun.env,
271
+ {},
272
+ { concurrency: selectionFlags.concurrency, allowAdhoc: true },
273
+ );
274
+ console.log(
275
+ renderTransferFanoutEnvelope(envelope, format, {
276
+ verbose: request.verbose ?? false,
277
+ }),
278
+ );
279
+ // Granular exit contract: 0 all-ok / 2 partial / 1 all-failed.
280
+ return fanoutExitCode(envelope);
281
+ } catch (error) {
282
+ // Strip credentials before any renderer touches the request (stderr/CI logs):
283
+ // pass an explicitly redacted request, with no raw-request fallback path.
284
+ const envelope = buildTransferFanoutErrorEnvelope(
285
+ { ...request, password: undefined, cdbPassword: undefined },
286
+ error,
287
+ );
288
+ console.error(
289
+ // 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:{}}.
290
+ renderTransferFanoutEnvelope(envelope, format, {
291
+ verbose: request.verbose ?? false,
292
+ }),
293
+ );
294
+ return 1;
295
+ }
296
+ }
297
+
220
298
  /**
221
299
  * Strip credential fields before a failed request is handed to the error
222
300
  * envelope/text renderers. Those outputs land on stderr (and CI logs), so the
@@ -237,13 +315,16 @@ function redactTransferRequest(
237
315
  interface ParsedTransfer {
238
316
  help?: boolean;
239
317
  request: TransferRequest;
318
+ selectionFlags: SelectionFlags;
319
+ targetPositionals: readonly string[];
240
320
  }
241
321
 
242
- function parseTransferCliArgs(
322
+ export function parseTransferCliArgs(
243
323
  args: readonly string[],
244
324
  fixedVerb?: TransferVerb,
245
325
  ): ParsedTransfer {
246
326
  const flags: Partial<TransferRequest> & { verbose?: boolean } = {};
327
+ const selectionFlags = emptySelectionFlags();
247
328
  const positional: string[] = [];
248
329
  let help = false;
249
330
  // `--verify <mode>` and `--no-verify` both map to the single `verify` field,
@@ -326,28 +407,39 @@ function parseTransferCliArgs(
326
407
  case "--no-validate":
327
408
  flags.validate = false;
328
409
  break;
410
+ case "--out-dir":
411
+ flags.outDir = expectValue(args, ++index, arg);
412
+ break;
413
+ case "--yes":
414
+ flags.yes = true;
415
+ break;
329
416
  case "--verbose":
330
417
  flags.verbose = true;
331
418
  break;
332
- default:
419
+ default: {
420
+ const consumed = consumeSelectionFlag(args, index, selectionFlags);
421
+ if (consumed !== null) {
422
+ index = consumed;
423
+ break;
424
+ }
333
425
  // A lone `-` is the stdin/stdout positional (upload from / download
334
426
  // to a pipe), not a flag.
335
427
  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
- });
428
+ throw unknownFlagError("transfer", arg, transferCommand.options);
343
429
  }
344
430
  positional.push(arg);
345
431
  break;
432
+ }
346
433
  }
347
434
  }
348
435
 
349
436
  if (help) {
350
- return { help: true, request: { verb: fixedVerb ?? "list" } };
437
+ return {
438
+ help: true,
439
+ request: { verb: fixedVerb ?? "list" },
440
+ selectionFlags,
441
+ targetPositionals: [],
442
+ };
351
443
  }
352
444
 
353
445
  if (sawNoVerify && explicitVerify !== undefined && explicitVerify !== "off") {
@@ -359,67 +451,101 @@ function parseTransferCliArgs(
359
451
  });
360
452
  }
361
453
 
362
- const request = assemblePositionals(positional, flags, fixedVerb);
363
- return { request };
454
+ const { request, targetPositionals } = assemblePositionals(
455
+ positional,
456
+ flags,
457
+ selectionFlags,
458
+ fixedVerb,
459
+ );
460
+ return { request, selectionFlags, targetPositionals };
461
+ }
462
+
463
+ /** Whether a positional token is a transfer verb keyword (canonical or alias). */
464
+ function isTransferVerbToken(token: string): boolean {
465
+ return transferVerbs.includes(token as TransferVerb) || token in VERB_ALIASES;
364
466
  }
365
467
 
366
468
  /**
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> …`.
469
+ * Map positionals to the per-verb request shape and split fan-out targets. The
470
+ * boundary is the **verb keyword**: positionals before it are targets, the verb +
471
+ * paths follow. The top-level `upload`/`download` alias fixes the verb, so its
472
+ * positionals are paths; with a selector present, positional fan-out targets are
473
+ * not expressible there (fan-out comes from the selector). See
474
+ * `docs/CONSTITUTION.md` (Target selection).
370
475
  */
371
476
  function assemblePositionals(
372
477
  positional: readonly string[],
373
478
  flags: Partial<TransferRequest> & { verbose?: boolean },
479
+ selectionFlags: SelectionFlags,
374
480
  fixedVerb?: TransferVerb,
375
- ): TransferRequest {
376
- let targetInput: string | undefined;
481
+ ): { request: TransferRequest; targetPositionals: readonly string[] } {
482
+ const hasSelector = isFanoutMode(selectionFlags, 0);
377
483
  let verb: TransferVerb;
484
+ let targetPositionals: readonly string[];
378
485
  let rest: readonly string[];
379
486
 
380
487
  if (fixedVerb) {
381
- targetInput = positional[0];
382
488
  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
- });
489
+ if (hasSelector) {
490
+ // alias + selector: targets come from the selector; positionals are paths.
491
+ targetPositionals = [];
492
+ rest = positional;
493
+ } else {
494
+ targetPositionals = positional[0] !== undefined ? [positional[0]] : [];
495
+ rest = positional.slice(1);
397
496
  }
398
- if (!rawVerb) {
497
+ } else {
498
+ const verbIndex = positional.findIndex(isTransferVerbToken);
499
+ if (verbIndex === -1) {
500
+ if (positional.length === 0 && !hasSelector) {
501
+ throw missingTargetError({
502
+ command: "transfer",
503
+ summary:
504
+ "`centrs transfer` requires a <router> target and a verb (upload|download|list|remove|mkdir|copy).",
505
+ remediation:
506
+ "Pass the target then a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
507
+ });
508
+ }
399
509
  throw new CentrsError({
400
510
  code: "input/invalid-command",
401
511
  summary:
402
- "`centrs transfer` requires a verb after the <router> (upload|download|list|remove|mkdir|copy).",
512
+ "`centrs transfer` requires a verb (upload|download|list|remove|mkdir|copy).",
403
513
  remediation:
404
514
  "Pass a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
405
- context: { targetInput, verb: rawVerb },
515
+ context: { positionals: positional },
406
516
  });
407
517
  }
408
- verb = canonicalVerb(rawVerb);
409
- rest = positional.slice(2);
518
+ targetPositionals = positional.slice(0, verbIndex);
519
+ verb = canonicalVerb(positional[verbIndex] as string);
520
+ rest = positional.slice(verbIndex + 1);
410
521
  }
411
522
 
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
- });
523
+ const request: TransferRequest = { verb, ...flags };
524
+ applyVerbPaths(request, verb, rest);
525
+
526
+ // Single-target (not fan-out mode) needs exactly one resolved target.
527
+ if (!isFanoutMode(selectionFlags, targetPositionals.length)) {
528
+ const targetInput = targetPositionals[0];
529
+ if (!targetInput) {
530
+ throw missingTargetError({
531
+ command: "transfer",
532
+ summary: "`centrs transfer` requires a <router> target.",
533
+ remediation:
534
+ "Pass the router host/identity as the first argument; run `centrs transfer --help` for the command shape.",
535
+ });
536
+ }
537
+ request.targetInput = targetInput;
419
538
  }
420
539
 
421
- const request: TransferRequest = { verb, targetInput, ...flags };
540
+ return { request, targetPositionals };
541
+ }
422
542
 
543
+ /** Map the post-verb positionals onto the per-verb request fields. */
544
+ function applyVerbPaths(
545
+ request: TransferRequest,
546
+ verb: TransferVerb,
547
+ rest: readonly string[],
548
+ ): void {
423
549
  switch (verb) {
424
550
  case "upload":
425
551
  request.local = rest[0];
@@ -441,8 +567,6 @@ function assemblePositionals(
441
567
  request.remoteDest = rest[1];
442
568
  break;
443
569
  }
444
-
445
- return request;
446
570
  }
447
571
 
448
572
  function canonicalVerb(raw: string): TransferVerb {
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ import { discoverCommand, runDiscoverCli } from "./cli/discover.ts";
7
7
  import { executeCommand, runExecuteCli } from "./cli/execute.ts";
8
8
  import { mcpCommand, runMcpCli } from "./cli/mcp.ts";
9
9
  import { retrieveCommand, runRetrieveCli } from "./cli/retrieve.ts";
10
+ import { runSettingsCli, settingsCommand } from "./cli/settings.ts";
10
11
  import { runTerminalCli, terminalCommand } from "./cli/terminal.ts";
11
12
  import { runTransferCli, transferCommand } from "./cli/transfer.ts";
12
13
  import { asCentrsError, formatCentrsErrorText } from "./errors.ts";
@@ -22,6 +23,7 @@ const commandSummaries: ReadonlyArray<{ name: string; summary: string }> = [
22
23
  discoverCommand,
23
24
  btestCommand,
24
25
  mcpCommand,
26
+ settingsCommand,
25
27
  ].map((command) => ({ name: command.name, summary: command.summary }));
26
28
 
27
29
  export function renderCliHelp(): string {
@@ -85,6 +87,9 @@ export async function runCli(
85
87
  if (command === "mcp") {
86
88
  return await runMcpCli(rest);
87
89
  }
90
+ if (command === "settings") {
91
+ return await runSettingsCli(rest);
92
+ }
88
93
  // `stream` (and the `tail` shorthand) folded into `api --stream`.
89
94
  if (command === "stream" || command === "tail") {
90
95
  console.error(
@@ -104,6 +109,7 @@ export async function runCli(
104
109
  }
105
110
 
106
111
  console.error(
112
+ // 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
113
  formatCentrsErrorText(
108
114
  asCentrsError(new Error(`Unknown centrs command: ${command}`), {
109
115
  code: "input/invalid-command",
@@ -118,6 +124,7 @@ export async function runCli(
118
124
  // to a structured envelope, so reaching here means an unconverted throw
119
125
  // escaped. Never let a raw stack trace reach the user.
120
126
  console.error(
127
+ // 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
128
  formatCentrsErrorText(
122
129
  asCentrsError(error, {
123
130
  code: "internal/unhandled",
@@ -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).",
@@ -295,6 +325,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
295
325
  },
296
326
 
297
327
  // settings/*
328
+ {
329
+ code: "settings/consequential-value",
330
+ summary:
331
+ "`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).",
332
+ },
298
333
  {
299
334
  code: "settings/invalid-boolean",
300
335
  summary: "A boolean-like setting received a non-boolean value.",
@@ -315,6 +350,21 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
315
350
  code: "settings/invalid-via",
316
351
  summary: "An unsupported protocol identifier was supplied to --via.",
317
352
  },
353
+ {
354
+ code: "settings/reserved-key",
355
+ summary:
356
+ "`settings set`/`reset` refused a credential-shaped or self-referential CENTRS_* key.",
357
+ },
358
+ {
359
+ code: "settings/skip-env-file-active",
360
+ summary:
361
+ "`settings print` ran under --skip-env-file; the shown centrs.env values would not apply to other commands run in the same environment.",
362
+ },
363
+ {
364
+ code: "settings/unknown-key",
365
+ summary:
366
+ "`settings get`/`set` received a token that is not a recognized CENTRS_*-shaped setting.",
367
+ },
318
368
  {
319
369
  code: "settings/unsafe-protocol-blocked",
320
370
  summary:
@@ -425,6 +475,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
425
475
  code: "usage/invalid-concurrency",
426
476
  summary: "The --concurrency value must be an integer >= 1.",
427
477
  },
478
+ {
479
+ code: "usage/fanout-not-supported",
480
+ summary:
481
+ "The command or mode cannot fan out across multiple targets (e.g. terminal, --listen/--stream).",
482
+ },
428
483
  {
429
484
  code: "usage/invalid-method",
430
485
  summary: "The -X/--method value is not a supported HTTP method.",
@@ -11,9 +11,18 @@
11
11
  * and counting.
12
12
  */
13
13
 
14
- import { CentrsError } from "../errors.ts";
14
+ import { CentrsError, serializeCentrsError } from "../errors.ts";
15
15
  import type { RouterOsProtocol } from "../protocols/index.ts";
16
- import type { FanoutSummary } from "./envelope.ts";
16
+ import type {
17
+ CentrsEnvelope,
18
+ CentrsErrorEnvelope,
19
+ CentrsSuccessEnvelope,
20
+ CommonSettingsMeta,
21
+ EnvelopeTargetMeta,
22
+ FanoutData,
23
+ FanoutSummary,
24
+ Warning,
25
+ } from "./envelope.ts";
17
26
 
18
27
  /** Transport-aware default worker-pool size (REST drops parallel POSTs above ~8). */
19
28
  export const FANOUT_CONCURRENCY_DEFAULTS: Record<
@@ -158,3 +167,135 @@ export function fanoutBackoffMs(attempt: number): number {
158
167
  export function defaultFanoutSleep(ms: number): Promise<void> {
159
168
  return new Promise((resolve) => setTimeout(resolve, ms));
160
169
  }
170
+
171
+ /**
172
+ * The per-command fan-out loop, generic over the selection member, the resolved
173
+ * per-target plan, and the inner envelope type. Mirrors the original
174
+ * `retrieveGroup` worker exactly: resolve each member (a resolve failure becomes
175
+ * an inner error envelope, never a throw out of the batch); run the resolved
176
+ * request through {@link runWithRetry} (a retryable transient drop is retried,
177
+ * everything else recovers to an inner error envelope). Results are returned in
178
+ * input (record-index) order via {@link runBoundedPool}.
179
+ */
180
+ export async function runFanout<Member, Resolved, Env>(opts: {
181
+ members: readonly Member[];
182
+ concurrency: number;
183
+ resolve: (member: Member) => Promise<Resolved> | Resolved;
184
+ onResolveError: (member: Member, error: unknown) => Env;
185
+ execute: (resolved: Resolved, member: Member) => Promise<Env>;
186
+ onExecuteError: (resolved: Resolved, member: Member, error: unknown) => Env;
187
+ sleep?: (ms: number) => Promise<void>;
188
+ }): Promise<Env[]> {
189
+ return runBoundedPool(opts.members, opts.concurrency, async (member) => {
190
+ let resolved: Resolved;
191
+ try {
192
+ resolved = await opts.resolve(member);
193
+ } catch (error) {
194
+ return opts.onResolveError(member, error);
195
+ }
196
+ return runWithRetry<Env>(
197
+ () => opts.execute(resolved, member),
198
+ (error) => opts.onExecuteError(resolved, member, error),
199
+ { sleep: opts.sleep },
200
+ );
201
+ });
202
+ }
203
+
204
+ /**
205
+ * The uniform fan-out process exit code (granular 0/2/1; see
206
+ * `docs/CONSTITUTION.md`, Target selection). `0` = every target ok (or an empty
207
+ * selection); `2` = partial (some ok, some failed); `1` = orchestration error
208
+ * (outer `ok: false`) OR every target failed. Decoupled from the envelope's
209
+ * outer `ok`, which only reports orchestration success.
210
+ */
211
+ export function fanoutExitCode(envelope: {
212
+ ok: boolean;
213
+ data?: { summary?: FanoutSummary };
214
+ }): 0 | 1 | 2 {
215
+ if (!envelope.ok) {
216
+ return 1;
217
+ }
218
+ const summary = envelope.data?.summary;
219
+ if (!summary || summary.total === 0 || summary.failed === 0) {
220
+ return 0;
221
+ }
222
+ if (summary.ok === 0) {
223
+ return 1;
224
+ }
225
+ return 2;
226
+ }
227
+
228
+ /** Collapse per-target `via` to one value, or `null` when targets disagree. */
229
+ export function commonVia(
230
+ targets: readonly { meta: { via: RouterOsProtocol | null } }[],
231
+ ): RouterOsProtocol | null {
232
+ let via: RouterOsProtocol | null | undefined;
233
+ for (const target of targets) {
234
+ const candidate = target.meta.via;
235
+ if (via === undefined) {
236
+ via = candidate;
237
+ } else if (via !== candidate) {
238
+ return null;
239
+ }
240
+ }
241
+ return via ?? null;
242
+ }
243
+
244
+ /**
245
+ * Assemble the locked fan-out success envelope: `data = { summary, targets }`,
246
+ * outer `ok: true` = orchestration succeeded. The command owns its outer
247
+ * `operation` meta shape and passes it in.
248
+ */
249
+ export function buildFanoutEnvelope<Data, Op, OuterOp>(input: {
250
+ summary: FanoutSummary;
251
+ targets: readonly CentrsEnvelope<Data, Op>[];
252
+ warnings: readonly Warning[];
253
+ settings: CommonSettingsMeta;
254
+ via: RouterOsProtocol | null;
255
+ operation: OuterOp;
256
+ }): CentrsSuccessEnvelope<FanoutData<Data, Op>, OuterOp> {
257
+ return {
258
+ ok: true,
259
+ data: { summary: input.summary, targets: input.targets },
260
+ warnings: input.warnings,
261
+ tips: [],
262
+ meta: {
263
+ target: {},
264
+ via: input.via,
265
+ settings: input.settings,
266
+ operation: input.operation,
267
+ },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Build an INNER per-target error envelope for a member that failed to resolve
273
+ * (before any transport attempt). Carries the member's target meta so the
274
+ * fan-out output identifies which device failed.
275
+ */
276
+ export function buildFanoutResolveFailure<Op = unknown>(input: {
277
+ error: unknown;
278
+ target: EnvelopeTargetMeta;
279
+ warnings?: readonly Warning[];
280
+ summary?: string;
281
+ }): CentrsErrorEnvelope<Op> {
282
+ const centrsError =
283
+ input.error instanceof CentrsError
284
+ ? input.error
285
+ : new CentrsError({
286
+ code: "internal/unhandled",
287
+ summary: input.summary ?? "Failed to resolve a fanout target.",
288
+ cause: input.error,
289
+ });
290
+ return {
291
+ ok: false,
292
+ error: serializeCentrsError(centrsError),
293
+ warnings: input.warnings ?? [],
294
+ tips: [],
295
+ meta: {
296
+ target: input.target,
297
+ via: null,
298
+ settings: {},
299
+ },
300
+ };
301
+ }