@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
@@ -3,49 +3,198 @@
3
3
  Read and write centrs's own settings — the global `centrs.env` preferences and
4
4
  the `__default__` fallback record — and run first-time setup.
5
5
 
6
- Status: `designed`. This file describes intent and flags; no implementation yet.
6
+ Status: `CHR-passed` for the surface described by `examples.md` (fixture-backed
7
+ integration tests — `settings` performs no network IO, so no CHR is needed).
7
8
  See `docs/MATRIX.md` for the cell state. `settings` is the front-end to the
8
9
  settings the constitution defines (Settings precedence: the `centrs.env` config
9
10
  file at tier 2; `__default__` fallback creds): it does not invent new settings,
10
- only edits the ones already specified.
11
+ only edits the ones already specified. The interactive first-time-setup flow
12
+ (bare `centrs settings` under a TTY) remains a deferred later slice — see
13
+ "Surface" below.
11
14
 
12
15
  ## Why
13
16
 
14
17
  centrs deliberately has no store of its own for *inventory* — that is the CDB.
15
- But it does have **global, centrs-only preferences** (default discovery window,
16
- default `--format`, concurrency, …) that have no per-device home, and a
18
+ But it does have **global, centrs-only preferences** (default output format,
19
+ default result limit, MCP toggles, …) that have no per-device home, and a
17
20
  **`__default__` fallback record** for credentials. Today both are hand-edited
18
21
  (`centrs.env`, or `devices add __default__`). `settings` makes that ergonomic and
19
22
  is where onboarding flows steer the user.
20
23
 
24
+ (Considered and declined: folding `centrs.env` into the future TikTOML device
25
+ format, #137. TikTOML's locked scope is inventory — devices/groups/links,
26
+ credential-by-reference — and stays that way; see the research posted on #137
27
+ for the reasoning. `centrs.env` stays a standalone file.)
28
+
29
+ ## Scope boundary: which `CENTRS_*` keys `settings` manages
30
+
31
+ Not every `CENTRS_*` env var belongs to `settings`. Two groups, and the split
32
+ matters for what `get`/`set`/`print`/`reset` accept:
33
+
34
+ 1. **Managed — full `settings` support.** Every `CENTRS_*` var that a real
35
+ command currently reads, validated with the same rules the consuming code
36
+ already applies (the shared `parseBoolean`/`parseDuration`, a command's own
37
+ enum, `resolveOptionalIntegerSetting`, …), gets full `get`/`set`/`print`/
38
+ `reset`. This includes both tool-behavior keys (`format`, `max-results`,
39
+ `cdb-file`, `resolve`, `mcp-allow-adhoc`) and keys that also have a
40
+ per-device CDB comment-kv counterpart (`via`, `validate`, `timeout`,
41
+ `port`, `ssh-key`, `insecure`) — an earlier draft of this README tried to
42
+ split those into a second, "unmanaged" tier on the theory that
43
+ `settings` "has no visibility into device context." That theory didn't
44
+ hold up: writing `CENTRS_VIA=ssh` to `centrs.env` is mechanically
45
+ identical to writing `CENTRS_FORMAT=json` — a context-free line write —
46
+ and the existing precedence ladder already places the file **below**
47
+ comment-kv (constitution: tier 2 < tier 3), so a device's own `via=`
48
+ override always wins regardless of how the global default was set.
49
+ There's no real distinction left to justify treating these keys worse.
50
+ See the table below for validation per key, including which ones also
51
+ have a comment-kv form (informational — it doesn't change how `settings`
52
+ treats them).
53
+ 2. **Refused — must not be written by `settings`.** Credential-shaped or
54
+ self-referential keys. `settings set`/`settings reset` on any of these is
55
+ a hard `settings/reserved-key` error with a remediation pointer:
56
+
57
+ | Key | Why refused | Use instead |
58
+ | --- | --- | --- |
59
+ | `CENTRS_PASSWORD`, `CENTRS_USERNAME` | Default credentials belong to the `__default__` CDB record, which routes through the `devices` write layer and is redacted from structured output (constitution: identity and CDB). A parallel copy in a plaintext file is a second source of truth for the same secret, one that is never redacted. | `centrs devices add __default__ --user … --password …` |
60
+ | `CENTRS_CDB_PASSWORD` | This is the CDB's own decryption password. Storing it beside an *encrypted* CDB in a plaintext sibling file defeats the encryption. Security-sensitive surface per `SECURITY.md`. | Pass `--cdb-password` per invocation, export the env var in the calling shell, or use `Bun.secret()` once available — never persist to `centrs.env`. |
61
+ | `CENTRS_SKIP_ENV_FILE` | Self-referential: it means "don't load `centrs.env`." Writing it *into* `centrs.env` is a no-op by construction (a file that says "ignore me" is never read to find out). | Export it in the shell or pass `--skip-env-file` per call. |
62
+ | `CENTRS_RUN_FAST_INTEGRATION` | A CI/test signal, not a user preference. | N/A — set by `bun run test:integration`, never by a human. |
63
+
64
+ `settings get`/`settings print` on a refused key still works as read-only
65
+ inspection, **except the value itself**: `CENTRS_PASSWORD` and
66
+ `CENTRS_CDB_PASSWORD` are always shown as `"(redacted)"` (with an `isSet`
67
+ boolean instead of the value) regardless of whether the real secret came
68
+ from the process environment or — despite `set` refusing to write it —
69
+ was hand-added to the file directly. `settings` cannot stop a human from
70
+ editing `centrs.env` with a text editor, so the redaction has to be a
71
+ read-time defense, not just a write-time refusal. `CENTRS_USERNAME` is
72
+ refused-on-write (it belongs on the `__default__` record, not for secrecy
73
+ reasons) but is not secret and is shown in full on read.
74
+
75
+ ## Known settings keys
76
+
77
+ | Key | Type | Built-in default | Comment-kv counterpart? | Notes |
78
+ | --- | --- | --- | --- | --- |
79
+ | `CENTRS_FORMAT` | enum: `text`\|`json`\|`yaml` | Varies **per command** (`api` defaults `json`; `retrieve`/`execute`/`transfer` default `text`) | No | See "Precedence and what `print`/`get` can actually report" below — `print` cannot show one universal current format. |
80
+ | `CENTRS_CDB_FILE` | path | `~/.config/tikoci/winbox.cdb` (`defaultCdbPath`) | No | Existence is not checked at `settings set` time — pre-configuring a path before the file exists is valid. |
81
+ | `CENTRS_MAX_RESULTS` | positive integer | command-specific (`retrieve`, `execute`) | No | `settings/invalid-integer` on a non-positive or non-integer value (`resolveOptionalIntegerSetting` rules). |
82
+ | `CENTRS_RESOLVE` | enum: `none`\|`arp` | `none` | No | Governs MAC→IP fallback resolution (`src/resolver/mac.ts`, `parseResolvePolicy`). |
83
+ | `CENTRS_MCP_ALLOW_ADHOC` | boolean | `false` | No | Currently gates a **not-yet-built** MCP feature (`src/mcp/config.ts` docstring: "reserved for future work, not yet honored"). Safe to expose in `settings` ahead of the feature — `print` should annotate it `(reserved, not yet active)`. **Its reader checks only the literal string `"1"`** (`test/unit/mcp-config.test.ts`: `"true"` → false), not the shared boolean parser — see the canonicalization rule below. |
84
+ | `CENTRS_VIA` | enum: `rest-api`\|`native-api`\|`ssh`\|`snmp`\|`mndp`\|`mac-telnet`\|`romon`\|`winbox-terminal` | `rest-api` (per `retrieve`; other commands vary the same way `format` does) | Yes — `via=` | `settings/invalid-via` on an unrecognized value. |
85
+ | `CENTRS_VALIDATE` | boolean | `true` | Yes — `validate=` | `settings/invalid-boolean`. |
86
+ | `CENTRS_TIMEOUT` | duration (`500ms`, `5s`, `1m`, or bare ms) | command-specific | Yes — `timeout=` | `settings/invalid-timeout` (`parseDuration` rules). |
87
+ | `CENTRS_PORT` | positive integer | protocol-default (no built-in default; unset falls through to the transport's own port) | Yes — `port=` | `settings/invalid-integer`. |
88
+ | `CENTRS_SSH_KEY` | path | unset | Yes — `ssh-key=` | Path only, never key material — matches the per-device form's own rule. Non-blank check only; existence isn't checked at set time. |
89
+ | `CENTRS_INSECURE` | boolean | `false` | Yes — `insecure=` | Accept RouterOS's self-signed TLS cert. `settings set` should still surface a one-line reminder of what it does (`meta.warnings`), since it's a security-relevant toggle even though it's fully validated like any other boolean key. |
90
+ | `CENTRS_TRANSFER_VIA` | enum: `auto`\|`rest`/`rest-api`\|`native`/`native-api`\|`sftp`\|`scp`\|`fetch`\|`ftp` | `auto` | **No** — `transfer` reads only the env var (`src/transfer.ts`, `selectTransferMethod`); there is no `transfer-via=` comment-kv key today. | `scp`/`fetch` currently error `not-implemented`; `ftp` is blocked unless `ALLOW_UNSAFE_PROTOCOLS` opts in — `settings set transfer-via ftp` should surface that same warning rather than silently accepting a value that will fail at call time. |
91
+ | `CENTRS_HOST` | string | unset (falls back to the target positional) | No (comment-kv has `target`/lookup keys, not a `host` override) | An unusual thing to default globally — most calls pass an explicit target — but it's a real, coded env var, so `settings` exposes it rather than editorializing. Non-blank check only. |
92
+
93
+ **Boolean write canonicalization:** `settings set <bool-key> <value>` accepts
94
+ any spelling the shared `parseBoolean` recognizes (`true`/`false`, `yes`/`no`,
95
+ `on`/`off`, `1`/`0`) as *input*, but always **writes the canonical `1` or
96
+ `0`** to the file, never `true`/`false`. This is driven by
97
+ `CENTRS_MCP_ALLOW_ADHOC`'s strict `=== "1"` reader (a locked, tested
98
+ behavior — changing it is out of scope here) — writing `1`/`0` uniformly
99
+ satisfies that reader *and* is valid input to every `parseBoolean`-based
100
+ reader, so one write rule works for both without touching `mcp/config.ts` or
101
+ its test.
102
+
103
+ *Devices/README.md note:* its comment-kv override-key table does not
104
+ currently list `insecure`, even though `commentKvAllowlist`
105
+ (`src/resolver/comment-kv.ts`) does. That's a pre-existing doc/code drift in
106
+ `devices`' own spec, unrelated to `settings` — flagging it here since this
107
+ research surfaced it, not fixing it in this file.
108
+
109
+ ### Not yet wired — exclude from v1
110
+
111
+ `CENTRS_CONCURRENCY`, `CENTRS_DISCOVER_TIMEOUT`, and `CENTRS_CHECK_IGNORE` are
112
+ named in this file's earlier prose and in the constitution's example list, but
113
+ **no command in `src/` currently reads them**:
114
+
115
+ - Fan-out concurrency is `--concurrency` plus a hardcoded transport-aware
116
+ default (REST 8, native-api 4) — there is no env var read anywhere in
117
+ `src/*-fanout.ts`.
118
+ - `discover` reads only `--timeout` / `DISCOVER_DEFAULT_TIMEOUT_MS` — no
119
+ `CENTRS_DISCOVER_TIMEOUT` read exists.
120
+ - `CENTRS_CHECK_IGNORE` belongs to `check`, which is itself only `designed`
121
+ (not coded).
122
+
123
+ Shipping `settings set concurrency 8` today would silently do nothing —
124
+ succeed, persist a line, and never affect a single fan-out call. That's a
125
+ worse UX than not offering the key. **Recommendation: drop these three from
126
+ the v1 `settings` vocabulary entirely** (not even `get`-able) until the
127
+ consuming command actually reads the env var; add them back to the table
128
+ above in the same change that wires the read. This is a real prerequisite,
129
+ not a documentation nit — pursuing it here would either ship a lying command
130
+ or block #135 on unrelated fan-out/discover changes.
131
+
21
132
  ## Surface
22
133
 
23
134
  ```text
24
135
  centrs settings # interactive setup (TTY)
25
- centrs settings print [<attr>] # show resolved settings + sources
136
+ centrs settings print [<attr>] [--all] [--cdb-file <path>] [--cdb-password <pw>]
26
137
  centrs settings get <attr> # read one value
27
138
  centrs settings set <attr> <value> # write one value to centrs.env
28
139
  centrs settings reset [<attr>] # clear one/all back to built-in default
29
140
  ```
30
141
 
31
- - **Interactive (`centrs settings`, TTY):** prompts (clack-style, like
32
- `@tikoci/quickchr`) to optionally collect a default `user`/`password` (written
33
- to the `__default__` record so a fresh CDB "starts useful"), confirm/update
34
- global preferences, and report where the `centrs.env` lives. Non-TTY runs of
35
- bare `settings` print the resolved settings instead of prompting.
142
+ - **Interactive (`centrs settings`, TTY) — deferred later slice, not built.**
143
+ Eventually: prompts (clack-style, like `@tikoci/quickchr`) to optionally
144
+ collect a default `user`/`password` (written to the `__default__` record so
145
+ a fresh CDB "starts useful"), confirm/update global preferences, and report
146
+ where the `centrs.env` lives. **Implementation prerequisite:** centrs has no
147
+ prompt-library dependency today (`package.json` lists only `zod` and the MCP
148
+ SDK) — this slice needs one added (`@clack/prompts`, matching the
149
+ `@tikoci/quickchr` reference). Until it ships, bare `centrs settings`
150
+ behaves identically to `settings print` **regardless of TTY** — matching
151
+ the issue's own phasing, which explicitly said not to block the
152
+ non-interactive surface (built below) on this later slice.
36
153
  - **Non-interactive (`set`/`get`/`reset`/`print`):** scriptable edits to
37
- `centrs.env`. `print` shows each resolved setting with its winning source
38
- (`default` / `centrs.env` / `env` / `cli`), mirroring `meta.settings`.
154
+ `centrs.env`. Exit code `0` on success, `1` on a validation/refusal error —
155
+ matching the plain (non-fan-out) CLI error convention already used
156
+ elsewhere (`src/cli/api.ts`'s `exitCode`), never a bare non-zero without an
157
+ envelope. `settings` has no fan-out surface, so the constitution's `2`
158
+ ("partial" fan-out result) never applies here.
159
+ - **Agent-facing:** every subcommand emits the standard envelope
160
+ (`ok`/`data`/`meta`/`error`) regardless of TTY — `--format json` is
161
+ honored on `settings` itself exactly like every other command, so an agent
162
+ never needs a TTY-detection special case to consume it.
163
+ - **`--cdb-file <path>` / `--cdb-password <pw>`** (on `print` only): scope
164
+ which CDB `print` probes for the `__default__` record summary (see
165
+ "`__default__` boundary" in the examples). These are **not** settings
166
+ precedence sources — they don't participate in the `default`/`config`/`env`
167
+ vocabulary below, because they're not resolving a `CENTRS_*` value at all;
168
+ they're a one-off read of a different file, reported alongside settings.
169
+ Absent, they fall back to the normal `CENTRS_CDB_FILE`/`CENTRS_CDB_PASSWORD`
170
+ resolution (built-in path / env — never `centrs.env`, since `CDB_PASSWORD`
171
+ is refused there).
39
172
  - Credentials collected for `__default__` route through the `devices` write
40
173
  layer (the only CDB writer) and are redacted from any structured output.
41
174
 
175
+ ### `--all`
176
+
177
+ `print`'s default view lists only the keys `settings` recognizes (the table
178
+ above). `--all` additionally lists every other `CENTRS_*=` line literally
179
+ present in the file — a foreign var a user added for their own shell use, a
180
+ typo of a real key, or (redacted) a refused credential key someone hand-added
181
+ despite `set` refusing to write it — each tagged `(unrecognized)`. This keeps
182
+ the default output focused on what `settings` actually understands while
183
+ still making `print --all` the honest "what's actually in this file" dump for
184
+ debugging.
185
+
42
186
  ## Boundaries
43
187
 
44
188
  - `settings` edits **global** preferences (`centrs.env`) and the **`__default__`**
45
189
  record only. Per-device settings stay in `devices` (CDB comment-kv).
46
190
  - It writes no RouterOS device; it is local-only, like `devices`.
47
191
  - `--skip-env-file` / `CENTRS_SKIP_ENV_FILE=1` makes a run ignore `centrs.env`
48
- (tests, dev-agent usage); `settings` still reports what *would* apply.
192
+ **for every other command's precedence resolution**. It does not affect
193
+ `settings` itself: `settings` always reads and writes the real file
194
+ regardless of this flag, since inspecting/editing the file *is* its job. A
195
+ `settings print` run under `--skip-env-file` should still show the file's
196
+ contents, with a one-line note that the flag is active and other commands
197
+ in this same invocation environment would not see them.
49
198
 
50
199
  ## Onboarding
51
200
 
@@ -55,15 +204,130 @@ creds) and `centrs devices discover` (populate the registry). See
55
204
  `commands/discover/README.md` (save-implied `devices discover`) and
56
205
  `docs/CONSTITUTION.md` (Default-device record).
57
206
 
58
- ## Settings keys
207
+ ## Settings keys: file format and write strategy
59
208
 
60
209
  `centrs.env` holds **canonical `CENTRS_*=value` lines** — identical to the
61
210
  environment-variable spellings, prefix included (`CENTRS_FORMAT=json`,
62
- `CENTRS_DISCOVER_TIMEOUT=15s`, `CENTRS_CONCURRENCY=8`). One vocabulary across
63
- file / env / CLI: the file *is* "just default env vars," so `set -a && source
64
- centrs.env` works and the loader applies each key as a default **only when the
65
- same `CENTRS_*` is not already in the process environment** (it never injects
66
- into `process.env`). `settings set <attr>` accepts the setting name with or
67
- without the prefix for convenience but always writes the canonical prefixed
68
- line. It is **user-global only** — the single XDG path above; there is no
69
- project-local config file discovered from the working directory.
211
+ `CENTRS_MAX_RESULTS=500`). One vocabulary across file / env / CLI: the file
212
+ *is* "just default env vars," so `set -a && source centrs.env` works and the
213
+ loader applies each key as a default **only when the same `CENTRS_*` is not
214
+ already in the process environment** (it never injects into `process.env`).
215
+ `settings set <attr>` accepts the setting name with or without the prefix, and
216
+ in kebab-case or SCREAMING_SNAKE (`format`, `FORMAT`, `centrs_format`,
217
+ `CENTRS_FORMAT` all resolve to the same key), but always **writes** the
218
+ canonical `CENTRS_FORMAT=` line. It is **user-global only** — the single XDG
219
+ path above; there is no project-local config file discovered from the working
220
+ directory.
221
+
222
+ **The `config` precedence layer** — `src/resolver/config-file.ts` resolves
223
+ `${XDG_CONFIG_HOME:-~/.config}/tikoci/centrs.env` (injectable `env` map,
224
+ mirroring `defaultCdbPath`) and `loadEnvFileDefaults()` parses it into a flat
225
+ `Record<string, string>`. `resolveStringSetting`/`resolveBooleanSetting`/
226
+ `resolveOptionalIntegerSetting` (`src/resolver/settings.ts`) each take this as
227
+ a `config` argument, checked *between* `comment-kv` and the built-in default,
228
+ producing `{ value, source: { kind: "config", key: "CENTRS_FORMAT" } }` — a
229
+ genuine new layer, not a value folded into the existing `defaultValue`
230
+ parameter (which would have reported it with `source: "default"`, losing
231
+ provenance). Every command that resolves a managed key (`retrieve`, `execute`,
232
+ `api`, `transfer`, `terminal`, plus `resolveCdb`/`expandCdbSelection` for
233
+ `CENTRS_CDB_FILE` and `mcp/config.ts` for `CENTRS_CDB_FILE`/
234
+ `CENTRS_MCP_ALLOW_ADHOC`) threads this `config` argument through, so a value
235
+ set via `centrs settings set` actually takes effect everywhere, not just in
236
+ `settings print`'s own report. `CENTRS_USERNAME`/`CENTRS_PASSWORD`/
237
+ `CENTRS_CDB_PASSWORD` deliberately never read this tier, even if hand-added to
238
+ the file — a stronger defense than read-time redaction alone, since a
239
+ hand-edited credential line then cannot function as a live source. Fan-out
240
+ via-pinning guards (`retrieve`/`execute`/`api`) also deliberately exclude
241
+ `config` — a `centrs.env` default is the weakest precedence tier and must not
242
+ override a per-device comment-kv `via=` across a mixed fan-out.
243
+
244
+ Write strategy (new; the current file is silent on this):
245
+
246
+ - **Preserve unrecognized content.** A hand-edited `centrs.env` may carry
247
+ blank lines, `#` comments, or foreign (non-`CENTRS_*`) lines. `set`/`reset`
248
+ parse-modify-write, touching only the matched line; everything else
249
+ round-trips byte-for-byte, mirroring the CDB's "unknown fields preserved
250
+ verbatim" principle (`commands/devices/README.md`).
251
+ - **`reset <attr>` deletes the line, never blanks it.** A blank
252
+ `CENTRS_FORMAT=` line is *not* equivalent to "unset" for a file meant to be
253
+ bash-`source`-able — sourcing it would set the shell variable to an empty
254
+ string, which most shells and centrs's own `env[KEY] !== undefined` check
255
+ both treat as "set." Deleting the line entirely is the only way `reset`
256
+ actually restores the built-in default. (This is the one place `settings`
257
+ must **not** copy the CDB comment-kv convention, where an empty value
258
+ removes the token — that convention is safe there because centrs parses
259
+ comment-kv itself; it would be a correctness bug here.)
260
+ - **`reset` with no `<attr>` removes every managed-key line** this file
261
+ currently holds, leaving unrecognized lines and all comments/blank lines
262
+ untouched, and reports `data.cleared: [...]` listing what it removed. No
263
+ confirmation gate — the blast radius is one local file, not N routers, and
264
+ every value is recoverable by re-running `settings set`.
265
+ - **Atomic write:** write to a temp file in the same directory, `fsync`, then
266
+ `rename()` over the original — same pattern as the CDB write layer, scaled
267
+ down (a single `.bak` copy is enough; this file has no multi-KB record set
268
+ to protect, and a user can always re-type a couple of lines, but a crash
269
+ mid-write must never corrupt a file other shells `source`).
270
+ - **First write creates the directory.** `${XDG_CONFIG_HOME:-~/.config}/tikoci/`
271
+ may not exist yet on a fresh machine; `set` creates it (and the file) rather
272
+ than erroring.
273
+
274
+ ## Precedence and what `print`/`get` can actually report
275
+
276
+ The constitution's five-tier precedence (`default < config < comment-kv < env
277
+ < cli`) is a **per-call** resolution — it needs a specific command invocation
278
+ (which CDB record matched, which CLI flags were passed) to produce a `cli` or
279
+ `cdb`/`comment-kv` source. A bare `centrs settings print` has no such call
280
+ context: there is no target, no command, no CLI flags being resolved. So for
281
+ each key, `print`/`get` can only ever report one of three sources —
282
+ `default` (built-in, nothing set), `config` (this file has a line for it —
283
+ the same `SettingSourceKind` every other command's `meta.settings` will use
284
+ once the loader above exists), or `env` (the real process environment
285
+ currently has the var set, which would shadow whatever the file says). It
286
+ should **not** claim `cli` or `comment-kv` sources — those require a call
287
+ `settings` never makes.
288
+
289
+ For `CENTRS_FORMAT` specifically: because each command defines its own
290
+ built-in default (`api`: `json`; others: `text`) independently
291
+ (`retrieveOutputFormats`/`apiOutputFormats`/`executeOutputFormats`/
292
+ `transferOutputFormats` are four separate `const` arrays that currently all
293
+ happen to agree on the same three values), `print` should show the
294
+ `config`/`env` value if set, and when unset show **all** per-command
295
+ defaults rather than a single misleading "default: text" — e.g. `default:
296
+ text (api: json)`.
297
+
298
+ ## Testability
299
+
300
+ `settings` is fixture-testable the same way `devices` is: `defaultSettingsPath`
301
+ (`src/resolver/config-file.ts`) takes an injectable
302
+ `env: Record<string, string | undefined>` map and reads `XDG_CONFIG_HOME`
303
+ from it (falling back to `HOME`, then `os.homedir()`) — mirroring
304
+ `defaultCdbPath`'s pattern. `test/integration/settings.test.ts` sets
305
+ `XDG_CONFIG_HOME` to a per-test temp directory (exactly like `$CDB` in the
306
+ `devices` fixtures), so no test ever touches a real user's
307
+ `~/.config/tikoci/centrs.env`.
308
+
309
+ ## Error and warning codes
310
+
311
+ Per the contributor contract in `docs/errors/README.md`, every code below has
312
+ a catalog entry (`src/core/error-catalog.ts`) and a `docs/errors/<code>.md`
313
+ page:
314
+
315
+ - `settings/reserved-key` — `set`/`reset` refused a credential or
316
+ self-referential key (see Scope boundary table).
317
+ - `settings/unknown-key` — `get`/`set` received a token that is not a
318
+ recognized `CENTRS_*`-shaped name at all — e.g. a typo. (Not needed for
319
+ reads via `print --all`, which tags-and-shows rather than rejecting.)
320
+ - `settings/consequential-value` — a non-fatal `set` warning (resolves the
321
+ former open question below) for exactly two keys: `insecure=true` and
322
+ `transfer-via=ftp`. `meta.warnings`, not an interactive confirm — a confirm
323
+ prompt in non-TTY mode isn't meaningful and would break scripting.
324
+ - `settings/skip-env-file-active` — a non-fatal `print` warning noting that
325
+ `--skip-env-file` is active for this invocation only; `settings` itself
326
+ always reads the real file regardless.
327
+ - `internal/settings-failed` — generic internal-error fallback, left as an
328
+ unenriched stub matching the sibling `internal/devices-failed`.
329
+ - `settings/invalid-boolean`, `settings/invalid-integer`,
330
+ `settings/invalid-timeout`, `settings/invalid-via`, `settings/invalid-format`,
331
+ `settings/unsafe-protocol-blocked` (referenced by the `consequential-value`
332
+ warning for `transfer-via=ftp`) already existed in the catalog (shared with
333
+ other commands' resolvers) and apply unchanged here.