@tikoci/centrs 0.1.0

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 (182) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/GLOSSARY.txt +420 -0
  3. package/README.md +196 -0
  4. package/SECURITY.md +30 -0
  5. package/commands/AGENTS.md +50 -0
  6. package/commands/btest/README.md +257 -0
  7. package/commands/btest/examples.md +190 -0
  8. package/commands/check/README.md +59 -0
  9. package/commands/devices/README.md +405 -0
  10. package/commands/devices/examples.md +488 -0
  11. package/commands/discover/README.md +170 -0
  12. package/commands/discover/examples.md +98 -0
  13. package/commands/execute/README.md +208 -0
  14. package/commands/execute/examples.md +353 -0
  15. package/commands/mcp/README.md +260 -0
  16. package/commands/mcp/examples.md +158 -0
  17. package/commands/retrieve/README.md +209 -0
  18. package/commands/retrieve/examples.md +306 -0
  19. package/commands/settings/README.md +69 -0
  20. package/commands/stream/README.md +70 -0
  21. package/commands/terminal/README.md +177 -0
  22. package/commands/terminal/examples.md +144 -0
  23. package/commands/transfer/README.md +315 -0
  24. package/commands/transfer/examples.md +368 -0
  25. package/docs/CONSTITUTION.md +417 -0
  26. package/docs/MATRIX.md +224 -0
  27. package/docs/errors/README.md +74 -0
  28. package/docs/errors/auth/failed.md +9 -0
  29. package/docs/errors/cdb/already-exists.md +9 -0
  30. package/docs/errors/cdb/created.md +9 -0
  31. package/docs/errors/cdb/decrypt-failed.md +9 -0
  32. package/docs/errors/cdb/empty-group.md +9 -0
  33. package/docs/errors/cdb/invalid-option.md +9 -0
  34. package/docs/errors/cdb/invalid-record.md +9 -0
  35. package/docs/errors/cdb/not-found-target.md +9 -0
  36. package/docs/errors/cdb/not-found.md +9 -0
  37. package/docs/errors/cdb/override-applied.md +9 -0
  38. package/docs/errors/cdb/parse-failed.md +9 -0
  39. package/docs/errors/cdb/password-not-needed.md +9 -0
  40. package/docs/errors/cdb/password-required.md +9 -0
  41. package/docs/errors/cdb/reserved-key.md +9 -0
  42. package/docs/errors/cdb/reserved-option.md +9 -0
  43. package/docs/errors/cdb/target-not-registered.md +9 -0
  44. package/docs/errors/cdb/unknown-field.md +9 -0
  45. package/docs/errors/cdb/unknown-option.md +9 -0
  46. package/docs/errors/cdb/write-not-permitted.md +9 -0
  47. package/docs/errors/discover/broadcast-unavailable.md +9 -0
  48. package/docs/errors/discover/failed.md +9 -0
  49. package/docs/errors/identity/ambiguous.md +9 -0
  50. package/docs/errors/identity/no-match.md +9 -0
  51. package/docs/errors/input/invalid-command.md +9 -0
  52. package/docs/errors/input/invalid-match.md +9 -0
  53. package/docs/errors/input/invalid-path.md +9 -0
  54. package/docs/errors/input/invalid-routeros-path.md +9 -0
  55. package/docs/errors/input/invalid-target.md +9 -0
  56. package/docs/errors/input/local-file-not-found.md +9 -0
  57. package/docs/errors/input/mac-address.md +9 -0
  58. package/docs/errors/input/max-results-exceeded.md +9 -0
  59. package/docs/errors/internal/devices-failed.md +9 -0
  60. package/docs/errors/internal/mcp-start.md +9 -0
  61. package/docs/errors/internal/mcp-tool.md +9 -0
  62. package/docs/errors/internal/native-api-length.md +9 -0
  63. package/docs/errors/internal/unhandled.md +9 -0
  64. package/docs/errors/internal/unreachable.md +9 -0
  65. package/docs/errors/mndp/encode-failed.md +9 -0
  66. package/docs/errors/mndp/listen-failed.md +9 -0
  67. package/docs/errors/mndp/malformed.md +9 -0
  68. package/docs/errors/routeros/api-fatal.md +9 -0
  69. package/docs/errors/routeros/api-protocol.md +9 -0
  70. package/docs/errors/routeros/api-trap.md +9 -0
  71. package/docs/errors/routeros/btest-protocol.md +12 -0
  72. package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
  73. package/docs/errors/routeros/command-failed.md +14 -0
  74. package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
  75. package/docs/errors/routeros/error.md +9 -0
  76. package/docs/errors/routeros/invalid-value.md +14 -0
  77. package/docs/errors/routeros/mac-telnet-error.md +9 -0
  78. package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
  79. package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
  80. package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
  81. package/docs/errors/routeros/protocol-not-implemented.md +9 -0
  82. package/docs/errors/routeros/request-failed.md +9 -0
  83. package/docs/errors/routeros/session-closed.md +13 -0
  84. package/docs/errors/routeros/unknown-attribute.md +13 -0
  85. package/docs/errors/routeros/unknown-path.md +15 -0
  86. package/docs/errors/routeros/unsupported-capability.md +9 -0
  87. package/docs/errors/settings/invalid-boolean.md +9 -0
  88. package/docs/errors/settings/invalid-format.md +9 -0
  89. package/docs/errors/settings/invalid-integer.md +9 -0
  90. package/docs/errors/settings/invalid-timeout.md +9 -0
  91. package/docs/errors/settings/invalid-via.md +9 -0
  92. package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
  93. package/docs/errors/target/mac-not-in-arp.md +14 -0
  94. package/docs/errors/target/mac-required.md +23 -0
  95. package/docs/errors/target/mac-unresolved.md +17 -0
  96. package/docs/errors/target/unresolved.md +9 -0
  97. package/docs/errors/transport/auth-failed.md +9 -0
  98. package/docs/errors/transport/auto-method.md +9 -0
  99. package/docs/errors/transport/capability-unsupported.md +22 -0
  100. package/docs/errors/transport/checksum-unavailable.md +9 -0
  101. package/docs/errors/transport/connection-closed.md +9 -0
  102. package/docs/errors/transport/connection-refused.md +9 -0
  103. package/docs/errors/transport/dns.md +9 -0
  104. package/docs/errors/transport/host-key-mismatch.md +9 -0
  105. package/docs/errors/transport/incomplete-transfer.md +9 -0
  106. package/docs/errors/transport/insecure-trust.md +9 -0
  107. package/docs/errors/transport/local-tool-missing.md +11 -0
  108. package/docs/errors/transport/network.md +9 -0
  109. package/docs/errors/transport/timeout.md +9 -0
  110. package/docs/errors/transport/tls-certificate.md +9 -0
  111. package/docs/errors/transport/unreachable.md +9 -0
  112. package/docs/errors/transport/unsupported-operation.md +9 -0
  113. package/docs/errors/usage/confirmation-required.md +9 -0
  114. package/docs/errors/usage/conflicting-flags.md +9 -0
  115. package/docs/errors/usage/invalid-concurrency.md +9 -0
  116. package/docs/errors/usage/missing-group.md +9 -0
  117. package/docs/errors/usage/not-implemented.md +9 -0
  118. package/docs/errors/usage/target-exists.md +9 -0
  119. package/docs/errors/usage/timeout-out-of-range.md +9 -0
  120. package/docs/errors/validation/not-implemented.md +9 -0
  121. package/docs/errors/validation/option.md +9 -0
  122. package/docs/errors/validation/syntax.md +9 -0
  123. package/docs/errors/validation/unknown-attribute.md +9 -0
  124. package/docs/errors/validation/unknown-path.md +9 -0
  125. package/package.json +96 -0
  126. package/src/AGENTS.md +7 -0
  127. package/src/btest.ts +906 -0
  128. package/src/cli/btest.ts +455 -0
  129. package/src/cli/common.ts +53 -0
  130. package/src/cli/devices.ts +527 -0
  131. package/src/cli/discover.ts +186 -0
  132. package/src/cli/execute.ts +333 -0
  133. package/src/cli/mcp.ts +109 -0
  134. package/src/cli/retrieve.ts +383 -0
  135. package/src/cli/terminal.ts +303 -0
  136. package/src/cli/transfer.ts +471 -0
  137. package/src/cli.ts +115 -0
  138. package/src/core/envelope.ts +221 -0
  139. package/src/core/error-catalog.ts +469 -0
  140. package/src/core/routeros-errors.ts +268 -0
  141. package/src/data/AGENTS.md +11 -0
  142. package/src/data/dude-db.ts +0 -0
  143. package/src/data/mndp-cache.ts +132 -0
  144. package/src/data/mndp.ts +405 -0
  145. package/src/data/winbox-cdb-write.ts +200 -0
  146. package/src/data/winbox-cdb.ts +913 -0
  147. package/src/devices-schema.ts +107 -0
  148. package/src/devices.ts +1717 -0
  149. package/src/discover.ts +802 -0
  150. package/src/errors.ts +189 -0
  151. package/src/execute.ts +1350 -0
  152. package/src/index.ts +353 -0
  153. package/src/mcp/config.ts +66 -0
  154. package/src/mcp/resources.ts +98 -0
  155. package/src/mcp/safety.ts +119 -0
  156. package/src/mcp/server.ts +225 -0
  157. package/src/mcp/tools.ts +657 -0
  158. package/src/mcp.ts +33 -0
  159. package/src/protocols/AGENTS.md +14 -0
  160. package/src/protocols/adapter.ts +866 -0
  161. package/src/protocols/btest-session.ts +1493 -0
  162. package/src/protocols/btest.ts +447 -0
  163. package/src/protocols/ec-srp5.ts +466 -0
  164. package/src/protocols/index.ts +118 -0
  165. package/src/protocols/mac-telnet-console.ts +655 -0
  166. package/src/protocols/mac-telnet.ts +1299 -0
  167. package/src/protocols/mtwei.ts +47 -0
  168. package/src/protocols/native-api.ts +796 -0
  169. package/src/protocols/sftp.ts +427 -0
  170. package/src/protocols/ssh.ts +297 -0
  171. package/src/resolver/cdb.ts +580 -0
  172. package/src/resolver/comment-kv.ts +360 -0
  173. package/src/resolver/index.ts +74 -0
  174. package/src/resolver/mac.ts +227 -0
  175. package/src/resolver/settings.ts +228 -0
  176. package/src/resolver/target.ts +442 -0
  177. package/src/retrieve-fanout.ts +533 -0
  178. package/src/retrieve.ts +1215 -0
  179. package/src/terminal.ts +444 -0
  180. package/src/transfer.ts +1554 -0
  181. package/src/tui.ts +22 -0
  182. package/src/webproxy.ts +16 -0
@@ -0,0 +1,1554 @@
1
+ /**
2
+ * `transfer` — copy files to and from a RouterOS device, and manage device
3
+ * files, over the REST / native-API `/file` menu.
4
+ *
5
+ * Design and grounding live in `commands/transfer/README.md`; the executable
6
+ * spec is `commands/transfer/examples.md`. The load-bearing facts this module
7
+ * encodes:
8
+ *
9
+ * - RouterOS file I/O is **asymmetric**: `/file/set contents` writes are capped
10
+ * at 60 KB, but reads scale via chunked `/file/read` (≤32 KB/chunk). So the
11
+ * default method is size- and direction-aware (see {@link selectTransferMethod}).
12
+ * - Every `/file` operation is command-shaped, so it rides the shared
13
+ * {@link ProtocolAdapter} `execute`/`list` seam over both REST and native-api.
14
+ * Each file is resolved to its `.id` by a `print` probe, which doubles as the
15
+ * validate-before-write existence guard.
16
+ * - `sftp`/`scp`/`fetch`/`ftp` are designed but not built in this pass; an
17
+ * explicit `--via` to one of them returns a defined not-implemented / gated
18
+ * error (examples P1–P4).
19
+ */
20
+
21
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import type {
25
+ CentrsEnvelope,
26
+ CentrsErrorEnvelope,
27
+ CentrsSuccessEnvelope,
28
+ SettingSource as CoreSettingSource,
29
+ EnvelopeValidationMeta,
30
+ Warning,
31
+ } from "./core/envelope.ts";
32
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
33
+ import {
34
+ createProtocolAdapter,
35
+ type ProtocolAdapter,
36
+ type ProtocolExecuteResult,
37
+ } from "./protocols/adapter.ts";
38
+ import type { RouterOsProtocol } from "./protocols/index.ts";
39
+ import { SftpClient } from "./protocols/sftp.ts";
40
+ import {
41
+ type CdbResolution,
42
+ parseDuration,
43
+ type ResolvedAuth,
44
+ type ResolvedSetting,
45
+ type ResolvedTarget,
46
+ resolveAuth,
47
+ resolveBooleanSetting,
48
+ resolveCdb,
49
+ resolveStringSetting,
50
+ resolveTarget,
51
+ toCoreSource,
52
+ } from "./resolver/index.ts";
53
+ import { toYaml } from "./retrieve.ts";
54
+
55
+ export const transferOutputFormats = ["text", "json", "yaml"] as const;
56
+ export type TransferOutputFormat = (typeof transferOutputFormats)[number];
57
+
58
+ /** Direction / management sub-verbs. `list` is sugar over a `/file/print` read. */
59
+ export const transferVerbs = [
60
+ "upload",
61
+ "download",
62
+ "list",
63
+ "remove",
64
+ "mkdir",
65
+ "copy",
66
+ ] as const;
67
+ export type TransferVerb = (typeof transferVerbs)[number];
68
+
69
+ /**
70
+ * `--via` method names. These are transfer *methods*, finer than the grid
71
+ * transport: `rest`/`native` ride rest-api/native-api; `sftp`/`scp` ride ssh;
72
+ * `fetch` rides rest-api/native-api plus an inbound HTTP server; `ftp` is gated.
73
+ */
74
+ export const transferMethods = [
75
+ "sftp",
76
+ "scp",
77
+ "rest",
78
+ "native",
79
+ "fetch",
80
+ "ftp",
81
+ ] as const;
82
+ export type TransferMethod = (typeof transferMethods)[number];
83
+
84
+ /** RouterOS `/file` contents write cap (`/file/set contents`). */
85
+ export const REST_FILE_WRITE_CAP_BYTES = 60_000;
86
+ /** Maximum `/file/read` chunk RouterOS accepts (`chunk-size` upper bound). */
87
+ export const FILE_READ_CHUNK_BYTES = 32_768;
88
+
89
+ export interface TransferRequest {
90
+ verb: TransferVerb;
91
+ targetInput?: string;
92
+ /** Local path (or `-` for stdin/stdout). Present for upload/download. */
93
+ local?: string;
94
+ /** RouterOS file path. Present for upload/download/remove/mkdir and copy source. */
95
+ remote?: string;
96
+ /** Copy destination (on-device). */
97
+ remoteDest?: string;
98
+ /** `list` path prefix filter (optional). */
99
+ path?: string;
100
+ via?: string;
101
+ host?: string;
102
+ port?: number;
103
+ username?: string;
104
+ password?: string;
105
+ /** SSH private-key path for sftp/scp (`--ssh-key`). Path only. */
106
+ sshKey?: string;
107
+ /** Accept self-signed TLS / new SSH host keys (`--insecure`). Default false. */
108
+ insecure?: boolean;
109
+ timeout?: string | number;
110
+ format?: string;
111
+ validate?: boolean;
112
+ verbose?: boolean;
113
+ /** Replace an existing destination instead of refusing it. */
114
+ force?: boolean;
115
+ /** Integrity check: `size` (default), `checksum`, or `off`. */
116
+ verify?: string;
117
+ /** `list` filter: row type. */
118
+ type?: string;
119
+ /** `list` filter: name glob. */
120
+ name?: string;
121
+ cdbFile?: string;
122
+ cdbPassword?: string;
123
+ }
124
+
125
+ export type TransferOp = TransferVerb;
126
+
127
+ export interface TransferData {
128
+ op: TransferOp;
129
+ remote: string | null;
130
+ local: string | null;
131
+ bytes: number | null;
132
+ verified: "size" | "checksum" | "off" | null;
133
+ /** Concrete method used (`rest`, `native`, …). */
134
+ method: TransferMethod;
135
+ durationMs?: number;
136
+ /** Only on `list`: the `/file` rows. */
137
+ rows?: readonly Record<string, unknown>[];
138
+ }
139
+
140
+ export interface TransferOperationMeta {
141
+ op: TransferOp;
142
+ method: TransferMethod;
143
+ request: {
144
+ verb: TransferVerb;
145
+ remote: string | null;
146
+ local: string | null;
147
+ force: boolean;
148
+ verify: "size" | "checksum" | "off";
149
+ validate: boolean;
150
+ format: TransferOutputFormat;
151
+ };
152
+ auth: { username?: string; passwordProvided: boolean };
153
+ }
154
+
155
+ export type TransferEnvelope = CentrsEnvelope<
156
+ TransferData | readonly Record<string, unknown>[],
157
+ TransferOperationMeta
158
+ >;
159
+ export type TransferSuccessEnvelope = CentrsSuccessEnvelope<
160
+ TransferData | readonly Record<string, unknown>[],
161
+ TransferOperationMeta
162
+ >;
163
+ export type TransferErrorEnvelope = CentrsErrorEnvelope<TransferOperationMeta>;
164
+
165
+ interface ResolvedTransferRequest {
166
+ verb: TransferVerb;
167
+ method: TransferMethod;
168
+ protocol: RouterOsProtocol;
169
+ target: ResolvedTarget;
170
+ auth: ResolvedAuth;
171
+ timeoutMs: ResolvedSetting<number>;
172
+ format: ResolvedSetting<TransferOutputFormat>;
173
+ validate: ResolvedSetting<boolean>;
174
+ insecure: ResolvedSetting<boolean>;
175
+ via: ResolvedSetting<string>;
176
+ local?: string;
177
+ remote?: string;
178
+ remoteDest?: string;
179
+ listPath?: string;
180
+ force: boolean;
181
+ verify: "size" | "checksum" | "off";
182
+ type?: string;
183
+ name?: string;
184
+ verbose: boolean;
185
+ warnings: readonly Warning[];
186
+ }
187
+
188
+ // ── Public entry ────────────────────────────────────────────────────────────
189
+
190
+ export async function transfer(
191
+ request: TransferRequest,
192
+ env: Record<string, string | undefined> = Bun.env,
193
+ ): Promise<TransferSuccessEnvelope> {
194
+ const resolved = await resolveTransferRequest(request, env);
195
+ const backend = createFileBackend(resolved);
196
+ try {
197
+ return await runResolvedTransfer(resolved, backend);
198
+ } finally {
199
+ await backend.close();
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Build the file backend for the resolved method. `rest`/`native` wrap the
205
+ * shared {@link ProtocolAdapter} `/file` command seam; `sftp` drives the host
206
+ * OpenSSH `sftp` subsystem (the SSH file path — RouterOS has no pseudo-tty, and
207
+ * its SFTP subsystem is the real file protocol, unlike a one-shot `ssh host "cmd"`
208
+ * exec). Both satisfy {@link FileBackend}, so the verb runners are
209
+ * backend-agnostic.
210
+ */
211
+ export function createFileBackend(
212
+ resolved: ResolvedTransferRequest,
213
+ ): FileBackend {
214
+ if (resolved.method === "sftp") {
215
+ return new SftpFileBackend(
216
+ new SftpClient({
217
+ host: resolved.target.host,
218
+ port: resolved.target.port,
219
+ username: resolved.auth.username,
220
+ sshKey: resolved.auth.sshKey,
221
+ insecure: resolved.insecure.value,
222
+ timeoutMs: resolved.timeoutMs.value,
223
+ }),
224
+ );
225
+ }
226
+ const adapter = createProtocolAdapter({
227
+ protocol: resolved.protocol,
228
+ host: resolved.target.host,
229
+ port: resolved.target.port,
230
+ tls: resolved.target.tls,
231
+ baseUrl: resolved.target.baseUrl,
232
+ username: resolved.auth.username,
233
+ password: resolved.auth.password,
234
+ timeoutMs: resolved.timeoutMs.value,
235
+ insecure: resolved.insecure.value,
236
+ });
237
+ return new AdapterFileBackend(adapter);
238
+ }
239
+
240
+ /**
241
+ * The verb dispatch shared by `transfer()` and (future) fanout. Takes an
242
+ * already-built {@link FileBackend} so tests can drive it with a mocked
243
+ * transport.
244
+ */
245
+ export async function runResolvedTransfer(
246
+ resolved: ResolvedTransferRequest,
247
+ backend: FileBackend,
248
+ ): Promise<TransferSuccessEnvelope> {
249
+ const startedAt = Date.now();
250
+ const warnings: Warning[] = [...resolved.warnings];
251
+
252
+ const result = await dispatchTransferVerb(resolved, backend, warnings);
253
+ const durationMs = Date.now() - startedAt;
254
+
255
+ if (resolved.verb === "list") {
256
+ return buildSuccessEnvelope(
257
+ resolved,
258
+ result.rows ?? [],
259
+ { ...result.data, durationMs },
260
+ warnings,
261
+ );
262
+ }
263
+
264
+ return buildSuccessEnvelope(
265
+ resolved,
266
+ { ...result.data, durationMs },
267
+ { ...result.data, durationMs },
268
+ warnings,
269
+ );
270
+ }
271
+
272
+ interface VerbResult {
273
+ data: TransferData;
274
+ rows?: readonly Record<string, unknown>[];
275
+ }
276
+
277
+ async function dispatchTransferVerb(
278
+ resolved: ResolvedTransferRequest,
279
+ backend: FileBackend,
280
+ warnings: Warning[],
281
+ ): Promise<VerbResult> {
282
+ switch (resolved.verb) {
283
+ case "list":
284
+ return runList(resolved, backend);
285
+ case "upload":
286
+ return runUpload(resolved, backend, warnings);
287
+ case "download":
288
+ return runDownload(resolved, backend, warnings);
289
+ case "remove":
290
+ return runRemove(resolved, backend);
291
+ case "mkdir":
292
+ return runMkdir(resolved, backend);
293
+ case "copy":
294
+ return runCopy(resolved, backend);
295
+ default:
296
+ return exhaustiveVerb(resolved.verb);
297
+ }
298
+ }
299
+
300
+ // ── Verb implementations (backend-agnostic over FileBackend) ─────────────────
301
+
302
+ async function runList(
303
+ resolved: ResolvedTransferRequest,
304
+ backend: FileBackend,
305
+ ): Promise<VerbResult> {
306
+ const rows = await backend.listFiles(resolved.listPath);
307
+ const filtered = rows.filter((row) => matchesListFilters(row, resolved));
308
+ return {
309
+ rows: filtered,
310
+ data: baseData(resolved, { op: "list", remote: null, local: null }),
311
+ };
312
+ }
313
+
314
+ async function runUpload(
315
+ resolved: ResolvedTransferRequest,
316
+ backend: FileBackend,
317
+ warnings: Warning[],
318
+ ): Promise<VerbResult> {
319
+ const remote = requireRemote(resolved);
320
+ const bytes = readLocalSource(resolved);
321
+
322
+ // REST/native cannot write past the 60 KB `/file/set contents` cap; fail
323
+ // before any network call. sftp has no such cap (it streams the file).
324
+ if (
325
+ isRestFamily(resolved.method) &&
326
+ bytes.byteLength > REST_FILE_WRITE_CAP_BYTES
327
+ ) {
328
+ throw restWriteCapError(remote, bytes.byteLength);
329
+ }
330
+
331
+ const existing = await backend.findFile(remote);
332
+ if (existing && !resolved.force) {
333
+ throw targetExistsError(remote);
334
+ }
335
+
336
+ await backend.writeFile(remote, bytes, existing);
337
+
338
+ const verified = await verifyWrittenSize(
339
+ resolved,
340
+ backend,
341
+ remote,
342
+ bytes.byteLength,
343
+ warnings,
344
+ );
345
+
346
+ return {
347
+ data: baseData(resolved, {
348
+ op: "upload",
349
+ remote,
350
+ local: localLabel(resolved),
351
+ bytes: bytes.byteLength,
352
+ verified,
353
+ }),
354
+ };
355
+ }
356
+
357
+ async function runDownload(
358
+ resolved: ResolvedTransferRequest,
359
+ backend: FileBackend,
360
+ warnings: Warning[],
361
+ ): Promise<VerbResult> {
362
+ const remote = requireRemote(resolved);
363
+ const row = await backend.findFile(remote);
364
+ if (!row) {
365
+ throw new CentrsError({
366
+ code: "routeros/command-failed",
367
+ summary: `RouterOS has no file named ${remote}.`,
368
+ remediation:
369
+ "Check the remote path (RouterOS file names have no leading slash); `transfer list` shows what is present.",
370
+ context: { remote },
371
+ });
372
+ }
373
+
374
+ const bytes = await backend.readFile(row);
375
+ // Verify the byte count against the device-reported size *before* writing the
376
+ // local sink, so a short read never clobbers a local file or emits partial
377
+ // bytes to stdout.
378
+ const verified = verifyReadSize(
379
+ resolved,
380
+ row.size,
381
+ bytes.byteLength,
382
+ remote,
383
+ warnings,
384
+ );
385
+ writeLocalSink(resolved, bytes);
386
+
387
+ return {
388
+ data: baseData(resolved, {
389
+ op: "download",
390
+ remote,
391
+ local: localLabel(resolved),
392
+ bytes: bytes.byteLength,
393
+ verified,
394
+ }),
395
+ };
396
+ }
397
+
398
+ async function runRemove(
399
+ resolved: ResolvedTransferRequest,
400
+ backend: FileBackend,
401
+ ): Promise<VerbResult> {
402
+ const remote = requireRemote(resolved);
403
+ const row = await backend.findFile(remote);
404
+ if (!row) {
405
+ throw new CentrsError({
406
+ code: "routeros/command-failed",
407
+ summary: `RouterOS has no file named ${remote}.`,
408
+ remediation: "Nothing to remove; `transfer list` shows what is present.",
409
+ context: { remote },
410
+ });
411
+ }
412
+ await backend.removeFile(row);
413
+ return {
414
+ data: baseData(resolved, { op: "remove", remote, local: null }),
415
+ };
416
+ }
417
+
418
+ async function runMkdir(
419
+ resolved: ResolvedTransferRequest,
420
+ backend: FileBackend,
421
+ ): Promise<VerbResult> {
422
+ const remote = requireRemote(resolved);
423
+ const existing = await backend.findFile(remote);
424
+ if (existing && !resolved.force) {
425
+ throw targetExistsError(remote);
426
+ }
427
+ await backend.makeDir(remote);
428
+ return {
429
+ data: baseData(resolved, { op: "mkdir", remote, local: null }),
430
+ };
431
+ }
432
+
433
+ async function runCopy(
434
+ resolved: ResolvedTransferRequest,
435
+ backend: FileBackend,
436
+ ): Promise<VerbResult> {
437
+ const remote = requireRemote(resolved);
438
+ const dest = resolved.remoteDest
439
+ ? normalizeRemotePath(resolved.remoteDest)
440
+ : undefined;
441
+ if (!dest) {
442
+ throw new CentrsError({
443
+ code: "usage/conflicting-flags",
444
+ summary: "`transfer copy` requires a source and a destination path.",
445
+ remediation: "Pass both: `transfer <router> copy <src> <dst>`.",
446
+ });
447
+ }
448
+ const source = await backend.findFile(remote);
449
+ if (!source) {
450
+ throw new CentrsError({
451
+ code: "routeros/command-failed",
452
+ summary: `RouterOS has no file named ${remote}.`,
453
+ remediation: "Check the copy source path; `transfer list` shows files.",
454
+ context: { remote },
455
+ });
456
+ }
457
+ if (!resolved.force && (await backend.findFile(dest))) {
458
+ throw targetExistsError(dest);
459
+ }
460
+ await backend.copyFile(source, dest);
461
+ return {
462
+ data: baseData(resolved, { op: "copy", remote, local: null }),
463
+ };
464
+ }
465
+
466
+ // ── FileBackend: the seam the verb runners drive ─────────────────────────────
467
+
468
+ interface FileRow {
469
+ id: string;
470
+ name: string;
471
+ type?: string;
472
+ size?: number;
473
+ }
474
+
475
+ /**
476
+ * A backend-agnostic view of a device's file store. The verb runners drive this
477
+ * seam so the orchestration (existence guard, verify, envelope) lives in one
478
+ * place regardless of transport. `rest`/`native` map these to `/file` commands;
479
+ * `sftp` maps them to SFTP-subsystem operations.
480
+ */
481
+ export interface FileBackend {
482
+ /** List files (optionally scoped to a directory prefix), `/file`-row shaped. */
483
+ listFiles(listPath?: string): Promise<Record<string, unknown>[]>;
484
+ /** Resolve a file by normalized name; `undefined` when absent. */
485
+ findFile(name: string): Promise<FileRow | undefined>;
486
+ /** Write `bytes` to `name`, reusing `existing` when present (overwrite). */
487
+ writeFile(
488
+ name: string,
489
+ bytes: Buffer,
490
+ existing: FileRow | undefined,
491
+ ): Promise<void>;
492
+ /** Read a file's bytes. */
493
+ readFile(row: FileRow): Promise<Buffer>;
494
+ removeFile(row: FileRow): Promise<void>;
495
+ makeDir(name: string): Promise<void>;
496
+ copyFile(source: FileRow, dest: string): Promise<void>;
497
+ close(): Promise<void>;
498
+ }
499
+
500
+ function isRestFamily(method: TransferMethod): boolean {
501
+ return method === "rest" || method === "native";
502
+ }
503
+
504
+ /** REST / native-api `/file` command seam. The one place those wire shapes live. */
505
+ class AdapterFileBackend implements FileBackend {
506
+ constructor(private readonly adapter: ProtocolAdapter) {}
507
+
508
+ async listFiles(): Promise<Record<string, unknown>[]> {
509
+ return (await this.adapter.list("/file", {
510
+ proplist: [".id", "name", "type", "size", "last-modified"],
511
+ })) as Record<string, unknown>[];
512
+ }
513
+
514
+ async findFile(name: string): Promise<FileRow | undefined> {
515
+ const rows = (await this.adapter.list("/file", {
516
+ proplist: [".id", "name", "type", "size"],
517
+ })) as Record<string, unknown>[];
518
+ const match = rows.find((row) => readString(row, "name") === name);
519
+ if (!match) {
520
+ return undefined;
521
+ }
522
+ return {
523
+ id: readString(match, ".id") ?? "",
524
+ name,
525
+ type: readString(match, "type"),
526
+ size: readNumber(match, "size"),
527
+ };
528
+ }
529
+
530
+ async writeFile(
531
+ name: string,
532
+ bytes: Buffer,
533
+ existing: FileRow | undefined,
534
+ ): Promise<void> {
535
+ const text = bytes.toString("utf8");
536
+ let id = existing?.id;
537
+ if (!id) {
538
+ const added = await this.adapter.execute({
539
+ path: "/file",
540
+ command: "add",
541
+ attributes: { name, type: "file" },
542
+ });
543
+ id = extractNewId(added) ?? (await this.findFile(name))?.id;
544
+ }
545
+ if (!id) {
546
+ throw new CentrsError({
547
+ code: "routeros/command-failed",
548
+ summary: `RouterOS did not create the file ${name}.`,
549
+ remediation:
550
+ "Confirm the path is writable and the user has `ftp`/`write` policy.",
551
+ context: { remote: name },
552
+ });
553
+ }
554
+ await this.adapter.execute({
555
+ path: "/file",
556
+ command: "set",
557
+ attributes: { ".id": id, contents: text },
558
+ });
559
+ }
560
+
561
+ async readFile(row: FileRow): Promise<Buffer> {
562
+ // Small file: `/file/get … contents`. Large: chunked `/file/read`.
563
+ if (row.size !== undefined && row.size <= REST_FILE_WRITE_CAP_BYTES) {
564
+ const result = await this.adapter.execute({
565
+ path: "/file",
566
+ command: "get",
567
+ attributes: { ".id": row.id, "value-name": "contents" },
568
+ });
569
+ return Buffer.from(extractContents(result), "utf8");
570
+ }
571
+ const parts: Buffer[] = [];
572
+ let offset = 0;
573
+ // Bound the loop defensively even if RouterOS never returns a short chunk.
574
+ const max = row.size ?? Number.MAX_SAFE_INTEGER;
575
+ while (offset < max) {
576
+ const result = await this.adapter.execute({
577
+ path: "/file",
578
+ command: "read",
579
+ attributes: {
580
+ file: row.name,
581
+ offset: String(offset),
582
+ "chunk-size": String(FILE_READ_CHUNK_BYTES),
583
+ },
584
+ });
585
+ const chunk = Buffer.from(extractReadData(result), "utf8");
586
+ if (chunk.byteLength === 0) {
587
+ break;
588
+ }
589
+ parts.push(chunk);
590
+ offset += chunk.byteLength;
591
+ if (chunk.byteLength < FILE_READ_CHUNK_BYTES) {
592
+ break;
593
+ }
594
+ }
595
+ return Buffer.concat(parts);
596
+ }
597
+
598
+ async removeFile(row: FileRow): Promise<void> {
599
+ await this.adapter.execute({
600
+ path: "/file",
601
+ command: "remove",
602
+ attributes: { ".id": row.id },
603
+ });
604
+ }
605
+
606
+ async makeDir(name: string): Promise<void> {
607
+ await this.adapter.execute({
608
+ path: "/file",
609
+ command: "add",
610
+ attributes: { name, type: "directory" },
611
+ });
612
+ }
613
+
614
+ async copyFile(source: FileRow, dest: string): Promise<void> {
615
+ await this.adapter.execute({
616
+ path: "/file",
617
+ command: "copy",
618
+ attributes: { ".id": source.id, name: dest },
619
+ });
620
+ }
621
+
622
+ async close(): Promise<void> {
623
+ await this.adapter.close();
624
+ }
625
+ }
626
+
627
+ /** SFTP-subsystem seam over the host OpenSSH `sftp` (see `protocols/sftp.ts`). */
628
+ class SftpFileBackend implements FileBackend {
629
+ constructor(private readonly client: SftpClient) {}
630
+
631
+ async listFiles(listPath?: string): Promise<Record<string, unknown>[]> {
632
+ const entries = await this.client.readdir(listPath ?? ".");
633
+ return entries.map((entry) => ({
634
+ // Prefix with the listed directory so the shared `--type`/`--name`/path
635
+ // filters see the same full-path shape the REST family returns.
636
+ name: listPath ? `${listPath}/${entry.name}` : entry.name,
637
+ type: entry.type,
638
+ ...(entry.size === undefined ? {} : { size: entry.size }),
639
+ }));
640
+ }
641
+
642
+ async findFile(name: string): Promise<FileRow | undefined> {
643
+ const entry = await this.client.stat(name);
644
+ if (!entry) {
645
+ return undefined;
646
+ }
647
+ // Existence probe only — no size. SFTP `put`/`get` are protocol-guaranteed
648
+ // complete (a partial transfer errors), so the upload `--verify size` trusts
649
+ // that guarantee rather than re-reading a server-format-dependent `ls -l`
650
+ // size; and `get` streams any size without needing it.
651
+ return { id: "", name, type: entry.type };
652
+ }
653
+
654
+ async writeFile(name: string, bytes: Buffer): Promise<void> {
655
+ await withTempFile(bytes, (path) => this.client.put(path, name));
656
+ }
657
+
658
+ async readFile(row: FileRow): Promise<Buffer> {
659
+ return withTempFile(undefined, async (path) => {
660
+ await this.client.get(row.name, path);
661
+ return readFileSync(path);
662
+ });
663
+ }
664
+
665
+ async removeFile(row: FileRow): Promise<void> {
666
+ await this.client.remove(row.name);
667
+ }
668
+
669
+ async makeDir(name: string): Promise<void> {
670
+ await this.client.mkdir(name);
671
+ }
672
+
673
+ async copyFile(): Promise<void> {
674
+ // RouterOS SFTP exposes no server-side copy; `copy` stays on rest/native.
675
+ throw new CentrsError({
676
+ code: "transport/unsupported-operation",
677
+ summary: "On-device `copy` is not available over sftp.",
678
+ remediation:
679
+ "Use `--via rest` / `--via native` for on-device copy; sftp moves bytes host↔device only.",
680
+ context: { via: "ssh", op: "copy" },
681
+ });
682
+ }
683
+
684
+ async close(): Promise<void> {
685
+ // The sftp client is stateless across calls (one batch per op).
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Run `fn` with a private temp-file path. When `seed` is given the file is
691
+ * pre-filled (upload); otherwise `fn` writes it (download). The temp dir is
692
+ * always removed.
693
+ */
694
+ async function withTempFile<T>(
695
+ seed: Buffer | undefined,
696
+ fn: (path: string) => Promise<T>,
697
+ ): Promise<T> {
698
+ const dir = mkdtempSync(join(tmpdir(), "centrs-sftp-"));
699
+ const path = join(dir, "data");
700
+ try {
701
+ if (seed !== undefined) {
702
+ writeFileSync(path, seed);
703
+ }
704
+ return await fn(path);
705
+ } finally {
706
+ rmSync(dir, { recursive: true, force: true });
707
+ }
708
+ }
709
+
710
+ async function verifyWrittenSize(
711
+ resolved: ResolvedTransferRequest,
712
+ backend: FileBackend,
713
+ remote: string,
714
+ expected: number,
715
+ warnings: Warning[],
716
+ ): Promise<"size" | "checksum" | "off"> {
717
+ if (resolved.verify === "off") {
718
+ return "off";
719
+ }
720
+ if (resolved.verify === "checksum") {
721
+ warnings.push({
722
+ code: "transport/checksum-unavailable",
723
+ message:
724
+ "RouterOS exposes no file digest over REST/native; verifying by size instead.",
725
+ });
726
+ }
727
+ const row = await backend.findFile(remote);
728
+ const actual = row?.size;
729
+ if (actual !== undefined && actual !== expected) {
730
+ throw new CentrsError({
731
+ code: "transport/incomplete-transfer",
732
+ summary: `Uploaded ${remote} settled at ${actual} bytes, expected ${expected}.`,
733
+ remediation:
734
+ "Re-run the upload; if it recurs, the device may be low on storage.",
735
+ context: { remote, expected, actual },
736
+ });
737
+ }
738
+ return "size";
739
+ }
740
+
741
+ /**
742
+ * Verify a download by byte count. REST/native `/file` rows carry a size, so a
743
+ * short/truncated read fails with `transport/incomplete-transfer`. SFTP rows
744
+ * report no reliable size, so it trusts the `get` guarantee (a partial transfer
745
+ * errors) — mirroring {@link verifyWrittenSize}.
746
+ */
747
+ function verifyReadSize(
748
+ resolved: ResolvedTransferRequest,
749
+ expected: number | undefined,
750
+ actual: number,
751
+ remote: string,
752
+ warnings: Warning[],
753
+ ): "size" | "checksum" | "off" {
754
+ if (resolved.verify === "off") {
755
+ return "off";
756
+ }
757
+ if (resolved.verify === "checksum") {
758
+ warnings.push({
759
+ code: "transport/checksum-unavailable",
760
+ message:
761
+ "RouterOS exposes no file digest over REST/native; verifying by size instead.",
762
+ });
763
+ }
764
+ if (expected !== undefined && expected !== actual) {
765
+ throw new CentrsError({
766
+ code: "transport/incomplete-transfer",
767
+ summary: `Downloaded ${remote} is ${actual} bytes, expected ${expected}.`,
768
+ remediation:
769
+ "Re-run the download; if it recurs, the remote file or link may be unstable.",
770
+ context: { remote, expected, actual },
771
+ });
772
+ }
773
+ return "size";
774
+ }
775
+
776
+ // ── Resolution ───────────────────────────────────────────────────────────────
777
+
778
+ export async function resolveTransferRequest(
779
+ request: TransferRequest,
780
+ env: Record<string, string | undefined>,
781
+ ): Promise<ResolvedTransferRequest> {
782
+ validateTransferRequestShape(request);
783
+
784
+ const direction: TransferDirection =
785
+ request.verb === "upload" ? "upload" : "download";
786
+ const sizeHint = uploadSizeHint(request);
787
+ const { method, protocol, via } = selectTransferMethod(
788
+ request,
789
+ direction,
790
+ sizeHint,
791
+ env,
792
+ );
793
+
794
+ const cdbResolution = await resolveCdb(
795
+ {
796
+ targetInput: request.targetInput,
797
+ cdbFile: request.cdbFile,
798
+ cdbPassword: request.cdbPassword,
799
+ },
800
+ env,
801
+ );
802
+
803
+ const format = resolveTransferFormat(request, env);
804
+ const validate = resolveBooleanSetting(
805
+ request.validate,
806
+ env,
807
+ "CENTRS_VALIDATE",
808
+ true,
809
+ "validate",
810
+ cdbResolution?.overrides.validate,
811
+ );
812
+ const insecure = resolveBooleanSetting(
813
+ request.insecure,
814
+ env,
815
+ "CENTRS_INSECURE",
816
+ false,
817
+ "insecure",
818
+ cdbResolution?.overrides.insecure,
819
+ );
820
+ const timeoutMs = resolveTransferTimeout(
821
+ request.timeout,
822
+ env,
823
+ protocol,
824
+ cdbResolution,
825
+ );
826
+ const target = resolveTarget(
827
+ {
828
+ targetInput: request.targetInput,
829
+ host: request.host,
830
+ port: request.port,
831
+ },
832
+ env,
833
+ protocol,
834
+ cdbResolution,
835
+ );
836
+ const auth = resolveAuth(
837
+ {
838
+ username: request.username,
839
+ password: request.password,
840
+ sshKey: request.sshKey,
841
+ },
842
+ env,
843
+ cdbResolution,
844
+ );
845
+
846
+ const warnings: Warning[] = [
847
+ ...((cdbResolution?.warnings ?? []) as readonly Warning[]),
848
+ ];
849
+ // Report the auto hop: a large upload cannot ride the REST family's 60 KB
850
+ // `/file/set` cap, so auto-selection moved it to sftp.
851
+ if (via.value === "auto" && method === "sftp") {
852
+ warnings.push({
853
+ code: "transport/auto-method",
854
+ message:
855
+ "Large upload (>60 KB) auto-selected sftp; the REST family cannot write past the 60 KB `/file/set` cap.",
856
+ });
857
+ }
858
+ // A self-signed-accepting run is an anomaly worth surfacing on every transport.
859
+ if (insecure.value) {
860
+ warnings.push({
861
+ code: "transport/insecure-trust",
862
+ message:
863
+ "`--insecure` is set: TLS peer verification and strict SSH host-key checking are disabled.",
864
+ });
865
+ }
866
+
867
+ return {
868
+ verb: request.verb,
869
+ method,
870
+ protocol,
871
+ target,
872
+ auth,
873
+ timeoutMs,
874
+ format,
875
+ validate,
876
+ insecure,
877
+ via,
878
+ local: request.local,
879
+ remote: resolveRemoteTarget(request),
880
+ remoteDest: request.remoteDest,
881
+ listPath: request.path ? normalizeRemotePath(request.path) : undefined,
882
+ force: request.force ?? false,
883
+ verify: parseVerifyMode(request.verify),
884
+ type: request.type,
885
+ name: request.name,
886
+ verbose: request.verbose ?? false,
887
+ warnings,
888
+ };
889
+ }
890
+
891
+ type TransferDirection = "upload" | "download";
892
+
893
+ /**
894
+ * Size/direction-aware method selection plus explicit-`--via` gating.
895
+ * Pure (no I/O) so it is unit-testable. Throws `CentrsError` for the
896
+ * not-implemented / gated methods (examples P1–P4) and the REST write cap.
897
+ */
898
+ export function selectTransferMethod(
899
+ request: TransferRequest,
900
+ direction: TransferDirection,
901
+ uploadBytes: number | undefined,
902
+ env: Record<string, string | undefined>,
903
+ ): {
904
+ method: TransferMethod;
905
+ protocol: RouterOsProtocol;
906
+ via: ResolvedSetting<string>;
907
+ } {
908
+ const via = resolveStringSetting(
909
+ request.via,
910
+ env,
911
+ "CENTRS_TRANSFER_VIA",
912
+ "auto",
913
+ "via",
914
+ ) as ResolvedSetting<string>;
915
+ const requested = via.value;
916
+
917
+ if (requested === "auto") {
918
+ // Auto: REST family carries reads and small writes; a large upload exceeds
919
+ // the 60 KB `/file/set` cap, so it auto-selects sftp (reported as an auto
920
+ // hop in meta.warnings by the caller).
921
+ if (
922
+ direction === "upload" &&
923
+ uploadBytes !== undefined &&
924
+ uploadBytes > REST_FILE_WRITE_CAP_BYTES
925
+ ) {
926
+ return { method: "sftp", protocol: "ssh", via };
927
+ }
928
+ return { method: "rest", protocol: "rest-api", via };
929
+ }
930
+
931
+ if (requested === "rest" || requested === "rest-api") {
932
+ return { method: "rest", protocol: "rest-api", via };
933
+ }
934
+ if (requested === "native" || requested === "native-api") {
935
+ return { method: "native", protocol: "native-api", via };
936
+ }
937
+ if (requested === "sftp") {
938
+ return { method: "sftp", protocol: "ssh", via };
939
+ }
940
+ if (requested === "scp" || requested === "fetch") {
941
+ throw notImplementedMethod(requested);
942
+ }
943
+ if (requested === "ftp") {
944
+ if ((env["ALLOW_UNSAFE_PROTOCOLS"] ?? "").split(",").includes("ftp")) {
945
+ throw notImplementedMethod("ftp");
946
+ }
947
+ throw new CentrsError({
948
+ code: "settings/unsafe-protocol-blocked",
949
+ summary: "`ftp` is cleartext and is gated behind an explicit opt-in.",
950
+ remediation:
951
+ "Set `ALLOW_UNSAFE_PROTOCOLS=ftp` to permit it, or use the secure default (sftp) or `--via rest`.",
952
+ context: { via: "ftp" },
953
+ });
954
+ }
955
+
956
+ throw new CentrsError({
957
+ code: "settings/invalid-via",
958
+ summary: `Unsupported transfer method: ${requested}`,
959
+ remediation: `Choose one of ${transferMethods.join(", ")} (or omit --via for the size-aware default).`,
960
+ context: { via: requested },
961
+ });
962
+ }
963
+
964
+ function notImplementedMethod(method: string, why?: string): CentrsError {
965
+ return new CentrsError({
966
+ code: "usage/not-implemented",
967
+ summary: why
968
+ ? `transfer ${why} — \`--via ${method}\` is not implemented yet.`
969
+ : `transfer \`--via ${method}\` is not implemented yet.`,
970
+ remediation:
971
+ "Use `--via sftp` for large transfers, or `--via rest` / `--via native` (≤60 KB writes and all reads); scp/fetch are not built yet.",
972
+ context: { via: method },
973
+ });
974
+ }
975
+
976
+ /**
977
+ * Validate request-shape concerns that do not need a target or the network:
978
+ * required positionals per verb, conflicting verify flags. Mutating ops that
979
+ * refuse-overwrite are checked against the device later (existence probe).
980
+ */
981
+ export function validateTransferRequestShape(request: TransferRequest): void {
982
+ if (!transferVerbs.includes(request.verb)) {
983
+ throw new CentrsError({
984
+ code: "input/invalid-command",
985
+ summary: `Unknown transfer verb: ${String(request.verb)}`,
986
+ remediation: `Use one of ${transferVerbs.join(", ")}.`,
987
+ });
988
+ }
989
+
990
+ if (request.verify !== undefined) {
991
+ parseVerifyMode(request.verify); // throws on a bad value
992
+ }
993
+
994
+ // `upload` needs a local source; `download` may omit local (defaults to the
995
+ // remote basename). Both need a remote, except `download` infers nothing there.
996
+ if (request.verb === "upload" && !request.local) {
997
+ throw missingArg(
998
+ "upload",
999
+ "a local source path (or `-`)",
1000
+ "<local> [remote]",
1001
+ );
1002
+ }
1003
+
1004
+ const needsRemote: readonly TransferVerb[] = [
1005
+ "upload",
1006
+ "download",
1007
+ "remove",
1008
+ "mkdir",
1009
+ ];
1010
+ if (needsRemote.includes(request.verb) && !request.remote) {
1011
+ // upload/download can take the remote as an optional positional; the
1012
+ // run-time `requireRemote` enforces it once defaults are applied, but a
1013
+ // bare `remove`/`mkdir` with no path is a usage error here.
1014
+ if (request.verb === "remove" || request.verb === "mkdir") {
1015
+ throw missingArg(request.verb, "a remote path", "<remote>");
1016
+ }
1017
+ }
1018
+
1019
+ if (request.verb === "copy" && (!request.remote || !request.remoteDest)) {
1020
+ throw new CentrsError({
1021
+ code: "usage/conflicting-flags",
1022
+ summary: "`transfer copy` requires a source and a destination path.",
1023
+ remediation: "Pass both: `transfer <router> copy <src> <dst>`.",
1024
+ });
1025
+ }
1026
+ }
1027
+
1028
+ function missingArg(verb: string, what: string, shape: string): CentrsError {
1029
+ return new CentrsError({
1030
+ code: "usage/conflicting-flags",
1031
+ summary: `\`transfer ${verb}\` requires ${what}.`,
1032
+ remediation: `Pass it: \`transfer <router> ${verb} ${shape}\`.`,
1033
+ });
1034
+ }
1035
+
1036
+ /**
1037
+ * Normalize a RouterOS file path: accept a leading `/` for ergonomics but strip
1038
+ * it (REST `/file` keys and SFTP paths want the bare name), and collapse any
1039
+ * duplicate slashes. `flash/x` and `/flash/x` resolve to the same target.
1040
+ */
1041
+ export function normalizeRemotePath(path: string): string {
1042
+ return path.replace(/^\/+/, "").replace(/\/{2,}/g, "/");
1043
+ }
1044
+
1045
+ function parseVerifyMode(
1046
+ value: string | undefined,
1047
+ ): "size" | "checksum" | "off" {
1048
+ if (value === undefined) {
1049
+ return "size";
1050
+ }
1051
+ if (value === "size" || value === "checksum" || value === "off") {
1052
+ return value;
1053
+ }
1054
+ throw new CentrsError({
1055
+ code: "usage/conflicting-flags",
1056
+ summary: `Unsupported --verify mode: ${value}`,
1057
+ remediation:
1058
+ "Use `--verify size` (default), `--verify checksum`, or `--no-verify`.",
1059
+ });
1060
+ }
1061
+
1062
+ function resolveTransferFormat(
1063
+ request: TransferRequest,
1064
+ env: Record<string, string | undefined>,
1065
+ ): ResolvedSetting<TransferOutputFormat> {
1066
+ return resolveStringSetting(
1067
+ request.format,
1068
+ env,
1069
+ "CENTRS_FORMAT",
1070
+ "text",
1071
+ "format",
1072
+ (value) => {
1073
+ if (transferOutputFormats.includes(value as TransferOutputFormat)) {
1074
+ return value as TransferOutputFormat;
1075
+ }
1076
+ throw new CentrsError({
1077
+ code: "settings/invalid-format",
1078
+ summary: `Unsupported output format: ${value}`,
1079
+ remediation: `Choose one of ${transferOutputFormats.join(", ")}.`,
1080
+ });
1081
+ },
1082
+ ) as ResolvedSetting<TransferOutputFormat>;
1083
+ }
1084
+
1085
+ function resolveTransferTimeout(
1086
+ timeout: TransferRequest["timeout"],
1087
+ env: Record<string, string | undefined>,
1088
+ protocol: RouterOsProtocol,
1089
+ cdb?: CdbResolution,
1090
+ ): ResolvedSetting<number> {
1091
+ const resolved = resolveStringSetting(
1092
+ timeout === undefined ? undefined : String(timeout),
1093
+ env,
1094
+ "CENTRS_TIMEOUT",
1095
+ "30000",
1096
+ "timeout",
1097
+ (value) => {
1098
+ const parsed = parseDuration(value);
1099
+ if (parsed <= 0) {
1100
+ throw new CentrsError({
1101
+ code: "settings/invalid-timeout",
1102
+ summary: `Timeout must be greater than zero. Received: ${value}`,
1103
+ remediation: "Use a positive duration like `5s` or `30000`.",
1104
+ });
1105
+ }
1106
+ return parsed;
1107
+ },
1108
+ cdb?.overrides.timeoutMs,
1109
+ ) as ResolvedSetting<number>;
1110
+
1111
+ if (protocol === "rest-api" && resolved.value > 60_000) {
1112
+ throw new CentrsError({
1113
+ code: "usage/timeout-out-of-range",
1114
+ summary: `REST timeout ${resolved.value}ms exceeds the RouterOS REST ceiling.`,
1115
+ remediation: "Use `--timeout 60s` or less, or `--via native` for longer.",
1116
+ context: { via: protocol, timeoutMs: resolved.value, ceilingMs: 60_000 },
1117
+ });
1118
+ }
1119
+ return resolved;
1120
+ }
1121
+
1122
+ // ── Local I/O (files / stdin / stdout) ───────────────────────────────────────
1123
+
1124
+ function readLocalSource(resolved: ResolvedTransferRequest): Buffer {
1125
+ const local = resolved.local;
1126
+ if (local === undefined) {
1127
+ throw new CentrsError({
1128
+ code: "usage/conflicting-flags",
1129
+ summary: "upload requires a local source path (or `-`).",
1130
+ remediation: "Pass the local file or `-` for stdin.",
1131
+ });
1132
+ }
1133
+ if (local === "-") {
1134
+ return readFileSync(0); // stdin
1135
+ }
1136
+ try {
1137
+ return readFileSync(local);
1138
+ } catch (error) {
1139
+ throw new CentrsError({
1140
+ code: "input/local-file-not-found",
1141
+ summary: `Cannot read local source ${local}.`,
1142
+ remediation: "Check the path and read permissions.",
1143
+ context: { local },
1144
+ cause: error,
1145
+ });
1146
+ }
1147
+ }
1148
+
1149
+ function writeLocalSink(
1150
+ resolved: ResolvedTransferRequest,
1151
+ bytes: Buffer,
1152
+ ): void {
1153
+ const remote = resolved.remote ?? "";
1154
+ const local = resolved.local ?? basename(remote);
1155
+ if (local === "-") {
1156
+ process.stdout.write(bytes);
1157
+ return;
1158
+ }
1159
+ writeFileSync(local, bytes);
1160
+ }
1161
+
1162
+ function localLabel(resolved: ResolvedTransferRequest): string {
1163
+ if (resolved.verb === "download") {
1164
+ const remote = resolved.remote ?? "";
1165
+ return resolved.local ?? basename(remote);
1166
+ }
1167
+ return resolved.local ?? "-";
1168
+ }
1169
+
1170
+ // ── Envelope assembly ────────────────────────────────────────────────────────
1171
+
1172
+ function baseData(
1173
+ resolved: ResolvedTransferRequest,
1174
+ fields: Partial<TransferData> & { op: TransferOp },
1175
+ ): TransferData {
1176
+ return {
1177
+ op: fields.op,
1178
+ remote: fields.remote ?? null,
1179
+ local: fields.local ?? null,
1180
+ bytes: fields.bytes ?? null,
1181
+ verified: fields.verified ?? null,
1182
+ method: resolved.method,
1183
+ ...(fields.durationMs !== undefined
1184
+ ? { durationMs: fields.durationMs }
1185
+ : {}),
1186
+ };
1187
+ }
1188
+
1189
+ function buildSuccessEnvelope(
1190
+ resolved: ResolvedTransferRequest,
1191
+ data: TransferData | readonly Record<string, unknown>[],
1192
+ operationData: TransferData,
1193
+ warnings: readonly Warning[],
1194
+ ): TransferSuccessEnvelope {
1195
+ return {
1196
+ ok: true,
1197
+ data,
1198
+ warnings,
1199
+ tips: [],
1200
+ meta: metaFromResolved(resolved, operationData, {
1201
+ enabled: resolved.validate.value,
1202
+ source: resolved.validate.value
1203
+ ? "device /file existence probe"
1204
+ : "disabled",
1205
+ result: resolved.validate.value ? "passed" : "skipped",
1206
+ }),
1207
+ };
1208
+ }
1209
+
1210
+ function metaFromResolved(
1211
+ resolved: ResolvedTransferRequest,
1212
+ operationData: TransferData,
1213
+ validation: EnvelopeValidationMeta,
1214
+ ): TransferEnvelope["meta"] {
1215
+ const target = resolved.target;
1216
+ const targetSources: Record<string, CoreSettingSource> = {};
1217
+ for (const [field, source] of Object.entries(target.sources)) {
1218
+ targetSources[field] = toCoreSource(source);
1219
+ }
1220
+
1221
+ return {
1222
+ target: {
1223
+ input: target.input,
1224
+ host: target.host,
1225
+ port: target.port,
1226
+ baseUrl: target.baseUrl,
1227
+ identity: target.identity,
1228
+ recordIndex: target.recordIndex,
1229
+ source: toCoreSource(target.source),
1230
+ sources: targetSources,
1231
+ },
1232
+ via: resolved.protocol,
1233
+ settings: {
1234
+ via: toCoreSource(resolved.via.source),
1235
+ host: toCoreSource(target.hostSource),
1236
+ port: toCoreSource(target.portSource),
1237
+ timeoutMs: toCoreSource(resolved.timeoutMs.source),
1238
+ format: toCoreSource(resolved.format.source),
1239
+ validate: toCoreSource(resolved.validate.source),
1240
+ insecure: toCoreSource(resolved.insecure.source),
1241
+ username: resolved.auth.usernameSource
1242
+ ? toCoreSource(resolved.auth.usernameSource)
1243
+ : undefined,
1244
+ password: resolved.auth.passwordSource
1245
+ ? toCoreSource(resolved.auth.passwordSource)
1246
+ : undefined,
1247
+ sshKey: resolved.auth.sshKeySource
1248
+ ? toCoreSource(resolved.auth.sshKeySource)
1249
+ : undefined,
1250
+ },
1251
+ validation,
1252
+ operation: {
1253
+ op: operationData.op,
1254
+ method: resolved.method,
1255
+ request: {
1256
+ verb: resolved.verb,
1257
+ remote: resolved.remote ?? null,
1258
+ local: resolved.local ?? null,
1259
+ force: resolved.force,
1260
+ verify: resolved.verify,
1261
+ validate: resolved.validate.value,
1262
+ format: resolved.format.value,
1263
+ },
1264
+ auth: {
1265
+ username: resolved.auth.username,
1266
+ passwordProvided: resolved.auth.passwordProvided,
1267
+ },
1268
+ },
1269
+ };
1270
+ }
1271
+
1272
+ export function buildTransferErrorEnvelope(
1273
+ request: TransferRequest,
1274
+ error: unknown,
1275
+ ): TransferErrorEnvelope {
1276
+ const centrsError =
1277
+ error instanceof CentrsError
1278
+ ? error
1279
+ : new CentrsError({
1280
+ code: "internal/unhandled",
1281
+ summary: "transfer failed with an unexpected internal error.",
1282
+ remediation:
1283
+ "Re-run with `--format json` to capture the structured error details.",
1284
+ cause: error,
1285
+ });
1286
+
1287
+ return {
1288
+ ok: false,
1289
+ error: serializeCentrsError(centrsError),
1290
+ warnings: [],
1291
+ tips: [],
1292
+ meta: {
1293
+ target: { input: request.targetInput },
1294
+ via: null,
1295
+ settings: {},
1296
+ },
1297
+ };
1298
+ }
1299
+
1300
+ // ── Rendering ────────────────────────────────────────────────────────────────
1301
+
1302
+ export function renderTransferEnvelope(
1303
+ envelope: TransferEnvelope,
1304
+ format: TransferOutputFormat,
1305
+ options: { verbose?: boolean } = {},
1306
+ ): string {
1307
+ switch (format) {
1308
+ case "json":
1309
+ return JSON.stringify(envelope, null, 2);
1310
+ case "yaml":
1311
+ return toYaml(envelope);
1312
+ case "text":
1313
+ return envelope.ok
1314
+ ? renderSuccessText(envelope, options)
1315
+ : renderErrorText(envelope);
1316
+ default:
1317
+ return exhaustiveFormat(format);
1318
+ }
1319
+ }
1320
+
1321
+ function renderSuccessText(
1322
+ envelope: TransferSuccessEnvelope,
1323
+ options: { verbose?: boolean },
1324
+ ): string {
1325
+ const lines: string[] = [];
1326
+ const operation = envelope.meta.operation;
1327
+
1328
+ if (Array.isArray(envelope.data)) {
1329
+ // list
1330
+ const rows = envelope.data as readonly Record<string, unknown>[];
1331
+ if (rows.length === 0) {
1332
+ lines.push("(no files)");
1333
+ }
1334
+ for (const row of rows) {
1335
+ const name = String(row["name"] ?? "");
1336
+ const type = String(row["type"] ?? "");
1337
+ const size =
1338
+ row["size"] === undefined ? "" : humanBytes(Number(row["size"]));
1339
+ lines.push(`${name}\t${type}\t${size}`.trimEnd());
1340
+ }
1341
+ } else {
1342
+ const data = envelope.data as TransferData;
1343
+ const parts = [`${data.op} ok`, `method=${data.method}`];
1344
+ if (data.remote) parts.push(`remote=${data.remote}`);
1345
+ if (data.local) parts.push(`local=${data.local}`);
1346
+ if (data.bytes !== null) parts.push(`bytes=${data.bytes}`);
1347
+ if (data.verified && data.verified !== "off")
1348
+ parts.push(`verified=${data.verified}`);
1349
+ lines.push(parts.join(" "));
1350
+ }
1351
+
1352
+ if (options.verbose && operation) {
1353
+ lines.push("");
1354
+ lines.push(`via: ${envelope.meta.via}`);
1355
+ }
1356
+
1357
+ if (envelope.warnings.length > 0) {
1358
+ lines.push("");
1359
+ for (const warning of envelope.warnings) {
1360
+ lines.push(`warning [${warning.code}]: ${warning.message}`);
1361
+ }
1362
+ }
1363
+
1364
+ return lines.join("\n");
1365
+ }
1366
+
1367
+ function renderErrorText(envelope: TransferErrorEnvelope): string {
1368
+ const { error } = envelope;
1369
+ const lines = [`[${error.code}] ${error.summary}`];
1370
+ if (error.remediation) {
1371
+ lines.push(`Fix: ${error.remediation}`);
1372
+ }
1373
+ return lines.join("\n");
1374
+ }
1375
+
1376
+ // ── Small helpers ────────────────────────────────────────────────────────────
1377
+
1378
+ function uploadSizeHint(request: TransferRequest): number | undefined {
1379
+ if (request.verb !== "upload" || !request.local || request.local === "-") {
1380
+ return undefined;
1381
+ }
1382
+ // Size from stat only — `readLocalSource` reads the bytes later, so reading
1383
+ // the whole file here just to count would double the I/O. A missing file
1384
+ // reports `0`; method selection treats that like an unknown hint (→ rest).
1385
+ const size = Bun.file(request.local).size;
1386
+ return size > 0 ? size : undefined;
1387
+ }
1388
+
1389
+ function matchesListFilters(
1390
+ row: Record<string, unknown>,
1391
+ resolved: ResolvedTransferRequest,
1392
+ ): boolean {
1393
+ const name = readString(row, "name") ?? "";
1394
+ if (resolved.listPath && !name.startsWith(resolved.listPath)) {
1395
+ return false;
1396
+ }
1397
+ if (resolved.type) {
1398
+ const type = readString(row, "type") ?? "";
1399
+ // RouterOS reports file types as e.g. ".txt file" / "directory" / "disk".
1400
+ const wanted = resolved.type;
1401
+ const isMatch =
1402
+ wanted === "file"
1403
+ ? type.includes("file")
1404
+ : type === wanted || type.startsWith(wanted);
1405
+ if (!isMatch) {
1406
+ return false;
1407
+ }
1408
+ }
1409
+ if (resolved.name && !globMatch(resolved.name, name)) {
1410
+ return false;
1411
+ }
1412
+ return true;
1413
+ }
1414
+
1415
+ function globMatch(glob: string, value: string): boolean {
1416
+ const pattern = glob
1417
+ .split("*")
1418
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
1419
+ .join(".*");
1420
+ return new RegExp(`^${pattern}$`).test(value);
1421
+ }
1422
+
1423
+ /**
1424
+ * Resolve the effective remote path. `upload` marks `[remote]` optional in the
1425
+ * CLI/docs: when it is omitted and the source is a real file (not stdin), it
1426
+ * defaults to that file's basename. Stdin uploads (`local === "-"`) can't derive
1427
+ * a name and fall through to {@link requireRemote}'s actionable error.
1428
+ */
1429
+ function resolveRemoteTarget(request: TransferRequest): string | undefined {
1430
+ if (request.remote) {
1431
+ return normalizeRemotePath(request.remote);
1432
+ }
1433
+ if (request.verb === "upload" && request.local && request.local !== "-") {
1434
+ return normalizeRemotePath(basename(request.local));
1435
+ }
1436
+ return undefined;
1437
+ }
1438
+
1439
+ function requireRemote(resolved: ResolvedTransferRequest): string {
1440
+ if (!resolved.remote) {
1441
+ throw new CentrsError({
1442
+ code: "usage/conflicting-flags",
1443
+ summary: `\`transfer ${resolved.verb}\` requires a remote path.`,
1444
+ remediation: "Pass the RouterOS file path.",
1445
+ });
1446
+ }
1447
+ return resolved.remote;
1448
+ }
1449
+
1450
+ function targetExistsError(remote: string): CentrsError {
1451
+ return new CentrsError({
1452
+ code: "usage/target-exists",
1453
+ summary: `${remote} already exists on the device.`,
1454
+ remediation: "Pass `--force` to overwrite, or choose a different name.",
1455
+ context: { remote },
1456
+ });
1457
+ }
1458
+
1459
+ function restWriteCapError(remote: string, bytes: number): CentrsError {
1460
+ return new CentrsError({
1461
+ code: "transport/unsupported-operation",
1462
+ summary: `Cannot write ${bytes} bytes to ${remote} over REST/native (60 KB cap).`,
1463
+ remediation:
1464
+ "Large uploads need sftp (lands with the SSH transport); the REST `/file` write cap is 60 KB.",
1465
+ context: { remote, bytes, capBytes: REST_FILE_WRITE_CAP_BYTES },
1466
+ });
1467
+ }
1468
+
1469
+ function extractNewId(result: ProtocolExecuteResult): string | undefined {
1470
+ if (typeof result.ret === "string" && result.ret.length > 0) {
1471
+ return result.ret;
1472
+ }
1473
+ const first = result.records[0];
1474
+ return first
1475
+ ? (readString(first, "ret") ?? readString(first, ".id"))
1476
+ : undefined;
1477
+ }
1478
+
1479
+ function extractContents(result: ProtocolExecuteResult): string {
1480
+ if (typeof result.ret === "string") {
1481
+ return result.ret;
1482
+ }
1483
+ const first = result.records[0];
1484
+ return (
1485
+ (first && (readString(first, "contents") ?? readString(first, "ret"))) ?? ""
1486
+ );
1487
+ }
1488
+
1489
+ function extractReadData(result: ProtocolExecuteResult): string {
1490
+ if (typeof result.ret === "string") {
1491
+ return result.ret;
1492
+ }
1493
+ const first = result.records[0];
1494
+ return (
1495
+ (first && (readString(first, "data") ?? readString(first, "ret"))) ?? ""
1496
+ );
1497
+ }
1498
+
1499
+ function readString(
1500
+ record: Record<string, unknown>,
1501
+ key: string,
1502
+ ): string | undefined {
1503
+ const value = record[key];
1504
+ return typeof value === "string" ? value : undefined;
1505
+ }
1506
+
1507
+ function readNumber(
1508
+ record: Record<string, unknown>,
1509
+ key: string,
1510
+ ): number | undefined {
1511
+ const value = record[key];
1512
+ if (typeof value === "number") {
1513
+ return value;
1514
+ }
1515
+ if (typeof value === "string" && value.trim().length > 0) {
1516
+ const parsed = Number(value);
1517
+ return Number.isNaN(parsed) ? undefined : parsed;
1518
+ }
1519
+ return undefined;
1520
+ }
1521
+
1522
+ function basename(path: string): string {
1523
+ // Split on both separators: this helper derives a name from RouterOS remote
1524
+ // paths (always `/`) *and* from a host-local path (which uses `\` on Windows).
1525
+ // A `\`-split is harmless for remote paths since RouterOS filenames never
1526
+ // contain a backslash. (Without this, an omitted upload remote on Windows
1527
+ // defaulted to the full `C:\…\file` path instead of `file`.)
1528
+ const parts = path.split(/[/\\]/).filter(Boolean);
1529
+ return parts[parts.length - 1] ?? path;
1530
+ }
1531
+
1532
+ function humanBytes(bytes: number): string {
1533
+ if (!Number.isFinite(bytes)) {
1534
+ return "";
1535
+ }
1536
+ const units = ["B", "KiB", "MiB", "GiB"];
1537
+ let value = bytes;
1538
+ let unit = 0;
1539
+ while (value >= 1024 && unit < units.length - 1) {
1540
+ value /= 1024;
1541
+ unit += 1;
1542
+ }
1543
+ return unit === 0
1544
+ ? `${value}${units[unit]}`
1545
+ : `${value.toFixed(1)}${units[unit]}`;
1546
+ }
1547
+
1548
+ function exhaustiveVerb(verb: never): never {
1549
+ throw new Error(`Unhandled transfer verb: ${String(verb)}`);
1550
+ }
1551
+
1552
+ function exhaustiveFormat(format: never): never {
1553
+ throw new Error(`Unhandled transfer output format: ${String(format)}`);
1554
+ }