dsh-remote 0.8.6 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +231 -220
- package/README.zh.md +187 -178
- package/cordis.patch.yml +53 -24
- package/lib/client.js +1310 -1188
- package/lib/credential.js +107 -107
- package/lib/errors.js +78 -78
- package/lib/forwards.js +219 -219
- package/lib/hostkey.js +48 -48
- package/lib/ignore.js +123 -123
- package/lib/index.js +266 -65
- package/lib/paths.js +169 -169
- package/lib/registry.js +89 -0
- package/lib/search.js +127 -127
- package/lib/sshconfig.js +57 -57
- package/lib/sync.js +270 -270
- package/lib/tasks.js +95 -95
- package/lib/update.js +196 -196
- package/package.json +66 -66
package/lib/index.js
CHANGED
|
@@ -42,6 +42,7 @@ import { searchTree } from './search.js'
|
|
|
42
42
|
import { TaskManager } from './tasks.js'
|
|
43
43
|
import { ForwardManager } from './forwards.js'
|
|
44
44
|
import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
|
|
45
|
+
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
|
|
45
46
|
|
|
46
47
|
const { Client } = ssh2
|
|
47
48
|
|
|
@@ -220,6 +221,9 @@ function migrateLegacyData() {
|
|
|
220
221
|
}
|
|
221
222
|
|
|
222
223
|
// ── persistent multi-machine registry ─────────────────────────────────────
|
|
224
|
+
// Pure registry logic lives in lib/registry.js (unit-tested): saved machines
|
|
225
|
+
// are STANDBY connections; only an explicit "set current" activates one.
|
|
226
|
+
// (issue #13 — Saved Connections != Active Remote Context)
|
|
223
227
|
const MACHINES_FILE = 'machines.json'
|
|
224
228
|
const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
|
|
225
229
|
const secretsDir = () => path.join(remoteWorkspacesRoot(), '.secrets')
|
|
@@ -227,40 +231,11 @@ const forwardsFile = () => path.join(remoteWorkspacesRoot(), 'forwards.json')
|
|
|
227
231
|
const auditFile = () => path.join(remoteWorkspacesRoot(), 'audit.log')
|
|
228
232
|
const ignoreFile = () => path.join(remoteWorkspacesRoot(), '.dsh-remote-ignore')
|
|
229
233
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return { list: [], currentId: null }
|
|
236
|
-
}
|
|
237
|
-
function saveMachines(list, currentId) {
|
|
238
|
-
try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
|
|
239
|
-
writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
|
|
240
|
-
}
|
|
241
|
-
function sanitizeMachine(m) {
|
|
242
|
-
if (!m) return m
|
|
243
|
-
const { password, proxy, ...rest } = m
|
|
244
|
-
const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
|
|
245
|
-
if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
|
|
246
|
-
return out
|
|
247
|
-
}
|
|
248
|
-
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
249
|
-
function applyMachine(config, m) {
|
|
250
|
-
if (!m) return
|
|
251
|
-
config.host = m.host
|
|
252
|
-
config.port = Number(m.port) || 22
|
|
253
|
-
config.username = m.username || ''
|
|
254
|
-
config.password = m.password || ''
|
|
255
|
-
config.privateKeyPath = m.privateKeyPath || ''
|
|
256
|
-
config.passphrase = m.passphrase || ''
|
|
257
|
-
config.workspace = m.workspace || (config.workspace || '')
|
|
258
|
-
if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
|
|
259
|
-
if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
|
|
260
|
-
if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
|
|
261
|
-
config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
|
|
262
|
-
}
|
|
263
|
-
function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
|
|
234
|
+
const loadMachines = () => _loadMachines(machinesFile())
|
|
235
|
+
const saveMachines = (list, currentId, keepCurrentKey) => _saveMachines(machinesFile(), list, currentId, keepCurrentKey)
|
|
236
|
+
const sanitizeMachine = _sanitizeMachine
|
|
237
|
+
const applyMachine = _applyMachine
|
|
238
|
+
const machineId = _machineId
|
|
264
239
|
|
|
265
240
|
// ── host-key registry (TOFU) ──────────────────────────────────────────────
|
|
266
241
|
const KNOWN_HOSTS_FILE = 'known_hosts.json'
|
|
@@ -753,6 +728,15 @@ export async function apply(ctx, config) {
|
|
|
753
728
|
}
|
|
754
729
|
|
|
755
730
|
const setCurrent = async (id) => {
|
|
731
|
+
if (!id) {
|
|
732
|
+
// Explicit "active remote = none": saved machines stay in the registry
|
|
733
|
+
// (issue #13) but nothing is bound to the pool anymore.
|
|
734
|
+
store.currentId = null
|
|
735
|
+
ephemeral = null
|
|
736
|
+
saveMachines(machines, null)
|
|
737
|
+
await clearActiveMachine()
|
|
738
|
+
return true
|
|
739
|
+
}
|
|
756
740
|
const i = machineIndex(id)
|
|
757
741
|
if (i < 0) return false
|
|
758
742
|
store.currentId = id
|
|
@@ -762,10 +746,41 @@ export async function apply(ctx, config) {
|
|
|
762
746
|
return true
|
|
763
747
|
}
|
|
764
748
|
|
|
765
|
-
|
|
749
|
+
/** Unbind the pool + live config from any machine: the plugin starts in a
|
|
750
|
+
* pure "no remote context" state (issue #13). The host stays configured for
|
|
751
|
+
* tool fallback, but no machine workspace leaks into this session. */
|
|
752
|
+
const clearActiveMachine = async () => {
|
|
753
|
+
ephemeral = null
|
|
754
|
+
if (!config.host) return
|
|
755
|
+
// Clear host + workspace directly (applyMachine keeps the old workspace
|
|
756
|
+
// when the incoming value is empty — it must not be used for a full reset).
|
|
757
|
+
config.host = ''
|
|
758
|
+
config.port = 22
|
|
759
|
+
config.username = ''
|
|
760
|
+
config.password = ''
|
|
761
|
+
config.workspace = ''
|
|
762
|
+
pool.setTarget({ host: '', port: 22, username: '', workspace: '' })
|
|
763
|
+
// Also stop any auto-push watcher bound to the old machine workspace.
|
|
764
|
+
// (autoPushWatchers is declared later; read it lazily to avoid TDZ.)
|
|
765
|
+
if (typeof autoPushWatchers !== 'undefined' && autoPushWatchers) {
|
|
766
|
+
for (const [, entry] of autoPushWatchers) {
|
|
767
|
+
if (!entry) continue
|
|
768
|
+
try { entry.watcher && entry.watcher.close() } catch {}
|
|
769
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
770
|
+
}
|
|
771
|
+
autoPushWatchers.clear()
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// If no stored current, adopt a CLI-provided default as the active machine —
|
|
776
|
+
// UNLESS the user explicitly cleared the current machine (currentId: null in
|
|
777
|
+
// the registry = "active remote = none", issue #13). A registry that exists
|
|
778
|
+
// with currentId null must stay inert; only a fresh/absent registry lets the
|
|
779
|
+
// config host serve as the bootstrap default.
|
|
766
780
|
{
|
|
767
781
|
const cur = currentMachine()
|
|
768
|
-
|
|
782
|
+
const registryExplicitNone = store.explicitNone
|
|
783
|
+
if (cur && cur.host && !store.currentId && !registryExplicitNone) applyMachine(config, cur)
|
|
769
784
|
}
|
|
770
785
|
|
|
771
786
|
/** Persist the active workspace on the machine the pool is actually bound to
|
|
@@ -980,9 +995,61 @@ export async function apply(ctx, config) {
|
|
|
980
995
|
|
|
981
996
|
// ── remote workspace state ────────────────────────────────────────────────
|
|
982
997
|
const wsPath = () => (config.workspace || '').trim()
|
|
998
|
+
/** Map a LOCAL path to the remote path its mirror represents ('' when the
|
|
999
|
+
* path is not inside any dsh-remote mirror). Shared by the resolve-mirror
|
|
1000
|
+
* route, the session-aware system-prompt injection, and status(). */
|
|
1001
|
+
const resolveMirrorForLocal = (local) => {
|
|
1002
|
+
const root = remoteWorkspacesRoot()
|
|
1003
|
+
const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || ''
|
|
1004
|
+
let matched = ''
|
|
1005
|
+
let matchedRemote = ''
|
|
1006
|
+
if (local && existsSync(root)) {
|
|
1007
|
+
const base = norm(local)
|
|
1008
|
+
for (const hostDir of readdirSync(root)) {
|
|
1009
|
+
const hostPath = path.join(root, hostDir)
|
|
1010
|
+
if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
|
|
1011
|
+
for (const mirrorDir of readdirSync(hostPath)) {
|
|
1012
|
+
const metaPath = path.join(hostPath, mirrorDir, '.dsh-remote-meta.json')
|
|
1013
|
+
if (!existsSync(metaPath)) continue
|
|
1014
|
+
try {
|
|
1015
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
1016
|
+
const mirrorAbs = norm(path.join(hostPath, mirrorDir))
|
|
1017
|
+
if (mirrorAbs === base || base.startsWith(mirrorAbs + path.sep) || base.startsWith(mirrorAbs + '/')) {
|
|
1018
|
+
if (mirrorAbs.length > matched.length) {
|
|
1019
|
+
matched = mirrorAbs
|
|
1020
|
+
matchedRemote = String(meta.remotePath || '')
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
} catch { /* skip unparsable meta */ }
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return { mirrorDir: matched || null, remotePath: matchedRemote || '' }
|
|
1028
|
+
}
|
|
983
1029
|
if (config.autoPush && wsPath()) {
|
|
984
1030
|
startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
|
|
985
1031
|
}
|
|
1032
|
+
/** The remote path the CURRENT agent session is really bound to, or '' when
|
|
1033
|
+
* the session is a plain local one. Walks the mirror registry (host side
|
|
1034
|
+
* copy of the resolve-mirror logic) so status / prompt / sidebar agree.
|
|
1035
|
+
* `sessionId` narrows to one session; without it the first live session's
|
|
1036
|
+
* cwd is used (best effort — status is informational, the per-session
|
|
1037
|
+
* resolve-mirror endpoint is authoritative). */
|
|
1038
|
+
const sessionRemotePath = (sessionId) => {
|
|
1039
|
+
let cwd = ''
|
|
1040
|
+
try {
|
|
1041
|
+
const sessions = ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null
|
|
1042
|
+
if (sessions && typeof sessions.get === 'function' && sessionId) {
|
|
1043
|
+
const s = sessions.get(sessionId)
|
|
1044
|
+
if (s && s.header && s.header.cwd) cwd = String(s.header.cwd)
|
|
1045
|
+
} else if (sessions && typeof sessions.list === 'function') {
|
|
1046
|
+
const s = sessions.list()[0]
|
|
1047
|
+
if (s && s.header && s.header.cwd) cwd = String(s.header.cwd)
|
|
1048
|
+
}
|
|
1049
|
+
} catch { /* sessions service unavailable */ }
|
|
1050
|
+
if (!cwd) return ''
|
|
1051
|
+
return resolveMirrorForLocal(cwd).remotePath
|
|
1052
|
+
}
|
|
986
1053
|
const status = () => ({
|
|
987
1054
|
host: config.host,
|
|
988
1055
|
port: config.port,
|
|
@@ -992,6 +1059,11 @@ export async function apply(ctx, config) {
|
|
|
992
1059
|
localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
|
|
993
1060
|
currentId: store.currentId || null,
|
|
994
1061
|
activeSource: ephemeral ? 'ephemeral' : (store.currentId ? 'machine' : (config.host ? 'config' : 'none')),
|
|
1062
|
+
// Issue #13: whether THIS session is in remote mode (cwd inside a mirror).
|
|
1063
|
+
// `none`/`local` means the plugin is present but no remote context is
|
|
1064
|
+
// active — saved machines stay standby-only.
|
|
1065
|
+
sessionMode: sessionRemotePath() ? 'remote' : (store.currentId ? 'standby' : 'local'),
|
|
1066
|
+
sessionRemotePath: sessionRemotePath(),
|
|
995
1067
|
machines: machines.map(sanitizeMachine),
|
|
996
1068
|
hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
|
|
997
1069
|
hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
|
|
@@ -1015,15 +1087,16 @@ export async function apply(ctx, config) {
|
|
|
1015
1087
|
defineTool({
|
|
1016
1088
|
name: 'rw_info',
|
|
1017
1089
|
description:
|
|
1018
|
-
'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity.',
|
|
1090
|
+
'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity. Note: remote context is session-scoped — a saved machine only becomes active when you explicitly rw_connect to it (issue #13).',
|
|
1019
1091
|
parameters: {},
|
|
1020
1092
|
output: textOut,
|
|
1021
1093
|
async execute() {
|
|
1022
1094
|
const s = status()
|
|
1023
1095
|
const lines = [
|
|
1024
1096
|
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}${s.activeSource !== 'machine' ? ` (source: ${s.activeSource})` : ''}`,
|
|
1025
|
-
`Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
|
|
1097
|
+
`Current remote workspace: ${s.workspace || '(none — call rw_connect then rw_pick_workspace to set one)'}`,
|
|
1026
1098
|
`Local mirror: ${s.localMirror || '(none)'}`,
|
|
1099
|
+
`Session remote context: ${s.sessionRemotePath ? s.sessionRemotePath : '(this session is LOCAL — no remote workspace bound)'}`,
|
|
1027
1100
|
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
1028
1101
|
`Active forwards: ${s.forwards.filter((f) => f.active).length} / ${s.forwards.length}`,
|
|
1029
1102
|
`Host key: ${s.hostKeyKnown ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
|
|
@@ -1689,19 +1762,30 @@ export async function apply(ctx, config) {
|
|
|
1689
1762
|
ctx.tools.register(t)
|
|
1690
1763
|
}
|
|
1691
1764
|
|
|
1692
|
-
// ── system-prompt injection:
|
|
1765
|
+
// ── system-prompt injection: session-aware remote workspace ──────────────
|
|
1766
|
+
// Issue #13: remote context must be SESSION-scoped, not machine-global.
|
|
1767
|
+
// The section text is evaluated per assembly with the current agent's
|
|
1768
|
+
// session; it is injected ONLY when that session's cwd actually maps to a
|
|
1769
|
+
// dsh-remote mirror (i.e. the user picked a remote workspace as the session
|
|
1770
|
+
// workspace). A plain local session gets no remote section — saved machines
|
|
1771
|
+
// never leak their workspace into an unrelated local session's prompt.
|
|
1772
|
+
// (resolveMirrorForLocal is defined above, next to wsPath.)
|
|
1693
1773
|
ctx.systemPrompt.section({
|
|
1694
1774
|
name: 'dsh-remote',
|
|
1695
1775
|
order: 88,
|
|
1696
|
-
text: () => {
|
|
1697
|
-
const
|
|
1698
|
-
|
|
1776
|
+
text: (promptContext) => {
|
|
1777
|
+
const agent = promptContext && promptContext.agent
|
|
1778
|
+
const session = agent && agent.session
|
|
1779
|
+
const cwd = session && session.header && session.header.cwd
|
|
1780
|
+
if (!cwd) return '' // no session cwd → nothing to map → stay quiet
|
|
1781
|
+
const { remotePath } = resolveMirrorForLocal(cwd)
|
|
1782
|
+
if (!remotePath) return '' // this session is NOT a remote session (issue #13)
|
|
1699
1783
|
const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
|
|
1700
1784
|
let extra = ''
|
|
1701
1785
|
if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
|
|
1702
1786
|
return (
|
|
1703
1787
|
'## Remote workspace\n' +
|
|
1704
|
-
`Current remote workspace: ${config.username}@${config.host}:${
|
|
1788
|
+
`Current remote workspace: ${config.username || 'user'}@${config.host}:${remotePath}\n` +
|
|
1705
1789
|
'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.' +
|
|
1706
1790
|
extra
|
|
1707
1791
|
)
|
|
@@ -1818,21 +1902,57 @@ export async function apply(ctx, config) {
|
|
|
1818
1902
|
throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
|
|
1819
1903
|
}
|
|
1820
1904
|
|
|
1905
|
+
/** The ctx.directoryPicker capability object, or null when the service is
|
|
1906
|
+
* absent/unusable. Never throws — callers fall through to pickLocalNative. */
|
|
1907
|
+
const localPickCapability = async () => {
|
|
1908
|
+
const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
|
|
1909
|
+
if (!dp || typeof dp.capability !== 'function') return null
|
|
1910
|
+
try {
|
|
1911
|
+
return await Promise.resolve(dp.capability())
|
|
1912
|
+
} catch {
|
|
1913
|
+
return null
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
/** Windows has no single filesystem root — enumerate fixed/mapped drive
|
|
1918
|
+
* letters so the in-app browser can switch between them (the browse
|
|
1919
|
+
* backend's crumbs stop at the current drive's root). Empty on POSIX. */
|
|
1920
|
+
const listLocalDrives = () => {
|
|
1921
|
+
if (process.platform !== 'win32') return []
|
|
1922
|
+
const out = []
|
|
1923
|
+
for (let i = 67; i <= 90; i++) { // C..Z (A/B are legacy floppy — skip)
|
|
1924
|
+
const root = String.fromCharCode(i) + ':\\'
|
|
1925
|
+
try {
|
|
1926
|
+
statSync(root)
|
|
1927
|
+
out.push({ name: root.slice(0, -1), path: root })
|
|
1928
|
+
} catch {}
|
|
1929
|
+
}
|
|
1930
|
+
return out
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1821
1933
|
// ── route helpers ─────────────────────────────────────────────────────────
|
|
1822
1934
|
const routes = [
|
|
1823
1935
|
{
|
|
1824
1936
|
kind: 'exact',
|
|
1825
1937
|
path: '/dsh-remote/status',
|
|
1826
1938
|
handler: async (req, res) => {
|
|
1827
|
-
if (req.method === 'GET')
|
|
1939
|
+
if (req.method === 'GET') {
|
|
1940
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1941
|
+
const sessionId = q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : ''
|
|
1942
|
+
// Per-session remote context (issue #13): when a sessionId is given,
|
|
1943
|
+
// sessionMode reflects THAT session's cwd, not the machine default.
|
|
1944
|
+
return sendJson(res, 200, sessionId ? { ...status(), sessionMode: sessionRemotePath(sessionId) ? 'remote' : 'local', sessionRemotePath: sessionRemotePath(sessionId) } : status())
|
|
1945
|
+
}
|
|
1828
1946
|
sendJson(res, 405, { error: 'method not allowed' })
|
|
1829
1947
|
},
|
|
1830
1948
|
},
|
|
1831
1949
|
{
|
|
1832
1950
|
// Map a LOCAL path (typically a session cwd that sits inside a remote
|
|
1833
1951
|
// mirror dir) to the remote path it mirrors, by reading each mirror
|
|
1834
|
-
// dir's .dsh-remote-meta.json.
|
|
1835
|
-
//
|
|
1952
|
+
// dir's .dsh-remote-meta.json. NO machine-workspace fallback (issue #13):
|
|
1953
|
+
// a session whose cwd is not inside any mirror is a pure LOCAL session —
|
|
1954
|
+
// the sidebar must show "no remote workspace", never another machine's
|
|
1955
|
+
// remembered default.
|
|
1836
1956
|
// Accepts either ?local=<abs path> or ?sessionId=<id> (resolved via
|
|
1837
1957
|
// the host sessions service header.cwd).
|
|
1838
1958
|
kind: 'exact',
|
|
@@ -1881,7 +2001,6 @@ export async function apply(ctx, config) {
|
|
|
1881
2001
|
} catch { /* session log scan failed */ }
|
|
1882
2002
|
}
|
|
1883
2003
|
}
|
|
1884
|
-
const remote = status().workspace || ''
|
|
1885
2004
|
const root = remoteWorkspacesRoot()
|
|
1886
2005
|
const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || ''
|
|
1887
2006
|
let matched = ''
|
|
@@ -1908,8 +2027,16 @@ export async function apply(ctx, config) {
|
|
|
1908
2027
|
}
|
|
1909
2028
|
}
|
|
1910
2029
|
}
|
|
1911
|
-
|
|
1912
|
-
|
|
2030
|
+
// Issue #13: a non-mirror session is LOCAL — remotePath stays empty.
|
|
2031
|
+
const remotePath = matchedRemote || ''
|
|
2032
|
+
return sendJson(res, 200, {
|
|
2033
|
+
local, remotePath, mirrorDir: matched || null,
|
|
2034
|
+
// `fallback:true` now means "this session is NOT a remote session"
|
|
2035
|
+
// (used to mean "fell back to machine workspace").
|
|
2036
|
+
fallback: !matchedRemote,
|
|
2037
|
+
mode: matchedRemote ? 'remote' : 'local',
|
|
2038
|
+
resolvedVia,
|
|
2039
|
+
})
|
|
1913
2040
|
} catch (err) {
|
|
1914
2041
|
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
1915
2042
|
}
|
|
@@ -2180,25 +2307,35 @@ export async function apply(ctx, config) {
|
|
|
2180
2307
|
try {
|
|
2181
2308
|
let outcome = null
|
|
2182
2309
|
let via = 'service'
|
|
2183
|
-
const
|
|
2184
|
-
if (
|
|
2310
|
+
const cap = await localPickCapability()
|
|
2311
|
+
if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
|
|
2185
2312
|
try {
|
|
2186
|
-
const
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
pickAbort.abort()
|
|
2191
|
-
outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
|
|
2192
|
-
}
|
|
2313
|
+
const pickAbort = new AbortController()
|
|
2314
|
+
const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
|
|
2315
|
+
pickAbort.abort()
|
|
2316
|
+
outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
|
|
2193
2317
|
} catch (err) {
|
|
2194
2318
|
outcome = null
|
|
2195
2319
|
}
|
|
2196
2320
|
}
|
|
2321
|
+
// Prefer a REAL OS dialog over the browse backend: operators expect
|
|
2322
|
+
// an Explorer-style chooser, and the own spawn (PowerShell
|
|
2323
|
+
// FolderBrowserDialog / osascript / zenity-kdialog) works even where
|
|
2324
|
+
// the host mounted browse — DSH Desktop (Electron) deliberately
|
|
2325
|
+
// mounts browse on win32 because the native backend's koffi dialog
|
|
2326
|
+
// worker cannot run under Electron, but a plain child process can.
|
|
2197
2327
|
if (!outcome) {
|
|
2198
2328
|
via = 'own'
|
|
2199
2329
|
try {
|
|
2200
2330
|
outcome = await pickLocalNative()
|
|
2201
2331
|
} catch (err) {
|
|
2332
|
+
// No usable OS dialog (headless host, missing chooser binary):
|
|
2333
|
+
// fall back to the host's browse capability — fs-only, works
|
|
2334
|
+
// display-less — served as an in-app browser by the client
|
|
2335
|
+
// (backed by /dsh-remote/local-list + /dsh-remote/local-mkdir).
|
|
2336
|
+
if (cap && cap.kind === 'browse' && typeof cap.list === 'function') {
|
|
2337
|
+
return sendJson(res, 200, { ok: true, kind: 'browse', via: 'browse' })
|
|
2338
|
+
}
|
|
2202
2339
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
|
|
2203
2340
|
}
|
|
2204
2341
|
}
|
|
@@ -2209,6 +2346,51 @@ export async function apply(ctx, config) {
|
|
|
2209
2346
|
}
|
|
2210
2347
|
},
|
|
2211
2348
|
},
|
|
2349
|
+
{
|
|
2350
|
+
kind: 'exact',
|
|
2351
|
+
path: '/dsh-remote/local-list',
|
|
2352
|
+
handler: async (req, res) => {
|
|
2353
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2354
|
+
try {
|
|
2355
|
+
const cap = await localPickCapability()
|
|
2356
|
+
if (!cap || cap.kind !== 'browse' || typeof cap.list !== 'function') return sendJson(res, 400, { ok: false, error: '当前目录选择器不是浏览后端,无法列出本机目录' })
|
|
2357
|
+
const m = (req.url || '').match(/path=([^&]*)/)
|
|
2358
|
+
const raw = m ? decodeURIComponent(m[1]) : ''
|
|
2359
|
+
// No path → the browse backend lists the host home directory.
|
|
2360
|
+
try {
|
|
2361
|
+
const out = await Promise.resolve(cap.list(raw ? raw : undefined))
|
|
2362
|
+
return sendJson(res, 200, { ok: true, ...out, drives: listLocalDrives() })
|
|
2363
|
+
} catch (listErr) {
|
|
2364
|
+
return sendJson(res, 400, { ok: false, error: String((listErr && listErr.message) || listErr), code: (listErr && listErr.code) || 'directory-unreadable' })
|
|
2365
|
+
}
|
|
2366
|
+
} catch (err) {
|
|
2367
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2368
|
+
}
|
|
2369
|
+
},
|
|
2370
|
+
},
|
|
2371
|
+
{
|
|
2372
|
+
kind: 'exact',
|
|
2373
|
+
path: '/dsh-remote/local-mkdir',
|
|
2374
|
+
handler: async (req, res) => {
|
|
2375
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2376
|
+
try {
|
|
2377
|
+
const cap = await localPickCapability()
|
|
2378
|
+
if (!cap || cap.kind !== 'browse' || typeof cap.createDirectory !== 'function') return sendJson(res, 400, { ok: false, error: '当前目录选择器不是浏览后端,无法新建文件夹' })
|
|
2379
|
+
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
2380
|
+
const p = String(payload.path || '')
|
|
2381
|
+
const name = String(payload.name || '')
|
|
2382
|
+
if (!p.trim() || !name.trim()) return sendJson(res, 400, { ok: false, error: 'path and name required' })
|
|
2383
|
+
try {
|
|
2384
|
+
const created = await Promise.resolve(cap.createDirectory(p, name))
|
|
2385
|
+
return sendJson(res, 200, { ok: true, path: created })
|
|
2386
|
+
} catch (mkErr) {
|
|
2387
|
+
return sendJson(res, 400, { ok: false, error: String((mkErr && mkErr.message) || mkErr), code: (mkErr && mkErr.code) || 'directory-create-failed' })
|
|
2388
|
+
}
|
|
2389
|
+
} catch (err) {
|
|
2390
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2391
|
+
}
|
|
2392
|
+
},
|
|
2393
|
+
},
|
|
2212
2394
|
{
|
|
2213
2395
|
kind: 'exact',
|
|
2214
2396
|
path: '/dsh-remote/machines',
|
|
@@ -2264,9 +2446,13 @@ export async function apply(ctx, config) {
|
|
|
2264
2446
|
rec.password = prev.password
|
|
2265
2447
|
}
|
|
2266
2448
|
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2449
|
+
// Issue #13: saving a machine must NOT make it the active
|
|
2450
|
+
// remote context. `currentId` stays untouched on add/update —
|
|
2451
|
+
// only an explicit "设为当前" (or rw_connect) activates one.
|
|
2452
|
+
// keepCurrentKey:false leaves the stored `currentId` byte-for-byte
|
|
2453
|
+
// alone, so a fresh registry stays "no choice yet" (not an
|
|
2454
|
+
// explicit none) and a previously-cleared registry stays cleared.
|
|
2455
|
+
saveMachines(machines, store.currentId, false)
|
|
2270
2456
|
return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
2271
2457
|
}
|
|
2272
2458
|
if (action === 'delete') {
|
|
@@ -2277,9 +2463,17 @@ export async function apply(ctx, config) {
|
|
|
2277
2463
|
await deleteSecret(m.id, secretsDir()).catch(() => {})
|
|
2278
2464
|
}
|
|
2279
2465
|
machines.splice(i, 1)
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2466
|
+
const wasCurrent = store.currentId === m.id
|
|
2467
|
+
if (wasCurrent) {
|
|
2468
|
+
// Issue #13: deleting the current machine leaves NO active
|
|
2469
|
+
// remote context (never auto-promote a sibling — that would
|
|
2470
|
+
// make an unrelated machine leak into the session again).
|
|
2471
|
+
store.currentId = null
|
|
2472
|
+
saveMachines(machines, null)
|
|
2473
|
+
await clearActiveMachine()
|
|
2474
|
+
} else {
|
|
2475
|
+
saveMachines(machines, store.currentId)
|
|
2476
|
+
}
|
|
2283
2477
|
return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
2284
2478
|
}
|
|
2285
2479
|
return sendJson(res, 400, { ok: false, error: 'unknown action' })
|
|
@@ -2339,7 +2533,14 @@ export async function apply(ctx, config) {
|
|
|
2339
2533
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2340
2534
|
try {
|
|
2341
2535
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2342
|
-
const
|
|
2536
|
+
const id = String(body.id || '').trim()
|
|
2537
|
+
// Issue #13: an empty id is an explicit "active remote = none" —
|
|
2538
|
+
// saved machines stay in the registry but nothing is current.
|
|
2539
|
+
if (!id) {
|
|
2540
|
+
await setCurrent('')
|
|
2541
|
+
return sendJson(res, 200, { ok: true, currentId: null, ...status() })
|
|
2542
|
+
}
|
|
2543
|
+
const okSet = await setCurrent(id)
|
|
2343
2544
|
if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
2344
2545
|
return sendJson(res, 200, { ok: true, ...status() })
|
|
2345
2546
|
} catch (err) {
|