@tikoci/centrs 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/GLOSSARY.txt +71 -0
- package/README.md +24 -8
- package/SECURITY.md +3 -3
- package/commands/api/README.md +27 -9
- package/commands/api/examples.md +109 -6
- package/commands/check/README.md +371 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/execute/README.md +39 -4
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +36 -50
- package/commands/retrieve/examples.md +40 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +32 -2
- package/commands/transfer/examples.md +66 -0
- package/docs/CONSTITUTION.md +36 -11
- package/docs/MATRIX.md +68 -172
- package/docs/errors/README.md +2 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/package.json +6 -6
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +91 -19
- package/src/cli/api.ts +135 -11
- package/src/cli/btest.ts +20 -2
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +107 -6
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +135 -90
- package/src/cli/selection.ts +200 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +171 -47
- package/src/cli.ts +7 -0
- package/src/core/error-catalog.ts +55 -0
- package/src/core/fanout.ts +143 -2
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +120 -23
- package/src/index.ts +105 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +46 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/selection.ts +304 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +20 -2
- package/src/retrieve-fanout.ts +225 -133
- package/src/retrieve.ts +39 -7
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +527 -0
- package/src/transfer.ts +61 -11
package/src/cli/transfer.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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(
|
|
363
|
-
|
|
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
|
|
368
|
-
*
|
|
369
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
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
|
|
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: {
|
|
515
|
+
context: { positionals: positional },
|
|
406
516
|
});
|
|
407
517
|
}
|
|
408
|
-
|
|
409
|
-
|
|
518
|
+
targetPositionals = positional.slice(0, verbIndex);
|
|
519
|
+
verb = canonicalVerb(positional[verbIndex] as string);
|
|
520
|
+
rest = positional.slice(verbIndex + 1);
|
|
410
521
|
}
|
|
411
522
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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.",
|
package/src/core/fanout.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|