@mgsoftwarebv/mg-dashboard-mcp 3.10.1 → 3.11.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/index.d.ts +20 -1
- package/dist/index.js +1221 -187
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { createClient } from '@supabase/supabase-js';
|
|
|
14
14
|
import { readFile, mkdtemp, writeFile, rm } from 'fs/promises';
|
|
15
15
|
import { tmpdir } from 'os';
|
|
16
16
|
import { Client } from 'ssh2';
|
|
17
|
-
import { ListObjectsV2Command, DeleteObjectsCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectCommand,
|
|
17
|
+
import { HeadObjectCommand, S3Client, ListObjectsV2Command, DeleteObjectsCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectCommand, GetObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
|
|
18
18
|
|
|
19
19
|
var __defProp = Object.defineProperty;
|
|
20
20
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
@@ -1373,6 +1373,7 @@ var TOOL_MODULE_MAP = {
|
|
|
1373
1373
|
"docker-logs": "ssh_servers",
|
|
1374
1374
|
"docker-exec": "ssh_servers",
|
|
1375
1375
|
"docker-compose": "ssh_servers",
|
|
1376
|
+
"wait-for": "ssh_servers",
|
|
1376
1377
|
"db-discover": "ssh_servers",
|
|
1377
1378
|
"db-tables": "ssh_servers",
|
|
1378
1379
|
"db-describe": "ssh_servers",
|
|
@@ -1861,7 +1862,12 @@ async function getProxyConnection() {
|
|
|
1861
1862
|
async function getServerConnection(serverId) {
|
|
1862
1863
|
assertServerAccess(serverId);
|
|
1863
1864
|
const { data, error } = await supabase.from("ssh_server").select("hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted, allowed_ssh_ips, os_type").eq("id", serverId).single();
|
|
1864
|
-
if (error || !data)
|
|
1865
|
+
if (error || !data) {
|
|
1866
|
+
const candidates = Array.from(KNOWN_SERVER_NAMES.values()).flatMap((s) => [s.name, s.id]);
|
|
1867
|
+
const hits = suggestSimilar(serverId, candidates);
|
|
1868
|
+
const hint = hits.length ? ` Did you mean: ${hits.join(", ")}?` : "";
|
|
1869
|
+
throw new Error(`Server not found: ${serverId}.${hint}`);
|
|
1870
|
+
}
|
|
1865
1871
|
if (!encryptionKey) throw new Error("ENCRYPTION_KEY required to decrypt server credentials");
|
|
1866
1872
|
const conn = {
|
|
1867
1873
|
hostname: data.hostname,
|
|
@@ -1877,42 +1883,73 @@ async function getServerConnection(serverId) {
|
|
|
1877
1883
|
return { conn, proxy, os };
|
|
1878
1884
|
}
|
|
1879
1885
|
async function sshExec(opts, command, proxy, options) {
|
|
1886
|
+
const first = await sshExecOnce(opts, command, proxy, options);
|
|
1887
|
+
if (options?.noRetry) return first;
|
|
1888
|
+
if (!isTransientSshError(first.stderr, first.exitCode)) return first;
|
|
1889
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
1890
|
+
const second = await sshExecOnce(opts, command, proxy, options);
|
|
1891
|
+
if (second.exitCode === -1 && second.stderr) {
|
|
1892
|
+
second.stderr = `[retry-1 also failed] ${second.stderr}`;
|
|
1893
|
+
}
|
|
1894
|
+
return second;
|
|
1895
|
+
}
|
|
1896
|
+
async function sshExecOnce(opts, command, proxy, options) {
|
|
1880
1897
|
if (proxy) return sshExecViaProxy(proxy, opts, command, options);
|
|
1881
1898
|
return new Promise((resolve) => {
|
|
1882
1899
|
const ssh = new Client();
|
|
1883
1900
|
let stdout = "";
|
|
1884
1901
|
let stderr = "";
|
|
1885
1902
|
let done = false;
|
|
1886
|
-
const
|
|
1887
|
-
const
|
|
1903
|
+
const wallTimeout = opts.timeout || 6e4;
|
|
1904
|
+
const idleTimeout = options?.idleTimeoutMs;
|
|
1905
|
+
const wallTimer = setTimeout(() => {
|
|
1888
1906
|
if (!done) {
|
|
1889
1907
|
done = true;
|
|
1908
|
+
clearTimeout(idleTimer);
|
|
1890
1909
|
ssh.end();
|
|
1891
|
-
resolve({ stdout, stderr
|
|
1910
|
+
resolve({ stdout, stderr: stderr || `idle/wall timeout after ${wallTimeout}ms`, exitCode: -1 });
|
|
1892
1911
|
}
|
|
1893
|
-
},
|
|
1912
|
+
}, wallTimeout);
|
|
1913
|
+
let idleTimer;
|
|
1914
|
+
const armIdle = () => {
|
|
1915
|
+
if (!idleTimeout) return;
|
|
1916
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1917
|
+
idleTimer = setTimeout(() => {
|
|
1918
|
+
if (!done) {
|
|
1919
|
+
done = true;
|
|
1920
|
+
clearTimeout(wallTimer);
|
|
1921
|
+
ssh.end();
|
|
1922
|
+
resolve({ stdout, stderr: stderr || `idle timeout after ${idleTimeout}ms with no output`, exitCode: -1 });
|
|
1923
|
+
}
|
|
1924
|
+
}, idleTimeout);
|
|
1925
|
+
};
|
|
1926
|
+
armIdle();
|
|
1894
1927
|
ssh.on("ready", () => {
|
|
1895
1928
|
const execOpts = options?.pty ? { pty: true } : {};
|
|
1896
1929
|
ssh.exec(command, execOpts, (err, stream) => {
|
|
1897
1930
|
if (err) {
|
|
1898
1931
|
if (!done) {
|
|
1899
1932
|
done = true;
|
|
1900
|
-
clearTimeout(
|
|
1933
|
+
clearTimeout(wallTimer);
|
|
1934
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1901
1935
|
ssh.end();
|
|
1902
|
-
resolve({ stdout, stderr, exitCode: -1 });
|
|
1936
|
+
resolve({ stdout, stderr: err.message || stderr, exitCode: -1 });
|
|
1903
1937
|
}
|
|
1904
1938
|
return;
|
|
1905
1939
|
}
|
|
1906
1940
|
stream.on("data", (d) => {
|
|
1907
1941
|
stdout += d.toString();
|
|
1942
|
+
armIdle();
|
|
1908
1943
|
});
|
|
1909
1944
|
stream.stderr.on("data", (d) => {
|
|
1910
1945
|
stderr += d.toString();
|
|
1946
|
+
armIdle();
|
|
1911
1947
|
});
|
|
1912
1948
|
stream.on("close", (code) => {
|
|
1913
1949
|
if (!done) {
|
|
1914
1950
|
done = true;
|
|
1915
|
-
clearTimeout(
|
|
1951
|
+
clearTimeout(wallTimer);
|
|
1952
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1916
1953
|
ssh.end();
|
|
1917
1954
|
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
1918
1955
|
}
|
|
@@ -1925,7 +1962,8 @@ async function sshExec(opts, command, proxy, options) {
|
|
|
1925
1962
|
ssh.on("error", (err) => {
|
|
1926
1963
|
if (!done) {
|
|
1927
1964
|
done = true;
|
|
1928
|
-
clearTimeout(
|
|
1965
|
+
clearTimeout(wallTimer);
|
|
1966
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1929
1967
|
resolve({ stdout, stderr: err.message, exitCode: -1 });
|
|
1930
1968
|
}
|
|
1931
1969
|
});
|
|
@@ -1936,7 +1974,7 @@ async function sshExec(opts, command, proxy, options) {
|
|
|
1936
1974
|
password: opts.password,
|
|
1937
1975
|
privateKey: opts.privateKey,
|
|
1938
1976
|
passphrase: opts.passphrase,
|
|
1939
|
-
readyTimeout:
|
|
1977
|
+
readyTimeout: wallTimeout
|
|
1940
1978
|
});
|
|
1941
1979
|
});
|
|
1942
1980
|
}
|
|
@@ -1944,16 +1982,35 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
|
1944
1982
|
return new Promise((resolve) => {
|
|
1945
1983
|
const proxyClient = new Client();
|
|
1946
1984
|
let done = false;
|
|
1947
|
-
const
|
|
1948
|
-
const
|
|
1985
|
+
const wallTimeout = targetOpts.timeout || 6e4;
|
|
1986
|
+
const idleTimeout = options?.idleTimeoutMs;
|
|
1987
|
+
const wallTimer = setTimeout(() => {
|
|
1949
1988
|
if (!done) {
|
|
1950
1989
|
done = true;
|
|
1990
|
+
clearTimeout(idleTimer);
|
|
1951
1991
|
proxyClient.end();
|
|
1952
1992
|
resolve({ stdout: "", stderr: "SSH proxy command timeout", exitCode: -1 });
|
|
1953
1993
|
}
|
|
1954
|
-
},
|
|
1994
|
+
}, wallTimeout);
|
|
1995
|
+
let idleTimer;
|
|
1996
|
+
let stdoutBuf = "";
|
|
1997
|
+
let stderrBuf = "";
|
|
1998
|
+
const armIdle = () => {
|
|
1999
|
+
if (!idleTimeout) return;
|
|
2000
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2001
|
+
idleTimer = setTimeout(() => {
|
|
2002
|
+
if (!done) {
|
|
2003
|
+
done = true;
|
|
2004
|
+
clearTimeout(wallTimer);
|
|
2005
|
+
proxyClient.end();
|
|
2006
|
+
resolve({ stdout: stdoutBuf, stderr: stderrBuf || `idle timeout after ${idleTimeout}ms with no output`, exitCode: -1 });
|
|
2007
|
+
}
|
|
2008
|
+
}, idleTimeout);
|
|
2009
|
+
};
|
|
2010
|
+
armIdle();
|
|
1955
2011
|
const cleanup = () => {
|
|
1956
|
-
clearTimeout(
|
|
2012
|
+
clearTimeout(wallTimer);
|
|
2013
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1957
2014
|
proxyClient.end();
|
|
1958
2015
|
};
|
|
1959
2016
|
proxyClient.on("ready", () => {
|
|
@@ -1967,8 +2024,6 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
|
1967
2024
|
return;
|
|
1968
2025
|
}
|
|
1969
2026
|
const targetClient = new Client();
|
|
1970
|
-
let stdout = "";
|
|
1971
|
-
let stderr = "";
|
|
1972
2027
|
targetClient.on("ready", () => {
|
|
1973
2028
|
const execOpts = options?.pty ? { pty: true } : {};
|
|
1974
2029
|
targetClient.exec(command, execOpts, (execErr, stream) => {
|
|
@@ -1977,22 +2032,24 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
|
1977
2032
|
done = true;
|
|
1978
2033
|
targetClient.end();
|
|
1979
2034
|
cleanup();
|
|
1980
|
-
resolve({ stdout, stderr, exitCode: -1 });
|
|
2035
|
+
resolve({ stdout: stdoutBuf, stderr: stderrBuf, exitCode: -1 });
|
|
1981
2036
|
}
|
|
1982
2037
|
return;
|
|
1983
2038
|
}
|
|
1984
2039
|
stream.on("data", (d) => {
|
|
1985
|
-
|
|
2040
|
+
stdoutBuf += d.toString();
|
|
2041
|
+
armIdle();
|
|
1986
2042
|
});
|
|
1987
2043
|
stream.stderr.on("data", (d) => {
|
|
1988
|
-
|
|
2044
|
+
stderrBuf += d.toString();
|
|
2045
|
+
armIdle();
|
|
1989
2046
|
});
|
|
1990
2047
|
stream.on("close", (code) => {
|
|
1991
2048
|
if (!done) {
|
|
1992
2049
|
done = true;
|
|
1993
2050
|
targetClient.end();
|
|
1994
2051
|
cleanup();
|
|
1995
|
-
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
2052
|
+
resolve({ stdout: stdoutBuf, stderr: stderrBuf, exitCode: code ?? 0 });
|
|
1996
2053
|
}
|
|
1997
2054
|
});
|
|
1998
2055
|
if (options?.stdin !== void 0) {
|
|
@@ -2005,7 +2062,7 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
|
2005
2062
|
done = true;
|
|
2006
2063
|
targetClient.end();
|
|
2007
2064
|
cleanup();
|
|
2008
|
-
resolve({ stdout, stderr: targetErr.message, exitCode: -1 });
|
|
2065
|
+
resolve({ stdout: stdoutBuf, stderr: targetErr.message, exitCode: -1 });
|
|
2009
2066
|
}
|
|
2010
2067
|
});
|
|
2011
2068
|
targetClient.connect({
|
|
@@ -2014,7 +2071,7 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
|
2014
2071
|
password: targetOpts.password,
|
|
2015
2072
|
privateKey: targetOpts.privateKey,
|
|
2016
2073
|
passphrase: targetOpts.passphrase,
|
|
2017
|
-
readyTimeout:
|
|
2074
|
+
readyTimeout: wallTimeout
|
|
2018
2075
|
});
|
|
2019
2076
|
});
|
|
2020
2077
|
});
|
|
@@ -2149,52 +2206,89 @@ async function r2List(bucket, prefix, options) {
|
|
|
2149
2206
|
} while (continuationToken && entries.length < options.maxResults);
|
|
2150
2207
|
return entries;
|
|
2151
2208
|
}
|
|
2209
|
+
function r2WrapError(bucket, key, e) {
|
|
2210
|
+
const err = e;
|
|
2211
|
+
const status = err?.$metadata?.httpStatusCode;
|
|
2212
|
+
const name = err?.name || "";
|
|
2213
|
+
if (name === "NoSuchKey" || name === "NotFound" || status === 404) {
|
|
2214
|
+
return new Error(`r2://${bucket}/${key} not found`);
|
|
2215
|
+
}
|
|
2216
|
+
if (name === "NoSuchBucket") return new Error(`R2 bucket "${bucket}" not found`);
|
|
2217
|
+
if (status === 403 || name === "AccessDenied") return new Error(`Access denied for r2://${bucket}/${key} (check R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY)`);
|
|
2218
|
+
return new Error(`R2 error (${name || "unknown"}${status ? ` ${status}` : ""}): ${err?.message || String(e)}`);
|
|
2219
|
+
}
|
|
2152
2220
|
async function r2GetObject(bucket, key, maxBytes) {
|
|
2153
2221
|
const client = getR2Client();
|
|
2154
|
-
|
|
2155
|
-
|
|
2222
|
+
let size = 0;
|
|
2223
|
+
try {
|
|
2224
|
+
const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
2225
|
+
size = head.ContentLength || 0;
|
|
2226
|
+
} catch (e) {
|
|
2227
|
+
throw r2WrapError(bucket, key, e);
|
|
2228
|
+
}
|
|
2156
2229
|
if (size > maxBytes) {
|
|
2157
2230
|
throw new Error(`Object too large: ${size} bytes (max ${maxBytes} for inline read; use sftp-write with sourcePath to mirror locally instead, or pass { offset, length } for a ranged read)`);
|
|
2158
2231
|
}
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2232
|
+
try {
|
|
2233
|
+
const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
2234
|
+
const body = result.Body;
|
|
2235
|
+
if (!body?.transformToString) throw new Error("R2 returned no readable body");
|
|
2236
|
+
return await body.transformToString();
|
|
2237
|
+
} catch (e) {
|
|
2238
|
+
throw r2WrapError(bucket, key, e);
|
|
2239
|
+
}
|
|
2163
2240
|
}
|
|
2164
2241
|
async function r2GetObjectRange(bucket, key, range) {
|
|
2165
2242
|
const client = getR2Client();
|
|
2166
|
-
|
|
2167
|
-
|
|
2243
|
+
let size = 0;
|
|
2244
|
+
try {
|
|
2245
|
+
const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
2246
|
+
size = head.ContentLength || 0;
|
|
2247
|
+
} catch (e) {
|
|
2248
|
+
throw r2WrapError(bucket, key, e);
|
|
2249
|
+
}
|
|
2168
2250
|
if (range.offset >= size && size > 0) throw new Error(`offset ${range.offset} is past end of object (size ${size})`);
|
|
2169
2251
|
const MAX = 1048576;
|
|
2170
2252
|
const remaining = Math.max(0, size - range.offset);
|
|
2171
2253
|
const effectiveLen = range.length !== void 0 ? Math.min(range.length, remaining, MAX) : Math.min(remaining, MAX);
|
|
2172
2254
|
const end = range.offset + effectiveLen - 1;
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2255
|
+
try {
|
|
2256
|
+
const result = await client.send(new GetObjectCommand({
|
|
2257
|
+
Bucket: bucket,
|
|
2258
|
+
Key: key,
|
|
2259
|
+
Range: `bytes=${range.offset}-${end}`
|
|
2260
|
+
}));
|
|
2261
|
+
const body = result.Body;
|
|
2262
|
+
if (!body?.transformToString) throw new Error("R2 returned no readable body");
|
|
2263
|
+
const text = await body.transformToString();
|
|
2264
|
+
const header = `# range: bytes ${range.offset}-${end} of ${size} (${effectiveLen} bytes)`;
|
|
2265
|
+
return `${header}
|
|
2183
2266
|
${text}`;
|
|
2267
|
+
} catch (e) {
|
|
2268
|
+
throw r2WrapError(bucket, key, e);
|
|
2269
|
+
}
|
|
2184
2270
|
}
|
|
2185
2271
|
async function r2PutObject(bucket, key, body, contentLength) {
|
|
2186
2272
|
const client = getR2Client();
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2273
|
+
try {
|
|
2274
|
+
await client.send(new PutObjectCommand({
|
|
2275
|
+
Bucket: bucket,
|
|
2276
|
+
Key: key,
|
|
2277
|
+
Body: body,
|
|
2278
|
+
ContentLength: contentLength,
|
|
2279
|
+
ContentType: "application/octet-stream"
|
|
2280
|
+
}));
|
|
2281
|
+
} catch (e) {
|
|
2282
|
+
throw r2WrapError(bucket, key, e);
|
|
2283
|
+
}
|
|
2194
2284
|
}
|
|
2195
2285
|
async function r2DeleteObject(bucket, key) {
|
|
2196
2286
|
const client = getR2Client();
|
|
2197
|
-
|
|
2287
|
+
try {
|
|
2288
|
+
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
|
2289
|
+
} catch (e) {
|
|
2290
|
+
throw r2WrapError(bucket, key, e);
|
|
2291
|
+
}
|
|
2198
2292
|
}
|
|
2199
2293
|
async function r2DeletePrefix(bucket, prefix) {
|
|
2200
2294
|
const client = getR2Client();
|
|
@@ -2270,6 +2364,44 @@ async function r2PutObjectMultipart(bucket, key, localPath, totalBytes, onProgre
|
|
|
2270
2364
|
throw e;
|
|
2271
2365
|
}
|
|
2272
2366
|
}
|
|
2367
|
+
async function r2CopyObject(srcBucket, srcKey, dstBucket, dstKey) {
|
|
2368
|
+
const client = getR2Client();
|
|
2369
|
+
let size = 0;
|
|
2370
|
+
try {
|
|
2371
|
+
const head = await client.send(new HeadObjectCommand({ Bucket: srcBucket, Key: srcKey }));
|
|
2372
|
+
size = head.ContentLength || 0;
|
|
2373
|
+
} catch (e) {
|
|
2374
|
+
throw r2WrapError(srcBucket, srcKey, e);
|
|
2375
|
+
}
|
|
2376
|
+
try {
|
|
2377
|
+
await client.send(new CopyObjectCommand({
|
|
2378
|
+
CopySource: encodeURIComponent(`${srcBucket}/${srcKey}`).replace(/%2F/g, "/"),
|
|
2379
|
+
Bucket: dstBucket,
|
|
2380
|
+
Key: dstKey
|
|
2381
|
+
}));
|
|
2382
|
+
} catch (e) {
|
|
2383
|
+
throw r2WrapError(dstBucket, dstKey, e);
|
|
2384
|
+
}
|
|
2385
|
+
return { size };
|
|
2386
|
+
}
|
|
2387
|
+
async function r2GetObjectStream(bucket, key) {
|
|
2388
|
+
const client = getR2Client();
|
|
2389
|
+
let size = 0;
|
|
2390
|
+
try {
|
|
2391
|
+
const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
2392
|
+
size = head.ContentLength || 0;
|
|
2393
|
+
} catch (e) {
|
|
2394
|
+
throw r2WrapError(bucket, key, e);
|
|
2395
|
+
}
|
|
2396
|
+
try {
|
|
2397
|
+
const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
2398
|
+
const body = result.Body;
|
|
2399
|
+
if (!body) throw new Error("R2 returned no readable body");
|
|
2400
|
+
return { stream: body, size };
|
|
2401
|
+
} catch (e) {
|
|
2402
|
+
throw r2WrapError(bucket, key, e);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2273
2405
|
function sanitizePath(path) {
|
|
2274
2406
|
let normalized = path.replace(/\\/g, "/").replace(/\0/g, "");
|
|
2275
2407
|
const parts = normalized.split("/");
|
|
@@ -2464,6 +2596,368 @@ function formatBytes(bytes) {
|
|
|
2464
2596
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
2465
2597
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
2466
2598
|
}
|
|
2599
|
+
var RESPONSE_MAX_BYTES = 8192;
|
|
2600
|
+
var NO_FOOTER_TOOLS = /* @__PURE__ */ new Set();
|
|
2601
|
+
var TOOL_CACHE_TTL_MS = {
|
|
2602
|
+
"list-servers": 6e4,
|
|
2603
|
+
"docker-list": 3e4
|
|
2604
|
+
};
|
|
2605
|
+
var TOOL_CACHE = /* @__PURE__ */ new Map();
|
|
2606
|
+
function cacheKeyFor(name, args2) {
|
|
2607
|
+
const { noCache: _ignored, ...rest } = args2;
|
|
2608
|
+
const sorted = Object.fromEntries(Object.entries(rest).sort(([a], [b]) => a.localeCompare(b)));
|
|
2609
|
+
return `${name}::${JSON.stringify(sorted)}`;
|
|
2610
|
+
}
|
|
2611
|
+
function getCachedTool(name, args2) {
|
|
2612
|
+
const ttl = TOOL_CACHE_TTL_MS[name];
|
|
2613
|
+
if (!ttl || ttl <= 0) return void 0;
|
|
2614
|
+
if (args2.noCache === true) return void 0;
|
|
2615
|
+
const k = cacheKeyFor(name, args2);
|
|
2616
|
+
const e = TOOL_CACHE.get(k);
|
|
2617
|
+
if (!e) return void 0;
|
|
2618
|
+
if (e.expiresAt < Date.now()) {
|
|
2619
|
+
TOOL_CACHE.delete(k);
|
|
2620
|
+
return void 0;
|
|
2621
|
+
}
|
|
2622
|
+
return e.value;
|
|
2623
|
+
}
|
|
2624
|
+
function setCachedTool(name, args2, value) {
|
|
2625
|
+
const ttl = TOOL_CACHE_TTL_MS[name];
|
|
2626
|
+
if (!ttl || ttl <= 0) return;
|
|
2627
|
+
if (args2.noCache === true) return;
|
|
2628
|
+
TOOL_CACHE.set(cacheKeyFor(name, args2), { value, expiresAt: Date.now() + ttl });
|
|
2629
|
+
}
|
|
2630
|
+
function levenshtein(a, b) {
|
|
2631
|
+
if (a === b) return 0;
|
|
2632
|
+
const m = a.length;
|
|
2633
|
+
const n = b.length;
|
|
2634
|
+
if (m === 0) return n;
|
|
2635
|
+
if (n === 0) return m;
|
|
2636
|
+
const dp = new Array(n + 1);
|
|
2637
|
+
for (let j = 0; j <= n; j++) dp[j] = j;
|
|
2638
|
+
for (let i = 1; i <= m; i++) {
|
|
2639
|
+
let prev = dp[0];
|
|
2640
|
+
dp[0] = i;
|
|
2641
|
+
for (let j = 1; j <= n; j++) {
|
|
2642
|
+
const tmp = dp[j];
|
|
2643
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
2644
|
+
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
|
|
2645
|
+
prev = tmp;
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
return dp[n];
|
|
2649
|
+
}
|
|
2650
|
+
function suggestSimilar(input, candidates, max = 3) {
|
|
2651
|
+
if (!input || !candidates.length) return [];
|
|
2652
|
+
const lo = input.toLowerCase();
|
|
2653
|
+
const threshold = Math.max(2, Math.ceil(input.length / 3));
|
|
2654
|
+
return candidates.map((c) => ({ c, d: levenshtein(lo, c.toLowerCase()) })).filter((x) => x.d <= threshold).sort((a, b) => a.d - b.d).slice(0, max).map((x) => x.c);
|
|
2655
|
+
}
|
|
2656
|
+
var KNOWN_SERVER_NAMES = /* @__PURE__ */ new Map();
|
|
2657
|
+
var KNOWN_CONTAINERS_BY_SERVER = /* @__PURE__ */ new Map();
|
|
2658
|
+
function rememberServers(servers) {
|
|
2659
|
+
for (const s of servers) KNOWN_SERVER_NAMES.set(s.id, s);
|
|
2660
|
+
}
|
|
2661
|
+
function rememberContainers(serverId, names) {
|
|
2662
|
+
KNOWN_CONTAINERS_BY_SERVER.set(serverId, { names, capturedAt: Date.now() });
|
|
2663
|
+
}
|
|
2664
|
+
function buildServerContextHints(servers) {
|
|
2665
|
+
const hints = [];
|
|
2666
|
+
for (const s of servers) {
|
|
2667
|
+
const tagSet = new Set((s.tags || []).map((t) => String(t).toLowerCase()));
|
|
2668
|
+
const name = (s.name || "").toLowerCase();
|
|
2669
|
+
const host = (s.hostname || "").toLowerCase();
|
|
2670
|
+
const found = [];
|
|
2671
|
+
if (tagSet.has("trigger") || /trigger/.test(name) || /trigger/.test(host)) found.push("Trigger.dev self-hosted");
|
|
2672
|
+
if (tagSet.has("supabase") || /supabase/.test(name) || /supabase/.test(host)) found.push("Supabase stack");
|
|
2673
|
+
if (tagSet.has("proxy") || /proxy|jump/.test(name)) found.push("SSH ProxyJump host");
|
|
2674
|
+
if (tagSet.has("giga") || /giga/.test(name)) found.push("shared workloads");
|
|
2675
|
+
if (tagSet.has("vca") || /vca/.test(name)) found.push("VCA hosting");
|
|
2676
|
+
if (tagSet.has("mg-boilers") || /boiler/.test(name)) found.push("boiler API");
|
|
2677
|
+
if (tagSet.has("refront") || /refront|tickets/.test(name) || /refront|tickets/.test(host)) found.push("Refront/tickets");
|
|
2678
|
+
if (found.length) hints.push(`- ${s.name}: ${found.join(", ")}`);
|
|
2679
|
+
}
|
|
2680
|
+
return hints;
|
|
2681
|
+
}
|
|
2682
|
+
function getKnownContainers(serverId, maxAgeMs = 5 * 6e4) {
|
|
2683
|
+
const e = KNOWN_CONTAINERS_BY_SERVER.get(serverId);
|
|
2684
|
+
if (!e) return void 0;
|
|
2685
|
+
if (Date.now() - e.capturedAt > maxAgeMs) return void 0;
|
|
2686
|
+
return e.names;
|
|
2687
|
+
}
|
|
2688
|
+
function truncateForLLM(text, maxBytes) {
|
|
2689
|
+
const totalBytes = Buffer.byteLength(text, "utf8");
|
|
2690
|
+
if (totalBytes <= maxBytes) return { text, truncated: false, totalBytes, shownBytes: totalBytes };
|
|
2691
|
+
const buf = Buffer.from(text, "utf8");
|
|
2692
|
+
let cut = maxBytes;
|
|
2693
|
+
while (cut > 0 && (buf[cut] & 192) === 128) cut--;
|
|
2694
|
+
const head = buf.subarray(0, cut).toString("utf8");
|
|
2695
|
+
return { text: head, truncated: true, totalBytes, shownBytes: cut };
|
|
2696
|
+
}
|
|
2697
|
+
function buildTruncationHint(name, args2, total, shown) {
|
|
2698
|
+
const base = `(showing first ${shown} of ${total} bytes;`;
|
|
2699
|
+
if (name === "sftp-read") {
|
|
2700
|
+
const off = Number(args2.offset) || 0;
|
|
2701
|
+
const next = off + shown;
|
|
2702
|
+
return `${base} pass { offset: ${next}, length: ${RESPONSE_MAX_BYTES} } to read more)`;
|
|
2703
|
+
}
|
|
2704
|
+
if (name === "docker-logs") {
|
|
2705
|
+
return `${base} use \`tail\`, \`since\`, or \`grep\` to narrow the window)`;
|
|
2706
|
+
}
|
|
2707
|
+
if (name === "ssh-execute") {
|
|
2708
|
+
return `${base} pipe through head/tail/grep server-side, or use sftp-read with offset for huge logs)`;
|
|
2709
|
+
}
|
|
2710
|
+
return `${base} narrow your query, paginate, or grep server-side to read the rest)`;
|
|
2711
|
+
}
|
|
2712
|
+
var TRANSIENT_RE = /(ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|connection\s+refused|connection\s+reset|getaddrinfo|connect\s+ETIMEDOUT|socket\s+hang\s*up|read\s+ECONNRESET|All configured authentication methods failed)/i;
|
|
2713
|
+
function isTransientSshError(stderr, exitCode) {
|
|
2714
|
+
if (exitCode !== -1) return false;
|
|
2715
|
+
if (!stderr) return false;
|
|
2716
|
+
return TRANSIENT_RE.test(stderr);
|
|
2717
|
+
}
|
|
2718
|
+
function postprocessResult(result, meta) {
|
|
2719
|
+
if (!result.content?.length) return result;
|
|
2720
|
+
const block = result.content[0];
|
|
2721
|
+
let text = String(block.text ?? "");
|
|
2722
|
+
const trunc = truncateForLLM(text, RESPONSE_MAX_BYTES);
|
|
2723
|
+
if (trunc.truncated) {
|
|
2724
|
+
text = trunc.text + "\n\n... " + buildTruncationHint(meta.toolName, meta.args, trunc.totalBytes, trunc.shownBytes);
|
|
2725
|
+
}
|
|
2726
|
+
if (!NO_FOOTER_TOOLS.has(meta.toolName)) {
|
|
2727
|
+
const tookMs = Date.now() - meta.startedAtMs;
|
|
2728
|
+
const tookStr = tookMs < 1e3 ? `${tookMs}ms` : `${(tookMs / 1e3).toFixed(1)}s`;
|
|
2729
|
+
const sizeStr = trunc.totalBytes >= 1024 ? `${(trunc.totalBytes / 1024).toFixed(1)} KB` : `${trunc.totalBytes} B`;
|
|
2730
|
+
const parts = [`took ${tookStr}`, sizeStr];
|
|
2731
|
+
if (meta.serverIdLabel) parts.push(`server: ${meta.serverIdLabel}`);
|
|
2732
|
+
if (meta.cached) parts.push("cached");
|
|
2733
|
+
text = `${text}
|
|
2734
|
+
|
|
2735
|
+
[${parts.join(", ")}]`;
|
|
2736
|
+
}
|
|
2737
|
+
return { ...result, content: [{ ...block, text }] };
|
|
2738
|
+
}
|
|
2739
|
+
function buildPipelineScript(commands, shell, marker, stopOnError) {
|
|
2740
|
+
if (shell === "powershell") {
|
|
2741
|
+
const lines = ["$ErrorActionPreference='Continue'", "$ProgressPreference='SilentlyContinue'"];
|
|
2742
|
+
for (let i = 0; i < commands.length; i++) {
|
|
2743
|
+
lines.push(`Write-Output '${marker} ${i} begin'`);
|
|
2744
|
+
lines.push(`& { ${commands[i]} } 2>&1`);
|
|
2745
|
+
lines.push(`$__c = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }`);
|
|
2746
|
+
lines.push(`Write-Output ('${marker} ' + ${i} + ' end exit=' + $__c)`);
|
|
2747
|
+
if (stopOnError) lines.push(`if ($__c -ne 0) { exit $__c }`);
|
|
2748
|
+
}
|
|
2749
|
+
return buildPowerShellEncodedCommand(lines.join("; "), []);
|
|
2750
|
+
}
|
|
2751
|
+
const sh = [];
|
|
2752
|
+
for (let i = 0; i < commands.length; i++) {
|
|
2753
|
+
sh.push(`echo "${marker} ${i} begin"`);
|
|
2754
|
+
sh.push(`{ ${commands[i]}; } 2>&1`);
|
|
2755
|
+
sh.push(`__c=$?`);
|
|
2756
|
+
sh.push(`echo "${marker} ${i} end exit=$__c"`);
|
|
2757
|
+
if (stopOnError) sh.push(`if [ "$__c" -ne 0 ]; then exit "$__c"; fi`);
|
|
2758
|
+
}
|
|
2759
|
+
return `bash -c ${posixQuote(sh.join("; "))}`;
|
|
2760
|
+
}
|
|
2761
|
+
function parsePipelineOutput(stdout, commands, marker) {
|
|
2762
|
+
const segments = [];
|
|
2763
|
+
for (let i = 0; i < commands.length; i++) {
|
|
2764
|
+
const beginRe = new RegExp(`^${escapeRegex(marker)}\\s+${i}\\s+begin\\s*$`, "m");
|
|
2765
|
+
const endRe = new RegExp(`^${escapeRegex(marker)}\\s+${i}\\s+end\\s+exit=(-?\\d+)\\s*$`, "m");
|
|
2766
|
+
const beginMatch = beginRe.exec(stdout);
|
|
2767
|
+
const endMatch = endRe.exec(stdout);
|
|
2768
|
+
if (!beginMatch || !endMatch) {
|
|
2769
|
+
segments.push({ index: i, command: commands[i], output: "(no output captured \u2014 pipeline aborted before this step?)", exitCode: -1 });
|
|
2770
|
+
continue;
|
|
2771
|
+
}
|
|
2772
|
+
const start = beginMatch.index + beginMatch[0].length + 1;
|
|
2773
|
+
const end = endMatch.index;
|
|
2774
|
+
const body = stdout.slice(start, end).replace(/\n$/, "");
|
|
2775
|
+
const code = parseInt(endMatch[1], 10);
|
|
2776
|
+
segments.push({ index: i, command: commands[i], output: body, exitCode: Number.isFinite(code) ? code : -1 });
|
|
2777
|
+
}
|
|
2778
|
+
return segments;
|
|
2779
|
+
}
|
|
2780
|
+
function escapeRegex(s) {
|
|
2781
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2782
|
+
}
|
|
2783
|
+
function renderPipelineSegments(segments, overall, format) {
|
|
2784
|
+
const sections = [];
|
|
2785
|
+
for (const seg of segments) {
|
|
2786
|
+
sections.push(`>>> [${seg.index + 1}/${segments.length}] ${seg.command} (exit ${seg.exitCode})`);
|
|
2787
|
+
let body = seg.output;
|
|
2788
|
+
if (format === "ndjson") {
|
|
2789
|
+
const parsed = tryParseNdjsonFromCommand(seg.command, body);
|
|
2790
|
+
if (parsed) body = parsed;
|
|
2791
|
+
}
|
|
2792
|
+
sections.push(body || "(no output)");
|
|
2793
|
+
}
|
|
2794
|
+
if (overall.stderr) sections.push(`--- aggregate stderr ---
|
|
2795
|
+
${overall.stderr.trimEnd()}`);
|
|
2796
|
+
return sections.join("\n");
|
|
2797
|
+
}
|
|
2798
|
+
function renderRepeatResult(iters, mode, format) {
|
|
2799
|
+
const formatBody = (s) => {
|
|
2800
|
+
if (format !== "ndjson") return s;
|
|
2801
|
+
return tryParseNdjsonFromCommand("", s) || s;
|
|
2802
|
+
};
|
|
2803
|
+
if (mode === "last") {
|
|
2804
|
+
const last = iters[iters.length - 1];
|
|
2805
|
+
return `--- iteration ${last.index + 1}/${iters.length} (exit ${last.result.exitCode}, ${last.durationMs}ms) ---
|
|
2806
|
+
${formatBody(last.result.stdout) || "(empty)"}${last.result.stderr ? `
|
|
2807
|
+
[stderr] ${last.result.stderr.trimEnd()}` : ""}`;
|
|
2808
|
+
}
|
|
2809
|
+
if (mode === "diff") {
|
|
2810
|
+
const sections2 = [`(repeat: ${iters.length} runs, diff mode \u2014 only differences vs. previous run shown)`];
|
|
2811
|
+
let prev = "";
|
|
2812
|
+
for (let i = 0; i < iters.length; i++) {
|
|
2813
|
+
const cur = iters[i].result.stdout;
|
|
2814
|
+
if (i === 0) {
|
|
2815
|
+
sections2.push(`--- iteration 1 (initial, ${iters[i].durationMs}ms) ---
|
|
2816
|
+
${formatBody(cur) || "(empty)"}`);
|
|
2817
|
+
} else {
|
|
2818
|
+
const changes = diffLines(prev, cur);
|
|
2819
|
+
const header = `--- iteration ${i + 1} (${iters[i].durationMs}ms, +${changes.added.length} -${changes.removed.length}) ---`;
|
|
2820
|
+
const block = [header];
|
|
2821
|
+
if (changes.added.length === 0 && changes.removed.length === 0) {
|
|
2822
|
+
block.push("(no changes)");
|
|
2823
|
+
} else {
|
|
2824
|
+
for (const line of changes.added) block.push(`+ ${line}`);
|
|
2825
|
+
for (const line of changes.removed) block.push(`- ${line}`);
|
|
2826
|
+
}
|
|
2827
|
+
sections2.push(block.join("\n"));
|
|
2828
|
+
}
|
|
2829
|
+
prev = cur;
|
|
2830
|
+
}
|
|
2831
|
+
return sections2.join("\n");
|
|
2832
|
+
}
|
|
2833
|
+
const sections = [`(repeat: ${iters.length} runs, all output)`];
|
|
2834
|
+
for (const it of iters) {
|
|
2835
|
+
sections.push(`--- iteration ${it.index + 1}/${iters.length} (exit ${it.result.exitCode}, ${it.durationMs}ms) ---`);
|
|
2836
|
+
sections.push(formatBody(it.result.stdout) || "(empty)");
|
|
2837
|
+
if (it.result.stderr) sections.push(`[stderr] ${it.result.stderr.trimEnd()}`);
|
|
2838
|
+
}
|
|
2839
|
+
return sections.join("\n");
|
|
2840
|
+
}
|
|
2841
|
+
function diffLines(a, b) {
|
|
2842
|
+
const aSet = new Set(a.split("\n"));
|
|
2843
|
+
const bSet = new Set(b.split("\n"));
|
|
2844
|
+
const added = [];
|
|
2845
|
+
const removed = [];
|
|
2846
|
+
for (const line of bSet) if (!aSet.has(line) && line) added.push(line);
|
|
2847
|
+
for (const line of aSet) if (!bSet.has(line) && line) removed.push(line);
|
|
2848
|
+
return { added, removed };
|
|
2849
|
+
}
|
|
2850
|
+
function tryParseNdjsonFromCommand(command, stdout) {
|
|
2851
|
+
if (!stdout.trim()) return null;
|
|
2852
|
+
const c = command.toLowerCase();
|
|
2853
|
+
const looksLike = (re) => re.test(c);
|
|
2854
|
+
if (looksLike(/\bdf\b/) || /^Filesystem\s+\S+\s+Used\s+/m.test(stdout)) {
|
|
2855
|
+
return parseDf(stdout);
|
|
2856
|
+
}
|
|
2857
|
+
if (looksLike(/\bfree\b/) || /^\s*total\s+used\s+free/m.test(stdout)) {
|
|
2858
|
+
return parseFree(stdout);
|
|
2859
|
+
}
|
|
2860
|
+
if (looksLike(/\bps\b/) || /^USER\s+PID\s+%CPU\s+%MEM/m.test(stdout)) {
|
|
2861
|
+
return parsePsAux(stdout);
|
|
2862
|
+
}
|
|
2863
|
+
if (looksLike(/\bss\b/) || /^State\s+Recv-Q\s+Send-Q/m.test(stdout)) {
|
|
2864
|
+
return parseSs(stdout);
|
|
2865
|
+
}
|
|
2866
|
+
if (looksLike(/\blsblk\b/) || /^NAME\s+MAJ:MIN/m.test(stdout)) {
|
|
2867
|
+
return parseLsblk(stdout);
|
|
2868
|
+
}
|
|
2869
|
+
return null;
|
|
2870
|
+
}
|
|
2871
|
+
function splitCols(line, max) {
|
|
2872
|
+
const parts = line.trim().split(/\s+/);
|
|
2873
|
+
if (max && parts.length > max) {
|
|
2874
|
+
return [...parts.slice(0, max - 1), parts.slice(max - 1).join(" ")];
|
|
2875
|
+
}
|
|
2876
|
+
return parts;
|
|
2877
|
+
}
|
|
2878
|
+
function parseDf(s) {
|
|
2879
|
+
const lines = s.split("\n").filter(Boolean);
|
|
2880
|
+
const header = lines[0];
|
|
2881
|
+
if (!header || !/Filesystem/i.test(header)) return null;
|
|
2882
|
+
const cols = header.trim().split(/\s+/);
|
|
2883
|
+
const out = [];
|
|
2884
|
+
for (const ln of lines.slice(1)) {
|
|
2885
|
+
const parts = splitCols(ln, cols.length);
|
|
2886
|
+
if (parts.length < 4) continue;
|
|
2887
|
+
const obj = {};
|
|
2888
|
+
for (let i = 0; i < cols.length; i++) obj[cols[i]] = parts[i] || "";
|
|
2889
|
+
out.push(JSON.stringify(obj));
|
|
2890
|
+
}
|
|
2891
|
+
return out.length ? out.join("\n") : null;
|
|
2892
|
+
}
|
|
2893
|
+
function parseFree(s) {
|
|
2894
|
+
const lines = s.split("\n").filter(Boolean);
|
|
2895
|
+
const out = [];
|
|
2896
|
+
for (const ln of lines) {
|
|
2897
|
+
if (/^\s*total\s+used\s+free/i.test(ln)) continue;
|
|
2898
|
+
const m = ln.match(/^(\S+):?\s+(.+)$/);
|
|
2899
|
+
if (!m) continue;
|
|
2900
|
+
const [type, rest] = [m[1], m[2]];
|
|
2901
|
+
const cols = rest.trim().split(/\s+/);
|
|
2902
|
+
out.push(JSON.stringify({ type: type.replace(/:$/, ""), values: cols.map(Number) }));
|
|
2903
|
+
}
|
|
2904
|
+
return out.length ? out.join("\n") : null;
|
|
2905
|
+
}
|
|
2906
|
+
function parsePsAux(s) {
|
|
2907
|
+
const lines = s.split("\n").filter(Boolean);
|
|
2908
|
+
if (!lines[0] || !/USER\s+PID/i.test(lines[0])) return null;
|
|
2909
|
+
const headers = lines[0].trim().split(/\s+/);
|
|
2910
|
+
const out = [];
|
|
2911
|
+
for (const ln of lines.slice(1)) {
|
|
2912
|
+
const parts = splitCols(ln, headers.length);
|
|
2913
|
+
if (parts.length < headers.length) continue;
|
|
2914
|
+
const obj = {};
|
|
2915
|
+
for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
|
|
2916
|
+
out.push(JSON.stringify(obj));
|
|
2917
|
+
}
|
|
2918
|
+
return out.length ? out.join("\n") : null;
|
|
2919
|
+
}
|
|
2920
|
+
function parseSs(s) {
|
|
2921
|
+
const lines = s.split("\n").filter(Boolean);
|
|
2922
|
+
if (!lines[0] || !/(State|Netid)/i.test(lines[0])) return null;
|
|
2923
|
+
const headers = lines[0].trim().split(/\s+/);
|
|
2924
|
+
const out = [];
|
|
2925
|
+
for (const ln of lines.slice(1)) {
|
|
2926
|
+
const parts = splitCols(ln, headers.length);
|
|
2927
|
+
const obj = {};
|
|
2928
|
+
for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
|
|
2929
|
+
out.push(JSON.stringify(obj));
|
|
2930
|
+
}
|
|
2931
|
+
return out.length ? out.join("\n") : null;
|
|
2932
|
+
}
|
|
2933
|
+
function parseLsblk(s) {
|
|
2934
|
+
const lines = s.split("\n").filter(Boolean);
|
|
2935
|
+
if (!lines[0] || !/NAME\s+MAJ:MIN/i.test(lines[0])) return null;
|
|
2936
|
+
const headers = lines[0].trim().split(/\s+/);
|
|
2937
|
+
const out = [];
|
|
2938
|
+
for (const ln of lines.slice(1)) {
|
|
2939
|
+
const parts = splitCols(ln, headers.length);
|
|
2940
|
+
const obj = {};
|
|
2941
|
+
for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
|
|
2942
|
+
out.push(JSON.stringify(obj));
|
|
2943
|
+
}
|
|
2944
|
+
return out.length ? out.join("\n") : null;
|
|
2945
|
+
}
|
|
2946
|
+
async function resolveServerLabel(serverId, serverIds) {
|
|
2947
|
+
if (Array.isArray(serverIds) && serverIds.length > 0) return `${serverIds.length} servers`;
|
|
2948
|
+
if (!serverId) return void 0;
|
|
2949
|
+
const cached = KNOWN_SERVER_NAMES.get(serverId);
|
|
2950
|
+
if (cached) return cached.name;
|
|
2951
|
+
try {
|
|
2952
|
+
const { data } = await supabase.from("ssh_server").select("id, name").eq("id", serverId).maybeSingle();
|
|
2953
|
+
if (data?.name) {
|
|
2954
|
+
KNOWN_SERVER_NAMES.set(serverId, { id: serverId, name: data.name });
|
|
2955
|
+
return data.name;
|
|
2956
|
+
}
|
|
2957
|
+
} catch {
|
|
2958
|
+
}
|
|
2959
|
+
return serverId.slice(0, 8);
|
|
2960
|
+
}
|
|
2467
2961
|
async function sftpWrite(opts, filePath, input, proxy, meta) {
|
|
2468
2962
|
const safe = sanitizePath(filePath);
|
|
2469
2963
|
assertWritablePath(safe);
|
|
@@ -2664,6 +3158,92 @@ async function sftpDelete(opts, filePath, proxy, options) {
|
|
|
2664
3158
|
return `Error: ${e.message}`;
|
|
2665
3159
|
}
|
|
2666
3160
|
}
|
|
3161
|
+
async function sftpCopy(src, dst) {
|
|
3162
|
+
const startedAt = Date.now();
|
|
3163
|
+
if (src.kind === "r2" && dst.kind === "r2") {
|
|
3164
|
+
const { size } = await r2CopyObject(src.bucket, src.key, dst.bucket, dst.key);
|
|
3165
|
+
return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "r2-server-side" };
|
|
3166
|
+
}
|
|
3167
|
+
if (src.kind === "r2" && dst.kind === "ssh") {
|
|
3168
|
+
const safe = sanitizePath(dst.path);
|
|
3169
|
+
assertWritablePath(safe);
|
|
3170
|
+
const { stream, size } = await r2GetObjectStream(src.bucket, src.key);
|
|
3171
|
+
const { client, cleanup } = await connectSshClient(dst.opts, dst.proxy, 6e4);
|
|
3172
|
+
try {
|
|
3173
|
+
await new Promise((resolve, reject) => {
|
|
3174
|
+
client.sftp((err, sftp) => {
|
|
3175
|
+
if (err) return reject(err);
|
|
3176
|
+
const ws = sftp.createWriteStream(safe);
|
|
3177
|
+
ws.on("close", () => resolve());
|
|
3178
|
+
ws.on("error", reject);
|
|
3179
|
+
stream.on("error", reject);
|
|
3180
|
+
stream.pipe(ws);
|
|
3181
|
+
});
|
|
3182
|
+
});
|
|
3183
|
+
} finally {
|
|
3184
|
+
cleanup();
|
|
3185
|
+
}
|
|
3186
|
+
return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "r2\u2192ssh-stream" };
|
|
3187
|
+
}
|
|
3188
|
+
if (src.kind === "ssh" && dst.kind === "r2") {
|
|
3189
|
+
const safeSrc = sanitizePath(src.path);
|
|
3190
|
+
const { client, cleanup } = await connectSshClient(src.opts, src.proxy, 6e4);
|
|
3191
|
+
try {
|
|
3192
|
+
const { stream, size } = await new Promise((resolve, reject) => {
|
|
3193
|
+
client.sftp((err, sftp) => {
|
|
3194
|
+
if (err) return reject(err);
|
|
3195
|
+
sftp.stat(safeSrc, (statErr, stats) => {
|
|
3196
|
+
if (statErr) return reject(statErr);
|
|
3197
|
+
const s = stats.size || 0;
|
|
3198
|
+
const rs = sftp.createReadStream(safeSrc);
|
|
3199
|
+
resolve({ stream: rs, size: s });
|
|
3200
|
+
});
|
|
3201
|
+
});
|
|
3202
|
+
});
|
|
3203
|
+
if (size > R2_MULTIPART_THRESHOLD) {
|
|
3204
|
+
cleanup();
|
|
3205
|
+
throw new Error(`Source object is ${formatBytes(size)} which exceeds R2 single-PUT cap (${formatBytes(R2_MULTIPART_THRESHOLD)}). For now: download via sftp-write { sourceRemote, target: local } first, then upload to R2 with sourcePath.`);
|
|
3206
|
+
}
|
|
3207
|
+
await r2PutObject(dst.bucket, dst.key, stream, size);
|
|
3208
|
+
return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "ssh\u2192r2-stream" };
|
|
3209
|
+
} finally {
|
|
3210
|
+
cleanup();
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
if (src.kind === "ssh" && dst.kind === "ssh") {
|
|
3214
|
+
const safeSrc = sanitizePath(src.path);
|
|
3215
|
+
const safeDst = sanitizePath(dst.path);
|
|
3216
|
+
assertWritablePath(safeDst);
|
|
3217
|
+
const srcConn = await connectSshClient(src.opts, src.proxy, 6e4);
|
|
3218
|
+
let dstConn;
|
|
3219
|
+
try {
|
|
3220
|
+
dstConn = await connectSshClient(dst.opts, dst.proxy, 6e4);
|
|
3221
|
+
const bytes = await new Promise((resolve, reject) => {
|
|
3222
|
+
srcConn.client.sftp((err, srcSftp) => {
|
|
3223
|
+
if (err) return reject(err);
|
|
3224
|
+
dstConn.client.sftp((err2, dstSftp) => {
|
|
3225
|
+
if (err2) return reject(err2);
|
|
3226
|
+
srcSftp.stat(safeSrc, (statErr, stats) => {
|
|
3227
|
+
if (statErr) return reject(statErr);
|
|
3228
|
+
const size = stats.size || 0;
|
|
3229
|
+
const rs = srcSftp.createReadStream(safeSrc);
|
|
3230
|
+
const ws = dstSftp.createWriteStream(safeDst);
|
|
3231
|
+
ws.on("close", () => resolve(size));
|
|
3232
|
+
ws.on("error", reject);
|
|
3233
|
+
rs.on("error", reject);
|
|
3234
|
+
rs.pipe(ws);
|
|
3235
|
+
});
|
|
3236
|
+
});
|
|
3237
|
+
});
|
|
3238
|
+
});
|
|
3239
|
+
return { bytes, elapsedMs: Date.now() - startedAt, mode: "ssh\u2192ssh-stream" };
|
|
3240
|
+
} finally {
|
|
3241
|
+
srcConn.cleanup();
|
|
3242
|
+
dstConn?.cleanup();
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
throw new Error("Unsupported copy combination");
|
|
3246
|
+
}
|
|
2667
3247
|
var BLOCKED_COMMANDS = [
|
|
2668
3248
|
"rm -rf /",
|
|
2669
3249
|
"rm -fr /",
|
|
@@ -2848,33 +3428,41 @@ async function mijnhostFetch(path, options = {}) {
|
|
|
2848
3428
|
var TOOLS = [
|
|
2849
3429
|
{
|
|
2850
3430
|
name: "list-servers",
|
|
2851
|
-
description:
|
|
3431
|
+
description: 'List all SSH servers you have access to. Returns id, name, hostname, tags, and os_type per server. Pass `includeStats: true` to also probe each server in parallel for container count, disk-free, and uptime (skips unreachable hosts gracefully). Cached for 60s \u2014 pass `noCache: true` to force a refresh. Adds an "auto-context" footer with one-line tags inferred from server name/tags so the LLM knows which host runs Trigger / Supabase / proxy / etc. (disable with `context: false`).',
|
|
2852
3432
|
inputSchema: {
|
|
2853
3433
|
type: "object",
|
|
2854
3434
|
properties: {
|
|
2855
3435
|
includeStats: { type: "boolean", description: "When true, probe each server (in parallel, with timeout) for container count, disk-free, and uptime." },
|
|
2856
3436
|
statsParallelism: { type: "number", description: "Max parallel probes when includeStats is true (default 8, cap 20)." },
|
|
2857
|
-
statsTimeoutMs: { type: "number", description: "Per-server probe timeout in ms when includeStats is true (default 6000, range 1000-30000)." }
|
|
3437
|
+
statsTimeoutMs: { type: "number", description: "Per-server probe timeout in ms when includeStats is true (default 6000, range 1000-30000)." },
|
|
3438
|
+
context: { type: "boolean", description: "Include auto-context footer with name/tag-based hints (default true). Pass false to suppress." },
|
|
3439
|
+
noCache: { type: "boolean", description: "Bypass the 60s in-memory cache." }
|
|
2858
3440
|
}
|
|
2859
3441
|
}
|
|
2860
3442
|
},
|
|
2861
3443
|
{
|
|
2862
3444
|
name: "ssh-execute",
|
|
2863
|
-
description: 'Execute
|
|
3445
|
+
description: 'Execute one or more commands on a remote server via SSH. OS-aware: bash on linux, `powershell -EncodedCommand` (UTF-16LE base64) on windows, so $, #, quotes, spaces inside `args` are never re-interpreted by a shell. Some dangerous commands are blocked. Use `list-servers` first to see each server\'s os_type.\n\nThree modes (compose them in one call):\n- Single: `command` (+ optional `args[]`). Quick or safe.\n- Pipeline: `pipeline: ["cmd1","cmd2","cmd3"]` runs all commands sequentially in one SSH session and returns segmented per-command output (each with its own exit code). Default continues on error; pass `pipelineStopOnError: true` to abort.\n- Repeat: `repeat: N, intervalSeconds: S, output: "all"|"diff"|"last"`. Diff mode shows only added/removed lines vs. the previous run \u2014 perfect for "watch this metric for 5 min".\n\n`stdin` pipes data into the remote process (queries, scripts, secrets) without putting it on the command line. `cwd` sets the working directory (auto `cd` on Linux / `Set-Location` on Windows).\n\nIdle timeout: defaults to 120s \u2014 if the remote produces no stdout/stderr for that long the call returns with a clear timeout message. Pass `idleTimeoutSeconds: 0` to disable, or e.g. `300` for slow workloads.\n\nFan-out: pass `serverIds: [id1, id2, ...]` (instead of `serverId`) to run across multiple servers in parallel.\n\nAuto-features: outputs >8 KB are auto-truncated with a continuation hint; transient SSH failures (ECONNRESET/ETIMEDOUT/etc) get one automatic retry. Set `format: "ndjson"` to parse common Linux outputs (df / free / ps aux / ss / lsblk) into newline-delimited JSON instead of whitespace-padded text.',
|
|
2864
3446
|
inputSchema: {
|
|
2865
3447
|
type: "object",
|
|
2866
3448
|
properties: {
|
|
2867
3449
|
serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
|
|
2868
3450
|
serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to run the command on in parallel. Mutually exclusive with serverId." },
|
|
2869
|
-
command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required.' },
|
|
3451
|
+
command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required unless `pipeline[]` is set.' },
|
|
2870
3452
|
args: { type: "array", items: { type: "string" }, description: "Optional argv list. When provided, each entry is safely quoted/encoded for the target OS." },
|
|
3453
|
+
pipeline: { type: "array", items: { type: "string" }, description: "Run multiple shell commands sequentially in ONE SSH session, with per-command exit codes and segmented output. Saves N round-trips for setup\u2192test\u2192cleanup workflows." },
|
|
3454
|
+
pipelineStopOnError: { type: "boolean", description: "When pipeline is set, abort the chain on the first non-zero exit (default false: keep going so all outputs reach the LLM)." },
|
|
3455
|
+
repeat: { type: "number", description: 'Re-run the command/pipeline N times (default 1, max 60). Combine with intervalSeconds + output to "watch" a metric.' },
|
|
3456
|
+
intervalSeconds: { type: "number", description: "When repeat>1, seconds to wait between iterations (default 0, max 600)." },
|
|
3457
|
+
output: { type: "string", enum: ["all", "diff", "last"], description: "When repeat>1: how to render iterations. `all` = full output per run; `diff` = only added/removed lines vs. previous run; `last` = only the final iteration." },
|
|
3458
|
+
format: { type: "string", enum: ["text", "ndjson"], description: "Output format. `ndjson` parses common Linux outputs (df, free, ps aux, ss, lsblk) into newline-delimited JSON." },
|
|
2871
3459
|
stdin: { type: "string", description: "Optional data piped to the remote process stdin." },
|
|
2872
3460
|
cwd: { type: "string", description: "Working directory on the remote (cd <cwd> on Linux, Set-Location on Windows)." },
|
|
2873
3461
|
shell: { type: "string", enum: ["auto", "bash", "powershell"], description: `Override shell selection (default "auto" uses the server's os_type).` },
|
|
2874
3462
|
parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." },
|
|
2875
|
-
timeout: { type: "number", description: "
|
|
2876
|
-
|
|
2877
|
-
|
|
3463
|
+
timeout: { type: "number", description: "Wall timeout in milliseconds (default: 60000). Usually idleTimeoutSeconds is what you want instead." },
|
|
3464
|
+
idleTimeoutSeconds: { type: "number", description: 'Abort if no output for N seconds (default 120). Pass 0 to disable. Useful when the wall budget is hard to predict but silence usually means "stuck".' }
|
|
3465
|
+
}
|
|
2878
3466
|
}
|
|
2879
3467
|
},
|
|
2880
3468
|
{
|
|
@@ -2913,65 +3501,73 @@ var TOOLS = [
|
|
|
2913
3501
|
},
|
|
2914
3502
|
{
|
|
2915
3503
|
name: "sftp-write",
|
|
2916
|
-
description: "Write a file
|
|
3504
|
+
description: "Write OR copy a file in remote storage. Two target backends (`serverId` or `bucket`) and three source modes:\n\nSources:\n- `content` (string): inline UTF-8 text (\u22481 MB max).\n- `sourcePath` (absolute local path): streams a local file. SSH uses ssh2 fastPut; R2 uses streamed PUT (or automatic multipart for >4.5 GB). Handles GB-scale.\n- `sourceRemote: { serverId|bucket, path }`: COPY mode \u2014 pulls from one remote and writes to another in a single operation, no local detour. Supports all four directions: ssh\u2194ssh, ssh\u2194r2, r2\u2194ssh, r2\u2194r2 (R2\u2194R2 uses server-side CopyObject = zero data egress).\n\nTargets:\n- `serverId`: SSH server via SFTP. Protected system paths blocked. Optional `mode` (POSIX bits e.g. 0o755) and `mtime` (modification time) \u2014 SSH only.\n- `bucket`: Cloudflare R2 bucket. `mode`/`mtime` are ignored on R2.",
|
|
2917
3505
|
inputSchema: {
|
|
2918
3506
|
type: "object",
|
|
2919
3507
|
properties: {
|
|
2920
|
-
serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
|
|
2921
|
-
bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
|
|
2922
|
-
path: { type: "string", description: "
|
|
2923
|
-
content: { type: "string", description: "
|
|
2924
|
-
sourcePath: { type: "string", description: "
|
|
2925
|
-
|
|
2926
|
-
|
|
3508
|
+
serverId: { type: "string", description: "TARGET: UUID of the SSH server (mutually exclusive with bucket)" },
|
|
3509
|
+
bucket: { type: "string", description: "TARGET: Cloudflare R2 bucket name (mutually exclusive with serverId)" },
|
|
3510
|
+
path: { type: "string", description: "TARGET path (SSH file path or R2 object key)" },
|
|
3511
|
+
content: { type: "string", description: "Source: inline UTF-8 file content (mutually exclusive with sourcePath / sourceRemote)" },
|
|
3512
|
+
sourcePath: { type: "string", description: "Source: absolute local file path (mutually exclusive with content / sourceRemote). Use for >1 MB or binary." },
|
|
3513
|
+
sourceRemote: { type: "object", description: "Source: copy from another remote. Shape: { serverId?: string, bucket?: string, path: string }. Picks the streaming strategy (ssh\u2192ssh, r2\u2192ssh, ssh\u2192r2, r2\u2192r2 server-side).", properties: { serverId: { type: "string" }, bucket: { type: "string" }, path: { type: "string" } } },
|
|
3514
|
+
mode: { oneOf: [{ type: "number" }, { type: "string" }], description: 'POSIX file permission bits (SSH target only). Number (0o755 / 493) or octal string ("755" / "0o755"). Default 0o644.' },
|
|
3515
|
+
mtime: { oneOf: [{ type: "number" }, { type: "string" }], description: "File modification time (SSH target only). ms-since-epoch number or ISO-8601 date string." }
|
|
2927
3516
|
},
|
|
2928
3517
|
required: ["path"]
|
|
2929
3518
|
}
|
|
2930
3519
|
},
|
|
2931
3520
|
{
|
|
2932
3521
|
name: "sftp-delete",
|
|
2933
|
-
description:
|
|
3522
|
+
description: 'Delete a file (or directory tree) from remote storage. Two backends (provide exactly one of `serverId` or `bucket`):\n- `serverId`: delete a file or empty directory on an SSH server via SFTP. Protected system paths blocked. With `recursive: true` walks the tree depth-first via SFTP and removes files + empty dirs from the bottom up (works on Windows OpenSSH too \u2014 no shell tricks).\n- `bucket`: delete an object from a Cloudflare R2 bucket. `path` is the object key. With `recursive: true`, treats `path` as a key prefix and batch-deletes every object beneath it.\n\nSafety nets:\n- `dryRun: true` \u2192 returns "Would delete N items (size)" with a sample preview, no actual deletion.\n- `confirmAbove: N` + `confirmCount: <exact>` \u2192 refuses recursive deletes touching more than N items unless the caller passes `confirmCount` matching the actual count.',
|
|
2934
3523
|
inputSchema: {
|
|
2935
3524
|
type: "object",
|
|
2936
3525
|
properties: {
|
|
2937
3526
|
serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
|
|
2938
3527
|
bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
|
|
2939
3528
|
path: { type: "string", description: "File path / object key (or directory / key prefix when recursive)" },
|
|
2940
|
-
recursive: { type: "boolean", description: "Recursively remove a directory tree (SSH) or all objects under a key prefix (R2)." }
|
|
3529
|
+
recursive: { type: "boolean", description: "Recursively remove a directory tree (SSH) or all objects under a key prefix (R2)." },
|
|
3530
|
+
dryRun: { type: "boolean", description: "Preview-only mode: returns what would be deleted (count, total size, sample paths) without touching anything." },
|
|
3531
|
+
confirmAbove: { type: "number", description: "Refuse recursive deletes that touch more than N items unless `confirmCount` matches the actual count." },
|
|
3532
|
+
confirmCount: { type: "number", description: "When confirmAbove gates a delete, pass the exact item count from the dry-run output to confirm intent." }
|
|
2941
3533
|
},
|
|
2942
3534
|
required: ["path"]
|
|
2943
3535
|
}
|
|
2944
3536
|
},
|
|
2945
3537
|
{
|
|
2946
3538
|
name: "docker-list",
|
|
2947
|
-
description:
|
|
3539
|
+
description: 'List Docker containers on one or more remote servers. Sorted by compose project then name, with a HEALTH column (healthy / unhealthy / starting / no-check). Filter by `nameFilter` (string OR array of substring/glob entries \u2014 passes if ANY entry matches), restrict to compose containers with `composeOnly`, or group output per project with `groupByProject`.\n\nFan-out: pass `serverIds: [...]` (instead of `serverId`) to query multiple servers in parallel \u2014 perfect for "show all unhealthy containers across the fleet". Per-server sections are clearly headed.\n\nCached for 30s \u2014 pass `noCache: true` to bypass.',
|
|
2948
3540
|
inputSchema: {
|
|
2949
3541
|
type: "object",
|
|
2950
3542
|
properties: {
|
|
2951
|
-
serverId: { type: "string", description: "UUID of
|
|
2952
|
-
|
|
3543
|
+
serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
|
|
3544
|
+
serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to query in parallel. Mutually exclusive with serverId." },
|
|
3545
|
+
format: { type: "string", enum: ["table", "json"], description: 'Output format: human table (default) or NDJSON. JSON adds a "Server" field per row in fan-out mode.' },
|
|
2953
3546
|
composeOnly: { type: "boolean", description: "Only show containers that have a docker-compose project label." },
|
|
2954
3547
|
nameFilter: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Filter by container name. Single entry or array; each entry is glob (* or ?) or substring. A row passes when ANY entry matches." },
|
|
2955
|
-
groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header." }
|
|
2956
|
-
|
|
2957
|
-
|
|
3548
|
+
groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header (single-server mode only)." },
|
|
3549
|
+
parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." },
|
|
3550
|
+
noCache: { type: "boolean", description: "Bypass the 30s in-memory cache." }
|
|
3551
|
+
}
|
|
2958
3552
|
}
|
|
2959
3553
|
},
|
|
2960
3554
|
{
|
|
2961
3555
|
name: "docker-logs",
|
|
2962
|
-
description: 'Get logs from one or more Docker containers. `containerName` accepts a string OR
|
|
3556
|
+
description: 'Get logs from one or more Docker containers, optionally across multiple servers. `containerName` accepts a string OR array \u2014 multi-container output is line-prefixed with `[name]`. Supports time-window (`since`), server-side `grep`, line-count (`tail`), and real-time follow (`followSeconds`). Always merges stderr into stdout. Outputs >8 KB get a continuation hint.\n\nFan-out: pass `serverIds: [...]` to tail the same container(s) across multiple servers in one call (e.g. all kong instances). Per-server output is grouped under headers.\n\nFuzzy hint: if a container name typo is detected, the error includes "Did you mean: ..." suggestions from the docker-list cache.',
|
|
2963
3557
|
inputSchema: {
|
|
2964
3558
|
type: "object",
|
|
2965
3559
|
properties: {
|
|
2966
|
-
serverId: { type: "string", description: "UUID of
|
|
3560
|
+
serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
|
|
3561
|
+
serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to tail in parallel. Mutually exclusive with serverId." },
|
|
2967
3562
|
containerName: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Single container name/ID or an array for multi-container tail. Multi mode prefixes each line with [name]." },
|
|
2968
|
-
tail: { type: "number", description: "Number of recent log lines per container (default 100).
|
|
3563
|
+
tail: { type: "number", description: "Number of recent log lines per container (default 100, default 0 in follow mode)." },
|
|
2969
3564
|
lines: { type: "number", description: "Deprecated alias for `tail`. Prefer `tail`." },
|
|
2970
3565
|
since: { type: "string", description: 'Time window, e.g. "10m", "2h", "24h", or an absolute "2026-05-09T10:00:00".' },
|
|
2971
3566
|
grep: { type: "string", description: "Case-insensitive regex/literal filter applied server-side (saves tokens for noisy containers)." },
|
|
2972
|
-
followSeconds: { type: "number", description: "When >0, stream new log lines for N seconds via `docker logs -f`, then exit (max 300). Multi-container follow runs all streams in parallel." }
|
|
3567
|
+
followSeconds: { type: "number", description: "When >0, stream new log lines for N seconds via `docker logs -f`, then exit (max 300). Multi-container follow runs all streams in parallel." },
|
|
3568
|
+
parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." }
|
|
2973
3569
|
},
|
|
2974
|
-
required: ["
|
|
3570
|
+
required: ["containerName"]
|
|
2975
3571
|
}
|
|
2976
3572
|
},
|
|
2977
3573
|
{
|
|
@@ -3009,6 +3605,24 @@ var TOOLS = [
|
|
|
3009
3605
|
required: ["serverId", "projectPath", "action"]
|
|
3010
3606
|
}
|
|
3011
3607
|
},
|
|
3608
|
+
{
|
|
3609
|
+
name: "wait-for",
|
|
3610
|
+
description: 'Poll until a condition holds (or timeout). Replaces 30+ ad-hoc loops the LLM would otherwise build itself. Four condition types:\n- `url`: `target: "https://..."`, `until: 200` (HTTP status, default 200). 10s per probe.\n- `container-health`: `serverId`, `target: "container-name"`, `until: "healthy" | "running" | "exited"` (default "healthy"). Uses `docker inspect`.\n- `file-exists`: `serverId`, `target: "/path/to/file"` \u2014 SSH stat check.\n- `sftp-exists`: same as file-exists but with `bucket: "name"` instead of serverId for R2.\n\nReturns a per-attempt trail so you can see how long it took to converge.',
|
|
3611
|
+
inputSchema: {
|
|
3612
|
+
type: "object",
|
|
3613
|
+
properties: {
|
|
3614
|
+
type: { type: "string", enum: ["url", "container-health", "file-exists", "sftp-exists"], description: "Condition type to wait on." },
|
|
3615
|
+
target: { type: "string", description: "Target identifier: URL, container name, file path, or R2 key (depends on type)." },
|
|
3616
|
+
until: { description: 'Expected end-state (depends on type): for url an HTTP status (default 200), for container-health a state string (default "healthy"). Omit for file-exists/sftp-exists.' },
|
|
3617
|
+
serverId: { type: "string", description: "SSH server UUID (required for container-health and file-exists)." },
|
|
3618
|
+
bucket: { type: "string", description: "R2 bucket name (used with sftp-exists when checking an R2 object)." },
|
|
3619
|
+
path: { type: "string", description: "Alias for `target` for file-exists/sftp-exists." },
|
|
3620
|
+
intervalSeconds: { type: "number", description: "Poll interval in seconds (default 3, max 60)." },
|
|
3621
|
+
timeout: { type: "number", description: "Total deadline in seconds (default 120, max 1800)." }
|
|
3622
|
+
},
|
|
3623
|
+
required: ["type", "target"]
|
|
3624
|
+
}
|
|
3625
|
+
},
|
|
3012
3626
|
{
|
|
3013
3627
|
name: "db-discover",
|
|
3014
3628
|
description: "Scan /var/www on a server for web applications (WordPress, PrestaShop, Laravel, .env) and list their database credentials. Use this first to find available sites before running other db-* tools.",
|
|
@@ -3047,15 +3661,20 @@ var TOOLS = [
|
|
|
3047
3661
|
},
|
|
3048
3662
|
{
|
|
3049
3663
|
name: "db-query",
|
|
3050
|
-
description:
|
|
3664
|
+
description: 'Execute a SQL query against any database. Two routing modes:\n- MySQL via `/var/www` autodiscover (default): pass `sitePath` (and `engine: "mysql"` implicitly). Credentials are read from wp-config.php, parameters.php, .env. Same behaviour as before.\n- Direct via Docker container: pass `containerName` (the container running the DB server). Engine defaults to `postgres`; pass `engine: "mysql"` or `"mssql"` to override. The query is piped via stdin so quotes / `$` / `;` are never re-interpreted.\n\nPostgres example: `{ serverId, containerName: "supabase-db", engine: "postgres", dbName: "main", dbUser: "postgres", query: "SELECT 1" }`. Defaults: dbUser=postgres, dbName=postgres.\nMSSQL example: `{ serverId, containerName: "mssql-1", engine: "mssql", dbUser: "sa", dbPass: "...", query: "SELECT 1" }`.\n\nDestructive operations (DROP, TRUNCATE, etc.) are blocked.',
|
|
3051
3665
|
inputSchema: {
|
|
3052
3666
|
type: "object",
|
|
3053
3667
|
properties: {
|
|
3054
3668
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
3055
|
-
|
|
3056
|
-
|
|
3669
|
+
query: { type: "string", description: "SQL query to execute" },
|
|
3670
|
+
sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
|
|
3671
|
+
containerName: { type: "string", description: 'Docker container running the DB server (e.g. "supabase-db", "trigger-postgres"). Activates direct-query mode.' },
|
|
3672
|
+
engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
|
|
3673
|
+
dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
|
|
3674
|
+
dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
|
|
3675
|
+
dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." }
|
|
3057
3676
|
},
|
|
3058
|
-
required: ["serverId", "
|
|
3677
|
+
required: ["serverId", "query"]
|
|
3059
3678
|
}
|
|
3060
3679
|
},
|
|
3061
3680
|
{
|
|
@@ -3201,9 +3820,21 @@ async function handleCallTool(request) {
|
|
|
3201
3820
|
}
|
|
3202
3821
|
const startTime = Date.now();
|
|
3203
3822
|
const serverId = a.serverId || a.server_id;
|
|
3204
|
-
const
|
|
3823
|
+
const serverIds = a.serverIds;
|
|
3824
|
+
const cached = getCachedTool(name, a);
|
|
3825
|
+
let cameFromCache = false;
|
|
3826
|
+
let result;
|
|
3827
|
+
if (cached) {
|
|
3828
|
+
result = cached;
|
|
3829
|
+
cameFromCache = true;
|
|
3830
|
+
} else {
|
|
3831
|
+
result = await executeToolCall(name, a);
|
|
3832
|
+
}
|
|
3205
3833
|
const durationMs = Date.now() - startTime;
|
|
3206
3834
|
const isError = result.content?.[0]?.text?.startsWith("Error:");
|
|
3835
|
+
if (!cameFromCache && !isError) {
|
|
3836
|
+
setCachedTool(name, a, result);
|
|
3837
|
+
}
|
|
3207
3838
|
void writeAuditLog({
|
|
3208
3839
|
toolName: name,
|
|
3209
3840
|
arguments: a,
|
|
@@ -3212,7 +3843,14 @@ async function handleCallTool(request) {
|
|
|
3212
3843
|
errorMessage: isError ? result.content?.[0]?.text : void 0,
|
|
3213
3844
|
durationMs
|
|
3214
3845
|
});
|
|
3215
|
-
|
|
3846
|
+
const serverIdLabel = await resolveServerLabel(serverId, serverIds);
|
|
3847
|
+
return postprocessResult(result, {
|
|
3848
|
+
startedAtMs: startTime,
|
|
3849
|
+
serverIdLabel,
|
|
3850
|
+
toolName: name,
|
|
3851
|
+
args: a,
|
|
3852
|
+
cached: cameFromCache
|
|
3853
|
+
});
|
|
3216
3854
|
}
|
|
3217
3855
|
async function executeToolCall(name, a, _serverId) {
|
|
3218
3856
|
const ctx = authContext;
|
|
@@ -3228,6 +3866,7 @@ async function executeToolCall(name, a, _serverId) {
|
|
|
3228
3866
|
if (error) throw new Error(error.message);
|
|
3229
3867
|
const includeStats = a.includeStats === true;
|
|
3230
3868
|
const servers = data || [];
|
|
3869
|
+
rememberServers(servers.map((s) => ({ id: s.id, name: s.name })));
|
|
3231
3870
|
let statsByServer = /* @__PURE__ */ new Map();
|
|
3232
3871
|
if (includeStats && servers.length > 0) {
|
|
3233
3872
|
const parallelism = Math.max(1, Math.min(20, Number(a.statsParallelism) || 8));
|
|
@@ -3278,17 +3917,34 @@ async function executeToolCall(name, a, _serverId) {
|
|
|
3278
3917
|
if (!st.reachable) return `${base} UNREACHABLE (${st.error || "unknown"})`;
|
|
3279
3918
|
return `${base} containers:${st.containers} disk_free:${st.diskFreeHuman} uptime:${st.uptimeHuman}`;
|
|
3280
3919
|
});
|
|
3281
|
-
|
|
3920
|
+
const contextHints = a.context !== false ? buildServerContextHints(servers) : [];
|
|
3921
|
+
const out = lines.length ? lines.join("\n") : "No servers found";
|
|
3922
|
+
return { content: [{ type: "text", text: contextHints.length ? `${out}
|
|
3923
|
+
|
|
3924
|
+
--- context ---
|
|
3925
|
+
${contextHints.join("\n")}` : out }] };
|
|
3282
3926
|
}
|
|
3283
3927
|
// ----- SSH -----
|
|
3284
3928
|
case "ssh-execute": {
|
|
3285
|
-
const
|
|
3286
|
-
|
|
3929
|
+
const pipeline = Array.isArray(a.pipeline) ? a.pipeline.map(String).filter(Boolean) : [];
|
|
3930
|
+
const command = pipeline.length > 0 ? "" : String(a.command || "");
|
|
3931
|
+
if (!command && pipeline.length === 0) {
|
|
3932
|
+
return { content: [{ type: "text", text: "Error: pass either `command` or `pipeline[]`" }] };
|
|
3933
|
+
}
|
|
3934
|
+
if (command) assertSafeCommand(command);
|
|
3935
|
+
for (const c of pipeline) assertSafeCommand(c);
|
|
3287
3936
|
const args2 = Array.isArray(a.args) ? a.args.map(String) : void 0;
|
|
3288
3937
|
const stdin = typeof a.stdin === "string" ? a.stdin : void 0;
|
|
3289
3938
|
const shellOverride = typeof a.shell === "string" ? a.shell : "auto";
|
|
3290
3939
|
const cwd = typeof a.cwd === "string" && a.cwd ? a.cwd : void 0;
|
|
3291
3940
|
const timeoutMs = a.timeout ? Number(a.timeout) : void 0;
|
|
3941
|
+
const rawIdle = a.idleTimeoutSeconds !== void 0 ? Number(a.idleTimeoutSeconds) : 120;
|
|
3942
|
+
const idleTimeoutMs = Number.isFinite(rawIdle) && rawIdle > 0 ? rawIdle * 1e3 : void 0;
|
|
3943
|
+
const repeat = Math.max(1, Math.min(60, Number(a.repeat) || 1));
|
|
3944
|
+
const intervalSeconds = Math.max(0, Math.min(600, Number(a.intervalSeconds) || 0));
|
|
3945
|
+
const repeatOutput = a.output === "diff" || a.output === "last" ? a.output : "all";
|
|
3946
|
+
const format = a.format === "ndjson" ? "ndjson" : "text";
|
|
3947
|
+
const pipelineStopOnError = a.pipelineStopOnError === true;
|
|
3292
3948
|
const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
|
|
3293
3949
|
if (targetIds.length === 0) {
|
|
3294
3950
|
return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
|
|
@@ -3299,44 +3955,88 @@ async function executeToolCall(name, a, _serverId) {
|
|
|
3299
3955
|
if (timeoutMs) conn.timeout = timeoutMs;
|
|
3300
3956
|
const shell = shellOverride === "auto" ? os === "windows" ? "powershell" : "bash" : shellOverride;
|
|
3301
3957
|
let body;
|
|
3302
|
-
|
|
3958
|
+
let pipelineMarker = null;
|
|
3959
|
+
if (pipeline.length > 0) {
|
|
3960
|
+
pipelineMarker = `__MCP_BREAK_${randomBytes(8).toString("hex")}__`;
|
|
3961
|
+
body = buildPipelineScript(pipeline, shell, pipelineMarker, pipelineStopOnError);
|
|
3962
|
+
} else if (args2 && args2.length > 0) {
|
|
3303
3963
|
body = shell === "powershell" ? buildPowerShellEncodedCommand(command, args2) : buildPosixCommand(command, args2);
|
|
3304
3964
|
} else if (shell === "powershell" && !/^powershell\b/i.test(command.trim())) {
|
|
3305
3965
|
body = buildPowerShellEncodedCommand(command, []);
|
|
3306
3966
|
} else {
|
|
3307
3967
|
body = command;
|
|
3308
3968
|
}
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
if (shell === "bash") {
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3969
|
+
const applyCwd = (rawBody) => {
|
|
3970
|
+
if (!cwd) return rawBody;
|
|
3971
|
+
if (shell === "bash") return `cd ${posixQuote(cwd)} && ${rawBody}`;
|
|
3972
|
+
const ecMatch = rawBody.match(/-EncodedCommand\s+(\S+)$/);
|
|
3973
|
+
if (!ecMatch) return rawBody;
|
|
3974
|
+
const decoded = Buffer.from(ecMatch[1], "base64").toString("utf16le");
|
|
3975
|
+
const wrapped = `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; ${decoded}`;
|
|
3976
|
+
const reencoded = Buffer.from(wrapped, "utf16le").toString("base64");
|
|
3977
|
+
return rawBody.replace(/-EncodedCommand\s+\S+$/, `-EncodedCommand ${reencoded}`);
|
|
3978
|
+
};
|
|
3979
|
+
const finalCmd = applyCwd(body);
|
|
3980
|
+
if (repeat > 1) {
|
|
3981
|
+
const iterations = [];
|
|
3982
|
+
for (let i = 0; i < repeat; i++) {
|
|
3983
|
+
const startedAt = Date.now();
|
|
3984
|
+
const r = await sshExec(conn, finalCmd, proxy, {
|
|
3985
|
+
...stdin !== void 0 ? { stdin } : {},
|
|
3986
|
+
idleTimeoutMs
|
|
3987
|
+
});
|
|
3988
|
+
iterations.push({ index: i, startedAt, durationMs: Date.now() - startedAt, result: r });
|
|
3989
|
+
if (i < repeat - 1 && intervalSeconds > 0) {
|
|
3990
|
+
await new Promise((res) => setTimeout(res, intervalSeconds * 1e3));
|
|
3320
3991
|
}
|
|
3321
3992
|
}
|
|
3993
|
+
const last = iterations[iterations.length - 1].result;
|
|
3994
|
+
let serverName2 = serverId;
|
|
3995
|
+
try {
|
|
3996
|
+
const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
|
|
3997
|
+
if (data?.name) serverName2 = data.name;
|
|
3998
|
+
} catch {
|
|
3999
|
+
}
|
|
4000
|
+
return { serverId, serverName: serverName2, os, shell, result: last, iterations };
|
|
4001
|
+
}
|
|
4002
|
+
const result = await sshExec(conn, finalCmd, proxy, {
|
|
4003
|
+
...stdin !== void 0 ? { stdin } : {},
|
|
4004
|
+
idleTimeoutMs
|
|
4005
|
+
});
|
|
4006
|
+
let pipelineSegments;
|
|
4007
|
+
if (pipelineMarker && pipeline.length > 0) {
|
|
4008
|
+
pipelineSegments = parsePipelineOutput(result.stdout, pipeline, pipelineMarker);
|
|
3322
4009
|
}
|
|
3323
|
-
const result = await sshExec(conn, finalCmd, proxy, stdin !== void 0 ? { stdin } : void 0);
|
|
3324
4010
|
let serverName = serverId;
|
|
3325
4011
|
try {
|
|
3326
|
-
const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).
|
|
4012
|
+
const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
|
|
3327
4013
|
if (data?.name) serverName = data.name;
|
|
3328
4014
|
} catch {
|
|
3329
4015
|
}
|
|
3330
|
-
return { serverId, serverName, os, shell, result };
|
|
4016
|
+
return { serverId, serverName, os, shell, result, pipelineSegments };
|
|
4017
|
+
};
|
|
4018
|
+
const renderOne = (r) => {
|
|
4019
|
+
if (r.iterations && r.iterations.length > 0) {
|
|
4020
|
+
return renderRepeatResult(r.iterations, repeatOutput, format);
|
|
4021
|
+
}
|
|
4022
|
+
if (r.pipelineSegments) {
|
|
4023
|
+
return renderPipelineSegments(r.pipelineSegments, r.result, format);
|
|
4024
|
+
}
|
|
4025
|
+
const output = [`Exit code: ${r.result.exitCode} (os: ${r.os}, shell: ${r.shell})`];
|
|
4026
|
+
let stdoutText = r.result.stdout;
|
|
4027
|
+
if (format === "ndjson" && stdoutText) {
|
|
4028
|
+
const parsed = tryParseNdjsonFromCommand(command, stdoutText);
|
|
4029
|
+
if (parsed) stdoutText = parsed;
|
|
4030
|
+
}
|
|
4031
|
+
if (stdoutText) output.push(`--- stdout ---
|
|
4032
|
+
${stdoutText}`);
|
|
4033
|
+
if (r.result.stderr) output.push(`--- stderr ---
|
|
4034
|
+
${r.result.stderr}`);
|
|
4035
|
+
return output.join("\n");
|
|
3331
4036
|
};
|
|
3332
4037
|
if (targetIds.length === 1) {
|
|
3333
|
-
const
|
|
3334
|
-
|
|
3335
|
-
if (result.stdout) output.push(`--- stdout ---
|
|
3336
|
-
${result.stdout}`);
|
|
3337
|
-
if (result.stderr) output.push(`--- stderr ---
|
|
3338
|
-
${result.stderr}`);
|
|
3339
|
-
return { content: [{ type: "text", text: output.join("\n") }] };
|
|
4038
|
+
const r = await runOne(targetIds[0]);
|
|
4039
|
+
return { content: [{ type: "text", text: renderOne(r) }] };
|
|
3340
4040
|
}
|
|
3341
4041
|
const allResults = [];
|
|
3342
4042
|
for (let i = 0; i < targetIds.length; i += parallelism) {
|
|
@@ -3360,10 +4060,8 @@ ${result.stderr}`);
|
|
|
3360
4060
|
if (r.result.exitCode === 0) okCount++;
|
|
3361
4061
|
else failCount++;
|
|
3362
4062
|
const header = `=== ${r.serverName} (${r.os}, exit ${r.result.exitCode}) ===`;
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
if (r.result.stderr) parts.push(`[stderr] ${r.result.stderr.trimEnd()}`);
|
|
3366
|
-
sections.push(parts.join("\n"));
|
|
4063
|
+
sections.push(`${header}
|
|
4064
|
+
${renderOne(r)}`);
|
|
3367
4065
|
}
|
|
3368
4066
|
sections.push(`--- fan-out summary: ${okCount} ok / ${failCount} failed across ${allResults.length} servers (parallelism ${parallelism}) ---`);
|
|
3369
4067
|
return { content: [{ type: "text", text: sections.join("\n\n") }] };
|
|
@@ -3465,6 +4163,46 @@ ${result.stderr}`);
|
|
|
3465
4163
|
}
|
|
3466
4164
|
case "sftp-write": {
|
|
3467
4165
|
const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
|
|
4166
|
+
if (a.sourceRemote && typeof a.sourceRemote === "object") {
|
|
4167
|
+
const srcRaw = a.sourceRemote;
|
|
4168
|
+
const srcBucket = typeof srcRaw.bucket === "string" && srcRaw.bucket ? String(srcRaw.bucket) : "";
|
|
4169
|
+
const srcServerId = typeof srcRaw.serverId === "string" && srcRaw.serverId ? String(srcRaw.serverId) : "";
|
|
4170
|
+
const srcPath = String(srcRaw.path || "");
|
|
4171
|
+
if (!srcPath) return { content: [{ type: "text", text: "Error: sourceRemote.path is required" }] };
|
|
4172
|
+
if (!srcBucket && !srcServerId) return { content: [{ type: "text", text: "Error: sourceRemote needs `serverId` or `bucket`" }] };
|
|
4173
|
+
if (srcBucket && srcServerId) return { content: [{ type: "text", text: "Error: sourceRemote can have either `serverId` or `bucket`, not both" }] };
|
|
4174
|
+
const dstPath = String(a.path || "");
|
|
4175
|
+
if (!dstPath) return { content: [{ type: "text", text: "Error: target `path` is required" }] };
|
|
4176
|
+
let srcEp;
|
|
4177
|
+
if (srcBucket) {
|
|
4178
|
+
const k = r2Key(srcPath);
|
|
4179
|
+
if (!k) return { content: [{ type: "text", text: "Error: sourceRemote.path resolves to empty key" }] };
|
|
4180
|
+
srcEp = { kind: "r2", bucket: srcBucket, key: k };
|
|
4181
|
+
} else {
|
|
4182
|
+
const { conn: conn2, proxy: proxy2 } = await getServerConnection(srcServerId);
|
|
4183
|
+
srcEp = { kind: "ssh", opts: conn2, proxy: proxy2, path: srcPath };
|
|
4184
|
+
}
|
|
4185
|
+
let dstEp;
|
|
4186
|
+
if (bucket) {
|
|
4187
|
+
const k = r2Key(dstPath);
|
|
4188
|
+
if (!k) return { content: [{ type: "text", text: "Error: target path resolves to empty key" }] };
|
|
4189
|
+
dstEp = { kind: "r2", bucket, key: k };
|
|
4190
|
+
} else if (a.serverId) {
|
|
4191
|
+
const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
|
|
4192
|
+
dstEp = { kind: "ssh", opts: conn2, proxy: proxy2, path: dstPath };
|
|
4193
|
+
} else {
|
|
4194
|
+
return { content: [{ type: "text", text: "Error: target needs `serverId` or `bucket`" }] };
|
|
4195
|
+
}
|
|
4196
|
+
try {
|
|
4197
|
+
const { bytes, elapsedMs, mode } = await sftpCopy(srcEp, dstEp);
|
|
4198
|
+
const srcLabel = srcEp.kind === "r2" ? `r2://${srcEp.bucket}/${srcEp.key}` : `ssh:${srcPath}`;
|
|
4199
|
+
const dstLabel = dstEp.kind === "r2" ? `r2://${dstEp.bucket}/${dstEp.key}` : `ssh:${dstPath}`;
|
|
4200
|
+
const mbps = bytes > 0 && elapsedMs > 0 ? bytes / (1024 * 1024) / (elapsedMs / 1e3) : 0;
|
|
4201
|
+
return { content: [{ type: "text", text: `Copied ${formatBytes(bytes)} from ${srcLabel} \u2192 ${dstLabel} via ${mode} in ${(elapsedMs / 1e3).toFixed(2)}s${mbps > 0 ? ` (${mbps.toFixed(1)} MB/s)` : ""}` }] };
|
|
4202
|
+
} catch (e) {
|
|
4203
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
3468
4206
|
let fileMode;
|
|
3469
4207
|
if (typeof a.mode === "number" && Number.isFinite(a.mode)) fileMode = a.mode & 4095;
|
|
3470
4208
|
else if (typeof a.mode === "string" && a.mode) {
|
|
@@ -3527,14 +4265,51 @@ ${result.stderr}`);
|
|
|
3527
4265
|
case "sftp-delete": {
|
|
3528
4266
|
const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
|
|
3529
4267
|
const recursive = a.recursive === true;
|
|
4268
|
+
const dryRun = a.dryRun === true;
|
|
4269
|
+
const confirmAbove = a.confirmAbove !== void 0 ? Math.max(0, Number(a.confirmAbove)) : void 0;
|
|
4270
|
+
const confirmCount = a.confirmCount !== void 0 ? Number(a.confirmCount) : void 0;
|
|
4271
|
+
const guard = (count, sizeBytes, where) => {
|
|
4272
|
+
if (confirmAbove !== void 0 && count > confirmAbove) {
|
|
4273
|
+
if (confirmCount === count) return null;
|
|
4274
|
+
return {
|
|
4275
|
+
content: [{
|
|
4276
|
+
type: "text",
|
|
4277
|
+
text: `Refusing to delete ${count} item(s) (${formatBytes(sizeBytes)}) under ${where}: exceeds confirmAbove=${confirmAbove}. To proceed, re-issue the call with confirmCount: ${count}.`
|
|
4278
|
+
}]
|
|
4279
|
+
};
|
|
4280
|
+
}
|
|
4281
|
+
return null;
|
|
4282
|
+
};
|
|
3530
4283
|
if (bucket) {
|
|
3531
4284
|
const key = r2Key(String(a.path));
|
|
3532
4285
|
if (!key) return { content: [{ type: "text", text: "Error: path is required" }] };
|
|
3533
4286
|
try {
|
|
3534
4287
|
if (recursive) {
|
|
4288
|
+
if (dryRun || confirmAbove !== void 0) {
|
|
4289
|
+
const listed = await r2List(bucket, key, { recursive: true, maxResults: 5e4 });
|
|
4290
|
+
const objects = listed.filter((e) => !e.isPrefix);
|
|
4291
|
+
const totalSize = objects.reduce((s, o) => s + (o.size || 0), 0);
|
|
4292
|
+
if (dryRun) {
|
|
4293
|
+
const sample = objects.slice(0, 5).map((o) => ` - r2://${bucket}/${o.key} (${formatBytes(o.size || 0)})`);
|
|
4294
|
+
return { content: [{ type: "text", text: `[dry-run] Would delete ${objects.length} object(s) totalling ${formatBytes(totalSize)} under r2://${bucket}/${key}${objects.length > 5 ? `
|
|
4295
|
+
${sample.join("\n")}
|
|
4296
|
+
... and ${objects.length - 5} more` : objects.length ? `
|
|
4297
|
+
${sample.join("\n")}` : ""}` }] };
|
|
4298
|
+
}
|
|
4299
|
+
const block = guard(objects.length, totalSize, `r2://${bucket}/${key}`);
|
|
4300
|
+
if (block) return block;
|
|
4301
|
+
}
|
|
3535
4302
|
const deleted = await r2DeletePrefix(bucket, key);
|
|
3536
4303
|
return { content: [{ type: "text", text: `Deleted ${deleted} object(s) under r2://${bucket}/${key}` }] };
|
|
3537
4304
|
}
|
|
4305
|
+
if (dryRun) {
|
|
4306
|
+
try {
|
|
4307
|
+
const head = await getR2Client().send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
4308
|
+
return { content: [{ type: "text", text: `[dry-run] Would delete r2://${bucket}/${key} (${formatBytes(head.ContentLength || 0)})` }] };
|
|
4309
|
+
} catch {
|
|
4310
|
+
return { content: [{ type: "text", text: `[dry-run] r2://${bucket}/${key} not found (delete would 404)` }] };
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
3538
4313
|
await r2DeleteObject(bucket, key);
|
|
3539
4314
|
return { content: [{ type: "text", text: `Deleted r2://${bucket}/${key}` }] };
|
|
3540
4315
|
} catch (e) {
|
|
@@ -3542,6 +4317,25 @@ ${result.stderr}`);
|
|
|
3542
4317
|
}
|
|
3543
4318
|
}
|
|
3544
4319
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
4320
|
+
if (recursive && (dryRun || confirmAbove !== void 0)) {
|
|
4321
|
+
const listed = await sftpReaddir(conn, String(a.path), proxy, {
|
|
4322
|
+
recursive: true,
|
|
4323
|
+
maxResults: 5e4
|
|
4324
|
+
});
|
|
4325
|
+
const files = listed.entries.filter((e) => e.kind === "-");
|
|
4326
|
+
const dirs = listed.entries.filter((e) => e.kind === "d");
|
|
4327
|
+
const totalSize = files.reduce((s, f) => s + (f.size || 0), 0);
|
|
4328
|
+
if (dryRun) {
|
|
4329
|
+
const sample = files.slice(0, 5).map((f) => ` - ${f.path} (${formatBytes(f.size || 0)})`);
|
|
4330
|
+
return { content: [{ type: "text", text: `[dry-run] Would delete ${files.length} file(s) + ${dirs.length} directory(ies) totalling ${formatBytes(totalSize)} under ${a.path}${files.length ? `
|
|
4331
|
+
${sample.join("\n")}${files.length > 5 ? `
|
|
4332
|
+
... and ${files.length - 5} more` : ""}` : ""}` }] };
|
|
4333
|
+
}
|
|
4334
|
+
const block = guard(files.length + dirs.length, totalSize, String(a.path));
|
|
4335
|
+
if (block) return block;
|
|
4336
|
+
} else if (dryRun) {
|
|
4337
|
+
return { content: [{ type: "text", text: `[dry-run] Would delete ${a.path} (single file/dir; pass recursive: true to preview tree contents)` }] };
|
|
4338
|
+
}
|
|
3545
4339
|
const result = await sftpDelete(conn, String(a.path), proxy, { recursive });
|
|
3546
4340
|
return { content: [{ type: "text", text: result }] };
|
|
3547
4341
|
}
|
|
@@ -3551,75 +4345,136 @@ ${result.stderr}`);
|
|
|
3551
4345
|
const composeOnly = a.composeOnly === true;
|
|
3552
4346
|
const groupByProject = a.groupByProject === true;
|
|
3553
4347
|
const nameFiltersRaw = Array.isArray(a.nameFilter) ? a.nameFilter.map((v) => String(v).trim()).filter(Boolean) : typeof a.nameFilter === "string" && a.nameFilter.trim() ? [a.nameFilter.trim()] : [];
|
|
4348
|
+
const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
|
|
4349
|
+
if (targetIds.length === 0) {
|
|
4350
|
+
return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
|
|
4351
|
+
}
|
|
4352
|
+
const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
|
|
3554
4353
|
const filterArg = composeOnly ? ` --filter "label=com.docker.compose.project"` : "";
|
|
3555
4354
|
const cmd = `docker ps -a${filterArg} --format '{{json .}}'`;
|
|
3556
|
-
const
|
|
3557
|
-
|
|
3558
|
-
if (result.exitCode !== 0) {
|
|
3559
|
-
return { content: [{ type: "text", text: `Error: ${result.stderr || result.stdout}` }] };
|
|
3560
|
-
}
|
|
3561
|
-
const containers = [];
|
|
3562
|
-
for (const line of result.stdout.split("\n")) {
|
|
3563
|
-
const trimmed = line.trim();
|
|
3564
|
-
if (!trimmed) continue;
|
|
4355
|
+
const probeOne = async (serverId) => {
|
|
4356
|
+
let serverName = serverId.slice(0, 8);
|
|
3565
4357
|
try {
|
|
3566
|
-
|
|
3567
|
-
|
|
4358
|
+
const { conn, proxy } = await getServerConnection(serverId);
|
|
4359
|
+
const r = await sshExec(conn, cmd, proxy);
|
|
4360
|
+
const cached = KNOWN_SERVER_NAMES.get(serverId);
|
|
4361
|
+
if (cached) serverName = cached.name;
|
|
4362
|
+
if (r.exitCode !== 0) {
|
|
4363
|
+
return { serverId, serverName, rows: [], error: (r.stderr || r.stdout || "exit " + r.exitCode).trim().slice(0, 200) };
|
|
4364
|
+
}
|
|
4365
|
+
const rows = [];
|
|
4366
|
+
for (const line of r.stdout.split("\n")) {
|
|
4367
|
+
const t = line.trim();
|
|
4368
|
+
if (!t) continue;
|
|
4369
|
+
try {
|
|
4370
|
+
rows.push(JSON.parse(t));
|
|
4371
|
+
} catch {
|
|
4372
|
+
}
|
|
4373
|
+
}
|
|
4374
|
+
for (const c of rows) {
|
|
4375
|
+
const m = (c.Labels || "").match(/com\.docker\.compose\.project=([^,]+)/);
|
|
4376
|
+
c.Project = m ? m[1] : "";
|
|
4377
|
+
const status = c.Status || "";
|
|
4378
|
+
if (/\(unhealthy\)/i.test(status)) c.Health = "unhealthy";
|
|
4379
|
+
else if (/\(health: starting\)|\(starting\)/i.test(status)) c.Health = "starting";
|
|
4380
|
+
else if (/\(healthy\)/i.test(status)) c.Health = "healthy";
|
|
4381
|
+
else if (/^Up\b/i.test(status)) c.Health = "no-check";
|
|
4382
|
+
else c.Health = "";
|
|
4383
|
+
}
|
|
4384
|
+
rememberContainers(serverId, rows.map((r2) => r2.Names || "").filter(Boolean));
|
|
4385
|
+
return { serverId, serverName, rows };
|
|
4386
|
+
} catch (err) {
|
|
4387
|
+
return { serverId, serverName, rows: [], error: err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200) };
|
|
3568
4388
|
}
|
|
4389
|
+
};
|
|
4390
|
+
const perServer = [];
|
|
4391
|
+
for (let i = 0; i < targetIds.length; i += parallelism) {
|
|
4392
|
+
const batch = targetIds.slice(i, i + parallelism);
|
|
4393
|
+
const chunk = await Promise.all(batch.map(probeOne));
|
|
4394
|
+
perServer.push(...chunk);
|
|
3569
4395
|
}
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
c.Project = m ? m[1] : "";
|
|
3573
|
-
const status = c.Status || "";
|
|
3574
|
-
if (/\(unhealthy\)/i.test(status)) c.Health = "unhealthy";
|
|
3575
|
-
else if (/\(health: starting\)|\(starting\)/i.test(status)) c.Health = "starting";
|
|
3576
|
-
else if (/\(healthy\)/i.test(status)) c.Health = "healthy";
|
|
3577
|
-
else if (/^Up\b/i.test(status)) c.Health = "no-check";
|
|
3578
|
-
else c.Health = "";
|
|
3579
|
-
}
|
|
3580
|
-
let filtered = containers;
|
|
3581
|
-
if (nameFiltersRaw.length > 0) {
|
|
4396
|
+
const applyFilter = (rows) => {
|
|
4397
|
+
if (nameFiltersRaw.length === 0) return rows;
|
|
3582
4398
|
const matchers = nameFiltersRaw.map((raw) => {
|
|
3583
4399
|
if (raw.includes("*") || raw.includes("?")) return { kind: "glob", re: globToRegExp(raw) };
|
|
3584
4400
|
return { kind: "sub", lower: raw.toLowerCase() };
|
|
3585
4401
|
});
|
|
3586
|
-
|
|
4402
|
+
return rows.filter((c) => {
|
|
3587
4403
|
const name2 = c.Names || "";
|
|
3588
4404
|
const lower = name2.toLowerCase();
|
|
3589
4405
|
return matchers.some((m) => m.kind === "glob" ? m.re.test(name2) : lower.includes(m.lower));
|
|
3590
4406
|
});
|
|
3591
|
-
}
|
|
3592
|
-
|
|
4407
|
+
};
|
|
4408
|
+
const sortRows = (rows) => rows.sort((x, y) => {
|
|
3593
4409
|
const px = x.Project || "~";
|
|
3594
4410
|
const py = y.Project || "~";
|
|
3595
4411
|
const p = px.localeCompare(py);
|
|
3596
4412
|
return p !== 0 ? p : (x.Names || "").localeCompare(y.Names || "");
|
|
3597
4413
|
});
|
|
4414
|
+
const fmtRow = (r) => `${(r.Names || "").padEnd(36)} ${(r.Image || "").padEnd(40)} ${(r.Status || "").padEnd(24)} ${(r.Health || "").padEnd(10)} ${(r.Ports || "").padEnd(40)}`;
|
|
4415
|
+
if (targetIds.length === 1) {
|
|
4416
|
+
const only = perServer[0];
|
|
4417
|
+
if (only.error) return { content: [{ type: "text", text: `Error: ${only.error}` }] };
|
|
4418
|
+
const filtered = sortRows(applyFilter(only.rows));
|
|
4419
|
+
if (format === "json") {
|
|
4420
|
+
const text2 = filtered.map((c) => JSON.stringify(c)).join("\n") || "(no containers)";
|
|
4421
|
+
return { content: [{ type: "text", text: text2 }] };
|
|
4422
|
+
}
|
|
4423
|
+
if (groupByProject) {
|
|
4424
|
+
const groups = /* @__PURE__ */ new Map();
|
|
4425
|
+
for (const c of filtered) {
|
|
4426
|
+
const key = c.Project || "(no compose project)";
|
|
4427
|
+
const list = groups.get(key);
|
|
4428
|
+
if (list) list.push(c);
|
|
4429
|
+
else groups.set(key, [c]);
|
|
4430
|
+
}
|
|
4431
|
+
const out = [];
|
|
4432
|
+
for (const [project, rows] of groups) {
|
|
4433
|
+
out.push(`=== ${project} (${rows.length}) ===`);
|
|
4434
|
+
for (const r of rows) out.push(" " + fmtRow(r));
|
|
4435
|
+
out.push("");
|
|
4436
|
+
}
|
|
4437
|
+
return { content: [{ type: "text", text: out.join("\n").trimEnd() || "(no containers)" }] };
|
|
4438
|
+
}
|
|
4439
|
+
const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
|
|
4440
|
+
const body = filtered.map((r) => `${fmtRow(r)} ${r.Project || ""}`);
|
|
4441
|
+
const text = filtered.length === 0 ? "(no containers match)" : [header, ...body].join("\n");
|
|
4442
|
+
return { content: [{ type: "text", text }] };
|
|
4443
|
+
}
|
|
3598
4444
|
if (format === "json") {
|
|
3599
|
-
const
|
|
3600
|
-
|
|
4445
|
+
const lines = [];
|
|
4446
|
+
for (const ps of perServer) {
|
|
4447
|
+
if (ps.error) {
|
|
4448
|
+
lines.push(JSON.stringify({ Server: ps.serverName, Error: ps.error }));
|
|
4449
|
+
continue;
|
|
4450
|
+
}
|
|
4451
|
+
const filtered = sortRows(applyFilter(ps.rows));
|
|
4452
|
+
for (const c of filtered) lines.push(JSON.stringify({ Server: ps.serverName, ...c }));
|
|
4453
|
+
}
|
|
4454
|
+
return { content: [{ type: "text", text: lines.join("\n") || "(no containers)" }] };
|
|
3601
4455
|
}
|
|
3602
|
-
const
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
else groups.set(key, [c]);
|
|
4456
|
+
const sections = [];
|
|
4457
|
+
let totalRows = 0;
|
|
4458
|
+
let serversWithRows = 0;
|
|
4459
|
+
for (const ps of perServer) {
|
|
4460
|
+
if (ps.error) {
|
|
4461
|
+
sections.push(`=== ${ps.serverName} === !! ${ps.error}`);
|
|
4462
|
+
continue;
|
|
3610
4463
|
}
|
|
3611
|
-
const
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
out.push("");
|
|
4464
|
+
const filtered = sortRows(applyFilter(ps.rows));
|
|
4465
|
+
if (filtered.length === 0) {
|
|
4466
|
+
sections.push(`=== ${ps.serverName} === (no containers match)`);
|
|
4467
|
+
continue;
|
|
3616
4468
|
}
|
|
3617
|
-
|
|
4469
|
+
serversWithRows++;
|
|
4470
|
+
totalRows += filtered.length;
|
|
4471
|
+
sections.push(`=== ${ps.serverName} (${filtered.length} container(s)) ===`);
|
|
4472
|
+
const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
|
|
4473
|
+
sections.push(header);
|
|
4474
|
+
for (const r of filtered) sections.push(`${fmtRow(r)} ${r.Project || ""}`);
|
|
3618
4475
|
}
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
const text = filtered.length === 0 ? "(no containers match)" : [header, ...body].join("\n");
|
|
3622
|
-
return { content: [{ type: "text", text }] };
|
|
4476
|
+
sections.push(`--- fan-out summary: ${totalRows} container(s) across ${serversWithRows}/${perServer.length} server(s) ---`);
|
|
4477
|
+
return { content: [{ type: "text", text: sections.join("\n") }] };
|
|
3623
4478
|
}
|
|
3624
4479
|
case "docker-logs": {
|
|
3625
4480
|
const rawNames = Array.isArray(a.containerName) ? a.containerName.map(String) : [String(a.containerName)];
|
|
@@ -3637,37 +4492,71 @@ ${result.stderr}`);
|
|
|
3637
4492
|
}
|
|
3638
4493
|
const sinceArg = sinceRaw ? ` --since ${posixQuote(sinceRaw)}` : "";
|
|
3639
4494
|
const grepSuffix = grepRaw ? ` | grep -i -E ${posixQuote(grepRaw)} --line-buffered` : "";
|
|
3640
|
-
const
|
|
4495
|
+
const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
|
|
4496
|
+
if (targetIds.length === 0) {
|
|
4497
|
+
return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
|
|
4498
|
+
}
|
|
4499
|
+
const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
|
|
3641
4500
|
const wallSeconds = followSeconds > 0 ? followSeconds : 30;
|
|
3642
|
-
conn.timeout = (wallSeconds + 10) * 1e3;
|
|
3643
4501
|
const followFlag = followSeconds > 0 ? " -f" : "";
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
const acceptable2 = result2.exitCode === 0 || result2.exitCode === 124 || result2.exitCode === 130 || result2.exitCode === 143 || !!grepRaw && result2.exitCode === 1;
|
|
3650
|
-
if (!acceptable2 && !result2.stdout) {
|
|
3651
|
-
return { content: [{ type: "text", text: `Error (exit ${result2.exitCode}): ${result2.stderr || "(no output)"}` }] };
|
|
4502
|
+
const buildCmd = () => {
|
|
4503
|
+
if (containers.length === 1) {
|
|
4504
|
+
const c = containers[0];
|
|
4505
|
+
const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
|
|
4506
|
+
return `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(inner)}`;
|
|
3652
4507
|
}
|
|
3653
|
-
const
|
|
4508
|
+
const subShells = containers.map((c) => {
|
|
4509
|
+
const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
|
|
4510
|
+
return `(${inner} | sed -u -e ${posixQuote(`s/^/[${c}] /`)})`;
|
|
4511
|
+
});
|
|
4512
|
+
const innerCmd = subShells.join(" & ") + " & wait";
|
|
4513
|
+
return `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(innerCmd)}`;
|
|
4514
|
+
};
|
|
4515
|
+
const cmd = buildCmd();
|
|
4516
|
+
const probeOne = async (serverId) => {
|
|
4517
|
+
const { conn, proxy } = await getServerConnection(serverId);
|
|
4518
|
+
conn.timeout = (wallSeconds + 10) * 1e3;
|
|
4519
|
+
const result = await sshExec(conn, cmd, proxy);
|
|
4520
|
+
const cached = KNOWN_SERVER_NAMES.get(serverId);
|
|
4521
|
+
return { serverId, serverName: cached?.name || serverId.slice(0, 8), result };
|
|
4522
|
+
};
|
|
4523
|
+
const acceptableCode = (code) => code === 0 || code === 124 || code === 130 || code === 143 || !!grepRaw && code === 1;
|
|
4524
|
+
if (targetIds.length === 1) {
|
|
4525
|
+
const r = await probeOne(targetIds[0]);
|
|
4526
|
+
const result = r.result;
|
|
4527
|
+
if (!acceptableCode(result.exitCode) && !result.stdout) {
|
|
4528
|
+
const known = getKnownContainers(targetIds[0]);
|
|
4529
|
+
if (known && containers.length === 1) {
|
|
4530
|
+
const hits = suggestSimilar(containers[0], known);
|
|
4531
|
+
const hint = hits.length ? ` Did you mean: ${hits.join(", ")}?` : "";
|
|
4532
|
+
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}${hint}` }] };
|
|
4533
|
+
}
|
|
4534
|
+
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}` }] };
|
|
4535
|
+
}
|
|
4536
|
+
const note = result.exitCode === 124 ? `
|
|
3654
4537
|
(note: command exceeded ${wallSeconds}s wall budget; partial output)` : "";
|
|
3655
|
-
return { content: [{ type: "text", text: (
|
|
4538
|
+
return { content: [{ type: "text", text: (result.stdout || "(no log lines matched)") + note }] };
|
|
3656
4539
|
}
|
|
3657
|
-
const
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
const cmd = `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(innerCmd)}`;
|
|
3663
|
-
const result = await sshExec(conn, cmd, proxy);
|
|
3664
|
-
const acceptable = result.exitCode === 0 || result.exitCode === 124 || result.exitCode === 130 || result.exitCode === 143 || !!grepRaw && result.exitCode === 1;
|
|
3665
|
-
if (!acceptable && !result.stdout) {
|
|
3666
|
-
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}` }] };
|
|
4540
|
+
const perServer = [];
|
|
4541
|
+
for (let i = 0; i < targetIds.length; i += parallelism) {
|
|
4542
|
+
const batch = targetIds.slice(i, i + parallelism);
|
|
4543
|
+
const chunk = await Promise.all(batch.map(probeOne));
|
|
4544
|
+
perServer.push(...chunk);
|
|
3667
4545
|
}
|
|
3668
|
-
const
|
|
3669
|
-
|
|
3670
|
-
|
|
4546
|
+
const sections = [];
|
|
4547
|
+
let okCount = 0;
|
|
4548
|
+
for (const ps of perServer) {
|
|
4549
|
+
if (acceptableCode(ps.result.exitCode) || ps.result.stdout) {
|
|
4550
|
+
okCount++;
|
|
4551
|
+
const note = ps.result.exitCode === 124 ? ` (timed out after ${wallSeconds}s; partial)` : "";
|
|
4552
|
+
sections.push(`=== ${ps.serverName}${note} ===`);
|
|
4553
|
+
sections.push(ps.result.stdout || "(no log lines matched)");
|
|
4554
|
+
} else {
|
|
4555
|
+
sections.push(`=== ${ps.serverName} === !! exit ${ps.result.exitCode}: ${ps.result.stderr.trim().slice(0, 200) || "(no output)"}`);
|
|
4556
|
+
}
|
|
4557
|
+
}
|
|
4558
|
+
sections.push(`--- fan-out summary: ${okCount}/${perServer.length} server(s) returned logs ---`);
|
|
4559
|
+
return { content: [{ type: "text", text: sections.join("\n") }] };
|
|
3671
4560
|
}
|
|
3672
4561
|
case "docker-exec": {
|
|
3673
4562
|
const container = String(a.container).replace(/[^a-zA-Z0-9._-]/g, "");
|
|
@@ -3692,8 +4581,20 @@ ${result.stderr}`);
|
|
|
3692
4581
|
const output = [`Exit code: ${result.exitCode}`];
|
|
3693
4582
|
if (result.stdout) output.push(`--- stdout ---
|
|
3694
4583
|
${result.stdout}`);
|
|
3695
|
-
if (result.stderr)
|
|
3696
|
-
|
|
4584
|
+
if (result.stderr) {
|
|
4585
|
+
let stderrOut = result.stderr;
|
|
4586
|
+
const noSuch = /No such container:\s*(\S+)/i.exec(result.stderr);
|
|
4587
|
+
if (noSuch) {
|
|
4588
|
+
const known = getKnownContainers(String(a.serverId));
|
|
4589
|
+
if (known && known.length) {
|
|
4590
|
+
const hits = suggestSimilar(noSuch[1], known);
|
|
4591
|
+
if (hits.length) stderrOut += `
|
|
4592
|
+
(hint) Did you mean: ${hits.join(", ")}?`;
|
|
4593
|
+
}
|
|
4594
|
+
}
|
|
4595
|
+
output.push(`--- stderr ---
|
|
4596
|
+
${stderrOut}`);
|
|
4597
|
+
}
|
|
3697
4598
|
return { content: [{ type: "text", text: output.join("\n") }] };
|
|
3698
4599
|
}
|
|
3699
4600
|
case "docker-compose": {
|
|
@@ -3745,6 +4646,94 @@ ${result.stderr}`);
|
|
|
3745
4646
|
${result.stderr}`);
|
|
3746
4647
|
return { content: [{ type: "text", text: output.join("\n") }] };
|
|
3747
4648
|
}
|
|
4649
|
+
// ----- wait-for: poll until a condition holds (one tool, four types) -----
|
|
4650
|
+
case "wait-for": {
|
|
4651
|
+
const type = String(a.type || "");
|
|
4652
|
+
if (!["url", "container-health", "file-exists", "sftp-exists"].includes(type)) {
|
|
4653
|
+
return { content: [{ type: "text", text: "Error: type must be one of: url, container-health, file-exists, sftp-exists" }] };
|
|
4654
|
+
}
|
|
4655
|
+
const timeoutSeconds = Math.max(1, Math.min(1800, Number(a.timeout) || 120));
|
|
4656
|
+
const intervalSeconds = Math.max(1, Math.min(60, Number(a.intervalSeconds) || 3));
|
|
4657
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
4658
|
+
const trail = [];
|
|
4659
|
+
const check = async () => {
|
|
4660
|
+
if (type === "url") {
|
|
4661
|
+
const target = String(a.target || "");
|
|
4662
|
+
if (!target) return { ok: false, observed: "no target url" };
|
|
4663
|
+
const expectStatus = a.until !== void 0 ? Number(a.until) : 200;
|
|
4664
|
+
try {
|
|
4665
|
+
const ctrl = new AbortController();
|
|
4666
|
+
const t = setTimeout(() => ctrl.abort(), 1e4);
|
|
4667
|
+
const res = await fetch(target, { signal: ctrl.signal, redirect: "manual" });
|
|
4668
|
+
clearTimeout(t);
|
|
4669
|
+
const ok2 = res.status === expectStatus || expectStatus === 200 && res.status >= 200 && res.status < 300;
|
|
4670
|
+
return { ok: ok2, observed: `HTTP ${res.status}` };
|
|
4671
|
+
} catch (e) {
|
|
4672
|
+
return { ok: false, observed: e instanceof Error ? e.message : String(e) };
|
|
4673
|
+
}
|
|
4674
|
+
}
|
|
4675
|
+
if (type === "container-health") {
|
|
4676
|
+
const containerName = String(a.target || "").replace(/[^a-zA-Z0-9._-]/g, "");
|
|
4677
|
+
const until = String(a.until || "healthy");
|
|
4678
|
+
if (!containerName) return { ok: false, observed: "no container name" };
|
|
4679
|
+
if (!a.serverId) return { ok: false, observed: "serverId is required for container-health" };
|
|
4680
|
+
const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
|
|
4681
|
+
const cmd2 = `docker inspect --format '{{json .State}}' ${containerName} 2>&1`;
|
|
4682
|
+
const r2 = await sshExec(conn2, cmd2, proxy2);
|
|
4683
|
+
if (r2.exitCode !== 0) return { ok: false, observed: r2.stderr.trim().slice(0, 80) || "inspect failed" };
|
|
4684
|
+
try {
|
|
4685
|
+
const state = JSON.parse(r2.stdout.trim());
|
|
4686
|
+
const healthStatus = state.Health?.Status;
|
|
4687
|
+
if (until === "healthy") {
|
|
4688
|
+
return { ok: healthStatus === "healthy", observed: `Status=${state.Status} Health=${healthStatus || "no-check"}` };
|
|
4689
|
+
}
|
|
4690
|
+
return { ok: state.Status === until, observed: `Status=${state.Status}` };
|
|
4691
|
+
} catch (e) {
|
|
4692
|
+
return { ok: false, observed: "parse error: " + (e instanceof Error ? e.message : String(e)) };
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4695
|
+
const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
|
|
4696
|
+
const path = String(a.target || a.path || "");
|
|
4697
|
+
if (!path) return { ok: false, observed: "no path" };
|
|
4698
|
+
if (bucket) {
|
|
4699
|
+
try {
|
|
4700
|
+
await getR2Client().send(new HeadObjectCommand({ Bucket: bucket, Key: r2Key(path) }));
|
|
4701
|
+
return { ok: true, observed: `r2://${bucket}/${path} exists` };
|
|
4702
|
+
} catch {
|
|
4703
|
+
return { ok: false, observed: `r2://${bucket}/${path} not found` };
|
|
4704
|
+
}
|
|
4705
|
+
}
|
|
4706
|
+
if (!a.serverId) return { ok: false, observed: "serverId or bucket is required for file-exists" };
|
|
4707
|
+
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
4708
|
+
const safe = sanitizePath(path);
|
|
4709
|
+
const cmd = `test -e ${posixQuote(safe)} && echo OK || echo MISSING`;
|
|
4710
|
+
const r = await sshExec(conn, cmd, proxy);
|
|
4711
|
+
const ok = r.stdout.trim() === "OK";
|
|
4712
|
+
return { ok, observed: ok ? `${safe} exists` : `${safe} missing` };
|
|
4713
|
+
};
|
|
4714
|
+
let attempts = 0;
|
|
4715
|
+
let lastObserved = "";
|
|
4716
|
+
while (Date.now() < deadline) {
|
|
4717
|
+
attempts++;
|
|
4718
|
+
const r = await check();
|
|
4719
|
+
lastObserved = r.observed;
|
|
4720
|
+
trail.push(`[${attempts}] ${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19)} ${r.ok ? "OK" : ".."} ${r.observed}`);
|
|
4721
|
+
if (r.ok) {
|
|
4722
|
+
return { content: [{ type: "text", text: `Condition met after ${attempts} attempt(s) in ${((Date.now() - (deadline - timeoutSeconds * 1e3)) / 1e3).toFixed(1)}s.
|
|
4723
|
+
Last observation: ${r.observed}
|
|
4724
|
+
|
|
4725
|
+
--- trail ---
|
|
4726
|
+
${trail.join("\n")}` }] };
|
|
4727
|
+
}
|
|
4728
|
+
if (Date.now() + intervalSeconds * 1e3 >= deadline) break;
|
|
4729
|
+
await new Promise((res) => setTimeout(res, intervalSeconds * 1e3));
|
|
4730
|
+
}
|
|
4731
|
+
return { content: [{ type: "text", text: `Timed out after ${timeoutSeconds}s without seeing condition (${attempts} probe(s)).
|
|
4732
|
+
Last observation: ${lastObserved}
|
|
4733
|
+
|
|
4734
|
+
--- trail ---
|
|
4735
|
+
${trail.join("\n")}` }] };
|
|
4736
|
+
}
|
|
3748
4737
|
// ----- Database -----
|
|
3749
4738
|
case "db-discover": {
|
|
3750
4739
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
@@ -3776,10 +4765,55 @@ ${result.stderr}`);
|
|
|
3776
4765
|
}
|
|
3777
4766
|
case "db-query": {
|
|
3778
4767
|
const query = String(a.query).trim();
|
|
4768
|
+
if (!query) return { content: [{ type: "text", text: "Error: query is required" }] };
|
|
3779
4769
|
assertSafeSql(query);
|
|
4770
|
+
const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName.replace(/[^a-zA-Z0-9._-]/g, "") : "";
|
|
4771
|
+
const explicitEngine = a.engine === "mysql" || a.engine === "postgres" || a.engine === "mssql" ? a.engine : null;
|
|
4772
|
+
const engine = explicitEngine || (containerName ? "postgres" : "mysql");
|
|
4773
|
+
if (!containerName && engine === "mysql") {
|
|
4774
|
+
if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
|
|
4775
|
+
const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
|
|
4776
|
+
const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
|
|
4777
|
+
return { content: [{ type: "text", text: output2 || "Query executed successfully (no output)" }] };
|
|
4778
|
+
}
|
|
4779
|
+
if (!containerName) {
|
|
4780
|
+
return { content: [{ type: "text", text: `Error: engine=${engine} requires containerName (the docker container running the DB server, e.g. "supabase-db" or "trigger-postgres")` }] };
|
|
4781
|
+
}
|
|
4782
|
+
const dbName = typeof a.dbName === "string" && a.dbName ? a.dbName.replace(/[^a-zA-Z0-9_-]/g, "") : "";
|
|
4783
|
+
const dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") : "";
|
|
3780
4784
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
3781
|
-
|
|
3782
|
-
|
|
4785
|
+
let cmd;
|
|
4786
|
+
let stdinPayload;
|
|
4787
|
+
if (engine === "postgres") {
|
|
4788
|
+
const user = dbUser || "postgres";
|
|
4789
|
+
const db = dbName || "postgres";
|
|
4790
|
+
cmd = `docker exec -i ${containerName} psql -U ${user} -d ${db} -P pager=off`;
|
|
4791
|
+
stdinPayload = query.endsWith(";") ? query : `${query};`;
|
|
4792
|
+
} else if (engine === "mssql") {
|
|
4793
|
+
const user = dbUser || "sa";
|
|
4794
|
+
const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
|
|
4795
|
+
if (!dbPass) return { content: [{ type: "text", text: "Error: mssql requires dbPass (sqlcmd has no equivalent of pg trust auth)" }] };
|
|
4796
|
+
const dbArg = dbName ? `-d ${dbName} ` : "";
|
|
4797
|
+
cmd = `docker exec -i -e MSSQL_SA_PASSWORD=${posixQuote(dbPass)} ${containerName} /opt/mssql-tools18/bin/sqlcmd -S localhost -U ${user} -P "$MSSQL_SA_PASSWORD" -C ${dbArg}-h -1`;
|
|
4798
|
+
stdinPayload = query.endsWith(";") ? `${query}
|
|
4799
|
+
GO
|
|
4800
|
+
` : `${query};
|
|
4801
|
+
GO
|
|
4802
|
+
`;
|
|
4803
|
+
} else {
|
|
4804
|
+
const user = dbUser || "root";
|
|
4805
|
+
const db = dbName ? ` ${dbName}` : "";
|
|
4806
|
+
const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
|
|
4807
|
+
const passArg = dbPass ? `-p${posixQuote(dbPass)} ` : "";
|
|
4808
|
+
cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${db}`;
|
|
4809
|
+
stdinPayload = query.endsWith(";") ? query : `${query};`;
|
|
4810
|
+
}
|
|
4811
|
+
const result = await sshExec(conn, cmd, proxy, { stdin: stdinPayload });
|
|
4812
|
+
const output = result.stdout.trim() || result.stderr.trim() || "(no output)";
|
|
4813
|
+
if (result.exitCode !== 0 && !result.stdout) {
|
|
4814
|
+
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
|
|
4815
|
+
}
|
|
4816
|
+
return { content: [{ type: "text", text: output }] };
|
|
3783
4817
|
}
|
|
3784
4818
|
// ----- Env Config -----
|
|
3785
4819
|
case "env-list": {
|