@kira905/dsh-diagnostics-extra 0.0.0-stage → 0.1.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 +54 -0
- package/LICENSE +661 -0
- package/README.en.md +269 -0
- package/README.md +276 -2
- package/attachments-audit.mjs +333 -0
- package/attachments-fetch.mjs +507 -0
- package/dsh-why.mjs +499 -0
- package/lib/attach-refs.mjs +224 -0
- package/lib/cli-args.mjs +183 -0
- package/lib/dsh-home.mjs +95 -0
- package/lib/generated-docs.mjs +397 -0
- package/lib/powershell-exe.mjs +188 -0
- package/lib/redact.mjs +157 -0
- package/lib/session-file-name.mjs +160 -0
- package/owner-todo-scan.mjs +441 -0
- package/package.json +45 -4
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ============================================================
|
|
3
|
+
// attachments-fetch.mjs — 图片附件对象 · 按需取回 / 导出(卡 AT1 阶段 2 / 方案 A)
|
|
4
|
+
// ------------------------------------------------------------
|
|
5
|
+
// 场景:DSH 的图片实体只存 `<DSH_HOME>/attachments/v1/objects/<sha前2位>/<sha256>`,
|
|
6
|
+
// 跨机同步(仓1/仓2)**不搬 attachments** ⇒ 同步过来的含图会话在本机续聊必报
|
|
7
|
+
// TRANSPORT(根因链见 attachments-audit.mjs 头部注释)。本脚本按 sha256 取回缺失对象。
|
|
8
|
+
//
|
|
9
|
+
// 用法:
|
|
10
|
+
// # 取回(缺什么补什么)
|
|
11
|
+
// node attachments-fetch.mjs --all-missing [--from <对端 .dsh\attachments 路径>] --verify
|
|
12
|
+
// node attachments-fetch.mjs --session <会话id|前缀>
|
|
13
|
+
// node attachments-fetch.mjs --hash <sha256> [--hash ...]
|
|
14
|
+
// node attachments-fetch.mjs --probe # 只列可用取回源与覆盖率(只读)
|
|
15
|
+
// node attachments-fetch.mjs --export <目录> # 反向:把本机会话引用的附件导出成中转包
|
|
16
|
+
// node attachments-fetch.mjs --rollback <报告.json> # 按报告删除本次新写文件
|
|
17
|
+
//
|
|
18
|
+
// 取回源(按优先级;命中即用)
|
|
19
|
+
// 1) --from <path> 可多个:对端 attachments 根 / v1 / objects / 任意目录(自动识别层级)
|
|
20
|
+
// 2) --relay <dir> 中转目录(`<dir>/objects/<2>/<sha>` 或 `<dir>/<2>/<sha>`);
|
|
21
|
+
// 未显式给时自动探测 $DSH_ATTACH_RELAY、<HOME>\dsh-attachment-relay、
|
|
22
|
+
// <HOME>\dsh-attachments\relay、<workspace>\_tmp\attachments-relay
|
|
23
|
+
// 3) 本机派生源(零跨机,自动):<home>/dsh-imagegen/{images,gallery/images}、<workspace>/generated
|
|
24
|
+
// —— 按**内容 sha256** 命中即取(实测:64 个缺失里 6 个可自愈)
|
|
25
|
+
//
|
|
26
|
+
// 落盘铁律
|
|
27
|
+
// · 目标路径 = `<home>/attachments/v1/objects/<sha前2位>/<sha256>`(与 DSH 完全同布局)
|
|
28
|
+
// · 写同目录临时文件 → 校验 sha256 → rename 原子落位 → 重读二次校验
|
|
29
|
+
// · 已存在且 sha 一致 ⇒ skip(幂等);已存在但**内容不符** ⇒ 报 conflict,不覆盖(除非 --force)
|
|
30
|
+
// · 退出码:0 = 全部就位 · 3 = 有未找到源的缺失 · 4 = 有失败(写盘/校验) · 2 = 用法错
|
|
31
|
+
// ============================================================
|
|
32
|
+
import fs from 'node:fs'
|
|
33
|
+
import os from 'node:os'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import crypto from 'node:crypto'
|
|
36
|
+
import { fileURLToPath } from 'node:url'
|
|
37
|
+
import { resolveDshHome, resolveDshWorkspace } from './lib/dsh-home.mjs'
|
|
38
|
+
import {
|
|
39
|
+
normalizeSha,
|
|
40
|
+
objectPathFor,
|
|
41
|
+
objectsRootFor,
|
|
42
|
+
sha256Bytes,
|
|
43
|
+
} from './lib/attach-refs.mjs'
|
|
44
|
+
import { collectAudit } from './attachments-audit.mjs'
|
|
45
|
+
|
|
46
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
47
|
+
const SELF = path.resolve(fileURLToPath(import.meta.url))
|
|
48
|
+
|
|
49
|
+
export function usage() {
|
|
50
|
+
return [
|
|
51
|
+
'usage: node attachments-fetch.mjs (--hash <sha256> | --session <id|prefix> | --all-missing | --probe | --export <dir> | --rollback <report.json>)',
|
|
52
|
+
' [--from <path>]... [--relay <dir>]... [--local-source <dir>]... [--no-local-sources]',
|
|
53
|
+
' [--home <dir>] [--verify|--no-verify] [--dry-run] [--force] [--json]',
|
|
54
|
+
' [--report <file>] [--quiet]',
|
|
55
|
+
'',
|
|
56
|
+
' 按 sha256 取回缺失的图片附件对象(落 <home>/attachments/v1/objects/<2>/<sha>)。',
|
|
57
|
+
' 退出码 0=就位 / 3=有缺失(未找到源) / 4=有失败 / 2=用法错',
|
|
58
|
+
].join('\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseFetchArgs(argv) {
|
|
62
|
+
const o = {
|
|
63
|
+
hashes: [], session: null, allMissing: false, probe: false, exportDir: null, rollback: null,
|
|
64
|
+
from: [], relay: [], localSources: [], noLocalSources: false, home: null,
|
|
65
|
+
verify: true, dryRun: false, force: false, json: false, report: null, quiet: false,
|
|
66
|
+
help: false, errors: [],
|
|
67
|
+
}
|
|
68
|
+
for (let i = 0; i < argv.length; i++) {
|
|
69
|
+
const a = argv[i]
|
|
70
|
+
const next = () => {
|
|
71
|
+
const v = argv[++i]
|
|
72
|
+
if (v === undefined || v.startsWith('--')) { o.errors.push(`缺少参数值: ${a}`); return null }
|
|
73
|
+
return v
|
|
74
|
+
}
|
|
75
|
+
if (a === '--hash') { const v = next(); if (v) o.hashes.push(...v.split(',').map((s) => s.trim()).filter(Boolean)) }
|
|
76
|
+
else if (a === '--session') o.session = next()
|
|
77
|
+
else if (a === '--all-missing') o.allMissing = true
|
|
78
|
+
else if (a === '--probe') o.probe = true
|
|
79
|
+
else if (a === '--export') o.exportDir = next()
|
|
80
|
+
else if (a === '--rollback') o.rollback = next()
|
|
81
|
+
else if (a === '--from') { const v = next(); if (v) o.from.push(v) }
|
|
82
|
+
else if (a === '--relay') { const v = next(); if (v) o.relay.push(v) }
|
|
83
|
+
else if (a === '--local-source') { const v = next(); if (v) o.localSources.push(v) }
|
|
84
|
+
else if (a === '--no-local-sources') o.noLocalSources = true
|
|
85
|
+
else if (a === '--home') o.home = next()
|
|
86
|
+
else if (a === '--verify') o.verify = true
|
|
87
|
+
else if (a === '--no-verify') o.verify = false
|
|
88
|
+
else if (a === '--dry-run') o.dryRun = true
|
|
89
|
+
else if (a === '--force') o.force = true
|
|
90
|
+
else if (a === '--json') o.json = true
|
|
91
|
+
else if (a === '--report') o.report = next()
|
|
92
|
+
else if (a === '--quiet') o.quiet = true
|
|
93
|
+
else if (a === '--help' || a === '-h') o.help = true
|
|
94
|
+
else o.errors.push(`未知参数: ${a}`)
|
|
95
|
+
}
|
|
96
|
+
return o
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const LOCAL_SOURCE_TAILS = [
|
|
100
|
+
path.join('dsh-imagegen', 'images'),
|
|
101
|
+
path.join('dsh-imagegen', 'gallery', 'images'),
|
|
102
|
+
]
|
|
103
|
+
const RELAY_HINTS = [
|
|
104
|
+
path.join(os.homedir(), 'dsh-attachment-relay'),
|
|
105
|
+
path.join(os.homedir(), 'dsh-attachments', 'relay'),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
/** 默认本机派生源目录(按内容 sha 命中)。 */
|
|
109
|
+
export function defaultLocalSources(home, workspace) {
|
|
110
|
+
const out = LOCAL_SOURCE_TAILS.map((t) => path.join(home, t))
|
|
111
|
+
out.push(path.join(workspace, 'generated'))
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 候选 relay 目录(存在才返回)。 */
|
|
116
|
+
export function candidateRelays(workspace) {
|
|
117
|
+
const out = []
|
|
118
|
+
const env = process.env.DSH_ATTACH_RELAY
|
|
119
|
+
if (env && env.trim()) out.push(path.resolve(env.trim()))
|
|
120
|
+
for (const h of RELAY_HINTS) out.push(h)
|
|
121
|
+
out.push(path.join(workspace, '_tmp', 'attachments-relay'))
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 一个源根下某个 sha 的候选路径(覆盖各种层级布局)。 */
|
|
126
|
+
export function objectCandidatesIn(root, sha) {
|
|
127
|
+
const b = sha.slice(0, 2)
|
|
128
|
+
return [
|
|
129
|
+
path.join(root, b, sha),
|
|
130
|
+
path.join(root, 'objects', b, sha),
|
|
131
|
+
path.join(root, 'v1', 'objects', b, sha),
|
|
132
|
+
path.join(root, 'attachments', 'v1', 'objects', b, sha),
|
|
133
|
+
path.join(root, '.dsh', 'attachments', 'v1', 'objects', b, sha),
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** relay 目录下某个 sha 的候选路径(多一层主机名目录)。 */
|
|
138
|
+
export function relayCandidatesIn(root, sha) {
|
|
139
|
+
const out = objectCandidatesIn(root, sha)
|
|
140
|
+
let ents = []
|
|
141
|
+
try { ents = fs.readdirSync(root, { withFileTypes: true }) } catch { return out }
|
|
142
|
+
for (const e of ents) {
|
|
143
|
+
if (!e.isDirectory()) continue
|
|
144
|
+
out.push(...objectCandidatesIn(path.join(root, e.name), sha))
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function firstExisting(paths) {
|
|
150
|
+
for (const p of paths) {
|
|
151
|
+
try { if (fs.statSync(p).isFile()) return p } catch { /* 继续 */ }
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 用内容 sha 建索引(本机派生源);返回 Map<sha, file>。 */
|
|
157
|
+
export function buildContentIndex(dirs, wanted = null) {
|
|
158
|
+
const index = new Map()
|
|
159
|
+
const stats = { dirs: [], files: 0, bytes: 0, skippedLinks: 0 }
|
|
160
|
+
const seen = new Set()
|
|
161
|
+
for (const dir of dirs) {
|
|
162
|
+
let real
|
|
163
|
+
try { real = fs.realpathSync(dir) } catch { continue }
|
|
164
|
+
if (seen.has(real.toLowerCase())) continue
|
|
165
|
+
seen.add(real.toLowerCase())
|
|
166
|
+
let count = 0
|
|
167
|
+
const stack = [dir]
|
|
168
|
+
while (stack.length) {
|
|
169
|
+
const cur = stack.pop()
|
|
170
|
+
let ents = []
|
|
171
|
+
try { ents = fs.readdirSync(cur, { withFileTypes: true }) } catch { continue }
|
|
172
|
+
for (const e of ents) {
|
|
173
|
+
const p = path.join(cur, e.name)
|
|
174
|
+
let st
|
|
175
|
+
try { st = fs.lstatSync(p) } catch { continue }
|
|
176
|
+
if (st.isSymbolicLink()) { stats.skippedLinks++; continue }
|
|
177
|
+
if (st.isDirectory()) { stack.push(p); continue }
|
|
178
|
+
if (!st.isFile()) continue
|
|
179
|
+
stats.files++
|
|
180
|
+
stats.bytes += st.size
|
|
181
|
+
count++
|
|
182
|
+
let buf
|
|
183
|
+
try { buf = fs.readFileSync(p) } catch { continue }
|
|
184
|
+
const sha = sha256Bytes(buf)
|
|
185
|
+
if (wanted === null || wanted.has(sha)) index.set(sha, p)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
stats.dirs.push({ dir, real, files: count })
|
|
189
|
+
}
|
|
190
|
+
return { index, stats }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 原子落盘:写同目录 tmp → rename → 校验。 */
|
|
194
|
+
function writeObject(objectsRoot, sha, bytes) {
|
|
195
|
+
const target = objectPathFor(objectsRoot, sha)
|
|
196
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
197
|
+
const tmp = path.join(path.dirname(target), `.tmp-${process.pid}-${crypto.randomUUID()}`)
|
|
198
|
+
try {
|
|
199
|
+
fs.writeFileSync(tmp, bytes)
|
|
200
|
+
const got = sha256Bytes(fs.readFileSync(tmp))
|
|
201
|
+
if (got !== sha) throw new Error(`临时文件校验失败(${got.slice(0, 16)}… ≠ ${sha.slice(0, 16)}…)`)
|
|
202
|
+
fs.renameSync(tmp, target)
|
|
203
|
+
} catch (e) {
|
|
204
|
+
try { fs.rmSync(tmp, { force: true }) } catch { /* 清理失败不掩盖原始错误 */ }
|
|
205
|
+
throw e
|
|
206
|
+
}
|
|
207
|
+
const final = fs.readFileSync(target)
|
|
208
|
+
const got2 = sha256Bytes(final)
|
|
209
|
+
if (got2 !== sha) throw new Error(`落盘后校验失败(${got2.slice(0, 16)}… ≠ ${sha.slice(0, 16)}…)`)
|
|
210
|
+
return { target, bytes: final.length }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function readObjectSha(file) {
|
|
214
|
+
try { return sha256Bytes(fs.readFileSync(file)) } catch { return null }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function defaultReportPath(workspace) {
|
|
218
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
219
|
+
return path.join(workspace, '_tmp', 'attachments-fetch', `${ts}-report.json`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function main(argv = process.argv.slice(2)) {
|
|
223
|
+
const o = parseFetchArgs(argv)
|
|
224
|
+
if (o.help) { process.stdout.write(usage() + '\n'); return 0 }
|
|
225
|
+
if (o.errors.length > 0) { process.stderr.write(o.errors.join('\n') + '\n' + usage() + '\n'); return 2 }
|
|
226
|
+
|
|
227
|
+
const home = resolveDshHome(o.home)
|
|
228
|
+
const workspace = resolveDshWorkspace()
|
|
229
|
+
const objectsRoot = objectsRootFor(home)
|
|
230
|
+
|
|
231
|
+
// ---- 回滚模式(只按清单删文件,绝不递归删目录)
|
|
232
|
+
if (o.rollback) {
|
|
233
|
+
const rp = path.resolve(o.rollback)
|
|
234
|
+
let rep
|
|
235
|
+
try { rep = JSON.parse(fs.readFileSync(rp, 'utf8')) } catch (e) {
|
|
236
|
+
process.stderr.write(`attachments-fetch: 读不到报告 ${rp}: ${e?.message ?? e}\n`)
|
|
237
|
+
return 2
|
|
238
|
+
}
|
|
239
|
+
const targets = (rep.fetched ?? []).map((f) => f.target).filter((p) => typeof p === 'string')
|
|
240
|
+
let removed = 0, missing = 0, failed = 0
|
|
241
|
+
for (const p of targets) {
|
|
242
|
+
try { fs.rmSync(p); removed++ } catch (e) {
|
|
243
|
+
if (e?.code === 'ENOENT') missing++
|
|
244
|
+
else { failed++; process.stderr.write(` !! 删除失败 ${p}: ${e?.message ?? e}\n`) }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const out = { rollbackOf: rp, removed, missing, failed, targets: targets.length }
|
|
248
|
+
process.stdout.write((o.json ? JSON.stringify(out, null, 2) : `回滚:删 ${removed} / 缺 ${missing} / 失败 ${failed}(清单 ${targets.length} 条)`) + '\n')
|
|
249
|
+
return failed > 0 ? 4 : 0
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- 导出模式(本机 → 中转目录)
|
|
253
|
+
if (o.exportDir) {
|
|
254
|
+
const outDir = path.resolve(o.exportDir)
|
|
255
|
+
const audit = collectAudit({ home, quiet: true })
|
|
256
|
+
if (!audit.ok) { process.stderr.write(`attachments-fetch: ${audit.reason}\n`); return audit.code }
|
|
257
|
+
const shas = new Set()
|
|
258
|
+
for (const m of audit.summary.missing) shas.add(m.sha)
|
|
259
|
+
// 导出「被引用的全部对象」= 缺失 ∪ 已在位;缺失的用于提示对端也无源
|
|
260
|
+
for (const sha of collectReferencedShas(home)) shas.add(sha)
|
|
261
|
+
const manifest = { kind: 'dsh-attachments-relay', host: os.hostname(), createdAt: new Date().toISOString(), objects: [] }
|
|
262
|
+
let copied = 0, absent = 0
|
|
263
|
+
for (const sha of shas) {
|
|
264
|
+
const src = objectPathFor(objectsRoot, sha)
|
|
265
|
+
if (!fs.existsSync(src)) { absent++; manifest.objects.push({ sha, present: false }); continue }
|
|
266
|
+
const dst = path.join(outDir, 'objects', sha.slice(0, 2), sha)
|
|
267
|
+
// ⚠️ dry-run 必须**零副作用**:连空目录都不建。
|
|
268
|
+
// 2026-10-10 卡「多端线·本机小件四件」S-12 实测踩到:原实现无条件 mkdirSync,
|
|
269
|
+
// ⇒ `--export <dir> --dry-run` 会在用户指定的介质目录里留下一整棵空的 objects/<2>/ 树。
|
|
270
|
+
if (!o.dryRun) {
|
|
271
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true })
|
|
272
|
+
fs.copyFileSync(src, dst)
|
|
273
|
+
}
|
|
274
|
+
copied++
|
|
275
|
+
manifest.objects.push({ sha, present: true, bytes: fs.statSync(src).size })
|
|
276
|
+
}
|
|
277
|
+
if (!o.dryRun) {
|
|
278
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
279
|
+
fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8')
|
|
280
|
+
}
|
|
281
|
+
const line = `导出:${copied} 个对象 → ${outDir}${o.dryRun ? '(dry-run,未写盘)' : ''};本机也没有的 ${absent} 个`
|
|
282
|
+
process.stdout.write((o.json ? JSON.stringify({ exportDir: outDir, copied, absent, dryRun: o.dryRun, manifest }, null, 2) : line) + '\n')
|
|
283
|
+
return 0
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---- 目标集合
|
|
287
|
+
const targets = []
|
|
288
|
+
const targetReasons = new Map()
|
|
289
|
+
let auditSummary = null
|
|
290
|
+
if (o.hashes.length > 0) {
|
|
291
|
+
for (const h of o.hashes) {
|
|
292
|
+
const sha = normalizeSha(h)
|
|
293
|
+
if (!sha) { process.stderr.write(`attachments-fetch: 非法 hash: ${h}\n`); return 2 }
|
|
294
|
+
if (!targets.includes(sha)) targets.push(sha)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (o.session || o.allMissing || (o.probe && targets.length === 0)) {
|
|
298
|
+
const audit = collectAudit({ home, session: o.session ?? null, quiet: true })
|
|
299
|
+
if (!audit.ok) { process.stderr.write(`attachments-fetch: ${audit.reason}\n`); return audit.code }
|
|
300
|
+
auditSummary = audit.summary
|
|
301
|
+
for (const m of audit.summary.missing) if (!targets.includes(m.sha)) targets.push(m.sha)
|
|
302
|
+
}
|
|
303
|
+
// 无论走哪条路径,都记录「谁引用它」(报告里有用)
|
|
304
|
+
if (auditSummary === null && targets.length > 0) {
|
|
305
|
+
const audit = collectAudit({ home, quiet: true })
|
|
306
|
+
if (audit.ok) {
|
|
307
|
+
auditSummary = audit.summary
|
|
308
|
+
for (const m of audit.summary.missing) targetReasons.set(m.sha, m.sessions)
|
|
309
|
+
}
|
|
310
|
+
} else if (auditSummary) {
|
|
311
|
+
for (const m of auditSummary.missing) targetReasons.set(m.sha, m.sessions)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const localDirs = o.noLocalSources ? [] : [...defaultLocalSources(home, workspace), ...o.localSources]
|
|
315
|
+
const explicitSources = o.from.map((p) => path.resolve(p))
|
|
316
|
+
const relayDirs = o.relay.length > 0
|
|
317
|
+
? o.relay.map((p) => path.resolve(p))
|
|
318
|
+
: candidateRelays(workspace).filter((d) => fs.existsSync(d))
|
|
319
|
+
|
|
320
|
+
// ---- probe
|
|
321
|
+
if (o.probe) {
|
|
322
|
+
const wanted = new Set(targets)
|
|
323
|
+
const report = {
|
|
324
|
+
dshHome: home, objectsRoot, workspace,
|
|
325
|
+
targets: targets.length,
|
|
326
|
+
explicit: [], relay: [], local: [], coverage: null,
|
|
327
|
+
}
|
|
328
|
+
for (const s of explicitSources) {
|
|
329
|
+
const roots = [s, path.join(s, 'objects'), path.join(s, 'v1', 'objects'), path.join(s, 'attachments', 'v1', 'objects')]
|
|
330
|
+
const probeRoot = roots.find((r) => { try { return fs.statSync(r).isDirectory() } catch { return false } }) ?? null
|
|
331
|
+
let hit = 0
|
|
332
|
+
if (probeRoot) for (const sha of targets) if (firstExisting(objectCandidatesIn(s, sha))) hit++
|
|
333
|
+
report.explicit.push({ path: s, usableRoot: probeRoot, hit, targets: targets.length, exists: fs.existsSync(s) })
|
|
334
|
+
}
|
|
335
|
+
for (const r of relayDirs) {
|
|
336
|
+
let hit = 0
|
|
337
|
+
for (const sha of targets) if (firstExisting(relayCandidatesIn(r, sha))) hit++
|
|
338
|
+
report.relay.push({ path: r, hit, targets: targets.length })
|
|
339
|
+
}
|
|
340
|
+
if (!o.noLocalSources) {
|
|
341
|
+
const { index, stats } = buildContentIndex(localDirs, wanted)
|
|
342
|
+
let hit = 0
|
|
343
|
+
for (const sha of targets) if (index.has(sha)) hit++
|
|
344
|
+
report.local = stats.dirs.map((d) => ({ dir: d.dir, files: d.files }))
|
|
345
|
+
report.coverage = { files: stats.files, bytes: stats.bytes, hit, targets: targets.length, indexSize: index.size }
|
|
346
|
+
}
|
|
347
|
+
if (o.json) process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
348
|
+
else {
|
|
349
|
+
const l = []
|
|
350
|
+
l.push('attachments 取回源探测(只读)')
|
|
351
|
+
l.push(` 目标缺失对象 ${targets.length}`)
|
|
352
|
+
l.push(` 显式 --from ${report.explicit.length} 个`)
|
|
353
|
+
for (const e of report.explicit) l.push(` ${e.exists ? '✓' : '✗'} ${e.path} → 可用根 ${e.usableRoot ?? '(无 objects 布局)'} · 命中 ${e.hit}/${e.targets}`)
|
|
354
|
+
l.push(` 中转 relay ${report.relay.length} 个`)
|
|
355
|
+
for (const e of report.relay) l.push(` ✓ ${e.path} · 命中 ${e.hit}/${e.targets}`)
|
|
356
|
+
if (o.noLocalSources) l.push(' 本机派生源 已用 --no-local-sources 关闭')
|
|
357
|
+
else {
|
|
358
|
+
l.push(` 本机派生源 ${report.local.length} 个目录 / ${report.coverage?.files ?? 0} 文件 / ${((report.coverage?.bytes ?? 0) / 1048576).toFixed(0)} MB`)
|
|
359
|
+
for (const d of report.local) l.push(` · ${d.dir}(${d.files} 文件)`)
|
|
360
|
+
l.push(` ⇒ 按内容命中 ${report.coverage?.hit ?? 0}/${targets.length}`)
|
|
361
|
+
}
|
|
362
|
+
if (explicitSources.length === 0 && relayDirs.length === 0) {
|
|
363
|
+
l.push('')
|
|
364
|
+
l.push(' ⚠ 没有可用的「跨机源」:附件不进 Gitee 三仓(仓1 白名单明确排除 attachments)。')
|
|
365
|
+
l.push(' 可用的跨机形态 = ① --from <对端 .dsh\\attachments 路径>(对端磁盘/移动盘在场时)')
|
|
366
|
+
l.push(' ② --relay <中转目录>(先用 --export 导出到介质,再插到对端取回)')
|
|
367
|
+
l.push(' ③ 独立附件仓(B′,需主人拍板,见 docs\\attachments跨机同步-方案-20260916.md §5 D1)')
|
|
368
|
+
}
|
|
369
|
+
process.stdout.write(l.join('\n') + '\n')
|
|
370
|
+
}
|
|
371
|
+
return 0
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (targets.length === 0) {
|
|
375
|
+
// --json 时即使没目标也输出结构化报告(机器消费口径一致:counts 全 0 + reason)
|
|
376
|
+
const empty = {
|
|
377
|
+
kind: 'dsh-attachments-fetch', host: os.hostname(), at: new Date().toISOString(),
|
|
378
|
+
dshHome: home, objectsRoot, dryRun: o.dryRun, verify: o.verify, force: o.force,
|
|
379
|
+
targets: 0, reason: 'nothing-missing',
|
|
380
|
+
requested: { hashes: o.hashes.length, session: o.session, allMissing: o.allMissing },
|
|
381
|
+
sources: { explicit: explicitSources, relay: relayDirs, local: o.noLocalSources ? [] : localDirs },
|
|
382
|
+
fetched: [], skipped: [], conflicts: [], missing: [], failed: [],
|
|
383
|
+
counts: { fetched: 0, skipped: 0, conflicts: 0, missing: 0, failed: 0 },
|
|
384
|
+
}
|
|
385
|
+
if (o.json) process.stdout.write(JSON.stringify(empty, null, 2) + '\n')
|
|
386
|
+
else process.stdout.write('attachments-fetch: 没有需要取回的对象(本机被引用的附件都在位 ✅)\n')
|
|
387
|
+
return 0
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- 取回
|
|
391
|
+
const fetched = [], skipped = [], conflicts = [], missing = [], failed = []
|
|
392
|
+
let localIndex = null
|
|
393
|
+
const ensureLocalIndex = () => {
|
|
394
|
+
if (localIndex !== null || o.noLocalSources) return localIndex
|
|
395
|
+
const wanted = new Set(targets)
|
|
396
|
+
localIndex = buildContentIndex(localDirs, wanted).index
|
|
397
|
+
return localIndex
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (const sha of targets) {
|
|
401
|
+
const target = objectPathFor(objectsRoot, sha)
|
|
402
|
+
const existsNow = fs.existsSync(target)
|
|
403
|
+
if (existsNow) {
|
|
404
|
+
const got = o.verify ? readObjectSha(target) : sha
|
|
405
|
+
if (got === sha) { skipped.push({ sha, target, reason: 'already-present' }); continue }
|
|
406
|
+
conflicts.push({ sha, target, actual: got })
|
|
407
|
+
if (!o.force) continue
|
|
408
|
+
}
|
|
409
|
+
let srcPath = null
|
|
410
|
+
let srcKind = null
|
|
411
|
+
for (const s of explicitSources) {
|
|
412
|
+
const hit = firstExisting(objectCandidatesIn(s, sha))
|
|
413
|
+
if (hit) { srcPath = hit; srcKind = `from:${s}`; break }
|
|
414
|
+
}
|
|
415
|
+
if (!srcPath) {
|
|
416
|
+
for (const r of relayDirs) {
|
|
417
|
+
const hit = firstExisting(relayCandidatesIn(r, sha))
|
|
418
|
+
if (hit) { srcPath = hit; srcKind = `relay:${r}`; break }
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (!srcPath) {
|
|
422
|
+
const idx = ensureLocalIndex()
|
|
423
|
+
const hit = idx?.get(sha)
|
|
424
|
+
if (hit) { srcPath = hit; srcKind = 'local-derived' }
|
|
425
|
+
}
|
|
426
|
+
if (!srcPath) { missing.push({ sha, target, sessions: targetReasons.get(sha) ?? [] }); continue }
|
|
427
|
+
|
|
428
|
+
let bytes
|
|
429
|
+
try { bytes = fs.readFileSync(srcPath) } catch (e) {
|
|
430
|
+
failed.push({ sha, source: srcPath, reason: `读源失败: ${e?.message ?? e}` }); continue
|
|
431
|
+
}
|
|
432
|
+
const srcSha = sha256Bytes(bytes)
|
|
433
|
+
if (o.verify && srcSha !== sha) {
|
|
434
|
+
failed.push({ sha, source: srcPath, reason: `源内容 sha 不符(${srcSha.slice(0, 16)}…)` }); continue
|
|
435
|
+
}
|
|
436
|
+
if (o.dryRun) { fetched.push({ sha, source: srcPath, sourceKind: srcKind, target, bytes: bytes.length, dryRun: true }); continue }
|
|
437
|
+
try {
|
|
438
|
+
const w = writeObject(objectsRoot, sha, bytes)
|
|
439
|
+
fetched.push({ sha, source: srcPath, sourceKind: srcKind, target: w.target, bytes: w.bytes })
|
|
440
|
+
} catch (e) {
|
|
441
|
+
failed.push({ sha, source: srcPath, reason: `写盘失败: ${e?.message ?? e}` })
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const reportPath = o.report ? path.resolve(o.report) : defaultReportPath(workspace)
|
|
446
|
+
const report = {
|
|
447
|
+
kind: 'dsh-attachments-fetch', host: os.hostname(), at: new Date().toISOString(),
|
|
448
|
+
dshHome: home, objectsRoot, dryRun: o.dryRun, verify: o.verify, force: o.force,
|
|
449
|
+
targets: targets.length,
|
|
450
|
+
requested: { hashes: o.hashes.length, session: o.session, allMissing: o.allMissing },
|
|
451
|
+
sources: { explicit: explicitSources, relay: relayDirs, local: o.noLocalSources ? [] : localDirs },
|
|
452
|
+
fetched, skipped, conflicts, missing, failed,
|
|
453
|
+
counts: { fetched: fetched.length, skipped: skipped.length, conflicts: conflicts.length, missing: missing.length, failed: failed.length },
|
|
454
|
+
rollbackHint: `node ${path.join(HERE, 'attachments-fetch.mjs')} --rollback "${reportPath}"`,
|
|
455
|
+
}
|
|
456
|
+
if (!o.dryRun) {
|
|
457
|
+
try {
|
|
458
|
+
fs.mkdirSync(path.dirname(reportPath), { recursive: true })
|
|
459
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf8')
|
|
460
|
+
} catch (e) { process.stderr.write(` !! 报告写入失败 ${reportPath}: ${e?.message ?? e}\n`) }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (o.json) process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
464
|
+
else if (!o.quiet) {
|
|
465
|
+
const l = []
|
|
466
|
+
l.push(`attachments 取回${o.dryRun ? '(dry-run,未写盘)' : ''}:目标 ${targets.length}`)
|
|
467
|
+
l.push(` 取回 ${fetched.length} · 已在位 ${skipped.length} · 未找到源 ${missing.length} · 冲突 ${conflicts.length} · 失败 ${failed.length}`)
|
|
468
|
+
for (const f of fetched.slice(0, 10)) l.push(` ✓ ${f.sha.slice(0, 16)}… ← ${f.sourceKind} ${f.source}`)
|
|
469
|
+
if (fetched.length > 10) l.push(` … 另 ${fetched.length - 10} 个`)
|
|
470
|
+
for (const m of missing.slice(0, 10)) l.push(` ✗ 无源 ${m.sha.slice(0, 16)}…${m.sessions?.length ? `(${m.sessions.length} 个会话引用)` : ''}`)
|
|
471
|
+
for (const c of conflicts.slice(0, 5)) l.push(` ! 冲突 ${c.sha.slice(0, 16)}… 目标已有其它内容(需 --force)`)
|
|
472
|
+
for (const f of failed.slice(0, 5)) l.push(` !! ${f.sha.slice(0, 16)}… ${f.reason}`)
|
|
473
|
+
if (!o.dryRun) {
|
|
474
|
+
l.push(` 报告 ${reportPath}`)
|
|
475
|
+
l.push(` 回滚 ${report.rollbackHint}`)
|
|
476
|
+
}
|
|
477
|
+
process.stdout.write(l.join('\n') + '\n')
|
|
478
|
+
}
|
|
479
|
+
if (failed.length > 0) return 4
|
|
480
|
+
if (missing.length > 0) return 3
|
|
481
|
+
return 0
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** 本机会话引用到的全部 sha(导出模式用;口径 = audit 的 referencedShas,别另数)。 */
|
|
485
|
+
function collectReferencedShas(home) {
|
|
486
|
+
const out = new Set()
|
|
487
|
+
const audit = collectAudit({ home, quiet: true })
|
|
488
|
+
if (!audit.ok) return out
|
|
489
|
+
for (const sha of audit.summary.referencedShas ?? []) out.add(sha)
|
|
490
|
+
for (const m of audit.summary.missing) out.add(m.sha)
|
|
491
|
+
return out
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function isMain() {
|
|
495
|
+
const arg = process.argv[1]
|
|
496
|
+
if (!arg) return false
|
|
497
|
+
try { return fs.realpathSync(arg) === fs.realpathSync(SELF) } catch { return false }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (isMain()) {
|
|
501
|
+
try {
|
|
502
|
+
process.exitCode = main()
|
|
503
|
+
} catch (e) {
|
|
504
|
+
process.stderr.write(`attachments-fetch: 内部错误 ${e?.stack ?? e}\n`)
|
|
505
|
+
process.exitCode = 4
|
|
506
|
+
}
|
|
507
|
+
}
|