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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/GLOSSARY.txt +23 -1
  3. package/README.md +30 -18
  4. package/commands/api/AGENTS.md +21 -3
  5. package/commands/api/README.md +18 -7
  6. package/commands/api/examples.md +1 -1
  7. package/commands/btest/README.md +9 -0
  8. package/commands/btest/examples.md +7 -0
  9. package/commands/devices/README.md +72 -3
  10. package/commands/devices/examples.md +56 -1
  11. package/commands/execute/README.md +37 -6
  12. package/commands/execute/examples.md +24 -5
  13. package/commands/explain/README.md +675 -64
  14. package/commands/explain/examples.md +199 -42
  15. package/commands/retrieve/README.md +35 -3
  16. package/commands/retrieve/examples.md +114 -1
  17. package/commands/transfer/README.md +33 -4
  18. package/commands/transfer/examples.md +46 -0
  19. package/docs/CLI.md +30 -8
  20. package/docs/CONSTITUTION.md +14 -9
  21. package/docs/MATRIX.md +37 -22
  22. package/docs/errors/cdb/encrypt-failed.md +10 -0
  23. package/docs/errors/cdb/password-required.md +5 -4
  24. package/docs/errors/cdb/plaintext-backups.md +11 -0
  25. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  26. package/docs/errors/input/invalid-routeros-path.md +25 -3
  27. package/docs/errors/routeros/permission-denied.md +24 -0
  28. package/docs/errors/transport/edge-auth-failed.md +22 -0
  29. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  30. package/docs/errors/usage/invalid-cursor.md +20 -0
  31. package/docs/errors/validation/unknown-path.md +9 -0
  32. package/package.json +9 -5
  33. package/src/api-fanout.ts +2 -0
  34. package/src/api.ts +46 -39
  35. package/src/btest.ts +7 -0
  36. package/src/cli/api.ts +18 -1
  37. package/src/cli/btest.ts +9 -0
  38. package/src/cli/devices.ts +76 -8
  39. package/src/cli/execute.ts +9 -0
  40. package/src/cli/explain.ts +206 -47
  41. package/src/cli/retrieve.ts +9 -0
  42. package/src/cli/terminal.ts +9 -0
  43. package/src/cli/transfer.ts +10 -1
  44. package/src/core/envelope.ts +10 -3
  45. package/src/core/error-catalog.ts +28 -2
  46. package/src/core/execute-command.ts +53 -0
  47. package/src/core/fanout.ts +12 -2
  48. package/src/core/inspect.ts +4 -5
  49. package/src/core/menu-path.ts +89 -0
  50. package/src/core/routeros-errors.ts +25 -0
  51. package/src/devices.ts +154 -9
  52. package/src/execute-cmr.ts +2 -8
  53. package/src/execute-fanout.ts +2 -0
  54. package/src/execute.ts +55 -20
  55. package/src/explain/args.ts +115 -10
  56. package/src/explain/blocks.ts +2 -2
  57. package/src/explain/catalog.ts +22 -12
  58. package/src/explain/group-faults.ts +162 -29
  59. package/src/explain/operators.ts +2 -1
  60. package/src/explain/pasted-prompt.ts +198 -0
  61. package/src/explain/pathresolve.ts +2 -2
  62. package/src/explain/scope-brace.ts +226 -11
  63. package/src/explain/segment.ts +138 -3
  64. package/src/explain/symbols.ts +148 -8
  65. package/src/explain/values.ts +55 -4
  66. package/src/explain/verbsplit.ts +2 -2
  67. package/src/explain/write.ts +2 -2
  68. package/src/explain-live-facets.ts +739 -0
  69. package/src/explain-live-types.ts +490 -0
  70. package/src/explain-live.ts +593 -0
  71. package/src/explain.ts +397 -43
  72. package/src/index.ts +10 -0
  73. package/src/protocols/adapter.ts +90 -10
  74. package/src/protocols/btest-session.ts +10 -30
  75. package/src/protocols/ec-srp5.ts +19 -10
  76. package/src/protocols/mac-telnet-console.ts +2 -2
  77. package/src/protocols/mac-telnet.ts +38 -10
  78. package/src/protocols/native-api.ts +13 -12
  79. package/src/resolver/cdb.ts +58 -2
  80. package/src/resolver/index.ts +1 -0
  81. package/src/resolver/quickchr-provider.ts +5 -0
  82. package/src/resolver/selection.ts +11 -0
  83. package/src/retrieve-fanout.ts +2 -0
  84. package/src/retrieve-follow.ts +2 -1
  85. package/src/retrieve-sample.ts +2 -1
  86. package/src/retrieve-wait.ts +10 -6
  87. package/src/retrieve.ts +88 -27
  88. package/src/settings.ts +1 -0
  89. package/src/terminal.ts +7 -0
  90. package/src/transfer-fanout.ts +2 -0
  91. package/src/transfer.ts +117 -27
@@ -0,0 +1,89 @@
1
+ /**
2
+ * RouterOS menu-path grammar for the read surfaces (`retrieve`, `api`) and
3
+ * the REST URL guard every REST request passes through (#586).
4
+ *
5
+ * Every dir and command name in the RouterOS 7.9–7.26 command tree (rosetta
6
+ * v0.12.0, 579 dirs / 5370 commands) is letters, digits and hyphens,
7
+ * starting with a letter or digit (`/interface/6to4`); case folds. Anything else
8
+ * in a menu segment is not a menu, and some of it changes what the request
9
+ * addresses: in a REST URL, `#` and `?` turn the appended `/print` into a
10
+ * fragment or query, so `retrieve '/system/reboot#'` posted to
11
+ * `/rest/system/reboot` and rebooted the router (CHR 7.24.5).
12
+ */
13
+
14
+ import { CentrsError } from "../errors.ts";
15
+ import { pathTokens } from "./inspect.ts";
16
+
17
+ const MENU_SEGMENT = /^[a-z0-9][a-z0-9-]*$/;
18
+
19
+ /** The menu segments of `path` that are not RouterOS menu or command names. */
20
+ export function invalidMenuSegments(path: string): string[] {
21
+ // Case folds, as RouterOS menus do (`src/explain/menus.ts` isMenuPath);
22
+ // letter case cannot change how a URL parses, so it is not a hazard.
23
+ return pathTokens(path).filter(
24
+ (segment) => !MENU_SEGMENT.test(segment.toLowerCase()),
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Reject a read-surface path whose segments are not RouterOS menu names.
30
+ * Runs before any network work and regardless of `--validate`: that flag
31
+ * skips command validation, not the guarantee that a read dispatches a read.
32
+ */
33
+ export function assertRouterOsMenuPath(
34
+ path: string,
35
+ context: { readonly flag?: string; readonly endpoint?: string } = {},
36
+ ): void {
37
+ const invalid = invalidMenuSegments(path);
38
+ if (invalid.length === 0) {
39
+ return;
40
+ }
41
+ throw new CentrsError({
42
+ code: "input/invalid-routeros-path",
43
+ summary: `RouterOS path ${JSON.stringify(path)} has a segment that is not a menu name: ${invalid.map((segment) => JSON.stringify(segment)).join(", ")}.`,
44
+ remediation:
45
+ "Write the menu path with letters, digits and hyphens between slashes, such as `/ip/address` or `/system/resource`. Filter rows with `--query`, not characters in the path.",
46
+ context: { path, invalidSegments: invalid, ...context },
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Characters and segments that make a WHATWG URL address something other
52
+ * than the path as written: `?` starts a query, `#` a fragment, `\` is read
53
+ * as `/`, tabs and newlines are stripped, and `.`/`..` segments collapse
54
+ * (also percent-encoded: `%2e%2e`, `.%2E`).
55
+ * RouterOS does not decode percent-escapes in REST paths (`reboot%23` is
56
+ * "no such command or directory" on CHR 7.24.5), so the rest is inert.
57
+ */
58
+ function hasUrlStructuralCharacter(path: string): boolean {
59
+ for (const char of path) {
60
+ const code = char.charCodeAt(0);
61
+ if (char === "?" || char === "#" || char === "\\") return true;
62
+ if (code <= 0x1f || code === 0x7f) return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /** `.`/`..` and the percent-encoded forms WHATWG also collapses (`%2e%2e`, `.%2E`). */
68
+ const DOT_SEGMENT = /^(?:\.|%2e){1,2}$/i;
69
+
70
+ /**
71
+ * Refuse a REST request path that the URL parser would rewrite. This is the
72
+ * backstop under every REST caller (retrieve, api, execute, transfer, explain)
73
+ * so an appended `/print` can never be cut off before dispatch.
74
+ */
75
+ export function assertRestRequestPath(path: string): void {
76
+ const dotSegment = path
77
+ .split("/")
78
+ .some((segment) => DOT_SEGMENT.test(segment));
79
+ if (!hasUrlStructuralCharacter(path) && !dotSegment) {
80
+ return;
81
+ }
82
+ throw new CentrsError({
83
+ code: "input/invalid-routeros-path",
84
+ summary: `Refusing REST request path ${JSON.stringify(path)}: it contains characters the URL would reinterpret (?, #, \\, control characters, or a . / .. segment), so the request would not reach the path as written.`,
85
+ remediation:
86
+ "Remove the character from the path. Pass filters with `--query` and values as fields; names containing these characters can be selected with a query instead of the URL path.",
87
+ context: { path, via: "rest-api" },
88
+ });
89
+ }
@@ -107,6 +107,31 @@ function cleanToken(token: string): string {
107
107
  * rules here; keep the most specific patterns above broader ones.
108
108
  */
109
109
  export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
110
+ {
111
+ code: "routeros/permission-denied",
112
+ description:
113
+ "RouterOS refused the command because the user's group lacks a policy it needs.",
114
+ // Grounded on CHR 7.25. REST answers HTTP 500 with this as `detail`:
115
+ // `not enough permissions (9)` for a write without `write`, and
116
+ // `std failure: not allowed (9)` for any request (even a GET) when the
117
+ // group has `rest-api` but not `api` (fixed in 7.26beta3). The native API traps with the
118
+ // first string; `/execute as-string` returns it as text with a
119
+ // `(<path>; line N)` suffix. In a multi-statement script the refusal is
120
+ // the last line, after earlier statements' stdout (#549: `:put
121
+ // "p5-first"; /system/identity/set …` as a read-only user returns HTTP
122
+ // 200 with ret `p5-first\r\nnot enough permissions (9)
123
+ // (/system/identity/set; line 1)`, identical on native-api), so the
124
+ // trailed form matches too — with the location suffix required, so a
125
+ // script that merely prints the bare phrase stays ordinary output.
126
+ test: /not enough permissions|not allowed \(9\)/i,
127
+ resultTest:
128
+ /^(?:not enough permissions \(9\)(?: \(\/[^\s();]+; line \d+\))?|[\s\S]*\r?\nnot enough permissions \(9\) \(\/[^\s();]+; line \d+\))$/i,
129
+ build: (_match, raw) => ({
130
+ summary: `RouterOS refused the command for lack of permission: ${raw.trim()}`,
131
+ remediation:
132
+ "Grant the missing policy to the RouterOS user's group (`/user/group`): `read` for reads, `write` for changes. If every REST request fails with `not allowed (9)`, the group has `rest-api` without `api`; before RouterOS 7.26beta3, REST needs both. Retrying will not help.",
133
+ }),
134
+ },
110
135
  {
111
136
  code: "routeros/unknown-path",
112
137
  description:
package/src/devices.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  decryptWinBoxCdb,
20
20
  type EncryptedWinBoxCdbFile,
21
21
  encodeOpenWinBoxCdb,
22
+ encryptWinBoxCdb,
22
23
  parseWinBoxCdb,
23
24
  type WinBoxCdbEntry,
24
25
  type WinBoxCdbField,
@@ -112,6 +113,7 @@ export interface DevicesGroupSummary {
112
113
 
113
114
  export type DevicesCommand =
114
115
  | "init"
116
+ | "encrypt"
115
117
  | "list"
116
118
  | "show"
117
119
  | "groups"
@@ -165,7 +167,7 @@ export interface LoadedCdb {
165
167
  entries: readonly WinBoxCdbEntry[];
166
168
  settings: DevicesSettings;
167
169
  warnings: readonly DevicesWarning[];
168
- /** True when the source file on disk was encrypted (writes are blocked). */
170
+ /** True when the source file on disk was encrypted. */
169
171
  encrypted: boolean;
170
172
  }
171
173
 
@@ -232,7 +234,7 @@ function isAlreadyExistsError(cause: unknown): boolean {
232
234
  }
233
235
 
234
236
  /**
235
- * Create an empty open CDB at `path` without clobbering an existing file.
237
+ * Create an empty CDB at `path` without clobbering an existing file.
236
238
  * The bytes are written to a private temp file first and then hard-linked
237
239
  * into place: `link` fails with EEXIST instead of replacing, and `path` never
238
240
  * exists half-written, so a concurrent creator that loses the race reads a
@@ -242,14 +244,20 @@ function isAlreadyExistsError(cause: unknown): boolean {
242
244
  * file. Mode `0o600` keeps the CDB (device credentials) unreadable by other
243
245
  * local users instead of falling back to the process umask default.
244
246
  */
245
- async function createEmptyCdbNoClobber(path: string): Promise<boolean> {
247
+ async function createEmptyCdbNoClobber(
248
+ path: string,
249
+ password?: string,
250
+ ): Promise<boolean> {
246
251
  await mkdir(dirname(path), { recursive: true });
247
252
  const tempPath = `${path}.init.${randomUUID()}`;
248
253
  const handle = await open(tempPath, "wx", 0o600);
249
254
  try {
250
255
  try {
251
256
  await handle.chmod(0o600);
252
- await handle.write(encodeOpenWinBoxCdb([]));
257
+ const bytes = encodeOpenWinBoxCdb([]);
258
+ await handle.write(
259
+ password === undefined ? bytes : encryptWinBoxCdb(bytes, password),
260
+ );
253
261
  await handle.sync();
254
262
  } finally {
255
263
  await handle.close();
@@ -270,11 +278,19 @@ export interface DevicesInitData {
270
278
  cdbFile: string;
271
279
  /** False when a readable CDB was already there; nothing was written. */
272
280
  created: boolean;
281
+ /** Actual mode of the newly created or verified existing CDB. */
282
+ encrypted: boolean;
273
283
  recordCount: number;
274
284
  }
275
285
 
286
+ export interface InitCdbOptions extends LoadCdbOptions {
287
+ /** Require encryption; an existing open CDB is refused, never converted. */
288
+ encrypt?: boolean;
289
+ }
290
+
276
291
  /**
277
- * `devices init` (#376): create an empty open CDB at the resolved path, the
292
+ * `devices init`: create an empty CDB (open by default, encrypted with
293
+ * `encrypt: true`) at the resolved path, the
278
294
  * explicit counterpart of the default path's first-run auto-create. Never
279
295
  * clobbers: like `git init`, re-running on an existing, readable CDB succeeds
280
296
  * with a `cdb/file-exists` warning and writes nothing, so a workspace bootstrap
@@ -282,15 +298,29 @@ export interface DevicesInitData {
282
298
  * (not a CDB, or encrypted without a password) fails with the load error.
283
299
  */
284
300
  export async function initCdb(
285
- options: LoadCdbOptions,
301
+ options: InitCdbOptions,
286
302
  ): Promise<CentrsSuccessEnvelope<DevicesInitData, DevicesOperationMeta>> {
287
303
  const settings = resolveDevicesSettings(options);
288
304
  const cdbFile = settings.cdbFile.value;
305
+ if (options.encrypt) requireEncryptionPassword(settings);
289
306
  const existingResult = async () => {
290
307
  const existing = await loadCdb(options);
308
+ if (options.encrypt && !existing.encrypted) {
309
+ throw new CentrsError({
310
+ code: "input/invalid-command",
311
+ summary: `An open CDB already exists at ${cdbFile}; encrypted initialization wrote nothing.`,
312
+ remediation: `Run \`centrs devices encrypt --cdb-file ${cdbFile}\` to convert the existing CDB explicitly.`,
313
+ context: { cdbFile },
314
+ });
315
+ }
291
316
  return {
292
317
  ok: true as const,
293
- data: { cdbFile, created: false, recordCount: existing.entries.length },
318
+ data: {
319
+ cdbFile,
320
+ created: false,
321
+ encrypted: existing.encrypted,
322
+ recordCount: existing.entries.length,
323
+ },
294
324
  warnings: [
295
325
  ...existing.warnings,
296
326
  {
@@ -310,7 +340,10 @@ export async function initCdb(
310
340
  }
311
341
  let created: boolean;
312
342
  try {
313
- created = await createEmptyCdbNoClobber(cdbFile);
343
+ created = await createEmptyCdbNoClobber(
344
+ cdbFile,
345
+ options.encrypt ? settings.cdbPassword.value : undefined,
346
+ );
314
347
  } catch (cause) {
315
348
  throw new CentrsError({
316
349
  code: "cdb/create-failed",
@@ -326,13 +359,113 @@ export async function initCdb(
326
359
  }
327
360
  return {
328
361
  ok: true,
329
- data: { cdbFile, created: true, recordCount: 0 },
362
+ data: {
363
+ cdbFile,
364
+ created: true,
365
+ encrypted: options.encrypt === true,
366
+ recordCount: 0,
367
+ },
330
368
  warnings: [],
331
369
  tips: [],
332
370
  meta: devicesMeta("init", settings),
333
371
  };
334
372
  }
335
373
 
374
+ function requireEncryptionPassword(settings: DevicesSettings): void {
375
+ if (
376
+ !settings.cdbPassword.provided ||
377
+ settings.cdbPassword.value.length === 0
378
+ ) {
379
+ throw new CentrsError({
380
+ code: "cdb/password-required",
381
+ summary: "CDB encryption requires a non-empty password.",
382
+ remediation:
383
+ "Pass --cdb-password or set CENTRS_CDB_PASSWORD to a non-empty CDB password.",
384
+ });
385
+ }
386
+ }
387
+
388
+ export interface EncryptCdbOptions extends LoadCdbOptions {
389
+ /**
390
+ * Skip the plaintext pre-conversion snapshot. Existing backups are still
391
+ * retained untouched; without the snapshot a forgotten password is final.
392
+ */
393
+ skipBackup?: boolean;
394
+ }
395
+
396
+ export interface DevicesEncryptData {
397
+ cdbFile: string;
398
+ encrypted: true;
399
+ /** False when the CDB was already encrypted and the password verified. */
400
+ changed: boolean;
401
+ recordCount: number;
402
+ /** Plaintext pre-conversion snapshot; absent when unchanged or `skipBackup`. */
403
+ backupPath?: string;
404
+ }
405
+
406
+ /**
407
+ * Explicitly convert an existing open CDB with a fresh salt and atomic replacement.
408
+ * Retain all backups: converting the active file does not protect old plaintext.
409
+ * Close WinBox and other writers first; this is not a cross-process transaction (#581).
410
+ * Already-encrypted files are verified with the supplied password and left untouched.
411
+ * `skipBackup` omits the new plaintext snapshot; it never deletes older backups.
412
+ */
413
+ export async function encryptCdb(
414
+ options: EncryptCdbOptions,
415
+ ): Promise<CentrsSuccessEnvelope<DevicesEncryptData, DevicesOperationMeta>> {
416
+ const settings = resolveDevicesSettings(options);
417
+ requireEncryptionPassword(settings);
418
+ const cdbFile = settings.cdbFile.value;
419
+ // Pin even the default path so conversion never implicitly creates a file.
420
+ const cdb = await loadCdb({ ...options, cdbFile });
421
+ let backupPath: string | undefined;
422
+ if (!cdb.encrypted) {
423
+ try {
424
+ const written = await writeWinBoxCdb(
425
+ cdbFile,
426
+ cdb.entries.map((entry) => entry.record),
427
+ {
428
+ encryptWith: { password: settings.cdbPassword.value },
429
+ backupRetention: -1,
430
+ skipBackup: options.skipBackup === true,
431
+ },
432
+ );
433
+ backupPath = written.backupPath;
434
+ } catch (cause) {
435
+ throw new CentrsError({
436
+ code: "cdb/encrypt-failed",
437
+ summary: `Could not encrypt the CDB at ${cdbFile}.`,
438
+ remediation:
439
+ "Check directory permissions and available disk space; retain the original and any backups before retrying.",
440
+ context: { cdbFile },
441
+ cause,
442
+ });
443
+ }
444
+ }
445
+ return {
446
+ ok: true,
447
+ data: {
448
+ cdbFile,
449
+ encrypted: true,
450
+ changed: !cdb.encrypted,
451
+ recordCount: cdb.entries.length,
452
+ ...(backupPath ? { backupPath } : {}),
453
+ },
454
+ warnings: [
455
+ ...cdb.warnings.filter(
456
+ (warning) => warning.code !== "cdb/password-not-needed",
457
+ ),
458
+ {
459
+ code: "cdb/plaintext-backups",
460
+ message:
461
+ "Backups may contain plaintext credentials. Encryption protects only the active CDB; no backups were deleted or encrypted.",
462
+ },
463
+ ],
464
+ tips: [],
465
+ meta: devicesMeta("encrypt", settings),
466
+ };
467
+ }
468
+
336
469
  export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
337
470
  const settings = resolveDevicesSettings(options);
338
471
  const warnings: DevicesWarning[] = [];
@@ -1859,6 +1992,18 @@ function renderText(envelope: DevicesEnvelope<unknown>): string {
1859
1992
  ? `created ${data.cdbFile}`
1860
1993
  : `exists ${data.cdbFile} (${data.recordCount} record(s))`,
1861
1994
  );
1995
+ lines.push(`encrypted: ${data.encrypted}`);
1996
+ break;
1997
+ }
1998
+ case "encrypt": {
1999
+ const data = envelope.data as DevicesEncryptData;
2000
+ lines.push(
2001
+ data.changed
2002
+ ? `encrypted ${data.cdbFile}`
2003
+ : `verified encrypted ${data.cdbFile} (unchanged)`,
2004
+ );
2005
+ lines.push(`records: ${data.recordCount}`);
2006
+ if (data.backupPath) lines.push(`backup: ${data.backupPath}`);
1862
2007
  break;
1863
2008
  }
1864
2009
  case "list":
@@ -1002,8 +1002,7 @@ function devicePassed(
1002
1002
  source: CMR_VALIDATION_SOURCE,
1003
1003
  result: "passed",
1004
1004
  syntax: true,
1005
- semantic:
1006
- resolved.canonical.mode === "structured" ? true : "not-applicable",
1005
+ ...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
1007
1006
  stages: passedStages(
1008
1007
  offline ?? { verdict: "pass", warnings: [] },
1009
1008
  CMR_VALIDATION_SOURCE,
@@ -1023,12 +1022,7 @@ function deviceRejected(
1023
1022
  enabled: true,
1024
1023
  source: CMR_VALIDATION_SOURCE,
1025
1024
  result: "failed",
1026
- ...(nameRejected
1027
- ? { syntax: true, semantic: false }
1028
- : {
1029
- syntax: false,
1030
- ...(structured ? {} : { semantic: "not-applicable" as const }),
1031
- }),
1025
+ ...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
1032
1026
  stages: [
1033
1027
  offlineStagePassed(offline?.verdict),
1034
1028
  { stage: "device", source: CMR_VALIDATION_SOURCE, result: "failed" },
@@ -60,6 +60,7 @@ import {
60
60
  } from "./execute.ts";
61
61
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
62
62
  import {
63
+ assertNoMatchInFanout,
63
64
  emptyWriteSelectionError,
64
65
  expandSelection,
65
66
  isDefaultRecordTarget,
@@ -206,6 +207,7 @@ export async function executeFanout(
206
207
  internals: ExecuteFanoutInternals = {},
207
208
  options: ExecuteFanoutOptions = {},
208
209
  ): Promise<ExecuteFanoutEnvelope> {
210
+ assertNoMatchInFanout(request.match);
209
211
  // Validate + canonicalize the command ONCE, before selection expansion: a bad
210
212
  // command shape is an outer error regardless of how many targets resolve, and
211
213
  // write-ness is target-independent.
package/src/execute.ts CHANGED
@@ -27,6 +27,7 @@ import { buildTip, notReachedReason } from "./core/envelope.ts";
27
27
  import {
28
28
  type CanonicalExecuteCommand,
29
29
  canonicalizeExecuteCommand,
30
+ isBareMenuPathCommand,
30
31
  isWriteShaped,
31
32
  } from "./core/execute-command.ts";
32
33
  import { sentWriteWarnings } from "./core/fanout.ts";
@@ -40,6 +41,7 @@ import {
40
41
  SYNTAX_REJECTED_REMEDIATION,
41
42
  serializeCentrsError,
42
43
  } from "./errors.ts";
44
+ import { countExecutableStatements } from "./explain/segment.ts";
43
45
  import {
44
46
  assertOfflineSyntax,
45
47
  isOfflineGateRejection,
@@ -118,6 +120,12 @@ export interface ExecuteRequest {
118
120
  maxResultsBytes?: number;
119
121
  cdbFile?: string;
120
122
  cdbPassword?: string;
123
+ /**
124
+ * `--match` selector for a `<router>` several CDB records share
125
+ * (`user=<name>`, `target=<addr>`, or a record type): the same selector
126
+ * `devices show --match` takes. Single-target only (#583).
127
+ */
128
+ match?: string;
121
129
  /** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
122
130
  resolve?: string;
123
131
  stdinIsTty?: boolean;
@@ -162,6 +170,7 @@ export type ExecuteErrorEnvelope = CentrsErrorEnvelope<ExecuteOperationMeta>;
162
170
  export {
163
171
  type CanonicalExecuteCommand,
164
172
  canonicalizeExecuteCommand,
173
+ isBareMenuPathCommand,
165
174
  isWriteShaped,
166
175
  };
167
176
 
@@ -364,6 +373,7 @@ export async function resolveExecuteRequest(
364
373
  targetInput: request.targetInput,
365
374
  cdbFile: request.cdbFile,
366
375
  cdbPassword: request.cdbPassword,
376
+ match: request.match,
367
377
  },
368
378
  env,
369
379
  config,
@@ -608,6 +618,22 @@ export function buildExecuteErrorEnvelope(
608
618
  };
609
619
  }
610
620
 
621
+ /**
622
+ * Whether the dispatch carried more than one statement (#549). Mirrors
623
+ * `runCommand`: the consoles run the raw CLI text whatever the canonical mode,
624
+ * while REST/native run structured input as one path-POST/talk sentence — a
625
+ * `;` inside a structured value never separates there, so the refusal means
626
+ * nothing ran. Executed control scopes count too: `:if … do={ A; B }` applies
627
+ * A before a refused B, while stored bodies never run at dispatch.
628
+ */
629
+ function isMultiStatementDispatch(resolved: ResolvedExecuteRequest): boolean {
630
+ const runsRawText =
631
+ resolved.via.value === "mac-telnet" ||
632
+ resolved.via.value === "ssh" ||
633
+ resolved.canonical.mode === "script";
634
+ return runsRawText && countExecutableStatements(resolved.command) > 1;
635
+ }
636
+
611
637
  export function buildExecuteErrorEnvelopeFromResolved(
612
638
  resolved: ResolvedExecuteRequest,
613
639
  error: unknown,
@@ -633,6 +659,7 @@ export function buildExecuteErrorEnvelopeFromResolved(
633
659
  isWriteShaped(resolved.canonical),
634
660
  centrsError,
635
661
  trace,
662
+ isMultiStatementDispatch(resolved),
636
663
  ),
637
664
  ],
638
665
  tips: resolvedExecuteTips(resolved),
@@ -664,20 +691,15 @@ function failedValidationMeta(
664
691
  }
665
692
  const deviceSource = deviceStageSource(resolved);
666
693
  const structured = resolved.canonical.mode === "structured";
667
- // A flat summary is present only when its check gave a verdict (GH#435).
668
- // Script-shaped input has no semantic half at all: `not-applicable`. For
669
- // structured input, a syntax rejection means the name-level check was
670
- // never asked, so `semantic` is left out rather than reported `false`.
671
- const semanticNotReached = structured
672
- ? {}
673
- : { semantic: "not-applicable" as const };
694
+ // A flat summary is present only when a stage gave that verdict (#535). A
695
+ // syntax rejection means the name-level check was never asked, so
696
+ // `semantic` is left out rather than reported `false`.
674
697
  if (isOfflineGateRejection(error)) {
675
698
  return {
676
699
  enabled: true,
677
700
  source: OFFLINE_GATE_SOURCE,
678
701
  result: "failed",
679
702
  syntax: false,
680
- ...semanticNotReached,
681
703
  stages: [
682
704
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
683
705
  {
@@ -705,9 +727,7 @@ function failedValidationMeta(
705
727
  enabled: true,
706
728
  source: deviceSource,
707
729
  result: "failed",
708
- ...(nameRejected
709
- ? { syntax: true, semantic: false }
710
- : { syntax: false, ...semanticNotReached }),
730
+ ...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
711
731
  stages: [
712
732
  offlineStagePassed(trace?.offlineVerdict),
713
733
  { stage: "device", source: deviceSource, result: "failed" },
@@ -781,15 +801,14 @@ export function offlineStagePassed(
781
801
  * `validate=false`. Both stages are listed as `skipped` rather than omitted:
782
802
  * the constitution's rule is that a gated surface always reports each stage, so
783
803
  * a consumer reading `stages` never has to special-case the disabled shape.
784
- * `reason` is left off — `enabled: false` already says why.
804
+ * `reason` is left off — `enabled: false` already says why. No stage answered,
805
+ * so there is no `syntax` / `semantic` summary (#535).
785
806
  */
786
807
  export function disabledValidationMeta(): EnvelopeValidationMeta {
787
808
  return {
788
809
  enabled: false,
789
810
  source: "disabled",
790
811
  result: "skipped",
791
- syntax: false,
792
- semantic: false,
793
812
  stages: [
794
813
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
795
814
  { stage: "device", source: "device preflight", result: "skipped" },
@@ -862,7 +881,6 @@ async function validateExecuteCommand(
862
881
  source: ":put [:parse]",
863
882
  result: "passed",
864
883
  syntax: true,
865
- semantic: "not-applicable",
866
884
  stages: passedStages(offline, ":put [:parse]"),
867
885
  };
868
886
  }
@@ -929,8 +947,7 @@ async function validateConsoleParseCommand(
929
947
  source: `:put [:parse ...] over ${resolved.via.value}`,
930
948
  result: "passed",
931
949
  syntax: true,
932
- semantic:
933
- resolved.canonical.mode === "structured" ? true : "not-applicable",
950
+ ...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
934
951
  stages: passedStages(
935
952
  offline,
936
953
  `:put [:parse ...] over ${resolved.via.value}`,
@@ -1452,20 +1469,38 @@ function executeRequestSummary(
1452
1469
  * downstream auth error.
1453
1470
  */
1454
1471
  export function resolvedExecuteTips(resolved: ResolvedExecuteRequest): Tip[] {
1472
+ const tips: Tip[] = [];
1455
1473
  if (
1456
1474
  resolved.via.value === "mac-telnet" &&
1457
1475
  resolved.auth.username === undefined &&
1458
1476
  !resolved.auth.passwordProvided
1459
1477
  ) {
1460
- return [
1478
+ tips.push(
1461
1479
  buildTip(
1462
1480
  "tip/mac-telnet-no-credentials",
1463
1481
  "No username or password was resolved for this mac-telnet target.",
1464
1482
  "mac-telnet logs in with RouterOS credentials over MTWEI (MD5 is refused): pass --username/--password, set CENTRS_USERNAME/CENTRS_PASSWORD, or add a CDB record for this device.",
1465
1483
  ),
1466
- ];
1484
+ );
1485
+ }
1486
+ // A bare menu path runs — the write gate no longer stops it — but it
1487
+ // answers nothing: consoles navigate silently and the APIs refuse it with
1488
+ // `no such command`. Name the read that does answer. `canonical` is
1489
+ // always resolved in production; the guard keeps hermetic stubs total.
1490
+ if (
1491
+ resolved.canonical !== undefined &&
1492
+ isBareMenuPathCommand(resolved.canonical)
1493
+ ) {
1494
+ const menu = `${resolved.canonical.path}/${resolved.canonical.verb}`;
1495
+ tips.push(
1496
+ buildTip(
1497
+ "tip/execute-bare-menu-path",
1498
+ `"${menu}" is a menu path, not a command: consoles navigate to it (no output) and the APIs refuse it with "no such command".`,
1499
+ `Append a verb such as "${menu}/print", or read rows with "centrs retrieve <router> ${menu}".`,
1500
+ ),
1501
+ );
1467
1502
  }
1468
- return [];
1503
+ return tips;
1469
1504
  }
1470
1505
 
1471
1506
  function dataFromResult(result: ProtocolExecuteResult): unknown {