@tikoci/centrs 0.1.0
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 +207 -0
- package/GLOSSARY.txt +420 -0
- package/README.md +196 -0
- package/SECURITY.md +30 -0
- package/commands/AGENTS.md +50 -0
- package/commands/btest/README.md +257 -0
- package/commands/btest/examples.md +190 -0
- package/commands/check/README.md +59 -0
- package/commands/devices/README.md +405 -0
- package/commands/devices/examples.md +488 -0
- package/commands/discover/README.md +170 -0
- package/commands/discover/examples.md +98 -0
- package/commands/execute/README.md +208 -0
- package/commands/execute/examples.md +353 -0
- package/commands/mcp/README.md +260 -0
- package/commands/mcp/examples.md +158 -0
- package/commands/retrieve/README.md +209 -0
- package/commands/retrieve/examples.md +306 -0
- package/commands/settings/README.md +69 -0
- package/commands/stream/README.md +70 -0
- package/commands/terminal/README.md +177 -0
- package/commands/terminal/examples.md +144 -0
- package/commands/transfer/README.md +315 -0
- package/commands/transfer/examples.md +368 -0
- package/docs/CONSTITUTION.md +417 -0
- package/docs/MATRIX.md +224 -0
- package/docs/errors/README.md +74 -0
- package/docs/errors/auth/failed.md +9 -0
- package/docs/errors/cdb/already-exists.md +9 -0
- package/docs/errors/cdb/created.md +9 -0
- package/docs/errors/cdb/decrypt-failed.md +9 -0
- package/docs/errors/cdb/empty-group.md +9 -0
- package/docs/errors/cdb/invalid-option.md +9 -0
- package/docs/errors/cdb/invalid-record.md +9 -0
- package/docs/errors/cdb/not-found-target.md +9 -0
- package/docs/errors/cdb/not-found.md +9 -0
- package/docs/errors/cdb/override-applied.md +9 -0
- package/docs/errors/cdb/parse-failed.md +9 -0
- package/docs/errors/cdb/password-not-needed.md +9 -0
- package/docs/errors/cdb/password-required.md +9 -0
- package/docs/errors/cdb/reserved-key.md +9 -0
- package/docs/errors/cdb/reserved-option.md +9 -0
- package/docs/errors/cdb/target-not-registered.md +9 -0
- package/docs/errors/cdb/unknown-field.md +9 -0
- package/docs/errors/cdb/unknown-option.md +9 -0
- package/docs/errors/cdb/write-not-permitted.md +9 -0
- package/docs/errors/discover/broadcast-unavailable.md +9 -0
- package/docs/errors/discover/failed.md +9 -0
- package/docs/errors/identity/ambiguous.md +9 -0
- package/docs/errors/identity/no-match.md +9 -0
- package/docs/errors/input/invalid-command.md +9 -0
- package/docs/errors/input/invalid-match.md +9 -0
- package/docs/errors/input/invalid-path.md +9 -0
- package/docs/errors/input/invalid-routeros-path.md +9 -0
- package/docs/errors/input/invalid-target.md +9 -0
- package/docs/errors/input/local-file-not-found.md +9 -0
- package/docs/errors/input/mac-address.md +9 -0
- package/docs/errors/input/max-results-exceeded.md +9 -0
- package/docs/errors/internal/devices-failed.md +9 -0
- package/docs/errors/internal/mcp-start.md +9 -0
- package/docs/errors/internal/mcp-tool.md +9 -0
- package/docs/errors/internal/native-api-length.md +9 -0
- package/docs/errors/internal/unhandled.md +9 -0
- package/docs/errors/internal/unreachable.md +9 -0
- package/docs/errors/mndp/encode-failed.md +9 -0
- package/docs/errors/mndp/listen-failed.md +9 -0
- package/docs/errors/mndp/malformed.md +9 -0
- package/docs/errors/routeros/api-fatal.md +9 -0
- package/docs/errors/routeros/api-protocol.md +9 -0
- package/docs/errors/routeros/api-trap.md +9 -0
- package/docs/errors/routeros/btest-protocol.md +12 -0
- package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
- package/docs/errors/routeros/command-failed.md +14 -0
- package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
- package/docs/errors/routeros/error.md +9 -0
- package/docs/errors/routeros/invalid-value.md +14 -0
- package/docs/errors/routeros/mac-telnet-error.md +9 -0
- package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
- package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
- package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
- package/docs/errors/routeros/protocol-not-implemented.md +9 -0
- package/docs/errors/routeros/request-failed.md +9 -0
- package/docs/errors/routeros/session-closed.md +13 -0
- package/docs/errors/routeros/unknown-attribute.md +13 -0
- package/docs/errors/routeros/unknown-path.md +15 -0
- package/docs/errors/routeros/unsupported-capability.md +9 -0
- package/docs/errors/settings/invalid-boolean.md +9 -0
- package/docs/errors/settings/invalid-format.md +9 -0
- package/docs/errors/settings/invalid-integer.md +9 -0
- package/docs/errors/settings/invalid-timeout.md +9 -0
- package/docs/errors/settings/invalid-via.md +9 -0
- package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
- package/docs/errors/target/mac-not-in-arp.md +14 -0
- package/docs/errors/target/mac-required.md +23 -0
- package/docs/errors/target/mac-unresolved.md +17 -0
- package/docs/errors/target/unresolved.md +9 -0
- package/docs/errors/transport/auth-failed.md +9 -0
- package/docs/errors/transport/auto-method.md +9 -0
- package/docs/errors/transport/capability-unsupported.md +22 -0
- package/docs/errors/transport/checksum-unavailable.md +9 -0
- package/docs/errors/transport/connection-closed.md +9 -0
- package/docs/errors/transport/connection-refused.md +9 -0
- package/docs/errors/transport/dns.md +9 -0
- package/docs/errors/transport/host-key-mismatch.md +9 -0
- package/docs/errors/transport/incomplete-transfer.md +9 -0
- package/docs/errors/transport/insecure-trust.md +9 -0
- package/docs/errors/transport/local-tool-missing.md +11 -0
- package/docs/errors/transport/network.md +9 -0
- package/docs/errors/transport/timeout.md +9 -0
- package/docs/errors/transport/tls-certificate.md +9 -0
- package/docs/errors/transport/unreachable.md +9 -0
- package/docs/errors/transport/unsupported-operation.md +9 -0
- package/docs/errors/usage/confirmation-required.md +9 -0
- package/docs/errors/usage/conflicting-flags.md +9 -0
- package/docs/errors/usage/invalid-concurrency.md +9 -0
- package/docs/errors/usage/missing-group.md +9 -0
- package/docs/errors/usage/not-implemented.md +9 -0
- package/docs/errors/usage/target-exists.md +9 -0
- package/docs/errors/usage/timeout-out-of-range.md +9 -0
- package/docs/errors/validation/not-implemented.md +9 -0
- package/docs/errors/validation/option.md +9 -0
- package/docs/errors/validation/syntax.md +9 -0
- package/docs/errors/validation/unknown-attribute.md +9 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +96 -0
- package/src/AGENTS.md +7 -0
- package/src/btest.ts +906 -0
- package/src/cli/btest.ts +455 -0
- package/src/cli/common.ts +53 -0
- package/src/cli/devices.ts +527 -0
- package/src/cli/discover.ts +186 -0
- package/src/cli/execute.ts +333 -0
- package/src/cli/mcp.ts +109 -0
- package/src/cli/retrieve.ts +383 -0
- package/src/cli/terminal.ts +303 -0
- package/src/cli/transfer.ts +471 -0
- package/src/cli.ts +115 -0
- package/src/core/envelope.ts +221 -0
- package/src/core/error-catalog.ts +469 -0
- package/src/core/routeros-errors.ts +268 -0
- package/src/data/AGENTS.md +11 -0
- package/src/data/dude-db.ts +0 -0
- package/src/data/mndp-cache.ts +132 -0
- package/src/data/mndp.ts +405 -0
- package/src/data/winbox-cdb-write.ts +200 -0
- package/src/data/winbox-cdb.ts +913 -0
- package/src/devices-schema.ts +107 -0
- package/src/devices.ts +1717 -0
- package/src/discover.ts +802 -0
- package/src/errors.ts +189 -0
- package/src/execute.ts +1350 -0
- package/src/index.ts +353 -0
- package/src/mcp/config.ts +66 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/safety.ts +119 -0
- package/src/mcp/server.ts +225 -0
- package/src/mcp/tools.ts +657 -0
- package/src/mcp.ts +33 -0
- package/src/protocols/AGENTS.md +14 -0
- package/src/protocols/adapter.ts +866 -0
- package/src/protocols/btest-session.ts +1493 -0
- package/src/protocols/btest.ts +447 -0
- package/src/protocols/ec-srp5.ts +466 -0
- package/src/protocols/index.ts +118 -0
- package/src/protocols/mac-telnet-console.ts +655 -0
- package/src/protocols/mac-telnet.ts +1299 -0
- package/src/protocols/mtwei.ts +47 -0
- package/src/protocols/native-api.ts +796 -0
- package/src/protocols/sftp.ts +427 -0
- package/src/protocols/ssh.ts +297 -0
- package/src/resolver/cdb.ts +580 -0
- package/src/resolver/comment-kv.ts +360 -0
- package/src/resolver/index.ts +74 -0
- package/src/resolver/mac.ts +227 -0
- package/src/resolver/settings.ts +228 -0
- package/src/resolver/target.ts +442 -0
- package/src/retrieve-fanout.ts +533 -0
- package/src/retrieve.ts +1215 -0
- package/src/terminal.ts +444 -0
- package/src/transfer.ts +1554 -0
- package/src/tui.ts +22 -0
- package/src/webproxy.ts +16 -0
package/src/execute.ts
ADDED
|
@@ -0,0 +1,1350 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CentrsEnvelope,
|
|
3
|
+
CentrsErrorEnvelope,
|
|
4
|
+
CentrsSuccessEnvelope,
|
|
5
|
+
CommonSettingsMeta,
|
|
6
|
+
SettingSource as CoreSettingSource,
|
|
7
|
+
EnvelopeValidationMeta,
|
|
8
|
+
Tip,
|
|
9
|
+
Warning,
|
|
10
|
+
} from "./core/envelope.ts";
|
|
11
|
+
import { buildTip } from "./core/envelope.ts";
|
|
12
|
+
import { mapRouterOsError } from "./core/routeros-errors.ts";
|
|
13
|
+
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
14
|
+
import {
|
|
15
|
+
createProtocolAdapter,
|
|
16
|
+
getProtocolPlan,
|
|
17
|
+
type ProtocolAdapter,
|
|
18
|
+
type ProtocolExecuteResult,
|
|
19
|
+
plannedProtocols,
|
|
20
|
+
type RouterOsProtocol,
|
|
21
|
+
} from "./protocols/index.ts";
|
|
22
|
+
import {
|
|
23
|
+
classifyParseResult,
|
|
24
|
+
parseScriptFor,
|
|
25
|
+
} from "./protocols/mac-telnet-console.ts";
|
|
26
|
+
import {
|
|
27
|
+
type CdbResolution,
|
|
28
|
+
effectiveHostCandidate,
|
|
29
|
+
isIpTransport,
|
|
30
|
+
isMacAddress,
|
|
31
|
+
parseDuration,
|
|
32
|
+
parseResolvePolicy,
|
|
33
|
+
type ResolvedAuth,
|
|
34
|
+
type ResolvedSetting,
|
|
35
|
+
type ResolvedTarget,
|
|
36
|
+
resolveAuth,
|
|
37
|
+
resolveBooleanSetting,
|
|
38
|
+
resolveCdb,
|
|
39
|
+
resolveMacTarget,
|
|
40
|
+
resolveOptionalIntegerSetting,
|
|
41
|
+
resolveStringSetting,
|
|
42
|
+
resolveTarget,
|
|
43
|
+
toCoreSource,
|
|
44
|
+
} from "./resolver/index.ts";
|
|
45
|
+
import { toYaml } from "./retrieve.ts";
|
|
46
|
+
|
|
47
|
+
export const executeOutputFormats = ["text", "json", "yaml"] as const;
|
|
48
|
+
export type ExecuteOutputFormat = (typeof executeOutputFormats)[number];
|
|
49
|
+
|
|
50
|
+
export interface ExecuteRequest {
|
|
51
|
+
targetInput?: string;
|
|
52
|
+
command: string;
|
|
53
|
+
via?: string;
|
|
54
|
+
host?: string;
|
|
55
|
+
port?: number;
|
|
56
|
+
username?: string;
|
|
57
|
+
password?: string;
|
|
58
|
+
/** SSH private-key path for `--via ssh` (path only; agent / ~/.ssh used if unset). */
|
|
59
|
+
sshKey?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Disable peer verification (`--insecure`). For `--via ssh` this sets
|
|
62
|
+
* `StrictHostKeyChecking=no` with a null `known_hosts`, so a *changed* /
|
|
63
|
+
* impersonated host key is accepted too — not just trust-on-first-use; for
|
|
64
|
+
* api-ssl it accepts a self-signed TLS cert. Default verifies.
|
|
65
|
+
*/
|
|
66
|
+
insecure?: boolean;
|
|
67
|
+
timeout?: string | number;
|
|
68
|
+
format?: string;
|
|
69
|
+
validate?: boolean;
|
|
70
|
+
verbose?: boolean;
|
|
71
|
+
yes?: boolean;
|
|
72
|
+
maxResultsBytes?: number;
|
|
73
|
+
cdbFile?: string;
|
|
74
|
+
cdbPassword?: string;
|
|
75
|
+
/** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
|
|
76
|
+
resolve?: string;
|
|
77
|
+
stdinIsTty?: boolean;
|
|
78
|
+
confirm?: (prompt: string) => Promise<boolean>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ExecuteRequestSummary {
|
|
82
|
+
command: string;
|
|
83
|
+
canonical?: CanonicalExecuteCommand;
|
|
84
|
+
write: boolean;
|
|
85
|
+
yes: boolean;
|
|
86
|
+
validate: boolean;
|
|
87
|
+
verbose: boolean;
|
|
88
|
+
timeoutMs: number;
|
|
89
|
+
format: ExecuteOutputFormat;
|
|
90
|
+
maxResultsBytes?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ExecuteOperationMeta {
|
|
94
|
+
kind: "execute";
|
|
95
|
+
objectCount: number;
|
|
96
|
+
request: ExecuteRequestSummary;
|
|
97
|
+
auth: {
|
|
98
|
+
username?: string;
|
|
99
|
+
passwordProvided: boolean;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type ExecuteEnvelope = CentrsEnvelope<unknown, ExecuteOperationMeta>;
|
|
104
|
+
export type ExecuteSuccessEnvelope = CentrsSuccessEnvelope<
|
|
105
|
+
unknown,
|
|
106
|
+
ExecuteOperationMeta
|
|
107
|
+
>;
|
|
108
|
+
export type ExecuteErrorEnvelope = CentrsErrorEnvelope<ExecuteOperationMeta>;
|
|
109
|
+
|
|
110
|
+
export interface CanonicalExecuteCommand {
|
|
111
|
+
mode: "structured" | "script";
|
|
112
|
+
input: string;
|
|
113
|
+
path: string;
|
|
114
|
+
verb: string;
|
|
115
|
+
attributes: Record<string, string>;
|
|
116
|
+
queries: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ResolvedExecuteRequest {
|
|
120
|
+
command: string;
|
|
121
|
+
canonical: CanonicalExecuteCommand;
|
|
122
|
+
via: ResolvedSetting<RouterOsProtocol>;
|
|
123
|
+
target: ResolvedTarget;
|
|
124
|
+
auth: ResolvedAuth;
|
|
125
|
+
timeoutMs: ResolvedSetting<number>;
|
|
126
|
+
format: ResolvedSetting<ExecuteOutputFormat>;
|
|
127
|
+
validate: ResolvedSetting<boolean>;
|
|
128
|
+
insecure: ResolvedSetting<boolean>;
|
|
129
|
+
maxResultsBytes?: ResolvedSetting<number>;
|
|
130
|
+
yes: boolean;
|
|
131
|
+
verbose: boolean;
|
|
132
|
+
warnings: readonly Warning[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface InspectChildItem {
|
|
136
|
+
type?: string;
|
|
137
|
+
name?: string;
|
|
138
|
+
"node-type"?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface InspectCompletionItem {
|
|
142
|
+
type?: string;
|
|
143
|
+
name?: string;
|
|
144
|
+
completion?: string;
|
|
145
|
+
value?: string;
|
|
146
|
+
text?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function execute(
|
|
150
|
+
request: ExecuteRequest,
|
|
151
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
152
|
+
): Promise<ExecuteSuccessEnvelope> {
|
|
153
|
+
const envelope = await executeEnvelope(request, env);
|
|
154
|
+
if (!envelope.ok) {
|
|
155
|
+
throw new CentrsError({
|
|
156
|
+
code: envelope.error.code,
|
|
157
|
+
summary: envelope.error.summary,
|
|
158
|
+
remediation: envelope.error.remediation,
|
|
159
|
+
context: envelope.error.context,
|
|
160
|
+
causeData: envelope.error.cause,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return envelope;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function executeEnvelope(
|
|
167
|
+
request: ExecuteRequest,
|
|
168
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
169
|
+
): Promise<ExecuteEnvelope> {
|
|
170
|
+
let resolved: ResolvedExecuteRequest | undefined;
|
|
171
|
+
try {
|
|
172
|
+
resolved = await resolveExecuteRequest(request, env);
|
|
173
|
+
await assertWriteConfirmed(request, resolved);
|
|
174
|
+
return await runResolvedExecute(resolved);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return resolved
|
|
177
|
+
? buildExecuteErrorEnvelopeFromResolved(resolved, error)
|
|
178
|
+
: buildExecuteErrorEnvelope(request, error, env);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Dry-run a command through validation only (`:parse` + `/console/inspect`)
|
|
184
|
+
* without ever running it. Used by the MCP `centrs_validate` tool: it shares the
|
|
185
|
+
* exact validation path `executeEnvelope` uses so a dry-run and a real run agree
|
|
186
|
+
* on what is acceptable, but it never reaches the mutate/read step. Validation is
|
|
187
|
+
* forced on regardless of any `validate=false` setting — a validate tool that can
|
|
188
|
+
* be told not to validate is pointless.
|
|
189
|
+
*/
|
|
190
|
+
export async function validateExecuteEnvelope(
|
|
191
|
+
request: ExecuteRequest,
|
|
192
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
193
|
+
): Promise<ExecuteEnvelope> {
|
|
194
|
+
let resolved: ResolvedExecuteRequest | undefined;
|
|
195
|
+
try {
|
|
196
|
+
resolved = await resolveExecuteRequest({ ...request, validate: true }, env);
|
|
197
|
+
const backend = createProtocolAdapter({
|
|
198
|
+
protocol: resolved.via.value,
|
|
199
|
+
host: resolved.target.host,
|
|
200
|
+
port: resolved.target.port,
|
|
201
|
+
tls: resolved.target.tls,
|
|
202
|
+
baseUrl: resolved.target.baseUrl,
|
|
203
|
+
username: resolved.auth.username,
|
|
204
|
+
password: resolved.auth.password,
|
|
205
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
206
|
+
mac: resolved.target.mac,
|
|
207
|
+
sshKey: resolved.auth.sshKey,
|
|
208
|
+
insecure: resolved.insecure.value,
|
|
209
|
+
});
|
|
210
|
+
try {
|
|
211
|
+
const validation = await validateExecuteCommand(resolved, backend);
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
data: null,
|
|
215
|
+
warnings: [...resolved.warnings],
|
|
216
|
+
tips: [],
|
|
217
|
+
meta: metaFromResolved(resolved, validation),
|
|
218
|
+
};
|
|
219
|
+
} finally {
|
|
220
|
+
await backend.close();
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return resolved
|
|
224
|
+
? buildExecuteErrorEnvelopeFromResolved(resolved, error)
|
|
225
|
+
: buildExecuteErrorEnvelope(request, error, env);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function runResolvedExecute(
|
|
230
|
+
resolved: ResolvedExecuteRequest,
|
|
231
|
+
): Promise<ExecuteSuccessEnvelope> {
|
|
232
|
+
const backend = createProtocolAdapter({
|
|
233
|
+
protocol: resolved.via.value,
|
|
234
|
+
host: resolved.target.host,
|
|
235
|
+
port: resolved.target.port,
|
|
236
|
+
tls: resolved.target.tls,
|
|
237
|
+
baseUrl: resolved.target.baseUrl,
|
|
238
|
+
username: resolved.auth.username,
|
|
239
|
+
password: resolved.auth.password,
|
|
240
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
241
|
+
mac: resolved.target.mac,
|
|
242
|
+
sshKey: resolved.auth.sshKey,
|
|
243
|
+
insecure: resolved.insecure.value,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Assigned unconditionally by the if/else below before first use; declared
|
|
247
|
+
// without an initializer so the dead default cannot mask a missing branch.
|
|
248
|
+
let validation: EnvelopeValidationMeta;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
if (resolved.validate.value) {
|
|
252
|
+
validation = await validateExecuteCommand(resolved, backend);
|
|
253
|
+
} else {
|
|
254
|
+
validation = {
|
|
255
|
+
enabled: false,
|
|
256
|
+
source: "disabled",
|
|
257
|
+
result: "skipped",
|
|
258
|
+
syntax: false,
|
|
259
|
+
semantic: false,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const result = await runCommand(resolved, backend);
|
|
264
|
+
const routerOsFailure = routerOsFailureFromResult(
|
|
265
|
+
result,
|
|
266
|
+
resolved.via.value,
|
|
267
|
+
);
|
|
268
|
+
if (routerOsFailure) {
|
|
269
|
+
throw routerOsFailure;
|
|
270
|
+
}
|
|
271
|
+
const data = dataFromResult(result);
|
|
272
|
+
return applyMaxResultsBudget({
|
|
273
|
+
ok: true,
|
|
274
|
+
data,
|
|
275
|
+
warnings: [...resolved.warnings],
|
|
276
|
+
tips: resolvedExecuteTips(resolved),
|
|
277
|
+
meta: metaFromResolved(resolved, validation, data),
|
|
278
|
+
});
|
|
279
|
+
} finally {
|
|
280
|
+
await backend.close();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function resolveExecuteRequest(
|
|
285
|
+
request: ExecuteRequest,
|
|
286
|
+
env: Record<string, string | undefined>,
|
|
287
|
+
): Promise<ResolvedExecuteRequest> {
|
|
288
|
+
validateExecuteRequestShape(request);
|
|
289
|
+
const canonical = canonicalizeExecuteCommand(request.command);
|
|
290
|
+
const cdbResolution = await resolveCdb(
|
|
291
|
+
{
|
|
292
|
+
targetInput: request.targetInput,
|
|
293
|
+
cdbFile: request.cdbFile,
|
|
294
|
+
cdbPassword: request.cdbPassword,
|
|
295
|
+
},
|
|
296
|
+
env,
|
|
297
|
+
);
|
|
298
|
+
const via = resolveExecuteProtocol(request, env, cdbResolution);
|
|
299
|
+
const format = resolveFormat(request, env);
|
|
300
|
+
const validate = resolveBooleanSetting(
|
|
301
|
+
request.validate,
|
|
302
|
+
env,
|
|
303
|
+
"CENTRS_VALIDATE",
|
|
304
|
+
true,
|
|
305
|
+
"validate",
|
|
306
|
+
cdbResolution?.overrides.validate,
|
|
307
|
+
);
|
|
308
|
+
const timeoutMs = resolveTimeoutSetting(
|
|
309
|
+
request.timeout,
|
|
310
|
+
env,
|
|
311
|
+
via.value,
|
|
312
|
+
cdbResolution?.overrides.timeoutMs,
|
|
313
|
+
);
|
|
314
|
+
const maxResultsBytes = resolveOptionalIntegerSetting(
|
|
315
|
+
request.maxResultsBytes,
|
|
316
|
+
env,
|
|
317
|
+
"CENTRS_MAX_RESULTS",
|
|
318
|
+
"max-results",
|
|
319
|
+
);
|
|
320
|
+
const macResolution = isIpTransport(via.value)
|
|
321
|
+
? await resolveMacTarget({
|
|
322
|
+
host: request.host,
|
|
323
|
+
targetInput: request.targetInput,
|
|
324
|
+
cdbTarget: cdbResolution?.target,
|
|
325
|
+
env,
|
|
326
|
+
policy: parseResolvePolicy(request.resolve ?? env["CENTRS_RESOLVE"]),
|
|
327
|
+
operation: "execute",
|
|
328
|
+
})
|
|
329
|
+
: undefined;
|
|
330
|
+
const target = resolveTarget(
|
|
331
|
+
{
|
|
332
|
+
targetInput: request.targetInput,
|
|
333
|
+
host: request.host,
|
|
334
|
+
port: request.port,
|
|
335
|
+
macResolution,
|
|
336
|
+
},
|
|
337
|
+
env,
|
|
338
|
+
via.value,
|
|
339
|
+
cdbResolution,
|
|
340
|
+
);
|
|
341
|
+
const auth = resolveAuth(
|
|
342
|
+
{
|
|
343
|
+
username: request.username,
|
|
344
|
+
password: request.password,
|
|
345
|
+
sshKey: request.sshKey,
|
|
346
|
+
},
|
|
347
|
+
env,
|
|
348
|
+
cdbResolution,
|
|
349
|
+
);
|
|
350
|
+
const insecure = resolveBooleanSetting(
|
|
351
|
+
request.insecure,
|
|
352
|
+
env,
|
|
353
|
+
"CENTRS_INSECURE",
|
|
354
|
+
false,
|
|
355
|
+
"insecure",
|
|
356
|
+
cdbResolution?.overrides.insecure,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
command: request.command,
|
|
361
|
+
canonical,
|
|
362
|
+
via,
|
|
363
|
+
target,
|
|
364
|
+
auth,
|
|
365
|
+
timeoutMs,
|
|
366
|
+
format,
|
|
367
|
+
validate,
|
|
368
|
+
insecure,
|
|
369
|
+
maxResultsBytes,
|
|
370
|
+
yes: request.yes ?? false,
|
|
371
|
+
verbose: request.verbose ?? false,
|
|
372
|
+
warnings: cdbResolution?.warnings ?? [],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function buildExecuteErrorEnvelope(
|
|
377
|
+
request: ExecuteRequest,
|
|
378
|
+
error: unknown,
|
|
379
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
380
|
+
): ExecuteErrorEnvelope {
|
|
381
|
+
const centrsError =
|
|
382
|
+
error instanceof CentrsError
|
|
383
|
+
? error
|
|
384
|
+
: new CentrsError({
|
|
385
|
+
code: "internal/unhandled",
|
|
386
|
+
summary: "execute failed with an unexpected internal error.",
|
|
387
|
+
remediation:
|
|
388
|
+
"Re-run with `--format json` to capture the structured error details for debugging.",
|
|
389
|
+
cause: error,
|
|
390
|
+
});
|
|
391
|
+
const requestedVia = plannedProtocols.includes(
|
|
392
|
+
request.via as RouterOsProtocol,
|
|
393
|
+
)
|
|
394
|
+
? (request.via as RouterOsProtocol)
|
|
395
|
+
: null;
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
ok: false,
|
|
399
|
+
error: serializeCentrsError(centrsError),
|
|
400
|
+
warnings: [],
|
|
401
|
+
tips: [],
|
|
402
|
+
meta: {
|
|
403
|
+
target: { input: request.targetInput },
|
|
404
|
+
via: requestedVia,
|
|
405
|
+
settings: {},
|
|
406
|
+
operation: {
|
|
407
|
+
kind: "execute",
|
|
408
|
+
objectCount: 0,
|
|
409
|
+
request: {
|
|
410
|
+
command: request.command,
|
|
411
|
+
write: false,
|
|
412
|
+
yes: request.yes ?? false,
|
|
413
|
+
validate: request.validate ?? true,
|
|
414
|
+
verbose: request.verbose ?? false,
|
|
415
|
+
timeoutMs: 10_000,
|
|
416
|
+
format: resolveErrorFormat(request, env),
|
|
417
|
+
},
|
|
418
|
+
auth: {
|
|
419
|
+
username: request.username,
|
|
420
|
+
passwordProvided: request.password !== undefined,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function buildExecuteErrorEnvelopeFromResolved(
|
|
428
|
+
resolved: ResolvedExecuteRequest,
|
|
429
|
+
error: unknown,
|
|
430
|
+
validation?: EnvelopeValidationMeta,
|
|
431
|
+
): ExecuteErrorEnvelope {
|
|
432
|
+
const centrsError =
|
|
433
|
+
error instanceof CentrsError
|
|
434
|
+
? error
|
|
435
|
+
: new CentrsError({
|
|
436
|
+
code: "internal/unhandled",
|
|
437
|
+
summary: "execute failed with an unexpected internal error.",
|
|
438
|
+
remediation:
|
|
439
|
+
"Re-run with `--format json` to capture the structured error details for debugging.",
|
|
440
|
+
cause: error,
|
|
441
|
+
});
|
|
442
|
+
return {
|
|
443
|
+
ok: false,
|
|
444
|
+
error: serializeCentrsError(centrsError),
|
|
445
|
+
warnings: [...resolved.warnings],
|
|
446
|
+
tips: resolvedExecuteTips(resolved),
|
|
447
|
+
meta: metaFromResolved(
|
|
448
|
+
resolved,
|
|
449
|
+
validation ?? {
|
|
450
|
+
enabled: resolved.validate.value,
|
|
451
|
+
source: resolved.validate.value
|
|
452
|
+
? ":put [:parse] + /console/inspect"
|
|
453
|
+
: "disabled",
|
|
454
|
+
result: resolved.validate.value ? "failed" : "skipped",
|
|
455
|
+
syntax: resolved.validate.value,
|
|
456
|
+
semantic: resolved.validate.value
|
|
457
|
+
? resolved.canonical.mode === "structured"
|
|
458
|
+
? false
|
|
459
|
+
: "not-applicable"
|
|
460
|
+
: false,
|
|
461
|
+
},
|
|
462
|
+
),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function renderExecuteEnvelope(
|
|
467
|
+
envelope: ExecuteEnvelope,
|
|
468
|
+
format: ExecuteOutputFormat,
|
|
469
|
+
options: { verbose?: boolean } = {},
|
|
470
|
+
): string {
|
|
471
|
+
switch (format) {
|
|
472
|
+
case "json":
|
|
473
|
+
return JSON.stringify(envelope, null, 2);
|
|
474
|
+
case "yaml":
|
|
475
|
+
return toYaml(envelope);
|
|
476
|
+
case "text":
|
|
477
|
+
return envelope.ok
|
|
478
|
+
? renderExecuteSuccessText(envelope, options)
|
|
479
|
+
: renderExecuteErrorText(envelope, options);
|
|
480
|
+
default:
|
|
481
|
+
return exhaustiveOutputFormat(format);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The **script-vs-structured execution gate** — centrs's load-bearing
|
|
487
|
+
* discriminator (with {@link isWriteShaped}) for which validation runs and
|
|
488
|
+
* whether the write-confirmation prompt fires. centrs owns this gate; the shared,
|
|
489
|
+
* prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
|
|
490
|
+
* publish is for *canonicalization*, never the structured-mode predicate —
|
|
491
|
+
* widening what counts as `structured` is a product regression. Behavior is
|
|
492
|
+
* pinned by `test/unit/execute-canonicalize-contract.test.ts`.
|
|
493
|
+
*
|
|
494
|
+
* centrs deliberately does **not** vendor the shared parser yet. Preconditions
|
|
495
|
+
* for adopting it: (1) that contract stays green (no gate widening); (2) the
|
|
496
|
+
* vendored file is clean under centrs's strict `tsconfig` (or explicitly
|
|
497
|
+
* quarantined with justification); (3) `lsp-routeros-ts` also vendors/consumes
|
|
498
|
+
* the same parser shape, so it is genuinely shared, not a premature first copy.
|
|
499
|
+
*/
|
|
500
|
+
export function canonicalizeExecuteCommand(
|
|
501
|
+
input: string,
|
|
502
|
+
): CanonicalExecuteCommand {
|
|
503
|
+
const asScript = (): CanonicalExecuteCommand => ({
|
|
504
|
+
mode: "script",
|
|
505
|
+
input,
|
|
506
|
+
path: "",
|
|
507
|
+
verb: "",
|
|
508
|
+
attributes: {},
|
|
509
|
+
queries: [],
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const trimmed = input.trim();
|
|
513
|
+
if (!trimmed.startsWith("/")) {
|
|
514
|
+
return asScript();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const tokens = tokenizeRouterOsCli(trimmed);
|
|
518
|
+
const pathToken = tokens[0] ?? "";
|
|
519
|
+
const pathParts = pathToken.split("/").filter(Boolean);
|
|
520
|
+
if (pathParts.length < 2) {
|
|
521
|
+
return asScript();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const verb = pathParts.at(-1) ?? "";
|
|
525
|
+
const path = `/${pathParts.slice(0, -1).join("/")}`;
|
|
526
|
+
const attributes: Record<string, string> = {};
|
|
527
|
+
const queries: string[] = [];
|
|
528
|
+
for (const token of tokens.slice(1)) {
|
|
529
|
+
// `[...]` subshell selectors (e.g. `numbers=[find ...]`) cannot be
|
|
530
|
+
// represented as a structured attribute map: the inner command contains
|
|
531
|
+
// spaces this tokenizer splits on, which would mangle a write-shaped
|
|
532
|
+
// command into corrupt key=value pairs. Fall back to the raw-script path
|
|
533
|
+
// so RouterOS evaluates the subshell with its real semantics.
|
|
534
|
+
if (token.includes("[") || token.includes("]")) {
|
|
535
|
+
return asScript();
|
|
536
|
+
}
|
|
537
|
+
if (token.startsWith("?")) {
|
|
538
|
+
queries.push(token);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const separator = token.indexOf("=");
|
|
542
|
+
if (separator <= 0) {
|
|
543
|
+
return asScript();
|
|
544
|
+
}
|
|
545
|
+
attributes[token.slice(0, separator)] = token.slice(separator + 1);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
mode: "structured",
|
|
550
|
+
input,
|
|
551
|
+
path,
|
|
552
|
+
verb,
|
|
553
|
+
attributes,
|
|
554
|
+
queries,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
559
|
+
return (
|
|
560
|
+
command.mode === "structured" &&
|
|
561
|
+
["add", "set", "remove"].includes(command.verb)
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function validateExecuteRequestShape(request: ExecuteRequest): void {
|
|
566
|
+
if (!request.command || request.command.trim().length === 0) {
|
|
567
|
+
throw new CentrsError({
|
|
568
|
+
code: "input/invalid-command",
|
|
569
|
+
summary: "`centrs execute` requires a RouterOS command string.",
|
|
570
|
+
remediation:
|
|
571
|
+
"Pass a RouterOS CLI command such as `/ip/address/add address=198.51.100.1/32 interface=ether1`.",
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function validateExecuteCommand(
|
|
577
|
+
resolved: ResolvedExecuteRequest,
|
|
578
|
+
backend: ProtocolAdapter,
|
|
579
|
+
): Promise<EnvelopeValidationMeta> {
|
|
580
|
+
if (resolved.via.value === "mac-telnet" || resolved.via.value === "ssh") {
|
|
581
|
+
return validateConsoleParseCommand(resolved, backend);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
await runSyntaxGate(resolved, backend);
|
|
585
|
+
|
|
586
|
+
if (resolved.canonical.mode !== "structured") {
|
|
587
|
+
return {
|
|
588
|
+
enabled: true,
|
|
589
|
+
source: ":put [:parse]",
|
|
590
|
+
result: "passed",
|
|
591
|
+
syntax: true,
|
|
592
|
+
semantic: "not-applicable",
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const availableAttributes = await inspectExecuteAttributes(resolved, backend);
|
|
597
|
+
const requested = Object.keys(resolved.canonical.attributes);
|
|
598
|
+
const missing = requested.filter(
|
|
599
|
+
(attribute) => !availableAttributes.includes(attribute),
|
|
600
|
+
);
|
|
601
|
+
if (missing.length > 0) {
|
|
602
|
+
throw new CentrsError({
|
|
603
|
+
code: "validation/unknown-attribute",
|
|
604
|
+
summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.canonical.path}/${resolved.canonical.verb}.`,
|
|
605
|
+
remediation:
|
|
606
|
+
"Check the attribute name against `/console/inspect`, or use `--validate=false` only when intentionally probing an undocumented RouterOS edge.",
|
|
607
|
+
context: {
|
|
608
|
+
path: resolved.canonical.path,
|
|
609
|
+
verb: resolved.canonical.verb,
|
|
610
|
+
attribute: missing[0],
|
|
611
|
+
requestedAttributes: requested,
|
|
612
|
+
availableAttributes,
|
|
613
|
+
validationSource: "/console/inspect request=child+completion",
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
enabled: true,
|
|
620
|
+
source: ":put [:parse] + /console/inspect",
|
|
621
|
+
result: "passed",
|
|
622
|
+
syntax: true,
|
|
623
|
+
semantic: true,
|
|
624
|
+
availableAttributes,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Validate a command over a console transport (mac-telnet or ssh) with a single
|
|
630
|
+
* `:put [:parse ...]`. Unlike REST/native (a syntax gate plus a `/console/inspect`
|
|
631
|
+
* semantic gate), one console `:parse` reports both `syntax error` and `bad
|
|
632
|
+
* parameter <name>`, so it covers syntax and the unknown-attribute (semantic)
|
|
633
|
+
* gate at once — identical strings over mac-telnet and ssh (CHR 7.23.1 grounded;
|
|
634
|
+
* see `mac-telnet-console.ts` and `ssh.ts`).
|
|
635
|
+
*/
|
|
636
|
+
async function validateConsoleParseCommand(
|
|
637
|
+
resolved: ResolvedExecuteRequest,
|
|
638
|
+
backend: ProtocolAdapter,
|
|
639
|
+
): Promise<EnvelopeValidationMeta> {
|
|
640
|
+
if (hasUnbalancedQuotes(resolved.command)) {
|
|
641
|
+
throw new CentrsError({
|
|
642
|
+
code: "validation/syntax",
|
|
643
|
+
summary:
|
|
644
|
+
"RouterOS rejected the command syntax during local quote preflight.",
|
|
645
|
+
remediation:
|
|
646
|
+
"Close the RouterOS string quote, then retry. The command was not executed.",
|
|
647
|
+
context: {
|
|
648
|
+
command: resolved.command,
|
|
649
|
+
validationSource: "local quote preflight before :put [:parse ...]",
|
|
650
|
+
via: resolved.via.value,
|
|
651
|
+
},
|
|
652
|
+
causeData: "unterminated string literal",
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
const result = await backend.execute({
|
|
656
|
+
path: "",
|
|
657
|
+
command: "",
|
|
658
|
+
script: parseScriptFor(resolved.command),
|
|
659
|
+
});
|
|
660
|
+
// Throws validation/syntax or validation/unknown-attribute on a bad parse.
|
|
661
|
+
classifyParseResult(result.ret ?? "", resolved.command);
|
|
662
|
+
return {
|
|
663
|
+
enabled: true,
|
|
664
|
+
source: `:put [:parse ...] over ${resolved.via.value}`,
|
|
665
|
+
result: "passed",
|
|
666
|
+
syntax: true,
|
|
667
|
+
semantic:
|
|
668
|
+
resolved.canonical.mode === "structured" ? true : "not-applicable",
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function runSyntaxGate(
|
|
673
|
+
resolved: ResolvedExecuteRequest,
|
|
674
|
+
backend: ProtocolAdapter,
|
|
675
|
+
): Promise<void> {
|
|
676
|
+
if (hasUnbalancedQuotes(resolved.command)) {
|
|
677
|
+
throw new CentrsError({
|
|
678
|
+
code: "validation/syntax",
|
|
679
|
+
summary:
|
|
680
|
+
"RouterOS rejected the command syntax during local quote preflight.",
|
|
681
|
+
remediation:
|
|
682
|
+
"Close the RouterOS string quote, then retry. The command was not executed.",
|
|
683
|
+
context: {
|
|
684
|
+
command: resolved.command,
|
|
685
|
+
validationSource: "local quote preflight before :put [:parse ...]",
|
|
686
|
+
via: resolved.via.value,
|
|
687
|
+
},
|
|
688
|
+
causeData: "unterminated string literal",
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
const script = `:put [:parse ${routerOsStringLiteral(resolved.command)}]`;
|
|
692
|
+
try {
|
|
693
|
+
await backend.execute({ path: "", command: "", script });
|
|
694
|
+
} catch (error) {
|
|
695
|
+
// The preflight is a syntax gate, but `backend.execute` also surfaces
|
|
696
|
+
// connection and authentication failures (login happens lazily here).
|
|
697
|
+
// Those are not syntax problems — rethrow them unchanged so the caller
|
|
698
|
+
// sees the real `transport/*` cause instead of a misleading
|
|
699
|
+
// `validation/syntax`. Only genuine RouterOS parse rejections (mapped to
|
|
700
|
+
// `routeros/*`) are relabeled as a syntax failure below.
|
|
701
|
+
if (error instanceof CentrsError && isPreflightTransportError(error)) {
|
|
702
|
+
throw error;
|
|
703
|
+
}
|
|
704
|
+
// If the mapped RouterOS fault carried a `(line N column M)` byte offset,
|
|
705
|
+
// keep it on the wrapped syntax error rather than dropping it.
|
|
706
|
+
const position = error instanceof CentrsError ? error.position : undefined;
|
|
707
|
+
throw new CentrsError({
|
|
708
|
+
code: "validation/syntax",
|
|
709
|
+
summary:
|
|
710
|
+
"RouterOS rejected the command syntax during `:parse` preflight.",
|
|
711
|
+
remediation:
|
|
712
|
+
"Fix the RouterOS CLI syntax, especially quotes and bracketed expressions, then retry.",
|
|
713
|
+
context: {
|
|
714
|
+
command: resolved.command,
|
|
715
|
+
validationSource: ":put [:parse ...]",
|
|
716
|
+
via: resolved.via.value,
|
|
717
|
+
},
|
|
718
|
+
...(position ? { position } : {}),
|
|
719
|
+
cause: error,
|
|
720
|
+
causeData: syntaxCause(error),
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* True when a `:parse` preflight error is actually a transport/auth/connection
|
|
727
|
+
* failure rather than a RouterOS syntax rejection. These must not be relabeled
|
|
728
|
+
* as `validation/syntax`.
|
|
729
|
+
*/
|
|
730
|
+
function isPreflightTransportError(error: CentrsError): boolean {
|
|
731
|
+
return error.code.startsWith("transport/") || error.code.startsWith("auth/");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function inspectExecuteAttributes(
|
|
735
|
+
resolved: ResolvedExecuteRequest,
|
|
736
|
+
backend: ProtocolAdapter,
|
|
737
|
+
): Promise<string[]> {
|
|
738
|
+
const commandPath = inspectPath([
|
|
739
|
+
...pathTokens(resolved.canonical.path),
|
|
740
|
+
resolved.canonical.verb,
|
|
741
|
+
]);
|
|
742
|
+
const children = (await backend.inspect(
|
|
743
|
+
"child",
|
|
744
|
+
commandPath,
|
|
745
|
+
)) as InspectChildItem[];
|
|
746
|
+
const childAttributes = children
|
|
747
|
+
.filter(isArgumentNode)
|
|
748
|
+
.map((child) => child.name)
|
|
749
|
+
.filter(
|
|
750
|
+
(name): name is string => typeof name === "string" && name.length > 0,
|
|
751
|
+
);
|
|
752
|
+
const completionRows = (await backend.inspect(
|
|
753
|
+
"completion",
|
|
754
|
+
commandPath,
|
|
755
|
+
)) as InspectCompletionItem[];
|
|
756
|
+
return [
|
|
757
|
+
...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
|
|
758
|
+
].sort();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function runCommand(
|
|
762
|
+
resolved: ResolvedExecuteRequest,
|
|
763
|
+
backend: ProtocolAdapter,
|
|
764
|
+
): Promise<ProtocolExecuteResult> {
|
|
765
|
+
if (resolved.via.value === "mac-telnet" || resolved.via.value === "ssh") {
|
|
766
|
+
// Console transports: run the raw CLI line and capture the console output
|
|
767
|
+
// (structured path-POST / tagged talk do not apply). ssh runs it as a
|
|
768
|
+
// single `ssh host "<command>"`; mac-telnet drives the L2 console.
|
|
769
|
+
return backend.execute({ path: "", command: "", script: resolved.command });
|
|
770
|
+
}
|
|
771
|
+
if (resolved.canonical.mode === "script") {
|
|
772
|
+
if (resolved.via.value === "native-api") {
|
|
773
|
+
throw new CentrsError({
|
|
774
|
+
code: "transport/unsupported-operation",
|
|
775
|
+
summary:
|
|
776
|
+
"The native API execute path requires a slash-prefixed RouterOS command.",
|
|
777
|
+
remediation:
|
|
778
|
+
"Use a path-shaped command such as `/system/identity/set name=x`, or pin `--via rest-api` for `/rest/execute` script mode.",
|
|
779
|
+
context: { via: resolved.via.value, command: resolved.command },
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
return backend.execute({ path: "", command: "", script: resolved.command });
|
|
783
|
+
}
|
|
784
|
+
return backend.execute({
|
|
785
|
+
path: resolved.canonical.path,
|
|
786
|
+
command: resolved.canonical.verb,
|
|
787
|
+
attributes: resolved.canonical.attributes,
|
|
788
|
+
queries: resolved.canonical.queries,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function assertWriteConfirmed(
|
|
793
|
+
request: ExecuteRequest,
|
|
794
|
+
resolved: ResolvedExecuteRequest,
|
|
795
|
+
): Promise<void> {
|
|
796
|
+
if (!isWriteShaped(resolved.canonical) || resolved.yes) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
800
|
+
const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
|
|
801
|
+
`Run write-shaped RouterOS command over ${resolved.via.value}? Type yes to continue: `,
|
|
802
|
+
);
|
|
803
|
+
if (confirmed) {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
throw new CentrsError({
|
|
808
|
+
code: "usage/confirmation-required",
|
|
809
|
+
summary:
|
|
810
|
+
"Write-shaped RouterOS execute commands require explicit confirmation.",
|
|
811
|
+
remediation:
|
|
812
|
+
"Pass `--yes` in non-interactive automation, or answer `yes` at the TTY prompt after reviewing the command.",
|
|
813
|
+
context: {
|
|
814
|
+
command: resolved.command,
|
|
815
|
+
path: resolved.canonical.path,
|
|
816
|
+
verb: resolved.canonical.verb,
|
|
817
|
+
via: resolved.via.value,
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function promptForWriteConfirmation(prompt: string): Promise<boolean> {
|
|
823
|
+
process.stderr.write(prompt);
|
|
824
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
825
|
+
const answer = new TextDecoder().decode(chunk).trim().toLowerCase();
|
|
826
|
+
return answer === "yes" || answer === "y";
|
|
827
|
+
}
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function resolveExecuteProtocol(
|
|
832
|
+
request: ExecuteRequest,
|
|
833
|
+
env: Record<string, string | undefined>,
|
|
834
|
+
cdb?: CdbResolution,
|
|
835
|
+
): ResolvedSetting<RouterOsProtocol> {
|
|
836
|
+
const defaultVia = isUnresolvedMacTarget(request, env, cdb)
|
|
837
|
+
? "mac-telnet"
|
|
838
|
+
: "native-api";
|
|
839
|
+
const via = resolveStringSetting(
|
|
840
|
+
request.via,
|
|
841
|
+
env,
|
|
842
|
+
"CENTRS_VIA",
|
|
843
|
+
defaultVia,
|
|
844
|
+
"via",
|
|
845
|
+
undefined,
|
|
846
|
+
cdb?.overrides.via,
|
|
847
|
+
);
|
|
848
|
+
if (!via) {
|
|
849
|
+
throw new CentrsError({
|
|
850
|
+
code: "internal/unhandled",
|
|
851
|
+
summary: "Failed to resolve the default execute protocol.",
|
|
852
|
+
remediation:
|
|
853
|
+
"Report this bug; execute should default to native-api or mac-telnet.",
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
if (!plannedProtocols.includes(via.value as RouterOsProtocol)) {
|
|
857
|
+
throw new CentrsError({
|
|
858
|
+
code: "settings/invalid-via",
|
|
859
|
+
summary: `Unsupported protocol identifier: ${via.value}`,
|
|
860
|
+
remediation:
|
|
861
|
+
"Choose one of the known `via` values, such as `native-api` or `rest-api` for execute.",
|
|
862
|
+
context: { via: via.value },
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
const plan = getProtocolPlan(via.value as RouterOsProtocol);
|
|
866
|
+
if (!plan.capabilities.includes("execute")) {
|
|
867
|
+
throw new CentrsError({
|
|
868
|
+
code: "routeros/unsupported-capability",
|
|
869
|
+
summary: `Protocol ${via.value} does not support the execute capability.`,
|
|
870
|
+
remediation:
|
|
871
|
+
"Choose an execute-capable protocol such as `native-api`, `rest-api`, or `mac-telnet`.",
|
|
872
|
+
context: { via: via.value, capability: "execute" },
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
if (!["native-api", "rest-api", "mac-telnet", "ssh"].includes(via.value)) {
|
|
876
|
+
throw new CentrsError({
|
|
877
|
+
code: "routeros/protocol-not-implemented",
|
|
878
|
+
summary: `Execute over ${via.value} is planned but not wired through the shared execute orchestrator yet.`,
|
|
879
|
+
remediation:
|
|
880
|
+
"Use `--via native-api`, `--via rest-api`, `--via ssh`, or `--via mac-telnet`. For an IP transport against a MAC target, add `--resolve arp`.",
|
|
881
|
+
context: { via: via.value, capability: "execute" },
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
return via as ResolvedSetting<RouterOsProtocol>;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function isUnresolvedMacTarget(
|
|
888
|
+
request: ExecuteRequest,
|
|
889
|
+
env: Record<string, string | undefined>,
|
|
890
|
+
cdb?: CdbResolution,
|
|
891
|
+
): boolean {
|
|
892
|
+
if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
// Decide the default transport from the *effective* target (host >
|
|
896
|
+
// CENTRS_HOST > CDB target > positional), not the positional alone — an IP
|
|
897
|
+
// supplied via --host/CENTRS_HOST must default to native-api even when the
|
|
898
|
+
// positional was a MAC.
|
|
899
|
+
const candidate = effectiveHostCandidate({
|
|
900
|
+
host: request.host,
|
|
901
|
+
targetInput: request.targetInput,
|
|
902
|
+
cdbTarget: cdb?.target,
|
|
903
|
+
env,
|
|
904
|
+
});
|
|
905
|
+
return candidate ? isMacAddress(candidate) : false;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function resolveFormat(
|
|
909
|
+
request: ExecuteRequest,
|
|
910
|
+
env: Record<string, string | undefined>,
|
|
911
|
+
): ResolvedSetting<ExecuteOutputFormat> {
|
|
912
|
+
return resolveStringSetting(
|
|
913
|
+
request.format,
|
|
914
|
+
env,
|
|
915
|
+
"CENTRS_FORMAT",
|
|
916
|
+
"text",
|
|
917
|
+
"format",
|
|
918
|
+
parseOutputFormat,
|
|
919
|
+
) as ResolvedSetting<ExecuteOutputFormat>;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Resolve the render format for an error envelope built before settings were
|
|
924
|
+
* resolved. Honors `--format`/`CENTRS_FORMAT` like {@link resolveFormat} but
|
|
925
|
+
* never throws on a bad value (the original error must surface), defaulting to
|
|
926
|
+
* the human-readable `text` form.
|
|
927
|
+
*/
|
|
928
|
+
function resolveErrorFormat(
|
|
929
|
+
request: ExecuteRequest,
|
|
930
|
+
env: Record<string, string | undefined>,
|
|
931
|
+
): ExecuteOutputFormat {
|
|
932
|
+
try {
|
|
933
|
+
return resolveFormat(request, env).value;
|
|
934
|
+
} catch {
|
|
935
|
+
return "text";
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function resolveTimeoutSetting(
|
|
940
|
+
timeout: ExecuteRequest["timeout"],
|
|
941
|
+
env: Record<string, string | undefined>,
|
|
942
|
+
via: RouterOsProtocol,
|
|
943
|
+
commentKv?: ResolvedSetting<number>,
|
|
944
|
+
): ResolvedSetting<number> {
|
|
945
|
+
const resolved = resolveStringSetting(
|
|
946
|
+
timeout === undefined ? undefined : String(timeout),
|
|
947
|
+
env,
|
|
948
|
+
"CENTRS_TIMEOUT",
|
|
949
|
+
"10000",
|
|
950
|
+
"timeout",
|
|
951
|
+
(value) => {
|
|
952
|
+
const parsed = parseDuration(value);
|
|
953
|
+
if (parsed <= 0) {
|
|
954
|
+
throw new CentrsError({
|
|
955
|
+
code: "settings/invalid-timeout",
|
|
956
|
+
summary: `Timeout must be greater than zero. Received: ${value}`,
|
|
957
|
+
remediation:
|
|
958
|
+
"Use a positive integer in milliseconds or a suffix like `5s` / `500ms`.",
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
return parsed;
|
|
962
|
+
},
|
|
963
|
+
commentKv,
|
|
964
|
+
);
|
|
965
|
+
if (!resolved) {
|
|
966
|
+
throw new Error("timeout resolution produced no value");
|
|
967
|
+
}
|
|
968
|
+
if (via === "rest-api" && resolved.value > 60_000) {
|
|
969
|
+
throw new CentrsError({
|
|
970
|
+
code: "usage/timeout-out-of-range",
|
|
971
|
+
summary: `REST timeout ${resolved.value}ms exceeds the current RouterOS REST ceiling.`,
|
|
972
|
+
remediation:
|
|
973
|
+
"Use `--timeout 60s` or less for the current REST execute path.",
|
|
974
|
+
context: { via, timeoutMs: resolved.value, ceilingMs: 60_000 },
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
return resolved;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function metaFromResolved(
|
|
981
|
+
resolved: ResolvedExecuteRequest,
|
|
982
|
+
validation: EnvelopeValidationMeta,
|
|
983
|
+
data?: unknown,
|
|
984
|
+
): ExecuteEnvelope["meta"] {
|
|
985
|
+
const target = resolved.target;
|
|
986
|
+
const targetSources: Record<string, CoreSettingSource> = {};
|
|
987
|
+
for (const [field, source] of Object.entries(target.sources)) {
|
|
988
|
+
targetSources[field] = toCoreSource(source);
|
|
989
|
+
}
|
|
990
|
+
const operation: ExecuteOperationMeta = {
|
|
991
|
+
kind: "execute",
|
|
992
|
+
objectCount: countResultObjects(data),
|
|
993
|
+
request: executeRequestSummary(resolved),
|
|
994
|
+
auth: {
|
|
995
|
+
username: resolved.auth.username,
|
|
996
|
+
passwordProvided: resolved.auth.passwordProvided,
|
|
997
|
+
},
|
|
998
|
+
};
|
|
999
|
+
return {
|
|
1000
|
+
target: {
|
|
1001
|
+
input: target.input,
|
|
1002
|
+
host: target.host,
|
|
1003
|
+
port: target.port,
|
|
1004
|
+
baseUrl: target.baseUrl,
|
|
1005
|
+
mac: target.mac,
|
|
1006
|
+
identity: target.identity,
|
|
1007
|
+
recordIndex: target.recordIndex,
|
|
1008
|
+
source: toCoreSource(target.source),
|
|
1009
|
+
sources: targetSources,
|
|
1010
|
+
},
|
|
1011
|
+
via: resolved.via.value,
|
|
1012
|
+
settings: settingsMeta(resolved),
|
|
1013
|
+
validation,
|
|
1014
|
+
operation,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function settingsMeta(resolved: ResolvedExecuteRequest): CommonSettingsMeta {
|
|
1019
|
+
return {
|
|
1020
|
+
via: toCoreSource(resolved.via.source),
|
|
1021
|
+
host: toCoreSource(resolved.target.hostSource),
|
|
1022
|
+
port: toCoreSource(resolved.target.portSource),
|
|
1023
|
+
timeoutMs: toCoreSource(resolved.timeoutMs.source),
|
|
1024
|
+
format: toCoreSource(resolved.format.source),
|
|
1025
|
+
validate: toCoreSource(resolved.validate.source),
|
|
1026
|
+
insecure: toCoreSource(resolved.insecure.source),
|
|
1027
|
+
maxResultsBytes: resolved.maxResultsBytes
|
|
1028
|
+
? toCoreSource(resolved.maxResultsBytes.source)
|
|
1029
|
+
: undefined,
|
|
1030
|
+
username: resolved.auth.usernameSource
|
|
1031
|
+
? toCoreSource(resolved.auth.usernameSource)
|
|
1032
|
+
: undefined,
|
|
1033
|
+
password: resolved.auth.passwordSource
|
|
1034
|
+
? toCoreSource(resolved.auth.passwordSource)
|
|
1035
|
+
: undefined,
|
|
1036
|
+
sshKey: resolved.auth.sshKeySource
|
|
1037
|
+
? toCoreSource(resolved.auth.sshKeySource)
|
|
1038
|
+
: undefined,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function executeRequestSummary(
|
|
1043
|
+
resolved: ResolvedExecuteRequest,
|
|
1044
|
+
): ExecuteRequestSummary {
|
|
1045
|
+
return {
|
|
1046
|
+
command: resolved.command,
|
|
1047
|
+
canonical: resolved.canonical,
|
|
1048
|
+
write: isWriteShaped(resolved.canonical),
|
|
1049
|
+
yes: resolved.yes,
|
|
1050
|
+
validate: resolved.validate.value,
|
|
1051
|
+
verbose: resolved.verbose,
|
|
1052
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
1053
|
+
format: resolved.format.value,
|
|
1054
|
+
maxResultsBytes: resolved.maxResultsBytes?.value,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Advisory tips derived from the resolved request (advice, never an anomaly —
|
|
1060
|
+
* see the constitution's tips channel). Today: a mac-telnet target with no
|
|
1061
|
+
* credentials resolved from any source. mac-telnet logs in with RouterOS
|
|
1062
|
+
* credentials over MTWEI (MD5 is refused by current RouterOS), so an empty login
|
|
1063
|
+
* is rejected at connect time — surface that up front rather than only as a
|
|
1064
|
+
* downstream auth error.
|
|
1065
|
+
*/
|
|
1066
|
+
export function resolvedExecuteTips(resolved: ResolvedExecuteRequest): Tip[] {
|
|
1067
|
+
if (
|
|
1068
|
+
resolved.via.value === "mac-telnet" &&
|
|
1069
|
+
resolved.auth.username === undefined &&
|
|
1070
|
+
!resolved.auth.passwordProvided
|
|
1071
|
+
) {
|
|
1072
|
+
return [
|
|
1073
|
+
buildTip(
|
|
1074
|
+
"tip/mac-telnet-no-credentials",
|
|
1075
|
+
"No username or password was resolved for this mac-telnet target.",
|
|
1076
|
+
"mac-telnet logs in with RouterOS credentials over MTWEI (MD5 is refused): pass --username/--password, set CENTRS_USERNAME/CENTRS_PASSWORD, or add a CDB record for this device.",
|
|
1077
|
+
),
|
|
1078
|
+
];
|
|
1079
|
+
}
|
|
1080
|
+
return [];
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function dataFromResult(result: ProtocolExecuteResult): unknown {
|
|
1084
|
+
if (result.ret !== undefined) {
|
|
1085
|
+
const data: Record<string, string> = { ret: result.ret };
|
|
1086
|
+
if (/^\*[0-9A-F]+$/i.test(result.ret)) {
|
|
1087
|
+
data[".id"] = result.ret;
|
|
1088
|
+
}
|
|
1089
|
+
return data;
|
|
1090
|
+
}
|
|
1091
|
+
if (result.records.length === 0) {
|
|
1092
|
+
return {};
|
|
1093
|
+
}
|
|
1094
|
+
if (result.records.length === 1) {
|
|
1095
|
+
const record = result.records[0] ?? {};
|
|
1096
|
+
const ret = record["ret"];
|
|
1097
|
+
if (typeof ret === "string" && /^\*[0-9A-F]+$/i.test(ret)) {
|
|
1098
|
+
return { ...record, ".id": ret };
|
|
1099
|
+
}
|
|
1100
|
+
return record;
|
|
1101
|
+
}
|
|
1102
|
+
return result.records;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function routerOsFailureFromResult(
|
|
1106
|
+
result: ProtocolExecuteResult,
|
|
1107
|
+
via: RouterOsProtocol,
|
|
1108
|
+
): CentrsError | undefined {
|
|
1109
|
+
const candidates = [
|
|
1110
|
+
result.ret,
|
|
1111
|
+
...result.records.flatMap((record) => [
|
|
1112
|
+
record["detail"],
|
|
1113
|
+
record["message"],
|
|
1114
|
+
record["failure"],
|
|
1115
|
+
record["error"],
|
|
1116
|
+
record["ret"],
|
|
1117
|
+
]),
|
|
1118
|
+
].filter((value): value is string => typeof value === "string");
|
|
1119
|
+
const failure = candidates.find((candidate) =>
|
|
1120
|
+
isRouterOsFailureString(candidate),
|
|
1121
|
+
);
|
|
1122
|
+
return failure
|
|
1123
|
+
? mapRouterOsError(failure, {
|
|
1124
|
+
transport:
|
|
1125
|
+
via === "rest-api" || via === "mac-telnet" || via === "ssh"
|
|
1126
|
+
? via
|
|
1127
|
+
: "native-api",
|
|
1128
|
+
context: { via },
|
|
1129
|
+
})
|
|
1130
|
+
: undefined;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function isRouterOsFailureString(value: string): boolean {
|
|
1134
|
+
const trimmed = value.trim();
|
|
1135
|
+
// REST/native fault strings (unchanged).
|
|
1136
|
+
if (
|
|
1137
|
+
/^(failure:|error:)|unknown parameter|invalid value|session closed|\(:error; line \d+\)/i.test(
|
|
1138
|
+
trimmed,
|
|
1139
|
+
)
|
|
1140
|
+
) {
|
|
1141
|
+
return true;
|
|
1142
|
+
}
|
|
1143
|
+
// Console (mac-telnet) error forms. `ret` here is ordinary stdout (every
|
|
1144
|
+
// mac-telnet command and REST `/execute`), so anchor to the grounded RouterOS
|
|
1145
|
+
// error *shapes* — the `(line N column M)` source location for parse rejections
|
|
1146
|
+
// — rather than bare phrases, so `:put "syntax error"` is not misread as a
|
|
1147
|
+
// failure.
|
|
1148
|
+
return (
|
|
1149
|
+
/(?:^|\n)\s*(?:bad parameter \S+|syntax error)\b[^\n]*\(line \d+ column \d+\)/i.test(
|
|
1150
|
+
trimmed,
|
|
1151
|
+
) ||
|
|
1152
|
+
/(?:^|\n)\s*bad command name\b/i.test(trimmed) ||
|
|
1153
|
+
/(?:^|\n)\s*expected end of command\b/i.test(trimmed)
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function applyMaxResultsBudget(
|
|
1158
|
+
envelope: ExecuteSuccessEnvelope,
|
|
1159
|
+
): ExecuteSuccessEnvelope {
|
|
1160
|
+
const request = envelope.meta.operation?.request;
|
|
1161
|
+
if (!request?.maxResultsBytes) {
|
|
1162
|
+
return envelope;
|
|
1163
|
+
}
|
|
1164
|
+
const rendered = renderExecuteEnvelope(envelope, request.format, {
|
|
1165
|
+
verbose: request.format === "text" && request.verbose,
|
|
1166
|
+
});
|
|
1167
|
+
const serializedBytes = new TextEncoder().encode(rendered).length;
|
|
1168
|
+
if (serializedBytes > request.maxResultsBytes) {
|
|
1169
|
+
throw new CentrsError({
|
|
1170
|
+
code: "input/max-results-exceeded",
|
|
1171
|
+
summary: `execute output exceeded the requested ${request.maxResultsBytes}-byte budget.`,
|
|
1172
|
+
remediation:
|
|
1173
|
+
"Increase `--max-results`, reduce command output, or switch to a narrower command.",
|
|
1174
|
+
context: {
|
|
1175
|
+
requiredBytes: serializedBytes,
|
|
1176
|
+
maxResultsBytes: request.maxResultsBytes,
|
|
1177
|
+
command: request.command,
|
|
1178
|
+
},
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
return envelope;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function renderExecuteSuccessText(
|
|
1185
|
+
envelope: ExecuteSuccessEnvelope,
|
|
1186
|
+
options: { verbose?: boolean },
|
|
1187
|
+
): string {
|
|
1188
|
+
const lines: string[] = [];
|
|
1189
|
+
if (options.verbose) {
|
|
1190
|
+
lines.push(
|
|
1191
|
+
`target: ${envelope.meta.target.input ?? envelope.meta.target.host}`,
|
|
1192
|
+
);
|
|
1193
|
+
lines.push(`via: ${envelope.meta.via}`);
|
|
1194
|
+
if (envelope.meta.validation?.source) {
|
|
1195
|
+
lines.push(`validation: ${envelope.meta.validation.source}`);
|
|
1196
|
+
}
|
|
1197
|
+
lines.push("");
|
|
1198
|
+
}
|
|
1199
|
+
if (typeof envelope.data === "string") {
|
|
1200
|
+
lines.push(envelope.data);
|
|
1201
|
+
} else {
|
|
1202
|
+
lines.push(JSON.stringify(envelope.data, null, 2));
|
|
1203
|
+
}
|
|
1204
|
+
return lines.join("\n");
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function renderExecuteErrorText(
|
|
1208
|
+
envelope: ExecuteErrorEnvelope,
|
|
1209
|
+
options: { verbose?: boolean },
|
|
1210
|
+
): string {
|
|
1211
|
+
const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
|
|
1212
|
+
if (envelope.error.remediation) {
|
|
1213
|
+
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
1214
|
+
}
|
|
1215
|
+
if (options.verbose && envelope.error.context) {
|
|
1216
|
+
lines.push("", JSON.stringify(envelope.error.context, null, 2));
|
|
1217
|
+
}
|
|
1218
|
+
return lines.join("\n");
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function hasUnbalancedQuotes(input: string): boolean {
|
|
1222
|
+
let quote: '"' | "'" | undefined;
|
|
1223
|
+
let escaped = false;
|
|
1224
|
+
for (const char of input) {
|
|
1225
|
+
if (escaped) {
|
|
1226
|
+
escaped = false;
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
if (char === "\\") {
|
|
1230
|
+
escaped = true;
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
if (quote) {
|
|
1234
|
+
if (char === quote) {
|
|
1235
|
+
quote = undefined;
|
|
1236
|
+
}
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
if (char === '"' || char === "'") {
|
|
1240
|
+
quote = char;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return quote !== undefined;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function tokenizeRouterOsCli(input: string): string[] {
|
|
1247
|
+
const tokens: string[] = [];
|
|
1248
|
+
let current = "";
|
|
1249
|
+
let quote: '"' | "'" | undefined;
|
|
1250
|
+
let escaped = false;
|
|
1251
|
+
for (const char of input) {
|
|
1252
|
+
if (escaped) {
|
|
1253
|
+
current += char;
|
|
1254
|
+
escaped = false;
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
if (char === "\\") {
|
|
1258
|
+
escaped = true;
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
if (quote) {
|
|
1262
|
+
if (char === quote) {
|
|
1263
|
+
quote = undefined;
|
|
1264
|
+
} else {
|
|
1265
|
+
current += char;
|
|
1266
|
+
}
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
if (char === '"' || char === "'") {
|
|
1270
|
+
quote = char;
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
if (/\s/.test(char)) {
|
|
1274
|
+
if (current.length > 0) {
|
|
1275
|
+
tokens.push(current);
|
|
1276
|
+
current = "";
|
|
1277
|
+
}
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
current += char;
|
|
1281
|
+
}
|
|
1282
|
+
if (escaped) {
|
|
1283
|
+
current += "\\";
|
|
1284
|
+
}
|
|
1285
|
+
if (current.length > 0) {
|
|
1286
|
+
tokens.push(current);
|
|
1287
|
+
}
|
|
1288
|
+
return tokens;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function routerOsStringLiteral(value: string): string {
|
|
1292
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function syntaxCause(error: unknown): unknown {
|
|
1296
|
+
if (error instanceof CentrsError) {
|
|
1297
|
+
return error.causeData ?? error.context ?? error.toJSON();
|
|
1298
|
+
}
|
|
1299
|
+
return error;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function pathTokens(path: string): string[] {
|
|
1303
|
+
return path.split("/").filter(Boolean);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function inspectPath(tokens: readonly string[]): string {
|
|
1307
|
+
return tokens.join(",");
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function isArgumentNode(child: InspectChildItem): boolean {
|
|
1311
|
+
return child.type === "arg" || child["node-type"] === "arg";
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function extractCompletionNames(
|
|
1315
|
+
rows: readonly InspectCompletionItem[],
|
|
1316
|
+
): string[] {
|
|
1317
|
+
return rows
|
|
1318
|
+
.flatMap((row) => [row.completion, row.name, row.value, row.text])
|
|
1319
|
+
.filter((value): value is string => typeof value === "string")
|
|
1320
|
+
.map((value) => value.replace(/=.*$/, "").trim())
|
|
1321
|
+
.filter((value) => value.length > 0);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function parseOutputFormat(value: string): ExecuteOutputFormat {
|
|
1325
|
+
if (executeOutputFormats.includes(value as ExecuteOutputFormat)) {
|
|
1326
|
+
return value as ExecuteOutputFormat;
|
|
1327
|
+
}
|
|
1328
|
+
throw new CentrsError({
|
|
1329
|
+
code: "settings/invalid-format",
|
|
1330
|
+
summary: `Unsupported output format: ${value}`,
|
|
1331
|
+
remediation: `Choose one of ${executeOutputFormats.join(", ")}.`,
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function countResultObjects(data: unknown): number {
|
|
1336
|
+
if (Array.isArray(data)) {
|
|
1337
|
+
return data.length;
|
|
1338
|
+
}
|
|
1339
|
+
if (data === null || data === undefined) {
|
|
1340
|
+
return 0;
|
|
1341
|
+
}
|
|
1342
|
+
if (typeof data === "object" && Object.keys(data).length === 0) {
|
|
1343
|
+
return 0;
|
|
1344
|
+
}
|
|
1345
|
+
return 1;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function exhaustiveOutputFormat(value: never): never {
|
|
1349
|
+
throw new Error(`Unhandled execute output format: ${String(value)}`);
|
|
1350
|
+
}
|