@tikoci/centrs 0.1.3 → 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 +39 -0
- package/GLOSSARY.txt +213 -0
- package/LICENSE +21 -0
- package/README.md +36 -8
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +19 -18
- package/commands/api/examples.md +15 -0
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +41 -29
- package/commands/execute/examples.md +41 -3
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +66 -15
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -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/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +45 -10
- package/src/api-fanout.ts +29 -14
- package/src/api.ts +97 -54
- package/src/cli/api.ts +25 -6
- package/src/cli/btest.ts +18 -2
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +40 -0
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +29 -14
- package/src/execute.ts +114 -63
- 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 +206 -4
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +26 -12
- package/src/retrieve.ts +81 -18
- package/src/transfer-fanout.ts +42 -25
- package/src/transfer.ts +87 -44
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote RouterOS command text as one double-quoted string literal.
|
|
3
|
+
*
|
|
4
|
+
* `$` is load-bearing here: RouterOS expands variables inside double quotes,
|
|
5
|
+
* so leaving it bare changes the bytes seen by wrappers such as
|
|
6
|
+
* `:put [:parse "<input>"]` before the parser receives them.
|
|
7
|
+
* Control whitespace (`\t`/`\r`/`\n`) is escaped so the decoded string
|
|
8
|
+
* carries the caller's original bytes. This is defensive: RouterOS 7.20
|
|
9
|
+
* changelog says "replace TAB characters with spaces when editing scripts
|
|
10
|
+
* and added `tab-width` in `/console/settings`" — version-gated (absent
|
|
11
|
+
* before 7.20), width-configurable via `tab-width`, and scoped to the
|
|
12
|
+
* script editor, not grounded as string-literal expansion (see #276).
|
|
13
|
+
* Escaping preserves the caller's bytes whether the device expands or
|
|
14
|
+
* preserves a raw tab.
|
|
15
|
+
*/
|
|
16
|
+
export function routerOsStringLiteral(value: string): string {
|
|
17
|
+
return `"${value
|
|
18
|
+
.replaceAll("\\", "\\\\")
|
|
19
|
+
.replaceAll('"', '\\"')
|
|
20
|
+
.replaceAll("$", "\\$")
|
|
21
|
+
.replaceAll("\t", "\\t")
|
|
22
|
+
.replaceAll("\r", "\\r")
|
|
23
|
+
.replaceAll("\n", "\\n")}"`;
|
|
24
|
+
}
|
package/src/errors.ts
CHANGED
package/src/execute-fanout.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Boundaries (see `commands/execute/README.md`, `docs/CONSTITUTION.md`), shared
|
|
11
11
|
* with `api-fanout.ts` / `retrieve-fanout.ts`:
|
|
12
|
-
* - The CDB is loaded ONCE (`
|
|
12
|
+
* - The CDB is loaded ONCE (`expandSelection`); a cdb member is resolved with
|
|
13
13
|
* its pre-resolved record, so `resolveCdb`'s `__default__` synthetic fallback
|
|
14
14
|
* never collides ad-hoc literals.
|
|
15
15
|
* - A `--default` / `__default__` member is guarded from being dialed as the
|
|
@@ -52,12 +52,12 @@ import {
|
|
|
52
52
|
} from "./execute.ts";
|
|
53
53
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
54
54
|
import {
|
|
55
|
-
|
|
56
|
-
type CdbSelectionMember,
|
|
57
|
-
type CdbSelectionResolveInput,
|
|
58
|
-
expandCdbSelection,
|
|
55
|
+
expandSelection,
|
|
59
56
|
isDefaultRecordTarget,
|
|
60
57
|
loadEnvFileDefaults,
|
|
58
|
+
type SelectionExpansion,
|
|
59
|
+
type SelectionMember,
|
|
60
|
+
type SelectionResolveInput,
|
|
61
61
|
type TargetSelection,
|
|
62
62
|
} from "./resolver/index.ts";
|
|
63
63
|
import { toYaml } from "./retrieve.ts";
|
|
@@ -69,6 +69,8 @@ export interface ExecuteSelectionSummary {
|
|
|
69
69
|
all: boolean;
|
|
70
70
|
default: boolean;
|
|
71
71
|
positionals: readonly string[];
|
|
72
|
+
/** quickchr machine names (`--quickchr`, repeatable; exclusive of the rest). */
|
|
73
|
+
quickchr?: readonly string[];
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
export interface ExecuteFanoutOperationMeta {
|
|
@@ -91,10 +93,10 @@ export type ExecuteFanoutErrorEnvelope =
|
|
|
91
93
|
export interface ExecuteFanoutInternals {
|
|
92
94
|
expand?: (
|
|
93
95
|
selection: TargetSelection,
|
|
94
|
-
input:
|
|
96
|
+
input: SelectionResolveInput,
|
|
95
97
|
env: Record<string, string | undefined>,
|
|
96
98
|
config?: Record<string, string | undefined>,
|
|
97
|
-
) => Promise<
|
|
99
|
+
) => Promise<SelectionExpansion>;
|
|
98
100
|
execute?: (
|
|
99
101
|
resolved: ResolvedExecuteRequest,
|
|
100
102
|
) => Promise<ExecuteSuccessEnvelope>;
|
|
@@ -116,6 +118,7 @@ function summarizeSelection(
|
|
|
116
118
|
all: selection.all,
|
|
117
119
|
default: selection.default,
|
|
118
120
|
positionals: selection.positionals,
|
|
121
|
+
quickchr: selection.quickchr,
|
|
119
122
|
};
|
|
120
123
|
}
|
|
121
124
|
|
|
@@ -124,7 +127,7 @@ function fanoutVia(
|
|
|
124
127
|
request: ExecuteRequest,
|
|
125
128
|
env: Record<string, string | undefined>,
|
|
126
129
|
globalVia: RouterOsProtocol,
|
|
127
|
-
expansion:
|
|
130
|
+
expansion: SelectionExpansion,
|
|
128
131
|
): RouterOsProtocol {
|
|
129
132
|
// Deliberately CLI/env only, not `config` — see the identical note in
|
|
130
133
|
// `retrieve-fanout.ts`'s `resolveFanoutConcurrencyProtocol`.
|
|
@@ -139,7 +142,7 @@ function fanoutVia(
|
|
|
139
142
|
return anyNative ? "native-api" : globalVia;
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
function memberTargetMeta(member:
|
|
145
|
+
function memberTargetMeta(member: SelectionMember): {
|
|
143
146
|
input?: string;
|
|
144
147
|
identity?: string;
|
|
145
148
|
recordIndex?: number;
|
|
@@ -151,6 +154,9 @@ function memberTargetMeta(member: CdbSelectionMember): {
|
|
|
151
154
|
recordIndex: member.recordIndex,
|
|
152
155
|
};
|
|
153
156
|
}
|
|
157
|
+
if (member.kind === "quickchr") {
|
|
158
|
+
return { input: member.name, identity: member.name };
|
|
159
|
+
}
|
|
154
160
|
return { input: member.input };
|
|
155
161
|
}
|
|
156
162
|
|
|
@@ -163,7 +169,7 @@ function memberTargetMeta(member: CdbSelectionMember): {
|
|
|
163
169
|
*/
|
|
164
170
|
function relabelAdhocMember(
|
|
165
171
|
envelope: ExecuteEnvelope,
|
|
166
|
-
member:
|
|
172
|
+
member: SelectionMember,
|
|
167
173
|
): ExecuteEnvelope {
|
|
168
174
|
if (member.kind !== "literal") {
|
|
169
175
|
return envelope;
|
|
@@ -195,7 +201,7 @@ export async function executeFanout(
|
|
|
195
201
|
const global = resolveExecuteGlobalContext(request, env, config);
|
|
196
202
|
const selectionSummary = summarizeSelection(selection);
|
|
197
203
|
|
|
198
|
-
const expand = internals.expand ??
|
|
204
|
+
const expand = internals.expand ?? expandSelection;
|
|
199
205
|
const expansion = await expand(
|
|
200
206
|
selection,
|
|
201
207
|
{
|
|
@@ -230,7 +236,7 @@ export async function executeFanout(
|
|
|
230
236
|
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
231
237
|
|
|
232
238
|
const targets = await runFanout<
|
|
233
|
-
|
|
239
|
+
SelectionMember,
|
|
234
240
|
ResolvedExecuteRequest,
|
|
235
241
|
ExecuteEnvelope
|
|
236
242
|
>({
|
|
@@ -254,6 +260,15 @@ export async function executeFanout(
|
|
|
254
260
|
{ cdbResolution: member.resolution, config },
|
|
255
261
|
);
|
|
256
262
|
}
|
|
263
|
+
if (member.kind === "quickchr") {
|
|
264
|
+
// The live descriptor resolves here, per member — a stopped/unknown
|
|
265
|
+
// machine is an inner per-target failure, not an expansion error.
|
|
266
|
+
return resolveExecuteRequest(
|
|
267
|
+
{ ...request, targetInput: undefined, quickchr: member.name },
|
|
268
|
+
env,
|
|
269
|
+
{ config },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
257
272
|
return resolveExecuteRequest(
|
|
258
273
|
{ ...request, targetInput: member.input },
|
|
259
274
|
env,
|
|
@@ -292,7 +307,7 @@ export async function executeFanout(
|
|
|
292
307
|
async function assertFanoutWriteConfirmed(
|
|
293
308
|
request: ExecuteRequest,
|
|
294
309
|
summary: ExecuteRequestSummary,
|
|
295
|
-
expansion:
|
|
310
|
+
expansion: SelectionExpansion,
|
|
296
311
|
): Promise<void> {
|
|
297
312
|
if (!summary.write || request.yes) {
|
|
298
313
|
return;
|
|
@@ -323,7 +338,7 @@ interface ExecuteFanoutEnvelopeInput {
|
|
|
323
338
|
requestSummary: ExecuteRequestSummary;
|
|
324
339
|
settings: CommonSettingsMeta;
|
|
325
340
|
via: RouterOsProtocol | null;
|
|
326
|
-
warnings:
|
|
341
|
+
warnings: SelectionExpansion["warnings"];
|
|
327
342
|
targets: readonly ExecuteEnvelope[];
|
|
328
343
|
}
|
|
329
344
|
|
package/src/execute.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
pathTokens,
|
|
18
18
|
} from "./core/inspect.ts";
|
|
19
19
|
import { mapRouterOsError } from "./core/routeros-errors.ts";
|
|
20
|
+
import { routerOsStringLiteral } from "./core/routeros-string.ts";
|
|
20
21
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
21
22
|
import {
|
|
22
23
|
createProtocolAdapter,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
parseScriptFor,
|
|
32
33
|
} from "./protocols/mac-telnet-console.ts";
|
|
33
34
|
import {
|
|
35
|
+
assertNoQuickchrOverrideConflict,
|
|
34
36
|
type CdbResolution,
|
|
35
37
|
effectiveHostCandidate,
|
|
36
38
|
isIpTransport,
|
|
@@ -38,6 +40,7 @@ import {
|
|
|
38
40
|
loadEnvFileDefaults,
|
|
39
41
|
parseDuration,
|
|
40
42
|
parseResolvePolicy,
|
|
43
|
+
quickchrConnection,
|
|
41
44
|
type ResolvedAuth,
|
|
42
45
|
type ResolvedSetting,
|
|
43
46
|
type ResolvedTarget,
|
|
@@ -46,6 +49,7 @@ import {
|
|
|
46
49
|
resolveCdb,
|
|
47
50
|
resolveMacTarget,
|
|
48
51
|
resolveOptionalIntegerSetting,
|
|
52
|
+
resolveQuickchrTarget,
|
|
49
53
|
resolveStringSetting,
|
|
50
54
|
resolveTarget,
|
|
51
55
|
toCoreSource,
|
|
@@ -57,6 +61,12 @@ export type ExecuteOutputFormat = (typeof executeOutputFormats)[number];
|
|
|
57
61
|
|
|
58
62
|
export interface ExecuteRequest {
|
|
59
63
|
targetInput?: string;
|
|
64
|
+
/**
|
|
65
|
+
* quickchr machine name (`--quickchr <name>`): resolve host/port/auth from
|
|
66
|
+
* the live VM descriptor instead of CDB/env (`docs/CONSTITUTION.md` →
|
|
67
|
+
* Resolution providers). Conflicts with host/port/username/password/sshKey.
|
|
68
|
+
*/
|
|
69
|
+
quickchr?: string;
|
|
60
70
|
command: string;
|
|
61
71
|
via?: string;
|
|
62
72
|
host?: string;
|
|
@@ -286,19 +296,30 @@ export async function resolveExecuteRequest(
|
|
|
286
296
|
validateExecuteRequestShape(request);
|
|
287
297
|
const canonical = canonicalizeExecuteCommand(request.command);
|
|
288
298
|
const config = options.config ?? (await loadEnvFileDefaults(env));
|
|
299
|
+
// A quickchr target is a named-live-provider: it supplies host/port/auth from
|
|
300
|
+
// the live descriptor and bypasses CDB/env resolution for those fields
|
|
301
|
+
// entirely (no CDB load, no `__default__` ladder, no MAC resolution).
|
|
302
|
+
let quickchrResolution: Awaited<
|
|
303
|
+
ReturnType<typeof resolveQuickchrTarget>
|
|
304
|
+
> | null = null;
|
|
305
|
+
if (request.quickchr !== undefined) {
|
|
306
|
+
assertNoQuickchrOverrideConflict(request, request.quickchr);
|
|
307
|
+
quickchrResolution = await resolveQuickchrTarget(request.quickchr);
|
|
308
|
+
}
|
|
289
309
|
// Fan-out passes the pre-resolved CDB record for a member (the CDB is loaded
|
|
290
|
-
// once in `
|
|
291
|
-
const cdbResolution =
|
|
292
|
-
|
|
293
|
-
(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
310
|
+
// once in `expandSelection`); single-target resolves it here.
|
|
311
|
+
const cdbResolution = quickchrResolution
|
|
312
|
+
? undefined
|
|
313
|
+
: (options.cdbResolution ??
|
|
314
|
+
(await resolveCdb(
|
|
315
|
+
{
|
|
316
|
+
targetInput: request.targetInput,
|
|
317
|
+
cdbFile: request.cdbFile,
|
|
318
|
+
cdbPassword: request.cdbPassword,
|
|
319
|
+
},
|
|
320
|
+
env,
|
|
321
|
+
config,
|
|
322
|
+
)));
|
|
302
323
|
const via = resolveExecuteProtocol(request, env, cdbResolution, config);
|
|
303
324
|
const format = resolveFormat(request, env, config);
|
|
304
325
|
const validate = resolveBooleanSetting(
|
|
@@ -325,50 +346,72 @@ export async function resolveExecuteRequest(
|
|
|
325
346
|
undefined,
|
|
326
347
|
config,
|
|
327
348
|
);
|
|
328
|
-
const macResolution =
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
349
|
+
const macResolution =
|
|
350
|
+
quickchrResolution === null && isIpTransport(via.value)
|
|
351
|
+
? await resolveMacTarget({
|
|
352
|
+
host: request.host,
|
|
353
|
+
targetInput: request.targetInput,
|
|
354
|
+
cdbTarget: cdbResolution?.target,
|
|
355
|
+
env,
|
|
356
|
+
config,
|
|
357
|
+
policy: parseResolvePolicy(
|
|
358
|
+
request.resolve ??
|
|
359
|
+
env["CENTRS_RESOLVE"] ??
|
|
360
|
+
config["CENTRS_RESOLVE"],
|
|
361
|
+
),
|
|
362
|
+
operation: "execute",
|
|
363
|
+
})
|
|
364
|
+
: undefined;
|
|
365
|
+
const connection = quickchrResolution
|
|
366
|
+
? quickchrConnection(quickchrResolution, via.value)
|
|
367
|
+
: null;
|
|
368
|
+
const target = connection
|
|
369
|
+
? connection.target
|
|
370
|
+
: resolveTarget(
|
|
371
|
+
{
|
|
372
|
+
targetInput: request.targetInput,
|
|
373
|
+
host: request.host,
|
|
374
|
+
port: request.port,
|
|
375
|
+
macResolution,
|
|
376
|
+
},
|
|
333
377
|
env,
|
|
378
|
+
via.value,
|
|
379
|
+
cdbResolution,
|
|
334
380
|
config,
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
cdbResolution?.overrides.insecure,
|
|
370
|
-
config,
|
|
371
|
-
);
|
|
381
|
+
);
|
|
382
|
+
const auth = connection
|
|
383
|
+
? connection.auth
|
|
384
|
+
: resolveAuth(
|
|
385
|
+
{
|
|
386
|
+
username: request.username,
|
|
387
|
+
password: request.password,
|
|
388
|
+
sshKey: request.sshKey,
|
|
389
|
+
},
|
|
390
|
+
env,
|
|
391
|
+
cdbResolution,
|
|
392
|
+
config,
|
|
393
|
+
);
|
|
394
|
+
// A quickchr TLS/SSH endpoint is trusted by provenance (self-signed VM cert /
|
|
395
|
+
// ephemeral host key) — `insecure` is forced with provider provenance and the
|
|
396
|
+
// provider-trust warning rides the envelope (see QuickchrConnection.insecure).
|
|
397
|
+
const insecure =
|
|
398
|
+
connection?.insecure === true
|
|
399
|
+
? {
|
|
400
|
+
value: true,
|
|
401
|
+
source: {
|
|
402
|
+
kind: "provider" as const,
|
|
403
|
+
key: `quickchr:${request.quickchr}`,
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
: resolveBooleanSetting(
|
|
407
|
+
request.insecure,
|
|
408
|
+
env,
|
|
409
|
+
"CENTRS_INSECURE",
|
|
410
|
+
false,
|
|
411
|
+
"insecure",
|
|
412
|
+
cdbResolution?.overrides.insecure,
|
|
413
|
+
config,
|
|
414
|
+
);
|
|
372
415
|
|
|
373
416
|
return {
|
|
374
417
|
command: request.command,
|
|
@@ -383,7 +426,9 @@ export async function resolveExecuteRequest(
|
|
|
383
426
|
maxResultsBytes,
|
|
384
427
|
yes: request.yes ?? false,
|
|
385
428
|
verbose: request.verbose ?? false,
|
|
386
|
-
warnings:
|
|
429
|
+
warnings: connection
|
|
430
|
+
? [...connection.warnings]
|
|
431
|
+
: (cdbResolution?.warnings ?? []),
|
|
387
432
|
};
|
|
388
433
|
}
|
|
389
434
|
|
|
@@ -694,7 +739,7 @@ async function validateExecuteCommand(
|
|
|
694
739
|
context: {
|
|
695
740
|
path: resolved.canonical.path,
|
|
696
741
|
verb: resolved.canonical.verb,
|
|
697
|
-
|
|
742
|
+
parameter: missing[0],
|
|
698
743
|
requestedAttributes: requested,
|
|
699
744
|
availableAttributes,
|
|
700
745
|
validationSource: "/console/inspect request=child+completion",
|
|
@@ -716,7 +761,7 @@ async function validateExecuteCommand(
|
|
|
716
761
|
* Validate a command over a console transport (mac-telnet or ssh) with a single
|
|
717
762
|
* `:put [:parse ...]`. Unlike REST/native (a syntax gate plus a `/console/inspect`
|
|
718
763
|
* semantic gate), one console `:parse` reports both `syntax error` and `bad
|
|
719
|
-
* parameter <name>`, so it covers syntax and the unknown-attribute (
|
|
764
|
+
* parameter <name>`, so it covers syntax and the unknown-attribute (name-level)
|
|
720
765
|
* gate at once — identical strings over mac-telnet and ssh (CHR 7.23.1 grounded;
|
|
721
766
|
* see `mac-telnet-console.ts` and `ssh.ts`).
|
|
722
767
|
*/
|
|
@@ -745,7 +790,7 @@ async function validateConsoleParseCommand(
|
|
|
745
790
|
script: parseScriptFor(resolved.command),
|
|
746
791
|
});
|
|
747
792
|
// Throws validation/syntax or validation/unknown-attribute on a bad parse.
|
|
748
|
-
classifyParseResult(result.ret ?? "", resolved.command);
|
|
793
|
+
classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
|
|
749
794
|
return {
|
|
750
795
|
enabled: true,
|
|
751
796
|
source: `:put [:parse ...] over ${resolved.via.value}`,
|
|
@@ -777,7 +822,14 @@ async function runSyntaxGate(
|
|
|
777
822
|
}
|
|
778
823
|
const script = `:put [:parse ${routerOsStringLiteral(resolved.command)}]`;
|
|
779
824
|
try {
|
|
780
|
-
|
|
825
|
+
// GH#230: `:parse` never throws (CHR 7.23.3). The diagnostic rides the
|
|
826
|
+
// success value's text — over REST/native the `ret` field (HTTP 200 /
|
|
827
|
+
// `as-string`), over console the printed output. The old code only watched
|
|
828
|
+
// for a thrown transport/RouterOS error and so never rejected anything.
|
|
829
|
+
// Read the return and classify it: one `:parse` covers syntax and the
|
|
830
|
+
// unknown-attribute (name-level) gate.
|
|
831
|
+
const result = await backend.execute({ path: "", command: "", script });
|
|
832
|
+
classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
|
|
781
833
|
} catch (error) {
|
|
782
834
|
// The preflight is a syntax gate, but `backend.execute` also surfaces
|
|
783
835
|
// connection and authentication failures (login happens lazily here).
|
|
@@ -788,6 +840,9 @@ async function runSyntaxGate(
|
|
|
788
840
|
if (error instanceof CentrsError && isPreflightTransportError(error)) {
|
|
789
841
|
throw error;
|
|
790
842
|
}
|
|
843
|
+
if (error instanceof CentrsError && error.code.startsWith("validation/")) {
|
|
844
|
+
throw error;
|
|
845
|
+
}
|
|
791
846
|
// If the mapped RouterOS fault carried a `(line N column M)` byte offset,
|
|
792
847
|
// keep it on the wrapped syntax error rather than dropping it.
|
|
793
848
|
const position = error instanceof CentrsError ? error.position : undefined;
|
|
@@ -1374,10 +1429,6 @@ function tokenizeRouterOsCli(input: string): string[] {
|
|
|
1374
1429
|
return tokens;
|
|
1375
1430
|
}
|
|
1376
1431
|
|
|
1377
|
-
function routerOsStringLiteral(value: string): string {
|
|
1378
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
1432
|
function syntaxCause(error: unknown): unknown {
|
|
1382
1433
|
if (error instanceof CentrsError) {
|
|
1383
1434
|
return error.causeData ?? error.context ?? error.toJSON();
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B2 arg fill — claims argument names and their `=` on the residual.
|
|
3
|
+
*
|
|
4
|
+
* This is the second B2 fill of #264, added after the operator fill. It runs
|
|
5
|
+
* **after** the proof-only spans (`comment` + `variable-*`) and **before** the
|
|
6
|
+
* operator fill, so the fill order is structural: `spans` claims first, then
|
|
7
|
+
* `arg`, then `operator` sees only what neither wanted. That ordering is why the
|
|
8
|
+
* operator fill can abstain on `, / = -` outside `( )`, on bytes glued after
|
|
9
|
+
* an argument `=`, and on bytes glued into an argument name — those bytes
|
|
10
|
+
* belong to a later fill, not to a smarter operator scanner.
|
|
11
|
+
*
|
|
12
|
+
* The source is `src/explain/args.ts`'s `Argument` — `span` and `valueSpan`
|
|
13
|
+
* already rebased into document analyzed-byte space by `src/explain.ts`. The
|
|
14
|
+
* name run is `[span.start, valueSpan.start - 1)` and the `=` is the single
|
|
15
|
+
* byte at `valueSpan.start - 1`; the value itself is `valueSpan` and is left
|
|
16
|
+
* for the next fill (see #293). A `positional` has no `name`/`valueSpan` and a
|
|
17
|
+
* `query` is a `?` word whose interior `args.ts` explicitly refuses to split
|
|
18
|
+
* (see `Argument.value`'s doc comment), so both are ignored — the `=` is
|
|
19
|
+
* derived from the token shape, never by scanning for the byte.
|
|
20
|
+
*
|
|
21
|
+
* `Argument.value` is not `Argument.text` and is never read: `value` is absent
|
|
22
|
+
* whenever the source spells a substitution or an escape this phase does not
|
|
23
|
+
* decode. This slice claims `span`/`valueSpan` *positions* only.
|
|
24
|
+
*
|
|
25
|
+
* A statement whose bytes were normalized is not addressable: `explain.ts`
|
|
26
|
+
* already refuses those (`its text was normalized`), and this fill inherits the
|
|
27
|
+
* refusal — it only sees tokens from `read === true` statements, which are
|
|
28
|
+
* exactly the addressable ones.
|
|
29
|
+
*
|
|
30
|
+
* Vocabulary is provisional until #264 B5: one `arg` class for both the name
|
|
31
|
+
* bytes and the `=` separator (design decision 2 of #293 — emit first, name
|
|
32
|
+
* later; `highlight`'s `syntax-meta` is a residual merge and never a source).
|
|
33
|
+
* If B5 splits the `=` into its own class, the byte coverage does not move,
|
|
34
|
+
* only the retag.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { ExplainArgumentToken, ExplainToken } from "../explain.ts";
|
|
38
|
+
import { clipToResidual } from "./token-ranges.ts";
|
|
39
|
+
|
|
40
|
+
export type ArgCandidate = ExplainArgumentToken;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Argument name + `=` spans on the residual.
|
|
44
|
+
*
|
|
45
|
+
* `analyzed` is the ASCII-normalized document text (only used for length
|
|
46
|
+
* checks); `residual` is the gap set left by earlier fills (sorted, no
|
|
47
|
+
* overlaps); `candidates` are the already-rebased `Argument` tokens from
|
|
48
|
+
* `read === true` statements. Every emitted span's bytes are fully inside
|
|
49
|
+
* `residual`, sorted by `start`, non-overlapping, and carry
|
|
50
|
+
* `class: "arg"` + `ev: "e11"`.
|
|
51
|
+
*/
|
|
52
|
+
export function argSpans(
|
|
53
|
+
analyzed: string,
|
|
54
|
+
residual: readonly { start: number; end: number }[],
|
|
55
|
+
candidates: readonly ArgCandidate[],
|
|
56
|
+
): ExplainToken[] {
|
|
57
|
+
const len = analyzed.length;
|
|
58
|
+
if (len === 0 || residual.length === 0 || candidates.length === 0) return [];
|
|
59
|
+
|
|
60
|
+
const out: ExplainToken[] = [];
|
|
61
|
+
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
if (candidate.kind !== "attribute") continue;
|
|
64
|
+
if (candidate.valueSpan === undefined) continue;
|
|
65
|
+
if (candidate.name === undefined) continue;
|
|
66
|
+
const nameStart = candidate.span.start;
|
|
67
|
+
const eqEnd = candidate.valueSpan.start;
|
|
68
|
+
const eqStart = eqEnd - 1;
|
|
69
|
+
const nameEnd = eqStart;
|
|
70
|
+
// Defensive: token shape must give non-empty name and single-byte `=`.
|
|
71
|
+
if (
|
|
72
|
+
!Number.isInteger(nameStart) ||
|
|
73
|
+
!Number.isInteger(nameEnd) ||
|
|
74
|
+
!Number.isInteger(eqStart) ||
|
|
75
|
+
!Number.isInteger(eqEnd)
|
|
76
|
+
)
|
|
77
|
+
continue;
|
|
78
|
+
if (nameStart < 0 || eqEnd > len) continue;
|
|
79
|
+
// Non-empty name. This is also what rejects `valueSpan.start === 0`, where
|
|
80
|
+
// `eqStart` would be -1: `nameEnd` is -1 too, so any `nameStart >= 0` trips
|
|
81
|
+
// it. `analyzed[-1]` on the next line would be `undefined` and continue
|
|
82
|
+
// anyway, so the order is safe either way — but the name check is the one
|
|
83
|
+
// that carries the intent.
|
|
84
|
+
if (nameStart >= nameEnd) continue;
|
|
85
|
+
// The whole token's `=` byte must be `=` in the analyzed text — a cheap
|
|
86
|
+
// shape check that catches a caller that passed un-rebased or misaligned
|
|
87
|
+
// spans. Not a scan for `=`; the position is derived from `valueSpan`.
|
|
88
|
+
if (analyzed[eqStart] !== "=") continue;
|
|
89
|
+
// Also ensure the candidate lies fully inside the analyzed length.
|
|
90
|
+
if (candidate.span.start < 0 || candidate.span.end > len) continue;
|
|
91
|
+
if (candidate.valueSpan.start < 0 || candidate.valueSpan.end > len)
|
|
92
|
+
continue;
|
|
93
|
+
|
|
94
|
+
// The name run and the `=` are contiguous (`nameEnd === eqStart`), so
|
|
95
|
+
// clip them as ONE range per candidate. That yields the maximal `[name=]`
|
|
96
|
+
// run for free — split only where the residual itself has a hole — and
|
|
97
|
+
// makes cross-candidate fusion structurally impossible rather than merely
|
|
98
|
+
// unreachable: coalescing adjacent runs globally would merge two distinct
|
|
99
|
+
// attributes into one token if `lexArguments` ever stopped guaranteeing a
|
|
100
|
+
// separator between them. A `variable-*` span claiming only the `=` still
|
|
101
|
+
// leaves the name run behind, because the residual hole does the splitting.
|
|
102
|
+
for (const r of clipToResidual(nameStart, eqEnd, residual)) {
|
|
103
|
+
out.push({
|
|
104
|
+
start: r.start,
|
|
105
|
+
end: r.end,
|
|
106
|
+
class: "arg" as const,
|
|
107
|
+
ev: "e11",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
out.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
113
|
+
return out;
|
|
114
|
+
}
|