@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
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Browser-side API client for the /api/dsh-ssh route family. The only data
3
+ * access path the panel components use — plain fetch/WebSocket, same origin.
4
+ */
5
+
6
+ import {
7
+ SSH_API,
8
+ type ClusterResult,
9
+ type ExecResult,
10
+ type HostPayload,
11
+ type ImportResult,
12
+ type RemoteDirEntry,
13
+ type SshHostSummary,
14
+ type TerminalClientFrame,
15
+ type TerminalServerFrame,
16
+ type TestResult,
17
+ type TransferProgress,
18
+ type TransferStreamLine,
19
+ type TunnelInfo,
20
+ } from '../protocol.ts'
21
+
22
+ /** Minimal File System Access API surface (not in all lib.dom versions). */
23
+ interface WindowWithFileSystemAccess {
24
+ showSaveFilePicker?: (options: { suggestedName?: string }) => Promise<{
25
+ createWritable: () => Promise<{ write: (data: Uint8Array) => Promise<void>; close: () => Promise<void> }>
26
+ }>
27
+ }
28
+
29
+ /** Error carrying the route's JSON error message. */
30
+ export class SshApiError extends Error {
31
+ constructor(message: string) {
32
+ super(message)
33
+ this.name = 'SshApiError'
34
+ }
35
+ }
36
+
37
+ /** Parse a JSON response or throw an SshApiError. */
38
+ async function readJson<T>(response: Response): Promise<T> {
39
+ let body: unknown
40
+ try {
41
+ body = await response.json()
42
+ } catch {
43
+ throw new SshApiError(`HTTP ${response.status}: invalid JSON response`)
44
+ }
45
+ if (!response.ok) {
46
+ const message = typeof body === 'object' && body !== null && typeof (body as { error?: unknown }).error === 'string'
47
+ ? (body as { error: string }).error
48
+ : `HTTP ${response.status}`
49
+ throw new SshApiError(message)
50
+ }
51
+ return body as T
52
+ }
53
+
54
+ /** Query-string helper. */
55
+ function query(params: Record<string, string | number | undefined>): string {
56
+ const search = new URLSearchParams()
57
+ for (const [key, value] of Object.entries(params)) {
58
+ if (value !== undefined && value !== '') search.set(key, String(value))
59
+ }
60
+ const text = search.toString()
61
+ return text === '' ? '' : '?' + text
62
+ }
63
+
64
+ /** One open terminal connection (WebSocket JSON frames). */
65
+ export interface TerminalConnection {
66
+ /** Fired on the ready frame (shell is up). */
67
+ onReady: (() => void) | undefined
68
+ /** Fired on every output frame. */
69
+ onOutput: ((data: string) => void) | undefined
70
+ /** Fired on the exit frame (or transport error). */
71
+ onExit: ((code: number | null, error?: string) => void) | undefined
72
+ /** Send raw input to the remote shell. */
73
+ send(data: string): void
74
+ /** Resize the remote PTY. */
75
+ resize(cols: number, rows: number): void
76
+ /** Close the socket and the remote session. */
77
+ close(): void
78
+ }
79
+
80
+ /** The browser half's only data entry point. */
81
+ export class SshApi {
82
+ // -------------------------------------------------------------- hosts
83
+ async listHosts(queryText?: string): Promise<SshHostSummary[]> {
84
+ const response = await fetch(SSH_API.hosts + query({ query: queryText }))
85
+ const body = await readJson<{ hosts: SshHostSummary[] }>(response)
86
+ return body.hosts
87
+ }
88
+
89
+ async createHost(payload: HostPayload): Promise<SshHostSummary> {
90
+ const response = await fetch(SSH_API.hosts, {
91
+ method: 'POST',
92
+ headers: { 'content-type': 'application/json' },
93
+ body: JSON.stringify(payload),
94
+ })
95
+ const body = await readJson<{ host: SshHostSummary }>(response)
96
+ return body.host
97
+ }
98
+
99
+ async updateHost(alias: string, patch: HostPayload): Promise<SshHostSummary> {
100
+ const response = await fetch(SSH_API.hosts + query({ alias }), {
101
+ method: 'PATCH',
102
+ headers: { 'content-type': 'application/json' },
103
+ body: JSON.stringify(patch),
104
+ })
105
+ const body = await readJson<{ host: SshHostSummary }>(response)
106
+ return body.host
107
+ }
108
+
109
+ async deleteHost(alias: string): Promise<void> {
110
+ const response = await fetch(SSH_API.hosts + query({ alias }), { method: 'DELETE' })
111
+ await readJson<{ ok: boolean }>(response)
112
+ }
113
+
114
+ async importSshConfig(): Promise<ImportResult> {
115
+ const response = await fetch(SSH_API.importSshConfig, { method: 'POST' })
116
+ const body = await readJson<{ result: ImportResult }>(response)
117
+ return body.result
118
+ }
119
+
120
+ // ---------------------------------------------------------------- ops
121
+ async testHost(alias: string): Promise<TestResult> {
122
+ const response = await fetch(SSH_API.test, {
123
+ method: 'POST',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: JSON.stringify({ alias }),
126
+ })
127
+ const body = await readJson<{ result: TestResult }>(response)
128
+ return body.result
129
+ }
130
+
131
+ async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
132
+ const response = await fetch(SSH_API.exec, {
133
+ method: 'POST',
134
+ headers: { 'content-type': 'application/json' },
135
+ body: JSON.stringify({ alias, command, timeoutMs }),
136
+ })
137
+ const body = await readJson<{ result: ExecResult }>(response)
138
+ return body.result
139
+ }
140
+
141
+ async cluster(options: {
142
+ command: string
143
+ aliases?: string[]
144
+ environment?: string
145
+ tags?: string[]
146
+ timeoutMs?: number
147
+ maxWorkers?: number
148
+ }): Promise<ClusterResult[]> {
149
+ const response = await fetch(SSH_API.cluster, {
150
+ method: 'POST',
151
+ headers: { 'content-type': 'application/json' },
152
+ body: JSON.stringify(options),
153
+ })
154
+ const body = await readJson<{ results: ClusterResult[] }>(response)
155
+ return body.results
156
+ }
157
+
158
+ // ----------------------------------------------------------------- ls
159
+ async ls(alias: string, path: string): Promise<RemoteDirEntry[]> {
160
+ const response = await fetch(SSH_API.ls + query({ alias, path }))
161
+ const body = await readJson<{ entries: RemoteDirEntry[] }>(response)
162
+ return body.entries
163
+ }
164
+
165
+ // ------------------------------------------------------------- tunnel
166
+ async listTunnels(): Promise<TunnelInfo[]> {
167
+ const response = await fetch(SSH_API.tunnel, {
168
+ method: 'POST',
169
+ headers: { 'content-type': 'application/json' },
170
+ body: JSON.stringify({ action: 'list' }),
171
+ })
172
+ const body = await readJson<{ tunnels: TunnelInfo[] }>(response)
173
+ return body.tunnels
174
+ }
175
+
176
+ async startTunnel(options: { alias: string; remotePort: number; remoteHost?: string; localPort?: number }): Promise<TunnelInfo> {
177
+ const response = await fetch(SSH_API.tunnel, {
178
+ method: 'POST',
179
+ headers: { 'content-type': 'application/json' },
180
+ body: JSON.stringify({ action: 'start', ...options }),
181
+ })
182
+ const body = await readJson<{ tunnel: TunnelInfo }>(response)
183
+ return body.tunnel
184
+ }
185
+
186
+ async stopTunnel(tunnelId: string): Promise<boolean> {
187
+ const response = await fetch(SSH_API.tunnel, {
188
+ method: 'POST',
189
+ headers: { 'content-type': 'application/json' },
190
+ body: JSON.stringify({ action: 'stop', tunnelId }),
191
+ })
192
+ const body = await readJson<{ ok: boolean }>(response)
193
+ return body.ok
194
+ }
195
+
196
+ async stopAllTunnels(alias?: string): Promise<number> {
197
+ const response = await fetch(SSH_API.tunnel, {
198
+ method: 'POST',
199
+ headers: { 'content-type': 'application/json' },
200
+ body: JSON.stringify({ action: 'stop-all', alias }),
201
+ })
202
+ const body = await readJson<{ stopped: number }>(response)
203
+ return body.stopped
204
+ }
205
+
206
+ // ------------------------------------------------------------ transfer
207
+ /**
208
+ * Upload one file (raw bytes) to a remote path. Progress arrives through
209
+ * the NDJSON response stream; resolves when the result frame lands.
210
+ */
211
+ async uploadFile(
212
+ file: File,
213
+ alias: string,
214
+ remotePath: string,
215
+ onProgress?: (progress: TransferProgress) => void,
216
+ ): Promise<{ transferredBytes: number }> {
217
+ const response = await fetch(SSH_API.upload + query({ alias, remotePath }), {
218
+ method: 'POST',
219
+ body: file,
220
+ })
221
+ if (!response.ok || response.body === null) {
222
+ throw new SshApiError(`upload failed: HTTP ${response.status}`)
223
+ }
224
+ const reader = response.body.getReader()
225
+ const decoder = new TextDecoder()
226
+ let buffer = ''
227
+ let finalError: string | undefined
228
+ let sawResult = false
229
+ let transferredBytes = 0
230
+ for (;;) {
231
+ const { done, value } = await reader.read()
232
+ if (done) break
233
+ buffer += decoder.decode(value, { stream: true })
234
+ const lines = buffer.split('\n')
235
+ buffer = lines.pop() ?? ''
236
+ for (const line of lines) {
237
+ if (line.trim() === '') continue
238
+ let parsed: TransferStreamLine
239
+ try {
240
+ parsed = JSON.parse(line) as TransferStreamLine
241
+ } catch {
242
+ continue
243
+ }
244
+ if (parsed.type === 'progress') {
245
+ onProgress?.(parsed.progress)
246
+ } else if (parsed.type === 'result') {
247
+ sawResult = true
248
+ if (parsed.ok) transferredBytes = parsed.transferredBytes ?? 0
249
+ finalError = parsed.ok ? undefined : parsed.error ?? 'upload failed'
250
+ }
251
+ }
252
+ }
253
+ if (finalError !== undefined) throw new SshApiError(finalError)
254
+ if (!sawResult) throw new SshApiError('upload ended without a result frame — the transfer did not complete')
255
+ return { transferredBytes }
256
+ }
257
+
258
+ /**
259
+ * Download a remote file with client-side progress. Streams straight to
260
+ * disk when the File System Access API is available (no full-file RAM
261
+ * copy); otherwise falls back to an in-memory Blob.
262
+ */
263
+ async downloadFile(
264
+ alias: string,
265
+ remotePath: string,
266
+ onProgress?: (progress: TransferProgress) => void,
267
+ ): Promise<{ blob?: Blob; filename: string; streamed: boolean; bytes: number }> {
268
+ const response = await fetch(SSH_API.download + query({ alias, remotePath }))
269
+ if (!response.ok || response.body === null) {
270
+ const text = await response.text().catch(() => '')
271
+ throw new SshApiError(text !== '' && text.startsWith('{') ? text : `download failed: HTTP ${response.status}`)
272
+ }
273
+ const total = Number(response.headers.get('content-length') ?? '0')
274
+ const disposition = response.headers.get('content-disposition') ?? ''
275
+ const match = /filename="([^"]+)"/.exec(disposition)
276
+ const filename = match?.[1] ?? remotePath.split('/').pop() ?? 'download'
277
+ const reader = response.body.getReader()
278
+ const picker = typeof window !== 'undefined'
279
+ ? (window as WindowWithFileSystemAccess).showSaveFilePicker
280
+ : undefined
281
+ let streamed = false
282
+ let writable: { write: (data: Uint8Array) => Promise<void>; close: () => Promise<void> } | undefined
283
+ const chunks: Uint8Array<ArrayBuffer>[] = []
284
+ let received = 0
285
+ const progress = (): void => {
286
+ onProgress?.({
287
+ phase: 'transferring',
288
+ file: remotePath,
289
+ transferred: received,
290
+ total,
291
+ percent: total > 0 ? Math.round((received / total) * 1000) / 10 : 0,
292
+ })
293
+ }
294
+ try {
295
+ if (picker !== undefined) {
296
+ const handle = await picker.call(window, { suggestedName: filename })
297
+ writable = await handle.createWritable()
298
+ streamed = true
299
+ }
300
+ } catch {
301
+ // User cancelled the save dialog or the API is unavailable: fall back.
302
+ }
303
+ for (;;) {
304
+ const { done, value } = await reader.read()
305
+ if (done) break
306
+ if (writable !== undefined) {
307
+ await writable.write(value as Uint8Array)
308
+ } else {
309
+ chunks.push(value as Uint8Array<ArrayBuffer>)
310
+ }
311
+ received += value.length
312
+ progress()
313
+ }
314
+ if (writable !== undefined) await writable.close()
315
+ onProgress?.({ phase: 'done', file: remotePath, transferred: received, total: received > 0 ? received : total, percent: 100 })
316
+ return {
317
+ blob: streamed ? undefined : new Blob(chunks),
318
+ filename,
319
+ streamed,
320
+ bytes: received,
321
+ }
322
+ }
323
+
324
+ // ------------------------------------------------------------ terminal
325
+ /** Open a WebSocket terminal session. */
326
+ openTerminal(alias: string, cols: number, rows: number): TerminalConnection {
327
+ const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
328
+ const url = scheme + '://' + window.location.host + SSH_API.terminal + query({ alias, cols, rows })
329
+ const socket = new WebSocket(url)
330
+ const connection: TerminalConnection = {
331
+ onReady: undefined,
332
+ onOutput: undefined,
333
+ onExit: undefined,
334
+ send: (data) => {
335
+ if (socket.readyState === WebSocket.OPEN) {
336
+ socket.send(JSON.stringify({ type: 'input', data } satisfies TerminalClientFrame))
337
+ }
338
+ },
339
+ resize: (cols, rows) => {
340
+ if (socket.readyState === WebSocket.OPEN) {
341
+ socket.send(JSON.stringify({ type: 'resize', cols, rows } satisfies TerminalClientFrame))
342
+ }
343
+ },
344
+ close: () => {
345
+ try { socket.close() } catch { /* already closed */ }
346
+ },
347
+ }
348
+ socket.onmessage = (event: MessageEvent<string>) => {
349
+ let frame: TerminalServerFrame
350
+ try {
351
+ frame = JSON.parse(event.data) as TerminalServerFrame
352
+ } catch {
353
+ return
354
+ }
355
+ if (frame.type === 'ready') connection.onReady?.()
356
+ else if (frame.type === 'output') connection.onOutput?.(frame.data)
357
+ else if (frame.type === 'exit') connection.onExit?.(frame.code, frame.error)
358
+ }
359
+ socket.onclose = () => { connection.onExit?.(null, 'connection closed') }
360
+ socket.onerror = () => { connection.onExit?.(null, 'connection error') }
361
+ return connection
362
+ }
363
+ }
@@ -0,0 +1,5 @@
1
+ /** CSS Modules ambient declaration for the tsdown virtual loader. */
2
+ declare module '*.module.css' {
3
+ const classes: Record<string, string>
4
+ export default classes
5
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Browser-half entry for the dsh-ssh plugin — runs inside the dsh web GUI.
3
+ *
4
+ * Registers the dsh-ssh locale dictionaries and mounts the two DOM surfaces:
5
+ * the sidebar entry row (toggles the panel) and the SSH operations panel in
6
+ * the center column. Failure policy: DOM mounting problems are logged, never
7
+ * thrown — the web shell fails the whole boot when a plugin apply throws, and
8
+ * an external plugin must not take the GUI down.
9
+ *
10
+ * Export discipline (packages/client rule): the /client surface carries what
11
+ * cordis loading needs plus types only — all value exports stay internal.
12
+ */
13
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
14
+ // Type-only: pulls the locale plugin's Context merge (ctx.locale).
15
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
16
+ // Type-only: pulls the LocaleNamespaceMap merge table.
17
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
18
+ import { SshApi } from './api.ts'
19
+ import { en, zh, type SshKey } from './locales.ts'
20
+ import { mountPanel } from './mount.tsx'
21
+ import { PanelController } from './panel/controller.ts'
22
+ import { mountSidebarEntry } from './sidebar-entry.ts'
23
+
24
+ /** Locale namespace this plugin owns. */
25
+ const NS = 'dsh-ssh'
26
+
27
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
28
+ interface LocaleNamespaceMap {
29
+ /** dsh-ssh surface copy. */
30
+ 'dsh-ssh': SshKey
31
+ }
32
+ }
33
+
34
+ /** Required services (fiber inject waiting — the runtime must be up first). */
35
+ export const inject = ['slots', 'locale']
36
+
37
+ /** Type-only surface (export discipline: no value exports beyond the plugin contract). */
38
+ export type { PanelControllerSnapshot } from './panel/controller.ts'
39
+ export type { SshPanelProps } from './panel/SshPanel.tsx'
40
+ export type { HostsTabProps } from './panel/HostsTab.tsx'
41
+ export type { HostFormDialogProps } from './panel/HostFormDialog.tsx'
42
+ export type { TerminalTabProps } from './panel/TerminalTab.tsx'
43
+ export type { TransferTabProps } from './panel/TransferTab.tsx'
44
+ export type { TunnelsTabProps } from './panel/TunnelsTab.tsx'
45
+ export type { ClusterTabProps } from './panel/ClusterTab.tsx'
46
+ export type { SshKey } from './locales.ts'
47
+
48
+ /**
49
+ * Mount the SSH panel.
50
+ * @param ctx - client root context (locale service).
51
+ */
52
+ export function apply(ctx: ClientContext): void {
53
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-ssh: dictionaries')
54
+
55
+ const controller = new PanelController()
56
+ const api = new SshApi()
57
+ const disposers: Array<() => void> = []
58
+ try {
59
+ disposers.push(mountSidebarEntry(controller))
60
+ disposers.push(mountPanel(controller, api))
61
+ } catch (error) {
62
+ // DOM failures degrade the panel, never the GUI.
63
+ console.warn('[dsh-ssh] mount failed:', error)
64
+ }
65
+ ctx.effect(() => () => {
66
+ for (const dispose of disposers.splice(0)) dispose()
67
+ }, 'dsh-ssh: ui mounts')
68
+ }