dsh-crossterm 0.7.0 → 0.7.2

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,55 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.7.2 — 2026-10-19
4
+
5
+ ### 停用云端老文件夹:设备心跳搬进账号共享目录
6
+
7
+ - 背景(User said, paraphrased):查清云盘老目录 `/dsh云盘/crossterm/bus` 里没有必须迁移的数据后,停用老文件夹。
8
+ - 老目录盘点:17 个目录 / 412 个文件 / 679,619 B,其中 200 个 `.md` 是历史邮件、200 个 `.tmp` 是原子写残留、
9
+ 12 个 `json` 是状态镜像;本机 `F:\dsh\001X\crossterm-data\mail` 有 253 个(342,402 B)、`inbox` 306 个(537,410 B),
10
+ 云端那份只是子集。唯一可能陌生的是发往本机的 2 封 `pending`,内容是 10-19 00:52 的跨终端验收测试信。
11
+ - 新文件 `lib/cloud/presence.js`:`DEVICE_FILE='_device.json'`、`makePresence()`、`writePresence({pan,layout,info})`
12
+ (写到 `shared/<deviceId>/_device.json`)、`listPresences({pan,layout,onlineMs=3分钟})`(遍历共享目录、按 `at` 倒序、标 `online`)。
13
+ - `lib/cloud/home.js`:新增 `deviceInfo` 参数(实时快照:版本/会话数/链/pid)、`presenceNow()`、`devices()`;
14
+ `status().presence = { at, error, intervalMs }`;`start()` 起 `presenceIntervalMs`(默认 60 s)定时器,启动 5 s 先报一次。
15
+ - `lib/index.js`:`enableCloud()` 里只有 `cfg.busSyncEnabled === true` 才创建老 `createCloud` 镜像;云端目录(home)
16
+ 一律启用。`term_cloud` 的 `once` 在镜像停用后明确回「总线镜像已停用」,status 兜底对象带 `design:'cloud-home'` 与 `home`。
17
+ - `lib/bus.js` DEFAULTS:新增 `busSyncEnabled: false`(老镜像默认关闭,需要时在 config.json 里开回来)与 `presenceIntervalMs: 60000`。
18
+ - `lib/web.js`:`/crossterm/api/devices` 合并两份来源 —— 老目录 `listCloudDevices(cloudRoot)`(给 `<0.7.2` 的设备)
19
+ + 新布局 `listPresences()`(`source: 'layout'`,同 nodeId 时新布局优先);`status.cloud` 增 `design`,
20
+ 非镜像模式下 `root` 显示账号目录、`intervalMs` 显示心跳间隔。
21
+ - 探针 `probe-presence.mjs`:16 项全 PASS(心跳字段/落点/两台设备在线判定/排序/跳过无心跳目录/无布局返回 no-layout/
22
+ 上传失败抛错/home 集成:`presenceNow`、`status.presence`、`devices`、`start` 后间隔)。
23
+ - 兼容:还跑 `<0.7.2` 的设备仍然读老目录里的邮件与 presence,所以老目录**只停写、不删除**;
24
+ 等所有设备升到 0.7.2 之后再整体删掉。
25
+ - 要不要跟着动手:**要** —— 别的机器升级到 0.7.2 后云端设备列表才来自新布局;老目录可在全部升级后删除。
26
+
27
+ ## 0.7.1 — 2026-10-19
28
+
29
+ ### 「更新」按钮在别的机器上装不上:三级兜底 + 全步骤回执
30
+
31
+ - 现象(User said, paraphrased):别的设备点卡片里的「更新」,看起来装不上。
32
+ - 根因:旧实现只有两条路 —— PATH 里的 `dsh`,或 `%USERPROFILE%\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\{node,pnpm}`
33
+ 这一固定目录。桌面版默认**不把 `dsh` 放进 PATH**,别的机器也不一定有这套运行时目录,
34
+ 于是直接落到「找不到可用的 dsh 命令」,卡片只显示一行字,看不出卡在哪一步。
35
+ - 桌面版的 `dsh` 真身(`F:\dsh\resources\runtime\cli\bin\dsh.cmd`):
36
+ `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 %*`,
37
+ 而 `dsh plugin ...` 内部就是在 profile 目录里跑 pnpm(`--profile` 必填)。所以不依赖 PATH 也能调起同一个 CLI。
38
+ - 现行三级兜底(`lib/web.js` 的 `updatePlugin(term, info, latest)`,按顺序尝试,第一个成功即止,每步都记档):
39
+ 1. `dsh plugin --profile <p> add dsh-crossterm@latest`;
40
+ 2. DSH 自带 CLI:`process.execPath --expose-internals <resources>\app.asar\...\cli.js plugin --profile <p> add ...`
41
+ (env 加 `ELECTRON_RUN_AS_NODE=1`);
42
+ 3. 直接 pnpm:`pnpmCandidates()` 依次找 `<DSH_HOME>\dsh-runtimes\...\dependencies\pnpm\bin\pnpm.cjs`
43
+ 与 `<安装目录>\resources\runtime\pnpm\bin\pnpm.cjs`;node 用自带 `node.exe`,没有就拿宿主 Electron 当 node。
44
+ - 每步记录 `{label, cmd, code, ms, out}`;装完读 `<profile>\node_modules\dsh-crossterm\package.json` **核对真实版本**,
45
+ 回执区分「已更新到 X,重启 DSH 后生效」「已经是最新(X)」「更新没成功(当前装着 X)」。
46
+ - 卡片:失败时把每步退出码、耗时、输出尾部(各 400 字)直接显示在卡片里,不用去翻宿主日志。
47
+ - 更新日志:`<busDir>\logs\update.log` 追加 JSON 行(含 profile / before / installed / latest / steps),
48
+ 别的会话排查时直接读这个文件。
49
+ - 顺带补 0.7.0 漏的字段:`GET /crossterm/api/status` 的 `home.layout` 现在带 `rulesVersion` / `rulesFailed` / `accountSource`。
50
+ - 探针 `F:\dsh\001X\crossterm-work\probe-update-plan.mjs` **14 项全 PASS**。
51
+ - 要不要跟着动手:**不用**,重启 DSH 即生效(本机是 `link:` 源码版,重启加载即可)。
52
+
3
53
  ## 0.7.0 — 2026-10-19
4
54
 
5
55
  ### 入口行升格为「总开关」,排到组件列表第一位
package/README.md CHANGED
@@ -28,12 +28,15 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
28
28
  > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
29
29
  > 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
30
30
 
31
- **本版更新(0.7.0)**
32
-
33
- - **第一个组件变成总开关**:排到列表最前,标注「跨端互通(总开关)」,说明写明关掉它会停掉下面四个组件。
34
- - 它不再是「只关卡片的入口行」:现在用 cordis 服务注入实现 —— ui 行 `provide('crossterm')`,
35
- 其余四行 `inject` 它;**关掉总开关,那四个组件连回调都不会执行**,工具/心跳/云同步/中转站/备份一起停。
36
- - 四个业务组件仍可各自单独开关(总开关开着时)。
31
+ **本版更新(0.7.2)**
32
+
33
+ - **云端老文件夹停用**:不再把本机总线镜像到 `/dsh云盘/crossterm/bus`(老目录里 12 个 json 是状态镜像、
34
+ 200 个 md 是本机邮件的子集、200 个 tmp 是原子写残留,没有必须迁移的数据)。
35
+ - **设备心跳搬进账号共享目录**:`<云端根>/<账号>/shared/<设备>/_device.json`,每 60 s 写一次;
36
+ 「云端设备」列表改为新布局优先,老目录只作 `<0.7.2` 设备的兼容来源。
37
+ - 老镜像需要时可在 `config.json` 里把 `busSyncEnabled` 设 true 开回来;`term_cloud` 的 `once` 在镜像停用时给出说明。
38
+ - 老目录**只停写、不删除**:还跑 `<0.7.2` 的设备仍从那里读邮件与 presence,等全部升级后再整体删掉。
39
+ - 要不要跟着动手:**要** —— 别的机器升到 0.7.2 后,云端设备列表才会来自新布局。
37
40
 
38
41
  ## 一、安装与卸载
39
42
 
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 {
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.7.0'
25
+ export const VERSION = '0.7.2'
26
26
 
27
27
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
28
  export function dshHome() {
@@ -61,6 +61,9 @@ const DEFAULTS = {
61
61
  inboxRoot: '',
62
62
  // 云总线(百度网盘)同步:默认关闭,开启后插件自己按 cloudIntervalMs 往返镜像。
63
63
  cloudEnabled: false,
64
+ // 0.7.2 起老的总线镜像(cloudRoot 那一套)默认不开,云端只用「账号/设备/共享收件箱」目录。
65
+ // 还跑 <0.7.2 的设备仍会把状态写进 cloudRoot,本机用只读方式兼容它。
66
+ busSyncEnabled: false,
64
67
  cloudRoot: '/dsh云盘/crossterm/bus',
65
68
  cloudIntervalMs: 4000,
66
69
  // 云端目录(0.3.39 起):<cloudHome>/<账号>/<设备>/ + <账号>/shared/<设备>/inbox
@@ -68,6 +71,7 @@ const DEFAULTS = {
68
71
  cloudAccount: '',
69
72
  relayIntervalMs: 20000,
70
73
  backupIntervalMs: 300000,
74
+ presenceIntervalMs: 60000,
71
75
  backupSessionsPerRun: 20,
72
76
  backupAttachmentsPerRun: 20,
73
77
  attachmentBackupEnabled: true,
package/lib/cloud/home.js CHANGED
@@ -4,19 +4,21 @@ import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { ensureLayout, readLayoutCache, DEFAULT_HOME } from './layout.js'
6
6
  import { createRelay } from './relay.js'
7
+ import { writePresence, listPresences } from './presence.js'
7
8
  import { backupSessions, backupAttachments, backupSummary } from './session-backup.js'
8
9
 
9
10
  /**
10
11
  * @param {object} opts
11
12
  * @param {object} opts.cfg 插件配置(readConfig 结果)
12
13
  * @param {object} opts.device { nodeId, name, version, platform, sessions }
14
+ * @param {Function} [opts.deviceInfo] 返回当前设备快照(版本/会话数/链),心跳用;不传就用 device
13
15
  * @param {object} opts.pan 云盘客户端
14
16
  * @param {string} opts.busDir 总线根(<DSH_HOME>/crossterm)
15
17
  * @param {Function} opts.deliver async ({ to, envelope, files }) => { ok, sessionId, reason }
16
18
  * @param {Function} [opts.names] 返回 { <会话短号>: { name } },给 _session.json 补显示名
17
19
  * @param {Function} [opts.log]
18
20
  */
19
- export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log = () => {} }) {
21
+ export function createCloudHome({ cfg, device, deviceInfo, pan, busDir, deliver, names, log = () => {} }) {
20
22
  const home = path.dirname(busDir)
21
23
  const root = path.join(busDir, 'cloud')
22
24
  fs.mkdirSync(root, { recursive: true })
@@ -33,6 +35,9 @@ export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log
33
35
  let backupBusy = null
34
36
  let relayTimer = 0
35
37
  let backupTimer = 0
38
+ let presenceTimer = 0
39
+ let presenceAt = 0
40
+ let presenceError = ''
36
41
 
37
42
  const relay = createRelay({
38
43
  pan,
@@ -114,15 +119,63 @@ export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log
114
119
  return relay.sendEnvelope(opts)
115
120
  }
116
121
 
122
+ /** 把本机状态写进共享目录(0.7.2:取代云端老目录的 presence 镜像) */
123
+ async function presenceNow() {
124
+ try {
125
+ const layout = await getLayout(false)
126
+ if (!layout || !layout.paths) return { ok: false, reason: 'no-layout' }
127
+ const extra = typeof deviceInfo === 'function' ? deviceInfo() || {} : {}
128
+ const ret = await writePresence({
129
+ pan,
130
+ layout,
131
+ info: {
132
+ nodeId: device.nodeId,
133
+ name: extra.name || device.name,
134
+ hostname: extra.hostname || device.hostname || '',
135
+ version: extra.version || device.version,
136
+ platform: extra.platform || device.platform,
137
+ pid: extra.pid || device.pid || 0,
138
+ sessions: extra.sessions ?? device.sessions ?? 0,
139
+ chains: extra.chains || [],
140
+ startedAt: extra.startedAt || 0,
141
+ },
142
+ log: (line) => log(line),
143
+ })
144
+ presenceAt = Date.now()
145
+ presenceError = ret.ok ? '' : ret.reason || 'unknown'
146
+ return ret
147
+ } catch (e) {
148
+ presenceError = (e && e.message) || String(e)
149
+ return { ok: false, reason: presenceError }
150
+ }
151
+ }
152
+
153
+ /** 读云端所有设备心跳(新布局;不含老目录) */
154
+ async function devices() {
155
+ const layout = await getLayout(false)
156
+ if (!layout || !layout.paths) return { ok: false, devices: [] }
157
+ return listPresences({ pan, layout, log: (line) => log(line) })
158
+ }
159
+
117
160
  function status() {
118
161
  const layout = readLayoutCache(cacheFile)
119
162
  return {
120
163
  home: `${cfg.cloudHome || DEFAULT_HOME}`,
121
164
  layout: layout
122
- ? { account: layout.account, role: layout.role, deviceId: layout.deviceId, paths: layout.paths, ensuredAt: layout.ensuredAt }
165
+ ? {
166
+ account: layout.account,
167
+ role: layout.role,
168
+ deviceId: layout.deviceId,
169
+ paths: layout.paths,
170
+ ensuredAt: layout.ensuredAt,
171
+ rulesVersion: layout.rulesVersion || 0,
172
+ rulesFailed: layout.rulesFailed || 0,
173
+ accountSource: layout.accountSource || '',
174
+ }
123
175
  : null,
124
176
  relay: { ...relay.stats(), intervalMs: relayTimer },
125
177
  backup: { ...backupSummary(backupState), intervalMs: backupTimer },
178
+ presence: { at: presenceAt, error: presenceError, intervalMs: presenceTimer },
126
179
  roots: { sessions: sessionsRoot, attachments: attachmentsRoot },
127
180
  }
128
181
  }
@@ -132,14 +185,17 @@ export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log
132
185
  started = true
133
186
  relayTimer = Number(cfg.relayIntervalMs || 20000)
134
187
  backupTimer = Number(cfg.backupIntervalMs || 300000)
188
+ presenceTimer = Number(cfg.presenceIntervalMs || 60000)
135
189
  void getLayout(false).catch(() => {})
136
190
  timers.push(setInterval(() => { void relayNow().catch(() => {}) }, relayTimer))
137
191
  timers.push(setInterval(() => { void backupNow().catch(() => {}) }, backupTimer))
192
+ timers.push(setInterval(() => { void presenceNow().catch(() => {}) }, presenceTimer))
138
193
  for (const t of timers) if (t && typeof t.unref === 'function') t.unref()
139
194
  // 启动 8 秒后先跑一轮收信、30 秒后跑第一轮备份(别和宿主启动抢资源)
140
195
  setTimeout(() => { void relayNow().catch(() => {}) }, 8000)
141
196
  setTimeout(() => { void backupNow().catch(() => {}) }, 30000)
142
- log(`云端目录就绪:relay ${relayTimer}ms / backup ${backupTimer}ms(根 ${cfg.cloudHome || DEFAULT_HOME})`)
197
+ setTimeout(() => { void presenceNow().catch(() => {}) }, 5000)
198
+ log(`云端目录就绪:relay ${relayTimer}ms / backup ${backupTimer}ms / presence ${presenceTimer}ms(根 ${cfg.cloudHome || DEFAULT_HOME})`)
143
199
  }
144
200
 
145
201
  function stop() {
@@ -148,5 +204,5 @@ export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log
148
204
  timers = []
149
205
  }
150
206
 
151
- return { start, stop, status, getLayout, backupNow, relayNow, send, cacheFile, relayDir }
207
+ return { start, stop, status, getLayout, backupNow, relayNow, send, presenceNow, devices, cacheFile, relayDir }
152
208
  }
@@ -0,0 +1,71 @@
1
+ // 新布局下的设备心跳:<accountDir>/shared/<deviceId>/_device.json
2
+ //
3
+ // 为什么要它:0.7.2 起不再把本地总线镜像到云端老目录(/dsh云盘/crossterm/bus),
4
+ // 但「云端设备」列表得继续有数据 —— 于是每台设备把自己的状态写进账号共享目录。
5
+ // 老目录仍在读(给还跑旧版本的设备留兼容),只是不再写。
6
+
7
+ export const DEVICE_FILE = '_device.json'
8
+
9
+ /** 组装一份心跳内容(全字段可 JSON 序列化) */
10
+ export function makePresence({
11
+ nodeId = '',
12
+ name = '',
13
+ hostname = '',
14
+ version = '',
15
+ platform = '',
16
+ pid = 0,
17
+ sessions = 0,
18
+ chains = [],
19
+ startedAt = 0,
20
+ at = Date.now(),
21
+ } = {}) {
22
+ return {
23
+ nodeId,
24
+ name,
25
+ hostname,
26
+ version,
27
+ platform,
28
+ pid,
29
+ sessions,
30
+ chains: Array.isArray(chains) ? chains : [],
31
+ startedAt,
32
+ at,
33
+ }
34
+ }
35
+
36
+ /** 把心跳写进共享目录(覆盖自己那份) */
37
+ export async function writePresence({ pan, layout, info, log = () => {} }) {
38
+ const paths = layout && layout.paths
39
+ if (!paths || !paths.deviceRoot) return { ok: false, reason: 'no-layout' }
40
+ const remote = `${paths.deviceRoot}/${DEVICE_FILE}`
41
+ const payload = makePresence(info)
42
+ const buf = Buffer.from(JSON.stringify(payload, null, 2) + '\n', 'utf8')
43
+ await pan.putBuffer(buf, remote)
44
+ log(`presence: 已上报 ${payload.name || payload.nodeId} v${payload.version} → ${remote}`)
45
+ return { ok: true, remote, payload }
46
+ }
47
+
48
+ /** 列出所有设备的共享目录,逐个取 _device.json */
49
+ export async function listPresences({ pan, layout, onlineMs = 3 * 60 * 1000, now = Date.now(), log = () => {} }) {
50
+ const paths = layout && layout.paths
51
+ if (!paths || !paths.sharedDir) return { ok: false, devices: [], files: 0, reason: 'no-layout' }
52
+ const list = await pan.lsAll(paths.sharedDir)
53
+ const entries = (list && list.list) || []
54
+ const devices = []
55
+ let files = 0
56
+ for (const entry of entries) {
57
+ if (entry.isdir !== 1) continue
58
+ const remote = `${paths.sharedDir}/${entry.server_filename}/${DEVICE_FILE}`
59
+ try {
60
+ const got = await pan.getBuffer(remote)
61
+ const info = JSON.parse(got.buffer.toString('utf8'))
62
+ devices.push({ ...info, online: now - Number(info.at || 0) < onlineMs })
63
+ files += 1
64
+ } catch {
65
+ // 目录里还没心跳(或刚建好),跳过
66
+ }
67
+ }
68
+ devices.sort((a, b) => Number(b.at || 0) - Number(a.at || 0))
69
+ log(`presence: 共享目录里读到 ${devices.length} 台设备(文件 ${files})`)
70
+ return { ok: true, devices, files, tmpFiles: 0 }
71
+ }
package/lib/index.js CHANGED
@@ -1205,27 +1205,30 @@ class CrossTerm {
1205
1205
  }
1206
1206
  }
1207
1207
 
1208
- /** 启动云总线同步(百度网盘):只有配置里 cloudEnabled 才会跑 */
1208
+ /** 启动云端:0.7.2 起默认只跑「云端目录」(账号/设备/共享收件箱/中转站/备份),
1209
+ * 老的总线镜像(cloudRoot=/dsh云盘/crossterm/bus)需要 busSyncEnabled=true 才跑 */
1209
1210
  enableCloud() {
1210
- if (this.cloud) return this.cloud
1211
1211
  const pan = createPan({
1212
1212
  appFile: this.cfg.baiduAppFile,
1213
1213
  authFile: this.cfg.baiduAuthFile,
1214
1214
  log: (m) => void this.note('cloud: ' + m),
1215
1215
  })
1216
- this.cloud = createCloud({
1217
- busDir: this.bus.root,
1218
- mailDir: this.cfg.mailDir,
1219
- inboxDir: this.cfg.inboxRoot,
1220
- cloudRoot: this.cfg.cloudRoot,
1221
- stateFile: this.cfg.cloudStateFile,
1222
- intervalMs: this.cfg.cloudIntervalMs,
1223
- pan,
1224
- log: (m) => void this.note('cloud: ' + m),
1225
- })
1226
- this.cloud.start()
1227
- void this.note(`cloud sync 启动 → ${this.cfg.cloudRoot}(${this.cfg.cloudIntervalMs}ms)`)
1216
+ if (this.cfg.busSyncEnabled && !this.cloud) {
1217
+ this.cloud = createCloud({
1218
+ busDir: this.bus.root,
1219
+ mailDir: this.cfg.mailDir,
1220
+ inboxDir: this.cfg.inboxRoot,
1221
+ cloudRoot: this.cfg.cloudRoot,
1222
+ stateFile: this.cfg.cloudStateFile,
1223
+ intervalMs: this.cfg.cloudIntervalMs,
1224
+ pan,
1225
+ log: (m) => void this.note('cloud: ' + m),
1226
+ })
1227
+ this.cloud.start()
1228
+ void this.note(`总线镜像启动 → ${this.cfg.cloudRoot}(${this.cfg.cloudIntervalMs}ms)`)
1229
+ }
1228
1230
  this.enableCloudHome(pan)
1231
+ this.pan = pan
1229
1232
  return this.cloud
1230
1233
  }
1231
1234
 
@@ -1246,6 +1249,17 @@ class CrossTerm {
1246
1249
  busDir: this.bus.root,
1247
1250
  deliver: (opts) => this.relayDeliver(opts),
1248
1251
  names: () => this.sessionNames(),
1252
+ deviceInfo: () => ({
1253
+ nodeId: this.nodeId,
1254
+ name: this.cfg.name,
1255
+ hostname: os.hostname(),
1256
+ version: VERSION,
1257
+ platform: process.platform,
1258
+ pid: process.pid,
1259
+ sessions: this.sessions().length,
1260
+ chains: [...new Set([...this.links.values()].map((l) => l.code))],
1261
+ startedAt: this.startedAt || 0,
1262
+ }),
1249
1263
  log: (m) => void this.note('cloud: ' + m),
1250
1264
  })
1251
1265
  this.home.start()
@@ -1803,10 +1817,23 @@ function buildTools(term, agent, part = 'all') {
1803
1817
  }
1804
1818
  else if (action === 'once') {
1805
1819
  const c = term.cloud || term.enableCloud()
1820
+ if (!c) return { ok: false, detail: '总线镜像已停用(0.7.2 起默认只跑云端目录:账号/设备/共享收件箱),改用 action="home" 或 "relay"/"backup"' }
1806
1821
  const result = await c.once()
1807
1822
  return { ok: true, status: c.status(), result }
1808
1823
  }
1809
- const status = term.cloud ? term.cloud.status() : { running: false, intervalMs: term.cfg.cloudIntervalMs, cloudRoot: term.cfg.cloudRoot, cycles: 0, up: 0, down: 0, merged: 0 }
1824
+ const status = term.cloud
1825
+ ? term.cloud.status()
1826
+ : {
1827
+ running: Boolean(term.home),
1828
+ design: term.home ? 'cloud-home' : 'off',
1829
+ intervalMs: term.cfg.cloudIntervalMs,
1830
+ cloudRoot: term.home ? term.cfg.cloudHome || 'dsh' : '',
1831
+ cycles: 0,
1832
+ up: 0,
1833
+ down: 0,
1834
+ merged: 0,
1835
+ home: term.home ? term.home.status() : null,
1836
+ }
1810
1837
  return { ok: true, status }
1811
1838
  } catch (e) {
1812
1839
  return { ok: false, detail: (e && e.message) || String(e) }
package/lib/web.js CHANGED
@@ -8,6 +8,7 @@ import { spawn } from 'node:child_process'
8
8
  import { VERSION, readConfig, writeConfig } from './bus.js'
9
9
  import { createPan } from './cloud/pan.js'
10
10
  import { listCloudDevices, localDevice } from './cloud/devices.js'
11
+ import { listPresences } from './cloud/presence.js'
11
12
  import { runSetup } from './cloud/setup.js'
12
13
 
13
14
  const PKG = 'dsh-crossterm'
@@ -163,6 +164,7 @@ function run(cmd, args, cwd, opts) {
163
164
  cwd,
164
165
  shell,
165
166
  windowsHide: true,
167
+ env: opts && opts.env ? { ...process.env, ...opts.env } : process.env,
166
168
  })
167
169
  } catch (error) {
168
170
  resolve({ code: -1, out: String((error && error.message) || error) })
@@ -197,19 +199,46 @@ function updateErrorText(result) {
197
199
  return tail
198
200
  }
199
201
 
200
- // 有些机器没把 `dsh` 命令放进 PATH(桌面版就是),这时退回 DSH 自带运行时里的
201
- // node + pnpm 直接装:<DSH_HOME>\dsh-runtimes\dsh-primary-runtime\dependencies\{node,pnpm}
202
- function runtimeTools(cfg) {
202
+ // DSH 安装根目录:process.resourcesPath = <root>\resources,上一级就是 <root>
203
+ function dshRoot() {
204
+ if (process.resourcesPath) return path.dirname(process.resourcesPath)
205
+ return path.dirname(process.execPath || '')
206
+ }
207
+
208
+ // 桌面版把 `dsh` 命令实现成「让 Electron 以 node 模式跑 cli.js」:
209
+ // <root>\DeepSeek Harness.exe --expose-internals <root>\resources\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js
210
+ // 所以不依赖 PATH 也能调起同一个 CLI(`dsh plugin add` 内部其实就是 pnpm add)。
211
+ function cliScript() {
212
+ const res = process.resourcesPath || path.join(dshRoot(), 'resources')
213
+ return path.join(res, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js')
214
+ }
215
+
216
+ function dshHome(cfg) {
203
217
  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
- }
218
+ return busDir ? path.dirname(busDir) : path.join(os.homedir(), '.dsh')
219
+ }
220
+
221
+ // pnpm 可能落在两处:DSH_HOME 的运行时依赖里,或安装目录的 resources\runtime\pnpm
222
+ function pnpmCandidates(cfg) {
223
+ const deps = path.join(dshHome(cfg), 'dsh-runtimes', 'dsh-primary-runtime', 'dependencies')
224
+ const res = process.resourcesPath || path.join(dshRoot(), 'resources')
225
+ return [
226
+ path.join(deps, 'pnpm', 'bin', 'pnpm.cjs'),
227
+ path.join(res, 'runtime', 'pnpm', 'bin', 'pnpm.cjs'),
228
+ ].filter((p) => p && fs.existsSync(p))
229
+ }
230
+
231
+ // 没有独立 node.exe 时用宿主自己的 Electron 以 node 模式跑脚本
232
+ function nodeTool(cfg) {
233
+ const deps = path.join(dshHome(cfg), 'dsh-runtimes', 'dsh-primary-runtime', 'dependencies')
234
+ const bundled = path.join(deps, 'node', 'bin', 'node.exe')
235
+ if (fs.existsSync(bundled)) return { cmd: bundled, env: {} }
236
+ return { cmd: process.execPath, env: { ELECTRON_RUN_AS_NODE: '1' } }
237
+ }
238
+
239
+ function runtimeTools(cfg) {
240
+ const pnpm = pnpmCandidates(cfg)[0] || ''
241
+ return { node: nodeTool(cfg).cmd, pnpm, ok: Boolean(pnpm) }
213
242
  }
214
243
 
215
244
  function looksMissingCommand(result) {
@@ -218,16 +247,91 @@ function looksMissingCommand(result) {
218
247
  )
219
248
  }
220
249
 
221
- async function updatePlugin(term, info) {
250
+ /** 读 profile 里实际装着的版本(registry 模式下 node_modules/<pkg>/package.json) */
251
+ function installedVersion(info) {
252
+ try {
253
+ const doc = JSON.parse(fs.readFileSync(path.join(info.dir, 'node_modules', PKG, 'package.json'), 'utf8'))
254
+ return typeof doc.version === 'string' ? doc.version : ''
255
+ } catch {
256
+ return ''
257
+ }
258
+ }
259
+
260
+ // 更新过程落一条日志,别的会话/排查时能直接看:<busDir>\logs\update.log
261
+ function appendUpdateLog(cfg, record) {
262
+ try {
263
+ const dir = path.join((cfg && cfg.busDir) || path.join(os.homedir(), '.dsh', 'crossterm'), 'logs')
264
+ fs.mkdirSync(dir, { recursive: true })
265
+ fs.appendFileSync(path.join(dir, 'update.log'), `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`, 'utf8')
266
+ } catch {
267
+ /* 日志写不进去不影响更新本身 */
268
+ }
269
+ }
270
+
271
+ /**
272
+ * 更新已安装的 registry 版:按「PATH 里的 dsh → DSH 自带 CLI → 直接 pnpm」依次尝试,
273
+ * 每条都记进 steps(命令、退出码、耗时、输出尾部),最后核对 node_modules 里的真实版本。
274
+ */
275
+ async function updatePlugin(term, info, latest) {
222
276
  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) }
277
+ const before = installedVersion(info)
278
+ const steps = []
279
+ const attempt = async (label, cmd, args, opts) => {
280
+ const started = Date.now()
281
+ const res = await run(cmd, args, (opts && opts.cwd) || info.dir, opts)
282
+ steps.push({
283
+ label,
284
+ cmd: `${path.basename(cmd)} ${args.join(' ')}`.slice(0, 200),
285
+ code: res.code,
286
+ ms: Date.now() - started,
287
+ out: (res.out || '').trim().slice(-600),
288
+ })
289
+ return res
290
+ }
291
+ const finish = (via) => {
292
+ const installed = installedVersion(info)
293
+ const updated = Boolean(installed) && installed !== before
294
+ const upToDate = Boolean(installed) && Boolean(latest) && compareVersion(installed, latest) >= 0
295
+ const ok = updated || upToDate
296
+ appendUpdateLog(term.cfg, { profile: info.name, ok, updated, via, before, installed, latest: latest || '', steps })
297
+ return { ok, updated, via, before, installed, latest: latest || '', steps }
298
+ }
299
+
300
+ const { cmd: node, env } = nodeTool(term.cfg)
301
+ const cli = cliScript()
302
+ const plan = [{ label: 'dsh', cmd: 'dsh', args: ['plugin', '--profile', info.name, 'add', target], opts: {} }]
303
+ if (fs.existsSync(cli)) {
304
+ plan.push({
305
+ label: 'cli',
306
+ cmd: process.execPath,
307
+ args: ['--expose-internals', cli, 'plugin', '--profile', info.name, 'add', target],
308
+ opts: { shell: false, env: { ELECTRON_RUN_AS_NODE: '1' } },
309
+ })
310
+ }
311
+ for (const pnpm of pnpmCandidates(term.cfg)) {
312
+ plan.push({ label: 'pnpm', cmd: node, args: [pnpm, 'add', target], opts: { shell: false, env } })
313
+ }
314
+
315
+ let last = null
316
+ for (const step of plan) {
317
+ const res = await attempt(step.label, step.cmd, step.args, step.opts)
318
+ last = res
319
+ if (res.code === 0) {
320
+ const result = finish(step.label)
321
+ if (result.ok) return result
322
+ }
323
+ }
324
+ appendUpdateLog(term.cfg, { profile: info.name, ok: false, updated: false, before, latest: latest || '', steps })
325
+ return {
326
+ ok: false,
327
+ updated: false,
328
+ via: 'none',
329
+ before,
330
+ installed: installedVersion(info),
331
+ latest: latest || '',
332
+ steps,
333
+ out: steps.map((s) => `[${s.label}] 退出码 ${s.code}\n${s.out}`).join('\n\n') || updateErrorText(last),
334
+ }
231
335
  }
232
336
 
233
337
  function statusOf(term) {
@@ -241,7 +345,10 @@ function statusOf(term) {
241
345
  /* ignore */
242
346
  }
243
347
  const cloud = term.cloud
348
+ const home = term.home
349
+ const homeStatus = home ? home.status() : null
244
350
  const provider = cloudProvider(cfg)
351
+ const busMirror = cfg.busSyncEnabled === true
245
352
  return {
246
353
  ok: true,
247
354
  version: VERSION,
@@ -255,14 +362,19 @@ function statusOf(term) {
255
362
  // 没配云盘时不要把默认路径当成「已连接的云端」显示出来
256
363
  configured: provider.configured,
257
364
  enabled: cfg.cloudEnabled === true && provider.configured,
258
- running: provider.configured && Boolean(cloud && cloud.running),
259
- root: provider.configured ? cfg.cloudRoot || '' : '',
260
- intervalMs: cfg.cloudIntervalMs || 0,
365
+ running: provider.configured && Boolean((cloud && cloud.running) || home),
366
+ root: provider.configured
367
+ ? busMirror
368
+ ? cfg.cloudRoot || ''
369
+ : (homeStatus && homeStatus.layout && homeStatus.layout.paths && homeStatus.layout.paths.accountDir) || cfg.cloudRoot || ''
370
+ : '',
371
+ intervalMs: busMirror ? cfg.cloudIntervalMs || 0 : (cfg.presenceIntervalMs || 60000),
372
+ design: busMirror ? 'bus+home' : 'home',
261
373
  provider,
262
374
  },
263
375
  chains,
264
- // 云端目录(0.3.39):账号/角色/设备目录/中转站/会话备份
265
- home: term.home ? term.home.status() : null,
376
+ // 云端目录(0.3.39):账号/角色/设备目录/中转站/会话备份/设备心跳
377
+ home: homeStatus,
266
378
  }
267
379
  }
268
380
 
@@ -379,6 +491,19 @@ export function registerWeb(ctx, term, log) {
379
491
  log: (m) => log('devices: ' + m),
380
492
  })
381
493
  const result = await listCloudDevices(pan, cfg.cloudRoot)
494
+ let fresh = { devices: [], files: 0 }
495
+ try {
496
+ const layout = term.home ? await term.home.getLayout(false) : null
497
+ if (layout) fresh = await listPresences({ pan, layout, log: (m) => log('devices: ' + m) })
498
+ } catch (error) {
499
+ log('devices: 新布局读取失败 ' + String((error && error.message) || error))
500
+ }
501
+ const byId = new Map()
502
+ for (const d of result.devices || []) byId.set(d.nodeId, d)
503
+ for (const d of fresh.devices || []) byId.set(d.nodeId, { ...byId.get(d.nodeId), ...d, source: 'layout' })
504
+ result.devices = [...byId.values()].sort((a, b) => Number(b.lastSeen ? Date.parse(b.lastSeen) : b.at || 0) - Number(a.lastSeen ? Date.parse(a.lastSeen) : a.at || 0))
505
+ delete result.tmpFiles
506
+ result.files = (result.files || 0) + (fresh.files || 0)
382
507
  const me = await localDevice(path.join(cfg.busDir || '', 'presence'), info.nodeId || '')
383
508
  if (me && !result.devices.some((d) => d.nodeId === me.nodeId)) result.devices.unshift(me)
384
509
  json(res, 200, { ...result, configured: true, me: info.nodeId || '' })
@@ -566,17 +691,25 @@ export function registerWeb(ctx, term, log) {
566
691
  })
567
692
  return
568
693
  }
569
- const result = await updatePlugin(term, info)
694
+ const latest = await latestVersion().catch(() => '')
695
+ const result = await updatePlugin(term, info, latest)
696
+ const installed = result.installed || ''
697
+ const steps = (result.steps || []).map((s) => ({ label: s.label, code: s.code, ms: s.ms, out: s.out }))
698
+ let message
699
+ if (result.ok && result.updated) message = `已更新到 ${installed},重启 DSH 后生效`
700
+ else if (result.ok) message = `已经是最新(${installed || latest}),不用更新`
701
+ else if (installed) message = `更新没成功(当前装着 ${installed}):${result.out || '命令都返回失败'}`
702
+ else message = `更新失败:${result.out || '未知原因'}`
570
703
  json(res, 200, {
571
704
  ok: result.ok,
572
705
  mode: 'registry',
573
706
  via: result.via,
574
- needRestart: result.ok,
575
- message: result.ok
576
- ? `已更新${result.via === 'pnpm' ? '(用 DSH 自带 pnpm)' : ''},重启 DSH 后生效`
577
- : result.via === 'none'
578
- ? result.out
579
- : (result.out || '').slice(-1200) || `更新失败(退出码不明)`,
707
+ before: result.before || '',
708
+ installed,
709
+ latest,
710
+ needRestart: Boolean(result.ok && result.updated),
711
+ steps,
712
+ message,
580
713
  })
581
714
  },
582
715
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",