@linxin666/dsh-ssh 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +15638 -0
- package/lib/index.js +3297 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +72 -0
- package/lib/types/client/index.d.ts +37 -0
- package/lib/types/client/locales.d.ts +137 -0
- package/lib/types/client/mount.d.ts +12 -0
- package/lib/types/client/panel/ClusterTab.d.ts +7 -0
- package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
- package/lib/types/client/panel/HostsTab.d.ts +9 -0
- package/lib/types/client/panel/SshPanel.d.ts +13 -0
- package/lib/types/client/panel/TerminalTab.d.ts +11 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
- package/lib/types/client/panel/controller.d.ts +22 -0
- package/lib/types/client/panel/helpers.d.ts +14 -0
- package/lib/types/client/panel/xterm.css.d.ts +2 -0
- package/lib/types/client/sidebar-entry.d.ts +24 -0
- package/lib/types/engine.d.ts +135 -0
- package/lib/types/index.d.ts +40 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/protocol.d.ts +189 -0
- package/lib/types/routes.d.ts +29 -0
- package/lib/types/store.d.ts +54 -0
- package/lib/types/tools.d.ts +18 -0
- package/package.json +106 -0
- package/src/client/api.ts +363 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +68 -0
- package/src/client/locales.ts +284 -0
- package/src/client/mount.tsx +83 -0
- package/src/client/panel/ClusterTab.tsx +123 -0
- package/src/client/panel/HostFormDialog.tsx +221 -0
- package/src/client/panel/HostsTab.tsx +166 -0
- package/src/client/panel/SshPanel.tsx +77 -0
- package/src/client/panel/TerminalTab.tsx +176 -0
- package/src/client/panel/TransferTab.tsx +232 -0
- package/src/client/panel/TunnelsTab.tsx +177 -0
- package/src/client/panel/controller.ts +48 -0
- package/src/client/panel/helpers.ts +26 -0
- package/src/client/panel/panel.module.css +833 -0
- package/src/client/panel/xterm.css.ts +2 -0
- package/src/client/sidebar-entry.ts +123 -0
- package/src/engine.ts +780 -0
- package/src/index.ts +155 -0
- package/src/invariant.ts +4 -0
- package/src/protocol.ts +188 -0
- package/src/routes.ts +540 -0
- package/src/store.ts +344 -0
- package/src/tools.ts +371 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3297 @@
|
|
|
1
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { Client } from "ssh2";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { unlink } from "node:fs/promises";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
10
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
11
|
+
//#region \0rolldown/runtime.js
|
|
12
|
+
var __create = Object.create;
|
|
13
|
+
var __defProp = Object.defineProperty;
|
|
14
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
15
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
16
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
17
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
18
|
+
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
19
|
+
var __copyProps = (to, from, except, desc) => {
|
|
20
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
21
|
+
key = keys[i];
|
|
22
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
23
|
+
get: ((k) => from[k]).bind(null, key),
|
|
24
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return to;
|
|
28
|
+
};
|
|
29
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
30
|
+
value: mod,
|
|
31
|
+
enumerable: true
|
|
32
|
+
}) : target, mod));
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region ../../node_modules/.pnpm/cosmokit@1.8.1/node_modules/cosmokit/lib/index.cjs
|
|
35
|
+
var require_lib$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
36
|
+
var __defProp = Object.defineProperty;
|
|
37
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
38
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
39
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
40
|
+
var __export = (target, all) => {
|
|
41
|
+
for (var name in all) __defProp(target, name, {
|
|
42
|
+
get: all[name],
|
|
43
|
+
enumerable: true
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
var __copyProps = (to, from, except, desc) => {
|
|
47
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
48
|
+
for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
49
|
+
get: () => from[key],
|
|
50
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return to;
|
|
54
|
+
};
|
|
55
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
56
|
+
var index_exports = {};
|
|
57
|
+
__export(index_exports, {
|
|
58
|
+
Binary: () => Binary,
|
|
59
|
+
Time: () => Time,
|
|
60
|
+
arrayBufferToBase64: () => arrayBufferToBase64,
|
|
61
|
+
arrayBufferToHex: () => arrayBufferToHex,
|
|
62
|
+
base64ToArrayBuffer: () => base64ToArrayBuffer,
|
|
63
|
+
camelCase: () => camelCase,
|
|
64
|
+
camelize: () => camelize,
|
|
65
|
+
capitalize: () => capitalize,
|
|
66
|
+
clone: () => clone,
|
|
67
|
+
contain: () => contain,
|
|
68
|
+
deduplicate: () => deduplicate,
|
|
69
|
+
deepEqual: () => deepEqual,
|
|
70
|
+
defineProperty: () => defineProperty,
|
|
71
|
+
difference: () => difference,
|
|
72
|
+
filterKeys: () => filterKeys,
|
|
73
|
+
formatProperty: () => formatProperty,
|
|
74
|
+
hexToArrayBuffer: () => hexToArrayBuffer,
|
|
75
|
+
hyphenate: () => hyphenate,
|
|
76
|
+
intersection: () => intersection,
|
|
77
|
+
is: () => is,
|
|
78
|
+
isNonNullable: () => isNonNullable,
|
|
79
|
+
isNullable: () => isNullable,
|
|
80
|
+
isPlainObject: () => isPlainObject,
|
|
81
|
+
makeArray: () => makeArray,
|
|
82
|
+
mapValues: () => mapValues,
|
|
83
|
+
noop: () => noop,
|
|
84
|
+
omit: () => omit,
|
|
85
|
+
paramCase: () => paramCase,
|
|
86
|
+
pick: () => pick,
|
|
87
|
+
remove: () => remove,
|
|
88
|
+
sanitize: () => sanitize,
|
|
89
|
+
snakeCase: () => snakeCase,
|
|
90
|
+
trimSlash: () => trimSlash,
|
|
91
|
+
uncapitalize: () => uncapitalize,
|
|
92
|
+
union: () => union,
|
|
93
|
+
valueMap: () => mapValues
|
|
94
|
+
});
|
|
95
|
+
module.exports = __toCommonJS(index_exports);
|
|
96
|
+
function noop() {}
|
|
97
|
+
function isNullable(value) {
|
|
98
|
+
return value === null || value === void 0;
|
|
99
|
+
}
|
|
100
|
+
function isNonNullable(value) {
|
|
101
|
+
return !isNullable(value);
|
|
102
|
+
}
|
|
103
|
+
function isPlainObject(data) {
|
|
104
|
+
return data && typeof data === "object" && !Array.isArray(data);
|
|
105
|
+
}
|
|
106
|
+
function filterKeys(object, filter) {
|
|
107
|
+
return Object.fromEntries(Object.entries(object).filter(([key, value]) => filter(key, value)));
|
|
108
|
+
}
|
|
109
|
+
function mapValues(object, transform) {
|
|
110
|
+
return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, transform(value, key)]));
|
|
111
|
+
}
|
|
112
|
+
function pick(source, keys, forced) {
|
|
113
|
+
if (!keys) return { ...source };
|
|
114
|
+
const result = {};
|
|
115
|
+
for (const key of keys) if (forced || source[key] !== void 0) result[key] = source[key];
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
function omit(source, keys) {
|
|
119
|
+
if (!keys) return { ...source };
|
|
120
|
+
const result = { ...source };
|
|
121
|
+
for (const key of keys) Reflect.deleteProperty(result, key);
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
function defineProperty(object, key, value) {
|
|
125
|
+
return Object.defineProperty(object, key, {
|
|
126
|
+
writable: true,
|
|
127
|
+
value,
|
|
128
|
+
enumerable: false
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function contain(array1, array2) {
|
|
132
|
+
return array2.every((item) => array1.includes(item));
|
|
133
|
+
}
|
|
134
|
+
function intersection(array1, array2) {
|
|
135
|
+
return array1.filter((item) => array2.includes(item));
|
|
136
|
+
}
|
|
137
|
+
function difference(array1, array2) {
|
|
138
|
+
return array1.filter((item) => !array2.includes(item));
|
|
139
|
+
}
|
|
140
|
+
function union(array1, array2) {
|
|
141
|
+
return Array.from(/* @__PURE__ */ new Set([...array1, ...array2]));
|
|
142
|
+
}
|
|
143
|
+
function deduplicate(array) {
|
|
144
|
+
return [...new Set(array)];
|
|
145
|
+
}
|
|
146
|
+
function remove(list, item) {
|
|
147
|
+
const index = list?.indexOf(item);
|
|
148
|
+
if (index >= 0) {
|
|
149
|
+
list.splice(index, 1);
|
|
150
|
+
return true;
|
|
151
|
+
} else return false;
|
|
152
|
+
}
|
|
153
|
+
function makeArray(source) {
|
|
154
|
+
return Array.isArray(source) ? source : isNullable(source) ? [] : [source];
|
|
155
|
+
}
|
|
156
|
+
function is(type, value) {
|
|
157
|
+
if (arguments.length === 1) return (value2) => is(type, value2);
|
|
158
|
+
return type in globalThis && value instanceof globalThis[type] || Object.prototype.toString.call(value).slice(8, -1) === type;
|
|
159
|
+
}
|
|
160
|
+
function isArrayBufferLike(value) {
|
|
161
|
+
return is("ArrayBuffer", value) || is("SharedArrayBuffer", value);
|
|
162
|
+
}
|
|
163
|
+
function isArrayBufferSource(value) {
|
|
164
|
+
return isArrayBufferLike(value) || ArrayBuffer.isView(value);
|
|
165
|
+
}
|
|
166
|
+
var Binary;
|
|
167
|
+
((Binary2) => {
|
|
168
|
+
Binary2.is = isArrayBufferLike;
|
|
169
|
+
Binary2.isSource = isArrayBufferSource;
|
|
170
|
+
function fromSource(source) {
|
|
171
|
+
if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
172
|
+
else return source;
|
|
173
|
+
}
|
|
174
|
+
Binary2.fromSource = fromSource;
|
|
175
|
+
function toBase64(source) {
|
|
176
|
+
source = fromSource(source);
|
|
177
|
+
if (typeof Buffer !== "undefined") return Buffer.from(source).toString("base64");
|
|
178
|
+
let binary = "";
|
|
179
|
+
const bytes = new Uint8Array(source);
|
|
180
|
+
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
|
181
|
+
return btoa(binary);
|
|
182
|
+
}
|
|
183
|
+
Binary2.toBase64 = toBase64;
|
|
184
|
+
function fromBase64(source) {
|
|
185
|
+
if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "base64"));
|
|
186
|
+
return Uint8Array.from(atob(source), (c) => c.charCodeAt(0));
|
|
187
|
+
}
|
|
188
|
+
Binary2.fromBase64 = fromBase64;
|
|
189
|
+
function toHex(source) {
|
|
190
|
+
source = fromSource(source);
|
|
191
|
+
if (typeof Buffer !== "undefined") return Buffer.from(source).toString("hex");
|
|
192
|
+
return Array.from(new Uint8Array(source), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
193
|
+
}
|
|
194
|
+
Binary2.toHex = toHex;
|
|
195
|
+
function fromHex(source) {
|
|
196
|
+
if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "hex"));
|
|
197
|
+
const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1);
|
|
198
|
+
const buffer = [];
|
|
199
|
+
for (let i = 0; i < hex.length; i += 2) buffer.push(parseInt(`${hex[i]}${hex[i + 1]}`, 16));
|
|
200
|
+
return Uint8Array.from(buffer).buffer;
|
|
201
|
+
}
|
|
202
|
+
Binary2.fromHex = fromHex;
|
|
203
|
+
})(Binary || (Binary = {}));
|
|
204
|
+
var base64ToArrayBuffer = Binary.fromBase64;
|
|
205
|
+
var arrayBufferToBase64 = Binary.toBase64;
|
|
206
|
+
var hexToArrayBuffer = Binary.fromHex;
|
|
207
|
+
var arrayBufferToHex = Binary.toHex;
|
|
208
|
+
function clone(source, refs = /* @__PURE__ */ new Map()) {
|
|
209
|
+
if (!source || typeof source !== "object") return source;
|
|
210
|
+
if (is("Date", source)) return new Date(source.valueOf());
|
|
211
|
+
if (is("RegExp", source)) return new RegExp(source.source, source.flags);
|
|
212
|
+
if (isArrayBufferLike(source)) return source.slice(0);
|
|
213
|
+
if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
214
|
+
const cached = refs.get(source);
|
|
215
|
+
if (cached) return cached;
|
|
216
|
+
if (Array.isArray(source)) {
|
|
217
|
+
const result2 = [];
|
|
218
|
+
refs.set(source, result2);
|
|
219
|
+
source.forEach((value, index) => {
|
|
220
|
+
result2[index] = Reflect.apply(clone, null, [value, refs]);
|
|
221
|
+
});
|
|
222
|
+
return result2;
|
|
223
|
+
}
|
|
224
|
+
const result = Object.create(Object.getPrototypeOf(source));
|
|
225
|
+
refs.set(source, result);
|
|
226
|
+
for (const key of Reflect.ownKeys(source)) {
|
|
227
|
+
const descriptor = { ...Reflect.getOwnPropertyDescriptor(source, key) };
|
|
228
|
+
if ("value" in descriptor) descriptor.value = Reflect.apply(clone, null, [descriptor.value, refs]);
|
|
229
|
+
Reflect.defineProperty(result, key, descriptor);
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
function deepEqual(a, b, strict) {
|
|
234
|
+
if (a === b) return true;
|
|
235
|
+
if (!strict && isNullable(a) && isNullable(b)) return true;
|
|
236
|
+
if (typeof a !== typeof b) return false;
|
|
237
|
+
if (typeof a !== "object") return false;
|
|
238
|
+
if (!a || !b) return false;
|
|
239
|
+
function check(test, then) {
|
|
240
|
+
return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
|
|
241
|
+
}
|
|
242
|
+
return check(Array.isArray, (a2, b2) => a2.length === b2.length && a2.every((item, index) => deepEqual(item, b2[index]))) ?? check(is("Date"), (a2, b2) => a2.valueOf() === b2.valueOf()) ?? check(is("RegExp"), (a2, b2) => a2.source === b2.source && a2.flags === b2.flags) ?? check(isArrayBufferLike, (a2, b2) => {
|
|
243
|
+
if (a2.byteLength !== b2.byteLength) return false;
|
|
244
|
+
const viewA = new Uint8Array(a2);
|
|
245
|
+
const viewB = new Uint8Array(b2);
|
|
246
|
+
for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
|
|
247
|
+
return true;
|
|
248
|
+
}) ?? Object.keys({
|
|
249
|
+
...a,
|
|
250
|
+
...b
|
|
251
|
+
}).every((key) => deepEqual(a[key], b[key], strict));
|
|
252
|
+
}
|
|
253
|
+
function capitalize(source) {
|
|
254
|
+
return source.charAt(0).toUpperCase() + source.slice(1);
|
|
255
|
+
}
|
|
256
|
+
function uncapitalize(source) {
|
|
257
|
+
return source.charAt(0).toLowerCase() + source.slice(1);
|
|
258
|
+
}
|
|
259
|
+
function camelCase(source) {
|
|
260
|
+
return source.replace(/[_-][a-z]/g, (str) => str.slice(1).toUpperCase());
|
|
261
|
+
}
|
|
262
|
+
function tokenize(source, delimiters, delimiter) {
|
|
263
|
+
const output = [];
|
|
264
|
+
let state = 0;
|
|
265
|
+
for (let i = 0; i < source.length; i++) {
|
|
266
|
+
const code = source.charCodeAt(i);
|
|
267
|
+
if (code >= 65 && code <= 90) {
|
|
268
|
+
if (state === 1) {
|
|
269
|
+
const next = source.charCodeAt(i + 1);
|
|
270
|
+
if (next >= 97 && next <= 122) output.push(delimiter);
|
|
271
|
+
output.push(code + 32);
|
|
272
|
+
} else {
|
|
273
|
+
if (state !== 0) output.push(delimiter);
|
|
274
|
+
output.push(code + 32);
|
|
275
|
+
}
|
|
276
|
+
state = 1;
|
|
277
|
+
} else if (code >= 97 && code <= 122) {
|
|
278
|
+
output.push(code);
|
|
279
|
+
state = 2;
|
|
280
|
+
} else if (delimiters.includes(code)) {
|
|
281
|
+
if (state !== 0) output.push(delimiter);
|
|
282
|
+
state = 0;
|
|
283
|
+
} else output.push(code);
|
|
284
|
+
}
|
|
285
|
+
return String.fromCharCode(...output);
|
|
286
|
+
}
|
|
287
|
+
function paramCase(source) {
|
|
288
|
+
return tokenize(source, [45, 95], 45);
|
|
289
|
+
}
|
|
290
|
+
function snakeCase(source) {
|
|
291
|
+
return tokenize(source, [45, 95], 95);
|
|
292
|
+
}
|
|
293
|
+
var camelize = camelCase;
|
|
294
|
+
var hyphenate = paramCase;
|
|
295
|
+
function formatProperty(key) {
|
|
296
|
+
if (typeof key !== "string") return `[${key.toString()}]`;
|
|
297
|
+
return /^[a-z_$][\w$]*$/i.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
|
|
298
|
+
}
|
|
299
|
+
function trimSlash(source) {
|
|
300
|
+
return source.replace(/\/$/, "");
|
|
301
|
+
}
|
|
302
|
+
function sanitize(source) {
|
|
303
|
+
if (!source.startsWith("/")) source = "/" + source;
|
|
304
|
+
return trimSlash(source);
|
|
305
|
+
}
|
|
306
|
+
var Time;
|
|
307
|
+
((Time2) => {
|
|
308
|
+
Time2.millisecond = 1;
|
|
309
|
+
Time2.second = 1e3;
|
|
310
|
+
Time2.minute = Time2.second * 60;
|
|
311
|
+
Time2.hour = Time2.minute * 60;
|
|
312
|
+
Time2.day = Time2.hour * 24;
|
|
313
|
+
Time2.week = Time2.day * 7;
|
|
314
|
+
let timezoneOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset();
|
|
315
|
+
function setTimezoneOffset(offset) {
|
|
316
|
+
timezoneOffset = offset;
|
|
317
|
+
}
|
|
318
|
+
Time2.setTimezoneOffset = setTimezoneOffset;
|
|
319
|
+
function getTimezoneOffset() {
|
|
320
|
+
return timezoneOffset;
|
|
321
|
+
}
|
|
322
|
+
Time2.getTimezoneOffset = getTimezoneOffset;
|
|
323
|
+
function getDateNumber(date = /* @__PURE__ */ new Date(), offset) {
|
|
324
|
+
if (typeof date === "number") date = new Date(date);
|
|
325
|
+
if (offset === void 0) offset = timezoneOffset;
|
|
326
|
+
return Math.floor((date.valueOf() / Time2.minute - offset) / 1440);
|
|
327
|
+
}
|
|
328
|
+
Time2.getDateNumber = getDateNumber;
|
|
329
|
+
function fromDateNumber(value, offset) {
|
|
330
|
+
const date = new Date(value * Time2.day);
|
|
331
|
+
if (offset === void 0) offset = timezoneOffset;
|
|
332
|
+
return new Date(+date + offset * Time2.minute);
|
|
333
|
+
}
|
|
334
|
+
Time2.fromDateNumber = fromDateNumber;
|
|
335
|
+
const numeric = /\d+(?:\.\d+)?/.source;
|
|
336
|
+
const timeRegExp = new RegExp(`^${[
|
|
337
|
+
"w(?:eek(?:s)?)?",
|
|
338
|
+
"d(?:ay(?:s)?)?",
|
|
339
|
+
"h(?:our(?:s)?)?",
|
|
340
|
+
"m(?:in(?:ute)?(?:s)?)?",
|
|
341
|
+
"s(?:ec(?:ond)?(?:s)?)?"
|
|
342
|
+
].map((unit) => `(${numeric}${unit})?`).join("")}$`);
|
|
343
|
+
function parseTime(source) {
|
|
344
|
+
const capture = timeRegExp.exec(source);
|
|
345
|
+
if (!capture) return 0;
|
|
346
|
+
return (parseFloat(capture[1]) * Time2.week || 0) + (parseFloat(capture[2]) * Time2.day || 0) + (parseFloat(capture[3]) * Time2.hour || 0) + (parseFloat(capture[4]) * Time2.minute || 0) + (parseFloat(capture[5]) * Time2.second || 0);
|
|
347
|
+
}
|
|
348
|
+
Time2.parseTime = parseTime;
|
|
349
|
+
function parseDate(date) {
|
|
350
|
+
const parsed = parseTime(date);
|
|
351
|
+
if (parsed) date = Date.now() + parsed;
|
|
352
|
+
else if (/^\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).toLocaleDateString()}-${date}`;
|
|
353
|
+
else if (/^\d{1,2}-\d{1,2}-\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).getFullYear()}-${date}`;
|
|
354
|
+
return date ? new Date(date) : /* @__PURE__ */ new Date();
|
|
355
|
+
}
|
|
356
|
+
Time2.parseDate = parseDate;
|
|
357
|
+
function format(ms) {
|
|
358
|
+
const abs = Math.abs(ms);
|
|
359
|
+
if (abs >= Time2.day - Time2.hour / 2) return Math.round(ms / Time2.day) + "d";
|
|
360
|
+
else if (abs >= Time2.hour - Time2.minute / 2) return Math.round(ms / Time2.hour) + "h";
|
|
361
|
+
else if (abs >= Time2.minute - Time2.second / 2) return Math.round(ms / Time2.minute) + "m";
|
|
362
|
+
else if (abs >= Time2.second) return Math.round(ms / Time2.second) + "s";
|
|
363
|
+
return ms + "ms";
|
|
364
|
+
}
|
|
365
|
+
Time2.format = format;
|
|
366
|
+
function toDigits(source, length = 2) {
|
|
367
|
+
return source.toString().padStart(length, "0");
|
|
368
|
+
}
|
|
369
|
+
Time2.toDigits = toDigits;
|
|
370
|
+
function template(template2, time = /* @__PURE__ */ new Date()) {
|
|
371
|
+
return template2.replace("yyyy", time.getFullYear().toString()).replace("yy", time.getFullYear().toString().slice(2)).replace("MM", toDigits(time.getMonth() + 1)).replace("dd", toDigits(time.getDate())).replace("hh", toDigits(time.getHours())).replace("mm", toDigits(time.getMinutes())).replace("ss", toDigits(time.getSeconds())).replace("SSS", toDigits(time.getMilliseconds(), 3));
|
|
372
|
+
}
|
|
373
|
+
Time2.template = template;
|
|
374
|
+
})(Time || (Time = {}));
|
|
375
|
+
0 && (module.exports = {
|
|
376
|
+
Binary,
|
|
377
|
+
Time,
|
|
378
|
+
arrayBufferToBase64,
|
|
379
|
+
arrayBufferToHex,
|
|
380
|
+
base64ToArrayBuffer,
|
|
381
|
+
camelCase,
|
|
382
|
+
camelize,
|
|
383
|
+
capitalize,
|
|
384
|
+
clone,
|
|
385
|
+
contain,
|
|
386
|
+
deduplicate,
|
|
387
|
+
deepEqual,
|
|
388
|
+
defineProperty,
|
|
389
|
+
difference,
|
|
390
|
+
filterKeys,
|
|
391
|
+
formatProperty,
|
|
392
|
+
hexToArrayBuffer,
|
|
393
|
+
hyphenate,
|
|
394
|
+
intersection,
|
|
395
|
+
is,
|
|
396
|
+
isNonNullable,
|
|
397
|
+
isNullable,
|
|
398
|
+
isPlainObject,
|
|
399
|
+
makeArray,
|
|
400
|
+
mapValues,
|
|
401
|
+
noop,
|
|
402
|
+
omit,
|
|
403
|
+
paramCase,
|
|
404
|
+
pick,
|
|
405
|
+
remove,
|
|
406
|
+
sanitize,
|
|
407
|
+
snakeCase,
|
|
408
|
+
trimSlash,
|
|
409
|
+
uncapitalize,
|
|
410
|
+
union,
|
|
411
|
+
valueMap
|
|
412
|
+
});
|
|
413
|
+
}));
|
|
414
|
+
//#endregion
|
|
415
|
+
//#region src/store.ts
|
|
416
|
+
var import_lib = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
417
|
+
var __defProp = Object.defineProperty;
|
|
418
|
+
var __name = (target, value) => __defProp(target, "name", {
|
|
419
|
+
value,
|
|
420
|
+
configurable: true
|
|
421
|
+
});
|
|
422
|
+
var import_cosmokit = require_lib$1();
|
|
423
|
+
var kSchema = Symbol.for("schemastery");
|
|
424
|
+
var kValidationError = Symbol.for("ValidationError");
|
|
425
|
+
globalThis.__schemastery_index__ ??= 0;
|
|
426
|
+
globalThis.__schemastery_refs__ = void 0;
|
|
427
|
+
var ValidationError = class extends TypeError {
|
|
428
|
+
constructor(message, options) {
|
|
429
|
+
let prefix = "$";
|
|
430
|
+
for (const segment of options.path || []) if (typeof segment === "string") prefix += "." + segment;
|
|
431
|
+
else if (typeof segment === "number") prefix += "[" + segment + "]";
|
|
432
|
+
else if (typeof segment === "symbol") prefix += `[Symbol(${segment.toString()})]`;
|
|
433
|
+
if (prefix.startsWith(".")) prefix = prefix.slice(1);
|
|
434
|
+
super((prefix === "$" ? "" : `${prefix} `) + message);
|
|
435
|
+
this.options = options;
|
|
436
|
+
}
|
|
437
|
+
static {
|
|
438
|
+
__name(this, "ValidationError");
|
|
439
|
+
}
|
|
440
|
+
name = "ValidationError";
|
|
441
|
+
static is(error) {
|
|
442
|
+
return !!error?.[kValidationError];
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
Object.defineProperty(ValidationError.prototype, kValidationError, { value: true });
|
|
446
|
+
var Schema = /* @__PURE__ */ __name(function(options) {
|
|
447
|
+
const schema = /* @__PURE__ */ __name(function(data, options2 = {}) {
|
|
448
|
+
return Schema.resolve(data, schema, options2)[0];
|
|
449
|
+
}, "schema");
|
|
450
|
+
if (options.refs) {
|
|
451
|
+
const refs = (0, import_cosmokit.valueMap)(options.refs, (options2) => new Schema(options2));
|
|
452
|
+
const getRef = /* @__PURE__ */ __name((uid) => refs[uid], "getRef");
|
|
453
|
+
for (const key in refs) {
|
|
454
|
+
const options2 = refs[key];
|
|
455
|
+
options2.sKey = getRef(options2.sKey);
|
|
456
|
+
options2.inner = getRef(options2.inner);
|
|
457
|
+
options2.list = options2.list && options2.list.map(getRef);
|
|
458
|
+
options2.dict = options2.dict && (0, import_cosmokit.valueMap)(options2.dict, getRef);
|
|
459
|
+
}
|
|
460
|
+
return refs[options.uid];
|
|
461
|
+
}
|
|
462
|
+
Object.assign(schema, options);
|
|
463
|
+
if (typeof schema.callback === "string") try {
|
|
464
|
+
schema.callback = new Function("return " + schema.callback)();
|
|
465
|
+
} catch {}
|
|
466
|
+
Object.defineProperty(schema, "uid", { value: globalThis.__schemastery_index__++ });
|
|
467
|
+
Object.setPrototypeOf(schema, Schema.prototype);
|
|
468
|
+
schema.meta ||= {};
|
|
469
|
+
schema.toString = schema.toString.bind(schema);
|
|
470
|
+
return schema;
|
|
471
|
+
}, "Schema");
|
|
472
|
+
Schema.prototype = Object.create(Function.prototype);
|
|
473
|
+
Schema.prototype[kSchema] = true;
|
|
474
|
+
Object.defineProperty(Schema.prototype, "~standard", { get() {
|
|
475
|
+
return {
|
|
476
|
+
version: 1,
|
|
477
|
+
vendor: "schemastery",
|
|
478
|
+
validate: /* @__PURE__ */ __name((value) => {
|
|
479
|
+
try {
|
|
480
|
+
return { value: Schema.resolve(value, this, {})[0] };
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if (ValidationError.is(error)) return { issues: [{
|
|
483
|
+
message: error.message,
|
|
484
|
+
path: error.options.path
|
|
485
|
+
}] };
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}, "validate")
|
|
489
|
+
};
|
|
490
|
+
} });
|
|
491
|
+
Schema.ValidationError = ValidationError;
|
|
492
|
+
Schema.prototype.toJSON = /* @__PURE__ */ __name(function toJSON() {
|
|
493
|
+
if (globalThis.__schemastery_refs__) {
|
|
494
|
+
globalThis.__schemastery_refs__[this.uid] ??= JSON.parse(JSON.stringify({ ...this }));
|
|
495
|
+
return this.uid;
|
|
496
|
+
}
|
|
497
|
+
globalThis.__schemastery_refs__ = { [this.uid]: { ...this } };
|
|
498
|
+
globalThis.__schemastery_refs__[this.uid] = JSON.parse(JSON.stringify({ ...this }));
|
|
499
|
+
const result = {
|
|
500
|
+
uid: this.uid,
|
|
501
|
+
refs: globalThis.__schemastery_refs__
|
|
502
|
+
};
|
|
503
|
+
globalThis.__schemastery_refs__ = void 0;
|
|
504
|
+
return result;
|
|
505
|
+
}, "toJSON");
|
|
506
|
+
Schema.prototype.set = /* @__PURE__ */ __name(function set(key, value) {
|
|
507
|
+
this.dict[key] = value;
|
|
508
|
+
return this;
|
|
509
|
+
}, "set");
|
|
510
|
+
Schema.prototype.push = /* @__PURE__ */ __name(function push(value) {
|
|
511
|
+
this.list.push(value);
|
|
512
|
+
return this;
|
|
513
|
+
}, "push");
|
|
514
|
+
function mergeDesc(original, messages) {
|
|
515
|
+
const result = typeof original === "string" ? { "": original } : { ...original };
|
|
516
|
+
for (const locale in messages) {
|
|
517
|
+
const value = messages[locale];
|
|
518
|
+
if (value?.$description || value?.$desc) result[locale] = value.$description || value.$desc;
|
|
519
|
+
else if (typeof value === "string") result[locale] = value;
|
|
520
|
+
}
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
523
|
+
__name(mergeDesc, "mergeDesc");
|
|
524
|
+
function getInner(value) {
|
|
525
|
+
return value?.$value ?? value?.$inner;
|
|
526
|
+
}
|
|
527
|
+
__name(getInner, "getInner");
|
|
528
|
+
function extractKeys(data) {
|
|
529
|
+
return (0, import_cosmokit.filterKeys)(data ?? {}, (key) => !key.startsWith("$"));
|
|
530
|
+
}
|
|
531
|
+
__name(extractKeys, "extractKeys");
|
|
532
|
+
Schema.prototype.i18n = /* @__PURE__ */ __name(function i18n(messages) {
|
|
533
|
+
const schema = Schema(this);
|
|
534
|
+
const desc = mergeDesc(schema.meta.description, messages);
|
|
535
|
+
if (Object.keys(desc).length) schema.meta.description = desc;
|
|
536
|
+
if (schema.dict) schema.dict = (0, import_cosmokit.valueMap)(schema.dict, (inner, key) => {
|
|
537
|
+
return inner.i18n((0, import_cosmokit.valueMap)(messages, (data) => getInner(data)?.[key] ?? data?.[key]));
|
|
538
|
+
});
|
|
539
|
+
if (schema.list) schema.list = schema.list.map((inner, index) => {
|
|
540
|
+
return inner.i18n((0, import_cosmokit.valueMap)(messages, (data = {}) => {
|
|
541
|
+
if (Array.isArray(getInner(data))) return getInner(data)[index];
|
|
542
|
+
if (Array.isArray(data)) return data[index];
|
|
543
|
+
return extractKeys(data);
|
|
544
|
+
}));
|
|
545
|
+
});
|
|
546
|
+
if (schema.inner) schema.inner = schema.inner.i18n((0, import_cosmokit.valueMap)(messages, (data) => {
|
|
547
|
+
if (getInner(data)) return getInner(data);
|
|
548
|
+
return extractKeys(data);
|
|
549
|
+
}));
|
|
550
|
+
if (schema.sKey) schema.sKey = schema.sKey.i18n((0, import_cosmokit.valueMap)(messages, (data) => data?.$key));
|
|
551
|
+
return schema;
|
|
552
|
+
}, "i18n");
|
|
553
|
+
Schema.prototype.extra = /* @__PURE__ */ __name(function extra(key, value) {
|
|
554
|
+
const schema = Schema(this);
|
|
555
|
+
schema.meta = {
|
|
556
|
+
...schema.meta,
|
|
557
|
+
[key]: value
|
|
558
|
+
};
|
|
559
|
+
return schema;
|
|
560
|
+
}, "extra");
|
|
561
|
+
for (const key of [
|
|
562
|
+
"required",
|
|
563
|
+
"disabled",
|
|
564
|
+
"collapse",
|
|
565
|
+
"hidden",
|
|
566
|
+
"loose"
|
|
567
|
+
]) Object.assign(Schema.prototype, { [key](value = true) {
|
|
568
|
+
const schema = Schema(this);
|
|
569
|
+
schema.meta = {
|
|
570
|
+
...schema.meta,
|
|
571
|
+
[key]: value
|
|
572
|
+
};
|
|
573
|
+
return schema;
|
|
574
|
+
} });
|
|
575
|
+
Schema.prototype.deprecated = /* @__PURE__ */ __name(function deprecated() {
|
|
576
|
+
const schema = Schema(this);
|
|
577
|
+
schema.meta.badges ||= [];
|
|
578
|
+
schema.meta.badges.push({
|
|
579
|
+
text: "deprecated",
|
|
580
|
+
type: "danger"
|
|
581
|
+
});
|
|
582
|
+
return schema;
|
|
583
|
+
}, "deprecated");
|
|
584
|
+
Schema.prototype.experimental = /* @__PURE__ */ __name(function experimental() {
|
|
585
|
+
const schema = Schema(this);
|
|
586
|
+
schema.meta.badges ||= [];
|
|
587
|
+
schema.meta.badges.push({
|
|
588
|
+
text: "experimental",
|
|
589
|
+
type: "warning"
|
|
590
|
+
});
|
|
591
|
+
return schema;
|
|
592
|
+
}, "experimental");
|
|
593
|
+
Schema.prototype.pattern = /* @__PURE__ */ __name(function pattern(regexp) {
|
|
594
|
+
const schema = Schema(this);
|
|
595
|
+
const pattern2 = (0, import_cosmokit.pick)(regexp, ["source", "flags"]);
|
|
596
|
+
schema.meta = {
|
|
597
|
+
...schema.meta,
|
|
598
|
+
pattern: pattern2
|
|
599
|
+
};
|
|
600
|
+
return schema;
|
|
601
|
+
}, "pattern");
|
|
602
|
+
Schema.prototype.simplify = /* @__PURE__ */ __name(function simplify(value) {
|
|
603
|
+
if ((0, import_cosmokit.deepEqual)(value, this.meta.default, this.type === "dict")) return null;
|
|
604
|
+
if ((0, import_cosmokit.isNullable)(value)) return value;
|
|
605
|
+
if (this.type === "object" || this.type === "dict") {
|
|
606
|
+
const result = {};
|
|
607
|
+
for (const key in value) {
|
|
608
|
+
const item = (this.type === "object" ? this.dict[key] : this.inner)?.simplify(value[key]);
|
|
609
|
+
if (this.type === "dict" || !(0, import_cosmokit.isNullable)(item)) result[key] = item;
|
|
610
|
+
}
|
|
611
|
+
if ((0, import_cosmokit.deepEqual)(result, this.meta.default, this.type === "dict")) return null;
|
|
612
|
+
return result;
|
|
613
|
+
} else if (this.type === "array" || this.type === "tuple") {
|
|
614
|
+
const result = [];
|
|
615
|
+
value.forEach((value2, index) => {
|
|
616
|
+
const schema = this.type === "array" ? this.inner : this.list[index];
|
|
617
|
+
const item = schema ? schema.simplify(value2) : value2;
|
|
618
|
+
result.push(item);
|
|
619
|
+
});
|
|
620
|
+
return result;
|
|
621
|
+
} else if (this.type === "intersect") {
|
|
622
|
+
const result = {};
|
|
623
|
+
for (const item of this.list) Object.assign(result, item.simplify(value));
|
|
624
|
+
return result;
|
|
625
|
+
} else if (this.type === "union") for (const schema of this.list) try {
|
|
626
|
+
Schema.resolve(value, schema, {});
|
|
627
|
+
return schema.simplify(value);
|
|
628
|
+
} catch {}
|
|
629
|
+
return value;
|
|
630
|
+
}, "simplify");
|
|
631
|
+
Schema.prototype.toString = /* @__PURE__ */ __name(function toString(inline) {
|
|
632
|
+
return formatters[this.type]?.(this, inline) ?? `Schema<${this.type}>`;
|
|
633
|
+
}, "toString");
|
|
634
|
+
Schema.prototype.role = /* @__PURE__ */ __name(function role(role, extra2) {
|
|
635
|
+
const schema = Schema(this);
|
|
636
|
+
schema.meta = {
|
|
637
|
+
...schema.meta,
|
|
638
|
+
role,
|
|
639
|
+
extra: extra2
|
|
640
|
+
};
|
|
641
|
+
return schema;
|
|
642
|
+
}, "role");
|
|
643
|
+
for (const key of [
|
|
644
|
+
"default",
|
|
645
|
+
"link",
|
|
646
|
+
"comment",
|
|
647
|
+
"description",
|
|
648
|
+
"max",
|
|
649
|
+
"min",
|
|
650
|
+
"step"
|
|
651
|
+
]) Object.assign(Schema.prototype, { [key](value) {
|
|
652
|
+
const schema = Schema(this);
|
|
653
|
+
schema.meta = {
|
|
654
|
+
...schema.meta,
|
|
655
|
+
[key]: value
|
|
656
|
+
};
|
|
657
|
+
return schema;
|
|
658
|
+
} });
|
|
659
|
+
var resolvers = {};
|
|
660
|
+
Schema.extend = /* @__PURE__ */ __name(function extend(type, resolve2) {
|
|
661
|
+
resolvers[type] = resolve2;
|
|
662
|
+
}, "extend");
|
|
663
|
+
Schema.resolve = /* @__PURE__ */ __name(function resolve(data, schema, options = {}, strict = false) {
|
|
664
|
+
if (!schema) return [data];
|
|
665
|
+
if (options.ignore?.(data, schema)) return [data];
|
|
666
|
+
if ((0, import_cosmokit.isNullable)(data) && schema.type !== "lazy") {
|
|
667
|
+
if (schema.meta.required) throw new ValidationError(`missing required value`, options);
|
|
668
|
+
let current = schema;
|
|
669
|
+
let fallback = schema.meta.default;
|
|
670
|
+
while (current?.type === "intersect" && (0, import_cosmokit.isNullable)(fallback)) {
|
|
671
|
+
current = current.list[0];
|
|
672
|
+
fallback = current?.meta.default;
|
|
673
|
+
}
|
|
674
|
+
if ((0, import_cosmokit.isNullable)(fallback)) return [data];
|
|
675
|
+
data = (0, import_cosmokit.clone)(fallback);
|
|
676
|
+
}
|
|
677
|
+
const callback = resolvers[schema.type];
|
|
678
|
+
if (!callback) throw new ValidationError(`unsupported type "${schema.type}"`, options);
|
|
679
|
+
try {
|
|
680
|
+
return callback(data, schema, options, strict);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (!schema.meta.loose) throw error;
|
|
683
|
+
return [schema.meta.default];
|
|
684
|
+
}
|
|
685
|
+
}, "resolve");
|
|
686
|
+
Schema.from = /* @__PURE__ */ __name(function from(source) {
|
|
687
|
+
if ((0, import_cosmokit.isNullable)(source)) return Schema.any();
|
|
688
|
+
else if ([
|
|
689
|
+
"string",
|
|
690
|
+
"number",
|
|
691
|
+
"boolean"
|
|
692
|
+
].includes(typeof source)) return Schema.const(source).required();
|
|
693
|
+
else if (source[kSchema]) return source;
|
|
694
|
+
else if (typeof source === "function") switch (source) {
|
|
695
|
+
case String: return Schema.string().required();
|
|
696
|
+
case Number: return Schema.number().required();
|
|
697
|
+
case Boolean: return Schema.boolean().required();
|
|
698
|
+
case Function: return Schema.function().required();
|
|
699
|
+
default: return Schema.is(source).required();
|
|
700
|
+
}
|
|
701
|
+
else throw new TypeError(`cannot infer schema from ${source}`);
|
|
702
|
+
}, "from");
|
|
703
|
+
Schema.lazy = /* @__PURE__ */ __name(function lazy(builder) {
|
|
704
|
+
const schema = new Schema({
|
|
705
|
+
type: "lazy",
|
|
706
|
+
builder,
|
|
707
|
+
inner: { toJSON: /* @__PURE__ */ __name(() => {
|
|
708
|
+
if (!schema.inner[kSchema]) {
|
|
709
|
+
schema.inner = schema.builder();
|
|
710
|
+
schema.inner.meta = {
|
|
711
|
+
...schema.meta,
|
|
712
|
+
...schema.inner.meta
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
return schema.inner.toJSON();
|
|
716
|
+
}, "toJSON") }
|
|
717
|
+
});
|
|
718
|
+
return schema;
|
|
719
|
+
}, "lazy");
|
|
720
|
+
Schema.natural = /* @__PURE__ */ __name(function natural() {
|
|
721
|
+
return Schema.number().step(1).min(0);
|
|
722
|
+
}, "natural");
|
|
723
|
+
Schema.percent = /* @__PURE__ */ __name(function percent() {
|
|
724
|
+
return Schema.number().step(.01).min(0).max(1).role("slider");
|
|
725
|
+
}, "percent");
|
|
726
|
+
Schema.date = /* @__PURE__ */ __name(function date() {
|
|
727
|
+
return Schema.union([Schema.is(Date), Schema.transform(Schema.string().role("datetime"), (value, options) => {
|
|
728
|
+
const date2 = new Date(value);
|
|
729
|
+
if (isNaN(+date2)) throw new ValidationError(`invalid date "${value}"`, options);
|
|
730
|
+
return date2;
|
|
731
|
+
}, true)]);
|
|
732
|
+
}, "date");
|
|
733
|
+
Schema.regExp = /* @__PURE__ */ __name(function regExp(flag = "") {
|
|
734
|
+
return Schema.union([Schema.is(RegExp), Schema.transform(Schema.string().role("regexp", { flag }), (value, options) => {
|
|
735
|
+
try {
|
|
736
|
+
return new RegExp(value, flag);
|
|
737
|
+
} catch (e) {
|
|
738
|
+
throw new ValidationError(e.message, options);
|
|
739
|
+
}
|
|
740
|
+
}, true)]);
|
|
741
|
+
}, "regExp");
|
|
742
|
+
Schema.arrayBuffer = /* @__PURE__ */ __name(function arrayBuffer(encoding) {
|
|
743
|
+
return Schema.union([
|
|
744
|
+
Schema.is(ArrayBuffer),
|
|
745
|
+
Schema.is(SharedArrayBuffer),
|
|
746
|
+
Schema.transform(Schema.any(), (value, options) => {
|
|
747
|
+
if (import_cosmokit.Binary.isSource(value)) return import_cosmokit.Binary.fromSource(value);
|
|
748
|
+
throw new ValidationError(`expected ArrayBufferSource but got ${value}`, options);
|
|
749
|
+
}, true),
|
|
750
|
+
...encoding ? [Schema.transform(Schema.string(), (value, options) => {
|
|
751
|
+
try {
|
|
752
|
+
return encoding === "base64" ? import_cosmokit.Binary.fromBase64(value) : import_cosmokit.Binary.fromHex(value);
|
|
753
|
+
} catch (e) {
|
|
754
|
+
throw new ValidationError(e.message, options);
|
|
755
|
+
}
|
|
756
|
+
}, true)] : []
|
|
757
|
+
]);
|
|
758
|
+
}, "arrayBuffer");
|
|
759
|
+
Schema.extend("lazy", (data, schema, options, strict) => {
|
|
760
|
+
if (!schema.inner[kSchema]) {
|
|
761
|
+
schema.inner = schema.builder();
|
|
762
|
+
schema.inner.meta = {
|
|
763
|
+
...schema.meta,
|
|
764
|
+
...schema.inner.meta
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
return Schema.resolve(data, schema.inner, options, strict);
|
|
768
|
+
});
|
|
769
|
+
Schema.extend("any", (data) => {
|
|
770
|
+
return [data];
|
|
771
|
+
});
|
|
772
|
+
Schema.extend("never", (data, _, options) => {
|
|
773
|
+
throw new ValidationError(`expected nullable but got ${data}`, options);
|
|
774
|
+
});
|
|
775
|
+
Schema.extend("const", (data, { value }, options) => {
|
|
776
|
+
if ((0, import_cosmokit.deepEqual)(data, value)) return [value];
|
|
777
|
+
throw new ValidationError(`expected ${value} but got ${data}`, options);
|
|
778
|
+
});
|
|
779
|
+
function checkWithinRange(data, meta, description, options, skipMin = false) {
|
|
780
|
+
const { max = Infinity, min = -Infinity } = meta;
|
|
781
|
+
if (data > max) throw new ValidationError(`expected ${description} <= ${max} but got ${data}`, options);
|
|
782
|
+
if (data < min && !skipMin) throw new ValidationError(`expected ${description} >= ${min} but got ${data}`, options);
|
|
783
|
+
}
|
|
784
|
+
__name(checkWithinRange, "checkWithinRange");
|
|
785
|
+
Schema.extend("string", (data, { meta }, options) => {
|
|
786
|
+
if (typeof data !== "string") throw new ValidationError(`expected string but got ${data}`, options);
|
|
787
|
+
if (meta.pattern) {
|
|
788
|
+
const regexp = new RegExp(meta.pattern.source, meta.pattern.flags);
|
|
789
|
+
if (!regexp.test(data)) throw new ValidationError(`expect string to match regexp ${regexp}`, options);
|
|
790
|
+
}
|
|
791
|
+
checkWithinRange(data.length, meta, "string length", options);
|
|
792
|
+
return [data];
|
|
793
|
+
});
|
|
794
|
+
function decimalShift(data, digits) {
|
|
795
|
+
const str = data.toString();
|
|
796
|
+
if (str.includes("e")) return data * Math.pow(10, digits);
|
|
797
|
+
const index = str.indexOf(".");
|
|
798
|
+
if (index === -1) return data * Math.pow(10, digits);
|
|
799
|
+
const frac = str.slice(index + 1);
|
|
800
|
+
const integer = str.slice(0, index);
|
|
801
|
+
if (frac.length <= digits) return +(integer + frac.padEnd(digits, "0"));
|
|
802
|
+
return +(integer + frac.slice(0, digits) + "." + frac.slice(digits));
|
|
803
|
+
}
|
|
804
|
+
__name(decimalShift, "decimalShift");
|
|
805
|
+
function isMultipleOf(data, min, step) {
|
|
806
|
+
step = Math.abs(step);
|
|
807
|
+
if (!/^\d+\.\d+$/.test(step.toString())) return (data - min) % step === 0;
|
|
808
|
+
const index = step.toString().indexOf(".");
|
|
809
|
+
const digits = step.toString().slice(index + 1).length;
|
|
810
|
+
return Math.abs(decimalShift(data, digits) - decimalShift(min, digits)) % decimalShift(step, digits) === 0;
|
|
811
|
+
}
|
|
812
|
+
__name(isMultipleOf, "isMultipleOf");
|
|
813
|
+
Schema.extend("number", (data, { meta }, options) => {
|
|
814
|
+
if (typeof data !== "number") throw new ValidationError(`expected number but got ${data}`, options);
|
|
815
|
+
checkWithinRange(data, meta, "number", options);
|
|
816
|
+
const { step } = meta;
|
|
817
|
+
if (step && !isMultipleOf(data, meta.min ?? 0, step)) throw new ValidationError(`expected number multiple of ${step} but got ${data}`, options);
|
|
818
|
+
return [data];
|
|
819
|
+
});
|
|
820
|
+
Schema.extend("boolean", (data, _, options) => {
|
|
821
|
+
if (typeof data === "boolean") return [data];
|
|
822
|
+
throw new ValidationError(`expected boolean but got ${data}`, options);
|
|
823
|
+
});
|
|
824
|
+
Schema.extend("bitset", (data, { bits, meta }, options) => {
|
|
825
|
+
let value = 0, keys = [];
|
|
826
|
+
if (typeof data === "number") {
|
|
827
|
+
value = data;
|
|
828
|
+
for (const key in bits) if (data & bits[key]) keys.push(key);
|
|
829
|
+
} else if (Array.isArray(data)) {
|
|
830
|
+
keys = data;
|
|
831
|
+
for (const key of keys) {
|
|
832
|
+
if (typeof key !== "string") throw new ValidationError(`expected string but got ${key}`, options);
|
|
833
|
+
if (key in bits) value |= bits[key];
|
|
834
|
+
}
|
|
835
|
+
} else throw new ValidationError(`expected number or array but got ${data}`, options);
|
|
836
|
+
if (value === meta.default) return [value];
|
|
837
|
+
return [value, keys];
|
|
838
|
+
});
|
|
839
|
+
Schema.extend("function", (data, _, options) => {
|
|
840
|
+
if (typeof data === "function") return [data];
|
|
841
|
+
throw new ValidationError(`expected function but got ${data}`, options);
|
|
842
|
+
});
|
|
843
|
+
Schema.extend("is", (data, { constructor }, options) => {
|
|
844
|
+
if (typeof constructor === "function") {
|
|
845
|
+
if (data instanceof constructor) return [data];
|
|
846
|
+
throw new ValidationError(`expected ${constructor.name} but got ${data}`, options);
|
|
847
|
+
} else {
|
|
848
|
+
if ((0, import_cosmokit.isNullable)(data)) throw new ValidationError(`expected ${constructor} but got ${data}`, options);
|
|
849
|
+
let prototype = Object.getPrototypeOf(data);
|
|
850
|
+
while (prototype) {
|
|
851
|
+
if (prototype.constructor?.name === constructor) return [data];
|
|
852
|
+
prototype = Object.getPrototypeOf(prototype);
|
|
853
|
+
}
|
|
854
|
+
throw new ValidationError(`expected ${constructor} but got ${data}`, options);
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
function property(data, key, schema, options) {
|
|
858
|
+
try {
|
|
859
|
+
const [value, adapted] = Schema.resolve(data[key], schema, {
|
|
860
|
+
...options,
|
|
861
|
+
path: [...options.path || [], key]
|
|
862
|
+
});
|
|
863
|
+
if (adapted !== void 0) data[key] = adapted;
|
|
864
|
+
return value;
|
|
865
|
+
} catch (e) {
|
|
866
|
+
if (!options?.autofix) throw e;
|
|
867
|
+
delete data[key];
|
|
868
|
+
return schema.meta.default;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
__name(property, "property");
|
|
872
|
+
Schema.extend("array", (data, { inner, meta }, options) => {
|
|
873
|
+
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
|
|
874
|
+
checkWithinRange(data.length, meta, "array length", options, !(0, import_cosmokit.isNullable)(inner.meta.default));
|
|
875
|
+
return [data.map((_, index) => property(data, index, inner, options))];
|
|
876
|
+
});
|
|
877
|
+
Schema.extend("dict", (data, { inner, sKey }, options, strict) => {
|
|
878
|
+
if (!(0, import_cosmokit.isPlainObject)(data)) throw new ValidationError(`expected object but got ${data}`, options);
|
|
879
|
+
const result = {};
|
|
880
|
+
for (const key in data) {
|
|
881
|
+
let rKey;
|
|
882
|
+
try {
|
|
883
|
+
rKey = Schema.resolve(key, sKey, options)[0];
|
|
884
|
+
} catch (error) {
|
|
885
|
+
if (strict) continue;
|
|
886
|
+
throw error;
|
|
887
|
+
}
|
|
888
|
+
result[rKey] = property(data, key, inner, options);
|
|
889
|
+
data[rKey] = data[key];
|
|
890
|
+
if (key !== rKey) delete data[key];
|
|
891
|
+
}
|
|
892
|
+
return [result];
|
|
893
|
+
});
|
|
894
|
+
Schema.extend("tuple", (data, { list }, options, strict) => {
|
|
895
|
+
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
|
|
896
|
+
const result = list.map((inner, index) => property(data, index, inner, options));
|
|
897
|
+
if (strict) return [result];
|
|
898
|
+
result.push(...data.slice(list.length));
|
|
899
|
+
return [result];
|
|
900
|
+
});
|
|
901
|
+
function merge(result, data) {
|
|
902
|
+
for (const key in data) {
|
|
903
|
+
if (key in result) continue;
|
|
904
|
+
result[key] = data[key];
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
__name(merge, "merge");
|
|
908
|
+
Schema.extend("object", (data, { dict }, options, strict) => {
|
|
909
|
+
if (!(0, import_cosmokit.isPlainObject)(data)) throw new ValidationError(`expected object but got ${data}`, options);
|
|
910
|
+
const result = {};
|
|
911
|
+
for (const key in dict) {
|
|
912
|
+
const value = property(data, key, dict[key], options);
|
|
913
|
+
if (!(0, import_cosmokit.isNullable)(value) || key in data) result[key] = value;
|
|
914
|
+
}
|
|
915
|
+
if (!strict) merge(result, data);
|
|
916
|
+
return [result];
|
|
917
|
+
});
|
|
918
|
+
Schema.extend("union", (data, { list, toString: toString2 }, options, strict) => {
|
|
919
|
+
const messages = [];
|
|
920
|
+
for (const inner of list) try {
|
|
921
|
+
return Schema.resolve(data, inner, options, strict);
|
|
922
|
+
} catch (error) {
|
|
923
|
+
messages.push(error);
|
|
924
|
+
}
|
|
925
|
+
throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
|
|
926
|
+
});
|
|
927
|
+
Schema.extend("intersect", (data, { list, toString: toString2 }, options, strict) => {
|
|
928
|
+
if (!list.length) return [data];
|
|
929
|
+
let result;
|
|
930
|
+
for (const inner of list) {
|
|
931
|
+
const value = Schema.resolve(data, inner, options, true)[0];
|
|
932
|
+
if ((0, import_cosmokit.isNullable)(value)) continue;
|
|
933
|
+
if ((0, import_cosmokit.isNullable)(result)) result = value;
|
|
934
|
+
else if (typeof result !== typeof value) throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
|
|
935
|
+
else if (typeof value === "object") merge(result ??= {}, value);
|
|
936
|
+
else if (result !== value) throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
|
|
937
|
+
}
|
|
938
|
+
if (!strict && (0, import_cosmokit.isPlainObject)(data)) merge(result, data);
|
|
939
|
+
return [result];
|
|
940
|
+
});
|
|
941
|
+
Schema.extend("transform", (data, { inner, callback, preserve }, options) => {
|
|
942
|
+
const [result, adapted = data] = Schema.resolve(data, inner, options, true);
|
|
943
|
+
if (preserve) return [callback(result)];
|
|
944
|
+
else return [callback(result), callback(adapted)];
|
|
945
|
+
});
|
|
946
|
+
var formatters = {};
|
|
947
|
+
function defineMethod(name, keys, format) {
|
|
948
|
+
formatters[name] = format;
|
|
949
|
+
Object.assign(Schema, { [name](...args) {
|
|
950
|
+
const schema = new Schema({ type: name });
|
|
951
|
+
keys.forEach((key, index) => {
|
|
952
|
+
switch (key) {
|
|
953
|
+
case "sKey":
|
|
954
|
+
schema.sKey = args[index] ?? Schema.string();
|
|
955
|
+
break;
|
|
956
|
+
case "inner":
|
|
957
|
+
schema.inner = Schema.from(args[index]);
|
|
958
|
+
break;
|
|
959
|
+
case "list":
|
|
960
|
+
schema.list = args[index].map(Schema.from);
|
|
961
|
+
break;
|
|
962
|
+
case "dict":
|
|
963
|
+
schema.dict = (0, import_cosmokit.valueMap)(args[index], Schema.from);
|
|
964
|
+
break;
|
|
965
|
+
case "bits":
|
|
966
|
+
schema.bits = {};
|
|
967
|
+
for (const key2 in args[index]) {
|
|
968
|
+
if (typeof args[index][key2] !== "number") continue;
|
|
969
|
+
schema.bits[key2] = args[index][key2];
|
|
970
|
+
}
|
|
971
|
+
break;
|
|
972
|
+
case "callback": {
|
|
973
|
+
const callback = schema.callback = args[index];
|
|
974
|
+
callback["toJSON"] ||= () => callback.toString();
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
case "constructor": {
|
|
978
|
+
const constructor = schema.constructor = args[index];
|
|
979
|
+
if (typeof constructor === "function") constructor["toJSON"] ||= () => constructor["name"];
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
default: schema[key] = args[index];
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
if (name === "object" || name === "dict") schema.meta.default = {};
|
|
986
|
+
else if (name === "array" || name === "tuple") schema.meta.default = [];
|
|
987
|
+
else if (name === "bitset") schema.meta.default = 0;
|
|
988
|
+
return schema;
|
|
989
|
+
} });
|
|
990
|
+
}
|
|
991
|
+
__name(defineMethod, "defineMethod");
|
|
992
|
+
defineMethod("is", ["constructor"], ({ constructor }) => {
|
|
993
|
+
if (typeof constructor === "function") return constructor.name;
|
|
994
|
+
else return constructor;
|
|
995
|
+
});
|
|
996
|
+
defineMethod("any", [], () => "any");
|
|
997
|
+
defineMethod("never", [], () => "never");
|
|
998
|
+
defineMethod("const", ["value"], ({ value }) => typeof value === "string" ? JSON.stringify(value) : value);
|
|
999
|
+
defineMethod("string", [], () => "string");
|
|
1000
|
+
defineMethod("number", [], () => "number");
|
|
1001
|
+
defineMethod("boolean", [], () => "boolean");
|
|
1002
|
+
defineMethod("bitset", ["bits"], () => "bitset");
|
|
1003
|
+
defineMethod("function", [], () => "function");
|
|
1004
|
+
defineMethod("array", ["inner"], ({ inner }) => `${inner.toString(true)}[]`);
|
|
1005
|
+
defineMethod("dict", ["inner", "sKey"], ({ inner, sKey }) => `{ [key: ${sKey.toString()}]: ${inner.toString()} }`);
|
|
1006
|
+
defineMethod("tuple", ["list"], ({ list }) => `[${list.map((inner) => inner.toString()).join(", ")}]`);
|
|
1007
|
+
defineMethod("object", ["dict"], ({ dict }) => {
|
|
1008
|
+
if (Object.keys(dict).length === 0) return "{}";
|
|
1009
|
+
return `{ ${Object.entries(dict).map(([key, inner]) => {
|
|
1010
|
+
return `${key}${inner.meta.required ? "" : "?"}: ${inner.toString()}`;
|
|
1011
|
+
}).join(", ")} }`;
|
|
1012
|
+
});
|
|
1013
|
+
defineMethod("union", ["list"], ({ list }, inline) => {
|
|
1014
|
+
const result = list.map(({ toString: format }) => format()).join(" | ");
|
|
1015
|
+
return inline ? `(${result})` : result;
|
|
1016
|
+
});
|
|
1017
|
+
defineMethod("intersect", ["list"], ({ list }) => {
|
|
1018
|
+
return `${list.map((inner) => inner.toString(true)).join(" & ")}`;
|
|
1019
|
+
});
|
|
1020
|
+
defineMethod("transform", [
|
|
1021
|
+
"inner",
|
|
1022
|
+
"callback",
|
|
1023
|
+
"preserve"
|
|
1024
|
+
], ({ inner }, isInner) => inner.toString(isInner));
|
|
1025
|
+
module.exports = Schema;
|
|
1026
|
+
})))(), 1);
|
|
1027
|
+
/**
|
|
1028
|
+
* Host config store: one JSON file (`~/.dsh/dsh-ssh.json`) holding every
|
|
1029
|
+
* SSH host entry, written atomically (tmp + rename). Also parses the user's
|
|
1030
|
+
* standard `~/.ssh/config` for one-shot import. Secrets (passwords,
|
|
1031
|
+
* passphrases) live in this user-owned file in plaintext — same trust model
|
|
1032
|
+
* as ssh-skill's annotated ssh-config comments; document it, never log it.
|
|
1033
|
+
*/
|
|
1034
|
+
/** File format version. */
|
|
1035
|
+
const FORMAT_VERSION = 1;
|
|
1036
|
+
/** Store file location: <home>/.dsh/dsh-ssh.json. */
|
|
1037
|
+
function storePath() {
|
|
1038
|
+
return join(homedir(), ".dsh", "dsh-ssh.json");
|
|
1039
|
+
}
|
|
1040
|
+
/** The user's standard OpenSSH config path. */
|
|
1041
|
+
function sshConfigPath() {
|
|
1042
|
+
return join(homedir(), ".ssh", "config");
|
|
1043
|
+
}
|
|
1044
|
+
/** Validate the wire shape of a host payload; returns a message or undefined. */
|
|
1045
|
+
function validateHostPayload(payload) {
|
|
1046
|
+
if (typeof payload !== "object" || payload === null) return "body must be a JSON object";
|
|
1047
|
+
const p = payload;
|
|
1048
|
+
if (typeof p.host !== "string" || p.host.trim() === "") return "host is required";
|
|
1049
|
+
if (typeof p.user !== "string" || p.user.trim() === "") return "user is required";
|
|
1050
|
+
const auth = p.auth;
|
|
1051
|
+
if (auth !== void 0) {
|
|
1052
|
+
if (typeof auth !== "object" || auth === null) return "auth must be an object";
|
|
1053
|
+
if (auth.kind !== "key" && auth.kind !== "password") return "auth.kind must be key or password";
|
|
1054
|
+
if (auth.kind === "key" && (typeof auth.keyPath !== "string" || auth.keyPath.trim() === "")) return "auth.keyPath is required for key auth";
|
|
1055
|
+
if (auth.kind === "password" && (typeof auth.password !== "string" || auth.password === "")) return "auth.password is required for password auth";
|
|
1056
|
+
}
|
|
1057
|
+
if (p.port !== void 0 && (typeof p.port !== "number" || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) return "port must be an integer in 1..65535";
|
|
1058
|
+
if (p.proxyJump !== void 0 && (!Array.isArray(p.proxyJump) || p.proxyJump.some((x) => typeof x !== "string" || x === ""))) return "proxyJump must be an array of alias strings";
|
|
1059
|
+
if (p.tags !== void 0 && (!Array.isArray(p.tags) || p.tags.some((x) => typeof x !== "string"))) return "tags must be an array of strings";
|
|
1060
|
+
}
|
|
1061
|
+
/** The alias grammar (same as the scaffold rule: lowercase, digits, single hyphens). */
|
|
1062
|
+
const ALIAS_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
1063
|
+
/** Validate an alias for creation. */
|
|
1064
|
+
function validateAlias(alias) {
|
|
1065
|
+
if (!ALIAS_RE.test(alias)) return "alias must be lowercase letters, digits and single hyphens";
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* The host store. Pure file I/O — no cordis dependency, unit-testable.
|
|
1069
|
+
*/
|
|
1070
|
+
var HostStore = class {
|
|
1071
|
+
/** The JSON file path. */
|
|
1072
|
+
path;
|
|
1073
|
+
/** Optional override of the ~/.ssh/config path (tests). */
|
|
1074
|
+
sshConfigOverride;
|
|
1075
|
+
/**
|
|
1076
|
+
* @param path - store file path (defaults to the standard location).
|
|
1077
|
+
* @param sshConfigOverride - ssh config path override (tests only).
|
|
1078
|
+
*/
|
|
1079
|
+
constructor(path, sshConfigOverride) {
|
|
1080
|
+
this.path = resolve(path ?? storePath());
|
|
1081
|
+
this.sshConfigOverride = sshConfigOverride;
|
|
1082
|
+
}
|
|
1083
|
+
/** Load all entries (empty store when the file is absent). */
|
|
1084
|
+
list() {
|
|
1085
|
+
return this.load().hosts;
|
|
1086
|
+
}
|
|
1087
|
+
/** Find one entry by alias. */
|
|
1088
|
+
find(alias) {
|
|
1089
|
+
return this.list().find((entry) => entry.alias === alias);
|
|
1090
|
+
}
|
|
1091
|
+
/** Secret-free projection for the browser and agent surfaces. */
|
|
1092
|
+
summarize(entry) {
|
|
1093
|
+
let keyReady = true;
|
|
1094
|
+
if (entry.auth.kind === "key" && entry.auth.keyPath) keyReady = existsSync(expandHome(entry.auth.keyPath));
|
|
1095
|
+
return {
|
|
1096
|
+
alias: entry.alias,
|
|
1097
|
+
host: entry.host,
|
|
1098
|
+
port: entry.port,
|
|
1099
|
+
user: entry.user,
|
|
1100
|
+
auth: entry.auth.kind,
|
|
1101
|
+
keyReady,
|
|
1102
|
+
proxyJump: [...entry.proxyJump],
|
|
1103
|
+
...entry.description !== void 0 ? { description: entry.description } : {},
|
|
1104
|
+
...entry.environment !== void 0 ? { environment: entry.environment } : {},
|
|
1105
|
+
tags: [...entry.tags],
|
|
1106
|
+
...entry.location !== void 0 ? { location: entry.location } : {},
|
|
1107
|
+
createdAt: entry.createdAt,
|
|
1108
|
+
updatedAt: entry.updatedAt
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
/** Create one entry. Throws on alias collision or invalid payload. */
|
|
1112
|
+
create(payload) {
|
|
1113
|
+
const alias = payload.alias?.trim();
|
|
1114
|
+
if (!alias) throw new Error("alias is required");
|
|
1115
|
+
const aliasError = validateAlias(alias);
|
|
1116
|
+
if (aliasError !== void 0) throw new Error(aliasError);
|
|
1117
|
+
const bodyError = validateHostPayload(payload);
|
|
1118
|
+
if (bodyError !== void 0) throw new Error(bodyError);
|
|
1119
|
+
if (payload.auth === void 0) throw new Error("auth is required");
|
|
1120
|
+
const file = this.load();
|
|
1121
|
+
if (file.hosts.some((entry) => entry.alias === alias)) throw new Error(`alias '${alias}' already exists`);
|
|
1122
|
+
const now = Date.now();
|
|
1123
|
+
const entry = {
|
|
1124
|
+
alias,
|
|
1125
|
+
host: payload.host.trim(),
|
|
1126
|
+
port: payload.port ?? 22,
|
|
1127
|
+
user: payload.user.trim(),
|
|
1128
|
+
auth: {
|
|
1129
|
+
kind: payload.auth.kind,
|
|
1130
|
+
keyPath: payload.auth.kind === "key" ? expandHome(payload.auth.keyPath?.trim() ?? "") : void 0,
|
|
1131
|
+
passphrase: payload.auth.kind === "key" ? payload.auth.passphrase ?? void 0 : void 0,
|
|
1132
|
+
password: payload.auth.kind === "password" ? payload.auth.password : void 0
|
|
1133
|
+
},
|
|
1134
|
+
proxyJump: [...payload.proxyJump ?? []],
|
|
1135
|
+
description: payload.description?.trim() || void 0,
|
|
1136
|
+
environment: payload.environment?.trim() || void 0,
|
|
1137
|
+
tags: [...payload.tags ?? []].map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
|
1138
|
+
location: payload.location?.trim() || void 0,
|
|
1139
|
+
createdAt: now,
|
|
1140
|
+
updatedAt: now
|
|
1141
|
+
};
|
|
1142
|
+
file.hosts.push(entry);
|
|
1143
|
+
this.save(file);
|
|
1144
|
+
return entry;
|
|
1145
|
+
}
|
|
1146
|
+
/** Update the fields present in `patch`; unknown aliases throw. */
|
|
1147
|
+
update(alias, patch) {
|
|
1148
|
+
const file = this.load();
|
|
1149
|
+
const entry = file.hosts.find((candidate) => candidate.alias === alias);
|
|
1150
|
+
if (entry === void 0) throw new Error(`alias '${alias}' not found`);
|
|
1151
|
+
if (patch.host !== void 0 && (typeof patch.host !== "string" || patch.host.trim() === "")) throw new Error("host is required");
|
|
1152
|
+
if (patch.user !== void 0 && (typeof patch.user !== "string" || patch.user.trim() === "")) throw new Error("user is required");
|
|
1153
|
+
if (patch.port !== void 0 && (typeof patch.port !== "number" || !Number.isInteger(patch.port) || patch.port < 1 || patch.port > 65535)) throw new Error("port must be an integer in 1..65535");
|
|
1154
|
+
if (patch.proxyJump !== void 0 && (!Array.isArray(patch.proxyJump) || patch.proxyJump.some((x) => typeof x !== "string" || x === ""))) throw new Error("proxyJump must be an array of alias strings");
|
|
1155
|
+
if (patch.tags !== void 0 && (!Array.isArray(patch.tags) || patch.tags.some((x) => typeof x !== "string"))) throw new Error("tags must be an array of strings");
|
|
1156
|
+
if (patch.host !== void 0) entry.host = patch.host.trim();
|
|
1157
|
+
if (patch.port !== void 0) entry.port = patch.port;
|
|
1158
|
+
if (patch.user !== void 0) entry.user = patch.user.trim();
|
|
1159
|
+
if (patch.auth !== void 0) {
|
|
1160
|
+
const auth = patch.auth;
|
|
1161
|
+
if (auth.kind !== "key" && auth.kind !== "password") throw new Error("auth.kind must be key or password");
|
|
1162
|
+
if (auth.kind === "key" && (typeof auth.keyPath !== "string" || auth.keyPath.trim() === "")) throw new Error("auth.keyPath is required for key auth");
|
|
1163
|
+
if (auth.kind === "password" && (typeof auth.password !== "string" || auth.password === "")) throw new Error("auth.password is required for password auth");
|
|
1164
|
+
const keyChanged = auth.kind === "key" && auth.keyPath !== void 0 && expandHome(auth.keyPath.trim()) !== entry.auth.keyPath;
|
|
1165
|
+
entry.auth = {
|
|
1166
|
+
kind: auth.kind,
|
|
1167
|
+
keyPath: auth.kind === "key" ? expandHome(auth.keyPath?.trim() ?? "") : void 0,
|
|
1168
|
+
passphrase: auth.kind === "key" ? auth.passphrase !== void 0 ? auth.passphrase : keyChanged ? void 0 : entry.auth.passphrase : void 0,
|
|
1169
|
+
password: auth.kind === "password" ? auth.password : void 0
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
if (patch.proxyJump !== void 0) entry.proxyJump = [...patch.proxyJump];
|
|
1173
|
+
if (patch.description !== void 0) entry.description = patch.description.trim() || void 0;
|
|
1174
|
+
if (patch.environment !== void 0) entry.environment = patch.environment.trim() || void 0;
|
|
1175
|
+
if (patch.tags !== void 0) entry.tags = [...patch.tags].map((tag) => tag.trim()).filter((tag) => tag !== "");
|
|
1176
|
+
if (patch.location !== void 0) entry.location = patch.location.trim() || void 0;
|
|
1177
|
+
entry.updatedAt = Date.now();
|
|
1178
|
+
this.save(file);
|
|
1179
|
+
return entry;
|
|
1180
|
+
}
|
|
1181
|
+
/** Remove one entry. */
|
|
1182
|
+
delete(alias) {
|
|
1183
|
+
const file = this.load();
|
|
1184
|
+
const index = file.hosts.findIndex((candidate) => candidate.alias === alias);
|
|
1185
|
+
if (index < 0) throw new Error(`alias '${alias}' not found`);
|
|
1186
|
+
file.hosts.splice(index, 1);
|
|
1187
|
+
this.save(file);
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
|
|
1191
|
+
* pattern and a HostName become entries (key auth via IdentityFile, jump
|
|
1192
|
+
* hosts via ProxyJump). Existing aliases are skipped.
|
|
1193
|
+
* @returns import statistics.
|
|
1194
|
+
*/
|
|
1195
|
+
importFromSshConfig() {
|
|
1196
|
+
this.skippedNames = /* @__PURE__ */ new Set();
|
|
1197
|
+
const configPath = this.sshConfigOverride ?? sshConfigPath();
|
|
1198
|
+
if (!existsSync(configPath)) return {
|
|
1199
|
+
parsed: 0,
|
|
1200
|
+
added: 0,
|
|
1201
|
+
skipped: 0,
|
|
1202
|
+
skippedNames: []
|
|
1203
|
+
};
|
|
1204
|
+
const lines = readFileSync(configPath, "utf8").split(/\r?\n/);
|
|
1205
|
+
const blocks = [];
|
|
1206
|
+
let current;
|
|
1207
|
+
const skip = (name, seen) => {
|
|
1208
|
+
if (name !== "" && !seen.has(name)) {
|
|
1209
|
+
seen.add(name);
|
|
1210
|
+
this.skippedNames.add(name);
|
|
1211
|
+
}
|
|
1212
|
+
};
|
|
1213
|
+
for (const raw of lines) {
|
|
1214
|
+
const line = raw.trim();
|
|
1215
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
1216
|
+
const match = /^([A-Za-z0-9_\-]+)\s+(.+)$/.exec(line);
|
|
1217
|
+
if (match === null) continue;
|
|
1218
|
+
const key = match[1].toLowerCase();
|
|
1219
|
+
const value = match[2].trim();
|
|
1220
|
+
if (key === "host") {
|
|
1221
|
+
current = {
|
|
1222
|
+
pattern: value,
|
|
1223
|
+
props: {}
|
|
1224
|
+
};
|
|
1225
|
+
blocks.push(current);
|
|
1226
|
+
} else if (current !== void 0) current.props[key] = value;
|
|
1227
|
+
}
|
|
1228
|
+
let added = 0;
|
|
1229
|
+
for (const block of blocks) {
|
|
1230
|
+
const pattern = block.pattern.split(/\s+/)[0];
|
|
1231
|
+
if (pattern.includes("*") || pattern.includes("?")) {
|
|
1232
|
+
skip(pattern, this.skippedNames);
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
const hostName = block.props.hostname;
|
|
1236
|
+
if (hostName === void 0 || hostName === "") {
|
|
1237
|
+
skip(pattern, this.skippedNames);
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
if (this.list().some((entry) => entry.alias === pattern)) {
|
|
1241
|
+
skip(pattern, this.skippedNames);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
const payload = {
|
|
1245
|
+
alias: pattern,
|
|
1246
|
+
host: hostName,
|
|
1247
|
+
port: block.props.port !== void 0 ? Number.parseInt(block.props.port, 10) : 22,
|
|
1248
|
+
user: block.props.user ?? process.env.USER ?? "root",
|
|
1249
|
+
auth: {
|
|
1250
|
+
kind: block.props.identityfile !== void 0 ? "key" : "password",
|
|
1251
|
+
keyPath: block.props.identityfile,
|
|
1252
|
+
password: block.props.password
|
|
1253
|
+
},
|
|
1254
|
+
proxyJump: block.props.proxyjump !== void 0 ? block.props.proxyjump.split(",").map((hop) => hop.trim()).filter((hop) => hop !== "") : [],
|
|
1255
|
+
description: block.props.description,
|
|
1256
|
+
environment: block.props.environment,
|
|
1257
|
+
tags: (block.props.tags ?? "").split(",").map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
|
1258
|
+
location: block.props.location
|
|
1259
|
+
};
|
|
1260
|
+
try {
|
|
1261
|
+
this.create(payload);
|
|
1262
|
+
added += 1;
|
|
1263
|
+
} catch {
|
|
1264
|
+
skip(pattern, this.skippedNames);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return {
|
|
1268
|
+
parsed: blocks.length,
|
|
1269
|
+
added,
|
|
1270
|
+
skipped: this.skippedNames.size,
|
|
1271
|
+
skippedNames: [...this.skippedNames]
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
skippedNames = /* @__PURE__ */ new Set();
|
|
1275
|
+
load() {
|
|
1276
|
+
if (!existsSync(this.path)) return {
|
|
1277
|
+
version: FORMAT_VERSION,
|
|
1278
|
+
hosts: []
|
|
1279
|
+
};
|
|
1280
|
+
try {
|
|
1281
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
1282
|
+
if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.hosts)) throw new Error("store file shape invalid");
|
|
1283
|
+
return parsed;
|
|
1284
|
+
} catch {
|
|
1285
|
+
try {
|
|
1286
|
+
renameSync(this.path, `${this.path}.corrupt-${Date.now()}`);
|
|
1287
|
+
} catch {}
|
|
1288
|
+
return {
|
|
1289
|
+
version: FORMAT_VERSION,
|
|
1290
|
+
hosts: []
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
save(file) {
|
|
1295
|
+
const dir = dirname(this.path);
|
|
1296
|
+
if (!existsSync(dir)) mkdirSync(dir, {
|
|
1297
|
+
recursive: true,
|
|
1298
|
+
mode: 448
|
|
1299
|
+
});
|
|
1300
|
+
const tmp = this.path + ".tmp";
|
|
1301
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", {
|
|
1302
|
+
encoding: "utf8",
|
|
1303
|
+
mode: 384
|
|
1304
|
+
});
|
|
1305
|
+
renameSync(tmp, this.path);
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
/** Expand a leading `~` in a filesystem path. */
|
|
1309
|
+
function expandHome(path) {
|
|
1310
|
+
if (path === "~") return homedir();
|
|
1311
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
1312
|
+
return path;
|
|
1313
|
+
}
|
|
1314
|
+
//#endregion
|
|
1315
|
+
//#region src/engine.ts
|
|
1316
|
+
/**
|
|
1317
|
+
* The SSH engine: a per-alias persistent connection pool (ssh2) with
|
|
1318
|
+
* multi-hop jump support, command execution, PTY shells, SFTP transfers,
|
|
1319
|
+
* local port-forward tunnels and cluster execution — the DSH counterpart of
|
|
1320
|
+
* ssh-skill's daemon + scripts, living entirely in the host process.
|
|
1321
|
+
*/
|
|
1322
|
+
const DEFAULTS = {
|
|
1323
|
+
idleTimeoutMs: 30 * 6e4,
|
|
1324
|
+
connectTimeoutMs: 15e3,
|
|
1325
|
+
keepaliveIntervalMs: 15e3,
|
|
1326
|
+
maxOutputBytes: 2 * 1024 * 1024,
|
|
1327
|
+
defaultExecTimeoutMs: 6e4,
|
|
1328
|
+
defaultMaxWorkers: 8,
|
|
1329
|
+
sftpConcurrency: 8
|
|
1330
|
+
};
|
|
1331
|
+
/** Build the ssh2 connect config for one entry (key read from disk). */
|
|
1332
|
+
function buildConnectConfig(entry, sock) {
|
|
1333
|
+
const config = {
|
|
1334
|
+
host: entry.host,
|
|
1335
|
+
port: entry.port,
|
|
1336
|
+
username: entry.user,
|
|
1337
|
+
readyTimeout: 15e3,
|
|
1338
|
+
keepaliveInterval: 15e3,
|
|
1339
|
+
keepaliveCountMax: 3
|
|
1340
|
+
};
|
|
1341
|
+
if (sock !== void 0) config.sock = sock;
|
|
1342
|
+
if (entry.auth.kind === "password") config.password = entry.auth.password;
|
|
1343
|
+
else {
|
|
1344
|
+
const keyPath = entry.auth.keyPath === void 0 ? void 0 : expandHome(entry.auth.keyPath);
|
|
1345
|
+
if (keyPath === void 0 || !existsSync(keyPath)) throw new Error(`private key not found: '${entry.auth.keyPath ?? "(unset)"}'`);
|
|
1346
|
+
config.privateKey = readFileSync(keyPath, "utf8");
|
|
1347
|
+
if (entry.auth.passphrase !== void 0 && entry.auth.passphrase !== "") config.passphrase = entry.auth.passphrase;
|
|
1348
|
+
}
|
|
1349
|
+
return config;
|
|
1350
|
+
}
|
|
1351
|
+
/** Connect one ssh2 client (resolve on ready, reject on error/close). */
|
|
1352
|
+
function connectClient(config) {
|
|
1353
|
+
return new Promise((resolve, reject) => {
|
|
1354
|
+
const client = new Client();
|
|
1355
|
+
let settled = false;
|
|
1356
|
+
client.once("ready", () => {
|
|
1357
|
+
if (settled) return;
|
|
1358
|
+
settled = true;
|
|
1359
|
+
resolve(client);
|
|
1360
|
+
});
|
|
1361
|
+
client.once("error", (error) => {
|
|
1362
|
+
if (settled) return;
|
|
1363
|
+
settled = true;
|
|
1364
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1365
|
+
});
|
|
1366
|
+
try {
|
|
1367
|
+
client.connect(config);
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
if (!settled) {
|
|
1370
|
+
settled = true;
|
|
1371
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
/** Cap captured output at the configured byte budget (marks truncation). */
|
|
1377
|
+
function appendOutput(target, chunk, maxBytes) {
|
|
1378
|
+
if (target.truncated) return;
|
|
1379
|
+
if (target.text.length + chunk.length > maxBytes) {
|
|
1380
|
+
let cut = chunk.toString("utf8").slice(0, maxBytes - target.text.length);
|
|
1381
|
+
if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1);
|
|
1382
|
+
target.text += cut + "…[output truncated]";
|
|
1383
|
+
target.truncated = true;
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
target.text += chunk.toString("utf8");
|
|
1387
|
+
}
|
|
1388
|
+
/** Walk a local directory, collecting relative paths of every file. */
|
|
1389
|
+
function walkLocalDir(root) {
|
|
1390
|
+
const files = [];
|
|
1391
|
+
const visit = (dir) => {
|
|
1392
|
+
for (const name of readdirSync(dir)) {
|
|
1393
|
+
const full = join(dir, name);
|
|
1394
|
+
const stat = statSync(full);
|
|
1395
|
+
if (stat.isDirectory()) visit(full);
|
|
1396
|
+
else if (stat.isFile()) files.push(relative(root, full));
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
visit(root);
|
|
1400
|
+
return files;
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* The engine. Owns the pool, tunnels, and all operations. One instance per
|
|
1404
|
+
* plugin apply; dispose() closes every connection.
|
|
1405
|
+
*/
|
|
1406
|
+
var SshEngine = class {
|
|
1407
|
+
store;
|
|
1408
|
+
opts;
|
|
1409
|
+
pool = /* @__PURE__ */ new Map();
|
|
1410
|
+
tunnels = /* @__PURE__ */ new Map();
|
|
1411
|
+
sweepTimer;
|
|
1412
|
+
nextTunnelId = 1;
|
|
1413
|
+
/**
|
|
1414
|
+
* @param store - the host config store.
|
|
1415
|
+
* @param options - engine knobs (defaults applied).
|
|
1416
|
+
*/
|
|
1417
|
+
constructor(store, options) {
|
|
1418
|
+
this.store = store;
|
|
1419
|
+
this.opts = {
|
|
1420
|
+
...DEFAULTS,
|
|
1421
|
+
...options
|
|
1422
|
+
};
|
|
1423
|
+
this.sweepTimer = setInterval(() => this.sweep(), Math.max(1e4, this.opts.idleTimeoutMs / 4));
|
|
1424
|
+
this.sweepTimer.unref?.();
|
|
1425
|
+
}
|
|
1426
|
+
/** Secret-free host list (filtered by the optional query). */
|
|
1427
|
+
list(query) {
|
|
1428
|
+
const needle = query?.trim().toLowerCase();
|
|
1429
|
+
return this.store.list().filter((entry) => needle === void 0 || needle === "" || entry.alias.toLowerCase().includes(needle) || (entry.description ?? "").toLowerCase().includes(needle) || entry.host.toLowerCase().includes(needle) || entry.tags.some((tag) => tag.toLowerCase().includes(needle))).map((entry) => this.store.summarize(entry));
|
|
1430
|
+
}
|
|
1431
|
+
/** One host summary by alias. */
|
|
1432
|
+
find(alias) {
|
|
1433
|
+
const entry = this.store.find(alias);
|
|
1434
|
+
return entry === void 0 ? void 0 : this.store.summarize(entry);
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Run `fn` with a live client for `alias`, reconnecting (up to the
|
|
1438
|
+
* attempt budget) when the connection broke mid-flight.
|
|
1439
|
+
*/
|
|
1440
|
+
async withClient(alias, fn, attempts = 3) {
|
|
1441
|
+
let lastError;
|
|
1442
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1443
|
+
let record = this.pool.get(alias);
|
|
1444
|
+
if (record === void 0 || record.broken) {
|
|
1445
|
+
if (record !== void 0) this.disposeRecord(alias, record);
|
|
1446
|
+
record = await this.acquire(alias);
|
|
1447
|
+
}
|
|
1448
|
+
record.idleAt = Date.now();
|
|
1449
|
+
record.inFlight += 1;
|
|
1450
|
+
try {
|
|
1451
|
+
const result = await fn(record.client);
|
|
1452
|
+
record.idleAt = Date.now();
|
|
1453
|
+
return result;
|
|
1454
|
+
} finally {
|
|
1455
|
+
record.inFlight -= 1;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Build one full jump chain for an entry: hop clients connected through in
|
|
1462
|
+
* order, each forwarding a stream to the next destination, ending with the
|
|
1463
|
+
* target client. Shared by the pool and standalone shell sessions.
|
|
1464
|
+
*/
|
|
1465
|
+
async connectChain(entry) {
|
|
1466
|
+
const hops = [];
|
|
1467
|
+
let sock;
|
|
1468
|
+
const chain = entry.proxyJump;
|
|
1469
|
+
for (let index = 0; index < chain.length; index += 1) {
|
|
1470
|
+
const hopAlias = chain[index];
|
|
1471
|
+
const hop = this.store.find(hopAlias);
|
|
1472
|
+
if (hop === void 0) {
|
|
1473
|
+
for (const client of hops) client.end();
|
|
1474
|
+
throw new Error(`proxyJump alias '${hopAlias}' not found — create it first`);
|
|
1475
|
+
}
|
|
1476
|
+
const hopClient = await connectClient(buildConnectConfig(hop, sock));
|
|
1477
|
+
hops.push(hopClient);
|
|
1478
|
+
const next = index + 1 < chain.length ? this.store.find(chain[index + 1]) : void 0;
|
|
1479
|
+
const nextHost = next !== void 0 ? next.host : entry.host;
|
|
1480
|
+
const nextPort = next !== void 0 ? next.port : entry.port;
|
|
1481
|
+
sock = await new Promise((resolve, reject) => {
|
|
1482
|
+
hopClient.forwardOut("127.0.0.1", 0, nextHost, nextPort, (error, stream) => {
|
|
1483
|
+
if (error !== void 0) {
|
|
1484
|
+
for (const client of hops) client.end();
|
|
1485
|
+
reject(error);
|
|
1486
|
+
} else resolve(stream);
|
|
1487
|
+
});
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
try {
|
|
1491
|
+
return {
|
|
1492
|
+
client: await connectClient(buildConnectConfig(entry, sock)),
|
|
1493
|
+
hops
|
|
1494
|
+
};
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
for (const client of hops) client.end();
|
|
1497
|
+
throw error;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
/** In-flight acquire promises, deduped per alias (concurrent first use). */
|
|
1501
|
+
acquireQueue = /* @__PURE__ */ new Map();
|
|
1502
|
+
/** Connect (or reuse) the pooled chain for one alias; pins nothing. */
|
|
1503
|
+
async acquire(alias) {
|
|
1504
|
+
const pending = this.acquireQueue.get(alias);
|
|
1505
|
+
if (pending !== void 0) return pending;
|
|
1506
|
+
const task = this.doAcquire(alias);
|
|
1507
|
+
this.acquireQueue.set(alias, task);
|
|
1508
|
+
try {
|
|
1509
|
+
return await task;
|
|
1510
|
+
} finally {
|
|
1511
|
+
if (this.acquireQueue.get(alias) === task) this.acquireQueue.delete(alias);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
async doAcquire(alias) {
|
|
1515
|
+
const entry = this.store.find(alias);
|
|
1516
|
+
if (entry === void 0) throw new Error(`alias '${alias}' not found — add it first`);
|
|
1517
|
+
const { client, hops } = await this.connectChain(entry);
|
|
1518
|
+
const record = {
|
|
1519
|
+
client,
|
|
1520
|
+
hops,
|
|
1521
|
+
idleAt: Date.now(),
|
|
1522
|
+
pinned: false,
|
|
1523
|
+
broken: false,
|
|
1524
|
+
inFlight: 0
|
|
1525
|
+
};
|
|
1526
|
+
client.on("error", () => {
|
|
1527
|
+
record.broken = true;
|
|
1528
|
+
});
|
|
1529
|
+
client.on("close", () => {
|
|
1530
|
+
record.broken = true;
|
|
1531
|
+
});
|
|
1532
|
+
this.pool.set(alias, record);
|
|
1533
|
+
return record;
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Tear down one alias's record. When `record` is given and no longer the
|
|
1537
|
+
* pooled record for the alias (a concurrent acquire replaced it), nothing
|
|
1538
|
+
* is torn down — the connection belongs to someone else now.
|
|
1539
|
+
*/
|
|
1540
|
+
disposeRecord(alias, record) {
|
|
1541
|
+
const current = this.pool.get(alias);
|
|
1542
|
+
if (record !== void 0 && current !== record) return;
|
|
1543
|
+
if (current === void 0) return;
|
|
1544
|
+
this.pool.delete(alias);
|
|
1545
|
+
try {
|
|
1546
|
+
current.client.end();
|
|
1547
|
+
} catch {}
|
|
1548
|
+
for (const hop of current.hops) try {
|
|
1549
|
+
hop.end();
|
|
1550
|
+
} catch {}
|
|
1551
|
+
}
|
|
1552
|
+
/** Close connections idle beyond the threshold (skips pinned and in-flight). */
|
|
1553
|
+
sweep() {
|
|
1554
|
+
const cutoff = Date.now() - this.opts.idleTimeoutMs;
|
|
1555
|
+
for (const [alias, record] of this.pool) if (!record.pinned && record.inFlight === 0 && record.idleAt < cutoff) this.disposeRecord(alias, record);
|
|
1556
|
+
}
|
|
1557
|
+
/** Run one command on `alias` (reusing the pooled connection). */
|
|
1558
|
+
async exec(alias, command, timeoutMs) {
|
|
1559
|
+
const started = Date.now();
|
|
1560
|
+
const budget = timeoutMs !== void 0 && timeoutMs > 0 ? timeoutMs : this.opts.defaultExecTimeoutMs;
|
|
1561
|
+
return this.withClient(alias, async (client) => {
|
|
1562
|
+
return await new Promise((resolve, reject) => {
|
|
1563
|
+
client.exec(command, (error, stream) => {
|
|
1564
|
+
if (error !== void 0) {
|
|
1565
|
+
reject(error);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
const stdout = {
|
|
1569
|
+
text: "",
|
|
1570
|
+
truncated: false
|
|
1571
|
+
};
|
|
1572
|
+
const stderr = {
|
|
1573
|
+
text: "",
|
|
1574
|
+
truncated: false
|
|
1575
|
+
};
|
|
1576
|
+
let timedOut = false;
|
|
1577
|
+
let settled = false;
|
|
1578
|
+
const finish = () => {
|
|
1579
|
+
if (settled) return;
|
|
1580
|
+
settled = true;
|
|
1581
|
+
clearTimeout(timer);
|
|
1582
|
+
resolve({
|
|
1583
|
+
success: false,
|
|
1584
|
+
exitCode: null,
|
|
1585
|
+
timedOut,
|
|
1586
|
+
stdout: stdout.text,
|
|
1587
|
+
stderr: stderr.text,
|
|
1588
|
+
durationMs: Date.now() - started,
|
|
1589
|
+
error: timedOut ? `command timed out after ${budget} ms` : void 0
|
|
1590
|
+
});
|
|
1591
|
+
};
|
|
1592
|
+
const timer = setTimeout(() => {
|
|
1593
|
+
timedOut = true;
|
|
1594
|
+
try {
|
|
1595
|
+
stream.signal("KILL");
|
|
1596
|
+
} catch {}
|
|
1597
|
+
try {
|
|
1598
|
+
stream.close();
|
|
1599
|
+
} catch {}
|
|
1600
|
+
finish();
|
|
1601
|
+
}, budget);
|
|
1602
|
+
stream.on("data", (chunk) => appendOutput(stdout, chunk, this.opts.maxOutputBytes));
|
|
1603
|
+
stream.stderr.on("data", (chunk) => appendOutput(stderr, chunk, this.opts.maxOutputBytes));
|
|
1604
|
+
stream.on("close", (code) => {
|
|
1605
|
+
if (settled) return;
|
|
1606
|
+
settled = true;
|
|
1607
|
+
clearTimeout(timer);
|
|
1608
|
+
resolve({
|
|
1609
|
+
success: code === 0 && !timedOut,
|
|
1610
|
+
exitCode: code,
|
|
1611
|
+
timedOut,
|
|
1612
|
+
stdout: stdout.text,
|
|
1613
|
+
stderr: stderr.text,
|
|
1614
|
+
durationMs: Date.now() - started
|
|
1615
|
+
});
|
|
1616
|
+
});
|
|
1617
|
+
stream.on("error", (streamError) => {
|
|
1618
|
+
if (settled) return;
|
|
1619
|
+
settled = true;
|
|
1620
|
+
clearTimeout(timer);
|
|
1621
|
+
reject(streamError);
|
|
1622
|
+
});
|
|
1623
|
+
});
|
|
1624
|
+
});
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
/** Run one command against many hosts concurrently. */
|
|
1628
|
+
async cluster(options) {
|
|
1629
|
+
let targets = this.store.list();
|
|
1630
|
+
if (options.aliases !== void 0 && options.aliases.length > 0) targets = targets.filter((entry) => options.aliases.includes(entry.alias));
|
|
1631
|
+
if (options.environment !== void 0 && options.environment !== "") targets = targets.filter((entry) => entry.environment === options.environment);
|
|
1632
|
+
if (options.tags !== void 0 && options.tags.length > 0) targets = targets.filter((entry) => options.tags.every((tag) => entry.tags.includes(tag)));
|
|
1633
|
+
if (targets.length === 0) return [];
|
|
1634
|
+
if (options.maxWorkers !== void 0 && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) throw new Error("maxWorkers must be a positive integer");
|
|
1635
|
+
const workers = Math.min(this.opts.defaultMaxWorkers, options.maxWorkers ?? this.opts.defaultMaxWorkers, targets.length);
|
|
1636
|
+
const results = [];
|
|
1637
|
+
const queue = [...targets];
|
|
1638
|
+
const run = async () => {
|
|
1639
|
+
while (queue.length > 0) {
|
|
1640
|
+
const entry = queue.shift();
|
|
1641
|
+
try {
|
|
1642
|
+
const result = await this.exec(entry.alias, options.command, options.timeoutMs);
|
|
1643
|
+
results.push({
|
|
1644
|
+
alias: entry.alias,
|
|
1645
|
+
ok: result.success,
|
|
1646
|
+
exitCode: result.exitCode,
|
|
1647
|
+
timedOut: result.timedOut,
|
|
1648
|
+
stdout: result.stdout,
|
|
1649
|
+
stderr: result.stderr,
|
|
1650
|
+
durationMs: result.durationMs
|
|
1651
|
+
});
|
|
1652
|
+
} catch (error) {
|
|
1653
|
+
results.push({
|
|
1654
|
+
alias: entry.alias,
|
|
1655
|
+
ok: false,
|
|
1656
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
};
|
|
1661
|
+
await Promise.all(Array.from({ length: workers }, () => run()));
|
|
1662
|
+
return results;
|
|
1663
|
+
}
|
|
1664
|
+
/** Open a PTY shell session for the web terminal (standalone connection). */
|
|
1665
|
+
async openShell(alias, size) {
|
|
1666
|
+
const entry = this.store.find(alias);
|
|
1667
|
+
if (entry === void 0) throw new Error(`alias '${alias}' not found — add it first`);
|
|
1668
|
+
const { client, hops } = await this.connectChain(entry);
|
|
1669
|
+
return await new Promise((resolve, reject) => {
|
|
1670
|
+
client.shell({
|
|
1671
|
+
term: "xterm-256color",
|
|
1672
|
+
cols: size.cols,
|
|
1673
|
+
rows: size.rows
|
|
1674
|
+
}, (error, stream) => {
|
|
1675
|
+
if (error !== void 0) {
|
|
1676
|
+
try {
|
|
1677
|
+
client.end();
|
|
1678
|
+
} catch {}
|
|
1679
|
+
for (const hop of hops) try {
|
|
1680
|
+
hop.end();
|
|
1681
|
+
} catch {}
|
|
1682
|
+
reject(error);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
let tornDown = false;
|
|
1686
|
+
const teardown = () => {
|
|
1687
|
+
if (tornDown) return;
|
|
1688
|
+
tornDown = true;
|
|
1689
|
+
try {
|
|
1690
|
+
client.end();
|
|
1691
|
+
} catch {}
|
|
1692
|
+
for (const hop of hops) try {
|
|
1693
|
+
hop.end();
|
|
1694
|
+
} catch {}
|
|
1695
|
+
};
|
|
1696
|
+
const session = {
|
|
1697
|
+
send: (data) => {
|
|
1698
|
+
try {
|
|
1699
|
+
stream.write(data);
|
|
1700
|
+
} catch {}
|
|
1701
|
+
},
|
|
1702
|
+
resize: (cols, rows) => {
|
|
1703
|
+
try {
|
|
1704
|
+
stream.setWindow(rows, cols, rows, cols);
|
|
1705
|
+
} catch {}
|
|
1706
|
+
},
|
|
1707
|
+
close: () => {
|
|
1708
|
+
try {
|
|
1709
|
+
stream.close();
|
|
1710
|
+
} catch {}
|
|
1711
|
+
teardown();
|
|
1712
|
+
},
|
|
1713
|
+
pause: () => {
|
|
1714
|
+
try {
|
|
1715
|
+
stream.pause();
|
|
1716
|
+
} catch {}
|
|
1717
|
+
},
|
|
1718
|
+
resume: () => {
|
|
1719
|
+
try {
|
|
1720
|
+
stream.resume();
|
|
1721
|
+
} catch {}
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
stream.on("data", (chunk) => {
|
|
1725
|
+
session.onData?.(chunk);
|
|
1726
|
+
});
|
|
1727
|
+
stream.on("close", (code) => {
|
|
1728
|
+
teardown();
|
|
1729
|
+
session.onExit?.(code);
|
|
1730
|
+
});
|
|
1731
|
+
stream.on("error", (streamError) => {
|
|
1732
|
+
teardown();
|
|
1733
|
+
session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError));
|
|
1734
|
+
});
|
|
1735
|
+
resolve(session);
|
|
1736
|
+
});
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
/** Upload one local file (or directory tree) to a remote path. */
|
|
1740
|
+
async upload(alias, localPath, remotePath, recursive, onProgress) {
|
|
1741
|
+
if (!remotePath.startsWith("/")) throw new Error(`remotePath must be an absolute path (got '${remotePath}')`);
|
|
1742
|
+
const local = resolve(localPath);
|
|
1743
|
+
if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`);
|
|
1744
|
+
return this.withClient(alias, async (client) => {
|
|
1745
|
+
const sftp = await this.sftp(client);
|
|
1746
|
+
const stat = statSync(local);
|
|
1747
|
+
let files;
|
|
1748
|
+
if (stat.isDirectory()) {
|
|
1749
|
+
if (!recursive) throw new Error(`'${localPath}' is a directory — enable recursive upload`);
|
|
1750
|
+
files = walkLocalDir(local);
|
|
1751
|
+
await this.ensureRemoteDir(sftp, remotePath);
|
|
1752
|
+
} else {
|
|
1753
|
+
files = [""];
|
|
1754
|
+
await this.ensureRemoteDir(sftp, dirname(remotePath));
|
|
1755
|
+
}
|
|
1756
|
+
let bytes = 0;
|
|
1757
|
+
for (const rel of files) {
|
|
1758
|
+
const src = rel === "" ? local : join(local, rel);
|
|
1759
|
+
const remoteRel = rel.split(/[\\/]/).join("/");
|
|
1760
|
+
const dst = rel === "" ? remotePath : remotePath.replace(/\/$/, "") + "/" + remoteRel;
|
|
1761
|
+
await this.fastPut(sftp, src, dst, onProgress);
|
|
1762
|
+
bytes += statSync(src).size;
|
|
1763
|
+
}
|
|
1764
|
+
return {
|
|
1765
|
+
bytes,
|
|
1766
|
+
files: files.length
|
|
1767
|
+
};
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
/** Download one remote file to a local path. */
|
|
1771
|
+
async download(alias, remotePath, localPath, onProgress) {
|
|
1772
|
+
return this.withClient(alias, async (client) => {
|
|
1773
|
+
const sftp = await this.sftp(client);
|
|
1774
|
+
if ((await new Promise((resolve, reject) => {
|
|
1775
|
+
sftp.stat(remotePath, (error, stats) => error !== void 0 ? reject(error) : resolve(stats));
|
|
1776
|
+
})).isDirectory()) throw new Error(`'${remotePath}' is a directory — directory download is not supported yet (download individual files)`);
|
|
1777
|
+
const local = resolve(localPath);
|
|
1778
|
+
if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true });
|
|
1779
|
+
await this.fastGet(sftp, remotePath, local, onProgress);
|
|
1780
|
+
return { bytes: statSync(local).size };
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
/** List a remote directory (file browser). */
|
|
1784
|
+
async ls(alias, path) {
|
|
1785
|
+
return this.withClient(alias, async (client) => {
|
|
1786
|
+
const sftp = await this.sftp(client);
|
|
1787
|
+
return await new Promise((resolve, reject) => {
|
|
1788
|
+
sftp.readdir(path, (error, list) => {
|
|
1789
|
+
if (error !== void 0) {
|
|
1790
|
+
reject(error);
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
resolve(list.map((item) => ({
|
|
1794
|
+
name: item.filename,
|
|
1795
|
+
type: item.attrs.isDirectory() ? "dir" : item.attrs.isFile() ? "file" : "other",
|
|
1796
|
+
size: item.attrs.size,
|
|
1797
|
+
mtimeMs: item.attrs.mtime * 1e3,
|
|
1798
|
+
mode: item.attrs.mode
|
|
1799
|
+
})));
|
|
1800
|
+
});
|
|
1801
|
+
});
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
sftp(client) {
|
|
1805
|
+
return new Promise((resolve, reject) => {
|
|
1806
|
+
client.sftp((error, sftp) => error !== void 0 ? reject(error) : resolve(sftp));
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
/** Create a remote directory chain (stat-then-mkdir per segment). */
|
|
1810
|
+
ensureRemoteDir(sftp, remote) {
|
|
1811
|
+
return new Promise((resolve, reject) => {
|
|
1812
|
+
const segments = remote.replace(/^\/+/, "").split("/").filter((segment) => segment !== "");
|
|
1813
|
+
const walk = (index) => {
|
|
1814
|
+
if (index >= segments.length) {
|
|
1815
|
+
resolve();
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
const current = "/" + segments.slice(0, index + 1).join("/");
|
|
1819
|
+
sftp.stat(current, (statError) => {
|
|
1820
|
+
if (statError === void 0) {
|
|
1821
|
+
walk(index + 1);
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
sftp.mkdir(current, (mkdirError) => {
|
|
1825
|
+
if (mkdirError !== void 0) {
|
|
1826
|
+
reject(mkdirError);
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
walk(index + 1);
|
|
1830
|
+
});
|
|
1831
|
+
});
|
|
1832
|
+
};
|
|
1833
|
+
walk(0);
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
fastPut(sftp, src, dst, onProgress) {
|
|
1837
|
+
return new Promise((resolve, reject) => {
|
|
1838
|
+
let last = 0;
|
|
1839
|
+
let lastEmit = 0;
|
|
1840
|
+
const started = Date.now();
|
|
1841
|
+
onProgress?.({
|
|
1842
|
+
phase: "transferring",
|
|
1843
|
+
file: dst,
|
|
1844
|
+
transferred: 0,
|
|
1845
|
+
total: statSync(src).size,
|
|
1846
|
+
percent: 0
|
|
1847
|
+
});
|
|
1848
|
+
sftp.fastPut(src, dst, {
|
|
1849
|
+
concurrency: this.opts.sftpConcurrency,
|
|
1850
|
+
step: (transferred, _chunk, total) => {
|
|
1851
|
+
const now = Date.now();
|
|
1852
|
+
if (now - lastEmit < 100 && transferred < total) return;
|
|
1853
|
+
lastEmit = now;
|
|
1854
|
+
const elapsed = (now - started) / 1e3;
|
|
1855
|
+
onProgress?.({
|
|
1856
|
+
phase: "transferring",
|
|
1857
|
+
file: dst,
|
|
1858
|
+
transferred,
|
|
1859
|
+
total,
|
|
1860
|
+
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
|
|
1861
|
+
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
|
|
1862
|
+
});
|
|
1863
|
+
last = transferred;
|
|
1864
|
+
}
|
|
1865
|
+
}, (error) => {
|
|
1866
|
+
if (error !== void 0) {
|
|
1867
|
+
onProgress?.({
|
|
1868
|
+
phase: "error",
|
|
1869
|
+
file: dst,
|
|
1870
|
+
transferred: 0,
|
|
1871
|
+
total: 0,
|
|
1872
|
+
percent: 0,
|
|
1873
|
+
error: String(error)
|
|
1874
|
+
});
|
|
1875
|
+
reject(error);
|
|
1876
|
+
} else {
|
|
1877
|
+
onProgress?.({
|
|
1878
|
+
phase: "done",
|
|
1879
|
+
file: dst,
|
|
1880
|
+
transferred: statSync(src).size,
|
|
1881
|
+
total: statSync(src).size,
|
|
1882
|
+
percent: 100
|
|
1883
|
+
});
|
|
1884
|
+
resolve();
|
|
1885
|
+
}
|
|
1886
|
+
});
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
fastGet(sftp, src, dst, onProgress) {
|
|
1890
|
+
return new Promise((resolve, reject) => {
|
|
1891
|
+
let last = 0;
|
|
1892
|
+
let lastEmit = 0;
|
|
1893
|
+
const started = Date.now();
|
|
1894
|
+
sftp.fastGet(src, dst, {
|
|
1895
|
+
concurrency: this.opts.sftpConcurrency,
|
|
1896
|
+
step: (transferred, _chunk, total) => {
|
|
1897
|
+
const now = Date.now();
|
|
1898
|
+
if (now - lastEmit < 100 && transferred < total) return;
|
|
1899
|
+
lastEmit = now;
|
|
1900
|
+
const elapsed = (now - started) / 1e3;
|
|
1901
|
+
onProgress?.({
|
|
1902
|
+
phase: "transferring",
|
|
1903
|
+
file: src,
|
|
1904
|
+
transferred,
|
|
1905
|
+
total,
|
|
1906
|
+
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0,
|
|
1907
|
+
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : void 0
|
|
1908
|
+
});
|
|
1909
|
+
last = transferred;
|
|
1910
|
+
}
|
|
1911
|
+
}, (error) => {
|
|
1912
|
+
if (error !== void 0) {
|
|
1913
|
+
onProgress?.({
|
|
1914
|
+
phase: "error",
|
|
1915
|
+
file: src,
|
|
1916
|
+
transferred: 0,
|
|
1917
|
+
total: 0,
|
|
1918
|
+
percent: 0,
|
|
1919
|
+
error: String(error)
|
|
1920
|
+
});
|
|
1921
|
+
reject(error);
|
|
1922
|
+
} else {
|
|
1923
|
+
onProgress?.({
|
|
1924
|
+
phase: "done",
|
|
1925
|
+
file: src,
|
|
1926
|
+
transferred: statSync(dst).size,
|
|
1927
|
+
total: statSync(dst).size,
|
|
1928
|
+
percent: 100
|
|
1929
|
+
});
|
|
1930
|
+
resolve();
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
/** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
|
|
1936
|
+
async startTunnel(alias, options) {
|
|
1937
|
+
if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) throw new Error("remotePort must be an integer in 1..65535");
|
|
1938
|
+
if (options.localPort !== void 0 && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) throw new Error("localPort must be an integer in 1..65535");
|
|
1939
|
+
if (this.store.find(alias) === void 0) throw new Error(`alias '${alias}' not found — add it first`);
|
|
1940
|
+
const remoteHost = options.remoteHost ?? "127.0.0.1";
|
|
1941
|
+
const id = `tun-${this.nextTunnelId++}`;
|
|
1942
|
+
const info = {
|
|
1943
|
+
id,
|
|
1944
|
+
alias,
|
|
1945
|
+
localPort: 0,
|
|
1946
|
+
remoteHost,
|
|
1947
|
+
remotePort: options.remotePort,
|
|
1948
|
+
state: "connecting",
|
|
1949
|
+
startedAt: Date.now()
|
|
1950
|
+
};
|
|
1951
|
+
const record = await this.acquire(alias);
|
|
1952
|
+
const client = record.client;
|
|
1953
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1954
|
+
const server = createServer((socket) => {
|
|
1955
|
+
sockets.add(socket);
|
|
1956
|
+
socket.on("close", () => {
|
|
1957
|
+
sockets.delete(socket);
|
|
1958
|
+
});
|
|
1959
|
+
client.forwardOut("127.0.0.1", 0, remoteHost, options.remotePort, (error, stream) => {
|
|
1960
|
+
if (error !== void 0) {
|
|
1961
|
+
socket.destroy();
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
const destroy = () => {
|
|
1965
|
+
try {
|
|
1966
|
+
socket.destroy();
|
|
1967
|
+
} catch {}
|
|
1968
|
+
try {
|
|
1969
|
+
stream.close();
|
|
1970
|
+
} catch {}
|
|
1971
|
+
};
|
|
1972
|
+
stream.on("error", destroy);
|
|
1973
|
+
socket.on("error", destroy);
|
|
1974
|
+
stream.on("close", destroy);
|
|
1975
|
+
socket.on("close", destroy);
|
|
1976
|
+
stream.pipe(socket).pipe(stream);
|
|
1977
|
+
});
|
|
1978
|
+
});
|
|
1979
|
+
try {
|
|
1980
|
+
await new Promise((resolve, reject) => {
|
|
1981
|
+
server.once("error", reject);
|
|
1982
|
+
server.listen(options.localPort ?? 0, "127.0.0.1", () => {
|
|
1983
|
+
server.removeListener("error", reject);
|
|
1984
|
+
resolve();
|
|
1985
|
+
});
|
|
1986
|
+
});
|
|
1987
|
+
} catch (error) {
|
|
1988
|
+
if (!record.pinned && record.inFlight === 0) this.disposeRecord(alias, record);
|
|
1989
|
+
throw error;
|
|
1990
|
+
}
|
|
1991
|
+
record.pinned = true;
|
|
1992
|
+
const address = server.address();
|
|
1993
|
+
info.localPort = typeof address === "object" && address !== null ? address.port : 0;
|
|
1994
|
+
info.state = "forwarding";
|
|
1995
|
+
this.tunnels.set(id, {
|
|
1996
|
+
info,
|
|
1997
|
+
server,
|
|
1998
|
+
alias,
|
|
1999
|
+
sockets
|
|
2000
|
+
});
|
|
2001
|
+
return info;
|
|
2002
|
+
}
|
|
2003
|
+
/** All active tunnels. */
|
|
2004
|
+
listTunnels() {
|
|
2005
|
+
return [...this.tunnels.values()].map((tunnel) => ({ ...tunnel.info }));
|
|
2006
|
+
}
|
|
2007
|
+
/** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
|
|
2008
|
+
stopTunnel(id) {
|
|
2009
|
+
const tunnel = this.tunnels.get(id);
|
|
2010
|
+
if (tunnel === void 0) return false;
|
|
2011
|
+
this.tunnels.delete(id);
|
|
2012
|
+
try {
|
|
2013
|
+
tunnel.server.close();
|
|
2014
|
+
} catch {}
|
|
2015
|
+
for (const socket of tunnel.sockets) try {
|
|
2016
|
+
socket.destroy();
|
|
2017
|
+
} catch {}
|
|
2018
|
+
tunnel.sockets.clear();
|
|
2019
|
+
this.disposeRecord(tunnel.alias);
|
|
2020
|
+
return true;
|
|
2021
|
+
}
|
|
2022
|
+
/** Stop all tunnels (optionally for one alias). */
|
|
2023
|
+
stopAllTunnels(alias) {
|
|
2024
|
+
let count = 0;
|
|
2025
|
+
for (const [id, tunnel] of [...this.tunnels]) if (alias === void 0 || tunnel.alias === alias) {
|
|
2026
|
+
this.stopTunnel(id);
|
|
2027
|
+
count += 1;
|
|
2028
|
+
}
|
|
2029
|
+
return count;
|
|
2030
|
+
}
|
|
2031
|
+
/** Probe connectivity: connect, run `true`, close. */
|
|
2032
|
+
async test(alias) {
|
|
2033
|
+
const started = Date.now();
|
|
2034
|
+
try {
|
|
2035
|
+
const result = await this.exec(alias, "true", 1e4);
|
|
2036
|
+
return result.success ? {
|
|
2037
|
+
ok: true,
|
|
2038
|
+
latencyMs: result.durationMs
|
|
2039
|
+
} : {
|
|
2040
|
+
ok: false,
|
|
2041
|
+
latencyMs: result.durationMs,
|
|
2042
|
+
error: `remote exit code ${result.exitCode}`
|
|
2043
|
+
};
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
return {
|
|
2046
|
+
ok: false,
|
|
2047
|
+
latencyMs: Date.now() - started,
|
|
2048
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
/** Close every pooled connection and tunnel. */
|
|
2053
|
+
dispose() {
|
|
2054
|
+
if (this.sweepTimer !== void 0) clearInterval(this.sweepTimer);
|
|
2055
|
+
for (const id of [...this.tunnels.keys()]) this.stopTunnel(id);
|
|
2056
|
+
for (const alias of [...this.pool.keys()]) this.disposeRecord(alias);
|
|
2057
|
+
}
|
|
2058
|
+
};
|
|
2059
|
+
//#endregion
|
|
2060
|
+
//#region src/protocol.ts
|
|
2061
|
+
const SSH_API = {
|
|
2062
|
+
hosts: "/api/dsh-ssh/hosts",
|
|
2063
|
+
importSshConfig: "/api/dsh-ssh/hosts/import-ssh-config",
|
|
2064
|
+
test: "/api/dsh-ssh/test",
|
|
2065
|
+
exec: "/api/dsh-ssh/exec",
|
|
2066
|
+
cluster: "/api/dsh-ssh/cluster",
|
|
2067
|
+
upload: "/api/dsh-ssh/upload",
|
|
2068
|
+
download: "/api/dsh-ssh/download",
|
|
2069
|
+
ls: "/api/dsh-ssh/ls",
|
|
2070
|
+
tunnel: "/api/dsh-ssh/tunnel",
|
|
2071
|
+
terminal: "/api/dsh-ssh/terminal"
|
|
2072
|
+
};
|
|
2073
|
+
//#endregion
|
|
2074
|
+
//#region src/routes.ts
|
|
2075
|
+
/**
|
|
2076
|
+
* The /api/dsh-ssh route family: host CRUD, exec, cluster, SFTP transfer
|
|
2077
|
+
* (NDJSON progress stream for uploads, binary stream for downloads), remote
|
|
2078
|
+
* listing, tunnels, and the WebSocket PTY terminal upgrade. Every route
|
|
2079
|
+
* carries a loopback-only trust fence (plus browser same-origin markers) —
|
|
2080
|
+
* these endpoints execute commands on remote servers, so LAN-exposed dsh web
|
|
2081
|
+
* deployments must not serve them.
|
|
2082
|
+
*/
|
|
2083
|
+
/** Cap on JSON request bodies (host entries and exec payloads are small). */
|
|
2084
|
+
const MAX_JSON_BODY_BYTES = 64 * 1024;
|
|
2085
|
+
/** Cap on declared upload bodies (staged to disk before SFTP). */
|
|
2086
|
+
const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024;
|
|
2087
|
+
/**
|
|
2088
|
+
* One noServer WebSocket server for terminal upgrades: the browser half uses
|
|
2089
|
+
* a standards-compliant WebSocket, so the host must speak real RFC 6455
|
|
2090
|
+
* frames (the webserver hands us the raw upgraded socket).
|
|
2091
|
+
*/
|
|
2092
|
+
const terminalWss = new WebSocketServer({ noServer: true });
|
|
2093
|
+
/** Pause the shell when the socket's send buffer exceeds this… */
|
|
2094
|
+
const BACKPRESSURE_HIGH_WATER = 1024 * 1024;
|
|
2095
|
+
/** …and resume once it drains below this. */
|
|
2096
|
+
const BACKPRESSURE_LOW_WATER = 512 * 1024;
|
|
2097
|
+
/** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
|
|
2098
|
+
function isLoopbackRequest(request) {
|
|
2099
|
+
const address = request.socket.remoteAddress;
|
|
2100
|
+
if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
|
|
2101
|
+
const host = request.headers.host;
|
|
2102
|
+
if (typeof host !== "string") return false;
|
|
2103
|
+
let hostUrl;
|
|
2104
|
+
try {
|
|
2105
|
+
hostUrl = new URL(`http://${host}`);
|
|
2106
|
+
} catch {
|
|
2107
|
+
return false;
|
|
2108
|
+
}
|
|
2109
|
+
if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
|
|
2110
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
2111
|
+
const origin = request.headers.origin;
|
|
2112
|
+
if (origin === void 0) return true;
|
|
2113
|
+
try {
|
|
2114
|
+
return new URL(origin).host === hostUrl.host;
|
|
2115
|
+
} catch {
|
|
2116
|
+
return false;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
/** One JSON response. */
|
|
2120
|
+
function writeJson(res, status, body) {
|
|
2121
|
+
const payload = JSON.stringify(body);
|
|
2122
|
+
res.writeHead(status, {
|
|
2123
|
+
"content-type": "application/json; charset=utf-8",
|
|
2124
|
+
"referrer-policy": "no-referrer"
|
|
2125
|
+
});
|
|
2126
|
+
res.end(payload);
|
|
2127
|
+
}
|
|
2128
|
+
/** Read a JSON request body (undefined when too large or unparseable). */
|
|
2129
|
+
async function readJsonBody(req) {
|
|
2130
|
+
const chunks = [];
|
|
2131
|
+
let size = 0;
|
|
2132
|
+
for await (const chunk of req) {
|
|
2133
|
+
const buffer = chunk;
|
|
2134
|
+
size += buffer.length;
|
|
2135
|
+
if (size > MAX_JSON_BODY_BYTES) return void 0;
|
|
2136
|
+
chunks.push(buffer);
|
|
2137
|
+
}
|
|
2138
|
+
try {
|
|
2139
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2140
|
+
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
2141
|
+
} catch {
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
/** URL query helper (first value, decoded). */
|
|
2146
|
+
function queryParam(url, name) {
|
|
2147
|
+
const value = url.searchParams.get(name);
|
|
2148
|
+
return value === null ? void 0 : value;
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
|
|
2152
|
+
* @param deps - store, engine, staging dir.
|
|
2153
|
+
* @returns routes and the upgrade route.
|
|
2154
|
+
*/
|
|
2155
|
+
function makeRoutes(deps) {
|
|
2156
|
+
const { store, engine } = deps;
|
|
2157
|
+
const staging = deps.stagingDir ?? join(tmpdir(), "dsh-ssh-uploads");
|
|
2158
|
+
mkdirSync(staging, { recursive: true });
|
|
2159
|
+
/** Guard helper: fence + method check. */
|
|
2160
|
+
const guard = (req, res, method) => {
|
|
2161
|
+
if (!isLoopbackRequest(req)) {
|
|
2162
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2163
|
+
return false;
|
|
2164
|
+
}
|
|
2165
|
+
if (req.method !== method) {
|
|
2166
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
2167
|
+
return false;
|
|
2168
|
+
}
|
|
2169
|
+
return true;
|
|
2170
|
+
};
|
|
2171
|
+
return {
|
|
2172
|
+
routes: [
|
|
2173
|
+
{
|
|
2174
|
+
kind: "exact",
|
|
2175
|
+
path: SSH_API.hosts,
|
|
2176
|
+
handler: async (req, res) => {
|
|
2177
|
+
const method = req.method ?? "GET";
|
|
2178
|
+
if (!isLoopbackRequest(req)) {
|
|
2179
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2183
|
+
if (method === "GET") {
|
|
2184
|
+
writeJson(res, 200, { hosts: engine.list(queryParam(url, "query")) });
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
if (method === "POST") {
|
|
2188
|
+
const body = await readJsonBody(req);
|
|
2189
|
+
if (body === void 0) {
|
|
2190
|
+
writeJson(res, 400, { error: "invalid JSON body" });
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
try {
|
|
2194
|
+
const entry = store.create(body);
|
|
2195
|
+
writeJson(res, 201, { host: store.summarize(entry) });
|
|
2196
|
+
} catch (error) {
|
|
2197
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2198
|
+
}
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
if (method !== "PATCH" && method !== "DELETE") {
|
|
2202
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
const alias = queryParam(url, "alias");
|
|
2206
|
+
if (alias === void 0 || alias === "") {
|
|
2207
|
+
writeJson(res, 400, { error: "alias query parameter is required" });
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
if (method === "PATCH") {
|
|
2211
|
+
const body = await readJsonBody(req);
|
|
2212
|
+
if (body === void 0) {
|
|
2213
|
+
writeJson(res, 400, { error: "invalid JSON body" });
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
try {
|
|
2217
|
+
const entry = store.update(alias, body);
|
|
2218
|
+
writeJson(res, 200, { host: store.summarize(entry) });
|
|
2219
|
+
} catch (error) {
|
|
2220
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2221
|
+
}
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
if (method === "DELETE") {
|
|
2225
|
+
try {
|
|
2226
|
+
engine.stopAllTunnels(alias);
|
|
2227
|
+
store.delete(alias);
|
|
2228
|
+
writeJson(res, 200, { ok: true });
|
|
2229
|
+
} catch (error) {
|
|
2230
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2231
|
+
}
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
2235
|
+
}
|
|
2236
|
+
},
|
|
2237
|
+
{
|
|
2238
|
+
kind: "exact",
|
|
2239
|
+
path: SSH_API.importSshConfig,
|
|
2240
|
+
handler: async (req, res) => {
|
|
2241
|
+
if (!guard(req, res, "POST")) return;
|
|
2242
|
+
try {
|
|
2243
|
+
writeJson(res, 200, { result: store.importFromSshConfig() });
|
|
2244
|
+
} catch (error) {
|
|
2245
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
},
|
|
2249
|
+
{
|
|
2250
|
+
kind: "exact",
|
|
2251
|
+
path: SSH_API.test,
|
|
2252
|
+
handler: async (req, res) => {
|
|
2253
|
+
if (!guard(req, res, "POST")) return;
|
|
2254
|
+
const body = await readJsonBody(req);
|
|
2255
|
+
const alias = typeof body?.alias === "string" ? body.alias : "";
|
|
2256
|
+
if (alias === "") {
|
|
2257
|
+
writeJson(res, 400, { error: "alias is required" });
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
try {
|
|
2261
|
+
writeJson(res, 200, { result: await engine.test(alias) });
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
},
|
|
2267
|
+
{
|
|
2268
|
+
kind: "exact",
|
|
2269
|
+
path: SSH_API.exec,
|
|
2270
|
+
handler: async (req, res) => {
|
|
2271
|
+
if (!guard(req, res, "POST")) return;
|
|
2272
|
+
const body = await readJsonBody(req);
|
|
2273
|
+
const alias = typeof body?.alias === "string" ? body.alias : "";
|
|
2274
|
+
const command = typeof body?.command === "string" ? body.command : "";
|
|
2275
|
+
if (alias === "" || command === "") {
|
|
2276
|
+
writeJson(res, 400, { error: "alias and command are required" });
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
|
|
2280
|
+
try {
|
|
2281
|
+
writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) });
|
|
2282
|
+
} catch (error) {
|
|
2283
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
},
|
|
2287
|
+
{
|
|
2288
|
+
kind: "exact",
|
|
2289
|
+
path: SSH_API.cluster,
|
|
2290
|
+
handler: async (req, res) => {
|
|
2291
|
+
if (!guard(req, res, "POST")) return;
|
|
2292
|
+
const body = await readJsonBody(req);
|
|
2293
|
+
const command = typeof body?.command === "string" ? body.command : "";
|
|
2294
|
+
if (command === "") {
|
|
2295
|
+
writeJson(res, 400, { error: "command is required" });
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x) => typeof x === "string") : void 0;
|
|
2299
|
+
const tags = Array.isArray(body?.tags) ? body.tags.filter((x) => typeof x === "string") : void 0;
|
|
2300
|
+
const environment = typeof body?.environment === "string" ? body.environment : void 0;
|
|
2301
|
+
const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
|
|
2302
|
+
const maxWorkers = typeof body?.maxWorkers === "number" ? body.maxWorkers : void 0;
|
|
2303
|
+
try {
|
|
2304
|
+
writeJson(res, 200, { results: await engine.cluster({
|
|
2305
|
+
command,
|
|
2306
|
+
aliases,
|
|
2307
|
+
environment,
|
|
2308
|
+
tags,
|
|
2309
|
+
timeoutMs,
|
|
2310
|
+
maxWorkers
|
|
2311
|
+
}) });
|
|
2312
|
+
} catch (error) {
|
|
2313
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
},
|
|
2317
|
+
{
|
|
2318
|
+
kind: "exact",
|
|
2319
|
+
path: SSH_API.ls,
|
|
2320
|
+
handler: async (req, res) => {
|
|
2321
|
+
if (!guard(req, res, "GET")) return;
|
|
2322
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2323
|
+
const alias = queryParam(url, "alias");
|
|
2324
|
+
const path = queryParam(url, "path") ?? "/";
|
|
2325
|
+
if (alias === void 0 || alias === "") {
|
|
2326
|
+
writeJson(res, 400, { error: "alias query parameter is required" });
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
try {
|
|
2330
|
+
writeJson(res, 200, { entries: await engine.ls(alias, path) });
|
|
2331
|
+
} catch (error) {
|
|
2332
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
},
|
|
2336
|
+
{
|
|
2337
|
+
kind: "exact",
|
|
2338
|
+
path: SSH_API.tunnel,
|
|
2339
|
+
handler: async (req, res) => {
|
|
2340
|
+
if (!guard(req, res, "POST")) return;
|
|
2341
|
+
const body = await readJsonBody(req);
|
|
2342
|
+
const action = typeof body?.action === "string" ? body.action : "";
|
|
2343
|
+
if (action === "list") {
|
|
2344
|
+
writeJson(res, 200, { tunnels: engine.listTunnels() });
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
if (action === "start") {
|
|
2348
|
+
const alias = typeof body?.alias === "string" ? body.alias : "";
|
|
2349
|
+
const remotePort = typeof body?.remotePort === "number" ? body.remotePort : void 0;
|
|
2350
|
+
if (alias === "" || remotePort === void 0) {
|
|
2351
|
+
writeJson(res, 400, { error: "alias and remotePort are required" });
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
try {
|
|
2355
|
+
writeJson(res, 200, { tunnel: await engine.startTunnel(alias, {
|
|
2356
|
+
remotePort,
|
|
2357
|
+
remoteHost: typeof body?.remoteHost === "string" && body.remoteHost !== "" ? body.remoteHost : void 0,
|
|
2358
|
+
localPort: typeof body?.localPort === "number" ? body.localPort : void 0
|
|
2359
|
+
}) });
|
|
2360
|
+
} catch (error) {
|
|
2361
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2362
|
+
}
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
if (action === "stop") {
|
|
2366
|
+
const id = typeof body?.tunnelId === "string" ? body.tunnelId : "";
|
|
2367
|
+
if (id === "") {
|
|
2368
|
+
writeJson(res, 400, { error: "tunnelId is required" });
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
writeJson(res, 200, { ok: engine.stopTunnel(id) });
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
if (action === "stop-all") {
|
|
2375
|
+
const alias = typeof body?.alias === "string" ? body.alias : void 0;
|
|
2376
|
+
writeJson(res, 200, { stopped: engine.stopAllTunnels(alias === "" ? void 0 : alias) });
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
writeJson(res, 400, { error: `unknown action '${action}'` });
|
|
2380
|
+
}
|
|
2381
|
+
},
|
|
2382
|
+
{
|
|
2383
|
+
kind: "exact",
|
|
2384
|
+
path: SSH_API.upload,
|
|
2385
|
+
handler: async (req, res) => {
|
|
2386
|
+
if (!guard(req, res, "POST")) return;
|
|
2387
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2388
|
+
const alias = queryParam(url, "alias");
|
|
2389
|
+
const remotePath = queryParam(url, "remotePath");
|
|
2390
|
+
if (alias === void 0 || remotePath === void 0) {
|
|
2391
|
+
writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
const declared = Number(req.headers["content-length"]);
|
|
2395
|
+
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
|
|
2396
|
+
writeJson(res, 413, { error: "upload body too large" });
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
res.writeHead(200, {
|
|
2400
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
2401
|
+
"cache-control": "no-cache",
|
|
2402
|
+
"referrer-policy": "no-referrer"
|
|
2403
|
+
});
|
|
2404
|
+
const emit = (line) => {
|
|
2405
|
+
try {
|
|
2406
|
+
res.write(JSON.stringify(line) + "\n");
|
|
2407
|
+
} catch {}
|
|
2408
|
+
};
|
|
2409
|
+
const tmp = join(staging, `upload-${randomBytes(6).toString("hex")}`);
|
|
2410
|
+
const sink = createWriteStream(tmp);
|
|
2411
|
+
let settled = false;
|
|
2412
|
+
const fail = (error) => {
|
|
2413
|
+
if (settled) return;
|
|
2414
|
+
settled = true;
|
|
2415
|
+
emit({
|
|
2416
|
+
type: "result",
|
|
2417
|
+
ok: false,
|
|
2418
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2419
|
+
});
|
|
2420
|
+
try {
|
|
2421
|
+
sink.destroy();
|
|
2422
|
+
} catch {}
|
|
2423
|
+
unlink(tmp).catch(() => void 0);
|
|
2424
|
+
try {
|
|
2425
|
+
res.end();
|
|
2426
|
+
} catch {}
|
|
2427
|
+
};
|
|
2428
|
+
const done = () => {
|
|
2429
|
+
if (settled) return;
|
|
2430
|
+
settled = true;
|
|
2431
|
+
try {
|
|
2432
|
+
res.end();
|
|
2433
|
+
} catch {}
|
|
2434
|
+
};
|
|
2435
|
+
sink.on("error", (error) => fail(error));
|
|
2436
|
+
req.on("error", (error) => fail(error));
|
|
2437
|
+
req.on("aborted", () => fail("upload aborted by the client"));
|
|
2438
|
+
res.on("error", () => fail("response stream closed"));
|
|
2439
|
+
res.on("close", () => {
|
|
2440
|
+
if (!res.writableEnded) fail("connection closed");
|
|
2441
|
+
});
|
|
2442
|
+
req.pipe(sink);
|
|
2443
|
+
sink.on("finish", async () => {
|
|
2444
|
+
if (settled) return;
|
|
2445
|
+
emit({
|
|
2446
|
+
type: "progress",
|
|
2447
|
+
progress: {
|
|
2448
|
+
phase: "connecting",
|
|
2449
|
+
file: remotePath,
|
|
2450
|
+
transferred: 0,
|
|
2451
|
+
total: 0,
|
|
2452
|
+
percent: 0
|
|
2453
|
+
}
|
|
2454
|
+
});
|
|
2455
|
+
try {
|
|
2456
|
+
const outcome = await engine.upload(alias, tmp, remotePath, false, (progress) => emit({
|
|
2457
|
+
type: "progress",
|
|
2458
|
+
progress
|
|
2459
|
+
}));
|
|
2460
|
+
emit({
|
|
2461
|
+
type: "result",
|
|
2462
|
+
ok: true,
|
|
2463
|
+
transferredBytes: outcome.bytes
|
|
2464
|
+
});
|
|
2465
|
+
} catch (error) {
|
|
2466
|
+
emit({
|
|
2467
|
+
type: "result",
|
|
2468
|
+
ok: false,
|
|
2469
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2470
|
+
});
|
|
2471
|
+
} finally {
|
|
2472
|
+
await unlink(tmp).catch(() => void 0);
|
|
2473
|
+
done();
|
|
2474
|
+
}
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
},
|
|
2478
|
+
{
|
|
2479
|
+
kind: "exact",
|
|
2480
|
+
path: SSH_API.download,
|
|
2481
|
+
handler: async (req, res) => {
|
|
2482
|
+
if (!guard(req, res, "GET")) return;
|
|
2483
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2484
|
+
const alias = queryParam(url, "alias");
|
|
2485
|
+
const remotePath = queryParam(url, "remotePath");
|
|
2486
|
+
if (alias === void 0 || remotePath === void 0) {
|
|
2487
|
+
writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
const tmp = join(staging, `download-${randomBytes(6).toString("hex")}`);
|
|
2491
|
+
try {
|
|
2492
|
+
const outcome = await engine.download(alias, remotePath, tmp);
|
|
2493
|
+
res.writeHead(200, {
|
|
2494
|
+
"content-type": "application/octet-stream",
|
|
2495
|
+
"content-length": String(outcome.bytes),
|
|
2496
|
+
"content-disposition": `attachment; filename="${basename(remotePath).replace(/"/g, "")}"`,
|
|
2497
|
+
"referrer-policy": "no-referrer"
|
|
2498
|
+
});
|
|
2499
|
+
await new Promise((resolve, reject) => {
|
|
2500
|
+
const source = createReadStream(tmp);
|
|
2501
|
+
source.on("error", reject);
|
|
2502
|
+
res.on("error", reject);
|
|
2503
|
+
source.pipe(res);
|
|
2504
|
+
source.on("end", resolve);
|
|
2505
|
+
});
|
|
2506
|
+
} catch (error) {
|
|
2507
|
+
if (!res.headersSent) writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2508
|
+
else res.destroy();
|
|
2509
|
+
} finally {
|
|
2510
|
+
await unlink(tmp).catch(() => void 0);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
],
|
|
2515
|
+
upgrade: {
|
|
2516
|
+
path: SSH_API.terminal,
|
|
2517
|
+
handler: (req, socket, head) => {
|
|
2518
|
+
if (!isLoopbackRequest(req)) {
|
|
2519
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
2520
|
+
socket.destroy();
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2524
|
+
const alias = queryParam(url, "alias");
|
|
2525
|
+
if (alias === void 0) {
|
|
2526
|
+
socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
2527
|
+
socket.destroy();
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
const cols = Number.parseInt(queryParam(url, "cols") ?? "80", 10);
|
|
2531
|
+
const rows = Number.parseInt(queryParam(url, "rows") ?? "24", 10);
|
|
2532
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2533
|
+
let session;
|
|
2534
|
+
let closed = false;
|
|
2535
|
+
let paused = false;
|
|
2536
|
+
const resume = () => {
|
|
2537
|
+
if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
|
|
2538
|
+
paused = false;
|
|
2539
|
+
session?.resume();
|
|
2540
|
+
}
|
|
2541
|
+
};
|
|
2542
|
+
const sendFrame = (frame) => {
|
|
2543
|
+
if (closed || ws.readyState !== WebSocket.OPEN) return;
|
|
2544
|
+
ws.send(JSON.stringify(frame), resume);
|
|
2545
|
+
if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
|
|
2546
|
+
paused = true;
|
|
2547
|
+
session?.pause();
|
|
2548
|
+
}
|
|
2549
|
+
};
|
|
2550
|
+
const closeSession = () => {
|
|
2551
|
+
const opened = session;
|
|
2552
|
+
session = void 0;
|
|
2553
|
+
if (opened !== void 0) opened.close();
|
|
2554
|
+
};
|
|
2555
|
+
engine.openShell(alias, {
|
|
2556
|
+
cols: Number.isFinite(cols) ? cols : 80,
|
|
2557
|
+
rows: Number.isFinite(rows) ? rows : 24
|
|
2558
|
+
}).then((opened) => {
|
|
2559
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
2560
|
+
opened.close();
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
session = opened;
|
|
2564
|
+
sendFrame({
|
|
2565
|
+
type: "ready",
|
|
2566
|
+
alias
|
|
2567
|
+
});
|
|
2568
|
+
opened.onData = (data) => sendFrame({
|
|
2569
|
+
type: "output",
|
|
2570
|
+
data: data.toString("utf8")
|
|
2571
|
+
});
|
|
2572
|
+
opened.onExit = (code, error) => {
|
|
2573
|
+
sendFrame({
|
|
2574
|
+
type: "exit",
|
|
2575
|
+
code,
|
|
2576
|
+
error
|
|
2577
|
+
});
|
|
2578
|
+
closed = true;
|
|
2579
|
+
try {
|
|
2580
|
+
ws.close(1e3);
|
|
2581
|
+
} catch {}
|
|
2582
|
+
};
|
|
2583
|
+
}).catch((error) => {
|
|
2584
|
+
sendFrame({
|
|
2585
|
+
type: "exit",
|
|
2586
|
+
code: null,
|
|
2587
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2588
|
+
});
|
|
2589
|
+
closed = true;
|
|
2590
|
+
try {
|
|
2591
|
+
ws.close(1e3);
|
|
2592
|
+
} catch {}
|
|
2593
|
+
});
|
|
2594
|
+
ws.on("message", (data) => {
|
|
2595
|
+
let frame;
|
|
2596
|
+
try {
|
|
2597
|
+
frame = JSON.parse(String(data));
|
|
2598
|
+
} catch {
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
if (frame.type === "input") session?.send(frame.data);
|
|
2602
|
+
else if (frame.type === "resize") session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows));
|
|
2603
|
+
});
|
|
2604
|
+
ws.on("close", () => {
|
|
2605
|
+
closed = true;
|
|
2606
|
+
closeSession();
|
|
2607
|
+
});
|
|
2608
|
+
ws.on("error", () => {
|
|
2609
|
+
closed = true;
|
|
2610
|
+
closeSession();
|
|
2611
|
+
});
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
//#endregion
|
|
2618
|
+
//#region src/tools.ts
|
|
2619
|
+
/**
|
|
2620
|
+
* Agent tools: the DSH-native counterpart of ssh-skill's CLI. Every tool
|
|
2621
|
+
* talks to the same engine the web UI uses, so a host configured in the GUI
|
|
2622
|
+
* is immediately operable by any agent, and vice versa.
|
|
2623
|
+
*/
|
|
2624
|
+
/** One text content block (the only render shape these tools emit). */
|
|
2625
|
+
function text(value) {
|
|
2626
|
+
return [{
|
|
2627
|
+
type: "text",
|
|
2628
|
+
text: value
|
|
2629
|
+
}];
|
|
2630
|
+
}
|
|
2631
|
+
/** Host table render shared by list surfaces. */
|
|
2632
|
+
function renderHosts(hosts) {
|
|
2633
|
+
if (hosts.length === 0) return "no hosts configured";
|
|
2634
|
+
return [
|
|
2635
|
+
"alias | host | port | user | auth | environment | tags | description",
|
|
2636
|
+
"--- | --- | --- | --- | --- | --- | --- | ---",
|
|
2637
|
+
...hosts.map((host) => [
|
|
2638
|
+
host.alias,
|
|
2639
|
+
host.host,
|
|
2640
|
+
String(host.port),
|
|
2641
|
+
host.user,
|
|
2642
|
+
host.auth,
|
|
2643
|
+
host.environment ?? "-",
|
|
2644
|
+
host.tags.length > 0 ? host.tags.join(",") : "-",
|
|
2645
|
+
host.description ?? ""
|
|
2646
|
+
].join(" | "))
|
|
2647
|
+
].join("\n");
|
|
2648
|
+
}
|
|
2649
|
+
/** Render one exec result (mirrors the bash-tool exit-code convention). */
|
|
2650
|
+
function renderExec(result) {
|
|
2651
|
+
const parts = [result.timedOut ? "[timed out]" : `[exit code: ${result.exitCode ?? "null"}]`];
|
|
2652
|
+
if (result.stdout !== "") parts.push("stdout:\n" + result.stdout);
|
|
2653
|
+
if (result.stderr !== "") parts.push("stderr:\n" + result.stderr);
|
|
2654
|
+
if (result.error !== void 0) parts.push("error: " + result.error);
|
|
2655
|
+
parts.push(`duration: ${result.durationMs} ms`);
|
|
2656
|
+
return parts.join("\n");
|
|
2657
|
+
}
|
|
2658
|
+
/** Render cluster outcomes compactly. */
|
|
2659
|
+
function renderCluster(results) {
|
|
2660
|
+
if (results.length === 0) return "no hosts matched";
|
|
2661
|
+
return results.map((result) => {
|
|
2662
|
+
const status = result.ok ? "ok" : result.timedOut === true ? "timed out" : "failed";
|
|
2663
|
+
const tail = result.error !== void 0 ? " (" + result.error + ")" : "";
|
|
2664
|
+
return `${result.alias}: ${status} [exit code: ${result.exitCode ?? "null"}]${tail}`;
|
|
2665
|
+
}).join("\n");
|
|
2666
|
+
}
|
|
2667
|
+
/** One tunnel line. */
|
|
2668
|
+
function renderTunnel(tunnel) {
|
|
2669
|
+
return `${tunnel.id} ${tunnel.alias} 127.0.0.1:${tunnel.localPort} -> ${tunnel.remoteHost}:${tunnel.remotePort} [${tunnel.state}]`;
|
|
2670
|
+
}
|
|
2671
|
+
/** The host-list tool. */
|
|
2672
|
+
function sshListTool(engine) {
|
|
2673
|
+
return defineTool({
|
|
2674
|
+
name: "ssh_list",
|
|
2675
|
+
description: "List configured SSH hosts (alias, host, user, auth, environment, tags, description). Use ssh_exec etc. with the alias. Triggers: SSH, remote server, server IP/hostname, connect/login, check server/status, deploy, upload/download, jump host, tunnel, port forward.",
|
|
2676
|
+
parameters: { query: {
|
|
2677
|
+
type: "string",
|
|
2678
|
+
description: "Optional fuzzy match against alias, description, host, and tags."
|
|
2679
|
+
} },
|
|
2680
|
+
output: {
|
|
2681
|
+
schema: {
|
|
2682
|
+
type: "object",
|
|
2683
|
+
additionalProperties: false,
|
|
2684
|
+
properties: { hosts: {
|
|
2685
|
+
type: "array",
|
|
2686
|
+
required: true,
|
|
2687
|
+
items: {
|
|
2688
|
+
type: "object",
|
|
2689
|
+
additionalProperties: false,
|
|
2690
|
+
properties: {
|
|
2691
|
+
alias: {
|
|
2692
|
+
type: "string",
|
|
2693
|
+
required: true
|
|
2694
|
+
},
|
|
2695
|
+
host: {
|
|
2696
|
+
type: "string",
|
|
2697
|
+
required: true
|
|
2698
|
+
},
|
|
2699
|
+
port: {
|
|
2700
|
+
type: "integer",
|
|
2701
|
+
required: true
|
|
2702
|
+
},
|
|
2703
|
+
user: {
|
|
2704
|
+
type: "string",
|
|
2705
|
+
required: true
|
|
2706
|
+
},
|
|
2707
|
+
auth: {
|
|
2708
|
+
type: "string",
|
|
2709
|
+
enum: ["key", "password"],
|
|
2710
|
+
required: true
|
|
2711
|
+
},
|
|
2712
|
+
keyReady: {
|
|
2713
|
+
type: "boolean",
|
|
2714
|
+
required: true
|
|
2715
|
+
},
|
|
2716
|
+
proxyJump: {
|
|
2717
|
+
type: "array",
|
|
2718
|
+
items: { type: "string" },
|
|
2719
|
+
required: true
|
|
2720
|
+
},
|
|
2721
|
+
description: { type: "string" },
|
|
2722
|
+
environment: { type: "string" },
|
|
2723
|
+
tags: {
|
|
2724
|
+
type: "array",
|
|
2725
|
+
items: { type: "string" },
|
|
2726
|
+
required: true
|
|
2727
|
+
},
|
|
2728
|
+
location: { type: "string" },
|
|
2729
|
+
createdAt: {
|
|
2730
|
+
type: "integer",
|
|
2731
|
+
required: true
|
|
2732
|
+
},
|
|
2733
|
+
updatedAt: {
|
|
2734
|
+
type: "integer",
|
|
2735
|
+
required: true
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
} }
|
|
2740
|
+
},
|
|
2741
|
+
render: (_args, value) => text(renderHosts(value.hosts ?? []))
|
|
2742
|
+
},
|
|
2743
|
+
async execute(args) {
|
|
2744
|
+
return { hosts: engine.list(args.query) };
|
|
2745
|
+
}
|
|
2746
|
+
});
|
|
2747
|
+
}
|
|
2748
|
+
/** The command-execution tool. */
|
|
2749
|
+
function sshExecTool(engine) {
|
|
2750
|
+
return defineTool({
|
|
2751
|
+
name: "ssh_exec",
|
|
2752
|
+
description: "Execute a command on a configured SSH host by alias. Prefer combining independent read-only queries into one command. Triggers: run command on server, deploy, check server/status, service control, view logs, any remote operation.",
|
|
2753
|
+
parameters: {
|
|
2754
|
+
alias: {
|
|
2755
|
+
type: "string",
|
|
2756
|
+
required: true,
|
|
2757
|
+
description: "Host alias from ssh_list."
|
|
2758
|
+
},
|
|
2759
|
+
command: {
|
|
2760
|
+
type: "string",
|
|
2761
|
+
required: true,
|
|
2762
|
+
description: "The shell command to run remotely."
|
|
2763
|
+
},
|
|
2764
|
+
timeoutMs: {
|
|
2765
|
+
type: "integer",
|
|
2766
|
+
description: "Timeout in milliseconds (default 60000)."
|
|
2767
|
+
}
|
|
2768
|
+
},
|
|
2769
|
+
output: {
|
|
2770
|
+
schema: {
|
|
2771
|
+
type: "object",
|
|
2772
|
+
additionalProperties: false,
|
|
2773
|
+
properties: {
|
|
2774
|
+
success: {
|
|
2775
|
+
type: "boolean",
|
|
2776
|
+
required: true
|
|
2777
|
+
},
|
|
2778
|
+
exitCode: {
|
|
2779
|
+
oneOf: [{ type: "integer" }, { type: "null" }],
|
|
2780
|
+
required: true
|
|
2781
|
+
},
|
|
2782
|
+
timedOut: {
|
|
2783
|
+
type: "boolean",
|
|
2784
|
+
required: true
|
|
2785
|
+
},
|
|
2786
|
+
stdout: {
|
|
2787
|
+
type: "string",
|
|
2788
|
+
required: true
|
|
2789
|
+
},
|
|
2790
|
+
stderr: {
|
|
2791
|
+
type: "string",
|
|
2792
|
+
required: true
|
|
2793
|
+
},
|
|
2794
|
+
durationMs: {
|
|
2795
|
+
type: "integer",
|
|
2796
|
+
required: true
|
|
2797
|
+
},
|
|
2798
|
+
error: { type: "string" }
|
|
2799
|
+
}
|
|
2800
|
+
},
|
|
2801
|
+
render: (_args, value) => text(renderExec(value))
|
|
2802
|
+
},
|
|
2803
|
+
async execute(args) {
|
|
2804
|
+
try {
|
|
2805
|
+
return await engine.exec(args.alias, args.command, args.timeoutMs);
|
|
2806
|
+
} catch (error) {
|
|
2807
|
+
return {
|
|
2808
|
+
success: false,
|
|
2809
|
+
exitCode: null,
|
|
2810
|
+
timedOut: false,
|
|
2811
|
+
stdout: "",
|
|
2812
|
+
stderr: "",
|
|
2813
|
+
durationMs: 0,
|
|
2814
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
/** The upload tool. */
|
|
2821
|
+
function sshUploadTool(engine) {
|
|
2822
|
+
return defineTool({
|
|
2823
|
+
name: "ssh_upload",
|
|
2824
|
+
description: "Upload a local file to a configured SSH host. The local path is on THIS machine (the dsh host). Triggers: upload file to server, deploy artifact, copy config to server.",
|
|
2825
|
+
parameters: {
|
|
2826
|
+
alias: {
|
|
2827
|
+
type: "string",
|
|
2828
|
+
required: true,
|
|
2829
|
+
description: "Host alias from ssh_list."
|
|
2830
|
+
},
|
|
2831
|
+
localPath: {
|
|
2832
|
+
type: "string",
|
|
2833
|
+
required: true,
|
|
2834
|
+
description: "Absolute local file path on this machine."
|
|
2835
|
+
},
|
|
2836
|
+
remotePath: {
|
|
2837
|
+
type: "string",
|
|
2838
|
+
required: true,
|
|
2839
|
+
description: "Destination path on the remote host (parent dirs are created)."
|
|
2840
|
+
}
|
|
2841
|
+
},
|
|
2842
|
+
output: {
|
|
2843
|
+
schema: {
|
|
2844
|
+
type: "object",
|
|
2845
|
+
additionalProperties: false,
|
|
2846
|
+
properties: {
|
|
2847
|
+
ok: {
|
|
2848
|
+
type: "boolean",
|
|
2849
|
+
required: true
|
|
2850
|
+
},
|
|
2851
|
+
transferredBytes: { type: "integer" },
|
|
2852
|
+
files: { type: "integer" },
|
|
2853
|
+
error: { type: "string" }
|
|
2854
|
+
}
|
|
2855
|
+
},
|
|
2856
|
+
render: (_args, value) => text(value.ok ? `uploaded ${value.files ?? 1} file(s), ${value.transferredBytes ?? 0} bytes` : `upload failed: ${value.error ?? "unknown error"}`)
|
|
2857
|
+
},
|
|
2858
|
+
async execute(args) {
|
|
2859
|
+
try {
|
|
2860
|
+
const outcome = await engine.upload(args.alias, args.localPath, args.remotePath, false);
|
|
2861
|
+
return {
|
|
2862
|
+
ok: true,
|
|
2863
|
+
transferredBytes: outcome.bytes,
|
|
2864
|
+
files: outcome.files
|
|
2865
|
+
};
|
|
2866
|
+
} catch (error) {
|
|
2867
|
+
return {
|
|
2868
|
+
ok: false,
|
|
2869
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
/** The download tool. */
|
|
2876
|
+
function sshDownloadTool(engine) {
|
|
2877
|
+
return defineTool({
|
|
2878
|
+
name: "ssh_download",
|
|
2879
|
+
description: "Download a remote FILE from a configured SSH host to a local path on this machine. Directory download is not supported — download files individually. Triggers: download file from server, fetch remote log/artifact.",
|
|
2880
|
+
parameters: {
|
|
2881
|
+
alias: {
|
|
2882
|
+
type: "string",
|
|
2883
|
+
required: true,
|
|
2884
|
+
description: "Host alias from ssh_list."
|
|
2885
|
+
},
|
|
2886
|
+
remotePath: {
|
|
2887
|
+
type: "string",
|
|
2888
|
+
required: true,
|
|
2889
|
+
description: "Remote file path."
|
|
2890
|
+
},
|
|
2891
|
+
localPath: {
|
|
2892
|
+
type: "string",
|
|
2893
|
+
required: true,
|
|
2894
|
+
description: "Absolute destination path on this machine."
|
|
2895
|
+
}
|
|
2896
|
+
},
|
|
2897
|
+
output: {
|
|
2898
|
+
schema: {
|
|
2899
|
+
type: "object",
|
|
2900
|
+
additionalProperties: false,
|
|
2901
|
+
properties: {
|
|
2902
|
+
ok: {
|
|
2903
|
+
type: "boolean",
|
|
2904
|
+
required: true
|
|
2905
|
+
},
|
|
2906
|
+
bytes: { type: "integer" },
|
|
2907
|
+
error: { type: "string" }
|
|
2908
|
+
}
|
|
2909
|
+
},
|
|
2910
|
+
render: (_args, value) => text(value.ok ? `downloaded ${value.bytes ?? 0} bytes` : `download failed: ${value.error ?? "unknown error"}`)
|
|
2911
|
+
},
|
|
2912
|
+
async execute(args) {
|
|
2913
|
+
try {
|
|
2914
|
+
return {
|
|
2915
|
+
ok: true,
|
|
2916
|
+
bytes: (await engine.download(args.alias, args.remotePath, args.localPath)).bytes
|
|
2917
|
+
};
|
|
2918
|
+
} catch (error) {
|
|
2919
|
+
return {
|
|
2920
|
+
ok: false,
|
|
2921
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
/** The tunnel tool. */
|
|
2928
|
+
function sshTunnelTool(engine) {
|
|
2929
|
+
return defineTool({
|
|
2930
|
+
name: "ssh_tunnel",
|
|
2931
|
+
description: "Manage local port-forward tunnels to a configured SSH host. Start a tunnel to reach a remote internal service (database, web UI, API) through 127.0.0.1 on this machine. Triggers: tunnel, port forward, connect database, access internal service.",
|
|
2932
|
+
parameters: {
|
|
2933
|
+
action: {
|
|
2934
|
+
type: "string",
|
|
2935
|
+
required: true,
|
|
2936
|
+
enum: [
|
|
2937
|
+
"start",
|
|
2938
|
+
"list",
|
|
2939
|
+
"stop",
|
|
2940
|
+
"stop-all"
|
|
2941
|
+
],
|
|
2942
|
+
description: "start / list / stop / stop-all."
|
|
2943
|
+
},
|
|
2944
|
+
alias: {
|
|
2945
|
+
type: "string",
|
|
2946
|
+
description: "Host alias (required for start, optional for stop-all)."
|
|
2947
|
+
},
|
|
2948
|
+
remotePort: {
|
|
2949
|
+
type: "integer",
|
|
2950
|
+
description: "Port on the remote side (required for start)."
|
|
2951
|
+
},
|
|
2952
|
+
remoteHost: {
|
|
2953
|
+
type: "string",
|
|
2954
|
+
description: "Remote host to forward to (default 127.0.0.1 — the server itself)."
|
|
2955
|
+
},
|
|
2956
|
+
localPort: {
|
|
2957
|
+
type: "integer",
|
|
2958
|
+
description: "Local listening port (default: auto-assigned)."
|
|
2959
|
+
},
|
|
2960
|
+
tunnelId: {
|
|
2961
|
+
type: "string",
|
|
2962
|
+
description: "Tunnel id (required for stop)."
|
|
2963
|
+
}
|
|
2964
|
+
},
|
|
2965
|
+
output: {
|
|
2966
|
+
schema: {
|
|
2967
|
+
type: "object",
|
|
2968
|
+
additionalProperties: false,
|
|
2969
|
+
properties: {
|
|
2970
|
+
ok: {
|
|
2971
|
+
type: "boolean",
|
|
2972
|
+
required: true
|
|
2973
|
+
},
|
|
2974
|
+
tunnel: {
|
|
2975
|
+
type: "object",
|
|
2976
|
+
additionalProperties: false,
|
|
2977
|
+
properties: {
|
|
2978
|
+
id: {
|
|
2979
|
+
type: "string",
|
|
2980
|
+
required: true
|
|
2981
|
+
},
|
|
2982
|
+
alias: {
|
|
2983
|
+
type: "string",
|
|
2984
|
+
required: true
|
|
2985
|
+
},
|
|
2986
|
+
localPort: {
|
|
2987
|
+
type: "integer",
|
|
2988
|
+
required: true
|
|
2989
|
+
},
|
|
2990
|
+
remoteHost: {
|
|
2991
|
+
type: "string",
|
|
2992
|
+
required: true
|
|
2993
|
+
},
|
|
2994
|
+
remotePort: {
|
|
2995
|
+
type: "integer",
|
|
2996
|
+
required: true
|
|
2997
|
+
},
|
|
2998
|
+
state: {
|
|
2999
|
+
type: "string",
|
|
3000
|
+
enum: [
|
|
3001
|
+
"forwarding",
|
|
3002
|
+
"connecting",
|
|
3003
|
+
"failed"
|
|
3004
|
+
],
|
|
3005
|
+
required: true
|
|
3006
|
+
},
|
|
3007
|
+
error: { type: "string" },
|
|
3008
|
+
startedAt: {
|
|
3009
|
+
type: "integer",
|
|
3010
|
+
required: true
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
},
|
|
3014
|
+
tunnels: {
|
|
3015
|
+
type: "array",
|
|
3016
|
+
items: {
|
|
3017
|
+
type: "object",
|
|
3018
|
+
additionalProperties: false,
|
|
3019
|
+
properties: {
|
|
3020
|
+
id: {
|
|
3021
|
+
type: "string",
|
|
3022
|
+
required: true
|
|
3023
|
+
},
|
|
3024
|
+
alias: {
|
|
3025
|
+
type: "string",
|
|
3026
|
+
required: true
|
|
3027
|
+
},
|
|
3028
|
+
localPort: {
|
|
3029
|
+
type: "integer",
|
|
3030
|
+
required: true
|
|
3031
|
+
},
|
|
3032
|
+
remoteHost: {
|
|
3033
|
+
type: "string",
|
|
3034
|
+
required: true
|
|
3035
|
+
},
|
|
3036
|
+
remotePort: {
|
|
3037
|
+
type: "integer",
|
|
3038
|
+
required: true
|
|
3039
|
+
},
|
|
3040
|
+
state: {
|
|
3041
|
+
type: "string",
|
|
3042
|
+
enum: [
|
|
3043
|
+
"forwarding",
|
|
3044
|
+
"connecting",
|
|
3045
|
+
"failed"
|
|
3046
|
+
],
|
|
3047
|
+
required: true
|
|
3048
|
+
},
|
|
3049
|
+
error: { type: "string" },
|
|
3050
|
+
startedAt: {
|
|
3051
|
+
type: "integer",
|
|
3052
|
+
required: true
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
},
|
|
3057
|
+
stopped: { type: "integer" },
|
|
3058
|
+
error: { type: "string" }
|
|
3059
|
+
}
|
|
3060
|
+
},
|
|
3061
|
+
render: (_args, value) => {
|
|
3062
|
+
if (value.error !== void 0) return text(`tunnel error: ${value.error}`);
|
|
3063
|
+
if (value.tunnel !== void 0) {
|
|
3064
|
+
if (value.tunnel.state === "failed") return text(`tunnel failed: ${value.tunnel.error ?? "unknown error"}`);
|
|
3065
|
+
return text(`tunnel started: ${renderTunnel(value.tunnel)}`);
|
|
3066
|
+
}
|
|
3067
|
+
if (value.tunnels !== void 0) return text(value.tunnels.length === 0 ? "no active tunnels" : value.tunnels.map(renderTunnel).join("\n"));
|
|
3068
|
+
return text(`stopped ${value.stopped ?? 0} tunnel(s)`);
|
|
3069
|
+
}
|
|
3070
|
+
},
|
|
3071
|
+
async execute(args) {
|
|
3072
|
+
if (args.action === "list") return {
|
|
3073
|
+
ok: true,
|
|
3074
|
+
tunnels: engine.listTunnels()
|
|
3075
|
+
};
|
|
3076
|
+
if (args.action === "start") {
|
|
3077
|
+
if (args.alias === void 0 || args.remotePort === void 0) throw new Error("alias and remotePort are required for start");
|
|
3078
|
+
try {
|
|
3079
|
+
return {
|
|
3080
|
+
ok: true,
|
|
3081
|
+
tunnel: await engine.startTunnel(args.alias, {
|
|
3082
|
+
remotePort: args.remotePort,
|
|
3083
|
+
remoteHost: args.remoteHost,
|
|
3084
|
+
localPort: args.localPort
|
|
3085
|
+
})
|
|
3086
|
+
};
|
|
3087
|
+
} catch (error) {
|
|
3088
|
+
return {
|
|
3089
|
+
ok: false,
|
|
3090
|
+
tunnel: {
|
|
3091
|
+
id: "",
|
|
3092
|
+
alias: args.alias,
|
|
3093
|
+
localPort: 0,
|
|
3094
|
+
remoteHost: args.remoteHost ?? "127.0.0.1",
|
|
3095
|
+
remotePort: args.remotePort,
|
|
3096
|
+
state: "failed",
|
|
3097
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3098
|
+
startedAt: Date.now()
|
|
3099
|
+
}
|
|
3100
|
+
};
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
if (args.action === "stop") {
|
|
3104
|
+
if (args.tunnelId === void 0) throw new Error("tunnelId is required for stop");
|
|
3105
|
+
return engine.stopTunnel(args.tunnelId) ? {
|
|
3106
|
+
ok: true,
|
|
3107
|
+
stopped: 1
|
|
3108
|
+
} : {
|
|
3109
|
+
ok: false,
|
|
3110
|
+
stopped: 0,
|
|
3111
|
+
error: `tunnel '${args.tunnelId}' not found`
|
|
3112
|
+
};
|
|
3113
|
+
}
|
|
3114
|
+
if (args.action === "stop-all") return {
|
|
3115
|
+
ok: true,
|
|
3116
|
+
stopped: engine.stopAllTunnels(args.alias)
|
|
3117
|
+
};
|
|
3118
|
+
throw new Error(`unknown action '${String(args.action)}'`);
|
|
3119
|
+
}
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
/** The cluster tool. */
|
|
3123
|
+
function sshClusterTool(engine) {
|
|
3124
|
+
return defineTool({
|
|
3125
|
+
name: "ssh_cluster",
|
|
3126
|
+
description: "Run one command concurrently across many SSH hosts (all hosts, or filtered by aliases / environment / tags). Triggers: run on all servers, batch operation, production servers, cluster command.",
|
|
3127
|
+
parameters: {
|
|
3128
|
+
command: {
|
|
3129
|
+
type: "string",
|
|
3130
|
+
required: true,
|
|
3131
|
+
description: "The shell command to run on every matched host."
|
|
3132
|
+
},
|
|
3133
|
+
aliases: {
|
|
3134
|
+
type: "array",
|
|
3135
|
+
items: { type: "string" },
|
|
3136
|
+
description: "Explicit alias list; when absent every configured host matches."
|
|
3137
|
+
},
|
|
3138
|
+
environment: {
|
|
3139
|
+
type: "string",
|
|
3140
|
+
description: "Only hosts with this environment label."
|
|
3141
|
+
},
|
|
3142
|
+
tags: {
|
|
3143
|
+
type: "array",
|
|
3144
|
+
items: { type: "string" },
|
|
3145
|
+
description: "Only hosts carrying ALL these tags."
|
|
3146
|
+
},
|
|
3147
|
+
timeoutMs: {
|
|
3148
|
+
type: "integer",
|
|
3149
|
+
description: "Per-host timeout in milliseconds."
|
|
3150
|
+
},
|
|
3151
|
+
maxWorkers: {
|
|
3152
|
+
type: "integer",
|
|
3153
|
+
description: "Concurrency cap (default 8)."
|
|
3154
|
+
}
|
|
3155
|
+
},
|
|
3156
|
+
output: {
|
|
3157
|
+
schema: {
|
|
3158
|
+
type: "object",
|
|
3159
|
+
additionalProperties: false,
|
|
3160
|
+
properties: { results: {
|
|
3161
|
+
type: "array",
|
|
3162
|
+
required: true,
|
|
3163
|
+
items: {
|
|
3164
|
+
type: "object",
|
|
3165
|
+
additionalProperties: false,
|
|
3166
|
+
properties: {
|
|
3167
|
+
alias: {
|
|
3168
|
+
type: "string",
|
|
3169
|
+
required: true
|
|
3170
|
+
},
|
|
3171
|
+
ok: {
|
|
3172
|
+
type: "boolean",
|
|
3173
|
+
required: true
|
|
3174
|
+
},
|
|
3175
|
+
exitCode: { oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
3176
|
+
timedOut: { type: "boolean" },
|
|
3177
|
+
stdout: { type: "string" },
|
|
3178
|
+
stderr: { type: "string" },
|
|
3179
|
+
durationMs: { type: "integer" },
|
|
3180
|
+
error: { type: "string" }
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
} }
|
|
3184
|
+
},
|
|
3185
|
+
render: (_args, value) => text(renderCluster(value.results ?? []))
|
|
3186
|
+
},
|
|
3187
|
+
async execute(args) {
|
|
3188
|
+
return { results: await engine.cluster(args) };
|
|
3189
|
+
}
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
//#endregion
|
|
3193
|
+
//#region src/index.ts
|
|
3194
|
+
/** Stable cordis plugin name. */
|
|
3195
|
+
const name = "ssh";
|
|
3196
|
+
/** Services required before the SSH surfaces can mount. */
|
|
3197
|
+
const inject = [
|
|
3198
|
+
"webServer",
|
|
3199
|
+
"tools",
|
|
3200
|
+
"systemPrompt"
|
|
3201
|
+
];
|
|
3202
|
+
/**
|
|
3203
|
+
* Settings namespace of the SSH capability — the section the web settings
|
|
3204
|
+
* surface edits. Spelled here rather than imported: the browser half spells
|
|
3205
|
+
* the same value and must not depend on a Host package.
|
|
3206
|
+
*/
|
|
3207
|
+
const SSH_SETTINGS_NAMESPACE = settingsNamespace("dsh-ssh");
|
|
3208
|
+
const Config = import_lib.default.object({
|
|
3209
|
+
announceToAgent: import_lib.default.boolean().default(true),
|
|
3210
|
+
enabled: import_lib.default.boolean().default(true)
|
|
3211
|
+
});
|
|
3212
|
+
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
3213
|
+
const DEFAULT_ANNOUNCE = true;
|
|
3214
|
+
/** Order of the announcement section within the tool-guidance band. */
|
|
3215
|
+
const SECTION_ORDER = 150;
|
|
3216
|
+
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
3217
|
+
const SSH_GUIDANCE = "本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 ~/.dsh/dsh-ssh.json(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。";
|
|
3218
|
+
/**
|
|
3219
|
+
* Mount the SSH engine, routes, tools, and announcement.
|
|
3220
|
+
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
3221
|
+
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
3222
|
+
*/
|
|
3223
|
+
function apply(ctx, config) {
|
|
3224
|
+
let current = () => config ?? {};
|
|
3225
|
+
const resolve = () => {
|
|
3226
|
+
const value = current();
|
|
3227
|
+
return {
|
|
3228
|
+
announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
|
|
3229
|
+
enabled: value.enabled ?? true
|
|
3230
|
+
};
|
|
3231
|
+
};
|
|
3232
|
+
const store = new HostStore();
|
|
3233
|
+
const engine = new SshEngine(store);
|
|
3234
|
+
ctx.effect(() => () => {
|
|
3235
|
+
engine.dispose();
|
|
3236
|
+
}, "dsh-ssh: engine");
|
|
3237
|
+
const { routes, upgrade } = makeRoutes({
|
|
3238
|
+
store,
|
|
3239
|
+
engine
|
|
3240
|
+
});
|
|
3241
|
+
let disposeRoutes;
|
|
3242
|
+
const tools = [
|
|
3243
|
+
sshListTool(engine),
|
|
3244
|
+
sshExecTool(engine),
|
|
3245
|
+
sshUploadTool(engine),
|
|
3246
|
+
sshDownloadTool(engine),
|
|
3247
|
+
sshTunnelTool(engine),
|
|
3248
|
+
sshClusterTool(engine)
|
|
3249
|
+
];
|
|
3250
|
+
let disposeTools;
|
|
3251
|
+
let disposeSection;
|
|
3252
|
+
const sync = () => {
|
|
3253
|
+
const value = resolve();
|
|
3254
|
+
if (disposeSection !== void 0) {
|
|
3255
|
+
disposeSection();
|
|
3256
|
+
disposeSection = void 0;
|
|
3257
|
+
}
|
|
3258
|
+
if (disposeRoutes !== void 0) {
|
|
3259
|
+
disposeRoutes();
|
|
3260
|
+
disposeRoutes = void 0;
|
|
3261
|
+
}
|
|
3262
|
+
if (disposeTools !== void 0) {
|
|
3263
|
+
disposeTools();
|
|
3264
|
+
disposeTools = void 0;
|
|
3265
|
+
}
|
|
3266
|
+
if (!value.enabled) return;
|
|
3267
|
+
if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
|
|
3268
|
+
name: "plugin:dsh-ssh",
|
|
3269
|
+
order: SECTION_ORDER,
|
|
3270
|
+
text: SSH_GUIDANCE
|
|
3271
|
+
});
|
|
3272
|
+
disposeRoutes = ctx.effect(() => {
|
|
3273
|
+
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
3274
|
+
const upgradeDisposer = ctx.webServer.registerUpgrade(upgrade);
|
|
3275
|
+
return () => {
|
|
3276
|
+
for (const dispose of disposers) dispose();
|
|
3277
|
+
upgradeDisposer();
|
|
3278
|
+
};
|
|
3279
|
+
}, "dsh-ssh: routes");
|
|
3280
|
+
disposeTools = ctx.effect(() => {
|
|
3281
|
+
const disposers = tools.map((tool) => ctx.tools.register(tool));
|
|
3282
|
+
return () => {
|
|
3283
|
+
for (const dispose of disposers) dispose();
|
|
3284
|
+
};
|
|
3285
|
+
}, "dsh-ssh: tools");
|
|
3286
|
+
};
|
|
3287
|
+
installSettingsSection(ctx, SSH_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
3288
|
+
setSource: (source) => {
|
|
3289
|
+
current = source;
|
|
3290
|
+
sync();
|
|
3291
|
+
},
|
|
3292
|
+
onChange: sync
|
|
3293
|
+
});
|
|
3294
|
+
sync();
|
|
3295
|
+
}
|
|
3296
|
+
//#endregion
|
|
3297
|
+
export { Config, SSH_GUIDANCE, SSH_SETTINGS_NAMESPACE, apply, inject, name };
|