dsh-crossterm 0.5.0 → 0.6.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,37 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.6.1 — 2026-10-19
4
+
5
+ ### 修:旧布局缓存挡住了新增的云端文档
6
+ - 症状:0.5.0 / 0.6.0 升级后,云盘里看不到 `_规则-账号.md`、`_规则-设备.md`、`inbox/_说明.md`,
7
+ `status.home.layout.rulesVersion` 也还是老值 —— 文档写着「命中缓存会自动补写」,实际没补。
8
+ - 根因:`ensureLayout()` 命中缓存时直接沿用 `cached.paths`,而路径是**算出来的**(`buildPaths()`)。
9
+ 旧版缓存里没有 `accountRulesFile` / `deviceRulesFile` / `inboxReadmeFile` 三个字段,
10
+ `ensureRules()` 拿到 `undefined` 就跳过,随后仍把 `rulesVersion` 标成最新 —— 等于自己认定「已写」。
11
+ - 修法(`lib/cloud/layout.js`):
12
+ - 命中缓存时用 `buildPaths()` 按 `home / account / deviceId` **重新算路径**,缓存只负责存 `account`、`role`、`ensuredAt` 这类状态;
13
+ - `ensureRules()` 返回 `{ written, failed }`,路径缺失或上传失败都计入 `failed`;
14
+ - 只有 `failed === 0` 才把 `rulesVersion` 抬到 `RULES_VERSION` 并写回缓存,否则保持 0,下次 `ensure` 继续重试;
15
+ - 缓存新增 `home` 字段,非默认 `cloudHome` 也能重建路径。
16
+ - 探针 `F:\dsh\001X\crossterm-work\probe-layout-stale-cache.mjs`:**12 项全 PASS**
17
+ (旧缓存→重算路径→补写三份文档→写回新缓存;第二次不重复上传;上传失败时 `rulesVersion` 不冒充最新)。
18
+ 回归:`probe-layout-rules.mjs` 26 PASS、`probe-cloud-home.mjs` 42 PASS、`probe-inbox-readme.mjs` 17 PASS。
19
+
20
+ ## 0.6.0 — 2026-10-19
21
+
22
+ ### 共享收件箱里也放一份说明
23
+ - 新常量 `INBOX_README_FILE = '_说明.md'`、`RULES_VERSION = 2`(`lib/cloud/layout.js`),
24
+ `buildPaths()` 增 `inboxReadmeFile`(`<账号>/shared/<设备>/inbox/_说明.md`);
25
+ 新增 `inboxReadmeMarkdown()`,`ensureRules()` 一并写入(只在缺失时写,不覆盖)。
26
+ - 说明内容:这个文件夹是什么、谁写谁取(别的设备写信封、本机中转站每 `relayIntervalMs` 取)、
27
+ 信封 JSON 格式(`id/from/to/subject/text/files/createdAt`)、注意事项
28
+ (别手工删改信封、`_` 开头的都是系统文件会被跳过、收件人找不到就留件不消费、附件按 `<mailId>/<文件名>` 存)。
29
+ - `lib/cloud/relay.js` 新增并导出 `isEnvelopeFile(name)`:只把 **`.json` 且不以 `_` 开头、非 `.tmp`/隐藏文件**
30
+ 当信封;`poll()` 改用它过滤 —— 否则 `_说明.md` 会被当成信封反复解析失败、记 failed。
31
+ - 探针:`probe-layout-rules.mjs` 扩到 **26 项全 PASS**(含三份文档位置与收件箱说明内容);
32
+ 新增 `probe-inbox-readme.mjs` **17 项全 PASS**(说明不被当信封、正常信封仍投递并归档到 `done/`、
33
+ 失败留件、第二轮不重复投递);`probe-cloud-home.mjs` 断言改为「收件箱只剩系统说明」后 **42 项全 PASS**。
34
+
3
35
  ## 0.5.0 — 2026-10-19
4
36
 
5
37
  ### 云端规则写进云端本身:账号规则放在大文件夹下,设备规则放在账号文件夹下
package/README.md CHANGED
@@ -23,17 +23,18 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
23
23
  | 跨设备 | 经云端总线,在多台机器的 DSH 之间互发邮件与文件 | `term_cloud` |
24
24
  | 跨账号 | 预留位:后续用于跨账号互通,当前不挂工具 | — |
25
25
 
26
- **本版更新(0.5.0)**
27
-
28
- - **云端规则写进云端本身**:账号规则放云端大文件夹下 `/dsh/_规则-账号.md`,
29
- 同账号多设备规则放账号文件夹下 `/dsh/<账号>/_规则-设备.md`,
30
- 由插件在建目录时自动生成(已存在不覆盖),任何设备打开云盘就能看到这套规则。
31
- - 账号规则涵盖:目录约定、账号名来源、管理员与成员(第一个进云的设备是 `ownerDevice`)、跨账号预留;
32
- 设备规则涵盖:每台设备目录、`shared/<设备>/{inbox,done,files}` 收发、会话字节增量备份与
33
- `_session.json` 水位、中转站轮询与按收件人投递、冲突与并发。
34
- - `ensureLayout()` 返回值新增 `rulesVersion` / `rulesWritten`;缓存命中时也会补写缺失的规则文档,
35
- 升级后不用手动清 `layout.json`。
36
- - 探针 `probe-layout-rules.mjs` 23 项全 PASS。
26
+ **本版更新(0.6.1)**
27
+
28
+ - **修:旧布局缓存把新增的云端文档挡住了**。0.5.0 / 0.6.0 说「命中旧缓存会自动补写」,实际没补 ——
29
+ 云盘里根本没有 `_规则-账号.md`、`_规则-设备.md`、`inbox/_说明.md`。
30
+ - 根因:`ensureLayout()` 命中缓存时直接沿用 `cached.paths`,而路径是 `buildPaths()` **算出来的**;
31
+ 旧缓存没有 `accountRulesFile` / `deviceRulesFile` / `inboxReadmeFile` 三个字段,`ensureRules()` 拿到 `undefined`
32
+ 就跳过,还照样把 `rulesVersion` 标成最新。
33
+ - 修法:命中缓存时按 `home / account / deviceId` 重新算路径;`ensureRules()` 返回 `{ written, failed }`,
34
+ **只有 `failed === 0` 才把 `rulesVersion` 抬上去**,否则保持 0 下次继续重试。
35
+ - 探针 `probe-layout-stale-cache.mjs` 12 项全 PASS(旧缓存→重算→补写三份文档→写回新缓存;
36
+ 第二次不重复上传;上传失败时不冒充最新),回归 26 + 42 + 17 项全 PASS。
37
+ - 上一版(0.6.0)的内容仍在:共享收件箱里的 `_说明.md`、中转站 `isEnvelopeFile()` 只认 `.json` 信封。
37
38
 
38
39
  ## 一、安装与卸载
39
40
 
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.5.0'
25
+ export const VERSION = '0.6.1'
26
26
 
27
27
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
28
  export function dshHome() {
@@ -12,10 +12,11 @@ export const DEFAULT_HOME = 'dsh'
12
12
  export const ACCOUNT_FILE = '_account.json'
13
13
  export const DEVICE_FILE = '_device.json'
14
14
  export const INBOX_FILE = '_inbox.json'
15
- /** 规则文档(自动生成,手改会被覆盖):账号规则放大文件夹下,设备规则放账号文件夹下 */
15
+ /** 规则文档(自动生成,手改会被覆盖):账号规则放大文件夹下,设备规则放账号文件夹下,收件箱说明放共享收件箱里 */
16
16
  export const ACCOUNT_RULES_FILE = '_规则-账号.md'
17
17
  export const DEVICE_RULES_FILE = '_规则-设备.md'
18
- export const RULES_VERSION = 1
18
+ export const INBOX_README_FILE = '_说明.md'
19
+ export const RULES_VERSION = 2
19
20
  const CACHE_MS = 10 * 60 * 1000
20
21
 
21
22
  /** 目录名净化:去掉路径分隔符/控制字符/中文等非 ASCII(云盘接口对特殊字符不友好) */
@@ -48,6 +49,7 @@ export function buildPaths({ home = DEFAULT_HOME, account = 'default', deviceId
48
49
  inboxFile: `${deviceRoot}/${INBOX_FILE}`,
49
50
  accountRulesFile: `${base}/${ACCOUNT_RULES_FILE}`,
50
51
  deviceRulesFile: `${accountDir}/${DEVICE_RULES_FILE}`,
52
+ inboxReadmeFile: `${deviceRoot}/inbox/${INBOX_README_FILE}`,
51
53
  }
52
54
  }
53
55
 
@@ -190,6 +192,46 @@ export function deviceRulesMarkdown({ account = 'default', device = 'device', ro
190
192
  `
191
193
  }
192
194
 
195
+ /** 云端共享收件箱说明(放在 shared/<设备>/inbox/ 里) */
196
+ export function inboxReadmeMarkdown({ account = 'default', device = 'device', version = '', at = iso() } = {}) {
197
+ return `# 云端共享收件箱 · 说明
198
+
199
+ 本文件夹是设备 \`${device}\` 的**云端收件箱**:同账号下别的设备发给它的消息/文件先落在这里,
200
+ 再由这台设备的「云端中转站」定时取走,按**收件人(会话名)**投递到本机会话。
201
+
202
+ ## 谁写、谁取
203
+
204
+ - **写**:别的设备发信时写一个 \`<mailId>.json\` 信封(附件在 \`../files/<mailId>/\`)。
205
+ - **取**:本机中转站每 \`relayIntervalMs\`(默认 20 s)扫一次,投递成功后把信封搬去 \`../done/\`,再删这里的原件。
206
+
207
+ ## 信封格式
208
+
209
+ \`\`\`json
210
+ {
211
+ "id": "m-<时间戳>-<随机>",
212
+ "from": { "nodeId": "…", "name": "…" },
213
+ "to": "<会话名 或 完整 sessionId 或 8 位短号>",
214
+ "subject": "主题",
215
+ "text": "正文",
216
+ "files": [{ "name": "文件名", "size": 1234 }],
217
+ "createdAt": 1792385549000
218
+ }
219
+ \`\`\`
220
+
221
+ ## 注意
222
+
223
+ - **别手工删/改** \`*.json\` 信封:投递与否以本机状态为准,删掉就等于丢信。
224
+ - 以 \`_\` 开头的文件(本说明、\`_inbox.json\` 等)是系统文件,中转站会跳过,不当作信封。
225
+ - 收件人找不到(会话不存在)时信封**留在云端不消费**,不会丢;本机把会话打开/建好后下一轮自动投递。
226
+ - 附件按 \`<mailId>/<文件名>\` 存放;重名以云端最后写入为准。
227
+ - 想从别的设备发信:\`term_cloud action="send" toDevice="<设备>" to="<会话名>" text="…"\`。
228
+
229
+ ## 版本
230
+
231
+ 规则版本 v${RULES_VERSION} · 设备 \`${device}\` · 账号 \`${account}\` · 由插件${version ? ` ${version}` : ''} 于 ${at} 生成
232
+ `
233
+ }
234
+
193
235
  /** 只在缺失时写规则文档;失败只记日志,不影响目录建立 */
194
236
  export async function ensureRules(pan, paths, info = {}) {
195
237
  const { log = () => {}, account = 'default', device = 'device', role = 'member', version = '' } = info
@@ -198,18 +240,24 @@ export async function ensureRules(pan, paths, info = {}) {
198
240
  [paths.accountRulesFile, accountRulesMarkdown({ account, device, version })],
199
241
  [paths.deviceRulesFile, deviceRulesMarkdown({ account, device, role, version })],
200
242
  ]
243
+ if (paths.inboxReadmeFile) docs.push([paths.inboxReadmeFile, inboxReadmeMarkdown({ account, device, version })])
201
244
  const written = []
245
+ let failed = 0
202
246
  for (const [remote, text] of docs) {
203
247
  try {
248
+ if (!remote) { failed += 1; continue }
204
249
  if (await pan.exists(remote)) continue
205
250
  await pan.putBuffer(Buffer.from(text, 'utf8'), remote)
206
251
  written.push(remote)
207
252
  log(`云端规则已写入:${remote}`)
208
253
  } catch (e) {
254
+ failed += 1
209
255
  log(`写云端规则失败 ${remote}:${(e && e.message) || String(e)}`)
210
256
  }
211
257
  }
212
- return { written }
258
+ // 收件箱说明算一份必须存在的文档:缺少它的旧路径缓存不能冒充「规则已是最新」
259
+ if (!paths.inboxReadmeFile) failed += 1
260
+ return { written, failed }
213
261
  }
214
262
 
215
263
  /**
@@ -220,18 +268,29 @@ export async function ensureLayout({ pan, cfg = {}, device = {}, cacheFile, forc
220
268
  const nodeId = sanitize(device.nodeId || 'device')
221
269
  const cached = readCache(cacheFile)
222
270
  if (!force && cached && cached.deviceId === nodeId && Date.now() - Number(cached.at || 0) < CACHE_MS && cached.paths) {
271
+ // 路径是「算出来的」而不是状态:升级后新增的路径(规则文档、收件箱说明)必须重新算,
272
+ // 直接沿用旧缓存的 paths 会让 ensureRules 拿到 undefined、规则永远写不出去。
273
+ const paths = buildPaths({
274
+ home: cached.home || cfg.cloudHome || DEFAULT_HOME,
275
+ account: cached.account || cached.paths.account || 'default',
276
+ deviceId: nodeId,
277
+ })
278
+ const state = { ...cached, home: cached.home || cfg.cloudHome || DEFAULT_HOME, paths }
223
279
  if (Number(cached.rulesVersion || 0) < RULES_VERSION) {
224
- await ensureRules(pan, cached.paths, {
280
+ const r = await ensureRules(pan, paths, {
225
281
  log,
226
- account: cached.account,
282
+ account: state.account,
227
283
  device: nodeId,
228
- role: cached.role,
284
+ role: state.role,
229
285
  version: device.version || '',
230
286
  })
231
- cached.rulesVersion = RULES_VERSION
232
- writeCache(cacheFile, cached)
287
+ if (!r.failed) {
288
+ state.rulesVersion = RULES_VERSION
289
+ state.rulesWritten = r.written
290
+ writeCache(cacheFile, state)
291
+ }
233
292
  }
234
- return { ...cached, cached: true, ensuredAt: cached.at }
293
+ return { ...state, cached: true, ensuredAt: cached.at }
235
294
  }
236
295
 
237
296
  const who = await resolveAccount(pan, cfg, log)
@@ -315,9 +374,12 @@ export async function ensureLayout({ pan, cfg = {}, device = {}, cacheFile, forc
315
374
  deviceId: nodeId,
316
375
  account: who.account,
317
376
  accountSource: who.source,
377
+ home: cfg.cloudHome || DEFAULT_HOME,
318
378
  role,
319
- rulesVersion: RULES_VERSION,
379
+ // 有文档没写成功就不要把版本号标上去,下次 ensure 还会重试
380
+ rulesVersion: rules.failed ? 0 : RULES_VERSION,
320
381
  rulesWritten: rules.written,
382
+ rulesFailed: rules.failed,
321
383
  paths,
322
384
  created,
323
385
  accountInfo,
@@ -28,6 +28,10 @@ function saveState(file, state) {
28
28
 
29
29
  const isTmp = (name) => /\.tmp$/.test(name) || name.startsWith('.')
30
30
 
31
+ /** 只处理信封 json:跳过 .tmp / 隐藏文件 / 以 _ 开头的系统说明(如 _说明.md、_inbox.json) */
32
+ export const isEnvelopeFile = (name) =>
33
+ /\.json$/i.test(name) && !isTmp(name) && !String(name).startsWith('_')
34
+
31
35
  function parseEnvelope(text) {
32
36
  const j = JSON.parse(text)
33
37
  const to = Array.isArray(j.to) ? j.to : j.to || j.toSession || j.recipient || ''
@@ -84,7 +88,7 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, log
84
88
  stats.at = Date.now()
85
89
  return { ...stats, error: `列收件箱失败 errno=${list.errno}` }
86
90
  }
87
- const files = (list.list || []).filter((f) => f.isdir === 0 && !isTmp(f.server_filename))
91
+ const files = (list.list || []).filter((f) => f.isdir === 0 && isEnvelopeFile(f.server_filename))
88
92
  stats.scanned = files.length
89
93
  stats.delivered = 0
90
94
  stats.failed = 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "DSH 跨终端互联:同一账号下的多个 DSH 终端/进程互发邮件与文件(主题内联、正文落盘,收件方自己读)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",