aura-basetools 0.0.0-stage → 0.13.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.
@@ -0,0 +1,355 @@
1
+ /**
2
+ * fs-provider.mjs —— 给独立 CLI / MCP 用的**本机文件提供方**(node:fs,零依赖,跨平台)。
3
+ *
4
+ * 引擎(`locator-lab/write-path.mjs`)只认一个四方法契约,不认框架:
5
+ * stat(target) -> { version, size } | undefined
6
+ * readText(target) -> string 提供方视角(解码时剥掉恰好一个前导 BOM)
7
+ * readByteRange(target, { offset, length }) -> Uint8Array
8
+ * writeText(target, content, expected) -> { version }
9
+ *
10
+ * 与 DSH 那边 `dsh-fs-local` 的语义逐条对齐(这样两条路上的判据才可比):
11
+ * · 解码用 `new TextDecoder('utf-8', { fatal: true })` —— 默认 `ignoreBOM:false`,**第一个 BOM 不进字符串**;
12
+ * · 写入用 `TextEncoder`,**逐字节**,不做行尾归一(DSH 那边踩过的坑:`editText` 会按多数派归一);
13
+ * · 落盘走**临时文件 + rename**(同目录),Windows 上 rename 也覆盖已存在文件;
14
+ * · `expected.kind === 'replaceIfVersion'` 时先比版本,不符抛 `FS_STALE_VERSION`(本机版 CAS);
15
+ * · `expected.kind === 'createIfAbsent'` 时文件已存在则抛 `FS_NOT_OBSERVED`。
16
+ *
17
+ * 版本指纹 = `size:mtimeMs:sha256(前 4KB + 后 4KB + size)`:
18
+ * 只读头尾,不整份读;但足以发现常见的内容改写。
19
+ *
20
+ * 写入边界(v0.6.19 起):
21
+ * · 现有文件的 mode 在替换后保留(POSIX;Windows 只保留可写位语义);
22
+ * · 符号链接**写穿**到 realpath,链接本身保留;悬空链接拒绝;
23
+ * · 硬链接(nlink > 1)拒绝,因为 rename 会打断另一个名字;
24
+ * · 文本读取/写入有大小上限(默认 512 MiB,可由 `AURA_MAX_FILE_BYTES` 覆盖);
25
+ * · `readByteRange` 只读请求区间,不再整份读。
26
+ */
27
+ import fs from 'node:fs'
28
+ import path from 'node:path'
29
+ import crypto from 'node:crypto'
30
+
31
+ const SAMPLE = 4096
32
+ /** 小文件走全文哈希;大文件才退回头尾采样。 */
33
+ const FULL_HASH_LIMIT = 16 * 1024 * 1024
34
+ export const DEFAULT_MAX_TEXT_BYTES = 512 * 1024 * 1024
35
+
36
+ /** 读文件(返回 Buffer);文件不存在 / 不是普通文件 / 超限抛带 code 的错误。 */
37
+ function readOrThrow(target, maxBytes) {
38
+ let st
39
+ try {
40
+ st = fs.statSync(target)
41
+ } catch (error) {
42
+ if (error?.code === 'ENOENT') throw Object.assign(new Error(`cannot read "${target}": not found`), { code: 'FS_NOT_FOUND' })
43
+ throw error
44
+ }
45
+ if (st.isDirectory()) throw Object.assign(new Error(`cannot read "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
46
+ if (!st.isFile()) throw Object.assign(new Error(`cannot read "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
47
+ if (st.size > maxBytes) {
48
+ throw Object.assign(
49
+ new Error(`cannot read "${target}": file is ${st.size} bytes, over the ${maxBytes}-byte text limit`),
50
+ { code: 'FS_TOO_LARGE', size: st.size, limit: maxBytes },
51
+ )
52
+ }
53
+ try {
54
+ return fs.readFileSync(target)
55
+ } catch (error) {
56
+ if (error?.code === 'ENOENT') throw Object.assign(new Error(`cannot read "${target}": not found`), { code: 'FS_NOT_FOUND' })
57
+ throw error
58
+ }
59
+ }
60
+
61
+ /** 版本指纹:size + mtimeMs + 头尾采样哈希。 */
62
+ export function fingerprintOf(buffer, stat) {
63
+ const hash = crypto.createHash('sha256')
64
+ if (buffer.length <= FULL_HASH_LIMIT) {
65
+ // 小文件全文哈希:中间改写即使 mtime/ctime 都没变也能被发现。
66
+ hash.update(buffer)
67
+ } else {
68
+ const head = buffer.subarray(0, SAMPLE)
69
+ const tail = buffer.length > SAMPLE ? buffer.subarray(buffer.length - SAMPLE) : Buffer.alloc(0)
70
+ hash.update(head).update(tail).update(String(buffer.length))
71
+ }
72
+ const digest = hash.digest('hex').slice(0, 16)
73
+ // ctime 不能由普通写者回拨;把它纳入版本,能抓住“改中间 + 恢复 mtime”的改写。
74
+ // 优先用 ctimeNs:Linux 上写入与 utimes 可能落在同一毫秒,ctimeMs 截断后看起来没变。
75
+ const ctime = stat.ctimeNs !== undefined ? stat.ctimeNs.toString() : String(Math.trunc(stat.ctimeMs ?? 0))
76
+ return `${buffer.length}:${Math.trunc(stat.mtimeMs)}:${ctime}:${digest}`
77
+ }
78
+
79
+ /** 只读一段;短读时返回实际读到的字节,不补零。 */
80
+ function readExact(fd, length, position) {
81
+ if (length <= 0) return Buffer.alloc(0)
82
+ const buffer = Buffer.alloc(length)
83
+ let got = 0
84
+ while (got < length) {
85
+ const n = fs.readSync(fd, buffer, got, length - got, position + got)
86
+ if (n === 0) break
87
+ got += n
88
+ }
89
+ return got === length ? buffer : buffer.subarray(0, got)
90
+ }
91
+
92
+ /** 头尾采样指纹:不再为了 fingerprintOf 把整份文件读进内存。 */
93
+ function fingerprintFile(file, stat) {
94
+ let fd
95
+ try {
96
+ fd = fs.openSync(file, 'r')
97
+ if (stat.size <= FULL_HASH_LIMIT) {
98
+ return fingerprintOf(fs.readFileSync(file), stat)
99
+ }
100
+ const head = readExact(fd, Math.min(SAMPLE, stat.size), 0)
101
+ const tailLength = stat.size > SAMPLE ? Math.min(SAMPLE, stat.size - SAMPLE) : 0
102
+ const tail = tailLength > 0 ? readExact(fd, tailLength, stat.size - tailLength) : Buffer.alloc(0)
103
+ const hash = crypto.createHash('sha256').update(head).update(tail).update(String(stat.size)).digest('hex').slice(0, 16)
104
+ const ctime = stat.ctimeNs !== undefined ? stat.ctimeNs.toString() : String(Math.trunc(stat.ctimeMs ?? 0))
105
+ return `${stat.size}:${Math.trunc(stat.mtimeMs)}:${ctime}:${hash}`
106
+ } finally {
107
+ if (fd !== undefined) {
108
+ try { fs.closeSync(fd) } catch { /* 不覆盖原始错误 */ }
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 原子写:同目录临时文件 + fsync + rename(跨平台)。
115
+ *
116
+ * `mode` 非空时在 POSIX 上把临时文件 chmod 回原 mode;Windows 的 mode 只承载可写位,
117
+ * 不做 chmod,避免把只读位带上临时文件后 rename 失败。ACL / xattr 不在本实现的保全范围。
118
+ */
119
+ function writeAtomic(target, content, { mode = null, beforeRename = null } = {}) {
120
+ const dir = path.dirname(target)
121
+ const tmp = path.join(dir, `.${path.basename(target)}.aura-${process.pid}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}.tmp`)
122
+ let fd
123
+ try {
124
+ fd = fs.openSync(tmp, 'wx', mode ?? 0o666)
125
+ const buffer = Buffer.from(content)
126
+ let offset = 0
127
+ while (offset < buffer.length) {
128
+ offset += fs.writeSync(fd, buffer, offset, buffer.length - offset)
129
+ }
130
+ // 数据落盘后再 rename;不支持的 FS 上 fsync 失败不应把一个已经写好的临时文件变成脏状态。
131
+ try { fs.fsyncSync(fd) } catch { /* 尽力而为 */ }
132
+ fs.closeSync(fd)
133
+ fd = undefined
134
+ // 最后一道 CAS:临时文件已经写好,rename 前再确认一次目标版本。
135
+ // 它把“检查 → rename”的窗口压到最小;不是跨进程锁,但能挡住绝大多数并发写者。
136
+ if (beforeRename !== null) beforeRename()
137
+ if (mode !== null && path.sep !== '\\') fs.chmodSync(tmp, mode)
138
+ fs.renameSync(tmp, target)
139
+ // 目录项也尽量落盘;失败不影响已经完成的 rename。
140
+ try {
141
+ const dirFd = fs.openSync(dir, 'r')
142
+ try { fs.fsyncSync(dirFd) } catch { /* 尽力而为 */ }
143
+ fs.closeSync(dirFd)
144
+ } catch { /* 尽力而为 */ }
145
+ } catch (error) {
146
+ if (fd !== undefined) {
147
+ try { fs.closeSync(fd) } catch { /* 不覆盖原始错误 */ }
148
+ }
149
+ try { fs.rmSync(tmp, { force: true }) } catch { /* 清理失败不影响正确性 */ }
150
+ throw error
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 写目标解析:符号链接写穿到 realpath;硬链接拒绝。
156
+ *
157
+ * 为什么硬链接要拒绝:原子替换是“换一个 inode”,另一个硬链接仍指向旧 inode。
158
+ * 这会让两个名字在写入后看到不同内容——对一个声称逐字节保真的编辑器来说,静默发生比拒绝更坏。
159
+ */
160
+ function resolveWriteTarget(target) {
161
+ let lst
162
+ try {
163
+ lst = fs.lstatSync(target)
164
+ } catch (error) {
165
+ if (error?.code === 'ENOENT') return { file: target, exists: false, stat: null }
166
+ throw error
167
+ }
168
+ if (lst.isSymbolicLink()) {
169
+ let real
170
+ try {
171
+ real = fs.realpathSync.native(target)
172
+ } catch {
173
+ throw Object.assign(new Error(`cannot patch "${target}": dangling symbolic link`), { code: 'FS_SYMLINK_BROKEN' })
174
+ }
175
+ let st
176
+ try {
177
+ st = fs.statSync(real)
178
+ } catch {
179
+ throw Object.assign(new Error(`cannot patch "${target}": symbolic link target is not readable`), { code: 'FS_SYMLINK_BROKEN' })
180
+ }
181
+ if (!st.isFile()) throw Object.assign(new Error(`cannot patch "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
182
+ if (st.nlink > 1) {
183
+ throw Object.assign(
184
+ new Error(`cannot patch "${target}": file has ${st.nlink} hard links; atomic replace would break the other names`),
185
+ { code: 'FS_HARDLINK_UNSUPPORTED', links: st.nlink },
186
+ )
187
+ }
188
+ return { file: real, exists: true, stat: st }
189
+ }
190
+ if (!lst.isFile()) throw Object.assign(new Error(`cannot patch "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
191
+ if (lst.nlink > 1) {
192
+ throw Object.assign(
193
+ new Error(`cannot patch "${target}": file has ${lst.nlink} hard links; atomic replace would break the other names`),
194
+ { code: 'FS_HARDLINK_UNSUPPORTED', links: lst.nlink },
195
+ )
196
+ }
197
+ return { file: target, exists: true, stat: lst }
198
+ }
199
+
200
+ /**
201
+ * 造一个提供方。
202
+ * @param options.resolveRoot - 相对路径的解析根(默认进程 cwd);对象里只留绝对路径,避免"被检查的路径"与"被写的路径"不是同一个。
203
+ * @param options.maxTextBytes - 单次文本读/写的上限;默认读 `AURA_MAX_FILE_BYTES`,否则 512 MiB。
204
+ */
205
+ export function createNodeFsProvider(options = {}) {
206
+ const root = path.resolve(options.resolveRoot ?? process.cwd())
207
+ const abs = (p) => (path.isAbsolute(p) ? path.normalize(p) : path.resolve(root, p))
208
+ const configuredMax = options.maxTextBytes ?? Number(process.env.AURA_MAX_FILE_BYTES)
209
+ const maxTextBytes = Number.isSafeInteger(configuredMax) && configuredMax > 0 ? configuredMax : DEFAULT_MAX_TEXT_BYTES
210
+
211
+ const stat = (target) => {
212
+ const file = abs(target)
213
+ let st
214
+ try {
215
+ st = fs.statSync(file)
216
+ } catch {
217
+ return undefined
218
+ }
219
+ /**
220
+ * 目录**不再抛 `FS_NOT_REGULAR_FILE`**,而是带上 `type` 返回。
221
+ *
222
+ * 为什么改:仓库级地图(T26)需要“这个路径是目录”这件事先被知道。
223
+ * 旧行为下 `aura read <目录>` 报的是 `cannot patch "...": not a regular file`——
224
+ * 一句话里有两个错:动作是读不是改,而真正的原因(它是个目录)根本没提。
225
+ * 类型字段与 `dsh-fs-local` 的 `probe()` 同形(`file` / `directory` / `other`),
226
+ * 两边判据因此可比。
227
+ */
228
+ if (st.isDirectory()) return { type: 'directory', size: 0 }
229
+ if (!st.isFile()) throw Object.assign(new Error(`cannot patch "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
230
+ return { type: 'file', version: fingerprintFile(file, st), size: st.size }
231
+ }
232
+
233
+ return {
234
+ /** 工作区根(事务日志/锁的键;DSH 侧不使用本提供方)。 */
235
+ root,
236
+ /** 解析成绝对路径(CLI 的显示路径 → 真实路径的唯一次转换)。 */
237
+ resolve: (p) => abs(p),
238
+ stat,
239
+ async readText(target) {
240
+ const buffer = readOrThrow(abs(target), maxTextBytes)
241
+ if (buffer.includes(0)) throw Object.assign(new Error(`cannot read "${target}": binary file`), { code: 'FS_NOT_TEXT' })
242
+ // 与 dsh-fs-local 同形:fatal 解码 + 默认 ignoreBOM(第一个 BOM 被吃掉,其余 U+FEFF 留在文本里)
243
+ return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
244
+ },
245
+ async readByteRange(target, range) {
246
+ const file = abs(target)
247
+ let st
248
+ try {
249
+ st = fs.statSync(file)
250
+ } catch (error) {
251
+ if (error?.code === 'ENOENT') throw Object.assign(new Error(`cannot read "${target}": not found`), { code: 'FS_NOT_FOUND' })
252
+ throw error
253
+ }
254
+ if (!st.isFile()) throw Object.assign(new Error(`cannot read "${target}": not a regular file`), { code: 'FS_NOT_REGULAR_FILE' })
255
+ const offset = Math.min(st.size, Math.max(0, range?.offset ?? 0))
256
+ const length = Math.min(Math.max(0, range?.length ?? 0), st.size - offset)
257
+ if (length === 0) return new Uint8Array(0)
258
+ let fd
259
+ try {
260
+ fd = fs.openSync(file, 'r')
261
+ return new Uint8Array(readExact(fd, length, offset))
262
+ } finally {
263
+ if (fd !== undefined) {
264
+ try { fs.closeSync(fd) } catch { /* 不覆盖原始错误 */ }
265
+ }
266
+ }
267
+ },
268
+ /**
269
+ * 列直接子项(目录级地图用,T26)。
270
+ *
271
+ * 形状与 `dsh-fs-local.listDir` 对齐而非自创:`{ name, type, size, target }`,
272
+ * `type` ∈ `directory` / `file` / `other`。
273
+ * 两边的地图因此可以由同一份引擎逻辑驱动(`collectRepoMap`),
274
+ * 不会出现“CLI 列目录的规则与 DSH 不一样”这类只有对比才能发现的偏差。
275
+ * `target` 用**绝对路径字符串**:本提供方的其余方法都接受绝对路径,不必再包一层。
276
+ *
277
+ * @param target - 目录(绝对或相对解析根)
278
+ * @returns `{name, type, size, target}[]`,按名字排序(可重现的回执)
279
+ */
280
+ async listDir(target) {
281
+ const dir = abs(target)
282
+ let entries
283
+ try {
284
+ entries = fs.readdirSync(dir, { withFileTypes: true })
285
+ } catch (error) {
286
+ if (error?.code === 'ENOENT') throw Object.assign(new Error(`cannot list "${target}": not found`), { code: 'FS_NOT_FOUND' })
287
+ throw error
288
+ }
289
+ return entries
290
+ .sort((a, b) => a.name.localeCompare(b.name))
291
+ .map((e) => {
292
+ const child = path.join(dir, e.name)
293
+ let size
294
+ if (e.isFile()) {
295
+ try {
296
+ size = fs.statSync(child).size
297
+ } catch {
298
+ size = 0
299
+ }
300
+ }
301
+ return {
302
+ name: e.name,
303
+ type: e.isDirectory() ? 'directory' : e.isFile() ? 'file' : 'other',
304
+ ...(size === undefined ? {} : { size }),
305
+ target: child,
306
+ }
307
+ })
308
+ },
309
+ async writeText(target, content, expected) {
310
+ const display = target
311
+ const bytes = Buffer.byteLength(content)
312
+ if (bytes > maxTextBytes) {
313
+ throw Object.assign(
314
+ new Error(`cannot patch "${display}": content is ${bytes} bytes, over the ${maxTextBytes}-byte text limit`),
315
+ { code: 'FS_TOO_LARGE', size: bytes, limit: maxTextBytes },
316
+ )
317
+ }
318
+ const resolved = resolveWriteTarget(abs(target))
319
+ if (expected?.kind === 'createIfAbsent' && resolved.exists) {
320
+ throw Object.assign(new Error(`cannot overwrite existing "${display}" without reading it first`), { code: 'FS_NOT_OBSERVED' })
321
+ }
322
+ if (expected?.kind === 'replaceIfVersion') {
323
+ if (!resolved.exists) throw Object.assign(new Error(`cannot patch "${display}": file no longer exists`), { code: 'FS_STALE_VERSION' })
324
+ const current = fingerprintFile(resolved.file, resolved.stat)
325
+ if (current !== expected.version) {
326
+ throw Object.assign(new Error(`cannot patch "${display}": file changed since it was read`), { code: 'FS_STALE_VERSION' })
327
+ }
328
+ }
329
+ let beforeRename = null
330
+ if (expected?.kind === 'replaceIfVersion') {
331
+ beforeRename = () => {
332
+ let st
333
+ try { st = fs.statSync(resolved.file) } catch {
334
+ throw Object.assign(new Error(`cannot patch "${display}": file no longer exists`), { code: 'FS_STALE_VERSION' })
335
+ }
336
+ if (fingerprintFile(resolved.file, st) !== expected.version) {
337
+ throw Object.assign(new Error(`cannot patch "${display}": file changed while the patch was being written`), { code: 'FS_STALE_VERSION' })
338
+ }
339
+ }
340
+ } else if (expected?.kind === 'createIfAbsent') {
341
+ beforeRename = () => {
342
+ if (fs.existsSync(resolved.file)) {
343
+ throw Object.assign(new Error(`cannot overwrite existing "${display}" without reading it first`), { code: 'FS_NOT_OBSERVED' })
344
+ }
345
+ }
346
+ }
347
+ writeAtomic(resolved.file, content, {
348
+ mode: resolved.exists ? resolved.stat.mode & 0o7777 : null,
349
+ beforeRename,
350
+ })
351
+ const after = fs.statSync(resolved.file)
352
+ return { version: fingerprintFile(resolved.file, after) }
353
+ },
354
+ }
355
+ }