@tikoci/centrs 0.1.0 → 0.1.2
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 +73 -2
- package/GLOSSARY.txt +16 -0
- package/README.md +1 -1
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +159 -0
- package/commands/api/examples.md +325 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/devices/README.md +26 -0
- package/commands/discover/README.md +17 -8
- package/commands/discover/examples.md +10 -5
- package/commands/execute/README.md +9 -3
- package/commands/retrieve/README.md +1 -1
- package/docs/CONSTITUTION.md +6 -6
- package/docs/MATRIX.md +46 -15
- package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +5 -5
- package/src/api.ts +1555 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +536 -0
- package/src/cli/btest.ts +45 -1
- package/src/cli/common.ts +64 -0
- package/src/cli/discover.ts +26 -0
- package/src/cli/execute.ts +30 -4
- package/src/cli/missing-target.ts +171 -0
- package/src/cli/retrieve.ts +32 -14
- package/src/cli/terminal.ts +18 -4
- package/src/cli/transfer.ts +37 -9
- package/src/cli.ts +22 -0
- package/src/core/error-catalog.ts +14 -0
- package/src/core/fanout.ts +160 -0
- package/src/core/inspect.ts +170 -0
- package/src/discover.ts +96 -23
- package/src/execute.ts +14 -47
- package/src/index.ts +61 -8
- package/src/protocols/adapter.ts +328 -0
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/native-api.ts +223 -30
- package/src/retrieve-fanout.ts +13 -141
- package/src/retrieve.ts +30 -87
- package/commands/stream/README.md +0 -70
package/src/cli/common.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* together.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { CentrsError } from "../errors.ts";
|
|
10
|
+
|
|
9
11
|
export interface CliCommandOption {
|
|
10
12
|
flag: string;
|
|
11
13
|
valueName?: string;
|
|
@@ -31,6 +33,68 @@ export function expectValue(
|
|
|
31
33
|
return value;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/** Flatten an option's `flag` field ("-X / --method") into individual `-`/`--` tokens. */
|
|
37
|
+
export function knownFlags(options: readonly CliCommandOption[]): string[] {
|
|
38
|
+
const flags: string[] = [];
|
|
39
|
+
for (const option of options) {
|
|
40
|
+
for (const token of option.flag.split("/")) {
|
|
41
|
+
const trimmed = token.trim();
|
|
42
|
+
if (trimmed.startsWith("-")) {
|
|
43
|
+
flags.push(trimmed);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return flags;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function editDistance(a: string, b: string): number {
|
|
51
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
52
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
53
|
+
const curr = [i];
|
|
54
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
55
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
56
|
+
curr[j] = Math.min(
|
|
57
|
+
(curr[j - 1] ?? 0) + 1,
|
|
58
|
+
(prev[j] ?? 0) + 1,
|
|
59
|
+
(prev[j - 1] ?? 0) + cost,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
prev = curr;
|
|
63
|
+
}
|
|
64
|
+
return prev[b.length] ?? 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Closest known flags to `arg` (Levenshtein ≤ 2 — tight enough to avoid noise), nearest first, capped at 3. */
|
|
68
|
+
export function closestFlags(arg: string, flags: readonly string[]): string[] {
|
|
69
|
+
return [...new Set(flags)]
|
|
70
|
+
.map((flag) => ({ flag, distance: editDistance(arg, flag) }))
|
|
71
|
+
.filter(({ distance }) => distance <= 2)
|
|
72
|
+
.sort((left, right) => left.distance - right.distance)
|
|
73
|
+
.slice(0, 3)
|
|
74
|
+
.map(({ flag }) => flag);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Actionable "unknown flag" error: lists the closest canonical matches (and
|
|
79
|
+
* aliases) so humans and agents can recover (commands/AGENTS.md → "Did you
|
|
80
|
+
* mean?"). Shared seam — parsers pass their own `command` + option metadata.
|
|
81
|
+
*/
|
|
82
|
+
export function unknownFlagError(
|
|
83
|
+
command: string,
|
|
84
|
+
arg: string,
|
|
85
|
+
options: readonly CliCommandOption[],
|
|
86
|
+
): CentrsError {
|
|
87
|
+
const suggestions = closestFlags(arg, knownFlags(options));
|
|
88
|
+
const hint =
|
|
89
|
+
suggestions.length > 0 ? ` Did you mean ${suggestions.join(", ")}?` : "";
|
|
90
|
+
return new CentrsError({
|
|
91
|
+
code: "input/invalid-command",
|
|
92
|
+
summary: `Unknown ${command} flag: ${arg}.${hint}`,
|
|
93
|
+
remediation: `Run \`centrs ${command} --help\` to see the supported flags${suggestions.length > 0 ? " and their aliases" : ""}.`,
|
|
94
|
+
context: { flag: arg, suggestions },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
34
98
|
export function renderCommandHelp(
|
|
35
99
|
describe: string,
|
|
36
100
|
command: CliCommandMetadata,
|
package/src/cli/discover.ts
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
DISCOVER_DEFAULT_TIMEOUT_MS,
|
|
9
10
|
type DiscoverOptions,
|
|
10
11
|
type DiscoverOutputFormat,
|
|
11
12
|
discover,
|
|
12
13
|
discoverOutputFormats,
|
|
14
|
+
MNDP_PORT,
|
|
13
15
|
renderDiscoverEnvelope,
|
|
14
16
|
} from "../discover.ts";
|
|
15
17
|
import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
|
|
@@ -142,6 +144,29 @@ function parseDiscoverCliArgs(args: readonly string[]): DiscoverCliArgs {
|
|
|
142
144
|
return parsed;
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A 15s passive listen looks like a hang on an interactive terminal, so print a
|
|
149
|
+
* one-line progress note to **stderr** for the human-facing text format on a
|
|
150
|
+
* TTY. Skipped when piped/redirected (not a TTY) and for the `--format
|
|
151
|
+
* json|yaml` structured modes, so machine consumers never see it. stderr is the
|
|
152
|
+
* sanctioned channel for discover scan progress (`docs/CONSTITUTION.md` → Result
|
|
153
|
+
* envelope).
|
|
154
|
+
*/
|
|
155
|
+
function emitDiscoverProgress(
|
|
156
|
+
options: DiscoverOptions,
|
|
157
|
+
format: DiscoverOutputFormat | undefined,
|
|
158
|
+
): void {
|
|
159
|
+
if (format === "json" || format === "yaml" || !process.stderr.isTTY) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const windowMs = options.timeoutMs ?? DISCOVER_DEFAULT_TIMEOUT_MS;
|
|
163
|
+
const port = options.port ?? MNDP_PORT;
|
|
164
|
+
const hint = options.save ? "" : " (add --save to populate the CDB)";
|
|
165
|
+
process.stderr.write(
|
|
166
|
+
`Listening for MNDP neighbors for ${Math.round(windowMs / 1000)}s on UDP ${port}…${hint}\n`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
145
170
|
export async function runDiscoverCli(args: readonly string[]): Promise<number> {
|
|
146
171
|
let parsed: DiscoverCliArgs | undefined;
|
|
147
172
|
try {
|
|
@@ -160,6 +185,7 @@ export async function runDiscoverCli(args: readonly string[]): Promise<number> {
|
|
|
160
185
|
cdbPassword: parsed.cdbPassword,
|
|
161
186
|
env: Bun.env,
|
|
162
187
|
};
|
|
188
|
+
emitDiscoverProgress(options, parsed.format);
|
|
163
189
|
const envelope = await discover(options);
|
|
164
190
|
const format = parsed.format ?? "text";
|
|
165
191
|
const rendered = renderDiscoverEnvelope(envelope, format);
|
package/src/cli/execute.ts
CHANGED
|
@@ -13,6 +13,14 @@ import {
|
|
|
13
13
|
expectValue,
|
|
14
14
|
renderCommandHelp,
|
|
15
15
|
} from "./common.ts";
|
|
16
|
+
import {
|
|
17
|
+
buildTargetSelectionTips,
|
|
18
|
+
cdbFileFromArgs,
|
|
19
|
+
formatTipsText,
|
|
20
|
+
isMissingTargetError,
|
|
21
|
+
missingTargetError,
|
|
22
|
+
withTips,
|
|
23
|
+
} from "./missing-target.ts";
|
|
16
24
|
|
|
17
25
|
export const executeCommand: CliCommandMetadata = {
|
|
18
26
|
name: "execute",
|
|
@@ -241,6 +249,18 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
241
249
|
console.log(renderCommandHelp(describeCentrs(), executeCommand));
|
|
242
250
|
return 0;
|
|
243
251
|
}
|
|
252
|
+
// No positional at all: lead with the missing-target guidance (CDB picker /
|
|
253
|
+
// discover) rather than the missing-command error — there is nothing to run
|
|
254
|
+
// without a device. A lone positional is treated as the target, so the
|
|
255
|
+
// "requires a command" path still fires for `execute <target>`.
|
|
256
|
+
if (!parsed.targetInput) {
|
|
257
|
+
throw missingTargetError({
|
|
258
|
+
command: "execute",
|
|
259
|
+
summary: "`centrs execute` requires a <target> and a RouterOS command.",
|
|
260
|
+
remediation:
|
|
261
|
+
'Pass the router host/identity then the command, e.g. `centrs execute 192.0.2.10 "/system/resource/print"`.',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
244
264
|
|
|
245
265
|
const envelope = await executeEnvelope(parsed);
|
|
246
266
|
const resolvedFormat =
|
|
@@ -264,10 +284,16 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
264
284
|
// Parse/usage errors land here too: surface them through the same typed
|
|
265
285
|
// envelope as every other runner (no raw message, no exit code 2).
|
|
266
286
|
const format = inferExecuteFormat(args, parsed);
|
|
287
|
+
const tips = isMissingTargetError(error)
|
|
288
|
+
? await buildTargetSelectionTips({
|
|
289
|
+
cdbFile: parsed?.cdbFile ?? cdbFileFromArgs(args),
|
|
290
|
+
env: Bun.env,
|
|
291
|
+
})
|
|
292
|
+
: [];
|
|
267
293
|
if (format === "json" || format === "yaml") {
|
|
268
|
-
const envelope =
|
|
269
|
-
parsed ?? { command: "" },
|
|
270
|
-
|
|
294
|
+
const envelope = withTips(
|
|
295
|
+
buildExecuteErrorEnvelope(parsed ?? { command: "" }, error),
|
|
296
|
+
tips,
|
|
271
297
|
);
|
|
272
298
|
console.error(
|
|
273
299
|
renderExecuteEnvelope(envelope, format, {
|
|
@@ -284,7 +310,7 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
284
310
|
"Use `centrs execute --help` to inspect the supported command shape and flags.",
|
|
285
311
|
}),
|
|
286
312
|
{ verbose: parsed?.verbose ?? args.includes("--verbose") },
|
|
287
|
-
),
|
|
313
|
+
) + formatTipsText(tips),
|
|
288
314
|
);
|
|
289
315
|
}
|
|
290
316
|
return 1;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "no `<router>` target" guidance for the CLI surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Every command that takes a `<router>` (terminal, execute, retrieve, transfer,
|
|
5
|
+
* btest client) raises the same `input/invalid-command` error when the target is
|
|
6
|
+
* missing, tagged with `context.missingTarget`. Each runner then calls
|
|
7
|
+
* {@link buildTargetSelectionTips} to turn that bare usage error into actionable
|
|
8
|
+
* advice: list a few saved CDB devices to pass as `<router>`, or — when the
|
|
9
|
+
* registry is empty — point at `centrs discover --save`. Tips ride the envelope's
|
|
10
|
+
* `tips` channel (`docs/CONSTITUTION.md` → Result envelope); this module also
|
|
11
|
+
* renders them for the **text** error path, which `formatCentrsErrorText` does
|
|
12
|
+
* not surface on its own.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { buildTip, type Tip } from "../core/envelope.ts";
|
|
16
|
+
import { defaultCdbPath, loadCdb } from "../devices.ts";
|
|
17
|
+
import { CentrsError } from "../errors.ts";
|
|
18
|
+
import { DEFAULT_RECORD_TARGET, identityFromComment } from "../resolver/cdb.ts";
|
|
19
|
+
|
|
20
|
+
/** Cap on how many saved devices a single tip names before "(+N more)". */
|
|
21
|
+
const MAX_LISTED_DEVICES = 8;
|
|
22
|
+
|
|
23
|
+
export interface MissingTargetErrorInit {
|
|
24
|
+
command: string;
|
|
25
|
+
summary: string;
|
|
26
|
+
remediation: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The canonical "missing `<router>`" usage error. The `context.missingTarget`
|
|
31
|
+
* flag is the marker {@link isMissingTargetError} keys on so a runner knows to
|
|
32
|
+
* attach {@link buildTargetSelectionTips} output.
|
|
33
|
+
*/
|
|
34
|
+
export function missingTargetError(init: MissingTargetErrorInit): CentrsError {
|
|
35
|
+
return new CentrsError({
|
|
36
|
+
code: "input/invalid-command",
|
|
37
|
+
summary: init.summary,
|
|
38
|
+
remediation: init.remediation,
|
|
39
|
+
context: { missingTarget: true, command: init.command },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True for an error raised by {@link missingTargetError} (or one tagged like it). */
|
|
44
|
+
export function isMissingTargetError(error: unknown): boolean {
|
|
45
|
+
return (
|
|
46
|
+
error instanceof CentrsError && error.context?.["missingTarget"] === true
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CdbInputs {
|
|
51
|
+
cdbFile?: string;
|
|
52
|
+
env?: Record<string, string | undefined>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pull the `--cdb-file` path out of raw args. Used by catch paths where the
|
|
57
|
+
* request never finished parsing (the missing-target throw happens mid-parse),
|
|
58
|
+
* so the request object is unavailable but the flag may still be on the command
|
|
59
|
+
* line. The CDB *password* is deliberately not extracted: a credential must
|
|
60
|
+
* never flow into the (logged) tip path, and the tips read only an unencrypted
|
|
61
|
+
* registry — an encrypted CDB falls back to generic guidance.
|
|
62
|
+
*/
|
|
63
|
+
export function cdbFileFromArgs(args: readonly string[]): string | undefined {
|
|
64
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
65
|
+
if (args[index] === "--cdb-file") {
|
|
66
|
+
if (index + 1 >= args.length) return undefined;
|
|
67
|
+
const value = args[index + 1] as string; // bounds-checked above
|
|
68
|
+
if (value.startsWith("--")) return undefined;
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Best-effort: inspect the CDB and turn "no `<router>`" into actionable tips —
|
|
77
|
+
* a list of saved targets when the registry has any, otherwise a pointer to
|
|
78
|
+
* `discover --save`. Never throws: an unreadable/encrypted CDB falls back to
|
|
79
|
+
* generic guidance, and a missing default CDB is reported as an empty registry
|
|
80
|
+
* (no file is created as a side effect of a usage error). The CDB password is
|
|
81
|
+
* intentionally never read here, so no credential can reach the rendered tip.
|
|
82
|
+
*/
|
|
83
|
+
export async function buildTargetSelectionTips(
|
|
84
|
+
inputs: CdbInputs,
|
|
85
|
+
): Promise<Tip[]> {
|
|
86
|
+
const env = inputs.env ?? {};
|
|
87
|
+
const explicitCdb =
|
|
88
|
+
inputs.cdbFile !== undefined || env["CENTRS_CDB_FILE"] !== undefined;
|
|
89
|
+
if (!explicitCdb && !(await Bun.file(defaultCdbPath(env)).exists())) {
|
|
90
|
+
return [emptyRegistryTip()];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let entries: readonly { target: string; comment: string }[];
|
|
94
|
+
try {
|
|
95
|
+
const cdb = await loadCdb({
|
|
96
|
+
cdbFile: inputs.cdbFile,
|
|
97
|
+
env,
|
|
98
|
+
});
|
|
99
|
+
entries = cdb.entries.filter(
|
|
100
|
+
(entry) =>
|
|
101
|
+
entry.target.length > 0 && entry.target !== DEFAULT_RECORD_TARGET,
|
|
102
|
+
);
|
|
103
|
+
} catch {
|
|
104
|
+
return [unreadableRegistryTip()];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (entries.length === 0) {
|
|
108
|
+
return [emptyRegistryTip()];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const handles = entries
|
|
112
|
+
.slice(0, MAX_LISTED_DEVICES)
|
|
113
|
+
.map((entry) => identityFromComment(entry.comment, entry.target));
|
|
114
|
+
const extra =
|
|
115
|
+
entries.length > MAX_LISTED_DEVICES
|
|
116
|
+
? ` (+${entries.length - MAX_LISTED_DEVICES} more)`
|
|
117
|
+
: "";
|
|
118
|
+
return [
|
|
119
|
+
buildTip(
|
|
120
|
+
"tip/select-target",
|
|
121
|
+
`Pass one of your ${entries.length} saved device(s) as <router>: ${handles.join(", ")}${extra}.`,
|
|
122
|
+
"Run `centrs devices list` for every saved target; <router> also resolves by identity=, mac=, or ip=.",
|
|
123
|
+
),
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function emptyRegistryTip(): Tip {
|
|
128
|
+
return buildTip(
|
|
129
|
+
"tip/no-devices",
|
|
130
|
+
"No devices are saved in the CDB yet.",
|
|
131
|
+
"Run `centrs discover --save` to find RouterOS neighbors on the LAN and add them, then pass one as <router> (or `centrs devices add <target> --user … --password …`).",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unreadableRegistryTip(): Tip {
|
|
136
|
+
return buildTip(
|
|
137
|
+
"tip/select-target",
|
|
138
|
+
"Could not read the CDB to list saved devices (it may be encrypted — pass --cdb-password).",
|
|
139
|
+
"Run `centrs devices list` to see saved targets, or `centrs discover --save` to find and add neighbors on the LAN.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Render tips for the **text** error path (mirrors the `devices` "Tips:"
|
|
145
|
+
* footer). Returns "" for an empty list so callers can append unconditionally.
|
|
146
|
+
*/
|
|
147
|
+
export function formatTipsText(tips: readonly Tip[]): string {
|
|
148
|
+
if (tips.length === 0) {
|
|
149
|
+
return "";
|
|
150
|
+
}
|
|
151
|
+
const lines = ["", "Tips:"];
|
|
152
|
+
for (const tip of tips) {
|
|
153
|
+
lines.push(` - [${tip.code}] ${tip.message}`);
|
|
154
|
+
if (tip.fix) {
|
|
155
|
+
lines.push(` fix: ${tip.fix}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return lines.join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Return a copy of an envelope with `tips` attached (no-op for an empty list).
|
|
163
|
+
* The `as E` keeps the call site's envelope type when an error envelope declares
|
|
164
|
+
* `tips: []` (an empty tuple) — we only ever widen it to a populated list.
|
|
165
|
+
*/
|
|
166
|
+
export function withTips<E extends { tips: readonly Tip[] }>(
|
|
167
|
+
envelope: E,
|
|
168
|
+
tips: readonly Tip[],
|
|
169
|
+
): E {
|
|
170
|
+
return tips.length > 0 ? ({ ...envelope, tips } as E) : envelope;
|
|
171
|
+
}
|
package/src/cli/retrieve.ts
CHANGED
|
@@ -22,6 +22,14 @@ import {
|
|
|
22
22
|
expectValue,
|
|
23
23
|
renderCommandHelp,
|
|
24
24
|
} from "./common.ts";
|
|
25
|
+
import {
|
|
26
|
+
buildTargetSelectionTips,
|
|
27
|
+
cdbFileFromArgs,
|
|
28
|
+
formatTipsText,
|
|
29
|
+
isMissingTargetError,
|
|
30
|
+
missingTargetError,
|
|
31
|
+
withTips,
|
|
32
|
+
} from "./missing-target.ts";
|
|
25
33
|
|
|
26
34
|
export const retrieveCommand: CliCommandMetadata = {
|
|
27
35
|
name: "retrieve",
|
|
@@ -171,6 +179,12 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
171
179
|
return 0;
|
|
172
180
|
} catch (error) {
|
|
173
181
|
const format = inferRequestedFormat(args, request);
|
|
182
|
+
const tips = isMissingTargetError(error)
|
|
183
|
+
? await buildTargetSelectionTips({
|
|
184
|
+
cdbFile: request?.cdbFile ?? cdbFileFromArgs(args),
|
|
185
|
+
env: Bun.env,
|
|
186
|
+
})
|
|
187
|
+
: [];
|
|
174
188
|
if (format === "json" || format === "yaml") {
|
|
175
189
|
if (request?.group) {
|
|
176
190
|
const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
|
|
@@ -180,9 +194,14 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
180
194
|
}),
|
|
181
195
|
);
|
|
182
196
|
} else {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
197
|
+
// When parsing failed before a request existed, build the error
|
|
198
|
+
// envelope from an empty request rather than reconstructing positionals
|
|
199
|
+
// from raw args — a credential value (e.g. the token after
|
|
200
|
+
// `--password` / `--cdb-password`) would otherwise be echoed as
|
|
201
|
+
// `meta.target.input`.
|
|
202
|
+
const envelope = withTips(
|
|
203
|
+
buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
|
|
204
|
+
tips,
|
|
186
205
|
);
|
|
187
206
|
console.error(
|
|
188
207
|
renderRetrieveEnvelope(envelope, format, {
|
|
@@ -202,7 +221,7 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
202
221
|
{
|
|
203
222
|
verbose: request?.verbose ?? args.includes("--verbose"),
|
|
204
223
|
},
|
|
205
|
-
),
|
|
224
|
+
) + formatTipsText(tips),
|
|
206
225
|
);
|
|
207
226
|
}
|
|
208
227
|
return 1;
|
|
@@ -341,9 +360,17 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
|
|
|
341
360
|
return request;
|
|
342
361
|
}
|
|
343
362
|
|
|
363
|
+
if (positional.length === 0) {
|
|
364
|
+
throw missingTargetError({
|
|
365
|
+
command: "retrieve",
|
|
366
|
+
summary: "`centrs retrieve` requires a <target> and a <routeros-path>.",
|
|
367
|
+
remediation:
|
|
368
|
+
"Pass the router host/identity then the RouterOS path, e.g. `centrs retrieve 192.0.2.10 /system/resource`.",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
344
371
|
if (positional.length < 2) {
|
|
345
372
|
throw new Error(
|
|
346
|
-
"`centrs retrieve` requires
|
|
373
|
+
"`centrs retrieve` requires a <routeros-path> after the <target>.",
|
|
347
374
|
);
|
|
348
375
|
}
|
|
349
376
|
|
|
@@ -372,12 +399,3 @@ function inferRequestedFormat(
|
|
|
372
399
|
}
|
|
373
400
|
return "text";
|
|
374
401
|
}
|
|
375
|
-
|
|
376
|
-
function fallbackRequestFromArgs(args: readonly string[]): RetrieveRequest {
|
|
377
|
-
const positionals = args.filter((arg) => !arg.startsWith("-"));
|
|
378
|
-
return {
|
|
379
|
-
targetInput: positionals[0],
|
|
380
|
-
path: positionals[1] ?? "",
|
|
381
|
-
format: inferRequestedFormat(args),
|
|
382
|
-
};
|
|
383
|
-
}
|
package/src/cli/terminal.ts
CHANGED
|
@@ -20,6 +20,14 @@ import {
|
|
|
20
20
|
expectValue,
|
|
21
21
|
renderCommandHelp,
|
|
22
22
|
} from "./common.ts";
|
|
23
|
+
import {
|
|
24
|
+
buildTargetSelectionTips,
|
|
25
|
+
cdbFileFromArgs,
|
|
26
|
+
formatTipsText,
|
|
27
|
+
isMissingTargetError,
|
|
28
|
+
missingTargetError,
|
|
29
|
+
withTips,
|
|
30
|
+
} from "./missing-target.ts";
|
|
23
31
|
|
|
24
32
|
export const terminalCommand: CliCommandMetadata = {
|
|
25
33
|
name: "terminal",
|
|
@@ -164,8 +172,14 @@ export async function runTerminalCli(args: readonly string[]): Promise<number> {
|
|
|
164
172
|
// error nor these renderers, so the alert is dismissed as a false positive
|
|
165
173
|
// repo-wide — see the matching dismissals on src/cli/{transfer,execute}.ts.
|
|
166
174
|
const format = inferRequestedFormat(args);
|
|
175
|
+
const tips = isMissingTargetError(error)
|
|
176
|
+
? await buildTargetSelectionTips({
|
|
177
|
+
cdbFile: request?.cdbFile ?? cdbFileFromArgs(args),
|
|
178
|
+
env: Bun.env,
|
|
179
|
+
})
|
|
180
|
+
: [];
|
|
167
181
|
if (format === "json" || format === "yaml") {
|
|
168
|
-
const envelope = buildTerminalErrorEnvelope(error);
|
|
182
|
+
const envelope = withTips(buildTerminalErrorEnvelope(error), tips);
|
|
169
183
|
console.error(
|
|
170
184
|
format === "yaml" ? toYaml(envelope) : JSON.stringify(envelope),
|
|
171
185
|
);
|
|
@@ -179,7 +193,7 @@ export async function runTerminalCli(args: readonly string[]): Promise<number> {
|
|
|
179
193
|
"Use `centrs terminal --help` for the supported command shape and flags.",
|
|
180
194
|
}),
|
|
181
195
|
{ verbose: args.includes("--verbose") },
|
|
182
|
-
),
|
|
196
|
+
) + formatTipsText(tips),
|
|
183
197
|
);
|
|
184
198
|
}
|
|
185
199
|
return 1;
|
|
@@ -272,8 +286,8 @@ function parseTerminalCliArgs(args: readonly string[]): ParsedTerminal {
|
|
|
272
286
|
}
|
|
273
287
|
const targetInput = positional[0];
|
|
274
288
|
if (!targetInput) {
|
|
275
|
-
throw
|
|
276
|
-
|
|
289
|
+
throw missingTargetError({
|
|
290
|
+
command: "terminal",
|
|
277
291
|
summary: "`centrs terminal` requires a <router> target.",
|
|
278
292
|
remediation:
|
|
279
293
|
"Pass the device MAC (or a CDB identity/ip) as the first argument; run `centrs terminal --help` for the shape.",
|
package/src/cli/transfer.ts
CHANGED
|
@@ -24,6 +24,14 @@ import {
|
|
|
24
24
|
expectValue,
|
|
25
25
|
renderCommandHelp,
|
|
26
26
|
} from "./common.ts";
|
|
27
|
+
import {
|
|
28
|
+
buildTargetSelectionTips,
|
|
29
|
+
cdbFileFromArgs,
|
|
30
|
+
formatTipsText,
|
|
31
|
+
isMissingTargetError,
|
|
32
|
+
missingTargetError,
|
|
33
|
+
withTips,
|
|
34
|
+
} from "./missing-target.ts";
|
|
27
35
|
|
|
28
36
|
/** Sub-verb aliases, resolved to canonical verbs silently (commands/AGENTS.md). */
|
|
29
37
|
const VERB_ALIASES: Record<string, TransferVerb> = {
|
|
@@ -173,10 +181,19 @@ export async function runTransferCli(
|
|
|
173
181
|
// (CodeQL js/clear-text-logging).
|
|
174
182
|
const safeRequest = redactTransferRequest(request);
|
|
175
183
|
const format = inferRequestedFormat(args, safeRequest);
|
|
184
|
+
const tips = isMissingTargetError(error)
|
|
185
|
+
? await buildTargetSelectionTips({
|
|
186
|
+
cdbFile: safeRequest?.cdbFile ?? cdbFileFromArgs(args),
|
|
187
|
+
env: Bun.env,
|
|
188
|
+
})
|
|
189
|
+
: [];
|
|
176
190
|
if (format === "json" || format === "yaml") {
|
|
177
|
-
const envelope =
|
|
178
|
-
|
|
179
|
-
|
|
191
|
+
const envelope = withTips(
|
|
192
|
+
buildTransferErrorEnvelope(
|
|
193
|
+
safeRequest ?? { verb: options.fixedVerb ?? "list" },
|
|
194
|
+
error,
|
|
195
|
+
),
|
|
196
|
+
tips,
|
|
180
197
|
);
|
|
181
198
|
console.error(
|
|
182
199
|
renderTransferEnvelope(envelope, format, {
|
|
@@ -193,7 +210,7 @@ export async function runTransferCli(
|
|
|
193
210
|
"Use `centrs transfer --help` to inspect the supported command shape and flags.",
|
|
194
211
|
}),
|
|
195
212
|
{ verbose: safeRequest?.verbose ?? args.includes("--verbose") },
|
|
196
|
-
),
|
|
213
|
+
) + formatTipsText(tips),
|
|
197
214
|
);
|
|
198
215
|
}
|
|
199
216
|
return 1;
|
|
@@ -367,13 +384,24 @@ function assemblePositionals(
|
|
|
367
384
|
} else {
|
|
368
385
|
targetInput = positional[0];
|
|
369
386
|
const rawVerb = positional[1];
|
|
370
|
-
|
|
387
|
+
// A missing target gets the tagged missing-target error (CDB picker tip);
|
|
388
|
+
// a present target with no verb is a distinct "which verb?" usage error.
|
|
389
|
+
if (!targetInput) {
|
|
390
|
+
throw missingTargetError({
|
|
391
|
+
command: "transfer",
|
|
392
|
+
summary:
|
|
393
|
+
"`centrs transfer` requires a <router> target and a verb (upload|download|list|remove|mkdir|copy).",
|
|
394
|
+
remediation:
|
|
395
|
+
"Pass the target then a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
if (!rawVerb) {
|
|
371
399
|
throw new CentrsError({
|
|
372
400
|
code: "input/invalid-command",
|
|
373
401
|
summary:
|
|
374
|
-
"`centrs transfer` requires <router>
|
|
402
|
+
"`centrs transfer` requires a verb after the <router> (upload|download|list|remove|mkdir|copy).",
|
|
375
403
|
remediation:
|
|
376
|
-
"Pass
|
|
404
|
+
"Pass a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
|
|
377
405
|
context: { targetInput, verb: rawVerb },
|
|
378
406
|
});
|
|
379
407
|
}
|
|
@@ -382,8 +410,8 @@ function assemblePositionals(
|
|
|
382
410
|
}
|
|
383
411
|
|
|
384
412
|
if (!targetInput) {
|
|
385
|
-
throw
|
|
386
|
-
|
|
413
|
+
throw missingTargetError({
|
|
414
|
+
command: "transfer",
|
|
387
415
|
summary: "`centrs transfer` requires a <router> target.",
|
|
388
416
|
remediation:
|
|
389
417
|
"Pass the router host/identity as the first argument; run `centrs transfer --help` for the command shape.",
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
import { apiCommand, runApiCli } from "./cli/api.ts";
|
|
3
4
|
import { btestCommand, runBtestCli } from "./cli/btest.ts";
|
|
4
5
|
import { devicesCommand, runDevicesCli } from "./cli/devices.ts";
|
|
5
6
|
import { discoverCommand, runDiscoverCli } from "./cli/discover.ts";
|
|
@@ -14,6 +15,7 @@ import { describeCentrs, plannedProtocols, plannedSurfaces } from "./index.ts";
|
|
|
14
15
|
const commandSummaries: ReadonlyArray<{ name: string; summary: string }> = [
|
|
15
16
|
retrieveCommand,
|
|
16
17
|
executeCommand,
|
|
18
|
+
apiCommand,
|
|
17
19
|
transferCommand,
|
|
18
20
|
terminalCommand,
|
|
19
21
|
devicesCommand,
|
|
@@ -54,6 +56,9 @@ export async function runCli(
|
|
|
54
56
|
if (command === "execute") {
|
|
55
57
|
return await runExecuteCli(rest);
|
|
56
58
|
}
|
|
59
|
+
if (command === "api") {
|
|
60
|
+
return await runApiCli(rest);
|
|
61
|
+
}
|
|
57
62
|
if (command === "transfer") {
|
|
58
63
|
return await runTransferCli(rest);
|
|
59
64
|
}
|
|
@@ -80,6 +85,23 @@ export async function runCli(
|
|
|
80
85
|
if (command === "mcp") {
|
|
81
86
|
return await runMcpCli(rest);
|
|
82
87
|
}
|
|
88
|
+
// `stream` (and the `tail` shorthand) folded into `api --stream`.
|
|
89
|
+
if (command === "stream" || command === "tail") {
|
|
90
|
+
console.error(
|
|
91
|
+
formatCentrsErrorText(
|
|
92
|
+
asCentrsError(
|
|
93
|
+
new Error(`\`centrs ${command}\` has been folded into \`api\`.`),
|
|
94
|
+
{
|
|
95
|
+
code: "input/invalid-command",
|
|
96
|
+
summary: `\`centrs ${command}\` is no longer a command — open-ended follow is now \`api … --stream\`.`,
|
|
97
|
+
remediation:
|
|
98
|
+
"Use `centrs api <router> <endpoint> --stream` (native-api only), e.g. `centrs api edge1 ip/address --stream`.",
|
|
99
|
+
},
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
83
105
|
|
|
84
106
|
console.error(
|
|
85
107
|
formatCentrsErrorText(
|
|
@@ -218,6 +218,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
218
218
|
summary:
|
|
219
219
|
"The RouterOS native API returned a !trap with no more specific mapping.",
|
|
220
220
|
},
|
|
221
|
+
{
|
|
222
|
+
code: "routeros/btest-connection-count-single-stream",
|
|
223
|
+
summary:
|
|
224
|
+
"Fewer TCP data connections were opened than --connection-count requested (e.g. authenticated multi-connection is not yet supported), so throughput will not scale to the full count.",
|
|
225
|
+
},
|
|
221
226
|
{
|
|
222
227
|
code: "routeros/btest-protocol",
|
|
223
228
|
summary: "A bandwidth-test (btest) packet could not be decoded.",
|
|
@@ -227,6 +232,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
227
232
|
summary:
|
|
228
233
|
"The btest server refused a client because max-sessions is reached.",
|
|
229
234
|
},
|
|
235
|
+
{
|
|
236
|
+
code: "routeros/btest-udp-tx-size-ignored",
|
|
237
|
+
summary:
|
|
238
|
+
"For UDP --direction both, the btest wire protocol carries a single tx-size; --remote-udp-tx-size is ignored and --local-udp-tx-size is used for both directions.",
|
|
239
|
+
},
|
|
230
240
|
{
|
|
231
241
|
code: "routeros/command-failed",
|
|
232
242
|
summary: "RouterOS reported a command failure.",
|
|
@@ -415,6 +425,10 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
415
425
|
code: "usage/invalid-concurrency",
|
|
416
426
|
summary: "The --concurrency value must be an integer >= 1.",
|
|
417
427
|
},
|
|
428
|
+
{
|
|
429
|
+
code: "usage/invalid-method",
|
|
430
|
+
summary: "The -X/--method value is not a supported HTTP method.",
|
|
431
|
+
},
|
|
418
432
|
{
|
|
419
433
|
code: "usage/missing-group",
|
|
420
434
|
summary: "The operation requires a non-empty --group value.",
|