@usex/mikrotik-mcp 3.32.2 → 3.34.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/README.md CHANGED
@@ -63,6 +63,9 @@ in memory and auto-reverts if your session drops, so you can't lock yourself out
63
63
  - 🖧 **Multiple devices** — define named routers and the AI targets one per call
64
64
  (a validated `device` argument). Configure **both ends of a tunnel** from one
65
65
  conversation. See **[docs/multi-device.md](docs/multi-device.md)**.
66
+ - 🪜 **SSH jump hosts** — reach a router with no exposed port by tunnelling
67
+ through another via `jumpVia` (ProxyJump/bastion) — commands, Safe Mode and
68
+ file upload all ride the hop. No new WAN port.
66
69
  - 🤖 **Guided prompts** — 9 built-in workflows (harden, diagnose, guest Wi-Fi, VPNs,
67
70
  cross-device tunnels, backup & document) that turn an intent into tool calls.
68
71
 
@@ -179,6 +182,17 @@ drives the whole both-ends flow. Full guide: **[docs/multi-device.md](docs/multi
179
182
  // create_wireguard_interface { "device": "site-a", "name": "wg-to-b", "listen_port": 13231 }
180
183
  ```
181
184
 
185
+ **Behind a bastion?** Reach a router with no exposed SSH port by jumping through
186
+ another (OpenSSH-style ProxyJump) — `jumpVia` names a configured device to tunnel
187
+ through; commands, Safe Mode and SFTP upload all ride the hop:
188
+
189
+ ```jsonc
190
+ "home-ax3": { "host": "10.10.30.100", "username": "admin", "jumpVia": "hex" }
191
+ ```
192
+
193
+ The bastion router needs SSH TCP forwarding enabled (`/ip ssh set
194
+ forwarding-enabled=local`). See **[docs/multi-device.md](docs/multi-device.md#ssh-jump-hosts-bastion--proxyjump)**.
195
+
182
196
  ## Built-in prompts
183
197
 
184
198
  MCP **prompts** are one-click guided workflows. This server ships 9 — authored as
@@ -220,20 +234,21 @@ See **[docs/safe-mode.md](docs/safe-mode.md)**.
220
234
  Connection and transport settings come from `MIKROTIK_*` env vars or matching CLI
221
235
  flags (highest precedence last: defaults → env → flags).
222
236
 
223
- | Variable | Flag | Default | Purpose |
224
- | ----------------------------- | ------------------ | ----------- | ----------------------------------------------------------------- |
225
- | `MIKROTIK_HOST` | `--host` | `127.0.0.1` | RouterOS host |
226
- | `MIKROTIK_USERNAME` | `--username` | `admin` | SSH user |
227
- | `MIKROTIK_PORT` | `--port` | `22` | SSH port |
228
- | `MIKROTIK_PASSWORD` | `--password` | — | SSH password _(or use a key →)_ |
229
- | `MIKROTIK_KEY_FILENAME` | `--key-filename` | — | SSH private-key file path |
230
- | `MIKROTIK_PRIVATE_KEY` | `--private-key` | — | Inline private key (PEM) |
231
- | `MIKROTIK_KEY_PASSPHRASE` | `--key-passphrase` | — | Passphrase for an encrypted key |
232
- | `MIKROTIK_CONFIG_FILE` | `--config` | — | JSON file of named devices ([multi-device](docs/multi-device.md)) |
233
- | `MIKROTIK_DEVICES` | `--devices` | — | Inline JSON of named devices |
234
- | `MIKROTIK_MCP__TRANSPORT` | `--transport` | `stdio` | `stdio` / `streamable-http` / `sse` |
235
- | `MIKROTIK_MCP__PORT` | `--mcp-port` | `8000` | HTTP bind port |
236
- | `MIKROTIK_DASHBOARD__ENABLED` | `--dashboard` | `false` | Real-time observability dashboard ([docs](docs/observability.md)) |
237
+ | Variable | Flag | Default | Purpose |
238
+ | ----------------------------- | ------------------ | ----------- | ---------------------------------------------------------------------------------------------------- |
239
+ | `MIKROTIK_HOST` | `--host` | `127.0.0.1` | RouterOS host |
240
+ | `MIKROTIK_USERNAME` | `--username` | `admin` | SSH user |
241
+ | `MIKROTIK_PORT` | `--port` | `22` | SSH port |
242
+ | `MIKROTIK_PASSWORD` | `--password` | — | SSH password _(or use a key →)_ |
243
+ | `MIKROTIK_KEY_FILENAME` | `--key-filename` | — | SSH private-key file path |
244
+ | `MIKROTIK_PRIVATE_KEY` | `--private-key` | — | Inline private key (PEM) |
245
+ | `MIKROTIK_KEY_PASSPHRASE` | `--key-passphrase` | — | Passphrase for an encrypted key |
246
+ | `MIKROTIK_JUMP_HOST` | `--jump-host` | — | SSH bastion to tunnel through ([jump hosts](docs/multi-device.md#ssh-jump-hosts-bastion--proxyjump)) |
247
+ | `MIKROTIK_CONFIG_FILE` | `--config` | — | JSON file of named devices ([multi-device](docs/multi-device.md)) |
248
+ | `MIKROTIK_DEVICES` | `--devices` | | Inline JSON of named devices |
249
+ | `MIKROTIK_MCP__TRANSPORT` | `--transport` | `stdio` | `stdio` / `streamable-http` / `sse` |
250
+ | `MIKROTIK_MCP__PORT` | `--mcp-port` | `8000` | HTTP bind port |
251
+ | `MIKROTIK_DASHBOARD__ENABLED` | `--dashboard` | `false` | Real-time observability dashboard ([docs](docs/observability.md)) |
237
252
 
238
253
  Full table (incl. HTTP host, allow-lists, timeouts, `MIKROTIK_LOG_LEVEL`):
239
254
  **[docs/configuration.md](docs/configuration.md)**.
package/dist/cli.js CHANGED
@@ -84,7 +84,7 @@ import {
84
84
  toggleAaaEntity,
85
85
  updateAaaEntity,
86
86
  writeBackup
87
- } from "./shared/cli-t84nm0d4.js";
87
+ } from "./shared/cli-gt8xtjx9.js";
88
88
 
89
89
  // src/cli.ts
90
90
  import { existsSync as existsSync2 } from "fs";
@@ -1220,6 +1220,8 @@ function devicesPayload(store) {
1220
1220
  authMode: dc.mac ? "mac-telnet" : dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
1221
1221
  isDefault: name === cfg.defaultDevice,
1222
1222
  description: dc.description,
1223
+ jumpVia: dc.jumpVia,
1224
+ jumpHost: dc.jumpHost ? { host: dc.jumpHost.host, port: dc.jumpHost.port } : undefined,
1223
1225
  status: getDeviceStatus(name),
1224
1226
  history: getDeviceHistory(name),
1225
1227
  activity: activity.get(name) ?? {
@@ -2231,7 +2233,7 @@ function registerPrompts(server) {
2231
2233
  // package.json
2232
2234
  var package_default = {
2233
2235
  name: "@usex/mikrotik-mcp",
2234
- version: "3.32.2",
2236
+ version: "3.34.0",
2235
2237
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
2236
2238
  keywords: [
2237
2239
  "ai",
@@ -2313,7 +2315,7 @@ var package_default = {
2313
2315
  },
2314
2316
  devDependencies: {
2315
2317
  "@resvg/resvg-js": "^2.6.2",
2316
- "@tailwindcss/vite": "^4.3.1",
2318
+ "@tailwindcss/vite": "^4.3.2",
2317
2319
  "@types/bun": "latest",
2318
2320
  "@types/node": "^26.0.1",
2319
2321
  "@types/react": "^19.2.17",
@@ -2324,13 +2326,13 @@ var package_default = {
2324
2326
  "class-variance-authority": "^0.7.1",
2325
2327
  clsx: "^2.1.1",
2326
2328
  gsap: "^3.15.0",
2327
- prettier: "^3.9.1",
2329
+ prettier: "^3.9.3",
2328
2330
  react: "^19.2.7",
2329
2331
  "react-dom": "^19.2.7",
2330
2332
  recharts: "^3.9.0",
2331
2333
  "release-it": "^20.2.1",
2332
2334
  "tailwind-merge": "^3.6.0",
2333
- tailwindcss: "^4.3.1",
2335
+ tailwindcss: "^4.3.2",
2334
2336
  vite: "npm:@voidzero-dev/vite-plus-core@latest",
2335
2337
  "vite-plus": "latest"
2336
2338
  },
package/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { z } from "zod";
2
- /** Connection details for a single MikroTik device. */
3
2
  declare const DeviceConfigSchema: unknown;
4
3
  type DeviceConfig = z.infer<typeof DeviceConfigSchema>;
5
4
  declare const MikrotikConfigSchema: unknown;
@@ -184,15 +183,47 @@ interface SSHClientOptions {
184
183
  /** Passphrase for an encrypted private key. */
185
184
  keyPassphrase?: string;
186
185
  timeoutMs?: number;
186
+ /**
187
+ * Optional SSH jump host (bastion / ProxyJump). When set, `connect()` reaches
188
+ * `host:port` THROUGH this hop: it opens an SSH session to the hop, asks it to
189
+ * `forwardOut` a TCP channel to the target, then runs the real SSH session
190
+ * over that channel. So the target never needs a port exposed to the network —
191
+ * only the bastion is reachable. The hop is itself an SSH endpoint and may
192
+ * carry its own `jump` for a multi-hop chain (A → B → target).
193
+ */
194
+ jump?: SSHClientOptions;
187
195
  }
188
196
  declare class MikroTikSSHClient {
189
197
  private client;
198
+ /**
199
+ * Open bastion connections, outermost-first, kept alive for the lifetime of
200
+ * the session (the target's transport rides a channel on the last one). Closed
201
+ * in reverse on `disconnect()`.
202
+ */
203
+ private bastions;
190
204
  private readonly opts;
191
205
  /** Human-readable reason the last `connect()` failed, if it did. */
192
206
  lastError?: string;
193
207
  constructor(opts: SSHClientOptions);
194
- /** Establish the SSH connection. Resolves `true` on success, `false` on failure. */
208
+ /**
209
+ * Establish the SSH connection — directly, or through one or more jump hosts
210
+ * when `opts.jump` is set. Resolves `true` on success, `false` on failure
211
+ * (with the reason on `lastError`).
212
+ */
195
213
  connect(): Promise<boolean>;
214
+ /**
215
+ * Open one ssh2 connection. When `sock` is given the connection rides that
216
+ * stream (a forwarded channel from a jump host) instead of dialling the
217
+ * network directly. Resolves the ready `Client`; rejects with a clear reason.
218
+ */
219
+ private openClient;
220
+ /**
221
+ * Ask a connected jump host to open a TCP channel to `host:port` and resolve
222
+ * the resulting stream (used as the next hop's transport `sock`). A RouterOS
223
+ * bastion rejects this unless SSH TCP forwarding is enabled, so the failure
224
+ * names the exact fix.
225
+ */
226
+ private forwardOut;
196
227
  /** Run a single command on a fresh SSH channel and return its decoded output. */
197
228
  run(command: string, opts?: {
198
229
  maxMs?: number;
@@ -211,7 +242,7 @@ declare class MikroTikSSHClient {
211
242
  cols?: number;
212
243
  rows?: number;
213
244
  }): Promise<ClientChannel>;
214
- /** Close the SSH connection. Safe to call multiple times. */
245
+ /** Close the SSH connection (and any jump hosts). Safe to call multiple times. */
215
246
  disconnect(): void;
216
247
  }
217
248
  /**
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  resolveDeviceName,
22
22
  selectToolModules,
23
23
  setConfig
24
- } from "./shared/library-bj9xrcg0.js";
24
+ } from "./shared/library-aa58hkb4.js";
25
25
  // src/server.ts
26
26
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
27
27
  import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -131,7 +131,7 @@ function registerPrompts(server) {
131
131
  // package.json
132
132
  var package_default = {
133
133
  name: "@usex/mikrotik-mcp",
134
- version: "3.32.2",
134
+ version: "3.34.0",
135
135
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
136
136
  keywords: [
137
137
  "ai",
@@ -213,7 +213,7 @@ var package_default = {
213
213
  },
214
214
  devDependencies: {
215
215
  "@resvg/resvg-js": "^2.6.2",
216
- "@tailwindcss/vite": "^4.3.1",
216
+ "@tailwindcss/vite": "^4.3.2",
217
217
  "@types/bun": "latest",
218
218
  "@types/node": "^26.0.1",
219
219
  "@types/react": "^19.2.17",
@@ -224,13 +224,13 @@ var package_default = {
224
224
  "class-variance-authority": "^0.7.1",
225
225
  clsx: "^2.1.1",
226
226
  gsap: "^3.15.0",
227
- prettier: "^3.9.1",
227
+ prettier: "^3.9.3",
228
228
  react: "^19.2.7",
229
229
  "react-dom": "^19.2.7",
230
230
  recharts: "^3.9.0",
231
231
  "release-it": "^20.2.1",
232
232
  "tailwind-merge": "^3.6.0",
233
- tailwindcss: "^4.3.1",
233
+ tailwindcss: "^4.3.2",
234
234
  vite: "npm:@voidzero-dev/vite-plus-core@latest",
235
235
  "vite-plus": "latest"
236
236
  },
@@ -4,7 +4,7 @@ import {
4
4
  allToolModules,
5
5
  moduleCatalog,
6
6
  selectToolModules
7
- } from "./cli-t84nm0d4.js";
7
+ } from "./cli-gt8xtjx9.js";
8
8
  export {
9
9
  selectToolModules,
10
10
  moduleCatalog,
@@ -25,6 +25,16 @@ var McpServerSettingsSchema = z.object({
25
25
  toolPageSize: z.coerce.number().int().min(0).default(0),
26
26
  appViews: z.boolean().default(true)
27
27
  });
28
+ var JumpHostSchema = z.object({
29
+ host: z.string(),
30
+ port: z.coerce.number().int().positive().default(22),
31
+ username: z.string().default("admin"),
32
+ password: z.string().optional(),
33
+ keyFilename: z.string().optional(),
34
+ privateKey: z.string().optional(),
35
+ keyPassphrase: z.string().optional(),
36
+ timeoutMs: z.coerce.number().int().positive().optional()
37
+ });
28
38
  var DeviceConfigSchema = z.object({
29
39
  host: z.string().default("127.0.0.1"),
30
40
  username: z.string().default("admin"),
@@ -34,6 +44,8 @@ var DeviceConfigSchema = z.object({
34
44
  privateKey: z.string().optional(),
35
45
  keyPassphrase: z.string().optional(),
36
46
  timeoutMs: z.coerce.number().int().positive().default(1e4),
47
+ jumpVia: z.string().optional(),
48
+ jumpHost: JumpHostSchema.optional(),
37
49
  mac: z.string().optional(),
38
50
  sourceMac: z.string().optional(),
39
51
  macHost: z.string().optional(),
@@ -131,6 +143,15 @@ function getConfigSource() {
131
143
  function loadConfig(argv = process.argv.slice(2)) {
132
144
  const flags = parseFlags(argv);
133
145
  const pick = (flag, ...envNames) => flags[flag] ?? env(...envNames);
146
+ const jumpHostName = pick("jump-host", "MIKROTIK_JUMP_HOST");
147
+ const jumpHost = jumpHostName ? {
148
+ host: jumpHostName,
149
+ port: pick("jump-port", "MIKROTIK_JUMP_PORT"),
150
+ username: pick("jump-username", "MIKROTIK_JUMP_USERNAME"),
151
+ password: pick("jump-password", "MIKROTIK_JUMP_PASSWORD"),
152
+ keyFilename: pick("jump-key-filename", "MIKROTIK_JUMP_KEY_FILENAME"),
153
+ keyPassphrase: pick("jump-key-passphrase", "MIKROTIK_JUMP_KEY_PASSPHRASE")
154
+ } : undefined;
134
155
  const single = {
135
156
  host: pick("host", "MIKROTIK_HOST"),
136
157
  username: pick("username", "MIKROTIK_USERNAME"),
@@ -140,6 +161,7 @@ function loadConfig(argv = process.argv.slice(2)) {
140
161
  privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
141
162
  keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
142
163
  timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
164
+ jumpHost,
143
165
  mac: pick("mac", "MIKROTIK_MAC"),
144
166
  sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
145
167
  macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
@@ -706,45 +728,72 @@ function decodeOutput(data) {
706
728
 
707
729
  class MikroTikSSHClient {
708
730
  client = null;
731
+ bastions = [];
709
732
  opts;
710
733
  lastError;
711
734
  constructor(opts) {
712
735
  this.opts = { port: 22, timeoutMs: 1e4, ...opts };
713
736
  }
714
- connect() {
737
+ async connect() {
715
738
  this.lastError = undefined;
716
- return new Promise((resolve2) => {
739
+ try {
740
+ const hops = [];
741
+ for (let j = this.opts.jump;j; j = j.jump)
742
+ hops.unshift(j);
743
+ const sequence = [...hops, this.opts];
744
+ let sock;
745
+ for (let i = 0;i < hops.length; i++) {
746
+ const client = await this.openClient(hops[i], sock);
747
+ this.bastions.push(client);
748
+ const next = sequence[i + 1];
749
+ sock = await this.forwardOut(client, next.host, next.port ?? 22);
750
+ }
751
+ this.client = await this.openClient(this.opts, sock);
752
+ return true;
753
+ } catch (e) {
754
+ this.lastError = e instanceof Error ? e.message : String(e);
755
+ logger.error(`Failed to connect to MikroTik: ${this.lastError}`);
756
+ this.disconnect();
757
+ return false;
758
+ }
759
+ }
760
+ openClient(o, sock) {
761
+ return new Promise((resolve2, reject) => {
717
762
  const client = new Client;
718
763
  const cfg = {
719
- host: this.opts.host,
720
- port: this.opts.port,
721
- username: this.opts.username,
722
- readyTimeout: this.opts.timeoutMs
764
+ host: o.host,
765
+ port: o.port ?? 22,
766
+ username: o.username,
767
+ readyTimeout: o.timeoutMs ?? 1e4
723
768
  };
724
- if (this.opts.privateKey) {
725
- cfg.privateKey = this.opts.privateKey;
726
- } else if (this.opts.keyFilename) {
769
+ if (sock)
770
+ cfg.sock = sock;
771
+ if (o.privateKey) {
772
+ cfg.privateKey = o.privateKey;
773
+ } else if (o.keyFilename) {
727
774
  try {
728
- cfg.privateKey = readFileSync2(this.opts.keyFilename);
775
+ cfg.privateKey = readFileSync2(o.keyFilename);
729
776
  } catch (e) {
730
- this.lastError = `could not read key file ${this.opts.keyFilename}: ${e instanceof Error ? e.message : String(e)}`;
731
- logger.error(`Failed to read SSH key file ${this.opts.keyFilename}: ${String(e)}`);
732
- resolve2(false);
777
+ reject(new Error(`could not read key file ${o.keyFilename}: ${e instanceof Error ? e.message : String(e)}`));
733
778
  return;
734
779
  }
735
780
  }
736
- if (cfg.privateKey && this.opts.keyPassphrase)
737
- cfg.passphrase = this.opts.keyPassphrase;
738
- if (this.opts.password)
739
- cfg.password = this.opts.password;
740
- client.on("ready", () => {
741
- this.client = client;
742
- resolve2(true);
743
- }).on("error", (err) => {
744
- this.lastError = err.message;
745
- logger.error(`Failed to connect to MikroTik: ${err.message}`);
746
- resolve2(false);
747
- }).connect(cfg);
781
+ if (cfg.privateKey && o.keyPassphrase)
782
+ cfg.passphrase = o.keyPassphrase;
783
+ if (o.password)
784
+ cfg.password = o.password;
785
+ client.on("ready", () => resolve2(client)).on("error", (err) => reject(err)).connect(cfg);
786
+ });
787
+ }
788
+ forwardOut(via, host, port) {
789
+ return new Promise((resolve2, reject) => {
790
+ via.forwardOut("127.0.0.1", 0, host, port, (err, stream) => {
791
+ if (err) {
792
+ reject(new Error(`jump host could not open a tunnel to ${host}:${port}: ${err.message}. ` + "If the jump router runs RouterOS, enable SSH TCP forwarding on it: " + "/ip ssh set forwarding-enabled=local (or both)."));
793
+ } else {
794
+ resolve2(stream);
795
+ }
796
+ });
748
797
  });
749
798
  }
750
799
  run(command, opts = {}) {
@@ -863,6 +912,12 @@ class MikroTikSSHClient {
863
912
  } catch {}
864
913
  this.client = null;
865
914
  }
915
+ for (const b of this.bastions.reverse()) {
916
+ try {
917
+ b.end();
918
+ } catch {}
919
+ }
920
+ this.bastions = [];
866
921
  }
867
922
  }
868
923
 
@@ -870,6 +925,43 @@ class MikroTikSSHClient {
870
925
  function isMacTelnetDevice(dc) {
871
926
  return Boolean(dc.mac);
872
927
  }
928
+ function sshOptionsOf(dc) {
929
+ return {
930
+ host: dc.host,
931
+ username: dc.username,
932
+ password: dc.password,
933
+ keyFilename: dc.keyFilename,
934
+ privateKey: dc.privateKey,
935
+ keyPassphrase: dc.keyPassphrase,
936
+ port: dc.port,
937
+ timeoutMs: dc.timeoutMs
938
+ };
939
+ }
940
+ function resolveJump(dc, seen = new Set) {
941
+ if (dc.jumpHost) {
942
+ return {
943
+ host: dc.jumpHost.host,
944
+ port: dc.jumpHost.port,
945
+ username: dc.jumpHost.username,
946
+ password: dc.jumpHost.password,
947
+ keyFilename: dc.jumpHost.keyFilename,
948
+ privateKey: dc.jumpHost.privateKey,
949
+ keyPassphrase: dc.jumpHost.keyPassphrase,
950
+ timeoutMs: dc.jumpHost.timeoutMs
951
+ };
952
+ }
953
+ if (!dc.jumpVia)
954
+ return;
955
+ if (seen.has(dc.jumpVia)) {
956
+ throw new Error(`SSH jump-host cycle detected at '${dc.jumpVia}' (a device can't jump through itself).`);
957
+ }
958
+ seen.add(dc.jumpVia);
959
+ const bastion = getDevice(dc.jumpVia);
960
+ if (isMacTelnetDevice(bastion)) {
961
+ throw new Error(`Jump host '${dc.jumpVia}' is a MAC-Telnet device; an SSH bastion must be reachable over SSH.`);
962
+ }
963
+ return { ...sshOptionsOf(bastion), jump: resolveJump(bastion, seen) };
964
+ }
873
965
  function createDeviceClient(dc) {
874
966
  if (isMacTelnetDevice(dc)) {
875
967
  return new MikroTikMacTelnetClient({
@@ -882,16 +974,7 @@ function createDeviceClient(dc) {
882
974
  timeoutMs: dc.timeoutMs
883
975
  });
884
976
  }
885
- return new MikroTikSSHClient({
886
- host: dc.host,
887
- username: dc.username,
888
- password: dc.password,
889
- keyFilename: dc.keyFilename,
890
- privateKey: dc.privateKey,
891
- keyPassphrase: dc.keyPassphrase,
892
- port: dc.port,
893
- timeoutMs: dc.timeoutMs
894
- });
977
+ return new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
895
978
  }
896
979
  function describeTransport(dc) {
897
980
  return dc.mac ? `MAC ${dc.mac} (mac-telnet)` : `${dc.host}:${dc.port}`;
@@ -4487,40 +4570,39 @@ var changePlanTools = [
4487
4570
  return "No commands provided to apply.";
4488
4571
  const device = resolveDeviceName(ctx.device);
4489
4572
  if (getDevice(ctx.device).mac) {
4490
- return "Safe Mode (and therefore apply_plan) requires SSH; it is not available on a MAC-Telnet device.";
4573
+ throw new Error("Safe Mode (and therefore apply_plan) requires SSH; it is not available on a MAC-Telnet device.");
4491
4574
  }
4492
4575
  const plan = buildChangePlan(commands);
4493
4576
  const safe = getSafeModeManager(device);
4494
4577
  const enabled2 = await safe.enable();
4495
4578
  if (enabled2.startsWith("Error"))
4496
- return enabled2;
4579
+ throw new Error(enabled2);
4497
4580
  const APPLY_BUDGET_MS = 90000;
4498
4581
  const startedAt = Date.now();
4499
4582
  const overBudget = () => Date.now() - startedAt > APPLY_BUDGET_MS;
4500
- try {
4501
- const before = normalizeExport(await safe.execute("/export terse"));
4502
- const log = [];
4503
- for (const step of plan.steps) {
4504
- if (overBudget()) {
4505
- await safe.rollback();
4506
- return `Aborted after ${Math.round((Date.now() - startedAt) / 1000)}s (time budget exceeded) \u2014 ` + "the plan was ROLLED BACK (nothing committed). Safe Mode is slow/unresponsive on this " + "device; apply the change with the direct write tools instead.";
4507
- }
4508
- const out = await safe.execute(step.command);
4509
- if (looksLikeError(out)) {
4510
- await safe.rollback();
4511
- return `Step ${step.index} failed and the plan was ROLLED BACK (nothing committed):
4512
- ` + ` ${step.command}
4513
- \u2192 ${out.trim()}`;
4583
+ const applyAndDiff = async () => {
4584
+ try {
4585
+ const before = normalizeExport(await safe.execute("/export terse"));
4586
+ const log = [];
4587
+ for (const step of plan.steps) {
4588
+ if (overBudget()) {
4589
+ throw new Error(`aborted after ${Math.round((Date.now() - startedAt) / 1000)}s (time budget exceeded). ` + "Safe Mode is slow/unresponsive on this device; apply the change with the direct write tools instead");
4590
+ }
4591
+ const out = await safe.execute(step.command);
4592
+ if (looksLikeError(out)) {
4593
+ throw new Error(`step ${step.index} failed:
4594
+ ${step.command}
4595
+ \u2192 ${out.trim()}`);
4596
+ }
4597
+ log.push(` \u2713 ${step.command}`);
4514
4598
  }
4515
- log.push(` \u2713 ${step.command}`);
4516
- }
4517
- const after = normalizeExport(await safe.execute("/export terse"));
4518
- const diff = diffLines(before, after, { fromLabel: "before", toLabel: "after" });
4519
- const reachable = !looksLikeError(await safe.execute("/system identity print"));
4520
- const planBody = renderPlan(plan).split(`
4599
+ const after = normalizeExport(await safe.execute("/export terse"));
4600
+ const diff = diffLines(before, after, { fromLabel: "before", toLabel: "after" });
4601
+ const reachable2 = !looksLikeError(await safe.execute("/system identity print"));
4602
+ const planBody = renderPlan(plan).split(`
4521
4603
  `).slice(2).join(`
4522
4604
  `);
4523
- const header = `APPLY PLAN \u2014 ${device}
4605
+ const header2 = `APPLY PLAN \u2014 ${device}
4524
4606
 
4525
4607
  ${planBody}
4526
4608
 
@@ -4530,32 +4612,35 @@ ${log.join(`
4530
4612
 
4531
4613
  ` + `EXACT DIFF (+${diff.summary.added}/-${diff.summary.removed}):
4532
4614
  ${diff.unified || "(no config change)"}`;
4533
- if (!a.confirm) {
4534
- await safe.rollback();
4535
- return `${header}
4615
+ return { header: header2, reachable: reachable2 };
4616
+ } catch (e) {
4617
+ await safe.rollback().catch(() => {});
4618
+ const msg = e instanceof Error ? e.message : String(e);
4619
+ throw new Error(`apply_plan failed and was rolled back (nothing committed): ${msg}`);
4620
+ }
4621
+ };
4622
+ const { header, reachable } = await applyAndDiff();
4623
+ if (!a.confirm) {
4624
+ await safe.rollback();
4625
+ return `${header}
4536
4626
 
4537
4627
  DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
4538
- }
4539
- if (!reachable) {
4540
- await safe.rollback();
4541
- return `${header}
4628
+ }
4629
+ if (!reachable) {
4630
+ await safe.rollback();
4631
+ throw new Error(`${header}
4542
4632
 
4543
- ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`;
4544
- }
4545
- const committed = await safe.commit();
4546
- if (!committed.ok) {
4547
- return `${header}
4633
+ ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`);
4634
+ }
4635
+ const committed = await safe.commit();
4636
+ if (!committed.ok) {
4637
+ throw new Error(`${header}
4548
4638
 
4549
- COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ` + `${committed.message}`;
4550
- }
4551
- return `${header}
4639
+ COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ${committed.message}`);
4640
+ }
4641
+ return `${header}
4552
4642
 
4553
4643
  COMMITTED: changes are now permanent. ${committed.message}`;
4554
- } catch (e) {
4555
- await safe.rollback();
4556
- const msg = e instanceof Error ? e.message : String(e);
4557
- return `apply_plan failed and was rolled back: ${msg}`;
4558
- }
4559
4644
  }
4560
4645
  })
4561
4646
  ];
@@ -5932,7 +6017,7 @@ var cache = null;
5932
6017
  async function gateway() {
5933
6018
  if (cache)
5934
6019
  return cache;
5935
- const { moduleCatalog } = await import("./cli-ytn5v321.js");
6020
+ const { moduleCatalog } = await import("./cli-g5kqcrt4.js");
5936
6021
  const forIndex = [];
5937
6022
  const byName = new Map;
5938
6023
  for (const mod of moduleCatalog) {