@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.27

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 (91) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/GLOSSARY.txt +23 -1
  3. package/README.md +30 -18
  4. package/commands/api/AGENTS.md +21 -3
  5. package/commands/api/README.md +18 -7
  6. package/commands/api/examples.md +1 -1
  7. package/commands/btest/README.md +9 -0
  8. package/commands/btest/examples.md +7 -0
  9. package/commands/devices/README.md +72 -3
  10. package/commands/devices/examples.md +56 -1
  11. package/commands/execute/README.md +37 -6
  12. package/commands/execute/examples.md +24 -5
  13. package/commands/explain/README.md +675 -64
  14. package/commands/explain/examples.md +199 -42
  15. package/commands/retrieve/README.md +35 -3
  16. package/commands/retrieve/examples.md +114 -1
  17. package/commands/transfer/README.md +33 -4
  18. package/commands/transfer/examples.md +46 -0
  19. package/docs/CLI.md +30 -8
  20. package/docs/CONSTITUTION.md +14 -9
  21. package/docs/MATRIX.md +37 -22
  22. package/docs/errors/cdb/encrypt-failed.md +10 -0
  23. package/docs/errors/cdb/password-required.md +5 -4
  24. package/docs/errors/cdb/plaintext-backups.md +11 -0
  25. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  26. package/docs/errors/input/invalid-routeros-path.md +25 -3
  27. package/docs/errors/routeros/permission-denied.md +24 -0
  28. package/docs/errors/transport/edge-auth-failed.md +22 -0
  29. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  30. package/docs/errors/usage/invalid-cursor.md +20 -0
  31. package/docs/errors/validation/unknown-path.md +9 -0
  32. package/package.json +9 -5
  33. package/src/api-fanout.ts +2 -0
  34. package/src/api.ts +46 -39
  35. package/src/btest.ts +7 -0
  36. package/src/cli/api.ts +18 -1
  37. package/src/cli/btest.ts +9 -0
  38. package/src/cli/devices.ts +76 -8
  39. package/src/cli/execute.ts +9 -0
  40. package/src/cli/explain.ts +206 -47
  41. package/src/cli/retrieve.ts +9 -0
  42. package/src/cli/terminal.ts +9 -0
  43. package/src/cli/transfer.ts +10 -1
  44. package/src/core/envelope.ts +10 -3
  45. package/src/core/error-catalog.ts +28 -2
  46. package/src/core/execute-command.ts +53 -0
  47. package/src/core/fanout.ts +12 -2
  48. package/src/core/inspect.ts +4 -5
  49. package/src/core/menu-path.ts +89 -0
  50. package/src/core/routeros-errors.ts +25 -0
  51. package/src/devices.ts +154 -9
  52. package/src/execute-cmr.ts +2 -8
  53. package/src/execute-fanout.ts +2 -0
  54. package/src/execute.ts +55 -20
  55. package/src/explain/args.ts +115 -10
  56. package/src/explain/blocks.ts +2 -2
  57. package/src/explain/catalog.ts +22 -12
  58. package/src/explain/group-faults.ts +162 -29
  59. package/src/explain/operators.ts +2 -1
  60. package/src/explain/pasted-prompt.ts +198 -0
  61. package/src/explain/pathresolve.ts +2 -2
  62. package/src/explain/scope-brace.ts +226 -11
  63. package/src/explain/segment.ts +138 -3
  64. package/src/explain/symbols.ts +148 -8
  65. package/src/explain/values.ts +55 -4
  66. package/src/explain/verbsplit.ts +2 -2
  67. package/src/explain/write.ts +2 -2
  68. package/src/explain-live-facets.ts +739 -0
  69. package/src/explain-live-types.ts +490 -0
  70. package/src/explain-live.ts +593 -0
  71. package/src/explain.ts +397 -43
  72. package/src/index.ts +10 -0
  73. package/src/protocols/adapter.ts +90 -10
  74. package/src/protocols/btest-session.ts +10 -30
  75. package/src/protocols/ec-srp5.ts +19 -10
  76. package/src/protocols/mac-telnet-console.ts +2 -2
  77. package/src/protocols/mac-telnet.ts +38 -10
  78. package/src/protocols/native-api.ts +13 -12
  79. package/src/resolver/cdb.ts +58 -2
  80. package/src/resolver/index.ts +1 -0
  81. package/src/resolver/quickchr-provider.ts +5 -0
  82. package/src/resolver/selection.ts +11 -0
  83. package/src/retrieve-fanout.ts +2 -0
  84. package/src/retrieve-follow.ts +2 -1
  85. package/src/retrieve-sample.ts +2 -1
  86. package/src/retrieve-wait.ts +10 -6
  87. package/src/retrieve.ts +88 -27
  88. package/src/settings.ts +1 -0
  89. package/src/terminal.ts +7 -0
  90. package/src/transfer-fanout.ts +2 -0
  91. package/src/transfer.ts +117 -27
package/src/retrieve.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  isCommandNode,
21
21
  pathTokens,
22
22
  } from "./core/inspect.ts";
23
+ import { assertRouterOsMenuPath } from "./core/menu-path.ts";
23
24
  import {
24
25
  compileQueryWords,
25
26
  type ParsedQuery,
@@ -101,6 +102,12 @@ export interface RetrieveRequest {
101
102
  maxResultsBytes?: number;
102
103
  cdbFile?: string;
103
104
  cdbPassword?: string;
105
+ /**
106
+ * `--match` selector for a `<router>` several CDB records share
107
+ * (`user=<name>`, `target=<addr>`, or a record type): the same selector
108
+ * `devices show --match` takes. Single-target only (#583).
109
+ */
110
+ match?: string;
104
111
  /** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
105
112
  resolve?: string;
106
113
  /** CDB group selector — expands to a fanout over all matching records. */
@@ -455,12 +462,10 @@ export async function runResolvedRetrieve(
455
462
  await inspectRetrievePath(resolved, backend),
456
463
  backend,
457
464
  );
458
- const validation: EnvelopeValidationMeta = {
459
- enabled: true,
460
- source: "live /console/inspect request=child+completion",
461
- result: "passed",
465
+ const validation = retrieveValidationPassed(
466
+ "live /console/inspect request=child+completion",
462
467
  availableAttributes,
463
- };
468
+ );
464
469
  trace.validation = validation;
465
470
  const envelope = buildSuccessEnvelope(
466
471
  resolved,
@@ -533,14 +538,26 @@ export async function validateRetrieveRead(
533
538
  return {
534
539
  inspection,
535
540
  query,
536
- validation: {
537
- enabled: true,
538
- source: availableAttributes
541
+ validation: retrieveValidationPassed(
542
+ availableAttributes
539
543
  ? "live /console/inspect request=child+completion"
540
544
  : "live /console/inspect request=child",
541
- result: "passed",
542
545
  availableAttributes,
543
- },
546
+ ),
547
+ };
548
+ }
549
+
550
+ /** retrieve's gate is the device stage alone; a path read has no CLI string to analyze. */
551
+ function retrieveValidationPassed(
552
+ source: string,
553
+ availableAttributes: readonly string[] | undefined,
554
+ ): EnvelopeValidationMeta {
555
+ return {
556
+ enabled: true,
557
+ source,
558
+ result: "passed",
559
+ availableAttributes,
560
+ stages: [{ stage: "device", source, result: "passed" }],
544
561
  };
545
562
  }
546
563
 
@@ -625,29 +642,20 @@ const RETRIEVE_DEVICE_STAGE_SOURCE = "live /console/inspect";
625
642
  * an inspect answer: `validation/*`, and the singleton `--query` refusal
626
643
  * (`usage/conflicting-flags`). A `transport/*` or `auth/*` failure means the
627
644
  * device never answered; a `routeros/*` error is a device answer that is not
628
- * a verdict; `internal/*` is a centrs defect. Those four report no `result`.
645
+ * a verdict; `internal/*` is a centrs defect. A `wait/*` error ends a `--wait`
646
+ * whose gate never settled; one that settled reports its own result instead
647
+ * (#537). Those five report no `result`.
629
648
  */
630
649
  function unsettledValidationMeta(
631
650
  resolved: ResolvedRetrieveRequest,
632
651
  error: CentrsError,
633
652
  ): EnvelopeValidationMeta {
653
+ if (stoppedBeforeGateAnswer(error.code)) {
654
+ return notReachedValidationMeta(resolved, error.code);
655
+ }
634
656
  if (!resolved.validate.value) {
635
657
  return { enabled: false, source: "disabled" };
636
658
  }
637
- if (/^(transport|auth|routeros|internal)\//.test(error.code)) {
638
- return {
639
- enabled: true,
640
- source: RETRIEVE_DEVICE_STAGE_SOURCE,
641
- stages: [
642
- {
643
- stage: "device",
644
- source: RETRIEVE_DEVICE_STAGE_SOURCE,
645
- result: "skipped",
646
- reason: notReachedReason(error.code),
647
- },
648
- ],
649
- };
650
- }
651
659
  return {
652
660
  enabled: true,
653
661
  source: RETRIEVE_DEVICE_STAGE_SOURCE,
@@ -662,6 +670,37 @@ function unsettledValidationMeta(
662
670
  };
663
671
  }
664
672
 
673
+ /** Whether an error code stops a run without being a gate answer (GH#435, #537). */
674
+ export function stoppedBeforeGateAnswer(code: string): boolean {
675
+ return /^(transport|auth|routeros|internal|wait)\//.test(code);
676
+ }
677
+
678
+ /**
679
+ * `meta.validation` when the gate was never reached. `cause` is the error code
680
+ * that stopped it, or `interrupted` for a caller's cancellation, which has no
681
+ * code (#537).
682
+ */
683
+ export function notReachedValidationMeta(
684
+ resolved: ResolvedRetrieveRequest,
685
+ cause: string,
686
+ ): EnvelopeValidationMeta {
687
+ if (!resolved.validate.value) {
688
+ return { enabled: false, source: "disabled" };
689
+ }
690
+ return {
691
+ enabled: true,
692
+ source: RETRIEVE_DEVICE_STAGE_SOURCE,
693
+ stages: [
694
+ {
695
+ stage: "device",
696
+ source: RETRIEVE_DEVICE_STAGE_SOURCE,
697
+ result: "skipped",
698
+ reason: notReachedReason(cause),
699
+ },
700
+ ],
701
+ };
702
+ }
703
+
665
704
  export function renderRetrieveEnvelope(
666
705
  envelope: RetrieveEnvelope,
667
706
  format: RetrieveOutputFormat,
@@ -888,6 +927,9 @@ export function validateRetrieveRequestShape(
888
927
  context: { path: request.path },
889
928
  });
890
929
  }
930
+ // Every transport, even under --validate=false: a malformed segment can
931
+ // cut the appended `/print` off a REST URL and dispatch a write (#586).
932
+ assertRouterOsMenuPath(request.path);
891
933
 
892
934
  const query = resolveRetrieveQuery(request);
893
935
  if (query && request.listAttributes) {
@@ -1325,6 +1367,7 @@ export async function resolveRetrieveRequest(
1325
1367
  targetInput: request.targetInput,
1326
1368
  cdbFile: request.cdbFile,
1327
1369
  cdbPassword: request.cdbPassword,
1370
+ match: request.match,
1328
1371
  },
1329
1372
  env,
1330
1373
  config,
@@ -1499,8 +1542,7 @@ export async function inspectRetrievePath(
1499
1542
  throw new CentrsError({
1500
1543
  code: "validation/unknown-path",
1501
1544
  summary: `RouterOS path ${resolved.path} does not expose a retrieve command.`,
1502
- remediation:
1503
- "Check the slash-prefixed RouterOS path, or use a known readable path such as `/system/resource`, `/system/identity`, `/ip/address`, or `/interface`.",
1545
+ remediation: retrieveUnknownPathRemediation(resolved.path),
1504
1546
  context: {
1505
1547
  path: resolved.path,
1506
1548
  validationSource: "/console/inspect request=child",
@@ -1526,6 +1568,25 @@ export async function inspectRetrievePath(
1526
1568
  };
1527
1569
  }
1528
1570
 
1571
+ /**
1572
+ * The `validation/unknown-path` remediation for a retrieve read. One path
1573
+ * earns a targeted hint: `/system/routerboard` exists only on MikroTik
1574
+ * hardware, so its absence on CHR and x86 systems is expected rather than a
1575
+ * typo — and the caller's likely want, the device's license ID, lives at
1576
+ * `/system/license` instead (`system-id` on CHR, `software-id` on x86; manual
1577
+ * "RouterOS License Keys", CLI Reference `/system/license`). The example
1578
+ * reads the whole object because a projected attribute would fail on one
1579
+ * platform or the other. The hint is static on purpose: on hardware the menu
1580
+ * always validates, so this branch only fires where the menu is genuinely
1581
+ * absent, and no extra round trip is needed to say so.
1582
+ */
1583
+ export function retrieveUnknownPathRemediation(path: string): string {
1584
+ if (path.replace(/\/$/, "").toLowerCase() === "/system/routerboard") {
1585
+ return "This device exposes no /system/routerboard menu — expected on CHR and x86 systems, which have no routerboard. For this device's license ID, read /system/license instead: `centrs retrieve <router> /system/license` (system-id on CHR, software-id on x86).";
1586
+ }
1587
+ return "Check the slash-prefixed RouterOS path, or use a known readable path such as `/system/resource`, `/system/identity`, `/ip/address`, or `/interface`.";
1588
+ }
1589
+
1529
1590
  /**
1530
1591
  * Inspect the menu's attribute names and reject requested ones it lacks with
1531
1592
  * `validation/unknown-attribute`. Shared by one-shot retrieve and `--follow`.
package/src/settings.ts CHANGED
@@ -153,6 +153,7 @@ const TRANSFER_VIA_VALUES = [
153
153
  "native",
154
154
  "native-api",
155
155
  "sftp",
156
+ "ssh",
156
157
  "scp",
157
158
  "fetch",
158
159
  "ftp",
package/src/terminal.ts CHANGED
@@ -81,6 +81,12 @@ export interface TerminalRequest {
81
81
  insecure?: boolean;
82
82
  cdbFile?: string;
83
83
  cdbPassword?: string;
84
+ /**
85
+ * `--match` selector for a `<router>` several CDB records share
86
+ * (`user=<name>`, `target=<addr>`, or a record type): the same selector
87
+ * `devices show --match` takes. Single-target only (#583).
88
+ */
89
+ match?: string;
84
90
  format?: string;
85
91
  verbose?: boolean;
86
92
  }
@@ -154,6 +160,7 @@ export async function resolveTerminalRequest(
154
160
  targetInput: request.targetInput,
155
161
  cdbFile: request.cdbFile,
156
162
  cdbPassword: request.cdbPassword,
163
+ match: request.match,
157
164
  },
158
165
  env,
159
166
  config,
@@ -48,6 +48,7 @@ import {
48
48
  import { promptForWriteConfirmation } from "./execute.ts";
49
49
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
50
50
  import {
51
+ assertNoMatchInFanout,
51
52
  emptyWriteSelectionError,
52
53
  expandSelection,
53
54
  isDefaultRecordTarget,
@@ -265,6 +266,7 @@ export async function transferFanout(
265
266
  options: TransferFanoutOptions = {},
266
267
  ): Promise<TransferFanoutEnvelope> {
267
268
  validateTransferRequestShape(request);
269
+ assertNoMatchInFanout(request.match);
268
270
  const config = await loadEnvFileDefaults(env);
269
271
  const selectionSummary = summarizeSelection(selection);
270
272
  const requestSummary: TransferFanoutRequestSummary = {
package/src/transfer.ts CHANGED
@@ -113,6 +113,20 @@ export const transferMethods = [
113
113
  ] as const;
114
114
  export type TransferMethod = (typeof transferMethods)[number];
115
115
 
116
+ /**
117
+ * The transfer method a CDB record's transport preference (`via=` in its
118
+ * comment, shared with execute) selects (#591). A transport missing here
119
+ * (mac-telnet, winbox, …) cannot carry files and fails `settings/invalid-via`
120
+ * when the record's preference is what decided the method.
121
+ */
122
+ export const transferMethodForTransport: Partial<
123
+ Record<RouterOsProtocol, TransferMethod>
124
+ > = {
125
+ ssh: "sftp",
126
+ "rest-api": "rest",
127
+ "native-api": "native",
128
+ };
129
+
116
130
  /** RouterOS `/file` contents write cap (`/file/set contents`). */
117
131
  export const REST_FILE_WRITE_CAP_BYTES = 60_000;
118
132
  /** Maximum `/file/read` chunk RouterOS accepts (`chunk-size` upper bound). */
@@ -158,6 +172,12 @@ export interface TransferRequest {
158
172
  name?: string;
159
173
  cdbFile?: string;
160
174
  cdbPassword?: string;
175
+ /**
176
+ * `--match` selector for a `<router>` several CDB records share
177
+ * (`user=<name>`, `target=<addr>`, or a record type): the same selector
178
+ * `devices show --match` takes. Single-target only (#583).
179
+ */
180
+ match?: string;
161
181
  /** `download` fan-out: directory to write one file per target into. */
162
182
  outDir?: string;
163
183
  /** Fan-out write gate: confirm a mutating fan-out (upload/remove/mkdir/copy). */
@@ -410,7 +430,7 @@ async function runUpload(
410
430
  isRestFamily(resolved.method) &&
411
431
  bytes.byteLength > REST_FILE_WRITE_CAP_BYTES
412
432
  ) {
413
- throw restWriteCapError(remote, bytes.byteLength);
433
+ throw restWriteCapError(remote, bytes.byteLength, resolved.via);
414
434
  }
415
435
 
416
436
  const existing = await backend.findFile(remote);
@@ -879,13 +899,6 @@ export async function resolveTransferRequest(
879
899
  const direction: TransferDirection =
880
900
  request.verb === "upload" ? "upload" : "download";
881
901
  const sizeHint = uploadSizeHint(request);
882
- const { method, protocol, via } = selectTransferMethod(
883
- request,
884
- direction,
885
- sizeHint,
886
- env,
887
- config,
888
- );
889
902
 
890
903
  // A quickchr target is a named-live-provider: host/port/auth come from the
891
904
  // live descriptor, bypassing CDB and the `__default__` ladder. For
@@ -908,10 +921,21 @@ export async function resolveTransferRequest(
908
921
  targetInput: request.targetInput,
909
922
  cdbFile: request.cdbFile,
910
923
  cdbPassword: request.cdbPassword,
924
+ match: request.match,
911
925
  },
912
926
  env,
913
927
  config,
914
928
  )));
929
+ // After CDB resolution: a record's `via=` preference is a layer of the
930
+ // method choice, mapped from transport to transfer-method names (#591).
931
+ const { method, protocol, via } = selectTransferMethod(
932
+ request,
933
+ direction,
934
+ sizeHint,
935
+ env,
936
+ config,
937
+ cdbResolution?.overrides.via,
938
+ );
915
939
 
916
940
  const format = resolveTransferFormat(request, env, config);
917
941
  const validate = resolveBooleanSetting(
@@ -1041,6 +1065,7 @@ export function selectTransferMethod(
1041
1065
  uploadBytes: number | undefined,
1042
1066
  env: Record<string, string | undefined>,
1043
1067
  config: Record<string, string | undefined> = {},
1068
+ cdbVia?: ResolvedSetting<RouterOsProtocol>,
1044
1069
  ): {
1045
1070
  method: TransferMethod;
1046
1071
  protocol: RouterOsProtocol;
@@ -1053,10 +1078,32 @@ export function selectTransferMethod(
1053
1078
  "auto",
1054
1079
  "via",
1055
1080
  undefined,
1056
- undefined,
1081
+ cdbVia === undefined
1082
+ ? undefined
1083
+ : {
1084
+ value: transferMethodForTransport[cdbVia.value] ?? cdbVia.value,
1085
+ source: cdbVia.source,
1086
+ },
1057
1087
  config,
1058
1088
  ) as ResolvedSetting<string>;
1059
1089
  const requested = via.value;
1090
+ if (
1091
+ via.source.kind === "comment-kv" &&
1092
+ cdbVia !== undefined &&
1093
+ transferMethodForTransport[cdbVia.value] === undefined
1094
+ ) {
1095
+ throw new CentrsError({
1096
+ code: "settings/invalid-via",
1097
+ summary: `The CDB record prefers \`via=${cdbVia.value}\`, which cannot carry files.`,
1098
+ remediation:
1099
+ "Pass `--via sftp`, `--via rest` or `--via native` for this transfer (or set `CENTRS_TRANSFER_VIA`). The record's `via` stays in effect for execute.",
1100
+ context: {
1101
+ via: cdbVia.value,
1102
+ source: cdbVia.source.key,
1103
+ supported: Object.keys(transferMethodForTransport),
1104
+ },
1105
+ });
1106
+ }
1060
1107
 
1061
1108
  if (requested === "auto") {
1062
1109
  // Auto: REST family carries reads and small writes; a large upload exceeds
@@ -1078,7 +1125,8 @@ export function selectTransferMethod(
1078
1125
  if (requested === "native" || requested === "native-api") {
1079
1126
  return { method: "native", protocol: "native-api", via };
1080
1127
  }
1081
- if (requested === "sftp") {
1128
+ // `ssh` is the transport name `execute` and CDB `via=` use (#591).
1129
+ if (requested === "sftp" || requested === "ssh") {
1082
1130
  return { method: "sftp", protocol: "ssh", via };
1083
1131
  }
1084
1132
  if (requested === "scp" || requested === "fetch") {
@@ -1401,13 +1449,26 @@ function buildSuccessEnvelope(
1401
1449
  data,
1402
1450
  warnings,
1403
1451
  tips: [],
1404
- meta: metaFromResolved(resolved, operationData, {
1405
- enabled: resolved.validate.value,
1406
- source: resolved.validate.value
1407
- ? "device /file existence probe"
1408
- : "disabled",
1409
- result: resolved.validate.value ? "passed" : "skipped",
1410
- }),
1452
+ meta: metaFromResolved(
1453
+ resolved,
1454
+ operationData,
1455
+ !hasExistenceProbe(resolved)
1456
+ ? { enabled: false }
1457
+ : resolved.validate.value
1458
+ ? {
1459
+ enabled: true,
1460
+ source: TRANSFER_DEVICE_STAGE_SOURCE,
1461
+ result: "passed",
1462
+ stages: [
1463
+ {
1464
+ stage: "device",
1465
+ source: TRANSFER_DEVICE_STAGE_SOURCE,
1466
+ result: "passed",
1467
+ },
1468
+ ],
1469
+ }
1470
+ : { enabled: false, source: "disabled", result: "skipped" },
1471
+ ),
1411
1472
  };
1412
1473
  }
1413
1474
 
@@ -1491,6 +1552,16 @@ function asTransferError(error: unknown): CentrsError {
1491
1552
  * see which host, user and credential sources were tried. Secrets never enter
1492
1553
  * it — auth meta is `username` + `passwordProvided` only.
1493
1554
  */
1555
+ const TRANSFER_DEVICE_STAGE_SOURCE = "device /file existence probe";
1556
+
1557
+ /**
1558
+ * `list` reads `/file` and has no precondition probe, so it is not a gate:
1559
+ * like `explain`, it reports `{ enabled: false }` and no verdict (#535).
1560
+ */
1561
+ function hasExistenceProbe(resolved: ResolvedTransferRequest): boolean {
1562
+ return resolved.verb !== "list";
1563
+ }
1564
+
1494
1565
  export function buildTransferErrorEnvelopeFromResolved(
1495
1566
  resolved: ResolvedTransferRequest,
1496
1567
  error: unknown,
@@ -1509,12 +1580,14 @@ export function buildTransferErrorEnvelopeFromResolved(
1509
1580
  remote: resolved.remote,
1510
1581
  local: resolved.local,
1511
1582
  }),
1512
- {
1513
- enabled: resolved.validate.value,
1514
- source: resolved.validate.value
1515
- ? "device /file existence probe"
1516
- : "disabled",
1517
- },
1583
+ hasExistenceProbe(resolved)
1584
+ ? {
1585
+ enabled: resolved.validate.value,
1586
+ source: resolved.validate.value
1587
+ ? TRANSFER_DEVICE_STAGE_SOURCE
1588
+ : "disabled",
1589
+ }
1590
+ : { enabled: false },
1518
1591
  ),
1519
1592
  };
1520
1593
  }
@@ -1747,13 +1820,30 @@ function targetExistsError(remote: string): CentrsError {
1747
1820
  });
1748
1821
  }
1749
1822
 
1750
- function restWriteCapError(remote: string, bytes: number): CentrsError {
1823
+ function restWriteCapError(
1824
+ remote: string,
1825
+ bytes: number,
1826
+ via: ResolvedSetting<string>,
1827
+ ): CentrsError {
1828
+ // A pinned REST method is never hopped to sftp; name the pin so a record
1829
+ // or env preference the caller did not type is visible (#591 review).
1830
+ const pinnedBy =
1831
+ via.source.kind === "comment-kv"
1832
+ ? ` The method came from the CDB record's \`via=\` (\`${via.source.key}\`).`
1833
+ : via.source.kind === "env" || via.source.kind === "config"
1834
+ ? ` The method came from \`${via.source.key}=${via.value}\` (${via.source.kind}).`
1835
+ : "";
1751
1836
  return new CentrsError({
1752
1837
  code: "transport/unsupported-operation",
1753
1838
  summary: `Cannot write ${bytes} bytes to ${remote} over REST/native (60 KB cap).`,
1754
- remediation:
1755
- "Large uploads need sftp (lands with the SSH transport); the REST `/file` write cap is 60 KB.",
1756
- context: { remote, bytes, capBytes: REST_FILE_WRITE_CAP_BYTES },
1839
+ remediation: `Pass \`--via sftp\` for this upload; the REST \`/file\` write cap is 60 KB.${pinnedBy}`,
1840
+ context: {
1841
+ remote,
1842
+ bytes,
1843
+ capBytes: REST_FILE_WRITE_CAP_BYTES,
1844
+ via: via.value,
1845
+ viaSource: via.source.key ?? via.source.kind,
1846
+ },
1757
1847
  });
1758
1848
  }
1759
1849