@usex/mikrotik-mcp 3.36.0 → 3.38.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/dist/cli.js CHANGED
@@ -84,7 +84,7 @@ import {
84
84
  toggleAaaEntity,
85
85
  updateAaaEntity,
86
86
  writeBackup
87
- } from "./shared/cli-3580mf22.js";
87
+ } from "./shared/cli-e484wzek.js";
88
88
 
89
89
  // src/cli.ts
90
90
  import { existsSync as existsSync2 } from "fs";
@@ -1948,6 +1948,90 @@ async function runDashboard(cfg, transportLabel) {
1948
1948
  const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
1949
1949
  return bearer === cfg.token || url.searchParams.get("token") === cfg.token;
1950
1950
  };
1951
+ async function dashboardRoute(req, url, srv, ca, tl) {
1952
+ if (url.pathname === "/api/stream") {
1953
+ if (srv.upgrade(req, { data: {} }))
1954
+ return;
1955
+ return new Response("WebSocket upgrade failed", { status: 400 });
1956
+ }
1957
+ if (url.pathname === "/api/sse") {
1958
+ return sseResponse(tl);
1959
+ }
1960
+ const configResp = await configRoutes(req, url, ca);
1961
+ if (configResp)
1962
+ return configResp;
1963
+ const modulesResp = await modulesRoutes(req, url);
1964
+ if (modulesResp)
1965
+ return modulesResp;
1966
+ const captureResp = await captureRoutes(req, url);
1967
+ if (captureResp)
1968
+ return captureResp;
1969
+ const clientsResp = await clientsRoutes(req, url);
1970
+ if (clientsResp)
1971
+ return clientsResp;
1972
+ const aaaResp = await aaaRoutes(req, url);
1973
+ if (aaaResp)
1974
+ return aaaResp;
1975
+ const usageResp = await usageRoutes(req, url);
1976
+ if (usageResp)
1977
+ return usageResp;
1978
+ const featureResp = await featureRoutes(req, url);
1979
+ if (featureResp)
1980
+ return featureResp;
1981
+ const db = getEventStore();
1982
+ if (!db)
1983
+ return json({ error: "recorder not active" }, 503);
1984
+ if (url.pathname === "/api/events" && req.method === "DELETE") {
1985
+ let body = {};
1986
+ try {
1987
+ body = await req.json();
1988
+ } catch {}
1989
+ const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
1990
+ const removed = body.all === true ? db.clear() : db.delete(ids);
1991
+ return json({ removed, total: db.total() });
1992
+ }
1993
+ if (url.pathname === "/api/devices") {
1994
+ return json(devicesPayload(db));
1995
+ }
1996
+ if (url.pathname === "/api/topology") {
1997
+ return json(topologyPayload());
1998
+ }
1999
+ if (url.pathname === "/api/config") {
2000
+ return json(configPayload());
2001
+ }
2002
+ if (url.pathname === "/" || url.pathname === "/index.html") {
2003
+ return new Response(dashboardHtml(), {
2004
+ headers: { "content-type": "text/html; charset=utf-8" }
2005
+ });
2006
+ }
2007
+ if (url.pathname === "/api/meta") {
2008
+ const f = facets(db);
2009
+ return json({
2010
+ ...f,
2011
+ risks: ["READ", "WRITE", "WRITE_IDEMPOTENT", "DESTRUCTIVE", "DANGEROUS"],
2012
+ total: db.total(),
2013
+ liveClients: subscriberCount(),
2014
+ transport: tl
2015
+ });
2016
+ }
2017
+ if (url.pathname === "/api/events") {
2018
+ const filter = filterFromQuery(url);
2019
+ return json({ events: db.query(filter), total: db.total() });
2020
+ }
2021
+ const eventMatch = url.pathname.match(/^\/api\/event\/(.+)$/);
2022
+ if (eventMatch) {
2023
+ const e = db.get(decodeURIComponent(eventMatch[1]));
2024
+ return e ? json(e) : json({ error: "not found" }, 404);
2025
+ }
2026
+ if (url.pathname === "/api/stats") {
2027
+ const now = Date.now();
2028
+ const windowMs = Number(url.searchParams.get("window") ?? 3600000);
2029
+ const buckets = Number(url.searchParams.get("buckets") ?? 60);
2030
+ const events = db.query({ since: now - windowMs, limit: 5000 });
2031
+ return json(computeStats(events, { now, windowMs, buckets }));
2032
+ }
2033
+ return new Response("Not Found", { status: 404 });
2034
+ }
1951
2035
  const server = serve({
1952
2036
  hostname: cfg.host,
1953
2037
  port: cfg.port,
@@ -1959,88 +2043,13 @@ async function runDashboard(cfg, transportLabel) {
1959
2043
  if (!tokenOk(req, url)) {
1960
2044
  return new Response("Unauthorized", { status: 401 });
1961
2045
  }
1962
- if (url.pathname === "/api/stream") {
1963
- if (srv.upgrade(req, { data: {} }))
1964
- return;
1965
- return new Response("WebSocket upgrade failed", { status: 400 });
1966
- }
1967
- if (url.pathname === "/api/sse") {
1968
- return sseResponse(transportLabel);
1969
- }
1970
- const configResp = await configRoutes(req, url, configAdmin);
1971
- if (configResp)
1972
- return configResp;
1973
- const modulesResp = await modulesRoutes(req, url);
1974
- if (modulesResp)
1975
- return modulesResp;
1976
- const captureResp = await captureRoutes(req, url);
1977
- if (captureResp)
1978
- return captureResp;
1979
- const clientsResp = await clientsRoutes(req, url);
1980
- if (clientsResp)
1981
- return clientsResp;
1982
- const aaaResp = await aaaRoutes(req, url);
1983
- if (aaaResp)
1984
- return aaaResp;
1985
- const usageResp = await usageRoutes(req, url);
1986
- if (usageResp)
1987
- return usageResp;
1988
- const featureResp = await featureRoutes(req, url);
1989
- if (featureResp)
1990
- return featureResp;
1991
- const db = getEventStore();
1992
- if (!db)
1993
- return json({ error: "recorder not active" }, 503);
1994
- if (url.pathname === "/api/events" && req.method === "DELETE") {
1995
- let body = {};
1996
- try {
1997
- body = await req.json();
1998
- } catch {}
1999
- const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
2000
- const removed = body.all === true ? db.clear() : db.delete(ids);
2001
- return json({ removed, total: db.total() });
2002
- }
2003
- if (url.pathname === "/api/devices") {
2004
- return json(devicesPayload(db));
2005
- }
2006
- if (url.pathname === "/api/topology") {
2007
- return json(topologyPayload());
2008
- }
2009
- if (url.pathname === "/api/config") {
2010
- return json(configPayload());
2011
- }
2012
- if (url.pathname === "/" || url.pathname === "/index.html") {
2013
- return new Response(dashboardHtml(), {
2014
- headers: { "content-type": "text/html; charset=utf-8" }
2015
- });
2016
- }
2017
- if (url.pathname === "/api/meta") {
2018
- const f = facets(db);
2019
- return json({
2020
- ...f,
2021
- risks: ["READ", "WRITE", "WRITE_IDEMPOTENT", "DESTRUCTIVE", "DANGEROUS"],
2022
- total: db.total(),
2023
- liveClients: subscriberCount(),
2024
- transport: transportLabel
2025
- });
2026
- }
2027
- if (url.pathname === "/api/events") {
2028
- const filter = filterFromQuery(url);
2029
- return json({ events: db.query(filter), total: db.total() });
2030
- }
2031
- const eventMatch = url.pathname.match(/^\/api\/event\/(.+)$/);
2032
- if (eventMatch) {
2033
- const e = db.get(decodeURIComponent(eventMatch[1]));
2034
- return e ? json(e) : json({ error: "not found" }, 404);
2035
- }
2036
- if (url.pathname === "/api/stats") {
2037
- const now = Date.now();
2038
- const windowMs = Number(url.searchParams.get("window") ?? 3600000);
2039
- const buckets = Number(url.searchParams.get("buckets") ?? 60);
2040
- const events = db.query({ since: now - windowMs, limit: 5000 });
2041
- return json(computeStats(events, { now, windowMs, buckets }));
2046
+ try {
2047
+ return await dashboardRoute(req, url, srv, configAdmin, transportLabel);
2048
+ } catch (e) {
2049
+ const msg = e instanceof Error ? e.message : String(e);
2050
+ logger.error(`Dashboard request failed (${url.pathname}): ${msg}`);
2051
+ return json({ error: msg }, 502);
2042
2052
  }
2043
- return new Response("Not Found", { status: 404 });
2044
2053
  },
2045
2054
  websocket: {
2046
2055
  open(ws) {
@@ -2233,7 +2242,7 @@ function registerPrompts(server) {
2233
2242
  // package.json
2234
2243
  var package_default = {
2235
2244
  name: "@usex/mikrotik-mcp",
2236
- version: "3.36.0",
2245
+ version: "3.38.0",
2237
2246
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
2238
2247
  keywords: [
2239
2248
  "ai",
package/dist/index.d.ts CHANGED
@@ -240,6 +240,12 @@ declare class MikroTikSSHClient {
240
240
  * Resolves on success; rejects with a clear reason on failure.
241
241
  */
242
242
  uploadFile(remotePath: string, data: Buffer): Promise<void>;
243
+ /**
244
+ * Download a file's bytes from the device over SFTP. `remotePath` is relative
245
+ * to the SFTP default directory (the flash root). Resolves with the raw file
246
+ * contents; rejects with a clear reason on failure.
247
+ */
248
+ downloadFile(remotePath: string): Promise<Buffer>;
243
249
  /** Open a persistent interactive shell channel (used by Safe Mode). */
244
250
  shell(opts?: {
245
251
  term?: string;
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  resolveDeviceName,
22
22
  selectToolModules,
23
23
  setConfig
24
- } from "./shared/library-jm3khjrr.js";
24
+ } from "./shared/library-nxh3rb8t.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.36.0",
134
+ version: "3.38.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",
@@ -906,6 +906,29 @@ class MikroTikSSHClient {
906
906
  });
907
907
  });
908
908
  }
909
+ downloadFile(remotePath) {
910
+ if (!this.client) {
911
+ return Promise.reject(new Error("Not connected to MikroTik device"));
912
+ }
913
+ const openSftp = this.client.sftp.bind(this.client);
914
+ return new Promise((resolve2, reject) => {
915
+ openSftp((err, sftp) => {
916
+ if (err) {
917
+ reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
918
+ return;
919
+ }
920
+ sftp.readFile(remotePath, (rerr, data) => {
921
+ try {
922
+ sftp.end();
923
+ } catch {}
924
+ if (rerr)
925
+ reject(new Error(`SFTP read failed: ${rerr.message}`));
926
+ else
927
+ resolve2(data);
928
+ });
929
+ });
930
+ });
931
+ }
909
932
  shell(opts = {}) {
910
933
  if (!this.client) {
911
934
  return Promise.reject(new Error("Not connected to MikroTik device"));
@@ -1258,6 +1281,22 @@ async function uploadFileToDevice(deviceName, remotePath, data) {
1258
1281
  ssh.disconnect();
1259
1282
  }
1260
1283
  }
1284
+ async function downloadFileFromDevice(deviceName, remotePath) {
1285
+ const name = resolveDeviceName(deviceName);
1286
+ const dc = getDevice(deviceName);
1287
+ if (isMacTelnetDevice(dc)) {
1288
+ throw new Error(`Cannot download a file from '${name}': it is reached over Layer-2 MAC-Telnet, which has no ` + "file transfer. Configure SSH (host + credentials) for this device, or use a text export " + "(stdout mode) instead of a binary backup.");
1289
+ }
1290
+ const ssh = new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
1291
+ if (!await ssh.connect()) {
1292
+ throw new Error(connectErrorMessage(name, dc, ssh.lastError));
1293
+ }
1294
+ try {
1295
+ return await ssh.downloadFile(remotePath);
1296
+ } finally {
1297
+ ssh.disconnect();
1298
+ }
1299
+ }
1261
1300
 
1262
1301
  // src/core/registry.ts
1263
1302
  import { z as z2 } from "zod";
@@ -2759,6 +2798,158 @@ ${rows.slice(0, 40).map((r) => ` \u2022 ${r.name ?? "?"} (${r.type ?? "?"})${r.
2759
2798
  // src/tools/backup.ts
2760
2799
  import { z as z5 } from "zod";
2761
2800
 
2801
+ // src/backups/disk-check.ts
2802
+ var DISK_THRESHOLD_PCT = 90;
2803
+ async function checkDiskSpace(ctx) {
2804
+ try {
2805
+ const raw = await executeMikrotikCommand("/system resource print", ctx);
2806
+ const sys = parseSystemResource(raw);
2807
+ if (!sys || sys.hddUsedPct == null) {
2808
+ return { low: false };
2809
+ }
2810
+ return {
2811
+ low: sys.hddUsedPct >= DISK_THRESHOLD_PCT,
2812
+ usedPct: sys.hddUsedPct,
2813
+ freeBytes: sys.freeHdd,
2814
+ totalBytes: sys.totalHdd
2815
+ };
2816
+ } catch {
2817
+ return { low: false };
2818
+ }
2819
+ }
2820
+
2821
+ // src/backups/vault.ts
2822
+ import {
2823
+ existsSync as existsSync2,
2824
+ mkdirSync,
2825
+ readFileSync as readFileSync4,
2826
+ readdirSync,
2827
+ renameSync,
2828
+ rmSync,
2829
+ statSync,
2830
+ writeFileSync
2831
+ } from "fs";
2832
+ import { basename, join as join4 } from "path";
2833
+ function backupDir() {
2834
+ if (process.env.MIKROTIK_BACKUP_DIR)
2835
+ return process.env.MIKROTIK_BACKUP_DIR;
2836
+ try {
2837
+ const dir = getConfig().backupDir;
2838
+ if (dir)
2839
+ return dir;
2840
+ } catch {}
2841
+ return DEFAULT_BACKUP_DIR;
2842
+ }
2843
+ function safeName(name) {
2844
+ const base = basename(name);
2845
+ if (base !== name || base === "." || base === ".." || !/^[A-Za-z0-9._-]+$/.test(base)) {
2846
+ throw new Error(`invalid backup name: ${name}`);
2847
+ }
2848
+ return base;
2849
+ }
2850
+ function listBackups() {
2851
+ const dir = backupDir();
2852
+ if (!existsSync2(dir))
2853
+ return [];
2854
+ return readdirSync(dir).filter((f) => f.endsWith(".rsc") || f.endsWith(".backup")).map((f) => {
2855
+ const st = statSync(join4(dir, f));
2856
+ const us = f.indexOf("_");
2857
+ return {
2858
+ name: f,
2859
+ bytes: st.size,
2860
+ modified: st.mtimeMs,
2861
+ device: us > 0 ? f.slice(0, us) : undefined,
2862
+ type: f.endsWith(".backup") ? "backup" : "rsc"
2863
+ };
2864
+ }).sort((a, b) => b.modified - a.modified);
2865
+ }
2866
+ function readBackup(name) {
2867
+ return readFileSync4(join4(backupDir(), safeName(name)), "utf8");
2868
+ }
2869
+ function writeBackup(name, content) {
2870
+ const dir = backupDir();
2871
+ mkdirSync(dir, { recursive: true });
2872
+ let final = safeName(name);
2873
+ if (existsSync2(join4(dir, final))) {
2874
+ const dot = final.lastIndexOf(".");
2875
+ const stem = dot > 0 ? final.slice(0, dot) : final;
2876
+ const ext = dot > 0 ? final.slice(dot) : "";
2877
+ let n = 2;
2878
+ while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
2879
+ n++;
2880
+ final = `${stem}_${n}${ext}`;
2881
+ }
2882
+ writeFileSync(join4(dir, final), content, "utf8");
2883
+ return final;
2884
+ }
2885
+ function writeBinaryBackup(name, data) {
2886
+ const dir = backupDir();
2887
+ mkdirSync(dir, { recursive: true });
2888
+ let final = safeName(name);
2889
+ if (existsSync2(join4(dir, final))) {
2890
+ const dot = final.lastIndexOf(".");
2891
+ const stem = dot > 0 ? final.slice(0, dot) : final;
2892
+ const ext = dot > 0 ? final.slice(dot) : "";
2893
+ let n = 2;
2894
+ while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
2895
+ n++;
2896
+ final = `${stem}_${n}${ext}`;
2897
+ }
2898
+ writeFileSync(join4(dir, final), data);
2899
+ return final;
2900
+ }
2901
+ function deleteBackup(name) {
2902
+ const p = join4(backupDir(), safeName(name));
2903
+ if (!existsSync2(p))
2904
+ return false;
2905
+ rmSync(p);
2906
+ return true;
2907
+ }
2908
+ function renameBackup(oldName, newName) {
2909
+ const dir = backupDir();
2910
+ const from = join4(dir, safeName(oldName));
2911
+ let to = safeName(newName);
2912
+ if (!to.endsWith(".rsc"))
2913
+ to += ".rsc";
2914
+ const toPath = join4(dir, to);
2915
+ if (!existsSync2(from))
2916
+ throw new Error(`backup not found: ${oldName}`);
2917
+ if (existsSync2(toPath))
2918
+ throw new Error(`a backup named '${to}' already exists`);
2919
+ renameSync(from, toPath);
2920
+ return to;
2921
+ }
2922
+ function exportToCommands(text) {
2923
+ const lines = [];
2924
+ let buf = "";
2925
+ for (const raw of text.split(`
2926
+ `)) {
2927
+ const line = raw.replace(/\r$/, "");
2928
+ buf = buf ? `${buf} ${line.trim()}` : line;
2929
+ if (buf.trimEnd().endsWith("\\")) {
2930
+ buf = buf.trimEnd().slice(0, -1).trimEnd();
2931
+ continue;
2932
+ }
2933
+ lines.push(buf);
2934
+ buf = "";
2935
+ }
2936
+ if (buf)
2937
+ lines.push(buf);
2938
+ const cmds = [];
2939
+ let section = "";
2940
+ for (const line of lines) {
2941
+ const t = line.trim();
2942
+ if (!t || t.startsWith("#"))
2943
+ continue;
2944
+ if (t.startsWith("/")) {
2945
+ section = t;
2946
+ continue;
2947
+ }
2948
+ cmds.push(section ? `${section} ${t}` : t);
2949
+ }
2950
+ return cmds;
2951
+ }
2952
+
2762
2953
  // src/core/datestamp.ts
2763
2954
  var MONTHS2 = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
2764
2955
  function pad(n) {
@@ -2831,6 +3022,12 @@ async function deviceDateStamp(ctx) {
2831
3022
  return hostStamp(new Date);
2832
3023
  }
2833
3024
 
3025
+ // src/core/slug.ts
3026
+ function deviceSlug(name) {
3027
+ const s = (name ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
3028
+ return s || "device";
3029
+ }
3030
+
2834
3031
  // src/tools/backup.ts
2835
3032
  var backupTools = [
2836
3033
  defineTool({
@@ -2847,6 +3044,7 @@ var backupTools = [
2847
3044
  async handler(a, ctx) {
2848
3045
  const name = a.name || `backup_${await deviceDateStamp(ctx)}`;
2849
3046
  ctx.info(`Creating backup: name=${name}`);
3047
+ const disk = await checkDiskSpace(ctx);
2850
3048
  const cmd = new Cmd("/system backup save").set("name", name);
2851
3049
  if (a.dont_encrypt)
2852
3050
  cmd.raw("dont-encrypt=yes");
@@ -2855,13 +3053,35 @@ var backupTools = [
2855
3053
  if (!a.include_password)
2856
3054
  cmd.raw("password-file=no");
2857
3055
  const result = await executeMikrotikCommand(cmd.build(), ctx);
2858
- if (result.includes("saved") || result.trim() === "") {
2859
- const fileDetails = await executeMikrotikCommand(`/file print detail where name=${name}.backup`, ctx);
2860
- return fileDetails ? `Backup created successfully:
3056
+ if (!(result.includes("saved") || result.trim() === "")) {
3057
+ if (disk.low) {
3058
+ return `Failed to create backup (device disk is ${disk.usedPct?.toFixed(0)}% used): ${result}
2861
3059
 
2862
- ${fileDetails}` : `Backup '${name}.backup' created successfully.`;
3060
+ ` + "Tip: use create_local_backup or create_export instead \u2014 text exports can be captured " + "directly to the local vault without using any device disk space.";
3061
+ }
3062
+ return `Failed to create backup: ${result}`;
3063
+ }
3064
+ if (disk.low) {
3065
+ const dc = getDevice(ctx.device);
3066
+ if (isMacTelnetDevice(dc)) {
3067
+ return `Backup '${name}.backup' created on device, but disk is ${disk.usedPct?.toFixed(0)}% used. ` + "This is a MAC-Telnet device (no SFTP), so the file cannot be downloaded automatically. " + "Free disk space manually or use create_local_backup for a text export that bypasses device storage.";
3068
+ }
3069
+ const deviceFile = `${name}.backup`;
3070
+ try {
3071
+ ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 downloading backup to local vault`);
3072
+ const data = await downloadFileFromDevice(ctx.device, deviceFile);
3073
+ const device = resolveDeviceName(ctx.device);
3074
+ const vaultName = writeBinaryBackup(`${deviceSlug(device)}_${deviceFile}`, data);
3075
+ await executeMikrotikCommand(`/file remove ${deviceFile}`, ctx);
3076
+ return `[LOW DISK \u2014 ${disk.usedPct?.toFixed(0)}% used] Backup '${deviceFile}' created, downloaded to ` + `local vault as '${vaultName}' (${data.length} bytes) at ${backupDir()}, and removed from ` + "the device to free disk space.";
3077
+ } catch (e) {
3078
+ return `Backup '${name}.backup' created on device, but the automatic download-and-cleanup ` + `failed: ${e instanceof Error ? e.message : String(e)}. The file is still on the device. ` + "Ensure the SSH user has the 'read' and 'sensitive' policies for SFTP downloads to work.";
3079
+ }
2863
3080
  }
2864
- return `Failed to create backup: ${result}`;
3081
+ const fileDetails = await executeMikrotikCommand(`/file print detail where name=${name}.backup`, ctx);
3082
+ return fileDetails ? `Backup created successfully:
3083
+
3084
+ ${fileDetails}` : `Backup '${name}.backup' created successfully.`;
2865
3085
  }
2866
3086
  }),
2867
3087
  defineTool({
@@ -2910,6 +3130,21 @@ ${result}`;
2910
3130
  async handler(a, ctx) {
2911
3131
  const name = a.name || `export_${await deviceDateStamp(ctx)}`;
2912
3132
  ctx.info(`Creating export: name=${name}, format=${a.file_format}`);
3133
+ const disk = await checkDiskSpace(ctx);
3134
+ if (disk.low) {
3135
+ ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 redirecting export to local vault`);
3136
+ const cmd2 = new Cmd("/export");
3137
+ cmd2.raw(a.verbose ? "verbose" : null);
3138
+ cmd2.raw(a.compact ? "compact" : null);
3139
+ cmd2.raw(!a.hide_sensitive ? "show-sensitive" : null);
3140
+ const body = await executeMikrotikCommand(cmd2.build(), ctx);
3141
+ if (isEmpty(body) || looksLikeError(body)) {
3142
+ return `Failed to create export: ${body}`;
3143
+ }
3144
+ const device = resolveDeviceName(ctx.device);
3145
+ const vaultName = writeBackup(`${deviceSlug(device)}_${name}.rsc`, body);
3146
+ return `[LOW DISK \u2014 ${disk.usedPct?.toFixed(0)}% used] Export saved to LOCAL VAULT as '${vaultName}' ` + `(${Buffer.byteLength(body)} bytes) at ${backupDir()} \u2014 no file was written to the device.`;
3147
+ }
2913
3148
  const extension = a.file_format === "json" || a.file_format === "xml" ? a.file_format : "rsc";
2914
3149
  const fullName = `${name}.${extension}`;
2915
3150
  const cmd = new Cmd("/export");
@@ -2948,6 +3183,20 @@ ${fileDetails}` : `Export '${fullName}' created successfully.`;
2948
3183
  name = `export_${cleanSection}_${await deviceDateStamp(ctx)}`;
2949
3184
  }
2950
3185
  ctx.info(`Exporting section: section=${a.section}, name=${name}`);
3186
+ const disk = await checkDiskSpace(ctx);
3187
+ if (disk.low) {
3188
+ ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 redirecting section export to local vault`);
3189
+ const cmd2 = new Cmd(`/${a.section} export`);
3190
+ cmd2.raw(!a.hide_sensitive ? "show-sensitive" : null);
3191
+ cmd2.raw(a.compact ? "compact" : null);
3192
+ const body = await executeMikrotikCommand(cmd2.build(), ctx);
3193
+ if (isEmpty(body) || looksLikeError(body)) {
3194
+ return `Failed to export section: ${body}`;
3195
+ }
3196
+ const device = resolveDeviceName(ctx.device);
3197
+ const vaultName = writeBackup(`${deviceSlug(device)}_${name}.rsc`, body);
3198
+ return `[LOW DISK \u2014 ${disk.usedPct?.toFixed(0)}% used] Section export saved to LOCAL VAULT as ` + `'${vaultName}' (${Buffer.byteLength(body)} bytes) at ${backupDir()} \u2014 no file was written ` + "to the device.";
3199
+ }
2951
3200
  const cmd = new Cmd(`/${a.section} export`).set("file", name);
2952
3201
  cmd.raw(!a.hide_sensitive ? "show-sensitive" : null);
2953
3202
  cmd.raw(a.compact ? "compact" : null);
@@ -3098,127 +3347,6 @@ ${result}`;
3098
3347
  // src/tools/local-backup.ts
3099
3348
  import { z as z6 } from "zod";
3100
3349
 
3101
- // src/core/slug.ts
3102
- function deviceSlug(name) {
3103
- const s = (name ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
3104
- return s || "device";
3105
- }
3106
-
3107
- // src/backups/vault.ts
3108
- import {
3109
- existsSync as existsSync2,
3110
- mkdirSync,
3111
- readFileSync as readFileSync4,
3112
- readdirSync,
3113
- renameSync,
3114
- rmSync,
3115
- statSync,
3116
- writeFileSync
3117
- } from "fs";
3118
- import { basename, join as join4 } from "path";
3119
- function backupDir() {
3120
- if (process.env.MIKROTIK_BACKUP_DIR)
3121
- return process.env.MIKROTIK_BACKUP_DIR;
3122
- try {
3123
- const dir = getConfig().backupDir;
3124
- if (dir)
3125
- return dir;
3126
- } catch {}
3127
- return DEFAULT_BACKUP_DIR;
3128
- }
3129
- function safeName(name) {
3130
- const base = basename(name);
3131
- if (base !== name || base === "." || base === ".." || !/^[A-Za-z0-9._-]+$/.test(base)) {
3132
- throw new Error(`invalid backup name: ${name}`);
3133
- }
3134
- return base;
3135
- }
3136
- function listBackups() {
3137
- const dir = backupDir();
3138
- if (!existsSync2(dir))
3139
- return [];
3140
- return readdirSync(dir).filter((f) => f.endsWith(".rsc")).map((f) => {
3141
- const st = statSync(join4(dir, f));
3142
- const us = f.indexOf("_");
3143
- return {
3144
- name: f,
3145
- bytes: st.size,
3146
- modified: st.mtimeMs,
3147
- device: us > 0 ? f.slice(0, us) : undefined
3148
- };
3149
- }).sort((a, b) => b.modified - a.modified);
3150
- }
3151
- function readBackup(name) {
3152
- return readFileSync4(join4(backupDir(), safeName(name)), "utf8");
3153
- }
3154
- function writeBackup(name, content) {
3155
- const dir = backupDir();
3156
- mkdirSync(dir, { recursive: true });
3157
- let final = safeName(name);
3158
- if (existsSync2(join4(dir, final))) {
3159
- const dot = final.lastIndexOf(".");
3160
- const stem = dot > 0 ? final.slice(0, dot) : final;
3161
- const ext = dot > 0 ? final.slice(dot) : "";
3162
- let n = 2;
3163
- while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
3164
- n++;
3165
- final = `${stem}_${n}${ext}`;
3166
- }
3167
- writeFileSync(join4(dir, final), content, "utf8");
3168
- return final;
3169
- }
3170
- function deleteBackup(name) {
3171
- const p = join4(backupDir(), safeName(name));
3172
- if (!existsSync2(p))
3173
- return false;
3174
- rmSync(p);
3175
- return true;
3176
- }
3177
- function renameBackup(oldName, newName) {
3178
- const dir = backupDir();
3179
- const from = join4(dir, safeName(oldName));
3180
- let to = safeName(newName);
3181
- if (!to.endsWith(".rsc"))
3182
- to += ".rsc";
3183
- const toPath = join4(dir, to);
3184
- if (!existsSync2(from))
3185
- throw new Error(`backup not found: ${oldName}`);
3186
- if (existsSync2(toPath))
3187
- throw new Error(`a backup named '${to}' already exists`);
3188
- renameSync(from, toPath);
3189
- return to;
3190
- }
3191
- function exportToCommands(text) {
3192
- const lines = [];
3193
- let buf = "";
3194
- for (const raw of text.split(`
3195
- `)) {
3196
- const line = raw.replace(/\r$/, "");
3197
- buf = buf ? `${buf} ${line.trim()}` : line;
3198
- if (buf.trimEnd().endsWith("\\")) {
3199
- buf = buf.trimEnd().slice(0, -1).trimEnd();
3200
- continue;
3201
- }
3202
- lines.push(buf);
3203
- buf = "";
3204
- }
3205
- if (buf)
3206
- lines.push(buf);
3207
- const cmds = [];
3208
- let section = "";
3209
- for (const line of lines) {
3210
- const t = line.trim();
3211
- if (!t || t.startsWith("#"))
3212
- continue;
3213
- if (t.startsWith("/")) {
3214
- section = t;
3215
- continue;
3216
- }
3217
- cmds.push(section ? `${section} ${t}` : t);
3218
- }
3219
- return cmds;
3220
- }
3221
-
3222
3350
  // src/backups/create.ts
3223
3351
  function labelSlug(label) {
3224
3352
  const s = (label ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
@@ -6013,7 +6141,7 @@ var cache = null;
6013
6141
  async function gateway() {
6014
6142
  if (cache)
6015
6143
  return cache;
6016
- const { moduleCatalog } = await import("./cli-ckbcx5hs.js");
6144
+ const { moduleCatalog } = await import("./cli-h0gydx8r.js");
6017
6145
  const forIndex = [];
6018
6146
  const byName = new Map;
6019
6147
  for (const mod of moduleCatalog) {
@@ -4,7 +4,7 @@ import {
4
4
  allToolModules,
5
5
  moduleCatalog,
6
6
  selectToolModules
7
- } from "./cli-3580mf22.js";
7
+ } from "./cli-e484wzek.js";
8
8
  export {
9
9
  selectToolModules,
10
10
  moduleCatalog,
@@ -903,6 +903,29 @@ class MikroTikSSHClient {
903
903
  });
904
904
  });
905
905
  }
906
+ downloadFile(remotePath) {
907
+ if (!this.client) {
908
+ return Promise.reject(new Error("Not connected to MikroTik device"));
909
+ }
910
+ const openSftp = this.client.sftp.bind(this.client);
911
+ return new Promise((resolve2, reject) => {
912
+ openSftp((err, sftp) => {
913
+ if (err) {
914
+ reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
915
+ return;
916
+ }
917
+ sftp.readFile(remotePath, (rerr, data) => {
918
+ try {
919
+ sftp.end();
920
+ } catch {}
921
+ if (rerr)
922
+ reject(new Error(`SFTP read failed: ${rerr.message}`));
923
+ else
924
+ resolve2(data);
925
+ });
926
+ });
927
+ });
928
+ }
906
929
  shell(opts = {}) {
907
930
  if (!this.client) {
908
931
  return Promise.reject(new Error("Not connected to MikroTik device"));
@@ -1252,6 +1275,22 @@ async function uploadFileToDevice(deviceName, remotePath, data) {
1252
1275
  ssh.disconnect();
1253
1276
  }
1254
1277
  }
1278
+ async function downloadFileFromDevice(deviceName, remotePath) {
1279
+ const name = resolveDeviceName(deviceName);
1280
+ const dc = getDevice(deviceName);
1281
+ if (isMacTelnetDevice(dc)) {
1282
+ throw new Error(`Cannot download a file from '${name}': it is reached over Layer-2 MAC-Telnet, which has no ` + "file transfer. Configure SSH (host + credentials) for this device, or use a text export " + "(stdout mode) instead of a binary backup.");
1283
+ }
1284
+ const ssh = new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
1285
+ if (!await ssh.connect()) {
1286
+ throw new Error(connectErrorMessage(name, dc, ssh.lastError));
1287
+ }
1288
+ try {
1289
+ return await ssh.downloadFile(remotePath);
1290
+ } finally {
1291
+ ssh.disconnect();
1292
+ }
1293
+ }
1255
1294
 
1256
1295
  // src/core/registry.ts
1257
1296
  import { z as z2 } from "zod";
@@ -1465,6 +1504,67 @@ function parseCertExpiry(detail, nowMs) {
1465
1504
  }
1466
1505
  return out;
1467
1506
  }
1507
+ function parseSize(s) {
1508
+ if (!s)
1509
+ return;
1510
+ const m = s.trim().match(/^([\d.]+)\s*([KMGT]i?B|B)?$/i);
1511
+ if (!m)
1512
+ return;
1513
+ const val = Number.parseFloat(m[1]);
1514
+ if (!Number.isFinite(val))
1515
+ return;
1516
+ const mult = {
1517
+ B: 1,
1518
+ KIB: 1024,
1519
+ MIB: 1024 ** 2,
1520
+ GIB: 1024 ** 3,
1521
+ TIB: 1024 ** 4,
1522
+ KB: 1000,
1523
+ MB: 1e6,
1524
+ GB: 1e9,
1525
+ TB: 1000000000000
1526
+ };
1527
+ return val * (mult[(m[2] ?? "B").toUpperCase()] ?? 1);
1528
+ }
1529
+ function parsePercent(s) {
1530
+ if (!s)
1531
+ return;
1532
+ const m = s.trim().match(/^([\d.]+)\s*%?$/);
1533
+ if (!m)
1534
+ return;
1535
+ const v = Number.parseFloat(m[1]);
1536
+ return Number.isFinite(v) ? v : undefined;
1537
+ }
1538
+ function usedPct(total, free) {
1539
+ if (total == null || free == null || total <= 0)
1540
+ return;
1541
+ return Math.max(0, Math.min(100, (total - free) / total * 100));
1542
+ }
1543
+ function parseSystemResource(text) {
1544
+ const r = parseKeyValues(text);
1545
+ const totalMemory = parseSize(r["total-memory"]);
1546
+ const freeMemory = parseSize(r["free-memory"]);
1547
+ const totalHdd = parseSize(r["total-hdd-space"]);
1548
+ const freeHdd = parseSize(r["free-hdd-space"]);
1549
+ const cpuLoad = parsePercent(r["cpu-load"]);
1550
+ const cpuCount = Number.parseInt(r["cpu-count"] ?? "", 10);
1551
+ const out = {
1552
+ version: r.version || undefined,
1553
+ boardName: r["board-name"] || undefined,
1554
+ architecture: r["architecture-name"] || undefined,
1555
+ cpuCount: Number.isFinite(cpuCount) ? cpuCount : undefined,
1556
+ cpuLoad,
1557
+ freeMemory,
1558
+ totalMemory,
1559
+ memUsedPct: usedPct(totalMemory, freeMemory),
1560
+ freeHdd,
1561
+ totalHdd,
1562
+ hddUsedPct: usedPct(totalHdd, freeHdd),
1563
+ uptime: r.uptime || undefined
1564
+ };
1565
+ const gotMetric = out.cpuLoad != null || out.totalMemory != null || out.totalHdd != null || out.version != null || out.uptime != null;
1566
+ return gotMetric ? out : null;
1567
+ }
1468
1568
  function parseFlagLegend(text) {
1469
1569
  const out = {};
1470
1570
  const line = text.split(`
@@ -2674,6 +2774,158 @@ ${rows.slice(0, 40).map((r) => ` \u2022 ${r.name ?? "?"} (${r.type ?? "?"})${r.
2674
2774
  // src/tools/backup.ts
2675
2775
  import { z as z5 } from "zod";
2676
2776
 
2777
+ // src/backups/disk-check.ts
2778
+ var DISK_THRESHOLD_PCT = 90;
2779
+ async function checkDiskSpace(ctx) {
2780
+ try {
2781
+ const raw = await executeMikrotikCommand("/system resource print", ctx);
2782
+ const sys = parseSystemResource(raw);
2783
+ if (!sys || sys.hddUsedPct == null) {
2784
+ return { low: false };
2785
+ }
2786
+ return {
2787
+ low: sys.hddUsedPct >= DISK_THRESHOLD_PCT,
2788
+ usedPct: sys.hddUsedPct,
2789
+ freeBytes: sys.freeHdd,
2790
+ totalBytes: sys.totalHdd
2791
+ };
2792
+ } catch {
2793
+ return { low: false };
2794
+ }
2795
+ }
2796
+
2797
+ // src/backups/vault.ts
2798
+ import {
2799
+ existsSync as existsSync2,
2800
+ mkdirSync,
2801
+ readFileSync as readFileSync4,
2802
+ readdirSync,
2803
+ renameSync,
2804
+ rmSync,
2805
+ statSync,
2806
+ writeFileSync
2807
+ } from "fs";
2808
+ import { basename, join as join4 } from "path";
2809
+ function backupDir() {
2810
+ if (process.env.MIKROTIK_BACKUP_DIR)
2811
+ return process.env.MIKROTIK_BACKUP_DIR;
2812
+ try {
2813
+ const dir = getConfig().backupDir;
2814
+ if (dir)
2815
+ return dir;
2816
+ } catch {}
2817
+ return DEFAULT_BACKUP_DIR;
2818
+ }
2819
+ function safeName(name) {
2820
+ const base = basename(name);
2821
+ if (base !== name || base === "." || base === ".." || !/^[A-Za-z0-9._-]+$/.test(base)) {
2822
+ throw new Error(`invalid backup name: ${name}`);
2823
+ }
2824
+ return base;
2825
+ }
2826
+ function listBackups() {
2827
+ const dir = backupDir();
2828
+ if (!existsSync2(dir))
2829
+ return [];
2830
+ return readdirSync(dir).filter((f) => f.endsWith(".rsc") || f.endsWith(".backup")).map((f) => {
2831
+ const st = statSync(join4(dir, f));
2832
+ const us = f.indexOf("_");
2833
+ return {
2834
+ name: f,
2835
+ bytes: st.size,
2836
+ modified: st.mtimeMs,
2837
+ device: us > 0 ? f.slice(0, us) : undefined,
2838
+ type: f.endsWith(".backup") ? "backup" : "rsc"
2839
+ };
2840
+ }).sort((a, b) => b.modified - a.modified);
2841
+ }
2842
+ function readBackup(name) {
2843
+ return readFileSync4(join4(backupDir(), safeName(name)), "utf8");
2844
+ }
2845
+ function writeBackup(name, content) {
2846
+ const dir = backupDir();
2847
+ mkdirSync(dir, { recursive: true });
2848
+ let final = safeName(name);
2849
+ if (existsSync2(join4(dir, final))) {
2850
+ const dot = final.lastIndexOf(".");
2851
+ const stem = dot > 0 ? final.slice(0, dot) : final;
2852
+ const ext = dot > 0 ? final.slice(dot) : "";
2853
+ let n = 2;
2854
+ while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
2855
+ n++;
2856
+ final = `${stem}_${n}${ext}`;
2857
+ }
2858
+ writeFileSync(join4(dir, final), content, "utf8");
2859
+ return final;
2860
+ }
2861
+ function writeBinaryBackup(name, data) {
2862
+ const dir = backupDir();
2863
+ mkdirSync(dir, { recursive: true });
2864
+ let final = safeName(name);
2865
+ if (existsSync2(join4(dir, final))) {
2866
+ const dot = final.lastIndexOf(".");
2867
+ const stem = dot > 0 ? final.slice(0, dot) : final;
2868
+ const ext = dot > 0 ? final.slice(dot) : "";
2869
+ let n = 2;
2870
+ while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
2871
+ n++;
2872
+ final = `${stem}_${n}${ext}`;
2873
+ }
2874
+ writeFileSync(join4(dir, final), data);
2875
+ return final;
2876
+ }
2877
+ function deleteBackup(name) {
2878
+ const p = join4(backupDir(), safeName(name));
2879
+ if (!existsSync2(p))
2880
+ return false;
2881
+ rmSync(p);
2882
+ return true;
2883
+ }
2884
+ function renameBackup(oldName, newName) {
2885
+ const dir = backupDir();
2886
+ const from = join4(dir, safeName(oldName));
2887
+ let to = safeName(newName);
2888
+ if (!to.endsWith(".rsc"))
2889
+ to += ".rsc";
2890
+ const toPath = join4(dir, to);
2891
+ if (!existsSync2(from))
2892
+ throw new Error(`backup not found: ${oldName}`);
2893
+ if (existsSync2(toPath))
2894
+ throw new Error(`a backup named '${to}' already exists`);
2895
+ renameSync(from, toPath);
2896
+ return to;
2897
+ }
2898
+ function exportToCommands(text) {
2899
+ const lines = [];
2900
+ let buf = "";
2901
+ for (const raw of text.split(`
2902
+ `)) {
2903
+ const line = raw.replace(/\r$/, "");
2904
+ buf = buf ? `${buf} ${line.trim()}` : line;
2905
+ if (buf.trimEnd().endsWith("\\")) {
2906
+ buf = buf.trimEnd().slice(0, -1).trimEnd();
2907
+ continue;
2908
+ }
2909
+ lines.push(buf);
2910
+ buf = "";
2911
+ }
2912
+ if (buf)
2913
+ lines.push(buf);
2914
+ const cmds = [];
2915
+ let section = "";
2916
+ for (const line of lines) {
2917
+ const t = line.trim();
2918
+ if (!t || t.startsWith("#"))
2919
+ continue;
2920
+ if (t.startsWith("/")) {
2921
+ section = t;
2922
+ continue;
2923
+ }
2924
+ cmds.push(section ? `${section} ${t}` : t);
2925
+ }
2926
+ return cmds;
2927
+ }
2928
+
2677
2929
  // src/core/datestamp.ts
2678
2930
  var MONTHS2 = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
2679
2931
  function pad(n) {
@@ -2746,6 +2998,12 @@ async function deviceDateStamp(ctx) {
2746
2998
  return hostStamp(new Date);
2747
2999
  }
2748
3000
 
3001
+ // src/core/slug.ts
3002
+ function deviceSlug(name) {
3003
+ const s = (name ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
3004
+ return s || "device";
3005
+ }
3006
+
2749
3007
  // src/tools/backup.ts
2750
3008
  var backupTools = [
2751
3009
  defineTool({
@@ -2762,6 +3020,7 @@ var backupTools = [
2762
3020
  async handler(a, ctx) {
2763
3021
  const name = a.name || `backup_${await deviceDateStamp(ctx)}`;
2764
3022
  ctx.info(`Creating backup: name=${name}`);
3023
+ const disk = await checkDiskSpace(ctx);
2765
3024
  const cmd = new Cmd("/system backup save").set("name", name);
2766
3025
  if (a.dont_encrypt)
2767
3026
  cmd.raw("dont-encrypt=yes");
@@ -2770,13 +3029,35 @@ var backupTools = [
2770
3029
  if (!a.include_password)
2771
3030
  cmd.raw("password-file=no");
2772
3031
  const result = await executeMikrotikCommand(cmd.build(), ctx);
2773
- if (result.includes("saved") || result.trim() === "") {
2774
- const fileDetails = await executeMikrotikCommand(`/file print detail where name=${name}.backup`, ctx);
2775
- return fileDetails ? `Backup created successfully:
3032
+ if (!(result.includes("saved") || result.trim() === "")) {
3033
+ if (disk.low) {
3034
+ return `Failed to create backup (device disk is ${disk.usedPct?.toFixed(0)}% used): ${result}
2776
3035
 
2777
- ${fileDetails}` : `Backup '${name}.backup' created successfully.`;
3036
+ ` + "Tip: use create_local_backup or create_export instead \u2014 text exports can be captured " + "directly to the local vault without using any device disk space.";
3037
+ }
3038
+ return `Failed to create backup: ${result}`;
3039
+ }
3040
+ if (disk.low) {
3041
+ const dc = getDevice(ctx.device);
3042
+ if (isMacTelnetDevice(dc)) {
3043
+ return `Backup '${name}.backup' created on device, but disk is ${disk.usedPct?.toFixed(0)}% used. ` + "This is a MAC-Telnet device (no SFTP), so the file cannot be downloaded automatically. " + "Free disk space manually or use create_local_backup for a text export that bypasses device storage.";
3044
+ }
3045
+ const deviceFile = `${name}.backup`;
3046
+ try {
3047
+ ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 downloading backup to local vault`);
3048
+ const data = await downloadFileFromDevice(ctx.device, deviceFile);
3049
+ const device = resolveDeviceName(ctx.device);
3050
+ const vaultName = writeBinaryBackup(`${deviceSlug(device)}_${deviceFile}`, data);
3051
+ await executeMikrotikCommand(`/file remove ${deviceFile}`, ctx);
3052
+ return `[LOW DISK \u2014 ${disk.usedPct?.toFixed(0)}% used] Backup '${deviceFile}' created, downloaded to ` + `local vault as '${vaultName}' (${data.length} bytes) at ${backupDir()}, and removed from ` + "the device to free disk space.";
3053
+ } catch (e) {
3054
+ return `Backup '${name}.backup' created on device, but the automatic download-and-cleanup ` + `failed: ${e instanceof Error ? e.message : String(e)}. The file is still on the device. ` + "Ensure the SSH user has the 'read' and 'sensitive' policies for SFTP downloads to work.";
3055
+ }
2778
3056
  }
2779
- return `Failed to create backup: ${result}`;
3057
+ const fileDetails = await executeMikrotikCommand(`/file print detail where name=${name}.backup`, ctx);
3058
+ return fileDetails ? `Backup created successfully:
3059
+
3060
+ ${fileDetails}` : `Backup '${name}.backup' created successfully.`;
2780
3061
  }
2781
3062
  }),
2782
3063
  defineTool({
@@ -2825,6 +3106,21 @@ ${result}`;
2825
3106
  async handler(a, ctx) {
2826
3107
  const name = a.name || `export_${await deviceDateStamp(ctx)}`;
2827
3108
  ctx.info(`Creating export: name=${name}, format=${a.file_format}`);
3109
+ const disk = await checkDiskSpace(ctx);
3110
+ if (disk.low) {
3111
+ ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 redirecting export to local vault`);
3112
+ const cmd2 = new Cmd("/export");
3113
+ cmd2.raw(a.verbose ? "verbose" : null);
3114
+ cmd2.raw(a.compact ? "compact" : null);
3115
+ cmd2.raw(!a.hide_sensitive ? "show-sensitive" : null);
3116
+ const body = await executeMikrotikCommand(cmd2.build(), ctx);
3117
+ if (isEmpty(body) || looksLikeError(body)) {
3118
+ return `Failed to create export: ${body}`;
3119
+ }
3120
+ const device = resolveDeviceName(ctx.device);
3121
+ const vaultName = writeBackup(`${deviceSlug(device)}_${name}.rsc`, body);
3122
+ return `[LOW DISK \u2014 ${disk.usedPct?.toFixed(0)}% used] Export saved to LOCAL VAULT as '${vaultName}' ` + `(${Buffer.byteLength(body)} bytes) at ${backupDir()} \u2014 no file was written to the device.`;
3123
+ }
2828
3124
  const extension = a.file_format === "json" || a.file_format === "xml" ? a.file_format : "rsc";
2829
3125
  const fullName = `${name}.${extension}`;
2830
3126
  const cmd = new Cmd("/export");
@@ -2863,6 +3159,20 @@ ${fileDetails}` : `Export '${fullName}' created successfully.`;
2863
3159
  name = `export_${cleanSection}_${await deviceDateStamp(ctx)}`;
2864
3160
  }
2865
3161
  ctx.info(`Exporting section: section=${a.section}, name=${name}`);
3162
+ const disk = await checkDiskSpace(ctx);
3163
+ if (disk.low) {
3164
+ ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 redirecting section export to local vault`);
3165
+ const cmd2 = new Cmd(`/${a.section} export`);
3166
+ cmd2.raw(!a.hide_sensitive ? "show-sensitive" : null);
3167
+ cmd2.raw(a.compact ? "compact" : null);
3168
+ const body = await executeMikrotikCommand(cmd2.build(), ctx);
3169
+ if (isEmpty(body) || looksLikeError(body)) {
3170
+ return `Failed to export section: ${body}`;
3171
+ }
3172
+ const device = resolveDeviceName(ctx.device);
3173
+ const vaultName = writeBackup(`${deviceSlug(device)}_${name}.rsc`, body);
3174
+ return `[LOW DISK \u2014 ${disk.usedPct?.toFixed(0)}% used] Section export saved to LOCAL VAULT as ` + `'${vaultName}' (${Buffer.byteLength(body)} bytes) at ${backupDir()} \u2014 no file was written ` + "to the device.";
3175
+ }
2866
3176
  const cmd = new Cmd(`/${a.section} export`).set("file", name);
2867
3177
  cmd.raw(!a.hide_sensitive ? "show-sensitive" : null);
2868
3178
  cmd.raw(a.compact ? "compact" : null);
@@ -3013,127 +3323,6 @@ ${result}`;
3013
3323
  // src/tools/local-backup.ts
3014
3324
  import { z as z6 } from "zod";
3015
3325
 
3016
- // src/core/slug.ts
3017
- function deviceSlug(name) {
3018
- const s = (name ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
3019
- return s || "device";
3020
- }
3021
-
3022
- // src/backups/vault.ts
3023
- import {
3024
- existsSync as existsSync2,
3025
- mkdirSync,
3026
- readFileSync as readFileSync4,
3027
- readdirSync,
3028
- renameSync,
3029
- rmSync,
3030
- statSync,
3031
- writeFileSync
3032
- } from "fs";
3033
- import { basename, join as join4 } from "path";
3034
- function backupDir() {
3035
- if (process.env.MIKROTIK_BACKUP_DIR)
3036
- return process.env.MIKROTIK_BACKUP_DIR;
3037
- try {
3038
- const dir = getConfig().backupDir;
3039
- if (dir)
3040
- return dir;
3041
- } catch {}
3042
- return DEFAULT_BACKUP_DIR;
3043
- }
3044
- function safeName(name) {
3045
- const base = basename(name);
3046
- if (base !== name || base === "." || base === ".." || !/^[A-Za-z0-9._-]+$/.test(base)) {
3047
- throw new Error(`invalid backup name: ${name}`);
3048
- }
3049
- return base;
3050
- }
3051
- function listBackups() {
3052
- const dir = backupDir();
3053
- if (!existsSync2(dir))
3054
- return [];
3055
- return readdirSync(dir).filter((f) => f.endsWith(".rsc")).map((f) => {
3056
- const st = statSync(join4(dir, f));
3057
- const us = f.indexOf("_");
3058
- return {
3059
- name: f,
3060
- bytes: st.size,
3061
- modified: st.mtimeMs,
3062
- device: us > 0 ? f.slice(0, us) : undefined
3063
- };
3064
- }).sort((a, b) => b.modified - a.modified);
3065
- }
3066
- function readBackup(name) {
3067
- return readFileSync4(join4(backupDir(), safeName(name)), "utf8");
3068
- }
3069
- function writeBackup(name, content) {
3070
- const dir = backupDir();
3071
- mkdirSync(dir, { recursive: true });
3072
- let final = safeName(name);
3073
- if (existsSync2(join4(dir, final))) {
3074
- const dot = final.lastIndexOf(".");
3075
- const stem = dot > 0 ? final.slice(0, dot) : final;
3076
- const ext = dot > 0 ? final.slice(dot) : "";
3077
- let n = 2;
3078
- while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
3079
- n++;
3080
- final = `${stem}_${n}${ext}`;
3081
- }
3082
- writeFileSync(join4(dir, final), content, "utf8");
3083
- return final;
3084
- }
3085
- function deleteBackup(name) {
3086
- const p = join4(backupDir(), safeName(name));
3087
- if (!existsSync2(p))
3088
- return false;
3089
- rmSync(p);
3090
- return true;
3091
- }
3092
- function renameBackup(oldName, newName) {
3093
- const dir = backupDir();
3094
- const from = join4(dir, safeName(oldName));
3095
- let to = safeName(newName);
3096
- if (!to.endsWith(".rsc"))
3097
- to += ".rsc";
3098
- const toPath = join4(dir, to);
3099
- if (!existsSync2(from))
3100
- throw new Error(`backup not found: ${oldName}`);
3101
- if (existsSync2(toPath))
3102
- throw new Error(`a backup named '${to}' already exists`);
3103
- renameSync(from, toPath);
3104
- return to;
3105
- }
3106
- function exportToCommands(text) {
3107
- const lines = [];
3108
- let buf = "";
3109
- for (const raw of text.split(`
3110
- `)) {
3111
- const line = raw.replace(/\r$/, "");
3112
- buf = buf ? `${buf} ${line.trim()}` : line;
3113
- if (buf.trimEnd().endsWith("\\")) {
3114
- buf = buf.trimEnd().slice(0, -1).trimEnd();
3115
- continue;
3116
- }
3117
- lines.push(buf);
3118
- buf = "";
3119
- }
3120
- if (buf)
3121
- lines.push(buf);
3122
- const cmds = [];
3123
- let section = "";
3124
- for (const line of lines) {
3125
- const t = line.trim();
3126
- if (!t || t.startsWith("#"))
3127
- continue;
3128
- if (t.startsWith("/")) {
3129
- section = t;
3130
- continue;
3131
- }
3132
- cmds.push(section ? `${section} ${t}` : t);
3133
- }
3134
- return cmds;
3135
- }
3136
-
3137
3326
  // src/backups/create.ts
3138
3327
  function labelSlug(label) {
3139
3328
  const s = (label ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
@@ -5928,7 +6117,7 @@ var cache = null;
5928
6117
  async function gateway() {
5929
6118
  if (cache)
5930
6119
  return cache;
5931
- const { moduleCatalog } = await import("./library-jrp4dawm.js");
6120
+ const { moduleCatalog } = await import("./library-q0q3kf6m.js");
5932
6121
  const forIndex = [];
5933
6122
  const byName = new Map;
5934
6123
  for (const mod of moduleCatalog) {
@@ -4,7 +4,7 @@ import {
4
4
  allToolModules,
5
5
  moduleCatalog,
6
6
  selectToolModules
7
- } from "./library-jm3khjrr.js";
7
+ } from "./library-nxh3rb8t.js";
8
8
  export {
9
9
  selectToolModules,
10
10
  moduleCatalog,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usex/mikrotik-mcp",
3
- "version": "3.36.0",
3
+ "version": "3.38.0",
4
4
  "description": "MCP server for MikroTik RouterOS — 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
5
5
  "keywords": [
6
6
  "ai",