@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.
- package/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17780 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +134 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +96 -0
- package/src/client/workspace-gate.ts +225 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- 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
|
+
}
|