@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +8 -4
  3. package/commands/api/README.md +21 -10
  4. package/commands/api/examples.md +70 -2
  5. package/commands/devices/README.md +10 -1
  6. package/commands/devices/examples.md +33 -2
  7. package/commands/execute/README.md +29 -15
  8. package/commands/execute/examples.md +89 -0
  9. package/commands/explain/README.md +12 -1
  10. package/commands/mcp/README.md +2 -1
  11. package/commands/retrieve/examples.md +20 -0
  12. package/docs/CLI.md +1 -1
  13. package/docs/CONSTITUTION.md +11 -6
  14. package/docs/errors/cdb/create-failed.md +20 -0
  15. package/docs/errors/cdb/file-exists.md +23 -0
  16. package/docs/errors/input/invalid-target.md +25 -3
  17. package/docs/errors/routeros/invalid-value.md +3 -2
  18. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  19. package/package.json +1 -1
  20. package/src/api.ts +109 -25
  21. package/src/cli/devices.ts +16 -3
  22. package/src/core/error-catalog.ts +14 -0
  23. package/src/core/execute-command.ts +313 -6
  24. package/src/core/inspect.ts +22 -22
  25. package/src/core/routeros-errors.ts +66 -0
  26. package/src/data/winbox-cdb-write.ts +22 -8
  27. package/src/devices.ts +142 -28
  28. package/src/execute-fanout.ts +1 -1
  29. package/src/execute.ts +22 -51
  30. package/src/explain/catalog.ts +8 -8
  31. package/src/explain/menus.ts +4 -4
  32. package/src/index.ts +2 -0
  33. package/src/mcp/tools.ts +1 -1
  34. package/src/protocols/adapter.ts +12 -1
  35. package/src/protocols/mac-telnet-console.ts +6 -4
  36. package/src/protocols/native-api.ts +29 -3
  37. package/src/resolver/target.ts +32 -12
  38. package/src/retrieve.ts +17 -4
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 {
@@ -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
- * 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.
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<void> {
241
+ async function createEmptyCdbNoClobber(path: string): Promise<boolean> {
232
242
  await mkdir(dirname(path), { recursive: true });
233
- const handle = await open(path, "wx", 0o600);
243
+ const tempPath = `${path}.init.${randomUUID()}`;
244
+ const handle = await open(tempPath, "wx", 0o600);
234
245
  try {
235
- await handle.write(encodeOpenWinBoxCdb([]));
236
- await handle.sync();
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 handle.close();
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(["INDEX", "TARGET", "TYPE", "USER", "GROUP"].join("\t"));
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 || "-",
@@ -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
  */
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 { mapRouterOsError } from "./core/routeros-errors.ts";
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
- const script = `:put [:parse ${routerOsStringLiteral(resolved.command)}]`;
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 ?? "", resolved.command, resolved.via.value);
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: resolved.command,
937
+ command,
929
938
  validationSource: ":put [:parse ...]",
930
- via: resolved.via.value,
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
- const candidates = [
1302
- result.ret,
1303
- ...result.records.flatMap((record) => [
1304
- record["detail"],
1305
- record["message"],
1306
- record["failure"],
1307
- record["error"],
1308
- record["ret"],
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(
@@ -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,053 pages, 1,060 entries — first-order about the definition
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.2/extra/deep-inspect.x86.json` | x86 | 7.24.2 | 41,345 |
23
- * | `7.24.2/extra/deep-inspect.arm64.json` | arm64 | 7.24.2 | 42,691 |
24
- * | `7.25beta3/extra/deep-inspect.x86.json` | x86 | 7.25beta3 | 41,798 |
25
- * | `7.25beta3/extra/deep-inspect.arm64.json` | arm64 | 7.25beta3 | 43,148 |
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 | 443 | 67 | 44 |
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|inspect
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|inspect
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
@@ -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.2/extra/deep-inspect.x86.json` | x86 | 7.24.2 | 41,345 | 554 |
16
- * | `7.24.2/extra/deep-inspect.arm64.json` | arm64 | 7.24.2 | 42,691 | 575 |
17
- * | `7.25beta3/extra/deep-inspect.x86.json` | x86 | 7.25beta3 | 41,798 | 557 |
18
- * | `7.25beta3/extra/deep-inspect.arm64.json` | arm64 | 7.25beta3 | 43,148 | 578 |
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 (add/set/remove). Reads ignore it.",
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
@@ -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, options)) {
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)` / `syntax error (line N column
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: { signal?: AbortSignal; onListening?: () => void } = {},
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
- const onAbort = (): void => this.cancel(tag);
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
- this.cancel(tag);
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.
@@ -428,20 +428,40 @@ export function resolveAuth(
428
428
  }
429
429
 
430
430
  export function parseHostCandidate(value: string): URL {
431
- if (/^https?:\/\//i.test(value)) {
432
- const url = new URL(value);
433
- if (url.pathname !== "/" && url.pathname !== "") {
434
- throw new CentrsError({
435
- code: "input/invalid-target",
436
- summary: `Target URL must not include a path. Received: ${value}`,
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
- return new URL(`http://${value}`);
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 {