@tikoci/centrs 0.1.1 → 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 (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Multi-target fan-out for `execute`.
3
+ *
4
+ * A target selection (`--group` / `--where` / `--all` / `--default` / multiple
5
+ * positionals before `--`) expands to N members and runs the SAME RouterOS
6
+ * command on each through the single-target execute tail (`resolveExecuteRequest`
7
+ * → `runResolvedExecute`), collecting per-target inner `ExecuteEnvelope`s into the
8
+ * locked `FanoutData` shape (`src/core/envelope.ts`).
9
+ *
10
+ * Boundaries (see `commands/execute/README.md`, `docs/CONSTITUTION.md`), shared
11
+ * with `api-fanout.ts` / `retrieve-fanout.ts`:
12
+ * - The CDB is loaded ONCE (`expandCdbSelection`); a cdb member is resolved with
13
+ * its pre-resolved record, so `resolveCdb`'s `__default__` synthetic fallback
14
+ * never collides ad-hoc literals.
15
+ * - A `--default` / `__default__` member is guarded from being dialed as the
16
+ * literal hostname `"__default__"` — it fails that one target deterministically
17
+ * with `target/unresolved`.
18
+ * - A **write-shaped** command (add/set/remove) is confirmed ONCE up front
19
+ * (`--yes`), never per target; without `--yes` the error names the blast radius
20
+ * (how many routers). Write-ness is target-independent (`canonicalizeExecuteCommand`).
21
+ */
22
+
23
+ import type {
24
+ CentrsErrorEnvelope,
25
+ CentrsSuccessEnvelope,
26
+ CommonSettingsMeta,
27
+ FanoutData,
28
+ FanoutSummary,
29
+ } from "./core/envelope.ts";
30
+ import {
31
+ buildFanoutEnvelope,
32
+ buildFanoutResolveFailure,
33
+ commonVia,
34
+ defaultFanoutSleep,
35
+ resolveFanoutConcurrency,
36
+ runFanout,
37
+ summarizeFanout,
38
+ } from "./core/fanout.ts";
39
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
40
+ import {
41
+ buildExecuteErrorEnvelopeFromResolved,
42
+ type ExecuteEnvelope,
43
+ type ExecuteOperationMeta,
44
+ type ExecuteRequest,
45
+ type ExecuteRequestSummary,
46
+ type ExecuteSuccessEnvelope,
47
+ promptForWriteConfirmation,
48
+ type ResolvedExecuteRequest,
49
+ resolveExecuteGlobalContext,
50
+ resolveExecuteRequest,
51
+ runResolvedExecute,
52
+ } from "./execute.ts";
53
+ import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
54
+ import {
55
+ type CdbSelectionExpansion,
56
+ type CdbSelectionMember,
57
+ type CdbSelectionResolveInput,
58
+ expandCdbSelection,
59
+ isDefaultRecordTarget,
60
+ loadEnvFileDefaults,
61
+ type TargetSelection,
62
+ } from "./resolver/index.ts";
63
+ import { toYaml } from "./retrieve.ts";
64
+
65
+ /** Compact description of the selector that produced the fan-out. */
66
+ export interface ExecuteSelectionSummary {
67
+ groups: readonly string[];
68
+ where: readonly string[];
69
+ all: boolean;
70
+ default: boolean;
71
+ positionals: readonly string[];
72
+ }
73
+
74
+ export interface ExecuteFanoutOperationMeta {
75
+ kind: "fanout";
76
+ concurrency: number;
77
+ summary: FanoutSummary;
78
+ request: ExecuteRequestSummary;
79
+ selection: ExecuteSelectionSummary;
80
+ }
81
+
82
+ export type ExecuteFanoutData = FanoutData<unknown, ExecuteOperationMeta>;
83
+ export type ExecuteFanoutEnvelope = CentrsSuccessEnvelope<
84
+ ExecuteFanoutData,
85
+ ExecuteFanoutOperationMeta
86
+ >;
87
+ export type ExecuteFanoutErrorEnvelope =
88
+ CentrsErrorEnvelope<ExecuteFanoutOperationMeta>;
89
+
90
+ /** Test/override seams; production callers pass nothing. */
91
+ export interface ExecuteFanoutInternals {
92
+ expand?: (
93
+ selection: TargetSelection,
94
+ input: CdbSelectionResolveInput,
95
+ env: Record<string, string | undefined>,
96
+ config?: Record<string, string | undefined>,
97
+ ) => Promise<CdbSelectionExpansion>;
98
+ execute?: (
99
+ resolved: ResolvedExecuteRequest,
100
+ ) => Promise<ExecuteSuccessEnvelope>;
101
+ sleep?: (ms: number) => Promise<void>;
102
+ }
103
+
104
+ export interface ExecuteFanoutOptions {
105
+ concurrency?: number;
106
+ /** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
107
+ allowAdhoc?: boolean;
108
+ }
109
+
110
+ function summarizeSelection(
111
+ selection: TargetSelection,
112
+ ): ExecuteSelectionSummary {
113
+ return {
114
+ groups: selection.groups,
115
+ where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
116
+ all: selection.all,
117
+ default: selection.default,
118
+ positionals: selection.positionals,
119
+ };
120
+ }
121
+
122
+ /** Representative transport for the concurrency default (pinned > native member > global). */
123
+ function fanoutVia(
124
+ request: ExecuteRequest,
125
+ env: Record<string, string | undefined>,
126
+ globalVia: RouterOsProtocol,
127
+ expansion: CdbSelectionExpansion,
128
+ ): RouterOsProtocol {
129
+ // Deliberately CLI/env only, not `config` — see the identical note in
130
+ // `retrieve-fanout.ts`'s `resolveFanoutConcurrencyProtocol`.
131
+ if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
132
+ return globalVia;
133
+ }
134
+ const anyNative = expansion.targets.some(
135
+ (member) =>
136
+ member.kind === "cdb" &&
137
+ member.resolution.overrides.via?.value === "native-api",
138
+ );
139
+ return anyNative ? "native-api" : globalVia;
140
+ }
141
+
142
+ function memberTargetMeta(member: CdbSelectionMember): {
143
+ input?: string;
144
+ identity?: string;
145
+ recordIndex?: number;
146
+ } {
147
+ if (member.kind === "cdb") {
148
+ return {
149
+ input: member.resolution.identity,
150
+ identity: member.resolution.identity,
151
+ recordIndex: member.recordIndex,
152
+ };
153
+ }
154
+ return { input: member.input };
155
+ }
156
+
157
+ /**
158
+ * Ad-hoc literal targets have no CDB record. `resolveExecuteRequest` may borrow the
159
+ * `__default__` record's index when filling fallback creds, so the inner envelope
160
+ * would carry that borrowed `recordIndex` — making distinct literals collide on one
161
+ * index. Drop it for literal members and stamp the caller's `input` so the target
162
+ * stays identifiable in renderers (mirrors `api-fanout.ts`).
163
+ */
164
+ function relabelAdhocMember(
165
+ envelope: ExecuteEnvelope,
166
+ member: CdbSelectionMember,
167
+ ): ExecuteEnvelope {
168
+ if (member.kind !== "literal") {
169
+ return envelope;
170
+ }
171
+ const { recordIndex: _borrowed, ...target } = envelope.meta.target;
172
+ return {
173
+ ...envelope,
174
+ meta: { ...envelope.meta, target: { ...target, input: member.input } },
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Run an `execute` fan-out. Returns a success envelope when the orchestration
180
+ * completes (even if every target failed); throws only for pre-flight failures
181
+ * (invalid command shape, CDB decrypt, unconfirmed write) the caller renders as
182
+ * an outer error via {@link buildExecuteFanoutErrorEnvelope}.
183
+ */
184
+ export async function executeFanout(
185
+ request: ExecuteRequest,
186
+ selection: TargetSelection,
187
+ env: Record<string, string | undefined> = Bun.env,
188
+ internals: ExecuteFanoutInternals = {},
189
+ options: ExecuteFanoutOptions = {},
190
+ ): Promise<ExecuteFanoutEnvelope> {
191
+ // Validate + canonicalize the command ONCE, before selection expansion: a bad
192
+ // command shape is an outer error regardless of how many targets resolve, and
193
+ // write-ness is target-independent.
194
+ const config = await loadEnvFileDefaults(env);
195
+ const global = resolveExecuteGlobalContext(request, env, config);
196
+ const selectionSummary = summarizeSelection(selection);
197
+
198
+ const expand = internals.expand ?? expandCdbSelection;
199
+ const expansion = await expand(
200
+ selection,
201
+ {
202
+ cdbFile: request.cdbFile,
203
+ cdbPassword: request.cdbPassword,
204
+ allowAdhoc: options.allowAdhoc ?? true,
205
+ },
206
+ env,
207
+ config,
208
+ );
209
+
210
+ const via = fanoutVia(request, env, global.via, expansion);
211
+ const concurrency = resolveFanoutConcurrency(options.concurrency, via);
212
+
213
+ if (expansion.empty) {
214
+ return executeFanoutEnvelope({
215
+ selection: selectionSummary,
216
+ concurrency,
217
+ summary: { total: 0, ok: 0, failed: 0 },
218
+ requestSummary: global.summary,
219
+ settings: global.settings,
220
+ via: null,
221
+ warnings: expansion.warnings,
222
+ targets: [],
223
+ });
224
+ }
225
+
226
+ // Write-shaped fan-out: confirm ONCE up front, naming the blast radius.
227
+ await assertFanoutWriteConfirmed(request, global.summary, expansion);
228
+
229
+ const execute = internals.execute ?? runResolvedExecute;
230
+ const sleep = internals.sleep ?? defaultFanoutSleep;
231
+
232
+ const targets = await runFanout<
233
+ CdbSelectionMember,
234
+ ResolvedExecuteRequest,
235
+ ExecuteEnvelope
236
+ >({
237
+ members: expansion.targets,
238
+ concurrency,
239
+ resolve: (member) => {
240
+ if (member.kind === "cdb") {
241
+ if (isDefaultRecordTarget(member.resolution.target)) {
242
+ throw new CentrsError({
243
+ code: "target/unresolved",
244
+ summary:
245
+ "The `__default__` record is a credential fallback, not a connectable router.",
246
+ remediation:
247
+ "Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
248
+ context: { target: member.resolution.target },
249
+ });
250
+ }
251
+ return resolveExecuteRequest(
252
+ { ...request, targetInput: member.resolution.target },
253
+ env,
254
+ { cdbResolution: member.resolution, config },
255
+ );
256
+ }
257
+ return resolveExecuteRequest(
258
+ { ...request, targetInput: member.input },
259
+ env,
260
+ { config },
261
+ );
262
+ },
263
+ onResolveError: (member, error) =>
264
+ buildFanoutResolveFailure<ExecuteOperationMeta>({
265
+ error,
266
+ target: memberTargetMeta(member),
267
+ warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
268
+ summary: "Failed to resolve a fanout target.",
269
+ }),
270
+ execute: async (resolved, member) =>
271
+ relabelAdhocMember(await execute(resolved), member),
272
+ onExecuteError: (resolved, member, error) =>
273
+ relabelAdhocMember(
274
+ buildExecuteErrorEnvelopeFromResolved(resolved, error),
275
+ member,
276
+ ),
277
+ sleep,
278
+ });
279
+
280
+ return executeFanoutEnvelope({
281
+ selection: selectionSummary,
282
+ concurrency,
283
+ summary: summarizeFanout(targets),
284
+ requestSummary: global.summary,
285
+ settings: global.settings,
286
+ via: commonVia(targets),
287
+ warnings: expansion.warnings,
288
+ targets,
289
+ });
290
+ }
291
+
292
+ async function assertFanoutWriteConfirmed(
293
+ request: ExecuteRequest,
294
+ summary: ExecuteRequestSummary,
295
+ expansion: CdbSelectionExpansion,
296
+ ): Promise<void> {
297
+ if (!summary.write || request.yes) {
298
+ return;
299
+ }
300
+ const count = expansion.targets.length;
301
+ const prompt = `Run write-shaped \`${summary.command}\` across ${count} router(s)? Type yes to continue: `;
302
+ if (request.stdinIsTty ?? process.stdin.isTTY) {
303
+ const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
304
+ prompt,
305
+ );
306
+ if (confirmed) {
307
+ return;
308
+ }
309
+ }
310
+ throw new CentrsError({
311
+ code: "usage/confirmation-required",
312
+ summary: `A write-shaped \`${summary.command}\` would run across ${count} router(s) and needs explicit confirmation.`,
313
+ remediation:
314
+ "Pass `--yes` to fan the write out to every selected router, or answer `yes` at the TTY prompt after reviewing the blast radius.",
315
+ context: { command: summary.command, routers: count },
316
+ });
317
+ }
318
+
319
+ interface ExecuteFanoutEnvelopeInput {
320
+ selection: ExecuteSelectionSummary;
321
+ concurrency: number;
322
+ summary: FanoutSummary;
323
+ requestSummary: ExecuteRequestSummary;
324
+ settings: CommonSettingsMeta;
325
+ via: RouterOsProtocol | null;
326
+ warnings: CdbSelectionExpansion["warnings"];
327
+ targets: readonly ExecuteEnvelope[];
328
+ }
329
+
330
+ function executeFanoutEnvelope(
331
+ input: ExecuteFanoutEnvelopeInput,
332
+ ): ExecuteFanoutEnvelope {
333
+ return buildFanoutEnvelope<
334
+ unknown,
335
+ ExecuteOperationMeta,
336
+ ExecuteFanoutOperationMeta
337
+ >({
338
+ summary: input.summary,
339
+ targets: input.targets,
340
+ warnings: input.warnings,
341
+ settings: input.settings,
342
+ via: input.via,
343
+ operation: {
344
+ kind: "fanout",
345
+ concurrency: input.concurrency,
346
+ summary: input.summary,
347
+ request: input.requestSummary,
348
+ selection: input.selection,
349
+ },
350
+ });
351
+ }
352
+
353
+ /** Outer error envelope for a fan-out that failed before per-target results. */
354
+ export function buildExecuteFanoutErrorEnvelope(
355
+ request: ExecuteRequest,
356
+ error: unknown,
357
+ ): ExecuteFanoutErrorEnvelope {
358
+ const centrsError =
359
+ error instanceof CentrsError
360
+ ? error
361
+ : new CentrsError({
362
+ code: "internal/unhandled",
363
+ summary: "execute fan-out failed with an unexpected error.",
364
+ remediation:
365
+ "Re-run with `--format json` to capture the structured error details.",
366
+ cause: error,
367
+ });
368
+ const requestedVia = plannedProtocols.includes(
369
+ request.via as RouterOsProtocol,
370
+ )
371
+ ? (request.via as RouterOsProtocol)
372
+ : null;
373
+ return {
374
+ ok: false,
375
+ error: serializeCentrsError(centrsError),
376
+ warnings: [],
377
+ tips: [],
378
+ meta: {
379
+ target: {},
380
+ via: requestedVia,
381
+ settings: {},
382
+ },
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Render an execute fan-out envelope. `json`/`yaml` serialize the whole envelope;
388
+ * `text` renders a summary line plus one line per target in record-index order.
389
+ */
390
+ export function renderExecuteFanoutEnvelope(
391
+ envelope: ExecuteFanoutEnvelope | ExecuteFanoutErrorEnvelope,
392
+ format: "json" | "yaml" | "text",
393
+ options: { verbose?: boolean } = {},
394
+ ): string {
395
+ if (format === "json") {
396
+ return JSON.stringify(envelope, null, 2);
397
+ }
398
+ if (format === "yaml") {
399
+ return toYaml(envelope);
400
+ }
401
+ if (!envelope.ok) {
402
+ const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
403
+ if (envelope.error.remediation) {
404
+ lines.push(`Fix: ${envelope.error.remediation}`);
405
+ }
406
+ return lines.join("\n");
407
+ }
408
+ const operation = envelope.meta.operation;
409
+ const { total, ok, failed } = envelope.data.summary;
410
+ const lines: string[] = [
411
+ `execute ${operation?.request.command ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
412
+ ];
413
+ for (const target of envelope.data.targets) {
414
+ const meta = target.meta;
415
+ const label =
416
+ meta.target.identity ??
417
+ meta.target.host ??
418
+ meta.target.input ??
419
+ "(unknown)";
420
+ const index = meta.target.recordIndex ?? "-";
421
+ if (target.ok) {
422
+ lines.push(
423
+ ` [${index}] ok ${label} (${target.meta.operation?.objectCount ?? 0} object(s))`,
424
+ );
425
+ } else {
426
+ lines.push(
427
+ ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
428
+ );
429
+ if (options.verbose && target.error.remediation) {
430
+ lines.push(` Fix: ${target.error.remediation}`);
431
+ }
432
+ }
433
+ }
434
+ for (const warning of envelope.warnings) {
435
+ lines.push(`warning [${warning.code}]: ${warning.message}`);
436
+ }
437
+ return lines.join("\n");
438
+ }