dsh-remote 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/paths.js CHANGED
@@ -127,6 +127,54 @@ export function toSftpPath(p) {
127
127
  return norm
128
128
  }
129
129
 
130
+ /**
131
+ * Convert a remote path to the form used in SHELL commands on the remote.
132
+ *
133
+ * On Windows remotes running Git Bash, native `D:\Code` (or `D:/Code`,
134
+ * `/D:/Code`, `/d/Code`) becomes the Git-Bash mount form `/d/Code`; a path
135
+ * already in Git-Bash form passes through unchanged. UNC (`\\server\share`)
136
+ * maps to `//server/share`. POSIX paths are normalized as-is. The conversion
137
+ * is syntactic, so it is safe on POSIX remotes too (only unambiguous Windows
138
+ * forms are rewritten).
139
+ */
140
+ export function toShellPath(p) {
141
+ let t = String(p || '').replace(/\\/g, '/')
142
+ const unc = t.match(/^\/\/([^/]+)(?:\/(.*))?$/)
143
+ if (unc) return '//' + unc[1] + (unc[2] ? '/' + unc[2] : '')
144
+ const drive = t.match(/^([a-zA-Z]):(\/|$)/) || t.match(/^\/([a-zA-Z]):(\/|$)/)
145
+ if (drive) {
146
+ t = '/' + drive[1].toLowerCase() + (drive[2] ? '/' : '') + t.slice(drive[0].length)
147
+ }
148
+ const parts = []
149
+ for (const seg of t.split('/')) {
150
+ if (seg === '' || seg === '.') continue
151
+ if (seg === '..') { parts.pop(); continue }
152
+ parts.push(seg)
153
+ }
154
+ return '/' + parts.join('/')
155
+ }
156
+
157
+ /**
158
+ * Display form of a path for a Windows remote: a Git-Bash mount `/d/Code` (or
159
+ * `/D:/Code`, `D:/Code`) renders as `D:\Code`; anything else (POSIX paths,
160
+ * native Windows paths) is returned normalized. POSIX remotes always pass
161
+ * through unchanged.
162
+ */
163
+ export function toDisplayPath(p, platform) {
164
+ if (platform !== 'windows') return normalizeRemotePath(p)
165
+ const t = String(p || '').replace(/\\/g, '/')
166
+ // /X:/rest (Win32-OpenSSH SFTP form)
167
+ const gs = t.match(/^\/([a-zA-Z]):\/(.*)$/)
168
+ if (gs) return gs[1].toUpperCase() + ':\\' + gs[2].replace(/\//g, '\\')
169
+ // /x/rest (Git-Bash mount form) and bare /x
170
+ const git = t.match(/^\/([a-zA-Z])(?:\/(.*))?$/)
171
+ if (git) return git[1].toUpperCase() + ':\\' + (git[2] || '').replace(/\//g, '\\')
172
+ // native X:/rest
173
+ const drive = t.match(/^([a-zA-Z]):(?:\/(.*))?$/)
174
+ if (drive) return drive[1].toUpperCase() + ':\\' + (drive[2] || '').replace(/\//g, '\\')
175
+ return t
176
+ }
177
+
130
178
  /** Basename of a remote absolute path (POSIX or Windows). */
131
179
  export function remotePathBase(p) {
132
180
  const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
@@ -0,0 +1,89 @@
1
+ // Machine-registry pure logic for dsh-remote.
2
+ //
3
+ // Issue #13: a SAVED machine is only a standby connection — it must never
4
+ // silently become the "active remote context" of every session. The registry
5
+ // therefore keeps `currentId` as an EXPLICIT user choice:
6
+ // • loadMachines never falls back to the first saved machine,
7
+ // • a stale currentId (machine deleted meanwhile) resolves to null,
8
+ // • add/update never auto-activate; only an explicit "set current" does,
9
+ // • deleting the current machine leaves NO current (no auto-promotion).
10
+ // Keeping this logic in one module makes the guarantee unit-testable and
11
+ // shared between the HTTP routes and the tools (rw_connect).
12
+
13
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
14
+ import path from 'node:path'
15
+
16
+ /** Load { list, currentId, explicitNone } from the registry file. */
17
+ export function loadMachines(file) {
18
+ try {
19
+ const j = JSON.parse(readFileSync(file, 'utf8'))
20
+ if (Array.isArray(j.list)) {
21
+ const list = j.list
22
+ // Explicit choice only — no fallback to the first machine, no activation
23
+ // of a stale/deleted id (issue #13).
24
+ const currentId = j.currentId && list.some((m) => m.id === j.currentId) ? j.currentId : null
25
+ // A persisted `currentId: null` is a deliberate "active remote = none"
26
+ // (the user cleared the current machine). It must survive restarts so a
27
+ // config-level default host cannot silently reactivate a context the user
28
+ // explicitly turned off. `explicitNone` is true when the file carries an
29
+ // explicit null (vs. a fresh file where currentId is simply absent).
30
+ const explicitNone = Object.prototype.hasOwnProperty.call(j, 'currentId') && j.currentId == null
31
+ return { list, currentId, explicitNone }
32
+ }
33
+ } catch {}
34
+ return { list: [], currentId: null, explicitNone: false }
35
+ }
36
+
37
+ /** Persist { list, currentId }. `currentId` may be null → active remote = none.
38
+ * Pass `keepCurrentKey: false` (add/update a machine without touching the
39
+ * current selection) to PRESERVE whatever `currentId` the file already holds
40
+ * — so merely saving a machine never flips a fresh registry into "explicit
41
+ * none", nor clears an explicit choice. When no previous file exists the
42
+ * `currentId` key is omitted entirely (fresh registry = "no choice yet"). */
43
+ export function saveMachines(file, list, currentId, keepCurrentKey = true) {
44
+ try { mkdirSync(path.dirname(file), { recursive: true }) } catch {}
45
+ let stored = currentId
46
+ let hasStored = keepCurrentKey
47
+ if (!keepCurrentKey) {
48
+ try {
49
+ const prev = JSON.parse(readFileSync(file, 'utf8'))
50
+ if (prev && Object.prototype.hasOwnProperty.call(prev, 'currentId')) {
51
+ stored = prev.currentId
52
+ hasStored = true
53
+ }
54
+ } catch { /* no previous file → omit the key */ }
55
+ }
56
+ const payload = { list }
57
+ if (hasStored) payload.currentId = stored
58
+ writeFileSync(file, JSON.stringify(payload, null, 2))
59
+ }
60
+
61
+ /** Strip secrets before sending a machine to the client. */
62
+ export function sanitizeMachine(m) {
63
+ if (!m) return m
64
+ const { password, proxy, ...rest } = m
65
+ const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
66
+ if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
67
+ return out
68
+ }
69
+
70
+ /** Apply a machine's fields onto the live config object (pool + tools read it). */
71
+ export function applyMachine(config, m) {
72
+ if (!m) return
73
+ config.host = m.host
74
+ config.port = Number(m.port) || 22
75
+ config.username = m.username || ''
76
+ config.password = m.password || ''
77
+ config.privateKeyPath = m.privateKeyPath || ''
78
+ config.passphrase = m.passphrase || ''
79
+ config.workspace = m.workspace || (config.workspace || '')
80
+ if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
81
+ if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
82
+ if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
83
+ config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
84
+ }
85
+
86
+ /** Mint a fresh machine id. */
87
+ export function machineId() {
88
+ return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6)
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 21 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",