@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/src/tools.ts ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Agent tools: the DSH-native counterpart of ssh-skill's CLI. Every tool
3
+ * talks to the same engine the web UI uses, so a host configured in the GUI
4
+ * is immediately operable by any agent, and vice versa.
5
+ */
6
+
7
+ import { defineTool } from '@deepseek-ai/dsh-tools'
8
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
9
+ import type { SshEngine } from './engine.ts'
10
+ import type { ClusterResult, ExecResult, SshHostSummary, TunnelInfo } from './protocol.ts'
11
+
12
+ /** One text content block (the only render shape these tools emit). */
13
+ function text(value: string): ContentBlock[] {
14
+ return [{ type: 'text', text: value }]
15
+ }
16
+
17
+ /** Host table render shared by list surfaces. */
18
+ function renderHosts(hosts: SshHostSummary[]): string {
19
+ if (hosts.length === 0) return 'no hosts configured'
20
+ const rows = hosts.map(host => [
21
+ host.alias,
22
+ host.host,
23
+ String(host.port),
24
+ host.user,
25
+ host.auth,
26
+ host.environment ?? '-',
27
+ (host.tags.length > 0 ? host.tags.join(',') : '-'),
28
+ host.description ?? '',
29
+ ].join(' | '))
30
+ return ['alias | host | port | user | auth | environment | tags | description', '--- | --- | --- | --- | --- | --- | --- | ---', ...rows].join('\n')
31
+ }
32
+
33
+ /** Render one exec result (mirrors the bash-tool exit-code convention). */
34
+ function renderExec(result: ExecResult): string {
35
+ const marker = result.timedOut
36
+ ? '[timed out]'
37
+ : `[exit code: ${result.exitCode ?? 'null'}]`
38
+ const parts = [marker]
39
+ if (result.stdout !== '') parts.push('stdout:\n' + result.stdout)
40
+ if (result.stderr !== '') parts.push('stderr:\n' + result.stderr)
41
+ if (result.error !== undefined) parts.push('error: ' + result.error)
42
+ parts.push(`duration: ${result.durationMs} ms`)
43
+ return parts.join('\n')
44
+ }
45
+
46
+ /** Render cluster outcomes compactly. */
47
+ function renderCluster(results: ClusterResult[]): string {
48
+ if (results.length === 0) return 'no hosts matched'
49
+ return results.map(result => {
50
+ const status = result.ok ? 'ok' : result.timedOut === true ? 'timed out' : 'failed'
51
+ const tail = result.error !== undefined ? ' (' + result.error + ')' : ''
52
+ return `${result.alias}: ${status} [exit code: ${result.exitCode ?? 'null'}]${tail}`
53
+ }).join('\n')
54
+ }
55
+
56
+ /** One tunnel line. */
57
+ function renderTunnel(tunnel: TunnelInfo): string {
58
+ return `${tunnel.id} ${tunnel.alias} 127.0.0.1:${tunnel.localPort} -> ${tunnel.remoteHost}:${tunnel.remotePort} [${tunnel.state}]`
59
+ }
60
+
61
+ /** The host-list tool. */
62
+ export function sshListTool(engine: SshEngine) {
63
+ return defineTool({
64
+ name: 'ssh_list',
65
+ description: 'List configured SSH hosts (alias, host, user, auth, environment, tags, description). Use ssh_exec etc. with the alias. ' +
66
+ 'Triggers: SSH, remote server, server IP/hostname, connect/login, check server/status, deploy, upload/download, jump host, tunnel, port forward.',
67
+ parameters: {
68
+ query: { type: 'string', description: 'Optional fuzzy match against alias, description, host, and tags.' },
69
+ },
70
+ output: {
71
+ schema: {
72
+ type: 'object',
73
+ additionalProperties: false,
74
+ properties: {
75
+ hosts: {
76
+ type: 'array',
77
+ required: true,
78
+ items: {
79
+ type: 'object',
80
+ additionalProperties: false,
81
+ properties: {
82
+ alias: { type: 'string', required: true },
83
+ host: { type: 'string', required: true },
84
+ port: { type: 'integer', required: true },
85
+ user: { type: 'string', required: true },
86
+ auth: { type: 'string', enum: ['key', 'password'], required: true },
87
+ keyReady: { type: 'boolean', required: true },
88
+ proxyJump: { type: 'array', items: { type: 'string' }, required: true },
89
+ description: { type: 'string' },
90
+ environment: { type: 'string' },
91
+ tags: { type: 'array', items: { type: 'string' }, required: true },
92
+ location: { type: 'string' },
93
+ createdAt: { type: 'integer', required: true },
94
+ updatedAt: { type: 'integer', required: true },
95
+ },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ render: (_args, value: { hosts?: SshHostSummary[] }) => text(renderHosts(value.hosts ?? [])),
101
+ },
102
+ async execute(args) {
103
+ return { hosts: engine.list(args.query) }
104
+ },
105
+ })
106
+ }
107
+
108
+ /** The command-execution tool. */
109
+ export function sshExecTool(engine: SshEngine) {
110
+ return defineTool({
111
+ name: 'ssh_exec',
112
+ description: 'Execute a command on a configured SSH host by alias. Prefer combining independent read-only queries into one command. ' +
113
+ 'Triggers: run command on server, deploy, check server/status, service control, view logs, any remote operation.',
114
+ parameters: {
115
+ alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
116
+ command: { type: 'string', required: true, description: 'The shell command to run remotely.' },
117
+ timeoutMs: { type: 'integer', description: 'Timeout in milliseconds (default 60000).' },
118
+ },
119
+ output: {
120
+ schema: {
121
+ type: 'object',
122
+ additionalProperties: false,
123
+ properties: {
124
+ success: { type: 'boolean', required: true },
125
+ exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }], required: true },
126
+ timedOut: { type: 'boolean', required: true },
127
+ stdout: { type: 'string', required: true },
128
+ stderr: { type: 'string', required: true },
129
+ durationMs: { type: 'integer', required: true },
130
+ error: { type: 'string' },
131
+ },
132
+ },
133
+ render: (_args, value: ExecResult) => text(renderExec(value)),
134
+ },
135
+ async execute(args) {
136
+ try {
137
+ return await engine.exec(args.alias, args.command, args.timeoutMs)
138
+ } catch (error) {
139
+ return {
140
+ success: false,
141
+ exitCode: null,
142
+ timedOut: false,
143
+ stdout: '',
144
+ stderr: '',
145
+ durationMs: 0,
146
+ error: error instanceof Error ? error.message : String(error),
147
+ }
148
+ }
149
+ },
150
+ })
151
+ }
152
+
153
+ /** The upload tool. */
154
+ export function sshUploadTool(engine: SshEngine) {
155
+ return defineTool({
156
+ name: 'ssh_upload',
157
+ description: 'Upload a local file to a configured SSH host. The local path is on THIS machine (the dsh host). ' +
158
+ 'Triggers: upload file to server, deploy artifact, copy config to server.',
159
+ parameters: {
160
+ alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
161
+ localPath: { type: 'string', required: true, description: 'Absolute local file path on this machine.' },
162
+ remotePath: { type: 'string', required: true, description: 'Destination path on the remote host (parent dirs are created).' },
163
+ },
164
+ output: {
165
+ schema: {
166
+ type: 'object',
167
+ additionalProperties: false,
168
+ properties: {
169
+ ok: { type: 'boolean', required: true },
170
+ transferredBytes: { type: 'integer' },
171
+ files: { type: 'integer' },
172
+ error: { type: 'string' },
173
+ },
174
+ },
175
+ render: (_args, value: { ok: boolean; transferredBytes?: number; files?: number; error?: string }) => text(value.ok
176
+ ? `uploaded ${value.files ?? 1} file(s), ${value.transferredBytes ?? 0} bytes`
177
+ : `upload failed: ${value.error ?? 'unknown error'}`),
178
+ },
179
+ async execute(args) {
180
+ try {
181
+ const outcome = await engine.upload(args.alias, args.localPath, args.remotePath, false)
182
+ return { ok: true, transferredBytes: outcome.bytes, files: outcome.files }
183
+ } catch (error) {
184
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
185
+ }
186
+ },
187
+ })
188
+ }
189
+
190
+ /** The download tool. */
191
+ export function sshDownloadTool(engine: SshEngine) {
192
+ return defineTool({
193
+ name: 'ssh_download',
194
+ 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. ' +
195
+ 'Triggers: download file from server, fetch remote log/artifact.',
196
+ parameters: {
197
+ alias: { type: 'string', required: true, description: 'Host alias from ssh_list.' },
198
+ remotePath: { type: 'string', required: true, description: 'Remote file path.' },
199
+ localPath: { type: 'string', required: true, description: 'Absolute destination path on this machine.' },
200
+ },
201
+ output: {
202
+ schema: {
203
+ type: 'object',
204
+ additionalProperties: false,
205
+ properties: {
206
+ ok: { type: 'boolean', required: true },
207
+ bytes: { type: 'integer' },
208
+ error: { type: 'string' },
209
+ },
210
+ },
211
+ render: (_args, value: { ok: boolean; bytes?: number; error?: string }) => text(value.ok
212
+ ? `downloaded ${value.bytes ?? 0} bytes`
213
+ : `download failed: ${value.error ?? 'unknown error'}`),
214
+ },
215
+ async execute(args) {
216
+ try {
217
+ const outcome = await engine.download(args.alias, args.remotePath, args.localPath)
218
+ return { ok: true, bytes: outcome.bytes }
219
+ } catch (error) {
220
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
221
+ }
222
+ },
223
+ })
224
+ }
225
+
226
+ /** The tunnel tool. */
227
+ export function sshTunnelTool(engine: SshEngine) {
228
+ return defineTool({
229
+ name: 'ssh_tunnel',
230
+ 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. ' +
231
+ 'Triggers: tunnel, port forward, connect database, access internal service.',
232
+ parameters: {
233
+ action: { type: 'string', required: true, enum: ['start', 'list', 'stop', 'stop-all'], description: 'start / list / stop / stop-all.' },
234
+ alias: { type: 'string', description: 'Host alias (required for start, optional for stop-all).' },
235
+ remotePort: { type: 'integer', description: 'Port on the remote side (required for start).' },
236
+ remoteHost: { type: 'string', description: 'Remote host to forward to (default 127.0.0.1 — the server itself).' },
237
+ localPort: { type: 'integer', description: 'Local listening port (default: auto-assigned).' },
238
+ tunnelId: { type: 'string', description: 'Tunnel id (required for stop).' },
239
+ },
240
+ output: {
241
+ schema: {
242
+ type: 'object',
243
+ additionalProperties: false,
244
+ properties: {
245
+ ok: { type: 'boolean', required: true },
246
+ tunnel: {
247
+ type: 'object',
248
+ additionalProperties: false,
249
+ properties: {
250
+ id: { type: 'string', required: true },
251
+ alias: { type: 'string', required: true },
252
+ localPort: { type: 'integer', required: true },
253
+ remoteHost: { type: 'string', required: true },
254
+ remotePort: { type: 'integer', required: true },
255
+ state: { type: 'string', enum: ['forwarding', 'connecting', 'failed'], required: true },
256
+ error: { type: 'string' },
257
+ startedAt: { type: 'integer', required: true },
258
+ },
259
+ },
260
+ tunnels: {
261
+ type: 'array',
262
+ items: {
263
+ type: 'object',
264
+ additionalProperties: false,
265
+ properties: {
266
+ id: { type: 'string', required: true },
267
+ alias: { type: 'string', required: true },
268
+ localPort: { type: 'integer', required: true },
269
+ remoteHost: { type: 'string', required: true },
270
+ remotePort: { type: 'integer', required: true },
271
+ state: { type: 'string', enum: ['forwarding', 'connecting', 'failed'], required: true },
272
+ error: { type: 'string' },
273
+ startedAt: { type: 'integer', required: true },
274
+ },
275
+ },
276
+ },
277
+ stopped: { type: 'integer' },
278
+ error: { type: 'string' },
279
+ },
280
+ },
281
+ render: (_args, value: { ok: boolean; tunnel?: TunnelInfo; tunnels?: TunnelInfo[]; stopped?: number; error?: string }) => {
282
+ if (value.error !== undefined) return text(`tunnel error: ${value.error}`)
283
+ if (value.tunnel !== undefined) {
284
+ if (value.tunnel.state === 'failed') return text(`tunnel failed: ${value.tunnel.error ?? 'unknown error'}`)
285
+ return text(`tunnel started: ${renderTunnel(value.tunnel)}`)
286
+ }
287
+ if (value.tunnels !== undefined) return text(value.tunnels.length === 0 ? 'no active tunnels' : value.tunnels.map(renderTunnel).join('\n'))
288
+ return text(`stopped ${value.stopped ?? 0} tunnel(s)`)
289
+ },
290
+ },
291
+ async execute(args) {
292
+ if (args.action === 'list') {
293
+ return { ok: true, tunnels: engine.listTunnels() }
294
+ }
295
+ if (args.action === 'start') {
296
+ if (args.alias === undefined || args.remotePort === undefined) {
297
+ throw new Error('alias and remotePort are required for start')
298
+ }
299
+ try {
300
+ const tunnel = await engine.startTunnel(args.alias, {
301
+ remotePort: args.remotePort,
302
+ remoteHost: args.remoteHost,
303
+ localPort: args.localPort,
304
+ })
305
+ return { ok: true, tunnel }
306
+ } catch (error) {
307
+ return { ok: false, tunnel: { id: '', alias: args.alias, localPort: 0, remoteHost: args.remoteHost ?? '127.0.0.1', remotePort: args.remotePort, state: 'failed' as const, error: error instanceof Error ? error.message : String(error), startedAt: Date.now() } }
308
+ }
309
+ }
310
+ if (args.action === 'stop') {
311
+ if (args.tunnelId === undefined) throw new Error('tunnelId is required for stop')
312
+ const stopped = engine.stopTunnel(args.tunnelId)
313
+ return stopped
314
+ ? { ok: true, stopped: 1 }
315
+ : { ok: false, stopped: 0, error: `tunnel '${args.tunnelId}' not found` }
316
+ }
317
+ if (args.action === 'stop-all') {
318
+ const stopped = engine.stopAllTunnels(args.alias)
319
+ return { ok: true, stopped }
320
+ }
321
+ throw new Error(`unknown action '${String(args.action)}'`)
322
+ },
323
+ })
324
+ }
325
+
326
+ /** The cluster tool. */
327
+ export function sshClusterTool(engine: SshEngine) {
328
+ return defineTool({
329
+ name: 'ssh_cluster',
330
+ description: 'Run one command concurrently across many SSH hosts (all hosts, or filtered by aliases / environment / tags). ' +
331
+ 'Triggers: run on all servers, batch operation, production servers, cluster command.',
332
+ parameters: {
333
+ command: { type: 'string', required: true, description: 'The shell command to run on every matched host.' },
334
+ aliases: { type: 'array', items: { type: 'string' }, description: 'Explicit alias list; when absent every configured host matches.' },
335
+ environment: { type: 'string', description: 'Only hosts with this environment label.' },
336
+ tags: { type: 'array', items: { type: 'string' }, description: 'Only hosts carrying ALL these tags.' },
337
+ timeoutMs: { type: 'integer', description: 'Per-host timeout in milliseconds.' },
338
+ maxWorkers: { type: 'integer', description: 'Concurrency cap (default 8).' },
339
+ },
340
+ output: {
341
+ schema: {
342
+ type: 'object',
343
+ additionalProperties: false,
344
+ properties: {
345
+ results: {
346
+ type: 'array',
347
+ required: true,
348
+ items: {
349
+ type: 'object',
350
+ additionalProperties: false,
351
+ properties: {
352
+ alias: { type: 'string', required: true },
353
+ ok: { type: 'boolean', required: true },
354
+ exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
355
+ timedOut: { type: 'boolean' },
356
+ stdout: { type: 'string' },
357
+ stderr: { type: 'string' },
358
+ durationMs: { type: 'integer' },
359
+ error: { type: 'string' },
360
+ },
361
+ },
362
+ },
363
+ },
364
+ },
365
+ render: (_args, value: { results?: ClusterResult[] }) => text(renderCluster(value.results ?? [])),
366
+ },
367
+ async execute(args) {
368
+ return { results: await engine.cluster(args) }
369
+ },
370
+ })
371
+ }