@tikoci/centrs 0.1.8 → 0.1.9-next.22

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 (80) hide show
  1. package/CHANGELOG.md +216 -31
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +16 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +44 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +218 -7
  8. package/commands/execute/examples.md +278 -1
  9. package/commands/explain/README.md +2 -1
  10. package/commands/mcp/README.md +11 -0
  11. package/commands/mcp/examples.md +6 -0
  12. package/commands/retrieve/README.md +32 -14
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +10 -8
  16. package/docs/CONSTITUTION.md +54 -3
  17. package/docs/MATRIX.md +20 -3
  18. package/docs/errors/cdb/empty-group.md +19 -3
  19. package/docs/errors/cdb/empty-selection.md +9 -3
  20. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  21. package/docs/errors/cmr/cancelled.md +15 -0
  22. package/docs/errors/cmr/client-disconnected.md +19 -0
  23. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  24. package/docs/errors/cmr/console-only.md +29 -0
  25. package/docs/errors/cmr/empty-selection.md +33 -0
  26. package/docs/errors/cmr/membership-changed.md +20 -0
  27. package/docs/errors/cmr/not-a-controller.md +26 -0
  28. package/docs/errors/cmr/outcome-unknown.md +29 -0
  29. package/docs/errors/cmr/output-truncated.md +16 -0
  30. package/docs/errors/cmr/policy-denied.md +31 -0
  31. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  32. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  33. package/docs/errors/cmr/selection-incomplete.md +18 -0
  34. package/docs/errors/cmr/validation-incomplete.md +23 -0
  35. package/docs/errors/routeros/version-unsupported.md +4 -0
  36. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  37. package/docs/errors/usage/flag-after-separator.md +34 -0
  38. package/docs/errors/usage/missing-labels.md +19 -0
  39. package/package.json +7 -5
  40. package/src/api-fanout.ts +18 -2
  41. package/src/api.ts +230 -71
  42. package/src/btest.ts +7 -2
  43. package/src/cli/api.ts +30 -16
  44. package/src/cli/common.ts +180 -1
  45. package/src/cli/devices.ts +0 -2
  46. package/src/cli/discover.ts +0 -1
  47. package/src/cli/execute.ts +176 -21
  48. package/src/cli/explain.ts +2 -0
  49. package/src/cli/mcp.ts +0 -1
  50. package/src/cli/retrieve.ts +40 -18
  51. package/src/cli/settings.ts +0 -3
  52. package/src/cli/terminal.ts +0 -2
  53. package/src/cli/transfer.ts +10 -8
  54. package/src/cli/version.ts +87 -0
  55. package/src/cli.ts +5 -3
  56. package/src/core/cmr-fleet.ts +486 -0
  57. package/src/core/cmr.ts +182 -0
  58. package/src/core/envelope.ts +46 -1
  59. package/src/core/error-catalog.ts +95 -2
  60. package/src/core/execute-command.ts +144 -19
  61. package/src/core/fanout.ts +207 -14
  62. package/src/errors.ts +3 -1
  63. package/src/execute-cmr.ts +1442 -0
  64. package/src/execute-fanout.ts +15 -1
  65. package/src/execute.ts +173 -40
  66. package/src/index.ts +35 -1
  67. package/src/mcp/safety.ts +11 -0
  68. package/src/mcp/tools.ts +4 -1
  69. package/src/protocols/adapter.ts +54 -12
  70. package/src/resolver/cdb.ts +13 -1
  71. package/src/resolver/index.ts +4 -0
  72. package/src/resolver/selection.ts +37 -1
  73. package/src/resolver/target.ts +96 -8
  74. package/src/retrieve-fanout.ts +4 -1
  75. package/src/retrieve-follow.ts +29 -33
  76. package/src/retrieve-sample.ts +14 -8
  77. package/src/retrieve-stream.ts +38 -6
  78. package/src/retrieve.ts +73 -59
  79. package/src/transfer-fanout.ts +8 -1
  80. package/src/transfer.ts +15 -19
@@ -15,17 +15,20 @@ import {
15
15
  buildRetrieveFanoutErrorEnvelope,
16
16
  describeCentrs,
17
17
  fanoutExitCode,
18
+ isStreamDataLine,
18
19
  type ResolvedRetrieveRequest,
19
20
  type RetrieveOutputFormat,
20
21
  type RetrieveRequest,
21
22
  renderRetrieveEnvelope,
22
23
  renderRetrieveFanoutEnvelope,
23
24
  renderRetrieveStreamLine,
25
+ resolveRetrieveOutputFormat,
24
26
  retrieve,
25
27
  retrieveFanout,
26
28
  retrieveFollow,
27
29
  retrieveOutputFormats,
28
30
  retrieveSample,
31
+ retrieveVerboseLines,
29
32
  retrieveWait,
30
33
  type ValidationTrace,
31
34
  validateRetrieveRequestShape,
@@ -34,6 +37,8 @@ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
34
37
  import {
35
38
  type CliCommandMetadata,
36
39
  expectValue,
40
+ printTextHeader,
41
+ printTextNotes,
37
42
  renderCommandHelp,
38
43
  unknownFlagError,
39
44
  } from "./common.ts";
@@ -125,13 +130,13 @@ export const retrieveCommand: CliCommandMetadata = {
125
130
  {
126
131
  flag: "--follow",
127
132
  description:
128
- "Keep the menu's rows current over native-api `listen`: snapshot frames, a `synced` line, then a line per change, then a summary (NDJSON for json/yaml/ndjson). Single target, list menus whose print takes follow-only.",
133
+ "Keep the menu's rows current over native-api `listen`: a `started` line, snapshot frames, a `synced` line, then a line per change, then a summary (NDJSON for json/yaml/ndjson). Single target, list menus whose print takes follow-only.",
129
134
  },
130
135
  {
131
136
  flag: "--sample",
132
137
  valueName: "<interval>",
133
138
  description:
134
- "Read the menu again every interval (with a unit, e.g. `5s`): one line per complete read, then a summary. With --wait, sets polling cadence and returns one final envelope. Sees counters and state listen never reports. Single target; rest-api or native-api.",
139
+ "Read the menu again every interval (with a unit, e.g. `5s`): a `started` line, one line per complete read, then a summary. With --wait, sets polling cadence and returns one final envelope. Sees counters and state listen never reports. Single target; rest-api or native-api.",
135
140
  },
136
141
  {
137
142
  flag: "--wait",
@@ -206,7 +211,7 @@ export const retrieveCommand: CliCommandMetadata = {
206
211
  },
207
212
  {
208
213
  flag: "--verbose",
209
- description: "Show resolved setting sources in text output.",
214
+ description: "Show resolved setting sources in text output, on stderr.",
210
215
  },
211
216
  ],
212
217
  };
@@ -283,6 +288,10 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
283
288
  request?: { format?: RetrieveOutputFormat };
284
289
  }
285
290
  )?.request?.format ?? "text";
291
+ const text = resolvedFormat === "text";
292
+ if (text && request.verbose) {
293
+ printTextHeader(retrieveVerboseLines(envelope));
294
+ }
286
295
  console.log(
287
296
  renderRetrieveEnvelope(
288
297
  envelope as Parameters<typeof renderRetrieveEnvelope>[0],
@@ -290,6 +299,7 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
290
299
  { verbose: request.verbose },
291
300
  ),
292
301
  );
302
+ if (text) printTextNotes(envelope);
293
303
  return 0;
294
304
  } catch (error) {
295
305
  const format = inferRequestedFormat(args, request);
@@ -313,14 +323,12 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
313
323
  tips,
314
324
  );
315
325
  console.error(
316
- // codeql[js/clear-text-logging] Twin of the established redacted-request dismissals: error builders carry only targetInput (host)+via (protocol); the raw password never reaches this render.
317
326
  renderRetrieveEnvelope(envelope, format, {
318
327
  verbose: request?.verbose ?? false,
319
328
  }),
320
329
  );
321
330
  } else {
322
331
  console.error(
323
- // codeql[js/clear-text-logging] Same false-positive pattern: password parsed from args never reaches error.message in the text-format catch path.
324
332
  formatCentrsErrorText(
325
333
  asCentrsError(error, {
326
334
  code: "input/invalid-command",
@@ -340,14 +348,21 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
340
348
 
341
349
  /**
342
350
  * Every `--follow`/`--sample` envelope goes to stdout, one per line, errors
343
- * included; any failure sets exit 1, even after successful lines. Ctrl-C asks
344
- * for a bounded stop that still ends with a summary.
351
+ * included; any failure sets exit 1, even after successful lines. In text,
352
+ * stdout is data (#470, #473): only `frame`/`sample` rows go there, and the
353
+ * status lines, tips, warnings and errors go to stderr. Ctrl-C asks for a
354
+ * bounded stop that still ends with a summary.
345
355
  */
346
356
  async function runRetrieveStreamCli(
347
357
  request: RetrieveCliArgs,
348
358
  args: readonly string[],
349
359
  ): Promise<number> {
350
- const format = inferRequestedFormat(args, request);
360
+ // Resolved once, up front: a config-file `CENTRS_FORMAT` is not in `args`,
361
+ // and follow frame lines carry no request (#462). An invalid format is
362
+ // reported by the stream's own error line, rendered as the args ask.
363
+ const format = await resolveRetrieveOutputFormat(request, Bun.env).catch(() =>
364
+ inferRequestedFormat(args, request),
365
+ );
351
366
  const controller = new AbortController();
352
367
  const onSigint = (): void => controller.abort();
353
368
  process.on("SIGINT", onSigint);
@@ -357,9 +372,14 @@ async function runRetrieveStreamCli(
357
372
  for await (const envelope of stream(request, Bun.env, {
358
373
  signal: controller.signal,
359
374
  })) {
360
- // A config-file `CENTRS_FORMAT` is known only after resolution.
361
- const resolvedFormat = envelope.meta.operation?.request.format ?? format;
362
- console.log(renderRetrieveStreamLine(envelope, resolvedFormat));
375
+ const line = renderRetrieveStreamLine(envelope, format);
376
+ if (format !== "text" || isStreamDataLine(envelope)) {
377
+ console.log(line);
378
+ } else {
379
+ // `started` renders its own tips; the summary repeats them.
380
+ if (line) console.error(line);
381
+ printTextNotes({ warnings: envelope.warnings, tips: [] });
382
+ }
363
383
  if (!envelope.ok) exitCode = 1;
364
384
  }
365
385
  } finally {
@@ -379,14 +399,17 @@ async function runRetrieveWaitCli(
379
399
  const envelope = await retrieveWait(request, Bun.env, {
380
400
  signal: controller.signal,
381
401
  });
402
+ const format =
403
+ envelope.meta.operation?.request.format ??
404
+ inferRequestedFormat(args, request);
405
+ const text = format === "text";
406
+ if (text && request.verbose) {
407
+ printTextHeader(retrieveVerboseLines(envelope));
408
+ }
382
409
  console.log(
383
- renderRetrieveEnvelope(
384
- envelope,
385
- envelope.meta.operation?.request.format ??
386
- inferRequestedFormat(args, request),
387
- { verbose: request.verbose },
388
- ),
410
+ renderRetrieveEnvelope(envelope, format, { verbose: request.verbose }),
389
411
  );
412
+ if (text) printTextNotes(envelope);
390
413
  return envelope.ok ? 0 : 1;
391
414
  } finally {
392
415
  process.off("SIGINT", onSigint);
@@ -423,7 +446,6 @@ async function runRetrieveFanoutCli(
423
446
  } catch (error) {
424
447
  const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
425
448
  console.error(
426
- // codeql[js/clear-text-logging] Twin of #80/#81/#83. No secret logged: error builders carry only targetInput (host)+via (protocol); serializeCentrsError holds code/summary/remediation/context.
427
449
  renderRetrieveFanoutEnvelope(envelope, format, {
428
450
  verbose: request.verbose ?? false,
429
451
  }),
@@ -244,7 +244,6 @@ export async function runSettingsCli(args: readonly string[]): Promise<number> {
244
244
  }
245
245
 
246
246
  const format = parsed.format ?? "text";
247
- // codeql[js/clear-text-logging] passwordSet is a has-password boolean, never the secret; the property-NAME heuristic (maybePassword) matches the field name, not its value type.
248
247
  console.log(renderSettingsEnvelope(envelope, format));
249
248
  return 0;
250
249
  } catch (error) {
@@ -263,10 +262,8 @@ export async function runSettingsCli(args: readonly string[]): Promise<number> {
263
262
  centrsError,
264
263
  );
265
264
  if (format === "json" || format === "yaml") {
266
- // codeql[js/clear-text-logging] sink renders only a literal error string (e.g. "Missing value for --password."), never the real value; CodeQL over-taints via shared expectValue/CentrsError code.
267
265
  console.error(renderSettingsEnvelope(envelope, format));
268
266
  } else {
269
- // codeql[js/clear-text-logging] sink renders only a literal error string (e.g. "Missing value for --password."), never the real value; CodeQL over-taints via shared expectValue/CentrsError code.
270
267
  console.error(formatCentrsErrorText(centrsError));
271
268
  }
272
269
  return 1;
@@ -188,12 +188,10 @@ export async function runTerminalCli(args: readonly string[]): Promise<number> {
188
188
  if (format === "json" || format === "yaml") {
189
189
  const envelope = withTips(buildTerminalErrorEnvelope(error), tips);
190
190
  console.error(
191
- // codeql[js/clear-text-logging] CLI error renderer false positive; password reaches neither the error nor the stderr renderers. Dismissed across all CLI commands.
192
191
  format === "yaml" ? toYaml(envelope) : JSON.stringify(envelope),
193
192
  );
194
193
  } else {
195
194
  console.error(
196
- // codeql[js/clear-text-logging] Password parsed from args never reaches error.message in the text-format catch path; the parsed request (which holds the raw password) is never rendered.
197
195
  formatCentrsErrorText(
198
196
  asCentrsError(error, {
199
197
  code: "input/invalid-command",
@@ -21,12 +21,15 @@ import {
21
21
  transferEnvelope,
22
22
  transferFanout,
23
23
  transferOutputFormats,
24
+ transferVerboseLines,
24
25
  transferVerbs,
25
26
  } from "../index.ts";
26
27
  import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
27
28
  import {
28
29
  type CliCommandMetadata,
29
30
  expectValue,
31
+ printTextHeader,
32
+ printTextNotes,
30
33
  renderCommandHelp,
31
34
  unknownFlagError,
32
35
  } from "./common.ts";
@@ -167,7 +170,7 @@ export const transferCommand: CliCommandMetadata = {
167
170
  },
168
171
  {
169
172
  flag: "--verbose",
170
- description: "Show the resolved transport in text output.",
173
+ description: "Show the resolved transport in text output, on stderr.",
171
174
  },
172
175
  ],
173
176
  };
@@ -206,11 +209,12 @@ export async function runTransferCli(
206
209
  const rendered = renderTransferEnvelope(envelope, format, {
207
210
  verbose: request.verbose,
208
211
  });
212
+ if (format === "text" && request.verbose) {
213
+ printTextHeader(transferVerboseLines(envelope));
214
+ }
209
215
  if (!envelope.ok) {
210
- console.error(
211
- // codeql[js/clear-text-logging] The envelope carries auth provenance only (`username`, `passwordProvided`, setting sources), never the password; see buildTransferErrorEnvelopeFromResolved.
212
- format === "text" ? rendered + formatTipsText(envelope.tips) : rendered,
213
- );
216
+ console.error(rendered);
217
+ if (format === "text") printTextNotes(envelope);
214
218
  return 1;
215
219
  }
216
220
  // A download to stdout (`local === "-"`) has already streamed the file bytes
@@ -221,6 +225,7 @@ export async function runTransferCli(
221
225
  } else {
222
226
  console.log(rendered);
223
227
  }
228
+ if (format === "text") printTextNotes(envelope);
224
229
  return 0;
225
230
  } catch (error) {
226
231
  // Drop credential fields before any renderer touches the request: error
@@ -243,14 +248,12 @@ export async function runTransferCli(
243
248
  tips,
244
249
  );
245
250
  console.error(
246
- // codeql[js/clear-text-logging] The transfer error path redacts password/cdbPassword before any renderer (redactTransferRequest), and buildTransferErrorEnvelope only emits targetInput.
247
251
  renderTransferEnvelope(envelope, format, {
248
252
  verbose: safeRequest?.verbose ?? false,
249
253
  }),
250
254
  );
251
255
  } else {
252
256
  console.error(
253
- // codeql[js/clear-text-logging] Credentials are redacted from the request before rendering (redactTransferRequest) and never reach the error/stderr; same known false positive dismissed across all CLI commands.
254
257
  formatCentrsErrorText(
255
258
  asCentrsError(error, {
256
259
  code: "input/invalid-command",
@@ -297,7 +300,6 @@ async function runTransferFanoutCli(
297
300
  error,
298
301
  );
299
302
  console.error(
300
- // codeql[js/clear-text-logging] No secret logged: request is redactTransferRequest'd (password/cdbPassword stripped) before the builder; fan-out error meta is {target:{},via,settings:{}}.
301
303
  renderTransferFanoutEnvelope(envelope, format, {
302
304
  verbose: request.verbose ?? false,
303
305
  }),
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `centrs --version` / `-V` (#463): the package version, for scripts, agents and
3
+ * field reports.
4
+ *
5
+ * Text output is the bare version string (`0.1.9-next.0`), so a script can use
6
+ * it as is. `--json` / `--format json|yaml` return the standard envelope with
7
+ * `data.version`. Format comes from flags only, never `CENTRS_FORMAT`, so a
8
+ * plain `centrs --version` always prints the bare string.
9
+ */
10
+
11
+ import type { CentrsEnvelope } from "../core/envelope.ts";
12
+ import { centrsVersion } from "../core/version.ts";
13
+ import { toYaml } from "../core/yaml.ts";
14
+ import { CentrsError, formatCentrsErrorText } from "../errors.ts";
15
+
16
+ const versionFormats = ["text", "json", "yaml"] as const;
17
+ type VersionFormat = (typeof versionFormats)[number];
18
+
19
+ export type VersionEnvelope = CentrsEnvelope<
20
+ { version: string },
21
+ { command: "version" }
22
+ >;
23
+
24
+ export function versionEnvelope(): VersionEnvelope {
25
+ return {
26
+ ok: true,
27
+ data: { version: centrsVersion },
28
+ warnings: [],
29
+ tips: [],
30
+ meta: {
31
+ target: {},
32
+ via: null,
33
+ settings: {},
34
+ operation: { command: "version" },
35
+ },
36
+ };
37
+ }
38
+
39
+ function parseVersionFormat(args: readonly string[]): VersionFormat {
40
+ let format: VersionFormat = "text";
41
+ for (let index = 0; index < args.length; index += 1) {
42
+ const arg = args[index];
43
+ if (arg === "--json") {
44
+ format = "json";
45
+ } else if (arg === "--yaml") {
46
+ format = "yaml";
47
+ } else if (arg === "--format") {
48
+ const value = args[++index];
49
+ if (!versionFormats.includes(value as VersionFormat)) {
50
+ throw new CentrsError({
51
+ code: "settings/invalid-format",
52
+ summary: `--format must be one of ${versionFormats.join(", ")}; got ${value ?? "(missing)"}.`,
53
+ remediation: `Choose one of ${versionFormats.join(", ")}.`,
54
+ context: { format: value },
55
+ });
56
+ }
57
+ format = value as VersionFormat;
58
+ } else {
59
+ throw new CentrsError({
60
+ code: "input/invalid-command",
61
+ summary: `\`centrs --version\` takes only an output format; got ${arg}.`,
62
+ remediation:
63
+ "Run `centrs --version`, or `centrs --version --json` for the envelope.",
64
+ context: { arg },
65
+ });
66
+ }
67
+ }
68
+ return format;
69
+ }
70
+
71
+ export function runVersionCli(args: readonly string[]): number {
72
+ let format: VersionFormat;
73
+ try {
74
+ format = parseVersionFormat(args);
75
+ } catch (error) {
76
+ console.error(formatCentrsErrorText(error as CentrsError));
77
+ return 1;
78
+ }
79
+ if (format === "json") {
80
+ console.log(JSON.stringify(versionEnvelope(), null, 2));
81
+ } else if (format === "yaml") {
82
+ console.log(toYaml(versionEnvelope()));
83
+ } else {
84
+ console.log(centrsVersion);
85
+ }
86
+ return 0;
87
+ }
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@ import { retrieveCommand, runRetrieveCli } from "./cli/retrieve.ts";
12
12
  import { runSettingsCli, settingsCommand } from "./cli/settings.ts";
13
13
  import { runTerminalCli, terminalCommand } from "./cli/terminal.ts";
14
14
  import { runTransferCli, transferCommand } from "./cli/transfer.ts";
15
+ import { runVersionCli } from "./cli/version.ts";
15
16
  import { asCentrsError, formatCentrsErrorText } from "./errors.ts";
16
17
  import { describeCentrs, plannedProtocols, plannedSurfaces } from "./index.ts";
17
18
 
@@ -48,7 +49,7 @@ export function renderCliHelp(): string {
48
49
  (command) => ` ${command.name.padEnd(12)} ${command.summary}`,
49
50
  ),
50
51
  "",
51
- "Use `centrs <command> --help` for command-specific options.",
52
+ "Use `centrs <command> --help` for command-specific options, and `centrs --version` (`-V`) for the version alone.",
52
53
  ].join("\n");
53
54
  }
54
55
 
@@ -60,6 +61,9 @@ export async function runCli(
60
61
  console.log(renderCliHelp());
61
62
  return 0;
62
63
  }
64
+ if (args[0] === "--version" || args[0] === "-V") {
65
+ return runVersionCli(args.slice(1));
66
+ }
63
67
 
64
68
  const [command, ...rest] = args;
65
69
  if (command === "retrieve") {
@@ -122,7 +126,6 @@ export async function runCli(
122
126
  }
123
127
 
124
128
  console.error(
125
- // codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
126
129
  formatCentrsErrorText(
127
130
  asCentrsError(new Error(`Unknown centrs command: ${command}`), {
128
131
  code: "input/invalid-command",
@@ -137,7 +140,6 @@ export async function runCli(
137
140
  // to a structured envelope, so reaching here means an unconverted throw
138
141
  // escaped. Never let a raw stack trace reach the user.
139
142
  console.error(
140
- // codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
141
143
  formatCentrsErrorText(
142
144
  asCentrsError(error, {
143
145
  code: "internal/unhandled",