@tikoci/centrs 0.1.2 → 0.1.3

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 (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/GLOSSARY.txt +71 -0
  3. package/README.md +24 -8
  4. package/SECURITY.md +3 -3
  5. package/commands/api/README.md +27 -9
  6. package/commands/api/examples.md +109 -6
  7. package/commands/check/README.md +371 -53
  8. package/commands/check/examples.md +390 -0
  9. package/commands/devices/README.md +177 -37
  10. package/commands/devices/examples.md +148 -0
  11. package/commands/execute/README.md +39 -4
  12. package/commands/execute/examples.md +90 -6
  13. package/commands/retrieve/README.md +36 -50
  14. package/commands/retrieve/examples.md +40 -9
  15. package/commands/settings/README.md +286 -22
  16. package/commands/settings/examples.md +408 -0
  17. package/commands/terminal/README.md +6 -0
  18. package/commands/transfer/README.md +32 -2
  19. package/commands/transfer/examples.md +66 -0
  20. package/docs/CONSTITUTION.md +36 -11
  21. package/docs/MATRIX.md +68 -172
  22. package/docs/errors/README.md +2 -1
  23. package/docs/errors/cdb/empty-selection.md +19 -0
  24. package/docs/errors/input/incomplete-gps.md +9 -0
  25. package/docs/errors/input/invalid-altitude.md +9 -0
  26. package/docs/errors/input/invalid-bbox.md +9 -0
  27. package/docs/errors/input/invalid-coordinate.md +9 -0
  28. package/docs/errors/input/invalid-radius.md +9 -0
  29. package/docs/errors/internal/settings-failed.md +9 -0
  30. package/docs/errors/settings/consequential-value.md +26 -0
  31. package/docs/errors/settings/invalid-boolean.md +21 -3
  32. package/docs/errors/settings/invalid-format.md +18 -3
  33. package/docs/errors/settings/invalid-integer.md +18 -3
  34. package/docs/errors/settings/invalid-timeout.md +20 -3
  35. package/docs/errors/settings/invalid-via.md +22 -3
  36. package/docs/errors/settings/reserved-key.md +29 -0
  37. package/docs/errors/settings/skip-env-file-active.md +25 -0
  38. package/docs/errors/settings/unknown-key.md +25 -0
  39. package/docs/errors/usage/fanout-not-supported.md +16 -0
  40. package/package.json +6 -6
  41. package/src/api-fanout.ts +454 -0
  42. package/src/api.ts +91 -19
  43. package/src/cli/api.ts +135 -11
  44. package/src/cli/btest.ts +20 -2
  45. package/src/cli/common.ts +11 -0
  46. package/src/cli/devices.ts +235 -6
  47. package/src/cli/discover.ts +3 -1
  48. package/src/cli/execute.ts +107 -6
  49. package/src/cli/mcp.ts +8 -1
  50. package/src/cli/retrieve.ts +135 -90
  51. package/src/cli/selection.ts +200 -0
  52. package/src/cli/settings.ts +286 -0
  53. package/src/cli/terminal.ts +22 -7
  54. package/src/cli/transfer.ts +171 -47
  55. package/src/cli.ts +7 -0
  56. package/src/core/error-catalog.ts +55 -0
  57. package/src/core/fanout.ts +143 -2
  58. package/src/devices.ts +212 -20
  59. package/src/discover.ts +7 -2
  60. package/src/execute-fanout.ts +438 -0
  61. package/src/execute.ts +120 -23
  62. package/src/index.ts +105 -1
  63. package/src/mcp/config.ts +12 -2
  64. package/src/mcp/tools.ts +137 -2
  65. package/src/protocols/adapter.ts +38 -18
  66. package/src/protocols/index.ts +11 -5
  67. package/src/resolver/cdb.ts +10 -2
  68. package/src/resolver/comment-kv.ts +58 -4
  69. package/src/resolver/config-file.ts +136 -0
  70. package/src/resolver/facts.ts +78 -0
  71. package/src/resolver/geo.ts +453 -0
  72. package/src/resolver/index.ts +46 -0
  73. package/src/resolver/mac.ts +12 -3
  74. package/src/resolver/selection.ts +304 -0
  75. package/src/resolver/settings.ts +54 -5
  76. package/src/resolver/target.ts +20 -2
  77. package/src/retrieve-fanout.ts +225 -133
  78. package/src/retrieve.ts +39 -7
  79. package/src/settings.ts +1078 -0
  80. package/src/terminal.ts +11 -1
  81. package/src/transfer-fanout.ts +527 -0
  82. package/src/transfer.ts +61 -11
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  apiListen,
18
18
  apiMethods,
19
19
  apiOutputFormats,
20
+ apiRequestSummaryFromRequest,
20
21
  buildApiBody,
21
22
  buildApiErrorEnvelope,
22
23
  buildApiErrorEnvelopeFromResolved,
@@ -26,6 +27,7 @@ export {
26
27
  mapMethodToVerb,
27
28
  type NormalizedApiEndpoint,
28
29
  normalizeApiEndpoint,
30
+ parseApiMethod,
29
31
  type ResolvedApiRequest,
30
32
  renderApiEnvelope,
31
33
  renderApiStreamLine,
@@ -33,6 +35,18 @@ export {
33
35
  runResolvedApi,
34
36
  validateApiRequestShape,
35
37
  } from "./api.ts";
38
+ export {
39
+ type ApiFanoutData,
40
+ type ApiFanoutEnvelope,
41
+ type ApiFanoutErrorEnvelope,
42
+ type ApiFanoutInternals,
43
+ type ApiFanoutOperationMeta,
44
+ type ApiFanoutOptions,
45
+ type ApiSelectionSummary,
46
+ apiFanout,
47
+ buildApiFanoutErrorEnvelope,
48
+ renderApiFanoutEnvelope,
49
+ } from "./api-fanout.ts";
36
50
  export {
37
51
  BTEST_CLIENT_CSV_HEADER,
38
52
  BTEST_SERVER_CSV_HEADER,
@@ -75,13 +89,18 @@ export {
75
89
  type Warning,
76
90
  } from "./core/envelope.ts";
77
91
  export {
92
+ buildFanoutEnvelope,
93
+ buildFanoutResolveFailure,
94
+ commonVia,
78
95
  FANOUT_BACKOFF_BASE_MS,
79
96
  FANOUT_CONCURRENCY_DEFAULTS,
80
97
  FANOUT_MAX_RETRIES,
81
98
  FANOUT_RETRYABLE_CODES,
99
+ fanoutExitCode,
82
100
  isRetryableFanoutError,
83
101
  resolveFanoutConcurrency,
84
102
  runBoundedPool,
103
+ runFanout,
85
104
  runWithRetry,
86
105
  summarizeFanout,
87
106
  } from "./core/fanout.ts";
@@ -173,6 +192,7 @@ export {
173
192
  type DevicesShowEnvelopeData,
174
193
  type DevicesShowItem,
175
194
  type DevicesWarning,
195
+ type DevicesWhereClause,
176
196
  defaultCdbPath,
177
197
  devicesOutputFormats,
178
198
  editInteractiveOnlyError,
@@ -228,6 +248,7 @@ export {
228
248
  canonicalizeExecuteCommand,
229
249
  type ExecuteEnvelope,
230
250
  type ExecuteErrorEnvelope,
251
+ type ExecuteGlobalContext,
231
252
  type ExecuteOperationMeta,
232
253
  type ExecuteOutputFormat,
233
254
  type ExecuteRequest,
@@ -238,10 +259,23 @@ export {
238
259
  executeOutputFormats,
239
260
  isWriteShaped,
240
261
  renderExecuteEnvelope,
262
+ resolveExecuteGlobalContext,
241
263
  resolveExecuteRequest,
242
264
  runResolvedExecute,
243
265
  validateExecuteRequestShape,
244
266
  } from "./execute.ts";
267
+ export {
268
+ buildExecuteFanoutErrorEnvelope,
269
+ type ExecuteFanoutData,
270
+ type ExecuteFanoutEnvelope,
271
+ type ExecuteFanoutErrorEnvelope,
272
+ type ExecuteFanoutInternals,
273
+ type ExecuteFanoutOperationMeta,
274
+ type ExecuteFanoutOptions,
275
+ type ExecuteSelectionSummary,
276
+ executeFanout,
277
+ renderExecuteFanoutEnvelope,
278
+ } from "./execute-fanout.ts";
245
279
  export {
246
280
  type ApiVerb,
247
281
  createProtocolAdapter,
@@ -262,27 +296,48 @@ export {
262
296
  type RouterOsProtocol,
263
297
  } from "./protocols/index.ts";
264
298
  export {
299
+ type AltitudeType,
265
300
  applyCommentKv,
301
+ type BboxPredicate,
266
302
  type CdbGroupExpansion,
267
303
  type CdbGroupResolveInput,
268
304
  type CdbGroupTarget,
269
305
  type CdbResolution,
270
306
  type CdbResolveInput,
307
+ type CdbSelectionExpansion,
308
+ type CdbSelectionMember,
309
+ type CdbSelectionResolveInput,
310
+ type CommentKvGeoKey,
271
311
  type CommentKvKey,
272
312
  type CommentKvLayer,
273
313
  type CommentKvOverrides,
274
314
  type CommentKvResult,
275
315
  type CommentKvUpdate,
276
316
  type CommentKvWarning,
317
+ canonicalizeGeoKey,
277
318
  coerceCommentKv,
278
319
  commentKvAllowlist,
320
+ commentKvGeoKeys,
279
321
  commentKvReservedKeys,
322
+ type DeviceLocation,
280
323
  defaultPortForScheme,
324
+ deviceLocation,
281
325
  expandCdbGroup,
326
+ expandCdbSelection,
327
+ isDefaultRecordTarget,
328
+ type NearPredicate,
329
+ type ParsedGpsTuple,
330
+ parseAltitude,
331
+ parseAltitudeType,
332
+ parseBbox,
282
333
  parseBoolean,
283
334
  parseCommentKv,
284
335
  parseDuration,
336
+ parseGpsTuple,
285
337
  parseHostCandidate,
338
+ parseLatLon,
339
+ parseNear,
340
+ parseRawCommentFacts,
286
341
  type ResolvedAuth,
287
342
  type ResolvedSetting,
288
343
  type ResolvedTarget,
@@ -296,7 +351,9 @@ export {
296
351
  resolveOptionalIntegerSetting,
297
352
  resolveStringSetting,
298
353
  resolveTarget,
354
+ type SelectionWhereClause,
299
355
  type TargetResolveInput,
356
+ type TargetSelection,
300
357
  toCoreSource,
301
358
  } from "./resolver/index.ts";
302
359
  export {
@@ -326,11 +383,44 @@ export {
326
383
  type RetrieveFanoutData,
327
384
  type RetrieveFanoutEnvelope,
328
385
  type RetrieveFanoutErrorEnvelope,
386
+ type RetrieveFanoutInternals,
329
387
  type RetrieveFanoutOperationMeta,
330
- type RetrieveGroupInternals,
388
+ type RetrieveFanoutOptions,
389
+ type RetrieveSelectionSummary,
331
390
  renderRetrieveFanoutEnvelope,
391
+ retrieveFanout,
332
392
  retrieveGroup,
333
393
  } from "./retrieve-fanout.ts";
394
+ export {
395
+ buildSettingsErrorEnvelope,
396
+ renderSettingsEnvelope,
397
+ type SettingsCommand,
398
+ type SettingsDefaultDeviceSummary,
399
+ type SettingsEnvelope,
400
+ type SettingsErrorEnvelope,
401
+ type SettingsGetArgs,
402
+ type SettingsGetData,
403
+ type SettingsKeyDef,
404
+ type SettingsKeyEntry,
405
+ type SettingsOperationMeta,
406
+ type SettingsOutputFormat,
407
+ type SettingsPrintArgs,
408
+ type SettingsPrintData,
409
+ type SettingsResetArgs,
410
+ type SettingsResetData,
411
+ type SettingsSetArgs,
412
+ type SettingsSetData,
413
+ type SettingsSuccessEnvelope,
414
+ type SettingsUnrecognizedEntry,
415
+ type SettingsValue,
416
+ settingsGet,
417
+ settingsManagedKeys,
418
+ settingsOutputFormats,
419
+ settingsPrint,
420
+ settingsRefusedKeys,
421
+ settingsReset,
422
+ settingsSet,
423
+ } from "./settings.ts";
334
424
  export {
335
425
  buildSshTerminalArgv,
336
426
  buildTerminalErrorEnvelope,
@@ -343,6 +433,7 @@ export {
343
433
  export {
344
434
  buildTransferErrorEnvelope,
345
435
  FILE_READ_CHUNK_BYTES,
436
+ isTransferWriteVerb,
346
437
  normalizeRemotePath,
347
438
  REST_FILE_WRITE_CAP_BYTES,
348
439
  renderTransferEnvelope,
@@ -365,6 +456,19 @@ export {
365
456
  transferVerbs,
366
457
  validateTransferRequestShape,
367
458
  } from "./transfer.ts";
459
+ export {
460
+ buildTransferFanoutErrorEnvelope,
461
+ renderTransferFanoutEnvelope,
462
+ type TransferFanoutData,
463
+ type TransferFanoutEnvelope,
464
+ type TransferFanoutErrorEnvelope,
465
+ type TransferFanoutInternals,
466
+ type TransferFanoutOperationMeta,
467
+ type TransferFanoutOptions,
468
+ type TransferFanoutRequestSummary,
469
+ type TransferSelectionSummary,
470
+ transferFanout,
471
+ } from "./transfer-fanout.ts";
368
472
 
369
473
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
370
474
 
package/src/mcp/config.ts CHANGED
@@ -46,16 +46,26 @@ export interface ResolveMcpConfigArgs {
46
46
  cdbPassword?: string;
47
47
  allowAdhocTargets?: boolean;
48
48
  env?: Record<string, string | undefined>;
49
+ /**
50
+ * The `centrs.env` config tier (`src/resolver/config-file.ts`), loaded by
51
+ * the caller — `resolveMcpConfig` itself stays synchronous so it can keep
52
+ * serving as a default-parameter value (`server.ts`). `CENTRS_CDB_PASSWORD`
53
+ * deliberately never reads it, same as `resolveAuth`'s credential fields.
54
+ */
55
+ config?: Record<string, string | undefined>;
49
56
  }
50
57
 
51
58
  export function resolveMcpConfig(
52
59
  args: ResolveMcpConfigArgs = {},
53
60
  ): CentrsMcpConfig {
54
61
  const env = args.env ?? Bun.env;
55
- const explicitPath = args.cdbFile ?? env[ENV_CDB_FILE] ?? undefined;
62
+ const config = args.config ?? {};
63
+ const explicitPath =
64
+ args.cdbFile ?? env[ENV_CDB_FILE] ?? config[ENV_CDB_FILE] ?? undefined;
56
65
  const cdbFile = explicitPath ?? defaultCdbPath(env);
57
66
  const allowAdhocTargets =
58
- args.allowAdhocTargets ?? env[ENV_ALLOW_ADHOC] === "1";
67
+ args.allowAdhocTargets ??
68
+ (env[ENV_ALLOW_ADHOC] ?? config[ENV_ALLOW_ADHOC]) === "1";
59
69
  return {
60
70
  cdbFile,
61
71
  cdbFileExplicit: explicitPath !== undefined,
package/src/mcp/tools.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  isWriteShaped,
34
34
  validateExecuteEnvelope,
35
35
  } from "../execute.ts";
36
+ import type { CommentKvUpdate } from "../resolver/comment-kv.ts";
37
+ import { parseBbox, parseNear } from "../resolver/geo.ts";
36
38
  import { parseDuration } from "../resolver/settings.ts";
37
39
  import { buildRetrieveErrorEnvelope, retrieve } from "../retrieve.ts";
38
40
  import {
@@ -416,6 +418,24 @@ export const devicesInputShape = {
416
418
  .describe(
417
419
  "Group filter for op=list; first-class group field for op=add/set.",
418
420
  ),
421
+ where: z
422
+ .array(z.string())
423
+ .optional()
424
+ .describe(
425
+ "op=list device-class filters `attr=value` (repeatable, AND-combined; e.g. board=RB5009 or lat=37.7749).",
426
+ ),
427
+ near: z
428
+ .string()
429
+ .optional()
430
+ .describe(
431
+ "op=list geo filter `<lat>,<lon>,<radius>` (lat-first; radius m/km/mi/ft, bare = km).",
432
+ ),
433
+ bbox: z
434
+ .string()
435
+ .optional()
436
+ .describe(
437
+ "op=list geo filter `<south>,<west>,<north>,<east>` (lat-first bounding box).",
438
+ ),
419
439
  user: z.string().optional().describe("CDB username for op=add/set."),
420
440
  password: z
421
441
  .string()
@@ -454,6 +474,30 @@ export const devicesInputShape = {
454
474
  .boolean()
455
475
  .optional()
456
476
  .describe("For op=set, reject comment keys outside the allowlist."),
477
+ lat: z
478
+ .number()
479
+ .optional()
480
+ .describe(
481
+ "Latitude in decimal degrees (-90..90) for op=add/set; a comment-kv fact, paired with lon (issue #146).",
482
+ ),
483
+ lon: z
484
+ .number()
485
+ .optional()
486
+ .describe(
487
+ "Longitude in decimal degrees (-180..180) for op=add/set; a comment-kv fact, paired with lat.",
488
+ ),
489
+ altitude: z
490
+ .number()
491
+ .optional()
492
+ .describe(
493
+ "Altitude in meters for op=add/set (may be negative); a comment-kv fact.",
494
+ ),
495
+ altitudeType: z
496
+ .enum(["MSL", "AGL"])
497
+ .optional()
498
+ .describe(
499
+ "Vertical datum for altitude, op=add/set (default MSL when altitude is set).",
500
+ ),
457
501
  confirm: z
458
502
  .boolean()
459
503
  .optional()
@@ -478,6 +522,56 @@ function requireDevicesTarget(
478
522
  });
479
523
  }
480
524
 
525
+ /**
526
+ * Lower the typed `lat`/`lon`/`altitude`/`altitudeType` MCP args into comment-kv
527
+ * updates — the same core surface the CLI's `--lat`/`--lon`/`--gps` flags lower
528
+ * into (`src/cli/devices.ts`). Light touch: no alias handling here (the MCP
529
+ * schema only exposes the canonical field names), range/enum/pairing
530
+ * validation happens once in `validateCommentKvUpdates` (`src/devices.ts`).
531
+ */
532
+ function buildGeoUpdates(args: DevicesArgs): CommentKvUpdate[] {
533
+ const updates: CommentKvUpdate[] = [];
534
+ if (args.lat !== undefined) {
535
+ updates.push({ key: "lat", value: String(args.lat) });
536
+ }
537
+ if (args.lon !== undefined) {
538
+ updates.push({ key: "lon", value: String(args.lon) });
539
+ }
540
+ if (args.altitude !== undefined) {
541
+ updates.push({ key: "altitude", value: String(args.altitude) });
542
+ }
543
+ if (args.altitudeType !== undefined) {
544
+ updates.push({ key: "altitude-type", value: args.altitudeType });
545
+ }
546
+ return updates;
547
+ }
548
+
549
+ /**
550
+ * Parse the MCP `where` array (`["attr=value", …]`) into device-class clauses
551
+ * for op=list. Geo-alias canonicalization happens centrally in `matchesWhere`
552
+ * (`src/resolver/facts.ts`), so this only splits on the first `=`.
553
+ */
554
+ function parseMcpWhereClauses(
555
+ where: readonly string[] | undefined,
556
+ ): { key: string; value: string }[] | undefined {
557
+ if (where === undefined) {
558
+ return undefined;
559
+ }
560
+ return where.map((clause) => {
561
+ const eq = clause.indexOf("=");
562
+ if (eq <= 0) {
563
+ throw new CentrsError({
564
+ code: "input/invalid-command",
565
+ summary: `where clause must be \`attr=value\`; received: ${clause}`,
566
+ remediation:
567
+ "Pass device-class filters like `board=RB5009` or `lat=37.7749`.",
568
+ context: { where: clause },
569
+ });
570
+ }
571
+ return { key: clause.slice(0, eq), value: clause.slice(eq + 1) };
572
+ });
573
+ }
574
+
481
575
  function resolveRecordType(recordType: string | undefined): number | undefined {
482
576
  if (recordType === undefined) {
483
577
  return undefined;
@@ -502,9 +596,49 @@ export async function handleDevices(
502
596
  ): Promise<McpEnvelope> {
503
597
  try {
504
598
  const cdb = await loadAllowlistCdb(config);
599
+ // GPS *setter* fields write via add/set only; the *query* selectors
600
+ // (where/near/bbox) select via list only. Reject the wrong-op combination
601
+ // instead of silently dropping the caller's input (mirrors the CLI's
602
+ // per-subcommand flag guard in src/cli/devices.ts).
603
+ if (
604
+ args.op !== "add" &&
605
+ args.op !== "set" &&
606
+ (args.lat !== undefined ||
607
+ args.lon !== undefined ||
608
+ args.altitude !== undefined ||
609
+ args.altitudeType !== undefined)
610
+ ) {
611
+ throw new CentrsError({
612
+ code: "input/invalid-command",
613
+ summary: `GPS fields (lat/lon/altitude/altitudeType) are not valid for op="${args.op}".`,
614
+ remediation:
615
+ "Store GPS with op=add or op=set; drop the GPS fields for list/show/groups/edit/remove.",
616
+ context: { op: args.op },
617
+ });
618
+ }
619
+ if (
620
+ args.op !== "list" &&
621
+ (args.where !== undefined ||
622
+ args.near !== undefined ||
623
+ args.bbox !== undefined)
624
+ ) {
625
+ throw new CentrsError({
626
+ code: "input/invalid-command",
627
+ summary: `Query selectors (where/near/bbox) are only valid for op="list", not op="${args.op}".`,
628
+ remediation:
629
+ "Use op=list with where/near/bbox to query the registry; for op=show name a target directly.",
630
+ context: { op: args.op },
631
+ });
632
+ }
505
633
  switch (args.op) {
506
634
  case "list":
507
- return listDevices({ cdb, group: args.group });
635
+ return listDevices({
636
+ cdb,
637
+ group: args.group,
638
+ where: parseMcpWhereClauses(args.where),
639
+ near: args.near !== undefined ? parseNear(args.near) : undefined,
640
+ bbox: args.bbox !== undefined ? parseBbox(args.bbox) : undefined,
641
+ });
508
642
  case "groups":
509
643
  return listGroups({ cdb, withMembers: true });
510
644
  case "show": {
@@ -527,6 +661,7 @@ export async function handleDevices(
527
661
  profile: args.profile,
528
662
  session: args.session,
529
663
  comment: args.comment,
664
+ commentKvUpdates: buildGeoUpdates(args),
530
665
  savedPassword: args.savedPassword,
531
666
  force: args.force,
532
667
  }),
@@ -542,7 +677,7 @@ export async function handleDevices(
542
677
  await setDevice({
543
678
  cdb,
544
679
  target,
545
- updates: args.updates,
680
+ updates: [...buildGeoUpdates(args), ...(args.updates ?? [])],
546
681
  user: args.user,
547
682
  password: args.password,
548
683
  group: args.group,
@@ -610,23 +610,39 @@ class NativeApiAdapter implements ProtocolAdapter {
610
610
  async execute(
611
611
  request: ProtocolExecuteRequest,
612
612
  ): Promise<ProtocolExecuteResult> {
613
- const command: NativeApiCommand = request.script
614
- ? { command: "/execute", attributes: { script: request.script } }
615
- : { command: `${request.path.replace(/\/$/, "")}/${request.command}` };
616
- if (
617
- !request.script &&
618
- request.attributes &&
619
- Object.keys(request.attributes).length > 0
620
- ) {
613
+ if (request.script !== undefined) {
614
+ return normalizeNativeExecute(await this.executeScript(request.script));
615
+ }
616
+ const command: NativeApiCommand = {
617
+ command: `${request.path.replace(/\/$/, "")}/${request.command}`,
618
+ };
619
+ if (request.attributes && Object.keys(request.attributes).length > 0) {
621
620
  command.attributes = request.attributes;
622
621
  }
623
- if (!request.script && request.queries && request.queries.length > 0) {
622
+ if (request.queries && request.queries.length > 0) {
624
623
  command.queries = request.queries;
625
624
  }
626
625
  const replies = await this.talk(command);
627
626
  return { records: repliesToRecords(replies) };
628
627
  }
629
628
 
629
+ /**
630
+ * Run a script through native `/execute` with `as-string`, the sole shared
631
+ * path for both the CLI-shaped `execute` command and the `api` run+script
632
+ * verb: without `as-string` the native API schedules a background job and
633
+ * returns only its id rather than the captured output (CHR-grounded —
634
+ * mirrors REST `/rest/execute`).
635
+ */
636
+ private async executeScript(
637
+ script: string,
638
+ ): Promise<Record<string, string>[]> {
639
+ const replies = await this.talk({
640
+ command: "/execute",
641
+ attributes: { script, "as-string": "" },
642
+ });
643
+ return repliesToRecords(replies);
644
+ }
645
+
630
646
  async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
631
647
  const base = request.path.replace(/\/$/, "");
632
648
  switch (request.verb) {
@@ -682,15 +698,7 @@ class NativeApiAdapter implements ProtocolAdapter {
682
698
  }
683
699
  case "run": {
684
700
  if (request.script !== undefined) {
685
- // `as-string` makes `/execute` run synchronously and return the captured
686
- // output; without it the native API schedules a job and returns its id
687
- // (CHR-grounded — mirrors REST `/rest/execute`).
688
- const records = repliesToRecords(
689
- await this.talk({
690
- command: "/execute",
691
- attributes: { script: request.script, "as-string": "" },
692
- }),
693
- );
701
+ const records = await this.executeScript(request.script);
694
702
  return { data: restStyleRunData(records) };
695
703
  }
696
704
  const command: NativeApiCommand = { command: base };
@@ -1089,6 +1097,18 @@ function exhaustiveApiVerb(verb: never): never {
1089
1097
  throw new Error(`Unhandled api verb: ${String(verb)}`);
1090
1098
  }
1091
1099
 
1100
+ /** Re-map an `as-string` native `/execute` reply into `ProtocolExecuteResult`,
1101
+ * mirroring `normalizeRestExecute`'s `ret`-vs-records split. */
1102
+ function normalizeNativeExecute(
1103
+ records: readonly Record<string, string>[],
1104
+ ): ProtocolExecuteResult {
1105
+ const ret = records.length === 1 ? records[0]?.["ret"] : undefined;
1106
+ if (typeof ret === "string") {
1107
+ return { records: [], ret };
1108
+ }
1109
+ return { records: [...records] };
1110
+ }
1111
+
1092
1112
  function normalizeRestExecute(data: unknown): ProtocolExecuteResult {
1093
1113
  if (Array.isArray(data)) {
1094
1114
  return { records: data.filter(isPlainObject) as Record<string, string>[] };
@@ -7,6 +7,11 @@
7
7
  * Status per protocol is the implementation reality (`implemented` flag).
8
8
  * Per-cell status across commands is in `docs/MATRIX.md`. Selection rules
9
9
  * are in `docs/CONSTITUTION.md` (protocol selection).
10
+ *
11
+ * Contract with MATRIX (asserted by `test/unit/protocol-registry.test.ts`):
12
+ * every `CHR-passed` cell in the `docs/MATRIX.md` grid implies its protocol
13
+ * here is `implemented: true` and lists the matching capability. MATRIX stays
14
+ * the only status surface; this registry must never under-claim against it.
10
15
  */
11
16
 
12
17
  export type ProtocolCapability =
@@ -35,7 +40,7 @@ export const protocolPlans = [
35
40
  },
36
41
  {
37
42
  id: "native-api",
38
- capabilities: ["retrieve", "execute", "proxy"],
43
+ capabilities: ["retrieve", "execute", "transfer", "proxy"],
39
44
  notes:
40
45
  "Binary API (TCP 8728 / TLS 8729). Transport base implemented in native-api.ts (word/sentence codec, login, tagged talk); command wiring tracked in docs/MATRIX.md.",
41
46
  implemented: true,
@@ -44,8 +49,8 @@ export const protocolPlans = [
44
49
  id: "ssh",
45
50
  capabilities: ["execute", "terminal", "transfer"],
46
51
  notes:
47
- "Reuses host ssh client. Important execute/terminal path after grounding.",
48
- implemented: false,
52
+ "Reuses host ssh client. execute/terminal/transfer all CHR-passed (docs/MATRIX.md); facts in ssh.ts, sftp.ts, terminal.ts.",
53
+ implemented: true,
49
54
  },
50
55
  {
51
56
  id: "snmp",
@@ -56,8 +61,9 @@ export const protocolPlans = [
56
61
  {
57
62
  id: "mndp",
58
63
  capabilities: ["discover"],
59
- notes: "Passive hint source only; not authoritative inventory.",
60
- implemented: false,
64
+ notes:
65
+ "Passive hint source only; not authoritative inventory. discover CHR-passed (docs/MATRIX.md); facts in src/data/mndp.ts.",
66
+ implemented: true,
61
67
  },
62
68
  {
63
69
  id: "mac-telnet",
@@ -153,6 +153,7 @@ function warningFromDevices(warning: DevicesWarning): ResolverWarning {
153
153
  export async function resolveCdb(
154
154
  input: CdbResolveInput,
155
155
  env: Record<string, string | undefined>,
156
+ config: Record<string, string | undefined> = {},
156
157
  ): Promise<CdbResolution | undefined> {
157
158
  if (!input.targetInput) {
158
159
  return undefined;
@@ -162,12 +163,18 @@ export async function resolveCdb(
162
163
  cdbFile: input.cdbFile,
163
164
  cdbPassword: input.cdbPassword,
164
165
  env,
166
+ config,
165
167
  });
168
+ // A user-configured `centrs.env` default CDB path counts as explicit, same
169
+ // as an env var: it is an intentional choice, not the built-in fallback,
170
+ // so a missing file there should surface as a real error rather than
171
+ // silently degrading to "no CDB in play".
166
172
  const explicitCdb =
167
173
  input.cdbFile !== undefined ||
168
174
  input.cdbPassword !== undefined ||
169
175
  env[ENV_CDB_FILE] !== undefined ||
170
- env[ENV_CDB_PASSWORD] !== undefined;
176
+ env[ENV_CDB_PASSWORD] !== undefined ||
177
+ config[ENV_CDB_FILE] !== undefined;
171
178
  if (!explicitCdb && !(await Bun.file(settings.cdbFile.value).exists())) {
172
179
  return undefined;
173
180
  }
@@ -178,6 +185,7 @@ export async function resolveCdb(
178
185
  cdbFile: input.cdbFile,
179
186
  cdbPassword: input.cdbPassword,
180
187
  env,
188
+ config,
181
189
  });
182
190
  } catch (error) {
183
191
  if (
@@ -312,7 +320,7 @@ export async function expandCdbGroup(
312
320
  return { targets, warnings, empty: false };
313
321
  }
314
322
 
315
- function resolutionFromEntry(
323
+ export function resolutionFromEntry(
316
324
  entry: WinBoxCdbEntry,
317
325
  recordIndex: number,
318
326
  ): CdbResolution {