dsh-crossterm 0.3.37 → 0.3.39

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/lib/cloud/sync.js CHANGED
@@ -44,6 +44,9 @@ export function createCloud(opts) {
44
44
  if (!r.includes('/')) continue
45
45
  if (EXCLUDE_TOP.has(r)) continue
46
46
  }
47
+ // 原子写的临时文件(xxx.json.<pid>.<hex>.tmp)绝不能当数据同步,
48
+ // 以前漏过滤,云端 presence/ 里攒了两百多个残骸。
49
+ if (e.name.endsWith('.tmp')) continue
47
50
  let st
48
51
  try { st = fs.statSync(path.join(root, r)) } catch { continue }
49
52
  out.push({
@@ -62,10 +65,11 @@ export function createCloud(opts) {
62
65
  async function walkCloud() {
63
66
  const out = new Map()
64
67
  async function rec(dir, rel) {
65
- const j = await pan.ls(dir)
68
+ const j = await pan.lsAll(dir)
66
69
  if (j.errno !== 0) return
67
70
  for (const f of j.list || []) {
68
71
  if (EXCLUDE_DIRS.has(f.server_filename)) continue
72
+ if (f.server_filename.endsWith('.tmp')) continue
69
73
  const r = rel ? path.posix.join(rel, f.server_filename) : f.server_filename
70
74
  if (f.isdir === 1) { await rec(f.path, r); continue }
71
75
  out.set(r, { size: f.size, mtimeMs: (f.server_mtime || 0) * 1000, fs_id: f.fs_id })
package/lib/index.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  import { deriveNameFromFile, findTranscript } from './names.js'
37
37
  import { createPan } from './cloud/pan.js'
38
38
  import { createCloud } from './cloud/sync.js'
39
+ import { createCloudHome } from './cloud/home.js'
39
40
  import { runSetup } from './cloud/setup.js'
40
41
  import { registerWeb } from './web.js'
41
42
 
@@ -730,6 +731,69 @@ class CrossTerm {
730
731
  return { ok: true, sessionId, files: landed }
731
732
  }
732
733
 
734
+ // ── 云端中转站(0.3.39)────────────────────────────────────────────────
735
+
736
+ /** 按会话名(也接受短号/完整 id)找本终端里的会话 */
737
+ resolveByName(raw) {
738
+ const s = String(raw || '').trim()
739
+ if (!s) return ''
740
+ const lower = s.toLowerCase()
741
+ const list = this.sessions()
742
+ const exact = list.find((x) => [x.name, x.title, x.short, x.id].some((v) => String(v || '').trim().toLowerCase() === lower))
743
+ if (exact) return exact.id
744
+ const part = list.find((x) => [x.name, x.title].some((v) => String(v || '').trim().toLowerCase().includes(lower)))
745
+ if (part) return part.id
746
+ return this.resolveSessionId(s) || ''
747
+ }
748
+
749
+ /** 中转站投递:把云端收件箱里的一封信投进本机对应会话(按收件人会话名) */
750
+ async relayDeliver({ to, envelope = {}, files = [] }) {
751
+ const targets = (Array.isArray(to) ? to : String(to || '').split(/[,,;;\s]+/))
752
+ .map((s) => String(s || '').trim())
753
+ .filter(Boolean)
754
+ if (!targets.length) return { ok: false, reason: '信封里没有收件人(要填会话名)' }
755
+ const controller = this.sessionController()
756
+ if (!controller || typeof controller.prompt !== 'function') {
757
+ return { ok: false, reason: '本 Host 没有 sessionController.prompt,中转站只能留件' }
758
+ }
759
+ const from = envelope.from || {}
760
+ const lines = ['[云端中转站] 收到 1 封']
761
+ lines.push(`来自:${from.name || from.nodeId || '未知设备'}${from.nodeId ? ` (${from.nodeId})` : ''}`)
762
+ if (envelope.subject) lines.push(`主题:${envelope.subject}`)
763
+ if (envelope.text) lines.push('', String(envelope.text).trim())
764
+ const landed = files.filter((f) => f && f.local)
765
+ if (landed.length) {
766
+ lines.push('', '附件已保存到本机:')
767
+ for (const f of landed) lines.push(`- ${f.local}(${formatBytes(f.bytes || 0)})`)
768
+ }
769
+ const bad = files.filter((f) => !f || !f.local)
770
+ if (bad.length) lines.push('', `(有 ${bad.length} 个附件没下载下来:${bad.map((f) => (f && f.name) || '未知').join('、')})`)
771
+ lines.push('', `(这封信由云端中转站投递;收件人:${targets.join('、')}。回复用 term_send。)`)
772
+ const text = lines.join('\n')
773
+ const delivered = []
774
+ const failed = []
775
+ for (const target of targets) {
776
+ const sessionId = this.resolveByName(target)
777
+ if (!sessionId) { failed.push({ target, reason: '本机没有这个会话' }); continue }
778
+ try {
779
+ await this.promptSession(controller, { id: `relay-${Date.now()}-${shortId(sessionId)}` }, sessionId, text)
780
+ delivered.push(sessionId)
781
+ await this.note(`relay delivered -> ${this.labelOf(sessionId)}`)
782
+ } catch (e) {
783
+ failed.push({ target, reason: (e && e.message) || String(e) })
784
+ }
785
+ }
786
+ if (!delivered.length) return { ok: false, reason: `投递失败:${failed.map((f) => `${f.target}(${f.reason})`).join('、')}` }
787
+ return { ok: true, sessionId: delivered[0], delivered, failed }
788
+ }
789
+
790
+ /** 给云端备份用的「会话短号 → 显示名」表 */
791
+ sessionNames() {
792
+ const out = {}
793
+ for (const s of this.sessions()) out[s.short] = { name: s.name || '', cwd: s.cwd || '' }
794
+ return out
795
+ }
796
+
733
797
  // ── 会话链(一串特征码链接多个会话)────────────────────────────────────
734
798
 
735
799
  async loadLinks() {
@@ -1161,10 +1225,42 @@ class CrossTerm {
1161
1225
  })
1162
1226
  this.cloud.start()
1163
1227
  void this.note(`cloud sync 启动 → ${this.cfg.cloudRoot}(${this.cfg.cloudIntervalMs}ms)`)
1228
+ this.enableCloudHome(pan)
1164
1229
  return this.cloud
1165
1230
  }
1166
1231
 
1232
+ /** 云端目录(账号/设备/共享收件箱)+ 中转站 + 会话备份,见 lib/cloud/home.js */
1233
+ enableCloudHome(pan) {
1234
+ if (this.home) return this.home
1235
+ try {
1236
+ this.home = createCloudHome({
1237
+ cfg: this.cfg,
1238
+ device: {
1239
+ nodeId: this.nodeId,
1240
+ name: this.cfg.name,
1241
+ version: VERSION,
1242
+ platform: process.platform,
1243
+ sessions: this.sessions().length,
1244
+ },
1245
+ pan,
1246
+ busDir: this.bus.root,
1247
+ deliver: (opts) => this.relayDeliver(opts),
1248
+ names: () => this.sessionNames(),
1249
+ log: (m) => void this.note('cloud: ' + m),
1250
+ })
1251
+ this.home.start()
1252
+ } catch (e) {
1253
+ this.home = null
1254
+ void this.warn('云端目录启动失败:' + ((e && e.message) || String(e)))
1255
+ }
1256
+ return this.home
1257
+ }
1258
+
1167
1259
  disableCloud() {
1260
+ if (this.home) {
1261
+ this.home.stop()
1262
+ this.home = null
1263
+ }
1168
1264
  if (!this.cloud) return false
1169
1265
  this.cloud.stop()
1170
1266
  this.cloud = null
@@ -1196,6 +1292,7 @@ class CrossTerm {
1196
1292
  if (this.timer) clearInterval(this.timer)
1197
1293
  this.timer = undefined
1198
1294
  if (this.cloud) { this.cloud.stop(); this.cloud = null }
1295
+ if (this.home) { this.home.stop(); this.home = null }
1199
1296
  await this.bus.leave(this.nodeId).catch(() => {})
1200
1297
  await this.note('stopped')
1201
1298
  }
@@ -1617,14 +1714,33 @@ function buildTools(term, agent, part = 'all') {
1617
1714
 
1618
1715
  const cloud = defineTool({
1619
1716
  name: 'term_cloud',
1620
- description: '云总线同步:查看/开启/关闭本机总线与云端目录的镜像同步,或立刻同步一次;setup 可一键配置自己的云盘(弹出记事本填 AppKey/AppSecret → 浏览器授权 → 自动开启同步)。',
1717
+ description: '云总线同步:查看/开启/关闭本机总线与云端目录的镜像同步,或立刻同步一次;setup 可一键配置自己的云盘(弹出记事本填 AppKey/AppSecret → 浏览器授权 → 自动开启同步);home/layout/relay/backup 管理云端目录(账号/设备/共享收件箱/中转站/会话备份)。',
1621
1718
  parameters: {
1622
- action: { type: 'string', enum: ['status', 'start', 'stop', 'once', 'setup'], description: 'status=查看状态;start=开启(本进程内生效);stop=关闭;once=立刻同步一次;setup=一键配置自己的云盘' },
1719
+ action: { type: 'string', enum: ['status', 'start', 'stop', 'once', 'setup', 'home', 'layout', 'relay', 'backup'], description: 'status=查看状态;start=开启(本进程内生效);stop=关闭;once=立刻同步一次;setup=一键配置自己的云盘;home=云端目录/中转站/备份概况;layout=重建云端目录;relay=立刻收一轮云端收件箱;backup=立刻备份一轮会话' },
1623
1720
  code: { type: 'string', description: '仅 setup 用:授权回调 URL 里的 code(浏览器没自动回调时手动粘贴);不填则插件自己起本地回调等待' },
1624
1721
  },
1625
1722
  output: {
1626
1723
  schema: { type: 'json' },
1627
1724
  render: textRender((v) => {
1725
+ if (v && v.home) {
1726
+ const h = v.home
1727
+ const lines = []
1728
+ if (h.layout) {
1729
+ lines.push(`云端账号:${h.layout.account}(角色 ${h.layout.role === 'admin' ? '管理员' : '成员'},设备 ${h.layout.deviceId})`)
1730
+ const p = h.layout.paths || {}
1731
+ lines.push(`设备目录:${p.deviceDir || '(未建)'}`)
1732
+ lines.push(`我的收件箱:${p.inboxDir || '(未建)'}`)
1733
+ lines.push(`会话备份:${p.sessionDir || '(未建)'}(${h.backup?.files || 0} 个会话,已备份 ${formatBytes(h.backup?.bytes || 0)})`)
1734
+ } else {
1735
+ lines.push('云端目录:还没建好(可能没连云盘,或建目录失败)')
1736
+ }
1737
+ if (h.relay) lines.push(`中转站:已收 ${h.relay.processed || 0} 封,失败 ${h.relay.failed || 0} 封,每 ${Math.round((h.relay.intervalMs || 0) / 1000)} s 查一次`)
1738
+ if (h.backup?.lastAt) lines.push(`上次备份:${new Date(h.backup.lastAt).toISOString()}(${h.backup.lastCount || 0} 个会话)`)
1739
+ if (v.layout) lines.push(`目录动作:${v.layout.created?.length ? '新建 ' + v.layout.created.join('、') : '已存在'}`)
1740
+ if (v.relay) lines.push(`本轮收信:拉取 ${v.relay.fetched || 0} 封,投递 ${v.relay.delivered || 0},失败 ${v.relay.failed || 0}`)
1741
+ if (v.backup) lines.push(`本轮备份:会话 ${v.backup.sessions?.uploaded || 0},附件 ${v.backup.attachments?.uploaded || 0},耗时 ${v.backup.ms || 0} ms`)
1742
+ return lines.join('\n')
1743
+ }
1628
1744
  if (v && v.setup) {
1629
1745
  const s = v.setup
1630
1746
  const lines = []
@@ -1666,6 +1782,24 @@ function buildTools(term, agent, part = 'all') {
1666
1782
  term.enableCloud()
1667
1783
  return { ok: true, status: term.cloud ? term.cloud.status() : undefined, setup: ret }
1668
1784
  }
1785
+ else if (action === 'home' || action === 'layout' || action === 'relay' || action === 'backup') {
1786
+ if (!term.cloud) term.enableCloud()
1787
+ const h = term.home
1788
+ if (!h) return { ok: false, detail: '云同步没开(先 action="start",或在设置里开云同步)' }
1789
+ if (action === 'layout') {
1790
+ const layout = await h.getLayout(true)
1791
+ return { ok: !!(layout && layout.paths), home: h.status(), layout }
1792
+ }
1793
+ if (action === 'relay') {
1794
+ const relay = await h.relayNow()
1795
+ return { ok: relay.ok !== false, relay, home: h.status() }
1796
+ }
1797
+ if (action === 'backup') {
1798
+ const backup = await h.backupNow()
1799
+ return { ok: backup.ok !== false, backup, home: h.status() }
1800
+ }
1801
+ return { ok: true, home: h.status() }
1802
+ }
1669
1803
  else if (action === 'once') {
1670
1804
  const c = term.cloud || term.enableCloud()
1671
1805
  const result = await c.once()
package/lib/web.js CHANGED
@@ -5,6 +5,8 @@ import os from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { spawn } from 'node:child_process'
7
7
  import { VERSION, readConfig, writeConfig } from './bus.js'
8
+ import { createPan } from './cloud/pan.js'
9
+ import { listCloudDevices, localDevice } from './cloud/devices.js'
8
10
  import { runSetup } from './cloud/setup.js'
9
11
 
10
12
  const PKG = 'dsh-crossterm'
@@ -256,6 +258,8 @@ function statusOf(term) {
256
258
  provider,
257
259
  },
258
260
  chains,
261
+ // 云端目录(0.3.39):账号/角色/设备目录/中转站/会话备份
262
+ home: term.home ? term.home.status() : null,
259
263
  }
260
264
  }
261
265
 
@@ -277,6 +281,107 @@ export function registerWeb(ctx, term, log) {
277
281
  handler: (req, res) => json(res, 200, statusOf(term)),
278
282
  }),
279
283
  )
284
+ disposers.push(
285
+ webCtx.webServer.register({
286
+ kind: 'exact',
287
+ path: '/crossterm/api/devices',
288
+ handler: async (req, res) => {
289
+ const cfg = term.cfg || {}
290
+ const info = statusOf(term)
291
+ if (!info.cloud.configured) {
292
+ json(res, 200, {
293
+ ok: false,
294
+ configured: false,
295
+ devices: [],
296
+ message: '这台机器还没连接云盘,看不到其他设备',
297
+ })
298
+ return
299
+ }
300
+ try {
301
+ const pan = createPan({
302
+ appFile: cfg.baiduAppFile,
303
+ authFile: cfg.baiduAuthFile,
304
+ log: (m) => log('devices: ' + m),
305
+ })
306
+ const result = await listCloudDevices(pan, cfg.cloudRoot)
307
+ const me = await localDevice(path.join(cfg.busDir || '', 'presence'), info.nodeId || '')
308
+ if (me && !result.devices.some((d) => d.nodeId === me.nodeId)) result.devices.unshift(me)
309
+ json(res, 200, { ...result, configured: true, me: info.nodeId || '' })
310
+ } catch (error) {
311
+ json(res, 200, {
312
+ ok: false,
313
+ configured: true,
314
+ devices: [],
315
+ message: String((error && error.message) || error),
316
+ })
317
+ }
318
+ },
319
+ }),
320
+ )
321
+ disposers.push(
322
+ webCtx.webServer.register({
323
+ kind: 'exact',
324
+ path: '/crossterm/api/home',
325
+ handler: async (req, res) => {
326
+ const cfg = term.cfg || {}
327
+ if (!statusOf(term).cloud.configured) {
328
+ json(res, 200, { ok: false, configured: false, message: '这台机器还没连接云盘:先「配置云盘」' })
329
+ return
330
+ }
331
+ try {
332
+ if (!term.home) term.enableCloud()
333
+ const home = term.home
334
+ if (!home) {
335
+ json(res, 200, { ok: false, configured: true, message: '云端目录还没启动(云同步可能被关掉了)' })
336
+ return
337
+ }
338
+ const url = String((req && req.url) || '')
339
+ const action = (() => {
340
+ try { return String(new URL(url, 'http://x').searchParams.get('action') || '') } catch { return '' }
341
+ })()
342
+ if (action === 'layout') {
343
+ const layout = await home.getLayout(true)
344
+ log(`home: 云端目录 ${layout && layout.paths ? layout.paths.deviceDir : '未建好'}`)
345
+ json(res, 200, { ok: !!(layout && layout.paths), home: home.status(), layout })
346
+ return
347
+ }
348
+ if (action === 'relay') {
349
+ const relay = await home.relayNow()
350
+ json(res, 200, { ok: relay.ok !== false, relay, home: home.status() })
351
+ return
352
+ }
353
+ if (action === 'backup') {
354
+ const backup = await home.backupNow()
355
+ json(res, 200, { ok: backup.ok !== false, backup, home: home.status() })
356
+ return
357
+ }
358
+ json(res, 200, { ok: true, configured: true, home: home.status(), cloudRoot: cfg.cloudRoot || '' })
359
+ } catch (error) {
360
+ json(res, 200, { ok: false, configured: true, message: String((error && error.message) || error) })
361
+ }
362
+ },
363
+ }),
364
+ )
365
+ disposers.push(
366
+ webCtx.webServer.register({
367
+ kind: 'exact',
368
+ path: '/crossterm/api/backup',
369
+ handler: async (req, res) => {
370
+ try {
371
+ if (!term.home) term.enableCloud()
372
+ if (!term.home) {
373
+ json(res, 200, { ok: false, message: '云端目录还没启动(先在卡片上打开云同步)' })
374
+ return
375
+ }
376
+ const result = await term.home.backupNow()
377
+ log(`backup: 会话 ${result.sessions ? result.sessions.uploaded : 0},附件 ${result.attachments ? result.attachments.uploaded : 0}`)
378
+ json(res, 200, { ok: result.ok !== false, backup: result, home: term.home.status() })
379
+ } catch (error) {
380
+ json(res, 200, { ok: false, message: String((error && error.message) || error) })
381
+ }
382
+ },
383
+ }),
384
+ )
280
385
  disposers.push(
281
386
  webCtx.webServer.register({
282
387
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.3.37",
3
+ "version": "0.3.39",
4
4
  "description": "DSH 跨终端互联:同一账号下的多个 DSH 终端/进程互发邮件与文件(主题内联、正文落盘,收件方自己读)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",