dsh-remote-vs 0.0.0-stage → 0.8.3
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 +21 -0
- package/README.en.md +385 -0
- package/README.md +347 -2
- package/cordis.patch.yml +259 -0
- package/icon.svg +7 -0
- package/lib/client.js +62602 -0
- package/lib/index.js +4287 -0
- package/lib/p2-bridge-refs.js +10 -0
- package/lib/p2-bridge.js +16 -0
- package/lib/p2-router-fs.js +252 -0
- package/lib/p2-router-refs.js +466 -0
- package/lib/p2-router-spill.js +62 -0
- package/lib/p2-router-subprocess.js +64 -0
- package/lib/p2-shell-bash.js +24 -0
- package/lib/ssh-proc-arI5lm4W.js +632 -0
- package/lib/types/changes/recorder.d.ts +240 -0
- package/lib/types/changes/status.d.ts +201 -0
- package/lib/types/changes/text-diff.d.ts +65 -0
- package/lib/types/client/CodeEditor.d.ts +125 -0
- package/lib/types/client/Dialog.d.ts +14 -0
- package/lib/types/client/DirectoryFlow.d.ts +30 -0
- package/lib/types/client/EditorPane.d.ts +30 -0
- package/lib/types/client/HostsView.d.ts +10 -0
- package/lib/types/client/NativeExplorer.d.ts +39 -0
- package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
- package/lib/types/client/RemoteEditorView.d.ts +20 -0
- package/lib/types/client/RemoteExplorer.d.ts +18 -0
- package/lib/types/client/RemoteRoot.d.ts +16 -0
- package/lib/types/client/SearchView.d.ts +14 -0
- package/lib/types/client/SettingsHosts.d.ts +15 -0
- package/lib/types/client/TerminalView.d.ts +16 -0
- package/lib/types/client/WorkspaceWizard.d.ts +16 -0
- package/lib/types/client/WorkspacesView.d.ts +14 -0
- package/lib/types/client/XTerm.d.ts +15 -0
- package/lib/types/client/api.d.ts +162 -0
- package/lib/types/client/changes/ChangesDock.d.ts +35 -0
- package/lib/types/client/changes/model.d.ts +36 -0
- package/lib/types/client/changes/undo-affordance.d.ts +48 -0
- package/lib/types/client/changes/undo-context.d.ts +25 -0
- package/lib/types/client/composer.d.ts +165 -0
- package/lib/types/client/i18n.d.ts +17 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/remote-file-open-target.d.ts +247 -0
- package/lib/types/client/remote-file-open.d.ts +35 -0
- package/lib/types/client/reveal.d.ts +15 -0
- package/lib/types/client/scm/DiffView.d.ts +24 -0
- package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
- package/lib/types/client/scm/RepoPicker.d.ts +24 -0
- package/lib/types/client/scm/ScmPanel.d.ts +15 -0
- package/lib/types/client/scm/actions.d.ts +170 -0
- package/lib/types/client/scm/diff-model.d.ts +29 -0
- package/lib/types/client/scm/stage-queue.d.ts +99 -0
- package/lib/types/client/scm/status-map.d.ts +157 -0
- package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
- package/lib/types/client/selection-popup.d.ts +61 -0
- package/lib/types/client/store.d.ts +437 -0
- package/lib/types/client/theme.d.ts +15 -0
- package/lib/types/client/ui.d.ts +46 -0
- package/lib/types/client/upload-plan.d.ts +78 -0
- package/lib/types/client/upload-source.d.ts +46 -0
- package/lib/types/client/use-remote-chat-target.d.ts +17 -0
- package/lib/types/client/vsc/FindWidget.d.ts +81 -0
- package/lib/types/client/vsc/change-marks.d.ts +150 -0
- package/lib/types/client/vsc/editor-langs.d.ts +5 -0
- package/lib/types/client/vsc/editor-palette.d.ts +37 -0
- package/lib/types/client/vsc/editor-theme.d.ts +32 -0
- package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
- package/lib/types/client/vsc/kit.d.ts +195 -0
- package/lib/types/client/vsc/markdown.d.ts +11 -0
- package/lib/types/client/vsc/reveal-line.d.ts +38 -0
- package/lib/types/client/vsc/tokenize.d.ts +104 -0
- package/lib/types/deploy.d.ts +94 -0
- package/lib/types/fs-ops.d.ts +8 -0
- package/lib/types/git-format.d.ts +34 -0
- package/lib/types/git-ops.d.ts +183 -0
- package/lib/types/http.d.ts +14 -0
- package/lib/types/importers.d.ts +20 -0
- package/lib/types/index.d.ts +87 -0
- package/lib/types/keygen.d.ts +19 -0
- package/lib/types/loopback.d.ts +7 -0
- package/lib/types/p2/bridge-refs.d.ts +21 -0
- package/lib/types/p2/bridge.d.ts +24 -0
- package/lib/types/p2/file-refs.d.ts +73 -0
- package/lib/types/p2/helper-rpc.d.ts +63 -0
- package/lib/types/p2/in-flight-gate.d.ts +25 -0
- package/lib/types/p2/router-fs.d.ts +92 -0
- package/lib/types/p2/router-refs.d.ts +45 -0
- package/lib/types/p2/router-spill.d.ts +11 -0
- package/lib/types/p2/router-subprocess.d.ts +23 -0
- package/lib/types/p2/shell-bash.d.ts +16 -0
- package/lib/types/p2/ssh-proc.d.ts +22 -0
- package/lib/types/p2/worlds.d.ts +179 -0
- package/lib/types/paths.d.ts +12 -0
- package/lib/types/pool.d.ts +105 -0
- package/lib/types/protocol.d.ts +452 -0
- package/lib/types/routes.d.ts +27 -0
- package/lib/types/search-ops.d.ts +68 -0
- package/lib/types/sftp.d.ts +90 -0
- package/lib/types/store.d.ts +66 -0
- package/lib/types/terminal.d.ts +51 -0
- package/lib/types/timeouts.d.ts +83 -0
- package/lib/types/tools.d.ts +41 -0
- package/lib/worlds-PUumRIh7.js +18765 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +189 -4
- package/src/changes/recorder.ts +902 -0
- package/src/changes/status.ts +448 -0
- package/src/changes/text-diff.ts +377 -0
- package/src/client/CodeEditor.tsx +923 -0
- package/src/client/Dialog.tsx +76 -0
- package/src/client/DirectoryFlow.tsx +431 -0
- package/src/client/EditorPane.tsx +512 -0
- package/src/client/HostsView.tsx +312 -0
- package/src/client/NativeExplorer.tsx +121 -0
- package/src/client/RemoteDirectoryPicker.tsx +157 -0
- package/src/client/RemoteEditorView.tsx +54 -0
- package/src/client/RemoteExplorer.tsx +1398 -0
- package/src/client/RemoteRoot.tsx +389 -0
- package/src/client/SearchView.tsx +407 -0
- package/src/client/SettingsHosts.tsx +136 -0
- package/src/client/TerminalView.tsx +308 -0
- package/src/client/WorkspaceWizard.tsx +199 -0
- package/src/client/WorkspacesView.tsx +93 -0
- package/src/client/XTerm.tsx +308 -0
- package/src/client/api.ts +331 -0
- package/src/client/changes/ChangesDock.tsx +348 -0
- package/src/client/changes/changes.css +230 -0
- package/src/client/changes/model.ts +347 -0
- package/src/client/changes/undo-affordance.ts +389 -0
- package/src/client/changes/undo-context.ts +34 -0
- package/src/client/composer.ts +436 -0
- package/src/client/css.d.ts +7 -0
- package/src/client/i18n.ts +702 -0
- package/src/client/index.tsx +286 -0
- package/src/client/remote-file-open-target.ts +553 -0
- package/src/client/remote-file-open.ts +261 -0
- package/src/client/reveal.ts +33 -0
- package/src/client/scm/DiffView.tsx +570 -0
- package/src/client/scm/HistoryPanel.tsx +353 -0
- package/src/client/scm/RepoPicker.tsx +238 -0
- package/src/client/scm/ScmPanel.tsx +857 -0
- package/src/client/scm/actions.ts +460 -0
- package/src/client/scm/diff-model.ts +206 -0
- package/src/client/scm/stage-queue.ts +264 -0
- package/src/client/scm/status-map.ts +381 -0
- package/src/client/scm/untracked-dirs.ts +139 -0
- package/src/client/selection-popup.ts +157 -0
- package/src/client/store.tsx +1670 -0
- package/src/client/theme.ts +54 -0
- package/src/client/ui.tsx +136 -0
- package/src/client/upload-plan.ts +153 -0
- package/src/client/upload-source.ts +145 -0
- package/src/client/use-remote-chat-target.ts +153 -0
- package/src/client/vsc/FindWidget.tsx +446 -0
- package/src/client/vsc/change-marks.ts +586 -0
- package/src/client/vsc/editor-langs.ts +137 -0
- package/src/client/vsc/editor-palette.ts +107 -0
- package/src/client/vsc/editor-theme.ts +93 -0
- package/src/client/vsc/fallback-highlight.ts +145 -0
- package/src/client/vsc/kit.tsx +777 -0
- package/src/client/vsc/markdown.tsx +172 -0
- package/src/client/vsc/reveal-line.ts +142 -0
- package/src/client/vsc/tokenize.ts +569 -0
- package/src/client/vsc/vsc.css +2669 -0
- package/src/deploy.ts +481 -0
- package/src/fs-ops.ts +57 -0
- package/src/git-format.ts +205 -0
- package/src/git-ops.ts +693 -0
- package/src/http.ts +55 -0
- package/src/importers.ts +141 -0
- package/src/index.ts +442 -0
- package/src/keygen.ts +77 -0
- package/src/loopback.ts +47 -0
- package/src/p2/bridge-refs.ts +27 -0
- package/src/p2/bridge.ts +32 -0
- package/src/p2/file-refs.ts +392 -0
- package/src/p2/helper-rpc.ts +394 -0
- package/src/p2/in-flight-gate.ts +63 -0
- package/src/p2/router-fs.ts +327 -0
- package/src/p2/router-refs.ts +166 -0
- package/src/p2/router-spill.ts +75 -0
- package/src/p2/router-subprocess.ts +92 -0
- package/src/p2/shell-bash.ts +30 -0
- package/src/p2/ssh-proc.ts +802 -0
- package/src/p2/worlds.ts +735 -0
- package/src/paths.ts +50 -0
- package/src/pool.ts +811 -0
- package/src/protocol.ts +460 -0
- package/src/routes.ts +802 -0
- package/src/search-ops.ts +220 -0
- package/src/sftp.ts +677 -0
- package/src/store.ts +364 -0
- package/src/terminal.ts +291 -0
- package/src/timeouts.ts +122 -0
- package/src/tools.ts +320 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
- package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable store for dsh-remote-vs: hosts + workspace bindings in
|
|
3
|
+
* $DSH_HOME/dsh-remote.json (0600, atomic tmp+rename writes).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, statSync, openSync, readFileSync, renameSync, writeSync, closeSync } from 'node:fs'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { aliasRoot, keysDir, storePath } from './paths.ts'
|
|
9
|
+
import type {
|
|
10
|
+
RemoteDeployState,
|
|
11
|
+
RemoteHostEntry,
|
|
12
|
+
RemoteHostSummary,
|
|
13
|
+
RemoteWorkspaceBinding,
|
|
14
|
+
} from './protocol.ts'
|
|
15
|
+
|
|
16
|
+
/** On-disk document shape. */
|
|
17
|
+
interface StoreDoc {
|
|
18
|
+
version: 1
|
|
19
|
+
hosts: RemoteHostEntry[]
|
|
20
|
+
workspaces: RemoteWorkspaceBinding[]
|
|
21
|
+
/** Removed alias dirs (fail-closed tombstones; never auto-deleted). */
|
|
22
|
+
tombstones: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Empty document. */
|
|
26
|
+
function emptyDoc(): StoreDoc {
|
|
27
|
+
return { version: 1, hosts: [], workspaces: [], tombstones: [] }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Identity of the store file at one point in time; `null` = the file is absent. */
|
|
31
|
+
type FileStamp = { mtimeMs: number; size: number } | null
|
|
32
|
+
|
|
33
|
+
/** Same file identity (both absent counts as same). */
|
|
34
|
+
function sameStamp(left: FileStamp, right: FileStamp): boolean {
|
|
35
|
+
if (left === null || right === null) return left === right
|
|
36
|
+
return left.mtimeMs === right.mtimeMs && left.size === right.size
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Alias grammar: 1-64 chars of letters/digits/._- */
|
|
40
|
+
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
41
|
+
|
|
42
|
+
function assertAlias(alias: unknown): asserts alias is string {
|
|
43
|
+
if (typeof alias !== 'string' || !ALIAS_RE.test(alias)) {
|
|
44
|
+
throw new Error('alias must match [A-Za-z0-9._-]{1,64} and start with an alphanumeric')
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Payload accepted by create/update (secrets included, host-side only). */
|
|
49
|
+
export interface HostPayload {
|
|
50
|
+
alias: string
|
|
51
|
+
host: string
|
|
52
|
+
port?: number
|
|
53
|
+
user: string
|
|
54
|
+
auth: { kind: 'key' | 'password'; keyPath?: string; passphrase?: string; password?: string }
|
|
55
|
+
description?: string
|
|
56
|
+
tags?: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface WorkspacePayload {
|
|
60
|
+
name: string
|
|
61
|
+
alias: string
|
|
62
|
+
remotePath: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class RemoteStore {
|
|
66
|
+
private readonly file: string
|
|
67
|
+
private doc: StoreDoc
|
|
68
|
+
/** File identity when `doc` was read from disk (undefined = `doc` is not known fresh). */
|
|
69
|
+
private stamp: FileStamp | undefined
|
|
70
|
+
|
|
71
|
+
constructor(file?: string) {
|
|
72
|
+
this.file = file ?? storePath()
|
|
73
|
+
// `doc` starts empty and `reload()` fills it: `stamp` is undefined, so the
|
|
74
|
+
// first read always takes the real path (missing file -> empty doc).
|
|
75
|
+
this.doc = emptyDoc()
|
|
76
|
+
this.reload()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reload from disk (picks up external edits).
|
|
81
|
+
*
|
|
82
|
+
* Re-reading on every access is deliberate: the root fs router asks this store
|
|
83
|
+
* 2-4 times per routed file operation, and an edit made by another process
|
|
84
|
+
* must be visible without a restart. A stat is orders of magnitude cheaper
|
|
85
|
+
* than read + parse, so the file's identity (mtime + size) decides instead —
|
|
86
|
+
* unchanged means the cached doc is already the file's content. The stamp is
|
|
87
|
+
* taken BEFORE the read: a write landing in between leaves the older stamp
|
|
88
|
+
* behind, so the next access re-reads rather than trusting a stale doc. The
|
|
89
|
+
* one edit this cannot see is a same-length rewrite inside the filesystem's
|
|
90
|
+
* mtime granularity (sub-ms on NTFS/ext4).
|
|
91
|
+
*/
|
|
92
|
+
reload(): void {
|
|
93
|
+
const stamp = this.stampOf()
|
|
94
|
+
if (stamp !== undefined && this.stamp !== undefined && sameStamp(stamp, this.stamp)) return
|
|
95
|
+
this.doc = this.loadDoc()
|
|
96
|
+
this.stamp = stamp
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* File identity right now: `null` when the file is absent, `undefined` when it
|
|
101
|
+
* cannot be statted at all. Both cases fall through to a full load, so the
|
|
102
|
+
* missing-file and unreadable-file paths behave exactly as they did before.
|
|
103
|
+
*/
|
|
104
|
+
private stampOf(): FileStamp | undefined {
|
|
105
|
+
try {
|
|
106
|
+
const stat = statSync(this.file, { throwIfNoEntry: false })
|
|
107
|
+
return stat === undefined ? null : { mtimeMs: stat.mtimeMs, size: stat.size }
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private loadDoc(): StoreDoc {
|
|
114
|
+
if (!existsSync(this.file)) return emptyDoc()
|
|
115
|
+
let parsed: unknown
|
|
116
|
+
try {
|
|
117
|
+
parsed = JSON.parse(readFileSync(this.file, 'utf8'))
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new Error(`cannot parse ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
|
|
120
|
+
}
|
|
121
|
+
if (typeof parsed !== 'object' || parsed === null) throw new Error(`${this.file}: root must be an object`)
|
|
122
|
+
const doc = parsed as Partial<StoreDoc>
|
|
123
|
+
if (doc.version !== 1) throw new Error(`${this.file}: unsupported version ${(doc as { version?: unknown }).version}`)
|
|
124
|
+
if (!Array.isArray(doc.hosts) || !Array.isArray(doc.workspaces)) {
|
|
125
|
+
throw new Error(`${this.file}: hosts/workspaces must be arrays`)
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
version: 1,
|
|
129
|
+
hosts: doc.hosts,
|
|
130
|
+
workspaces: doc.workspaces,
|
|
131
|
+
tombstones: Array.isArray(doc.tombstones) ? doc.tombstones.filter(t => typeof t === 'string') : [],
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private save(): void {
|
|
136
|
+
// The caller already mutated `doc`, so in-memory state is ahead of the file:
|
|
137
|
+
// invalidate the stamp. A failed write must not pin the dirty doc in place
|
|
138
|
+
// (the next read goes back to the file, as it always did), and a successful
|
|
139
|
+
// one is picked up by that same re-read, which re-arms the fast path.
|
|
140
|
+
this.stamp = undefined
|
|
141
|
+
const payload = JSON.stringify(this.doc, null, 2)
|
|
142
|
+
const tmp = `${this.file}.${process.pid}.tmp`
|
|
143
|
+
// 0600 from creation (Windows: best effort, mode is advisory there).
|
|
144
|
+
const fd = openSync(tmp, 'w', 0o600)
|
|
145
|
+
try {
|
|
146
|
+
writeSync(fd, payload)
|
|
147
|
+
} finally {
|
|
148
|
+
closeSync(fd)
|
|
149
|
+
}
|
|
150
|
+
renameSync(tmp, this.file)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ------------------------------------------------------------- hosts
|
|
154
|
+
// NOTE: reads revalidate the file first (one stat; the file is tiny and a
|
|
155
|
+
// read + parse only happens when its identity changed), so routers + routes +
|
|
156
|
+
// GUI still see fresh state without a watcher.
|
|
157
|
+
|
|
158
|
+
list(): RemoteHostEntry[] {
|
|
159
|
+
this.reload()
|
|
160
|
+
return [...this.doc.hosts]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get(alias: string): RemoteHostEntry | undefined {
|
|
164
|
+
this.reload()
|
|
165
|
+
return this.doc.hosts.find(h => h.alias === alias)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
require(alias: string): RemoteHostEntry {
|
|
169
|
+
const entry = this.get(alias)
|
|
170
|
+
if (entry === undefined) throw new Error(`unknown host alias: ${alias}`)
|
|
171
|
+
return entry
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
create(payload: HostPayload): RemoteHostEntry {
|
|
175
|
+
assertAlias(payload.alias)
|
|
176
|
+
if (this.get(payload.alias) !== undefined) throw new Error(`alias already exists: ${payload.alias}`)
|
|
177
|
+
const entry = this.normalize(payload, undefined)
|
|
178
|
+
this.doc.hosts.push(entry)
|
|
179
|
+
this.save()
|
|
180
|
+
return entry
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
update(alias: string, patch: Partial<Omit<HostPayload, 'alias'>>): RemoteHostEntry {
|
|
184
|
+
const current = this.require(alias)
|
|
185
|
+
// Empty auth materials mean "keep the stored one" (the browser never
|
|
186
|
+
// echoes secrets back, so an untouched field arrives empty/omitted).
|
|
187
|
+
let auth = patch.auth ?? current.auth
|
|
188
|
+
if (patch.auth !== undefined) {
|
|
189
|
+
if (patch.auth.kind === 'key') {
|
|
190
|
+
const keyPath = (patch.auth.keyPath ?? '').trim()
|
|
191
|
+
auth = {
|
|
192
|
+
kind: 'key',
|
|
193
|
+
keyPath: keyPath !== ''
|
|
194
|
+
? keyPath
|
|
195
|
+
: current.auth.kind === 'key'
|
|
196
|
+
? (current.auth.keyPath as string)
|
|
197
|
+
: '',
|
|
198
|
+
...(patch.auth.passphrase !== undefined
|
|
199
|
+
? { passphrase: patch.auth.passphrase }
|
|
200
|
+
: current.auth.kind === 'key' && current.auth.passphrase !== undefined
|
|
201
|
+
? { passphrase: current.auth.passphrase }
|
|
202
|
+
: {}),
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
const password = patch.auth.password ?? ''
|
|
206
|
+
auth = {
|
|
207
|
+
kind: 'password',
|
|
208
|
+
password: password !== ''
|
|
209
|
+
? password
|
|
210
|
+
: current.auth.kind === 'password'
|
|
211
|
+
? (current.auth.password as string)
|
|
212
|
+
: '',
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const merged: HostPayload = {
|
|
217
|
+
alias,
|
|
218
|
+
host: patch.host ?? current.host,
|
|
219
|
+
port: patch.port ?? current.port,
|
|
220
|
+
user: patch.user ?? current.user,
|
|
221
|
+
auth,
|
|
222
|
+
description: patch.description ?? current.description,
|
|
223
|
+
tags: patch.tags ?? current.tags,
|
|
224
|
+
}
|
|
225
|
+
const next = this.normalize(merged, current)
|
|
226
|
+
const index = this.doc.hosts.findIndex(h => h.alias === alias)
|
|
227
|
+
this.doc.hosts[index] = next
|
|
228
|
+
this.save()
|
|
229
|
+
return next
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
remove(alias: string): boolean {
|
|
233
|
+
const before = this.doc.hosts.length
|
|
234
|
+
this.doc.hosts = this.doc.hosts.filter(h => h.alias !== alias)
|
|
235
|
+
this.doc.workspaces = this.doc.workspaces.filter(w => w.alias !== alias)
|
|
236
|
+
if (this.doc.hosts.length === before) return false
|
|
237
|
+
this.save()
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private normalize(payload: HostPayload, current: RemoteHostEntry | undefined): RemoteHostEntry {
|
|
242
|
+
if (typeof payload.host !== 'string' || payload.host.trim() === '') throw new Error('host must be a non-empty string')
|
|
243
|
+
const port = payload.port ?? 22
|
|
244
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('port must be an integer 1-65535')
|
|
245
|
+
if (typeof payload.user !== 'string' || payload.user.trim() === '') throw new Error('user must be a non-empty string')
|
|
246
|
+
const auth = payload.auth
|
|
247
|
+
if (auth === undefined || (auth.kind !== 'key' && auth.kind !== 'password')) {
|
|
248
|
+
throw new Error('auth.kind must be key or password')
|
|
249
|
+
}
|
|
250
|
+
if (auth.kind === 'key') {
|
|
251
|
+
if (typeof auth.keyPath !== 'string' || auth.keyPath.trim() === '') throw new Error('auth.keyPath is required for key auth')
|
|
252
|
+
if (auth.passphrase !== undefined && typeof auth.passphrase !== 'string') throw new Error('auth.passphrase must be a string when provided')
|
|
253
|
+
} else {
|
|
254
|
+
if (typeof auth.password !== 'string' || auth.password === '') throw new Error('auth.password must be a non-empty string for password auth')
|
|
255
|
+
}
|
|
256
|
+
if (payload.description !== undefined && typeof payload.description !== 'string') throw new Error('description must be a string when provided')
|
|
257
|
+
if (payload.tags !== undefined && (!Array.isArray(payload.tags) || payload.tags.some(t => typeof t !== 'string'))) {
|
|
258
|
+
throw new Error('tags must be string[] when provided')
|
|
259
|
+
}
|
|
260
|
+
const now = Date.now()
|
|
261
|
+
return {
|
|
262
|
+
alias: payload.alias,
|
|
263
|
+
host: payload.host.trim(),
|
|
264
|
+
port,
|
|
265
|
+
user: payload.user.trim(),
|
|
266
|
+
auth: {
|
|
267
|
+
kind: auth.kind,
|
|
268
|
+
...(auth.keyPath !== undefined ? { keyPath: auth.keyPath } : {}),
|
|
269
|
+
...(auth.passphrase !== undefined ? { passphrase: auth.passphrase } : {}),
|
|
270
|
+
...(auth.password !== undefined ? { password: auth.password } : {}),
|
|
271
|
+
},
|
|
272
|
+
...(payload.description !== undefined ? { description: payload.description } : {}),
|
|
273
|
+
tags: payload.tags ?? [],
|
|
274
|
+
...(current?.deploy !== undefined ? { deploy: current.deploy } : {}),
|
|
275
|
+
createdAt: current?.createdAt ?? now,
|
|
276
|
+
updatedAt: now,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Secret-free projection for browser/agent surfaces. */
|
|
281
|
+
summarize(entry: RemoteHostEntry): RemoteHostSummary {
|
|
282
|
+
let keyReady = false
|
|
283
|
+
if (entry.auth.kind === 'key' && entry.auth.keyPath !== undefined) {
|
|
284
|
+
try {
|
|
285
|
+
keyReady = existsSync(entry.auth.keyPath)
|
|
286
|
+
} catch {
|
|
287
|
+
keyReady = false
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
alias: entry.alias,
|
|
292
|
+
host: entry.host,
|
|
293
|
+
port: entry.port,
|
|
294
|
+
user: entry.user,
|
|
295
|
+
auth: entry.auth.kind,
|
|
296
|
+
keyReady,
|
|
297
|
+
managedKey: entry.auth.kind === 'key'
|
|
298
|
+
&& entry.auth.keyPath !== undefined
|
|
299
|
+
&& entry.auth.keyPath.startsWith(keysDir() + (process.platform === 'win32' ? '\\' : '/')),
|
|
300
|
+
...(entry.description !== undefined ? { description: entry.description } : {}),
|
|
301
|
+
tags: [...entry.tags],
|
|
302
|
+
...(entry.deploy !== undefined ? { deploy: entry.deploy } : {}),
|
|
303
|
+
createdAt: entry.createdAt,
|
|
304
|
+
updatedAt: entry.updatedAt,
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
setDeploy(alias: string, state: RemoteDeployState): void {
|
|
309
|
+
const entry = this.require(alias)
|
|
310
|
+
entry.deploy = state
|
|
311
|
+
entry.updatedAt = Date.now()
|
|
312
|
+
this.save()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// -------------------------------------------------------- workspaces
|
|
316
|
+
|
|
317
|
+
listWorkspaces(): RemoteWorkspaceBinding[] {
|
|
318
|
+
this.reload()
|
|
319
|
+
return [...this.doc.workspaces]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
createWorkspace(payload: WorkspacePayload): RemoteWorkspaceBinding {
|
|
323
|
+
if (typeof payload.name !== 'string' || payload.name.trim() === '') throw new Error('name must be a non-empty string')
|
|
324
|
+
this.require(payload.alias)
|
|
325
|
+
if (typeof payload.remotePath !== 'string' || !payload.remotePath.startsWith('/')) {
|
|
326
|
+
throw new Error('remotePath must be an absolute POSIX path')
|
|
327
|
+
}
|
|
328
|
+
const binding: RemoteWorkspaceBinding = {
|
|
329
|
+
id: randomUUID().slice(0, 8),
|
|
330
|
+
name: payload.name.trim(),
|
|
331
|
+
alias: payload.alias,
|
|
332
|
+
remotePath: payload.remotePath,
|
|
333
|
+
createdAt: Date.now(),
|
|
334
|
+
}
|
|
335
|
+
this.doc.workspaces.push(binding)
|
|
336
|
+
this.save()
|
|
337
|
+
return binding
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
removeWorkspace(id: string): boolean {
|
|
341
|
+
this.reload()
|
|
342
|
+
const victim = this.doc.workspaces.find(w => w.id === id)
|
|
343
|
+
if (victim === undefined) return false
|
|
344
|
+
// Tombstone the alias BEFORE dropping the binding (history stays readable,
|
|
345
|
+
// new tool calls fail closed instead of silently running locally).
|
|
346
|
+
this.addTombstone(join(aliasRoot(), id))
|
|
347
|
+
this.doc.workspaces = this.doc.workspaces.filter(w => w.id !== id)
|
|
348
|
+
this.save()
|
|
349
|
+
return true
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Record a removed alias path (idempotent). */
|
|
353
|
+
addTombstone(aliasPath: string): void {
|
|
354
|
+
if (!this.doc.tombstones.includes(aliasPath)) {
|
|
355
|
+
this.doc.tombstones.push(aliasPath)
|
|
356
|
+
this.save()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
listTombstones(): string[] {
|
|
361
|
+
this.reload()
|
|
362
|
+
return [...this.doc.tombstones]
|
|
363
|
+
}
|
|
364
|
+
}
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote PTY terminal sessions: one ssh2 shell per session, surviving socket
|
|
3
|
+
* detaches (reattach by id). Frames are JSON text (see protocol.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import type { WebSocket } from 'ws'
|
|
7
|
+
import type { ClientChannel } from 'ssh2'
|
|
8
|
+
import type { RemotePool } from './pool.ts'
|
|
9
|
+
import type { TerminalClientFrame, TerminalServerFrame } from './protocol.ts'
|
|
10
|
+
|
|
11
|
+
interface Session {
|
|
12
|
+
id: string
|
|
13
|
+
alias: string
|
|
14
|
+
channel: ClientChannel
|
|
15
|
+
alive: boolean
|
|
16
|
+
exitCode: number | null
|
|
17
|
+
sockets: Set<WebSocket>
|
|
18
|
+
/** Bytes handed to sockets that have not flushed yet (see broadcast). */
|
|
19
|
+
pending: number
|
|
20
|
+
/** True while the channel is paused because a socket fell behind. */
|
|
21
|
+
paused: boolean
|
|
22
|
+
/** Fires once the last socket has been gone for EMPTY_SOCKET_TTL_MS. */
|
|
23
|
+
reap: ReturnType<typeof setTimeout> | undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A session with no attached socket is kept this long so that a page reload
|
|
28
|
+
* can reattach by id. Past it the session closes and the remote shell dies.
|
|
29
|
+
*
|
|
30
|
+
* Without this sweep nothing ever released the session: its `pool.hold` never
|
|
31
|
+
* returned to zero, so the connection could not idle out, and the remote sshd
|
|
32
|
+
* session slot stayed taken for the life of the host process. Ten minutes is
|
|
33
|
+
* far longer than a reload needs and far shorter than "until you restart DSH".
|
|
34
|
+
*/
|
|
35
|
+
const EMPTY_SOCKET_TTL_MS = 10 * 60_000
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Terminal output is a live stream with no natural end, so it needs a bound.
|
|
39
|
+
* When a socket falls this far behind, pause the channel — that pushes the
|
|
40
|
+
* brake all the way back to the remote command, instead of growing the host
|
|
41
|
+
* process. Resume once the socket has drained.
|
|
42
|
+
*
|
|
43
|
+
* The pause is real backpressure, not a local buffer: ssh2 only sends a window
|
|
44
|
+
* adjustment while the stream is flowing, so a paused channel lets the peer's
|
|
45
|
+
* send window drain to zero and the remote command blocks on its own write.
|
|
46
|
+
* The retained ceiling is therefore this mark plus ssh2's window (2 MiB) plus
|
|
47
|
+
* whatever is in flight — bounded, but not exactly 4 MiB.
|
|
48
|
+
*
|
|
49
|
+
* The counter is per session, not per socket: one lagging socket pauses the
|
|
50
|
+
* channel for every socket attached to that shell. That is deliberate — the
|
|
51
|
+
* alternative is letting the slowest consumer grow the host process.
|
|
52
|
+
*/
|
|
53
|
+
const OUT_HIGH_WATER = 4 * 1024 * 1024
|
|
54
|
+
const OUT_LOW_WATER = 1 * 1024 * 1024
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The largest client frame accepted, in bytes.
|
|
58
|
+
*
|
|
59
|
+
* The only frame with a payload is `in` — keystrokes, and pastes, which the
|
|
60
|
+
* client chunks to stay under this. The upgrade server (`routes.ts`) passes
|
|
61
|
+
* this value as ws's `maxPayload`, so an oversized frame is refused before it
|
|
62
|
+
* is buffered at all; the check in `hookSocket` is the second layer, and it is
|
|
63
|
+
* what keeps such a frame from becoming a string plus an object graph in the
|
|
64
|
+
* process that also runs the agent.
|
|
65
|
+
*/
|
|
66
|
+
export const TERMINAL_MAX_FRAME_BYTES = 1024 * 1024
|
|
67
|
+
|
|
68
|
+
export class TerminalSessionRegistry {
|
|
69
|
+
private readonly sessions = new Map<string, Session>()
|
|
70
|
+
|
|
71
|
+
constructor(private readonly pool: RemotePool) {}
|
|
72
|
+
|
|
73
|
+
/** Open a shell and attach the first socket. */
|
|
74
|
+
async open(ws: WebSocket, alias: string, cols: number, rows: number): Promise<string> {
|
|
75
|
+
const id = randomUUID().slice(0, 8)
|
|
76
|
+
let channel: ClientChannel
|
|
77
|
+
try {
|
|
78
|
+
channel = await this.pool.shell(alias, { cols, rows })
|
|
79
|
+
} catch (error) {
|
|
80
|
+
this.send(ws, { t: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
81
|
+
try {
|
|
82
|
+
ws.close(1011)
|
|
83
|
+
} catch {
|
|
84
|
+
// Ignore.
|
|
85
|
+
}
|
|
86
|
+
throw error
|
|
87
|
+
}
|
|
88
|
+
const session: Session = {
|
|
89
|
+
id,
|
|
90
|
+
alias,
|
|
91
|
+
channel,
|
|
92
|
+
alive: true,
|
|
93
|
+
exitCode: null,
|
|
94
|
+
sockets: new Set(),
|
|
95
|
+
pending: 0,
|
|
96
|
+
paused: false,
|
|
97
|
+
reap: undefined,
|
|
98
|
+
}
|
|
99
|
+
this.sessions.set(id, session)
|
|
100
|
+
channel.on('data', (chunk: Buffer) => {
|
|
101
|
+
this.broadcast(session, { t: 'out', d: chunk.toString('utf8') })
|
|
102
|
+
})
|
|
103
|
+
channel.stderr.on('data', (chunk: Buffer) => {
|
|
104
|
+
this.broadcast(session, { t: 'out', d: chunk.toString('utf8') })
|
|
105
|
+
})
|
|
106
|
+
channel.on('close', (code: number | null) => {
|
|
107
|
+
session.alive = false
|
|
108
|
+
session.exitCode = code
|
|
109
|
+
this.broadcast(session, { t: 'exit', code })
|
|
110
|
+
for (const socket of session.sockets) {
|
|
111
|
+
try {
|
|
112
|
+
socket.close(1000)
|
|
113
|
+
} catch {
|
|
114
|
+
// Ignore.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
this.hookSocket(session, ws)
|
|
119
|
+
this.send(ws, { t: 'ready', id })
|
|
120
|
+
return id
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Attach another socket to a live session; false when unknown/dead.
|
|
125
|
+
*
|
|
126
|
+
* The size in the URL is deliberately NOT applied here. It is the same
|
|
127
|
+
* fixed 80x24 the view uses to open a shell, and re-applying it on every
|
|
128
|
+
* reattach — which is what a page reload does — SHRANK a running shell:
|
|
129
|
+
* SIGWINCH, and a full-screen program re-laying itself out at 80 columns,
|
|
130
|
+
* until that tab became visible and refit. The session already has a size.
|
|
131
|
+
* The socket sends a real `resize` frame as soon as it can measure itself
|
|
132
|
+
* (`XTerm.pushSize`), which is the only place that knows how wide it is.
|
|
133
|
+
*
|
|
134
|
+
* The parameters stay because the upgrade handler owns the URL and passes
|
|
135
|
+
* them on both paths; they matter to `open` (the PTY's initial size) only.
|
|
136
|
+
*/
|
|
137
|
+
attach(ws: WebSocket, id: string, _cols: number, _rows: number): boolean {
|
|
138
|
+
const session = this.sessions.get(id)
|
|
139
|
+
if (session === undefined || !session.alive) return false
|
|
140
|
+
this.hookSocket(session, ws)
|
|
141
|
+
this.send(ws, { t: 'ready', id })
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Close one session (kills the remote shell). */
|
|
146
|
+
close(id: string): boolean {
|
|
147
|
+
const session = this.sessions.get(id)
|
|
148
|
+
if (session === undefined) return false
|
|
149
|
+
this.sessions.delete(id)
|
|
150
|
+
this.cancelReap(session)
|
|
151
|
+
try {
|
|
152
|
+
session.channel.close()
|
|
153
|
+
} catch {
|
|
154
|
+
// Ignore.
|
|
155
|
+
}
|
|
156
|
+
for (const socket of session.sockets) {
|
|
157
|
+
try {
|
|
158
|
+
socket.close(1000)
|
|
159
|
+
} catch {
|
|
160
|
+
// Ignore.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
session.sockets.clear()
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Close every session (plugin unload). */
|
|
168
|
+
dispose(): void {
|
|
169
|
+
for (const id of [...this.sessions.keys()]) this.close(id)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
list(): { id: string; alias: string; alive: boolean }[] {
|
|
173
|
+
return [...this.sessions.values()].map(s => ({ id: s.id, alias: s.alias, alive: s.alive }))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private hookSocket(session: Session, ws: WebSocket): void {
|
|
177
|
+
// A socket is back: the session is in use, so cancel any pending reap.
|
|
178
|
+
this.cancelReap(session)
|
|
179
|
+
session.sockets.add(ws)
|
|
180
|
+
ws.on('message', (raw) => {
|
|
181
|
+
// Refuse an oversized frame before parsing it — see
|
|
182
|
+
// TERMINAL_MAX_FRAME_BYTES. 1009 is the protocol's "message too big".
|
|
183
|
+
const bytes = Array.isArray(raw) ? raw.reduce((total, part) => total + part.byteLength, 0) : raw.byteLength
|
|
184
|
+
if (bytes > TERMINAL_MAX_FRAME_BYTES) {
|
|
185
|
+
try {
|
|
186
|
+
ws.close(1009)
|
|
187
|
+
} catch {
|
|
188
|
+
// Already gone.
|
|
189
|
+
}
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
let frame: TerminalClientFrame
|
|
193
|
+
try {
|
|
194
|
+
frame = JSON.parse(String(raw)) as TerminalClientFrame
|
|
195
|
+
} catch {
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (!session.alive) return
|
|
199
|
+
if (frame.t === 'in' && typeof frame.d === 'string') {
|
|
200
|
+
try {
|
|
201
|
+
session.channel.write(frame.d)
|
|
202
|
+
} catch {
|
|
203
|
+
// Ignore.
|
|
204
|
+
}
|
|
205
|
+
} else if (frame.t === 'resize' && Number.isFinite(frame.cols) && Number.isFinite(frame.rows)) {
|
|
206
|
+
try {
|
|
207
|
+
session.channel.setWindow(frame.rows, frame.cols, 0, 0)
|
|
208
|
+
} catch {
|
|
209
|
+
// Ignore.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
const detach = (): void => {
|
|
214
|
+
session.sockets.delete(ws)
|
|
215
|
+
if (session.sockets.size > 0) return
|
|
216
|
+
// Nobody is watching any more. Release the brake first — a paused channel
|
|
217
|
+
// would otherwise stay paused with no socket left to resume it — then
|
|
218
|
+
// start the clock that ends the session (and with it the remote shell).
|
|
219
|
+
session.pending = 0
|
|
220
|
+
this.setPaused(session, false)
|
|
221
|
+
this.armReap(session)
|
|
222
|
+
}
|
|
223
|
+
ws.on('close', detach)
|
|
224
|
+
ws.on('error', detach)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Keep the shell for a while (reload can reattach), then close it for good. */
|
|
228
|
+
private armReap(session: Session): void {
|
|
229
|
+
this.cancelReap(session)
|
|
230
|
+
const timer = setTimeout(() => {
|
|
231
|
+
session.reap = undefined
|
|
232
|
+
// A late detach can arm a timer for a session that close() already
|
|
233
|
+
// removed; never act on an entry that is no longer the registered one.
|
|
234
|
+
if (this.sessions.get(session.id) !== session) return
|
|
235
|
+
if (session.sockets.size === 0) this.close(session.id)
|
|
236
|
+
}, EMPTY_SOCKET_TTL_MS)
|
|
237
|
+
timer.unref?.()
|
|
238
|
+
session.reap = timer
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private cancelReap(session: Session): void {
|
|
242
|
+
if (session.reap === undefined) return
|
|
243
|
+
clearTimeout(session.reap)
|
|
244
|
+
session.reap = undefined
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private setPaused(session: Session, paused: boolean): void {
|
|
248
|
+
if (session.paused === paused) return
|
|
249
|
+
session.paused = paused
|
|
250
|
+
try {
|
|
251
|
+
if (paused) session.channel.pause()
|
|
252
|
+
else session.channel.resume()
|
|
253
|
+
} catch {
|
|
254
|
+
// The channel may already be gone; the flag still records the intent.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private send(ws: WebSocket, frame: TerminalServerFrame): void {
|
|
259
|
+
try {
|
|
260
|
+
ws.send(JSON.stringify(frame))
|
|
261
|
+
} catch {
|
|
262
|
+
// Socket raced away.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private broadcast(session: Session, frame: TerminalServerFrame): void {
|
|
267
|
+
const text = JSON.stringify(frame)
|
|
268
|
+
// Bytes, not UTF-16 code units: CJK and emoji output would otherwise be
|
|
269
|
+
// undercounted by up to 3x and the brake would come on too late.
|
|
270
|
+
const size = Buffer.byteLength(text, 'utf8')
|
|
271
|
+
for (const socket of session.sockets) {
|
|
272
|
+
let settled = false
|
|
273
|
+
// The send callback fires once the frame is on the wire. It is the only
|
|
274
|
+
// public signal for "this socket has caught up", and it avoids relying on
|
|
275
|
+
// ws internals (bufferedAmount does not report the socket write queue).
|
|
276
|
+
const done = (): void => {
|
|
277
|
+
if (settled) return
|
|
278
|
+
settled = true
|
|
279
|
+
session.pending = Math.max(0, session.pending - size)
|
|
280
|
+
if (session.pending <= OUT_LOW_WATER) this.setPaused(session, false)
|
|
281
|
+
}
|
|
282
|
+
session.pending += size
|
|
283
|
+
try {
|
|
284
|
+
socket.send(text, done)
|
|
285
|
+
} catch {
|
|
286
|
+
done()
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (session.pending > OUT_HIGH_WATER) this.setPaused(session, true)
|
|
290
|
+
}
|
|
291
|
+
}
|