fix(windows): canonicalize native watch paths

This commit is contained in:
Tianyi Cui
2026-08-08 19:29:43 +08:00
parent a6c129a3c6
commit 702e2d024a
21 changed files with 157 additions and 36 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/util/paths/README.md
README.md: 2b3272e019ef2f37386da9156b06a5c151836d8c
README.zh.md: 7fe0ec04117ae439ade653cefd1c8f5da094d8fd
README.md: 8d10ed855a37f1205f87420b3f36f45b10e65bd3
README.zh.md: ed3ca377bd48252fe0ef3f95186dc6eb1fb6e6a0
+5 -1
View File
@@ -18,9 +18,13 @@ Shared filesystem path helpers for DeepSeek Harness user data.
`expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched.
## Watch paths
`canonicalizeWatchPath()` gives a native filesystem watcher one stable spelling of its target. It resolves the deepest existing ancestor through `fs.realpath()` and restores any missing suffix, so a file or directory may still be watched before it is created. In particular, Windows 8.3 aliases cannot be mixed with the long paths emitted by the native watcher backend.
This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another.
## Known Limitations and Deferred Work
- **Expansion is deliberately narrow** — only bare `~`, `~/...`, and `~\...` use the current operating-system home; named-user forms such as `~alice/...`, environment variables, and shell expressions remain unchanged.
- **Helpers do not touch the filesystem** — callers still own directory creation, existence checks, permissions, and trust policy for the resulting path.
- **Canonicalization reads but never mutates** — `canonicalizeWatchPath()` performs `realpath` probes and propagates errors other than absence; callers still own directory creation, permissions, and trust policy for the resulting path.
+5 -1
View File
@@ -18,9 +18,13 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。
`expandHomePath()` 使用操作系统主目录展开 `~``~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。
## 监听路径
`canonicalizeWatchPath()` 为原生文件系统 watcher 提供一种稳定的目标路径表示。它通过 `fs.realpath()` 解析层级最深的现有祖先路径,再拼回缺失的后缀,因此即使文件或目录尚未创建也仍可监听。尤其是,Windows 8.3 别名不能与原生 watcher 后端发出的长路径混用。
该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。
## 已知限制与暂缓事项
- **展开范围刻意保持狭窄**:只有单独的 `~``~/...``~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。
- **辅助工具不会操作文件系统**调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。
- **规范化会读取,但绝不修改**`canonicalizeWatchPath()` 会执行 `realpath` 探测,并传播除路径不存在以外的错误;调用方仍负责目录创建、权限,以及对结果路径应用信任策略。
+29 -1
View File
@@ -4,8 +4,9 @@
* @module @deepseek-ai/dsh-paths
*/
import { realpath } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { basename, dirname, join, resolve } from 'node:path'
/** Directory name for the default DeepSeek Harness home under the OS home. */
export const DSH_HOME_DIR_NAME = '.dsh'
@@ -16,6 +17,33 @@ export const DEFAULT_DSH_HOME_DISPLAY = `~/${DSH_HOME_DIR_NAME}`
/** Environment variable that overrides the default DeepSeek Harness home. */
export const DSH_HOME_ENV = 'DSH_HOME'
/**
* Give a native filesystem watcher one canonical spelling of a path, even
* when its final components do not exist yet. The deepest existing ancestor
* is resolved through {@link realpath}; the missing suffix is then restored.
* This prevents Windows short-name aliases from being mixed with long paths
* emitted by the native watcher backend.
* @param path - Watch target or root, resolved against the current directory.
* @returns the target with its existing ancestor canonicalized.
* @throws when ancestor traversal encounters an error other than absence.
*/
export async function canonicalizeWatchPath(path: string): Promise<string> {
let current = resolve(path)
const missing: string[] = []
while (true) {
try {
return join(await realpath(current), ...missing.reverse())
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const parent = dirname(current)
/* v8 ignore next -- a filesystem root exists, so traversal resolves before this guard */
if (parent === current) throw error
missing.push(basename(current))
current = parent
}
}
}
/**
* Resolve the default DeepSeek Harness home using Node's platform path rules.
* @returns the absolute default harness home path.
+21 -1
View File
@@ -1,9 +1,11 @@
import { homedir } from 'node:os'
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
DEFAULT_DSH_HOME_DISPLAY,
DSH_HOME_DIR_NAME,
canonicalizeWatchPath,
defaultDshHome,
dshHomeDisplay,
dshHomePath,
@@ -53,4 +55,22 @@ describe('dsh path helpers', () => {
expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh')
expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME')
})
it('canonicalizes a watcher ancestor while preserving a missing suffix', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-watch-path-'))
const target = join(root, 'target')
const alias = join(root, 'alias')
try {
await mkdir(target)
await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
await expect(canonicalizeWatchPath(join(alias, 'later', 'config.yml'))).resolves.toBe(
join(await realpath(target), 'later', 'config.yml'),
)
const file = join(root, 'file')
await writeFile(file, 'not a directory')
await expect(canonicalizeWatchPath(join(file, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' })
} finally {
await rm(root, { recursive: true, force: true })
}
})
})