@tiphareth/dsh-hardssh 0.1.2

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 (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17780 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +134 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +96 -0
  126. package/src/client/workspace-gate.ts +225 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * One-time migration from the legacy GLOBAL local⇄remote mode. The previous
3
+ * hardssh client persisted each session's mode in localStorage under
4
+ * `ssh-session-state:<sessionId>`; a remembered REMOTE target meant the whole
5
+ * host switched to that SSH server. With the workspace-bound redesign, the
6
+ * correct home for that binding is an SSH workspace record. This module
7
+ * scans the legacy keys, converts each DISTINCT remote target (alias +
8
+ * remoteRoot) into a ledger record (reusing it for every session that shared
9
+ * it), and clears the legacy keys so the migration runs exactly once.
10
+ *
11
+ * Best-effort: any failure is logged by the caller and never blocks boot.
12
+ * @module dsh-hardssh/client/migrate
13
+ */
14
+
15
+ import type { WorkspaceApi } from './api.ts'
16
+
17
+ const LEGACY_KEY_PREFIX = 'ssh-session-state:'
18
+
19
+ /** The distinct remote targets remembered by the legacy build. */
20
+ interface LegacyTarget {
21
+ alias: string
22
+ remoteRoot?: string
23
+ // A human label when the legacy build kept one ('~' or an explicit path).
24
+ label?: string
25
+ }
26
+
27
+ /** Read one legacy key (guarded, lenient). */
28
+ function readLegacy(key: string): LegacyTarget | null {
29
+ try {
30
+ const raw = localStorage.getItem(key)
31
+ if (raw === null) return null
32
+ const parsed = JSON.parse(raw) as Record<string, unknown>
33
+ if (parsed.mode !== 'remote' || typeof parsed.alias !== 'string' || parsed.alias === '') return null
34
+ return {
35
+ alias: parsed.alias,
36
+ remoteRoot: typeof parsed.remoteRoot === 'string' && parsed.remoteRoot !== '' ? parsed.remoteRoot : undefined,
37
+ label: typeof parsed.remoteRootLabel === 'string' ? parsed.remoteRootLabel : undefined,
38
+ }
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ /** All legacy session-memory keys present. */
45
+ function legacyKeys(): string[] {
46
+ const keys: string[] = []
47
+ for (let index = 0; index < localStorage.length; index += 1) {
48
+ const key = localStorage.key(index)
49
+ if (key !== null && key.startsWith(LEGACY_KEY_PREFIX)) keys.push(key)
50
+ }
51
+ return keys
52
+ }
53
+
54
+ /**
55
+ * Migrate legacy remote memories into SSH workspace records. Returns the
56
+ * number of workspaces created (0 when nothing to do).
57
+ */
58
+ export async function migrateLegacySessionMemory(api: WorkspaceApi): Promise<number> {
59
+ const keys = legacyKeys()
60
+ if (keys.length === 0) return 0
61
+
62
+ // Collect the distinct remote targets (dedupe by alias + remoteRoot).
63
+ const targets: LegacyTarget[] = []
64
+ const seen = new Set<string>()
65
+ for (const key of keys) {
66
+ const target = readLegacy(key)
67
+ if (target === null) continue
68
+ const id = `${target.alias}|${target.remoteRoot ?? ''}`
69
+ if (seen.has(id)) continue
70
+ seen.add(id)
71
+ targets.push(target)
72
+ }
73
+ if (targets.length === 0) {
74
+ // Nothing remote in the legacy memory: just clear the stale keys.
75
+ for (const key of keys) localStorage.removeItem(key)
76
+ return 0
77
+ }
78
+
79
+ // Existing workspace records (so a re-run never duplicates).
80
+ const existing = await api.listWorkspaces()
81
+ const existingKey = new Set(existing.map((record) => `${record.alias}|${record.remoteRoot}`))
82
+ let created = 0
83
+
84
+ for (const target of targets) {
85
+ const id = `${target.alias}|${target.remoteRoot ?? ''}`
86
+ if (existingKey.has(id)) continue
87
+ // The legacy build stored '~' as the label but the RESOLVED root in
88
+ // remoteRoot — use the root as the workspace root verbatim.
89
+ if (target.remoteRoot === undefined || target.remoteRoot === '') {
90
+ // No resolved root: the legacy host resolved it on connect; guard by
91
+ // skipping a target we cannot address (can't create without a root).
92
+ continue
93
+ }
94
+ const title = target.label === '~' || target.label === undefined
95
+ ? `${target.alias}:home`
96
+ : target.label.split('/').filter(Boolean).pop() ?? target.alias
97
+ try {
98
+ await api.createWorkspace({ title, alias: target.alias, remoteRoot: target.remoteRoot })
99
+ created += 1
100
+ } catch (error) {
101
+ // One failed target must not block the rest.
102
+ console.warn(`[dsh-hardssh] migration of ${target.alias}@${target.remoteRoot} failed:`, error)
103
+ }
104
+ }
105
+
106
+ // Clear the legacy keys: the migration is done exactly once.
107
+ for (const key of keys) localStorage.removeItem(key)
108
+ return created
109
+ }
@@ -0,0 +1,411 @@
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 KnownHostAction,
13
+ type KnownHostView,
14
+ type RemoteDirEntry,
15
+ type SshHostSummary,
16
+ type TerminalClientFrame,
17
+ type TerminalServerFrame,
18
+ type TestResult,
19
+ type TransferProgress,
20
+ type TransferStreamLine,
21
+ type TunnelInfo,
22
+ } from '../../ssh/protocol.ts'
23
+
24
+ /** Minimal File System Access API surface (not in all lib.dom versions). */
25
+ interface WindowWithFileSystemAccess {
26
+ showSaveFilePicker?: (options: { suggestedName?: string }) => Promise<{
27
+ createWritable: () => Promise<{ write: (data: Uint8Array) => Promise<void>; close: () => Promise<void> }>
28
+ }>
29
+ }
30
+
31
+ /** Error carrying the route's JSON error message. */
32
+ export class SshApiError extends Error {
33
+ /** Stable machine code from the route error body (HOST_KEY_*, HOST_IN_USE, …). */
34
+ readonly code: string | undefined
35
+ readonly hostKeyFingerprint: string | undefined
36
+ constructor(message: string, code?: string, hostKeyFingerprint?: string) {
37
+ super(message)
38
+ this.name = 'SshApiError'
39
+ this.code = code
40
+ this.hostKeyFingerprint = hostKeyFingerprint
41
+ }
42
+ }
43
+
44
+ /** Parse a JSON response or throw an SshApiError. */
45
+ async function readJson<T>(response: Response): Promise<T> {
46
+ let body: unknown
47
+ try {
48
+ body = await response.json()
49
+ } catch {
50
+ throw new SshApiError(`HTTP ${response.status}: invalid JSON response`)
51
+ }
52
+ if (!response.ok) {
53
+ const record = typeof body === 'object' && body !== null ? body as Record<string, unknown> : {}
54
+ const message = typeof record.error === 'string' ? record.error : `HTTP ${response.status}`
55
+ const code = typeof record.code === 'string' ? record.code : undefined
56
+ const hostKeyFingerprint = typeof record.hostKeyFingerprint === 'string' ? record.hostKeyFingerprint : undefined
57
+ throw new SshApiError(message, code, hostKeyFingerprint)
58
+ }
59
+ return body as T
60
+ }
61
+
62
+ /** Query-string helper. */
63
+ function query(params: Record<string, string | number | undefined>): string {
64
+ const search = new URLSearchParams()
65
+ for (const [key, value] of Object.entries(params)) {
66
+ if (value !== undefined && value !== '') search.set(key, String(value))
67
+ }
68
+ const text = search.toString()
69
+ return text === '' ? '' : '?' + text
70
+ }
71
+
72
+ /** One open terminal connection (WebSocket JSON frames). */
73
+ export interface TerminalConnection {
74
+ /** Fired on the ready frame (shell is up). */
75
+ onReady: (() => void) | undefined
76
+ /** Fired on every output frame. */
77
+ onOutput: ((data: string) => void) | undefined
78
+ /** Fired on the exit frame (or transport error). */
79
+ onExit: ((code: number | null, error?: string) => void) | undefined
80
+ /** Send raw input to the remote shell. */
81
+ send(data: string): void
82
+ /** Resize the remote PTY. */
83
+ resize(cols: number, rows: number): void
84
+ /** Close the socket and the remote session. */
85
+ close(): void
86
+ }
87
+
88
+ /** The browser half's only data entry point. */
89
+ export class SshApi {
90
+ // -------------------------------------------------------------- hosts
91
+ async listHosts(queryText?: string): Promise<SshHostSummary[]> {
92
+ const response = await fetch(SSH_API.hosts + query({ query: queryText }))
93
+ const body = await readJson<{ hosts: SshHostSummary[] }>(response)
94
+ return body.hosts
95
+ }
96
+
97
+ async createHost(payload: HostPayload): Promise<SshHostSummary> {
98
+ const response = await fetch(SSH_API.hosts, {
99
+ method: 'POST',
100
+ headers: { 'content-type': 'application/json' },
101
+ body: JSON.stringify(payload),
102
+ })
103
+ const body = await readJson<{ host: SshHostSummary }>(response)
104
+ return body.host
105
+ }
106
+
107
+ async updateHost(alias: string, patch: HostPayload): Promise<SshHostSummary> {
108
+ const response = await fetch(SSH_API.hosts + query({ alias }), {
109
+ method: 'PATCH',
110
+ headers: { 'content-type': 'application/json' },
111
+ body: JSON.stringify(patch),
112
+ })
113
+ const body = await readJson<{ host: SshHostSummary }>(response)
114
+ return body.host
115
+ }
116
+
117
+ async deleteHost(alias: string): Promise<void> {
118
+ const response = await fetch(SSH_API.hosts + query({ alias }), { method: 'DELETE' })
119
+ await readJson<{ ok: boolean }>(response)
120
+ }
121
+
122
+ async importSshConfig(): Promise<ImportResult> {
123
+ const response = await fetch(SSH_API.importSshConfig, { method: 'POST' })
124
+ const body = await readJson<{ result: ImportResult }>(response)
125
+ return body.result
126
+ }
127
+
128
+ // ---------------------------------------------------------------- ops
129
+ async testHost(alias: string): Promise<TestResult> {
130
+ const response = await fetch(SSH_API.test, {
131
+ method: 'POST',
132
+ headers: { 'content-type': 'application/json' },
133
+ body: JSON.stringify({ alias }),
134
+ })
135
+ const body = await readJson<{ result: TestResult }>(response)
136
+ return body.result
137
+ }
138
+
139
+ async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
140
+ const response = await fetch(SSH_API.exec, {
141
+ method: 'POST',
142
+ headers: { 'content-type': 'application/json' },
143
+ body: JSON.stringify({ alias, command, timeoutMs }),
144
+ })
145
+ const body = await readJson<{ result: ExecResult }>(response)
146
+ return body.result
147
+ }
148
+
149
+ async cluster(options: {
150
+ command: string
151
+ aliases?: string[]
152
+ environment?: string
153
+ tags?: string[]
154
+ timeoutMs?: number
155
+ maxWorkers?: number
156
+ }): Promise<ClusterResult[]> {
157
+ const response = await fetch(SSH_API.cluster, {
158
+ method: 'POST',
159
+ headers: { 'content-type': 'application/json' },
160
+ body: JSON.stringify(options),
161
+ })
162
+ const body = await readJson<{ results: ClusterResult[] }>(response)
163
+ return body.results
164
+ }
165
+
166
+ // ----------------------------------------------------------------- ls
167
+ async ls(alias: string, path: string): Promise<RemoteDirEntry[]> {
168
+ const response = await fetch(SSH_API.ls + query({ alias, path }))
169
+ const body = await readJson<{ entries: RemoteDirEntry[] }>(response)
170
+ return body.entries
171
+ }
172
+
173
+ // ------------------------------------------------------------- tunnel
174
+ async listTunnels(): Promise<TunnelInfo[]> {
175
+ const response = await fetch(SSH_API.tunnel, {
176
+ method: 'POST',
177
+ headers: { 'content-type': 'application/json' },
178
+ body: JSON.stringify({ action: 'list' }),
179
+ })
180
+ const body = await readJson<{ tunnels: TunnelInfo[] }>(response)
181
+ return body.tunnels
182
+ }
183
+
184
+ async startTunnel(options: { alias: string; remotePort: number; remoteHost?: string; localPort?: number }): Promise<TunnelInfo> {
185
+ const response = await fetch(SSH_API.tunnel, {
186
+ method: 'POST',
187
+ headers: { 'content-type': 'application/json' },
188
+ body: JSON.stringify({ action: 'start', ...options }),
189
+ })
190
+ const body = await readJson<{ tunnel: TunnelInfo }>(response)
191
+ return body.tunnel
192
+ }
193
+
194
+ async stopTunnel(tunnelId: string): Promise<boolean> {
195
+ const response = await fetch(SSH_API.tunnel, {
196
+ method: 'POST',
197
+ headers: { 'content-type': 'application/json' },
198
+ body: JSON.stringify({ action: 'stop', tunnelId }),
199
+ })
200
+ const body = await readJson<{ ok: boolean }>(response)
201
+ return body.ok
202
+ }
203
+
204
+ async stopAllTunnels(alias?: string): Promise<number> {
205
+ const response = await fetch(SSH_API.tunnel, {
206
+ method: 'POST',
207
+ headers: { 'content-type': 'application/json' },
208
+ body: JSON.stringify({ action: 'stop-all', alias }),
209
+ })
210
+ const body = await readJson<{ stopped: number }>(response)
211
+ return body.stopped
212
+ }
213
+
214
+ // ------------------------------------------------------------ transfer
215
+ /**
216
+ * Upload one file (raw bytes) to a remote path. Progress arrives through
217
+ * the NDJSON response stream; resolves when the result frame lands.
218
+ */
219
+ async uploadFile(
220
+ file: File,
221
+ alias: string,
222
+ remotePath: string,
223
+ onProgress?: (progress: TransferProgress) => void,
224
+ ): Promise<{ transferredBytes: number }> {
225
+ const response = await fetch(SSH_API.upload + query({ alias, remotePath }), {
226
+ method: 'POST',
227
+ body: file,
228
+ })
229
+ if (!response.ok || response.body === null) {
230
+ throw new SshApiError(`upload failed: HTTP ${response.status}`)
231
+ }
232
+ const reader = response.body.getReader()
233
+ const decoder = new TextDecoder()
234
+ let buffer = ''
235
+ let finalError: string | undefined
236
+ let sawResult = false
237
+ let transferredBytes = 0
238
+ for (;;) {
239
+ const { done, value } = await reader.read()
240
+ if (done) break
241
+ buffer += decoder.decode(value, { stream: true })
242
+ const lines = buffer.split('\n')
243
+ buffer = lines.pop() ?? ''
244
+ for (const line of lines) {
245
+ if (line.trim() === '') continue
246
+ let parsed: TransferStreamLine
247
+ try {
248
+ parsed = JSON.parse(line) as TransferStreamLine
249
+ } catch {
250
+ continue
251
+ }
252
+ if (parsed.type === 'progress') {
253
+ onProgress?.(parsed.progress)
254
+ } else if (parsed.type === 'result') {
255
+ sawResult = true
256
+ if (parsed.ok) transferredBytes = parsed.transferredBytes ?? 0
257
+ finalError = parsed.ok ? undefined : parsed.error ?? 'upload failed'
258
+ }
259
+ }
260
+ }
261
+ if (finalError !== undefined) throw new SshApiError(finalError)
262
+ if (!sawResult) throw new SshApiError('upload ended without a result frame — the transfer did not complete')
263
+ return { transferredBytes }
264
+ }
265
+
266
+ /**
267
+ * Download a remote file with client-side progress. Streams straight to
268
+ * disk when the File System Access API is available (no full-file RAM
269
+ * copy); otherwise falls back to an in-memory Blob.
270
+ */
271
+ async downloadFile(
272
+ alias: string,
273
+ remotePath: string,
274
+ onProgress?: (progress: TransferProgress) => void,
275
+ ): Promise<{ blob?: Blob; filename: string; streamed: boolean; bytes: number }> {
276
+ const response = await fetch(SSH_API.download + query({ alias, remotePath }))
277
+ if (!response.ok || response.body === null) {
278
+ const text = await response.text().catch(() => '')
279
+ throw new SshApiError(text !== '' && text.startsWith('{') ? text : `download failed: HTTP ${response.status}`)
280
+ }
281
+ const total = Number(response.headers.get('content-length') ?? '0')
282
+ const disposition = response.headers.get('content-disposition') ?? ''
283
+ const match = /filename="([^"]+)"/.exec(disposition)
284
+ const filename = match?.[1] ?? remotePath.split('/').pop() ?? 'download'
285
+ const reader = response.body.getReader()
286
+ const picker = typeof window !== 'undefined'
287
+ ? (window as WindowWithFileSystemAccess).showSaveFilePicker
288
+ : undefined
289
+ let streamed = false
290
+ let writable: { write: (data: Uint8Array) => Promise<void>; close: () => Promise<void> } | undefined
291
+ const chunks: Uint8Array<ArrayBuffer>[] = []
292
+ let received = 0
293
+ const progress = (): void => {
294
+ onProgress?.({
295
+ phase: 'transferring',
296
+ file: remotePath,
297
+ transferred: received,
298
+ total,
299
+ percent: total > 0 ? Math.round((received / total) * 1000) / 10 : 0,
300
+ })
301
+ }
302
+ try {
303
+ if (picker !== undefined) {
304
+ const handle = await picker.call(window, { suggestedName: filename })
305
+ writable = await handle.createWritable()
306
+ streamed = true
307
+ }
308
+ } catch {
309
+ // User cancelled the save dialog or the API is unavailable: fall back.
310
+ }
311
+ for (;;) {
312
+ const { done, value } = await reader.read()
313
+ if (done) break
314
+ if (writable !== undefined) {
315
+ await writable.write(value as Uint8Array)
316
+ } else {
317
+ chunks.push(value as Uint8Array<ArrayBuffer>)
318
+ }
319
+ received += value.length
320
+ progress()
321
+ }
322
+ if (writable !== undefined) await writable.close()
323
+ onProgress?.({ phase: 'done', file: remotePath, transferred: received, total: received > 0 ? received : total, percent: 100 })
324
+ return {
325
+ blob: streamed ? undefined : new Blob(chunks),
326
+ filename,
327
+ streamed,
328
+ bytes: received,
329
+ }
330
+ }
331
+
332
+ // ------------------------------------------------------------ terminal
333
+ /** Open a WebSocket terminal session. */
334
+ openTerminal(alias: string, cols: number, rows: number): TerminalConnection {
335
+ const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
336
+ const url = scheme + '://' + window.location.host + SSH_API.terminal + query({ alias, cols, rows })
337
+ const socket = new WebSocket(url)
338
+ const connection: TerminalConnection = {
339
+ onReady: undefined,
340
+ onOutput: undefined,
341
+ onExit: undefined,
342
+ send: (data) => {
343
+ if (socket.readyState === WebSocket.OPEN) {
344
+ socket.send(JSON.stringify({ type: 'input', data } satisfies TerminalClientFrame))
345
+ }
346
+ },
347
+ resize: (cols, rows) => {
348
+ if (socket.readyState === WebSocket.OPEN) {
349
+ socket.send(JSON.stringify({ type: 'resize', cols, rows } satisfies TerminalClientFrame))
350
+ }
351
+ },
352
+ close: () => {
353
+ try { socket.close() } catch { /* already closed */ }
354
+ },
355
+ }
356
+ socket.onmessage = (event: MessageEvent<string>) => {
357
+ let frame: TerminalServerFrame
358
+ try {
359
+ frame = JSON.parse(event.data) as TerminalServerFrame
360
+ } catch {
361
+ return
362
+ }
363
+ if (frame.type === 'ready') connection.onReady?.()
364
+ else if (frame.type === 'output') connection.onOutput?.(frame.data)
365
+ else if (frame.type === 'exit') connection.onExit?.(frame.code, frame.error)
366
+ }
367
+ socket.onclose = () => { connection.onExit?.(null, 'connection closed') }
368
+ socket.onerror = () => { connection.onExit?.(null, 'connection error') }
369
+ return connection
370
+ }
371
+
372
+ // --------------------------------------------------------- known-hosts
373
+ /** Read the host-key trust records (optionally one alias's). */
374
+ async listKnownHosts(alias?: string): Promise<KnownHostView[]> {
375
+ const response = await fetch(SSH_API.knownHosts + query({ alias }))
376
+ const body = await readJson<{ records: KnownHostView[] }>(response)
377
+ return body.records
378
+ }
379
+
380
+ /** Confirm (trust) or reset (forget) one alias's host key. */
381
+ async hostKeyAction(alias: string, action: KnownHostAction): Promise<void> {
382
+ const response = await fetch(SSH_API.knownHosts, {
383
+ method: 'POST',
384
+ headers: { 'content-type': 'application/json' },
385
+ body: JSON.stringify({ alias, action }),
386
+ })
387
+ await readJson<{ ok: true }>(response)
388
+ }
389
+
390
+ // -------------------------------------------------------- connections
391
+ /** Aliases currently holding a live pooled SSH connection (badge coloring). */
392
+ async connectedAliases(): Promise<string[]> {
393
+ const response = await fetch(SSH_API.connections)
394
+ const body = await readJson<{ connected: string[] }>(response)
395
+ return body.connected
396
+ }
397
+
398
+ // ------------------------------------------------------ session secrets
399
+
400
+ /** Provide a password/passphrase for `alias` THIS SESSION ONLY (never
401
+ * persisted). Called when a connection replied NEEDS_PASSWORD; after
402
+ * injection the caller retries the operation. */
403
+ async setSessionSecret(alias: string, secret: { password?: string; passphrase?: string }): Promise<void> {
404
+ const response = await fetch(SSH_API.sessionSecret, {
405
+ method: 'POST',
406
+ headers: { 'content-type': 'application/json' },
407
+ body: JSON.stringify({ alias, ...secret }),
408
+ })
409
+ await readJson<{ ok: true }>(response)
410
+ }
411
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * SSH operations browser surfaces, hosted inside dsh-hardssh's client bundle
3
+ * (migrated from the legacy dsh-ssh package): the 'dsh-ssh'
4
+ * locale dictionaries, the sidebar entry row, and the SSH operations panel
5
+ * in the center column. Mounted from src/client/index.ts alongside the
6
+ * workspace surfaces; a DOM failure here degrades the SSH panel only, never
7
+ * the GUI.
8
+ */
9
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
10
+ // Type-only: pulls the locale plugin's Context merge (ctx.locale).
11
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
12
+ // Type-only: pulls the LocaleNamespaceMap merge table.
13
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
14
+ import { SshApi } from './api.ts'
15
+ import { en, zh } from './locales.ts'
16
+ import { mountPanel } from './mount.tsx'
17
+ import { PanelController } from './panel/controller.ts'
18
+ import { mountSidebarEntry } from './sidebar-entry.ts'
19
+
20
+ /** Locale namespace this capability owns (kept as 'dsh-ssh'). */
21
+ export const NS = 'dsh-ssh'
22
+
23
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
24
+ interface LocaleNamespaceMap {
25
+ /** dsh-ssh surface copy (now inside dsh-hardssh). */
26
+ 'dsh-ssh': import('./locales.ts').SshKey
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Mount the SSH operations surfaces (sidebar entry + operations panel).
32
+ * @param ctx - client root context (locale service).
33
+ */
34
+ export function mountSshOperations(ctx: ClientContext): void {
35
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-ssh: dictionaries')
36
+
37
+ const controller = new PanelController()
38
+ const api = new SshApi()
39
+ const disposers: Array<() => void> = []
40
+ try {
41
+ disposers.push(mountSidebarEntry(controller))
42
+ disposers.push(mountPanel(controller, api))
43
+ } catch (error) {
44
+ // DOM failures degrade the panel, never the GUI.
45
+ console.warn('[dsh-ssh] mount failed:', error)
46
+ }
47
+ ctx.effect(() => () => {
48
+ for (const dispose of disposers.splice(0)) dispose()
49
+ }, 'dsh-ssh: ui mounts')
50
+ }