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/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.6.2",
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",