dsh-crossterm 0.8.9 → 0.8.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.8.11 — 2026-10-20
4
+
5
+ ### 更新不再把版本装低(YOCE_C 实测事故)
6
+
7
+ 来源:跨设备邮件(YOCE_C,2026-10-20T06:04:39Z)报告 —— 0.8.2 那台点「更新」被装成 **0.6.1**,还回 `ok:true updated:true`。
8
+
9
+ - **根因**:`updatePlugin()` 用 `pnpm add dsh-crossterm@latest`,pnpm 命中了缓存里的旧 packument
10
+ (`reused 2, downloaded 0, added 1`),装成 0.6.1;成功判定又是「装完版本 != 装前版本」,
11
+ 于是降级也算成功。同类事故 10-19 09:51Z 也发生过(0.7.2 → 0.3.38)。
12
+ - **修法**:已知 `latest` 就钉版本号 `dsh-crossterm@<latest>`(拿不到才回退 `@latest`),
13
+ 三级兜底(dsh → cli → pnpm)每步都加 `--prefer-online`;新增 `settle()`:
14
+ 装完重读版本,`installed < before` 判为降级 → 按 `before` 精确回滚并记 `rollback`;
15
+ **成功条件是 `installed === latest`**,降级一律报错、日志写 `downgraded/rollback`。
16
+ - 路由返回体加 `downgraded`、`rollback`;文案分「已恢复到 X,请重试」「恢复失败请手动重装 X」。
17
+
18
+ ### 同 id 邮件副本不再各自重投(F7 收尾)
19
+
20
+ - 以前只有「已投递成功」才按信封 id 归档重复副本;第一份投递失败时,同 id 的其他副本会各自重试,
21
+ 最后可能投两次(YOCE_C 用 dedupe-a/dedupe-b 实测到)。
22
+ - 现在 `state.failedIds[信封id] = 首份文件名`:失败也记 id,同 id 的其他副本直接归档进 `done/`;
23
+ 首份重投成功后清 `failedIds`。`state` 形状兼容旧文件(缺字段按空对象)。
24
+
25
+ ## 0.8.10 — 2026-10-19
26
+
27
+ - **云端设备的显示规则调整**(User said (m11109):名称(权限)+在线状态不变,版本只在有差异时显示):
28
+ 每台设备从 `名称 · 角色 · 状态 · v版本` 改为 `名称(角色) · 状态`,
29
+ 例如 `YOCE_C(成员) · 3 小时前`;版本号只在该设备与**本机版本不一致**时才追加(` · v0.8.2`),
30
+ 与本机一致的设备不再显示版本,避免每台都挂一串相同版本号。
31
+ - 仅改 `client/client.js` 的 `devicesText()` 渲染逻辑,接口与数据不变。
32
+
3
33
  ## 0.8.9 — 2026-10-19
4
34
 
5
35
  ### 第一个组件从「总开关」改成「自更新开关」
package/README.md CHANGED
@@ -28,27 +28,25 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
28
28
  > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
29
29
  > 所以它单独做入口(配置卡片 + 自动更新开关),标题带「(自更新)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
30
30
 
31
- **本版更新(0.8.9)**
32
-
33
- - **第一个组件从「总开关」改成「自更新开关」**:它只管「有没有新版本、要不要自动装」,
34
- 不再管另外四个组件。以前关掉它会连坐停掉工具、心跳、云同步、中转站与备份;现在关掉它只失去卡片与自动更新,
35
- 跨会话 / 跨终端 / 跨设备 / 跨账号 各自独立开关、照常工作。
36
- - **自动更新默认关**:卡片新增「自动更新」一行与开关,打开后按 `autoUpdateIntervalMs`(默认 6 小时)检查
37
- npm 最新版并自动安装,结果写进 `logs/update.log`。装完**不会自己重启**,卡片提示「重启 DSH 后生效」;
38
- 关掉后仍可手动点「检查更新 / 更新」。
39
- - **服务注入拆掉**:0.7.x–0.8.8 是 `ui` 行 `root.provide('crossterm')`、其余四行 `inject` 它;
40
- 0.8.9 起五行都只 `inject(['agents'])`,各挂各的。
41
- - 新增配置:`autoUpdate`(默认 `false`)、`autoUpdateIntervalMs`(默认 `21600000`,即 6 小时,范围 1 分钟–7 天)。
42
-
43
- **上一版(0.8.8)**
44
-
45
- - **「维护者」一栏只给维护端看**:以前任何设备装上插件都能看到负责人是谁;现在只在维护端显示 ——
46
- 本机 `nodeId` 必须出现在 `MAINTAINERS.md` 的负责人/助手条目里(两者在同一台机器上时都算),
47
- 或用配置 `maintainerDevices` 显式放行。其他设备上这行直接不渲染。
48
- - **换机器只改一个字段**:负责人换机器时,把 `MAINTAINERS.md` 里 `current.nodeId` 改成新机器,
49
- 旧机器下次刷新卡片自动隐藏,不用改代码。
50
-
51
- **更早(0.8.7 / 0.8.6 / 0.8.5 / 0.8.2 / 0.8.0)**
31
+ **本版更新(0.8.11)**
32
+
33
+ - **更新不再把版本装低**:点「更新」时钉死 `dsh-crossterm@<npm 最新版>` 并加 `--prefer-online`
34
+ (以前 `@latest` 会命中 pnpm 缓存里的旧 packument,实测把 0.8.2 装成 0.6.1 还判成功);
35
+ 装完必须**等于**最新版才算成功,装低了会自动回滚到原版本并报错。
36
+ - **同 id 邮件副本不再各自重投**:一份邮件投递失败时,同 id 的其他副本直接归档,不会最后重复投递。
37
+ - 本机是 `link:` 源码版,点更新仍会提示「改源码后重启」;以上对用 npm 安装的其他设备生效。
38
+
39
+ **上一版(0.8.10)**
40
+
41
+ - **云端设备显示规则调整**:每台设备显示 `名称(权限) · 在线状态`,例如 `YOCE_C(成员) · 3 小时前`;
42
+ 版本号**只在它与本机版本不一致时**才显示(如 ` · v0.8.2`),与本机一致的设备不再挂版本号。
43
+ - 只改客户端渲染,接口与数据不变;升级后刷新卡片即生效。
44
+
45
+ - **第一个组件从「总开关」改成「自更新开关」**:关掉它只失去卡片与自动更新,跨会话 / 跨终端 / 跨设备 / 跨账号 各自独立。
46
+ - **自动更新默认关**:卡片「自动更新」一行 + 开关,默认每 6 小时检查一次并自动安装,装完提示重启、不自动重启。
47
+ - **服务注入拆掉**:五行都只 `inject(['agents'])`,不再 provide/inject 连锁。
48
+
49
+ **更早(0.8.8 / 0.8.7 / 0.8.6 / 0.8.5 / 0.8.2 / 0.8.0)**
52
50
 
53
51
  - 0.8.7:修 0.8.6 的回归(卡片「云端设备」整块消失,接口报 `layout is not defined`);
54
52
  合并逻辑变成可测纯函数 `mergeDevices({ old, fresh, selfId, ownerDevice })`,配套 20 项单测;
package/client/client.js CHANGED
@@ -177,13 +177,17 @@ window.__ModuleLoader__.load({
177
177
  if (!devices.ok) return devices.message || "读取失败";
178
178
  const list = (devices.devices || []).filter((d) => d && (!devices.me || d.nodeId !== devices.me));
179
179
  if (!list.length) return "云端还没有其他设备";
180
+ const local = String((status && status.version) || "");
180
181
  return list
181
182
  .map((d) => {
182
183
  const label = d.name || d.nodeId || "未知设备";
183
184
  const role = d.role || "成员";
184
185
  const at = d.updatedAt || d.at || (d.lastSeen ? Date.parse(d.lastSeen) : 0);
185
186
  const state = d.online ? "在线" : agoText(at);
186
- return label + " · " + role + " · " + state + (d.version ? " · v" + d.version : "");
187
+ const ver = String(d.version || "");
188
+ // 版本只在「与本机不一致」时显示,避免每台都挂一串相同的 vX
189
+ const verText = ver && ver !== local ? " · v" + ver : "";
190
+ return label + "(" + role + ") · " + state + verText;
187
191
  })
188
192
  .join(";");
189
193
  }
package/lib/bus.js CHANGED
@@ -23,7 +23,7 @@ import path from 'node:path'
23
23
  import crypto from 'node:crypto'
24
24
  import { rotateIfNeeded } from './logs.js'
25
25
 
26
- export const VERSION = '0.8.9'
26
+ export const VERSION = '0.8.11'
27
27
 
28
28
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
29
29
  export function dshHome() {
@@ -13,9 +13,16 @@ const nowIso = () => new Date().toISOString()
13
13
  function loadState(file) {
14
14
  try {
15
15
  const j = JSON.parse(fs.readFileSync(file, 'utf8'))
16
- return { at: j.at || 0, processed: j.processed || {}, seen: j.seen || {}, failed: j.failed || {}, sent: j.sent || [] }
16
+ return {
17
+ at: j.at || 0,
18
+ processed: j.processed || {},
19
+ seen: j.seen || {},
20
+ failed: j.failed || {},
21
+ failedIds: j.failedIds || {},
22
+ sent: j.sent || [],
23
+ }
17
24
  } catch {
18
- return { at: 0, processed: {}, seen: {}, failed: {}, sent: [] }
25
+ return { at: 0, processed: {}, seen: {}, failed: {}, failedIds: {}, sent: [] }
19
26
  }
20
27
  }
21
28
 
@@ -159,6 +166,21 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
159
166
  stats.pending = 0
160
167
  const incomingDir = path.join(localDir, 'incoming')
161
168
  fs.mkdirSync(incomingDir, { recursive: true })
169
+ // 0.8.11 F7 补完:同一个信封 id 出现多份文件时(哪怕第一份投递失败),
170
+ // 其余副本一律归档进 done/,不让它们各自重试、最后变成重复投递。
171
+ const archiveDuplicate = async (dupName, dupLocal, dupId, why) => {
172
+ try {
173
+ await pan.mkdir(layout.paths.doneDir)
174
+ await pan.put(dupLocal, `${layout.paths.doneDir}/${dupName}`)
175
+ await pan.rm(`${layout.paths.inboxDir}/${dupName}`)
176
+ state.seen[dupName] = dupId
177
+ delete state.failed[dupName]
178
+ saveState(stateFile, { ...state, at: Date.now() })
179
+ log(`中转站:${dupName} ${why},已归档不重投`)
180
+ } catch (e) {
181
+ log(`中转站:${dupName} 重复信封归档失败:${(e && e.message) || String(e)}`)
182
+ }
183
+ }
162
184
 
163
185
  for (const f of files) {
164
186
  const name = f.server_filename
@@ -191,17 +213,15 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
191
213
  const mailId = envelope.id || path.basename(name, '.json')
192
214
  if (state.processed[mailId]) {
193
215
  // 0.8.2 F7:同一个信封换了文件名又出现 —— 已经投过,把这个副本归档掉,不再投第二次
194
- try {
195
- await pan.mkdir(layout.paths.doneDir)
196
- await pan.put(localJson, `${layout.paths.doneDir}/${name}`)
197
- await pan.rm(`${layout.paths.inboxDir}/${name}`)
198
- state.seen[name] = mailId
199
- delete state.failed[name]
200
- saveState(stateFile, { ...state, at: Date.now() })
201
- log(`中转站:${name} 与已投递的 ${mailId} 同一个信封,已归档不重投`)
202
- } catch (e) {
203
- log(`中转站:${name} 重复信封归档失败:${(e && e.message) || String(e)}`)
204
- }
216
+ await archiveDuplicate(name, localJson, mailId, `与已投递的 ${mailId} 同一个信封`)
217
+ continue
218
+ }
219
+ // 0.8.11 F7 补完:这份信封的「第一份」如果已经投递失败在退避里,
220
+ // 同 id 的其他副本也不该各自重试 —— 归档掉,等第一份成功或进 dead/。
221
+ const failedFirst = state.failedIds && state.failedIds[mailId]
222
+ if (failedFirst && failedFirst !== name) {
223
+ stats.pending += 1
224
+ await archiveDuplicate(name, localJson, mailId, `与待重投的 ${failedFirst} 同一个信封`)
205
225
  continue
206
226
  }
207
227
  // 0.8.0 F3:有账号密钥就要求签名对得上,否则拒收(防伪造发件人 / 提示注入)
@@ -214,6 +234,7 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
214
234
  const prevSig = state.failed[name] || {}
215
235
  const sigTries = Number(prevSig.tries || 0) + 1
216
236
  state.failed[name] = { at: nowIso(), mailId, reason: '签名无效', tries: sigTries, nextAt: Date.now() + 3600000 }
237
+ state.failedIds[mailId] = name
217
238
  saveState(stateFile, { ...state, at: Date.now() })
218
239
  continue
219
240
  }
@@ -249,6 +270,7 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
249
270
  state.processed[mailId] = { at: nowIso(), name, mailId, archived, sessionId: result.sessionId || '', to: String(envelope.to) }
250
271
  state.seen[name] = mailId
251
272
  delete state.failed[name]
273
+ delete state.failedIds[mailId]
252
274
  log(`中转站投递 ${mailId} → ${result.sessionId || String(envelope.to)}`)
253
275
  } else {
254
276
  stats.pending += 1
@@ -263,6 +285,7 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
263
285
  tries,
264
286
  nextAt: Date.now() + delays[Math.min(tries - 1, delays.length - 1)],
265
287
  }
288
+ state.failedIds[mailId] = name
266
289
  log(`中转站留件 ${name}(第 ${tries} 次):${(result && result.reason) || '投递失败'}`)
267
290
  if (tries >= 8) {
268
291
  try {
package/lib/web.js CHANGED
@@ -368,10 +368,13 @@ function appendUpdateLog(cfg, record) {
368
368
  * 每条都记进 steps(命令、退出码、耗时、输出尾部),最后核对 node_modules 里的真实版本。
369
369
  */
370
370
  async function updatePlugin(term, info, latest) {
371
- const target = `${PKG}@latest`
371
+ // 0.8.11:不再用 @latest —— pnpm 可能拿缓存里的旧 packument,把机器「更新」成老版本
372
+ // (YOCE_C 实测 0.8.2 → 0.6.1,还判成功)。已知确切版本就钉版本号,并加 --prefer-online。
373
+ const pin = latest ? `${PKG}@${latest}` : ''
374
+ const target = pin || `${PKG}@latest`
372
375
  const before = installedVersion(info)
373
376
  const steps = []
374
- const attempt = async (label, cmd, args, opts) => {
377
+ const record = async (label, cmd, args, opts) => {
375
378
  const started = Date.now()
376
379
  const res = await run(cmd, args, (opts && opts.cwd) || info.dir, opts)
377
380
  steps.push({
@@ -383,48 +386,55 @@ async function updatePlugin(term, info, latest) {
383
386
  })
384
387
  return res
385
388
  }
386
- const finish = (via) => {
387
- const installed = installedVersion(info)
388
- const updated = Boolean(installed) && installed !== before
389
- const upToDate = Boolean(installed) && Boolean(latest) && compareVersion(installed, latest) >= 0
390
- const ok = updated || upToDate
391
- appendUpdateLog(term.cfg, { profile: info.name, ok, updated, via, before, installed, latest: latest || '', steps })
392
- return { ok, updated, via, before, installed, latest: latest || '', steps }
389
+ // 装完必须等于 latest 才算成功;装低了就按 before 精确回滚(同样钉版本号)
390
+ const settle = async (via, step) => {
391
+ let installed = installedVersion(info)
392
+ const downgraded = Boolean(before) && Boolean(installed) && compareVersion(installed, before) < 0
393
+ let rollback = null
394
+ if (downgraded && step) {
395
+ const args = step.args.map((a) => (a === target && pin ? `${PKG}@${before}` : a))
396
+ const res = await record(`rollback:${step.label}`, step.cmd, args, step.opts)
397
+ const restored = installedVersion(info)
398
+ rollback = { ok: res.code === 0 && restored === before, restored, code: res.code }
399
+ installed = restored
400
+ }
401
+ const exact = Boolean(installed) && Boolean(latest) && compareVersion(installed, latest) === 0
402
+ const ok = exact && !downgraded
403
+ const updated = ok && Boolean(before) && installed !== before
404
+ const out = { profile: info.name, ok, updated, via, before, installed, latest: latest || '', downgraded, rollback, steps }
405
+ appendUpdateLog(term.cfg, out)
406
+ return out
393
407
  }
394
408
 
395
409
  const { cmd: node, env } = nodeTool(term.cfg)
396
410
  const cli = cliScript()
397
- const plan = [{ label: 'dsh', cmd: 'dsh', args: ['plugin', '--profile', info.name, 'add', target], opts: {} }]
411
+ const flags = pin ? ['--prefer-online'] : []
412
+ const plan = [{ label: 'dsh', cmd: 'dsh', args: ['plugin', '--profile', info.name, 'add', target, ...flags], opts: {} }]
398
413
  if (fs.existsSync(cli)) {
399
414
  plan.push({
400
415
  label: 'cli',
401
416
  cmd: process.execPath,
402
- args: ['--expose-internals', cli, 'plugin', '--profile', info.name, 'add', target],
417
+ args: ['--expose-internals', cli, 'plugin', '--profile', info.name, 'add', target, ...flags],
403
418
  opts: { shell: false, env: { ELECTRON_RUN_AS_NODE: '1' } },
404
419
  })
405
420
  }
406
421
  for (const pnpm of pnpmCandidates(term.cfg)) {
407
- plan.push({ label: 'pnpm', cmd: node, args: [pnpm, 'add', target], opts: { shell: false, env } })
422
+ plan.push({ label: 'pnpm', cmd: node, args: [pnpm, 'add', target, ...flags], opts: { shell: false, env } })
408
423
  }
409
424
 
410
425
  let last = null
411
426
  for (const step of plan) {
412
- const res = await attempt(step.label, step.cmd, step.args, step.opts)
427
+ const res = await record(step.label, step.cmd, step.args, step.opts)
413
428
  last = res
414
- if (res.code === 0) {
415
- const result = finish(step.label)
416
- if (result.ok) return result
417
- }
429
+ if (res.code !== 0) continue
430
+ const result = await settle(step.label, step)
431
+ if (result.ok) return result
418
432
  }
419
- appendUpdateLog(term.cfg, { profile: info.name, ok: false, updated: false, before, latest: latest || '', steps })
433
+ const settled = await settle('none', null)
420
434
  return {
435
+ ...settled,
421
436
  ok: false,
422
437
  updated: false,
423
- via: 'none',
424
- before,
425
- installed: installedVersion(info),
426
- latest: latest || '',
427
- steps,
428
438
  out: steps.map((s) => `[${s.label}] 退出码 ${s.code}\n${s.out}`).join('\n\n') || updateErrorText(last),
429
439
  }
430
440
  }
@@ -951,6 +961,11 @@ export function registerWeb(ctx, term, log) {
951
961
  let message
952
962
  if (result.ok && result.updated) message = `已更新到 ${installed},重启 DSH 后生效`
953
963
  else if (result.ok) message = `已经是最新(${installed || latest}),不用更新`
964
+ else if (result.downgraded) {
965
+ message = result.rollback && result.rollback.ok
966
+ ? `更新把版本装低了(原 ${result.before},装成更旧版本),已恢复到 ${installed};请重试`
967
+ : `更新把版本装低了(原 ${result.before},现在 ${installed}),恢复失败,请手动重装 ${result.before}`
968
+ } else if (installed && latest) message = `更新没成功(当前装着 ${installed},npm 最新 ${latest}):${result.out || '命令都返回失败'}`
954
969
  else if (installed) message = `更新没成功(当前装着 ${installed}):${result.out || '命令都返回失败'}`
955
970
  else message = `更新失败:${result.out || '未知原因'}`
956
971
  json(res, 200, {
@@ -960,6 +975,8 @@ export function registerWeb(ctx, term, log) {
960
975
  before: result.before || '',
961
976
  installed,
962
977
  latest,
978
+ downgraded: Boolean(result.downgraded),
979
+ rollback: result.rollback || null,
963
980
  needRestart: Boolean(result.ok && result.updated),
964
981
  steps,
965
982
  message,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",