@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.
- package/CHANGELOG.md +342 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +10 -6
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +135 -42
- package/commands/api/examples.md +275 -19
- package/commands/devices/README.md +21 -4
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +60 -18
- package/commands/execute/examples.md +224 -0
- package/commands/explain/README.md +42 -5
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +442 -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 +29 -21
- package/docs/CONSTITUTION.md +41 -16
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/invalid-target.md +25 -3
- 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/routeros/invalid-value.md +3 -2
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/cancel-unacknowledged.md +24 -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 +603 -194
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/devices.ts +16 -3
- 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 +70 -1
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +65 -27
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +67 -1
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +148 -37
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +14 -9
- package/src/execute.ts +128 -116
- package/src/explain/catalog.ts +64 -16
- package/src/explain/menus.ts +27 -6
- package/src/explain.ts +6 -6
- package/src/index.ts +31 -1
- package/src/mcp/tools.ts +10 -15
- package/src/protocols/adapter.ts +235 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +12 -7
- package/src/protocols/native-api.ts +252 -73
- 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 +84 -18
- 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 +741 -109
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- 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
|
-
|
|
163
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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<
|
|
245
|
+
async function createEmptyCdbNoClobber(path: string): Promise<boolean> {
|
|
232
246
|
await mkdir(dirname(path), { recursive: true });
|
|
233
|
-
const
|
|
247
|
+
const tempPath = `${path}.init.${randomUUID()}`;
|
|
248
|
+
const handle = await open(tempPath, "wx", 0o600);
|
|
234
249
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
lines.push(JSON.stringify(serialized.context, null, 2));
|
|
148
|
-
}
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
|
149
193
|
|
|
150
|
-
|
|
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 {
|
package/src/execute-fanout.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
462
|
+
lines.push(
|
|
463
|
+
terminalSafeLine(` Fix: ${target.error.remediation}`),
|
|
464
|
+
);
|
|
460
465
|
}
|
|
461
466
|
}
|
|
462
467
|
}
|