dsh-crossterm 0.4.1 → 0.6.0

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,38 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.6.0 — 2026-10-19
4
+
5
+ ### 共享收件箱里也放一份说明
6
+ - 新常量 `INBOX_README_FILE = '_说明.md'`、`RULES_VERSION = 2`(`lib/cloud/layout.js`),
7
+ `buildPaths()` 增 `inboxReadmeFile`(`<账号>/shared/<设备>/inbox/_说明.md`);
8
+ 新增 `inboxReadmeMarkdown()`,`ensureRules()` 一并写入(只在缺失时写,不覆盖)。
9
+ - 说明内容:这个文件夹是什么、谁写谁取(别的设备写信封、本机中转站每 `relayIntervalMs` 取)、
10
+ 信封 JSON 格式(`id/from/to/subject/text/files/createdAt`)、注意事项
11
+ (别手工删改信封、`_` 开头的都是系统文件会被跳过、收件人找不到就留件不消费、附件按 `<mailId>/<文件名>` 存)。
12
+ - `lib/cloud/relay.js` 新增并导出 `isEnvelopeFile(name)`:只把 **`.json` 且不以 `_` 开头、非 `.tmp`/隐藏文件**
13
+ 当信封;`poll()` 改用它过滤 —— 否则 `_说明.md` 会被当成信封反复解析失败、记 failed。
14
+ - 探针:`probe-layout-rules.mjs` 扩到 **26 项全 PASS**(含三份文档位置与收件箱说明内容);
15
+ 新增 `probe-inbox-readme.mjs` **17 项全 PASS**(说明不被当信封、正常信封仍投递并归档到 `done/`、
16
+ 失败留件、第二轮不重复投递);`probe-cloud-home.mjs` 断言改为「收件箱只剩系统说明」后 **42 项全 PASS**。
17
+
18
+ ## 0.5.0 — 2026-10-19
19
+
20
+ ### 云端规则写进云端本身:账号规则放在大文件夹下,设备规则放在账号文件夹下
21
+ - 新常量 `ACCOUNT_RULES_FILE = '_规则-账号.md'`、`DEVICE_RULES_FILE = '_规则-设备.md'`、`RULES_VERSION = 1`
22
+ (`lib/cloud/layout.js`),`buildPaths()` 多出 `accountRulesFile`(`<大文件夹>/_规则-账号.md`)与
23
+ `deviceRulesFile`(`<大文件夹>/<账号>/_规则-设备.md`)两个路径。
24
+ - 新增 `accountRulesMarkdown()` / `deviceRulesMarkdown()` / `ensureRules()`:
25
+ - **账号规则**(放 `/dsh/_规则-账号.md`):这个文件夹是什么、目录约定、账号名来源、
26
+ 管理员与成员(第一个进云的设备写 `_account.json` 的 `ownerDevice`,其余为成员)、跨账号预留。
27
+ - **设备规则**(放 `/dsh/<账号>/_规则-设备.md`):同账号多设备共享这块空间、每台设备自己的
28
+ `sessions/` `attachments/` `_device.json`、`shared/<设备>/{inbox,done,files}` 收发约定、
29
+ 会话字节增量备份与 `_session.json` 水位、中转站轮询与按收件人投递、冲突与并发。
30
+ - `ensureLayout()` 在建完目录后调用 `ensureRules()`(**只在缺失时写**,已存在不覆盖,改动只剩日志),
31
+ 返回值新增 `rulesVersion` / `rulesWritten`;缓存文件也记 `rulesVersion`,老缓存命中时会补写规则文档
32
+ (升级后无需手动清理 `layout.json`)。
33
+ - 探针 `F:\dsh\001X\crossterm-work\probe-layout-rules.mjs`:23 项全 PASS
34
+ (两份文档落在正确位置与内容、缓存命中不重写、旧缓存补写、force 不覆盖、新账号独立生成)。
35
+
3
36
  ## 0.4.1 — 2026-10-19
4
37
 
5
38
  ### 发版纪律:简介与本版更新随包一起走
package/README.md CHANGED
@@ -23,12 +23,15 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
23
23
  | 跨设备 | 经云端总线,在多台机器的 DSH 之间互发邮件与文件 | `term_cloud` |
24
24
  | 跨账号 | 预留位:后续用于跨账号互通,当前不挂工具 | — |
25
25
 
26
- **本版更新(0.4.1)**
27
-
28
- - 发版纪律落地:**每个版本随包附带简介与本版更新**(`README.md` 这一节 + `CHANGELOG.md` + `更新说明-<版本>.md`),
29
- npm 包、B 机 zip、云盘 `setup/` 三处一起上传,装到哪台机器都能看到这一版改了什么。
30
- - `package.json` 的 `files` 补 `CHANGELOG.md`,npm 包里从此能看到完整变更记录。
31
- - 功能与 0.4.0 完全一致,无行为变化。
26
+ **本版更新(0.6.0)**
27
+
28
+ - **共享收件箱里也带一份说明**:`/dsh/<账号>/shared/<设备>/inbox/_说明.md` 由插件自动生成,
29
+ 打开云盘就知道这个收件箱是干什么的 —— 谁写谁取、信封 JSON 格式、`_` 开头的系统文件会被跳过、
30
+ 别手工删改信封、收件人找不到就留件不消费、附件怎么存。
31
+ - `lib/cloud/relay.js` 新增 `isEnvelopeFile()`:只把 `.json` 且不以 `_` 开头、非 `.tmp`/隐藏文件当信封,
32
+ 所以说明文件不会被反复解析失败、也不影响正常投递与 `done/` 归档。
33
+ - `RULES_VERSION` 升到 2,升级后命中旧缓存会自动补写这份新说明。
34
+ - 探针:`probe-layout-rules.mjs` 26 项、`probe-inbox-readme.mjs` 17 项、`probe-cloud-home.mjs` 42 项全 PASS。
32
35
 
33
36
  ## 一、安装与卸载
34
37
 
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.4.1'
25
+ export const VERSION = '0.6.0'
26
26
 
27
27
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
28
  export function dshHome() {
@@ -12,6 +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
+ /** 规则文档(自动生成,手改会被覆盖):账号规则放大文件夹下,设备规则放账号文件夹下,收件箱说明放共享收件箱里 */
16
+ export const ACCOUNT_RULES_FILE = '_规则-账号.md'
17
+ export const DEVICE_RULES_FILE = '_规则-设备.md'
18
+ export const INBOX_README_FILE = '_说明.md'
19
+ export const RULES_VERSION = 2
15
20
  const CACHE_MS = 10 * 60 * 1000
16
21
 
17
22
  /** 目录名净化:去掉路径分隔符/控制字符/中文等非 ASCII(云盘接口对特殊字符不友好) */
@@ -42,6 +47,9 @@ export function buildPaths({ home = DEFAULT_HOME, account = 'default', deviceId
42
47
  accountFile: `${accountDir}/${ACCOUNT_FILE}`,
43
48
  deviceFile: `${deviceDir}/${DEVICE_FILE}`,
44
49
  inboxFile: `${deviceRoot}/${INBOX_FILE}`,
50
+ accountRulesFile: `${base}/${ACCOUNT_RULES_FILE}`,
51
+ deviceRulesFile: `${accountDir}/${DEVICE_RULES_FILE}`,
52
+ inboxReadmeFile: `${deviceRoot}/inbox/${INBOX_README_FILE}`,
45
53
  }
46
54
  }
47
55
 
@@ -89,6 +97,164 @@ async function readJson(pan, remote, log) {
89
97
 
90
98
  const iso = (ts = Date.now()) => new Date(ts).toISOString()
91
99
 
100
+ /** 账号规则(放在云端大文件夹下) */
101
+ export function accountRulesMarkdown({ account = 'default', device = 'device', version = '', at = iso() } = {}) {
102
+ return `# dsh 云端互通 · 账号规则
103
+
104
+ 本文件由 dsh-crossterm 自动维护(\`_\` 开头的都是系统文件,手改会被覆盖,删掉会被重建)。
105
+
106
+ ## 这个文件夹是什么
107
+
108
+ 云端大文件夹 \`dsh\` 是所有 DSH 设备互通数据的根。**一个 dsh 账号 = 这里的一个子文件夹**,账号之间互不可见。
109
+
110
+ ## 目录约定
111
+
112
+ \`\`\`
113
+ /dsh/ 根(本文件所在)
114
+ /dsh/<账号>/ 一个 dsh 账号一个文件夹
115
+ /dsh/<账号>/_规则-设备.md 同账号多设备规则(自动生成)
116
+ /dsh/<账号>/_account.json 账号信息:ownerDevice 即管理员设备
117
+ /dsh/<账号>/shared/ 云端设备共享文件夹(跨设备收发都走这里)
118
+ /dsh/<账号>/<设备>/ 每台设备自己的目录(会话备份、附件)
119
+ \`\`\`
120
+
121
+ ## 账号名从哪来
122
+
123
+ 1. 插件配置 \`cloudAccount\`(手填优先)
124
+ 2. 环境变量 \`DSH_ACCOUNT\`
125
+ 3. 云盘账号昵称 / uk
126
+ 4. 兜底 \`default\`
127
+
128
+ ## 管理员与成员
129
+
130
+ - 同账号里**第一个**建出 \`_account.json\` 的设备成为**管理员**(\`role: admin\`),之后进来的设备是**成员**(\`role: member\`)。
131
+ - 判据就是 \`_account.json\` 的 \`ownerDevice\`;换管理员 = 改这个字段,或删掉该文件让先到者重新占位。
132
+ - 成员只写自己的设备目录和 \`shared/<自己>\`;共享区里其他设备的目录只读。
133
+
134
+ ## 预留:跨账号
135
+
136
+ 同云端、不同账号之间的互通(跨账号投递/共享)尚未开放。目录上每个账号已是独立命名空间,将来只加通道,不改结构。
137
+
138
+ ## 版本
139
+
140
+ 规则版本 v${RULES_VERSION} · 当前账号 \`${account}\` · 由设备 \`${device}\` 于 ${at} 生成${version ? ` · 插件 ${version}` : ''}
141
+ `
142
+ }
143
+
144
+ /** 同账号多设备规则(放在账号文件夹下) */
145
+ export function deviceRulesMarkdown({ account = 'default', device = 'device', role = 'member', version = '', at = iso() } = {}) {
146
+ return `# dsh 云端互通 · 同账号多设备规则
147
+
148
+ 本文件由 dsh-crossterm 自动维护(\`_\` 开头为系统文件,手改会被覆盖)。
149
+
150
+ ## 谁在共享
151
+
152
+ 账号 \`${account}\` 下的所有设备共享这块云端空间。这台设备 \`${device}\` 的角色是 **${role}**。
153
+
154
+ ## 每台设备自己的目录
155
+
156
+ \`\`\`
157
+ /dsh/${account}/<设备>/sessions/ 会话转录分片 + _session.json 水位标注
158
+ /dsh/${account}/<设备>/attachments/ 附件(文件名 = sha256,天然去重)
159
+ /dsh/${account}/<设备>/_device.json 设备信息(名字、角色、版本、加入时间、最后心跳)
160
+ \`\`\`
161
+
162
+ ## 共享区(跨设备收发)
163
+
164
+ \`\`\`
165
+ /dsh/${account}/shared/<设备>/inbox/ 该设备的收件箱(别的设备发来的信封)
166
+ /dsh/${account}/shared/<设备>/done/ 已投递归档
167
+ /dsh/${account}/shared/<设备>/files/ 信封附带的文件
168
+ \`\`\`
169
+
170
+ ## 会话备份规则
171
+
172
+ - 按**字节增量**:首次 \`full-<时间戳>.bin\`;之后只在变长时追加 \`part-NNNN.bin\`(只含新增段);变小则整份重传。
173
+ - 水位写在会话目录的 \`_session.json\`:\`{bytes, parts, chunks:[{name,from,to,sha256}], name, cwd}\`;
174
+ 下次从 \`bytes\` 往后继续,所以重装、换机都不会重传已完成部分。
175
+ - 每轮 \`backupSessionsPerRun\`(默认 20)个会话、\`backupAttachmentsPerRun\`(默认 20)个附件,没传完的下一轮继续。
176
+
177
+ ## 中转站规则
178
+
179
+ - 每台设备本地有一个名为「云端中转站」的会话(自动创建/复用)。
180
+ - 每 \`relayIntervalMs\`(默认 20 s)拉一次 \`shared/<自己>/inbox/\`:读信封 → 下载附件到本地 →
181
+ **按收件人(会话名)** 投递 → 成功后把信封 \`put\` 到 \`done/\` 再 \`rm\` 收件箱原件;失败留件并记 \`failed\`。
182
+ - 收件人可写会话显示名、完整 sessionId 或 8 位短号;找不到就留在云端不消费,不会丢信。
183
+
184
+ ## 冲突与并发
185
+
186
+ - 同名文件按最后写入覆盖;附件按 sha256 去重,不存在覆盖问题。
187
+ - 两台设备同时往 \`shared/<设备>/inbox/\` 写信时,文件名是 \`mailId\`(时间戳+随机),不会互相覆盖。
188
+
189
+ ## 版本
190
+
191
+ 规则版本 v${RULES_VERSION} · 账号 \`${account}\` · 由设备 \`${device}\` 于 ${at} 生成${version ? ` · 插件 ${version}` : ''}
192
+ `
193
+ }
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
+
235
+ /** 只在缺失时写规则文档;失败只记日志,不影响目录建立 */
236
+ export async function ensureRules(pan, paths, info = {}) {
237
+ const { log = () => {}, account = 'default', device = 'device', role = 'member', version = '' } = info
238
+ if (!paths || !paths.accountRulesFile || !paths.deviceRulesFile) return { written: [] }
239
+ const docs = [
240
+ [paths.accountRulesFile, accountRulesMarkdown({ account, device, version })],
241
+ [paths.deviceRulesFile, deviceRulesMarkdown({ account, device, role, version })],
242
+ ]
243
+ if (paths.inboxReadmeFile) docs.push([paths.inboxReadmeFile, inboxReadmeMarkdown({ account, device, version })])
244
+ const written = []
245
+ for (const [remote, text] of docs) {
246
+ try {
247
+ if (await pan.exists(remote)) continue
248
+ await pan.putBuffer(Buffer.from(text, 'utf8'), remote)
249
+ written.push(remote)
250
+ log(`云端规则已写入:${remote}`)
251
+ } catch (e) {
252
+ log(`写云端规则失败 ${remote}:${(e && e.message) || String(e)}`)
253
+ }
254
+ }
255
+ return { written }
256
+ }
257
+
92
258
  /**
93
259
  * 建立/校正云端目录结构,返回 { account, role, paths, created, accountInfo, deviceInfo }
94
260
  * @param {{pan: object, cfg: object, device: {nodeId: string, name?: string, version?: string, platform?: string, sessions?: number}, cacheFile?: string, force?: boolean, log?: Function}} opts
@@ -97,6 +263,17 @@ export async function ensureLayout({ pan, cfg = {}, device = {}, cacheFile, forc
97
263
  const nodeId = sanitize(device.nodeId || 'device')
98
264
  const cached = readCache(cacheFile)
99
265
  if (!force && cached && cached.deviceId === nodeId && Date.now() - Number(cached.at || 0) < CACHE_MS && cached.paths) {
266
+ if (Number(cached.rulesVersion || 0) < RULES_VERSION) {
267
+ await ensureRules(pan, cached.paths, {
268
+ log,
269
+ account: cached.account,
270
+ device: nodeId,
271
+ role: cached.role,
272
+ version: device.version || '',
273
+ })
274
+ cached.rulesVersion = RULES_VERSION
275
+ writeCache(cacheFile, cached)
276
+ }
100
277
  return { ...cached, cached: true, ensuredAt: cached.at }
101
278
  }
102
279
 
@@ -167,6 +344,14 @@ export async function ensureLayout({ pan, cfg = {}, device = {}, cacheFile, forc
167
344
  }
168
345
  await pan.putBuffer(Buffer.from(JSON.stringify(inboxInfo, null, 2), 'utf8'), paths.inboxFile)
169
346
 
347
+ const rules = await ensureRules(pan, paths, {
348
+ log,
349
+ account: who.account,
350
+ device: nodeId,
351
+ role,
352
+ version: device.version || '',
353
+ })
354
+
170
355
  const out = {
171
356
  at: Date.now(),
172
357
  ensuredAt: iso(),
@@ -174,6 +359,8 @@ export async function ensureLayout({ pan, cfg = {}, device = {}, cacheFile, forc
174
359
  account: who.account,
175
360
  accountSource: who.source,
176
361
  role,
362
+ rulesVersion: RULES_VERSION,
363
+ rulesWritten: rules.written,
177
364
  paths,
178
365
  created,
179
366
  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.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "DSH 跨终端互联:同一账号下的多个 DSH 终端/进程互发邮件与文件(主题内联、正文落盘,收件方自己读)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",