@tikoci/centrs 0.1.2 → 0.1.4
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 +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- package/src/transfer.ts +133 -40
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `centrs explain` CLI surface: command metadata, arg parsing, dispatch, and
|
|
3
|
+
* envelope rendering for the phase-1 OFFLINE analysis (#202b, #202c-2).
|
|
4
|
+
*
|
|
5
|
+
* ## The positional grammar is conditional, and that is deliberate
|
|
6
|
+
*
|
|
7
|
+
* `explain '<input>'` is offline; `explain <router> '<input>'` is live. One
|
|
8
|
+
* positional means the input, two mean router-then-input — target-first like
|
|
9
|
+
* every other router-taking command, so adding a router never reinterprets a
|
|
10
|
+
* previously valid offline invocation (`commands/explain/README.md` → Surface).
|
|
11
|
+
* No other centrs command has an optional target where arity changes meaning,
|
|
12
|
+
* which is why the split is done here rather than in a shared helper.
|
|
13
|
+
*
|
|
14
|
+
* `--file` REPLACES the input positional, so with it every positional is a
|
|
15
|
+
* target. **Ambient stdin does not**: it is read only when no positional could
|
|
16
|
+
* be the input, a positional always wins, and a redirected fd 0 that went
|
|
17
|
+
* unread is reported as a `usage/stdin-ignored` warning rather than dropped
|
|
18
|
+
* quietly. `--file -` names stdin explicitly and works in both forms. The
|
|
19
|
+
* reasoning — and the measurement behind it — is on {@link readInput}.
|
|
20
|
+
*
|
|
21
|
+
* ## What this surface refuses to do
|
|
22
|
+
*
|
|
23
|
+
* - **The live form is rejected, not degraded.** Phase 2 owns the probes; a
|
|
24
|
+
* phase-1 run that silently analyzed offline while a router was named would
|
|
25
|
+
* report `mode: "offline"` about an invocation that asked for more.
|
|
26
|
+
* - **Fan-out is rejected** (`usage/fanout-not-supported`, the `terminal`
|
|
27
|
+
* pattern): `explain` takes at most one router.
|
|
28
|
+
* - **`--complete` / `--schema` enumerate nothing offline.** They are accepted,
|
|
29
|
+
* they emit the live-target tip, and they add no fabricated `data.schema` —
|
|
30
|
+
* offline consults no schema snapshot, by ratified decision.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { fstatSync } from "node:fs";
|
|
34
|
+
import type { Warning } from "../core/envelope.ts";
|
|
35
|
+
import { CentrsError, formatCentrsErrorText } from "../errors.ts";
|
|
36
|
+
import {
|
|
37
|
+
buildExplainErrorEnvelope,
|
|
38
|
+
buildExplainFacetTip,
|
|
39
|
+
type ExplainFailOn,
|
|
40
|
+
type ExplainOutputFormat,
|
|
41
|
+
explainEnvelope,
|
|
42
|
+
explainExitCode,
|
|
43
|
+
explainFailOnLevels,
|
|
44
|
+
explainOutputFormats,
|
|
45
|
+
renderExplainEnvelope,
|
|
46
|
+
resolveExplainFormat,
|
|
47
|
+
} from "../explain.ts";
|
|
48
|
+
import { describeCentrs } from "../index.ts";
|
|
49
|
+
import { loadEnvFileDefaults } from "../resolver/config-file.ts";
|
|
50
|
+
import type { ResolvedSetting } from "../resolver/settings.ts";
|
|
51
|
+
import {
|
|
52
|
+
type CliCommandMetadata,
|
|
53
|
+
expectValue,
|
|
54
|
+
renderCommandHelp,
|
|
55
|
+
unknownFlagError,
|
|
56
|
+
} from "./common.ts";
|
|
57
|
+
import { selectionFlagTokens } from "./selection.ts";
|
|
58
|
+
|
|
59
|
+
export const explainCliCommand: CliCommandMetadata = {
|
|
60
|
+
name: "explain",
|
|
61
|
+
usage: "centrs explain '<input>' [flags]",
|
|
62
|
+
summary:
|
|
63
|
+
"Analyze a RouterOS command offline: canonical form, structure, and syntax diagnostics.",
|
|
64
|
+
options: [
|
|
65
|
+
{
|
|
66
|
+
flag: "--file",
|
|
67
|
+
valueName: "<path>",
|
|
68
|
+
description:
|
|
69
|
+
"Read the input from a file instead of the positional (`-` reads stdin). Piped stdin is used automatically when no input positional is given.",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
flag: "--fail-on",
|
|
73
|
+
valueName: `<${explainFailOnLevels.join("|")}>`,
|
|
74
|
+
description:
|
|
75
|
+
"Exit 2 when the verdict reaches this severity. Default `error`; an ambiguous or unknown statement is a warning, never an error.",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
flag: "--complete",
|
|
79
|
+
description:
|
|
80
|
+
"Continuation candidates at the cursor. Live evidence — offline emits a tip and enumerates nothing.",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
flag: "--schema",
|
|
84
|
+
description:
|
|
85
|
+
"Path enumeration (verbs, args, types, enums). Live evidence — offline emits a tip and enumerates nothing.",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
flag: "--curl",
|
|
89
|
+
description:
|
|
90
|
+
"Render a ready-to-edit REST curl for statements covered by a runtime-tested mapping rule.",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
flag: "--tokens",
|
|
94
|
+
description:
|
|
95
|
+
"Emit the total, gapless token partition behind `data.tokens[]` (provisional `class` until #264 B5: every byte not claimed by an analyzer is `unclassified`).",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
flag: "--format",
|
|
99
|
+
valueName: `<${explainOutputFormats.join("|")}>`,
|
|
100
|
+
description: "Output format for the CLI response.",
|
|
101
|
+
},
|
|
102
|
+
{ flag: "--json", description: "Shortcut for `--format json`." },
|
|
103
|
+
{ flag: "--yaml", description: "Shortcut for `--format yaml`." },
|
|
104
|
+
{ flag: "--verbose", description: "Verbose error output." },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export interface ExplainCliArgs {
|
|
109
|
+
help?: boolean;
|
|
110
|
+
positionals: string[];
|
|
111
|
+
filePath?: string;
|
|
112
|
+
failOn: ExplainFailOn;
|
|
113
|
+
facets: string[];
|
|
114
|
+
curl?: boolean;
|
|
115
|
+
tokens?: boolean;
|
|
116
|
+
format?: ExplainOutputFormat;
|
|
117
|
+
verbose?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
|
|
121
|
+
const parsed: ExplainCliArgs = {
|
|
122
|
+
positionals: [],
|
|
123
|
+
failOn: "error",
|
|
124
|
+
facets: [],
|
|
125
|
+
};
|
|
126
|
+
let literal = false;
|
|
127
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
128
|
+
const arg = args[index];
|
|
129
|
+
if (arg === undefined) continue;
|
|
130
|
+
if (literal) {
|
|
131
|
+
parsed.positionals.push(arg);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
switch (arg) {
|
|
135
|
+
case "--":
|
|
136
|
+
// Everything after `--` is input, however command-shaped it looks:
|
|
137
|
+
// RouterOS input legitimately starts with `-` (`--` is also a comment
|
|
138
|
+
// in some spellings) and must never be read as a centrs flag.
|
|
139
|
+
literal = true;
|
|
140
|
+
break;
|
|
141
|
+
case "--help":
|
|
142
|
+
case "-h":
|
|
143
|
+
parsed.help = true;
|
|
144
|
+
break;
|
|
145
|
+
case "--file":
|
|
146
|
+
parsed.filePath = expectValue(args, ++index, arg);
|
|
147
|
+
break;
|
|
148
|
+
case "--fail-on": {
|
|
149
|
+
const value = expectValue(args, ++index, arg);
|
|
150
|
+
if (!(explainFailOnLevels as readonly string[]).includes(value)) {
|
|
151
|
+
throw new CentrsError({
|
|
152
|
+
code: "input/invalid-command",
|
|
153
|
+
summary: `--fail-on must be one of ${explainFailOnLevels.join(", ")}; got ${value}.`,
|
|
154
|
+
remediation:
|
|
155
|
+
"Pass `--fail-on error` (default), `--fail-on warning`, or `--fail-on never`.",
|
|
156
|
+
context: { failOn: value },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
parsed.failOn = value as ExplainFailOn;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "--complete":
|
|
163
|
+
parsed.facets.push("--complete");
|
|
164
|
+
break;
|
|
165
|
+
case "--schema":
|
|
166
|
+
parsed.facets.push("--schema");
|
|
167
|
+
break;
|
|
168
|
+
case "--curl":
|
|
169
|
+
parsed.curl = true;
|
|
170
|
+
break;
|
|
171
|
+
case "--tokens":
|
|
172
|
+
parsed.tokens = true;
|
|
173
|
+
break;
|
|
174
|
+
case "--format": {
|
|
175
|
+
const value = expectValue(args, ++index, arg);
|
|
176
|
+
if (!(explainOutputFormats as readonly string[]).includes(value)) {
|
|
177
|
+
throw new CentrsError({
|
|
178
|
+
code: "settings/invalid-format",
|
|
179
|
+
summary: `--format must be one of ${explainOutputFormats.join(", ")}; got ${value}.`,
|
|
180
|
+
remediation: `Choose one of ${explainOutputFormats.join(", ")}.`,
|
|
181
|
+
context: { format: value },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
parsed.format = value as ExplainOutputFormat;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "--json":
|
|
188
|
+
parsed.format = "json";
|
|
189
|
+
break;
|
|
190
|
+
case "--yaml":
|
|
191
|
+
parsed.format = "yaml";
|
|
192
|
+
break;
|
|
193
|
+
case "--verbose":
|
|
194
|
+
parsed.verbose = true;
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
// `explain` analyzes one input against at most one router, so a
|
|
198
|
+
// multi-target selection has nothing to mean here (the `terminal`
|
|
199
|
+
// pattern, shared catalog code).
|
|
200
|
+
if ((selectionFlagTokens as readonly string[]).includes(arg)) {
|
|
201
|
+
throw fanoutNotSupportedError({ flag: arg });
|
|
202
|
+
}
|
|
203
|
+
if (arg.startsWith("-")) {
|
|
204
|
+
throw unknownFlagError("explain", arg, explainCliCommand.options);
|
|
205
|
+
}
|
|
206
|
+
parsed.positionals.push(arg);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return parsed;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The one fan-out refusal, for both ways of asking for one.
|
|
215
|
+
*
|
|
216
|
+
* A selection flag and more than one positional target are the SAME fact under
|
|
217
|
+
* the constitution (`isFanoutMode` keys on either), so they get the same code.
|
|
218
|
+
* Giving the positional form its own `input/invalid-command` would have made a
|
|
219
|
+
* documented contract — the module header and the command README both promise
|
|
220
|
+
* `usage/fanout-not-supported` — false for half its cases.
|
|
221
|
+
*/
|
|
222
|
+
function fanoutNotSupportedError(context: {
|
|
223
|
+
flag?: string;
|
|
224
|
+
targets?: readonly string[];
|
|
225
|
+
}): CentrsError {
|
|
226
|
+
return new CentrsError({
|
|
227
|
+
code: "usage/fanout-not-supported",
|
|
228
|
+
summary:
|
|
229
|
+
"`centrs explain` analyzes one input against at most one router and cannot fan out across a multi-target selection.",
|
|
230
|
+
remediation:
|
|
231
|
+
"Pass one input and at most one router, quoting the input so the shell keeps it as one argument (`centrs explain '/ip/route print'`); use `execute` / `api` for multi-target commands.",
|
|
232
|
+
context: {
|
|
233
|
+
capability: "explain",
|
|
234
|
+
...(context.flag === undefined ? {} : { flag: context.flag }),
|
|
235
|
+
...(context.targets === undefined
|
|
236
|
+
? {}
|
|
237
|
+
: { targets: [...context.targets] }),
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The one live-form error.
|
|
244
|
+
*
|
|
245
|
+
* Named separately because it is a PHASE boundary, not a usage mistake: the
|
|
246
|
+
* invocation is the ratified grammar and will work unchanged when phase 2
|
|
247
|
+
* lands, so the remediation points at the offline form rather than at `--help`.
|
|
248
|
+
*/
|
|
249
|
+
function liveNotImplementedError(router: string): CentrsError {
|
|
250
|
+
// A "router" that starts `/` or `:` is almost always an unquoted input the
|
|
251
|
+
// shell split, so name that reading first rather than reporting a router
|
|
252
|
+
// nobody meant to give.
|
|
253
|
+
const looksLikeInput = /^[/:]/.test(router);
|
|
254
|
+
return new CentrsError({
|
|
255
|
+
code: "usage/not-implemented",
|
|
256
|
+
summary: looksLikeInput
|
|
257
|
+
? `Read \`${router}\` as a router, because two positionals mean \`<router> '<input>'\` — the input was probably not quoted.`
|
|
258
|
+
: `\`centrs explain <router> '<input>'\` needs live \`/console/inspect\` evidence, which is phase 2; the router \`${router}\` was not contacted.`,
|
|
259
|
+
remediation: looksLikeInput
|
|
260
|
+
? "Quote the whole input as one argument: `centrs explain '/ip/route print'`."
|
|
261
|
+
: "Drop the router for the offline analysis (`centrs explain '<input>'`), or use `rosetta` for documented RouterOS schema facts.",
|
|
262
|
+
context: { router, phase: "2" },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Is fd 0 attached to something that could be carrying input?
|
|
268
|
+
*
|
|
269
|
+
* A **stat**, never a read. Stated as an exclusion, because the carriers are
|
|
270
|
+
* open-ended while the non-carriers are two: a TTY (a human who would be left
|
|
271
|
+
* waiting on EOF) and the ambient `/dev/null` that CI runners and `< /dev/null`
|
|
272
|
+
* scripts attach. Both are character devices, and so is `/dev/zero`, the one
|
|
273
|
+
* shape whose read never ends. Everything else — a shell pipe, a redirected
|
|
274
|
+
* file, the socket `Bun.spawn` hands a child — may be handing us bytes.
|
|
275
|
+
*
|
|
276
|
+
* An `isTTY`-only check is not enough: it reads `< /dev/null` as "the user
|
|
277
|
+
* piped input", which would break every non-interactive invocation.
|
|
278
|
+
*/
|
|
279
|
+
function stdinMayCarryInput(): boolean {
|
|
280
|
+
try {
|
|
281
|
+
const stat = fstatSync(0);
|
|
282
|
+
return !stat.isCharacterDevice() && !stat.isDirectory();
|
|
283
|
+
} catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Split the positionals into targets and the one that may be the input.
|
|
290
|
+
*
|
|
291
|
+
* Pure and cheap **on purpose**: the caller must be able to refuse the live and
|
|
292
|
+
* fan-out forms BEFORE any input source is touched. Reading first meant
|
|
293
|
+
* `centrs explain router --file -` blocked on EOF before returning
|
|
294
|
+
* `usage/not-implemented`, and `… --file missing` reported a file error instead
|
|
295
|
+
* of the live refusal — the wrong answer, arrived at slowly.
|
|
296
|
+
*
|
|
297
|
+
* `--file` replaces the input positional, so with it every positional is a
|
|
298
|
+
* target. Otherwise the LAST positional is the input and everything before it is
|
|
299
|
+
* a target; splitting from the end is what keeps the two forms unambiguous.
|
|
300
|
+
*
|
|
301
|
+
* PRESENCE, never truthiness: `centrs explain ""` gave an empty positional that
|
|
302
|
+
* a `&&` spread dropped, so the same argv fell through to ambient stdin and
|
|
303
|
+
* analyzed whatever was piped in. An explicit empty argument is a mistake the
|
|
304
|
+
* user made, not an absent argument, and "a positional always wins" has to hold
|
|
305
|
+
* for it too — otherwise identical argv means different things depending on the
|
|
306
|
+
* shape of fd 0.
|
|
307
|
+
*/
|
|
308
|
+
function splitPositionals(parsed: ExplainCliArgs): {
|
|
309
|
+
targets: readonly string[];
|
|
310
|
+
inputPositional?: string;
|
|
311
|
+
} {
|
|
312
|
+
if (parsed.filePath !== undefined) return { targets: parsed.positionals };
|
|
313
|
+
const positionals = [...parsed.positionals];
|
|
314
|
+
const inputPositional = positionals.pop();
|
|
315
|
+
return inputPositional === undefined
|
|
316
|
+
? { targets: positionals }
|
|
317
|
+
: { targets: positionals, inputPositional };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Read the input text, once the invocation is known to be offline.
|
|
322
|
+
*
|
|
323
|
+
* ## Ambient stdin is read ONLY when nothing else could be the input
|
|
324
|
+
*
|
|
325
|
+
* Measured, not assumed: consuming fd 0 whenever it looked like a carrier made
|
|
326
|
+
* `bun test` read the invoking shell's stdin, feed those bytes to the analyzer,
|
|
327
|
+
* and fail 21 unrelated tests — once, because the read DRAINED the fd and the
|
|
328
|
+
* next run passed. `runCli` is called in-process by tests, so any ambient read
|
|
329
|
+
* on a path a test can reach is a landmine that fires on whoever's terminal has
|
|
330
|
+
* bytes queued. Zero positionals is the one shape no in-process caller uses.
|
|
331
|
+
*
|
|
332
|
+
* ## The collision is reported, not resolved
|
|
333
|
+
*
|
|
334
|
+
* `… | centrs explain '<input>'` cannot silently drop the pipe — but proving the
|
|
335
|
+
* pipe has bytes would mean reading it, which is the landmine above. So the
|
|
336
|
+
* stat-only signal becomes a WARNING on a result that is otherwise exactly what
|
|
337
|
+
* the arguments asked for, naming `--file -` as the way to mean the other thing.
|
|
338
|
+
* A warning is a fact about the invocation; it changes no analysis, so it cannot
|
|
339
|
+
* make a test's subject non-deterministic.
|
|
340
|
+
*/
|
|
341
|
+
async function readInput(
|
|
342
|
+
parsed: ExplainCliArgs,
|
|
343
|
+
inputPositional: string | undefined,
|
|
344
|
+
): Promise<{ input: string; warnings: readonly Warning[] }> {
|
|
345
|
+
if (parsed.filePath !== undefined) {
|
|
346
|
+
const input =
|
|
347
|
+
parsed.filePath === "-"
|
|
348
|
+
? await Bun.stdin.text()
|
|
349
|
+
: await readFileInput(parsed.filePath);
|
|
350
|
+
return { input, warnings: [] };
|
|
351
|
+
}
|
|
352
|
+
if (inputPositional === undefined) {
|
|
353
|
+
const piped = stdinMayCarryInput() ? await Bun.stdin.text() : "";
|
|
354
|
+
if (piped.trim() === "") throw missingInputError();
|
|
355
|
+
return { input: piped, warnings: [] };
|
|
356
|
+
}
|
|
357
|
+
// An empty positional is rejected HERE, before fd 0 is even stat'd: it is a
|
|
358
|
+
// present-but-empty argument, so falling through to stdin would make the same
|
|
359
|
+
// argv mean two different things.
|
|
360
|
+
if (inputPositional.trim() === "") throw missingInputError();
|
|
361
|
+
return {
|
|
362
|
+
input: inputPositional,
|
|
363
|
+
warnings: stdinMayCarryInput() ? [STDIN_IGNORED_WARNING] : [],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const STDIN_IGNORED_WARNING: Warning = {
|
|
368
|
+
code: "usage/stdin-ignored",
|
|
369
|
+
message:
|
|
370
|
+
"stdin is redirected, and the input came from the positional argument instead — anything piped in was NOT analyzed. Pass `--file -` to analyze stdin, or drop the positional.",
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
function missingInputError(): CentrsError {
|
|
374
|
+
return new CentrsError({
|
|
375
|
+
code: "input/invalid-command",
|
|
376
|
+
summary: "`centrs explain` needs a RouterOS input to analyze.",
|
|
377
|
+
remediation:
|
|
378
|
+
"Pass it as a quoted positional (`centrs explain '/ip/route print'`), with `--file <path>`, or on stdin.",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function readFileInput(path: string): Promise<string> {
|
|
383
|
+
const file = Bun.file(path);
|
|
384
|
+
if (!(await file.exists())) {
|
|
385
|
+
throw new CentrsError({
|
|
386
|
+
code: "input/local-file-not-found",
|
|
387
|
+
summary: `--file path not found: ${path}`,
|
|
388
|
+
remediation:
|
|
389
|
+
"Check the path, or pass the input as a quoted positional instead.",
|
|
390
|
+
context: { path },
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return await file.text();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Flags whose NEXT token is a value, so a scanner must skip it. */
|
|
397
|
+
const VALUE_FLAGS = new Set(["--file", "--fail-on", "--format"]);
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The render options, recovered when the parse loop THREW.
|
|
401
|
+
*
|
|
402
|
+
* ONE scan for both, walking the same grammar the parser does — left to right,
|
|
403
|
+
* stopping at `--`, skipping each value-flag's value, last format winning. Two
|
|
404
|
+
* `includes()` scans is how the bug appeared twice: `centrs explain -- --json`
|
|
405
|
+
* read a format request out of RouterOS input, and `-- --verbose` turned on the
|
|
406
|
+
* private error context for a router literally named `--verbose`. A second
|
|
407
|
+
* option parser that disagrees with the first is the defect, so there is one.
|
|
408
|
+
*/
|
|
409
|
+
function recoverRenderOptions(args: readonly string[]): {
|
|
410
|
+
format?: string;
|
|
411
|
+
verbose: boolean;
|
|
412
|
+
} {
|
|
413
|
+
let format: string | undefined;
|
|
414
|
+
let verbose = false;
|
|
415
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
416
|
+
const arg = args[index];
|
|
417
|
+
if (arg === undefined || arg === "--") break;
|
|
418
|
+
if (arg === "--json") format = "json";
|
|
419
|
+
else if (arg === "--yaml") format = "yaml";
|
|
420
|
+
else if (arg === "--verbose") verbose = true;
|
|
421
|
+
else if (arg === "--format") format = args[++index];
|
|
422
|
+
else if (VALUE_FLAGS.has(arg)) index += 1;
|
|
423
|
+
}
|
|
424
|
+
return { format, verbose };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The same ladder as the success path, but it must never throw: a bad
|
|
429
|
+
* `CENTRS_FORMAT` (or a bad `--format`, which is often WHY we are here) would
|
|
430
|
+
* otherwise replace the user's actual error with a complaint about rendering.
|
|
431
|
+
*/
|
|
432
|
+
function recoverFormat(
|
|
433
|
+
args: readonly string[],
|
|
434
|
+
env: Record<string, string | undefined>,
|
|
435
|
+
config: Record<string, string | undefined>,
|
|
436
|
+
): ResolvedSetting<ExplainOutputFormat> {
|
|
437
|
+
const { format } = recoverRenderOptions(args);
|
|
438
|
+
try {
|
|
439
|
+
return resolveExplainFormat(format, env, config);
|
|
440
|
+
} catch {
|
|
441
|
+
try {
|
|
442
|
+
return resolveExplainFormat(undefined, env, config);
|
|
443
|
+
} catch {
|
|
444
|
+
return { value: "text", source: { kind: "default", key: "format" } };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function runExplainCli(args: readonly string[]): Promise<number> {
|
|
450
|
+
const env = Bun.env;
|
|
451
|
+
let config: Record<string, string | undefined> = {};
|
|
452
|
+
try {
|
|
453
|
+
// Inside the try: a settings file that cannot be read or parsed is a real
|
|
454
|
+
// failure with a real remedy, and swallowing it would silently bypass the
|
|
455
|
+
// configured format and the rest of the precedence ladder.
|
|
456
|
+
config = await loadEnvFileDefaults(env);
|
|
457
|
+
const parsed = parseExplainCliArgs(args);
|
|
458
|
+
if (parsed.help) {
|
|
459
|
+
console.log(renderCommandHelp(describeCentrs(), explainCliCommand));
|
|
460
|
+
return 0;
|
|
461
|
+
}
|
|
462
|
+
// Resolved ONCE across config < env < CLI, so `meta.settings.format` can
|
|
463
|
+
// name the tier that won (`docs/CONSTITUTION.md` → Settings precedence).
|
|
464
|
+
const format = resolveExplainFormat(parsed.format, env, config);
|
|
465
|
+
|
|
466
|
+
// Targets first, from the positionals alone — no file opened, no fd read.
|
|
467
|
+
// More than one positional target IS fan-out mode under the constitution
|
|
468
|
+
// (`isFanoutMode`), so it gets the same refusal as `--group` rather than a
|
|
469
|
+
// second code for the same fact.
|
|
470
|
+
const { targets, inputPositional } = splitPositionals(parsed);
|
|
471
|
+
if (targets.length > 1) throw fanoutNotSupportedError({ targets });
|
|
472
|
+
const router = targets[0];
|
|
473
|
+
if (router !== undefined) throw liveNotImplementedError(router);
|
|
474
|
+
|
|
475
|
+
const { input, warnings } = await readInput(parsed, inputPositional);
|
|
476
|
+
|
|
477
|
+
const envelope = explainEnvelope(input, {
|
|
478
|
+
format,
|
|
479
|
+
curl: parsed.curl,
|
|
480
|
+
tokens: parsed.tokens,
|
|
481
|
+
warnings,
|
|
482
|
+
tips:
|
|
483
|
+
parsed.facets.length > 0
|
|
484
|
+
? [buildExplainFacetTip(parsed.facets)]
|
|
485
|
+
: undefined,
|
|
486
|
+
});
|
|
487
|
+
console.log(renderExplainEnvelope(envelope, format.value));
|
|
488
|
+
return explainExitCode(envelope.data.verdict, parsed.failOn);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
// Only here is the parser's answer unavailable — it threw. `config` is
|
|
491
|
+
// whatever loaded before the throw, which is `{}` when the loader itself is
|
|
492
|
+
// what failed: the tier that broke cannot also be the one that renders it.
|
|
493
|
+
const format = recoverFormat(args, env, config);
|
|
494
|
+
const { verbose } = recoverRenderOptions(args);
|
|
495
|
+
const envelope = buildExplainErrorEnvelope(error, [], format);
|
|
496
|
+
console.error(
|
|
497
|
+
format.value === "text"
|
|
498
|
+
? formatCentrsErrorText(envelope.error, { verbose })
|
|
499
|
+
: renderExplainEnvelope(envelope, format.value),
|
|
500
|
+
);
|
|
501
|
+
return 1;
|
|
502
|
+
}
|
|
503
|
+
}
|
package/src/cli/mcp.ts
CHANGED
|
@@ -11,10 +11,12 @@ import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
|
|
|
11
11
|
import { describeCentrs } from "../index.ts";
|
|
12
12
|
import { resolveMcpConfig } from "../mcp/config.ts";
|
|
13
13
|
import { runMcpStdio } from "../mcp/server.ts";
|
|
14
|
+
import { loadEnvFileDefaults } from "../resolver/index.ts";
|
|
14
15
|
import {
|
|
15
16
|
type CliCommandMetadata,
|
|
16
17
|
expectValue,
|
|
17
18
|
renderCommandHelp,
|
|
19
|
+
unknownFlagError,
|
|
18
20
|
} from "./common.ts";
|
|
19
21
|
|
|
20
22
|
export const mcpCommand: CliCommandMetadata = {
|
|
@@ -55,6 +57,9 @@ export function parseMcpCliArgs(args: readonly string[]): McpCliArgs {
|
|
|
55
57
|
const parsed: McpCliArgs = {};
|
|
56
58
|
for (let index = 0; index < args.length; index += 1) {
|
|
57
59
|
const arg = args[index];
|
|
60
|
+
if (arg === undefined) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
58
63
|
switch (arg) {
|
|
59
64
|
case "start":
|
|
60
65
|
// Optional verb; `centrs mcp` and `centrs mcp start` are equivalent.
|
|
@@ -73,7 +78,7 @@ export function parseMcpCliArgs(args: readonly string[]): McpCliArgs {
|
|
|
73
78
|
parsed.allowAdhocTargets = true;
|
|
74
79
|
break;
|
|
75
80
|
default:
|
|
76
|
-
throw
|
|
81
|
+
throw unknownFlagError("mcp", arg, mcpCommand.options);
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
return parsed;
|
|
@@ -90,11 +95,13 @@ export async function runMcpCli(args: readonly string[]): Promise<number> {
|
|
|
90
95
|
cdbFile: parsed.cdbFile,
|
|
91
96
|
cdbPassword: parsed.cdbPassword,
|
|
92
97
|
allowAdhocTargets: parsed.allowAdhocTargets,
|
|
98
|
+
config: await loadEnvFileDefaults(),
|
|
93
99
|
});
|
|
94
100
|
await runMcpStdio(config);
|
|
95
101
|
return 0;
|
|
96
102
|
} catch (error) {
|
|
97
103
|
console.error(
|
|
104
|
+
// codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
|
|
98
105
|
formatCentrsErrorText(
|
|
99
106
|
asCentrsError(error, {
|
|
100
107
|
code: "internal/mcp-start",
|