@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.
Files changed (52) hide show
  1. package/README.md +75 -0
  2. package/cordis.patch.yml +12 -0
  3. package/lib/client.js +15638 -0
  4. package/lib/index.js +3297 -0
  5. package/lib/invariant.js +6 -0
  6. package/lib/types/client/api.d.ts +72 -0
  7. package/lib/types/client/index.d.ts +37 -0
  8. package/lib/types/client/locales.d.ts +137 -0
  9. package/lib/types/client/mount.d.ts +12 -0
  10. package/lib/types/client/panel/ClusterTab.d.ts +7 -0
  11. package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
  12. package/lib/types/client/panel/HostsTab.d.ts +9 -0
  13. package/lib/types/client/panel/SshPanel.d.ts +13 -0
  14. package/lib/types/client/panel/TerminalTab.d.ts +11 -0
  15. package/lib/types/client/panel/TransferTab.d.ts +7 -0
  16. package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
  17. package/lib/types/client/panel/controller.d.ts +22 -0
  18. package/lib/types/client/panel/helpers.d.ts +14 -0
  19. package/lib/types/client/panel/xterm.css.d.ts +2 -0
  20. package/lib/types/client/sidebar-entry.d.ts +24 -0
  21. package/lib/types/engine.d.ts +135 -0
  22. package/lib/types/index.d.ts +40 -0
  23. package/lib/types/invariant.d.ts +3 -0
  24. package/lib/types/protocol.d.ts +189 -0
  25. package/lib/types/routes.d.ts +29 -0
  26. package/lib/types/store.d.ts +54 -0
  27. package/lib/types/tools.d.ts +18 -0
  28. package/package.json +106 -0
  29. package/src/client/api.ts +363 -0
  30. package/src/client/css-modules.d.ts +5 -0
  31. package/src/client/index.ts +68 -0
  32. package/src/client/locales.ts +284 -0
  33. package/src/client/mount.tsx +83 -0
  34. package/src/client/panel/ClusterTab.tsx +123 -0
  35. package/src/client/panel/HostFormDialog.tsx +221 -0
  36. package/src/client/panel/HostsTab.tsx +166 -0
  37. package/src/client/panel/SshPanel.tsx +77 -0
  38. package/src/client/panel/TerminalTab.tsx +176 -0
  39. package/src/client/panel/TransferTab.tsx +232 -0
  40. package/src/client/panel/TunnelsTab.tsx +177 -0
  41. package/src/client/panel/controller.ts +48 -0
  42. package/src/client/panel/helpers.ts +26 -0
  43. package/src/client/panel/panel.module.css +833 -0
  44. package/src/client/panel/xterm.css.ts +2 -0
  45. package/src/client/sidebar-entry.ts +123 -0
  46. package/src/engine.ts +780 -0
  47. package/src/index.ts +155 -0
  48. package/src/invariant.ts +4 -0
  49. package/src/protocol.ts +188 -0
  50. package/src/routes.ts +540 -0
  51. package/src/store.ts +344 -0
  52. 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 };