@tikoci/centrs 0.1.6 → 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.
Files changed (89) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +10 -6
  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 +135 -42
  8. package/commands/api/examples.md +275 -19
  9. package/commands/devices/README.md +21 -4
  10. package/commands/devices/examples.md +33 -2
  11. package/commands/execute/README.md +60 -18
  12. package/commands/execute/examples.md +224 -0
  13. package/commands/explain/README.md +42 -5
  14. package/commands/mcp/README.md +2 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +442 -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 +29 -21
  22. package/docs/CONSTITUTION.md +41 -16
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/cdb/create-failed.md +20 -0
  26. package/docs/errors/cdb/file-exists.md +23 -0
  27. package/docs/errors/input/invalid-query.md +36 -0
  28. package/docs/errors/input/invalid-target.md +25 -3
  29. package/docs/errors/input/local-destination.md +20 -0
  30. package/docs/errors/input/unsupported-query.md +19 -0
  31. package/docs/errors/internal/byte-string.md +13 -0
  32. package/docs/errors/routeros/invalid-value.md +3 -2
  33. package/docs/errors/settings/invalid-timeout.md +6 -0
  34. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  35. package/docs/errors/transport/stream-overflow.md +21 -0
  36. package/docs/errors/usage/removed-flag.md +20 -0
  37. package/docs/errors/validation/menu-unavailable.md +22 -0
  38. package/docs/errors/validation/not-followable.md +23 -0
  39. package/docs/errors/validation/package-missing.md +29 -0
  40. package/docs/errors/validation/unknown-path.md +16 -4
  41. package/docs/errors/wait/deadline-exceeded.md +14 -0
  42. package/docs/errors/wait/interrupted.md +10 -0
  43. package/package.json +14 -11
  44. package/src/absent-menu.ts +280 -0
  45. package/src/api-fanout.ts +22 -12
  46. package/src/api.ts +603 -194
  47. package/src/btest.ts +19 -17
  48. package/src/cli/api.ts +126 -34
  49. package/src/cli/devices.ts +16 -3
  50. package/src/cli/execute.ts +18 -5
  51. package/src/cli/retrieve.ts +216 -22
  52. package/src/cli/terminal.ts +10 -5
  53. package/src/cli/transfer.ts +23 -8
  54. package/src/core/envelope.ts +14 -0
  55. package/src/core/error-catalog.ts +70 -1
  56. package/src/core/execute-command.ts +313 -6
  57. package/src/core/inspect.ts +65 -27
  58. package/src/core/query.ts +508 -0
  59. package/src/core/routeros-errors.ts +67 -1
  60. package/src/data/winbox-cdb-write.ts +22 -8
  61. package/src/devices.ts +148 -37
  62. package/src/discover.ts +7 -8
  63. package/src/errors.ts +116 -7
  64. package/src/execute-fanout.ts +14 -9
  65. package/src/execute.ts +128 -116
  66. package/src/explain/catalog.ts +64 -16
  67. package/src/explain/menus.ts +27 -6
  68. package/src/explain.ts +6 -6
  69. package/src/index.ts +31 -1
  70. package/src/mcp/tools.ts +10 -15
  71. package/src/protocols/adapter.ts +235 -118
  72. package/src/protocols/btest-session.ts +3 -2
  73. package/src/protocols/endpoint.ts +10 -0
  74. package/src/protocols/mac-telnet-console.ts +12 -7
  75. package/src/protocols/native-api.ts +252 -73
  76. package/src/protocols/sftp.ts +44 -14
  77. package/src/protocols/ssh.ts +42 -14
  78. package/src/resolver/cdb.ts +28 -7
  79. package/src/resolver/index.ts +1 -0
  80. package/src/resolver/target.ts +84 -18
  81. package/src/retrieve-fanout.ts +51 -12
  82. package/src/retrieve-follow.ts +862 -0
  83. package/src/retrieve-sample.ts +244 -0
  84. package/src/retrieve-stream.ts +115 -0
  85. package/src/retrieve-wait.ts +286 -0
  86. package/src/retrieve.ts +741 -109
  87. package/src/settings.ts +15 -15
  88. package/src/transfer-fanout.ts +40 -15
  89. package/src/transfer.ts +234 -32
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import {
23
+ chmod,
23
24
  copyFile,
24
25
  mkdir,
25
26
  open,
@@ -137,10 +138,21 @@ export async function writeWinBoxCdb(
137
138
  let backupPath: string | undefined;
138
139
  const prunedBackups: string[] = [];
139
140
  const targetExists = await pathExists(target);
141
+ // The backup and the replacement both carry the CDB's mode (a new CDB gets
142
+ // 0600): the file holds device credentials, and neither a plain open nor
143
+ // `copyFile` is guaranteed to keep it narrower than the umask default,
144
+ // e.g. 0644 (#382 review).
145
+ const mode = targetExists ? (await stat(target)).mode & 0o777 : 0o600;
140
146
  if (targetExists && !options.skipBackup) {
141
147
  const stamp = backupStamp(options.now ?? new Date());
142
148
  backupPath = join(dir, `${backupPrefix(target)}${stamp}`);
143
149
  await copyFile(target, backupPath);
150
+ try {
151
+ await chmod(backupPath, mode);
152
+ } catch (error) {
153
+ await unlink(backupPath).catch(() => undefined);
154
+ throw error;
155
+ }
144
156
 
145
157
  if (retention >= 0) {
146
158
  const backups = await listWinBoxCdbBackups(target);
@@ -159,15 +171,17 @@ export async function writeWinBoxCdb(
159
171
  dir,
160
172
  `${basename(target)}${TEMP_INFIX}${process.pid}.${Date.now().toString(36)}`,
161
173
  );
162
- const handle = await open(tempPath, "w");
163
- try {
164
- await handle.write(bytes);
165
- await handle.sync();
166
- } finally {
167
- await handle.close();
168
- }
169
-
174
+ // Any failure before the rename removes the temp file: it holds the
175
+ // serialized CDB, credentials included.
170
176
  try {
177
+ const handle = await open(tempPath, "w", mode);
178
+ try {
179
+ await handle.chmod(mode);
180
+ await handle.write(bytes);
181
+ await handle.sync();
182
+ } finally {
183
+ await handle.close();
184
+ }
171
185
  await rename(tempPath, target);
172
186
  } catch (error) {
173
187
  await unlink(tempPath).catch(() => undefined);
package/src/devices.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { mkdir, open } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { link, mkdir, open, unlink } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
5
  import type {
@@ -32,7 +33,11 @@ import {
32
33
  writeWinBoxCdb,
33
34
  } from "./data/winbox-cdb-write.ts";
34
35
  import { parseDeviceRecord } from "./devices-schema.ts";
35
- import { CentrsError, serializeCentrsError } from "./errors.ts";
36
+ import {
37
+ CentrsError,
38
+ formatCentrsErrorText,
39
+ serializeCentrsError,
40
+ } from "./errors.ts";
36
41
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
37
42
  import {
38
43
  applyCommentKv,
@@ -79,6 +84,10 @@ export interface DevicesListItem {
79
84
  user: string;
80
85
  cdbRecordIndex: number;
81
86
  source?: string;
87
+ /** `identity=` / `mac=` / `ip=` comment lookup keys — the names `<router>` resolves by (issue #378). */
88
+ identity?: string;
89
+ mac?: string;
90
+ ip?: string;
82
91
  sources?: Record<string, SettingSource>;
83
92
  /** GPS location, parsed from the `lat`/`lon`/`altitude`/`altitude-type` comment facts (issue #146). */
84
93
  location?: DeviceLocation;
@@ -102,6 +111,7 @@ export interface DevicesGroupSummary {
102
111
  }
103
112
 
104
113
  export type DevicesCommand =
114
+ | "init"
105
115
  | "list"
106
116
  | "show"
107
117
  | "groups"
@@ -223,20 +233,104 @@ function isAlreadyExistsError(cause: unknown): boolean {
223
233
 
224
234
  /**
225
235
  * Create an empty open CDB at `path` without clobbering an existing file.
226
- * Uses an exclusive-create open (`wx`) so a concurrent writer's CDB is never
227
- * overwritten; the caller treats EEXIST as "another process created it".
228
- * Mode `0o600` keeps the CDB (device credentials) unreadable by other local
229
- * users instead of falling back to the process umask default.
236
+ * The bytes are written to a private temp file first and then hard-linked
237
+ * into place: `link` fails with EEXIST instead of replacing, and `path` never
238
+ * exists half-written, so a concurrent creator that loses the race reads a
239
+ * complete CDB (#382 review). Returns false when `link` found a CDB already
240
+ * there (another process created it). Only `link`'s EEXIST means that: a
241
+ * recursive `mkdir` also reports EEXIST when a path component is a regular
242
+ * file. Mode `0o600` keeps the CDB (device credentials) unreadable by other
243
+ * local users instead of falling back to the process umask default.
230
244
  */
231
- async function createEmptyCdbNoClobber(path: string): Promise<void> {
245
+ async function createEmptyCdbNoClobber(path: string): Promise<boolean> {
232
246
  await mkdir(dirname(path), { recursive: true });
233
- const handle = await open(path, "wx", 0o600);
247
+ const tempPath = `${path}.init.${randomUUID()}`;
248
+ const handle = await open(tempPath, "wx", 0o600);
234
249
  try {
235
- await handle.write(encodeOpenWinBoxCdb([]));
236
- await handle.sync();
250
+ try {
251
+ await handle.chmod(0o600);
252
+ await handle.write(encodeOpenWinBoxCdb([]));
253
+ await handle.sync();
254
+ } finally {
255
+ await handle.close();
256
+ }
257
+ await link(tempPath, path);
258
+ return true;
259
+ } catch (error) {
260
+ if (isAlreadyExistsError(error)) {
261
+ return false;
262
+ }
263
+ throw error;
237
264
  } finally {
238
- await handle.close();
265
+ await unlink(tempPath);
266
+ }
267
+ }
268
+
269
+ export interface DevicesInitData {
270
+ cdbFile: string;
271
+ /** False when a readable CDB was already there; nothing was written. */
272
+ created: boolean;
273
+ recordCount: number;
274
+ }
275
+
276
+ /**
277
+ * `devices init` (#376): create an empty open CDB at the resolved path, the
278
+ * explicit counterpart of the default path's first-run auto-create. Never
279
+ * clobbers: like `git init`, re-running on an existing, readable CDB succeeds
280
+ * with a `cdb/file-exists` warning and writes nothing, so a workspace bootstrap
281
+ * script can run it unconditionally. An existing file that does not load
282
+ * (not a CDB, or encrypted without a password) fails with the load error.
283
+ */
284
+ export async function initCdb(
285
+ options: LoadCdbOptions,
286
+ ): Promise<CentrsSuccessEnvelope<DevicesInitData, DevicesOperationMeta>> {
287
+ const settings = resolveDevicesSettings(options);
288
+ const cdbFile = settings.cdbFile.value;
289
+ const existingResult = async () => {
290
+ const existing = await loadCdb(options);
291
+ return {
292
+ ok: true as const,
293
+ data: { cdbFile, created: false, recordCount: existing.entries.length },
294
+ warnings: [
295
+ ...existing.warnings,
296
+ {
297
+ code: "cdb/file-exists",
298
+ message: `A CDB already exists at ${cdbFile} (${existing.entries.length} record(s)); nothing was written.`,
299
+ },
300
+ ],
301
+ tips: [],
302
+ meta: devicesMeta("init", settings),
303
+ };
304
+ };
305
+ // Check first: an existing CDB in a read-only directory is still an
306
+ // idempotent success, although no temp file could be created beside it.
307
+ // A `false` from the create covers a creator that wins the race after this.
308
+ if (await Bun.file(cdbFile).exists()) {
309
+ return existingResult();
310
+ }
311
+ let created: boolean;
312
+ try {
313
+ created = await createEmptyCdbNoClobber(cdbFile);
314
+ } catch (cause) {
315
+ throw new CentrsError({
316
+ code: "cdb/create-failed",
317
+ summary: `Could not create a CDB at ${cdbFile}.`,
318
+ remediation:
319
+ "Check that the directory is writable, or pass --cdb-file PATH to a writable location.",
320
+ context: { cdbFile },
321
+ cause,
322
+ });
323
+ }
324
+ if (!created) {
325
+ return existingResult();
239
326
  }
327
+ return {
328
+ ok: true,
329
+ data: { cdbFile, created: true, recordCount: 0 },
330
+ warnings: [],
331
+ tips: [],
332
+ meta: devicesMeta("init", settings),
333
+ };
240
334
  }
241
335
 
242
336
  export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
@@ -250,33 +344,33 @@ export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
250
344
  // `devices`/identity commands work out of the box. An explicit missing
251
345
  // path stays an error (typo protection).
252
346
  if (settings.cdbFile.source.kind === "default") {
347
+ // `false` means another process won the race and created the CDB
348
+ // after our existence check; that file is authoritative, so fall
349
+ // through and read it instead of erroring.
350
+ let created: boolean;
253
351
  try {
254
- await createEmptyCdbNoClobber(settings.cdbFile.value);
352
+ created = await createEmptyCdbNoClobber(settings.cdbFile.value);
353
+ } catch (cause) {
354
+ throw new CentrsError({
355
+ code: "cdb/not-found",
356
+ summary: `CDB file not found and could not be created: ${settings.cdbFile.value}`,
357
+ remediation:
358
+ "Check directory permissions for ~/.config/tikoci, or pass --cdb-file PATH to a writable location.",
359
+ context: { cdbFile: settings.cdbFile.value },
360
+ cause,
361
+ });
362
+ }
363
+ if (created) {
255
364
  warnings.push({
256
365
  code: "cdb/created",
257
366
  message: `No CDB found at the default path; created an empty CDB at ${settings.cdbFile.value}.`,
258
367
  });
259
- } catch (cause) {
260
- // EEXIST means another process won the race and created the CDB
261
- // between our existence check and the exclusive open — that file
262
- // is authoritative; fall through and read it instead of erroring.
263
- if (!isAlreadyExistsError(cause)) {
264
- throw new CentrsError({
265
- code: "cdb/not-found",
266
- summary: `CDB file not found and could not be created: ${settings.cdbFile.value}`,
267
- remediation:
268
- "Check directory permissions for ~/.config/tikoci, or pass --cdb-file PATH to a writable location.",
269
- context: { cdbFile: settings.cdbFile.value },
270
- cause,
271
- });
272
- }
273
368
  }
274
369
  } else {
275
370
  throw new CentrsError({
276
371
  code: "cdb/not-found",
277
372
  summary: `CDB file not found: ${settings.cdbFile.value}`,
278
- remediation:
279
- "Pass --cdb-file PATH, set CENTRS_CDB_FILE, or place a CDB at ~/.config/tikoci/winbox.cdb.",
373
+ remediation: `To start a new CDB there, run \`centrs devices init --cdb-file ${settings.cdbFile.value}\`. Otherwise check the path; an explicit --cdb-file or CENTRS_CDB_FILE is never created implicitly.`,
280
374
  context: { cdbFile: settings.cdbFile.value },
281
375
  });
282
376
  }
@@ -752,6 +846,18 @@ function entryToListItem(
752
846
  key: `record:${cdbRecordIndex}:source`,
753
847
  };
754
848
  }
849
+ for (const key of commentKvLookupKeys) {
850
+ const value = parsed.lookups[key];
851
+ // Same non-blank rule as `entryRouterKeys`: never advertise a key that
852
+ // does not resolve.
853
+ if (value !== undefined && value.trim().length > 0) {
854
+ item[key] = value;
855
+ sources[key] = {
856
+ kind: "comment-kv",
857
+ key: `record:${cdbRecordIndex}:${key}`,
858
+ };
859
+ }
860
+ }
755
861
  const location = deviceLocation(parseRawCommentFacts(entry.comment));
756
862
  if (location !== undefined) {
757
863
  item.location = location;
@@ -1739,20 +1845,22 @@ function renderYamlInline(value: unknown, indent: string): string {
1739
1845
  function renderText(envelope: DevicesEnvelope<unknown>): string {
1740
1846
  const lines: string[] = [];
1741
1847
  if (!envelope.ok) {
1742
- const error = envelope.error;
1743
- lines.push(`[${error.code}] ${error.summary}`);
1744
- if (error.remediation) {
1745
- lines.push(`Fix: ${error.remediation}`);
1746
- }
1747
- if (error.detailsUrl) {
1748
- lines.push(`Details: ${error.detailsUrl}`);
1749
- }
1848
+ lines.push(formatCentrsErrorText(envelope.error));
1750
1849
  appendWarnings(lines, envelope.warnings);
1751
1850
  appendTips(lines, envelope.tips);
1752
1851
  return lines.join("\n");
1753
1852
  }
1754
1853
 
1755
1854
  switch (envelope.meta.operation?.command) {
1855
+ case "init": {
1856
+ const data = envelope.data as DevicesInitData;
1857
+ lines.push(
1858
+ data.created
1859
+ ? `created ${data.cdbFile}`
1860
+ : `exists ${data.cdbFile} (${data.recordCount} record(s))`,
1861
+ );
1862
+ break;
1863
+ }
1756
1864
  case "list":
1757
1865
  renderListText(lines, envelope.data as readonly DevicesListItem[]);
1758
1866
  break;
@@ -1828,12 +1936,15 @@ function renderListText(
1828
1936
  lines.push("(no entries)");
1829
1937
  return;
1830
1938
  }
1831
- lines.push(["INDEX", "TARGET", "TYPE", "USER", "GROUP"].join("\t"));
1939
+ lines.push(
1940
+ ["INDEX", "TARGET", "IDENTITY", "TYPE", "USER", "GROUP"].join("\t"),
1941
+ );
1832
1942
  for (const item of items) {
1833
1943
  lines.push(
1834
1944
  [
1835
1945
  String(item.cdbRecordIndex),
1836
1946
  item.target,
1947
+ item.identity || "-",
1837
1948
  item.recordTypeName,
1838
1949
  item.user || "-",
1839
1950
  item.group || "-",
package/src/discover.ts CHANGED
@@ -37,7 +37,12 @@ import {
37
37
  loadCdb,
38
38
  resolveDevicesSettings,
39
39
  } from "./devices.ts";
40
- import { asCentrsError, CentrsError, serializeCentrsError } from "./errors.ts";
40
+ import {
41
+ asCentrsError,
42
+ CentrsError,
43
+ formatCentrsErrorText,
44
+ serializeCentrsError,
45
+ } from "./errors.ts";
41
46
  import { parseCommentKv, renderCommentKvToken } from "./resolver/comment-kv.ts";
42
47
  import { normalizeMac } from "./resolver/mac.ts";
43
48
 
@@ -771,13 +776,7 @@ export function renderDiscoverEnvelope(
771
776
  function renderDiscoverText(envelope: DiscoverEnvelope): string {
772
777
  const lines: string[] = [];
773
778
  if (!envelope.ok) {
774
- lines.push(`[${envelope.error.code}] ${envelope.error.summary}`);
775
- if (envelope.error.remediation) {
776
- lines.push(`Fix: ${envelope.error.remediation}`);
777
- }
778
- if (envelope.error.detailsUrl) {
779
- lines.push(`Details: ${envelope.error.detailsUrl}`);
780
- }
779
+ lines.push(formatCentrsErrorText(envelope.error));
781
780
  appendDiscoverWarnings(lines, envelope.warnings);
782
781
  return lines.join("\n");
783
782
  }
package/src/errors.ts CHANGED
@@ -5,7 +5,16 @@
5
5
  */
6
6
  export const ERROR_DETAILS_BASE_URL = "https://tikoci.github.io/centrs/errors/";
7
7
 
8
+ /**
9
+ * Remediation for a device-stage `:parse` rejection. It does not claim the
10
+ * syntax is wrong: RouterOS rejects an absent menu (unknown path, package not
11
+ * installed) in the same words, and #361 tracks telling those apart.
12
+ */
13
+ export const SYNTAX_REJECTED_REMEDIATION =
14
+ "RouterOS refused to parse this command; its own words are in `Device said`. Check the syntax with `centrs explain '<command>'`. A menu that does not exist on this device (an unknown path, or a package that is not installed) is rejected the same way.";
15
+
8
16
  export type CentrsErrorCode =
17
+ | `wait/${string}`
9
18
  | `auth/${string}`
10
19
  | `cdb/${string}`
11
20
  | `discover/${string}`
@@ -120,34 +129,134 @@ export function asCentrsError(
120
129
  });
121
130
  }
122
131
 
132
+ /**
133
+ * The one text renderer for an error, shared by every command (GH#362).
134
+ *
135
+ * Order: header, where (`At:`), the device's own words (`Device said:`), `Fix:`,
136
+ * `Details:`. Where and the device's words print without `--verbose`: they are
137
+ * what lets a caller recover from a remediation that names the wrong cause.
138
+ * `--verbose` adds the full `context` object. A command that has more to say
139
+ * (warnings, tips) appends to this output; it never re-derives the header.
140
+ */
123
141
  export function formatCentrsErrorText(
124
142
  error: CentrsError | SerializedCentrsError,
125
143
  options: { verbose?: boolean } = {},
126
144
  ): string {
127
145
  const serialized = serializeCentrsError(error);
128
- const lines = [`[${serialized.code}] ${serialized.summary}`];
146
+ const lines = errorFields(serialized);
147
+ if (options.verbose && serialized.context) {
148
+ lines.push("");
149
+ lines.push(terminalSafe(JSON.stringify(serialized.context, null, 2)));
150
+ }
151
+ return lines.join("\n");
152
+ }
153
+
154
+ /**
155
+ * The same fields as `formatCentrsErrorText`, on one line joined by ` — `, for
156
+ * output that must stay line-per-record (`api --stream --format text`).
157
+ * `--verbose` adds nothing here: a context block cannot be one line.
158
+ */
159
+ export function formatCentrsErrorLine(
160
+ error: CentrsError | SerializedCentrsError,
161
+ ): string {
162
+ return errorFields(serializeCentrsError(error)).map(oneLine).join(" — ");
163
+ }
164
+
165
+ function errorFields(serialized: SerializedCentrsError): string[] {
166
+ const lines = [`[${serialized.code}] ${terminalSafe(serialized.summary)}`];
129
167
 
130
168
  if (serialized.position) {
131
169
  // RouterOS's authoritative byte column (1-based); see RouterOsErrorPosition.
132
170
  lines.push(
133
171
  `At: line ${serialized.position.line}, column ${serialized.position.column} (RouterOS byte offset)`,
134
172
  );
173
+ } else {
174
+ const offline = offlineSpanText(serialized.context);
175
+ if (offline) lines.push(offline);
176
+ }
177
+
178
+ const deviceSaid = deviceSaidText(serialized);
179
+ if (deviceSaid) {
180
+ lines.push(`Device said: ${terminalSafe(deviceSaid)}`);
135
181
  }
136
182
 
137
183
  if (serialized.remediation) {
138
- lines.push(`Fix: ${serialized.remediation}`);
184
+ lines.push(`Fix: ${terminalSafe(serialized.remediation)}`);
139
185
  }
140
186
 
141
187
  if (serialized.detailsUrl) {
142
188
  lines.push(`Details: ${serialized.detailsUrl}`);
143
189
  }
144
190
 
145
- if (options.verbose && serialized.context) {
146
- lines.push("");
147
- lines.push(JSON.stringify(serialized.context, null, 2));
148
- }
191
+ return lines;
192
+ }
149
193
 
150
- return lines.join("\n");
194
+ const DEVICE_SAID_MAX = 240;
195
+
196
+ /**
197
+ * Control characters other than tab and newline, C1 included. Summaries and
198
+ * `detail` carry router-supplied text; an ESC or CR in it would drive the
199
+ * terminal. `JSON.stringify` escapes C0 but not C1, so serialized excerpts need
200
+ * this too. `--json` keeps the raw value.
201
+ */
202
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
203
+ const TERMINAL_CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
204
+
205
+ function terminalSafe(text: string): string {
206
+ return text.replace(TERMINAL_CONTROL, " ");
207
+ }
208
+
209
+ /** Compact records also replace tab/newline so external text cannot add rows. */
210
+ export function terminalSafeLine(text: string): string {
211
+ return terminalSafe(text).replace(/[\t\n]/g, " ");
212
+ }
213
+
214
+ function oneLine(text: string): string {
215
+ return text.replace(/\s+/g, " ").trim();
216
+ }
217
+
218
+ /**
219
+ * RouterOS's own verdict, from `context.detail`: the raw `:parse` return or the
220
+ * trap / REST message, as `mapRouterOsError` and `classifyParseResult` store it.
221
+ * Collapsed to one line and capped. Omitted when the summary already quotes it,
222
+ * so a trap message is not printed twice. `--json` keeps it unabridged.
223
+ */
224
+ function deviceSaidText(error: SerializedCentrsError): string | undefined {
225
+ const detail = error.context?.["detail"];
226
+ if (typeof detail !== "string") return undefined;
227
+ const said = oneLine(detail);
228
+ if (said.length === 0 || oneLine(error.summary).includes(said))
229
+ return undefined;
230
+ return said.length > DEVICE_SAID_MAX
231
+ ? `${said.slice(0, DEVICE_SAID_MAX)}…`
232
+ : said;
233
+ }
234
+
235
+ /**
236
+ * The offline gate's rejected byte range (`context.span`, 0-based, end-exclusive,
237
+ * into `context.command`). Labelled as offline analysis so it is never mistaken
238
+ * for a RouterOS-reported position (see `src/offline-gate.ts`).
239
+ */
240
+ function offlineSpanText(
241
+ context: Record<string, unknown> | undefined,
242
+ ): string | undefined {
243
+ const span = context?.["span"];
244
+ if (
245
+ !span ||
246
+ typeof span !== "object" ||
247
+ typeof (span as { start?: unknown }).start !== "number" ||
248
+ typeof (span as { end?: unknown }).end !== "number"
249
+ ) {
250
+ return undefined;
251
+ }
252
+ const { start, end } = span as { start: number; end: number };
253
+ const command = context?.["command"];
254
+ const excerpt =
255
+ typeof command === "string" && end > start
256
+ ? `: ${terminalSafe(JSON.stringify(new TextDecoder().decode(new TextEncoder().encode(command).subarray(start, end))))}`
257
+ : "";
258
+ // Half-open, exactly as `context.span` carries it.
259
+ return `At: bytes [${start}, ${end}) (offline analysis)${excerpt}`;
151
260
  }
152
261
 
153
262
  export function serializeUnknownError(error: unknown): unknown {
@@ -15,7 +15,7 @@
15
15
  * - A `--default` / `__default__` member is guarded from being dialed as the
16
16
  * literal hostname `"__default__"` — it fails that one target deterministically
17
17
  * with `target/unresolved`.
18
- * - A **write-shaped** command (add/set/remove) is confirmed ONCE up front
18
+ * - A **write-shaped** command is confirmed ONCE up front
19
19
  * (`--yes`), never per target; without `--yes` the error names the blast radius
20
20
  * (how many routers). Write-ness is target-independent (`canonicalizeExecuteCommand`).
21
21
  */
@@ -38,7 +38,12 @@ import {
38
38
  summarizeFanout,
39
39
  } from "./core/fanout.ts";
40
40
  import { toYaml } from "./core/yaml.ts";
41
- import { CentrsError, serializeCentrsError } from "./errors.ts";
41
+ import {
42
+ CentrsError,
43
+ formatCentrsErrorText,
44
+ serializeCentrsError,
45
+ terminalSafeLine,
46
+ } from "./errors.ts";
42
47
  import {
43
48
  buildExecuteErrorEnvelopeFromResolved,
44
49
  type ExecuteEnvelope,
@@ -428,11 +433,7 @@ export function renderExecuteFanoutEnvelope(
428
433
  return toYaml(envelope);
429
434
  }
430
435
  if (!envelope.ok) {
431
- const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
432
- if (envelope.error.remediation) {
433
- lines.push(`Fix: ${envelope.error.remediation}`);
434
- }
435
- return lines.join("\n");
436
+ return formatCentrsErrorText(envelope.error, options);
436
437
  }
437
438
  const operation = envelope.meta.operation;
438
439
  const { total, ok, failed } = envelope.data.summary;
@@ -453,10 +454,14 @@ export function renderExecuteFanoutEnvelope(
453
454
  );
454
455
  } else {
455
456
  lines.push(
456
- ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
457
+ terminalSafeLine(
458
+ ` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
459
+ ),
457
460
  );
458
461
  if (options.verbose && target.error.remediation) {
459
- lines.push(` Fix: ${target.error.remediation}`);
462
+ lines.push(
463
+ terminalSafeLine(` Fix: ${target.error.remediation}`),
464
+ );
460
465
  }
461
466
  }
462
467
  }