dsh-remote 0.8.23 → 0.8.25

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 CHANGED
@@ -20,6 +20,48 @@ Manage several SSH machines, then pick a **remote workspace** (or a **local** on
20
20
 
21
21
  The harness Web UI intentionally binds `127.0.0.1` (the CLI rejects `--host 0.0.0.0` for safety). This plugin goes the other way: **you connect out** to the machines you maintain, pick a workspace, and work in it through the normal DSH workspace + agent fs flows — no changes to `dsh-workspace` or the harness core.
22
22
 
23
+ ## Data collection / telemetry
24
+
25
+ dsh-remote sends **one anonymous heartbeat per launch** (at most once every 6 hours) so the author can measure real usage: daily active installs, which version is actually running, and platform distribution. npm download counts cannot answer this (they are release-driven and include mirrors/crawlers), and GitHub clones include CI.
26
+
27
+ **What is sent** — exactly five fields, and nothing else:
28
+
29
+ | Field | Example | Purpose |
30
+ |---|---|---|
31
+ | `idHash` | `872bd8cf…` (32 hex) | `HMAC-SHA256('dsh-remote/telemetry/v1', installId)` — a pseudonym for this install |
32
+ | `version` | `0.8.24` | which version is actually running |
33
+ | `platform` | `win32` / `darwin` / `linux` | platform distribution |
34
+ | `arch` | `x64` / `arm64` | architecture |
35
+ | `node` | `24.14.0` | Node version |
36
+
37
+ **What is never sent** — hostnames, usernames, file paths, IP addresses, SSH hosts/ports/keys, your machine list, conversation content, or anything from your remote sessions. The original `installId` **never leaves your machine**: only its HMAC is transmitted, so the server cannot correlate it with anything else and cannot reverse it.
38
+
39
+ **Where the identity lives** — a random UUID in `<DSH_HOME>/.dsh-remote-install-id` (e.g. `~/.dsh/`). It is deliberately **not** stored in the plugin directory, which npm/pnpm overwrites on every upgrade; keeping it in `DSH_HOME` means an upgrade does not make you look like a new user. Delete that file to reset the identity.
40
+
41
+ The heartbeat is **fire-and-forget**: it never blocks loading, never logs noise, and any failure (offline, blocked, endpoint change) is swallowed silently — it can never affect any plugin feature.
42
+
43
+ <!--中文-->
44
+
45
+ ## 数据采集 / 遥测
46
+
47
+ dsh-remote 每次启动会发送**一次匿名心跳**(同一安装最多每 6 小时一次),用于统计真实使用量:去重日活、实际在跑的版本、平台分布。npm 下载量回答不了这些问题(由发版驱动、且含镜像与爬虫),GitHub clone 也混有 CI。
48
+
49
+ **发送的内容** —— 严格只有 5 个字段:
50
+
51
+ | 字段 | 示例 | 用途 |
52
+ |---|---|---|
53
+ | `idHash` | `872bd8cf…`(32 位 hex) | `HMAC-SHA256('dsh-remote/telemetry/v1', installId)`,本安装的伪名 |
54
+ | `version` | `0.8.24` | 实际在运行的版本 |
55
+ | `platform` | `win32` / `darwin` / `linux` | 平台分布 |
56
+ | `arch` | `x64` / `arm64` | 架构 |
57
+ | `node` | `24.14.0` | Node 版本 |
58
+
59
+ **绝不发送** —— 主机名、用户名、文件路径、IP、SSH 主机/端口/密钥、你的机器列表、会话内容、任何远程工作区数据。原始 `installId` **不离开本机**:只有它的 HMAC 被传出,服务端无法与其他数据关联,也无法反推。
60
+
61
+ **身份存放位置** —— `<DSH_HOME>/.dsh-remote-install-id`(如 `~/.dsh/`)中的一个随机 UUID。刻意**不放在插件目录**(npm/pnpm 每次升级都会覆盖),放 `DSH_HOME` 才能保证升级后不会把你算成新用户。删除该文件即重置身份。
62
+
63
+ 心跳是**尽力而为**的旁路:不阻塞加载、不产生日志噪音、任何失败(离线/被拦截/端点变更)都静默吞掉,绝不影响插件的任何功能。
64
+
23
65
  ## Screen previews
24
66
 
25
67
  Settings → **远程工作区** — a multi-machine SSH registry (add / edit / delete / set-current, password stored locally):
package/README.zh.md CHANGED
@@ -41,6 +41,26 @@ Desktop 安装器还可能要求明确配置 `ssh2` / `cpu-features` 可选构
41
41
 
42
42
  DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.0.0`)。本插件反过来:**由你主动连出**到你维护的机器,选一个工作区,然后通过 DSH 原生的工作区 + 文件流来工作——**不改动 `dsh-workspace` 核心**。
43
43
 
44
+ ## 数据采集 / 遥测
45
+
46
+ dsh-remote 每次启动会发送**一次匿名心跳**(同一安装最多每 6 小时一次),用于统计真实使用量:去重日活、实际在跑的版本、平台分布。npm 下载量回答不了这些问题(由发版驱动、且含镜像与爬虫),GitHub clone 也混有 CI。
47
+
48
+ **发送的内容** —— 严格只有 5 个字段,一个都不多:
49
+
50
+ | 字段 | 示例 | 用途 |
51
+ |---|---|---|
52
+ | `idHash` | `872bd8cf…`(32 位 hex) | `HMAC-SHA256('dsh-remote/telemetry/v1', installId)`,本安装的伪名 |
53
+ | `version` | `0.8.24` | 实际在运行的版本 |
54
+ | `platform` | `win32` / `darwin` / `linux` | 平台分布 |
55
+ | `arch` | `x64` / `arm64` | 架构 |
56
+ | `node` | `24.14.0` | Node 版本 |
57
+
58
+ **绝不发送** —— 主机名、用户名、文件路径、IP、SSH 主机/端口/密钥、你的机器列表、会话内容、任何远程工作区数据。本插件恰恰能拿到这些信息,所以边界在这里划得很硬:原始 `installId` **不离开本机**,只有它的 HMAC 被传出,服务端无法与其他数据关联,也无法反推身份。
59
+
60
+ **身份存放位置** —— `<DSH_HOME>/.dsh-remote-install-id`(如 `~/.dsh/`)中的一个随机 UUID。刻意**不放在插件目录**:npm/pnpm 每次升级都会覆盖插件目录,放那里会让同一台机器每次升级都换身份,把 1 个用户算成 N 个。删除该文件即重置身份。
61
+
62
+ 心跳是**尽力而为**的旁路:不阻塞加载、不产生日志噪音、任何失败(离线/被拦截/端点变更)都静默吞掉,绝不影响插件的任何功能。
63
+
44
64
  ## 界面预览
45
65
 
46
66
  设置 → **远程工作区** —— 多机 SSH 列表(增/删/改/设为当前,密码本地保存、不回显):
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},替换本机插件文件。更新后需重启 Harness 生效。继续?',
143
- 'settings.updateApplied': '✅ 已更新到 v{to}({from} → {to})。重启 Harness 生效。',
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 requires a Harness restart to take effect. Continue?',
367
- 'settings.updateApplied': '✅ Updated to v{to} ({from} → {to}). Restart Harness to apply.',
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
- checkUpdate(true)
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 } },
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,8 @@ 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, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
44
+ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload } from './update.js'
45
+ import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
45
46
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
46
47
  import { registerHttpTransports } from './http-transport.js'
47
48
  import { SshPool } from './pool.js'
@@ -130,6 +131,10 @@ export const Config = z.object({
130
131
  updateMode: z.string().default('manual'),
131
132
  /** How often (ms) auto mode checks the npm registry for a newer release. */
132
133
  updateCheckIntervalMs: z.number().step(1).min(60000).default(6 * 3600 * 1000),
134
+ /** Whether a landed update also hot-swaps the running host half (default
135
+ * true). With `false` an update only takes effect on the next process start
136
+ * and `/dsh-remote/update-check` reports `pendingReload: true` meanwhile. */
137
+ updateAutoReload: z.boolean().default(true),
133
138
  })
134
139
 
135
140
  // ── shell / path helpers (pure implementations in lib/paths.js) ───────────
@@ -258,6 +263,21 @@ export async function apply(ctx, config) {
258
263
 
259
264
  migrateLegacyData()
260
265
 
266
+ // ── 安装心跳(日活统计)─────────────────────────────────────────────────
267
+ // 旁路:不 await、不阻塞加载、失败静默(见 lib/telemetry.js 的隐私与失败策略)。
268
+ // 无配置开关(按作者要求默认开启);上报的是本插件专属 HMAC 伪名,
269
+ // 原始安装 id 与任何机器信息都不离开本机。
270
+ void sendHeartbeat(dshBase(), LOADED_VERSION)
271
+
272
+ // The update marker means "a newer version landed on disk but is not the code
273
+ // running yet". Reaching apply() with a marker naming the version we just
274
+ // loaded means the swap already happened (or this is a plain boot into the
275
+ // updated files) → clear it, so the UI's "restart to finish" hint stays true.
276
+ try {
277
+ const marker = path.join(selfDir(), '.dsh-remote-updated')
278
+ if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === LOADED_VERSION) rmSync(marker, { force: true })
279
+ } catch {}
280
+
261
281
  // ── machine registry (multi-host) ─────────────────────────────────────────
262
282
  const store = loadMachines()
263
283
  const machines = store.list
@@ -2765,17 +2785,26 @@ export async function apply(ctx, config) {
2765
2785
  path: '/dsh-remote/update-check',
2766
2786
  handler: async (req, res) => {
2767
2787
  if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2768
- const current = readVersion()
2769
- const latest = await fetchLatestVersion()
2770
- if (latest === null) return sendJson(res, 200, { ok: false, current, error: '无法连接 npm registry' })
2788
+ // `loaded` is the code actually running; `disk` may already be newer.
2789
+ // They differ between an applied update and its hot swap (or a boot).
2790
+ const loaded = LOADED_VERSION
2791
+ const disk = diskVersion()
2771
2792
  const rawMode = readUpdateMode() || config.updateMode || 'manual'
2793
+ const base = {
2794
+ current: loaded,
2795
+ loaded,
2796
+ disk,
2797
+ pendingReload: gtVersion(disk, loaded),
2798
+ updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
2799
+ updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
2800
+ }
2801
+ const latest = await fetchLatestVersion()
2802
+ if (latest === null) return sendJson(res, 200, { ok: false, ...base, error: '无法连接 npm registry' })
2772
2803
  return sendJson(res, 200, {
2773
2804
  ok: true,
2774
- current,
2805
+ ...base,
2775
2806
  latest,
2776
- updateAvailable: gtVersion(latest, current),
2777
- updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
2778
- updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
2807
+ updateAvailable: gtVersion(latest, loaded),
2779
2808
  })
2780
2809
  },
2781
2810
  },
@@ -2785,16 +2814,45 @@ export async function apply(ctx, config) {
2785
2814
  handler: async (req, res) => {
2786
2815
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2787
2816
  try {
2817
+ // Capture the outgoing version BEFORE the swap: reading it afterwards
2818
+ // reports the version we just installed, not the one we replaced.
2819
+ const from = diskVersion()
2820
+ const loaded = LOADED_VERSION
2788
2821
  const body = JSON.parse((await readBody(req)) || '{}')
2789
2822
  const target = String(body.version || '')
2790
2823
  if (!target) return sendJson(res, 400, { ok: false, error: 'version is required' })
2791
2824
  const result = await applyUpdate(target)
2792
- return sendJson(res, 200, { ok: true, from: readVersion(), ...result })
2825
+ const wantReload = body.reload !== false && config.updateAutoReload !== false
2826
+ // Answer first, then swap: the swap disposes the fiber serving this
2827
+ // very request, so nothing may touch the plugin after it.
2828
+ if (wantReload) scheduleSelfReload(ctx.loader, 300)
2829
+ return sendJson(res, 200, {
2830
+ ok: true,
2831
+ from,
2832
+ loadedBefore: loaded,
2833
+ ...result,
2834
+ reloadScheduled: wantReload,
2835
+ // what the swap will land on, for a caller that wants to verify
2836
+ loadedAfterReload: wantReload ? result.to : loaded,
2837
+ })
2793
2838
  } catch (err) {
2794
2839
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2795
2840
  }
2796
2841
  },
2797
2842
  },
2843
+ {
2844
+ kind: 'exact',
2845
+ path: '/dsh-remote/update-reload',
2846
+ handler: async (req, res) => {
2847
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2848
+ // Hot-swap the host half to whatever is on disk now. Answers first (with
2849
+ // what was loaded and what it is about to become), then swaps.
2850
+ const loaded = LOADED_VERSION
2851
+ const disk = diskVersion()
2852
+ scheduleSelfReload(ctx.loader, 300)
2853
+ return sendJson(res, 200, { ok: true, scheduled: true, loaded, disk, willReloadTo: disk })
2854
+ },
2855
+ },
2798
2856
  {
2799
2857
  kind: 'exact',
2800
2858
  path: '/dsh-remote/update-mode',
@@ -2821,12 +2879,24 @@ export async function apply(ctx, config) {
2821
2879
  const rawMode = readUpdateMode() || config.updateMode || 'manual'
2822
2880
  const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual'
2823
2881
  if (effectiveUpdateMode === 'auto') {
2824
- const currentVersion = readVersion()
2825
2882
  const checkAndApply = async () => {
2826
2883
  const latest = await fetchLatestVersion()
2827
- if (latest && gtVersion(latest, currentVersion)) {
2828
- try { await applyUpdate(latest) } catch {}
2884
+ if (!latest) return
2885
+ // The version in flight is whichever of "already on disk" / "already
2886
+ // loaded" is newer. Pinning it once outside this closure (as v0.8.23 did)
2887
+ // made every later interval re-download and re-apply the same tarball,
2888
+ // because the pinned value never moves past the version it installed.
2889
+ const disk = diskVersion()
2890
+ const current = gtVersion(disk, LOADED_VERSION) ? disk : LOADED_VERSION
2891
+ if (!gtVersion(latest, current)) return
2892
+ try {
2893
+ await applyUpdate(latest)
2894
+ } catch {
2895
+ return
2829
2896
  }
2897
+ // Landed: swap the running host half too, otherwise the code being served
2898
+ // and the code being run stay one version apart until the next boot.
2899
+ if (config.updateAutoReload !== false) scheduleSelfReload(ctx.loader, 500)
2830
2900
  }
2831
2901
  void checkAndApply()
2832
2902
  const updateTimer = setInterval(checkAndApply, Math.max(config.updateCheckIntervalMs, 60000))
@@ -0,0 +1,163 @@
1
+ // dsh-remote — 安装级心跳(日活统计)。
2
+ //
3
+ // 目的:npm 下载量只能证明"有人下载",GitHub clone 里混着 CI 与爬虫,
4
+ // 都无法回答"多少人真的在用、用的是哪一版、留在哪个平台"。这里上报一个
5
+ // **最小字段的心跳**,让作者能算出去重日活 / 版本分布 / 平台分布 / 留存。
6
+ //
7
+ // ── 隐私边界(改这个文件前先读)──────────────────────────────────────────
8
+ // 1) 上报的是伪名:`idHash = HMAC-SHA256(SALT, installId)[:32]`。
9
+ // 原始 installId **绝不出机器**,服务端拿到的是本插件专属伪名,
10
+ // 无法与其他数据交叉关联(installId 本身是随机 UUID,不含任何机器信息)。
11
+ // 2) installId 存在 `<DSH_HOME>/.dsh-remote-install-id`,**不在插件目录**:
12
+ // 插件目录会被 pnpm 重装覆盖,放在那里会让同一台机器每次升级都换身份,
13
+ // 把 1 个人算成 N 个。DSH_HOME 跨重装稳定。
14
+ // 3) 字段白名单固定为 {idHash, version, platform, arch, node}。
15
+ // 本插件能拿到 SSH 主机、路径、机器列表——**这些一律不上报**。
16
+ //
17
+ // ── 失败策略 ─────────────────────────────────────────────────────────────
18
+ // 全程静默:任何异常(网络不可达、磁盘只读、端点变更)都只吞掉,
19
+ // 绝不阻塞加载、绝不写日志噪音、绝不重试轰炸。心跳是"尽力而为",
20
+ // 它失败不能影响用户任何真实功能。
21
+
22
+ import { createHmac, randomUUID } from 'node:crypto'
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
24
+ import path from 'node:path'
25
+
26
+ /** 上报端点(CloudBase HTTP 访问服务)。改域名只需改这一行。 */
27
+ const HEARTBEAT_URL = 'https://gitbolg-d7gmnsrw46e011706-1256429518.ap-shanghai.app.tcloudbase.com/dsh-hb/heartbeat'
28
+ /** 伪名盐:服务端无法反推身份,且换盐即可切断历史关联。 */
29
+ const SALT = 'dsh-remote/telemetry/v1'
30
+ const ID_FILE = '.dsh-remote-install-id'
31
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
32
+ /** 上报超时:心跳是旁路,不能拖慢启动;但也不能太短 —— 实测端点本身
33
+ * 250~520ms,而 **DSH 启动瞬间的并发初始化会把首帧 fetch 饿到 5s 以上**
34
+ * (曾以 5s 超时稳定失败在首启,靠下次启动才补上,等于丢掉当天最早的那批用户)。
35
+ * 15s 覆盖启动抖动,且因为不 await,慢也不会影响加载。 */
36
+ const TIMEOUT_MS = 15000
37
+ /** 启动后延迟多久再发:避开启动高峰(插件树/路由/服务都在抢事件循环)。
38
+ * 仍不 await——延迟是为了让心跳真的成功,不是为了让用户等待。 */
39
+ const STARTUP_DELAY_MS = 3000
40
+ /** 两次上报之间的最小间隔(同一天内重复启动不再上报)。 */
41
+ const MIN_INTERVAL_MS = 6 * 60 * 60 * 1000
42
+
43
+ /** 安装身份的进程内缓存,**按身份文件路径分键**。
44
+ * ★ 不能只用一个全局变量:同一进程内出现第二个 DSH_HOME(隔离沙箱、
45
+ * 测试夹具、desktop 与 cli 并存)时会串号,把 A 的 id 当成 B 的,
46
+ * 日活与装机量一起失真。DSH 官方的 anonymous-user-id 同样是按路径 memo。 */
47
+ const idCache = new Map()
48
+
49
+ /**
50
+ * 读取或创建本安装的随机身份(稳定 UUID,存 DSH_HOME)。
51
+ * 磁盘只读等异常时返回内存生成的临时身份:日活会多算一次,但绝不报错。
52
+ * @param home - DSH_HOME 绝对路径。
53
+ */
54
+ export function installId(home) {
55
+ const file = path.join(home, ID_FILE)
56
+ const cached = idCache.get(file)
57
+ if (cached) return cached
58
+
59
+ try {
60
+ const existing = readFileSync(file, 'utf8').trim()
61
+ if (UUID_RE.test(existing)) {
62
+ idCache.set(file, existing)
63
+ return existing
64
+ }
65
+ } catch { /* 不存在或不可读 → 下面创建 */ }
66
+ const created = randomUUID()
67
+ try {
68
+ mkdirSync(home, { recursive: true })
69
+ writeFileSync(file, `${created}\n`, 'utf8')
70
+ } catch { /* 只读也不影响本次运行 */ }
71
+ idCache.set(file, created)
72
+ return created
73
+ }
74
+
75
+ /** 稳定的匿名伪名(32 位 hex)。原始 id 不离开本进程。 */
76
+ export function pseudonym(home) {
77
+ return createHmac('sha256', SALT).update(installId(home)).digest('hex').slice(0, 32)
78
+ }
79
+
80
+ /** 是否到该上报了(进程内节流,避免每次工具调用都发)。 */
81
+ let lastSentAt = 0
82
+
83
+ /**
84
+ * 发送一次心跳。**永不抛出**,也永不返回可导致分支的失败态:
85
+ * 调用方不需要 try/catch,心跳成功与否都不应改变任何行为。
86
+ * @param home - DSH_HOME 绝对路径。
87
+ * @param version - 运行中的插件版本(用 LOADED_VERSION,即真正在跑的代码版本)。
88
+ * @param options.delayMs - 发送前延迟(默认 {@link STARTUP_DELAY_MS});
89
+ * 避开启动高峰。测试可传 0。
90
+ * @returns 是否真的发出了请求(仅用于测试与自检)。
91
+ */
92
+ export async function sendHeartbeat(home, version, options = {}) {
93
+ const now = Date.now()
94
+ if (now - lastSentAt < MIN_INTERVAL_MS) return false
95
+
96
+ const delayMs = options.delayMs === undefined ? STARTUP_DELAY_MS : Math.max(0, options.delayMs)
97
+ // 先占位,防止并发触发重复上报(本插件是单进程,够用)
98
+ lastSentAt = now
99
+ try {
100
+ if (delayMs) await new Promise((r) => {
101
+ const t = setTimeout(r, delayMs)
102
+ if (typeof t.unref === 'function') t.unref()
103
+ })
104
+ const body = JSON.stringify({
105
+ idHash: pseudonym(home),
106
+ version: String(version || '0.0.0'),
107
+ platform: process.platform,
108
+ arch: process.arch,
109
+ node: process.versions.node,
110
+ })
111
+ const controller = new AbortController()
112
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
113
+ try {
114
+ await fetch(HEARTBEAT_URL, {
115
+ method: 'POST',
116
+ headers: { 'content-type': 'application/json' },
117
+ body,
118
+ signal: controller.signal,
119
+ })
120
+ } finally {
121
+ clearTimeout(timer)
122
+ }
123
+ return true
124
+ } catch {
125
+ // 网络不可达 / 端点变更 / 被拦截:一律忽略。
126
+ // 允许下次重试:回滚节流,避免一次失败就把身份静默六个小时。
127
+ lastSentAt = 0
128
+ return false
129
+ }
130
+ }
131
+
132
+ /** 清空节流与缓存(仅测试用)。 */
133
+ export function _resetForTest() {
134
+ lastSentAt = 0
135
+ idCache.clear()
136
+ }
137
+
138
+ /** 心跳是否已配置(供设置页显示;不含端点本身)。 */
139
+ export function heartbeatEnabled() {
140
+ return typeof HEARTBEAT_URL === 'string' && HEARTBEAT_URL.startsWith('https://')
141
+ }
142
+
143
+ /** 供自检/文档使用的端点(不涉密,可公开)。 */
144
+ export function heartbeatUrl() {
145
+ return HEARTBEAT_URL
146
+ }
147
+
148
+ /** 身份文件是否已存在(自检用,不创建)。 */
149
+ export function hasPersistedId(home) {
150
+ try {
151
+ return UUID_RE.test(readFileSync(path.join(home, ID_FILE), 'utf8').trim())
152
+ } catch {
153
+ return false
154
+ }
155
+ }
156
+
157
+ /** 供自检:确认 installId 落盘位置(不创建)。 */
158
+ export function installIdPath(home) {
159
+ return path.join(home, ID_FILE)
160
+ }
161
+
162
+ // existsSync 仅用于自检导出,避免被 tree-shake 掉误报
163
+ export { existsSync as _existsSync }
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
- // atomically copy over the installed files. A `.dsh-remote-updated` marker
12
- // is written so the UI can tell the user to reload.
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 fetch(tarballUrl, { signal: controller.signal })
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
- // Atomically swap: copy fresh files over the installed ones.
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)) copyFileSync(src, path.join(installLib, name))
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)) copyFileSync(src, path.join(dir, rel))
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.23",
3
+ "version": "0.8.25",
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>",