dsh-remote 0.7.3 → 0.7.5

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 CHANGED
@@ -85,6 +85,46 @@ window.__ModuleLoader__.load({
85
85
  const refresh = () => api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setCurrentId(r.currentId || '') })
86
86
  React.useEffect(() => { refresh() }, [])
87
87
 
88
+ // ── update state ────────────────────────────────────────────────────────
89
+ const [upd, setUpd] = React.useState(null) // { current, latest, updateAvailable, updateMode, updatedMarker }
90
+ const [updBusy, setUpdBusy] = React.useState(false)
91
+ const [updMsg, setUpdMsg] = React.useState('')
92
+ const [updMode, setUpdMode] = React.useState('manual')
93
+ const checkUpdate = (quiet) => {
94
+ if (updBusy) return
95
+ setUpdBusy(true); setUpdMsg('')
96
+ api('GET', '/dsh-remote/update-check')
97
+ .then((r) => {
98
+ if (!r || !r.ok) { setUpdMsg((r && r.error) || '检查更新失败'); return }
99
+ setUpd(r)
100
+ if (r.updateMode) setUpdMode(r.updateMode)
101
+ if (!quiet) setUpdMsg(r.updateAvailable ? '发现新版本 ' + r.latest : '已是最新版本(v' + r.current + ')')
102
+ })
103
+ .catch((e) => setUpdMsg(String((e && e.message) || e)))
104
+ .finally(() => setUpdBusy(false))
105
+ }
106
+ const applyUpdateNow = () => {
107
+ if (!upd || !upd.latest) return
108
+ if (!window.confirm('将更新 dsh-remote 到 v' + upd.latest + ',替换本机插件文件。更新后需重启 Harness 生效。继续?')) return
109
+ setUpdBusy(true); setUpdMsg('')
110
+ api('POST', '/dsh-remote/update-apply', { version: upd.latest })
111
+ .then((r) => {
112
+ if (r && r.ok) setUpdMsg('✅ 已更新到 v' + r.to + '(' + r.from + ' → ' + r.to + ')。重启 Harness 生效。')
113
+ else setUpdMsg((r && r.error) || '更新失败')
114
+ checkUpdate(true)
115
+ })
116
+ .catch((e) => setUpdMsg('更新失败: ' + String((e && e.message) || e)))
117
+ .finally(() => setUpdBusy(false))
118
+ }
119
+ // 更新模式持久化:auto/manual/off 通过 /dsh-remote/update-check 返回当前值;
120
+ // 修改模式这里仅本地提示(host 侧 config 由 profile 配置)。为简单可靠,
121
+ // 模式切换写入 host 配置目录下的 override 文件,host 加载时读取。
122
+ const saveUpdateMode = (mode) => {
123
+ setUpdMode(mode); setUpdMsg('更新模式已设为「' + (mode === 'auto' ? '自动' : mode === 'off' ? '关闭' : '手动') + '」')
124
+ api('POST', '/dsh-remote/update-mode', { mode }).catch((e) => setUpdMsg('模式保存失败: ' + String((e && e.message) || e)))
125
+ }
126
+ React.useEffect(() => { checkUpdate(true) }, [])
127
+
88
128
  const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
89
129
  const startEdit = (m) => setForm({ name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root', password: '', privateKeyPath: m.privateKeyPath || '', id: m.id })
90
130
  const save = (action) => {
@@ -140,6 +180,35 @@ window.__ModuleLoader__.load({
140
180
  ),
141
181
  ),
142
182
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
183
+ React.createElement('div', { style: boxS },
184
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '更新'),
185
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 } },
186
+ upd
187
+ ? React.createElement('span', { style: { opacity: 0.85 } }, '当前 v' + upd.current + (upd.updateAvailable ? React.createElement('b', { style: { color: T.ok, marginLeft: 6 } }, '→ 新版本 v' + upd.latest) : null))
188
+ : React.createElement('span', { style: { opacity: 0.6 } }, '版本信息加载中…'),
189
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => checkUpdate(false), disabled: updBusy }, updBusy ? '检查中…' : '检查更新'),
190
+ upd && upd.updateAvailable
191
+ ? React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontWeight: 700, color: T.ok }, onClick: applyUpdateNow, disabled: updBusy }, '立即更新')
192
+ : null,
193
+ ),
194
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, fontSize: 12 } },
195
+ React.createElement('span', { style: { opacity: 0.8 } }, '更新模式:'),
196
+ ['manual', 'auto', 'off'].map((m) =>
197
+ React.createElement('button', {
198
+ key: m, style: { ...buttonS, padding: '2px 10px', fontWeight: updMode === m ? 700 : 400, color: updMode === m ? T.ok : 'inherit' },
199
+ onClick: () => saveUpdateMode(m), disabled: updBusy,
200
+ }, m === 'manual' ? '手动' : m === 'auto' ? '自动' : '关闭'),
201
+ ),
202
+ ),
203
+ updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') || updMsg.startsWith('已是最新') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
204
+ ),
205
+ 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 } },
206
+ React.createElement('span', {}, '觉得好用?'),
207
+ React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
208
+ React.createElement('span', {}, '·'),
209
+ React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
210
+ React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v0.7.5'),
211
+ ),
143
212
  )
144
213
  }
145
214
 
@@ -451,6 +520,11 @@ window.__ModuleLoader__.load({
451
520
  ),
452
521
  ),
453
522
  (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
523
+ React.createElement('div', { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center', paddingTop: 6, fontSize: 10, opacity: 0.5 } },
524
+ React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ Star dsh-remote'),
525
+ React.createElement('span', {}, '·'),
526
+ React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '提建议 / 报问题'),
527
+ ),
454
528
  ),
455
529
  )
456
530
  }
package/lib/index.js CHANGED
@@ -20,9 +20,10 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
20
20
  import ssh2 from 'ssh2'
21
21
  import { createHash } from 'node:crypto'
22
22
  import { execFile } from 'node:child_process'
23
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync } from 'node:fs'
23
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync, rmSync } from 'node:fs'
24
24
  import { homedir } from 'node:os'
25
25
  import path from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
26
27
 
27
28
  const { Client } = ssh2
28
29
 
@@ -62,6 +63,12 @@ export const Config = z.object({
62
63
  * accept-new); `verify` also rejects hosts never seen before; `off` skips
63
64
  * verification entirely (MITM-unsafe, not recommended). */
64
65
  hostKeyMode: z.string().default('accept-new'),
66
+ /** Update mode: `manual` (default) only checks when asked; `auto` checks on
67
+ * load and periodically, applying a newer npm release automatically;
68
+ * `off` disables version checks entirely. */
69
+ updateMode: z.enum(['manual', 'auto', 'off']).default('manual'),
70
+ /** How often (ms) auto mode checks the npm registry for a newer release. */
71
+ updateCheckIntervalMs: z.number().step(1).min(60000).default(6 * 3600 * 1000),
65
72
  })
66
73
 
67
74
  // ── shell / path helpers (proven in the read tooling) ───────────────────────
@@ -1462,6 +1469,169 @@ export async function apply(ctx, config) {
1462
1469
  res.setHeader('Content-Type', 'application/json; charset=utf-8')
1463
1470
  res.end(JSON.stringify(body))
1464
1471
  }
1472
+
1473
+ // ── self-update (version check + apply) ────────────────────────────────────
1474
+ // dsh-remote is a zero-build plugin: the host half (lib/index.js) is loaded
1475
+ // straight from the installed package, so "updating" = replacing the package
1476
+ // files with the newer npm tarball. We never mutate node_modules directly
1477
+ // (pnpm owns it); instead we write the fresh files into the same directory
1478
+ // the loader already resolved, which is the profile's node_modules/dsh-remote
1479
+ // (hoisted) or the .pnpm store copy — both are writable for the local user.
1480
+ const NPM_PACKAGE = 'dsh-remote'
1481
+ const REGISTRY_URL = 'https://registry.npmjs.org/' + NPM_PACKAGE + '/latest'
1482
+ /** Absolute directory holding this plugin's installed files (lib/ + package.json). */
1483
+ const selfDir = () => {
1484
+ try {
1485
+ return path.dirname(path.dirname(fileURLToPath(import.meta.url)))
1486
+ } catch {
1487
+ return path.dirname(path.dirname(process.argv[1] || ''))
1488
+ }
1489
+ }
1490
+ const readVersion = () => {
1491
+ try {
1492
+ const pkg = JSON.parse(readFileSync(path.join(selfDir(), 'package.json'), 'utf8'))
1493
+ return String(pkg.version || '0.0.0')
1494
+ } catch {
1495
+ return '0.0.0'
1496
+ }
1497
+ }
1498
+ const currentVersion = readVersion()
1499
+ // A mode persisted via the settings UI (/dsh-remote/update-mode) wins over
1500
+ // the profile-config default, so the user's choice survives restarts without
1501
+ // editing cordis config.
1502
+ const persistedMode = (() => {
1503
+ try {
1504
+ const m = readFileSync(path.join(selfDir(), 'update-mode'), 'utf8').trim()
1505
+ return ['manual', 'auto', 'off'].includes(m) ? m : null
1506
+ } catch {
1507
+ return null
1508
+ }
1509
+ })()
1510
+ if (persistedMode) config.updateMode = persistedMode
1511
+ /** Query the npm registry for the latest release; resolves null on any failure. */
1512
+ const fetchLatestVersion = async (timeoutMs = 8000) => {
1513
+ try {
1514
+ const controller = new AbortController()
1515
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
1516
+ try {
1517
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal, headers: { 'accept': 'application/json' } })
1518
+ if (!res.ok) return null
1519
+ const data = await res.json()
1520
+ return typeof data?.version === 'string' && data.version ? data.version : null
1521
+ } finally {
1522
+ clearTimeout(timer)
1523
+ }
1524
+ } catch {
1525
+ return null
1526
+ }
1527
+ }
1528
+ /** Compare dotted versions; returns true when a > b. */
1529
+ const gtVersion = (a, b) => {
1530
+ const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0)
1531
+ const pb = String(b || '').split('.').map((n) => parseInt(n, 10) || 0)
1532
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1533
+ const da = pa[i] || 0
1534
+ const db = pb[i] || 0
1535
+ if (da !== db) return da > db
1536
+ }
1537
+ return false
1538
+ }
1539
+ /**
1540
+ * Download the given version's tarball and atomically replace the installed
1541
+ * package files (lib/*.js, cordis.patch.yml, package.json). A stale marker
1542
+ * file (.dsh-remote-updated) is written so the client can tell the user to
1543
+ * reload; the loader picks the new host half up on the next boot.
1544
+ */
1545
+ const applyUpdate = async (targetVersion) => {
1546
+ const tarballUrl = `https://registry.npmjs.org/${NPM_PACKAGE}/-/${NPM_PACKAGE}-${targetVersion}.tgz`
1547
+ const dir = selfDir()
1548
+ const tmpRoot = path.join(dir, `.dsh-remote-update-${Date.now()}`)
1549
+ const tmpPkg = path.join(tmpRoot, 'package')
1550
+ try {
1551
+ const controller = new AbortController()
1552
+ const timer = setTimeout(() => controller.abort(), 60000)
1553
+ let res
1554
+ try {
1555
+ res = await fetch(tarballUrl, { signal: controller.signal })
1556
+ } finally {
1557
+ clearTimeout(timer)
1558
+ }
1559
+ if (!res.ok) throw new Error(`download failed (HTTP ${res.status})`)
1560
+ const buf = Buffer.from(await res.arrayBuffer())
1561
+ // npm tarballs are gzipped tar; extract the package/ directory.
1562
+ mkdirSync(tmpRoot, { recursive: true })
1563
+ // Minimal tar.gz extractor: we only need package/{lib,package.json,cordis.patch.yml}.
1564
+ // Use node's zlib gunzip + a tiny tar parser for the regular files we ship.
1565
+ const { gunzipSync } = await import('node:zlib')
1566
+ const tar = gunzipSync(buf)
1567
+ const files = parseTar(tar)
1568
+ let extracted = 0
1569
+ for (const f of files) {
1570
+ const rel = f.name.replace(/^package\//, '')
1571
+ if (!rel || rel === 'package/' || rel.endsWith('/')) continue
1572
+ if (rel !== 'package.json' && rel !== 'cordis.patch.yml' && !rel.startsWith('lib/')) continue
1573
+ const dest = path.join(tmpPkg, rel)
1574
+ mkdirSync(path.dirname(dest), { recursive: true })
1575
+ writeFileSync(dest, f.data)
1576
+ extracted++
1577
+ }
1578
+ if (extracted === 0) throw new Error('tarball contained no package files')
1579
+ // Verify the downloaded package.json matches the requested version.
1580
+ const newPkg = JSON.parse(readFileSync(path.join(tmpPkg, 'package.json'), 'utf8'))
1581
+ if (newPkg.version !== targetVersion) throw new Error(`tarball version mismatch: ${newPkg.version}`)
1582
+ // Atomically swap: copy fresh files over the installed ones.
1583
+ const installLib = path.join(dir, 'lib')
1584
+ const tmpLib = path.join(tmpPkg, 'lib')
1585
+ for (const name of ['index.js', 'client.js']) {
1586
+ const src = path.join(tmpLib, name)
1587
+ if (existsSync(src)) copyFileSync(src, path.join(installLib, name))
1588
+ }
1589
+ for (const rel of ['package.json', 'cordis.patch.yml']) {
1590
+ const src = path.join(tmpPkg, rel)
1591
+ if (existsSync(src)) copyFileSync(src, path.join(dir, rel))
1592
+ }
1593
+ writeFileSync(path.join(dir, '.dsh-remote-updated'), String(targetVersion))
1594
+ return { ok: true, from: currentVersion, to: targetVersion }
1595
+ } catch (err) {
1596
+ throw new Error('update failed: ' + ((err && err.message) || err))
1597
+ } finally {
1598
+ try { rmSync(tmpRoot, { recursive: true, force: true }) } catch {}
1599
+ }
1600
+ }
1601
+ /** Minimal POSIX ustar parser (regular files only) for npm tarballs. */
1602
+ function parseTar(buf) {
1603
+ const files = []
1604
+ let off = 0
1605
+ while (off + 512 <= buf.length) {
1606
+ const header = buf.subarray(off, off + 512)
1607
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
1608
+ if (!name) break
1609
+ const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim()
1610
+ const size = parseInt(sizeStr, 8) || 0
1611
+ const type = String.fromCharCode(header[156] || 48)
1612
+ off += 512
1613
+ const data = type === '48' || type === '0' ? Buffer.from(buf.subarray(off, off + size)) : null
1614
+ if (data) files.push({ name, data })
1615
+ off += Math.ceil(size / 512) * 512
1616
+ }
1617
+ return files
1618
+ }
1619
+ // Auto mode: check once on load and on an interval; apply silently when a
1620
+ // newer version exists. Failures are swallowed (never break the plugin).
1621
+ let updateTimer = null
1622
+ if (config.updateMode === 'auto') {
1623
+ const checkAndApply = async () => {
1624
+ const latest = await fetchLatestVersion()
1625
+ if (latest && gtVersion(latest, currentVersion)) {
1626
+ try { await applyUpdate(latest) } catch {}
1627
+ }
1628
+ }
1629
+ void checkAndApply()
1630
+ updateTimer = setInterval(checkAndApply, Math.max(config.updateCheckIntervalMs, 60000))
1631
+ if (typeof updateTimer.unref === 'function') updateTimer.unref()
1632
+ ctx.effect(() => () => { if (updateTimer) clearInterval(updateTimer) }, 'dsh-remote.update-timer')
1633
+ }
1634
+
1465
1635
  // Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
1466
1636
  // unbounded memory on a local-only route.
1467
1637
  const MAX_BODY_BYTES = 1024 * 1024
@@ -1804,6 +1974,60 @@ export async function apply(ctx, config) {
1804
1974
  return sendJson(res, 200, { ok: true, ...status() })
1805
1975
  },
1806
1976
  },
1977
+ {
1978
+ kind: 'exact',
1979
+ path: '/dsh-remote/update-check',
1980
+ handler: async (req, res) => {
1981
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1982
+ const latest = await fetchLatestVersion()
1983
+ if (latest === null) return sendJson(res, 200, { ok: false, current: currentVersion, error: '无法连接 npm registry' })
1984
+ const updateAvailable = gtVersion(latest, currentVersion)
1985
+ const updatedMarker = existsSync(path.join(selfDir(), '.dsh-remote-updated'))
1986
+ return sendJson(res, 200, {
1987
+ ok: true,
1988
+ current: currentVersion,
1989
+ latest,
1990
+ updateAvailable,
1991
+ updateMode: config.updateMode,
1992
+ updatedMarker,
1993
+ })
1994
+ },
1995
+ },
1996
+ {
1997
+ kind: 'exact',
1998
+ path: '/dsh-remote/update-mode',
1999
+ handler: async (req, res) => {
2000
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2001
+ try {
2002
+ const body = JSON.parse((await readBody(req)) || '{}')
2003
+ const mode = String(body.mode || '')
2004
+ if (!['manual', 'auto', 'off'].includes(mode)) return sendJson(res, 400, { ok: false, error: 'mode must be manual | auto | off' })
2005
+ // Persist to the plugin's install dir so it survives restarts; the
2006
+ // effective value is read back on next load by the host half.
2007
+ const dir = selfDir()
2008
+ try { writeFileSync(path.join(dir, 'update-mode'), mode) } catch (e) { return sendJson(res, 500, { ok: false, error: 'cannot persist mode: ' + ((e && e.message) || e) }) }
2009
+ return sendJson(res, 200, { ok: true, mode })
2010
+ } catch (err) {
2011
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2012
+ }
2013
+ },
2014
+ },
2015
+ {
2016
+ kind: 'exact',
2017
+ path: '/dsh-remote/update-apply',
2018
+ handler: async (req, res) => {
2019
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2020
+ try {
2021
+ const body = JSON.parse((await readBody(req)) || '{}')
2022
+ const target = String(body.version || '')
2023
+ if (!target) return sendJson(res, 400, { ok: false, error: 'version is required' })
2024
+ const result = await applyUpdate(target)
2025
+ return sendJson(res, 200, { ok: true, ...result })
2026
+ } catch (err) {
2027
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2028
+ }
2029
+ },
2030
+ },
1807
2031
  ]
1808
2032
 
1809
2033
  const disposers = routes.map((r) => webServer.register(r))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password or key), pick a remote workspace, operate on it with rw_pick_workspace / rw_list_dir / rw_read_file / rw_exec / rw_sync tools, and mirror it to a real local directory (SFTP) so the DSH native workspace can adopt it.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",