- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
254 lines
10 KiB
JavaScript
254 lines
10 KiB
JavaScript
/**
|
||
* @dsh-local/workspace-scoped-picker — 会话内工作区目录选择器(根 = 用户自有目录)。
|
||
*
|
||
* 档案 18:官方 `dsh-host-directory-picker-browse` 的策略是 whole-filesystem scope
|
||
* (向导 /etc、/usr、/proc),本包把 enumerate/create 的根收敛为「当前用户自有目录」,
|
||
* **所有用户含 admin 一视同仁**;越界一律抛 seam 的封闭错误码。
|
||
*
|
||
* 根解析优先级:`process.env.DSH_WORKSPACE_ROOT` → `process.cwd()`
|
||
* (编排器 spawn 时以 `--chdir <userRoot>/ws` 启动,故 cwd 即用户工作区,双保险)。
|
||
*
|
||
* 依赖策略(红线 R2:不复制、不修改官方包):
|
||
* 唯一需要官方物 = seam 基类;用**绝对路径动态 import** 取得,解析到与核心同一个模块实例
|
||
* (ESM 模块缓存按 realpath 去重)。可选 env `DSH_SEAM_DIRECTORY_PICKER` 指定路径。
|
||
*
|
||
* 官方契约(对齐 `dsh-host-directory-picker` 类型定义 与 `-browse` 实现):
|
||
* - 默认导出 = 继承 `DirectoryPicker` 的 Service 子类;加载即注册 `ctx.directoryPicker`
|
||
* - `capability()` 返回稳定对象:`{ kind: 'browse', list(path?, signal?), createDirectory(path, name) }`
|
||
* - 列举只返回**目录**行,按名排序,跟随指向目录的符号链接,`hidden` = 点号前缀
|
||
* - `crumbs` = 祖先链(本实现以自有根为顶层,而不是文件系统 /)
|
||
* - 错误码封闭:`directory-unreadable` / `directory-exists` / `directory-create-failed`
|
||
*
|
||
* @module @dsh-local/workspace-scoped-picker
|
||
*/
|
||
|
||
import { mkdir, opendir, realpath, stat } from 'node:fs/promises'
|
||
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
||
import { pathToFileURL } from 'node:url'
|
||
|
||
/** 单个列举层级的行数上限(与官方后端同为 GitHub 风格 1000)。 */
|
||
const MAX_ENTRIES = 1000
|
||
|
||
/** 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。 */
|
||
const DEFAULT_SEAM_CANDIDATES = [
|
||
'/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js',
|
||
'/usr/local/lib/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js',
|
||
]
|
||
|
||
/** 解析并导入官方 seam 基类。 */
|
||
async function loadSeam() {
|
||
const candidates = []
|
||
const override = process.env.DSH_SEAM_DIRECTORY_PICKER
|
||
if (override !== undefined && override !== '') candidates.push(override)
|
||
candidates.push(...DEFAULT_SEAM_CANDIDATES)
|
||
const failures = []
|
||
for (const candidate of candidates) {
|
||
try {
|
||
const mod = await import(pathToFileURL(candidate).href)
|
||
if (typeof mod.DirectoryPicker !== 'function') throw new Error('seam module has no DirectoryPicker export')
|
||
return mod
|
||
} catch (error) {
|
||
failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`)
|
||
}
|
||
}
|
||
throw new Error(
|
||
`workspace-scoped-picker: 无法解析官方 seam 基类(@deepseek-ai/dsh-host-directory-picker)。已尝试:\n ${failures.join('\n ')}\n` +
|
||
'可通过环境变量 DSH_SEAM_DIRECTORY_PICKER 指定该包 lib/index.js 的绝对路径。',
|
||
)
|
||
}
|
||
|
||
const { DirectoryPicker, DirectoryPickerError } = await loadSeam()
|
||
|
||
/** 单段目录名校验(不得含分隔符,不得为 . / ..)。 */
|
||
function isSingleSegment(name) {
|
||
return name.trim() !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
|
||
}
|
||
|
||
/**
|
||
* 自有目录作用域内的目录选择服务。
|
||
* 范围语义:`list()` 的根 = 自有目录;向上不可越界;`crumbs` 顶层即自有目录。
|
||
*/
|
||
class WorkspaceScopedDirectoryPicker extends DirectoryPicker {
|
||
/** 自有根(绝对路径)。 */
|
||
root = resolve(process.env.DSH_WORKSPACE_ROOT ?? process.cwd())
|
||
|
||
/** 稳定的 browse 能力对象(consumers 可能跨调用缓存它)。 */
|
||
browseCapability = {
|
||
kind: 'browse',
|
||
list: (path, signal) => this.list(path, signal),
|
||
createDirectory: (path, name) => this.createDirectory(path, name),
|
||
}
|
||
|
||
/** @param ctx - cordis 上下文(由 loader 注入)。 */
|
||
constructor(ctx) {
|
||
super(ctx)
|
||
// 加载标记:实例启动日志里可直接确认本插件是否生效(排障用,2026-09-11)。
|
||
process.stderr.write(
|
||
`[workspace-scoped-picker] loaded root=${this.root} (${process.env.DSH_WORKSPACE_ROOT !== undefined ? 'env' : 'cwd'})\n`,
|
||
)
|
||
}
|
||
|
||
/** @returns 稳定的 `browse` 能力对象。 */
|
||
capability() {
|
||
return this.browseCapability
|
||
}
|
||
|
||
/** 自有根(含符号链接解析后的真实路径)。 */
|
||
async realRoot() {
|
||
try {
|
||
return await realpath(this.root)
|
||
} catch {
|
||
return this.root
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 校验候选路径落在自有根之内(先词法归一,再 realpath 抗符号链接逃逸)。
|
||
* 注:用普通方法而非 `#私有字段`——服务实例可能被框架 Proxy 包装,私有 brand 检查会失败。
|
||
* @param candidate - 待校验的绝对路径。
|
||
* @param code - 校验失败时抛出的封闭错误码。
|
||
* @returns 归一后的绝对路径。
|
||
*/
|
||
async assertInside(candidate, code) {
|
||
if (typeof candidate !== 'string' || !isAbsolute(candidate)) {
|
||
throw new DirectoryPickerError(code, String(candidate), `not a fully qualified path: ${String(candidate)}`)
|
||
}
|
||
const target = resolve(candidate)
|
||
const realRoot = await this.realRoot()
|
||
const lexicalOk = target === realRoot || target.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep)
|
||
if (!lexicalOk) {
|
||
throw new DirectoryPickerError(code, target, `outside the workspace root: ${target}`)
|
||
}
|
||
// 抗符号链接:若目标已存在,比较真实路径;不存在则沿用词法判定(调用方随后会自然失败)。
|
||
try {
|
||
const realTarget = await realpath(target)
|
||
const inside =
|
||
realTarget === realRoot || realTarget.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep)
|
||
if (!inside) throw new DirectoryPickerError(code, target, `symlink escapes the workspace root: ${target}`)
|
||
} catch (error) {
|
||
if (error instanceof DirectoryPickerError) throw error
|
||
// ENOENT:目标不存在,交由上层语义处理(列举会报 directory-unreadable)。
|
||
}
|
||
return target
|
||
}
|
||
|
||
/**
|
||
* 自有根到目标(含)的祖先链,顶层即自有根 —— crumbs 不暴露文件系统 /。
|
||
* 顶层用**友好名**(默认「我的工作区」,可用 DSH_WORKSPACE_LABEL 覆盖),
|
||
* 不在 UI 上展示 `/var/lib/.../users/<uuid>/ws` 这类完整路径(档案 24 后续项)。
|
||
*/
|
||
crumbs(target) {
|
||
const rootLabel = process.env.DSH_WORKSPACE_LABEL ?? '我的工作区'
|
||
const chain = []
|
||
let current = target
|
||
for (;;) {
|
||
chain.unshift({
|
||
name: current === this.root ? rootLabel : basename(current),
|
||
path: current,
|
||
hidden: false,
|
||
})
|
||
if (current === this.root) return chain
|
||
const parent = dirname(current)
|
||
if (parent === current) return chain // 兜底:理论不可达(越界已在入口拦截)
|
||
current = parent
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 列举自有根内的一层目录。
|
||
* @param path - 省略时列举自有根。
|
||
* @param signal - 可选中止信号。
|
||
* @returns 列举结果(`home` 锚点为自有根)。
|
||
*/
|
||
async list(path, signal) {
|
||
const target = path === undefined ? this.root : await this.assertInside(path, 'directory-unreadable')
|
||
let dir
|
||
try {
|
||
dir = await opendir(target)
|
||
} catch (error) {
|
||
throw new DirectoryPickerError(
|
||
'directory-unreadable',
|
||
target,
|
||
`cannot list ${target}: ${error instanceof Error ? error.message : String(error)}`,
|
||
)
|
||
}
|
||
const names = []
|
||
let truncated = false
|
||
try {
|
||
for await (const entry of dir) {
|
||
if (signal?.aborted === true) throw new DirectoryPickerError('directory-unreadable', target, 'aborted')
|
||
if (names.length >= MAX_ENTRIES) {
|
||
truncated = true
|
||
break
|
||
}
|
||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
||
const child = join(target, entry.name)
|
||
if (entry.isSymbolicLink()) {
|
||
// 与官方后端一致:跟随指向目录的符号链接;断链/指向文件则跳过。
|
||
try {
|
||
const st = await stat(child)
|
||
if (!st.isDirectory()) continue
|
||
} catch {
|
||
continue
|
||
}
|
||
}
|
||
// 符号链接目标也必须留在自有根内,否则不展示(避免借链接越界浏览)。
|
||
try {
|
||
await this.assertInside(child, 'directory-unreadable')
|
||
} catch {
|
||
continue
|
||
}
|
||
names.push(entry.name)
|
||
}
|
||
} finally {
|
||
await dir.close().catch(() => undefined)
|
||
}
|
||
names.sort((a, b) => a.localeCompare(b))
|
||
return {
|
||
path: target,
|
||
home: this.root,
|
||
crumbs: this.crumbs(target),
|
||
entries: names.map((name) => ({
|
||
name,
|
||
path: join(target, name),
|
||
hidden: name.startsWith('.'),
|
||
})),
|
||
truncated,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 在自有根内的既有父目录下创建单层子目录。
|
||
* @param path - 父目录(省略时用自有根)。
|
||
* @param name - 单个路径段。
|
||
* @returns 新目录的绝对路径。
|
||
*/
|
||
async createDirectory(path, name) {
|
||
const parent = path === undefined || path === '' ? this.root : await this.assertInside(path, 'directory-create-failed')
|
||
if (typeof name !== 'string' || !isSingleSegment(name)) {
|
||
throw new DirectoryPickerError(
|
||
'directory-create-failed',
|
||
join(parent, String(name)),
|
||
`"${String(name)}" is not a single path segment`,
|
||
)
|
||
}
|
||
const target = join(parent, name)
|
||
await this.assertInside(target, 'directory-create-failed')
|
||
try {
|
||
await mkdir(target)
|
||
return target
|
||
} catch (error) {
|
||
const code = error instanceof Error && 'code' in error ? error.code : undefined
|
||
if (code === 'EEXIST') throw new DirectoryPickerError('directory-exists', target, `${target} already exists`)
|
||
throw new DirectoryPickerError(
|
||
'directory-create-failed',
|
||
target,
|
||
`cannot create ${target}: ${error instanceof Error ? error.message : String(error)}`,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
export { WorkspaceScopedDirectoryPicker, isSingleSegment }
|
||
export default WorkspaceScopedDirectoryPicker
|