dsh-remote 0.8.25 → 0.8.27

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
@@ -10,7 +10,7 @@
10
10
  [![license](https://img.shields.io/github/license/flymysql/dsh-remote)](LICENSE)
11
11
  [![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-7a3ef3)](https://github.com/topics/dsh-plugin)
12
12
 
13
- Maintained by [@flymysql](https://github.com/flymysql) · [Blog](https://gitpull.cn) · [Discussions](https://github.com/flymysql/dsh-remote/discussions) · [Issues](https://github.com/flymysql/dsh-remote/issues) · [中文说明](./README.zh.md)
13
+ Maintained by [@flymysql](https://github.com/flymysql) · [Homepage](https://flymysql.github.io/dsh-remote/) · [Usage stats](https://flymysql.github.io/dsh-remote/stats/) · [Blog](https://gitpull.cn) · [Discussions](https://github.com/flymysql/dsh-remote/discussions) · [Issues](https://github.com/flymysql/dsh-remote/issues) · [中文说明](./README.zh.md)
14
14
 
15
15
  ![dsh-remote — make any SSH machine a real DSH workspace](docs/cover.png)
16
16
 
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # dsh-remote
6
6
 
7
- 由 [@flymysql](https://github.com/flymysql) 维护 · [博客](https://gitpull.cn) · [讨论区](https://github.com/flymysql/dsh-remote/discussions) · [Issue](https://github.com/flymysql/dsh-remote/issues) · [English](./README.md)
7
+ 由 [@flymysql](https://github.com/flymysql) 维护 · [主页](https://flymysql.github.io/dsh-remote/) · [用量统计](https://flymysql.github.io/dsh-remote/stats/) · [博客](https://gitpull.cn) · [讨论区](https://github.com/flymysql/dsh-remote/discussions) · [Issue](https://github.com/flymysql/dsh-remote/issues) · [English](./README.md)
8
8
 
9
9
  ## 官方 Desktop 兼容适配(实验性,尚未发布)
10
10
 
package/lib/client.js CHANGED
@@ -143,6 +143,7 @@ window.__ModuleLoader__.load({
143
143
  'settings.updateApplied': '✅ 已更新到 v{to}({from} → {to}),宿主半已在线热切换。',
144
144
  'settings.updateAppliedNoReload': '✅ 已更新到 v{to}({from} → {to})。重启 Harness 生效。',
145
145
  'settings.updatePendingReload': '⚠️ 磁盘已是 v{disk},运行中的仍是 v{loaded};点「立即更新」或重启 Harness 完成切换。',
146
+ 'settings.selfUpdateBlocked': 'ℹ️ 当前是 link:/开发模式安装(不在 node_modules 下),已禁止自动更新以免覆盖源码——请在你的源码仓库里更新。',
146
147
  'settings.updateFailed': '更新失败',
147
148
  'settings.updateFailedMsg': '更新失败: {msg}',
148
149
  'settings.registryUnreachable': '无法连接 npm registry',
@@ -369,6 +370,7 @@ window.__ModuleLoader__.load({
369
370
  'settings.updateApplied': '✅ Updated to v{to} ({from} → {to}); the host half hot-swapped in place.',
370
371
  'settings.updateAppliedNoReload': '✅ Updated to v{to} ({from} → {to}). Restart Harness to apply.',
371
372
  'settings.updatePendingReload': '⚠️ v{disk} is on disk but v{loaded} is still running; update again or restart Harness to switch.',
373
+ 'settings.selfUpdateBlocked': 'ℹ️ This is a link:/dev install (not under node_modules), so self-update is disabled to avoid overwriting source — update it from its source repo.',
372
374
  'settings.updateFailed': 'Update failed',
373
375
  'settings.updateFailedMsg': 'Update failed: {msg}',
374
376
  'settings.registryUnreachable': 'Cannot reach the npm registry',
@@ -820,7 +822,9 @@ window.__ModuleLoader__.load({
820
822
  const [upd, setUpd] = React.useState(null) // { current, latest, updateAvailable, updateMode, updatedMarker }
821
823
  const [updBusy, setUpdBusy] = React.useState(false)
822
824
  const [updMsg, setUpdMsg] = React.useState('')
823
- const [updMode, setUpdMode] = React.useState('manual')
825
+ // Mirrors the host default (DEFAULT_UPDATE_MODE = 'auto') so the panel
826
+ // does not flash "manual" before the first /update-check answers.
827
+ const [updMode, setUpdMode] = React.useState('auto')
824
828
  const checkUpdate = (quiet) => {
825
829
  if (updBusy) return
826
830
  setUpdBusy(true); setUpdMsg('')
@@ -1139,6 +1143,10 @@ window.__ModuleLoader__.load({
1139
1143
  ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.warn } },
1140
1144
  tr('settings.updatePendingReload', { disk: upd.disk, loaded: upd.loaded }))
1141
1145
  : null,
1146
+ upd && upd.selfUpdateAllowed === false
1147
+ ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.warn } },
1148
+ tr('settings.selfUpdateBlocked'))
1149
+ : null,
1142
1150
  updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
1143
1151
  ),
1144
1152
  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
@@ -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, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload } from './update.js'
44
+ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, isInstalledCopy } from './update.js'
45
45
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
46
46
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
47
47
  import { registerHttpTransports } from './http-transport.js'
@@ -51,6 +51,16 @@ import { listDirStructured, removeRemoteTree } from './remote-fs.js'
51
51
 
52
52
  export const name = 'dsh-remote'
53
53
 
54
+ /** Update mode used when neither the persisted `update-mode` file nor the
55
+ * profile config supplies one.
56
+ *
57
+ * Kept as one exported constant on purpose: the value appears in the schema
58
+ * default, in the `/update-check` response and in the auto-update gate, and
59
+ * three hard-coded copies drift (the first version of this change left two
60
+ * `|| 'manual'` fallbacks that quietly disagreed with the new schema default).
61
+ * Changing the default must change exactly one place. */
62
+ export const DEFAULT_UPDATE_MODE = 'auto'
63
+
54
64
  // Web and Desktop share the SSH tools; each optional UI transport is attached
55
65
  // reactively below, so service startup order does not drop the JSON routes.
56
66
  export const inject = ['tools', 'systemPrompt']
@@ -124,11 +134,18 @@ export const Config = z.object({
124
134
  /** Wall-clock budget (ms) for one remote `@` index pass; on expiry the
125
135
  * partial index answers rather than making the caret wait. */
126
136
  fileReferenceTimeoutMs: z.number().step(1).min(200).default(4000),
127
- /** Update mode: `manual` (default) only checks when asked; `auto` checks on
128
- * load and periodically, applying a newer npm release automatically;
129
- * `off` disables version checks entirely. (schemastery 3.18 has no .enum —
130
- * keep string and validate in code.) */
131
- updateMode: z.string().default('manual'),
137
+ /** Update mode: `auto` (default) checks on load and periodically, applies a
138
+ * newer npm release automatically, then hot-swaps the host half (see
139
+ * `updateAutoReload`); `manual` only checks when the user asks; `off` disables
140
+ * version checks entirely. (schemastery 3.18 has no .enum — keep string and
141
+ * validate in code.)
142
+ *
143
+ * `auto` became the default in 0.8.27. It could not have been a safe default
144
+ * before 0.8.24: an update only replaced files on disk and needed a restart,
145
+ * which left users running a new browser half against an old host half with no
146
+ * visible signal. 0.8.24 added the hot swap (and `pendingReload` when it is
147
+ * turned off), so an unattended update now actually takes effect. */
148
+ updateMode: z.string().default(DEFAULT_UPDATE_MODE),
132
149
  /** How often (ms) auto mode checks the npm registry for a newer release. */
133
150
  updateCheckIntervalMs: z.number().step(1).min(60000).default(6 * 3600 * 1000),
134
151
  /** Whether a landed update also hot-swaps the running host half (default
@@ -2789,14 +2806,21 @@ export async function apply(ctx, config) {
2789
2806
  // They differ between an applied update and its hot swap (or a boot).
2790
2807
  const loaded = LOADED_VERSION
2791
2808
  const disk = diskVersion()
2792
- const rawMode = readUpdateMode() || config.updateMode || 'manual'
2809
+ // Three-layer precedence: the persisted `update-mode` override (settings
2810
+ // UI) beats the profile config, which beats DEFAULT_UPDATE_MODE. The
2811
+ // fallback must not hard-code a second default here, or this path would
2812
+ // silently disagree with the schema default.
2813
+ const rawMode = readUpdateMode() || config.updateMode || DEFAULT_UPDATE_MODE
2793
2814
  const base = {
2794
2815
  current: loaded,
2795
2816
  loaded,
2796
2817
  disk,
2797
2818
  pendingReload: gtVersion(disk, loaded),
2798
- updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
2819
+ updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : DEFAULT_UPDATE_MODE,
2799
2820
  updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
2821
+ // Exposed so the settings page can explain why `auto` may not act:
2822
+ // a link:/dev install must not be overwritten by an npm publish.
2823
+ selfUpdateAllowed: isInstalledCopy(),
2800
2824
  }
2801
2825
  const latest = await fetchLatestVersion()
2802
2826
  if (latest === null) return sendJson(res, 200, { ok: false, ...base, error: '无法连接 npm registry' })
@@ -2875,9 +2899,10 @@ export async function apply(ctx, config) {
2875
2899
 
2876
2900
  // ── auto-update: check on load + on an interval; apply silently ───────────
2877
2901
  // Failures are swallowed (never break the plugin). The persisted override
2878
- // (settings UI) wins over the profile-config default.
2879
- const rawMode = readUpdateMode() || config.updateMode || 'manual'
2880
- const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual'
2902
+ // (settings UI) wins over the profile-config default, which wins over
2903
+ // DEFAULT_UPDATE_MODE — the single source of truth for the fallback.
2904
+ const rawMode = readUpdateMode() || config.updateMode || DEFAULT_UPDATE_MODE
2905
+ const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : DEFAULT_UPDATE_MODE
2881
2906
  if (effectiveUpdateMode === 'auto') {
2882
2907
  const checkAndApply = async () => {
2883
2908
  const latest = await fetchLatestVersion()
@@ -2892,6 +2917,9 @@ export async function apply(ctx, config) {
2892
2917
  try {
2893
2918
  await applyUpdate(latest)
2894
2919
  } catch {
2920
+ // 常见且预期的失败:link:/开发模式安装时 applyUpdate 会拒绝
2921
+ // (避免覆盖源码仓库)。这不是异常,静默即可——那类用户在自己的
2922
+ // 仓库里更新。
2895
2923
  return
2896
2924
  }
2897
2925
  // Landed: swap the running host half too, otherwise the code being served
package/lib/telemetry.js CHANGED
@@ -23,8 +23,19 @@ import { createHmac, randomUUID } from 'node:crypto'
23
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
24
24
  import path from 'node:path'
25
25
 
26
- /** 上报端点(CloudBase HTTP 访问服务)。改域名只需改这一行。 */
27
- const HEARTBEAT_URL = 'https://gitbolg-d7gmnsrw46e011706-1256429518.ap-shanghai.app.tcloudbase.com/dsh-hb/heartbeat'
26
+ /** 上报端点(CloudBase HTTP 访问服务)。改域名只需改这一行。
27
+ * 可用 `DSH_REMOTE_HEARTBEAT_URL` 覆盖——存在的理由不是"让用户改"
28
+ * (默认行为不该需要配置),而是:① 测试必须能把它重定向掉,
29
+ * 否则跑一次 npm test 就会往生产统计里灌假装机(发生过);
30
+ * ② 作者换端点/灰度时不必为了改一行字符串发一次版。 */
31
+ const DEFAULT_HEARTBEAT_URL = 'https://gitbolg-d7gmnsrw46e011706-1256429518.ap-shanghai.app.tcloudbase.com/dsh-hb/heartbeat'
32
+
33
+ /** 解析实际使用的端点(env 覆盖优先,仅接受 https,避免被降级到明文)。 */
34
+ export function heartbeatUrl() {
35
+ const override = process.env.DSH_REMOTE_HEARTBEAT_URL
36
+ if (override && /^https:\/\//i.test(override)) return override
37
+ return DEFAULT_HEARTBEAT_URL
38
+ }
28
39
  /** 伪名盐:服务端无法反推身份,且换盐即可切断历史关联。 */
29
40
  const SALT = 'dsh-remote/telemetry/v1'
30
41
  const ID_FILE = '.dsh-remote-install-id'
@@ -80,16 +91,34 @@ export function pseudonym(home) {
80
91
  /** 是否到该上报了(进程内节流,避免每次工具调用都发)。 */
81
92
  let lastSentAt = 0
82
93
 
94
+ /** 是否处于测试进程。
95
+ *
96
+ * ★ 这是一道**代码级**兜底,不是便利设施。事故背景:光靠 package.json 里的
97
+ * `--import ./test/setup-telemetry-off.mjs` 前置不够——它只覆盖 `npm test`,
98
+ * 任何人直接 `node --test test/upload.test.js` 就会绕过它,而 upload.test.js
99
+ * 会调用真实 apply(),于是**线上日活表被写进来自测试的假装机**
100
+ * (实测:直接跑一次,云端立刻多一行;曾出现"只用过 Windows 的机器冒出 Linux 装机")。
101
+ *
102
+ * Node 的测试运行器会在**子进程**里设 `NODE_TEST_CONTEXT`(如 child-v8),
103
+ * 父进程可能没有,所以同时看父进程的 `--test` 参数。判定为真时直接不发。
104
+ * 代价:测试里也就无法验证"真的发出去了"——那正是本函数要保证的,
105
+ * 真机端到端请用隔离沙箱启动真实 DSH(README 有说明)。
106
+ */
107
+ export function inTestProcess() {
108
+ if (process.env.NODE_TEST_CONTEXT) return true
109
+ const execArgv = process.execArgv || []
110
+ if (execArgv.some((a) => a === '--test' || String(a).startsWith('--test-'))) return true
111
+ return false
112
+ }
113
+
83
114
  /**
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 是否真的发出了请求(仅用于测试与自检)。
115
+ * 真正执行发送(不含"是否处于测试进程"的守卫)。
116
+ *
117
+ * 拆出来的原因:守卫本身必须可测,而"能测"又不能变成生产代码的后门——
118
+ * 所以守卫只存在于 {@link sendHeartbeat} 里,本函数只被它与测试直接调用,
119
+ * 且**不导出给业务代码**(导出名带下划线前缀,见文件末尾)。
91
120
  */
92
- export async function sendHeartbeat(home, version, options = {}) {
121
+ async function deliver(home, version, options = {}) {
93
122
  const now = Date.now()
94
123
  if (now - lastSentAt < MIN_INTERVAL_MS) return false
95
124
 
@@ -98,8 +127,12 @@ export async function sendHeartbeat(home, version, options = {}) {
98
127
  lastSentAt = now
99
128
  try {
100
129
  if (delayMs) await new Promise((r) => {
101
- const t = setTimeout(r, delayMs)
102
- if (typeof t.unref === 'function') t.unref()
130
+ // ★ 这里**不能 unref**。曾经 unref 过延迟定时器,结果是:await 一个已
131
+ // unref 的定时器不会让事件循环保活,当进程恰好没有别的 pending 句柄时
132
+ // Node 会**直接退出**,心跳一次都发不出去(CI 上稳定复现:本地因为其它
133
+ // 测试还留着句柄而侥幸变绿)。延迟是为了让心跳成功,就必须让它把进程
134
+ // 留住这几秒;插件真实运行时也不可能因为多等 3s 影响用户。
135
+ setTimeout(r, delayMs)
103
136
  })
104
137
  const body = JSON.stringify({
105
138
  idHash: pseudonym(home),
@@ -111,7 +144,7 @@ export async function sendHeartbeat(home, version, options = {}) {
111
144
  const controller = new AbortController()
112
145
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
113
146
  try {
114
- await fetch(HEARTBEAT_URL, {
147
+ await fetch(heartbeatUrl(), {
115
148
  method: 'POST',
116
149
  headers: { 'content-type': 'application/json' },
117
150
  body,
@@ -129,6 +162,21 @@ export async function sendHeartbeat(home, version, options = {}) {
129
162
  }
130
163
  }
131
164
 
165
+ /**
166
+ * 发送一次心跳。**永不抛出**,也永不返回可导致分支的失败态:
167
+ * 调用方不需要 try/catch,心跳成功与否都不应改变任何行为。
168
+ * @param home - DSH_HOME 绝对路径。
169
+ * @param version - 运行中的插件版本(用 LOADED_VERSION,即真正在跑的代码版本)。
170
+ * @param options.delayMs - 发送前延迟(默认 {@link STARTUP_DELAY_MS});
171
+ * 避开启动高峰。测试可传 0。
172
+ * @returns 是否真的发出了请求(仅用于测试与自检)。
173
+ */
174
+ export async function sendHeartbeat(home, version, options = {}) {
175
+ // 测试进程一律不外呼:统计数据的可信度比"测试里能验证外呼"重要得多。
176
+ if (inTestProcess()) return false
177
+ return deliver(home, version, options)
178
+ }
179
+
132
180
  /** 清空节流与缓存(仅测试用)。 */
133
181
  export function _resetForTest() {
134
182
  lastSentAt = 0
@@ -137,12 +185,7 @@ export function _resetForTest() {
137
185
 
138
186
  /** 心跳是否已配置(供设置页显示;不含端点本身)。 */
139
187
  export function heartbeatEnabled() {
140
- return typeof HEARTBEAT_URL === 'string' && HEARTBEAT_URL.startsWith('https://')
141
- }
142
-
143
- /** 供自检/文档使用的端点(不涉密,可公开)。 */
144
- export function heartbeatUrl() {
145
- return HEARTBEAT_URL
188
+ return heartbeatUrl().startsWith('https://')
146
189
  }
147
190
 
148
191
  /** 身份文件是否已存在(自检用,不创建)。 */
@@ -159,5 +202,12 @@ export function installIdPath(home) {
159
202
  return path.join(home, ID_FILE)
160
203
  }
161
204
 
162
- // existsSync 仅用于自检导出,避免被 tree-shake 掉误报
163
- export { existsSync as _existsSync }
205
+ /**
206
+ * 测试专用入口:绕过"测试进程不外呼"的守卫,用来验证发送逻辑本身
207
+ * (字段白名单、节流、失败回滚、延迟)。
208
+ *
209
+ * 之所以必须存在:守卫让 sendHeartbeat 在测试里恒返回 false,于是发送逻辑
210
+ * 就完全测不到了。真正的安全来自**端点可覆盖**(测试把 URL 指向本地不可达地址)
211
+ * 而不是来自这个函数——它只是把守卫那道门打开给测试用。
212
+ */
213
+ export const _deliverForTest = deliver
package/lib/update.js CHANGED
@@ -39,6 +39,27 @@ export function selfDir() {
39
39
  }
40
40
  }
41
41
 
42
+ /**
43
+ * 本插件是否装在 `node_modules` 之下(即真正的"安装副本")。
44
+ *
45
+ * ★ 为什么必须有这道判断(实测依据,不是防御性猜测):
46
+ * `selfDir()` 基于 `import.meta.url`,而 **Node 的 ESM 解析会对符号链接做
47
+ * realpath**(已实测:经由 symlink 导入时 `import.meta.url` 报告的是
48
+ * `/real/mod.mjs` 而不是 `/link/mod.mjs`)。于是当插件以 `link:` / 开发模式
49
+ * 安装时,`selfDir()` 指向的是**用户的源码仓库**,而不是安装副本。
50
+ * 一旦 `auto` 更新落地,`applyUpdate` 就会拿 npm 包覆盖那份源码
51
+ * (丢改动、脏工作树)。默认改成 `auto` 之后这个风险从"手点才触发"
52
+ * 变成"自动发生",所以在此拦下:不在 node_modules 下就不自更新。
53
+ *
54
+ * 代价:少数把插件放在非标准路径的部署不会被自动更新——他们仍可手动更新。
55
+ * 这个方向的取舍是正确的:宁可少更新,也不能悄悄毁掉源码。
56
+ * @param dir - 要判定的目录(默认本插件的 selfDir())。
57
+ */
58
+ export function isInstalledCopy(dir = selfDir()) {
59
+ const normalized = String(dir).split(path.sep).join('/').toLowerCase()
60
+ return normalized.includes('/node_modules/')
61
+ }
62
+
42
63
  /** Read the installed package.json version; "0.0.0" when unreadable. */
43
64
  export function readVersion() {
44
65
  try {
@@ -77,6 +98,9 @@ export async function fetchLatestVersion(timeoutMs = 8000) {
77
98
  try {
78
99
  const controller = new AbortController()
79
100
  const timer = setTimeout(() => controller.abort(), timeoutMs)
101
+ // 纯看门狗:调用方并不 await 它,所以必须 unref,否则这一段超时窗口
102
+ // 会拖住事件循环、让本该退出的短命进程(脚本、测试)多挂数秒。
103
+ if (typeof timer.unref === 'function') timer.unref()
80
104
  try {
81
105
  const res = await fetch(REGISTRY_URL, { signal: controller.signal, headers: { accept: 'application/json' } })
82
106
  if (!res.ok) return null
@@ -117,6 +141,13 @@ function parseTar(buf) {
117
141
  * @param targetVersion - exact npm version to install (e.g. "0.8.0").
118
142
  */
119
143
  export async function applyUpdate(targetVersion, options = {}) {
144
+ // 拒绝对非安装副本自更新:`auto` 默认开启后,一次自动更新就会覆盖
145
+ // `selfDir()` 指向的目录,而以 link:/开发模式安装时那正是用户的源码仓库
146
+ // (见 isInstalledCopy 的实测说明)。测试用 options.dir 指向临时目录,
147
+ // 因此不受此限(options.dir 存在即视为显式指定,跳过检查)。
148
+ if (!options.dir && !isInstalledCopy()) {
149
+ throw new Error('refusing to self-update: this copy is not under node_modules (link/dev install) — update it from its source repo instead')
150
+ }
120
151
  const tarballUrl = `https://registry.npmjs.org/${NPM_PACKAGE}/-/${NPM_PACKAGE}-${targetVersion}.tgz`
121
152
  const dir = options.dir || selfDir()
122
153
  const doFetch = options.fetchImpl || fetch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.25",
3
+ "version": "0.8.27",
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>",
@@ -77,6 +77,6 @@
77
77
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
78
78
  },
79
79
  "scripts": {
80
- "test": "node --test"
80
+ "test": "node --import ./test/setup-telemetry-off.mjs --test"
81
81
  }
82
82
  }