dsh-remote 0.8.22 → 0.8.24
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/client.js +210 -51
- package/lib/index.js +176 -21
- package/lib/pool.js +49 -18
- package/lib/routes-fs.js +14 -2
- package/lib/update.js +185 -11
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -139,8 +139,10 @@ window.__ModuleLoader__.load({
|
|
|
139
139
|
'settings.updateFail': '检查更新失败',
|
|
140
140
|
'settings.updateAvailable': '发现新版本 v{version}',
|
|
141
141
|
'settings.updateLatest': '已是最新版本(v{version})',
|
|
142
|
-
'settings.updateConfirm': '将更新 dsh-remote 到 v{version}
|
|
143
|
-
'settings.updateApplied': '✅ 已更新到 v{to}({from} → {to}
|
|
142
|
+
'settings.updateConfirm': '将更新 dsh-remote 到 v{version},替换本机插件文件并在线热切换宿主半(不重启 Harness)。继续?',
|
|
143
|
+
'settings.updateApplied': '✅ 已更新到 v{to}({from} → {to}),宿主半已在线热切换。',
|
|
144
|
+
'settings.updateAppliedNoReload': '✅ 已更新到 v{to}({from} → {to})。重启 Harness 生效。',
|
|
145
|
+
'settings.updatePendingReload': '⚠️ 磁盘已是 v{disk},运行中的仍是 v{loaded};点「立即更新」或重启 Harness 完成切换。',
|
|
144
146
|
'settings.updateFailed': '更新失败',
|
|
145
147
|
'settings.updateFailedMsg': '更新失败: {msg}',
|
|
146
148
|
'settings.registryUnreachable': '无法连接 npm registry',
|
|
@@ -363,8 +365,10 @@ window.__ModuleLoader__.load({
|
|
|
363
365
|
'settings.updateFail': 'Failed to check for updates',
|
|
364
366
|
'settings.updateAvailable': 'New version found: v{version}',
|
|
365
367
|
'settings.updateLatest': 'Already up to date (v{version})',
|
|
366
|
-
'settings.updateConfirm': 'Update dsh-remote to v{version}? This replaces the local plugin files and
|
|
367
|
-
'settings.updateApplied': '✅ Updated to v{to} ({from} → {to})
|
|
368
|
+
'settings.updateConfirm': 'Update dsh-remote to v{version}? This replaces the local plugin files and hot-swaps the host half in place (no Harness restart). Continue?',
|
|
369
|
+
'settings.updateApplied': '✅ Updated to v{to} ({from} → {to}); the host half hot-swapped in place.',
|
|
370
|
+
'settings.updateAppliedNoReload': '✅ Updated to v{to} ({from} → {to}). Restart Harness to apply.',
|
|
371
|
+
'settings.updatePendingReload': '⚠️ v{disk} is on disk but v{loaded} is still running; update again or restart Harness to switch.',
|
|
368
372
|
'settings.updateFailed': 'Update failed',
|
|
369
373
|
'settings.updateFailedMsg': 'Update failed: {msg}',
|
|
370
374
|
'settings.registryUnreachable': 'Cannot reach the npm registry',
|
|
@@ -836,9 +840,13 @@ window.__ModuleLoader__.load({
|
|
|
836
840
|
setUpdBusy(true); setUpdMsg('')
|
|
837
841
|
api('POST', '/dsh-remote/update-apply', { version: upd.latest })
|
|
838
842
|
.then((r) => {
|
|
839
|
-
if (r && r.ok) setUpdMsg(tr('settings.updateApplied', { to: r.to, from: (r.from || '') }))
|
|
843
|
+
if (r && r.ok) setUpdMsg(tr(r.reloadScheduled ? 'settings.updateApplied' : 'settings.updateAppliedNoReload', { to: r.to, from: (r.from || '') }))
|
|
840
844
|
else setUpdMsg((r && r.error) || tr('settings.updateFailed'))
|
|
841
|
-
|
|
845
|
+
// The host half swaps itself ~300ms after answering, so the panel
|
|
846
|
+
// must not re-check immediately: it would still be talking to the
|
|
847
|
+
// outgoing fiber and report the pre-swap version, i.e. "update
|
|
848
|
+
// available" right after a successful update.
|
|
849
|
+
window.setTimeout(() => checkUpdate(true), 2000)
|
|
842
850
|
})
|
|
843
851
|
.catch((e) => setUpdMsg(tr('settings.updateFailedMsg', { msg: String((e && e.message) || e) })))
|
|
844
852
|
.finally(() => setUpdBusy(false))
|
|
@@ -1127,6 +1135,10 @@ window.__ModuleLoader__.load({
|
|
|
1127
1135
|
}, m === 'manual' ? tr('settings.modeManual') : m === 'auto' ? tr('settings.modeAuto') : tr('settings.modeOff')),
|
|
1128
1136
|
),
|
|
1129
1137
|
),
|
|
1138
|
+
upd && upd.pendingReload
|
|
1139
|
+
? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.warn } },
|
|
1140
|
+
tr('settings.updatePendingReload', { disk: upd.disk, loaded: upd.loaded }))
|
|
1141
|
+
: null,
|
|
1130
1142
|
updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
|
|
1131
1143
|
),
|
|
1132
1144
|
React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11, opacity: 0.65, borderTop: '1px solid ' + T.border, paddingTop: 10, marginTop: 4 } },
|
|
@@ -1213,9 +1225,25 @@ window.__ModuleLoader__.load({
|
|
|
1213
1225
|
const [mkdirPrompt, setMkdirPrompt] = React.useState(false)
|
|
1214
1226
|
const suggestTimer = React.useRef(null)
|
|
1215
1227
|
const suggestRef = React.useRef(null)
|
|
1228
|
+
// Autocomplete performance (slow per-keystroke remote scans):
|
|
1229
|
+
// - postedCurrentRef/currentPromiseRef: POST /current once per machine
|
|
1230
|
+
// switch; concurrent callers share ONE in-flight promise and a failed
|
|
1231
|
+
// switch REJECTS (never run /ls — or cache its listing — on the wrong
|
|
1232
|
+
// machine)
|
|
1233
|
+
// - lsCacheRef/lsInflightRef: one /ls per (machine, dir), TTL-cached
|
|
1234
|
+
// - lsGenRef/fetchSeqRef: per-key fetch generation — a cache write from
|
|
1235
|
+
// a superseded fetch (fresh navigation, picker reopen) is dropped
|
|
1236
|
+
// - suggestSeqRef: drop stale suggestion responses (older keystrokes)
|
|
1237
|
+
const postedCurrentRef = React.useRef('')
|
|
1238
|
+
const currentPromiseRef = React.useRef(null)
|
|
1239
|
+
const lsCacheRef = React.useRef(new Map())
|
|
1240
|
+
const lsInflightRef = React.useRef(new Map())
|
|
1241
|
+
const lsGenRef = React.useRef(new Map())
|
|
1242
|
+
const fetchSeqRef = React.useRef(0)
|
|
1243
|
+
const suggestSeqRef = React.useRef(0)
|
|
1216
1244
|
React.useEffect(() => {
|
|
1217
1245
|
if (!suggestOpen) return
|
|
1218
|
-
const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
|
|
1246
|
+
const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) { suggestSeqRef.current++; setSuggestOpen(false) } }
|
|
1219
1247
|
document.addEventListener('mousedown', onDown)
|
|
1220
1248
|
return () => document.removeEventListener('mousedown', onDown)
|
|
1221
1249
|
}, [suggestOpen])
|
|
@@ -1247,26 +1275,125 @@ window.__ModuleLoader__.load({
|
|
|
1247
1275
|
return b.replace(/[\\/]+$/, '') + sep + n
|
|
1248
1276
|
}
|
|
1249
1277
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1278
|
+
// Identity of the machine a listing was answered for (the /ls response
|
|
1279
|
+
// echoes it), checked against the machine the fetch INTENDED. The picker
|
|
1280
|
+
// memoizes "machine already active", but the settings page can switch
|
|
1281
|
+
// machines too — validate every listing before trusting the memo. A
|
|
1282
|
+
// machine may show up as its ~/.ssh/config alias OR its resolved host, so
|
|
1283
|
+
// accept either identity; nothing to compare against → accept.
|
|
1284
|
+
const echoMatches = (id, echo) => {
|
|
1285
|
+
if (!echo || !echo.host) return true // older host half without an echo
|
|
1286
|
+
const m = (machines || []).find((x) => x && x.id === id)
|
|
1287
|
+
if (!m) return true
|
|
1288
|
+
const cands = [{ host: String(m.host || ''), port: Number(m.port) || 22, username: String(m.username || '') }]
|
|
1289
|
+
const r = m.sshConfigResolved
|
|
1290
|
+
if (r && r.host) cands.push({ host: String(r.host), port: Number(r.port) || 22, username: String(r.username || m.username || '') })
|
|
1291
|
+
return cands.some((c) => c.host === String(echo.host) && c.port === (Number(echo.port) || 22) && c.username === String(echo.username || ''))
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// POST /current once per machine switch. The picker used to post it before
|
|
1295
|
+
// EVERY /ls; the host re-applies the machine on that route, which used to
|
|
1296
|
+
// close the live SSH connection — so each autocomplete keystroke paid a
|
|
1297
|
+
// full reconnect. The memo is set only on SUCCESS and concurrent callers
|
|
1298
|
+
// share ONE in-flight promise, so no request can overtake a pending switch
|
|
1299
|
+
// and read the OLD machine's listing. A failed switch REJECTS: the
|
|
1300
|
+
// caller's fetch chain aborts WITHOUT writing the cache.
|
|
1301
|
+
const ensureCurrent = (id) => {
|
|
1302
|
+
if (!id) return Promise.resolve()
|
|
1303
|
+
if (postedCurrentRef.current === id) return Promise.resolve()
|
|
1304
|
+
const inflight = currentPromiseRef.current
|
|
1305
|
+
if (inflight) {
|
|
1306
|
+
if (inflight.id === id) return inflight.promise
|
|
1307
|
+
// Serialize switches: ours must land AFTER any pending one (the host
|
|
1308
|
+
// applies whichever POST arrives last), never race it.
|
|
1309
|
+
return inflight.promise.then(() => ensureCurrent(id), () => ensureCurrent(id))
|
|
1310
|
+
}
|
|
1311
|
+
const p = api('POST', '/dsh-remote/current', { id }).then(() => {
|
|
1312
|
+
postedCurrentRef.current = id
|
|
1313
|
+
})
|
|
1314
|
+
const entry = { id, promise: p }
|
|
1315
|
+
currentPromiseRef.current = entry
|
|
1316
|
+
const clear = () => { if (currentPromiseRef.current === entry) currentPromiseRef.current = null }
|
|
1317
|
+
p.then(clear, clear)
|
|
1318
|
+
return p
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const LS_TTL_MS = 30000
|
|
1322
|
+
// Cached remote listing per (machine, dir): typing inside one parent
|
|
1323
|
+
// directory used to re-fetch the identical list on every keystroke.
|
|
1324
|
+
// `fresh` bypasses the cache (browse navigation / post-mkdir refresh) and
|
|
1325
|
+
// SUPERSEDES older fetches for the key: a cache write from a superseded
|
|
1326
|
+
// fetch is dropped (it still resolves its own callers), so a pre-mkdir
|
|
1327
|
+
// listing can never overwrite the post-mkdir one. Concurrent identical
|
|
1328
|
+
// requests share one in-flight fetch.
|
|
1329
|
+
const fetchDirList = (id, dirParam, fresh) => {
|
|
1330
|
+
const key = String(id) + '\u0000' + String(dirParam)
|
|
1331
|
+
const hit = lsCacheRef.current.get(key)
|
|
1332
|
+
if (!fresh && hit && Date.now() - hit.at < LS_TTL_MS) return Promise.resolve(hit.node)
|
|
1333
|
+
const inflight = fresh ? null : lsInflightRef.current.get(key)
|
|
1334
|
+
if (inflight) return inflight
|
|
1335
|
+
const gen = ++fetchSeqRef.current
|
|
1336
|
+
lsGenRef.current.set(key, gen)
|
|
1337
|
+
const toNode = (res) => {
|
|
1338
|
+
const real = res && res.path ? res.path : String(dirParam)
|
|
1258
1339
|
// Server returns [{ type, name, size, mtime, path }] where `path` is
|
|
1259
1340
|
// the FULL display-form path (C:\Users\dev on Windows) — the client
|
|
1260
1341
|
// never joins paths itself, so drives and Windows paths just work.
|
|
1261
1342
|
const list = Array.isArray(res && res.items)
|
|
1262
1343
|
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime, path: it.path || joinDisplay(real, it.name), drive: !!it.drive }))
|
|
1263
1344
|
: parseLs(res && res.text)
|
|
1264
|
-
|
|
1345
|
+
return {
|
|
1346
|
+
path: real,
|
|
1347
|
+
platform: !!(res && res.platform === 'windows'),
|
|
1348
|
+
dirs: sortItems(list.filter((it) => it.dir)),
|
|
1349
|
+
all: sortItems(list),
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const run = (retried) => ensureCurrent(id)
|
|
1353
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(dirParam)))
|
|
1354
|
+
.then((res) => {
|
|
1355
|
+
// External-switch defense: on an identity-echo mismatch the host is
|
|
1356
|
+
// no longer on the machine this fetch intended — bust the memo, force
|
|
1357
|
+
// one re-switch and retry ONCE. A mismatched listing is never cached.
|
|
1358
|
+
if (!echoMatches(id, res && res.machine)) {
|
|
1359
|
+
if (retried) return toNode(res)
|
|
1360
|
+
postedCurrentRef.current = ''
|
|
1361
|
+
return run(true)
|
|
1362
|
+
}
|
|
1363
|
+
const node = toNode(res)
|
|
1364
|
+
if (lsGenRef.current.get(key) === gen) lsCacheRef.current.set(key, { at: Date.now(), node })
|
|
1365
|
+
return node
|
|
1366
|
+
})
|
|
1367
|
+
const p = run(false)
|
|
1368
|
+
.finally(() => { if (lsInflightRef.current.get(key) === p) lsInflightRef.current.delete(key) })
|
|
1369
|
+
lsInflightRef.current.set(key, p)
|
|
1370
|
+
return p
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// Fresh session on every open: forget everything a previous open left
|
|
1374
|
+
// behind. A stale in-flight response must not repopulate the cache
|
|
1375
|
+
// (generation fence) and a stale suggestion response must not reopen the
|
|
1376
|
+
// dropdown on the now-live tree.
|
|
1377
|
+
const resetPickerFetchState = () => {
|
|
1378
|
+
postedCurrentRef.current = ''
|
|
1379
|
+
lsCacheRef.current.clear()
|
|
1380
|
+
lsInflightRef.current.clear()
|
|
1381
|
+
lsGenRef.current.clear()
|
|
1382
|
+
suggestSeqRef.current++
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const loadLevels = (id, p, toIndex) => {
|
|
1386
|
+
if (!id) return
|
|
1387
|
+
setLoading(true); setErr('')
|
|
1388
|
+
fetchDirList(id, p || '', true)
|
|
1389
|
+
.then((node) => {
|
|
1390
|
+
setWin(node.platform)
|
|
1391
|
+
const level = { path: node.path, dirs: node.dirs, all: node.all }
|
|
1265
1392
|
setLevels((prev) => {
|
|
1266
1393
|
const base = prev && prev.length ? prev.slice() : []
|
|
1267
1394
|
let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
|
|
1268
|
-
if (idx >= base.length) return base.concat([
|
|
1269
|
-
base[idx] =
|
|
1395
|
+
if (idx >= base.length) return base.concat([level])
|
|
1396
|
+
base[idx] = level
|
|
1270
1397
|
return base.slice(0, idx + 1)
|
|
1271
1398
|
})
|
|
1272
1399
|
})
|
|
@@ -1279,6 +1406,12 @@ window.__ModuleLoader__.load({
|
|
|
1279
1406
|
// path — reset the path bar (and the browse state) on every open.
|
|
1280
1407
|
setPath(''); setLevels(null); setWin(false)
|
|
1281
1408
|
setLocalPop(null); setNewDirName('')
|
|
1409
|
+
// Fresh session: re-post /current on the first request and forget
|
|
1410
|
+
// everything a previous open left behind (TTL bounds staleness within
|
|
1411
|
+
// one open; reopening should see the live tree) — cached AND in-flight
|
|
1412
|
+
// listings, plus any pending suggestion response.
|
|
1413
|
+
resetPickerFetchState()
|
|
1414
|
+
if (suggestTimer.current) { clearTimeout(suggestTimer.current); suggestTimer.current = null }
|
|
1282
1415
|
api('GET', '/dsh-remote/machines').then((r) => {
|
|
1283
1416
|
setMachines(r.machines || [])
|
|
1284
1417
|
const cur = (r.machines || []).find((m) => m.id === r.currentId)
|
|
@@ -1373,11 +1506,38 @@ window.__ModuleLoader__.load({
|
|
|
1373
1506
|
if (par) loadLevels(machineId, par, 1)
|
|
1374
1507
|
}
|
|
1375
1508
|
|
|
1509
|
+
// Drop any pending suggestion response and close the dropdown.
|
|
1510
|
+
const killSuggestions = () => {
|
|
1511
|
+
suggestSeqRef.current++
|
|
1512
|
+
setSuggest([])
|
|
1513
|
+
setSuggestOpen(false)
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// One seq-guarded "fetch listing → derive suggestion paths" pipeline
|
|
1517
|
+
// shared by loadSuggestions (typing: dirs matching a prefix) and
|
|
1518
|
+
// continueSuggest (expanding a picked dir: all its entries) — the
|
|
1519
|
+
// differing parts stay at the call sites as the `pick` parameter. Only
|
|
1520
|
+
// the newest request owns the dropdown: a slow older /ls must never
|
|
1521
|
+
// overwrite the suggestions for the text now in the input. The catch
|
|
1522
|
+
// keeps the seq-guarded empty fallback (a failed machine switch rejects
|
|
1523
|
+
// too, and must leave the dropdown closed and empty).
|
|
1524
|
+
const suggestFrom = (id, dirParam, pick, fresh) => {
|
|
1525
|
+
const seq = ++suggestSeqRef.current
|
|
1526
|
+
return fetchDirList(id, dirParam, fresh).then((node) => {
|
|
1527
|
+
if (seq !== suggestSeqRef.current) return
|
|
1528
|
+
const matches = pick(node).slice(0, 40)
|
|
1529
|
+
setSuggest(matches)
|
|
1530
|
+
setSuggestOpen(!!matches.length)
|
|
1531
|
+
}).catch(() => {
|
|
1532
|
+
if (seq !== suggestSeqRef.current) return
|
|
1533
|
+
setSuggest([]); setSuggestOpen(false)
|
|
1534
|
+
})
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1376
1537
|
const loadSuggestions = (raw, mid) => {
|
|
1377
1538
|
const id = mid || machineId
|
|
1378
|
-
if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
|
|
1379
1539
|
const t = String(raw || '').trim()
|
|
1380
|
-
if (!t) {
|
|
1540
|
+
if (!id || !t) { killSuggestions(); return }
|
|
1381
1541
|
// Separator-aware split: `\` when the input looks like a Windows path.
|
|
1382
1542
|
const backslash = t.includes('\\') || /^[a-zA-Z]:/.test(t)
|
|
1383
1543
|
const sep = backslash ? '\\' : '/'
|
|
@@ -1386,18 +1546,9 @@ window.__ModuleLoader__.load({
|
|
|
1386
1546
|
if (idx < 0) { parent = backslash ? t + '\\' : '/'; lastSeg = backslash ? '' : t }
|
|
1387
1547
|
else if (idx === t.length - 1) { parent = t.slice(0, idx) || '/'; lastSeg = '' }
|
|
1388
1548
|
else { parent = t.slice(0, idx) || '/'; lastSeg = t.slice(idx + 1) }
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
const list = Array.isArray(res && res.items)
|
|
1393
|
-
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(parent, it.name) }))
|
|
1394
|
-
: []
|
|
1395
|
-
const matches = list
|
|
1396
|
-
.filter((it) => it.dir && (!lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase())))
|
|
1397
|
-
.slice(0, 40).map((it) => it.path)
|
|
1398
|
-
setSuggest(matches)
|
|
1399
|
-
setSuggestOpen(!!matches.length)
|
|
1400
|
-
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
1549
|
+
suggestFrom(id, parent || '/', (node) => node.dirs
|
|
1550
|
+
.filter((it) => !lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
|
|
1551
|
+
.map((it) => it.path))
|
|
1401
1552
|
}
|
|
1402
1553
|
|
|
1403
1554
|
const onPathChange = (raw) => {
|
|
@@ -1407,18 +1558,13 @@ window.__ModuleLoader__.load({
|
|
|
1407
1558
|
}
|
|
1408
1559
|
|
|
1409
1560
|
const continueSuggest = (dir) => {
|
|
1410
|
-
if (!machineId || !dir) {
|
|
1561
|
+
if (!machineId || !dir) { killSuggestions(); return }
|
|
1411
1562
|
setSuggestOpen(false)
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
: []
|
|
1418
|
-
const kids = list.slice(0, 40).map((it) => it.path)
|
|
1419
|
-
setSuggest(kids)
|
|
1420
|
-
setSuggestOpen(!!kids.length)
|
|
1421
|
-
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
1563
|
+
const target = String(dir).replace(/[\\/]+$/, '') || '/'
|
|
1564
|
+
// Expanding a just-selected directory is a NAVIGATION step: the caching
|
|
1565
|
+
// spec only sanctions "typing inside one parent directory, local
|
|
1566
|
+
// filtering", so fetch fresh instead of serving up to 30s-stale entries.
|
|
1567
|
+
suggestFrom(machineId, target, (node) => node.all.map((it) => it.path), true)
|
|
1422
1568
|
}
|
|
1423
1569
|
|
|
1424
1570
|
const selectSuggestion = (s) => {
|
|
@@ -1434,21 +1580,34 @@ window.__ModuleLoader__.load({
|
|
|
1434
1580
|
const commitPath = (p) => {
|
|
1435
1581
|
const target = String(p || '').trim()
|
|
1436
1582
|
if (!target || !machineId || busy) return
|
|
1437
|
-
setPopOpen(false);
|
|
1438
|
-
|
|
1583
|
+
setPopOpen(false); killSuggestions()
|
|
1584
|
+
// State the machine this pick was made on. The picker acts on the shared
|
|
1585
|
+
// active pool, and another actor (the settings page, or rw_connect in
|
|
1586
|
+
// another session) can switch it while the picker is open — the mirror
|
|
1587
|
+
// would then be created with THAT machine's identity while the user was
|
|
1588
|
+
// looking at this one's tree. The host re-asserts the named machine
|
|
1589
|
+
// instead of trusting whichever happens to be active.
|
|
1590
|
+
ensureCurrent(machineId)
|
|
1591
|
+
.then(() => api('POST', '/dsh-remote/mirror', { path: target, machineId }))
|
|
1592
|
+
.then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || '')))
|
|
1593
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1439
1594
|
}
|
|
1440
1595
|
|
|
1441
1596
|
const acceptBrowserPick = (p) => {
|
|
1442
1597
|
setPath(String(p || ''))
|
|
1443
|
-
|
|
1598
|
+
killSuggestions()
|
|
1444
1599
|
setPopOpen(false)
|
|
1445
1600
|
}
|
|
1446
1601
|
|
|
1447
1602
|
const pickHome = () => {
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1603
|
+
// Same identity contract as commitPath: /home is answered by the active
|
|
1604
|
+
// pool, so name the machine this picker is showing.
|
|
1605
|
+
ensureCurrent(machineId)
|
|
1606
|
+
.then(() => api('POST', '/dsh-remote/home', { machineId }))
|
|
1607
|
+
.then((r) => {
|
|
1608
|
+
if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
|
|
1609
|
+
else setErr((r && (r.hint || r.error)) || tr('picker.noHome'))
|
|
1610
|
+
}).catch((e) => setErr(String((e && e.message) || e)))
|
|
1452
1611
|
}
|
|
1453
1612
|
|
|
1454
1613
|
const mkdirHere = () => {
|
|
@@ -1584,7 +1743,7 @@ window.__ModuleLoader__.load({
|
|
|
1584
1743
|
if (mkdirPrompt) return
|
|
1585
1744
|
if (e.key === 'Escape') {
|
|
1586
1745
|
if (popOpen) { e.preventDefault(); setPopOpen(false); return }
|
|
1587
|
-
if (suggestOpen) { e.preventDefault(); setSuggestOpen(false); return }
|
|
1746
|
+
if (suggestOpen) { e.preventDefault(); suggestSeqRef.current++; setSuggestOpen(false); return }
|
|
1588
1747
|
if (!busy) { e.preventDefault(); onCancel() }
|
|
1589
1748
|
return
|
|
1590
1749
|
}
|
package/lib/index.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import z from '@deepseek-ai/schemastery'
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
23
|
import { execFile } from 'node:child_process'
|
|
24
|
-
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, appendFileSync, watch } from 'node:fs'
|
|
24
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, appendFileSync, watch, rmSync } from 'node:fs'
|
|
25
25
|
import { homedir } from 'node:os'
|
|
26
26
|
import path from 'node:path'
|
|
27
27
|
import iconv from 'iconv-lite'
|
|
@@ -41,7 +41,7 @@ import { searchRemote } from './search.js'
|
|
|
41
41
|
import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
|
|
42
42
|
import { TaskManager } from './tasks.js'
|
|
43
43
|
import { ForwardManager } from './forwards.js'
|
|
44
|
-
import { selfDir,
|
|
44
|
+
import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload } from './update.js'
|
|
45
45
|
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
|
|
46
46
|
import { registerHttpTransports } from './http-transport.js'
|
|
47
47
|
import { SshPool } from './pool.js'
|
|
@@ -130,6 +130,10 @@ export const Config = z.object({
|
|
|
130
130
|
updateMode: z.string().default('manual'),
|
|
131
131
|
/** How often (ms) auto mode checks the npm registry for a newer release. */
|
|
132
132
|
updateCheckIntervalMs: z.number().step(1).min(60000).default(6 * 3600 * 1000),
|
|
133
|
+
/** Whether a landed update also hot-swaps the running host half (default
|
|
134
|
+
* true). With `false` an update only takes effect on the next process start
|
|
135
|
+
* and `/dsh-remote/update-check` reports `pendingReload: true` meanwhile. */
|
|
136
|
+
updateAutoReload: z.boolean().default(true),
|
|
133
137
|
})
|
|
134
138
|
|
|
135
139
|
// ── shell / path helpers (pure implementations in lib/paths.js) ───────────
|
|
@@ -258,6 +262,15 @@ export async function apply(ctx, config) {
|
|
|
258
262
|
|
|
259
263
|
migrateLegacyData()
|
|
260
264
|
|
|
265
|
+
// The update marker means "a newer version landed on disk but is not the code
|
|
266
|
+
// running yet". Reaching apply() with a marker naming the version we just
|
|
267
|
+
// loaded means the swap already happened (or this is a plain boot into the
|
|
268
|
+
// updated files) → clear it, so the UI's "restart to finish" hint stays true.
|
|
269
|
+
try {
|
|
270
|
+
const marker = path.join(selfDir(), '.dsh-remote-updated')
|
|
271
|
+
if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === LOADED_VERSION) rmSync(marker, { force: true })
|
|
272
|
+
} catch {}
|
|
273
|
+
|
|
261
274
|
// ── machine registry (multi-host) ─────────────────────────────────────────
|
|
262
275
|
const store = loadMachines()
|
|
263
276
|
const machines = store.list
|
|
@@ -360,18 +373,47 @@ export async function apply(ctx, config) {
|
|
|
360
373
|
return machinePassword(m)
|
|
361
374
|
}
|
|
362
375
|
|
|
376
|
+
/** The identity fields pool.setTarget diffs. Used to snapshot the diff
|
|
377
|
+
* baseline BEFORE applyMachine mutates the shared config in place. */
|
|
378
|
+
const targetFields = (c) => ({
|
|
379
|
+
host: c.host, port: c.port, username: c.username,
|
|
380
|
+
password: c.password, privateKeyPath: c.privateKeyPath,
|
|
381
|
+
passphrase: c.passphrase, workspace: c.workspace,
|
|
382
|
+
useAgent: c.useAgent, keyboardInteractive: c.keyboardInteractive,
|
|
383
|
+
proxy: c.proxy, hostKeyMode: c.hostKeyMode,
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
/** Fingerprint of the machine record last applied to the shared pool config.
|
|
387
|
+
* Re-applying an identical record is a no-op: the picker posts /current before
|
|
388
|
+
* every /ls and used to rewrite the registry + re-read credentials + re-point
|
|
389
|
+
* the pool on every autocomplete keystroke. A record change (settings edit)
|
|
390
|
+
* changes the fingerprint, so the next /current re-applies it. The value is
|
|
391
|
+
* maintained INSIDE applyActiveMachine / clearActiveMachine (not in
|
|
392
|
+
* setCurrent) so every apply path — boot restore, rw_connect save:true,
|
|
393
|
+
* POST /current — keeps the "fingerprint == applied" invariant without extra
|
|
394
|
+
* bookkeeping. */
|
|
395
|
+
let lastAppliedMachine = ''
|
|
396
|
+
|
|
397
|
+
/** Fingerprint of the registry record the shared config is bound to ('' when
|
|
398
|
+
* the binding is ephemeral or no machine is current). */
|
|
399
|
+
const appliedFingerprint = () => {
|
|
400
|
+
if (ephemeral || !store.currentId) return ''
|
|
401
|
+
const i = machineIndex(store.currentId)
|
|
402
|
+
return i >= 0 ? JSON.stringify(machines[i]) : ''
|
|
403
|
+
}
|
|
404
|
+
|
|
363
405
|
const applyActiveMachine = async () => {
|
|
364
406
|
const m = activeMachine()
|
|
365
407
|
if (!m || !m.host) return
|
|
366
408
|
const pw = await machinePassword(m)
|
|
409
|
+
// Snapshot the diff baseline BEFORE applyMachine mutates the shared config
|
|
410
|
+
// IN PLACE: pool.config IS this config object, so setTarget would otherwise
|
|
411
|
+
// compare the new values against themselves, find them equal and keep the
|
|
412
|
+
// OLD connection alive across a machine switch (issue #25 hazard).
|
|
413
|
+
const previous = targetFields(config)
|
|
367
414
|
applyMachine(config, { ...m, password: pw || m.password || '' })
|
|
368
|
-
pool.setTarget(
|
|
369
|
-
|
|
370
|
-
password: config.password, privateKeyPath: config.privateKeyPath,
|
|
371
|
-
passphrase: config.passphrase, workspace: config.workspace,
|
|
372
|
-
useAgent: config.useAgent, keyboardInteractive: config.keyboardInteractive,
|
|
373
|
-
proxy: config.proxy, hostKeyMode: config.hostKeyMode,
|
|
374
|
-
})
|
|
415
|
+
pool.setTarget(targetFields(config), previous)
|
|
416
|
+
lastAppliedMachine = appliedFingerprint()
|
|
375
417
|
}
|
|
376
418
|
|
|
377
419
|
// ── per-machine SSH pools (session-bound) ─────────────────────────────────
|
|
@@ -501,6 +543,34 @@ export async function apply(ctx, config) {
|
|
|
501
543
|
return created
|
|
502
544
|
}
|
|
503
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Re-assert the ACTIVE machine that a machine-scoped request names.
|
|
548
|
+
*
|
|
549
|
+
* The workspace picker (and the settings page's workspace flow) acts on the
|
|
550
|
+
* shared active pool, but it remembers its own `machineId` locally. Nothing
|
|
551
|
+
* on `/mirror` / `/home` / `/fs` used to carry that identity, so a switch
|
|
552
|
+
* performed by ANOTHER actor between the picker's last fetch and its commit —
|
|
553
|
+
* the settings page's "set as current", or an `rw_connect` in another agent
|
|
554
|
+
* session — silently redirected the commit onto that other machine. Before
|
|
555
|
+
* this PR the picker re-posted /current on every keystroke, which masked the
|
|
556
|
+
* window; the per-keystroke reconnect fix removes that accident. Let the
|
|
557
|
+
* request state its intent so the host can honour it atomically instead.
|
|
558
|
+
*
|
|
559
|
+
* An absent / empty `machineId` keeps the old behaviour (the caller is
|
|
560
|
+
* machine-scoped through the active pool by design). An UNKNOWN id is
|
|
561
|
+
* refused rather than ignored: quietly mirroring onto whichever machine
|
|
562
|
+
* happens to be active is exactly the hazard this closes.
|
|
563
|
+
*
|
|
564
|
+
* @returns {Promise<boolean>} false when the request was already answered.
|
|
565
|
+
*/
|
|
566
|
+
const applyRequestedMachine = async (body, res) => {
|
|
567
|
+
const id = String((body && body.machineId) || '').trim()
|
|
568
|
+
if (!id) return true
|
|
569
|
+
if (await setCurrent(id)) return true
|
|
570
|
+
sendJson(res, 404, { ok: false, error: `machine not found: ${id}` })
|
|
571
|
+
return false
|
|
572
|
+
}
|
|
573
|
+
|
|
504
574
|
const setCurrent = async (id) => {
|
|
505
575
|
if (!id) {
|
|
506
576
|
// Explicit "active remote = none": saved machines stay in the registry
|
|
@@ -513,6 +583,11 @@ export async function apply(ctx, config) {
|
|
|
513
583
|
}
|
|
514
584
|
const i = machineIndex(id)
|
|
515
585
|
if (i < 0) return false
|
|
586
|
+
// Idempotent: same machine, no ephemeral override, already applied → keep
|
|
587
|
+
// the registry file, the credentials cache and the live pool untouched.
|
|
588
|
+
// (The fingerprint is maintained by applyActiveMachine itself.)
|
|
589
|
+
const fingerprint = JSON.stringify(machines[i])
|
|
590
|
+
if (store.currentId === id && !ephemeral && config.host && lastAppliedMachine === fingerprint) return true
|
|
516
591
|
store.currentId = id
|
|
517
592
|
ephemeral = null
|
|
518
593
|
saveMachines(machines, id)
|
|
@@ -525,7 +600,13 @@ export async function apply(ctx, config) {
|
|
|
525
600
|
* tool fallback, but no machine workspace leaks into this session. */
|
|
526
601
|
const clearActiveMachine = async () => {
|
|
527
602
|
ephemeral = null
|
|
603
|
+
// Nothing is applied anymore — reset the applied-machine fingerprint too.
|
|
604
|
+
lastAppliedMachine = ''
|
|
528
605
|
if (!config.host) return
|
|
606
|
+
// Snapshot the diff baseline BEFORE zeroing: pool.config IS this config
|
|
607
|
+
// object, so setTarget would otherwise see an already-zeroed config and
|
|
608
|
+
// keep the old connection alive ("active remote = none" must disconnect).
|
|
609
|
+
const previous = targetFields(config)
|
|
529
610
|
// Clear host + workspace directly (applyMachine keeps the old workspace
|
|
530
611
|
// when the incoming value is empty — it must not be used for a full reset).
|
|
531
612
|
config.host = ''
|
|
@@ -533,7 +614,7 @@ export async function apply(ctx, config) {
|
|
|
533
614
|
config.username = ''
|
|
534
615
|
config.password = ''
|
|
535
616
|
config.workspace = ''
|
|
536
|
-
pool.setTarget({ host: '', port: 22, username: '', workspace: '' })
|
|
617
|
+
pool.setTarget({ host: '', port: 22, username: '', workspace: '' }, previous)
|
|
537
618
|
// Also stop any auto-push watcher bound to the old machine workspace.
|
|
538
619
|
// (autoPushWatchers is declared later; read it lazily to avoid TDZ.)
|
|
539
620
|
if (typeof autoPushWatchers !== 'undefined' && autoPushWatchers) {
|
|
@@ -1180,9 +1261,17 @@ export async function apply(ctx, config) {
|
|
|
1180
1261
|
saveMachines(machines, store.currentId)
|
|
1181
1262
|
}
|
|
1182
1263
|
ephemeral = null
|
|
1264
|
+
// rw_connect means "connect now": force a fresh dial even for an
|
|
1265
|
+
// identical target (setTarget alone would reuse a possibly half-open
|
|
1266
|
+
// cached client — same reconnect-probe reasoning as /connect).
|
|
1267
|
+
pool.invalidate()
|
|
1183
1268
|
await applyActiveMachine()
|
|
1184
1269
|
} else {
|
|
1185
1270
|
ephemeral = { id: machineId(), name: host, ...rec }
|
|
1271
|
+
// rw_connect means "connect now": force a fresh dial even for an
|
|
1272
|
+
// identical target (setTarget alone would reuse a possibly half-open
|
|
1273
|
+
// cached client — same reconnect-probe reasoning as /connect).
|
|
1274
|
+
pool.invalidate()
|
|
1186
1275
|
pool.setTarget({
|
|
1187
1276
|
host: eff.host, port: effPort, username: effUser,
|
|
1188
1277
|
password: rec.password, privateKeyPath: eff.privateKeyPath || '',
|
|
@@ -2181,6 +2270,12 @@ export async function apply(ctx, config) {
|
|
|
2181
2270
|
let payload = {}
|
|
2182
2271
|
try {
|
|
2183
2272
|
payload = parseJsonObject(await readBody(req))
|
|
2273
|
+
// This route is a reconnect PROBE ("Connect" button): with the
|
|
2274
|
+
// unchanged-target no-op in setTarget, an identical target would
|
|
2275
|
+
// reuse a possibly half-open cached client and the probe could hang.
|
|
2276
|
+
// invalidate() is the design's forced-reconnect escape hatch — drop
|
|
2277
|
+
// the cached client so the `echo ok` below always re-dials.
|
|
2278
|
+
pool.invalidate()
|
|
2184
2279
|
pool.setTarget({
|
|
2185
2280
|
host: payload.host,
|
|
2186
2281
|
port: payload.port,
|
|
@@ -2230,6 +2325,11 @@ export async function apply(ctx, config) {
|
|
|
2230
2325
|
const p = normalizeRemotePath(String(payload.path || ''))
|
|
2231
2326
|
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
|
|
2232
2327
|
if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
|
|
2328
|
+
// The picker names the machine it was browsing. Re-assert it so a
|
|
2329
|
+
// switch made elsewhere between the listing and this commit cannot
|
|
2330
|
+
// mirror onto a machine the user never looked at (the picker sends
|
|
2331
|
+
// /current only once per machine now, so nothing else re-applies it).
|
|
2332
|
+
if (!(await applyRequestedMachine(payload, res))) return
|
|
2233
2333
|
const okDir = await isRemoteDir(p)
|
|
2234
2334
|
if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
|
|
2235
2335
|
const local = ensureMirror(p, config.host, config.username, config.port, { alias: aliasOf(activeMachine()) })
|
|
@@ -2659,6 +2759,11 @@ export async function apply(ctx, config) {
|
|
|
2659
2759
|
handler: async (req, res) => {
|
|
2660
2760
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2661
2761
|
try {
|
|
2762
|
+
const body = parseJsonObject(await readBody(req))
|
|
2763
|
+
// The picker names the machine it is showing: re-assert it before
|
|
2764
|
+
// answering, so a switch made elsewhere cannot make /home describe a
|
|
2765
|
+
// different host than the tree the user is looking at.
|
|
2766
|
+
if (!(await applyRequestedMachine(body, res))) return
|
|
2662
2767
|
const out = await pool.exec('echo ~', { timeoutMs: Math.min(config.commandTimeoutMs, 5000) })
|
|
2663
2768
|
const home = String(out.stdout || '').replace(/\s+/g, '').trim()
|
|
2664
2769
|
if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
|
|
@@ -2673,17 +2778,26 @@ export async function apply(ctx, config) {
|
|
|
2673
2778
|
path: '/dsh-remote/update-check',
|
|
2674
2779
|
handler: async (req, res) => {
|
|
2675
2780
|
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2781
|
+
// `loaded` is the code actually running; `disk` may already be newer.
|
|
2782
|
+
// They differ between an applied update and its hot swap (or a boot).
|
|
2783
|
+
const loaded = LOADED_VERSION
|
|
2784
|
+
const disk = diskVersion()
|
|
2679
2785
|
const rawMode = readUpdateMode() || config.updateMode || 'manual'
|
|
2786
|
+
const base = {
|
|
2787
|
+
current: loaded,
|
|
2788
|
+
loaded,
|
|
2789
|
+
disk,
|
|
2790
|
+
pendingReload: gtVersion(disk, loaded),
|
|
2791
|
+
updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
|
|
2792
|
+
updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
|
|
2793
|
+
}
|
|
2794
|
+
const latest = await fetchLatestVersion()
|
|
2795
|
+
if (latest === null) return sendJson(res, 200, { ok: false, ...base, error: '无法连接 npm registry' })
|
|
2680
2796
|
return sendJson(res, 200, {
|
|
2681
2797
|
ok: true,
|
|
2682
|
-
|
|
2798
|
+
...base,
|
|
2683
2799
|
latest,
|
|
2684
|
-
updateAvailable: gtVersion(latest,
|
|
2685
|
-
updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
|
|
2686
|
-
updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
|
|
2800
|
+
updateAvailable: gtVersion(latest, loaded),
|
|
2687
2801
|
})
|
|
2688
2802
|
},
|
|
2689
2803
|
},
|
|
@@ -2693,16 +2807,45 @@ export async function apply(ctx, config) {
|
|
|
2693
2807
|
handler: async (req, res) => {
|
|
2694
2808
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2695
2809
|
try {
|
|
2810
|
+
// Capture the outgoing version BEFORE the swap: reading it afterwards
|
|
2811
|
+
// reports the version we just installed, not the one we replaced.
|
|
2812
|
+
const from = diskVersion()
|
|
2813
|
+
const loaded = LOADED_VERSION
|
|
2696
2814
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2697
2815
|
const target = String(body.version || '')
|
|
2698
2816
|
if (!target) return sendJson(res, 400, { ok: false, error: 'version is required' })
|
|
2699
2817
|
const result = await applyUpdate(target)
|
|
2700
|
-
|
|
2818
|
+
const wantReload = body.reload !== false && config.updateAutoReload !== false
|
|
2819
|
+
// Answer first, then swap: the swap disposes the fiber serving this
|
|
2820
|
+
// very request, so nothing may touch the plugin after it.
|
|
2821
|
+
if (wantReload) scheduleSelfReload(ctx.loader, 300)
|
|
2822
|
+
return sendJson(res, 200, {
|
|
2823
|
+
ok: true,
|
|
2824
|
+
from,
|
|
2825
|
+
loadedBefore: loaded,
|
|
2826
|
+
...result,
|
|
2827
|
+
reloadScheduled: wantReload,
|
|
2828
|
+
// what the swap will land on, for a caller that wants to verify
|
|
2829
|
+
loadedAfterReload: wantReload ? result.to : loaded,
|
|
2830
|
+
})
|
|
2701
2831
|
} catch (err) {
|
|
2702
2832
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2703
2833
|
}
|
|
2704
2834
|
},
|
|
2705
2835
|
},
|
|
2836
|
+
{
|
|
2837
|
+
kind: 'exact',
|
|
2838
|
+
path: '/dsh-remote/update-reload',
|
|
2839
|
+
handler: async (req, res) => {
|
|
2840
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2841
|
+
// Hot-swap the host half to whatever is on disk now. Answers first (with
|
|
2842
|
+
// what was loaded and what it is about to become), then swaps.
|
|
2843
|
+
const loaded = LOADED_VERSION
|
|
2844
|
+
const disk = diskVersion()
|
|
2845
|
+
scheduleSelfReload(ctx.loader, 300)
|
|
2846
|
+
return sendJson(res, 200, { ok: true, scheduled: true, loaded, disk, willReloadTo: disk })
|
|
2847
|
+
},
|
|
2848
|
+
},
|
|
2706
2849
|
{
|
|
2707
2850
|
kind: 'exact',
|
|
2708
2851
|
path: '/dsh-remote/update-mode',
|
|
@@ -2729,12 +2872,24 @@ export async function apply(ctx, config) {
|
|
|
2729
2872
|
const rawMode = readUpdateMode() || config.updateMode || 'manual'
|
|
2730
2873
|
const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual'
|
|
2731
2874
|
if (effectiveUpdateMode === 'auto') {
|
|
2732
|
-
const currentVersion = readVersion()
|
|
2733
2875
|
const checkAndApply = async () => {
|
|
2734
2876
|
const latest = await fetchLatestVersion()
|
|
2735
|
-
if (latest
|
|
2736
|
-
|
|
2877
|
+
if (!latest) return
|
|
2878
|
+
// The version in flight is whichever of "already on disk" / "already
|
|
2879
|
+
// loaded" is newer. Pinning it once outside this closure (as v0.8.23 did)
|
|
2880
|
+
// made every later interval re-download and re-apply the same tarball,
|
|
2881
|
+
// because the pinned value never moves past the version it installed.
|
|
2882
|
+
const disk = diskVersion()
|
|
2883
|
+
const current = gtVersion(disk, LOADED_VERSION) ? disk : LOADED_VERSION
|
|
2884
|
+
if (!gtVersion(latest, current)) return
|
|
2885
|
+
try {
|
|
2886
|
+
await applyUpdate(latest)
|
|
2887
|
+
} catch {
|
|
2888
|
+
return
|
|
2737
2889
|
}
|
|
2890
|
+
// Landed: swap the running host half too, otherwise the code being served
|
|
2891
|
+
// and the code being run stay one version apart until the next boot.
|
|
2892
|
+
if (config.updateAutoReload !== false) scheduleSelfReload(ctx.loader, 500)
|
|
2738
2893
|
}
|
|
2739
2894
|
void checkAndApply()
|
|
2740
2895
|
const updateTimer = setInterval(checkAndApply, Math.max(config.updateCheckIntervalMs, 60000))
|
package/lib/pool.js
CHANGED
|
@@ -41,24 +41,48 @@ export class SshPool {
|
|
|
41
41
|
return p
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// the
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
44
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }, previous) {
|
|
45
|
+
// Collect the requested changes first: an UNCHANGED target is a no-op. The
|
|
46
|
+
// old behavior closed the live connection and re-ran platform detection
|
|
47
|
+
// even for an identical target, so every picker keystroke
|
|
48
|
+
// (POST /current → applyActiveMachine → setTarget) paid a full SSH
|
|
49
|
+
// reconnect + re-detect — the "slow remote path autocomplete" bug.
|
|
50
|
+
//
|
|
51
|
+
// `previous` is the diff BASELINE. Callers whose shared config was already
|
|
52
|
+
// mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
|
|
53
|
+
// live config IN PLACE, and pool.config IS that config object) pass a
|
|
54
|
+
// pre-mutation snapshot — otherwise the comparison runs the new values
|
|
55
|
+
// against themselves, is always equal and never closes even for a machine
|
|
56
|
+
// switch (the wrong-host hazard of issue #25). Defaults to the current
|
|
57
|
+
// config for callers that pass fresh values read independently of it.
|
|
58
|
+
const base = previous || this.config
|
|
59
|
+
const wanted = []
|
|
60
|
+
if (host !== undefined) wanted.push(['host', String(host)])
|
|
61
|
+
if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
|
|
62
|
+
if (username !== undefined) wanted.push(['username', String(username)])
|
|
63
|
+
if (password !== undefined && password !== null) wanted.push(['password', String(password)])
|
|
64
|
+
if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
|
|
65
|
+
if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
|
|
66
|
+
if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
|
|
67
|
+
if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
|
|
68
|
+
if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
|
|
69
|
+
if (proxy !== undefined) wanted.push(['proxy', proxy])
|
|
70
|
+
if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
|
|
71
|
+
const differs = ([k, v]) => (k === 'proxy'
|
|
72
|
+
? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
|
|
73
|
+
: base[k] !== v)
|
|
74
|
+
// Something actually changed → apply it and force a fresh connection.
|
|
75
|
+
// (A caller that WANTS a reconnect for an identical target uses
|
|
76
|
+
// invalidate(); setTarget semantics stay "re-point when moved".)
|
|
77
|
+
if (wanted.some(differs)) {
|
|
78
|
+
for (const [k, v] of wanted) this.config[k] = v
|
|
79
|
+
// the new target may be a different OS — re-detect on the next command
|
|
80
|
+
this.platform = 'unknown'
|
|
81
|
+
this.gitBashPath = ''
|
|
82
|
+
this.shellMode = 'native'
|
|
83
|
+
this._detecting = null
|
|
84
|
+
this.close()
|
|
85
|
+
}
|
|
62
86
|
return this
|
|
63
87
|
}
|
|
64
88
|
|
|
@@ -484,6 +508,13 @@ export class SshPool {
|
|
|
484
508
|
if (client) {
|
|
485
509
|
try { client.end() } catch {}
|
|
486
510
|
}
|
|
511
|
+
// Same teardown as close(): the forwards manager must detach from the
|
|
512
|
+
// dying client, or its stale `close -> stopAll()` listener later tears
|
|
513
|
+
// down forwards that were re-attached to the NEW client (onReady does
|
|
514
|
+
// the re-attach on the next successful connect).
|
|
515
|
+
if (this.onCloseHook) {
|
|
516
|
+
try { this.onCloseHook() } catch {}
|
|
517
|
+
}
|
|
487
518
|
}
|
|
488
519
|
|
|
489
520
|
close() {
|
package/lib/routes-fs.js
CHANGED
|
@@ -40,6 +40,17 @@ export function createFsRoutes({
|
|
|
40
40
|
const hint = requestSessionHint(req)
|
|
41
41
|
const b = await bindOrReply(req, res, hint)
|
|
42
42
|
if (!b) return
|
|
43
|
+
// Identity echo (external-switch defense): name the machine this
|
|
44
|
+
// listing is answered for, straight from the pool's target — the
|
|
45
|
+
// picker validates every listing against the machine it INTENDED and
|
|
46
|
+
// re-switches on mismatch (the settings page can change the active
|
|
47
|
+
// machine under a memoized picker).
|
|
48
|
+
const tgt = (b.pool && b.pool.config) || {}
|
|
49
|
+
const machine = {
|
|
50
|
+
host: String(tgt.host || b.host || ''),
|
|
51
|
+
port: Number(tgt.port) || Number(b.port) || 22,
|
|
52
|
+
username: String(tgt.username || b.username || ''),
|
|
53
|
+
}
|
|
43
54
|
await b.pool.detect()
|
|
44
55
|
const q = new URL(req.url, 'http://localhost').searchParams
|
|
45
56
|
const raw = q.get('path') ? decodeURIComponent(q.get('path')) : (b.ws || '')
|
|
@@ -48,9 +59,9 @@ export function createFsRoutes({
|
|
|
48
59
|
if (canon === '/' || raw === '' || raw === '/') {
|
|
49
60
|
if (win || b.pool.platform !== 'posix') {
|
|
50
61
|
const drives = await listRemoteDrives(b.pool)
|
|
51
|
-
if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound })
|
|
62
|
+
if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound, machine })
|
|
52
63
|
}
|
|
53
|
-
if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound })
|
|
64
|
+
if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound, machine })
|
|
54
65
|
}
|
|
55
66
|
const sftp = await b.pool.sftp()
|
|
56
67
|
const out = await listDirStructured(sftp, canon)
|
|
@@ -64,6 +75,7 @@ export function createFsRoutes({
|
|
|
64
75
|
items,
|
|
65
76
|
missing: !!out.missing,
|
|
66
77
|
bound: b.bound,
|
|
78
|
+
machine,
|
|
67
79
|
})
|
|
68
80
|
} catch (err) {
|
|
69
81
|
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
package/lib/update.js
CHANGED
|
@@ -8,15 +8,24 @@
|
|
|
8
8
|
// • checkLatestVersion() — query the npm registry for the `latest` dist-tag;
|
|
9
9
|
// • applyUpdate() — download the tarball, extract package/{lib,package.json,
|
|
10
10
|
// cordis.patch.yml} with a minimal tar reader, verify the version, then
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// replace the installed files atomically (temp file + rename, never a
|
|
12
|
+
// half-written lib/*.js). A `.dsh-remote-updated` marker records the version
|
|
13
|
+
// that landed; the host half removes it once that version is the code it is
|
|
14
|
+
// actually running (see LOADED_VERSION), so the marker means "restart still
|
|
15
|
+
// required" rather than "an update once happened".
|
|
16
|
+
// • reloadSelf() — swap the RUNNING host half to the code now on disk: drop
|
|
17
|
+
// Node's module caches for this package's own files, then dispose + re-init
|
|
18
|
+
// the loader entry so apply() runs again from the fresh module. Without it
|
|
19
|
+
// an update only takes effect on the next process start, which is what left
|
|
20
|
+
// the served client half newer than the loaded host half.
|
|
13
21
|
//
|
|
14
22
|
// Every failure aborts without touching the installed files (a bad download or
|
|
15
23
|
// a version mismatch must never break the running plugin).
|
|
16
24
|
|
|
17
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { createRequire } from 'node:module'
|
|
18
27
|
import path from 'node:path'
|
|
19
|
-
import { fileURLToPath } from 'node:url'
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
20
29
|
|
|
21
30
|
const NPM_PACKAGE = 'dsh-remote'
|
|
22
31
|
const REGISTRY_URL = 'https://registry.npmjs.org/' + NPM_PACKAGE + '/latest'
|
|
@@ -40,6 +49,17 @@ export function readVersion() {
|
|
|
40
49
|
}
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/** Version of the code this process actually loaded (snapshot at import time).
|
|
53
|
+
* `readVersion()` reads package.json from disk, so after an update lands it
|
|
54
|
+
* reports the NEW version while the running module is still the old one —
|
|
55
|
+
* comparing the two is the only way to tell "applied" from "active". */
|
|
56
|
+
export const LOADED_VERSION = readVersion()
|
|
57
|
+
|
|
58
|
+
/** Package version currently on disk (may be newer than {@link LOADED_VERSION}). */
|
|
59
|
+
export function diskVersion() {
|
|
60
|
+
return readVersion()
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
/** Compare dotted versions; returns true when a > b. */
|
|
44
64
|
export function gtVersion(a, b) {
|
|
45
65
|
const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0)
|
|
@@ -96,9 +116,10 @@ function parseTar(buf) {
|
|
|
96
116
|
* installed files untouched).
|
|
97
117
|
* @param targetVersion - exact npm version to install (e.g. "0.8.0").
|
|
98
118
|
*/
|
|
99
|
-
export async function applyUpdate(targetVersion) {
|
|
119
|
+
export async function applyUpdate(targetVersion, options = {}) {
|
|
100
120
|
const tarballUrl = `https://registry.npmjs.org/${NPM_PACKAGE}/-/${NPM_PACKAGE}-${targetVersion}.tgz`
|
|
101
|
-
const dir = selfDir()
|
|
121
|
+
const dir = options.dir || selfDir()
|
|
122
|
+
const doFetch = options.fetchImpl || fetch
|
|
102
123
|
const tmpRoot = path.join(dir, `.dsh-remote-update-${Date.now()}`)
|
|
103
124
|
const tmpPkg = path.join(tmpRoot, 'package')
|
|
104
125
|
try {
|
|
@@ -106,7 +127,7 @@ export async function applyUpdate(targetVersion) {
|
|
|
106
127
|
const timer = setTimeout(() => controller.abort(), 60000)
|
|
107
128
|
let res
|
|
108
129
|
try {
|
|
109
|
-
res = await
|
|
130
|
+
res = await doFetch(tarballUrl, { signal: controller.signal })
|
|
110
131
|
} finally {
|
|
111
132
|
clearTimeout(timer)
|
|
112
133
|
}
|
|
@@ -135,20 +156,34 @@ export async function applyUpdate(targetVersion) {
|
|
|
135
156
|
// break the next boot — check the file size and that it is not empty).
|
|
136
157
|
const newIndex = path.join(tmpPkg, 'lib', 'index.js')
|
|
137
158
|
if (!existsSync(newIndex) || statSize(newIndex) < 100) throw new Error('tarball missing lib/index.js')
|
|
138
|
-
//
|
|
159
|
+
// Swap the installed files one at a time: atomic, and idempotent.
|
|
160
|
+
//
|
|
161
|
+
// Atomicity (temp file + rename) is not cosmetic here — since the browser
|
|
162
|
+
// half is hot-swapped by dsh-client-hmr, which stat-polls lib/client.js
|
|
163
|
+
// every 500 ms, a torn write would be re-hashed and served mid-copy.
|
|
164
|
+
// Skipping byte-identical files keeps a re-apply of the same version from
|
|
165
|
+
// churning mtimes (and the host half from reloading for nothing).
|
|
139
166
|
const installLib = path.join(dir, 'lib')
|
|
140
167
|
const tmpLib = path.join(tmpPkg, 'lib')
|
|
168
|
+
const changed = []
|
|
169
|
+
const install = (src, dest) => {
|
|
170
|
+
const bytes = readFileSync(src)
|
|
171
|
+
if (sameFileContent(dest, bytes)) return
|
|
172
|
+
try { mkdirSync(path.dirname(dest), { recursive: true }) } catch {}
|
|
173
|
+
writeFileAtomic(dest, bytes)
|
|
174
|
+
changed.push(path.relative(dir, dest).split(path.sep).join('/'))
|
|
175
|
+
}
|
|
141
176
|
for (const name of readdirSafe(tmpLib)) {
|
|
142
177
|
if (!name.endsWith('.js')) continue
|
|
143
178
|
const src = path.join(tmpLib, name)
|
|
144
|
-
if (existsSync(src))
|
|
179
|
+
if (existsSync(src)) install(src, path.join(installLib, name))
|
|
145
180
|
}
|
|
146
181
|
for (const rel of ['package.json', 'cordis.patch.yml']) {
|
|
147
182
|
const src = path.join(tmpPkg, rel)
|
|
148
|
-
if (existsSync(src))
|
|
183
|
+
if (existsSync(src)) install(src, path.join(dir, rel))
|
|
149
184
|
}
|
|
150
185
|
writeFileSync(path.join(dir, '.dsh-remote-updated'), String(targetVersion))
|
|
151
|
-
return { ok: true, to: targetVersion }
|
|
186
|
+
return { ok: true, to: targetVersion, changed }
|
|
152
187
|
} catch (err) {
|
|
153
188
|
throw new Error('update failed: ' + ((err && err.message) || err))
|
|
154
189
|
} finally {
|
|
@@ -174,6 +209,145 @@ function readdirSafe(dir) {
|
|
|
174
209
|
}
|
|
175
210
|
}
|
|
176
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Write `data` to `dest` atomically: the bytes land in a sibling temp file and
|
|
214
|
+
* are then renamed over the target, so a concurrent reader (the client-half HMR
|
|
215
|
+
* poll, or the next boot) never observes a truncated file. Falls back to an
|
|
216
|
+
* in-place copy if the platform refuses the replace, and never leaves the temp
|
|
217
|
+
* file behind.
|
|
218
|
+
* @param dest - absolute destination path.
|
|
219
|
+
* @param data - bytes to write.
|
|
220
|
+
*/
|
|
221
|
+
export function writeFileAtomic(dest, data) {
|
|
222
|
+
const tmp = `${dest}.dsh-tmp-${process.pid}-${Date.now().toString(36)}`
|
|
223
|
+
writeFileSync(tmp, data)
|
|
224
|
+
try {
|
|
225
|
+
renameSync(tmp, dest)
|
|
226
|
+
return
|
|
227
|
+
} catch {
|
|
228
|
+
try {
|
|
229
|
+
copyFileSync(tmp, dest)
|
|
230
|
+
} finally {
|
|
231
|
+
try { rmSync(tmp, { force: true }) } catch {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Whether the file at `p` already holds exactly `bytes` (missing/short → false). */
|
|
237
|
+
function sameFileContent(p, bytes) {
|
|
238
|
+
try {
|
|
239
|
+
return readFileSync(p).equals(bytes)
|
|
240
|
+
} catch {
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── hot swap of the running host half ─────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/** Loader entry ids this package can own. */
|
|
248
|
+
const SELF_ENTRY_NAMES = ['dsh-remote']
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Find our own loader entry (by id, then by module specifier). Returns undefined
|
|
252
|
+
* when the tree does not contain us (e.g. a test loader, or a renamed row).
|
|
253
|
+
* @param loader - the cordis Loader service (`ctx.loader`).
|
|
254
|
+
*/
|
|
255
|
+
function findSelfEntry(loader) {
|
|
256
|
+
let entries = []
|
|
257
|
+
try {
|
|
258
|
+
entries = [...(loader?.entries?.() ?? [])]
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined
|
|
261
|
+
}
|
|
262
|
+
for (const want of SELF_ENTRY_NAMES) {
|
|
263
|
+
const byId = entries.find((entry) => entry?.options?.id === want)
|
|
264
|
+
if (byId) return byId
|
|
265
|
+
}
|
|
266
|
+
for (const want of SELF_ENTRY_NAMES) {
|
|
267
|
+
const byName = entries.find((entry) => entry?.options?.name === want)
|
|
268
|
+
if (byName) return byName
|
|
269
|
+
}
|
|
270
|
+
return undefined
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Drop Node's module caches for this package's own files, so the next import of
|
|
275
|
+
* the plugin actually re-reads disk instead of handing back the cached module.
|
|
276
|
+
* Both caches matter: the ESM `loadCache` (a plain Map on Node 22/23, a
|
|
277
|
+
* `LoadCache extends Map` on Node 24 — hence the explicit `Map.prototype` calls)
|
|
278
|
+
* and, for CJS modules pulled in through `import()`, `require.cache`.
|
|
279
|
+
* @param loader - the cordis Loader service.
|
|
280
|
+
* @param dir - package directory whose `lib/` to evict (defaults to this
|
|
281
|
+
* package's own install dir; overridable so the swap can be exercised against
|
|
282
|
+
* a fixture install).
|
|
283
|
+
* @returns how many ESM entries were dropped.
|
|
284
|
+
*/
|
|
285
|
+
export function clearSelfModuleCache(loader, dir = selfDir()) {
|
|
286
|
+
const libDir = path.join(dir, 'lib')
|
|
287
|
+
const loadCache = loader?.internal?.loadCache
|
|
288
|
+
const req = (() => {
|
|
289
|
+
try { return createRequire(import.meta.url) } catch { return undefined }
|
|
290
|
+
})()
|
|
291
|
+
let cleared = 0
|
|
292
|
+
for (const name of readdirSafe(libDir)) {
|
|
293
|
+
if (!name.endsWith('.js')) continue
|
|
294
|
+
const url = pathToFileURL(path.join(libDir, name)).href
|
|
295
|
+
try {
|
|
296
|
+
if (loadCache && Map.prototype.has.call(loadCache, url)) {
|
|
297
|
+
Map.prototype.delete.call(loadCache, url)
|
|
298
|
+
cleared += 1
|
|
299
|
+
}
|
|
300
|
+
} catch {}
|
|
301
|
+
try {
|
|
302
|
+
const file = fileURLToPath(url)
|
|
303
|
+
if (req?.cache && req.cache[file]) delete req.cache[file]
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
return cleared
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Swap the RUNNING host half to the code now on disk: clear the module caches
|
|
311
|
+
* for our own files, dispose our loader entry (tools, JSON routes, SSH pools and
|
|
312
|
+
* every `ctx.effect` disposer go with the fiber), then re-init it so `apply()`
|
|
313
|
+
* runs again from the freshly imported module.
|
|
314
|
+
*
|
|
315
|
+
* Callers must not depend on this plugin after a successful reload — the fiber
|
|
316
|
+
* that served the current request is gone. Schedule it (see
|
|
317
|
+
* {@link scheduleSelfReload}) rather than awaiting it from a request handler.
|
|
318
|
+
* @param loader - the cordis Loader service (`ctx.loader`).
|
|
319
|
+
* @returns a small result object; never throws.
|
|
320
|
+
*/
|
|
321
|
+
export async function reloadSelf(loader) {
|
|
322
|
+
const entry = findSelfEntry(loader)
|
|
323
|
+
if (!entry) return { ok: false, reason: 'own loader entry not found' }
|
|
324
|
+
try {
|
|
325
|
+
const cleared = clearSelfModuleCache(loader)
|
|
326
|
+
if (typeof entry._dispose === 'function') await entry._dispose()
|
|
327
|
+
else if (typeof entry.dispose === 'function') await entry.dispose()
|
|
328
|
+
else return { ok: false, reason: 'entry exposes no dispose hook' }
|
|
329
|
+
await entry.init()
|
|
330
|
+
return { ok: true, cleared }
|
|
331
|
+
} catch (err) {
|
|
332
|
+
return { ok: false, reason: String((err && err.message) || err) }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Fire-and-forget {@link reloadSelf} after a short delay, so the caller can
|
|
338
|
+
* finish answering an HTTP request (or an update check) before its own module is
|
|
339
|
+
* torn down. Failures are swallowed: a failed swap leaves the running code in
|
|
340
|
+
* place, which is the pre-hot-update behaviour, not a crash.
|
|
341
|
+
* @param loader - the cordis Loader service.
|
|
342
|
+
* @param delayMs - grace period before the swap (default 300 ms).
|
|
343
|
+
* @returns true when the swap was scheduled.
|
|
344
|
+
*/
|
|
345
|
+
export function scheduleSelfReload(loader, delayMs = 300) {
|
|
346
|
+
const timer = setTimeout(() => { void reloadSelf(loader).catch(() => {}) }, Math.max(0, delayMs))
|
|
347
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
|
|
177
351
|
/** Write the persisted update-mode override (settings UI). */
|
|
178
352
|
export function persistUpdateMode(mode) {
|
|
179
353
|
if (!['manual', 'auto', 'off'].includes(mode)) return false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.24",
|
|
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 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|