cs-bots 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.
@@ -0,0 +1,555 @@
1
+ /**
2
+ * cs-bots /dirbrowse —— 给 Web UI 用的「目录浏览器」后端。
3
+ *
4
+ * ── 为什么自建(不用宿主原生目录选择器)───────────────────────────────────
5
+ * 浏览器 / Web UI 里没有可靠的「选目录」原语:`<input type="file" webkitdirectory>`
6
+ * 只能选文件、拿不到绝对路径;部分 Linux 环境 + 部分浏览器下宿主的选择器直接不可用。
7
+ * ⇒ 我们在**服务端**做目录遍历,UI 只负责发 `list()` / `mkdir()` 请求。
8
+ *
9
+ * ── 安全模型(这是给浏览器开的文件系统口子,必须假定调用方不可信)────────────
10
+ * ① 一律 `resolve()` 后再做**存在性 / 类型**复核,绝不把原始字符串拼进路径。
11
+ * ② 名字(mkdir / rename / resolveSuggested)只允许单段文件名,
12
+ * `/`、`\`、`..`、控制字符、Windows 保留字符一律 `DIR_BAD_NAME`。
13
+ * ③ **写操作**(mkdir / rename)锚定在「允许范围」内:
14
+ * 家目录 ∪ 进程 cwd ∪ config.roots。判定用 **realpath**,
15
+ * 所以「允许范围内的符号链接指向范围外」也会被拦(`DIR_OUTSIDE_ROOTS`)。
16
+ * 读操作不设限,只用一个 `outsideAllowedRoots` 标记告知 UI(用户要能往上翻)。
17
+ * ④ 权限不足(EACCES)**不抛**:返回空列表 + `error: 'EACCES'`,
18
+ * 否则用户会卡在一个永远翻不上去的目录里。
19
+ *
20
+ * ── 与 botprofile.js 一致的约定 ─────────────────────────────────────────────
21
+ * 零第三方依赖(只 node:fs / node:path / node:os);参数校验抛错、不静默兜底;
22
+ * 任何截断都显式告知(`truncated: true`),不让调用方误以为看到了全量。
23
+ */
24
+
25
+ import {
26
+ accessSync,
27
+ constants as fsConstants,
28
+ lstatSync,
29
+ mkdirSync,
30
+ readdirSync,
31
+ realpathSync,
32
+ renameSync,
33
+ statSync,
34
+ } from 'node:fs'
35
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
36
+ import { homedir } from 'node:os'
37
+
38
+ /** 单次 list 最多返回的条目数(超出显式 truncated)。 */
39
+ export const MAX_ENTRIES = 500
40
+
41
+ /** 合法单段名:不含路径分隔符、Windows 保留字符、控制字符,长度 1..255。 */
42
+ export const NAME_PATTERN = /^[^/\\:*?"<>|\x00-\x1f]{1,255}$/
43
+
44
+ /**
45
+ * 系统顶层目录名(小写)——Bot 工作目录放在这些位置只给 warning 不给 error:
46
+ * 它们本身可写但语义上是系统区域,放 Bot 进去会「看起来能跑、实则污染系统」。
47
+ * `/` 与盘符根单独判定(见 isSystemTopDir)。
48
+ */
49
+ const SYSTEM_TOP_NAMES = new Set([
50
+ 'bin', 'boot', 'dev', 'etc', 'home', 'lib', 'lib32', 'lib64', 'media', 'mnt',
51
+ 'opt', 'proc', 'root', 'run', 'sbin', 'srv', 'sys', 'tmp', 'usr', 'var',
52
+ ])
53
+
54
+ /** 目录浏览器错误(带机器可读 code,HTTP 层直接映射状态码即可)。 */
55
+ export class DirBrowseError extends Error {
56
+ constructor(message, code, detail) {
57
+ super(message)
58
+ this.name = 'DirBrowseError'
59
+ this.code = code
60
+ this.detail = detail
61
+ }
62
+ }
63
+
64
+ // ── 小工具 ───────────────────────────────────────────────────────────────────
65
+
66
+ /** 造一个带 code 的错误(少写 new)。 */
67
+ function fail(message, code, detail) {
68
+ return new DirBrowseError(message, code, detail)
69
+ }
70
+
71
+ /** 取错误里的 code(非标准错误返回 undefined)。 */
72
+ function errCode(error) {
73
+ return typeof error?.code === 'string' ? error.code : undefined
74
+ }
75
+
76
+ /** realpath,失败则退回 resolve(目录可能在检查间隙被删)。 */
77
+ function realOrSelf(p) {
78
+ try {
79
+ return realpathSync(p)
80
+ } catch {
81
+ return resolve(p)
82
+ }
83
+ }
84
+
85
+ /**
86
+ * child 是否在 parent 之内(含相等)。
87
+ * 用 relative() 而不是字符串前缀 —— 前缀法会把 `/home/u2` 误判成在 `/home/u` 内。
88
+ */
89
+ function isInside(child, parent) {
90
+ const rel = relative(parent, child)
91
+ if (rel === '') return true
92
+ return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
93
+ }
94
+
95
+ /** 是否就是文件系统根(`/` 或 `C:\`)。 */
96
+ function isFsRoot(p) {
97
+ return dirname(p) === p
98
+ }
99
+
100
+ /**
101
+ * 是否是「系统顶层目录」——`/`、`/home`、`/usr`、`/etc`、`/tmp`、`C:\` 这类。
102
+ * 只按深度 1 + 已知系统名判定,不对 `/home/ubuntu/work` 误报。
103
+ */
104
+ function isSystemTopDir(p) {
105
+ if (/^[A-Za-z]:[\\/]?$/.test(p)) return true
106
+ if (isFsRoot(p)) return true
107
+ const parent = dirname(p)
108
+ if (!isFsRoot(parent)) return false
109
+ const name = p.slice(parent.length).replace(/^[\\/]+/, '').toLowerCase()
110
+ return SYSTEM_TOP_NAMES.has(name)
111
+ }
112
+
113
+ /** 当前进程能否在该目录创建东西。 */
114
+ function canWrite(dir) {
115
+ try {
116
+ accessSync(dir, fsConstants.W_OK)
117
+ return true
118
+ } catch {
119
+ return false
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 校验并规范化一个「必须存在且是目录」的绝对路径。
125
+ * @returns {string} 绝对路径
126
+ */
127
+ function takeDirPath(value, label) {
128
+ const abs = takeAbsPath(value, label)
129
+ let st
130
+ try {
131
+ st = statSync(abs)
132
+ } catch (error) {
133
+ const code = errCode(error)
134
+ if (code === 'EACCES' || code === 'EPERM') {
135
+ throw fail(`无权访问:${abs}`, 'DIR_EACCES', { label, path: abs })
136
+ }
137
+ throw fail(`目录不存在:${abs}`, 'DIR_NOT_FOUND', { label, path: abs, cause: String(error?.message ?? error) })
138
+ }
139
+ if (!st.isDirectory()) {
140
+ throw fail(`不是目录:${abs}`, 'DIR_NOT_DIRECTORY', { label, path: abs })
141
+ }
142
+ return abs
143
+ }
144
+
145
+ /** 校验绝对路径(不要求存在)。 */
146
+ function takeAbsPath(value, label) {
147
+ if (typeof value !== 'string' || value.trim() === '') {
148
+ throw fail(`${label} 必填,且必须是字符串`, 'DIR_BAD_PATH', { label, value })
149
+ }
150
+ const raw = value.trim()
151
+ if (!isAbsolute(raw)) {
152
+ throw fail(`${label} 必须是绝对路径,收到 ${JSON.stringify(value)}`, 'DIR_NOT_ABSOLUTE', { label, value })
153
+ }
154
+ return resolve(raw)
155
+ }
156
+
157
+ /**
158
+ * 校验单段目录名。`name` 里出现 `/`、`\`、`..` 直接拒 —— 这是防穿越的第一道闸。
159
+ * @param {unknown} value
160
+ * @returns {string} 去掉首尾空白后的合法名
161
+ */
162
+ function takeName(value) {
163
+ if (typeof value !== 'string') {
164
+ throw fail(`name 必须是字符串,收到 ${typeof value}`, 'DIR_BAD_NAME', { value })
165
+ }
166
+ const name = value.trim()
167
+ if (name === '') {
168
+ throw fail('name 不能为空', 'DIR_BAD_NAME', { value })
169
+ }
170
+ if (name === '.' || name === '..' || name.includes('..')) {
171
+ throw fail(`name 不允许出现 "..":${JSON.stringify(value)}`, 'DIR_BAD_NAME', { value })
172
+ }
173
+ if (name.includes('/') || name.includes('\\')) {
174
+ throw fail(`name 不允许包含路径分隔符:${JSON.stringify(value)}`, 'DIR_BAD_NAME', { value })
175
+ }
176
+ if (name.length > 255) {
177
+ throw fail(`name 超长:${name.length} > 255`, 'DIR_BAD_NAME', { value, length: name.length })
178
+ }
179
+ if (!NAME_PATTERN.test(name)) {
180
+ throw fail(
181
+ `name 含非法字符:${JSON.stringify(value)}(不允许 / \\ : * ? " < > | 与控制字符)`,
182
+ 'DIR_BAD_NAME',
183
+ { value },
184
+ )
185
+ }
186
+ return name
187
+ }
188
+
189
+ /** 配置里的路径字段(stateDir / dshHome):必须是字符串,缺省即抛,不静默兜底。 */
190
+ function takeConfigPath(value, field, hint) {
191
+ if (typeof value !== 'string' || value.trim() === '') {
192
+ throw fail(
193
+ `dirbrowse.createDirBrowser:config.${field} 必填${hint ? `(${hint})` : ''}`,
194
+ 'DIR_BAD_CONFIG',
195
+ { field, value },
196
+ )
197
+ }
198
+ return resolve(value.trim())
199
+ }
200
+
201
+ /** 额外的允许 roots(数组,逐项 resolve)。 */
202
+ function takeRoots(value) {
203
+ if (value === undefined || value === null) return []
204
+ if (!Array.isArray(value)) {
205
+ throw fail('config.roots 必须是字符串数组', 'DIR_BAD_CONFIG', { value })
206
+ }
207
+ return value.map((v, i) => {
208
+ if (typeof v !== 'string' || v.trim() === '') {
209
+ throw fail(`config.roots[${i}] 必须是非空字符串`, 'DIR_BAD_CONFIG', { index: i, value: v })
210
+ }
211
+ return resolve(v.trim())
212
+ })
213
+ }
214
+
215
+ /** 排序:目录在前,其次按名字(大小写不敏感,再用原串兜底以保稳定)。 */
216
+ function compareEntries(a, b) {
217
+ const aDir = a.kind === 'dir' ? 0 : 1
218
+ const bDir = b.kind === 'dir' ? 0 : 1
219
+ if (aDir !== bDir) return aDir - bDir
220
+ const al = a.name.toLowerCase()
221
+ const bl = b.name.toLowerCase()
222
+ if (al < bl) return -1
223
+ if (al > bl) return 1
224
+ if (a.name < b.name) return -1
225
+ if (a.name > b.name) return 1
226
+ return 0
227
+ }
228
+
229
+ // ── 工厂 ─────────────────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * 创建一个目录浏览器实例。
233
+ * @param {{ stateDir: string, roots?: string[], dshHome?: string }} config
234
+ * @returns {{
235
+ * list: (args?: { path?: string, showHidden?: boolean }) => object,
236
+ * mkdir: (args: { path: string, name: string }) => object,
237
+ * rename: (args: { path: string, name: string }) => object,
238
+ * validateAsBotCwd: (args: { path: string }) => object,
239
+ * resolveSuggested: (args: { parent: string, name: string }) => object,
240
+ * roots: () => { home: string, cwd: string, roots: string[] },
241
+ * dispose: () => void,
242
+ * }}
243
+ */
244
+ export function createDirBrowser(config) {
245
+ const cfg = config ?? {}
246
+ if (typeof cfg !== 'object' || Array.isArray(cfg)) {
247
+ throw fail('dirbrowse.createDirBrowser:config 必须是对象', 'DIR_BAD_CONFIG')
248
+ }
249
+
250
+ // stateDir 是「必须保护」的区域之一,缺了就无法判断,所以强制要求显式给出。
251
+ const stateDir = takeConfigPath(cfg.stateDir, 'stateDir', '裸检出必须显式传')
252
+ const dshHome = takeConfigPath(
253
+ cfg.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'),
254
+ 'dshHome',
255
+ '未传也未设 DSH_HOME 时默认 ~/.dsh',
256
+ )
257
+ const extraRoots = takeRoots(cfg.roots)
258
+ const home = resolve(homedir())
259
+ /** 家目录下永远不许当 Bot cwd 的目录(官方技能目录)。 */
260
+ const agentsDir = join(home, '.agents')
261
+
262
+ /** 允许范围:读不限制,写只允许落在这里面(realpath 判定)。 */
263
+ function allowedRoots() {
264
+ return [realOrSelf(home), realOrSelf(resolve(process.cwd())), ...extraRoots.map(realOrSelf)]
265
+ }
266
+
267
+ /** 是否落在允许范围内(用于读侧标记:字面路径或 realpath 任一命中都算)。 */
268
+ function withinAllowed(abs) {
269
+ const candidates = [abs, realOrSelf(abs)]
270
+ return allowedRoots().some((root) => candidates.some((c) => isInside(c, root)))
271
+ }
272
+
273
+ /**
274
+ * 写侧判定:**只认 realpath**。
275
+ * 字面路径在范围内但软链指向范围外 ⇒ 不算在范围内(否则软链就是越狱通道)。
276
+ */
277
+ function withinAllowedForWrite(dir) {
278
+ let real
279
+ try {
280
+ real = realpathSync(dir)
281
+ } catch {
282
+ real = resolve(dir) // 父目录刚被删的竞态:退回字面路径
283
+ }
284
+ return allowedRoots().some((root) => isInside(real, root))
285
+ }
286
+
287
+ /** 写操作的位置闸门。 */
288
+ function assertWritableLocation(dir, label) {
289
+ if (!withinAllowedForWrite(dir)) {
290
+ throw fail(
291
+ `拒绝在允许范围外写入:${dir}(允许:家目录 / 进程 cwd / config.roots;软链指向范围外同样拒绝)`,
292
+ 'DIR_OUTSIDE_ROOTS',
293
+ { label, path: dir, real: realOrSelf(dir), roots: allowedRoots() },
294
+ )
295
+ }
296
+ }
297
+
298
+ /**
299
+ * 列出目录内容。
300
+ * @param {{ path?: string, showHidden?: boolean }} [args]
301
+ */
302
+ function list(args = {}) {
303
+ const opts = args ?? {}
304
+ const showHidden = opts.showHidden === true
305
+ const abs = opts.path === undefined ? resolve(homedir()) : takeAbsPath(opts.path, 'path')
306
+ const parentPath = dirname(abs)
307
+ const parent = parentPath === abs ? null : parentPath
308
+ const outside = !withinAllowed(abs)
309
+
310
+ let st
311
+ try {
312
+ st = statSync(abs)
313
+ } catch (error) {
314
+ const code = errCode(error)
315
+ if (code === 'EACCES' || code === 'EPERM') {
316
+ // 权限不足:返回空列表而不是抛错,用户才能继续往上翻。
317
+ return { path: abs, parent, entries: [], writable: false, outsideAllowedRoots: outside, error: 'EACCES' }
318
+ }
319
+ throw fail(`目录不存在:${abs}`, 'DIR_NOT_FOUND', { path: abs, cause: String(error?.message ?? error) })
320
+ }
321
+ if (!st.isDirectory()) {
322
+ throw fail(`不是目录:${abs}`, 'DIR_NOT_DIRECTORY', { path: abs })
323
+ }
324
+
325
+ let names
326
+ try {
327
+ names = readdirSync(abs)
328
+ } catch (error) {
329
+ const code = errCode(error)
330
+ if (code === 'EACCES' || code === 'EPERM') {
331
+ return { path: abs, parent, entries: [], writable: false, outsideAllowedRoots: outside, error: 'EACCES' }
332
+ }
333
+ throw fail(`读取目录失败:${abs}(${error?.message ?? error})`, 'DIR_READ_FAILED', {
334
+ path: abs,
335
+ cause: String(error?.message ?? error),
336
+ })
337
+ }
338
+
339
+ const entries = []
340
+ for (const name of names) {
341
+ if (!showHidden && name.startsWith('.')) continue
342
+ const full = join(abs, name)
343
+ let est
344
+ try {
345
+ // 跟随符号链接取类型(读操作允许指向范围外)。
346
+ est = statSync(full)
347
+ } catch {
348
+ // 坏链接 / 权限不足:仍然展示,标成 other,别让用户以为目录里"少了东西"。
349
+ entries.push({ name, path: full, kind: 'other', mtime: null })
350
+ continue
351
+ }
352
+ const kind = est.isDirectory() ? 'dir' : est.isFile() ? 'file' : 'other'
353
+ const entry = { name, path: full, kind }
354
+ if (kind === 'file') entry.size = est.size
355
+ entry.mtime = Math.floor(est.mtimeMs / 1000)
356
+ entries.push(entry)
357
+ }
358
+
359
+ entries.sort(compareEntries)
360
+ const truncated = entries.length > MAX_ENTRIES
361
+ const out = {
362
+ path: abs,
363
+ parent,
364
+ entries: truncated ? entries.slice(0, MAX_ENTRIES) : entries,
365
+ writable: canWrite(abs),
366
+ outsideAllowedRoots: outside,
367
+ }
368
+ if (truncated) {
369
+ out.truncated = true
370
+ out.total = entries.length
371
+ }
372
+ return out
373
+ }
374
+
375
+ /**
376
+ * 在 `path` 下新建子目录 `name`。
377
+ * @param {{ path: string, name: string }} args
378
+ */
379
+ function mkdir(args) {
380
+ const opts = args ?? {}
381
+ const dir = takeDirPath(opts.path, 'path')
382
+ const name = takeName(opts.name)
383
+ const target = join(dir, name)
384
+
385
+ // 用 lstat:坏符号链接也算「已存在」,不能悄悄覆盖。
386
+ let exists = false
387
+ try {
388
+ lstatSync(target)
389
+ exists = true
390
+ } catch (error) {
391
+ if (errCode(error) !== 'ENOENT' && errCode(error) !== 'ENOTDIR') {
392
+ throw fail(`无法检查目标:${target}(${error?.message ?? error})`, 'DIR_READ_FAILED', {
393
+ path: target,
394
+ cause: String(error?.message ?? error),
395
+ })
396
+ }
397
+ }
398
+ if (exists) {
399
+ throw fail(`同名目录/文件已存在:${target}`, 'DIR_EXISTS', { path: target })
400
+ }
401
+
402
+ assertWritableLocation(dir, 'path')
403
+
404
+ try {
405
+ // 非 recursive:父目录必须已存在(父缺失已在上面 takeDirPath 拦掉)
406
+ mkdirSync(target)
407
+ } catch (error) {
408
+ const code = errCode(error)
409
+ if (code === 'EEXIST') throw fail(`同名目录/文件已存在:${target}`, 'DIR_EXISTS', { path: target })
410
+ if (code === 'ENOENT') throw fail(`父目录不存在:${dir}`, 'DIR_NOT_FOUND', { path: dir })
411
+ if (code === 'EACCES' || code === 'EPERM') {
412
+ throw fail(`无权在 ${dir} 下创建目录`, 'DIR_EACCES', { path: target })
413
+ }
414
+ throw fail(`创建目录失败:${target}(${error?.message ?? error})`, 'DIR_WRITE_FAILED', {
415
+ path: target,
416
+ cause: String(error?.message ?? error),
417
+ })
418
+ }
419
+ return { path: target, created: true }
420
+ }
421
+
422
+ /**
423
+ * 把 `path` 重命名为同级的新名字 `name`(只改名,不移动)。
424
+ * @param {{ path: string, name: string }} args
425
+ */
426
+ function rename(args) {
427
+ const opts = args ?? {}
428
+ const from = takeDirPath(opts.path, 'path')
429
+ const parentPath = dirname(from)
430
+ if (parentPath === from) {
431
+ // 根目录 / 盘符根:parent 为 null ⇒ 不允许改名(挂载点也走这条语义)。
432
+ throw fail(`不允许重命名根目录或挂载点:${from}`, 'DIR_CANNOT_RENAME_ROOT', { path: from })
433
+ }
434
+ const name = takeName(opts.name)
435
+ const to = join(parentPath, name)
436
+ if (to === from) return { from, to }
437
+
438
+ let exists = false
439
+ try {
440
+ lstatSync(to)
441
+ exists = true
442
+ } catch (error) {
443
+ if (errCode(error) !== 'ENOENT' && errCode(error) !== 'ENOTDIR') {
444
+ throw fail(`无法检查目标:${to}(${error?.message ?? error})`, 'DIR_READ_FAILED', {
445
+ path: to,
446
+ cause: String(error?.message ?? error),
447
+ })
448
+ }
449
+ }
450
+ if (exists) {
451
+ throw fail(`目标已存在:${to}`, 'DIR_EXISTS', { path: to, from })
452
+ }
453
+
454
+ assertWritableLocation(parentPath, 'path')
455
+
456
+ try {
457
+ renameSync(from, to)
458
+ } catch (error) {
459
+ const code = errCode(error)
460
+ if (code === 'EEXIST' || code === 'ENOTEMPTY') throw fail(`目标已存在:${to}`, 'DIR_EXISTS', { path: to, from })
461
+ if (code === 'ENOENT') throw fail(`目录不存在:${from}`, 'DIR_NOT_FOUND', { path: from })
462
+ if (code === 'EACCES' || code === 'EPERM') throw fail(`无权重命名:${from}`, 'DIR_EACCES', { path: from })
463
+ throw fail(`重命名失败:${from} → ${to}(${error?.message ?? error})`, 'DIR_WRITE_FAILED', {
464
+ path: from,
465
+ to,
466
+ cause: String(error?.message ?? error),
467
+ })
468
+ }
469
+ return { from, to }
470
+ }
471
+
472
+ /**
473
+ * 校验「把 Bot 工作目录设成 path」是否可接受。
474
+ * errors 非空 ⇒ ok:false;只有 warnings ⇒ ok:true(用户可继续,但要知道风险)。
475
+ * @param {{ path: string }} args
476
+ */
477
+ function validateAsBotCwd(args) {
478
+ const opts = args ?? {}
479
+ const warnings = []
480
+ const errors = []
481
+
482
+ if (typeof opts.path !== 'string' || opts.path.trim() === '') {
483
+ errors.push('path 必填:需要给出 Bot 的工作目录(绝对路径)')
484
+ return { ok: false, path: null, warnings, errors }
485
+ }
486
+ const raw = opts.path.trim()
487
+ if (!isAbsolute(raw)) {
488
+ errors.push(`必须是绝对路径,收到 ${JSON.stringify(opts.path)}`)
489
+ return { ok: false, path: null, warnings, errors }
490
+ }
491
+ const abs = resolve(raw)
492
+
493
+ let st
494
+ try {
495
+ st = statSync(abs)
496
+ } catch (error) {
497
+ const code = errCode(error)
498
+ if (code === 'EACCES' || code === 'EPERM') errors.push(`目录不可访问:${abs}`)
499
+ else if (code === 'ENOENT' || code === 'ENOTDIR') errors.push(`目录不存在:${abs}`)
500
+ else errors.push(`目录不可用:${abs}(${error?.message ?? error})`)
501
+ return { ok: false, path: abs, warnings, errors }
502
+ }
503
+ if (!st.isDirectory()) {
504
+ errors.push(`不是目录:${abs}`)
505
+ return { ok: false, path: abs, warnings, errors }
506
+ }
507
+
508
+ // 受保护区域:用 realpath 判,防止「软链绕进来」。
509
+ const real = realOrSelf(abs)
510
+ const guarded = [
511
+ { dir: dshHome, label: 'DSH 家目录(Bot 会改坏宿主配置)' },
512
+ { dir: agentsDir, label: '~/.agents(宿主技能目录)' },
513
+ { dir: stateDir, label: 'cs-bots 状态目录' },
514
+ ]
515
+ for (const { dir, label } of guarded) {
516
+ const root = realOrSelf(dir)
517
+ if (isInside(real, root) || isInside(abs, dir)) {
518
+ errors.push(`不能在${label} 之内:${abs}`)
519
+ }
520
+ }
521
+
522
+ if (!canWrite(abs)) {
523
+ errors.push(`当前进程不可写:${abs}(Bot 无法在此创建文件)`)
524
+ }
525
+
526
+ if (isSystemTopDir(abs)) {
527
+ warnings.push(`不要把 Bot 放在系统根目录:${abs}`)
528
+ }
529
+
530
+ return { ok: errors.length === 0, path: abs, warnings, errors }
531
+ }
532
+
533
+ /**
534
+ * 计算「在 parent 下新建 name 给 Bot 用」的目标路径 —— 不创建任何东西。
535
+ * @param {{ parent: string, name: string }} args
536
+ */
537
+ function resolveSuggested(args) {
538
+ const opts = args ?? {}
539
+ const parent = takeAbsPath(opts.parent, 'parent')
540
+ const name = takeName(opts.name)
541
+ return { path: join(parent, name) }
542
+ }
543
+
544
+ /** 给 UI 的快捷入口:家目录 / 当前目录 / 额外 roots。 */
545
+ function roots() {
546
+ return { home, cwd: process.cwd(), roots: [...extraRoots] }
547
+ }
548
+
549
+ /** 无状态实现,dispose 只作为接口占位(保持与其他服务一致的生命周期)。 */
550
+ function dispose() {}
551
+
552
+ return { list, mkdir, rename, validateAsBotCwd, resolveSuggested, roots, dispose }
553
+ }
554
+
555
+ export default createDirBrowser