@tikoci/centrs 0.1.6 → 0.1.7
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 +108 -1
- package/README.md +8 -4
- package/commands/api/README.md +21 -10
- package/commands/api/examples.md +70 -2
- package/commands/devices/README.md +10 -1
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +29 -15
- package/commands/execute/examples.md +89 -0
- package/commands/explain/README.md +12 -1
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/examples.md +20 -0
- package/docs/CLI.md +1 -1
- package/docs/CONSTITUTION.md +11 -6
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/package.json +1 -1
- package/src/api.ts +109 -25
- package/src/cli/devices.ts +16 -3
- package/src/core/error-catalog.ts +14 -0
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +22 -22
- package/src/core/routeros-errors.ts +66 -0
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +142 -28
- package/src/execute-fanout.ts +1 -1
- package/src/execute.ts +22 -51
- package/src/explain/catalog.ts +8 -8
- package/src/explain/menus.ts +4 -4
- package/src/index.ts +2 -0
- package/src/mcp/tools.ts +1 -1
- package/src/protocols/adapter.ts +12 -1
- package/src/protocols/mac-telnet-console.ts +6 -4
- package/src/protocols/native-api.ts +29 -3
- package/src/resolver/target.ts +32 -12
- package/src/retrieve.ts +17 -4
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 {
|
|
@@ -79,6 +80,10 @@ export interface DevicesListItem {
|
|
|
79
80
|
user: string;
|
|
80
81
|
cdbRecordIndex: number;
|
|
81
82
|
source?: string;
|
|
83
|
+
/** `identity=` / `mac=` / `ip=` comment lookup keys — the names `<router>` resolves by (issue #378). */
|
|
84
|
+
identity?: string;
|
|
85
|
+
mac?: string;
|
|
86
|
+
ip?: string;
|
|
82
87
|
sources?: Record<string, SettingSource>;
|
|
83
88
|
/** GPS location, parsed from the `lat`/`lon`/`altitude`/`altitude-type` comment facts (issue #146). */
|
|
84
89
|
location?: DeviceLocation;
|
|
@@ -102,6 +107,7 @@ export interface DevicesGroupSummary {
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
export type DevicesCommand =
|
|
110
|
+
| "init"
|
|
105
111
|
| "list"
|
|
106
112
|
| "show"
|
|
107
113
|
| "groups"
|
|
@@ -223,22 +229,106 @@ function isAlreadyExistsError(cause: unknown): boolean {
|
|
|
223
229
|
|
|
224
230
|
/**
|
|
225
231
|
* Create an empty open CDB at `path` without clobbering an existing file.
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
232
|
+
* The bytes are written to a private temp file first and then hard-linked
|
|
233
|
+
* into place: `link` fails with EEXIST instead of replacing, and `path` never
|
|
234
|
+
* exists half-written, so a concurrent creator that loses the race reads a
|
|
235
|
+
* complete CDB (#382 review). Returns false when `link` found a CDB already
|
|
236
|
+
* there (another process created it). Only `link`'s EEXIST means that: a
|
|
237
|
+
* recursive `mkdir` also reports EEXIST when a path component is a regular
|
|
238
|
+
* file. Mode `0o600` keeps the CDB (device credentials) unreadable by other
|
|
239
|
+
* local users instead of falling back to the process umask default.
|
|
230
240
|
*/
|
|
231
|
-
async function createEmptyCdbNoClobber(path: string): Promise<
|
|
241
|
+
async function createEmptyCdbNoClobber(path: string): Promise<boolean> {
|
|
232
242
|
await mkdir(dirname(path), { recursive: true });
|
|
233
|
-
const
|
|
243
|
+
const tempPath = `${path}.init.${randomUUID()}`;
|
|
244
|
+
const handle = await open(tempPath, "wx", 0o600);
|
|
234
245
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
246
|
+
try {
|
|
247
|
+
await handle.chmod(0o600);
|
|
248
|
+
await handle.write(encodeOpenWinBoxCdb([]));
|
|
249
|
+
await handle.sync();
|
|
250
|
+
} finally {
|
|
251
|
+
await handle.close();
|
|
252
|
+
}
|
|
253
|
+
await link(tempPath, path);
|
|
254
|
+
return true;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (isAlreadyExistsError(error)) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
throw error;
|
|
237
260
|
} finally {
|
|
238
|
-
await
|
|
261
|
+
await unlink(tempPath);
|
|
239
262
|
}
|
|
240
263
|
}
|
|
241
264
|
|
|
265
|
+
export interface DevicesInitData {
|
|
266
|
+
cdbFile: string;
|
|
267
|
+
/** False when a readable CDB was already there; nothing was written. */
|
|
268
|
+
created: boolean;
|
|
269
|
+
recordCount: number;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* `devices init` (#376): create an empty open CDB at the resolved path, the
|
|
274
|
+
* explicit counterpart of the default path's first-run auto-create. Never
|
|
275
|
+
* clobbers: like `git init`, re-running on an existing, readable CDB succeeds
|
|
276
|
+
* with a `cdb/file-exists` warning and writes nothing, so a workspace bootstrap
|
|
277
|
+
* script can run it unconditionally. An existing file that does not load
|
|
278
|
+
* (not a CDB, or encrypted without a password) fails with the load error.
|
|
279
|
+
*/
|
|
280
|
+
export async function initCdb(
|
|
281
|
+
options: LoadCdbOptions,
|
|
282
|
+
): Promise<CentrsSuccessEnvelope<DevicesInitData, DevicesOperationMeta>> {
|
|
283
|
+
const settings = resolveDevicesSettings(options);
|
|
284
|
+
const cdbFile = settings.cdbFile.value;
|
|
285
|
+
const existingResult = async () => {
|
|
286
|
+
const existing = await loadCdb(options);
|
|
287
|
+
return {
|
|
288
|
+
ok: true as const,
|
|
289
|
+
data: { cdbFile, created: false, recordCount: existing.entries.length },
|
|
290
|
+
warnings: [
|
|
291
|
+
...existing.warnings,
|
|
292
|
+
{
|
|
293
|
+
code: "cdb/file-exists",
|
|
294
|
+
message: `A CDB already exists at ${cdbFile} (${existing.entries.length} record(s)); nothing was written.`,
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
tips: [],
|
|
298
|
+
meta: devicesMeta("init", settings),
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
// Check first: an existing CDB in a read-only directory is still an
|
|
302
|
+
// idempotent success, although no temp file could be created beside it.
|
|
303
|
+
// A `false` from the create covers a creator that wins the race after this.
|
|
304
|
+
if (await Bun.file(cdbFile).exists()) {
|
|
305
|
+
return existingResult();
|
|
306
|
+
}
|
|
307
|
+
let created: boolean;
|
|
308
|
+
try {
|
|
309
|
+
created = await createEmptyCdbNoClobber(cdbFile);
|
|
310
|
+
} catch (cause) {
|
|
311
|
+
throw new CentrsError({
|
|
312
|
+
code: "cdb/create-failed",
|
|
313
|
+
summary: `Could not create a CDB at ${cdbFile}.`,
|
|
314
|
+
remediation:
|
|
315
|
+
"Check that the directory is writable, or pass --cdb-file PATH to a writable location.",
|
|
316
|
+
context: { cdbFile },
|
|
317
|
+
cause,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (!created) {
|
|
321
|
+
return existingResult();
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
data: { cdbFile, created: true, recordCount: 0 },
|
|
326
|
+
warnings: [],
|
|
327
|
+
tips: [],
|
|
328
|
+
meta: devicesMeta("init", settings),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
242
332
|
export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
|
|
243
333
|
const settings = resolveDevicesSettings(options);
|
|
244
334
|
const warnings: DevicesWarning[] = [];
|
|
@@ -250,33 +340,33 @@ export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
|
|
|
250
340
|
// `devices`/identity commands work out of the box. An explicit missing
|
|
251
341
|
// path stays an error (typo protection).
|
|
252
342
|
if (settings.cdbFile.source.kind === "default") {
|
|
343
|
+
// `false` means another process won the race and created the CDB
|
|
344
|
+
// after our existence check; that file is authoritative, so fall
|
|
345
|
+
// through and read it instead of erroring.
|
|
346
|
+
let created: boolean;
|
|
253
347
|
try {
|
|
254
|
-
await createEmptyCdbNoClobber(settings.cdbFile.value);
|
|
348
|
+
created = await createEmptyCdbNoClobber(settings.cdbFile.value);
|
|
349
|
+
} catch (cause) {
|
|
350
|
+
throw new CentrsError({
|
|
351
|
+
code: "cdb/not-found",
|
|
352
|
+
summary: `CDB file not found and could not be created: ${settings.cdbFile.value}`,
|
|
353
|
+
remediation:
|
|
354
|
+
"Check directory permissions for ~/.config/tikoci, or pass --cdb-file PATH to a writable location.",
|
|
355
|
+
context: { cdbFile: settings.cdbFile.value },
|
|
356
|
+
cause,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (created) {
|
|
255
360
|
warnings.push({
|
|
256
361
|
code: "cdb/created",
|
|
257
362
|
message: `No CDB found at the default path; created an empty CDB at ${settings.cdbFile.value}.`,
|
|
258
363
|
});
|
|
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
364
|
}
|
|
274
365
|
} else {
|
|
275
366
|
throw new CentrsError({
|
|
276
367
|
code: "cdb/not-found",
|
|
277
368
|
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.",
|
|
369
|
+
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
370
|
context: { cdbFile: settings.cdbFile.value },
|
|
281
371
|
});
|
|
282
372
|
}
|
|
@@ -752,6 +842,18 @@ function entryToListItem(
|
|
|
752
842
|
key: `record:${cdbRecordIndex}:source`,
|
|
753
843
|
};
|
|
754
844
|
}
|
|
845
|
+
for (const key of commentKvLookupKeys) {
|
|
846
|
+
const value = parsed.lookups[key];
|
|
847
|
+
// Same non-blank rule as `entryRouterKeys`: never advertise a key that
|
|
848
|
+
// does not resolve.
|
|
849
|
+
if (value !== undefined && value.trim().length > 0) {
|
|
850
|
+
item[key] = value;
|
|
851
|
+
sources[key] = {
|
|
852
|
+
kind: "comment-kv",
|
|
853
|
+
key: `record:${cdbRecordIndex}:${key}`,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
}
|
|
755
857
|
const location = deviceLocation(parseRawCommentFacts(entry.comment));
|
|
756
858
|
if (location !== undefined) {
|
|
757
859
|
item.location = location;
|
|
@@ -1753,6 +1855,15 @@ function renderText(envelope: DevicesEnvelope<unknown>): string {
|
|
|
1753
1855
|
}
|
|
1754
1856
|
|
|
1755
1857
|
switch (envelope.meta.operation?.command) {
|
|
1858
|
+
case "init": {
|
|
1859
|
+
const data = envelope.data as DevicesInitData;
|
|
1860
|
+
lines.push(
|
|
1861
|
+
data.created
|
|
1862
|
+
? `created ${data.cdbFile}`
|
|
1863
|
+
: `exists ${data.cdbFile} (${data.recordCount} record(s))`,
|
|
1864
|
+
);
|
|
1865
|
+
break;
|
|
1866
|
+
}
|
|
1756
1867
|
case "list":
|
|
1757
1868
|
renderListText(lines, envelope.data as readonly DevicesListItem[]);
|
|
1758
1869
|
break;
|
|
@@ -1828,12 +1939,15 @@ function renderListText(
|
|
|
1828
1939
|
lines.push("(no entries)");
|
|
1829
1940
|
return;
|
|
1830
1941
|
}
|
|
1831
|
-
lines.push(
|
|
1942
|
+
lines.push(
|
|
1943
|
+
["INDEX", "TARGET", "IDENTITY", "TYPE", "USER", "GROUP"].join("\t"),
|
|
1944
|
+
);
|
|
1832
1945
|
for (const item of items) {
|
|
1833
1946
|
lines.push(
|
|
1834
1947
|
[
|
|
1835
1948
|
String(item.cdbRecordIndex),
|
|
1836
1949
|
item.target,
|
|
1950
|
+
item.identity || "-",
|
|
1837
1951
|
item.recordTypeName,
|
|
1838
1952
|
item.user || "-",
|
|
1839
1953
|
item.group || "-",
|
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
|
*/
|
package/src/execute.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
isWriteShaped,
|
|
18
18
|
} from "./core/execute-command.ts";
|
|
19
19
|
import { inspectArgumentNames, pathTokens } from "./core/inspect.ts";
|
|
20
|
-
import {
|
|
20
|
+
import { mapRouterOsResultError } from "./core/routeros-errors.ts";
|
|
21
21
|
import { routerOsStringLiteral } from "./core/routeros-string.ts";
|
|
22
22
|
import { toYaml } from "./core/yaml.ts";
|
|
23
23
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
@@ -892,7 +892,16 @@ async function runSyntaxGate(
|
|
|
892
892
|
resolved: ResolvedExecuteRequest,
|
|
893
893
|
backend: ProtocolAdapter,
|
|
894
894
|
): Promise<void> {
|
|
895
|
-
|
|
895
|
+
await validateRouterOsScript(resolved.command, backend, resolved.via.value);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Shared live `:parse` stage for every surface that carries a CLI string. */
|
|
899
|
+
export async function validateRouterOsScript(
|
|
900
|
+
command: string,
|
|
901
|
+
backend: ProtocolAdapter,
|
|
902
|
+
via: RouterOsProtocol,
|
|
903
|
+
): Promise<void> {
|
|
904
|
+
const script = `:put [:parse ${routerOsStringLiteral(command)}]`;
|
|
896
905
|
try {
|
|
897
906
|
// GH#230: `:parse` never throws (CHR 7.23.3). The diagnostic rides the
|
|
898
907
|
// success value's text — over REST/native the `ret` field (HTTP 200 /
|
|
@@ -901,7 +910,7 @@ async function runSyntaxGate(
|
|
|
901
910
|
// Read the return and classify it: one `:parse` covers syntax and the
|
|
902
911
|
// unknown-attribute (name-level) gate.
|
|
903
912
|
const result = await backend.execute({ path: "", command: "", script });
|
|
904
|
-
classifyParseResult(result.ret ?? "",
|
|
913
|
+
classifyParseResult(result.ret ?? "", command, via);
|
|
905
914
|
} catch (error) {
|
|
906
915
|
// The preflight is a syntax gate, but `backend.execute` also surfaces
|
|
907
916
|
// connection and authentication failures (login happens lazily here).
|
|
@@ -925,9 +934,9 @@ async function runSyntaxGate(
|
|
|
925
934
|
remediation:
|
|
926
935
|
"Fix the RouterOS CLI syntax, especially quotes and bracketed expressions, then retry.",
|
|
927
936
|
context: {
|
|
928
|
-
command
|
|
937
|
+
command,
|
|
929
938
|
validationSource: ":put [:parse ...]",
|
|
930
|
-
via
|
|
939
|
+
via,
|
|
931
940
|
},
|
|
932
941
|
...(position ? { position } : {}),
|
|
933
942
|
cause: error,
|
|
@@ -1298,52 +1307,14 @@ function routerOsFailureFromResult(
|
|
|
1298
1307
|
result: ProtocolExecuteResult,
|
|
1299
1308
|
via: RouterOsProtocol,
|
|
1300
1309
|
): CentrsError | undefined {
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
]),
|
|
1310
|
-
].filter((value): value is string => typeof value === "string");
|
|
1311
|
-
const failure = candidates.find((candidate) =>
|
|
1312
|
-
isRouterOsFailureString(candidate),
|
|
1313
|
-
);
|
|
1314
|
-
return failure
|
|
1315
|
-
? mapRouterOsError(failure, {
|
|
1316
|
-
transport:
|
|
1317
|
-
via === "rest-api" || via === "mac-telnet" || via === "ssh"
|
|
1318
|
-
? via
|
|
1319
|
-
: "native-api",
|
|
1320
|
-
context: { via },
|
|
1321
|
-
})
|
|
1322
|
-
: undefined;
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
function isRouterOsFailureString(value: string): boolean {
|
|
1326
|
-
const trimmed = value.trim();
|
|
1327
|
-
// REST/native fault strings (unchanged).
|
|
1328
|
-
if (
|
|
1329
|
-
/^(failure:|error:)|unknown parameter|invalid value|session closed|\(:error; line \d+\)/i.test(
|
|
1330
|
-
trimmed,
|
|
1331
|
-
)
|
|
1332
|
-
) {
|
|
1333
|
-
return true;
|
|
1334
|
-
}
|
|
1335
|
-
// Console (mac-telnet) error forms. `ret` here is ordinary stdout (every
|
|
1336
|
-
// mac-telnet command and REST `/execute`), so anchor to the grounded RouterOS
|
|
1337
|
-
// error *shapes* — the `(line N column M)` source location for parse rejections
|
|
1338
|
-
// — rather than bare phrases, so `:put "syntax error"` is not misread as a
|
|
1339
|
-
// failure.
|
|
1340
|
-
return (
|
|
1341
|
-
/(?:^|\n)\s*(?:bad parameter \S+|syntax error)\b[^\n]*\(line \d+ column \d+\)/i.test(
|
|
1342
|
-
trimmed,
|
|
1343
|
-
) ||
|
|
1344
|
-
/(?:^|\n)\s*bad command name\b/i.test(trimmed) ||
|
|
1345
|
-
/(?:^|\n)\s*expected end of command\b/i.test(trimmed)
|
|
1346
|
-
);
|
|
1310
|
+
if (typeof result.ret !== "string") return undefined;
|
|
1311
|
+
return mapRouterOsResultError(result.ret, {
|
|
1312
|
+
transport:
|
|
1313
|
+
via === "rest-api" || via === "mac-telnet" || via === "ssh"
|
|
1314
|
+
? via
|
|
1315
|
+
: "native-api",
|
|
1316
|
+
context: { via },
|
|
1317
|
+
});
|
|
1347
1318
|
}
|
|
1348
1319
|
|
|
1349
1320
|
function applyMaxResultsBudget(
|
package/src/explain/catalog.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Union of two first-order sources (#228):
|
|
10
10
|
*
|
|
11
11
|
* 1. MikroTik's published CLI Reference (`https://manual.mikrotik.com/docs/cli-reference/`),
|
|
12
|
-
* 1,
|
|
12
|
+
* 1,055 pages, 1,062 entries — first-order about the definition
|
|
13
13
|
* structs and their build-time gates. Since #285 every page is a leaf whose
|
|
14
14
|
* slug is the CLI path, so no spelling has to be rewritten to be looked up.
|
|
15
15
|
* 2. 6 pinned restraml `/console/inspect` trees
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
* | ---- | ---- | -------- | ----- |
|
|
20
20
|
* | `7.10.2/extra/inspect.json` | x86 | 7.10.2 | 29,086 |
|
|
21
21
|
* | `7.16/extra/inspect.json` | x86 | 7.16 | 34,876 |
|
|
22
|
-
* | `7.24.
|
|
23
|
-
* | `7.24.
|
|
24
|
-
* | `7.
|
|
25
|
-
* | `7.
|
|
22
|
+
* | `7.24.4/extra/deep-inspect.x86.json` | x86 | 7.24.4 | 41,345 |
|
|
23
|
+
* | `7.24.4/extra/deep-inspect.arm64.json` | arm64 | 7.24.4 | 42,691 |
|
|
24
|
+
* | `7.25beta5/extra/deep-inspect.x86.json` | x86 | 7.25beta5 | 41,794 |
|
|
25
|
+
* | `7.25beta5/extra/deep-inspect.arm64.json` | arm64 | 7.25beta5 | 43,150 |
|
|
26
26
|
*
|
|
27
27
|
* | Kind | Total | `both` | `inspect` | `published` |
|
|
28
28
|
* | ---- | ----- | ------ | --------- | ----------- |
|
|
29
|
-
* | `menu` | 554 |
|
|
29
|
+
* | `menu` | 554 | 445 | 65 | 44 |
|
|
30
30
|
* | `command` | 450 | 409 | 0 | 41 |
|
|
31
31
|
* | `settings` | 116 | 107 | 0 | 9 |
|
|
32
32
|
*
|
|
@@ -285,7 +285,7 @@ const ROWS = `
|
|
|
285
285
|
/interface/ethernet/poe/monitor|command|published|||(poe or poe-in)
|
|
286
286
|
/interface/ethernet/poe/power-cycle|command|published|||(poe or poe-in)
|
|
287
287
|
/interface/ethernet/poe/settings|settings|published|||(poe or poe-in) and poesettings
|
|
288
|
-
/interface/ethernet/pon|menu|
|
|
288
|
+
/interface/ethernet/pon|menu|both
|
|
289
289
|
/interface/ethernet/reset-counters|command|both
|
|
290
290
|
/interface/ethernet/reset-mac-address|command|both
|
|
291
291
|
/interface/ethernet/switch|menu|both
|
|
@@ -744,7 +744,7 @@ const ROWS = `
|
|
|
744
744
|
/ip/ssh|settings|both
|
|
745
745
|
/ip/ssh/export-host-key|command|both
|
|
746
746
|
/ip/ssh/import-host-key|command|both
|
|
747
|
-
/ip/ssh/known-hosts|menu|
|
|
747
|
+
/ip/ssh/known-hosts|menu|both
|
|
748
748
|
/ip/ssh/regenerate-host-key|command|both
|
|
749
749
|
/ip/tftp|menu|both
|
|
750
750
|
/ip/tftp/settings|settings|both
|
package/src/explain/menus.ts
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
* | ---- | ---- | -------- | ----- | ---------- |
|
|
13
13
|
* | `7.10.2/extra/inspect.json` | x86 | 7.10.2 | 29,086 | 458 |
|
|
14
14
|
* | `7.16/extra/inspect.json` | x86 | 7.16 | 34,876 | 522 |
|
|
15
|
-
* | `7.24.
|
|
16
|
-
* | `7.24.
|
|
17
|
-
* | `7.
|
|
18
|
-
* | `7.
|
|
15
|
+
* | `7.24.4/extra/deep-inspect.x86.json` | x86 | 7.24.4 | 41,345 | 554 |
|
|
16
|
+
* | `7.24.4/extra/deep-inspect.arm64.json` | arm64 | 7.24.4 | 42,691 | 575 |
|
|
17
|
+
* | `7.25beta5/extra/deep-inspect.x86.json` | x86 | 7.25beta5 | 41,794 | 557 |
|
|
18
|
+
* | `7.25beta5/extra/deep-inspect.arm64.json` | arm64 | 7.25beta5 | 43,150 | 578 |
|
|
19
19
|
*
|
|
20
20
|
* Zero node-type conflicts across those trees — no `dir`↔`cmd` flip across
|
|
21
21
|
* 4 versions or across architectures — which is what makes the union
|
package/src/index.ts
CHANGED
|
@@ -203,6 +203,7 @@ export {
|
|
|
203
203
|
type DevicesCommand,
|
|
204
204
|
type DevicesEnvelope,
|
|
205
205
|
type DevicesGroupSummary,
|
|
206
|
+
type DevicesInitData,
|
|
206
207
|
type DevicesListItem,
|
|
207
208
|
type DevicesMutationData,
|
|
208
209
|
type DevicesMutationEnvelope,
|
|
@@ -215,6 +216,7 @@ export {
|
|
|
215
216
|
defaultCdbPath,
|
|
216
217
|
devicesOutputFormats,
|
|
217
218
|
editInteractiveOnlyError,
|
|
219
|
+
initCdb,
|
|
218
220
|
type LoadCdbOptions,
|
|
219
221
|
type LoadedCdb,
|
|
220
222
|
listDevices,
|
package/src/mcp/tools.ts
CHANGED
|
@@ -352,7 +352,7 @@ export const executeInputShape = {
|
|
|
352
352
|
.boolean()
|
|
353
353
|
.optional()
|
|
354
354
|
.describe(
|
|
355
|
-
"Required true for write-shaped commands
|
|
355
|
+
"Required true for write-shaped commands, including selector/positional and non-CRUD mutations. Reads ignore it.",
|
|
356
356
|
),
|
|
357
357
|
via: z.string().optional().describe("Override transport."),
|
|
358
358
|
timeout: z
|
package/src/protocols/adapter.ts
CHANGED
|
@@ -152,6 +152,14 @@ export interface ProtocolListenOptions {
|
|
|
152
152
|
* after the follow is established, instead of a blind timer.
|
|
153
153
|
*/
|
|
154
154
|
onListening?: () => void;
|
|
155
|
+
/**
|
|
156
|
+
* How long to wait for RouterOS to acknowledge `/cancel` after `signal`
|
|
157
|
+
* aborts before ending the listen locally. Native API defaults to the
|
|
158
|
+
* adapter's `timeoutMs`, the same bound as any other reply wait.
|
|
159
|
+
*/
|
|
160
|
+
cancelGraceMs?: number;
|
|
161
|
+
/** Fired when that wait expires without the router's acknowledgement. */
|
|
162
|
+
onCancelUnacknowledged?: () => void;
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
/**
|
|
@@ -733,7 +741,10 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
733
741
|
command.proplist = request.proplist;
|
|
734
742
|
}
|
|
735
743
|
const session = await this.connect();
|
|
736
|
-
for await (const reply of session.listen(command,
|
|
744
|
+
for await (const reply of session.listen(command, {
|
|
745
|
+
...options,
|
|
746
|
+
cancelGraceMs: options.cancelGraceMs ?? this.config.timeoutMs,
|
|
747
|
+
})) {
|
|
737
748
|
// Each `!re` becomes a rest-style record (string values; `.dead` preserved).
|
|
738
749
|
yield { ...reply.attributes };
|
|
739
750
|
}
|
|
@@ -627,14 +627,16 @@ export function parseScriptFor(cli: string): string {
|
|
|
627
627
|
* with RouterOS's position suffix. All 425 corpus rows of this shape are
|
|
628
628
|
* the entire return; none appear embedded. Requiring both line-start and
|
|
629
629
|
* the `(line N column M)` suffix is what separates a real verdict from
|
|
630
|
-
* echoed body text.
|
|
630
|
+
* echoed body text. The pinned 7.23.5/7.24.2/7.25beta3 captures add no
|
|
631
|
+
* new heads beyond `syntax error`, `expected …` and `missing …`
|
|
632
|
+
* (`missing closing brace`, `missing value for where`; GH#375).
|
|
631
633
|
*/
|
|
632
634
|
const WRAPPED_BAD_PARAMETER = /\(evl\s+bad parameter\s+(\S+)/i;
|
|
633
635
|
const WRAPPED_BAD_COMMAND = /\(<%%\s*bad command name\b/i;
|
|
634
636
|
const BARE_BAD_PARAMETER =
|
|
635
637
|
/^[ \t]*bad parameter\s+(\S+)[^\n]*\(line \d+ column \d+\)/im;
|
|
636
638
|
const BARE_SYNTAX =
|
|
637
|
-
/^[ \t]*(?:syntax error|bad command name|expected [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
|
|
639
|
+
/^[ \t]*(?:syntax error|bad command name|(?:expected|missing) [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
|
|
638
640
|
|
|
639
641
|
/**
|
|
640
642
|
* Classify the printed value of `:put [:parse "<cli>"]`. Grounded on CHR 7.23.3
|
|
@@ -646,8 +648,8 @@ const BARE_SYNTAX =
|
|
|
646
648
|
* RouterOS emits them (see the grammar note above): wrapped as
|
|
647
649
|
* `(evl bad parameter <name> (line N column M) /path)` /
|
|
648
650
|
* `(<%% bad command name <name> (line N column M) a;b;c)`, or bare and
|
|
649
|
-
* line-initial as `expected … (line N column M)` / `
|
|
650
|
-
* M)`. A single `:parse` covers both the syntax and the unknown-attribute
|
|
651
|
+
* line-initial as `expected … (line N column M)` / `missing … (line N column
|
|
652
|
+
* M)` / `syntax error (line N column M)`. A single `:parse` covers both the syntax and the unknown-attribute
|
|
651
653
|
* (name-level) gate on every transport — REST and native API expose the same text
|
|
652
654
|
* in the `ret` value (HTTP 200 / `as-string`), the console transports print it.
|
|
653
655
|
*
|
|
@@ -635,10 +635,21 @@ export class NativeApiSession {
|
|
|
635
635
|
* trap/`!done` are not awaited) and the generator returns.
|
|
636
636
|
* A non-`interrupted` trap (e.g. a bad path) or `!fatal`/transport closure
|
|
637
637
|
* throws. CHR-grounded (7.23.1): see `commands/api/AGENTS.md`.
|
|
638
|
+
*
|
|
639
|
+
* After an abort, `cancelGraceMs` bounds the wait for that acknowledgement: a
|
|
640
|
+
* peer that never answers `/cancel` would otherwise hold the generator open
|
|
641
|
+
* forever (#385). On expiry the generator ends without the router's
|
|
642
|
+
* `!done` and `onCancelUnacknowledged` fires, so the caller can close the
|
|
643
|
+
* session and report that the stop was local, not acknowledged.
|
|
638
644
|
*/
|
|
639
645
|
async *listen(
|
|
640
646
|
command: NativeApiCommand,
|
|
641
|
-
options: {
|
|
647
|
+
options: {
|
|
648
|
+
signal?: AbortSignal;
|
|
649
|
+
onListening?: () => void;
|
|
650
|
+
cancelGraceMs?: number;
|
|
651
|
+
onCancelUnacknowledged?: () => void;
|
|
652
|
+
} = {},
|
|
642
653
|
): AsyncGenerator<ApiReply, void, void> {
|
|
643
654
|
if (this.closed) {
|
|
644
655
|
throw (
|
|
@@ -702,10 +713,24 @@ export class NativeApiSession {
|
|
|
702
713
|
// can act on a real barrier rather than a blind timer.
|
|
703
714
|
options.onListening?.();
|
|
704
715
|
|
|
705
|
-
|
|
716
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
717
|
+
const onAbort = (): void => {
|
|
718
|
+
this.cancel(tag);
|
|
719
|
+
if (options.cancelGraceMs === undefined) return;
|
|
720
|
+
graceTimer = setTimeout(() => {
|
|
721
|
+
if (ended) return;
|
|
722
|
+
// Stop routing this tag and drop anything queued: a peer that ignores
|
|
723
|
+
// `/cancel` but keeps sending frames must not keep the loop draining.
|
|
724
|
+
this.subscriptions.delete(tag);
|
|
725
|
+
queue.length = 0;
|
|
726
|
+
ended = true;
|
|
727
|
+
options.onCancelUnacknowledged?.();
|
|
728
|
+
signalWake();
|
|
729
|
+
}, options.cancelGraceMs);
|
|
730
|
+
};
|
|
706
731
|
if (options.signal) {
|
|
707
732
|
if (options.signal.aborted) {
|
|
708
|
-
|
|
733
|
+
onAbort();
|
|
709
734
|
} else {
|
|
710
735
|
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
711
736
|
}
|
|
@@ -738,6 +763,7 @@ export class NativeApiSession {
|
|
|
738
763
|
throw this.trapToError(command.command, trap);
|
|
739
764
|
}
|
|
740
765
|
} finally {
|
|
766
|
+
clearTimeout(graceTimer);
|
|
741
767
|
options.signal?.removeEventListener("abort", onAbort);
|
|
742
768
|
this.subscriptions.delete(tag);
|
|
743
769
|
// Consumer broke out early (e.g. --count reached): tell the router to stop.
|
package/src/resolver/target.ts
CHANGED
|
@@ -428,20 +428,40 @@ export function resolveAuth(
|
|
|
428
428
|
}
|
|
429
429
|
|
|
430
430
|
export function parseHostCandidate(value: string): URL {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
remediation:
|
|
438
|
-
"Pass only the RouterOS host or base URL, then provide the RouterOS menu path as the second positional argument.",
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
return url;
|
|
431
|
+
const hasScheme = /^https?:\/\//i.test(value);
|
|
432
|
+
let url: URL;
|
|
433
|
+
try {
|
|
434
|
+
url = new URL(hasScheme ? value : `http://${value}`);
|
|
435
|
+
} catch (cause) {
|
|
436
|
+
throw invalidTargetError(value, cause);
|
|
442
437
|
}
|
|
438
|
+
if (hasScheme && url.pathname !== "/" && url.pathname !== "") {
|
|
439
|
+
throw new CentrsError({
|
|
440
|
+
code: "input/invalid-target",
|
|
441
|
+
summary: `Target URL must not include a path. Received: ${value}`,
|
|
442
|
+
remediation:
|
|
443
|
+
"Pass only the RouterOS host or base URL, then provide the RouterOS menu path as the second positional argument.",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return url;
|
|
447
|
+
}
|
|
443
448
|
|
|
444
|
-
|
|
449
|
+
/**
|
|
450
|
+
* A `<router>` that is not a CDB handle and does not parse as a host (#381).
|
|
451
|
+
* `quickchr:NAME` is the likely guess for a quickchr VM, so it names the flag.
|
|
452
|
+
*/
|
|
453
|
+
function invalidTargetError(value: string, cause: unknown): CentrsError {
|
|
454
|
+
const quickchrGuess = /^quickchr:(.+)$/i.exec(value)?.[1];
|
|
455
|
+
return new CentrsError({
|
|
456
|
+
code: "input/invalid-target",
|
|
457
|
+
summary: `Target is not a known device or a valid host: ${value}`,
|
|
458
|
+
remediation:
|
|
459
|
+
quickchrGuess !== undefined
|
|
460
|
+
? `Select a quickchr VM with the flag: \`--quickchr ${quickchrGuess}\` (there is no \`quickchr:\` target prefix).`
|
|
461
|
+
: "Pass a CDB target or identity (see `centrs devices list`), an IP or hostname with optional `:port`, or an `http(s)://host[:port]` base URL.",
|
|
462
|
+
context: { target: value },
|
|
463
|
+
cause,
|
|
464
|
+
});
|
|
445
465
|
}
|
|
446
466
|
|
|
447
467
|
function readPort(parsedUrl: URL, scheme: "http" | "https"): number {
|