@tikoci/centrs 0.1.7 → 0.1.8

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 (82) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/mcp/README.md +5 -1
  14. package/commands/mcp/examples.md +18 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +422 -5
  17. package/commands/terminal/README.md +14 -4
  18. package/commands/terminal/examples.md +22 -3
  19. package/commands/transfer/README.md +55 -9
  20. package/commands/transfer/examples.md +106 -7
  21. package/docs/CLI.md +28 -20
  22. package/docs/CONSTITUTION.md +30 -10
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/input/invalid-query.md +36 -0
  26. package/docs/errors/input/local-destination.md +20 -0
  27. package/docs/errors/input/unsupported-query.md +19 -0
  28. package/docs/errors/internal/byte-string.md +13 -0
  29. package/docs/errors/settings/invalid-timeout.md +6 -0
  30. package/docs/errors/transport/stream-overflow.md +21 -0
  31. package/docs/errors/usage/removed-flag.md +20 -0
  32. package/docs/errors/validation/menu-unavailable.md +22 -0
  33. package/docs/errors/validation/not-followable.md +23 -0
  34. package/docs/errors/validation/package-missing.md +29 -0
  35. package/docs/errors/validation/unknown-path.md +16 -4
  36. package/docs/errors/wait/deadline-exceeded.md +14 -0
  37. package/docs/errors/wait/interrupted.md +10 -0
  38. package/package.json +14 -11
  39. package/src/absent-menu.ts +280 -0
  40. package/src/api-fanout.ts +22 -12
  41. package/src/api.ts +511 -186
  42. package/src/btest.ts +19 -17
  43. package/src/cli/api.ts +126 -34
  44. package/src/cli/execute.ts +18 -5
  45. package/src/cli/retrieve.ts +223 -22
  46. package/src/cli/terminal.ts +10 -5
  47. package/src/cli/transfer.ts +23 -8
  48. package/src/core/envelope.ts +14 -0
  49. package/src/core/error-catalog.ts +56 -1
  50. package/src/core/inspect.ts +43 -5
  51. package/src/core/query.ts +508 -0
  52. package/src/core/routeros-errors.ts +1 -1
  53. package/src/devices.ts +6 -9
  54. package/src/discover.ts +7 -8
  55. package/src/errors.ts +116 -7
  56. package/src/execute-fanout.ts +13 -8
  57. package/src/execute.ts +106 -65
  58. package/src/explain/catalog.ts +58 -10
  59. package/src/explain/menus.ts +23 -2
  60. package/src/explain.ts +6 -6
  61. package/src/index.ts +29 -1
  62. package/src/mcp/safety.ts +43 -18
  63. package/src/mcp/tools.ts +14 -17
  64. package/src/protocols/adapter.ts +224 -118
  65. package/src/protocols/btest-session.ts +3 -2
  66. package/src/protocols/endpoint.ts +10 -0
  67. package/src/protocols/mac-telnet-console.ts +6 -3
  68. package/src/protocols/native-api.ts +223 -70
  69. package/src/protocols/sftp.ts +44 -14
  70. package/src/protocols/ssh.ts +42 -14
  71. package/src/resolver/cdb.ts +28 -7
  72. package/src/resolver/index.ts +1 -0
  73. package/src/resolver/target.ts +53 -7
  74. package/src/retrieve-fanout.ts +51 -12
  75. package/src/retrieve-follow.ts +862 -0
  76. package/src/retrieve-sample.ts +244 -0
  77. package/src/retrieve-stream.ts +115 -0
  78. package/src/retrieve-wait.ts +286 -0
  79. package/src/retrieve.ts +732 -108
  80. package/src/settings.ts +15 -15
  81. package/src/transfer-fanout.ts +40 -15
  82. 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 { CentrsError, serializeCentrsError } from "./errors.ts";
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
- !retrieveOutputFormats.includes(
173
- value as (typeof retrieveOutputFormats)[number],
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 ${retrieveOutputFormats.join(", ")}.`,
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
- : renderSettingsErrorText(envelope);
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
- }
@@ -39,7 +39,12 @@ import {
39
39
  summarizeFanout,
40
40
  } from "./core/fanout.ts";
41
41
  import { toYaml } from "./core/yaml.ts";
42
- import { CentrsError, serializeCentrsError } from "./errors.ts";
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
- transfer(req, env, {
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: (resolved, member, error) =>
377
- applyMemberMeta(
378
- buildTransferErrorEnvelope(resolved.request, error),
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
- const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
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
- ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
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(` Fix: ${target.error.remediation}`);
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 { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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 { CentrsError, serializeCentrsError } from "./errors.ts";
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: { cdbResolution?: CdbResolution } = {},
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
- /** REST / native-api `/file` command seam. The one place those wire shapes live. */
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: text },
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), "utf8");
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), "utf8");
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
- writeFileSync(local, bytes, { mode: 0o600 });
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
- : renderErrorText(envelope);
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 {