dsh-remote 0.8.72 → 0.8.74
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 -21
- package/README.en.md +470 -389
- package/README.md +529 -462
- package/cordis.patch.yml +9 -9
- package/lib/binding.js +172 -172
- package/lib/client.js +4039 -4022
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -141
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/host-api.js +808 -0
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4923 -4541
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -893
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- 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/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/lib/workspace-cleanup.js +337 -0
- package/package.json +100 -99
package/lib/host-api.js
ADDED
|
@@ -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
|
+
}
|