dsh-remote 0.8.73 → 0.8.75
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.en.md +85 -0
- package/README.md +76 -5
- package/lib/client.js +19 -2
- package/lib/host-api.js +808 -0
- package/lib/index.js +692 -310
- package/lib/remote-shell.js +39 -1
- package/lib/tui/flow.js +130 -0
- package/lib/tui/format.js +332 -0
- package/lib/tui/i18n.js +344 -0
- package/lib/tui/index.js +322 -0
- package/lib/tui/provider.js +327 -0
- package/lib/tui/scene.js +996 -0
- package/lib/workspace-cleanup.js +337 -0
- package/package.json +1 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// Local-mirror workspace cleanup — the filesystem half of the TUI's
|
|
2
|
+
// "彻底删除工作区 / 只删除对话" actions.
|
|
3
|
+
//
|
|
4
|
+
// Two rules drive everything here:
|
|
5
|
+
// 1. NEVER delete outside `$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<name>`.
|
|
6
|
+
// The mirror root is the only tree this module accepts, the target must be
|
|
7
|
+
// exactly two levels below it, and its `.dsh-remote-meta.json` must parse —
|
|
8
|
+
// a symlinked or half-typed path is refused instead of guessed at.
|
|
9
|
+
// 2. Session logs live at `$DSH_HOME/sessions/<project-key(cwd)>/<encoded id>`
|
|
10
|
+
// (the layout of `@deepseek-ai/dsh-session-persistence-jsonl`). The key and
|
|
11
|
+
// segment encoders below mirror that package's `projectKey()` /
|
|
12
|
+
// `encodeSegment()` so this module can find the directories DSH itself
|
|
13
|
+
// writes. They are pure and covered by tests; if DSH ever changes the
|
|
14
|
+
// layout, those tests name the drift instead of silently deleting nothing.
|
|
15
|
+
//
|
|
16
|
+
// Deletion is intentionally synchronous: the caller runs it from a maintenance
|
|
17
|
+
// action (not a hot path), and it must finish before the UI reports success.
|
|
18
|
+
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, statSync } from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
|
|
22
|
+
/** Name of the per-mirror binding file (`ensureMirror()` writes it). */
|
|
23
|
+
export const MIRROR_META_FILE = '.dsh-remote-meta.json'
|
|
24
|
+
|
|
25
|
+
/** Encode an arbitrary string as one path segment (mirror of DSH's encoder).
|
|
26
|
+
*
|
|
27
|
+
* Safe code units stay literal; everything else (including `~`) becomes
|
|
28
|
+
* `~XXXX` (UTF-16 code unit, uppercase hex, at least four digits). `.`/`..`
|
|
29
|
+
* are special-cased so a segment can never traverse.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} raw - non-empty string (session id).
|
|
32
|
+
* @returns {string} a single filesystem-safe segment.
|
|
33
|
+
*/
|
|
34
|
+
export function encodeSegment(raw) {
|
|
35
|
+
const value = String(raw === undefined || raw === null ? '' : raw)
|
|
36
|
+
if (value.length === 0) throw new Error('cannot encode an empty path segment')
|
|
37
|
+
if (value === '.') return '~002E'
|
|
38
|
+
if (value === '..') return '~002E~002E'
|
|
39
|
+
let out = ''
|
|
40
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
41
|
+
const code = value.charCodeAt(i)
|
|
42
|
+
const ch = String.fromCharCode(code)
|
|
43
|
+
if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch
|
|
44
|
+
else out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Decode one `encodeSegment()` result back to the original string.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} segment - encoded path segment.
|
|
52
|
+
* @returns {string} the decoded value (unknown escapes are left verbatim).
|
|
53
|
+
*/
|
|
54
|
+
export function decodeSegment(segment) {
|
|
55
|
+
const value = String(segment === undefined || segment === null ? '' : segment)
|
|
56
|
+
let out = ''
|
|
57
|
+
for (let i = 0; i < value.length;) {
|
|
58
|
+
if (value[i] === '~' && /^[0-9A-Fa-f]{4}$/.test(value.slice(i + 1, i + 5))) {
|
|
59
|
+
out += String.fromCharCode(parseInt(value.slice(i + 1, i + 5), 16))
|
|
60
|
+
i += 5
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
out += value[i]
|
|
64
|
+
i += 1
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The project directory key DSH gives a session cwd (mirror of its encoder).
|
|
70
|
+
*
|
|
71
|
+
* Separators (`/`, `\`, `:`) collapse into one `-`; unsafe units escape as
|
|
72
|
+
* `~XXXX`; the whole key is wrapped in `--` and capped at 251 characters.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} cwd - absolute project directory.
|
|
75
|
+
* @returns {string} the directory name under the sessions root.
|
|
76
|
+
*/
|
|
77
|
+
export function projectKey(cwd) {
|
|
78
|
+
const value = String(cwd === undefined || cwd === null ? '' : cwd)
|
|
79
|
+
if (value.length === 0) throw new Error('cannot encode an empty project path')
|
|
80
|
+
let readable = ''
|
|
81
|
+
let separatorRun = false
|
|
82
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
83
|
+
const code = value.charCodeAt(i)
|
|
84
|
+
const ch = String.fromCharCode(code)
|
|
85
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
86
|
+
if (!separatorRun) readable += '-'
|
|
87
|
+
separatorRun = true
|
|
88
|
+
} else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
89
|
+
readable += ch
|
|
90
|
+
separatorRun = false
|
|
91
|
+
} else {
|
|
92
|
+
readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
|
|
93
|
+
separatorRun = false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** True when `target` is a strict descendant of `root` (separator-aware). */
|
|
100
|
+
export function isUnder(root, target) {
|
|
101
|
+
if (!root || !target) return false
|
|
102
|
+
const rel = path.relative(path.resolve(String(root)), path.resolve(String(target)))
|
|
103
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Parse a mirror's `.dsh-remote-meta.json`; undefined when absent/unusable.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} localMirror - candidate mirror directory.
|
|
109
|
+
* @returns {{host: string, port: number, username: string, remotePath: string, alias: string}|undefined}
|
|
110
|
+
*/
|
|
111
|
+
export function readMirrorMeta(localMirror) {
|
|
112
|
+
const file = path.join(String(localMirror || ''), MIRROR_META_FILE)
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
115
|
+
if (!parsed || typeof parsed !== 'object') return undefined
|
|
116
|
+
if (!parsed.host) return undefined
|
|
117
|
+
return {
|
|
118
|
+
host: String(parsed.host),
|
|
119
|
+
port: Number(parsed.port) || 22,
|
|
120
|
+
username: String(parsed.username || ''),
|
|
121
|
+
remotePath: String(parsed.remotePath || ''),
|
|
122
|
+
alias: String(parsed.alias || ''),
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Validate that `localMirror` is a real mirror exactly two levels under the root.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} input
|
|
132
|
+
* @param {string} input.mirrorRoot - `$DSH_HOME/remote-workspaces`.
|
|
133
|
+
* @param {string} input.localMirror - candidate directory.
|
|
134
|
+
* @returns {{real: string, meta: object}} resolved path + parsed meta.
|
|
135
|
+
* @throws {Error} `code: 'VALIDATION'` when the path is not a usable mirror.
|
|
136
|
+
*/
|
|
137
|
+
export function validateMirror({ mirrorRoot, localMirror } = {}) {
|
|
138
|
+
const invalid = (message) => {
|
|
139
|
+
const error = new Error(message)
|
|
140
|
+
error.code = 'VALIDATION'
|
|
141
|
+
return error
|
|
142
|
+
}
|
|
143
|
+
if (!mirrorRoot || !existsSync(mirrorRoot)) throw invalid('远程工作区根目录不存在')
|
|
144
|
+
if (!localMirror || !path.isAbsolute(String(localMirror))) throw invalid('必须是绝对路径')
|
|
145
|
+
let realRoot
|
|
146
|
+
let real
|
|
147
|
+
try {
|
|
148
|
+
realRoot = realpathSync(String(mirrorRoot))
|
|
149
|
+
} catch {
|
|
150
|
+
throw invalid('远程工作区根目录不可读')
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
real = realpathSync(String(localMirror))
|
|
154
|
+
} catch {
|
|
155
|
+
throw invalid('目录不存在或已删除')
|
|
156
|
+
}
|
|
157
|
+
if (!isUnder(realRoot, real)) throw invalid('不是远程工作区镜像(不在 remote-workspaces 下)')
|
|
158
|
+
const parts = path.relative(realRoot, real).split(path.sep).filter(Boolean)
|
|
159
|
+
if (parts.length !== 2) throw invalid('不是远程工作区镜像(层级不符)')
|
|
160
|
+
const meta = readMirrorMeta(real)
|
|
161
|
+
if (!meta) throw invalid('缺少 .dsh-remote-meta.json,拒绝删除')
|
|
162
|
+
return { real, meta }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Every mirror found under the registry root (read-only scan, no SSH). */
|
|
166
|
+
export function listMirrorEntries(mirrorRoot) {
|
|
167
|
+
const entries = []
|
|
168
|
+
if (!mirrorRoot || !existsSync(mirrorRoot)) return entries
|
|
169
|
+
let hostDirs = []
|
|
170
|
+
try {
|
|
171
|
+
hostDirs = readdirSync(mirrorRoot, { withFileTypes: true })
|
|
172
|
+
} catch {
|
|
173
|
+
return entries
|
|
174
|
+
}
|
|
175
|
+
for (const hostDir of hostDirs) {
|
|
176
|
+
if (!hostDir.isDirectory()) continue
|
|
177
|
+
const hostPath = path.join(mirrorRoot, hostDir.name)
|
|
178
|
+
let children = []
|
|
179
|
+
try {
|
|
180
|
+
children = readdirSync(hostPath, { withFileTypes: true })
|
|
181
|
+
} catch {
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
for (const child of children) {
|
|
185
|
+
if (!child.isDirectory()) continue
|
|
186
|
+
const localMirror = path.join(hostPath, child.name)
|
|
187
|
+
const meta = readMirrorMeta(localMirror)
|
|
188
|
+
if (!meta) continue
|
|
189
|
+
entries.push({ localMirror, mirrorName: child.name, hostDir: hostDir.name, ...meta })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return entries
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Sum a tree's bytes, capped so a huge mirror cannot stall the UI. */
|
|
196
|
+
export function dirSizeBytes(dir, limit = 5000) {
|
|
197
|
+
let bytes = 0
|
|
198
|
+
let files = 0
|
|
199
|
+
let truncated = false
|
|
200
|
+
const walk = (current, depth) => {
|
|
201
|
+
if (truncated || depth > 24) return
|
|
202
|
+
let list
|
|
203
|
+
try {
|
|
204
|
+
list = readdirSync(current, { withFileTypes: true })
|
|
205
|
+
} catch {
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
for (const entry of list) {
|
|
209
|
+
if (entry.name === '.git') continue
|
|
210
|
+
const full = path.join(current, entry.name)
|
|
211
|
+
if (entry.isDirectory()) {
|
|
212
|
+
walk(full, depth + 1)
|
|
213
|
+
if (truncated) return
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
files += 1
|
|
217
|
+
if (files > limit) {
|
|
218
|
+
truncated = true
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
bytes += statSync(full).size
|
|
223
|
+
} catch { /* unreadable file: skip */ }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
walk(String(dir || ''), 0)
|
|
227
|
+
return { bytes, truncated }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The project directory DSH groups one cwd's sessions under. */
|
|
231
|
+
export function sessionsDirFor(sessionsRoot, cwd) {
|
|
232
|
+
return path.join(String(sessionsRoot), projectKey(String(cwd)))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Session directories DSH keeps for one project cwd.
|
|
236
|
+
*
|
|
237
|
+
* @param {object} input
|
|
238
|
+
* @param {string} input.sessionsRoot - `$DSH_HOME/sessions`.
|
|
239
|
+
* @param {string} input.cwd - the mirror directory recorded as the session cwd.
|
|
240
|
+
* @returns {Array<{id: string, name: string, dir: string, modifiedAt: number}>}
|
|
241
|
+
*/
|
|
242
|
+
export function listSessionDirs({ sessionsRoot, cwd, cwds = [] } = {}) {
|
|
243
|
+
const out = []
|
|
244
|
+
if (!sessionsRoot || !cwd || !existsSync(sessionsRoot)) return out
|
|
245
|
+
// DSH stores the cwd as the session header saw it. When `$DSH_HOME` itself
|
|
246
|
+
// is behind a symlink, realpath() and the header path differ, so accept both
|
|
247
|
+
// spellings (the caller passes the alias it was given) and dedupe by dir.
|
|
248
|
+
const candidates = []
|
|
249
|
+
for (const candidate of [cwd, ...(Array.isArray(cwds) ? cwds : [])]) {
|
|
250
|
+
const value = String(candidate || '')
|
|
251
|
+
if (!value) continue
|
|
252
|
+
try {
|
|
253
|
+
const dir = sessionsDirFor(sessionsRoot, value)
|
|
254
|
+
if (!candidates.includes(dir)) candidates.push(dir)
|
|
255
|
+
} catch { /* unencodable candidate */ }
|
|
256
|
+
}
|
|
257
|
+
const seen = new Set()
|
|
258
|
+
for (const projectDir of candidates) {
|
|
259
|
+
if (!existsSync(projectDir)) continue
|
|
260
|
+
let children = []
|
|
261
|
+
try {
|
|
262
|
+
children = readdirSync(projectDir, { withFileTypes: true })
|
|
263
|
+
} catch {
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
for (const child of children) {
|
|
267
|
+
if (!child.isDirectory()) continue
|
|
268
|
+
const dir = path.join(projectDir, child.name)
|
|
269
|
+
if (seen.has(dir)) continue
|
|
270
|
+
seen.add(dir)
|
|
271
|
+
let modifiedAt = 0
|
|
272
|
+
try {
|
|
273
|
+
modifiedAt = statSync(dir).mtimeMs || 0
|
|
274
|
+
} catch { /* keep 0 */ }
|
|
275
|
+
out.push({ id: decodeSegment(child.name), name: child.name, dir, modifiedAt })
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return out
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Delete one mirror tree, refusing anything that is not a validated mirror.
|
|
282
|
+
*
|
|
283
|
+
* @param {object} input
|
|
284
|
+
* @param {string} input.mirrorRoot - registry root.
|
|
285
|
+
* @param {string} input.localMirror - mirror to remove.
|
|
286
|
+
* @returns {boolean} true when removed.
|
|
287
|
+
*/
|
|
288
|
+
export function deleteMirrorTree({ mirrorRoot, localMirror } = {}) {
|
|
289
|
+
const { real } = validateMirror({ mirrorRoot, localMirror })
|
|
290
|
+
rmSync(real, { recursive: true, force: true })
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Delete every session directory of one project cwd.
|
|
295
|
+
*
|
|
296
|
+
* `isLive(sessionId, dir)` is consulted per session; live ones are skipped and
|
|
297
|
+
* reported so the caller can tell the person to stop them first. With
|
|
298
|
+
* `force: true` they are deleted anyway (the confirmation text says so).
|
|
299
|
+
*
|
|
300
|
+
* @param {object} input
|
|
301
|
+
* @param {string} input.sessionsRoot - `$DSH_HOME/sessions`.
|
|
302
|
+
* @param {string} input.cwd - project cwd (the local mirror).
|
|
303
|
+
* @param {(id: string, dir: string) => boolean|Promise<boolean>} [input.isLive]
|
|
304
|
+
* @param {boolean} [input.force]
|
|
305
|
+
* @returns {Promise<{removed: string[], skippedLive: string[]}>}
|
|
306
|
+
*/
|
|
307
|
+
export async function deleteSessionsFor({ sessionsRoot, cwd, cwds = [], isLive, force = false } = {}) {
|
|
308
|
+
const removed = []
|
|
309
|
+
const skippedLive = []
|
|
310
|
+
for (const session of listSessionDirs({ sessionsRoot, cwd, cwds })) {
|
|
311
|
+
let live = false
|
|
312
|
+
if (!force && typeof isLive === 'function') {
|
|
313
|
+
try {
|
|
314
|
+
live = await isLive(session.id, session.dir) === true
|
|
315
|
+
} catch {
|
|
316
|
+
live = false
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (live) {
|
|
320
|
+
skippedLive.push(session.id)
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
rmSync(session.dir, { recursive: true, force: true })
|
|
325
|
+
removed.push(session.id)
|
|
326
|
+
} catch { /* unreadable dir: leave it and let the caller report fewer removals */ }
|
|
327
|
+
}
|
|
328
|
+
// Leave no empty history group behind when the last session is gone; a
|
|
329
|
+
// non-empty dir (live session, unreadable sibling) makes rmdir a no-op.
|
|
330
|
+
for (const candidate of [cwd, ...(Array.isArray(cwds) ? cwds : [])]) {
|
|
331
|
+
if (!candidate) continue
|
|
332
|
+
try {
|
|
333
|
+
rmdirSync(sessionsDirFor(sessionsRoot, candidate))
|
|
334
|
+
} catch { /* still holds something */ }
|
|
335
|
+
}
|
|
336
|
+
return { removed, skippedLive }
|
|
337
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.75",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive, multi-hop ProxyJump), list hosts from ~/.ssh/config, pick a remote workspace, operate on it with 21 rw_* tools, conflict-aware mirror sync, an embedded PTY terminal, remote DSH web UI attach, port forwarding, host grouping, remote sandbox fencing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|