@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.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.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1165 -1329
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +36 -11
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -99
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -232
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/client/ssh/api.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* access path the panel components use — plain fetch/WebSocket, same origin.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { HttpApiError, buildQuery, readJson, throwHttpError, type HttpErrorBody } from '../../client-http.ts'
|
|
6
7
|
import {
|
|
7
8
|
SSH_API,
|
|
8
9
|
type ClusterResult,
|
|
@@ -14,7 +15,6 @@ import {
|
|
|
14
15
|
type RemoteDirEntry,
|
|
15
16
|
type SshHostSummary,
|
|
16
17
|
type TerminalClientFrame,
|
|
17
|
-
type TerminalServerFrame,
|
|
18
18
|
type TestResult,
|
|
19
19
|
type TransferProgress,
|
|
20
20
|
type TransferStreamLine,
|
|
@@ -24,49 +24,44 @@ import {
|
|
|
24
24
|
/** Minimal File System Access API surface (not in all lib.dom versions). */
|
|
25
25
|
interface WindowWithFileSystemAccess {
|
|
26
26
|
showSaveFilePicker?: (options: { suggestedName?: string }) => Promise<{
|
|
27
|
-
createWritable: () => Promise<{
|
|
27
|
+
createWritable: () => Promise<{
|
|
28
|
+
write: (data: Uint8Array) => Promise<void>
|
|
29
|
+
close: () => Promise<void>
|
|
30
|
+
/** Discard the browser-managed temporary file instead of publishing it. */
|
|
31
|
+
abort?: (reason?: unknown) => Promise<void>
|
|
32
|
+
}>
|
|
28
33
|
}>
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this.code = code
|
|
40
|
-
this.hostKeyFingerprint = hostKeyFingerprint
|
|
41
|
-
}
|
|
42
|
-
}
|
|
36
|
+
/**
|
|
37
|
+
* This family's error name for the ONE shared transport error
|
|
38
|
+
* (`HttpApiError`): same class at runtime, so it carries the HTTP status and
|
|
39
|
+
* the parsed body (code / secret / hostKeyFingerprint / hostKeyMismatch /
|
|
40
|
+
* remaining / retryAfterMs) and existing `instanceof SshApiError` checks keep
|
|
41
|
+
* working. Parsing lives in ./client-http.ts — never re-implemented here.
|
|
42
|
+
*/
|
|
43
|
+
export { HttpApiError as SshApiError }
|
|
43
44
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
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
|
-
}
|
|
45
|
+
/** Query-string helper (shared transport helper, no local copy). */
|
|
46
|
+
const query = buildQuery
|
|
61
47
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Structured fields of one streamed (`application/x-ndjson`) failure frame.
|
|
50
|
+
* The upload route currently sends only `error`, so the interactive-gate
|
|
51
|
+
* fields are picked up when present and simply absent otherwise — the same
|
|
52
|
+
* shape the JSON routes produce, so callers handle one error type.
|
|
53
|
+
*/
|
|
54
|
+
function frameFailure(frame: TransferStreamLine): HttpErrorBody {
|
|
55
|
+
const record = frame as TransferStreamLine & Partial<HttpErrorBody>
|
|
56
|
+
const secret = record.secret
|
|
57
|
+
return {
|
|
58
|
+
...(typeof record.error === 'string' ? { error: record.error } : {}),
|
|
59
|
+
...(typeof record.code === 'string' ? { code: record.code } : {}),
|
|
60
|
+
...(secret === 'password' || secret === 'passphrase' ? { secret } : {}),
|
|
61
|
+
...(typeof record.hostKeyFingerprint === 'string' ? { hostKeyFingerprint: record.hostKeyFingerprint } : {}),
|
|
62
|
+
...(typeof record.remaining === 'number' ? { remaining: record.remaining } : {}),
|
|
63
|
+
...(typeof record.retryAfterMs === 'number' ? { retryAfterMs: record.retryAfterMs } : {}),
|
|
67
64
|
}
|
|
68
|
-
const text = search.toString()
|
|
69
|
-
return text === '' ? '' : '?' + text
|
|
70
65
|
}
|
|
71
66
|
|
|
72
67
|
/** One open terminal connection (WebSocket JSON frames). */
|
|
@@ -221,45 +216,80 @@ export class SshApi {
|
|
|
221
216
|
alias: string,
|
|
222
217
|
remotePath: string,
|
|
223
218
|
onProgress?: (progress: TransferProgress) => void,
|
|
219
|
+
signal?: AbortSignal,
|
|
224
220
|
): Promise<{ transferredBytes: number }> {
|
|
225
221
|
const response = await fetch(SSH_API.upload + query({ alias, remotePath }), {
|
|
226
222
|
method: 'POST',
|
|
227
223
|
body: file,
|
|
224
|
+
signal,
|
|
228
225
|
})
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
// Errors here are JSON too (the route maps them through errorBody), so the
|
|
227
|
+
// shared transport parses them instead of flattening the body to a string.
|
|
228
|
+
if (!response.ok) await throwHttpError(response, 'upload failed')
|
|
229
|
+
if (response.body === null) throw new HttpApiError('upload failed: the response carried no body stream', response.status)
|
|
232
230
|
const reader = response.body.getReader()
|
|
233
231
|
const decoder = new TextDecoder()
|
|
234
232
|
let buffer = ''
|
|
235
|
-
let
|
|
233
|
+
let failure: HttpErrorBody | undefined
|
|
236
234
|
let sawResult = false
|
|
235
|
+
let commitStarted = false
|
|
236
|
+
let readerDone = false
|
|
237
237
|
let transferredBytes = 0
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
238
|
+
try {
|
|
239
|
+
for (;;) {
|
|
240
|
+
const { done, value } = await reader.read()
|
|
241
|
+
if (done) {
|
|
242
|
+
readerDone = true
|
|
243
|
+
break
|
|
251
244
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
245
|
+
buffer += decoder.decode(value, { stream: true })
|
|
246
|
+
const lines = buffer.split('\n')
|
|
247
|
+
buffer = lines.pop() ?? ''
|
|
248
|
+
for (const line of lines) {
|
|
249
|
+
if (line.trim() === '') continue
|
|
250
|
+
let parsed: TransferStreamLine
|
|
251
|
+
try {
|
|
252
|
+
parsed = JSON.parse(line) as TransferStreamLine
|
|
253
|
+
} catch {
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
if (parsed.type === 'progress') {
|
|
257
|
+
onProgress?.(parsed.progress)
|
|
258
|
+
} else if (parsed.type === 'commit') {
|
|
259
|
+
commitStarted = true
|
|
260
|
+
} else if (parsed.type === 'result') {
|
|
261
|
+
sawResult = true
|
|
262
|
+
if (parsed.ok) transferredBytes = parsed.transferredBytes ?? 0
|
|
263
|
+
else failure = frameFailure(parsed)
|
|
264
|
+
}
|
|
258
265
|
}
|
|
259
266
|
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (commitStarted) {
|
|
269
|
+
throw new HttpApiError(
|
|
270
|
+
'upload connection closed after commit began; the remote result is unknown',
|
|
271
|
+
response.status,
|
|
272
|
+
{ code: 'RESULT_UNKNOWN' },
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
throw error
|
|
276
|
+
} finally {
|
|
277
|
+
if (!readerDone) await reader.cancel().catch(() => undefined)
|
|
278
|
+
}
|
|
279
|
+
// A failed transfer is a typed transport error too: the interactive gates
|
|
280
|
+
// (NEEDS_PASSWORD / HOST_KEY_*) must be able to prompt from this path.
|
|
281
|
+
if (failure !== undefined) {
|
|
282
|
+
throw new HttpApiError(typeof failure.error === 'string' ? failure.error : 'upload failed', response.status, failure)
|
|
283
|
+
}
|
|
284
|
+
if (!sawResult) {
|
|
285
|
+
throw new HttpApiError(
|
|
286
|
+
commitStarted
|
|
287
|
+
? 'upload connection closed after commit began; the remote result is unknown'
|
|
288
|
+
: 'upload ended without a result frame — the remote destination was not published',
|
|
289
|
+
response.status,
|
|
290
|
+
commitStarted ? { code: 'RESULT_UNKNOWN' } : { code: 'ABORTED' },
|
|
291
|
+
)
|
|
260
292
|
}
|
|
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
293
|
return { transferredBytes }
|
|
264
294
|
}
|
|
265
295
|
|
|
@@ -272,12 +302,14 @@ export class SshApi {
|
|
|
272
302
|
alias: string,
|
|
273
303
|
remotePath: string,
|
|
274
304
|
onProgress?: (progress: TransferProgress) => void,
|
|
305
|
+
signal?: AbortSignal,
|
|
275
306
|
): Promise<{ blob?: Blob; filename: string; streamed: boolean; bytes: number }> {
|
|
276
|
-
const response = await fetch(SSH_API.download + query({ alias, remotePath }))
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
307
|
+
const response = await fetch(SSH_API.download + query({ alias, remotePath }), { signal })
|
|
308
|
+
// The success path is a raw byte stream, but the error path is the same
|
|
309
|
+
// JSON body as every other route — the shared transport parses it, so the
|
|
310
|
+
// caller keeps code/status instead of a flattened JSON string.
|
|
311
|
+
if (!response.ok) await throwHttpError(response, 'download failed')
|
|
312
|
+
if (response.body === null) throw new HttpApiError('download failed: the response carried no body stream', response.status)
|
|
281
313
|
const total = Number(response.headers.get('content-length') ?? '0')
|
|
282
314
|
const disposition = response.headers.get('content-disposition') ?? ''
|
|
283
315
|
const match = /filename="([^"]+)"/.exec(disposition)
|
|
@@ -287,9 +319,15 @@ export class SshApi {
|
|
|
287
319
|
? (window as WindowWithFileSystemAccess).showSaveFilePicker
|
|
288
320
|
: undefined
|
|
289
321
|
let streamed = false
|
|
290
|
-
let writable: {
|
|
322
|
+
let writable: {
|
|
323
|
+
write: (data: Uint8Array) => Promise<void>
|
|
324
|
+
close: () => Promise<void>
|
|
325
|
+
abort?: (reason?: unknown) => Promise<void>
|
|
326
|
+
} | undefined
|
|
291
327
|
const chunks: Uint8Array<ArrayBuffer>[] = []
|
|
292
328
|
let received = 0
|
|
329
|
+
let readerDone = false
|
|
330
|
+
let writableClosed = false
|
|
293
331
|
const progress = (): void => {
|
|
294
332
|
onProgress?.({
|
|
295
333
|
phase: 'transferring',
|
|
@@ -300,32 +338,52 @@ export class SshApi {
|
|
|
300
338
|
})
|
|
301
339
|
}
|
|
302
340
|
try {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
341
|
+
try {
|
|
342
|
+
if (picker !== undefined) {
|
|
343
|
+
const handle = await picker.call(window, { suggestedName: filename })
|
|
344
|
+
signal?.throwIfAborted()
|
|
345
|
+
writable = await handle.createWritable()
|
|
346
|
+
streamed = true
|
|
347
|
+
}
|
|
348
|
+
} catch (error) {
|
|
349
|
+
// A user-cancelled picker falls back to Blob. An operation abort must
|
|
350
|
+
// not continue into memory or later trigger a browser save.
|
|
351
|
+
if (signal?.aborted === true) throw error
|
|
307
352
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
353
|
+
for (;;) {
|
|
354
|
+
signal?.throwIfAborted()
|
|
355
|
+
const { done, value } = await reader.read()
|
|
356
|
+
if (done) {
|
|
357
|
+
readerDone = true
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
if (writable !== undefined) {
|
|
361
|
+
await writable.write(value as Uint8Array)
|
|
362
|
+
} else {
|
|
363
|
+
chunks.push(value as Uint8Array<ArrayBuffer>)
|
|
364
|
+
}
|
|
365
|
+
received += value.length
|
|
366
|
+
progress()
|
|
367
|
+
}
|
|
368
|
+
signal?.throwIfAborted()
|
|
314
369
|
if (writable !== undefined) {
|
|
315
|
-
await writable.
|
|
316
|
-
|
|
317
|
-
chunks.push(value as Uint8Array<ArrayBuffer>)
|
|
370
|
+
await writable.close()
|
|
371
|
+
writableClosed = true
|
|
318
372
|
}
|
|
319
|
-
received
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
373
|
+
onProgress?.({ phase: 'done', file: remotePath, transferred: received, total: received > 0 ? received : total, percent: 100 })
|
|
374
|
+
return {
|
|
375
|
+
blob: streamed ? undefined : new Blob(chunks),
|
|
376
|
+
filename,
|
|
377
|
+
streamed,
|
|
378
|
+
bytes: received,
|
|
379
|
+
}
|
|
380
|
+
} catch (error) {
|
|
381
|
+
chunks.length = 0
|
|
382
|
+
if (!readerDone) await reader.cancel(error).catch(() => undefined)
|
|
383
|
+
if (writable !== undefined && !writableClosed) {
|
|
384
|
+
await writable.abort?.(error).catch(() => undefined)
|
|
385
|
+
}
|
|
386
|
+
throw error
|
|
329
387
|
}
|
|
330
388
|
}
|
|
331
389
|
|
|
@@ -335,37 +393,116 @@ export class SshApi {
|
|
|
335
393
|
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
|
336
394
|
const url = scheme + '://' + window.location.host + SSH_API.terminal + query({ alias, cols, rows })
|
|
337
395
|
const socket = new WebSocket(url)
|
|
396
|
+
const MAX_PENDING_INPUT = 64 * 1024
|
|
397
|
+
let ready = false
|
|
398
|
+
let finalized = false
|
|
399
|
+
let queuedInput = ''
|
|
400
|
+
let queuedResize: { cols: number; rows: number } | undefined
|
|
401
|
+
let readyListener: (() => void) | undefined
|
|
402
|
+
let readyDelivered = false
|
|
403
|
+
let outputListener: ((data: string) => void) | undefined
|
|
404
|
+
let exitListener: ((code: number | null, error?: string) => void) | undefined
|
|
405
|
+
let exitRecord: { code: number | null; error?: string } | undefined
|
|
406
|
+
let exitDelivered = false
|
|
407
|
+
const deliverReady = (): void => {
|
|
408
|
+
if (!ready || readyDelivered || readyListener === undefined) return
|
|
409
|
+
readyDelivered = true
|
|
410
|
+
readyListener()
|
|
411
|
+
}
|
|
412
|
+
const deliverExit = (): void => {
|
|
413
|
+
if (exitRecord === undefined || exitDelivered || exitListener === undefined) return
|
|
414
|
+
exitDelivered = true
|
|
415
|
+
exitListener(exitRecord.code, exitRecord.error)
|
|
416
|
+
}
|
|
417
|
+
const finalize = (code: number | null, error?: string): void => {
|
|
418
|
+
if (finalized) return
|
|
419
|
+
finalized = true
|
|
420
|
+
queuedInput = ''
|
|
421
|
+
queuedResize = undefined
|
|
422
|
+
exitRecord = error === undefined ? { code } : { code, error }
|
|
423
|
+
deliverExit()
|
|
424
|
+
}
|
|
425
|
+
const sendFrame = (frame: TerminalClientFrame): void => {
|
|
426
|
+
if (finalized || !ready || socket.readyState !== WebSocket.OPEN) return
|
|
427
|
+
socket.send(JSON.stringify(frame))
|
|
428
|
+
}
|
|
338
429
|
const connection: TerminalConnection = {
|
|
339
|
-
onReady
|
|
340
|
-
|
|
341
|
-
|
|
430
|
+
get onReady() { return readyListener },
|
|
431
|
+
set onReady(listener) {
|
|
432
|
+
readyListener = listener
|
|
433
|
+
deliverReady()
|
|
434
|
+
},
|
|
435
|
+
get onOutput() { return outputListener },
|
|
436
|
+
set onOutput(listener) { outputListener = listener },
|
|
437
|
+
get onExit() { return exitListener },
|
|
438
|
+
set onExit(listener) {
|
|
439
|
+
exitListener = listener
|
|
440
|
+
deliverExit()
|
|
441
|
+
},
|
|
342
442
|
send: (data) => {
|
|
343
|
-
if (
|
|
344
|
-
|
|
443
|
+
if (finalized) return
|
|
444
|
+
if (!ready || socket.readyState !== WebSocket.OPEN) {
|
|
445
|
+
if (queuedInput.length + data.length > MAX_PENDING_INPUT) {
|
|
446
|
+
finalize(null, 'terminal input buffer exceeded before ready')
|
|
447
|
+
try { socket.close(1008, 'terminal input buffer exceeded') } catch { /* already closed */ }
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
queuedInput += data
|
|
451
|
+
return
|
|
345
452
|
}
|
|
453
|
+
sendFrame({ type: 'input', data })
|
|
346
454
|
},
|
|
347
|
-
resize: (
|
|
348
|
-
if (
|
|
349
|
-
|
|
455
|
+
resize: (nextCols, nextRows) => {
|
|
456
|
+
if (finalized) return
|
|
457
|
+
const frame = { type: 'resize', cols: nextCols, rows: nextRows } satisfies TerminalClientFrame
|
|
458
|
+
if (!ready || socket.readyState !== WebSocket.OPEN) {
|
|
459
|
+
// Resize is state, not a sequence: one latest value is the complete
|
|
460
|
+
// bounded buffer needed before the server reports shell readiness.
|
|
461
|
+
queuedResize = { cols: nextCols, rows: nextRows }
|
|
462
|
+
return
|
|
350
463
|
}
|
|
464
|
+
sendFrame(frame)
|
|
351
465
|
},
|
|
352
466
|
close: () => {
|
|
353
467
|
try { socket.close() } catch { /* already closed */ }
|
|
354
468
|
},
|
|
355
469
|
}
|
|
356
470
|
socket.onmessage = (event: MessageEvent<string>) => {
|
|
357
|
-
let frame: TerminalServerFrame
|
|
358
471
|
try {
|
|
359
|
-
|
|
472
|
+
const value: unknown = JSON.parse(event.data)
|
|
473
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('invalid terminal frame')
|
|
474
|
+
const frame = value as Record<string, unknown>
|
|
475
|
+
if (frame.type === 'ready' && typeof frame.alias === 'string') {
|
|
476
|
+
if (ready || finalized) return
|
|
477
|
+
ready = true
|
|
478
|
+
deliverReady()
|
|
479
|
+
if (queuedResize !== undefined) {
|
|
480
|
+
sendFrame({ type: 'resize', ...queuedResize })
|
|
481
|
+
queuedResize = undefined
|
|
482
|
+
}
|
|
483
|
+
if (queuedInput !== '') {
|
|
484
|
+
sendFrame({ type: 'input', data: queuedInput })
|
|
485
|
+
queuedInput = ''
|
|
486
|
+
}
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
if (frame.type === 'output' && typeof frame.data === 'string') {
|
|
490
|
+
outputListener?.(frame.data)
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
if (frame.type === 'exit' && (typeof frame.code === 'number' || frame.code === null) && (frame.error === undefined || typeof frame.error === 'string')) {
|
|
494
|
+
finalize(frame.code as number | null, frame.error as string | undefined)
|
|
495
|
+
try { socket.close(1000) } catch { /* already closed */ }
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
throw new Error('invalid terminal frame')
|
|
360
499
|
} catch {
|
|
361
|
-
|
|
500
|
+
finalize(null, 'invalid terminal server frame')
|
|
501
|
+
try { socket.close(1008, 'invalid terminal server frame') } catch { /* already closed */ }
|
|
362
502
|
}
|
|
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
503
|
}
|
|
367
|
-
socket.onclose = () => {
|
|
368
|
-
socket.onerror = () => {
|
|
504
|
+
socket.onclose = () => { finalize(null, 'connection closed') }
|
|
505
|
+
socket.onerror = () => { finalize(null, 'connection error') }
|
|
369
506
|
return connection
|
|
370
507
|
}
|
|
371
508
|
|
package/src/client/ssh/apply.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SSH operations browser surfaces, hosted inside dsh-hardssh's client bundle
|
|
3
|
-
* (migrated from the legacy dsh-ssh package): the 'dsh-ssh'
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
3
|
+
* (migrated from the legacy dsh-ssh package): the 'dsh-ssh' locale
|
|
4
|
+
* dictionaries and the SSH operations console mounted as a RIGHT-Sidebar page
|
|
5
|
+
* tab.
|
|
6
|
+
*
|
|
7
|
+
* Nothing here touches the DOM. The old build injected a sidebar row and a
|
|
8
|
+
* panel container by selector, which the 0.1.5 shell no longer renders — the
|
|
9
|
+
* surfaces are standard plugin extension points now (see ./ops-tab.tsx).
|
|
8
10
|
*/
|
|
9
11
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
10
12
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
11
13
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
12
14
|
// Type-only: pulls the LocaleNamespaceMap merge table.
|
|
13
15
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
|
-
import { SshApi } from './api.ts'
|
|
16
|
+
import type { SshApi } from './api.ts'
|
|
15
17
|
import { en, zh } from './locales.ts'
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
18
|
+
import { registerOperationsTab } from './ops-tab.tsx'
|
|
19
|
+
import type { SessionSshTargetSource } from './session-target.ts'
|
|
19
20
|
|
|
20
21
|
/** Locale namespace this capability owns (kept as 'dsh-ssh'). */
|
|
21
22
|
export const NS = 'dsh-ssh'
|
|
@@ -28,23 +29,21 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
|
-
* Mount the SSH operations surfaces (
|
|
32
|
-
* @param ctx - client root context (locale service).
|
|
32
|
+
* Mount the SSH operations surfaces (locale dictionaries + the right-Sidebar tab).
|
|
33
|
+
* @param ctx - client root context (locale service, slot registry, tab registry).
|
|
34
|
+
* @param api - shared SSH API client.
|
|
35
|
+
* @param target - selected Session's fixed SSH target, or null for local Sessions.
|
|
33
36
|
*/
|
|
34
|
-
export function mountSshOperations(
|
|
35
|
-
ctx
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const disposers: Array<() => void> = []
|
|
37
|
+
export function mountSshOperations(
|
|
38
|
+
ctx: ClientContext,
|
|
39
|
+
api: SshApi,
|
|
40
|
+
target: SessionSshTargetSource,
|
|
41
|
+
): void {
|
|
40
42
|
try {
|
|
41
|
-
|
|
42
|
-
disposers.push(mountPanel(controller, api))
|
|
43
|
+
registerOperationsTab(ctx, api, target)
|
|
43
44
|
} catch (error) {
|
|
44
|
-
//
|
|
45
|
-
console.warn('[dsh-ssh]
|
|
45
|
+
// A wiring failure degrades the SSH operations tab only, never the GUI.
|
|
46
|
+
console.warn('[dsh-ssh] operations tab registration failed:', error)
|
|
46
47
|
}
|
|
47
|
-
ctx.effect(() => ()
|
|
48
|
-
for (const dispose of disposers.splice(0)) dispose()
|
|
49
|
-
}, 'dsh-ssh: ui mounts')
|
|
48
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-ssh: dictionaries')
|
|
50
49
|
}
|
|
@@ -11,6 +11,13 @@ export const zh = {
|
|
|
11
11
|
'tab.transfer': '传输',
|
|
12
12
|
'tab.tunnels': '隧道',
|
|
13
13
|
'tab.cluster': '集群',
|
|
14
|
+
'session.server': '当前服务器',
|
|
15
|
+
'session.target': '跟随当前会话:{alias} · {remoteRoot}',
|
|
16
|
+
'session.localTitle': '当前会话位于本地工作区',
|
|
17
|
+
'session.localUnavailable': 'SSH 操作台仅适用于 SSH 工作区会话。请切换到远程会话后使用。',
|
|
18
|
+
'connectionError.title': '无法连接服务器 {alias}',
|
|
19
|
+
'connectionError.intro': 'SSH 连接未建立,请检查服务器地址、网络和认证配置。',
|
|
20
|
+
'connectionError.close': '知道了',
|
|
14
21
|
// hosts
|
|
15
22
|
'hosts.empty': '尚未配置主机。点击「新增主机」添加,或从 ~/.ssh/config 导入。',
|
|
16
23
|
'hosts.add': '新增主机',
|
|
@@ -68,7 +75,7 @@ export const zh = {
|
|
|
68
75
|
'terminal.selectHost': '选择主机',
|
|
69
76
|
'terminal.connect': '连接',
|
|
70
77
|
'terminal.disconnect': '断开',
|
|
71
|
-
'terminal.placeholder': '
|
|
78
|
+
'terminal.placeholder': '点击「连接」打开当前会话服务器的远程终端。',
|
|
72
79
|
'terminal.connecting': '正在连接…',
|
|
73
80
|
'terminal.ready': '终端已连接({alias})',
|
|
74
81
|
'terminal.exited': '终端已退出({alias})',
|
|
@@ -168,6 +175,13 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
168
175
|
'tab.transfer': 'Transfer',
|
|
169
176
|
'tab.tunnels': 'Tunnels',
|
|
170
177
|
'tab.cluster': 'Cluster',
|
|
178
|
+
'session.server': 'Current server',
|
|
179
|
+
'session.target': 'Following current session: {alias} · {remoteRoot}',
|
|
180
|
+
'session.localTitle': 'This session uses a local workspace',
|
|
181
|
+
'session.localUnavailable': 'The SSH console is available only for SSH workspace sessions. Switch to a remote session to use it.',
|
|
182
|
+
'connectionError.title': 'Unable to connect to {alias}',
|
|
183
|
+
'connectionError.intro': 'The SSH connection was not established. Check the server address, network, and authentication settings.',
|
|
184
|
+
'connectionError.close': 'OK',
|
|
171
185
|
'hosts.empty': 'No hosts configured. Click "Add host" or import from ~/.ssh/config.',
|
|
172
186
|
'hosts.add': 'Add host',
|
|
173
187
|
'hosts.import': 'Import ~/.ssh/config',
|
|
@@ -222,7 +236,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
222
236
|
'terminal.selectHost': 'Select host',
|
|
223
237
|
'terminal.connect': 'Connect',
|
|
224
238
|
'terminal.disconnect': 'Disconnect',
|
|
225
|
-
'terminal.placeholder': '
|
|
239
|
+
'terminal.placeholder': 'Click "Connect" to open a terminal on the current session server.',
|
|
226
240
|
'terminal.connecting': 'Connecting…',
|
|
227
241
|
'terminal.ready': 'Terminal connected ({alias})',
|
|
228
242
|
'terminal.exited': 'Terminal exited ({alias})',
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSH operations surface as a RIGHT-Sidebar page tab.
|
|
3
|
+
*
|
|
4
|
+
* Registration follows the same public two-stage path any shipped tab type
|
|
5
|
+
* uses:
|
|
6
|
+
* 1. `ctx.sidebarRightTabs.register(...)` declares the type — its `id` is the
|
|
7
|
+
* key its body and chip title register under, its `kind` is what
|
|
8
|
+
* `ctx.sidebarRight.openTab(kind)` names.
|
|
9
|
+
* 2. the body goes into the keyed `sidebar.right.pane.tab` seat, the chip title
|
|
10
|
+
* into `sidebar.right.pane.tab.title`, both under that `id`.
|
|
11
|
+
*
|
|
12
|
+
* It is a PAGE type (no `patterns`): it recognizes no resource address, so it is
|
|
13
|
+
* opened by kind from the right sidebar's own add control or from its guide
|
|
14
|
+
* entry — the plugin never forces the column open.
|
|
15
|
+
*
|
|
16
|
+
* The left sidebar's workspace manager is a separate, GLOBAL surface; host
|
|
17
|
+
* CRUD lives there, so this console keeps only the per-session operations:
|
|
18
|
+
* terminal, transfer, tunnels, cluster.
|
|
19
|
+
*/
|
|
20
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
21
|
+
// Type-only: pulls the SidebarRightTabDefinition type and the `sidebar.right.*`
|
|
22
|
+
// SlotMap augmentations.
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
|
24
|
+
// Type-only: pulls the LocaleNamespaceMap augmentation.
|
|
25
|
+
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
26
|
+
import { tt } from '../text.ts'
|
|
27
|
+
import { SshPanel } from './panel/SshPanel.tsx'
|
|
28
|
+
import type { SshApi } from './api.ts'
|
|
29
|
+
import type { SessionSshTargetSource } from './session-target.ts'
|
|
30
|
+
|
|
31
|
+
/** This implementation's identity in the tab system; also the body/title key. */
|
|
32
|
+
export const OPS_TAB_ID = '@tiphareth/dsh-hardssh/operations'
|
|
33
|
+
|
|
34
|
+
/** The page kind `ctx.sidebarRight.openTab(kind)` names. */
|
|
35
|
+
export const OPS_TAB_KIND = 'hardssh-operations'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Register the regression type, its body, and its chip title.
|
|
39
|
+
* @param ctx - client root context carrying the tab registry and slot registry.
|
|
40
|
+
* @param api - the SSH API client the panel's tabs operate through.
|
|
41
|
+
* @param target - live target inherited from the selected Session.
|
|
42
|
+
*/
|
|
43
|
+
export function registerOperationsTab(
|
|
44
|
+
ctx: ClientContext,
|
|
45
|
+
api: SshApi,
|
|
46
|
+
target: SessionSshTargetSource,
|
|
47
|
+
): void {
|
|
48
|
+
ctx.effect(() => ctx.sidebarRightTabs.register({
|
|
49
|
+
id: OPS_TAB_ID,
|
|
50
|
+
kind: OPS_TAB_KIND,
|
|
51
|
+
// The chip's initial text is captured into the layout record at open time;
|
|
52
|
+
// a thunk keeps it following the active language without re-registering.
|
|
53
|
+
title: () => tt('ops.tab'),
|
|
54
|
+
// An entry box on the right sidebar's empty-state guide, so the surface is
|
|
55
|
+
// discoverable without a plugin-owned button.
|
|
56
|
+
guide: [{
|
|
57
|
+
order: 100,
|
|
58
|
+
title: () => tt('ops.tab'),
|
|
59
|
+
description: () => tt('ops.guide'),
|
|
60
|
+
}],
|
|
61
|
+
}), 'dsh-hardssh: operations tab type')
|
|
62
|
+
|
|
63
|
+
// The body: receives every tab of this kind, in every pane, docked or floating.
|
|
64
|
+
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
|
|
65
|
+
{ name: 'sidebar.right.pane.tab', key: OPS_TAB_ID, inject: () => ({ api, target }) },
|
|
66
|
+
SshPanel,
|
|
67
|
+
))
|
|
68
|
+
|
|
69
|
+
// The chip title in the strip and a floating panel's header.
|
|
70
|
+
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
|
|
71
|
+
{ name: 'sidebar.right.pane.tab.title', key: OPS_TAB_ID },
|
|
72
|
+
OpsTabTitle,
|
|
73
|
+
))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The tab chip and floating-panel header text. */
|
|
77
|
+
function OpsTabTitle(): string {
|
|
78
|
+
return tt('ops.tab')
|
|
79
|
+
}
|