@tikoci/centrs 0.1.7 → 0.1.8-next.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.
- package/CHANGELOG.md +235 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +123 -41
- package/commands/api/examples.md +205 -17
- package/commands/devices/README.md +11 -3
- package/commands/execute/README.md +31 -3
- package/commands/execute/examples.md +136 -1
- package/commands/explain/README.md +30 -4
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +422 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +28 -20
- package/docs/CONSTITUTION.md +30 -10
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +511 -186
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +216 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +56 -1
- package/src/core/inspect.ts +43 -5
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +1 -1
- package/src/devices.ts +6 -9
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +13 -8
- package/src/execute.ts +106 -65
- package/src/explain/catalog.ts +58 -10
- package/src/explain/menus.ts +23 -2
- package/src/explain.ts +6 -6
- package/src/index.ts +29 -1
- package/src/mcp/tools.ts +9 -14
- package/src/protocols/adapter.ts +224 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +6 -3
- package/src/protocols/native-api.ts +223 -70
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +53 -7
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +727 -108
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
package/src/settings.ts
CHANGED
|
@@ -26,7 +26,11 @@ import type {
|
|
|
26
26
|
import { buildTip } from "./core/envelope.ts";
|
|
27
27
|
import { toYaml } from "./core/yaml.ts";
|
|
28
28
|
import { loadCdb, resolveDevicesSettings } from "./devices.ts";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
CentrsError,
|
|
31
|
+
formatCentrsErrorText,
|
|
32
|
+
serializeCentrsError,
|
|
33
|
+
} from "./errors.ts";
|
|
30
34
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
31
35
|
import {
|
|
32
36
|
DEFAULT_RECORD_TARGET,
|
|
@@ -38,7 +42,6 @@ import {
|
|
|
38
42
|
readSettingsFileRaw,
|
|
39
43
|
resolveOptionalIntegerSetting,
|
|
40
44
|
} from "./resolver/index.ts";
|
|
41
|
-
import { retrieveOutputFormats } from "./retrieve.ts";
|
|
42
45
|
|
|
43
46
|
// ── Envelope shape ───────────────────────────────────────────────────────────
|
|
44
47
|
|
|
@@ -155,6 +158,12 @@ const TRANSFER_VIA_VALUES = [
|
|
|
155
158
|
"ftp",
|
|
156
159
|
] as const;
|
|
157
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The formats every command renders. `ndjson` is per-command (`api`,
|
|
163
|
+
* `retrieve`), so a shared `format` setting must not select it.
|
|
164
|
+
*/
|
|
165
|
+
const sharedOutputFormats = ["text", "json", "yaml"] as const;
|
|
166
|
+
|
|
158
167
|
/**
|
|
159
168
|
* The 13 `CENTRS_*` keys `settings` fully manages (validated get/set/print/
|
|
160
169
|
* reset). `commands/settings/README.md` ("Not yet wired — exclude from v1")
|
|
@@ -169,14 +178,14 @@ export const settingsManagedKeys: readonly SettingsKeyDef[] = [
|
|
|
169
178
|
parse: (raw) => {
|
|
170
179
|
const value = raw.trim();
|
|
171
180
|
if (
|
|
172
|
-
!
|
|
173
|
-
value as (typeof
|
|
181
|
+
!sharedOutputFormats.includes(
|
|
182
|
+
value as (typeof sharedOutputFormats)[number],
|
|
174
183
|
)
|
|
175
184
|
) {
|
|
176
185
|
throw new CentrsError({
|
|
177
186
|
code: "settings/invalid-format",
|
|
178
187
|
summary: `Unsupported output format: ${value}`,
|
|
179
|
-
remediation: `Choose one of ${
|
|
188
|
+
remediation: `Choose one of ${sharedOutputFormats.join(", ")}.`,
|
|
180
189
|
});
|
|
181
190
|
}
|
|
182
191
|
return { value, write: value };
|
|
@@ -981,7 +990,7 @@ export function renderSettingsEnvelope(
|
|
|
981
990
|
}
|
|
982
991
|
return envelope.ok
|
|
983
992
|
? renderSettingsSuccessText(envelope)
|
|
984
|
-
:
|
|
993
|
+
: formatCentrsErrorText(envelope.error);
|
|
985
994
|
}
|
|
986
995
|
|
|
987
996
|
function formatValue(value: SettingsValue | null): string {
|
|
@@ -1068,12 +1077,3 @@ function renderSettingsSuccessText(
|
|
|
1068
1077
|
|
|
1069
1078
|
return lines.join("\n");
|
|
1070
1079
|
}
|
|
1071
|
-
|
|
1072
|
-
function renderSettingsErrorText(envelope: SettingsErrorEnvelope): string {
|
|
1073
|
-
const error = envelope.error;
|
|
1074
|
-
const lines = [`[${error.code}] ${error.summary}`];
|
|
1075
|
-
if (error.remediation) {
|
|
1076
|
-
lines.push(`Fix: ${error.remediation}`);
|
|
1077
|
-
}
|
|
1078
|
-
return lines.join("\n");
|
|
1079
|
-
}
|
package/src/transfer-fanout.ts
CHANGED
|
@@ -39,7 +39,12 @@ import {
|
|
|
39
39
|
summarizeFanout,
|
|
40
40
|
} from "./core/fanout.ts";
|
|
41
41
|
import { toYaml } from "./core/yaml.ts";
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
CentrsError,
|
|
44
|
+
formatCentrsErrorText,
|
|
45
|
+
serializeCentrsError,
|
|
46
|
+
terminalSafeLine,
|
|
47
|
+
} from "./errors.ts";
|
|
43
48
|
import { promptForWriteConfirmation } from "./execute.ts";
|
|
44
49
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
45
50
|
import {
|
|
@@ -53,7 +58,9 @@ import {
|
|
|
53
58
|
} from "./resolver/index.ts";
|
|
54
59
|
import {
|
|
55
60
|
buildTransferErrorEnvelope,
|
|
61
|
+
buildTransferErrorEnvelopeFromResolved,
|
|
56
62
|
isTransferWriteVerb,
|
|
63
|
+
type ResolvedTransferRequest,
|
|
57
64
|
type TransferEnvelope,
|
|
58
65
|
type TransferOperationMeta,
|
|
59
66
|
type TransferOutputFormat,
|
|
@@ -317,14 +324,20 @@ export async function transferFanout(
|
|
|
317
324
|
)
|
|
318
325
|
: undefined;
|
|
319
326
|
|
|
327
|
+
// Resolution happens inside `transfer()`, so a failure after it would lose the
|
|
328
|
+
// resolved target/method/sources unless the member's resolution is kept.
|
|
329
|
+
const resolvedByMember = new Map<SelectionMember, ResolvedTransferRequest>();
|
|
320
330
|
const execute =
|
|
321
331
|
internals.execute ??
|
|
322
|
-
((req: TransferRequest, member: SelectionMember) =>
|
|
323
|
-
|
|
332
|
+
((req: TransferRequest, member: SelectionMember) => {
|
|
333
|
+
resolvedByMember.delete(member);
|
|
334
|
+
return transfer(req, env, {
|
|
324
335
|
// cdb members carry their pre-resolved record (CDB loaded once);
|
|
325
336
|
// literals fall through to single-target resolution.
|
|
326
337
|
cdbResolution: member.kind === "cdb" ? member.resolution : undefined,
|
|
327
|
-
|
|
338
|
+
onResolved: (resolved) => resolvedByMember.set(member, resolved),
|
|
339
|
+
});
|
|
340
|
+
});
|
|
328
341
|
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
329
342
|
|
|
330
343
|
const targets = await runFanout<
|
|
@@ -373,11 +386,15 @@ export async function transferFanout(
|
|
|
373
386
|
}),
|
|
374
387
|
execute: async (resolved, member) =>
|
|
375
388
|
relabelAdhocMember(await execute(resolved.request, member), member),
|
|
376
|
-
onExecuteError: (
|
|
377
|
-
|
|
378
|
-
|
|
389
|
+
onExecuteError: (planned, member, error) => {
|
|
390
|
+
const resolved = resolvedByMember.get(member);
|
|
391
|
+
return applyMemberMeta(
|
|
392
|
+
resolved
|
|
393
|
+
? buildTransferErrorEnvelopeFromResolved(resolved, error)
|
|
394
|
+
: buildTransferErrorEnvelope(planned.request, error),
|
|
379
395
|
member,
|
|
380
|
-
)
|
|
396
|
+
);
|
|
397
|
+
},
|
|
381
398
|
sleep,
|
|
382
399
|
});
|
|
383
400
|
|
|
@@ -501,11 +518,7 @@ export function renderTransferFanoutEnvelope(
|
|
|
501
518
|
return toYaml(envelope);
|
|
502
519
|
}
|
|
503
520
|
if (!envelope.ok) {
|
|
504
|
-
|
|
505
|
-
if (envelope.error.remediation) {
|
|
506
|
-
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
507
|
-
}
|
|
508
|
-
return lines.join("\n");
|
|
521
|
+
return formatCentrsErrorText(envelope.error, options);
|
|
509
522
|
}
|
|
510
523
|
const operation = envelope.meta.operation;
|
|
511
524
|
const { total, ok, failed } = envelope.data.summary;
|
|
@@ -530,10 +543,22 @@ export function renderTransferFanoutEnvelope(
|
|
|
530
543
|
lines.push(` [${index}] ok ${label}${detail}`);
|
|
531
544
|
} else {
|
|
532
545
|
lines.push(
|
|
533
|
-
|
|
546
|
+
terminalSafeLine(
|
|
547
|
+
` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
|
|
548
|
+
),
|
|
534
549
|
);
|
|
535
550
|
if (options.verbose && target.error.remediation) {
|
|
536
|
-
lines.push(
|
|
551
|
+
lines.push(
|
|
552
|
+
terminalSafeLine(` Fix: ${target.error.remediation}`),
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
for (const tip of target.tips) {
|
|
556
|
+
lines.push(
|
|
557
|
+
terminalSafeLine(` tip [${tip.code}]: ${tip.message}`),
|
|
558
|
+
);
|
|
559
|
+
if (options.verbose && tip.fix) {
|
|
560
|
+
lines.push(terminalSafeLine(` fix: ${tip.fix}`));
|
|
561
|
+
}
|
|
537
562
|
}
|
|
538
563
|
}
|
|
539
564
|
}
|
package/src/transfer.ts
CHANGED
|
@@ -18,19 +18,32 @@
|
|
|
18
18
|
* error (examples P1–P4).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
mkdtempSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
type Stats,
|
|
26
|
+
statSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from "node:fs";
|
|
22
29
|
import { tmpdir } from "node:os";
|
|
23
|
-
import { join } from "node:path";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
24
31
|
import type {
|
|
25
32
|
CentrsEnvelope,
|
|
26
33
|
CentrsErrorEnvelope,
|
|
27
34
|
CentrsSuccessEnvelope,
|
|
28
35
|
SettingSource as CoreSettingSource,
|
|
29
36
|
EnvelopeValidationMeta,
|
|
37
|
+
Tip,
|
|
30
38
|
Warning,
|
|
31
39
|
} from "./core/envelope.ts";
|
|
40
|
+
import { buildTip } from "./core/envelope.ts";
|
|
32
41
|
import { toYaml } from "./core/yaml.ts";
|
|
33
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
CentrsError,
|
|
44
|
+
formatCentrsErrorText,
|
|
45
|
+
serializeCentrsError,
|
|
46
|
+
} from "./errors.ts";
|
|
34
47
|
import {
|
|
35
48
|
createProtocolAdapter,
|
|
36
49
|
type ProtocolAdapter,
|
|
@@ -194,7 +207,7 @@ export type TransferSuccessEnvelope = CentrsSuccessEnvelope<
|
|
|
194
207
|
>;
|
|
195
208
|
export type TransferErrorEnvelope = CentrsErrorEnvelope<TransferOperationMeta>;
|
|
196
209
|
|
|
197
|
-
interface ResolvedTransferRequest {
|
|
210
|
+
export interface ResolvedTransferRequest {
|
|
198
211
|
verb: TransferVerb;
|
|
199
212
|
method: TransferMethod;
|
|
200
213
|
protocol: RouterOsProtocol;
|
|
@@ -219,12 +232,23 @@ interface ResolvedTransferRequest {
|
|
|
219
232
|
|
|
220
233
|
// ── Public entry ────────────────────────────────────────────────────────────
|
|
221
234
|
|
|
235
|
+
export interface TransferOptions {
|
|
236
|
+
cdbResolution?: CdbResolution;
|
|
237
|
+
/**
|
|
238
|
+
* Called once resolution succeeds, before any connection. A caller that
|
|
239
|
+
* catches the later throw uses it to keep the resolved target, method and
|
|
240
|
+
* setting sources in its error envelope.
|
|
241
|
+
*/
|
|
242
|
+
onResolved?: (resolved: ResolvedTransferRequest) => void;
|
|
243
|
+
}
|
|
244
|
+
|
|
222
245
|
export async function transfer(
|
|
223
246
|
request: TransferRequest,
|
|
224
247
|
env: Record<string, string | undefined> = Bun.env,
|
|
225
|
-
options:
|
|
248
|
+
options: TransferOptions = {},
|
|
226
249
|
): Promise<TransferSuccessEnvelope> {
|
|
227
250
|
const resolved = await resolveTransferRequest(request, env, options);
|
|
251
|
+
options.onResolved?.(resolved);
|
|
228
252
|
const backend = createFileBackend(resolved);
|
|
229
253
|
try {
|
|
230
254
|
return await runResolvedTransfer(resolved, backend);
|
|
@@ -233,6 +257,33 @@ export async function transfer(
|
|
|
233
257
|
}
|
|
234
258
|
}
|
|
235
259
|
|
|
260
|
+
/**
|
|
261
|
+
* {@link transfer} as an envelope that never throws. A failure after resolution
|
|
262
|
+
* carries the resolved target, method and setting sources
|
|
263
|
+
* ({@link buildTransferErrorEnvelopeFromResolved}); a failure before it keeps
|
|
264
|
+
* only the caller's input ({@link buildTransferErrorEnvelope}).
|
|
265
|
+
*/
|
|
266
|
+
export async function transferEnvelope(
|
|
267
|
+
request: TransferRequest,
|
|
268
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
269
|
+
options: TransferOptions = {},
|
|
270
|
+
): Promise<TransferEnvelope> {
|
|
271
|
+
let resolved: ResolvedTransferRequest | undefined;
|
|
272
|
+
try {
|
|
273
|
+
return await transfer(request, env, {
|
|
274
|
+
...options,
|
|
275
|
+
onResolved: (value) => {
|
|
276
|
+
resolved = value;
|
|
277
|
+
options.onResolved?.(value);
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return resolved
|
|
282
|
+
? buildTransferErrorEnvelopeFromResolved(resolved, error)
|
|
283
|
+
: buildTransferErrorEnvelope(request, error);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
236
287
|
/**
|
|
237
288
|
* Build the file backend for the resolved method. `rest`/`native` wrap the
|
|
238
289
|
* shared {@link ProtocolAdapter} `/file` command seam; `sftp` drives the host
|
|
@@ -393,6 +444,9 @@ async function runDownload(
|
|
|
393
444
|
warnings: Warning[],
|
|
394
445
|
): Promise<VerbResult> {
|
|
395
446
|
const remote = requireRemote(resolved);
|
|
447
|
+
// A destination that cannot be written fails before any device read, so a
|
|
448
|
+
// typo in the local path costs no transfer and never reads as a centrs bug.
|
|
449
|
+
checkLocalSink(resolved);
|
|
396
450
|
const row = await backend.findFile(remote);
|
|
397
451
|
if (!row) {
|
|
398
452
|
throw new CentrsError({
|
|
@@ -534,7 +588,10 @@ function isRestFamily(method: TransferMethod): boolean {
|
|
|
534
588
|
return method === "rest" || method === "native";
|
|
535
589
|
}
|
|
536
590
|
|
|
537
|
-
/**
|
|
591
|
+
/**
|
|
592
|
+
* REST / native-api `/file` command seam. The one place those wire shapes live.
|
|
593
|
+
* Contents move in the adapters' `binary` mode, as byte strings (#404).
|
|
594
|
+
*/
|
|
538
595
|
class AdapterFileBackend implements FileBackend {
|
|
539
596
|
constructor(private readonly adapter: ProtocolAdapter) {}
|
|
540
597
|
|
|
@@ -565,7 +622,6 @@ class AdapterFileBackend implements FileBackend {
|
|
|
565
622
|
bytes: Buffer,
|
|
566
623
|
existing: FileRow | undefined,
|
|
567
624
|
): Promise<void> {
|
|
568
|
-
const text = bytes.toString("utf8");
|
|
569
625
|
let id = existing?.id;
|
|
570
626
|
if (!id) {
|
|
571
627
|
const added = await this.adapter.execute({
|
|
@@ -587,7 +643,8 @@ class AdapterFileBackend implements FileBackend {
|
|
|
587
643
|
await this.adapter.execute({
|
|
588
644
|
path: "/file",
|
|
589
645
|
command: "set",
|
|
590
|
-
attributes: { ".id": id, contents:
|
|
646
|
+
attributes: { ".id": id, contents: bytes.toString("latin1") },
|
|
647
|
+
binary: true,
|
|
591
648
|
});
|
|
592
649
|
}
|
|
593
650
|
|
|
@@ -598,8 +655,9 @@ class AdapterFileBackend implements FileBackend {
|
|
|
598
655
|
path: "/file",
|
|
599
656
|
command: "get",
|
|
600
657
|
attributes: { ".id": row.id, "value-name": "contents" },
|
|
658
|
+
binary: true,
|
|
601
659
|
});
|
|
602
|
-
return Buffer.from(extractContents(result), "
|
|
660
|
+
return Buffer.from(extractContents(result), "latin1");
|
|
603
661
|
}
|
|
604
662
|
const parts: Buffer[] = [];
|
|
605
663
|
let offset = 0;
|
|
@@ -610,12 +668,13 @@ class AdapterFileBackend implements FileBackend {
|
|
|
610
668
|
path: "/file",
|
|
611
669
|
command: "read",
|
|
612
670
|
attributes: {
|
|
613
|
-
file: row.name,
|
|
671
|
+
file: Buffer.from(row.name, "utf8").toString("latin1"),
|
|
614
672
|
offset: String(offset),
|
|
615
673
|
"chunk-size": String(FILE_READ_CHUNK_BYTES),
|
|
616
674
|
},
|
|
675
|
+
binary: true,
|
|
617
676
|
});
|
|
618
|
-
const chunk = Buffer.from(extractReadData(result), "
|
|
677
|
+
const chunk = Buffer.from(extractReadData(result), "latin1");
|
|
619
678
|
if (chunk.byteLength === 0) {
|
|
620
679
|
break;
|
|
621
680
|
}
|
|
@@ -1249,7 +1308,58 @@ function writeLocalSink(
|
|
|
1249
1308
|
// so a newly created destination file gets owner-only permissions
|
|
1250
1309
|
// instead of the process umask default. Mode only applies on creation —
|
|
1251
1310
|
// an existing destination keeps its current permissions.
|
|
1252
|
-
|
|
1311
|
+
try {
|
|
1312
|
+
writeFileSync(local, bytes, { mode: 0o600 });
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
throw localSinkError(local, "cannot be written", error);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* The local half of download's validate-before-write: the parent directory
|
|
1320
|
+
* must exist (centrs does not create it) and the destination must not be a
|
|
1321
|
+
* directory. stdout (`-`) needs no check.
|
|
1322
|
+
*/
|
|
1323
|
+
function checkLocalSink(resolved: ResolvedTransferRequest): void {
|
|
1324
|
+
const local = resolved.local ?? basename(resolved.remote ?? "");
|
|
1325
|
+
if (local === "-") {
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
const parent = dirname(local);
|
|
1329
|
+
if (!statLocal(local, parent)?.isDirectory()) {
|
|
1330
|
+
throw localSinkError(local, `has no parent directory ${parent}`);
|
|
1331
|
+
}
|
|
1332
|
+
if (statLocal(local, local)?.isDirectory()) {
|
|
1333
|
+
throw localSinkError(local, "is a directory");
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* `statSync` for the sink preflight. Only a missing entry reads as absent;
|
|
1339
|
+
* `throwIfNoEntry` does not cover EACCES, ENOTDIR or ELOOP, and those are
|
|
1340
|
+
* still the caller's path, not a centrs fault.
|
|
1341
|
+
*/
|
|
1342
|
+
function statLocal(local: string, path: string): Stats | undefined {
|
|
1343
|
+
try {
|
|
1344
|
+
return statSync(path, { throwIfNoEntry: false });
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
throw localSinkError(local, `cannot be checked (${path})`, error);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function localSinkError(
|
|
1351
|
+
local: string,
|
|
1352
|
+
reason: string,
|
|
1353
|
+
cause?: unknown,
|
|
1354
|
+
): CentrsError {
|
|
1355
|
+
return new CentrsError({
|
|
1356
|
+
code: "input/local-destination",
|
|
1357
|
+
summary: `Local destination ${local} ${reason}.`,
|
|
1358
|
+
remediation:
|
|
1359
|
+
"Create the parent directory first (centrs does not create it), or pass a writable file path, or `-` for stdout.",
|
|
1360
|
+
context: { local },
|
|
1361
|
+
cause,
|
|
1362
|
+
});
|
|
1253
1363
|
}
|
|
1254
1364
|
|
|
1255
1365
|
function localLabel(resolved: ResolvedTransferRequest): string {
|
|
@@ -1362,20 +1472,121 @@ function metaFromResolved(
|
|
|
1362
1472
|
};
|
|
1363
1473
|
}
|
|
1364
1474
|
|
|
1475
|
+
function asTransferError(error: unknown): CentrsError {
|
|
1476
|
+
return error instanceof CentrsError
|
|
1477
|
+
? error
|
|
1478
|
+
: new CentrsError({
|
|
1479
|
+
code: "internal/unhandled",
|
|
1480
|
+
summary: "transfer failed with an unexpected internal error.",
|
|
1481
|
+
remediation:
|
|
1482
|
+
"Re-run with `--format json` to capture the structured error details.",
|
|
1483
|
+
cause: error,
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* The error envelope for a failure after resolution: the same target, method,
|
|
1489
|
+
* setting sources and operation meta a success would carry, so the caller can
|
|
1490
|
+
* see which host, user and credential sources were tried. Secrets never enter
|
|
1491
|
+
* it — auth meta is `username` + `passwordProvided` only.
|
|
1492
|
+
*/
|
|
1493
|
+
export function buildTransferErrorEnvelopeFromResolved(
|
|
1494
|
+
resolved: ResolvedTransferRequest,
|
|
1495
|
+
error: unknown,
|
|
1496
|
+
): TransferErrorEnvelope {
|
|
1497
|
+
const centrsError = asTransferError(error);
|
|
1498
|
+
return {
|
|
1499
|
+
ok: false,
|
|
1500
|
+
error: serializeCentrsError(centrsError),
|
|
1501
|
+
warnings: [...resolved.warnings],
|
|
1502
|
+
tips: transferErrorTips(resolved, centrsError),
|
|
1503
|
+
// Which probe ran before the failure is unknown here, so no `result`.
|
|
1504
|
+
meta: metaFromResolved(
|
|
1505
|
+
resolved,
|
|
1506
|
+
baseData(resolved, {
|
|
1507
|
+
op: resolved.verb,
|
|
1508
|
+
remote: resolved.remote,
|
|
1509
|
+
local: resolved.local,
|
|
1510
|
+
}),
|
|
1511
|
+
{
|
|
1512
|
+
enabled: resolved.validate.value,
|
|
1513
|
+
source: resolved.validate.value
|
|
1514
|
+
? "device /file existence probe"
|
|
1515
|
+
: "disabled",
|
|
1516
|
+
},
|
|
1517
|
+
),
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Batch sftp never sends a password. When one was resolved anyway, say so —
|
|
1523
|
+
* "the API accepted this password" is otherwise read as "SFTP tried it" (#391).
|
|
1524
|
+
* The REST/native alternative is advice only (never a fallback) and is left out
|
|
1525
|
+
* when the upload is known to exceed their write cap.
|
|
1526
|
+
*/
|
|
1527
|
+
function transferErrorTips(
|
|
1528
|
+
resolved: ResolvedTransferRequest,
|
|
1529
|
+
error: CentrsError,
|
|
1530
|
+
): Tip[] {
|
|
1531
|
+
if (
|
|
1532
|
+
resolved.method !== "sftp" ||
|
|
1533
|
+
error.code !== "auth/failed" ||
|
|
1534
|
+
!resolved.auth.passwordProvided
|
|
1535
|
+
) {
|
|
1536
|
+
return [];
|
|
1537
|
+
}
|
|
1538
|
+
const source = resolved.auth.passwordSource
|
|
1539
|
+
? ` (from ${describeSource(toCoreSource(resolved.auth.passwordSource))})`
|
|
1540
|
+
: "";
|
|
1541
|
+
const tips = [
|
|
1542
|
+
buildTip(
|
|
1543
|
+
"tip/sftp-password-not-used",
|
|
1544
|
+
`A password was resolved${source}, but batch SFTP does not send it; it tries SSH keys only.`,
|
|
1545
|
+
"Install the user's public key on the device and select it with --ssh-key, or use a password transport for this transfer.",
|
|
1546
|
+
),
|
|
1547
|
+
];
|
|
1548
|
+
if (!uploadExceedsWriteCap(resolved)) {
|
|
1549
|
+
// An explicit port here is the SSH port; carried over, rest would dial it.
|
|
1550
|
+
const portSource = toCoreSource(resolved.target.portSource);
|
|
1551
|
+
const port =
|
|
1552
|
+
portSource.kind === "default"
|
|
1553
|
+
? ""
|
|
1554
|
+
: ` Port ${resolved.target.port} (from ${describeSource(portSource)}) is the SSH port; point --port at the REST service or leave it unset.`;
|
|
1555
|
+
tips.push(
|
|
1556
|
+
buildTip(
|
|
1557
|
+
"tip/transfer-password-transport",
|
|
1558
|
+
"`--via rest` and `--via native-api` log in with the resolved username and password.",
|
|
1559
|
+
`Re-run with --via rest; uploads over rest/native-api are capped at ${REST_FILE_WRITE_CAP_BYTES} bytes.${port}`,
|
|
1560
|
+
),
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
return tips;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function describeSource(source: CoreSettingSource): string {
|
|
1567
|
+
return source.key ? `${source.kind} ${source.key}` : source.kind;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function uploadExceedsWriteCap(resolved: ResolvedTransferRequest): boolean {
|
|
1571
|
+
if (
|
|
1572
|
+
resolved.verb !== "upload" ||
|
|
1573
|
+
resolved.local === undefined ||
|
|
1574
|
+
resolved.local === "-"
|
|
1575
|
+
) {
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1578
|
+
try {
|
|
1579
|
+
return statSync(resolved.local).size > REST_FILE_WRITE_CAP_BYTES;
|
|
1580
|
+
} catch {
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1365
1585
|
export function buildTransferErrorEnvelope(
|
|
1366
1586
|
request: TransferRequest,
|
|
1367
1587
|
error: unknown,
|
|
1368
1588
|
): TransferErrorEnvelope {
|
|
1369
|
-
const centrsError =
|
|
1370
|
-
error instanceof CentrsError
|
|
1371
|
-
? error
|
|
1372
|
-
: new CentrsError({
|
|
1373
|
-
code: "internal/unhandled",
|
|
1374
|
-
summary: "transfer failed with an unexpected internal error.",
|
|
1375
|
-
remediation:
|
|
1376
|
-
"Re-run with `--format json` to capture the structured error details.",
|
|
1377
|
-
cause: error,
|
|
1378
|
-
});
|
|
1589
|
+
const centrsError = asTransferError(error);
|
|
1379
1590
|
|
|
1380
1591
|
return {
|
|
1381
1592
|
ok: false,
|
|
@@ -1405,7 +1616,7 @@ export function renderTransferEnvelope(
|
|
|
1405
1616
|
case "text":
|
|
1406
1617
|
return envelope.ok
|
|
1407
1618
|
? renderSuccessText(envelope, options)
|
|
1408
|
-
:
|
|
1619
|
+
: formatCentrsErrorText(envelope.error, options);
|
|
1409
1620
|
default:
|
|
1410
1621
|
return exhaustiveFormat(format);
|
|
1411
1622
|
}
|
|
@@ -1457,15 +1668,6 @@ function renderSuccessText(
|
|
|
1457
1668
|
return lines.join("\n");
|
|
1458
1669
|
}
|
|
1459
1670
|
|
|
1460
|
-
function renderErrorText(envelope: TransferErrorEnvelope): string {
|
|
1461
|
-
const { error } = envelope;
|
|
1462
|
-
const lines = [`[${error.code}] ${error.summary}`];
|
|
1463
|
-
if (error.remediation) {
|
|
1464
|
-
lines.push(`Fix: ${error.remediation}`);
|
|
1465
|
-
}
|
|
1466
|
-
return lines.join("\n");
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
1671
|
// ── Small helpers ────────────────────────────────────────────────────────────
|
|
1470
1672
|
|
|
1471
1673
|
function uploadSizeHint(request: TransferRequest): number | undefined {
|