@linxin666/dsh-ssh 0.1.19 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/lib/client.js +224 -93
- package/lib/client.js.map +1 -1
- package/lib/index.js +182 -91
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/panel/HostsTab.d.ts +16 -0
- package/lib/types/engine/connection-pool.d.ts +2 -0
- package/lib/types/engine/tunnel.d.ts +4 -2
- package/lib/types/engine.d.ts +7 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/routes.d.ts +2 -0
- package/lib/types/store.d.ts +6 -0
- package/package.json +12 -12
- package/src/client/locales.ts +16 -0
- package/src/client/panel/HostsTab.tsx +152 -48
- package/src/client/panel/panel.module.css +75 -0
- package/src/engine/connection-pool.ts +23 -3
- package/src/engine/sftp.ts +29 -38
- package/src/engine/tunnel.ts +19 -5
- package/src/engine.ts +11 -0
- package/src/index.ts +4 -1
- package/src/mount-once.ts +48 -0
- package/src/routes.ts +46 -5
- package/src/store.ts +21 -2
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
|
-
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { Client } from "ssh2";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
@@ -1272,16 +1272,35 @@ var HostStore = class {
|
|
|
1272
1272
|
};
|
|
1273
1273
|
}
|
|
1274
1274
|
skippedNames = /* @__PURE__ */ new Set();
|
|
1275
|
+
/**
|
|
1276
|
+
* Last parsed store keyed by file identity. list/find ride every acquire
|
|
1277
|
+
* and GUI refresh; re-reading and re-parsing the whole file each call is
|
|
1278
|
+
* wasted work when the file has not changed. Any save invalidates.
|
|
1279
|
+
*/
|
|
1280
|
+
cache;
|
|
1275
1281
|
load() {
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
}
|
|
1282
|
+
let stats;
|
|
1283
|
+
try {
|
|
1284
|
+
stats = statSync(this.path);
|
|
1285
|
+
} catch {
|
|
1286
|
+
this.cache = void 0;
|
|
1287
|
+
return {
|
|
1288
|
+
version: FORMAT_VERSION,
|
|
1289
|
+
hosts: []
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
if (this.cache !== void 0 && this.cache.mtimeMs === stats.mtimeMs && this.cache.size === stats.size) return this.cache.file;
|
|
1280
1293
|
try {
|
|
1281
1294
|
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
1282
1295
|
if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.hosts)) throw new Error("store file shape invalid");
|
|
1296
|
+
this.cache = {
|
|
1297
|
+
mtimeMs: stats.mtimeMs,
|
|
1298
|
+
size: stats.size,
|
|
1299
|
+
file: parsed
|
|
1300
|
+
};
|
|
1283
1301
|
return parsed;
|
|
1284
1302
|
} catch {
|
|
1303
|
+
this.cache = void 0;
|
|
1285
1304
|
try {
|
|
1286
1305
|
renameSync(this.path, `${this.path}.corrupt-${Date.now()}`);
|
|
1287
1306
|
} catch {}
|
|
@@ -1303,6 +1322,7 @@ var HostStore = class {
|
|
|
1303
1322
|
mode: 384
|
|
1304
1323
|
});
|
|
1305
1324
|
renameSync(tmp, this.path);
|
|
1325
|
+
this.cache = void 0;
|
|
1306
1326
|
}
|
|
1307
1327
|
};
|
|
1308
1328
|
/** Expand a leading `~` in a filesystem path. */
|
|
@@ -1473,10 +1493,14 @@ function disposeRecord(engine, alias, record) {
|
|
|
1473
1493
|
if (record !== void 0 && current !== record) return;
|
|
1474
1494
|
if (current === void 0) return;
|
|
1475
1495
|
engine.pool.delete(alias);
|
|
1496
|
+
endRecordChain(current);
|
|
1497
|
+
}
|
|
1498
|
+
/** End one record's client and hop chain (best-effort, safe to repeat). */
|
|
1499
|
+
function endRecordChain(record) {
|
|
1476
1500
|
try {
|
|
1477
|
-
|
|
1501
|
+
record.client.end();
|
|
1478
1502
|
} catch {}
|
|
1479
|
-
for (const hop of
|
|
1503
|
+
for (const hop of record.hops) try {
|
|
1480
1504
|
hop.end();
|
|
1481
1505
|
} catch {}
|
|
1482
1506
|
}
|
|
@@ -1503,6 +1527,10 @@ async function withClient(engine, alias, fn, attempts = 3) {
|
|
|
1503
1527
|
const result = await fn(record.client);
|
|
1504
1528
|
record.idleAt = Date.now();
|
|
1505
1529
|
return result;
|
|
1530
|
+
} catch (error) {
|
|
1531
|
+
lastError = error;
|
|
1532
|
+
if (!record.broken) throw error;
|
|
1533
|
+
disposeRecord(engine, alias, record);
|
|
1506
1534
|
} finally {
|
|
1507
1535
|
record.inFlight -= 1;
|
|
1508
1536
|
}
|
|
@@ -1560,8 +1588,12 @@ async function execCommand(engine, alias, command, timeoutMs) {
|
|
|
1560
1588
|
if (settled) return;
|
|
1561
1589
|
settled = true;
|
|
1562
1590
|
clearTimeout(timer);
|
|
1591
|
+
if (typeof code !== "number" && !timedOut) {
|
|
1592
|
+
reject(/* @__PURE__ */ new Error("ssh: connection lost mid-flight (channel closed without an exit status)"));
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1563
1595
|
resolve({
|
|
1564
|
-
success: code === 0
|
|
1596
|
+
success: code === 0,
|
|
1565
1597
|
exitCode: code,
|
|
1566
1598
|
timedOut,
|
|
1567
1599
|
stdout: stdout.text,
|
|
@@ -1673,7 +1705,8 @@ function walkLocalDir(root) {
|
|
|
1673
1705
|
const visit = (dir) => {
|
|
1674
1706
|
for (const name of readdirSync(dir)) {
|
|
1675
1707
|
const full = join(dir, name);
|
|
1676
|
-
const stat =
|
|
1708
|
+
const stat = lstatSync(full);
|
|
1709
|
+
if (stat.isSymbolicLink()) continue;
|
|
1677
1710
|
if (stat.isDirectory()) visit(full);
|
|
1678
1711
|
else if (stat.isFile()) files.push(relative(root, full));
|
|
1679
1712
|
}
|
|
@@ -1797,40 +1830,41 @@ function ensureRemoteDir(sftp, remote) {
|
|
|
1797
1830
|
walk(0);
|
|
1798
1831
|
});
|
|
1799
1832
|
}
|
|
1800
|
-
|
|
1833
|
+
/** One fastPut/fastGet transfer with throttled progress (the two directions share everything but the verb). */
|
|
1834
|
+
function fastTransfer(sftp, kind, src, dst, concurrency, onProgress) {
|
|
1801
1835
|
return new Promise((resolve, reject) => {
|
|
1836
|
+
const file = kind === "put" ? dst : src;
|
|
1837
|
+
const finalSize = () => statSync(kind === "put" ? src : dst).size;
|
|
1802
1838
|
let last = 0;
|
|
1803
1839
|
let lastEmit = 0;
|
|
1804
1840
|
const started = Date.now();
|
|
1805
|
-
onProgress?.({
|
|
1841
|
+
if (kind === "put") onProgress?.({
|
|
1806
1842
|
phase: "transferring",
|
|
1807
|
-
file
|
|
1843
|
+
file,
|
|
1808
1844
|
transferred: 0,
|
|
1809
1845
|
total: statSync(src).size,
|
|
1810
1846
|
percent: 0
|
|
1811
1847
|
});
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
}
|
|
1829
|
-
}, (error) => {
|
|
1848
|
+
const step = (transferred, _chunk, total) => {
|
|
1849
|
+
const now = Date.now();
|
|
1850
|
+
if (now - lastEmit < 100 && transferred < total) return;
|
|
1851
|
+
lastEmit = now;
|
|
1852
|
+
const elapsed = (now - started) / 1e3;
|
|
1853
|
+
onProgress?.({
|
|
1854
|
+
phase: "transferring",
|
|
1855
|
+
file,
|
|
1856
|
+
transferred,
|
|
1857
|
+
total,
|
|
1858
|
+
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
|
|
1859
|
+
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
|
|
1860
|
+
});
|
|
1861
|
+
last = transferred;
|
|
1862
|
+
};
|
|
1863
|
+
const done = (error) => {
|
|
1830
1864
|
if (error !== void 0) {
|
|
1831
1865
|
onProgress?.({
|
|
1832
1866
|
phase: "error",
|
|
1833
|
-
file
|
|
1867
|
+
file,
|
|
1834
1868
|
transferred: 0,
|
|
1835
1869
|
total: 0,
|
|
1836
1870
|
percent: 0,
|
|
@@ -1840,61 +1874,29 @@ function fastPut(sftp, src, dst, concurrency, onProgress) {
|
|
|
1840
1874
|
} else {
|
|
1841
1875
|
onProgress?.({
|
|
1842
1876
|
phase: "done",
|
|
1843
|
-
file
|
|
1844
|
-
transferred:
|
|
1845
|
-
total:
|
|
1877
|
+
file,
|
|
1878
|
+
transferred: finalSize(),
|
|
1879
|
+
total: finalSize(),
|
|
1846
1880
|
percent: 100
|
|
1847
1881
|
});
|
|
1848
1882
|
resolve();
|
|
1849
1883
|
}
|
|
1850
|
-
}
|
|
1884
|
+
};
|
|
1885
|
+
if (kind === "put") sftp.fastPut(src, dst, {
|
|
1886
|
+
concurrency,
|
|
1887
|
+
step
|
|
1888
|
+
}, done);
|
|
1889
|
+
else sftp.fastGet(src, dst, {
|
|
1890
|
+
concurrency,
|
|
1891
|
+
step
|
|
1892
|
+
}, done);
|
|
1851
1893
|
});
|
|
1852
1894
|
}
|
|
1895
|
+
function fastPut(sftp, src, dst, concurrency, onProgress) {
|
|
1896
|
+
return fastTransfer(sftp, "put", src, dst, concurrency, onProgress);
|
|
1897
|
+
}
|
|
1853
1898
|
function fastGet(sftp, src, dst, concurrency, onProgress) {
|
|
1854
|
-
return
|
|
1855
|
-
let last = 0;
|
|
1856
|
-
let lastEmit = 0;
|
|
1857
|
-
const started = Date.now();
|
|
1858
|
-
sftp.fastGet(src, dst, {
|
|
1859
|
-
concurrency,
|
|
1860
|
-
step: (transferred, _chunk, total) => {
|
|
1861
|
-
const now = Date.now();
|
|
1862
|
-
if (now - lastEmit < 100 && transferred < total) return;
|
|
1863
|
-
lastEmit = now;
|
|
1864
|
-
const elapsed = (now - started) / 1e3;
|
|
1865
|
-
onProgress?.({
|
|
1866
|
-
phase: "transferring",
|
|
1867
|
-
file: src,
|
|
1868
|
-
transferred,
|
|
1869
|
-
total,
|
|
1870
|
-
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
|
|
1871
|
-
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
|
|
1872
|
-
});
|
|
1873
|
-
last = transferred;
|
|
1874
|
-
}
|
|
1875
|
-
}, (error) => {
|
|
1876
|
-
if (error !== void 0) {
|
|
1877
|
-
onProgress?.({
|
|
1878
|
-
phase: "error",
|
|
1879
|
-
file: src,
|
|
1880
|
-
transferred: 0,
|
|
1881
|
-
total: 0,
|
|
1882
|
-
percent: 0,
|
|
1883
|
-
error: String(error)
|
|
1884
|
-
});
|
|
1885
|
-
reject(error);
|
|
1886
|
-
} else {
|
|
1887
|
-
onProgress?.({
|
|
1888
|
-
phase: "done",
|
|
1889
|
-
file: src,
|
|
1890
|
-
transferred: statSync(dst).size,
|
|
1891
|
-
total: statSync(dst).size,
|
|
1892
|
-
percent: 100
|
|
1893
|
-
});
|
|
1894
|
-
resolve();
|
|
1895
|
-
}
|
|
1896
|
-
});
|
|
1897
|
-
});
|
|
1899
|
+
return fastTransfer(sftp, "get", src, dst, concurrency, onProgress);
|
|
1898
1900
|
}
|
|
1899
1901
|
//#endregion
|
|
1900
1902
|
//#region src/engine/tunnel.ts
|
|
@@ -1920,7 +1922,9 @@ async function startTunnel(engine, alias, options) {
|
|
|
1920
1922
|
state: "connecting",
|
|
1921
1923
|
startedAt: Date.now()
|
|
1922
1924
|
};
|
|
1923
|
-
const
|
|
1925
|
+
const existing = engine.pool.get(alias);
|
|
1926
|
+
if (existing !== void 0 && existing.broken) disposeRecord(engine, alias, existing);
|
|
1927
|
+
const record = engine.pool.get(alias) ?? await acquire(engine, alias);
|
|
1924
1928
|
const client = record.client;
|
|
1925
1929
|
const sockets = /* @__PURE__ */ new Set();
|
|
1926
1930
|
const server = createServer((socket) => {
|
|
@@ -1968,6 +1972,7 @@ async function startTunnel(engine, alias, options) {
|
|
|
1968
1972
|
info,
|
|
1969
1973
|
server,
|
|
1970
1974
|
alias,
|
|
1975
|
+
record,
|
|
1971
1976
|
sockets
|
|
1972
1977
|
});
|
|
1973
1978
|
return info;
|
|
@@ -1988,7 +1993,8 @@ function stopTunnel(engine, id) {
|
|
|
1988
1993
|
socket.destroy();
|
|
1989
1994
|
} catch {}
|
|
1990
1995
|
tunnel.sockets.clear();
|
|
1991
|
-
disposeRecord(engine, tunnel.alias);
|
|
1996
|
+
if (![...engine.tunnels.values()].some((candidate) => candidate.record === tunnel.record)) if (engine.pool.get(tunnel.alias) === tunnel.record) disposeRecord(engine, tunnel.alias, tunnel.record);
|
|
1997
|
+
else endRecordChain(tunnel.record);
|
|
1992
1998
|
return true;
|
|
1993
1999
|
}
|
|
1994
2000
|
/** Stop all tunnels (optionally for one alias). */
|
|
@@ -2116,6 +2122,16 @@ var SshEngine = class {
|
|
|
2116
2122
|
stopAllTunnels(alias) {
|
|
2117
2123
|
return stopAllTunnels(this, alias);
|
|
2118
2124
|
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Drop every live artifact bound to one alias: stop its tunnels and close
|
|
2127
|
+
* the pooled connection. Host entries that are deleted or whose connection
|
|
2128
|
+
* fields change must never keep serving a stale, previously authenticated
|
|
2129
|
+
* connection — the next operation re-connects from the current config.
|
|
2130
|
+
*/
|
|
2131
|
+
dropAlias(alias) {
|
|
2132
|
+
stopAllTunnels(this, alias);
|
|
2133
|
+
disposeRecord(this, alias);
|
|
2134
|
+
}
|
|
2119
2135
|
/** Probe connectivity: connect, run `true`, close. */
|
|
2120
2136
|
async test(alias) {
|
|
2121
2137
|
const started = Date.now();
|
|
@@ -2266,6 +2282,7 @@ function queryParam(url, name) {
|
|
|
2266
2282
|
function makeRoutes(deps) {
|
|
2267
2283
|
const { store, engine } = deps;
|
|
2268
2284
|
const staging = deps.stagingDir ?? join(tmpdir(), "dsh-ssh-uploads");
|
|
2285
|
+
const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES;
|
|
2269
2286
|
mkdirSync(staging, { recursive: true });
|
|
2270
2287
|
/** Guard helper: fence + method check. */
|
|
2271
2288
|
const guard = (req, res, method) => {
|
|
@@ -2326,6 +2343,14 @@ function makeRoutes(deps) {
|
|
|
2326
2343
|
}
|
|
2327
2344
|
try {
|
|
2328
2345
|
const entry = store.update(alias, body);
|
|
2346
|
+
const patch = body;
|
|
2347
|
+
if ([
|
|
2348
|
+
"host",
|
|
2349
|
+
"port",
|
|
2350
|
+
"user",
|
|
2351
|
+
"auth",
|
|
2352
|
+
"proxyJump"
|
|
2353
|
+
].some((key) => patch[key] !== void 0)) engine.dropAlias(alias);
|
|
2329
2354
|
writeJson(res, 200, { host: store.summarize(entry) });
|
|
2330
2355
|
} catch (error) {
|
|
2331
2356
|
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -2334,7 +2359,7 @@ function makeRoutes(deps) {
|
|
|
2334
2359
|
}
|
|
2335
2360
|
if (method === "DELETE") {
|
|
2336
2361
|
try {
|
|
2337
|
-
engine.
|
|
2362
|
+
engine.dropAlias(alias);
|
|
2338
2363
|
store.delete(alias);
|
|
2339
2364
|
writeJson(res, 200, { ok: true });
|
|
2340
2365
|
} catch (error) {
|
|
@@ -2503,7 +2528,7 @@ function makeRoutes(deps) {
|
|
|
2503
2528
|
return;
|
|
2504
2529
|
}
|
|
2505
2530
|
const declared = Number(req.headers["content-length"]);
|
|
2506
|
-
if (Number.isFinite(declared) && declared >
|
|
2531
|
+
if (Number.isFinite(declared) && declared > maxUploadBytes) {
|
|
2507
2532
|
writeJson(res, 413, { error: "upload body too large" });
|
|
2508
2533
|
return;
|
|
2509
2534
|
}
|
|
@@ -2528,13 +2553,22 @@ function makeRoutes(deps) {
|
|
|
2528
2553
|
ok: false,
|
|
2529
2554
|
error: error instanceof Error ? error.message : String(error)
|
|
2530
2555
|
});
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
}
|
|
2556
|
+
const cleanup = () => {
|
|
2557
|
+
unlink(tmp).catch(() => void 0).finally(() => {
|
|
2558
|
+
try {
|
|
2559
|
+
res.end();
|
|
2560
|
+
} catch {}
|
|
2561
|
+
});
|
|
2562
|
+
};
|
|
2563
|
+
if (sink.destroyed) cleanup();
|
|
2564
|
+
else {
|
|
2565
|
+
sink.once("close", cleanup);
|
|
2566
|
+
try {
|
|
2567
|
+
sink.destroy();
|
|
2568
|
+
} catch {
|
|
2569
|
+
cleanup();
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2538
2572
|
};
|
|
2539
2573
|
const done = () => {
|
|
2540
2574
|
if (settled) return;
|
|
@@ -2550,6 +2584,21 @@ function makeRoutes(deps) {
|
|
|
2550
2584
|
res.on("close", () => {
|
|
2551
2585
|
if (!res.writableEnded) fail("connection closed");
|
|
2552
2586
|
});
|
|
2587
|
+
let received = 0;
|
|
2588
|
+
let capped = false;
|
|
2589
|
+
req.on("data", (chunk) => {
|
|
2590
|
+
received += chunk.byteLength;
|
|
2591
|
+
if (received > maxUploadBytes && !capped) {
|
|
2592
|
+
capped = true;
|
|
2593
|
+
fail("upload body too large");
|
|
2594
|
+
res.on("finish", () => {
|
|
2595
|
+
try {
|
|
2596
|
+
req.destroy();
|
|
2597
|
+
} catch {}
|
|
2598
|
+
});
|
|
2599
|
+
req.resume();
|
|
2600
|
+
}
|
|
2601
|
+
});
|
|
2553
2602
|
req.pipe(sink);
|
|
2554
2603
|
sink.on("finish", async () => {
|
|
2555
2604
|
if (settled) return;
|
|
@@ -3301,6 +3350,47 @@ function sshClusterTool(engine) {
|
|
|
3301
3350
|
});
|
|
3302
3351
|
}
|
|
3303
3352
|
//#endregion
|
|
3353
|
+
//#region src/mount-once.ts
|
|
3354
|
+
/**
|
|
3355
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
3356
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
3357
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
3358
|
+
* without this guard the second instance would still re-register the same
|
|
3359
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
3360
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
3361
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
3362
|
+
* package name in the client module host).
|
|
3363
|
+
*
|
|
3364
|
+
* The registry rides a global symbol so two module instances of the same
|
|
3365
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
3366
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
3367
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
3368
|
+
*/
|
|
3369
|
+
const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
|
|
3370
|
+
function mountedSet() {
|
|
3371
|
+
const registry = globalThis;
|
|
3372
|
+
return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
|
|
3373
|
+
}
|
|
3374
|
+
/**
|
|
3375
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
3376
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
3377
|
+
* any later mount of the same package name is a no-op.
|
|
3378
|
+
* @param packageName - npm package identity shared by every install source.
|
|
3379
|
+
* @param fn - the original plugin apply.
|
|
3380
|
+
* @returns an apply of the same shape.
|
|
3381
|
+
*/
|
|
3382
|
+
function mountOnce(packageName, fn) {
|
|
3383
|
+
return ((...args) => {
|
|
3384
|
+
const mounted = mountedSet();
|
|
3385
|
+
if (mounted.has(packageName)) return;
|
|
3386
|
+
mounted.add(packageName);
|
|
3387
|
+
args[0]?.effect?.(() => () => {
|
|
3388
|
+
mounted.delete(packageName);
|
|
3389
|
+
});
|
|
3390
|
+
return fn(...args);
|
|
3391
|
+
});
|
|
3392
|
+
}
|
|
3393
|
+
//#endregion
|
|
3304
3394
|
//#region src/index.ts
|
|
3305
3395
|
/** Stable cordis plugin name. */
|
|
3306
3396
|
const name = "ssh";
|
|
@@ -3331,7 +3421,8 @@ const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维)
|
|
|
3331
3421
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
3332
3422
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
3333
3423
|
*/
|
|
3334
|
-
|
|
3424
|
+
const apply = mountOnce("@linxin666/dsh-ssh", applyImpl);
|
|
3425
|
+
function applyImpl(ctx, config) {
|
|
3335
3426
|
let current = () => config ?? {};
|
|
3336
3427
|
const resolve = () => {
|
|
3337
3428
|
const value = current();
|
|
@@ -15,6 +15,14 @@ export declare const zh: {
|
|
|
15
15
|
readonly 'hosts.add': "新增主机";
|
|
16
16
|
readonly 'hosts.import': "导入 ~/.ssh/config";
|
|
17
17
|
readonly 'hosts.search': "搜索别名 / 描述 / 标签…";
|
|
18
|
+
readonly 'hosts.groupBy.label': "分组方式";
|
|
19
|
+
readonly 'hosts.groupBy.none': "不分组";
|
|
20
|
+
readonly 'hosts.groupBy.environment': "按环境分组";
|
|
21
|
+
readonly 'hosts.groupBy.tags': "按标签分组";
|
|
22
|
+
readonly 'hosts.group.ungrouped': "未设置环境";
|
|
23
|
+
readonly 'hosts.group.noTags': "无标签";
|
|
24
|
+
readonly 'hosts.group.count': "{count} 台";
|
|
25
|
+
readonly 'hosts.group.testAll': "测试全部";
|
|
18
26
|
readonly 'hosts.col.alias': "别名";
|
|
19
27
|
readonly 'hosts.col.host': "主机";
|
|
20
28
|
readonly 'hosts.col.user': "用户";
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
import type { SshApi } from '../api.ts';
|
|
2
|
+
import type { SshHostSummary } from '../../protocol.ts';
|
|
2
3
|
/** Hosts tab props. */
|
|
3
4
|
export interface HostsTabProps {
|
|
4
5
|
api: SshApi;
|
|
5
6
|
/** Connect the given alias in the terminal tab. */
|
|
6
7
|
onConnect: (alias: string) => void;
|
|
7
8
|
}
|
|
9
|
+
/** Host list grouping modes (#379). */
|
|
10
|
+
export type HostGroupBy = 'none' | 'environment' | 'tags';
|
|
11
|
+
/** One collapsible group section of the grouped host list. */
|
|
12
|
+
export interface HostGroup {
|
|
13
|
+
/** Group key: the environment name, one tag, or '' for the ungrouped bucket. */
|
|
14
|
+
key: string;
|
|
15
|
+
hosts: SshHostSummary[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Bucket hosts into collapsible groups (#379). Grouping by tags places a
|
|
19
|
+
* multi-tag host in every one of its tag groups (folder view); hosts without
|
|
20
|
+
* the grouping key land in the '' bucket, which always sorts last. Groups
|
|
21
|
+
* sort alphabetically; host order inside a group follows the API listing.
|
|
22
|
+
*/
|
|
23
|
+
export declare function groupHosts(hosts: SshHostSummary[], groupBy: HostGroupBy): HostGroup[];
|
|
8
24
|
/** The hosts table plus its toolbar and dialogs. */
|
|
9
25
|
export declare function HostsTab({ api, onConnect }: HostsTabProps): import("react").JSX.Element;
|
|
@@ -72,6 +72,8 @@ export declare function acquire(engine: PoolEngine, alias: string): Promise<Pool
|
|
|
72
72
|
* is torn down — the connection belongs to someone else now.
|
|
73
73
|
*/
|
|
74
74
|
export declare function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRecord): void;
|
|
75
|
+
/** End one record's client and hop chain (best-effort, safe to repeat). */
|
|
76
|
+
export declare function endRecordChain(record: PoolRecord): void;
|
|
75
77
|
/** Close connections idle beyond the threshold (skips pinned and in-flight). */
|
|
76
78
|
export declare function sweepPool(engine: PoolEngine): void;
|
|
77
79
|
/**
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { type Server as NetServer, type Socket } from 'node:net';
|
|
7
7
|
import type { TunnelInfo } from '../protocol.ts';
|
|
8
|
-
import { type PoolEngine } from './connection-pool.ts';
|
|
9
|
-
/** One active tunnel record (server + pinned
|
|
8
|
+
import { type PoolEngine, type PoolRecord } from './connection-pool.ts';
|
|
9
|
+
/** One active tunnel record (server + pinned connection + live sockets). */
|
|
10
10
|
export interface TunnelRecord {
|
|
11
11
|
info: TunnelInfo;
|
|
12
12
|
server: NetServer;
|
|
13
13
|
alias: string;
|
|
14
|
+
/** The pooled connection this tunnel pins; siblings on one alias may share it. */
|
|
15
|
+
record: PoolRecord;
|
|
14
16
|
sockets: Set<Socket>;
|
|
15
17
|
}
|
|
16
18
|
/** The engine slice the tunnel module needs (adds the tunnel registry). */
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -74,6 +74,13 @@ export declare class SshEngine {
|
|
|
74
74
|
stopTunnel(id: string): boolean;
|
|
75
75
|
/** Stop all tunnels (optionally for one alias). */
|
|
76
76
|
stopAllTunnels(alias?: string): number;
|
|
77
|
+
/**
|
|
78
|
+
* Drop every live artifact bound to one alias: stop its tunnels and close
|
|
79
|
+
* the pooled connection. Host entries that are deleted or whose connection
|
|
80
|
+
* fields change must never keep serving a stale, previously authenticated
|
|
81
|
+
* connection — the next operation re-connects from the current config.
|
|
82
|
+
*/
|
|
83
|
+
dropAlias(alias: string): void;
|
|
77
84
|
/** Probe connectivity: connect, run `true`, close. */
|
|
78
85
|
test(alias: string): Promise<TestResult>;
|
|
79
86
|
/** Close every pooled connection and tunnel. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -37,4 +37,6 @@ export declare const SSH_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ssh \u63
|
|
|
37
37
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
38
38
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
39
39
|
*/
|
|
40
|
-
export declare
|
|
40
|
+
export declare const apply: typeof applyImpl;
|
|
41
|
+
declare function applyImpl(ctx: Context, config?: Config): void;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
3
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
4
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
5
|
+
* without this guard the second instance would still re-register the same
|
|
6
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
7
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
8
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
9
|
+
* package name in the client module host).
|
|
10
|
+
*
|
|
11
|
+
* The registry rides a global symbol so two module instances of the same
|
|
12
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
13
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
14
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
18
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
19
|
+
* any later mount of the same package name is a no-op.
|
|
20
|
+
* @param packageName - npm package identity shared by every install source.
|
|
21
|
+
* @param fn - the original plugin apply.
|
|
22
|
+
* @returns an apply of the same shape.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T;
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface SshRoutesDeps {
|
|
|
17
17
|
engine: SshEngine;
|
|
18
18
|
/** Temp dir for upload/download staging (tests inject a sandbox). */
|
|
19
19
|
stagingDir?: string;
|
|
20
|
+
/** Upload byte cap override (tests); defaults to MAX_UPLOAD_BYTES. */
|
|
21
|
+
maxUploadBytes?: number;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
24
|
* Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
|
package/lib/types/store.d.ts
CHANGED
|
@@ -47,6 +47,12 @@ export declare class HostStore {
|
|
|
47
47
|
*/
|
|
48
48
|
importFromSshConfig(): ImportResult;
|
|
49
49
|
private skippedNames;
|
|
50
|
+
/**
|
|
51
|
+
* Last parsed store keyed by file identity. list/find ride every acquire
|
|
52
|
+
* and GUI refresh; re-reading and re-parsing the whole file each call is
|
|
53
|
+
* wasted work when the file has not changed. Any save invalidates.
|
|
54
|
+
*/
|
|
55
|
+
private cache;
|
|
50
56
|
private load;
|
|
51
57
|
private save;
|
|
52
58
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-ssh",
|
|
3
3
|
"description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -49,17 +49,17 @@
|
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
52
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.
|
|
53
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.
|
|
54
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.
|
|
56
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.
|
|
57
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.
|
|
58
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.
|
|
59
|
-
"@deepseek-ai/dsh-llm": "^0.1.0-rc.
|
|
60
|
-
"@deepseek-ai/dsh-settings": "^0.1.0-rc.
|
|
61
|
-
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.
|
|
62
|
-
"@deepseek-ai/dsh-tools": "^0.1.0-rc.
|
|
52
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.7",
|
|
53
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
|
|
54
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.7",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
|
|
58
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
|
|
59
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
|
|
60
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
61
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.7",
|
|
62
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
|
|
63
63
|
"@types/node": "^22.20.0",
|
|
64
64
|
"@types/react": "~18.3.1",
|
|
65
65
|
"@types/react-dom": "^18.3.5",
|
package/src/client/locales.ts
CHANGED
|
@@ -17,6 +17,14 @@ export const zh = {
|
|
|
17
17
|
'hosts.add': '新增主机',
|
|
18
18
|
'hosts.import': '导入 ~/.ssh/config',
|
|
19
19
|
'hosts.search': '搜索别名 / 描述 / 标签…',
|
|
20
|
+
'hosts.groupBy.label': '分组方式',
|
|
21
|
+
'hosts.groupBy.none': '不分组',
|
|
22
|
+
'hosts.groupBy.environment': '按环境分组',
|
|
23
|
+
'hosts.groupBy.tags': '按标签分组',
|
|
24
|
+
'hosts.group.ungrouped': '未设置环境',
|
|
25
|
+
'hosts.group.noTags': '无标签',
|
|
26
|
+
'hosts.group.count': '{count} 台',
|
|
27
|
+
'hosts.group.testAll': '测试全部',
|
|
20
28
|
'hosts.col.alias': '别名',
|
|
21
29
|
'hosts.col.host': '主机',
|
|
22
30
|
'hosts.col.user': '用户',
|
|
@@ -153,6 +161,14 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
153
161
|
'hosts.add': 'Add host',
|
|
154
162
|
'hosts.import': 'Import ~/.ssh/config',
|
|
155
163
|
'hosts.search': 'Search alias / description / tags…',
|
|
164
|
+
'hosts.groupBy.label': 'Group by',
|
|
165
|
+
'hosts.groupBy.none': 'No grouping',
|
|
166
|
+
'hosts.groupBy.environment': 'Group by environment',
|
|
167
|
+
'hosts.groupBy.tags': 'Group by tags',
|
|
168
|
+
'hosts.group.ungrouped': 'No environment',
|
|
169
|
+
'hosts.group.noTags': 'No tags',
|
|
170
|
+
'hosts.group.count': '{count} hosts',
|
|
171
|
+
'hosts.group.testAll': 'Test all',
|
|
156
172
|
'hosts.col.alias': 'Alias',
|
|
157
173
|
'hosts.col.host': 'Host',
|
|
158
174
|
'hosts.col.user': 'User',
|