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.
@@ -0,0 +1,808 @@
1
+ // Host-side in-process API for OTHER plugins (the TUI layer), exposed as
2
+ // `ctx.dshRemote` from `apply()`.
3
+ //
4
+ // Why a service instead of a second implementation:
5
+ // Every mutable piece of dsh-remote state (the active pool, the per-machine
6
+ // pools, the in-memory `machines` snapshot, `currentId`, the workspace
7
+ // mirror, forwards/terminals) lives inside `apply()`'s closure. A consumer
8
+ // that re-read `machines.json` or dialled its own SSH pool would hold a stale
9
+ // registry snapshot, open a second TCP connection per host, and race
10
+ // `saveMachines()`'s whole-file rewrite against this one. `createHostApi()`
11
+ // therefore takes those closure functions as `deps` and only reshapes their
12
+ // results — it never imports cordis, never opens a pool and never touches
13
+ // the registry file.
14
+ //
15
+ // Contract (docs/tui.md §3, `apiVersion: 1`):
16
+ // · every method is async and returns PURE DATA (no pool / client / sftp
17
+ // handles, no passwords, no passphrases);
18
+ // · failures THROW one `Error` carrying a semantic `.code`; they never return
19
+ // `{ok:false}` (a caller's `if (res.ok)` would swallow the reason);
20
+ // · `error.message` is already the user-facing Chinese sentence
21
+ // (`friendlyMessage` from lib/errors.js, idempotent);
22
+ // · the API has its own tiny emitter: an event fires after THIS object's
23
+ // mutations. It deliberately does not splice itself into the SSH pool's
24
+ // single-slot `onReady`/`onCloseHook` (the forwards manager owns those).
25
+ import { classifyError, friendlyMessage } from './errors.js'
26
+ import { joinRemotePath, normalizeRemotePath, remoteDirname, toDisplayPath, remoteWorkspaceTitle } from './paths.js'
27
+ import { listDirStructured, listRemoteDrives } from './remote-fs.js'
28
+ import {
29
+ deleteMirrorTree,
30
+ deleteSessionsFor,
31
+ dirSizeBytes,
32
+ listMirrorEntries,
33
+ listSessionDirs,
34
+ validateMirror,
35
+ } from './workspace-cleanup.js'
36
+
37
+
38
+ /** Contract version. Consumers check `api.apiVersion === 1` before use. */
39
+ const API_VERSION = 1
40
+
41
+ /** Error codes the contract allows (`docs/tui.md` §3). */
42
+ const API_CODES = new Set([
43
+ 'MACHINE_NOT_FOUND',
44
+ 'NO_ACTIVE_MACHINE',
45
+ 'NOT_A_DIRECTORY',
46
+ 'AUTH',
47
+ 'CREDENTIALS',
48
+ 'HOSTKEY',
49
+ 'NETWORK',
50
+ 'TIMEOUT',
51
+ 'SFTP',
52
+ 'UNSUPPORTED',
53
+ 'LOCAL_SESSION',
54
+ 'VALIDATION',
55
+ 'WORKSPACE_BUSY',
56
+ 'INTERNAL',
57
+ ])
58
+
59
+ /** One coded error. `extra` carries optional machine identity (`host`/`port`)
60
+ * so a UI can point at the failing row. */
61
+ function apiError(code, message, extra = {}) {
62
+ const err = new Error(message)
63
+ err.code = API_CODES.has(code) ? code : 'INTERNAL'
64
+ if (extra && typeof extra === 'object') Object.assign(err, extra)
65
+ return err
66
+ }
67
+
68
+ /** Map whatever a dep throws onto the contract's error vocabulary.
69
+ *
70
+ * Coded errors thrown by lib/index.js deps (and by this module) pass through
71
+ * untouched; HTTP-layer codes (`httpStatus`) are translated, because the
72
+ * same closures answer the web routes; everything else is classified with
73
+ * lib/errors.js, which is what makes `AUTH` / `NETWORK` / `HOSTKEY` /
74
+ * `TIMEOUT` / `SFTP` / `CREDENTIALS` actionable instead of `INTERNAL`. */
75
+ function asApiError(err, opts = {}) {
76
+ if (!err) return apiError('INTERNAL', 'unknown error')
77
+ if (err.code && API_CODES.has(err.code)) return err
78
+ const status = Number(err.httpStatus) || 0
79
+ if (status === 400 || err.code === 'HOST_REQUIRED') {
80
+ // The route layer throws English strings (`host required`); the contract
81
+ // promises a user-facing Chinese sentence, so translate the known one and
82
+ // prefix the rest rather than leaking raw English into the TUI footer.
83
+ const raw = String(err.message || err)
84
+ const message = err.code === 'HOST_REQUIRED' ? '缺少主机地址(host 必填)' : `请求参数不可用:${raw}`
85
+ return apiError('VALIDATION', message)
86
+ }
87
+ if (status === 404) return apiError('MACHINE_NOT_FOUND', friendlyMessage(err, opts))
88
+ if (status === 403) return apiError('LOCAL_SESSION', String(err.message || err))
89
+ if (status === 409) return apiError('NO_ACTIVE_MACHINE', String(err.message || err))
90
+ const category = classifyError(err, opts).category
91
+ const map = {
92
+ auth: 'AUTH',
93
+ network: 'NETWORK',
94
+ hostkey: 'HOSTKEY',
95
+ timeout: 'TIMEOUT',
96
+ sftp: 'SFTP',
97
+ credentials: 'CREDENTIALS',
98
+ }
99
+ const code = map[category] || 'INTERNAL'
100
+ const out = apiError(code, friendlyMessage(err, opts), {
101
+ host: err.host != null ? err.host : opts.host,
102
+ port: err.port != null ? err.port : opts.port,
103
+ cause: err,
104
+ })
105
+ return out
106
+ }
107
+
108
+ /** Defensive secret strip for anything that leaves this module.
109
+ *
110
+ * The deps hand back views the web settings page already receives
111
+ * (`sanitizeMachine` — password/passphrase replaced by booleans), so this is
112
+ * a belt-and-braces pass: a future field added to the record must not leak
113
+ * through the in-process API by accident. It also makes the promise
114
+ * "no secret ever leaves" testable by seeding a fake deps that returns the
115
+ * raw record.
116
+ *
117
+ * @param {*} value - plain data from a dep.
118
+ * @returns {*} a deep copy with `password` / `passphrase` keys removed.
119
+ */
120
+ function scrub(value) {
121
+ if (Array.isArray(value)) return value.map(scrub)
122
+ if (value && typeof value === 'object') {
123
+ const out = {}
124
+ for (const [k, v] of Object.entries(value)) {
125
+ if (k === 'password' || k === 'passphrase') continue
126
+ out[k] = scrub(v)
127
+ }
128
+ return out
129
+ }
130
+ return value
131
+ }
132
+
133
+ /** The Status shape the TUI contract names (`machineId`), derived from the
134
+ * host's own snapshot (`currentId`). Pure data either way. */
135
+ function asStatus(snapshot) {
136
+ const s = (snapshot && typeof snapshot === 'object') ? { ...snapshot } : {}
137
+ return { ...scrub(s), machineId: s.currentId == null ? null : String(s.currentId) }
138
+ }
139
+
140
+ /** Map the SFTP entry types lib/remote-fs.js produces onto the contract's
141
+ * four-value vocabulary. */
142
+ function publicEntryType(type) {
143
+ if (type === 'dir') return 'dir'
144
+ if (type === 'symlink' || type === 'link') return 'link'
145
+ if (type === 'file') return 'file'
146
+ return 'other'
147
+ }
148
+
149
+ /** Parent directory for a DirListing, or null for a filesystem root. */
150
+ function parentOf(p, platform) {
151
+ const norm = normalizeRemotePath(p)
152
+ if (norm === '/' || /^[a-zA-Z]:\\?$/.test(norm) || /^\\\\[^\\]+\\[^\\]+$/.test(norm)) return null
153
+ return toDisplayPath(remoteDirname(norm), platform)
154
+ }
155
+
156
+ /**
157
+ * Build the host API object.
158
+ *
159
+ * @param {object} deps - closure functions supplied by lib/index.js. Required
160
+ * keys (all throw `INTERNAL` here when missing, never at require time):
161
+ * · `listMachinesView()` → `{machines, currentId}` (sanitized views);
162
+ * · `upsertMachineRecord(body)` → `{machine, machines, currentId, warning?,
163
+ * warningDetail?, sshConfigWarning?}`; throws HOST_REQUIRED / coded errors;
164
+ * · `removeMachineRecord(id)` → `{machines, currentId}`; throws
165
+ * MACHINE_NOT_FOUND;
166
+ * · `setCurrentForApi(id|null)`; throws MACHINE_NOT_FOUND;
167
+ * · `connectMachine({machineId, persist})` → Status (throws classified);
168
+ * · `probeMachineRecord(body)` → ProbeResult (throws classified);
169
+ * · `statusSnapshot()` → Status snapshot;
170
+ * · `resolveDirBinding({machineId, sessionId, local})` → `{pool, ws, bound,
171
+ * machine}` (throws MACHINE_NOT_FOUND / NO_ACTIVE_MACHINE);
172
+ * · `listRemoteProjects()` → mirror registry entries (+machineId);
173
+ * · `resolveMachine(machineId?)` → `{record, machineId, name, host, port,
174
+ * username, alias}` (resolved connect identity);
175
+ * · `ensureMirror(remotePath, host, username, port, extra)` → local dir;
176
+ * · `registerMirroredWorkspace(localDir, remotePath, record)`;
177
+ * · `checkRemoteDir(remotePath, machineId?)` → boolean (SSH stat).
178
+ * @returns {object} the `ctx.dshRemote` value.
179
+ */
180
+ export function createHostApi(deps = {}) {
181
+ const listeners = new Set()
182
+
183
+ const need = (name) => {
184
+ const fn = deps[name]
185
+ if (typeof fn !== 'function') {
186
+ throw apiError('INTERNAL', `dshRemote: host dependency "${name}" is missing`)
187
+ }
188
+ return fn
189
+ }
190
+
191
+ /** Fire one lifecycle event. Listener failures are contained: a broken TUI
192
+ * subscriber must never break a machine save. */
193
+ const emit = (type) => {
194
+ for (const listener of [...listeners]) {
195
+ try { listener({ type }) } catch { /* subscriber is on its own */ }
196
+ }
197
+ }
198
+
199
+ const status = async () => {
200
+ const snapshot = await need('statusSnapshot')()
201
+ return asStatus(snapshot)
202
+ }
203
+
204
+ /** Project the record layer's grouping fields onto the contract's `group`.
205
+ *
206
+ * The registry has no `group` column: `normalizeMachineGroup()` writes
207
+ * `environment` / `location` / `description` / `tags` (lib/registry.js),
208
+ * and the Web settings page edits `environment` as the host's group label.
209
+ * The TUI form exposes ONE text field, so the contract's `group` is that
210
+ * `environment` value — without this projection the form would save into a
211
+ * field the host never persists.
212
+ *
213
+ * @param {object} machine - a sanitized record view.
214
+ * @returns {object} the same view plus a string `group`.
215
+ */
216
+ const withGroup = (machine) => {
217
+ if (!machine || typeof machine !== 'object') return machine
218
+ const group = machine.group !== undefined && machine.group !== null
219
+ ? machine.group
220
+ : machine.environment
221
+ return { ...machine, group: group == null ? '' : String(group) }
222
+ }
223
+ /** Inverse of {@link withGroup} for a write patch. */
224
+ const withGroupPatch = (patch) => {
225
+ const body = { ...patch }
226
+ if (body.group !== undefined && body.environment === undefined) body.environment = body.group
227
+ delete body.group
228
+ return body
229
+ }
230
+
231
+ const listMachines = async () => {
232
+ const view = await need('listMachinesView')()
233
+ return {
234
+ machines: scrub(Array.isArray(view && view.machines) ? view.machines : []).map(withGroup),
235
+ currentId: view && view.currentId != null ? String(view.currentId) : null,
236
+ }
237
+ }
238
+
239
+ const upsertMachine = async (patch = {}) => {
240
+ const body = withGroupPatch({ ...(patch && typeof patch === 'object' ? patch : {}) })
241
+ let result
242
+ try {
243
+ result = await need('upsertMachineRecord')(body)
244
+ } catch (err) {
245
+ throw asApiError(err, { host: body.host, port: body.port })
246
+ }
247
+ emit('machines')
248
+ // Non-fatal problems the save could not throw for: the OS secure store
249
+ // refused the password, or ~/.ssh/config already names this host with a
250
+ // user-owned block. They are collected into one string list per contract.
251
+ const warnings = []
252
+ if (result && result.warning) {
253
+ warnings.push(String(result.warningDetail ? `${result.warning}: ${result.warningDetail}` : result.warning))
254
+ }
255
+ if (result && result.sshConfigWarning) warnings.push(String(result.sshConfigWarning))
256
+ return {
257
+ machine: withGroup(scrub(result && result.machine)),
258
+ machines: scrub(Array.isArray(result && result.machines) ? result.machines : []).map(withGroup),
259
+ currentId: result && result.currentId != null ? String(result.currentId) : null,
260
+ warnings,
261
+ }
262
+ }
263
+
264
+ const removeMachine = async (id) => {
265
+ let result
266
+ try {
267
+ result = await need('removeMachineRecord')(String(id == null ? '' : id))
268
+ } catch (err) {
269
+ throw asApiError(err)
270
+ }
271
+ emit('machines')
272
+ emit('status')
273
+ return {
274
+ machines: scrub(Array.isArray(result && result.machines) ? result.machines : []).map(withGroup),
275
+ currentId: result && result.currentId != null ? String(result.currentId) : null,
276
+ }
277
+ }
278
+
279
+ const setCurrent = async (id) => {
280
+ const clean = id == null || String(id).trim() === '' ? null : String(id).trim()
281
+ try {
282
+ await need('setCurrentForApi')(clean)
283
+ } catch (err) {
284
+ throw asApiError(err)
285
+ }
286
+ emit('status')
287
+ return status()
288
+ }
289
+
290
+ const connect = async (opts = {}) => {
291
+ const o = opts && typeof opts === 'object' ? opts : {}
292
+ let snapshot
293
+ try {
294
+ snapshot = await need('connectMachine')({
295
+ machineId: o.machineId == null ? undefined : String(o.machineId),
296
+ persist: o.persist !== false,
297
+ })
298
+ } catch (err) {
299
+ throw asApiError(err)
300
+ }
301
+ emit('status')
302
+ return asStatus(snapshot)
303
+ }
304
+
305
+ const probeMachine = async (patch = {}) => {
306
+ const body = { ...(patch && typeof patch === 'object' ? patch : {}) }
307
+ try {
308
+ return await need('probeMachineRecord')(body)
309
+ } catch (err) {
310
+ throw asApiError(err, { host: body.host, port: body.port })
311
+ }
312
+ }
313
+
314
+ /** Resolve which remote pool a listing should run against.
315
+ * Addressing priority (contract): machineId > the active machine. */
316
+ const bindingForDir = async (opts = {}) => {
317
+ const o = opts && typeof opts === 'object' ? opts : {}
318
+ try {
319
+ return await need('resolveDirBinding')({
320
+ machineId: o.machineId == null ? undefined : String(o.machineId),
321
+ sessionId: o.sessionId == null ? undefined : String(o.sessionId),
322
+ local: o.local == null ? undefined : String(o.local),
323
+ })
324
+ } catch (err) {
325
+ throw asApiError(err)
326
+ }
327
+ }
328
+
329
+ const listDir = async (opts = {}) => {
330
+ const o = opts && typeof opts === 'object' ? opts : {}
331
+ const binding = await bindingForDir(o)
332
+ const pool = binding && binding.pool
333
+ if (!pool || typeof pool.detect !== 'function') {
334
+ throw apiError('INTERNAL', 'dshRemote.listDir: no remote pool resolved')
335
+ }
336
+ const machine = {
337
+ id: (binding.machine && binding.machine.id) || '',
338
+ name: (binding.machine && binding.machine.name) || '',
339
+ host: (binding.machine && binding.machine.host) || '',
340
+ port: Number(binding.machine && binding.machine.port) || 22,
341
+ username: (binding.machine && binding.machine.username) || '',
342
+ }
343
+ try {
344
+ await pool.detect()
345
+ const win = pool.platform === 'windows'
346
+ const requested = o.path != null && String(o.path).trim() !== '' ? String(o.path) : (binding.ws || '')
347
+ const canon = normalizeRemotePath(requested)
348
+ // A Windows remote's filesystem root is a drive list, exactly like /ls.
349
+ if (canon === '/' || canon === '') {
350
+ if (win || pool.platform !== 'posix') {
351
+ const drives = await listRemoteDrives(pool)
352
+ if (drives.length) {
353
+ return {
354
+ path: '',
355
+ parent: null,
356
+ platform: 'windows',
357
+ items: drives.map((d) => ({
358
+ name: String(d.name || ''),
359
+ path: String(d.path || ''),
360
+ type: 'dir',
361
+ size: 0,
362
+ mtime: 0,
363
+ })),
364
+ machine,
365
+ }
366
+ }
367
+ }
368
+ if (win) return { path: '', parent: null, platform: 'windows', items: [], machine }
369
+ }
370
+ const sftp = await pool.sftp()
371
+ const out = await listDirStructured(sftp, canon)
372
+ const platform = win ? 'windows' : 'posix'
373
+ return {
374
+ path: toDisplayPath(out.path, pool.platform),
375
+ parent: parentOf(out.path, pool.platform),
376
+ platform,
377
+ items: out.items.map((it) => ({
378
+ name: String(it.name),
379
+ path: toDisplayPath(joinRemotePath(canon, it.name), pool.platform),
380
+ type: publicEntryType(it.type),
381
+ size: typeof it.size === 'number' ? it.size : 0,
382
+ mtime: typeof it.mtime === 'number' ? it.mtime : 0,
383
+ })),
384
+ machine,
385
+ }
386
+ } catch (err) {
387
+ throw asApiError(err, { host: machine.host, port: machine.port })
388
+ }
389
+ }
390
+
391
+ const listRemoteProjects = async () => {
392
+ const projects = await need('listRemoteProjects')()
393
+ return (Array.isArray(projects) ? projects : []).map((p) => ({
394
+ dir: String((p && p.dir) || ''),
395
+ host: String((p && p.host) || ''),
396
+ port: Number(p && p.port) || 22,
397
+ username: String((p && p.username) || ''),
398
+ remotePath: String((p && p.remotePath) || ''),
399
+ alias: String((p && p.alias) || ''),
400
+ name: String((p && p.name) || ''),
401
+ machineId: p && p.machineId != null ? String(p.machineId) : null,
402
+ }))
403
+ }
404
+
405
+ /** Shared body of ensureRemoteWorkspace / pickRemoteWorkspace.
406
+ * Deliberately NO SSH: it only touches the local mirror directory. */
407
+ const ensureWorkspace = async (opts = {}) => {
408
+ const o = opts && typeof opts === 'object' ? opts : {}
409
+ const raw = String(o.remotePath == null ? '' : o.remotePath).trim()
410
+ if (!raw) throw apiError('NOT_A_DIRECTORY', '远程路径不能为空')
411
+ const remotePath = normalizeRemotePath(raw)
412
+ let machine
413
+ try {
414
+ machine = await need('resolveMachine')(o.machineId == null ? undefined : String(o.machineId))
415
+ } catch (err) {
416
+ throw asApiError(err)
417
+ }
418
+ const extra = machine.alias ? { alias: machine.alias } : {}
419
+ let localMirror
420
+ try {
421
+ localMirror = await need('ensureMirror')(remotePath, machine.host, machine.username, machine.port, extra)
422
+ } catch (err) {
423
+ throw asApiError(err, { host: machine.host, port: machine.port })
424
+ }
425
+ const title = remoteWorkspaceTitle(remotePath, { name: machine.name, host: machine.host })
426
+ let workspace
427
+ if (o.register !== false) {
428
+ try {
429
+ const created = await need('registerMirroredWorkspace')(localMirror, remotePath, machine.record)
430
+ if (created && created.id != null) {
431
+ workspace = { id: String(created.id), title: String(created.title || title) }
432
+ }
433
+ } catch {
434
+ // Registration is explicitly best-effort in the host (older harness
435
+ // without ctx.workspaceRegistry must not fail the mirror); the mirror
436
+ // and its meta are already on disk.
437
+ }
438
+ }
439
+ return {
440
+ machineId: machine.machineId == null ? '' : String(machine.machineId),
441
+ remotePath,
442
+ localMirror,
443
+ title,
444
+ ...(workspace ? { workspace } : {}),
445
+ }
446
+ }
447
+
448
+ const ensureRemoteWorkspace = async (opts = {}) => {
449
+ const result = await ensureWorkspace(opts)
450
+ emit('status')
451
+ return result
452
+ }
453
+
454
+ const pickRemoteWorkspace = async (opts = {}) => {
455
+ const o = opts && typeof opts === 'object' ? opts : {}
456
+ const raw = String(o.remotePath == null ? '' : o.remotePath).trim()
457
+ if (!raw) throw apiError('NOT_A_DIRECTORY', '远程路径不能为空')
458
+ const remotePath = normalizeRemotePath(raw)
459
+ let okDir = false
460
+ try {
461
+ okDir = await need('checkRemoteDir')(remotePath, o.machineId == null ? undefined : String(o.machineId))
462
+ } catch (err) {
463
+ throw asApiError(err)
464
+ }
465
+ if (!okDir) throw apiError('NOT_A_DIRECTORY', `远程路径不是目录(或无法访问):${remotePath}`)
466
+ const result = await ensureWorkspace({
467
+ machineId: o.machineId,
468
+ remotePath,
469
+ register: o.registerWorkspace !== false,
470
+ })
471
+ emit('status')
472
+ return { ...result, status: await status() }
473
+ }
474
+
475
+ /** The workspace registry, when the composition serves one. */
476
+ const registryService = () => {
477
+ try {
478
+ const registry = need('workspaceRegistry')()
479
+ return registry && typeof registry === 'object' ? registry : undefined
480
+ } catch {
481
+ return undefined
482
+ }
483
+ }
484
+
485
+ /** Quick liveness probe: in-process only unless `deep` is requested. */
486
+ const liveCheck = async (sessionId, dir, opts = {}) => {
487
+ try {
488
+ const fn = deps.isSessionLive
489
+ if (typeof fn !== 'function') return false
490
+ return await fn(String(sessionId), String(dir), opts) === true
491
+ } catch {
492
+ return false
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Every local mirror (workspace) with its conversations — the read model the
498
+ * TUI's cleanup page lists.
499
+ *
500
+ * Read-only: no SSH, no mkdir. `live` is the cheap in-process answer; the
501
+ * deletion calls re-check with `{ deep: true }` (kernel lock file) before
502
+ * removing anything.
503
+ */
504
+ const listWorkspaceMirrors = async (opts = {}) => {
505
+ const o = opts && typeof opts === 'object' ? opts : {}
506
+ let root
507
+ let sessionsRoot
508
+ try {
509
+ root = String(need('workspaceRoot')())
510
+ sessionsRoot = String(need('sessionsRoot')())
511
+ } catch (err) {
512
+ throw asApiError(err)
513
+ }
514
+ const wantedMachine = o.machineId == null ? '' : String(o.machineId)
515
+ const projects = await need('listRemoteProjects')()
516
+ const view = await need('listMachinesView')()
517
+ const machines = Array.isArray(view && view.machines) ? view.machines : []
518
+ const registry = registryService()
519
+ let registered = []
520
+ try {
521
+ registered = typeof registry?.list === 'function' ? [...registry.list()] : []
522
+ } catch {
523
+ registered = []
524
+ }
525
+ const byPath = new Map()
526
+ for (const record of registered) {
527
+ if (record && typeof record.path === 'string') byPath.set(String(record.path), record)
528
+ }
529
+ const out = []
530
+ for (const project of projects) {
531
+ if (!project || !project.dir) continue
532
+ if (wantedMachine && String(project.machineId || '') !== wantedMachine) continue
533
+ const machine = machines.find((m) => m && String(m.id) === String(project.machineId))
534
+ || machines.find((m) => m && String(m.host) === String(project.host)
535
+ && (Number(m.port) || 22) === (Number(project.port) || 22)
536
+ && String(m.username || '') === String(project.username || ''))
537
+ if (wantedMachine && !machine) continue
538
+ let sessionList
539
+ try {
540
+ sessionList = listSessionDirs({ sessionsRoot, cwd: project.dir, cwds: [String(project.dir)] })
541
+ } catch {
542
+ sessionList = []
543
+ }
544
+ const sessions = []
545
+ for (const session of sessionList) {
546
+ sessions.push({
547
+ id: String(session.id || ''),
548
+ modifiedAt: Number(session.modifiedAt) || 0,
549
+ live: await liveCheck(session.id, session.dir),
550
+ })
551
+ }
552
+ let size
553
+ try {
554
+ size = dirSizeBytes(project.dir)
555
+ } catch {
556
+ size = { bytes: 0, truncated: false }
557
+ }
558
+ const recent = machine && Array.isArray(machine.recentWorkspaces)
559
+ ? machine.recentWorkspaces.includes(project.remotePath)
560
+ : false
561
+ out.push({
562
+ localMirror: String(project.dir),
563
+ remotePath: String(project.remotePath || ''),
564
+ host: String(project.host || ''),
565
+ port: Number(project.port) || 22,
566
+ username: String(project.username || ''),
567
+ alias: String(project.alias || ''),
568
+ machineId: project.machineId == null ? null : String(project.machineId),
569
+ machineName: machine ? String(machine.name || machine.host || '') : String(project.name || ''),
570
+ title: remoteWorkspaceTitle(String(project.remotePath || ''), {
571
+ name: machine && machine.name,
572
+ host: project.host,
573
+ }),
574
+ registered: byPath.has(String(project.dir)),
575
+ recent,
576
+ sessions,
577
+ sessionCount: sessions.length,
578
+ liveSessionCount: sessions.filter((s) => s.live).length,
579
+ mirrorBytes: Number(size.bytes) || 0,
580
+ mirrorSizeTruncated: size.truncated === true,
581
+ })
582
+ }
583
+ out.sort((left, right) => {
584
+ const byMachine = String(left.machineName).localeCompare(String(right.machineName))
585
+ if (byMachine !== 0) return byMachine
586
+ return String(left.remotePath).localeCompare(String(right.remotePath))
587
+ })
588
+ return out
589
+ }
590
+
591
+ /** Delete the sessions of one mirror (keeps the mirror + registration). */
592
+ const deleteWorkspaceSessions = async (opts = {}) => {
593
+ const o = opts && typeof opts === 'object' ? opts : {}
594
+ let root
595
+ let sessionsRoot
596
+ try {
597
+ root = String(need('workspaceRoot')())
598
+ sessionsRoot = String(need('sessionsRoot')())
599
+ } catch (err) {
600
+ throw asApiError(err)
601
+ }
602
+ let validated
603
+ try {
604
+ validated = validateMirror({ mirrorRoot: root, localMirror: o.localMirror })
605
+ } catch (err) {
606
+ throw asApiError(err)
607
+ }
608
+ const cwdAliases = [...new Set([validated.real, String(o.localMirror || '')].filter(Boolean))]
609
+ const report = await deleteSessionsFor({
610
+ sessionsRoot,
611
+ cwd: validated.real,
612
+ cwds: cwdAliases,
613
+ isLive: (id, dir) => liveCheck(id, dir, { deep: true }),
614
+ force: o.force === true,
615
+ })
616
+ emit('status')
617
+ return {
618
+ localMirror: validated.real,
619
+ remotePath: validated.meta.remotePath,
620
+ removedSessions: report.removed.length,
621
+ removedSessionIds: report.removed,
622
+ skippedLiveSessions: report.skippedLive,
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Permanently delete one remote workspace: its conversations, its workspace
628
+ * registration, its "recent" entry (so the provider cannot recreate it) and
629
+ * the local mirror directory itself.
630
+ *
631
+ * Refuses while any of its sessions is still live unless `force: true`.
632
+ */
633
+ const deleteRemoteWorkspace = async (opts = {}) => {
634
+ const o = opts && typeof opts === 'object' ? opts : {}
635
+ let root
636
+ let sessionsRoot
637
+ try {
638
+ root = String(need('workspaceRoot')())
639
+ sessionsRoot = String(need('sessionsRoot')())
640
+ } catch (err) {
641
+ throw asApiError(err)
642
+ }
643
+ let validated
644
+ try {
645
+ validated = validateMirror({ mirrorRoot: root, localMirror: o.localMirror })
646
+ } catch (err) {
647
+ throw asApiError(err)
648
+ }
649
+ const deleteSessions = o.deleteSessions !== false
650
+ const force = o.force === true
651
+ let pending = []
652
+ if (deleteSessions) {
653
+ try {
654
+ pending = listSessionDirs({ sessionsRoot, cwd: validated.real, cwds: [String(o.localMirror || '')] })
655
+ } catch {
656
+ pending = []
657
+ }
658
+ if (!force && pending.length > 0) {
659
+ const live = []
660
+ for (const session of pending) {
661
+ if (await liveCheck(session.id, session.dir, { deep: true })) live.push(session.id)
662
+ }
663
+ if (live.length > 0) {
664
+ const error = apiError(
665
+ 'WORKSPACE_BUSY',
666
+ `还有 ${live.length} 个会话正在使用该工作区,请先在会话管理里停止它们(或退出对应终端)再删除`,
667
+ )
668
+ error.liveSessions = live
669
+ throw error
670
+ }
671
+ }
672
+ }
673
+ let sessionReport = { removed: [], skippedLive: [] }
674
+ if (deleteSessions) {
675
+ const cwdAliases = [...new Set([validated.real, String(o.localMirror || '')].filter(Boolean))]
676
+ sessionReport = await deleteSessionsFor({
677
+ sessionsRoot,
678
+ cwd: validated.real,
679
+ cwds: cwdAliases,
680
+ isLive: (id, dir) => liveCheck(id, dir, { deep: true }),
681
+ force,
682
+ })
683
+ if (!force && sessionReport.skippedLive.length > 0) {
684
+ const error = apiError(
685
+ 'WORKSPACE_BUSY',
686
+ `还有 ${sessionReport.skippedLive.length} 个会话正在使用该工作区,请先停止后再删除`,
687
+ )
688
+ error.liveSessions = sessionReport.skippedLive
689
+ throw error
690
+ }
691
+ }
692
+ let removedRegistration = false
693
+ try {
694
+ const registry = registryService()
695
+ if (registry && typeof registry.delete === 'function') {
696
+ let record
697
+ if (typeof registry.resolveByPath === 'function') {
698
+ try {
699
+ record = await registry.resolveByPath(validated.real)
700
+ } catch {
701
+ record = undefined
702
+ }
703
+ }
704
+ if (!record && typeof registry.list === 'function') {
705
+ try {
706
+ record = [...registry.list()].find((entry) => entry && String(entry.path) === validated.real)
707
+ } catch {
708
+ record = undefined
709
+ }
710
+ }
711
+ if (record && record.id != null) {
712
+ removedRegistration = await registry.delete(record.id) === true
713
+ }
714
+ }
715
+ } catch {
716
+ removedRegistration = false
717
+ }
718
+ let forgotten = { recents: 0, current: 0 }
719
+ try {
720
+ const result = await need('forgetRemotePath')(validated.meta.remotePath)
721
+ if (result && typeof result === 'object') {
722
+ forgotten = { recents: Number(result.recents) || 0, current: Number(result.current) || 0 }
723
+ }
724
+ } catch {
725
+ forgotten = { recents: 0, current: 0 }
726
+ }
727
+ let removedMirror = false
728
+ try {
729
+ removedMirror = deleteMirrorTree({ mirrorRoot: root, localMirror: validated.real }) === true
730
+ } catch (err) {
731
+ throw asApiError(err)
732
+ }
733
+ emit('machines')
734
+ emit('status')
735
+ return {
736
+ localMirror: validated.real,
737
+ remotePath: validated.meta.remotePath,
738
+ removedMirror,
739
+ removedRegistration,
740
+ removedSessions: sessionReport.removed.length,
741
+ removedSessionIds: sessionReport.removed,
742
+ removedRecents: forgotten.recents,
743
+ clearedMachineWorkspace: forgotten.current,
744
+ }
745
+ }
746
+
747
+ /** Drop a mirror from every machine's recent list / current workspace only. */
748
+ const forgetWorkspaceRecent = async (opts = {}) => {
749
+ const o = opts && typeof opts === 'object' ? opts : {}
750
+ let root
751
+ try {
752
+ root = String(need('workspaceRoot')())
753
+ } catch (err) {
754
+ throw asApiError(err)
755
+ }
756
+ let validated
757
+ try {
758
+ validated = validateMirror({ mirrorRoot: root, localMirror: o.localMirror })
759
+ } catch (err) {
760
+ throw asApiError(err)
761
+ }
762
+ try {
763
+ const result = await need('forgetRemotePath')(validated.meta.remotePath)
764
+ emit('machines')
765
+ return {
766
+ localMirror: validated.real,
767
+ remotePath: validated.meta.remotePath,
768
+ removedRecents: Number(result && result.recents) || 0,
769
+ clearedMachineWorkspace: Number(result && result.current) || 0,
770
+ }
771
+ } catch (err) {
772
+ throw asApiError(err)
773
+ }
774
+ }
775
+
776
+ const subscribe = (listener) => {
777
+ if (typeof listener !== 'function') {
778
+ throw apiError('INTERNAL', 'dshRemote.subscribe: listener must be a function')
779
+ }
780
+ listeners.add(listener)
781
+ let active = true
782
+ return () => {
783
+ if (!active) return
784
+ active = false
785
+ listeners.delete(listener)
786
+ }
787
+ }
788
+
789
+ return {
790
+ apiVersion: API_VERSION,
791
+ listMachines,
792
+ upsertMachine,
793
+ removeMachine,
794
+ setCurrent,
795
+ connect,
796
+ probeMachine,
797
+ status,
798
+ listDir,
799
+ listRemoteProjects,
800
+ ensureRemoteWorkspace,
801
+ pickRemoteWorkspace,
802
+ listWorkspaceMirrors,
803
+ deleteWorkspaceSessions,
804
+ deleteRemoteWorkspace,
805
+ forgetWorkspaceRecent,
806
+ subscribe,
807
+ }
808
+ }