dsh-liuhen 0.1.2 → 0.1.3
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/README.md +37 -0
- package/lib/box.js +57 -22
- package/package.json +2 -2
- package/scripts/dedupe-vault.js +72 -0
- package/scripts/doctor.js +176 -0
- package/scripts/selfcheck.js +19 -0
package/README.md
CHANGED
|
@@ -91,12 +91,41 @@ dsh plugin --profile web add dsh-liuhen
|
|
|
91
91
|
- **打包不再调 `zip` 命令**:自己拼 zip 容器(`lib/zip.js`,只用 Node 自带的 `zlib`)。
|
|
92
92
|
0.1.1 在电脑上会报 `打包失败:spawnSync zip ENOENT`,根就在这儿。
|
|
93
93
|
- **面板「打开」不再调 `unzip -l`**:直接读 zip 的中央目录,中文文件名按 UTF-8 原样读回来。
|
|
94
|
+
- **软链/目录联接会被解开**:以前 `fs.cpSync` 原样重建软链,Windows 建软链要管理员权限,
|
|
95
|
+
于是 `.dsh/vault` 是个联接的机器会报 `EPERM: operation not permitted, symlink …`。
|
|
96
|
+
现在一律跟着链接拷真内容(0.1.3)。
|
|
94
97
|
- **共享目录按平台走**(`lib/platform.js`):手机 → `/storage/emulated/0/DSHBox`;
|
|
95
98
|
Windows → `%USERPROFILE%\Downloads`;macOS → `~/Downloads`。面板文案也跟着换,
|
|
96
99
|
电脑上不会再写「存到手机」。
|
|
97
100
|
- 打包是**可复现**的:同一天同样的数据,打两次 `sha256` 一样(目录条目固定时间戳,
|
|
98
101
|
脱敏后把 mtime 拨回去)——面板上那个 sha 才有核对的意义。
|
|
99
102
|
|
|
103
|
+
### 拿一台新机器,先体检
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
node scripts/doctor.js
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
只读、不碰真数据、30 秒跑完,逐项说清这台机器上哪一层不行:
|
|
110
|
+
|
|
111
|
+
- 十六个模块能不能 `require`
|
|
112
|
+
- 三层数据在哪、**是不是软链/目录联接**
|
|
113
|
+
- `zstd` 有没有(Node ≥ 22.15 才有,存档与会话回填要用)
|
|
114
|
+
- zip 能不能自己写自己读(中文名)
|
|
115
|
+
- 软链/联接能不能拷出真内容
|
|
116
|
+
- 包该放到哪、那个目录在不在
|
|
117
|
+
|
|
118
|
+
带 ❌ 的那几行就是病灶,整段贴出来即可。
|
|
119
|
+
|
|
120
|
+
**离线装(不发 npm 也能验)**:把 `dsh-liuhen-x.y.z.tgz` 解出来得到 `package/`:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
node package/scripts/doctor.js # 先体检,确认环境没问题
|
|
124
|
+
# 备份旧的,再把 package/ 整个覆盖到插件目录(一般在 <DSH_HOME>\profiles\web
|
|
125
|
+
ode_modules\dsh-liuhen)
|
|
126
|
+
# 然后重启 DSH
|
|
127
|
+
```
|
|
128
|
+
|
|
100
129
|
**在电脑上验一遍**(`lib/*.js` 改了必须重启一次 DSH,面板的 HMR 管不到服务端):
|
|
101
130
|
|
|
102
131
|
```bash
|
|
@@ -106,6 +135,14 @@ dsh plugin --profile web add dsh-liuhen
|
|
|
106
135
|
# 4. 自检(可选,Windows 上也能跑):node scripts/selfcheck.js → 82/82
|
|
107
136
|
```
|
|
108
137
|
|
|
138
|
+
## 0.1.3 · 电脑也能用(小更新,不是第二版)
|
|
139
|
+
|
|
140
|
+
- **修 Windows 上的 `EPERM … symlink`**:`$DSH_HOME/vault` 是软链 / 目录联接时,打包会在
|
|
141
|
+
「把数据拷进沙箱」这一步炸。根因是 `fs.cpSync` 会原样重建软链,而 Windows 建符号链接要
|
|
142
|
+
管理员权限或开发者模式。现在换成自己走目录树、解开链接拷真内容(`copyTree`),
|
|
143
|
+
顺带挡住了软链绕圈导致的无限递归。
|
|
144
|
+
- 自检仍 82/82;软链场景已单独验过(vault 是软链时,包里带的是真实文件内容)
|
|
145
|
+
|
|
109
146
|
## 0.1.2(小更新,不是第二版)
|
|
110
147
|
|
|
111
148
|
- **电脑分支**:打包与列清单不再依赖系统的 `zip` / `unzip` 命令(Windows 上两者都没有),
|
package/lib/box.js
CHANGED
|
@@ -186,6 +186,48 @@ function manifestBody(rebuildDate, where) {
|
|
|
186
186
|
].join('\n')
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* 把源目录拷进沙箱:跟着软链走、拷真内容。
|
|
191
|
+
*
|
|
192
|
+
* 为什么不用 fs.cpSync(2026-11-01 在 Windows 上踩到的):
|
|
193
|
+
* cpSync 遇到软链 / 目录联接会「原样重建一个软链」,而 Windows 创建符号链接
|
|
194
|
+
* 要管理员权限或开发者模式,没有就报
|
|
195
|
+
* EPERM: operation not permitted, symlink 'C:\Users\…\.dsh\vault' -> '…\.box-stage-…\vault'
|
|
196
|
+
* 整个打包直接废。小盒子要的是内容,不是链接本身,所以一律解开。
|
|
197
|
+
*
|
|
198
|
+
* mtime 手动带过去:zip 条目要写时间戳,不带的话每次拷贝都盖成"现在",
|
|
199
|
+
* 同一份数据打两次 sha256 就不一样了。
|
|
200
|
+
*/
|
|
201
|
+
function copyTree(from, to, keep, seen, depth) {
|
|
202
|
+
const visited = seen || new Set()
|
|
203
|
+
const level = depth || 0
|
|
204
|
+
if (level > 32) return
|
|
205
|
+
let stat
|
|
206
|
+
try { stat = fs.statSync(from) } catch { return } /* 跟随软链;断链就跳过 */
|
|
207
|
+
if (stat.isDirectory()) {
|
|
208
|
+
let real = from
|
|
209
|
+
try { real = fs.realpathSync(from) } catch {}
|
|
210
|
+
/* 软链绕回来会无限递归:撞见过的真实目录就停 */
|
|
211
|
+
if (visited.has(real)) return
|
|
212
|
+
visited.add(real)
|
|
213
|
+
try { fs.mkdirSync(to, { recursive: true, mode: 0o700 }) } catch { return }
|
|
214
|
+
let names = []
|
|
215
|
+
try { names = fs.readdirSync(from) } catch { return }
|
|
216
|
+
for (const name of names) {
|
|
217
|
+
const child = path.join(from, name)
|
|
218
|
+
if (keep && !keep(child)) continue
|
|
219
|
+
copyTree(child, path.join(to, name), keep, visited, level + 1)
|
|
220
|
+
}
|
|
221
|
+
try { fs.utimesSync(to, stat.atime, stat.mtime) } catch {}
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
if (!stat.isFile()) return
|
|
225
|
+
try {
|
|
226
|
+
fs.copyFileSync(from, to)
|
|
227
|
+
try { fs.utimesSync(to, stat.atime, stat.mtime) } catch {}
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
|
|
189
231
|
/**
|
|
190
232
|
* 打小盒子。
|
|
191
233
|
*
|
|
@@ -225,27 +267,19 @@ function exportBox(options = {}) {
|
|
|
225
267
|
*/
|
|
226
268
|
try { fs.rmSync(out, { force: true }) } catch {}
|
|
227
269
|
/* 三层都拷进沙箱;private/、备份、半成品一概不拷,连排除参数都省了。 */
|
|
228
|
-
for (const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (base.endsWith('.lock')) return false
|
|
242
|
-
if (/\.bak($|[-.])/.test(base)) return false
|
|
243
|
-
if (name === 'liuhen' && base === 'backfill.json') return false
|
|
244
|
-
if (base.startsWith('.box-stage-')) return false
|
|
245
|
-
/* 按日期补打:仓库只装那一天的原文 */
|
|
246
|
-
if (name === 'vault' && rebuildDate && base.endsWith('.jsonl')) return base === `${rebuildDate}.jsonl`
|
|
247
|
-
return true
|
|
248
|
-
}
|
|
270
|
+
for (const layer of present) {
|
|
271
|
+
copyTree(path.join(paths.home, layer), path.join(stage, layer), (src) => {
|
|
272
|
+
const base = path.basename(src)
|
|
273
|
+
if (layer === 'liuhen' && base === 'private') return false
|
|
274
|
+
if (base.endsWith('.tmp')) return false
|
|
275
|
+
/* 锁文件是运行时的临时物,不该进包 */
|
|
276
|
+
if (base.endsWith('.lock')) return false
|
|
277
|
+
if (/\.bak($|[-.])/.test(base)) return false
|
|
278
|
+
if (layer === 'liuhen' && base === 'backfill.json') return false
|
|
279
|
+
if (base.startsWith('.box-stage-')) return false
|
|
280
|
+
/* 按日期补打:仓库只装那一天的原文 */
|
|
281
|
+
if (layer === 'vault' && rebuildDate && base.endsWith('.jsonl')) return base === `${rebuildDate}.jsonl`
|
|
282
|
+
return true
|
|
249
283
|
})
|
|
250
284
|
}
|
|
251
285
|
/*
|
|
@@ -300,4 +334,5 @@ function exportBox(options = {}) {
|
|
|
300
334
|
return { ok: true, path: out, name: path.basename(out), bytes, sha256: sha, excluded: EXCLUDES, shared }
|
|
301
335
|
}
|
|
302
336
|
|
|
303
|
-
|
|
337
|
+
/* copyTree 给 scripts/doctor.js 用:体检要能在不碰真数据的前提下验「软链拷不拷得动」。 */
|
|
338
|
+
module.exports = { exportBox, boxPath, copyTree }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-liuhen",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "留痕 Trace — 可核对的 AI 记忆:索引、溯源、自动注入(默认关)、可选日记。",
|
|
5
5
|
"author": "xiaoman000",
|
|
6
6
|
"keywords": [
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"main": "lib/index.js",
|
|
18
18
|
"files": [
|
|
19
19
|
"lib",
|
|
20
|
-
"scripts
|
|
20
|
+
"scripts",
|
|
21
21
|
"client.js",
|
|
22
22
|
"cordis.patch.yml",
|
|
23
23
|
"README.md",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 仓库物理去重:把 vault/<日期>.jsonl 里重复的 (session, seq) 收成一条。
|
|
6
|
+
*
|
|
7
|
+
* 为什么要单独一个脚本:2026-10-26 加跨进程锁之前,两个进程同时回填会各写一遍,
|
|
8
|
+
* 同一个 DSH_HOME 里真出过重复行(这次 3 条)。
|
|
9
|
+
* 读取侧已经按 session:seq 去重了(看 readDay),这里只是把文件本身也收干净。
|
|
10
|
+
*
|
|
11
|
+
* 只动"同一条消息写了两份"这种重复:正文取先写的那份,批注全部保留。
|
|
12
|
+
* 不认识的坏行照丢(跟读取侧同一条规矩)。
|
|
13
|
+
*
|
|
14
|
+
* 用法:
|
|
15
|
+
* node scripts/dedupe-vault.js # 预演,只报数
|
|
16
|
+
* node scripts/dedupe-vault.js --write # 真写(先自己备份)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs')
|
|
20
|
+
const path = require('node:path')
|
|
21
|
+
|
|
22
|
+
const { paths } = require('../lib/paths')
|
|
23
|
+
const { isGoodRow } = require('../lib/rows')
|
|
24
|
+
|
|
25
|
+
const WRITE = process.argv.includes('--write')
|
|
26
|
+
|
|
27
|
+
function dayFiles() {
|
|
28
|
+
const dir = path.join(paths.home, 'vault')
|
|
29
|
+
try {
|
|
30
|
+
return fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl')).sort()
|
|
31
|
+
} catch { return [] }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dedupeDay(file) {
|
|
35
|
+
const raw = fs.readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '')
|
|
36
|
+
const marks = []
|
|
37
|
+
const order = []
|
|
38
|
+
const byKey = new Map()
|
|
39
|
+
let dropped = 0
|
|
40
|
+
let bad = 0
|
|
41
|
+
for (const line of raw) {
|
|
42
|
+
let row
|
|
43
|
+
try { row = JSON.parse(line) } catch { bad += 1; continue }
|
|
44
|
+
if (row && row.m === 'mark') { marks.push(line); continue }
|
|
45
|
+
if (!isGoodRow(row)) { bad += 1; continue }
|
|
46
|
+
const key = `${row.s}:${row.n}`
|
|
47
|
+
const kept = byKey.get(key)
|
|
48
|
+
if (kept) { dropped += 1; continue }
|
|
49
|
+
byKey.set(key, line)
|
|
50
|
+
order.push(line)
|
|
51
|
+
}
|
|
52
|
+
return { lines: order.concat(marks), rows: order.length, marks: marks.length, dropped, bad }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let totalDropped = 0
|
|
56
|
+
let totalBad = 0
|
|
57
|
+
for (const name of dayFiles()) {
|
|
58
|
+
const file = path.join(paths.home, 'vault', name)
|
|
59
|
+
const result = dedupeDay(file)
|
|
60
|
+
totalDropped += result.dropped
|
|
61
|
+
totalBad += result.bad
|
|
62
|
+
if (result.dropped > 0 || result.bad > 0) {
|
|
63
|
+
console.log(`${name}:${result.rows} 条 + ${result.marks} 条批注,重复 ${result.dropped},坏行 ${result.bad}`)
|
|
64
|
+
if (WRITE) {
|
|
65
|
+
const tmp = `${file}.tmp`
|
|
66
|
+
fs.writeFileSync(tmp, result.lines.length ? `${result.lines.join('\n')}\n` : '', { mode: 0o600 })
|
|
67
|
+
fs.renameSync(tmp, file)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (totalDropped === 0 && totalBad === 0) console.log('仓库很干净:没有重复行,也没有坏行。')
|
|
72
|
+
else console.log(WRITE ? `已写回:去掉 ${totalDropped} 条重复、${totalBad} 条坏行。` : `预演:会去掉 ${totalDropped} 条重复、${totalBad} 条坏行(加 --write 才真写)。`)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 留痕 · 体检(doctor)——只读,不碰真数据,专门用来回答「这台机器上到底哪个模块不行」。
|
|
6
|
+
*
|
|
7
|
+
* 自检(selfcheck)是往沙箱里拷一份数据跑全部 82 条;体检是轻量的、只看这台机器的
|
|
8
|
+
* 环境与三条关键能力:模块能不能加载、三层数据在哪、打包/压缩/软链能不能用。
|
|
9
|
+
*
|
|
10
|
+
* 为什么要有它(2026-11-01):插件要在「电脑 + 手机」两种机器上都成立,
|
|
11
|
+
* 但我手里只有手机。电脑上出问题(zip 命令没有、vault 是联接、权限位是摆设),
|
|
12
|
+
* 光看面板一句红字分不清是哪一层坏的——所以给一台机器一份 30 秒能跑完的清单。
|
|
13
|
+
*
|
|
14
|
+
* 用法:node scripts/doctor.js (Windows / macOS / 手机都一样)
|
|
15
|
+
* 全程只读:唯一会写的是自己建的临时目录,跑完就删;不会打真包、不碰共享目录。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs')
|
|
19
|
+
const os = require('node:os')
|
|
20
|
+
const path = require('node:path')
|
|
21
|
+
const zlib = require('node:zlib')
|
|
22
|
+
|
|
23
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
24
|
+
const ok = (t) => `✅ ${t}`
|
|
25
|
+
const warn = (t) => `⚠️ ${t}`
|
|
26
|
+
const bad = (t) => `❌ ${t}`
|
|
27
|
+
|
|
28
|
+
let failures = 0
|
|
29
|
+
const lines = []
|
|
30
|
+
function say(text) { lines.push(text); console.log(text) }
|
|
31
|
+
function fail(text) { failures += 1; say(bad(text)) }
|
|
32
|
+
|
|
33
|
+
const { paths } = require(path.join(ROOT, 'lib', 'paths'))
|
|
34
|
+
|
|
35
|
+
say('留痕 · 体检')
|
|
36
|
+
say(`平台:${process.platform} · Node ${process.version} · ${os.arch()}`)
|
|
37
|
+
say(`DSH_HOME:${paths.home}`)
|
|
38
|
+
say('')
|
|
39
|
+
|
|
40
|
+
/* ── 一、模块能不能加载 ───────────────────────────── */
|
|
41
|
+
const MODULES = ['config', 'store', 'paths', 'redact', 'diary', 'notes', 'inject', 'tools',
|
|
42
|
+
'autoindex', 'box', 'index', 'retention', 'vault', 'feelbox', 'zip', 'platform']
|
|
43
|
+
say('一、模块')
|
|
44
|
+
for (const name of MODULES) {
|
|
45
|
+
try {
|
|
46
|
+
const mod = require(path.join(ROOT, 'lib', name))
|
|
47
|
+
const n = Object.keys(mod).length
|
|
48
|
+
if (!n) fail(`${name}.js 加载了但没导出东西`)
|
|
49
|
+
else say(` ${ok(name.padEnd(10))} ${n} 个导出`)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
fail(`${name}.js 加载失败:${error && error.message ? error.message : error}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
say('')
|
|
55
|
+
|
|
56
|
+
/* ── 二、三层数据在哪、是不是软链 ─────────────────── */
|
|
57
|
+
say('二、数据三层')
|
|
58
|
+
function describeDir(dir) {
|
|
59
|
+
let st
|
|
60
|
+
try { st = fs.lstatSync(dir) } catch { return null }
|
|
61
|
+
const link = st.isSymbolicLink()
|
|
62
|
+
let real = ''
|
|
63
|
+
if (link) { try { real = fs.readlinkSync(dir) } catch {} }
|
|
64
|
+
let files = 0
|
|
65
|
+
let bytes = 0
|
|
66
|
+
const walk = (d) => {
|
|
67
|
+
let items = []
|
|
68
|
+
try { items = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
|
|
69
|
+
for (const it of items) {
|
|
70
|
+
const full = path.join(d, it.name)
|
|
71
|
+
let s2 = null
|
|
72
|
+
try { s2 = fs.statSync(full) } catch {}
|
|
73
|
+
if (!s2) continue
|
|
74
|
+
if (s2.isDirectory()) { walk(full); continue }
|
|
75
|
+
files += 1
|
|
76
|
+
bytes += s2.size
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
walk(dir)
|
|
80
|
+
return { link, real, files, bytes }
|
|
81
|
+
}
|
|
82
|
+
let smallest = null
|
|
83
|
+
for (const name of ['pet-memory', 'liuhen', 'vault']) {
|
|
84
|
+
const dir = path.join(paths.home, name)
|
|
85
|
+
const info = describeDir(dir)
|
|
86
|
+
if (info === null) {
|
|
87
|
+
/* 三层缺一层还算能用(新装会缺),只是提醒 */
|
|
88
|
+
say(` ${warn(name.padEnd(10))} 没有这个目录(新装通常如此)`)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
const kb = `${(info.bytes / 1024).toFixed(0)} KB`
|
|
92
|
+
if (info.link) say(` ${ok(name.padEnd(10))} 软链/联接 → ${info.real} · ${info.files} 个文件 · ${kb}`)
|
|
93
|
+
else say(` ${ok(name.padEnd(10))} ${info.files} 个文件 · ${kb}`)
|
|
94
|
+
if (smallest === null || info.bytes < smallest.bytes) smallest = { name, dir, bytes: info.bytes }
|
|
95
|
+
}
|
|
96
|
+
say('')
|
|
97
|
+
|
|
98
|
+
/* ── 三、三条关键能力 ─────────────────────────────── */
|
|
99
|
+
say('三、能力')
|
|
100
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'liuhen-doctor-'))
|
|
101
|
+
try {
|
|
102
|
+
/* 1. 压缩库:存档/回填要用 zstd,Node 太老就没有 */
|
|
103
|
+
if (typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function') {
|
|
104
|
+
say(` ${ok('zstd'.padEnd(10))} node:zlib 自带(存档、会话回填要用)`)
|
|
105
|
+
} else {
|
|
106
|
+
fail(`zstd 不可用:这个 Node(${process.version})里没有 zlib.zstd*,需要 Node ≥ 22.15`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* 2. zip 往返:自己写的 writer/reader,中文名不能乱 */
|
|
110
|
+
try {
|
|
111
|
+
const { writeZip, listZip, readEntry } = require(path.join(ROOT, 'lib', 'zip'))
|
|
112
|
+
const z = path.join(tmp, 'roundtrip.zip')
|
|
113
|
+
const body = '中文名 · 往返测试\n'
|
|
114
|
+
writeZip(z, [{ name: '中文目录/读我.txt', data: body }, { name: 'liuhen/', data: Buffer.alloc(0), dir: true }])
|
|
115
|
+
const names = listZip(z).map((e) => e.name)
|
|
116
|
+
const back = readEntry(z, '中文目录/读我.txt').toString('utf8')
|
|
117
|
+
if (!names.includes('中文目录/读我.txt') || back !== body) fail('zip 往返不对(中文名或内容)')
|
|
118
|
+
else say(` ${ok('zip'.padEnd(10))} 自己读写正常,中文名不乱码(${names.length} 条)`)
|
|
119
|
+
} catch (error) {
|
|
120
|
+
fail(`zip 读写失败:${error && error.message ? error.message : error}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* 3. 软链/联接能不能拷:Windows 上 vault 常常是联接,拷不动就整包废 */
|
|
124
|
+
if (smallest === null) {
|
|
125
|
+
say(` ${warn('软链'.padEnd(10))} 没有数据目录,跳过`)
|
|
126
|
+
} else {
|
|
127
|
+
try {
|
|
128
|
+
const { copyTree } = require(path.join(ROOT, 'lib', 'box'))
|
|
129
|
+
const dest = path.join(tmp, 'copytree')
|
|
130
|
+
copyTree(smallest.dir, dest, null)
|
|
131
|
+
let copied = 0
|
|
132
|
+
const count = (d) => {
|
|
133
|
+
let items = []
|
|
134
|
+
try { items = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
|
|
135
|
+
for (const it of items) {
|
|
136
|
+
const full = path.join(d, it.name)
|
|
137
|
+
let s2 = null
|
|
138
|
+
try { s2 = fs.statSync(full) } catch {}
|
|
139
|
+
if (!s2) continue
|
|
140
|
+
if (s2.isDirectory()) count(full)
|
|
141
|
+
else copied += 1
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
count(dest)
|
|
145
|
+
const origin = describeDir(smallest.dir)
|
|
146
|
+
if (copied === 0) fail(`软链拷出来是空的(${smallest.name})`)
|
|
147
|
+
else if (origin && copied !== origin.files) fail(`软链拷出来少了:${copied}/${origin.files}`)
|
|
148
|
+
else say(` ${ok('软链'.padEnd(10))} ${smallest.name} 解开拷成功(${copied} 个文件)`)
|
|
149
|
+
} catch (error) {
|
|
150
|
+
fail(`软链拷不动:${error && error.message ? error.message : error}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ── 四、这台机器上包该放哪 ───────────────────────── */
|
|
158
|
+
say('')
|
|
159
|
+
say('四、共享目录')
|
|
160
|
+
try {
|
|
161
|
+
const platform = require(path.join(ROOT, 'lib', 'platform'))
|
|
162
|
+
const where = platform.describe()
|
|
163
|
+
say(` ${ok('位置'.padEnd(10))} ${where.label}`)
|
|
164
|
+
say(` ${where.exists ? ok('存在'.padEnd(10)) : warn('不存在'.padEnd(10))} ${where.exists ? '可以往里放一份' : '打包后会留在容器里,用面板「保存」取走'}`)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
fail(`平台判断失败:${error && error.message ? error.message : error}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ── 结论 ─────────────────────────────────────────── */
|
|
170
|
+
say('')
|
|
171
|
+
if (failures === 0) {
|
|
172
|
+
say('结论:这台机器上留痕的模块都能起来,打包/压缩/软链三条路都通。')
|
|
173
|
+
process.exit(0)
|
|
174
|
+
}
|
|
175
|
+
say(`结论:${failures} 处不行,上面带 ❌ 的就是。把这份输出整段贴给作者。`)
|
|
176
|
+
process.exit(1)
|
package/scripts/selfcheck.js
CHANGED
|
@@ -278,6 +278,11 @@ check('感受待播:当天留着、10 分钟内不重复、跨天自动清', (
|
|
|
278
278
|
assert(nextDay.length === 0, '昨天的还在念')
|
|
279
279
|
assert(box.pending().length === 0, '跨天没清')
|
|
280
280
|
assert(box.read().delivered.some((x) => x.purged), '清掉的那条没留底')
|
|
281
|
+
/* Windows 上 mode 是摆设(NTFS 不给 POSIX 权限位),这条只验"文件在、别的没被破坏"。 */
|
|
282
|
+
if (process.platform === 'win32') {
|
|
283
|
+
try { fs.rmSync(file, { force: true }) } catch {}
|
|
284
|
+
return '当天留着 · 10 分钟节流 · 跨天清(Windows 不验 600,跳过)'
|
|
285
|
+
}
|
|
281
286
|
const mode = fs.statSync(file).mode & 0o777
|
|
282
287
|
assert(mode === 0o600, `权限是 ${mode.toString(8)},该 600`)
|
|
283
288
|
try { fs.rmSync(file, { force: true }) } catch {}
|
|
@@ -848,6 +853,11 @@ check('目录权限:新建的目录一律 700', () => {
|
|
|
848
853
|
const { ensureDir } = require('../lib/paths')
|
|
849
854
|
const dir = path.join(sandbox, 'perm-check', 'a', 'b')
|
|
850
855
|
ensureDir(dir)
|
|
856
|
+
/*
|
|
857
|
+
* Windows 的 chmod 是空操作,stat 回来的是 666/777 这类默认位——
|
|
858
|
+
* 在那儿断言 700 只会造出一条假失败,把真问题埋了。
|
|
859
|
+
*/
|
|
860
|
+
if (process.platform === 'win32') return 'Windows 不验权限位(跳过)'
|
|
851
861
|
const mode = fs.statSync(dir).mode & 0o777
|
|
852
862
|
assert(mode === 0o700, `权限是 ${mode.toString(8)},该 700`)
|
|
853
863
|
return '700'
|
|
@@ -1002,6 +1012,15 @@ check('client.js 语法', () => {
|
|
|
1002
1012
|
return `${(source.length / 1024).toFixed(1)} KB,语法通过`
|
|
1003
1013
|
})
|
|
1004
1014
|
|
|
1015
|
+
check('scripts/doctor.js 存在且语法通过', () => {
|
|
1016
|
+
const file = path.join(ROOT, 'scripts', 'doctor.js')
|
|
1017
|
+
assert(fs.existsSync(file), '体检脚本不在了——电脑上就没法自报家门')
|
|
1018
|
+
/* 去掉 #! 再验语法:Function 不认 shebang。只验语法,不执行——doctor 会读真数据。 */
|
|
1019
|
+
const source = fs.readFileSync(file, 'utf8').replace(/^#![^\n]*\n/, '')
|
|
1020
|
+
new Function(source)
|
|
1021
|
+
return `${(source.length / 1024).toFixed(1)} KB`
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1005
1024
|
/* ── 汇总(等异步的那几条也落表)──────────────────── */
|
|
1006
1025
|
Promise.all(pending).then(() => {
|
|
1007
1026
|
console.log('留痕自检 · 沙箱 ' + sandbox)
|