@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.
- package/CHANGELOG.md +417 -1
- package/GLOSSARY.txt +40 -1
- package/README.md +16 -8
- package/commands/AGENTS.md +7 -4
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +50 -10
- package/commands/api/examples.md +100 -2
- package/commands/devices/README.md +10 -1
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +68 -25
- package/commands/execute/examples.md +168 -7
- package/commands/explain/README.md +1206 -155
- package/commands/explain/examples.md +99 -3
- package/commands/mcp/README.md +9 -5
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/examples.md +20 -0
- package/commands/terminal/examples.md +7 -1
- package/docs/CLI.md +3 -3
- package/docs/CONSTITUTION.md +83 -21
- package/docs/MATRIX.md +89 -52
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/package.json +13 -2
- package/src/api-fanout.ts +17 -4
- package/src/api.ts +346 -75
- package/src/cli/api.ts +1 -1
- package/src/cli/btest.ts +1 -1
- package/src/cli/devices.ts +16 -3
- package/src/cli/explain.ts +1 -1
- package/src/cli/terminal.ts +1 -1
- package/src/core/envelope.ts +53 -0
- package/src/core/error-catalog.ts +16 -1
- package/src/core/execute-command.ts +455 -0
- package/src/core/inspect.ts +58 -26
- package/src/core/routeros-errors.ts +66 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +142 -28
- package/src/execute-fanout.ts +19 -5
- package/src/execute.ts +247 -295
- package/src/explain/arg-tokens.ts +62 -25
- package/src/explain/args.ts +398 -52
- package/src/explain/blocks.ts +54 -11
- package/src/explain/brace-tokens.ts +4 -3
- package/src/explain/catalog.ts +42 -44
- package/src/explain/coordinates.ts +159 -0
- package/src/explain/defects.ts +2 -1
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/menus.ts +9 -5
- package/src/explain/operator-tokens.ts +13 -7
- package/src/explain/operators.ts +27 -20
- package/src/explain/path-tokens.ts +1 -1
- package/src/explain/pathresolve.ts +358 -113
- package/src/explain/quoted-string.ts +110 -20
- package/src/explain/scope-brace.ts +175 -22
- package/src/explain/segment.ts +429 -21
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +14 -4
- package/src/explain/symbol-values.ts +209 -173
- package/src/explain/symbols.ts +21 -1
- package/src/explain/transport.ts +17 -0
- package/src/explain/value-tokens.ts +15 -12
- package/src/explain/verbsplit.ts +165 -23
- package/src/explain/write.ts +47 -6
- package/src/explain.ts +522 -72
- package/src/index.ts +21 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/adapter.ts +12 -1
- package/src/protocols/mac-telnet-console.ts +6 -4
- package/src/protocols/native-api.ts +29 -3
- package/src/resolver/target.ts +32 -12
- package/src/retrieve-fanout.ts +1 -1
- package/src/retrieve.ts +18 -63
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +1 -1
- package/src/transfer.ts +1 -1
package/src/core/inspect.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
.
|
|
108
|
-
.
|
|
109
|
-
.
|
|
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
|
*
|
package/src/core/yaml.ts
ADDED
|
@@ -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
|
-
|
|
163
|
-
|
|
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 {
|
|
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
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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<
|
|
241
|
+
async function createEmptyCdbNoClobber(path: string): Promise<boolean> {
|
|
232
242
|
await mkdir(dirname(path), { recursive: true });
|
|
233
|
-
const
|
|
243
|
+
const tempPath = `${path}.init.${randomUUID()}`;
|
|
244
|
+
const handle = await open(tempPath, "wx", 0o600);
|
|
234
245
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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(
|
|
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 || "-",
|