@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.
- package/CHANGELOG.md +107 -2
- package/GLOSSARY.txt +87 -0
- package/README.md +25 -9
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +177 -0
- package/commands/api/examples.md +428 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- 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 +48 -7
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +37 -51
- 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 +40 -15
- package/docs/MATRIX.md +69 -142
- 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/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -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/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +9 -9
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +1627 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +660 -0
- package/src/cli/btest.ts +20 -2
- package/src/cli/common.ts +75 -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 +29 -0
- package/src/core/error-catalog.ts +69 -0
- package/src/core/fanout.ts +301 -0
- package/src/core/inspect.ts +170 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +134 -70
- package/src/index.ts +166 -9
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +357 -9
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/index.ts +11 -5
- package/src/protocols/native-api.ts +223 -30
- 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 +232 -268
- package/src/retrieve.ts +69 -94
- 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/commands/stream/README.md +0 -70
package/src/terminal.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type CdbResolution,
|
|
32
32
|
isIpTransport,
|
|
33
33
|
isMacAddress,
|
|
34
|
+
loadEnvFileDefaults,
|
|
34
35
|
parseResolvePolicy,
|
|
35
36
|
type ResolvedAuth,
|
|
36
37
|
type ResolvedTarget,
|
|
@@ -147,6 +148,7 @@ export async function resolveTerminalRequest(
|
|
|
147
148
|
if (preliminaryVia !== undefined) {
|
|
148
149
|
gateTerminalVia(preliminaryVia);
|
|
149
150
|
}
|
|
151
|
+
const config = await loadEnvFileDefaults(env);
|
|
150
152
|
const cdb = await resolveCdb(
|
|
151
153
|
{
|
|
152
154
|
targetInput: request.targetInput,
|
|
@@ -154,6 +156,7 @@ export async function resolveTerminalRequest(
|
|
|
154
156
|
cdbPassword: request.cdbPassword,
|
|
155
157
|
},
|
|
156
158
|
env,
|
|
159
|
+
config,
|
|
157
160
|
);
|
|
158
161
|
// A MAC target defaults to the L2 console (mac-telnet); any other target
|
|
159
162
|
// (host / IP / CDB identity) defaults to ssh — the IP-level terminal transport.
|
|
@@ -168,6 +171,7 @@ export async function resolveTerminalRequest(
|
|
|
168
171
|
"via",
|
|
169
172
|
undefined,
|
|
170
173
|
cdb?.overrides.via,
|
|
174
|
+
config,
|
|
171
175
|
);
|
|
172
176
|
const viaValue = via?.value ?? defaultVia;
|
|
173
177
|
// Re-gate on the fully resolved value to catch a CDB comment-kv `via=` override.
|
|
@@ -183,7 +187,10 @@ export async function resolveTerminalRequest(
|
|
|
183
187
|
targetInput: request.targetInput,
|
|
184
188
|
cdbTarget: cdb?.target,
|
|
185
189
|
env,
|
|
186
|
-
|
|
190
|
+
config,
|
|
191
|
+
policy: parseResolvePolicy(
|
|
192
|
+
request.resolve ?? env["CENTRS_RESOLVE"] ?? config["CENTRS_RESOLVE"],
|
|
193
|
+
),
|
|
187
194
|
operation: "terminal",
|
|
188
195
|
})
|
|
189
196
|
: undefined;
|
|
@@ -197,6 +204,7 @@ export async function resolveTerminalRequest(
|
|
|
197
204
|
env,
|
|
198
205
|
viaValue === "ssh" ? "ssh" : "mac-telnet",
|
|
199
206
|
cdb,
|
|
207
|
+
config,
|
|
200
208
|
);
|
|
201
209
|
const auth = resolveAuth(
|
|
202
210
|
{
|
|
@@ -206,6 +214,7 @@ export async function resolveTerminalRequest(
|
|
|
206
214
|
},
|
|
207
215
|
env,
|
|
208
216
|
cdb,
|
|
217
|
+
config,
|
|
209
218
|
);
|
|
210
219
|
const insecure = resolveBooleanSetting(
|
|
211
220
|
request.insecure,
|
|
@@ -214,6 +223,7 @@ export async function resolveTerminalRequest(
|
|
|
214
223
|
false,
|
|
215
224
|
"insecure",
|
|
216
225
|
cdb?.overrides.insecure,
|
|
226
|
+
config,
|
|
217
227
|
);
|
|
218
228
|
return {
|
|
219
229
|
via: viaValue,
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-target fan-out for `transfer`.
|
|
3
|
+
*
|
|
4
|
+
* A target selection (`--group` / `--where` / `--all` / `--default` / multiple
|
|
5
|
+
* positionals before the verb) runs the SAME transfer verb on each member through
|
|
6
|
+
* the single-target tail (`transfer`), collecting per-target inner
|
|
7
|
+
* `TransferEnvelope`s into the locked `FanoutData` shape (`src/core/envelope.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Boundaries (see `commands/transfer/README.md`, `docs/CONSTITUTION.md`), shared
|
|
10
|
+
* with `api`/`retrieve`/`execute` fan-out:
|
|
11
|
+
* - The CDB is loaded ONCE (`expandCdbSelection`); a cdb member is resolved with
|
|
12
|
+
* its pre-resolved record, so `resolveCdb`'s `__default__` synthetic fallback
|
|
13
|
+
* never collides ad-hoc literals.
|
|
14
|
+
* - A `--default` / `__default__` member fails that one target deterministically
|
|
15
|
+
* with `target/unresolved`.
|
|
16
|
+
* - A **mutating** verb (`upload`/`remove`/`mkdir`/`copy`) is confirmed ONCE up
|
|
17
|
+
* front (`--yes`), never per target; without `--yes` the error names the blast
|
|
18
|
+
* radius. `download`/`list` only read the device.
|
|
19
|
+
* - **`download` fan-out requires `--out-dir`**: N devices cannot share one local
|
|
20
|
+
* path, so each target writes a collision-safe file named by its CDB
|
|
21
|
+
* target/identity into the directory.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { basename, extname, join } from "node:path";
|
|
25
|
+
import type {
|
|
26
|
+
CentrsErrorEnvelope,
|
|
27
|
+
CentrsSuccessEnvelope,
|
|
28
|
+
CommonSettingsMeta,
|
|
29
|
+
FanoutData,
|
|
30
|
+
FanoutSummary,
|
|
31
|
+
} from "./core/envelope.ts";
|
|
32
|
+
import {
|
|
33
|
+
buildFanoutEnvelope,
|
|
34
|
+
buildFanoutResolveFailure,
|
|
35
|
+
commonVia,
|
|
36
|
+
defaultFanoutSleep,
|
|
37
|
+
resolveFanoutConcurrency,
|
|
38
|
+
runFanout,
|
|
39
|
+
summarizeFanout,
|
|
40
|
+
} from "./core/fanout.ts";
|
|
41
|
+
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
42
|
+
import { promptForWriteConfirmation } from "./execute.ts";
|
|
43
|
+
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
44
|
+
import {
|
|
45
|
+
type CdbSelectionExpansion,
|
|
46
|
+
type CdbSelectionMember,
|
|
47
|
+
type CdbSelectionResolveInput,
|
|
48
|
+
expandCdbSelection,
|
|
49
|
+
isDefaultRecordTarget,
|
|
50
|
+
loadEnvFileDefaults,
|
|
51
|
+
type TargetSelection,
|
|
52
|
+
} from "./resolver/index.ts";
|
|
53
|
+
import { toYaml } from "./retrieve.ts";
|
|
54
|
+
import {
|
|
55
|
+
buildTransferErrorEnvelope,
|
|
56
|
+
isTransferWriteVerb,
|
|
57
|
+
type TransferEnvelope,
|
|
58
|
+
type TransferOperationMeta,
|
|
59
|
+
type TransferOutputFormat,
|
|
60
|
+
type TransferRequest,
|
|
61
|
+
type TransferSuccessEnvelope,
|
|
62
|
+
type TransferVerb,
|
|
63
|
+
transfer,
|
|
64
|
+
validateTransferRequestShape,
|
|
65
|
+
} from "./transfer.ts";
|
|
66
|
+
|
|
67
|
+
/** Compact description of the selector that produced the fan-out. */
|
|
68
|
+
export interface TransferSelectionSummary {
|
|
69
|
+
groups: readonly string[];
|
|
70
|
+
where: readonly string[];
|
|
71
|
+
all: boolean;
|
|
72
|
+
default: boolean;
|
|
73
|
+
positionals: readonly string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TransferFanoutRequestSummary {
|
|
77
|
+
verb: TransferVerb;
|
|
78
|
+
remote: string | null;
|
|
79
|
+
force: boolean;
|
|
80
|
+
validate: boolean;
|
|
81
|
+
outDir: string | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface TransferFanoutOperationMeta {
|
|
85
|
+
kind: "fanout";
|
|
86
|
+
concurrency: number;
|
|
87
|
+
summary: FanoutSummary;
|
|
88
|
+
request: TransferFanoutRequestSummary;
|
|
89
|
+
selection: TransferSelectionSummary;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type TransferFanoutData = FanoutData<unknown, TransferOperationMeta>;
|
|
93
|
+
export type TransferFanoutEnvelope = CentrsSuccessEnvelope<
|
|
94
|
+
TransferFanoutData,
|
|
95
|
+
TransferFanoutOperationMeta
|
|
96
|
+
>;
|
|
97
|
+
export type TransferFanoutErrorEnvelope =
|
|
98
|
+
CentrsErrorEnvelope<TransferFanoutOperationMeta>;
|
|
99
|
+
|
|
100
|
+
/** Test/override seams; production callers pass nothing. */
|
|
101
|
+
export interface TransferFanoutInternals {
|
|
102
|
+
expand?: (
|
|
103
|
+
selection: TargetSelection,
|
|
104
|
+
input: CdbSelectionResolveInput,
|
|
105
|
+
env: Record<string, string | undefined>,
|
|
106
|
+
config?: Record<string, string | undefined>,
|
|
107
|
+
) => Promise<CdbSelectionExpansion>;
|
|
108
|
+
execute?: (
|
|
109
|
+
request: TransferRequest,
|
|
110
|
+
member: CdbSelectionMember,
|
|
111
|
+
) => Promise<TransferSuccessEnvelope>;
|
|
112
|
+
sleep?: (ms: number) => Promise<void>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface TransferFanoutOptions {
|
|
116
|
+
concurrency?: number;
|
|
117
|
+
/** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
|
|
118
|
+
allowAdhoc?: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function summarizeSelection(
|
|
122
|
+
selection: TargetSelection,
|
|
123
|
+
): TransferSelectionSummary {
|
|
124
|
+
return {
|
|
125
|
+
groups: selection.groups,
|
|
126
|
+
where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
|
|
127
|
+
all: selection.all,
|
|
128
|
+
default: selection.default,
|
|
129
|
+
positionals: selection.positionals,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function memberLabel(member: CdbSelectionMember): string {
|
|
134
|
+
return member.kind === "cdb" ? member.resolution.identity : member.input;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function memberTargetMeta(member: CdbSelectionMember): {
|
|
138
|
+
input?: string;
|
|
139
|
+
identity?: string;
|
|
140
|
+
recordIndex?: number;
|
|
141
|
+
} {
|
|
142
|
+
if (member.kind === "cdb") {
|
|
143
|
+
return {
|
|
144
|
+
input: member.resolution.identity,
|
|
145
|
+
identity: member.resolution.identity,
|
|
146
|
+
recordIndex: member.recordIndex,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { input: member.input };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Sanitize a device label into a single safe filename segment. */
|
|
153
|
+
function sanitizeFilename(label: string): string {
|
|
154
|
+
// Character-class replaces (no backtracking); strip leading dots so a label
|
|
155
|
+
// cannot produce a hidden / traversal-looking name.
|
|
156
|
+
const cleaned = label.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+/, "");
|
|
157
|
+
return cleaned.length > 0 ? cleaned : "target";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pre-compute one collision-safe local path per member for a `download` fan-out.
|
|
162
|
+
* Files are named by the device label (CDB identity / literal input) and keep the
|
|
163
|
+
* remote file's extension; same-label collisions are disambiguated deterministically.
|
|
164
|
+
*/
|
|
165
|
+
function planDownloadPaths(
|
|
166
|
+
outDir: string,
|
|
167
|
+
remote: string,
|
|
168
|
+
members: readonly CdbSelectionMember[],
|
|
169
|
+
): Map<CdbSelectionMember, string> {
|
|
170
|
+
const ext = extname(basename(remote));
|
|
171
|
+
const used = new Set<string>();
|
|
172
|
+
const paths = new Map<CdbSelectionMember, string>();
|
|
173
|
+
members.forEach((member, index) => {
|
|
174
|
+
const label = sanitizeFilename(memberLabel(member));
|
|
175
|
+
let name = `${label}${ext}`;
|
|
176
|
+
if (used.has(name)) {
|
|
177
|
+
const disambiguator =
|
|
178
|
+
member.kind === "cdb" ? `-${member.recordIndex}` : `-${index}`;
|
|
179
|
+
name = `${label}${disambiguator}${ext}`;
|
|
180
|
+
let bump = 1;
|
|
181
|
+
while (used.has(name)) {
|
|
182
|
+
name = `${label}${disambiguator}-${bump}${ext}`;
|
|
183
|
+
bump += 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
used.add(name);
|
|
187
|
+
paths.set(member, join(outDir, name));
|
|
188
|
+
});
|
|
189
|
+
return paths;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Ad-hoc literal targets have no CDB record. The resolver may borrow the
|
|
194
|
+
* `__default__` record's index for fallback creds, so the inner envelope would
|
|
195
|
+
* carry that borrowed `recordIndex` — making distinct literals collide on one
|
|
196
|
+
* index. Drop it for literal members and stamp the caller's `input`.
|
|
197
|
+
*/
|
|
198
|
+
function relabelAdhocMember(
|
|
199
|
+
envelope: TransferEnvelope,
|
|
200
|
+
member: CdbSelectionMember,
|
|
201
|
+
): TransferEnvelope {
|
|
202
|
+
if (member.kind !== "literal") {
|
|
203
|
+
return envelope;
|
|
204
|
+
}
|
|
205
|
+
const { recordIndex: _borrowed, ...target } = envelope.meta.target;
|
|
206
|
+
return {
|
|
207
|
+
...envelope,
|
|
208
|
+
meta: { ...envelope.meta, target: { ...target, input: member.input } },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Stamp the member's identity onto a per-target ENVELOPE. `buildTransferErrorEnvelope`
|
|
214
|
+
* only knows `request.targetInput`, so a failed cdb member would otherwise lose its
|
|
215
|
+
* `recordIndex`/`identity` (breaking record-index ordering). Literals are relabeled
|
|
216
|
+
* to drop a borrowed `__default__` index; cdb members get their record meta restored.
|
|
217
|
+
*/
|
|
218
|
+
function applyMemberMeta(
|
|
219
|
+
envelope: TransferEnvelope,
|
|
220
|
+
member: CdbSelectionMember,
|
|
221
|
+
): TransferEnvelope {
|
|
222
|
+
if (member.kind === "literal") {
|
|
223
|
+
return relabelAdhocMember(envelope, member);
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
...envelope,
|
|
227
|
+
meta: {
|
|
228
|
+
...envelope.meta,
|
|
229
|
+
target: { ...envelope.meta.target, ...memberTargetMeta(member) },
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Run a `transfer` fan-out. Returns a success envelope when the orchestration
|
|
236
|
+
* completes (even if every target failed); throws only for pre-flight failures
|
|
237
|
+
* (bad request shape, missing `--out-dir`, CDB decrypt, unconfirmed write) the
|
|
238
|
+
* caller renders as an outer error via {@link buildTransferFanoutErrorEnvelope}.
|
|
239
|
+
*/
|
|
240
|
+
export async function transferFanout(
|
|
241
|
+
request: TransferRequest,
|
|
242
|
+
selection: TargetSelection,
|
|
243
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
244
|
+
internals: TransferFanoutInternals = {},
|
|
245
|
+
options: TransferFanoutOptions = {},
|
|
246
|
+
): Promise<TransferFanoutEnvelope> {
|
|
247
|
+
validateTransferRequestShape(request);
|
|
248
|
+
const config = await loadEnvFileDefaults(env);
|
|
249
|
+
const selectionSummary = summarizeSelection(selection);
|
|
250
|
+
const requestSummary: TransferFanoutRequestSummary = {
|
|
251
|
+
verb: request.verb,
|
|
252
|
+
remote: request.remote ?? request.path ?? null,
|
|
253
|
+
force: request.force ?? false,
|
|
254
|
+
validate: request.validate ?? true,
|
|
255
|
+
outDir: request.outDir ?? null,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// `download` fan-out needs a per-target local path: `--out-dir` is mandatory
|
|
259
|
+
// (N devices cannot share one local path).
|
|
260
|
+
if (request.verb === "download" && request.outDir === undefined) {
|
|
261
|
+
throw new CentrsError({
|
|
262
|
+
code: "usage/conflicting-flags",
|
|
263
|
+
summary:
|
|
264
|
+
"`download` fan-out writes one file per target and requires `--out-dir <dir>`.",
|
|
265
|
+
remediation:
|
|
266
|
+
"Pass `--out-dir <dir>`; each target's file is written there, named by its CDB identity.",
|
|
267
|
+
context: { verb: "download" },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const expand = internals.expand ?? expandCdbSelection;
|
|
272
|
+
const expansion = await expand(
|
|
273
|
+
selection,
|
|
274
|
+
{
|
|
275
|
+
cdbFile: request.cdbFile,
|
|
276
|
+
cdbPassword: request.cdbPassword,
|
|
277
|
+
allowAdhoc: options.allowAdhoc ?? true,
|
|
278
|
+
},
|
|
279
|
+
env,
|
|
280
|
+
config,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const concurrency = resolveFanoutConcurrency(options.concurrency, "rest-api");
|
|
284
|
+
|
|
285
|
+
if (expansion.empty) {
|
|
286
|
+
return transferFanoutEnvelope({
|
|
287
|
+
selection: selectionSummary,
|
|
288
|
+
concurrency,
|
|
289
|
+
summary: { total: 0, ok: 0, failed: 0 },
|
|
290
|
+
requestSummary,
|
|
291
|
+
via: null,
|
|
292
|
+
warnings: expansion.warnings,
|
|
293
|
+
targets: [],
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Mutating verbs (upload/remove/mkdir/copy): confirm ONCE up front.
|
|
298
|
+
if (isTransferWriteVerb(request.verb)) {
|
|
299
|
+
await assertFanoutWriteConfirmed(request, expansion);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const downloadPaths =
|
|
303
|
+
request.verb === "download"
|
|
304
|
+
? planDownloadPaths(
|
|
305
|
+
request.outDir ?? ".",
|
|
306
|
+
request.remote ?? "",
|
|
307
|
+
expansion.targets,
|
|
308
|
+
)
|
|
309
|
+
: undefined;
|
|
310
|
+
|
|
311
|
+
const execute =
|
|
312
|
+
internals.execute ??
|
|
313
|
+
((req: TransferRequest, member: CdbSelectionMember) =>
|
|
314
|
+
transfer(req, env, {
|
|
315
|
+
// cdb members carry their pre-resolved record (CDB loaded once);
|
|
316
|
+
// literals fall through to single-target resolution.
|
|
317
|
+
cdbResolution: member.kind === "cdb" ? member.resolution : undefined,
|
|
318
|
+
}));
|
|
319
|
+
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
320
|
+
|
|
321
|
+
const targets = await runFanout<
|
|
322
|
+
CdbSelectionMember,
|
|
323
|
+
{ request: TransferRequest; member: CdbSelectionMember },
|
|
324
|
+
TransferEnvelope
|
|
325
|
+
>({
|
|
326
|
+
members: expansion.targets,
|
|
327
|
+
concurrency,
|
|
328
|
+
resolve: (member) => {
|
|
329
|
+
if (
|
|
330
|
+
member.kind === "cdb" &&
|
|
331
|
+
isDefaultRecordTarget(member.resolution.target)
|
|
332
|
+
) {
|
|
333
|
+
throw new CentrsError({
|
|
334
|
+
code: "target/unresolved",
|
|
335
|
+
summary:
|
|
336
|
+
"The `__default__` record is a credential fallback, not a connectable router.",
|
|
337
|
+
remediation:
|
|
338
|
+
"Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
|
|
339
|
+
context: { target: member.resolution.target },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const targetInput =
|
|
343
|
+
member.kind === "cdb" ? member.resolution.target : member.input;
|
|
344
|
+
const perRequest: TransferRequest = { ...request, targetInput };
|
|
345
|
+
if (downloadPaths !== undefined) {
|
|
346
|
+
perRequest.local = downloadPaths.get(member);
|
|
347
|
+
}
|
|
348
|
+
return { request: perRequest, member };
|
|
349
|
+
},
|
|
350
|
+
onResolveError: (member, error) =>
|
|
351
|
+
buildFanoutResolveFailure<TransferOperationMeta>({
|
|
352
|
+
error,
|
|
353
|
+
target: memberTargetMeta(member),
|
|
354
|
+
warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
|
|
355
|
+
summary: "Failed to resolve a fanout target.",
|
|
356
|
+
}),
|
|
357
|
+
execute: async (resolved, member) =>
|
|
358
|
+
relabelAdhocMember(await execute(resolved.request, member), member),
|
|
359
|
+
onExecuteError: (resolved, member, error) =>
|
|
360
|
+
applyMemberMeta(
|
|
361
|
+
buildTransferErrorEnvelope(resolved.request, error),
|
|
362
|
+
member,
|
|
363
|
+
),
|
|
364
|
+
sleep,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
return transferFanoutEnvelope({
|
|
368
|
+
selection: selectionSummary,
|
|
369
|
+
concurrency,
|
|
370
|
+
summary: summarizeFanout(targets),
|
|
371
|
+
requestSummary,
|
|
372
|
+
via: commonVia(targets),
|
|
373
|
+
warnings: expansion.warnings,
|
|
374
|
+
targets,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function assertFanoutWriteConfirmed(
|
|
379
|
+
request: TransferRequest,
|
|
380
|
+
expansion: CdbSelectionExpansion,
|
|
381
|
+
): Promise<void> {
|
|
382
|
+
if (request.yes) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const count = expansion.targets.length;
|
|
386
|
+
const prompt = `Run mutating \`${request.verb}\` across ${count} router(s)? Type yes to continue: `;
|
|
387
|
+
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
388
|
+
const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
|
|
389
|
+
prompt,
|
|
390
|
+
);
|
|
391
|
+
if (confirmed) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
throw new CentrsError({
|
|
396
|
+
code: "usage/confirmation-required",
|
|
397
|
+
summary: `A mutating \`${request.verb}\` would run across ${count} router(s) and needs explicit confirmation.`,
|
|
398
|
+
remediation:
|
|
399
|
+
"Pass `--yes` to fan the write out to every selected router, or answer `yes` at the TTY prompt after reviewing the blast radius.",
|
|
400
|
+
context: { verb: request.verb, routers: count },
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
interface TransferFanoutEnvelopeInput {
|
|
405
|
+
selection: TransferSelectionSummary;
|
|
406
|
+
concurrency: number;
|
|
407
|
+
summary: FanoutSummary;
|
|
408
|
+
requestSummary: TransferFanoutRequestSummary;
|
|
409
|
+
via: RouterOsProtocol | string | null;
|
|
410
|
+
warnings: CdbSelectionExpansion["warnings"];
|
|
411
|
+
targets: readonly TransferEnvelope[];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function transferFanoutEnvelope(
|
|
415
|
+
input: TransferFanoutEnvelopeInput,
|
|
416
|
+
): TransferFanoutEnvelope {
|
|
417
|
+
const settings: CommonSettingsMeta = {};
|
|
418
|
+
return buildFanoutEnvelope<
|
|
419
|
+
unknown,
|
|
420
|
+
TransferOperationMeta,
|
|
421
|
+
TransferFanoutOperationMeta
|
|
422
|
+
>({
|
|
423
|
+
summary: input.summary,
|
|
424
|
+
targets: input.targets,
|
|
425
|
+
warnings: input.warnings,
|
|
426
|
+
settings,
|
|
427
|
+
via: (input.via as RouterOsProtocol | null) ?? null,
|
|
428
|
+
operation: {
|
|
429
|
+
kind: "fanout",
|
|
430
|
+
concurrency: input.concurrency,
|
|
431
|
+
summary: input.summary,
|
|
432
|
+
request: input.requestSummary,
|
|
433
|
+
selection: input.selection,
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Outer error envelope for a fan-out that failed before per-target results. */
|
|
439
|
+
export function buildTransferFanoutErrorEnvelope(
|
|
440
|
+
request: TransferRequest,
|
|
441
|
+
error: unknown,
|
|
442
|
+
): TransferFanoutErrorEnvelope {
|
|
443
|
+
const centrsError =
|
|
444
|
+
error instanceof CentrsError
|
|
445
|
+
? error
|
|
446
|
+
: new CentrsError({
|
|
447
|
+
code: "internal/unhandled",
|
|
448
|
+
summary: "transfer fan-out failed with an unexpected error.",
|
|
449
|
+
remediation:
|
|
450
|
+
"Re-run with `--format json` to capture the structured error details.",
|
|
451
|
+
cause: error,
|
|
452
|
+
});
|
|
453
|
+
const requestedVia = plannedProtocols.includes(
|
|
454
|
+
request.via as RouterOsProtocol,
|
|
455
|
+
)
|
|
456
|
+
? (request.via as RouterOsProtocol)
|
|
457
|
+
: null;
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
error: serializeCentrsError(centrsError),
|
|
461
|
+
warnings: [],
|
|
462
|
+
tips: [],
|
|
463
|
+
meta: {
|
|
464
|
+
target: {},
|
|
465
|
+
via: requestedVia,
|
|
466
|
+
settings: {},
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Render a transfer fan-out envelope. `json`/`yaml` serialize the whole envelope;
|
|
473
|
+
* `text` renders a summary line plus one line per target in record-index order.
|
|
474
|
+
*/
|
|
475
|
+
export function renderTransferFanoutEnvelope(
|
|
476
|
+
envelope: TransferFanoutEnvelope | TransferFanoutErrorEnvelope,
|
|
477
|
+
format: TransferOutputFormat,
|
|
478
|
+
options: { verbose?: boolean } = {},
|
|
479
|
+
): string {
|
|
480
|
+
if (format === "json") {
|
|
481
|
+
return JSON.stringify(envelope, null, 2);
|
|
482
|
+
}
|
|
483
|
+
if (format === "yaml") {
|
|
484
|
+
return toYaml(envelope);
|
|
485
|
+
}
|
|
486
|
+
if (!envelope.ok) {
|
|
487
|
+
const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
|
|
488
|
+
if (envelope.error.remediation) {
|
|
489
|
+
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
490
|
+
}
|
|
491
|
+
return lines.join("\n");
|
|
492
|
+
}
|
|
493
|
+
const operation = envelope.meta.operation;
|
|
494
|
+
const { total, ok, failed } = envelope.data.summary;
|
|
495
|
+
const remote = operation?.request.remote ?? "";
|
|
496
|
+
const lines: string[] = [
|
|
497
|
+
`transfer ${operation?.request.verb ?? "?"} ${remote}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
|
|
498
|
+
];
|
|
499
|
+
for (const target of envelope.data.targets) {
|
|
500
|
+
const meta = target.meta;
|
|
501
|
+
const label =
|
|
502
|
+
meta.target.identity ??
|
|
503
|
+
meta.target.host ??
|
|
504
|
+
meta.target.input ??
|
|
505
|
+
"(unknown)";
|
|
506
|
+
const index = meta.target.recordIndex ?? "-";
|
|
507
|
+
if (target.ok) {
|
|
508
|
+
const data = Array.isArray(target.data) ? undefined : target.data;
|
|
509
|
+
const detail =
|
|
510
|
+
data && typeof data === "object" && "local" in data && data.local
|
|
511
|
+
? ` → ${String((data as { local?: string }).local)}`
|
|
512
|
+
: "";
|
|
513
|
+
lines.push(` [${index}] ok ${label}${detail}`);
|
|
514
|
+
} else {
|
|
515
|
+
lines.push(
|
|
516
|
+
` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
|
|
517
|
+
);
|
|
518
|
+
if (options.verbose && target.error.remediation) {
|
|
519
|
+
lines.push(` Fix: ${target.error.remediation}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
for (const warning of envelope.warnings) {
|
|
524
|
+
lines.push(`warning [${warning.code}]: ${warning.message}`);
|
|
525
|
+
}
|
|
526
|
+
return lines.join("\n");
|
|
527
|
+
}
|