@tikoci/centrs 0.1.4 → 0.1.7

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 (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. package/src/transfer.ts +1 -1
@@ -5,10 +5,16 @@
5
5
  * (path tokenizing, the comma-path join, node-type predicates, completion-name
6
6
  * extraction). This module is the single home so a third consumer — `api`, and a
7
7
  * future `explain`/`check` — reuses one grounded implementation instead of
8
- * forking a fourth. It deliberately holds only the transport-agnostic
9
- * *primitives*; each command composes its own discovery strategy on top (their
10
- * strategies differ: `retrieve` probes print/get support, `execute` unions
11
- * child-args with completions), so the strategy stays in the command.
8
+ * forking a fourth. It holds the transport-agnostic *primitives* plus the one
9
+ * discovery strategy that is shared verbatim: {@link inspectArgumentNames},
10
+ * command-level `request=child` only, used by `execute` and `api`. Strategies
11
+ * that differ stay in the command — `retrieve` probes print/get support and
12
+ * reads argument-level completion (`print,proplist` / `get,value-name`) with a
13
+ * child fallback.
14
+ *
15
+ * Command-level `request=completion` is not part of any argument-discovery
16
+ * strategy: on RouterOS 7.12.2 it hangs the REST handler and closes the
17
+ * native-API connection. See {@link inspectArgumentNames}.
12
18
  *
13
19
  * Grounding: the request modes mirror `tikoci/lsp-routeros-ts`
14
20
  * (`server/src/routeros.ts` `InspectRequest`). The behavioral facts (the array-
@@ -49,12 +55,20 @@ export interface InspectChildItem {
49
55
  }
50
56
 
51
57
  /** A row from `request=completion`: a candidate token for a path/argument. */
58
+ /**
59
+ * One `request=completion` row. These are the only fields RouterOS sends, on
60
+ * every build recorded from 7.9.2 to 7.25beta3 (lsp-routeros-ts
61
+ * `test-data/inspect-shapes.v*.json`). `text` is the candidate's help string,
62
+ * never its name.
63
+ */
52
64
  export interface InspectCompletionItem {
53
65
  type?: string;
54
- name?: string;
55
66
  completion?: string;
56
- value?: string;
67
+ show?: string;
68
+ style?: string;
57
69
  text?: string;
70
+ offset?: string;
71
+ preference?: string;
58
72
  }
59
73
 
60
74
  /**
@@ -93,31 +107,23 @@ export function isCommandNode(child: InspectChildItem, name: string): boolean {
93
107
  }
94
108
 
95
109
  /**
96
- * Flatten completion rows into attribute names. Reads every name-like field a
97
- * RouterOS build might populate (`completion`/`name`/`value`/`text`), strips
98
- * everything from the first `=` onward (the `name=value` completion form), and
99
- * drops blanks. Returns the names in row order **without** de-duplication or
100
- * sorting — callers that need a stable set wrap with `[...new Set(names)].sort()`
101
- * (both current callers do).
110
+ * The candidate names from completion rows: `completion` of each
111
+ * `show: "true"` row that is not `syntax-meta`. `show: "false"` rows are
112
+ * syntax completions (`[`, `(`, `$`, `"`, `*`, `<value>`), not properties
113
+ * (CHR 7.23.7, on both `print,proplist` and `get,value-name`; #380). At an
114
+ * argument boundary RouterOS also offers a shown `=` ("argument value
115
+ * separator", `style: "syntax-meta"`) on every recorded build, 7.9.2 →
116
+ * 7.25beta3; no build offers a `name=value` completion. Returns
117
+ * the names in row order **without** de-duplication or sorting; callers that
118
+ * need a stable set wrap with `[...new Set(names)].sort()`.
102
119
  */
103
120
  export function extractCompletionNames(
104
121
  rows: readonly InspectCompletionItem[],
105
122
  ): string[] {
106
123
  return rows
107
- .flatMap((row) => [row.completion, row.name, row.value, row.text])
108
- .filter((value): value is string => typeof value === "string")
109
- .map((value) => nameBeforeEquals(value).trim())
110
- .filter((value) => value.length > 0);
111
- }
112
-
113
- /**
114
- * The token before the first `=` in a RouterOS completion row. Plain string
115
- * slicing (no regex) — `value.replace(/=.*$/, "")` triggers a CodeQL
116
- * polynomial-ReDoS flag on this exported function's caller-supplied input.
117
- */
118
- function nameBeforeEquals(value: string): string {
119
- const eq = value.indexOf("=");
120
- return eq === -1 ? value : value.slice(0, eq);
124
+ .filter((row) => row.show === "true" && row.style !== "syntax-meta")
125
+ .map((row) => row.completion?.trim() ?? "")
126
+ .filter((name) => name.length > 0);
121
127
  }
122
128
 
123
129
  /** `request=child` for a token path. */
@@ -131,6 +137,32 @@ export async function inspectChildren(
131
137
  )) as InspectChildItem[];
132
138
  }
133
139
 
140
+ /**
141
+ * Return the argument names exposed by `request=child` at a command path.
142
+ *
143
+ * Do not supplement this with a command-level `request=completion`: RouterOS
144
+ * 7.12.2 hangs the REST handler and closes the native-API connection for that
145
+ * request shape, while `child` returns the complete argument list immediately.
146
+ * Argument-level completion (for example `print,proplist`) is a distinct probe
147
+ * and remains available through {@link inspectCompletions}.
148
+ */
149
+ export async function inspectArgumentNames(
150
+ backend: InspectBackend,
151
+ tokens: readonly string[],
152
+ ): Promise<string[]> {
153
+ const children = await inspectChildren(backend, tokens);
154
+ return [
155
+ ...new Set(
156
+ children
157
+ .filter(isArgumentNode)
158
+ .map((child) => child.name)
159
+ .filter(
160
+ (name): name is string => typeof name === "string" && name.length > 0,
161
+ ),
162
+ ),
163
+ ].sort();
164
+ }
165
+
134
166
  /** `request=completion` for a token path. */
135
167
  export async function inspectCompletions(
136
168
  backend: InspectBackend,
@@ -88,6 +88,12 @@ export interface RouterOsErrorRule {
88
88
  description: string;
89
89
  /** Pattern matched against the trimmed RouterOS string. */
90
90
  test: RegExp;
91
+ /**
92
+ * Anchored form safe for classifying a successful transport's returned text.
93
+ * Unlike an HTTP error body / native trap, `/execute as-string` also carries
94
+ * ordinary stdout, so substring matches would turn user output into failures.
95
+ */
96
+ resultTest?: RegExp;
91
97
  /** Builds the normalized error fields from the regex match and raw string. */
92
98
  build: (match: RegExpMatchArray, raw: string) => RouterOsErrorRuleResult;
93
99
  }
@@ -106,6 +112,8 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
106
112
  description:
107
113
  "RouterOS did not recognize the command path or menu item ('no such ...').",
108
114
  test: /no such command prefix|no such item|no such entry/i,
115
+ resultTest:
116
+ /^(?:failure:\s*)?(?:no such command prefix|no such item|no such entry)\b.*$/i,
109
117
  build: (_match, raw) => ({
110
118
  summary: `RouterOS does not recognize the path: ${raw.trim()}`,
111
119
  remediation:
@@ -119,6 +127,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
119
127
  // (mac-telnet) says "bad parameter <name> (line N column M)" for the same
120
128
  // fault. Grounded on CHR 7.23.1.
121
129
  test: /(?:unknown|bad) parameter\s+(\S+)/i,
130
+ resultTest: /^(?:failure:\s*)?(?:unknown|bad) parameter\s+\S+.*$/i,
122
131
  build: (match) => {
123
132
  const parameter = cleanToken(match[1] ?? "");
124
133
  return {
@@ -135,6 +144,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
135
144
  "RouterOS console rejected the command word ('bad command name …').",
136
145
  // Console form (mac-telnet) of an unrecognized command/path word.
137
146
  test: /bad command name/i,
147
+ resultTest: /^(?:failure:\s*)?(?:\(<%%\s*)?bad command name\b.*$/i,
138
148
  build: (_match, raw) => ({
139
149
  summary: `RouterOS does not recognize the command: ${raw.trim()}`,
140
150
  remediation:
@@ -145,6 +155,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
145
155
  code: "routeros/invalid-value",
146
156
  description: "RouterOS rejected the value supplied for an argument.",
147
157
  test: /invalid value (?:for argument|of)\s+(\S+)/i,
158
+ resultTest: /^(?:failure:\s*)?invalid value (?:for argument|of)\s+\S+.*$/i,
148
159
  build: (match) => {
149
160
  const argument = cleanToken(match[1] ?? "");
150
161
  return {
@@ -155,11 +166,24 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
155
166
  };
156
167
  },
157
168
  },
169
+ {
170
+ code: "routeros/invalid-value",
171
+ description:
172
+ "RouterOS rejected a value because it does not match the accepted domain.",
173
+ test: /input does not match/i,
174
+ resultTest: /^(?:failure:\s*)?input does not match\b.*$/i,
175
+ build: (_match, raw) => ({
176
+ summary: `RouterOS rejected the supplied value: ${raw.trim()}`,
177
+ remediation:
178
+ "Check the accepted values for this command on the target RouterOS version, then retry with a matching value.",
179
+ }),
180
+ },
158
181
  {
159
182
  code: "routeros/session-closed",
160
183
  description:
161
184
  "RouterOS closed the REST session before the request completed (60-second cap).",
162
185
  test: /session closed/i,
186
+ resultTest: /^(?:failure:\s*)?session closed\b.*$/i,
163
187
  build: () => ({
164
188
  summary: "RouterOS closed the session before the request completed.",
165
189
  remediation:
@@ -171,6 +195,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
171
195
  description:
172
196
  "Generic RouterOS command failure carrying a `failure: <msg>` string.",
173
197
  test: /^\s*failure:\s*(.+?)\s*$/i,
198
+ resultTest: /^\s*failure:\s*.+?\s*$/i,
174
199
  build: (match) => {
175
200
  const failure = (match[1] ?? "").trim();
176
201
  return {
@@ -181,11 +206,27 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
181
206
  };
182
207
  },
183
208
  },
209
+ {
210
+ code: "routeros/command-failed",
211
+ description: "Generic RouterOS command failure carrying an error prefix.",
212
+ test: /^\s*error:\s*(.+?)\s*$/i,
213
+ resultTest: /^\s*error:\s*.+?\s*$/i,
214
+ build: (match) => {
215
+ const failure = (match[1] ?? "").trim();
216
+ return {
217
+ summary: `RouterOS command failed: ${failure}`,
218
+ remediation:
219
+ "Inspect the RouterOS error message, then adjust the script or request shape accordingly.",
220
+ context: { failure },
221
+ };
222
+ },
223
+ },
184
224
  {
185
225
  code: "routeros/command-failed",
186
226
  description:
187
227
  "RouterOS /execute as-string returned a script :error value with source location.",
188
228
  test: /^\s*(.+?)\s*\(:error; line \d+\)\s*$/i,
229
+ resultTest: /^\s*.+?\s*\(:error; line \d+\)\s*$/i,
189
230
  build: (match) => {
190
231
  const failure = (match[1] ?? "").trim();
191
232
  return {
@@ -203,6 +244,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
203
244
  code: "routeros/unknown-path",
204
245
  description: "RouterOS reported a path-shaped 'not found'.",
205
246
  test: /not found/i,
247
+ resultTest: /^\s*\/\S+\s+not found\s*$/i,
206
248
  build: (_match, raw) => ({
207
249
  summary: `RouterOS does not recognize the path: ${raw.trim()}`,
208
250
  remediation:
@@ -211,6 +253,30 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
211
253
  },
212
254
  ];
213
255
 
256
+ /**
257
+ * Grounded result shapes that intentionally use the transport-specific
258
+ * catch-all mapping. They are errors, but do not carry enough information for a
259
+ * more specific normalized code.
260
+ */
261
+ const ROUTEROS_RESULT_CATCH_ALL =
262
+ /^(?:syntax error|expected (?:end of command|command name|input value))\b[^\n]*$/i;
263
+
264
+ /**
265
+ * Map text returned by a successful transport only when it matches a grounded,
266
+ * stdout-safe result shape. Returns `undefined` for ordinary script output.
267
+ */
268
+ export function mapRouterOsResultError(
269
+ raw: string,
270
+ opts: MapRouterOsErrorOptions = {},
271
+ ): CentrsError | undefined {
272
+ const trimmed = raw.trim();
273
+ if (trimmed.length === 0) return undefined;
274
+ const matched =
275
+ ROUTEROS_RESULT_CATCH_ALL.test(trimmed) ||
276
+ routerOsErrorRules.some((rule) => rule.resultTest?.test(trimmed));
277
+ return matched ? mapRouterOsError(raw, opts) : undefined;
278
+ }
279
+
214
280
  /**
215
281
  * Maps a raw RouterOS error string to a normalized {@link CentrsError}.
216
282
  *
@@ -0,0 +1,4 @@
1
+ import packageMetadata from "../../package.json" with { type: "json" };
2
+
3
+ /** Package version reported by every frontend and the root library surface. */
4
+ export const centrsVersion = packageMetadata.version;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Envelope YAML rendering — the `--format yaml` writer shared by every command.
3
+ *
4
+ * This is a **renderer, not a YAML library**: it serializes the envelope shapes
5
+ * centrs builds (scalars, arrays, plain objects) and quotes every string through
6
+ * `JSON.stringify`, which is valid YAML double-quoted scalar syntax. It does not
7
+ * parse YAML, emit anchors/aliases, or handle cycles.
8
+ *
9
+ * It lives in `src/core/` because it is **pure** — no Bun, filesystem, network,
10
+ * or CDB reach. `retrieve.ts` used to own it, which put every consumer of
11
+ * `toYaml` one import away from the transport stack; `explain`'s offline entry
12
+ * runs in a browser only because this function no longer drags that graph
13
+ * behind it (#312). Every importer now takes `toYaml` from here; `retrieve.ts`
14
+ * is one of them, and the function itself moved character for character.
15
+ */
16
+
17
+ /**
18
+ * Render `value` as YAML at `indent` spaces of nesting.
19
+ *
20
+ * `undefined` object entries are dropped (they have no YAML spelling and the
21
+ * envelope uses them for absent-optional); `undefined` in scalar position and
22
+ * `null` both render `null`. A non-plain object that is neither array nor
23
+ * scalar falls back to its JSON text as a quoted scalar rather than guessing.
24
+ */
25
+ export function toYaml(value: unknown, indent = 0): string {
26
+ const prefix = " ".repeat(indent);
27
+ if (value === null || value === undefined) {
28
+ return "null";
29
+ }
30
+ if (typeof value === "string") {
31
+ return yamlScalar(value);
32
+ }
33
+ if (typeof value === "number" || typeof value === "boolean") {
34
+ return String(value);
35
+ }
36
+ if (Array.isArray(value)) {
37
+ if (value.length === 0) {
38
+ return `${prefix}[]`;
39
+ }
40
+ return value
41
+ .map((item) => {
42
+ if (isScalar(item)) {
43
+ return `${prefix}- ${toYaml(item, indent + 2)}`;
44
+ }
45
+ return `${prefix}-\n${toYaml(item, indent + 2)}`;
46
+ })
47
+ .join("\n");
48
+ }
49
+ if (isPlainObject(value)) {
50
+ const entries = Object.entries(value).filter(
51
+ ([, entryValue]) => entryValue !== undefined,
52
+ );
53
+ if (entries.length === 0) {
54
+ return `${prefix}{}`;
55
+ }
56
+ return entries
57
+ .map(([key, entryValue]) => {
58
+ if (isScalar(entryValue)) {
59
+ return `${prefix}${key}: ${toYaml(entryValue, indent + 2)}`;
60
+ }
61
+ return `${prefix}${key}:\n${toYaml(entryValue, indent + 2)}`;
62
+ })
63
+ .join("\n");
64
+ }
65
+ return yamlScalar(JSON.stringify(value));
66
+ }
67
+
68
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value);
70
+ }
71
+
72
+ function isScalar(value: unknown): value is boolean | number | string | null {
73
+ return (
74
+ value === null ||
75
+ typeof value === "string" ||
76
+ typeof value === "number" ||
77
+ typeof value === "boolean"
78
+ );
79
+ }
80
+
81
+ function yamlScalar(value: string): string {
82
+ if (value.length === 0) {
83
+ return '""';
84
+ }
85
+ return JSON.stringify(value);
86
+ }
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import {
23
+ chmod,
23
24
  copyFile,
24
25
  mkdir,
25
26
  open,
@@ -137,10 +138,21 @@ export async function writeWinBoxCdb(
137
138
  let backupPath: string | undefined;
138
139
  const prunedBackups: string[] = [];
139
140
  const targetExists = await pathExists(target);
141
+ // The backup and the replacement both carry the CDB's mode (a new CDB gets
142
+ // 0600): the file holds device credentials, and neither a plain open nor
143
+ // `copyFile` is guaranteed to keep it narrower than the umask default,
144
+ // e.g. 0644 (#382 review).
145
+ const mode = targetExists ? (await stat(target)).mode & 0o777 : 0o600;
140
146
  if (targetExists && !options.skipBackup) {
141
147
  const stamp = backupStamp(options.now ?? new Date());
142
148
  backupPath = join(dir, `${backupPrefix(target)}${stamp}`);
143
149
  await copyFile(target, backupPath);
150
+ try {
151
+ await chmod(backupPath, mode);
152
+ } catch (error) {
153
+ await unlink(backupPath).catch(() => undefined);
154
+ throw error;
155
+ }
144
156
 
145
157
  if (retention >= 0) {
146
158
  const backups = await listWinBoxCdbBackups(target);
@@ -159,15 +171,17 @@ export async function writeWinBoxCdb(
159
171
  dir,
160
172
  `${basename(target)}${TEMP_INFIX}${process.pid}.${Date.now().toString(36)}`,
161
173
  );
162
- const handle = await open(tempPath, "w");
163
- try {
164
- await handle.write(bytes);
165
- await handle.sync();
166
- } finally {
167
- await handle.close();
168
- }
169
-
174
+ // Any failure before the rename removes the temp file: it holds the
175
+ // serialized CDB, credentials included.
170
176
  try {
177
+ const handle = await open(tempPath, "w", mode);
178
+ try {
179
+ await handle.chmod(mode);
180
+ await handle.write(bytes);
181
+ await handle.sync();
182
+ } finally {
183
+ await handle.close();
184
+ }
171
185
  await rename(tempPath, target);
172
186
  } catch (error) {
173
187
  await unlink(tempPath).catch(() => undefined);
package/src/devices.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { mkdir, open } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { link, mkdir, open, unlink } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
5
  import type {
@@ -79,6 +80,10 @@ export interface DevicesListItem {
79
80
  user: string;
80
81
  cdbRecordIndex: number;
81
82
  source?: string;
83
+ /** `identity=` / `mac=` / `ip=` comment lookup keys — the names `<router>` resolves by (issue #378). */
84
+ identity?: string;
85
+ mac?: string;
86
+ ip?: string;
82
87
  sources?: Record<string, SettingSource>;
83
88
  /** GPS location, parsed from the `lat`/`lon`/`altitude`/`altitude-type` comment facts (issue #146). */
84
89
  location?: DeviceLocation;
@@ -102,6 +107,7 @@ export interface DevicesGroupSummary {
102
107
  }
103
108
 
104
109
  export type DevicesCommand =
110
+ | "init"
105
111
  | "list"
106
112
  | "show"
107
113
  | "groups"
@@ -223,22 +229,106 @@ function isAlreadyExistsError(cause: unknown): boolean {
223
229
 
224
230
  /**
225
231
  * Create an empty open CDB at `path` without clobbering an existing file.
226
- * Uses an exclusive-create open (`wx`) so a concurrent writer's CDB is never
227
- * overwritten; the caller treats EEXIST as "another process created it".
228
- * Mode `0o600` keeps the CDB (device credentials) unreadable by other local
229
- * users instead of falling back to the process umask default.
232
+ * The bytes are written to a private temp file first and then hard-linked
233
+ * into place: `link` fails with EEXIST instead of replacing, and `path` never
234
+ * exists half-written, so a concurrent creator that loses the race reads a
235
+ * complete CDB (#382 review). Returns false when `link` found a CDB already
236
+ * there (another process created it). Only `link`'s EEXIST means that: a
237
+ * recursive `mkdir` also reports EEXIST when a path component is a regular
238
+ * file. Mode `0o600` keeps the CDB (device credentials) unreadable by other
239
+ * local users instead of falling back to the process umask default.
230
240
  */
231
- async function createEmptyCdbNoClobber(path: string): Promise<void> {
241
+ async function createEmptyCdbNoClobber(path: string): Promise<boolean> {
232
242
  await mkdir(dirname(path), { recursive: true });
233
- const handle = await open(path, "wx", 0o600);
243
+ const tempPath = `${path}.init.${randomUUID()}`;
244
+ const handle = await open(tempPath, "wx", 0o600);
234
245
  try {
235
- await handle.write(encodeOpenWinBoxCdb([]));
236
- await handle.sync();
246
+ try {
247
+ await handle.chmod(0o600);
248
+ await handle.write(encodeOpenWinBoxCdb([]));
249
+ await handle.sync();
250
+ } finally {
251
+ await handle.close();
252
+ }
253
+ await link(tempPath, path);
254
+ return true;
255
+ } catch (error) {
256
+ if (isAlreadyExistsError(error)) {
257
+ return false;
258
+ }
259
+ throw error;
237
260
  } finally {
238
- await handle.close();
261
+ await unlink(tempPath);
239
262
  }
240
263
  }
241
264
 
265
+ export interface DevicesInitData {
266
+ cdbFile: string;
267
+ /** False when a readable CDB was already there; nothing was written. */
268
+ created: boolean;
269
+ recordCount: number;
270
+ }
271
+
272
+ /**
273
+ * `devices init` (#376): create an empty open CDB at the resolved path, the
274
+ * explicit counterpart of the default path's first-run auto-create. Never
275
+ * clobbers: like `git init`, re-running on an existing, readable CDB succeeds
276
+ * with a `cdb/file-exists` warning and writes nothing, so a workspace bootstrap
277
+ * script can run it unconditionally. An existing file that does not load
278
+ * (not a CDB, or encrypted without a password) fails with the load error.
279
+ */
280
+ export async function initCdb(
281
+ options: LoadCdbOptions,
282
+ ): Promise<CentrsSuccessEnvelope<DevicesInitData, DevicesOperationMeta>> {
283
+ const settings = resolveDevicesSettings(options);
284
+ const cdbFile = settings.cdbFile.value;
285
+ const existingResult = async () => {
286
+ const existing = await loadCdb(options);
287
+ return {
288
+ ok: true as const,
289
+ data: { cdbFile, created: false, recordCount: existing.entries.length },
290
+ warnings: [
291
+ ...existing.warnings,
292
+ {
293
+ code: "cdb/file-exists",
294
+ message: `A CDB already exists at ${cdbFile} (${existing.entries.length} record(s)); nothing was written.`,
295
+ },
296
+ ],
297
+ tips: [],
298
+ meta: devicesMeta("init", settings),
299
+ };
300
+ };
301
+ // Check first: an existing CDB in a read-only directory is still an
302
+ // idempotent success, although no temp file could be created beside it.
303
+ // A `false` from the create covers a creator that wins the race after this.
304
+ if (await Bun.file(cdbFile).exists()) {
305
+ return existingResult();
306
+ }
307
+ let created: boolean;
308
+ try {
309
+ created = await createEmptyCdbNoClobber(cdbFile);
310
+ } catch (cause) {
311
+ throw new CentrsError({
312
+ code: "cdb/create-failed",
313
+ summary: `Could not create a CDB at ${cdbFile}.`,
314
+ remediation:
315
+ "Check that the directory is writable, or pass --cdb-file PATH to a writable location.",
316
+ context: { cdbFile },
317
+ cause,
318
+ });
319
+ }
320
+ if (!created) {
321
+ return existingResult();
322
+ }
323
+ return {
324
+ ok: true,
325
+ data: { cdbFile, created: true, recordCount: 0 },
326
+ warnings: [],
327
+ tips: [],
328
+ meta: devicesMeta("init", settings),
329
+ };
330
+ }
331
+
242
332
  export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
243
333
  const settings = resolveDevicesSettings(options);
244
334
  const warnings: DevicesWarning[] = [];
@@ -250,33 +340,33 @@ export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
250
340
  // `devices`/identity commands work out of the box. An explicit missing
251
341
  // path stays an error (typo protection).
252
342
  if (settings.cdbFile.source.kind === "default") {
343
+ // `false` means another process won the race and created the CDB
344
+ // after our existence check; that file is authoritative, so fall
345
+ // through and read it instead of erroring.
346
+ let created: boolean;
253
347
  try {
254
- await createEmptyCdbNoClobber(settings.cdbFile.value);
348
+ created = await createEmptyCdbNoClobber(settings.cdbFile.value);
349
+ } catch (cause) {
350
+ throw new CentrsError({
351
+ code: "cdb/not-found",
352
+ summary: `CDB file not found and could not be created: ${settings.cdbFile.value}`,
353
+ remediation:
354
+ "Check directory permissions for ~/.config/tikoci, or pass --cdb-file PATH to a writable location.",
355
+ context: { cdbFile: settings.cdbFile.value },
356
+ cause,
357
+ });
358
+ }
359
+ if (created) {
255
360
  warnings.push({
256
361
  code: "cdb/created",
257
362
  message: `No CDB found at the default path; created an empty CDB at ${settings.cdbFile.value}.`,
258
363
  });
259
- } catch (cause) {
260
- // EEXIST means another process won the race and created the CDB
261
- // between our existence check and the exclusive open — that file
262
- // is authoritative; fall through and read it instead of erroring.
263
- if (!isAlreadyExistsError(cause)) {
264
- throw new CentrsError({
265
- code: "cdb/not-found",
266
- summary: `CDB file not found and could not be created: ${settings.cdbFile.value}`,
267
- remediation:
268
- "Check directory permissions for ~/.config/tikoci, or pass --cdb-file PATH to a writable location.",
269
- context: { cdbFile: settings.cdbFile.value },
270
- cause,
271
- });
272
- }
273
364
  }
274
365
  } else {
275
366
  throw new CentrsError({
276
367
  code: "cdb/not-found",
277
368
  summary: `CDB file not found: ${settings.cdbFile.value}`,
278
- remediation:
279
- "Pass --cdb-file PATH, set CENTRS_CDB_FILE, or place a CDB at ~/.config/tikoci/winbox.cdb.",
369
+ remediation: `To start a new CDB there, run \`centrs devices init --cdb-file ${settings.cdbFile.value}\`. Otherwise check the path; an explicit --cdb-file or CENTRS_CDB_FILE is never created implicitly.`,
280
370
  context: { cdbFile: settings.cdbFile.value },
281
371
  });
282
372
  }
@@ -752,6 +842,18 @@ function entryToListItem(
752
842
  key: `record:${cdbRecordIndex}:source`,
753
843
  };
754
844
  }
845
+ for (const key of commentKvLookupKeys) {
846
+ const value = parsed.lookups[key];
847
+ // Same non-blank rule as `entryRouterKeys`: never advertise a key that
848
+ // does not resolve.
849
+ if (value !== undefined && value.trim().length > 0) {
850
+ item[key] = value;
851
+ sources[key] = {
852
+ kind: "comment-kv",
853
+ key: `record:${cdbRecordIndex}:${key}`,
854
+ };
855
+ }
856
+ }
755
857
  const location = deviceLocation(parseRawCommentFacts(entry.comment));
756
858
  if (location !== undefined) {
757
859
  item.location = location;
@@ -1753,6 +1855,15 @@ function renderText(envelope: DevicesEnvelope<unknown>): string {
1753
1855
  }
1754
1856
 
1755
1857
  switch (envelope.meta.operation?.command) {
1858
+ case "init": {
1859
+ const data = envelope.data as DevicesInitData;
1860
+ lines.push(
1861
+ data.created
1862
+ ? `created ${data.cdbFile}`
1863
+ : `exists ${data.cdbFile} (${data.recordCount} record(s))`,
1864
+ );
1865
+ break;
1866
+ }
1756
1867
  case "list":
1757
1868
  renderListText(lines, envelope.data as readonly DevicesListItem[]);
1758
1869
  break;
@@ -1828,12 +1939,15 @@ function renderListText(
1828
1939
  lines.push("(no entries)");
1829
1940
  return;
1830
1941
  }
1831
- lines.push(["INDEX", "TARGET", "TYPE", "USER", "GROUP"].join("\t"));
1942
+ lines.push(
1943
+ ["INDEX", "TARGET", "IDENTITY", "TYPE", "USER", "GROUP"].join("\t"),
1944
+ );
1832
1945
  for (const item of items) {
1833
1946
  lines.push(
1834
1947
  [
1835
1948
  String(item.cdbRecordIndex),
1836
1949
  item.target,
1950
+ item.identity || "-",
1837
1951
  item.recordTypeName,
1838
1952
  item.user || "-",
1839
1953
  item.group || "-",