@tikoci/centrs 0.1.1 → 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 (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
@@ -0,0 +1,1078 @@
1
+ /**
2
+ * `settings` — read/write centrs's own global preferences (`centrs.env`) and
3
+ * probe the `__default__` CDB record. Transport-less, like `devices`: no
4
+ * RouterOS device is ever touched, `meta.via` is always `null`, and the
5
+ * matrix row stays `—`. See `commands/settings/README.md` for the full
6
+ * product spec this module implements.
7
+ *
8
+ * `centrs.env` holds one `CENTRS_KEY=value` line per managed setting.
9
+ * `settings` always reads/writes the REAL file regardless of
10
+ * `--skip-env-file`/`CENTRS_SKIP_ENV_FILE` — that flag only controls whether
11
+ * *other* commands' resolvers see the file (`resolver/config-file.ts`'s
12
+ * `loadEnvFileDefaults`); inspecting/editing the file is this command's job.
13
+ */
14
+
15
+ import { copyFile, mkdir, open, rename, unlink } from "node:fs/promises";
16
+ import { basename, dirname, join } from "node:path";
17
+ import type {
18
+ CentrsEnvelope,
19
+ CentrsErrorEnvelope,
20
+ CentrsSuccessEnvelope,
21
+ EnvelopeMeta,
22
+ SettingSource,
23
+ Tip,
24
+ Warning,
25
+ } from "./core/envelope.ts";
26
+ import { buildTip } from "./core/envelope.ts";
27
+ import { loadCdb, resolveDevicesSettings } from "./devices.ts";
28
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
29
+ import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
30
+ import {
31
+ DEFAULT_RECORD_TARGET,
32
+ defaultSettingsPath,
33
+ parseBoolean,
34
+ parseDuration,
35
+ parseEnvFileDefaults,
36
+ parseResolvePolicy,
37
+ readSettingsFileRaw,
38
+ resolveOptionalIntegerSetting,
39
+ } from "./resolver/index.ts";
40
+ import { retrieveOutputFormats, toYaml } from "./retrieve.ts";
41
+
42
+ // ── Envelope shape ───────────────────────────────────────────────────────────
43
+
44
+ export type SettingsCommand = "print" | "get" | "set" | "reset";
45
+
46
+ export interface SettingsOperationMeta {
47
+ command: SettingsCommand;
48
+ settingsFile: string;
49
+ }
50
+
51
+ export type SettingsEnvelope<Data> = CentrsEnvelope<
52
+ Data,
53
+ SettingsOperationMeta
54
+ >;
55
+ export type SettingsSuccessEnvelope<Data> = CentrsSuccessEnvelope<
56
+ Data,
57
+ SettingsOperationMeta
58
+ >;
59
+ export type SettingsErrorEnvelope = CentrsErrorEnvelope<SettingsOperationMeta>;
60
+
61
+ function settingsMeta(
62
+ command: SettingsCommand,
63
+ settingsFile: string,
64
+ ): EnvelopeMeta<SettingsOperationMeta> {
65
+ return {
66
+ target: {},
67
+ via: null,
68
+ settings: {},
69
+ operation: { command, settingsFile },
70
+ };
71
+ }
72
+
73
+ export function buildSettingsErrorEnvelope(
74
+ command: SettingsCommand,
75
+ settingsFile: string,
76
+ warnings: readonly Warning[],
77
+ error: unknown,
78
+ ): SettingsErrorEnvelope {
79
+ const centrsError =
80
+ error instanceof CentrsError
81
+ ? error
82
+ : new CentrsError({
83
+ code: "internal/settings-failed",
84
+ summary: error instanceof Error ? error.message : String(error),
85
+ cause: error,
86
+ });
87
+ return {
88
+ ok: false,
89
+ error: serializeCentrsError(centrsError),
90
+ warnings,
91
+ tips: [],
92
+ meta: settingsMeta(command, settingsFile),
93
+ };
94
+ }
95
+
96
+ // ── Managed key registry ─────────────────────────────────────────────────────
97
+
98
+ /** A managed key's typed value, as reported by `get`/`print`. */
99
+ export type SettingsValue = string | number | boolean;
100
+
101
+ interface ParsedSetting {
102
+ value: SettingsValue;
103
+ /** Canonical on-disk form. Differs from `value` only for booleans (1/0). */
104
+ write: string;
105
+ }
106
+
107
+ export interface SettingsKeyDef {
108
+ /** Kebab-case CLI-facing name, e.g. "max-results". */
109
+ attr: string;
110
+ /** `CENTRS_*` env var name. */
111
+ envKey: string;
112
+ /** Per-device CDB comment-kv counterpart, if one exists (informational only). */
113
+ commentKvKey?: string;
114
+ /** Only true for `format`: the built-in default genuinely varies by command. */
115
+ hasPerCommandDefault?: boolean;
116
+ /** Parse + validate raw input. Throws a `settings/invalid-*` CentrsError on bad input. */
117
+ parse: (raw: string, envKey: string) => ParsedSetting;
118
+ /** A one-line reminder to surface as a warning when `set` accepts this value. */
119
+ warn?: (value: SettingsValue) => string | undefined;
120
+ }
121
+
122
+ function parseBooleanSetting(raw: string, envKey: string): ParsedSetting {
123
+ const value = parseBoolean(raw, envKey);
124
+ return { value, write: value ? "1" : "0" };
125
+ }
126
+
127
+ function parseIntegerSetting(raw: string, envKey: string): ParsedSetting {
128
+ const resolved = resolveOptionalIntegerSetting(
129
+ undefined,
130
+ { [envKey]: raw },
131
+ envKey,
132
+ envKey,
133
+ );
134
+ // resolveOptionalIntegerSetting only returns undefined when the env var is
135
+ // absent, which cannot happen here — we always supply it.
136
+ const value = resolved?.value as number;
137
+ return { value, write: String(value) };
138
+ }
139
+
140
+ function parsePassthroughString(raw: string): ParsedSetting {
141
+ const value = raw.trim();
142
+ return { value, write: value };
143
+ }
144
+
145
+ const TRANSFER_VIA_VALUES = [
146
+ "auto",
147
+ "rest",
148
+ "rest-api",
149
+ "native",
150
+ "native-api",
151
+ "sftp",
152
+ "scp",
153
+ "fetch",
154
+ "ftp",
155
+ ] as const;
156
+
157
+ /**
158
+ * The 13 `CENTRS_*` keys `settings` fully manages (validated get/set/print/
159
+ * reset). `commands/settings/README.md` ("Not yet wired — exclude from v1")
160
+ * deliberately excludes `CENTRS_CONCURRENCY`/`CENTRS_DISCOVER_TIMEOUT`/
161
+ * `CENTRS_CHECK_IGNORE` — no shipped command reads them yet.
162
+ */
163
+ export const settingsManagedKeys: readonly SettingsKeyDef[] = [
164
+ {
165
+ attr: "format",
166
+ envKey: "CENTRS_FORMAT",
167
+ hasPerCommandDefault: true,
168
+ parse: (raw) => {
169
+ const value = raw.trim();
170
+ if (
171
+ !retrieveOutputFormats.includes(
172
+ value as (typeof retrieveOutputFormats)[number],
173
+ )
174
+ ) {
175
+ throw new CentrsError({
176
+ code: "settings/invalid-format",
177
+ summary: `Unsupported output format: ${value}`,
178
+ remediation: `Choose one of ${retrieveOutputFormats.join(", ")}.`,
179
+ });
180
+ }
181
+ return { value, write: value };
182
+ },
183
+ },
184
+ {
185
+ attr: "cdb-file",
186
+ envKey: "CENTRS_CDB_FILE",
187
+ parse: parsePassthroughString,
188
+ },
189
+ {
190
+ attr: "max-results",
191
+ envKey: "CENTRS_MAX_RESULTS",
192
+ parse: parseIntegerSetting,
193
+ },
194
+ {
195
+ attr: "resolve",
196
+ envKey: "CENTRS_RESOLVE",
197
+ parse: (raw, envKey) => {
198
+ const value = parseResolvePolicy(raw);
199
+ // parseResolvePolicy uses `validation/option`, not a `settings/*` code —
200
+ // intentional: `settings set resolve <bad>` should fail identically to
201
+ // setting the raw CENTRS_RESOLVE env var, since both funnel through the
202
+ // same validator.
203
+ void envKey;
204
+ return { value, write: value };
205
+ },
206
+ },
207
+ {
208
+ attr: "mcp-allow-adhoc",
209
+ envKey: "CENTRS_MCP_ALLOW_ADHOC",
210
+ parse: parseBooleanSetting,
211
+ },
212
+ {
213
+ attr: "via",
214
+ envKey: "CENTRS_VIA",
215
+ commentKvKey: "via",
216
+ parse: (raw) => {
217
+ const value = raw.trim();
218
+ if (!plannedProtocols.includes(value as RouterOsProtocol)) {
219
+ throw new CentrsError({
220
+ code: "settings/invalid-via",
221
+ summary: `Unsupported protocol identifier: ${value}`,
222
+ remediation: `Choose one of ${plannedProtocols.join(", ")}.`,
223
+ context: { via: value },
224
+ });
225
+ }
226
+ return { value, write: value };
227
+ },
228
+ },
229
+ {
230
+ attr: "validate",
231
+ envKey: "CENTRS_VALIDATE",
232
+ commentKvKey: "validate",
233
+ parse: parseBooleanSetting,
234
+ },
235
+ {
236
+ attr: "timeout",
237
+ envKey: "CENTRS_TIMEOUT",
238
+ commentKvKey: "timeout",
239
+ parse: (raw) => {
240
+ const value = parseDuration(raw);
241
+ if (value <= 0) {
242
+ throw new CentrsError({
243
+ code: "settings/invalid-timeout",
244
+ summary: `Timeout must be greater than zero. Received: ${raw}`,
245
+ remediation:
246
+ "Use a positive integer in milliseconds or a suffix like `5s` / `500ms`.",
247
+ });
248
+ }
249
+ return { value, write: String(value) };
250
+ },
251
+ },
252
+ {
253
+ attr: "port",
254
+ envKey: "CENTRS_PORT",
255
+ commentKvKey: "port",
256
+ parse: parseIntegerSetting,
257
+ },
258
+ {
259
+ attr: "ssh-key",
260
+ envKey: "CENTRS_SSH_KEY",
261
+ commentKvKey: "ssh-key",
262
+ parse: parsePassthroughString,
263
+ },
264
+ {
265
+ attr: "insecure",
266
+ envKey: "CENTRS_INSECURE",
267
+ commentKvKey: "insecure",
268
+ parse: parseBooleanSetting,
269
+ warn: (value) =>
270
+ value === true
271
+ ? "Accepting RouterOS's self-signed TLS cert / unknown SSH host key globally. Every command run without an explicit --insecure/--no-insecure override will trust an unverified peer."
272
+ : undefined,
273
+ },
274
+ {
275
+ attr: "transfer-via",
276
+ envKey: "CENTRS_TRANSFER_VIA",
277
+ parse: (raw) => {
278
+ const value = raw.trim().toLowerCase();
279
+ if (!(TRANSFER_VIA_VALUES as readonly string[]).includes(value)) {
280
+ throw new CentrsError({
281
+ code: "settings/invalid-via",
282
+ summary: `Unsupported transfer method: ${value}`,
283
+ remediation: `Choose one of ${TRANSFER_VIA_VALUES.join(", ")}.`,
284
+ context: { via: value },
285
+ });
286
+ }
287
+ return { value, write: value };
288
+ },
289
+ warn: (value) =>
290
+ value === "ftp"
291
+ ? "`transfer` currently rejects `ftp` at call time unless ALLOW_UNSAFE_PROTOCOLS includes it (settings/unsafe-protocol-blocked) — this value will not work until that opt-in is also set."
292
+ : undefined,
293
+ },
294
+ {
295
+ attr: "host",
296
+ envKey: "CENTRS_HOST",
297
+ parse: parsePassthroughString,
298
+ },
299
+ ];
300
+
301
+ interface RefusedKeyDef {
302
+ attr: string;
303
+ envKey: string;
304
+ /** Secret-shaped: always redacted on read, regardless of source. */
305
+ secret: boolean;
306
+ remediation: string;
307
+ }
308
+
309
+ /**
310
+ * Credential-shaped or self-referential keys `set`/`reset` must refuse.
311
+ * `get`/`print` still work as read-only inspection (README: Scope boundary).
312
+ */
313
+ export const settingsRefusedKeys: readonly RefusedKeyDef[] = [
314
+ {
315
+ attr: "password",
316
+ envKey: "CENTRS_PASSWORD",
317
+ secret: true,
318
+ remediation:
319
+ "Use `centrs devices add __default__ --user … --password …` — default credentials belong on the __default__ CDB record, not a plaintext file.",
320
+ },
321
+ {
322
+ attr: "username",
323
+ envKey: "CENTRS_USERNAME",
324
+ secret: false,
325
+ remediation:
326
+ "Use `centrs devices add __default__ --user …` — default credentials belong on the __default__ CDB record.",
327
+ },
328
+ {
329
+ attr: "cdb-password",
330
+ envKey: "CENTRS_CDB_PASSWORD",
331
+ secret: true,
332
+ remediation:
333
+ "Pass --cdb-password per invocation, export CENTRS_CDB_PASSWORD in the calling shell, or use Bun.secret() once available — never persist a CDB decrypt password to centrs.env.",
334
+ },
335
+ {
336
+ attr: "skip-env-file",
337
+ envKey: "CENTRS_SKIP_ENV_FILE",
338
+ secret: false,
339
+ remediation:
340
+ "Export CENTRS_SKIP_ENV_FILE in the shell or pass --skip-env-file per call — writing it into the file it tells centrs to ignore is a no-op by construction.",
341
+ },
342
+ {
343
+ attr: "run-fast-integration",
344
+ envKey: "CENTRS_RUN_FAST_INTEGRATION",
345
+ secret: false,
346
+ remediation:
347
+ "This is a CI/test signal set by `bun run test:integration`, never by a human.",
348
+ },
349
+ ];
350
+
351
+ /** Normalize `CENTRS_MAX_RESULTS` / `max-results` / `centrs_format` / `FORMAT` to one kebab-case form. */
352
+ function normalizeAttrName(input: string): string {
353
+ let name = input.trim();
354
+ if (/^centrs[_-]/i.test(name)) {
355
+ name = name.replace(/^centrs[_-]/i, "");
356
+ }
357
+ return name.toLowerCase().replace(/_/g, "-");
358
+ }
359
+
360
+ type AttrLookup =
361
+ | { kind: "managed"; def: SettingsKeyDef }
362
+ | { kind: "refused"; def: RefusedKeyDef }
363
+ | { kind: "unknown" };
364
+
365
+ function lookupAttr(input: string): AttrLookup {
366
+ const normalized = normalizeAttrName(input);
367
+ const managed = settingsManagedKeys.find((def) => def.attr === normalized);
368
+ if (managed) {
369
+ return { kind: "managed", def: managed };
370
+ }
371
+ const refused = settingsRefusedKeys.find((def) => def.attr === normalized);
372
+ if (refused) {
373
+ return { kind: "refused", def: refused };
374
+ }
375
+ return { kind: "unknown" };
376
+ }
377
+
378
+ function unknownKeyError(input: string): CentrsError {
379
+ return new CentrsError({
380
+ code: "settings/unknown-key",
381
+ summary: `"${input}" is not a recognized centrs setting.`,
382
+ remediation:
383
+ "Run `centrs settings print --all` to see recognized keys plus any unrecognized CENTRS_* lines already in the file.",
384
+ context: { attr: input },
385
+ });
386
+ }
387
+
388
+ function reservedKeyError(def: RefusedKeyDef): CentrsError {
389
+ return new CentrsError({
390
+ code: "settings/reserved-key",
391
+ summary: `"${def.attr}" (${def.envKey}) cannot be written by settings.`,
392
+ remediation: def.remediation,
393
+ context: { attr: def.attr, envKey: def.envKey },
394
+ });
395
+ }
396
+
397
+ /** `format`'s built-in default genuinely varies per command — the only key this applies to. */
398
+ const FORMAT_PER_COMMAND_DEFAULT: Record<string, string> = {
399
+ retrieve: "text",
400
+ execute: "text",
401
+ transfer: "text",
402
+ api: "json",
403
+ };
404
+
405
+ // ── Shared file I/O ──────────────────────────────────────────────────────────
406
+
407
+ async function readRawLines(path: string): Promise<string[]> {
408
+ const { lines } = await readSettingsFileRaw(path);
409
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
410
+ return lines.slice(0, -1);
411
+ }
412
+ return [...lines];
413
+ }
414
+
415
+ function findLineIndex(lines: readonly string[], envKey: string): number {
416
+ return lines.findIndex((line) => {
417
+ const trimmed = line.trim();
418
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
419
+ return false;
420
+ }
421
+ const eq = trimmed.indexOf("=");
422
+ if (eq <= 0) {
423
+ return false;
424
+ }
425
+ return trimmed.slice(0, eq).trim() === envKey;
426
+ });
427
+ }
428
+
429
+ function rawValueAtLine(lines: readonly string[], index: number): string {
430
+ const line = (lines[index] ?? "").trim();
431
+ return line.slice(line.indexOf("=") + 1).trim();
432
+ }
433
+
434
+ /**
435
+ * Atomic write, scaled down from `data/winbox-cdb-write.ts`'s pattern: a
436
+ * single `.bak` copy (this file has no multi-KB record set or retention
437
+ * policy to manage), temp file + fsync + rename.
438
+ */
439
+ async function writeSettingsFileLines(
440
+ path: string,
441
+ lines: readonly string[],
442
+ ): Promise<void> {
443
+ const dir = dirname(path);
444
+ await mkdir(dir, { recursive: true });
445
+ if (await Bun.file(path).exists()) {
446
+ await copyFile(path, `${path}.bak`);
447
+ }
448
+ const content = lines.length > 0 ? `${lines.join("\n")}\n` : "";
449
+ const tempPath = join(
450
+ dir,
451
+ `${basename(path)}.tmp.${process.pid}.${Date.now().toString(36)}`,
452
+ );
453
+ const handle = await open(tempPath, "w");
454
+ try {
455
+ await handle.write(content);
456
+ await handle.sync();
457
+ } finally {
458
+ await handle.close();
459
+ }
460
+ try {
461
+ await rename(tempPath, path);
462
+ } catch (error) {
463
+ await unlink(tempPath).catch(() => undefined);
464
+ throw error;
465
+ }
466
+ await syncDirectory(dir);
467
+ }
468
+
469
+ /** Best-effort directory fsync so the rename is durable across a crash. */
470
+ async function syncDirectory(dir: string): Promise<void> {
471
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
472
+ try {
473
+ handle = await open(dir, "r");
474
+ await handle.sync();
475
+ } catch {
476
+ // Directory fsync is unsupported on some platforms; the rename itself is
477
+ // still atomic, so this is best-effort durability only.
478
+ } finally {
479
+ await handle?.close();
480
+ }
481
+ }
482
+
483
+ // ── print ────────────────────────────────────────────────────────────────────
484
+
485
+ export interface SettingsKeyEntry {
486
+ value: SettingsValue | null;
487
+ source: SettingSource;
488
+ perCommandDefault?: Record<string, string>;
489
+ isSet?: boolean;
490
+ }
491
+
492
+ export interface SettingsUnrecognizedEntry {
493
+ key: string;
494
+ value: string;
495
+ }
496
+
497
+ export interface SettingsDefaultDeviceSummary {
498
+ configured: boolean;
499
+ user?: string;
500
+ passwordSet?: boolean;
501
+ }
502
+
503
+ export interface SettingsPrintData {
504
+ [attr: string]:
505
+ | SettingsKeyEntry
506
+ | readonly SettingsUnrecognizedEntry[]
507
+ | SettingsDefaultDeviceSummary
508
+ | undefined;
509
+ unrecognized?: readonly SettingsUnrecognizedEntry[];
510
+ defaultDevice?: SettingsDefaultDeviceSummary;
511
+ }
512
+
513
+ export interface SettingsPrintArgs {
514
+ /** Print only this key, instead of every managed key. */
515
+ attr?: string;
516
+ /** Additionally list unrecognized `CENTRS_*` lines present in the file. */
517
+ all?: boolean;
518
+ env: Record<string, string | undefined>;
519
+ /** True when the invocation passed `--skip-env-file` (informational only — see module docs). */
520
+ skipEnvFile?: boolean;
521
+ cdbFile?: string;
522
+ cdbPassword?: string;
523
+ }
524
+
525
+ function redactIfSecret(
526
+ def: RefusedKeyDef,
527
+ rawValue: string,
528
+ source: SettingSource,
529
+ ): SettingsKeyEntry {
530
+ if (def.secret) {
531
+ return { value: "(redacted)", isSet: true, source };
532
+ }
533
+ return { value: rawValue, isSet: true, source };
534
+ }
535
+
536
+ /**
537
+ * Resolve one managed key for display: env > config (raw file) > default.
538
+ * Never `cli`/`comment-kv` — `settings` has no call context to produce them.
539
+ * Tolerant of a malformed value (a hand-edited file should not break
540
+ * inspecting every OTHER key) — falls back to the raw string with the
541
+ * correct source rather than throwing, unlike {@link resolveGetEntry}.
542
+ */
543
+ function resolvePrintEntry(
544
+ def: SettingsKeyDef,
545
+ env: Record<string, string | undefined>,
546
+ config: Record<string, string>,
547
+ ): SettingsKeyEntry {
548
+ const envValue = env[def.envKey];
549
+ if (envValue !== undefined) {
550
+ return buildPrintEntryTolerant(def, envValue, {
551
+ kind: "env",
552
+ key: def.envKey,
553
+ });
554
+ }
555
+ const configValue = config[def.envKey];
556
+ if (configValue !== undefined) {
557
+ return buildPrintEntryTolerant(def, configValue, {
558
+ kind: "config",
559
+ key: def.envKey,
560
+ });
561
+ }
562
+ if (def.hasPerCommandDefault) {
563
+ return {
564
+ value: null,
565
+ source: { kind: "default", key: def.attr },
566
+ perCommandDefault: FORMAT_PER_COMMAND_DEFAULT,
567
+ };
568
+ }
569
+ return { value: null, source: { kind: "default", key: def.attr } };
570
+ }
571
+
572
+ function buildPrintEntryTolerant(
573
+ def: SettingsKeyDef,
574
+ raw: string,
575
+ source: SettingSource,
576
+ ): SettingsKeyEntry {
577
+ try {
578
+ return { value: def.parse(raw, def.envKey).value, source };
579
+ } catch {
580
+ // Malformed value in a hand-edited file/env: report it as-is rather than
581
+ // failing the whole `print` call. `get` on this same key still throws.
582
+ return { value: raw, source };
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Parse a raw on-disk value for reporting as `set`'s `previous`, tolerating a
588
+ * malformed value rather than throwing — a hand-edited file with garbage in
589
+ * it (e.g. `CENTRS_PORT=abc`) must not block `set` from overwriting it with a
590
+ * valid one. Mirrors {@link buildPrintEntryTolerant}'s tolerance.
591
+ */
592
+ function parsePreviousTolerant(
593
+ def: SettingsKeyDef,
594
+ raw: string,
595
+ ): SettingsValue {
596
+ try {
597
+ return def.parse(raw, def.envKey).value;
598
+ } catch {
599
+ return raw;
600
+ }
601
+ }
602
+
603
+ function collectUnrecognized(
604
+ lines: readonly string[],
605
+ ): readonly SettingsUnrecognizedEntry[] {
606
+ // Only managed keys are "known" here — refused keys (e.g. a hand-added
607
+ // CENTRS_PASSWORD line) deliberately fall through to this listing so
608
+ // `print --all` still surfaces them (redacted via secretKeys below)
609
+ // instead of silently hiding them because they're recognized-but-refused.
610
+ const knownKeys = new Set<string>(
611
+ settingsManagedKeys.map((def) => def.envKey),
612
+ );
613
+ const secretKeys = new Set(
614
+ settingsRefusedKeys.filter((def) => def.secret).map((def) => def.envKey),
615
+ );
616
+ const entries: SettingsUnrecognizedEntry[] = [];
617
+ for (const rawLine of lines) {
618
+ const line = rawLine.trim();
619
+ if (line.length === 0 || line.startsWith("#")) {
620
+ continue;
621
+ }
622
+ const eq = line.indexOf("=");
623
+ if (eq <= 0) {
624
+ continue;
625
+ }
626
+ const key = line.slice(0, eq).trim();
627
+ if (!key.startsWith("CENTRS_") || knownKeys.has(key)) {
628
+ continue;
629
+ }
630
+ const value = line.slice(eq + 1).trim();
631
+ entries.push({
632
+ key,
633
+ value: secretKeys.has(key) ? "(redacted)" : value,
634
+ });
635
+ }
636
+ return entries;
637
+ }
638
+
639
+ /**
640
+ * Probe the `__default__` CDB record for `print`'s summary. Deliberately
641
+ * never consults the `centrs.env` config tier for the CDB path (README
642
+ * Surface: "never centrs.env") — only cli/env, matching `resolveDevicesSettings`
643
+ * called with no `config`. A missing CDB is `{configured: false}`, not an
644
+ * error, unlike `devices list` — settings should never require a CDB to exist.
645
+ */
646
+ async function probeDefaultDevice(
647
+ args: Pick<SettingsPrintArgs, "cdbFile" | "cdbPassword" | "env">,
648
+ ): Promise<SettingsDefaultDeviceSummary> {
649
+ const settings = resolveDevicesSettings({
650
+ cdbFile: args.cdbFile,
651
+ cdbPassword: args.cdbPassword,
652
+ env: args.env,
653
+ });
654
+ if (!(await Bun.file(settings.cdbFile.value).exists())) {
655
+ return { configured: false };
656
+ }
657
+ const cdb = await loadCdb({
658
+ cdbFile: settings.cdbFile.value,
659
+ cdbPassword: args.cdbPassword,
660
+ env: args.env,
661
+ });
662
+ const entry = cdb.entries.find(
663
+ (candidate) => candidate.target === DEFAULT_RECORD_TARGET,
664
+ );
665
+ if (!entry) {
666
+ return { configured: false };
667
+ }
668
+ return {
669
+ configured: true,
670
+ ...(entry.user ? { user: entry.user } : {}),
671
+ passwordSet: entry.password.length > 0,
672
+ };
673
+ }
674
+
675
+ export async function settingsPrint(
676
+ args: SettingsPrintArgs,
677
+ ): Promise<SettingsSuccessEnvelope<SettingsPrintData>> {
678
+ const settingsFile = defaultSettingsPath(args.env);
679
+ const lines = await readRawLines(settingsFile);
680
+ const config = parseEnvFileDefaults(lines);
681
+ const warnings: Warning[] = [];
682
+
683
+ if (args.attr !== undefined) {
684
+ const lookup = lookupAttr(args.attr);
685
+ if (lookup.kind === "unknown") {
686
+ throw unknownKeyError(args.attr);
687
+ }
688
+ const data: SettingsPrintData = {};
689
+ if (lookup.kind === "managed") {
690
+ data[lookup.def.attr] = resolvePrintEntry(lookup.def, args.env, config);
691
+ } else {
692
+ data[lookup.def.attr] = resolveRefusedEntry(lookup.def, args.env, config);
693
+ }
694
+ return {
695
+ ok: true,
696
+ data,
697
+ warnings,
698
+ tips: [],
699
+ meta: settingsMeta("print", settingsFile),
700
+ };
701
+ }
702
+
703
+ const data: SettingsPrintData = {};
704
+ for (const def of settingsManagedKeys) {
705
+ data[def.attr] = resolvePrintEntry(def, args.env, config);
706
+ }
707
+ if (args.all) {
708
+ data.unrecognized = collectUnrecognized(lines);
709
+ }
710
+ data.defaultDevice = await probeDefaultDevice(args);
711
+
712
+ if (args.skipEnvFile) {
713
+ warnings.push({
714
+ code: "settings/skip-env-file-active",
715
+ message:
716
+ "--skip-env-file is active for this invocation: other commands run in this same environment would not see these centrs.env values. `settings` itself always reads the real file.",
717
+ });
718
+ }
719
+
720
+ return {
721
+ ok: true,
722
+ data,
723
+ warnings,
724
+ tips: [],
725
+ meta: settingsMeta("print", settingsFile),
726
+ };
727
+ }
728
+
729
+ // ── get ──────────────────────────────────────────────────────────────────────
730
+
731
+ export type SettingsGetData = SettingsKeyEntry;
732
+
733
+ export interface SettingsGetArgs {
734
+ attr: string;
735
+ env: Record<string, string | undefined>;
736
+ }
737
+
738
+ /** Strict counterpart of {@link resolvePrintEntry}: throws on a malformed value. */
739
+ function resolveGetEntry(
740
+ def: SettingsKeyDef,
741
+ env: Record<string, string | undefined>,
742
+ config: Record<string, string>,
743
+ ): SettingsKeyEntry {
744
+ const envValue = env[def.envKey];
745
+ if (envValue !== undefined) {
746
+ return {
747
+ value: def.parse(envValue, def.envKey).value,
748
+ source: { kind: "env", key: def.envKey },
749
+ };
750
+ }
751
+ const configValue = config[def.envKey];
752
+ if (configValue !== undefined) {
753
+ return {
754
+ value: def.parse(configValue, def.envKey).value,
755
+ source: { kind: "config", key: def.envKey },
756
+ };
757
+ }
758
+ if (def.hasPerCommandDefault) {
759
+ return {
760
+ value: null,
761
+ source: { kind: "default", key: def.attr },
762
+ perCommandDefault: FORMAT_PER_COMMAND_DEFAULT,
763
+ };
764
+ }
765
+ return { value: null, source: { kind: "default", key: def.attr } };
766
+ }
767
+
768
+ function resolveRefusedEntry(
769
+ def: RefusedKeyDef,
770
+ env: Record<string, string | undefined>,
771
+ config: Record<string, string>,
772
+ ): SettingsKeyEntry {
773
+ const envValue = env[def.envKey];
774
+ if (envValue !== undefined) {
775
+ return redactIfSecret(def, envValue, { kind: "env", key: def.envKey });
776
+ }
777
+ const configValue = config[def.envKey];
778
+ if (configValue !== undefined) {
779
+ return redactIfSecret(def, configValue, {
780
+ kind: "config",
781
+ key: def.envKey,
782
+ });
783
+ }
784
+ return {
785
+ value: null,
786
+ isSet: false,
787
+ source: { kind: "default", key: def.attr },
788
+ };
789
+ }
790
+
791
+ function viaCommentKvTip(def: SettingsKeyDef): Tip[] {
792
+ if (def.commentKvKey === undefined) {
793
+ return [];
794
+ }
795
+ return [
796
+ buildTip(
797
+ "tip/comment-kv-may-override",
798
+ `A device's own \`${def.commentKvKey}=\` comment-kv override, if set, takes precedence over this global default.`,
799
+ `Check a specific device with \`centrs devices show <target>\`.`,
800
+ ),
801
+ ];
802
+ }
803
+
804
+ export async function settingsGet(
805
+ args: SettingsGetArgs,
806
+ ): Promise<SettingsSuccessEnvelope<SettingsGetData>> {
807
+ const settingsFile = defaultSettingsPath(args.env);
808
+ const lines = await readRawLines(settingsFile);
809
+ const config = parseEnvFileDefaults(lines);
810
+ const lookup = lookupAttr(args.attr);
811
+ if (lookup.kind === "unknown") {
812
+ throw unknownKeyError(args.attr);
813
+ }
814
+ const data =
815
+ lookup.kind === "managed"
816
+ ? resolveGetEntry(lookup.def, args.env, config)
817
+ : resolveRefusedEntry(lookup.def, args.env, config);
818
+ const tips = lookup.kind === "managed" ? viaCommentKvTip(lookup.def) : [];
819
+ return {
820
+ ok: true,
821
+ data,
822
+ warnings: [],
823
+ tips,
824
+ meta: settingsMeta("get", settingsFile),
825
+ };
826
+ }
827
+
828
+ // ── set ──────────────────────────────────────────────────────────────────────
829
+
830
+ export interface SettingsSetData {
831
+ key: string;
832
+ previous: SettingsValue | null;
833
+ value: SettingsValue;
834
+ }
835
+
836
+ export interface SettingsSetArgs {
837
+ attr: string;
838
+ value: string;
839
+ env: Record<string, string | undefined>;
840
+ }
841
+
842
+ export async function settingsSet(
843
+ args: SettingsSetArgs,
844
+ ): Promise<SettingsSuccessEnvelope<SettingsSetData>> {
845
+ const settingsFile = defaultSettingsPath(args.env);
846
+ const lookup = lookupAttr(args.attr);
847
+ if (lookup.kind === "unknown") {
848
+ throw unknownKeyError(args.attr);
849
+ }
850
+ if (lookup.kind === "refused") {
851
+ throw reservedKeyError(lookup.def);
852
+ }
853
+ const def = lookup.def;
854
+ const parsed = def.parse(args.value, def.envKey);
855
+
856
+ const lines = await readRawLines(settingsFile);
857
+ const index = findLineIndex(lines, def.envKey);
858
+ const previousRaw = index >= 0 ? rawValueAtLine(lines, index) : undefined;
859
+ const previous =
860
+ previousRaw !== undefined ? parsePreviousTolerant(def, previousRaw) : null;
861
+
862
+ const newLine = `${def.envKey}=${parsed.write}`;
863
+ if (index >= 0) {
864
+ lines[index] = newLine;
865
+ } else {
866
+ lines.push(newLine);
867
+ }
868
+ await writeSettingsFileLines(settingsFile, lines);
869
+
870
+ const warnings: Warning[] = [];
871
+ const warning = def.warn?.(parsed.value);
872
+ if (warning !== undefined) {
873
+ warnings.push({ code: "settings/consequential-value", message: warning });
874
+ }
875
+
876
+ return {
877
+ ok: true,
878
+ data: { key: def.attr, previous, value: parsed.value },
879
+ warnings,
880
+ tips: [],
881
+ meta: settingsMeta("set", settingsFile),
882
+ };
883
+ }
884
+
885
+ // ── reset ────────────────────────────────────────────────────────────────────
886
+
887
+ export interface SettingsResetData {
888
+ key?: string;
889
+ wasSet?: boolean;
890
+ cleared?: readonly string[];
891
+ }
892
+
893
+ export interface SettingsResetArgs {
894
+ attr?: string;
895
+ env: Record<string, string | undefined>;
896
+ }
897
+
898
+ export async function settingsReset(
899
+ args: SettingsResetArgs,
900
+ ): Promise<SettingsSuccessEnvelope<SettingsResetData>> {
901
+ const settingsFile = defaultSettingsPath(args.env);
902
+
903
+ if (args.attr !== undefined) {
904
+ const lookup = lookupAttr(args.attr);
905
+ if (lookup.kind === "unknown") {
906
+ throw unknownKeyError(args.attr);
907
+ }
908
+ if (lookup.kind === "refused") {
909
+ throw reservedKeyError(lookup.def);
910
+ }
911
+ const lines = await readRawLines(settingsFile);
912
+ const index = findLineIndex(lines, lookup.def.envKey);
913
+ const wasSet = index >= 0;
914
+ if (wasSet) {
915
+ lines.splice(index, 1);
916
+ await writeSettingsFileLines(settingsFile, lines);
917
+ }
918
+ return {
919
+ ok: true,
920
+ data: { key: lookup.def.attr, wasSet },
921
+ warnings: [],
922
+ tips: [],
923
+ meta: settingsMeta("reset", settingsFile),
924
+ };
925
+ }
926
+
927
+ const lines = await readRawLines(settingsFile);
928
+ const managedEnvKeys = new Map(
929
+ settingsManagedKeys.map((def) => [def.envKey, def.attr]),
930
+ );
931
+ const cleared: string[] = [];
932
+ const kept = lines.filter((line) => {
933
+ const trimmed = line.trim();
934
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
935
+ return true;
936
+ }
937
+ const eq = trimmed.indexOf("=");
938
+ if (eq <= 0) {
939
+ return true;
940
+ }
941
+ const key = trimmed.slice(0, eq).trim();
942
+ const attr = managedEnvKeys.get(key);
943
+ if (attr === undefined) {
944
+ return true;
945
+ }
946
+ cleared.push(attr);
947
+ return false;
948
+ });
949
+ if (cleared.length > 0) {
950
+ await writeSettingsFileLines(settingsFile, kept);
951
+ }
952
+
953
+ return {
954
+ ok: true,
955
+ data: { cleared },
956
+ warnings: [],
957
+ tips: [],
958
+ meta: settingsMeta("reset", settingsFile),
959
+ };
960
+ }
961
+
962
+ // ── Rendering ────────────────────────────────────────────────────────────────
963
+
964
+ export type SettingsOutputFormat = "text" | "json" | "yaml";
965
+ export const settingsOutputFormats: readonly SettingsOutputFormat[] = [
966
+ "text",
967
+ "json",
968
+ "yaml",
969
+ ];
970
+
971
+ export function renderSettingsEnvelope(
972
+ envelope: SettingsEnvelope<unknown>,
973
+ format: SettingsOutputFormat,
974
+ ): string {
975
+ if (format === "json") {
976
+ return JSON.stringify(envelope, null, 2);
977
+ }
978
+ if (format === "yaml") {
979
+ return toYaml(envelope);
980
+ }
981
+ return envelope.ok
982
+ ? renderSettingsSuccessText(envelope)
983
+ : renderSettingsErrorText(envelope);
984
+ }
985
+
986
+ function formatValue(value: SettingsValue | null): string {
987
+ if (value === null) {
988
+ return "(unset)";
989
+ }
990
+ return String(value);
991
+ }
992
+
993
+ function renderSettingsSuccessText(
994
+ envelope: SettingsSuccessEnvelope<unknown>,
995
+ ): string {
996
+ const lines: string[] = [];
997
+ const command = envelope.meta.operation?.command;
998
+ const data = envelope.data as Record<string, unknown>;
999
+
1000
+ if (command === "print" || command === "get") {
1001
+ for (const [key, entry] of Object.entries(data)) {
1002
+ if (key === "unrecognized" || key === "defaultDevice") {
1003
+ continue;
1004
+ }
1005
+ const typed = entry as SettingsKeyEntry;
1006
+ const sourceKey = typed.source.key ? ` (${typed.source.key})` : "";
1007
+ lines.push(
1008
+ `${key}: ${formatValue(typed.value)} [source: ${typed.source.kind}${sourceKey}]`,
1009
+ );
1010
+ }
1011
+ const unrecognized = data["unrecognized"] as
1012
+ | readonly SettingsUnrecognizedEntry[]
1013
+ | undefined;
1014
+ if (unrecognized && unrecognized.length > 0) {
1015
+ lines.push("");
1016
+ lines.push("Unrecognized CENTRS_* lines:");
1017
+ for (const item of unrecognized) {
1018
+ lines.push(` ${item.key}=${item.value} (unrecognized)`);
1019
+ }
1020
+ }
1021
+ const defaultDevice = data["defaultDevice"] as
1022
+ | SettingsDefaultDeviceSummary
1023
+ | undefined;
1024
+ if (defaultDevice) {
1025
+ lines.push("");
1026
+ lines.push(
1027
+ defaultDevice.configured
1028
+ ? `__default__: configured (user=${defaultDevice.user ?? "(none)"}, passwordSet=${defaultDevice.passwordSet ?? false})`
1029
+ : "__default__: not configured",
1030
+ );
1031
+ }
1032
+ } else if (command === "set") {
1033
+ const set = data as unknown as SettingsSetData;
1034
+ lines.push(
1035
+ `${set.key}: ${formatValue(set.previous)} -> ${formatValue(set.value)}`,
1036
+ );
1037
+ } else if (command === "reset") {
1038
+ const reset = data as unknown as SettingsResetData;
1039
+ if (reset.cleared !== undefined) {
1040
+ lines.push(
1041
+ reset.cleared.length > 0
1042
+ ? `Cleared: ${reset.cleared.join(", ")}`
1043
+ : "Nothing to clear.",
1044
+ );
1045
+ } else {
1046
+ lines.push(
1047
+ reset.wasSet
1048
+ ? `${reset.key}: reset to built-in default.`
1049
+ : `${reset.key}: was not set.`,
1050
+ );
1051
+ }
1052
+ }
1053
+
1054
+ if (envelope.warnings.length > 0) {
1055
+ lines.push("");
1056
+ for (const warning of envelope.warnings) {
1057
+ lines.push(`warning [${warning.code}]: ${warning.message}`);
1058
+ }
1059
+ }
1060
+ if (envelope.tips.length > 0) {
1061
+ lines.push("");
1062
+ lines.push("Tips:");
1063
+ for (const item of envelope.tips) {
1064
+ lines.push(` - [${item.code}] ${item.message}`);
1065
+ }
1066
+ }
1067
+
1068
+ return lines.join("\n");
1069
+ }
1070
+
1071
+ function renderSettingsErrorText(envelope: SettingsErrorEnvelope): string {
1072
+ const error = envelope.error;
1073
+ const lines = [`[${error.code}] ${error.summary}`];
1074
+ if (error.remediation) {
1075
+ lines.push(`Fix: ${error.remediation}`);
1076
+ }
1077
+ return lines.join("\n");
1078
+ }