@tikoci/centrs 0.1.3 → 0.1.6
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 +348 -0
- package/GLOSSARY.txt +252 -0
- package/LICENSE +21 -0
- package/README.md +44 -12
- package/commands/AGENTS.md +14 -5
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +56 -26
- package/commands/api/examples.md +46 -1
- 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 +77 -36
- package/commands/execute/examples.md +120 -10
- package/commands/explain/README.md +2756 -0
- package/commands/explain/examples.md +702 -0
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/terminal/examples.md +7 -1
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +138 -30
- package/docs/MATRIX.md +92 -18
- 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/syntax.md +29 -4
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +56 -10
- package/src/api-fanout.ts +46 -18
- package/src/api.ts +351 -121
- package/src/cli/api.ts +26 -7
- package/src/cli/btest.ts +19 -3
- 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/terminal.ts +1 -1
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +58 -2
- package/src/core/error-catalog.ts +42 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +47 -18
- package/src/execute.ts +339 -307
- package/src/explain/arg-tokens.ts +151 -0
- package/src/explain/args.ts +1749 -0
- package/src/explain/blocks.ts +175 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +39 -0
- package/src/explain/catalog.ts +1343 -0
- package/src/explain/coordinates.ts +488 -0
- package/src/explain/defects.ts +218 -0
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +668 -0
- package/src/explain/operator-tokens.ts +316 -0
- package/src/explain/operators.ts +465 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1569 -0
- package/src/explain/quoted-string.ts +331 -0
- package/src/explain/scope-brace.ts +461 -0
- package/src/explain/segment.ts +1165 -0
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +77 -0
- package/src/explain/symbol-values.ts +556 -0
- package/src/explain/symbols.ts +1617 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +514 -0
- package/src/explain/value-tokens.ts +83 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +955 -0
- package/src/explain/write.ts +856 -0
- package/src/explain.ts +2602 -0
- package/src/index.ts +225 -6
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- 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 +27 -13
- package/src/retrieve.ts +82 -77
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +43 -26
- package/src/transfer.ts +88 -45
package/src/execute.ts
CHANGED
|
@@ -5,19 +5,28 @@ import type {
|
|
|
5
5
|
CommonSettingsMeta,
|
|
6
6
|
SettingSource as CoreSettingSource,
|
|
7
7
|
EnvelopeValidationMeta,
|
|
8
|
+
EnvelopeValidationStage,
|
|
8
9
|
Tip,
|
|
10
|
+
ValidationTrace,
|
|
9
11
|
Warning,
|
|
10
12
|
} from "./core/envelope.ts";
|
|
11
13
|
import { buildTip } from "./core/envelope.ts";
|
|
12
14
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from "./core/inspect.ts";
|
|
15
|
+
type CanonicalExecuteCommand,
|
|
16
|
+
canonicalizeExecuteCommand,
|
|
17
|
+
isWriteShaped,
|
|
18
|
+
} from "./core/execute-command.ts";
|
|
19
|
+
import { inspectArgumentNames, pathTokens } from "./core/inspect.ts";
|
|
19
20
|
import { mapRouterOsError } from "./core/routeros-errors.ts";
|
|
21
|
+
import { routerOsStringLiteral } from "./core/routeros-string.ts";
|
|
22
|
+
import { toYaml } from "./core/yaml.ts";
|
|
20
23
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
24
|
+
import {
|
|
25
|
+
assertOfflineSyntax,
|
|
26
|
+
isOfflineGateRejection,
|
|
27
|
+
OFFLINE_GATE_SOURCE,
|
|
28
|
+
type OfflineGateResult,
|
|
29
|
+
} from "./offline-gate.ts";
|
|
21
30
|
import {
|
|
22
31
|
createProtocolAdapter,
|
|
23
32
|
getProtocolPlan,
|
|
@@ -31,6 +40,7 @@ import {
|
|
|
31
40
|
parseScriptFor,
|
|
32
41
|
} from "./protocols/mac-telnet-console.ts";
|
|
33
42
|
import {
|
|
43
|
+
assertNoQuickchrOverrideConflict,
|
|
34
44
|
type CdbResolution,
|
|
35
45
|
effectiveHostCandidate,
|
|
36
46
|
isIpTransport,
|
|
@@ -38,6 +48,7 @@ import {
|
|
|
38
48
|
loadEnvFileDefaults,
|
|
39
49
|
parseDuration,
|
|
40
50
|
parseResolvePolicy,
|
|
51
|
+
quickchrConnection,
|
|
41
52
|
type ResolvedAuth,
|
|
42
53
|
type ResolvedSetting,
|
|
43
54
|
type ResolvedTarget,
|
|
@@ -46,17 +57,23 @@ import {
|
|
|
46
57
|
resolveCdb,
|
|
47
58
|
resolveMacTarget,
|
|
48
59
|
resolveOptionalIntegerSetting,
|
|
60
|
+
resolveQuickchrTarget,
|
|
49
61
|
resolveStringSetting,
|
|
50
62
|
resolveTarget,
|
|
51
63
|
toCoreSource,
|
|
52
64
|
} from "./resolver/index.ts";
|
|
53
|
-
import { toYaml } from "./retrieve.ts";
|
|
54
65
|
|
|
55
66
|
export const executeOutputFormats = ["text", "json", "yaml"] as const;
|
|
56
67
|
export type ExecuteOutputFormat = (typeof executeOutputFormats)[number];
|
|
57
68
|
|
|
58
69
|
export interface ExecuteRequest {
|
|
59
70
|
targetInput?: string;
|
|
71
|
+
/**
|
|
72
|
+
* quickchr machine name (`--quickchr <name>`): resolve host/port/auth from
|
|
73
|
+
* the live VM descriptor instead of CDB/env (`docs/CONSTITUTION.md` →
|
|
74
|
+
* Resolution providers). Conflicts with host/port/username/password/sshKey.
|
|
75
|
+
*/
|
|
76
|
+
quickchr?: string;
|
|
60
77
|
command: string;
|
|
61
78
|
via?: string;
|
|
62
79
|
host?: string;
|
|
@@ -115,14 +132,17 @@ export type ExecuteSuccessEnvelope = CentrsSuccessEnvelope<
|
|
|
115
132
|
>;
|
|
116
133
|
export type ExecuteErrorEnvelope = CentrsErrorEnvelope<ExecuteOperationMeta>;
|
|
117
134
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The execution gate itself lives in `src/core/execute-command.ts` (pure, no
|
|
137
|
+
* transport reach) and is re-exported here because this module is its
|
|
138
|
+
* documented public home — `src/index.ts`, `src/mcp/tools.ts` and the locked
|
|
139
|
+
* contract table all import it from `execute` (#312).
|
|
140
|
+
*/
|
|
141
|
+
export {
|
|
142
|
+
type CanonicalExecuteCommand,
|
|
143
|
+
canonicalizeExecuteCommand,
|
|
144
|
+
isWriteShaped,
|
|
145
|
+
};
|
|
126
146
|
|
|
127
147
|
export interface ResolvedExecuteRequest {
|
|
128
148
|
command: string;
|
|
@@ -162,13 +182,23 @@ export async function executeEnvelope(
|
|
|
162
182
|
env: Record<string, string | undefined> = Bun.env,
|
|
163
183
|
): Promise<ExecuteEnvelope> {
|
|
164
184
|
let resolved: ResolvedExecuteRequest | undefined;
|
|
185
|
+
// The gate finishes inside `runResolvedExecute`, but the error envelope is
|
|
186
|
+
// built out here after the throw has unwound past it. Without the trace, a
|
|
187
|
+
// RouterOS fault on the *run* — both stages already green — reported a
|
|
188
|
+
// reconstructed `result: "failed"` that erased the real outcome.
|
|
189
|
+
const trace: ValidationTrace = {};
|
|
165
190
|
try {
|
|
166
191
|
resolved = await resolveExecuteRequest(request, env);
|
|
167
192
|
await assertWriteConfirmed(request, resolved);
|
|
168
|
-
return await runResolvedExecute(resolved);
|
|
193
|
+
return await runResolvedExecute(resolved, trace);
|
|
169
194
|
} catch (error) {
|
|
170
195
|
return resolved
|
|
171
|
-
? buildExecuteErrorEnvelopeFromResolved(
|
|
196
|
+
? buildExecuteErrorEnvelopeFromResolved(
|
|
197
|
+
resolved,
|
|
198
|
+
error,
|
|
199
|
+
trace.validation,
|
|
200
|
+
trace,
|
|
201
|
+
)
|
|
172
202
|
: buildExecuteErrorEnvelope(request, error, env);
|
|
173
203
|
}
|
|
174
204
|
}
|
|
@@ -186,6 +216,7 @@ export async function validateExecuteEnvelope(
|
|
|
186
216
|
env: Record<string, string | undefined> = Bun.env,
|
|
187
217
|
): Promise<ExecuteEnvelope> {
|
|
188
218
|
let resolved: ResolvedExecuteRequest | undefined;
|
|
219
|
+
const trace: ValidationTrace = {};
|
|
189
220
|
try {
|
|
190
221
|
resolved = await resolveExecuteRequest({ ...request, validate: true }, env);
|
|
191
222
|
const backend = createProtocolAdapter({
|
|
@@ -202,7 +233,7 @@ export async function validateExecuteEnvelope(
|
|
|
202
233
|
insecure: resolved.insecure.value,
|
|
203
234
|
});
|
|
204
235
|
try {
|
|
205
|
-
const validation = await validateExecuteCommand(resolved, backend);
|
|
236
|
+
const validation = await validateExecuteCommand(resolved, backend, trace);
|
|
206
237
|
return {
|
|
207
238
|
ok: true,
|
|
208
239
|
data: null,
|
|
@@ -215,13 +246,14 @@ export async function validateExecuteEnvelope(
|
|
|
215
246
|
}
|
|
216
247
|
} catch (error) {
|
|
217
248
|
return resolved
|
|
218
|
-
? buildExecuteErrorEnvelopeFromResolved(resolved, error)
|
|
249
|
+
? buildExecuteErrorEnvelopeFromResolved(resolved, error, undefined, trace)
|
|
219
250
|
: buildExecuteErrorEnvelope(request, error, env);
|
|
220
251
|
}
|
|
221
252
|
}
|
|
222
253
|
|
|
223
254
|
export async function runResolvedExecute(
|
|
224
255
|
resolved: ResolvedExecuteRequest,
|
|
256
|
+
trace: ValidationTrace = {},
|
|
225
257
|
): Promise<ExecuteSuccessEnvelope> {
|
|
226
258
|
const backend = createProtocolAdapter({
|
|
227
259
|
protocol: resolved.via.value,
|
|
@@ -243,16 +275,13 @@ export async function runResolvedExecute(
|
|
|
243
275
|
|
|
244
276
|
try {
|
|
245
277
|
if (resolved.validate.value) {
|
|
246
|
-
validation = await validateExecuteCommand(resolved, backend);
|
|
278
|
+
validation = await validateExecuteCommand(resolved, backend, trace);
|
|
247
279
|
} else {
|
|
248
|
-
validation =
|
|
249
|
-
enabled: false,
|
|
250
|
-
source: "disabled",
|
|
251
|
-
result: "skipped",
|
|
252
|
-
syntax: false,
|
|
253
|
-
semantic: false,
|
|
254
|
-
};
|
|
280
|
+
validation = disabledValidationMeta();
|
|
255
281
|
}
|
|
282
|
+
// Recorded BEFORE the run, so a RouterOS fault or transport drop below
|
|
283
|
+
// reports the gate that actually passed rather than a guess.
|
|
284
|
+
trace.validation = validation;
|
|
256
285
|
|
|
257
286
|
const result = await runCommand(resolved, backend);
|
|
258
287
|
const routerOsFailure = routerOsFailureFromResult(
|
|
@@ -286,19 +315,30 @@ export async function resolveExecuteRequest(
|
|
|
286
315
|
validateExecuteRequestShape(request);
|
|
287
316
|
const canonical = canonicalizeExecuteCommand(request.command);
|
|
288
317
|
const config = options.config ?? (await loadEnvFileDefaults(env));
|
|
318
|
+
// A quickchr target is a named-live-provider: it supplies host/port/auth from
|
|
319
|
+
// the live descriptor and bypasses CDB/env resolution for those fields
|
|
320
|
+
// entirely (no CDB load, no `__default__` ladder, no MAC resolution).
|
|
321
|
+
let quickchrResolution: Awaited<
|
|
322
|
+
ReturnType<typeof resolveQuickchrTarget>
|
|
323
|
+
> | null = null;
|
|
324
|
+
if (request.quickchr !== undefined) {
|
|
325
|
+
assertNoQuickchrOverrideConflict(request, request.quickchr);
|
|
326
|
+
quickchrResolution = await resolveQuickchrTarget(request.quickchr);
|
|
327
|
+
}
|
|
289
328
|
// 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
|
-
|
|
329
|
+
// once in `expandSelection`); single-target resolves it here.
|
|
330
|
+
const cdbResolution = quickchrResolution
|
|
331
|
+
? undefined
|
|
332
|
+
: (options.cdbResolution ??
|
|
333
|
+
(await resolveCdb(
|
|
334
|
+
{
|
|
335
|
+
targetInput: request.targetInput,
|
|
336
|
+
cdbFile: request.cdbFile,
|
|
337
|
+
cdbPassword: request.cdbPassword,
|
|
338
|
+
},
|
|
339
|
+
env,
|
|
340
|
+
config,
|
|
341
|
+
)));
|
|
302
342
|
const via = resolveExecuteProtocol(request, env, cdbResolution, config);
|
|
303
343
|
const format = resolveFormat(request, env, config);
|
|
304
344
|
const validate = resolveBooleanSetting(
|
|
@@ -325,50 +365,72 @@ export async function resolveExecuteRequest(
|
|
|
325
365
|
undefined,
|
|
326
366
|
config,
|
|
327
367
|
);
|
|
328
|
-
const macResolution =
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
368
|
+
const macResolution =
|
|
369
|
+
quickchrResolution === null && isIpTransport(via.value)
|
|
370
|
+
? await resolveMacTarget({
|
|
371
|
+
host: request.host,
|
|
372
|
+
targetInput: request.targetInput,
|
|
373
|
+
cdbTarget: cdbResolution?.target,
|
|
374
|
+
env,
|
|
375
|
+
config,
|
|
376
|
+
policy: parseResolvePolicy(
|
|
377
|
+
request.resolve ??
|
|
378
|
+
env["CENTRS_RESOLVE"] ??
|
|
379
|
+
config["CENTRS_RESOLVE"],
|
|
380
|
+
),
|
|
381
|
+
operation: "execute",
|
|
382
|
+
})
|
|
383
|
+
: undefined;
|
|
384
|
+
const connection = quickchrResolution
|
|
385
|
+
? quickchrConnection(quickchrResolution, via.value)
|
|
386
|
+
: null;
|
|
387
|
+
const target = connection
|
|
388
|
+
? connection.target
|
|
389
|
+
: resolveTarget(
|
|
390
|
+
{
|
|
391
|
+
targetInput: request.targetInput,
|
|
392
|
+
host: request.host,
|
|
393
|
+
port: request.port,
|
|
394
|
+
macResolution,
|
|
395
|
+
},
|
|
333
396
|
env,
|
|
397
|
+
via.value,
|
|
398
|
+
cdbResolution,
|
|
334
399
|
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
|
-
);
|
|
400
|
+
);
|
|
401
|
+
const auth = connection
|
|
402
|
+
? connection.auth
|
|
403
|
+
: resolveAuth(
|
|
404
|
+
{
|
|
405
|
+
username: request.username,
|
|
406
|
+
password: request.password,
|
|
407
|
+
sshKey: request.sshKey,
|
|
408
|
+
},
|
|
409
|
+
env,
|
|
410
|
+
cdbResolution,
|
|
411
|
+
config,
|
|
412
|
+
);
|
|
413
|
+
// A quickchr TLS/SSH endpoint is trusted by provenance (self-signed VM cert /
|
|
414
|
+
// ephemeral host key) — `insecure` is forced with provider provenance and the
|
|
415
|
+
// provider-trust warning rides the envelope (see QuickchrConnection.insecure).
|
|
416
|
+
const insecure =
|
|
417
|
+
connection?.insecure === true
|
|
418
|
+
? {
|
|
419
|
+
value: true,
|
|
420
|
+
source: {
|
|
421
|
+
kind: "provider" as const,
|
|
422
|
+
key: `quickchr:${request.quickchr}`,
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
: resolveBooleanSetting(
|
|
426
|
+
request.insecure,
|
|
427
|
+
env,
|
|
428
|
+
"CENTRS_INSECURE",
|
|
429
|
+
false,
|
|
430
|
+
"insecure",
|
|
431
|
+
cdbResolution?.overrides.insecure,
|
|
432
|
+
config,
|
|
433
|
+
);
|
|
372
434
|
|
|
373
435
|
return {
|
|
374
436
|
command: request.command,
|
|
@@ -383,7 +445,9 @@ export async function resolveExecuteRequest(
|
|
|
383
445
|
maxResultsBytes,
|
|
384
446
|
yes: request.yes ?? false,
|
|
385
447
|
verbose: request.verbose ?? false,
|
|
386
|
-
warnings:
|
|
448
|
+
warnings: connection
|
|
449
|
+
? [...connection.warnings]
|
|
450
|
+
: (cdbResolution?.warnings ?? []),
|
|
387
451
|
};
|
|
388
452
|
}
|
|
389
453
|
|
|
@@ -515,6 +579,7 @@ export function buildExecuteErrorEnvelopeFromResolved(
|
|
|
515
579
|
resolved: ResolvedExecuteRequest,
|
|
516
580
|
error: unknown,
|
|
517
581
|
validation?: EnvelopeValidationMeta,
|
|
582
|
+
trace?: ValidationTrace,
|
|
518
583
|
): ExecuteErrorEnvelope {
|
|
519
584
|
const centrsError =
|
|
520
585
|
error instanceof CentrsError
|
|
@@ -533,23 +598,133 @@ export function buildExecuteErrorEnvelopeFromResolved(
|
|
|
533
598
|
tips: resolvedExecuteTips(resolved),
|
|
534
599
|
meta: metaFromResolved(
|
|
535
600
|
resolved,
|
|
536
|
-
validation ??
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
? ":put [:parse] + /console/inspect"
|
|
540
|
-
: "disabled",
|
|
541
|
-
result: resolved.validate.value ? "failed" : "skipped",
|
|
542
|
-
syntax: resolved.validate.value,
|
|
543
|
-
semantic: resolved.validate.value
|
|
544
|
-
? resolved.canonical.mode === "structured"
|
|
545
|
-
? false
|
|
546
|
-
: "not-applicable"
|
|
547
|
-
: false,
|
|
548
|
-
},
|
|
601
|
+
validation ??
|
|
602
|
+
trace?.validation ??
|
|
603
|
+
failedValidationMeta(resolved, error, trace),
|
|
549
604
|
),
|
|
550
605
|
};
|
|
551
606
|
}
|
|
552
607
|
|
|
608
|
+
/**
|
|
609
|
+
* The `meta.validation` for an error envelope built without a validation result
|
|
610
|
+
* of its own — i.e. the gate threw, or something downstream did.
|
|
611
|
+
*
|
|
612
|
+
* A stage-1 rejection is the case worth spelling out: the offline analyzer never
|
|
613
|
+
* opens a connection, so reporting the old flat
|
|
614
|
+
* `source: ":put [:parse] + /console/inspect"` / `result: "failed"` would claim
|
|
615
|
+
* a device probe that did not happen. `stages` says which one actually spoke.
|
|
616
|
+
*/
|
|
617
|
+
function failedValidationMeta(
|
|
618
|
+
resolved: ResolvedExecuteRequest,
|
|
619
|
+
error: unknown,
|
|
620
|
+
trace?: ValidationTrace,
|
|
621
|
+
): EnvelopeValidationMeta {
|
|
622
|
+
if (!resolved.validate.value) {
|
|
623
|
+
return disabledValidationMeta();
|
|
624
|
+
}
|
|
625
|
+
const deviceSource = deviceStageSource(resolved);
|
|
626
|
+
// Script-shaped input has no `/console/inspect` half to fail, on either
|
|
627
|
+
// branch below. Reporting `semantic: false` for it would say a semantic gate
|
|
628
|
+
// ran and said no, which is a different claim from "there was none".
|
|
629
|
+
const semantic: boolean | "not-applicable" =
|
|
630
|
+
resolved.canonical.mode === "structured" ? false : "not-applicable";
|
|
631
|
+
if (isOfflineGateRejection(error)) {
|
|
632
|
+
return {
|
|
633
|
+
enabled: true,
|
|
634
|
+
source: OFFLINE_GATE_SOURCE,
|
|
635
|
+
result: "failed",
|
|
636
|
+
syntax: false,
|
|
637
|
+
semantic,
|
|
638
|
+
stages: [
|
|
639
|
+
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
|
|
640
|
+
{
|
|
641
|
+
stage: "device",
|
|
642
|
+
source: deviceSource,
|
|
643
|
+
result: "skipped",
|
|
644
|
+
reason: "offline analysis rejected the command; no connection opened",
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
// Only a `validation/*` error tells us the DEVICE stage rejected it — and
|
|
650
|
+
// only then do we know stage 1 passed, because stage 1 runs first and would
|
|
651
|
+
// have thrown its own (offline-tagged) error otherwise. This builder also
|
|
652
|
+
// catches everything downstream — a RouterOS fault on the run, a transport
|
|
653
|
+
// drop — which passed both stages and is served by `trace.validation`. When
|
|
654
|
+
// neither applies, `stages` is omitted rather than asserting a verdict this
|
|
655
|
+
// function cannot know.
|
|
656
|
+
const deviceRejected =
|
|
657
|
+
error instanceof CentrsError && error.code.startsWith("validation/");
|
|
658
|
+
return {
|
|
659
|
+
enabled: true,
|
|
660
|
+
source: deviceSource,
|
|
661
|
+
result: "failed",
|
|
662
|
+
syntax: true,
|
|
663
|
+
semantic,
|
|
664
|
+
...(deviceRejected
|
|
665
|
+
? {
|
|
666
|
+
stages: [
|
|
667
|
+
offlineStagePassed(trace?.offlineVerdict),
|
|
668
|
+
{
|
|
669
|
+
stage: "device" as const,
|
|
670
|
+
source: deviceSource,
|
|
671
|
+
result: "failed" as const,
|
|
672
|
+
},
|
|
673
|
+
],
|
|
674
|
+
}
|
|
675
|
+
: {}),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** The device-stage validator identity for a resolved request's transport. */
|
|
680
|
+
function deviceStageSource(resolved: ResolvedExecuteRequest): string {
|
|
681
|
+
return resolved.via.value === "mac-telnet" || resolved.via.value === "ssh"
|
|
682
|
+
? `:put [:parse ...] over ${resolved.via.value}`
|
|
683
|
+
: ":put [:parse] + /console/inspect";
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The offline stage entry for a gate stage 1 cleared.
|
|
688
|
+
*
|
|
689
|
+
* `verdict` comes from the {@link ValidationTrace}, not from a re-run: a `warn`
|
|
690
|
+
* is an ABSTENTION, and an error envelope that flattened it to a bare `passed`
|
|
691
|
+
* would tell a consumer the analyzer judged bytes it explicitly declined to
|
|
692
|
+
* judge. An absent verdict means the caller did not thread a trace, so say
|
|
693
|
+
* nothing extra rather than invent the reason.
|
|
694
|
+
*/
|
|
695
|
+
function offlineStagePassed(
|
|
696
|
+
verdict: ValidationTrace["offlineVerdict"],
|
|
697
|
+
): EnvelopeValidationStage {
|
|
698
|
+
return {
|
|
699
|
+
stage: "offline",
|
|
700
|
+
source: OFFLINE_GATE_SOURCE,
|
|
701
|
+
result: "passed",
|
|
702
|
+
...(verdict === "warn"
|
|
703
|
+
? { reason: "analyzer abstained on part of the input" }
|
|
704
|
+
: {}),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* `validate=false`. Both stages are listed as `skipped` rather than omitted:
|
|
710
|
+
* the constitution's rule is that a gated surface always reports each stage, so
|
|
711
|
+
* a consumer reading `stages` never has to special-case the disabled shape.
|
|
712
|
+
* `reason` is left off — `enabled: false` already says why.
|
|
713
|
+
*/
|
|
714
|
+
function disabledValidationMeta(): EnvelopeValidationMeta {
|
|
715
|
+
return {
|
|
716
|
+
enabled: false,
|
|
717
|
+
source: "disabled",
|
|
718
|
+
result: "skipped",
|
|
719
|
+
syntax: false,
|
|
720
|
+
semantic: false,
|
|
721
|
+
stages: [
|
|
722
|
+
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
|
|
723
|
+
{ stage: "device", source: "device preflight", result: "skipped" },
|
|
724
|
+
],
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
553
728
|
export function renderExecuteEnvelope(
|
|
554
729
|
envelope: ExecuteEnvelope,
|
|
555
730
|
format: ExecuteOutputFormat,
|
|
@@ -569,86 +744,6 @@ export function renderExecuteEnvelope(
|
|
|
569
744
|
}
|
|
570
745
|
}
|
|
571
746
|
|
|
572
|
-
/**
|
|
573
|
-
* The **script-vs-structured execution gate** — centrs's load-bearing
|
|
574
|
-
* discriminator (with {@link isWriteShaped}) for which validation runs and
|
|
575
|
-
* whether the write-confirmation prompt fires. centrs owns this gate; the shared,
|
|
576
|
-
* prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
|
|
577
|
-
* publish is for *canonicalization*, never the structured-mode predicate —
|
|
578
|
-
* widening what counts as `structured` is a product regression. Behavior is
|
|
579
|
-
* pinned by `test/unit/execute-canonicalize-contract.test.ts`.
|
|
580
|
-
*
|
|
581
|
-
* centrs deliberately does **not** vendor the shared parser yet. Preconditions
|
|
582
|
-
* for adopting it: (1) that contract stays green (no gate widening); (2) the
|
|
583
|
-
* vendored file is clean under centrs's strict `tsconfig` (or explicitly
|
|
584
|
-
* quarantined with justification); (3) `lsp-routeros-ts` also vendors/consumes
|
|
585
|
-
* the same parser shape, so it is genuinely shared, not a premature first copy.
|
|
586
|
-
*/
|
|
587
|
-
export function canonicalizeExecuteCommand(
|
|
588
|
-
input: string,
|
|
589
|
-
): CanonicalExecuteCommand {
|
|
590
|
-
const asScript = (): CanonicalExecuteCommand => ({
|
|
591
|
-
mode: "script",
|
|
592
|
-
input,
|
|
593
|
-
path: "",
|
|
594
|
-
verb: "",
|
|
595
|
-
attributes: {},
|
|
596
|
-
queries: [],
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
const trimmed = input.trim();
|
|
600
|
-
if (!trimmed.startsWith("/")) {
|
|
601
|
-
return asScript();
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
const tokens = tokenizeRouterOsCli(trimmed);
|
|
605
|
-
const pathToken = tokens[0] ?? "";
|
|
606
|
-
const pathParts = pathToken.split("/").filter(Boolean);
|
|
607
|
-
if (pathParts.length < 2) {
|
|
608
|
-
return asScript();
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const verb = pathParts.at(-1) ?? "";
|
|
612
|
-
const path = `/${pathParts.slice(0, -1).join("/")}`;
|
|
613
|
-
const attributes: Record<string, string> = {};
|
|
614
|
-
const queries: string[] = [];
|
|
615
|
-
for (const token of tokens.slice(1)) {
|
|
616
|
-
// `[...]` subshell selectors (e.g. `numbers=[find ...]`) cannot be
|
|
617
|
-
// represented as a structured attribute map: the inner command contains
|
|
618
|
-
// spaces this tokenizer splits on, which would mangle a write-shaped
|
|
619
|
-
// command into corrupt key=value pairs. Fall back to the raw-script path
|
|
620
|
-
// so RouterOS evaluates the subshell with its real semantics.
|
|
621
|
-
if (token.includes("[") || token.includes("]")) {
|
|
622
|
-
return asScript();
|
|
623
|
-
}
|
|
624
|
-
if (token.startsWith("?")) {
|
|
625
|
-
queries.push(token);
|
|
626
|
-
continue;
|
|
627
|
-
}
|
|
628
|
-
const separator = token.indexOf("=");
|
|
629
|
-
if (separator <= 0) {
|
|
630
|
-
return asScript();
|
|
631
|
-
}
|
|
632
|
-
attributes[token.slice(0, separator)] = token.slice(separator + 1);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
return {
|
|
636
|
-
mode: "structured",
|
|
637
|
-
input,
|
|
638
|
-
path,
|
|
639
|
-
verb,
|
|
640
|
-
attributes,
|
|
641
|
-
queries,
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
646
|
-
return (
|
|
647
|
-
command.mode === "structured" &&
|
|
648
|
-
["add", "set", "remove"].includes(command.verb)
|
|
649
|
-
);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
747
|
export function validateExecuteRequestShape(request: ExecuteRequest): void {
|
|
653
748
|
if (!request.command || request.command.trim().length === 0) {
|
|
654
749
|
throw new CentrsError({
|
|
@@ -660,12 +755,31 @@ export function validateExecuteRequestShape(request: ExecuteRequest): void {
|
|
|
660
755
|
}
|
|
661
756
|
}
|
|
662
757
|
|
|
758
|
+
/**
|
|
759
|
+
* The two-stage gate (GH#354): offline analysis first, then the device probe.
|
|
760
|
+
*
|
|
761
|
+
* Stage 1 runs before anything is sent, on every transport, so a syntax fault is
|
|
762
|
+
* a byte span and no round trip. It is not a substitute for stage 2 — `explain`
|
|
763
|
+
* has no per-menu schema, so it cannot see an unknown attribute — and it never
|
|
764
|
+
* reports success on the device's behalf. Stage 2 is unchanged: `:parse` (which
|
|
765
|
+
* on a console transport also covers the name-level attribute gate) plus
|
|
766
|
+
* `/console/inspect request=child` for structured input over REST/native.
|
|
767
|
+
*/
|
|
663
768
|
async function validateExecuteCommand(
|
|
664
769
|
resolved: ResolvedExecuteRequest,
|
|
665
770
|
backend: ProtocolAdapter,
|
|
771
|
+
trace: ValidationTrace = {},
|
|
666
772
|
): Promise<EnvelopeValidationMeta> {
|
|
773
|
+
const offline = assertOfflineSyntax(resolved.command, {
|
|
774
|
+
surface: "execute",
|
|
775
|
+
via: resolved.via.value,
|
|
776
|
+
});
|
|
777
|
+
// Recorded before stage 2 can throw, so a device rejection still reports
|
|
778
|
+
// whether stage 1 read the whole input or abstained on part of it.
|
|
779
|
+
trace.offlineVerdict = offline.verdict;
|
|
780
|
+
|
|
667
781
|
if (resolved.via.value === "mac-telnet" || resolved.via.value === "ssh") {
|
|
668
|
-
return validateConsoleParseCommand(resolved, backend);
|
|
782
|
+
return validateConsoleParseCommand(resolved, backend, offline);
|
|
669
783
|
}
|
|
670
784
|
|
|
671
785
|
await runSyntaxGate(resolved, backend);
|
|
@@ -677,10 +791,14 @@ async function validateExecuteCommand(
|
|
|
677
791
|
result: "passed",
|
|
678
792
|
syntax: true,
|
|
679
793
|
semantic: "not-applicable",
|
|
794
|
+
stages: passedStages(offline, ":put [:parse]"),
|
|
680
795
|
};
|
|
681
796
|
}
|
|
682
797
|
|
|
683
|
-
const availableAttributes = await
|
|
798
|
+
const availableAttributes = await inspectArgumentNames(backend, [
|
|
799
|
+
...pathTokens(resolved.canonical.path),
|
|
800
|
+
resolved.canonical.verb,
|
|
801
|
+
]);
|
|
684
802
|
const requested = Object.keys(resolved.canonical.attributes);
|
|
685
803
|
const missing = requested.filter(
|
|
686
804
|
(attribute) => !availableAttributes.includes(attribute),
|
|
@@ -694,10 +812,10 @@ async function validateExecuteCommand(
|
|
|
694
812
|
context: {
|
|
695
813
|
path: resolved.canonical.path,
|
|
696
814
|
verb: resolved.canonical.verb,
|
|
697
|
-
|
|
815
|
+
parameter: missing[0],
|
|
698
816
|
requestedAttributes: requested,
|
|
699
817
|
availableAttributes,
|
|
700
|
-
validationSource: "/console/inspect request=child
|
|
818
|
+
validationSource: "/console/inspect request=child",
|
|
701
819
|
},
|
|
702
820
|
});
|
|
703
821
|
}
|
|
@@ -709,6 +827,7 @@ async function validateExecuteCommand(
|
|
|
709
827
|
syntax: true,
|
|
710
828
|
semantic: true,
|
|
711
829
|
availableAttributes,
|
|
830
|
+
stages: passedStages(offline, ":put [:parse] + /console/inspect"),
|
|
712
831
|
};
|
|
713
832
|
}
|
|
714
833
|
|
|
@@ -716,36 +835,22 @@ async function validateExecuteCommand(
|
|
|
716
835
|
* Validate a command over a console transport (mac-telnet or ssh) with a single
|
|
717
836
|
* `:put [:parse ...]`. Unlike REST/native (a syntax gate plus a `/console/inspect`
|
|
718
837
|
* semantic gate), one console `:parse` reports both `syntax error` and `bad
|
|
719
|
-
* parameter <name>`, so it covers syntax and the unknown-attribute (
|
|
838
|
+
* parameter <name>`, so it covers syntax and the unknown-attribute (name-level)
|
|
720
839
|
* gate at once — identical strings over mac-telnet and ssh (CHR 7.23.1 grounded;
|
|
721
840
|
* see `mac-telnet-console.ts` and `ssh.ts`).
|
|
722
841
|
*/
|
|
723
842
|
async function validateConsoleParseCommand(
|
|
724
843
|
resolved: ResolvedExecuteRequest,
|
|
725
844
|
backend: ProtocolAdapter,
|
|
845
|
+
offline: OfflineGateResult,
|
|
726
846
|
): Promise<EnvelopeValidationMeta> {
|
|
727
|
-
if (hasUnbalancedQuotes(resolved.command)) {
|
|
728
|
-
throw new CentrsError({
|
|
729
|
-
code: "validation/syntax",
|
|
730
|
-
summary:
|
|
731
|
-
"RouterOS rejected the command syntax during local quote preflight.",
|
|
732
|
-
remediation:
|
|
733
|
-
"Close the RouterOS string quote, then retry. The command was not executed.",
|
|
734
|
-
context: {
|
|
735
|
-
command: resolved.command,
|
|
736
|
-
validationSource: "local quote preflight before :put [:parse ...]",
|
|
737
|
-
via: resolved.via.value,
|
|
738
|
-
},
|
|
739
|
-
causeData: "unterminated string literal",
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
847
|
const result = await backend.execute({
|
|
743
848
|
path: "",
|
|
744
849
|
command: "",
|
|
745
850
|
script: parseScriptFor(resolved.command),
|
|
746
851
|
});
|
|
747
852
|
// Throws validation/syntax or validation/unknown-attribute on a bad parse.
|
|
748
|
-
classifyParseResult(result.ret ?? "", resolved.command);
|
|
853
|
+
classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
|
|
749
854
|
return {
|
|
750
855
|
enabled: true,
|
|
751
856
|
source: `:put [:parse ...] over ${resolved.via.value}`,
|
|
@@ -753,31 +858,50 @@ async function validateConsoleParseCommand(
|
|
|
753
858
|
syntax: true,
|
|
754
859
|
semantic:
|
|
755
860
|
resolved.canonical.mode === "structured" ? true : "not-applicable",
|
|
861
|
+
stages: passedStages(
|
|
862
|
+
offline,
|
|
863
|
+
`:put [:parse ...] over ${resolved.via.value}`,
|
|
864
|
+
),
|
|
756
865
|
};
|
|
757
866
|
}
|
|
758
867
|
|
|
868
|
+
/**
|
|
869
|
+
* The stage breakdown for a gate both stages passed. `offline` is carried rather
|
|
870
|
+
* than re-derived so the meta reports the verdict the run actually saw — a
|
|
871
|
+
* `warn` is an abstention, and saying so is the difference between "the analyzer
|
|
872
|
+
* read all of this" and "it declined to judge part of it".
|
|
873
|
+
*/
|
|
874
|
+
function passedStages(
|
|
875
|
+
offline: OfflineGateResult,
|
|
876
|
+
deviceSource: string,
|
|
877
|
+
): EnvelopeValidationStage[] {
|
|
878
|
+
return [
|
|
879
|
+
{
|
|
880
|
+
stage: "offline",
|
|
881
|
+
source: OFFLINE_GATE_SOURCE,
|
|
882
|
+
result: "passed",
|
|
883
|
+
...(offline.verdict === "warn"
|
|
884
|
+
? { reason: "analyzer abstained on part of the input" }
|
|
885
|
+
: {}),
|
|
886
|
+
},
|
|
887
|
+
{ stage: "device", source: deviceSource, result: "passed" },
|
|
888
|
+
];
|
|
889
|
+
}
|
|
890
|
+
|
|
759
891
|
async function runSyntaxGate(
|
|
760
892
|
resolved: ResolvedExecuteRequest,
|
|
761
893
|
backend: ProtocolAdapter,
|
|
762
894
|
): Promise<void> {
|
|
763
|
-
if (hasUnbalancedQuotes(resolved.command)) {
|
|
764
|
-
throw new CentrsError({
|
|
765
|
-
code: "validation/syntax",
|
|
766
|
-
summary:
|
|
767
|
-
"RouterOS rejected the command syntax during local quote preflight.",
|
|
768
|
-
remediation:
|
|
769
|
-
"Close the RouterOS string quote, then retry. The command was not executed.",
|
|
770
|
-
context: {
|
|
771
|
-
command: resolved.command,
|
|
772
|
-
validationSource: "local quote preflight before :put [:parse ...]",
|
|
773
|
-
via: resolved.via.value,
|
|
774
|
-
},
|
|
775
|
-
causeData: "unterminated string literal",
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
895
|
const script = `:put [:parse ${routerOsStringLiteral(resolved.command)}]`;
|
|
779
896
|
try {
|
|
780
|
-
|
|
897
|
+
// GH#230: `:parse` never throws (CHR 7.23.3). The diagnostic rides the
|
|
898
|
+
// success value's text — over REST/native the `ret` field (HTTP 200 /
|
|
899
|
+
// `as-string`), over console the printed output. The old code only watched
|
|
900
|
+
// for a thrown transport/RouterOS error and so never rejected anything.
|
|
901
|
+
// Read the return and classify it: one `:parse` covers syntax and the
|
|
902
|
+
// unknown-attribute (name-level) gate.
|
|
903
|
+
const result = await backend.execute({ path: "", command: "", script });
|
|
904
|
+
classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
|
|
781
905
|
} catch (error) {
|
|
782
906
|
// The preflight is a syntax gate, but `backend.execute` also surfaces
|
|
783
907
|
// connection and authentication failures (login happens lazily here).
|
|
@@ -788,6 +912,9 @@ async function runSyntaxGate(
|
|
|
788
912
|
if (error instanceof CentrsError && isPreflightTransportError(error)) {
|
|
789
913
|
throw error;
|
|
790
914
|
}
|
|
915
|
+
if (error instanceof CentrsError && error.code.startsWith("validation/")) {
|
|
916
|
+
throw error;
|
|
917
|
+
}
|
|
791
918
|
// If the mapped RouterOS fault carried a `(line N column M)` byte offset,
|
|
792
919
|
// keep it on the wrapped syntax error rather than dropping it.
|
|
793
920
|
const position = error instanceof CentrsError ? error.position : undefined;
|
|
@@ -818,27 +945,6 @@ function isPreflightTransportError(error: CentrsError): boolean {
|
|
|
818
945
|
return error.code.startsWith("transport/") || error.code.startsWith("auth/");
|
|
819
946
|
}
|
|
820
947
|
|
|
821
|
-
async function inspectExecuteAttributes(
|
|
822
|
-
resolved: ResolvedExecuteRequest,
|
|
823
|
-
backend: ProtocolAdapter,
|
|
824
|
-
): Promise<string[]> {
|
|
825
|
-
const commandTokens = [
|
|
826
|
-
...pathTokens(resolved.canonical.path),
|
|
827
|
-
resolved.canonical.verb,
|
|
828
|
-
];
|
|
829
|
-
const children = await inspectChildren(backend, commandTokens);
|
|
830
|
-
const childAttributes = children
|
|
831
|
-
.filter(isArgumentNode)
|
|
832
|
-
.map((child) => child.name)
|
|
833
|
-
.filter(
|
|
834
|
-
(name): name is string => typeof name === "string" && name.length > 0,
|
|
835
|
-
);
|
|
836
|
-
const completionRows = await inspectCompletions(backend, commandTokens);
|
|
837
|
-
return [
|
|
838
|
-
...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
|
|
839
|
-
].sort();
|
|
840
|
-
}
|
|
841
|
-
|
|
842
948
|
async function runCommand(
|
|
843
949
|
resolved: ResolvedExecuteRequest,
|
|
844
950
|
backend: ProtocolAdapter,
|
|
@@ -1304,80 +1410,6 @@ function renderExecuteErrorText(
|
|
|
1304
1410
|
return lines.join("\n");
|
|
1305
1411
|
}
|
|
1306
1412
|
|
|
1307
|
-
function hasUnbalancedQuotes(input: string): boolean {
|
|
1308
|
-
let quote: '"' | "'" | undefined;
|
|
1309
|
-
let escaped = false;
|
|
1310
|
-
for (const char of input) {
|
|
1311
|
-
if (escaped) {
|
|
1312
|
-
escaped = false;
|
|
1313
|
-
continue;
|
|
1314
|
-
}
|
|
1315
|
-
if (char === "\\") {
|
|
1316
|
-
escaped = true;
|
|
1317
|
-
continue;
|
|
1318
|
-
}
|
|
1319
|
-
if (quote) {
|
|
1320
|
-
if (char === quote) {
|
|
1321
|
-
quote = undefined;
|
|
1322
|
-
}
|
|
1323
|
-
continue;
|
|
1324
|
-
}
|
|
1325
|
-
if (char === '"' || char === "'") {
|
|
1326
|
-
quote = char;
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
return quote !== undefined;
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
function tokenizeRouterOsCli(input: string): string[] {
|
|
1333
|
-
const tokens: string[] = [];
|
|
1334
|
-
let current = "";
|
|
1335
|
-
let quote: '"' | "'" | undefined;
|
|
1336
|
-
let escaped = false;
|
|
1337
|
-
for (const char of input) {
|
|
1338
|
-
if (escaped) {
|
|
1339
|
-
current += char;
|
|
1340
|
-
escaped = false;
|
|
1341
|
-
continue;
|
|
1342
|
-
}
|
|
1343
|
-
if (char === "\\") {
|
|
1344
|
-
escaped = true;
|
|
1345
|
-
continue;
|
|
1346
|
-
}
|
|
1347
|
-
if (quote) {
|
|
1348
|
-
if (char === quote) {
|
|
1349
|
-
quote = undefined;
|
|
1350
|
-
} else {
|
|
1351
|
-
current += char;
|
|
1352
|
-
}
|
|
1353
|
-
continue;
|
|
1354
|
-
}
|
|
1355
|
-
if (char === '"' || char === "'") {
|
|
1356
|
-
quote = char;
|
|
1357
|
-
continue;
|
|
1358
|
-
}
|
|
1359
|
-
if (/\s/.test(char)) {
|
|
1360
|
-
if (current.length > 0) {
|
|
1361
|
-
tokens.push(current);
|
|
1362
|
-
current = "";
|
|
1363
|
-
}
|
|
1364
|
-
continue;
|
|
1365
|
-
}
|
|
1366
|
-
current += char;
|
|
1367
|
-
}
|
|
1368
|
-
if (escaped) {
|
|
1369
|
-
current += "\\";
|
|
1370
|
-
}
|
|
1371
|
-
if (current.length > 0) {
|
|
1372
|
-
tokens.push(current);
|
|
1373
|
-
}
|
|
1374
|
-
return tokens;
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
function routerOsStringLiteral(value: string): string {
|
|
1378
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
1413
|
function syntaxCause(error: unknown): unknown {
|
|
1382
1414
|
if (error instanceof CentrsError) {
|
|
1383
1415
|
return error.causeData ?? error.context ?? error.toJSON();
|