dsh-crossterm 0.6.2 → 0.7.1

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,49 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.7.1 — 2026-10-19
4
+
5
+ ### 「更新」按钮在别的机器上装不上:三级兜底 + 全步骤回执
6
+
7
+ - 现象(User said, paraphrased):别的设备点卡片里的「更新」,看起来装不上。
8
+ - 根因:旧实现只有两条路 —— PATH 里的 `dsh`,或 `%USERPROFILE%\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\{node,pnpm}`
9
+ 这一固定目录。桌面版默认**不把 `dsh` 放进 PATH**,别的机器也不一定有这套运行时目录,
10
+ 于是直接落到「找不到可用的 dsh 命令」,卡片只显示一行字,看不出卡在哪一步。
11
+ - 桌面版的 `dsh` 真身(`F:\dsh\resources\runtime\cli\bin\dsh.cmd`):
12
+ `set ELECTRON_RUN_AS_NODE=1` + `DeepSeek Harness.exe --expose-internals <resources>\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js %*`,
13
+ 而 `dsh plugin ...` 内部就是在 profile 目录里跑 pnpm(`--profile` 必填)。所以不依赖 PATH 也能调起同一个 CLI。
14
+ - 现行三级兜底(`lib/web.js` 的 `updatePlugin(term, info, latest)`,按顺序尝试,第一个成功即止,每步都记档):
15
+ 1. `dsh plugin --profile <p> add dsh-crossterm@latest`;
16
+ 2. DSH 自带 CLI:`process.execPath --expose-internals <resources>\app.asar\...\cli.js plugin --profile <p> add ...`
17
+ (env 加 `ELECTRON_RUN_AS_NODE=1`);
18
+ 3. 直接 pnpm:`pnpmCandidates()` 依次找 `<DSH_HOME>\dsh-runtimes\...\dependencies\pnpm\bin\pnpm.cjs`
19
+ 与 `<安装目录>\resources\runtime\pnpm\bin\pnpm.cjs`;node 用自带 `node.exe`,没有就拿宿主 Electron 当 node。
20
+ - 每步记录 `{label, cmd, code, ms, out}`;装完读 `<profile>\node_modules\dsh-crossterm\package.json` **核对真实版本**,
21
+ 回执区分「已更新到 X,重启 DSH 后生效」「已经是最新(X)」「更新没成功(当前装着 X)」。
22
+ - 卡片:失败时把每步退出码、耗时、输出尾部(各 400 字)直接显示在卡片里,不用去翻宿主日志。
23
+ - 更新日志:`<busDir>\logs\update.log` 追加 JSON 行(含 profile / before / installed / latest / steps),
24
+ 别的会话排查时直接读这个文件。
25
+ - 顺带补 0.7.0 漏的字段:`GET /crossterm/api/status` 的 `home.layout` 现在带 `rulesVersion` / `rulesFailed` / `accountSource`。
26
+ - 探针 `F:\dsh\001X\crossterm-work\probe-update-plan.mjs` **14 项全 PASS**。
27
+ - 要不要跟着动手:**不用**,重启 DSH 即生效(本机是 `link:` 源码版,重启加载即可)。
28
+
29
+ ## 0.7.0 — 2026-10-19
30
+
31
+ ### 入口行升格为「总开关」,排到组件列表第一位
32
+ - 用户要求(paraphrased):把入口那一行放到第一个,开关做成和插件开关一样的**总开关**,并标注清楚。
33
+ - `cordis.patch.yml` 顺序变化:`crossterm-ui`(裸包名 `dsh-crossterm`,`config.part: ui`)从列表末尾
34
+ 移到**第一位**,其后依次是 session / terminal / device / account。五行指向同一个包,顺序只影响展示。
35
+ - **总开关是真开关**,不是只关卡片(`lib/index.js` 的 `apply()`):
36
+ - `part === 'ui'` 时 `root.provide('crossterm', { master: true, part, since })`;
37
+ - 其余四个组件改为 `root.inject(['agents', 'crossterm'], ...)` —— **总开关关掉时服务不存在,
38
+ 这四个回调根本不会执行**,工具、心跳、云同步、云端中转站、会话备份一起停;
39
+ - 总开关开着时,四个组件之间仍可各自单独开关;
40
+ - 总开关 3 秒内没出现时打一行 `console.warn`(`总开关(入口组件)未启用,组件「X」已跳过挂载`)方便排查;
41
+ - 用 cordis 服务注入而不是「读配置 / 看加载顺序」,所以谁先加载都不影响结果。
42
+ - 标注清楚:`locale/zh.json` 的 `meta.title` 改为 **跨端互通(总开关)**,`meta.description` 写明
43
+ 「关掉这一项,下面四个组件全部停用……开着它时四个组件可各自单独开关」;
44
+ 新增 `meta.shortTitle = 跨端互通`,配置卡片标题用短名(`GET /crossterm/api/about` 多返回一个 `shortTitle`)。
45
+ - 探针 `probe-about.mjs` 改为断言 `title === '跨端互通(总开关)'` 且 `shortTitle === '跨端互通'`。
46
+
3
47
  ## 0.6.2 — 2026-10-19
4
48
 
5
49
  ### 插件页:插件名/简介/组件表/更新日志
package/README.md CHANGED
@@ -12,28 +12,30 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
12
12
 
13
13
  ## 零、简介与本版更新(每次发版重写这一节)
14
14
 
15
- **插件名称**:`dsh-crossterm`(插件页显示 **跨端互通**)| **插件简介**:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份。
16
-
17
- **四个组件(插件页里就是这四个开关,名称与简介来自各自的 manifest)**
18
-
19
- | 组件名称 | 组件简介 | 工具 |
20
- |---|---|---|
21
- | 跨会话 | 同一终端里的多个会话之间互通:特征码链、内联邮件、收件箱 | `term_link` `term_unlink` `term_broadcast` `term_inbox` `term_config` |
22
- | 跨终端 | 同一台机器上的多个 DSH 终端/进程之间互发邮件与文件 | `term_whoami` `term_list` `term_send` `term_send_files` |
23
- | 跨设备 | 经云端总线,在多台机器的 DSH 之间互发邮件与文件 | `term_cloud` |
24
- | 跨账号 | 预留位:后续用于跨账号互通,当前不挂工具 | — |
25
-
26
- > 插件页里还会多出一行 **跨端互通**(组件列表最后一行):它是「入口行」,裸包名加载浏览器半端,
27
- > 承载插件标题与配置卡片,不挂任何工具。浏览器 client 半端只认裸包名行,所以这一行没法省。
28
-
29
- **本版更新(0.6.2)**
30
-
31
- - **插件页终于像个插件了**:插件改名 **跨端互通**(原来叫「跨会话」,看起来像只管会话),
32
- 并写上了插件级简介;四个业务组件名保持不变。
33
- - 配置卡片里新增:**插件简介**、**包含的组件(名称 + 简介两列表格)**、底部可折叠 **更新日志**
34
- (直接读包内 `CHANGELOG.md`,展开就能看最近三版改了什么)。
35
- - 数据来自新接口 `GET /crossterm/api/about`(组件名与简介读 `parts/*.manifest.json`,与组件行同源)。
36
- - 上一版(0.6.1)的内容仍在:旧布局缓存不再挡住云端规则文档与 `inbox/_说明.md`。
15
+ **插件名称**:`dsh-crossterm`(插件页显示 **跨端互通(总开关)**)| **插件简介**:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份。
16
+
17
+ **组件列表(插件页第一个是总开关,后面四个是业务组件,名称与简介来自各自的 manifest)**
18
+
19
+ | 顺序 | 组件名称 | 作用 | 工具 |
20
+ |---|---|---|---|
21
+ | ① | 跨端互通(总开关) | 插件总开关:关掉它,下面四个组件全部停用(工具、心跳、云同步、中转站、备份一起停);开着时四个组件可各自单独开关。裸包名行,承载插件标题/简介与配置卡片,不挂工具 | — |
22
+ | ② | 跨会话 | 同一终端里的多个会话之间互通:特征码链、内联邮件、收件箱 | `term_link` `term_unlink` `term_broadcast` `term_inbox` `term_config` |
23
+ | ③ | 跨终端 | 同一台机器上的多个 DSH 终端/进程之间互发邮件与文件 | `term_whoami` `term_list` `term_send` `term_send_files` |
24
+ | ④ | 跨设备 | 经云端总线,在多台机器的 DSH 之间互发邮件与文件 | `term_cloud` |
25
+ | ⑤ | 跨账号 | 预留位:后续用于跨账号互通,当前不挂工具 | — |
26
+
27
+ > 总开关为什么必须用裸包名:浏览器 client 半端只对裸包名行生效(`dsh-client-modules` 的
28
+ > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
29
+ > 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
30
+
31
+ **本版更新(0.7.1)**
32
+
33
+ - **修「别的机器点更新装不上」**:更新不再只依赖 PATH 里的 `dsh`,改成三级兜底 ——
34
+ ① PATH 里的 `dsh`;② DSH 自带 CLI(`Electron --expose-internals <安装目录>\resources\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js`);
35
+ ③ 直接调自带 pnpm(`<DSH_HOME>\dsh-runtimes\...\dependencies\pnpm` 或 `<安装目录>\resources\runtime\pnpm`)。
36
+ - 装完会读 `<profile>\node_modules\dsh-crossterm\package.json` **核对真实版本**,分别提示「已更新到 X」「已经是最新」「更新没成功」。
37
+ - 失败时卡片直接把每步的退出码、耗时、输出尾部显示出来;全过程写 `<总线目录>\logs\update.log`。
38
+ - 顺带补上 `status.home.layout` 里缺的 `rulesVersion` / `rulesFailed` / `accountSource`。
37
39
 
38
40
  ## 一、安装与卸载
39
41
 
package/client/client.js CHANGED
@@ -255,7 +255,15 @@ window.__ModuleLoader__.load({
255
255
  setMsg("正在更新…");
256
256
  try {
257
257
  const data = await call("/crossterm/api/update", "POST");
258
- setMsg((data && data.message) || (data && data.ok ? "已更新" : "未能更新"));
258
+ const text = (data && data.message) || (data && data.ok ? "已更新" : "未能更新");
259
+ const steps = (data && data.steps) || [];
260
+ // 失败时把每条尝试(dsh / DSH 自带 cli / pnpm)的退出码与输出带上,方便远端排查
261
+ const detail = data && data.ok
262
+ ? ""
263
+ : steps.length
264
+ ? "\n\n" + steps.map((s) => `[${s.label}] 退出码 ${s.code} · ${Math.round((s.ms || 0) / 1000)}s` + (s.out ? `\n${String(s.out).slice(-400)}` : "")).join("\n\n")
265
+ : "";
266
+ setMsg(text + detail);
259
267
  } catch (error) {
260
268
  setMsg("更新失败:" + String((error && error.message) || error));
261
269
  } finally {
@@ -373,7 +381,7 @@ window.__ModuleLoader__.load({
373
381
  h(
374
382
  "div",
375
383
  null,
376
- h("div", { style: S.title }, `${(about && about.title) || "跨端互通"} ${version}`),
384
+ h("div", { style: S.title }, `${(about && (about.shortTitle || about.title)) || "跨端互通"} ${version}`),
377
385
  h("div", { style: S.sub }, subtitle),
378
386
  ),
379
387
  // h("span", { style: S.chevron }, open ? "\u2303" : "\u2304"),
package/cordis.patch.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  # ============================================================================
2
- # dsh-crossterm —— DSH bundle patch(host-only 插件)
2
+ # dsh-crossterm —— DSH bundle patch
3
3
  # ============================================================================
4
4
  # 安装(本地目录,对每个要互通的 profile 各装一次):
5
5
  # dsh plugin --profile desktop add link:F:\dsh\001X\dsh-crossterm
@@ -7,30 +7,41 @@
7
7
  # 卸载:
8
8
  # dsh plugin --profile desktop remove dsh-crossterm
9
9
  #
10
- # 每个 insert 行在 DSH「插件 → dsh-crossterm → 包含的组件」里是一行组件,可单独开关。
11
- # 五行指向同一个包,只是走不同子路径:
10
+ # 每个 insert 行在 DSH「插件 → 跨端互通 → 包含的组件」里是一行组件,可单独开关。
11
+ # 五行指向同一个包,只是走不同子路径 / 不同 config.part:
12
12
  #
13
- # dsh-crossterm 入口:插件页配置卡片(浏览器半端)+ 全插件简介/更新日志,
14
- # 不挂任何工具(config.part: ui)
15
- # dsh-crossterm/parts/session 跨会话:term_link / term_unlink / term_broadcast / term_inbox / term_config
16
- # dsh-crossterm/parts/terminal 跨终端:term_whoami / term_list / term_send / term_send_files
17
- # dsh-crossterm/parts/device 跨设备:term_cloud(云端总线同步)
18
- # dsh-crossterm/parts/account 跨账号:预留占位,暂不挂工具
13
+ # ① dsh-crossterm 总开关(入口):提供 crossterm 服务,插件页标题/简介/配置卡片,
14
+ # 不挂任何工具(config.part: ui)。
15
+ # 关掉它 → 服务消失 → 下面四个组件全部停用
16
+ # (工具、心跳、云同步、中转站、会话备份一起停)。
17
+ # ② dsh-crossterm/parts/session 跨会话:term_link / term_unlink / term_broadcast / term_inbox / term_config
18
+ # ③ dsh-crossterm/parts/terminal 跨终端:term_whoami / term_list / term_send / term_send_files
19
+ # ④ dsh-crossterm/parts/device 跨设备:term_cloud(云端总线同步)
20
+ # ⑤ dsh-crossterm/parts/account 跨账号:预留占位,暂不挂工具
19
21
  #
20
22
  # 组件名来自 package.json 的 exports 映射:
21
23
  # "./parts/session/package.json" -> "./parts/session.manifest.json"
22
24
  # 每个组件显示自己的中文名,实际加载的仍是 lib/index.js,靠 config.part 决定挂哪组工具。
23
25
  #
24
- # 为什么入口行放在最后:浏览器的 client 半端只对「裸包名」行生效(dsh-client-modules 的
25
- # locatePkgJson 对子路径行直接返回 undefined),而插件页顶部标题与裸包名行共用同一份
26
- # locale/zh.json 的 meta —— 插件叫「跨端互通」、四个业务组件叫跨会话/跨终端/跨设备/跨账号,
27
- # 只能让裸包名行单独做入口,且排在组件列表末尾,四个组件仍是列表里的前四行。
26
+ # 为什么总开关必须用「裸包名」:浏览器的 client 半端只对裸包名行生效
27
+ # (dsh-client-modules 的 locatePkgJson 对子路径行直接返回 undefined),而插件页顶部标题
28
+ # 与裸包名行共用同一份 locale/zh.json 的 meta —— 所以裸包名行单独做总开关,
29
+ # 标题写「跨端互通(总开关)」,四个业务组件排在它后面、名字仍是跨会话/跨终端/跨设备/跨账号。
30
+ #
31
+ # 总开关是「服务注入」实现的:ui 行 root.provide('crossterm'),其余四行 root.inject 它。
32
+ # 谁先加载不影响结果(cordis 的 inject 会等服务出现),顺序只为让列表好看。
28
33
  #
29
34
  # 五个组件在同一 DSH 进程里共用一份总线实例:心跳只起一次,云同步只在 device 组件启用
30
35
  # 且 config.json 里 cloudEnabled=true 时才跑。安装/改动后需重启对应的 DSH 进程。
31
36
  # ============================================================================
32
37
 
33
38
  - insert:
39
+ - id: crossterm-ui
40
+ # 总开关(入口):裸包名,提供 crossterm 服务 + 浏览器半端的配置卡片 + 插件页标题/简介。
41
+ # 关掉这一行等于整插件停用;它自己不挂工具。
42
+ name: dsh-crossterm
43
+ config:
44
+ part: ui
34
45
  - id: crossterm-session
35
46
  name: dsh-crossterm/parts/session
36
47
  config:
@@ -47,9 +58,3 @@
47
58
  name: dsh-crossterm/parts/account
48
59
  config:
49
60
  part: account
50
- - id: crossterm-ui
51
- # 裸包名:浏览器 client 半端只认这一行,插件页配置卡片挂在这里。
52
- # 也是插件页顶部标题/简介的来源(locale/zh.json 的 meta)。
53
- name: dsh-crossterm
54
- config:
55
- part: ui
package/lib/bus.js CHANGED
@@ -22,7 +22,7 @@ import os from 'node:os'
22
22
  import path from 'node:path'
23
23
  import crypto from 'node:crypto'
24
24
 
25
- export const VERSION = '0.6.2'
25
+ export const VERSION = '0.7.1'
26
26
 
27
27
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
28
  export function dshHome() {
package/lib/cloud/home.js CHANGED
@@ -119,7 +119,16 @@ export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log
119
119
  return {
120
120
  home: `${cfg.cloudHome || DEFAULT_HOME}`,
121
121
  layout: layout
122
- ? { account: layout.account, role: layout.role, deviceId: layout.deviceId, paths: layout.paths, ensuredAt: layout.ensuredAt }
122
+ ? {
123
+ account: layout.account,
124
+ role: layout.role,
125
+ deviceId: layout.deviceId,
126
+ paths: layout.paths,
127
+ ensuredAt: layout.ensuredAt,
128
+ rulesVersion: layout.rulesVersion || 0,
129
+ rulesFailed: layout.rulesFailed || 0,
130
+ accountSource: layout.accountSource || '',
131
+ }
123
132
  : null,
124
133
  relay: { ...relay.stats(), intervalMs: relayTimer },
125
134
  backup: { ...backupSummary(backupState), intervalMs: backupTimer },
package/lib/index.js CHANGED
@@ -1859,7 +1859,24 @@ export default {
1859
1859
  name: PLUGIN,
1860
1860
  apply(root, config) {
1861
1861
  const part = normalizePart(config?.part)
1862
- root.inject(['agents'], (ctx) => {
1862
+ // 「入口」这一行就是总开关:它提供 crossterm 服务,其余四个组件都 inject 它。
1863
+ // 关掉这一行 → 服务不存在 → 另外四个组件的 inject 回调根本不会执行,
1864
+ // 工具、心跳、云同步、中转站、会话备份一起停(不是只关卡片)。
1865
+ // 反过来,只要总开关开着,四个组件各自还能单独关。
1866
+ const isMaster = part === 'ui'
1867
+ if (isMaster) {
1868
+ root.provide('crossterm', { master: true, part, since: Date.now() })
1869
+ } else {
1870
+ const timer = setTimeout(() => {
1871
+ let alive = true
1872
+ try {
1873
+ alive = Boolean(root.get?.('crossterm'))
1874
+ } catch { alive = true }
1875
+ if (!alive) console.warn(`[dsh-crossterm] 总开关(入口组件)未启用,组件「${PART_LABEL[part]}」已跳过挂载`)
1876
+ }, 3000)
1877
+ if (timer && typeof timer.unref === 'function') timer.unref()
1878
+ }
1879
+ root.inject(isMaster ? ['agents'] : ['agents', 'crossterm'], (ctx) => {
1863
1880
  // 四个组件跑在同一个进程里,共用一份总线实例:各自只挂自己那组工具,
1864
1881
  // 心跳/云同步只在第一个组件加载时启动一次,最后一个组件卸载时才停。
1865
1882
  const term = acquireTerm(ctx)
package/lib/web.js CHANGED
@@ -163,6 +163,7 @@ function run(cmd, args, cwd, opts) {
163
163
  cwd,
164
164
  shell,
165
165
  windowsHide: true,
166
+ env: opts && opts.env ? { ...process.env, ...opts.env } : process.env,
166
167
  })
167
168
  } catch (error) {
168
169
  resolve({ code: -1, out: String((error && error.message) || error) })
@@ -197,19 +198,46 @@ function updateErrorText(result) {
197
198
  return tail
198
199
  }
199
200
 
200
- // 有些机器没把 `dsh` 命令放进 PATH(桌面版就是),这时退回 DSH 自带运行时里的
201
- // node + pnpm 直接装:<DSH_HOME>\dsh-runtimes\dsh-primary-runtime\dependencies\{node,pnpm}
202
- function runtimeTools(cfg) {
201
+ // DSH 安装根目录:process.resourcesPath = <root>\resources,上一级就是 <root>
202
+ function dshRoot() {
203
+ if (process.resourcesPath) return path.dirname(process.resourcesPath)
204
+ return path.dirname(process.execPath || '')
205
+ }
206
+
207
+ // 桌面版把 `dsh` 命令实现成「让 Electron 以 node 模式跑 cli.js」:
208
+ // <root>\DeepSeek Harness.exe --expose-internals <root>\resources\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js
209
+ // 所以不依赖 PATH 也能调起同一个 CLI(`dsh plugin add` 内部其实就是 pnpm add)。
210
+ function cliScript() {
211
+ const res = process.resourcesPath || path.join(dshRoot(), 'resources')
212
+ return path.join(res, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js')
213
+ }
214
+
215
+ function dshHome(cfg) {
203
216
  const busDir = (cfg && cfg.busDir) || ''
204
- const home = busDir ? path.dirname(busDir) : path.join(os.homedir(), '.dsh')
205
- const deps = path.join(home, 'dsh-runtimes', 'dsh-primary-runtime', 'dependencies')
206
- return {
207
- node: path.join(deps, 'node', 'bin', 'node.exe'),
208
- pnpm: path.join(deps, 'pnpm', 'bin', 'pnpm.cjs'),
209
- ok:
210
- fs.existsSync(path.join(deps, 'node', 'bin', 'node.exe')) &&
211
- fs.existsSync(path.join(deps, 'pnpm', 'bin', 'pnpm.cjs')),
212
- }
217
+ return busDir ? path.dirname(busDir) : path.join(os.homedir(), '.dsh')
218
+ }
219
+
220
+ // pnpm 可能落在两处:DSH_HOME 的运行时依赖里,或安装目录的 resources\runtime\pnpm
221
+ function pnpmCandidates(cfg) {
222
+ const deps = path.join(dshHome(cfg), 'dsh-runtimes', 'dsh-primary-runtime', 'dependencies')
223
+ const res = process.resourcesPath || path.join(dshRoot(), 'resources')
224
+ return [
225
+ path.join(deps, 'pnpm', 'bin', 'pnpm.cjs'),
226
+ path.join(res, 'runtime', 'pnpm', 'bin', 'pnpm.cjs'),
227
+ ].filter((p) => p && fs.existsSync(p))
228
+ }
229
+
230
+ // 没有独立 node.exe 时用宿主自己的 Electron 以 node 模式跑脚本
231
+ function nodeTool(cfg) {
232
+ const deps = path.join(dshHome(cfg), 'dsh-runtimes', 'dsh-primary-runtime', 'dependencies')
233
+ const bundled = path.join(deps, 'node', 'bin', 'node.exe')
234
+ if (fs.existsSync(bundled)) return { cmd: bundled, env: {} }
235
+ return { cmd: process.execPath, env: { ELECTRON_RUN_AS_NODE: '1' } }
236
+ }
237
+
238
+ function runtimeTools(cfg) {
239
+ const pnpm = pnpmCandidates(cfg)[0] || ''
240
+ return { node: nodeTool(cfg).cmd, pnpm, ok: Boolean(pnpm) }
213
241
  }
214
242
 
215
243
  function looksMissingCommand(result) {
@@ -218,16 +246,91 @@ function looksMissingCommand(result) {
218
246
  )
219
247
  }
220
248
 
221
- async function updatePlugin(term, info) {
249
+ /** 读 profile 里实际装着的版本(registry 模式下 node_modules/<pkg>/package.json) */
250
+ function installedVersion(info) {
251
+ try {
252
+ const doc = JSON.parse(fs.readFileSync(path.join(info.dir, 'node_modules', PKG, 'package.json'), 'utf8'))
253
+ return typeof doc.version === 'string' ? doc.version : ''
254
+ } catch {
255
+ return ''
256
+ }
257
+ }
258
+
259
+ // 更新过程落一条日志,别的会话/排查时能直接看:<busDir>\logs\update.log
260
+ function appendUpdateLog(cfg, record) {
261
+ try {
262
+ const dir = path.join((cfg && cfg.busDir) || path.join(os.homedir(), '.dsh', 'crossterm'), 'logs')
263
+ fs.mkdirSync(dir, { recursive: true })
264
+ fs.appendFileSync(path.join(dir, 'update.log'), `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`, 'utf8')
265
+ } catch {
266
+ /* 日志写不进去不影响更新本身 */
267
+ }
268
+ }
269
+
270
+ /**
271
+ * 更新已安装的 registry 版:按「PATH 里的 dsh → DSH 自带 CLI → 直接 pnpm」依次尝试,
272
+ * 每条都记进 steps(命令、退出码、耗时、输出尾部),最后核对 node_modules 里的真实版本。
273
+ */
274
+ async function updatePlugin(term, info, latest) {
222
275
  const target = `${PKG}@latest`
223
- const direct = await run('dsh', ['plugin', '--profile', info.name, 'add', target], info.dir)
224
- if (direct.code === 0) return { ok: true, via: 'dsh', out: direct.out }
225
- const miss = looksMissingCommand(direct) || direct.out.trim() === ''
226
- if (!miss) return { ok: false, via: 'dsh', out: direct.out }
227
- const tools = runtimeTools(term.cfg)
228
- if (!tools.ok) return { ok: false, via: 'none', out: updateErrorText(direct) }
229
- const fallback = await run(tools.node, [tools.pnpm, 'add', target], info.dir, { shell: false })
230
- return { ok: fallback.code === 0, via: 'pnpm', out: fallback.out || updateErrorText(fallback) }
276
+ const before = installedVersion(info)
277
+ const steps = []
278
+ const attempt = async (label, cmd, args, opts) => {
279
+ const started = Date.now()
280
+ const res = await run(cmd, args, (opts && opts.cwd) || info.dir, opts)
281
+ steps.push({
282
+ label,
283
+ cmd: `${path.basename(cmd)} ${args.join(' ')}`.slice(0, 200),
284
+ code: res.code,
285
+ ms: Date.now() - started,
286
+ out: (res.out || '').trim().slice(-600),
287
+ })
288
+ return res
289
+ }
290
+ const finish = (via) => {
291
+ const installed = installedVersion(info)
292
+ const updated = Boolean(installed) && installed !== before
293
+ const upToDate = Boolean(installed) && Boolean(latest) && compareVersion(installed, latest) >= 0
294
+ const ok = updated || upToDate
295
+ appendUpdateLog(term.cfg, { profile: info.name, ok, updated, via, before, installed, latest: latest || '', steps })
296
+ return { ok, updated, via, before, installed, latest: latest || '', steps }
297
+ }
298
+
299
+ const { cmd: node, env } = nodeTool(term.cfg)
300
+ const cli = cliScript()
301
+ const plan = [{ label: 'dsh', cmd: 'dsh', args: ['plugin', '--profile', info.name, 'add', target], opts: {} }]
302
+ if (fs.existsSync(cli)) {
303
+ plan.push({
304
+ label: 'cli',
305
+ cmd: process.execPath,
306
+ args: ['--expose-internals', cli, 'plugin', '--profile', info.name, 'add', target],
307
+ opts: { shell: false, env: { ELECTRON_RUN_AS_NODE: '1' } },
308
+ })
309
+ }
310
+ for (const pnpm of pnpmCandidates(term.cfg)) {
311
+ plan.push({ label: 'pnpm', cmd: node, args: [pnpm, 'add', target], opts: { shell: false, env } })
312
+ }
313
+
314
+ let last = null
315
+ for (const step of plan) {
316
+ const res = await attempt(step.label, step.cmd, step.args, step.opts)
317
+ last = res
318
+ if (res.code === 0) {
319
+ const result = finish(step.label)
320
+ if (result.ok) return result
321
+ }
322
+ }
323
+ appendUpdateLog(term.cfg, { profile: info.name, ok: false, updated: false, before, latest: latest || '', steps })
324
+ return {
325
+ ok: false,
326
+ updated: false,
327
+ via: 'none',
328
+ before,
329
+ installed: installedVersion(info),
330
+ latest: latest || '',
331
+ steps,
332
+ out: steps.map((s) => `[${s.label}] 退出码 ${s.code}\n${s.out}`).join('\n\n') || updateErrorText(last),
333
+ }
231
334
  }
232
335
 
233
336
  function statusOf(term) {
@@ -316,6 +419,8 @@ function aboutOf() {
316
419
  name: PKG,
317
420
  version: VERSION,
318
421
  title: meta.title || PKG,
422
+ // 插件页顶部标题要带「(总开关)」才看得懂这一行是什么,但卡片头用短名更清爽
423
+ shortTitle: meta.shortTitle || meta.title || PKG,
319
424
  description: meta.description || '',
320
425
  components,
321
426
  changelog: readChangelog(),
@@ -564,17 +669,25 @@ export function registerWeb(ctx, term, log) {
564
669
  })
565
670
  return
566
671
  }
567
- const result = await updatePlugin(term, info)
672
+ const latest = await latestVersion().catch(() => '')
673
+ const result = await updatePlugin(term, info, latest)
674
+ const installed = result.installed || ''
675
+ const steps = (result.steps || []).map((s) => ({ label: s.label, code: s.code, ms: s.ms, out: s.out }))
676
+ let message
677
+ if (result.ok && result.updated) message = `已更新到 ${installed},重启 DSH 后生效`
678
+ else if (result.ok) message = `已经是最新(${installed || latest}),不用更新`
679
+ else if (installed) message = `更新没成功(当前装着 ${installed}):${result.out || '命令都返回失败'}`
680
+ else message = `更新失败:${result.out || '未知原因'}`
568
681
  json(res, 200, {
569
682
  ok: result.ok,
570
683
  mode: 'registry',
571
684
  via: result.via,
572
- needRestart: result.ok,
573
- message: result.ok
574
- ? `已更新${result.via === 'pnpm' ? '(用 DSH 自带 pnpm)' : ''},重启 DSH 后生效`
575
- : result.via === 'none'
576
- ? result.out
577
- : (result.out || '').slice(-1200) || `更新失败(退出码不明)`,
685
+ before: result.before || '',
686
+ installed,
687
+ latest,
688
+ needRestart: Boolean(result.ok && result.updated),
689
+ steps,
690
+ message,
578
691
  })
579
692
  },
580
693
  }),
package/locale/en.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "meta": {
3
- "title": "CrossTerm",
4
- "description": "Connect sessions, terminals and devices of one DSH account: chain codes, inline mail, a cloud relay and automatic backups. Four components, each with its own switch."
3
+ "title": "CrossTerm (master switch)",
4
+ "shortTitle": "CrossTerm",
5
+ "description": "Master switch: turning this off stops everything below — tools, heartbeat, cloud sync, cloud relay and session backups for all four components (cross-session / cross-terminal / cross-device / cross-account). While it is on, each component can still be switched off individually."
5
6
  }
6
7
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "meta": {
3
- "title": "跨端互通",
4
- "description": "同一 DSH 账号下的会话、终端、设备之间互通:特征码链、内联邮件、云端中转站与自动备份。四个组件可单独开关。"
3
+ "title": "跨端互通(总开关)",
4
+ "shortTitle": "跨端互通",
5
+ "description": "总开关:关掉这一项,下面四个组件(跨会话 / 跨终端 / 跨设备 / 跨账号)全部停用 —— 工具、心跳、云同步、云端中转站与会话备份一起停。开着它时,四个组件可各自单独开关。插件简介:同一 DSH 账号下的会话、终端、设备之间互通。"
5
6
  }
6
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",