dsh-linked-workspace 0.6.2 → 0.8.0
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/CHANGELOG.md +15 -0
- package/README.en.md +11 -3
- package/README.md +7 -3
- package/docs/API.md +8 -3
- package/docs/VERIFY.md +22 -8
- package/lib/client.js +719 -61
- package/lib/index.js +241 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -34,7 +34,7 @@ export const inject = ['webServer', 'tools', 'systemPrompt', 'workingDirectory']
|
|
|
34
34
|
|
|
35
35
|
const DATA_VERSION = 1
|
|
36
36
|
const SETTINGS_VERSION = 1
|
|
37
|
-
const SETTINGS_DEFAULTS = Object.freeze({ editorTheme: 'github-light', editorThemeFamily: 'github', editorThemeMode: 'system', fileOpenMode: 'editor' })
|
|
37
|
+
const SETTINGS_DEFAULTS = Object.freeze({ editorTheme: 'github-light', editorThemeFamily: 'github', editorThemeMode: 'system', fileOpenMode: 'editor', autoSave: false })
|
|
38
38
|
const EDITOR_THEME_FAMILIES = new Set(['github', 'solarized', 'one', 'dracula', 'monokai', 'nord', 'vscode', 'xcode', 'catppuccin'])
|
|
39
39
|
const THEME_FAMILY_OF = (id) => {
|
|
40
40
|
const value = String(id || '')
|
|
@@ -67,6 +67,18 @@ const EDITABLE_LIMIT = 2 * 1024 * 1024
|
|
|
67
67
|
const PEEK_LIMIT = 512 * 1024
|
|
68
68
|
const READ_CAP = 8 * 1024 * 1024
|
|
69
69
|
const MAX_WRITE_BYTES = 8 * 1024 * 1024
|
|
70
|
+
const MAX_MEDIA_BYTES = 24 * 1024 * 1024
|
|
71
|
+
const IMAGE_MIME = Object.freeze({
|
|
72
|
+
'.png': 'image/png',
|
|
73
|
+
'.jpg': 'image/jpeg',
|
|
74
|
+
'.jpeg': 'image/jpeg',
|
|
75
|
+
'.gif': 'image/gif',
|
|
76
|
+
'.webp': 'image/webp',
|
|
77
|
+
'.bmp': 'image/bmp',
|
|
78
|
+
'.ico': 'image/x-icon',
|
|
79
|
+
'.avif': 'image/avif',
|
|
80
|
+
'.svg': 'image/svg+xml',
|
|
81
|
+
})
|
|
70
82
|
const MAX_NAME_LENGTH = 120
|
|
71
83
|
const CONNECT_TIMEOUT_MS = 20_000
|
|
72
84
|
const OP_TIMEOUT_MS = 60_000
|
|
@@ -87,6 +99,7 @@ const ERROR_STATUS = {
|
|
|
87
99
|
FS_IS_DIR: 400,
|
|
88
100
|
FS_NOT_DIR: 400,
|
|
89
101
|
FS_TOO_LARGE: 413,
|
|
102
|
+
FS_EXISTS: 409,
|
|
90
103
|
CONFLICT: 409,
|
|
91
104
|
INTERNAL: 500,
|
|
92
105
|
}
|
|
@@ -183,6 +196,85 @@ function openWithSystem(targetPath) {
|
|
|
183
196
|
}
|
|
184
197
|
}
|
|
185
198
|
|
|
199
|
+
/** Spawn a detached console process and report whether the executable started. */
|
|
200
|
+
function launchDetached(command, args, extra = {}) {
|
|
201
|
+
return new Promise((resolveLaunch) => {
|
|
202
|
+
let child
|
|
203
|
+
try {
|
|
204
|
+
child = spawn(command, args, { detached: true, stdio: 'ignore', ...extra })
|
|
205
|
+
} catch {
|
|
206
|
+
resolveLaunch(false)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
let settled = false
|
|
210
|
+
child.once('error', () => {
|
|
211
|
+
if (settled) return
|
|
212
|
+
settled = true
|
|
213
|
+
resolveLaunch(false)
|
|
214
|
+
})
|
|
215
|
+
child.once('spawn', () => {
|
|
216
|
+
if (settled) return
|
|
217
|
+
settled = true
|
|
218
|
+
try { child.unref() } catch { /* already detached */ }
|
|
219
|
+
resolveLaunch(true)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function quotePosix(text) {
|
|
225
|
+
return `'${String(text).replace(/'/g, `'\\''`)}'`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function quotePowerShell(text) {
|
|
229
|
+
return `'${String(text).replace(/'/g, "''")}'`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function quoteAppleScript(text) {
|
|
233
|
+
return `"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function remoteTerminalCommand(remotePath) {
|
|
237
|
+
return `cd ${quotePosix(remotePath)} && exec bash -l`
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function sshTarget(host, username) {
|
|
241
|
+
return `${username || ''}@${host}`
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Open a local terminal whose working directory is `dir`. */
|
|
245
|
+
async function openLocalTerminal(dir) {
|
|
246
|
+
if (process.platform === 'win32') {
|
|
247
|
+
if (await launchDetached('wt.exe', ['-d', dir], { windowsHide: false })) return true
|
|
248
|
+
const script = `Set-Location -LiteralPath ${quotePowerShell(dir)}`
|
|
249
|
+
return launchDetached('powershell.exe', ['-NoLogo', '-NoExit', '-Command', script], { windowsHide: false })
|
|
250
|
+
}
|
|
251
|
+
if (process.platform === 'darwin') {
|
|
252
|
+
return launchDetached('open', ['-a', 'Terminal', dir])
|
|
253
|
+
}
|
|
254
|
+
if (await launchDetached('x-terminal-emulator', [`--working-directory=${dir}`])) return true
|
|
255
|
+
return launchDetached('gnome-terminal', [`--working-directory=${dir}`])
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Open a terminal that SSHes into `host` and cd's to `remotePath`. */
|
|
259
|
+
async function openRemoteTerminal(host, port, username, remotePath) {
|
|
260
|
+
const target = sshTarget(host, username)
|
|
261
|
+
const command = remoteTerminalCommand(remotePath)
|
|
262
|
+
if (!username) return false
|
|
263
|
+
if (process.platform === 'win32') {
|
|
264
|
+
const sshArgs = ['-p', String(port || 22), '-t', `${username}@${host}`, command]
|
|
265
|
+
if (await launchDetached('wt.exe', ['new-tab', '--', 'ssh.exe', ...sshArgs], { windowsHide: false })) return true
|
|
266
|
+
const script = `& ssh.exe -p ${Number(port) || 22} -t ${quotePowerShell(`${username}@${host}`)} ${quotePowerShell(command)}`
|
|
267
|
+
return launchDetached('powershell.exe', ['-NoLogo', '-NoExit', '-Command', script], { windowsHide: false })
|
|
268
|
+
}
|
|
269
|
+
if (process.platform === 'darwin') {
|
|
270
|
+
const line = `ssh -p ${Number(port) || 22} -t ${target} ${command}`
|
|
271
|
+
return launchDetached('osascript', ['-e', `tell application "Terminal" to do script ${quoteAppleScript(line)}`])
|
|
272
|
+
}
|
|
273
|
+
const sshArgs = ['-p', String(port || 22), '-t', target, command]
|
|
274
|
+
if (await launchDetached('x-terminal-emulator', ['-e', 'ssh', ...sshArgs])) return true
|
|
275
|
+
return launchDetached('gnome-terminal', ['--', 'ssh', ...sshArgs])
|
|
276
|
+
}
|
|
277
|
+
|
|
186
278
|
function readJsonFile(file, fallback) {
|
|
187
279
|
try {
|
|
188
280
|
return JSON.parse(readFileSync(file, 'utf8'))
|
|
@@ -216,6 +308,12 @@ function isInside(root, target) {
|
|
|
216
308
|
return next === base || next.startsWith(`${base}/`)
|
|
217
309
|
}
|
|
218
310
|
|
|
311
|
+
function isInsideLocal(root, target) {
|
|
312
|
+
const base = resolve(root)
|
|
313
|
+
const next = resolve(target)
|
|
314
|
+
return next === base || next.startsWith(`${base}${sep}`)
|
|
315
|
+
}
|
|
316
|
+
|
|
219
317
|
function withTimeout(promise, ms, label) {
|
|
220
318
|
let timer
|
|
221
319
|
const timeout = new Promise((_, reject) => {
|
|
@@ -336,6 +434,9 @@ class SettingsStore {
|
|
|
336
434
|
if (settings && typeof settings === 'object' && FILE_OPEN_MODES.has(settings.fileOpenMode)) {
|
|
337
435
|
this.value.fileOpenMode = settings.fileOpenMode
|
|
338
436
|
}
|
|
437
|
+
if (settings && typeof settings === 'object' && typeof settings.autoSave === 'boolean') {
|
|
438
|
+
this.value.autoSave = settings.autoSave
|
|
439
|
+
}
|
|
339
440
|
if (settings && typeof settings === 'object' && settings.themeExplicit === true) {
|
|
340
441
|
this.themeExplicit = true
|
|
341
442
|
}
|
|
@@ -388,6 +489,10 @@ class SettingsStore {
|
|
|
388
489
|
if (!FILE_OPEN_MODES.has(patch.fileOpenMode)) throw new ApiError('BAD_REQUEST', '文件打开方式无效')
|
|
389
490
|
this.value.fileOpenMode = patch.fileOpenMode
|
|
390
491
|
}
|
|
492
|
+
if (patch && typeof patch === 'object' && patch.autoSave !== undefined) {
|
|
493
|
+
if (typeof patch.autoSave !== 'boolean') throw new ApiError('BAD_REQUEST', '自动保存设置无效')
|
|
494
|
+
this.value.autoSave = patch.autoSave
|
|
495
|
+
}
|
|
391
496
|
writeJsonAtomic(this.file, { version: SETTINGS_VERSION, settings: { ...this.value, themeExplicit: this.themeExplicit === true } })
|
|
392
497
|
return this.get()
|
|
393
498
|
}
|
|
@@ -1739,6 +1844,24 @@ export function apply(ctx) {
|
|
|
1739
1844
|
}
|
|
1740
1845
|
},
|
|
1741
1846
|
|
|
1847
|
+
async 'media.read'(params = {}) {
|
|
1848
|
+
const scope = await requireScope(params)
|
|
1849
|
+
const target = scopeTarget(scope, params.path)
|
|
1850
|
+
const mime = imageMimeOf(target)
|
|
1851
|
+
if (!mime) throw new ApiError('BAD_REQUEST', '该文件不是支持的图片格式')
|
|
1852
|
+
const stat = await scope.remote.stat(target)
|
|
1853
|
+
if (stat.type === 'dir') throw new ApiError('FS_IS_DIR', '目标是目录,无法预览')
|
|
1854
|
+
if (stat.size > MAX_MEDIA_BYTES) throw new ApiError('FS_TOO_LARGE', '图片超过 24 MB,无法预览')
|
|
1855
|
+
const read = await scope.remote.readFile(target, { maxBytes: MAX_MEDIA_BYTES })
|
|
1856
|
+
return {
|
|
1857
|
+
path: target,
|
|
1858
|
+
size: stat.size,
|
|
1859
|
+
mtime: stat.mtime,
|
|
1860
|
+
mime,
|
|
1861
|
+
base64: read.data.toString('base64'),
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
|
|
1742
1865
|
async 'fs.write'(params = {}) {
|
|
1743
1866
|
const scope = await requireScope(params)
|
|
1744
1867
|
if (typeof params.content !== 'string') throw new ApiError('BAD_REQUEST', 'content is required')
|
|
@@ -1950,6 +2073,27 @@ export function apply(ctx) {
|
|
|
1950
2073
|
return {}
|
|
1951
2074
|
},
|
|
1952
2075
|
|
|
2076
|
+
async 'fs.move'(params = {}) {
|
|
2077
|
+
const scope = await requireScope(params)
|
|
2078
|
+
const source = scopeTarget(scope, params.path)
|
|
2079
|
+
if (source === scope.root) throw new ApiError('BAD_REQUEST', '不能移动工作区根目录')
|
|
2080
|
+
const sourceStat = await scope.remote.stat(source)
|
|
2081
|
+
const targetDir = params.targetDir === undefined || params.targetDir === ''
|
|
2082
|
+
? posix.dirname(source)
|
|
2083
|
+
: scopeTarget(scope, params.targetDir)
|
|
2084
|
+
const dirStat = await scope.remote.stat(targetDir)
|
|
2085
|
+
if (dirStat.type !== 'dir') throw new ApiError('FS_NOT_DIR', '目标不是目录')
|
|
2086
|
+
if (sourceStat.type === 'dir' && (targetDir === source || targetDir.startsWith(`${source}/`))) {
|
|
2087
|
+
throw new ApiError('BAD_REQUEST', '不能把目录移动到自身内部')
|
|
2088
|
+
}
|
|
2089
|
+
const target = posix.join(targetDir, posix.basename(source))
|
|
2090
|
+
if (target === source) return { path: target }
|
|
2091
|
+
if (await scope.remote.stat(target).catch(() => null)) throw new ApiError('FS_EXISTS', '目标目录中已存在同名文件或文件夹')
|
|
2092
|
+
await scope.remote.rename(source, target)
|
|
2093
|
+
await scopeMirrorRemove(scope, source)
|
|
2094
|
+
return { path: target }
|
|
2095
|
+
},
|
|
2096
|
+
|
|
1953
2097
|
async 'fs.delete'(params = {}) {
|
|
1954
2098
|
const scope = await requireScope(params)
|
|
1955
2099
|
const target = scopeTarget(scope, params.path)
|
|
@@ -2123,6 +2267,29 @@ export function apply(ctx) {
|
|
|
2123
2267
|
}
|
|
2124
2268
|
},
|
|
2125
2269
|
|
|
2270
|
+
async 'local.media'(params = {}) {
|
|
2271
|
+
const target = typeof params.path === 'string' ? params.path.trim() : ''
|
|
2272
|
+
if (target === '' || !isAbsolute(target)) throw new ApiError('BAD_REQUEST', '本地路径必须是绝对路径')
|
|
2273
|
+
const absolute = resolve(target)
|
|
2274
|
+
const mime = imageMimeOf(absolute)
|
|
2275
|
+
if (!mime) throw new ApiError('BAD_REQUEST', '该文件不是支持的图片格式')
|
|
2276
|
+
let info
|
|
2277
|
+
try {
|
|
2278
|
+
info = await fsStat(absolute)
|
|
2279
|
+
} catch (error) {
|
|
2280
|
+
throw mapLocalFsError(error)
|
|
2281
|
+
}
|
|
2282
|
+
if (info.isDirectory()) throw new ApiError('FS_IS_DIR', '目标是目录,无法预览')
|
|
2283
|
+
if (info.size > MAX_MEDIA_BYTES) throw new ApiError('FS_TOO_LARGE', '图片超过 24 MB,无法预览')
|
|
2284
|
+
let data
|
|
2285
|
+
try {
|
|
2286
|
+
data = readFileSync(absolute)
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
throw mapLocalFsError(error)
|
|
2289
|
+
}
|
|
2290
|
+
return { path: absolute, size: info.size, mtime: info.mtimeMs, mime, base64: data.toString('base64') }
|
|
2291
|
+
},
|
|
2292
|
+
|
|
2126
2293
|
async 'local.write'(params = {}) {
|
|
2127
2294
|
const target = typeof params.path === 'string' ? params.path.trim() : ''
|
|
2128
2295
|
if (target === '' || !isAbsolute(target)) throw new ApiError('BAD_REQUEST', '本地路径必须是绝对路径')
|
|
@@ -2169,6 +2336,46 @@ export function apply(ctx) {
|
|
|
2169
2336
|
return { path: resolve(to) }
|
|
2170
2337
|
},
|
|
2171
2338
|
|
|
2339
|
+
async 'local.move'(params = {}) {
|
|
2340
|
+
const source = typeof params.path === 'string' ? params.path.trim() : ''
|
|
2341
|
+
const targetDir = typeof params.targetDir === 'string' ? params.targetDir.trim() : ''
|
|
2342
|
+
if (source === '' || targetDir === '' || !isAbsolute(source) || !isAbsolute(targetDir)) {
|
|
2343
|
+
throw new ApiError('BAD_REQUEST', '本地路径必须是绝对路径')
|
|
2344
|
+
}
|
|
2345
|
+
const root = typeof params.root === 'string' && params.root.trim() !== '' ? resolve(params.root.trim()) : ''
|
|
2346
|
+
const sourcePath = resolve(source)
|
|
2347
|
+
const dirPath = resolve(targetDir)
|
|
2348
|
+
if (root && sourcePath === root) throw new ApiError('BAD_REQUEST', '不能移动工作区根目录')
|
|
2349
|
+
if (root && (!isInsideLocal(root, sourcePath) || !isInsideLocal(root, dirPath))) {
|
|
2350
|
+
throw new ApiError('BAD_REQUEST', '移动范围超出本地目录')
|
|
2351
|
+
}
|
|
2352
|
+
let sourceInfo
|
|
2353
|
+
try {
|
|
2354
|
+
sourceInfo = await fsStat(sourcePath)
|
|
2355
|
+
} catch (error) {
|
|
2356
|
+
throw mapLocalFsError(error)
|
|
2357
|
+
}
|
|
2358
|
+
let dirInfo
|
|
2359
|
+
try {
|
|
2360
|
+
dirInfo = await fsStat(dirPath)
|
|
2361
|
+
} catch (error) {
|
|
2362
|
+
throw mapLocalFsError(error)
|
|
2363
|
+
}
|
|
2364
|
+
if (!dirInfo.isDirectory()) throw new ApiError('FS_NOT_DIR', '目标不是目录')
|
|
2365
|
+
if (sourceInfo.isDirectory() && (dirPath === sourcePath || dirPath.startsWith(sourcePath + sep))) {
|
|
2366
|
+
throw new ApiError('BAD_REQUEST', '不能把目录移动到自身内部')
|
|
2367
|
+
}
|
|
2368
|
+
const target = join(dirPath, basename(sourcePath))
|
|
2369
|
+
if (target === sourcePath) return { path: target }
|
|
2370
|
+
if (existsSync(target)) throw new ApiError('FS_EXISTS', '目标目录中已存在同名文件或文件夹')
|
|
2371
|
+
try {
|
|
2372
|
+
renameSync(sourcePath, target)
|
|
2373
|
+
} catch (error) {
|
|
2374
|
+
throw mapLocalFsError(error)
|
|
2375
|
+
}
|
|
2376
|
+
return { path: target, type: sourceInfo.isDirectory() ? 'dir' : 'file' }
|
|
2377
|
+
},
|
|
2378
|
+
|
|
2172
2379
|
async 'local.delete'(params = {}) {
|
|
2173
2380
|
const target = typeof params.path === 'string' ? params.path.trim() : ''
|
|
2174
2381
|
if (target === '' || !isAbsolute(target)) throw new ApiError('BAD_REQUEST', '本地路径必须是绝对路径')
|
|
@@ -2279,6 +2486,35 @@ export function apply(ctx) {
|
|
|
2279
2486
|
return { opened: true, localPath, size: read.data.length }
|
|
2280
2487
|
},
|
|
2281
2488
|
|
|
2489
|
+
async 'terminal.open'(params = {}) {
|
|
2490
|
+
const side = params.side === 'local' ? 'local' : 'remote'
|
|
2491
|
+
const testMode = process.env.DSH_LINKED_WORKSPACE_NO_TERMINAL === '1'
|
|
2492
|
+
const scope = await requireScope(params)
|
|
2493
|
+
const snapshot = scope.kind === 'linked' ? (scope.record.hostSnapshot || {}) : (scope.workspace.hostSnapshot || {})
|
|
2494
|
+
if (side === 'local') {
|
|
2495
|
+
if (scope.kind !== 'linked') throw new ApiError('BAD_REQUEST', '该工作区没有本地目录')
|
|
2496
|
+
const target = scope.record.localPath
|
|
2497
|
+
let info
|
|
2498
|
+
try {
|
|
2499
|
+
info = await fsStat(target)
|
|
2500
|
+
} catch (error) {
|
|
2501
|
+
throw mapLocalFsError(error)
|
|
2502
|
+
}
|
|
2503
|
+
if (!info.isDirectory()) throw new ApiError('FS_NOT_DIR', '本地目录不存在')
|
|
2504
|
+
if (testMode) return { opened: true, side, path: target, test: true }
|
|
2505
|
+
if (!(await openLocalTerminal(target))) throw new ApiError('INTERNAL', '无法启动本地终端')
|
|
2506
|
+
return { opened: true, side, path: target }
|
|
2507
|
+
}
|
|
2508
|
+
const stat = await scope.remote.stat(scope.root)
|
|
2509
|
+
if (stat.type !== 'dir') throw new ApiError('FS_NOT_DIR', '远程目录不存在')
|
|
2510
|
+
if (!snapshot.host || !snapshot.username) throw new ApiError('AUTH_FAILED', '缺少 SSH 主机或用户名,无法打开终端')
|
|
2511
|
+
if (testMode) return { opened: true, side, path: scope.root, host: snapshot.host, test: true }
|
|
2512
|
+
if (!(await openRemoteTerminal(snapshot.host, snapshot.port, snapshot.username, scope.root))) {
|
|
2513
|
+
throw new ApiError('INTERNAL', '无法启动远程终端(找不到可用的终端程序)')
|
|
2514
|
+
}
|
|
2515
|
+
return { opened: true, side, path: scope.root, host: snapshot.host }
|
|
2516
|
+
},
|
|
2517
|
+
|
|
2282
2518
|
async 'session.info'(params = {}) {
|
|
2283
2519
|
const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : ''
|
|
2284
2520
|
if (sessionId === '') throw new ApiError('BAD_REQUEST', 'sessionId is required')
|
|
@@ -2984,6 +3220,10 @@ function loopbackRejection(req) {
|
|
|
2984
3220
|
|
|
2985
3221
|
// ── file helpers ────────────────────────────────────────────────────────────
|
|
2986
3222
|
|
|
3223
|
+
function imageMimeOf(filePath) {
|
|
3224
|
+
return IMAGE_MIME[extname(String(filePath || '')).toLowerCase()] || ''
|
|
3225
|
+
}
|
|
3226
|
+
|
|
2987
3227
|
function looksBinary(buffer) {
|
|
2988
3228
|
const sample = buffer.subarray(0, Math.min(buffer.length, 8192))
|
|
2989
3229
|
for (let index = 0; index < sample.length; index += 1) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-linked-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Linked local ⇄ remote workspaces and SSH-backed remote directories for DeepSeek Harness: theme-aware split editor, file-type icons, and an agent tool that edits remote files directly.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|