@deepseek-ai/dsh-fs-local 0.0.1-rc.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/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +44 -0
- package/README.zh.md +44 -0
- package/lib/index.js +784 -0
- package/lib/invariant.js +23 -0
- package/lib/types/fsio.d.ts +189 -0
- package/lib/types/index.d.ts +62 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/win32.d.ts +25 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, DeepSeek
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md
|
|
5
|
+
README.md: a3239905e3eebaae7fa3099122ee3a4ed91d3fe8
|
|
6
|
+
README.zh.md: bbd9d2f66c4e582011bd0ea459e6c342eb653bda
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-fs-local
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
The **local-filesystem implementation** of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eleven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
|
|
6
|
+
|
|
7
|
+
```ts ignore-check
|
|
8
|
+
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
|
9
|
+
|
|
10
|
+
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
|
11
|
+
// ctx.fs uses the local backend; load @deepseek-ai/dsh-fs-policy for the
|
|
12
|
+
// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit.
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Behavior
|
|
16
|
+
|
|
17
|
+
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
|
|
18
|
+
- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`.
|
|
19
|
+
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
|
|
20
|
+
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing.
|
|
21
|
+
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
|
22
|
+
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a regular file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`, while a non-regular path entry is preserved and rejected with `FS_NOT_REGULAR_FILE`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). An overwrite returns the prior text as its contextual diff basis only when both the opened prior file and UTF-8 replacement are strictly below `config.diffBasisMaxBytes` (default 10 MiB). The descriptor read enforces that limit even if an external writer replaces or changes the file size after the initial probe. Otherwise the provider returns `before: null`, so presentation uses its whole-file fallback.
|
|
23
|
+
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
|
|
24
|
+
|
|
25
|
+
The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
|
|
26
|
+
|
|
27
|
+
## Model Experience
|
|
28
|
+
|
|
29
|
+
Indirectly, through [`dsh-tool-fs`](../tool-fs/README.md), which renders this provider's line-windowed UTF-8 content, mutation acknowledgements, and exact provider messages in capped retained results while versions, atomic-write mechanics, and directory metadata remain internal.
|
|
30
|
+
|
|
31
|
+
#### KV Cache effect
|
|
32
|
+
|
|
33
|
+
No direct invalidation; the named consumer owns any request-prefix changes.
|
|
34
|
+
|
|
35
|
+
## Known Limitations and Deferred Work
|
|
36
|
+
|
|
37
|
+
- **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)).
|
|
38
|
+
- **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard.
|
|
39
|
+
- **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path.
|
|
40
|
+
- **A sub-limit overwrite still buffers a contextual basis** — `writeText` may retain up to just below `config.diffBasisMaxBytes` of prior text in addition to the caller-owned replacement; the bound does not cap the returned `after` value or presentation's whole-file fallback.
|
|
41
|
+
- **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits.
|
|
42
|
+
- **The per-target mutation lock is in-process only** — guarded create still uses an atomic no-replace publication across processes, but replacement writers in another process are caught only when the optional version guard observes their metadata change; they are never serialized.
|
|
43
|
+
- **Guarded creation requires hard-link support** — filesystems or mounts that reject hard-link publication cannot serve `createIfAbsent`; the provider preserves the missing target and reports `FS_IO_ERROR`.
|
|
44
|
+
- **Post-commit cleanup is best effort** — a successful publication remains successful if removal of its owner-only staging directory fails, leaving private residue for later operator cleanup.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-fs-local
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十一个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。
|
|
6
|
+
|
|
7
|
+
```ts ignore-check
|
|
8
|
+
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
|
9
|
+
|
|
10
|
+
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
|
11
|
+
// ctx.fs uses the local backend; load @deepseek-ai/dsh-fs-policy for the
|
|
12
|
+
// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit.
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 行为
|
|
16
|
+
|
|
17
|
+
- **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent(智能体)的会话 cwd;见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename;只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。
|
|
18
|
+
- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。
|
|
19
|
+
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。
|
|
20
|
+
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。
|
|
21
|
+
- **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。
|
|
22
|
+
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。仅当打开后的旧文件和 UTF-8 替换内容都严格低于 `config.diffBasisMaxBytes`(默认 10 MiB)时,覆写才返回旧文本作为上下文 diff 基础。即使外部写入方在初次探测后替换文件或改变文件大小,文件描述符读取仍会强制执行该上限;否则提供方返回 `before: null`,由展示层使用整文件回退。
|
|
23
|
+
- **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。
|
|
24
|
+
|
|
25
|
+
包根 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。
|
|
26
|
+
|
|
27
|
+
## 模型体验
|
|
28
|
+
|
|
29
|
+
通过 [`dsh-tool-fs`](../tool-fs/README.md) 间接产生影响;该消费方把本提供方带行窗口的 UTF-8 内容、变更确认和精确提供方消息渲染为有上限且保留的结果,而版本、原子写入机制和目录元数据保持内部可见。
|
|
30
|
+
|
|
31
|
+
#### KV Cache 影响
|
|
32
|
+
|
|
33
|
+
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
|
|
34
|
+
|
|
35
|
+
## 已知限制与延期工作
|
|
36
|
+
|
|
37
|
+
- **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences))。
|
|
38
|
+
- **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 和纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。
|
|
39
|
+
- **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。
|
|
40
|
+
- **低于上限的覆写仍会缓冲上下文基础**:`writeText` 除调用方持有的替换内容外,最多还会保留略低于 `config.diffBasisMaxBytes` 的旧文本;该上限不限制返回的 `after` 值,也不限制展示层的整文件回退。
|
|
41
|
+
- **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。
|
|
42
|
+
- **每目标变更锁仅限进程内**:即使跨进程,带防护的创建仍采用原子且不替换的发布方式;但只有当可选版本防护观察到元数据变化时,系统才能发现其他进程中的替换写入方,且绝不会将其串行化。
|
|
43
|
+
- **带防护的创建要求支持硬链接**:拒绝硬链接发布的文件系统或挂载点无法支持 `createIfAbsent`;提供方会使目标保持缺失状态并报告 `FS_IO_ERROR`。
|
|
44
|
+
- **提交后清理采用尽力而为语义**:如果移除仅所有者可访问的暂存目录失败,成功发布仍视为成功,并留下私有残留供运维人员后续清理。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import { constants } from "node:buffer";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, toNamespacedPath } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import z from "@deepseek-ai/schemastery";
|
|
5
|
+
import { FileSystem, FsError, FsTargetKey, FsVersion } from "@deepseek-ai/dsh-fs";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { createReadStream } from "node:fs";
|
|
8
|
+
import { chmod, link, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
9
|
+
import { TextDecoder } from "node:util";
|
|
10
|
+
//#region lib/types/win32.js
|
|
11
|
+
/**
|
|
12
|
+
* Windows security-descriptor helpers for atomic local-file replacement. Koffi loads lazily so
|
|
13
|
+
* non-Windows processes never open Win32 libraries.
|
|
14
|
+
* @module @deepseek-ai/dsh-fs-local/win32
|
|
15
|
+
*/
|
|
16
|
+
const DACL_SECURITY_INFORMATION = 4;
|
|
17
|
+
const ERROR_FILE_NOT_FOUND = 2;
|
|
18
|
+
const ERROR_PATH_NOT_FOUND = 3;
|
|
19
|
+
const ERROR_ACCESS_DENIED = 5;
|
|
20
|
+
let bindings;
|
|
21
|
+
async function win32() {
|
|
22
|
+
if (bindings !== void 0) return bindings;
|
|
23
|
+
const koffi = (await import("koffi")).default;
|
|
24
|
+
const advapi32 = koffi.load("advapi32.dll");
|
|
25
|
+
const kernel32 = koffi.load("kernel32.dll");
|
|
26
|
+
bindings = {
|
|
27
|
+
getFileSecurityW: advapi32.func("int __stdcall GetFileSecurityW(const char16_t *path, uint32_t requested, void *descriptor, uint32_t length, _Out_ uint32_t *needed)"),
|
|
28
|
+
setFileSecurityW: advapi32.func("int __stdcall SetFileSecurityW(const char16_t *path, uint32_t information, const void *descriptor)"),
|
|
29
|
+
replaceFileW: kernel32.func("int __stdcall ReplaceFileW(const char16_t *replaced, const char16_t *replacement, const char16_t *backup, uint32_t flags, void *exclude, void *reserved)"),
|
|
30
|
+
getLastError: kernel32.func("uint32_t __stdcall GetLastError()")
|
|
31
|
+
};
|
|
32
|
+
return bindings;
|
|
33
|
+
}
|
|
34
|
+
function errnoCode(win32Code) {
|
|
35
|
+
switch (win32Code) {
|
|
36
|
+
case ERROR_FILE_NOT_FOUND:
|
|
37
|
+
case ERROR_PATH_NOT_FOUND: return "ENOENT";
|
|
38
|
+
case ERROR_ACCESS_DENIED: return "EACCES";
|
|
39
|
+
default: return "EIO";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function win32Error(syscall, win32Code, path) {
|
|
43
|
+
const code = errnoCode(win32Code);
|
|
44
|
+
const error = /* @__PURE__ */ new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path}`);
|
|
45
|
+
error.code = code;
|
|
46
|
+
error.errno = win32Code;
|
|
47
|
+
error.syscall = syscall;
|
|
48
|
+
error.path = path;
|
|
49
|
+
error.win32Code = win32Code;
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Read a file's self-relative DACL security descriptor.
|
|
54
|
+
* @param path - existing file whose DACL is read.
|
|
55
|
+
* @returns a descriptor buffer accepted by `SetFileSecurityW`.
|
|
56
|
+
*/
|
|
57
|
+
async function readFileDaclWin32(path) {
|
|
58
|
+
const api = await win32();
|
|
59
|
+
const nativePath = toNamespacedPath(path);
|
|
60
|
+
const needed = [0];
|
|
61
|
+
api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, null, 0, needed);
|
|
62
|
+
if (needed[0] === 0) throw win32Error("GetFileSecurityW", api.getLastError(), path);
|
|
63
|
+
const descriptor = Buffer.alloc(needed[0]);
|
|
64
|
+
if (api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, descriptor, descriptor.length, needed) === 0) throw win32Error("GetFileSecurityW", api.getLastError(), path);
|
|
65
|
+
return descriptor.subarray(0, needed[0]);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Copy an existing file's DACL onto another file and protect it from staging-parent inheritance.
|
|
69
|
+
* The destination must still be empty when confidentiality depends on this call.
|
|
70
|
+
* @param source - existing file whose DACL is copied.
|
|
71
|
+
* @param destination - existing file that receives the protected DACL.
|
|
72
|
+
*/
|
|
73
|
+
async function copyFileDaclWin32(source, destination) {
|
|
74
|
+
const descriptor = await readFileDaclWin32(source);
|
|
75
|
+
const api = await win32();
|
|
76
|
+
if (api.setFileSecurityW(toNamespacedPath(destination), 2147483652, descriptor) === 0) throw win32Error("SetFileSecurityW", api.getLastError(), destination);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Replace a Windows file while preserving the replaced file's ACL and other replace metadata.
|
|
80
|
+
* @param replaced - existing destination file.
|
|
81
|
+
* @param replacement - closed staging file on the same volume.
|
|
82
|
+
*/
|
|
83
|
+
async function replaceFileWin32(replaced, replacement) {
|
|
84
|
+
const api = await win32();
|
|
85
|
+
if (api.replaceFileW(toNamespacedPath(replaced), toNamespacedPath(replacement), null, 0, null, null) === 0) throw win32Error("ReplaceFileW", api.getLastError(), replaced);
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region lib/types/fsio.js
|
|
89
|
+
/**
|
|
90
|
+
* Cordis-free local filesystem mechanics. This provider layer returns validated UTF-8 text,
|
|
91
|
+
* streams large files, and rejects binary data; line windows belong to `dsh-tool-fs`. Writes
|
|
92
|
+
* stage an exclusive owner-only file in a private sibling directory and atomically publish it.
|
|
93
|
+
* @module @deepseek-ai/dsh-fs-local/fsio
|
|
94
|
+
*/
|
|
95
|
+
const BINARY_SAMPLE_BYTES = 8192;
|
|
96
|
+
const DIFF_BASIS_READ_CHUNK_BYTES = 64 * 1024;
|
|
97
|
+
function isENOENT(error) {
|
|
98
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
99
|
+
}
|
|
100
|
+
function isEEXIST(error) {
|
|
101
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A path component that is expected to be a directory is a regular file (e.g.
|
|
105
|
+
* resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
|
|
106
|
+
* cannot exist — so the resolution/probe paths treat it as "absent" rather than
|
|
107
|
+
* letting a raw Node error escape without the structured `FsError` taxonomy.
|
|
108
|
+
*/
|
|
109
|
+
function isENOTDIR(error) {
|
|
110
|
+
return error instanceof Error && "code" in error && error.code === "ENOTDIR";
|
|
111
|
+
}
|
|
112
|
+
function isAbortError(error) {
|
|
113
|
+
return error instanceof Error && error.name === "AbortError";
|
|
114
|
+
}
|
|
115
|
+
/* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */
|
|
116
|
+
function errorMessage(error) {
|
|
117
|
+
return error instanceof Error ? error.message : String(error);
|
|
118
|
+
}
|
|
119
|
+
/* v8 ignore stop */
|
|
120
|
+
function isPermissionError(error) {
|
|
121
|
+
return error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
|
|
122
|
+
}
|
|
123
|
+
function throwIfAborted(signal, verb) {
|
|
124
|
+
if (signal?.aborted) throw new FsError(`${verb} aborted`, "FS_ABORTED");
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* `readFile` with the supplied signal, translating a mid-read `AbortError` into
|
|
128
|
+
* the seam's structured `FsError('FS_ABORTED')` (Node rejects an aborted
|
|
129
|
+
* `readFile` with a bare `AbortError`, which would otherwise escape the seam's
|
|
130
|
+
* error taxonomy — the streaming/write paths translate it the same way).
|
|
131
|
+
*/
|
|
132
|
+
async function readFileAbortable(absolutePath, verb, signal) {
|
|
133
|
+
try {
|
|
134
|
+
return await readFile(absolutePath, signal ? { signal } : {});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
/* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */
|
|
137
|
+
if (!isAbortError(error)) throw error;
|
|
138
|
+
throw new FsError(`${verb} aborted`, "FS_ABORTED");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Opaque version token from high-resolution identity and freshness metadata. */
|
|
142
|
+
function versionOf(info) {
|
|
143
|
+
return FsVersion(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolve a path to its absolute display path and realpath identity. For a missing target,
|
|
147
|
+
* realpath the nearest existing ancestor and append the missing suffix, preserving identity
|
|
148
|
+
* across symlinked ancestors before and after creation.
|
|
149
|
+
* @param cwd - base directory a relative `path` resolves against.
|
|
150
|
+
* @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`.
|
|
151
|
+
* @returns the absolute display path plus the realpath-derived stable target key.
|
|
152
|
+
*/
|
|
153
|
+
async function resolveLocalTarget(cwd, path) {
|
|
154
|
+
if (path.trim().length === 0) throw new FsError("file_path must be a non-empty string", "FS_NOT_FOUND");
|
|
155
|
+
const displayPath = resolve(cwd, path);
|
|
156
|
+
try {
|
|
157
|
+
return {
|
|
158
|
+
displayPath,
|
|
159
|
+
targetKey: FsTargetKey(await realpath(displayPath))
|
|
160
|
+
};
|
|
161
|
+
} catch (error) {
|
|
162
|
+
/* v8 ignore next -- Windows reports this case as ENOENT and repairs it in the ancestor walk below. */
|
|
163
|
+
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, "FS_NOT_FOUND");
|
|
164
|
+
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
|
|
165
|
+
if (!isENOENT(error)) throw error;
|
|
166
|
+
}
|
|
167
|
+
const missing = [basename(displayPath)];
|
|
168
|
+
let ancestor = dirname(displayPath);
|
|
169
|
+
while (true) try {
|
|
170
|
+
const realAncestor = await realpath(ancestor);
|
|
171
|
+
/* v8 ignore start -- native Windows coverage exercises this repair; POSIX reports ENOTDIR before this point. */
|
|
172
|
+
if (process.platform === "win32") {
|
|
173
|
+
if (!(await stat(realAncestor)).isDirectory()) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, "FS_NOT_FOUND");
|
|
174
|
+
}
|
|
175
|
+
/* v8 ignore stop */
|
|
176
|
+
return {
|
|
177
|
+
displayPath,
|
|
178
|
+
targetKey: FsTargetKey(join(realAncestor, ...missing))
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
/* v8 ignore next -- native Windows coverage exercises the FsError raised by the repair above. */
|
|
182
|
+
if (error instanceof FsError) throw error;
|
|
183
|
+
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
|
|
184
|
+
if (!isENOENT(error)) throw error;
|
|
185
|
+
const parent = dirname(ancestor);
|
|
186
|
+
/* v8 ignore next -- the filesystem root always realpaths, so the walk terminates before parent === ancestor. */
|
|
187
|
+
if (parent === ancestor) return {
|
|
188
|
+
displayPath,
|
|
189
|
+
targetKey: FsTargetKey(displayPath)
|
|
190
|
+
};
|
|
191
|
+
missing.unshift(basename(ancestor));
|
|
192
|
+
ancestor = parent;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function pathType(info) {
|
|
196
|
+
if (info.isFile()) return "file";
|
|
197
|
+
/* v8 ignore else -- Windows has no special-entry fixture for the non-directory branch. */
|
|
198
|
+
if (info.isDirectory()) return "directory";
|
|
199
|
+
/* v8 ignore next -- the corresponding special-entry return is covered on POSIX. */
|
|
200
|
+
return "other";
|
|
201
|
+
}
|
|
202
|
+
function pathLinkType(info) {
|
|
203
|
+
if (info.isSymbolicLink()) return "symlink";
|
|
204
|
+
return pathType(info);
|
|
205
|
+
}
|
|
206
|
+
async function probeStats(absolutePath, readStats) {
|
|
207
|
+
try {
|
|
208
|
+
return await readStats(absolutePath);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
/* v8 ignore next -- a non-ENOENT/ENOTDIR metadata failure needs a permission/IO fault; surface it. */
|
|
211
|
+
if (!isENOENT(error) && !isENOTDIR(error)) throw error;
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Probe a path for its version, mode, type, and size. Null if absent.
|
|
217
|
+
* @param absolutePath - the path to stat (typically a target key; symlinks are followed).
|
|
218
|
+
* @returns the metadata, or null when the path — or a parent segment — does not exist.
|
|
219
|
+
*/
|
|
220
|
+
async function probe(absolutePath) {
|
|
221
|
+
const info = await probeStats(absolutePath, (path) => stat(path, { bigint: true }));
|
|
222
|
+
if (!info) return null;
|
|
223
|
+
return {
|
|
224
|
+
version: versionOf(info),
|
|
225
|
+
mode: Number(info.mode & 511n),
|
|
226
|
+
type: pathType(info),
|
|
227
|
+
size: Number(info.size)
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Probe a path without following the final symlink component.
|
|
232
|
+
* @param absolutePath - the path entry to inspect with `lstat` semantics.
|
|
233
|
+
* @returns path-entry metadata, or null when the entry is absent.
|
|
234
|
+
*/
|
|
235
|
+
async function probeNoFollow(absolutePath) {
|
|
236
|
+
const info = await probeStats(absolutePath, (path) => lstat(path, { bigint: true }));
|
|
237
|
+
if (!info) return null;
|
|
238
|
+
return {
|
|
239
|
+
version: versionOf(info),
|
|
240
|
+
mode: Number(info.mode & 511n),
|
|
241
|
+
type: pathLinkType(info),
|
|
242
|
+
size: Number(info.size)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function listingIoError(displayPath, error) {
|
|
246
|
+
/* v8 ignore next -- defensive pass-through for races where a child resolver has already produced a structured FsError. */
|
|
247
|
+
if (error instanceof FsError) return error;
|
|
248
|
+
/* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */
|
|
249
|
+
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, "FS_NOT_FOUND", { cause: error });
|
|
250
|
+
/* v8 ignore next -- Windows chmod does not deny directory listing; POSIX covers permission translation. */
|
|
251
|
+
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, "FS_PERMISSION_DENIED", { cause: error });
|
|
252
|
+
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, "FS_IO_ERROR", { cause: error });
|
|
253
|
+
}
|
|
254
|
+
async function resolveListedChildTarget(parent, name) {
|
|
255
|
+
const identity = await resolveLocalTarget(parent.targetKey, name);
|
|
256
|
+
return {
|
|
257
|
+
displayPath: join(parent.displayPath, name),
|
|
258
|
+
targetKey: identity.targetKey
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* List direct children of a directory in stable name order. Each child includes
|
|
263
|
+
* a resolved target plus stat metadata when still available; file contents are
|
|
264
|
+
* never read.
|
|
265
|
+
* @param target - the resolved directory to list; a missing or non-directory target throws.
|
|
266
|
+
* @param signal - aborts the listing, checked between children (`FS_ABORTED`).
|
|
267
|
+
* @returns one entry per direct child, sorted by name.
|
|
268
|
+
*/
|
|
269
|
+
async function listDirectory(target, signal) {
|
|
270
|
+
throwIfAborted(signal, "list");
|
|
271
|
+
let info;
|
|
272
|
+
try {
|
|
273
|
+
info = await probe(target.targetKey);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
throw listingIoError(target.displayPath, error);
|
|
276
|
+
}
|
|
277
|
+
if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, "FS_NOT_FOUND");
|
|
278
|
+
if (info.type !== "directory") throw new FsError(`cannot list "${target.displayPath}": not a directory`, "FS_NOT_DIRECTORY");
|
|
279
|
+
let entries;
|
|
280
|
+
try {
|
|
281
|
+
entries = await readdir(target.targetKey, {
|
|
282
|
+
withFileTypes: true,
|
|
283
|
+
encoding: "utf8"
|
|
284
|
+
});
|
|
285
|
+
} catch (error) {
|
|
286
|
+
/* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */
|
|
287
|
+
throw listingIoError(target.displayPath, error);
|
|
288
|
+
}
|
|
289
|
+
throwIfAborted(signal, "list");
|
|
290
|
+
const result = [];
|
|
291
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
292
|
+
throwIfAborted(signal, "list");
|
|
293
|
+
try {
|
|
294
|
+
const childTarget = await resolveListedChildTarget(target, entry.name);
|
|
295
|
+
const childInfo = await probe(childTarget.targetKey);
|
|
296
|
+
result.push({
|
|
297
|
+
name: entry.name,
|
|
298
|
+
type: childInfo?.type ?? "other",
|
|
299
|
+
target: childTarget,
|
|
300
|
+
...childInfo ? { version: childInfo.version } : {},
|
|
301
|
+
...childInfo?.type === "file" ? { size: childInfo.size } : {}
|
|
302
|
+
});
|
|
303
|
+
} catch (error) {
|
|
304
|
+
throw listingIoError(join(target.displayPath, entry.name), error);
|
|
305
|
+
}
|
|
306
|
+
throwIfAborted(signal, "list");
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
function notTextError(verb, displayPath) {
|
|
311
|
+
return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT");
|
|
312
|
+
}
|
|
313
|
+
function decodeUtf8(buffer, verb, displayPath) {
|
|
314
|
+
try {
|
|
315
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
|
318
|
+
if (!(error instanceof TypeError)) throw error;
|
|
319
|
+
throw notTextError(verb, displayPath);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function decodeUtf8Stream(decoder, chunk, verb, displayPath) {
|
|
323
|
+
try {
|
|
324
|
+
return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode();
|
|
325
|
+
} catch (error) {
|
|
326
|
+
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
|
327
|
+
if (!(error instanceof TypeError)) throw error;
|
|
328
|
+
throw notTextError(verb, displayPath);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function statRegularFile(target, verb, signal) {
|
|
332
|
+
throwIfAborted(signal, verb);
|
|
333
|
+
let info;
|
|
334
|
+
try {
|
|
335
|
+
info = await stat(target.targetKey);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */
|
|
338
|
+
if (!isENOENT(error)) throw error;
|
|
339
|
+
throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, "FS_NOT_FOUND");
|
|
340
|
+
}
|
|
341
|
+
if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
342
|
+
return info;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Read a whole regular UTF-8 text file into a single decoded string. Rejects
|
|
346
|
+
* non-regular files, invalid UTF-8, and NUL-byte binary samples.
|
|
347
|
+
* @param target - the resolved file to read.
|
|
348
|
+
* @param signal - aborts the read (`FS_ABORTED`).
|
|
349
|
+
* @returns the full decoded text, byte-for-byte (no normalization).
|
|
350
|
+
*/
|
|
351
|
+
async function readWholeText(target, signal) {
|
|
352
|
+
await statRegularFile(target, "read", signal);
|
|
353
|
+
const raw = await readFileAbortable(target.targetKey, "read", signal);
|
|
354
|
+
throwIfAborted(signal, "read");
|
|
355
|
+
if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) throw new FsError(`cannot read "${target.displayPath}": binary file`, "FS_NOT_TEXT");
|
|
356
|
+
return decodeUtf8(raw, "read", target.displayPath);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
|
|
360
|
+
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
|
|
361
|
+
* cross-chunk UTF-8 decoding), but never holds the whole file in memory.
|
|
362
|
+
* @param target - the resolved file to stream.
|
|
363
|
+
* @param signal - aborts the stream, including between chunks (`FS_ABORTED`).
|
|
364
|
+
* @returns decoded text chunks in file order; chunk boundaries carry no meaning.
|
|
365
|
+
*/
|
|
366
|
+
async function* streamWholeText(target, signal) {
|
|
367
|
+
await statRegularFile(target, "read", signal);
|
|
368
|
+
const stream = createReadStream(target.targetKey, signal ? { signal } : {});
|
|
369
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
370
|
+
let sampledBytes = 0;
|
|
371
|
+
function scanBinarySample(chunk) {
|
|
372
|
+
if (sampledBytes >= BINARY_SAMPLE_BYTES) return;
|
|
373
|
+
const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes));
|
|
374
|
+
if (sample.includes(0)) throw new FsError(`cannot read "${target.displayPath}": binary file`, "FS_NOT_TEXT");
|
|
375
|
+
sampledBytes += sample.length;
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
for await (const chunk of stream) {
|
|
379
|
+
scanBinarySample(chunk);
|
|
380
|
+
yield decodeUtf8Stream(decoder, chunk, "read", target.displayPath);
|
|
381
|
+
}
|
|
382
|
+
yield decodeUtf8Stream(decoder, void 0, "read", target.displayPath);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
/* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */
|
|
385
|
+
if (isAbortError(error)) throw new FsError("read aborted", "FS_ABORTED");
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async function removeStagingDirOrThrow(stagingDir, originalError, removeStagingDir) {
|
|
390
|
+
try {
|
|
391
|
+
await removeStagingDir(stagingDir);
|
|
392
|
+
} catch (cleanupError) {
|
|
393
|
+
/* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */
|
|
394
|
+
throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, "FS_NOT_FOUND", { cause: originalError });
|
|
395
|
+
}
|
|
396
|
+
throw originalError;
|
|
397
|
+
}
|
|
398
|
+
async function throwGuardedCreateFailure(error, absolutePath, displayPath, inspectPublicationTarget) {
|
|
399
|
+
let existing;
|
|
400
|
+
try {
|
|
401
|
+
existing = await inspectPublicationTarget(absolutePath);
|
|
402
|
+
} catch (metadataError) {
|
|
403
|
+
if (!isENOENT(metadataError) && !isENOTDIR(metadataError)) throw new FsError(`cannot write "${displayPath}": ${errorMessage(metadataError)}`, "FS_IO_ERROR", { cause: metadataError });
|
|
404
|
+
}
|
|
405
|
+
if (existing !== void 0) {
|
|
406
|
+
if (!existing.isFile()) throw new FsError(`cannot write "${displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE", { cause: error });
|
|
407
|
+
throw new FsError(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED", { cause: error });
|
|
408
|
+
}
|
|
409
|
+
if (isEEXIST(error)) throw new FsError(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED", { cause: error });
|
|
410
|
+
throw new FsError(`cannot write "${displayPath}": ${errorMessage(error)}`, "FS_IO_ERROR", { cause: error });
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Atomically replace a file through a private, synced staging file in the same directory.
|
|
414
|
+
* POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file
|
|
415
|
+
* inherits the destination directory's DACL; a replacement copies the existing target's DACL
|
|
416
|
+
* onto the empty temp before writing and preserves the target descriptor at publication.
|
|
417
|
+
* @param absolutePath - destination; missing parent directories are created.
|
|
418
|
+
* @param content - the full UTF-8 text to write.
|
|
419
|
+
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
|
|
420
|
+
* inert as a mode on Windows but identifies replacement security semantics.
|
|
421
|
+
* @param signal - cancellation checked before final publication.
|
|
422
|
+
* @param internals - Test hook for pinning temp names and observing the staged file.
|
|
423
|
+
* @param createIfAbsent - when provided, publish with a hard-link no-replace
|
|
424
|
+
* primitive; a concurrent creator's file is preserved and this write is
|
|
425
|
+
* rejected with `FS_NOT_OBSERVED` using the supplied display path.
|
|
426
|
+
*/
|
|
427
|
+
async function writeFileAtomic(absolutePath, content, mode, signal, internals = {}, createIfAbsent) {
|
|
428
|
+
throwIfAborted(signal, "write");
|
|
429
|
+
const directory = dirname(absolutePath);
|
|
430
|
+
await mkdir(directory, { recursive: true });
|
|
431
|
+
throwIfAborted(signal, "write");
|
|
432
|
+
const stagingDir = join(directory, internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir`);
|
|
433
|
+
const tempPath = join(stagingDir, internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`);
|
|
434
|
+
const platform = internals.platform ?? process.platform;
|
|
435
|
+
const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32;
|
|
436
|
+
const replaceFile = internals.replaceFile ?? replaceFileWin32;
|
|
437
|
+
const linkFile = internals.linkFile ?? link;
|
|
438
|
+
const inspectPublicationTarget = internals.inspectPublicationTarget ?? ((path) => lstat(path, { bigint: true }));
|
|
439
|
+
const removeStagingDir = internals.removeStagingDir ?? ((path) => rm(path, {
|
|
440
|
+
recursive: true,
|
|
441
|
+
force: true
|
|
442
|
+
}));
|
|
443
|
+
let handle;
|
|
444
|
+
let stagingCreated = false;
|
|
445
|
+
try {
|
|
446
|
+
await mkdir(stagingDir, { mode: 448 });
|
|
447
|
+
stagingCreated = true;
|
|
448
|
+
await chmod(stagingDir, 448);
|
|
449
|
+
handle = await open(tempPath, "wx", 384);
|
|
450
|
+
await handle.chmod(384);
|
|
451
|
+
if (platform === "win32" && mode !== void 0) await copyFileDacl(absolutePath, tempPath);
|
|
452
|
+
await handle.writeFile(content, {
|
|
453
|
+
encoding: "utf8",
|
|
454
|
+
...signal ? { signal } : {}
|
|
455
|
+
});
|
|
456
|
+
await handle.sync();
|
|
457
|
+
await internals.inspectTemp?.({
|
|
458
|
+
stagingDir,
|
|
459
|
+
tempPath
|
|
460
|
+
});
|
|
461
|
+
if (mode !== void 0) await handle.chmod(mode);
|
|
462
|
+
await handle.close();
|
|
463
|
+
handle = void 0;
|
|
464
|
+
throwIfAborted(signal, "write");
|
|
465
|
+
if (createIfAbsent !== void 0) try {
|
|
466
|
+
await linkFile(tempPath, absolutePath);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
await throwGuardedCreateFailure(error, absolutePath, createIfAbsent.displayPath, inspectPublicationTarget);
|
|
469
|
+
}
|
|
470
|
+
else if (platform === "win32" && mode !== void 0) try {
|
|
471
|
+
await replaceFile(absolutePath, tempPath);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (!isENOENT(error)) throw error;
|
|
474
|
+
await rename(tempPath, absolutePath);
|
|
475
|
+
}
|
|
476
|
+
else await rename(tempPath, absolutePath);
|
|
477
|
+
try {
|
|
478
|
+
await removeStagingDir(stagingDir);
|
|
479
|
+
} catch (_committedStagingCleanupFailure) {}
|
|
480
|
+
} catch (error) {
|
|
481
|
+
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */
|
|
482
|
+
let failure = isAbortError(error) ? new FsError("write aborted", "FS_ABORTED") : error;
|
|
483
|
+
/* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */
|
|
484
|
+
if (handle) try {
|
|
485
|
+
await handle.close();
|
|
486
|
+
} catch (closeError) {
|
|
487
|
+
failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, "FS_NOT_FOUND", { cause: failure });
|
|
488
|
+
}
|
|
489
|
+
if (!stagingCreated) throw failure;
|
|
490
|
+
return removeStagingDirOrThrow(stagingDir, failure, removeStagingDir);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Collapse CRLF to LF — the canonical in-memory form every edit/diff basis
|
|
495
|
+
* uses. Lone `\r` bytes (not followed by `\n`) are left untouched.
|
|
496
|
+
* @param content - decoded text in whatever line-ending style the file had.
|
|
497
|
+
* @returns the text with every `\r\n` pair replaced by `\n`.
|
|
498
|
+
*/
|
|
499
|
+
function normalizeLineEndings(content) {
|
|
500
|
+
return content.replaceAll("\r\n", "\n");
|
|
501
|
+
}
|
|
502
|
+
function detectLineEndings(raw) {
|
|
503
|
+
const sample = raw.slice(0, 4096);
|
|
504
|
+
const crlfCount = sample.split("\r\n").length - 1;
|
|
505
|
+
return crlfCount > sample.split("\n").length - 1 - crlfCount ? "CRLF" : "LF";
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Convert LF-normalized content back to the line-ending style detected at read
|
|
509
|
+
* time, for write-back. `LF` returns the content unchanged; `CRLF` re-normalizes
|
|
510
|
+
* first so an already-CRLF sequence is never doubled to `\r\r\n`.
|
|
511
|
+
* @param content - the LF-normalized (edited) text.
|
|
512
|
+
* @param lineEndings - the original file's style, as detected by {@link readForEdit}.
|
|
513
|
+
* @returns the text in the original file's line-ending style.
|
|
514
|
+
*/
|
|
515
|
+
function restoreLineEndings(content, lineEndings) {
|
|
516
|
+
return lineEndings === "LF" ? content : normalizeLineEndings(content).split("\n").join("\r\n");
|
|
517
|
+
}
|
|
518
|
+
function countOccurrences(content, needle) {
|
|
519
|
+
let count = 0;
|
|
520
|
+
let index = 0;
|
|
521
|
+
while (true) {
|
|
522
|
+
const found = content.indexOf(needle, index);
|
|
523
|
+
if (found === -1) return count;
|
|
524
|
+
count += 1;
|
|
525
|
+
index = found + needle.length;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Read and decode a file for editing: rejects binaries, returns LF-normalized
|
|
530
|
+
* content plus the original line-ending style for write-back.
|
|
531
|
+
* @param absolutePath - the file to read (typically a target key).
|
|
532
|
+
* @param displayPath - the caller-facing path used in error messages.
|
|
533
|
+
* @param signal - aborts the read (`FS_ABORTED`).
|
|
534
|
+
* @returns the LF-normalized content and the detected style to restore on write-back.
|
|
535
|
+
*/
|
|
536
|
+
async function readForEdit(absolutePath, displayPath, signal) {
|
|
537
|
+
throwIfAborted(signal, "edit");
|
|
538
|
+
const buffer = await readFileAbortable(absolutePath, "edit", signal);
|
|
539
|
+
throwIfAborted(signal, "edit");
|
|
540
|
+
if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, "FS_NOT_TEXT");
|
|
541
|
+
const raw = decodeUtf8(buffer, "edit", displayPath);
|
|
542
|
+
return {
|
|
543
|
+
content: normalizeLineEndings(raw),
|
|
544
|
+
lineEndings: detectLineEndings(raw)
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Best-effort overwrite diff basis. Binary, invalid UTF-8, a file at/above the byte limit,
|
|
549
|
+
* or a file deleted/made unreadable after the caller's preflight returns `null` so the write
|
|
550
|
+
* still succeeds and presentation falls back to a whole-file diff. The bound is enforced on
|
|
551
|
+
* the opened descriptor rather than a prior path stat, so concurrent external replacement or
|
|
552
|
+
* size changes cannot make this helper buffer more than `maxBytes`.
|
|
553
|
+
* @param absolutePath - the file to read (typically a target key).
|
|
554
|
+
* @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis.
|
|
555
|
+
* @param signal - aborts the read (`FS_ABORTED`); cancellation propagates, unlike I/O failure.
|
|
556
|
+
* @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8,
|
|
557
|
+
* descriptor-size-changed, or unreadable file.
|
|
558
|
+
*/
|
|
559
|
+
async function readTextForDiff(absolutePath, maxBytes, signal) {
|
|
560
|
+
throwIfAborted(signal, "read");
|
|
561
|
+
try {
|
|
562
|
+
const handle = await open(absolutePath, "r");
|
|
563
|
+
let buffer;
|
|
564
|
+
let total = 0;
|
|
565
|
+
let openedSize = 0;
|
|
566
|
+
try {
|
|
567
|
+
throwIfAborted(signal, "read");
|
|
568
|
+
const info = await handle.stat();
|
|
569
|
+
throwIfAborted(signal, "read");
|
|
570
|
+
if (!info.isFile()) return null;
|
|
571
|
+
if (info.size >= maxBytes) return null;
|
|
572
|
+
openedSize = info.size;
|
|
573
|
+
buffer = Buffer.allocUnsafe(openedSize + 1);
|
|
574
|
+
while (total < buffer.length) {
|
|
575
|
+
throwIfAborted(signal, "read");
|
|
576
|
+
const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES);
|
|
577
|
+
const { bytesRead } = await handle.read(buffer, total, length, null);
|
|
578
|
+
if (bytesRead === 0) break;
|
|
579
|
+
total += bytesRead;
|
|
580
|
+
}
|
|
581
|
+
} finally {
|
|
582
|
+
await handle.close();
|
|
583
|
+
}
|
|
584
|
+
throwIfAborted(signal, "read");
|
|
585
|
+
if (total !== openedSize) return null;
|
|
586
|
+
const basis = buffer.subarray(0, total);
|
|
587
|
+
if (basis.includes(0)) return null;
|
|
588
|
+
try {
|
|
589
|
+
return normalizeLineEndings(new TextDecoder("utf-8", { fatal: true }).decode(basis));
|
|
590
|
+
} catch (error) {
|
|
591
|
+
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes;
|
|
592
|
+
* any other throw is an unreachable runtime fault. */
|
|
593
|
+
if (!(error instanceof TypeError)) throw error;
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
} catch (error) {
|
|
597
|
+
if (error instanceof FsError) throw error;
|
|
598
|
+
if (error instanceof Error && "code" in error) return null;
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Apply a literal replacement to LF-normalized content. Empty or missing search text throws
|
|
604
|
+
* `FS_EDIT_NOT_FOUND`; multiple matches throw `FS_AMBIGUOUS_EDIT` unless `replaceAll` is true.
|
|
605
|
+
* @param content - the current file content, already LF-normalized.
|
|
606
|
+
* @param oldString - literal text to find; CRLF inside it is normalized to LF before
|
|
607
|
+
* matching.
|
|
608
|
+
* @param newString - literal replacement text, normalized the same way.
|
|
609
|
+
* @param replaceAll - replace every match instead of requiring exactly one.
|
|
610
|
+
* @param displayPath - the caller-facing path used in error messages.
|
|
611
|
+
* @returns the edited LF-normalized content plus how many occurrences were replaced.
|
|
612
|
+
*/
|
|
613
|
+
function applyLiteralEdit(content, oldString, newString, replaceAll, displayPath) {
|
|
614
|
+
const oldNorm = normalizeLineEndings(oldString);
|
|
615
|
+
if (oldNorm.length === 0) throw new FsError("old_string must be a non-empty string", "FS_EDIT_NOT_FOUND");
|
|
616
|
+
const newNorm = normalizeLineEndings(newString);
|
|
617
|
+
const replacements = countOccurrences(content, oldNorm);
|
|
618
|
+
if (replacements === 0) throw new FsError(`old_string was not found in "${displayPath}"`, "FS_EDIT_NOT_FOUND");
|
|
619
|
+
if (!replaceAll && replacements > 1) throw new FsError(`old_string matched ${replacements} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`, "FS_AMBIGUOUS_EDIT");
|
|
620
|
+
return {
|
|
621
|
+
content: content.split(oldNorm).join(newNorm),
|
|
622
|
+
replacements
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region lib/types/index.js
|
|
627
|
+
/**
|
|
628
|
+
* Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases
|
|
629
|
+
* share stale guards, and writes through a symlink update its target without replacing the link.
|
|
630
|
+
* @module @deepseek-ai/dsh-fs-local
|
|
631
|
+
*/
|
|
632
|
+
const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024;
|
|
633
|
+
const MAX_DIFF_BASIS_BYTES = Math.min(constants.MAX_LENGTH, constants.MAX_STRING_LENGTH);
|
|
634
|
+
/**
|
|
635
|
+
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
|
|
636
|
+
* (a resolution default, NOT a containment boundary — see the filesystem
|
|
637
|
+
* capability-seam Agent Note); enforce
|
|
638
|
+
* containment with a stricter backend or a `tools/execute` permission plugin.
|
|
639
|
+
*/
|
|
640
|
+
var LocalFileSystem = class extends FileSystem {
|
|
641
|
+
static Config = z.object({
|
|
642
|
+
cwd: z.string().default(process.cwd()),
|
|
643
|
+
diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES)
|
|
644
|
+
});
|
|
645
|
+
/** Validated config (schemastery applied the defaults before construction). */
|
|
646
|
+
config;
|
|
647
|
+
/** Test hook forwarded to fsio for atomic-publication boundaries. */
|
|
648
|
+
internals = {};
|
|
649
|
+
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
|
|
650
|
+
* window can't interleave, making concurrent writes/edits deterministically
|
|
651
|
+
* ordered (one wins, the rest see the new version and reject as stale). */
|
|
652
|
+
locks = /* @__PURE__ */ new Map();
|
|
653
|
+
constructor(ctx, config) {
|
|
654
|
+
super(ctx);
|
|
655
|
+
const resolved = config;
|
|
656
|
+
if (!Number.isSafeInteger(resolved.diffBasisMaxBytes) || resolved.diffBasisMaxBytes <= 0 || resolved.diffBasisMaxBytes > MAX_DIFF_BASIS_BYTES) throw new Error(`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${MAX_DIFF_BASIS_BYTES}`);
|
|
657
|
+
this.config = resolved;
|
|
658
|
+
}
|
|
659
|
+
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
|
|
660
|
+
async withLock(targetKey, op) {
|
|
661
|
+
const run = (this.locks.get(targetKey) ?? Promise.resolve()).then(op, op);
|
|
662
|
+
const tail = run.then(() => void 0, () => void 0);
|
|
663
|
+
this.locks.set(targetKey, tail);
|
|
664
|
+
try {
|
|
665
|
+
return await run;
|
|
666
|
+
} finally {
|
|
667
|
+
if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async resolve(path, opts) {
|
|
671
|
+
if (opts?.signal?.aborted) throw new FsError("resolve aborted", "FS_ABORTED");
|
|
672
|
+
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path);
|
|
673
|
+
if (opts?.signal?.aborted) throw new FsError("resolve aborted", "FS_ABORTED");
|
|
674
|
+
return {
|
|
675
|
+
targetKey: local.targetKey,
|
|
676
|
+
displayPath: local.displayPath
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
processPath(target) {
|
|
680
|
+
return String(target.targetKey);
|
|
681
|
+
}
|
|
682
|
+
fileUrl(target) {
|
|
683
|
+
return pathToFileURL(this.processPath(target)).href;
|
|
684
|
+
}
|
|
685
|
+
contains(parent, child) {
|
|
686
|
+
const path = relative(this.processPath(parent), this.processPath(child));
|
|
687
|
+
return path === "" || path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
|
|
688
|
+
}
|
|
689
|
+
async stat(target, signal) {
|
|
690
|
+
if (signal?.aborted) throw new FsError("stat aborted", "FS_ABORTED");
|
|
691
|
+
const info = await probe(target.targetKey);
|
|
692
|
+
if (signal?.aborted) throw new FsError("stat aborted", "FS_ABORTED");
|
|
693
|
+
if (!info) return void 0;
|
|
694
|
+
return {
|
|
695
|
+
version: info.version,
|
|
696
|
+
type: info.type,
|
|
697
|
+
size: info.size
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
async lstat(path, opts, signal) {
|
|
701
|
+
if (signal?.aborted) throw new FsError("lstat aborted", "FS_ABORTED");
|
|
702
|
+
if (path.trim().length === 0) throw new FsError("file_path must be a non-empty string", "FS_NOT_FOUND");
|
|
703
|
+
const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path));
|
|
704
|
+
if (signal?.aborted) throw new FsError("lstat aborted", "FS_ABORTED");
|
|
705
|
+
if (!info) return void 0;
|
|
706
|
+
return {
|
|
707
|
+
version: info.version,
|
|
708
|
+
type: info.type,
|
|
709
|
+
size: info.size
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
async readText(target, signal) {
|
|
713
|
+
return readWholeText({
|
|
714
|
+
displayPath: target.displayPath,
|
|
715
|
+
targetKey: target.targetKey
|
|
716
|
+
}, signal);
|
|
717
|
+
}
|
|
718
|
+
streamText(target, signal) {
|
|
719
|
+
return Promise.resolve(streamWholeText({
|
|
720
|
+
displayPath: target.displayPath,
|
|
721
|
+
targetKey: target.targetKey
|
|
722
|
+
}, signal));
|
|
723
|
+
}
|
|
724
|
+
async listDir(target, signal) {
|
|
725
|
+
return (await listDirectory({
|
|
726
|
+
displayPath: target.displayPath,
|
|
727
|
+
targetKey: target.targetKey
|
|
728
|
+
}, signal)).map((entry) => ({
|
|
729
|
+
name: entry.name,
|
|
730
|
+
type: entry.type,
|
|
731
|
+
target: {
|
|
732
|
+
targetKey: entry.target.targetKey,
|
|
733
|
+
displayPath: entry.target.displayPath
|
|
734
|
+
},
|
|
735
|
+
...entry.version !== void 0 ? { version: entry.version } : {},
|
|
736
|
+
...entry.size !== void 0 ? { size: entry.size } : {}
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
async writeText(target, content, expected, signal) {
|
|
740
|
+
return this.withLock(target.targetKey, async () => {
|
|
741
|
+
const existing = await probe(target.targetKey);
|
|
742
|
+
if (existing && existing.type !== "file") throw new FsError(`cannot write "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
743
|
+
if (expected?.kind === "replaceIfVersion") {
|
|
744
|
+
if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
745
|
+
if (existing.version !== expected.version) throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
746
|
+
} else if (expected?.kind === "createIfAbsent" && existing) throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
747
|
+
const before = existing !== null && Buffer.byteLength(content, "utf8") < this.config.diffBasisMaxBytes ? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal) : null;
|
|
748
|
+
await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals, expected?.kind === "createIfAbsent" ? { displayPath: target.displayPath } : void 0);
|
|
749
|
+
const after = await probe(target.targetKey);
|
|
750
|
+
return {
|
|
751
|
+
operation: existing ? "update" : "create",
|
|
752
|
+
version: this.versionAfterWrite(after, target),
|
|
753
|
+
before,
|
|
754
|
+
after: normalizeLineEndings(content)
|
|
755
|
+
};
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
async editText(target, edit, expected, signal) {
|
|
759
|
+
return this.withLock(target.targetKey, async () => {
|
|
760
|
+
const existing = await probe(target.targetKey);
|
|
761
|
+
if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
762
|
+
if (existing.type !== "file") throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
763
|
+
if (expected && existing.version !== expected.version) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
764
|
+
const original = await readForEdit(target.targetKey, target.displayPath, signal);
|
|
765
|
+
const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath);
|
|
766
|
+
const content = restoreLineEndings(edited.content, original.lineEndings);
|
|
767
|
+
await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals);
|
|
768
|
+
const after = await probe(target.targetKey);
|
|
769
|
+
return {
|
|
770
|
+
version: this.versionAfterWrite(after, target),
|
|
771
|
+
before: original.content,
|
|
772
|
+
after: edited.content
|
|
773
|
+
};
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
/* v8 ignore next 5 -- the post-write probe finding the file absent requires a
|
|
777
|
+
* concurrent unlink between rename and stat; fall back to a sentinel version. */
|
|
778
|
+
versionAfterWrite(after, target) {
|
|
779
|
+
if (after) return after.version;
|
|
780
|
+
return FsVersion(`missing:${target.targetKey}`);
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
//#endregion
|
|
784
|
+
export { LocalFileSystem, LocalFileSystem as default };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-fs-local`.
|
|
4
|
+
* @module @deepseek-ai/dsh-fs-local/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-fs-local";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "fs-local-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
|
13
|
+
* beyond contracts enforced at its owning seam.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cordis-free local filesystem mechanics. This provider layer returns validated UTF-8 text,
|
|
3
|
+
* streams large files, and rejects binary data; line windows belong to `dsh-tool-fs`. Writes
|
|
4
|
+
* stage an exclusive owner-only file in a private sibling directory and atomically publish it.
|
|
5
|
+
* @module @deepseek-ai/dsh-fs-local/fsio
|
|
6
|
+
*/
|
|
7
|
+
import type { BigIntStats } from 'node:fs';
|
|
8
|
+
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs';
|
|
9
|
+
/**
|
|
10
|
+
* Test hook: lets specs pin the atomic-write temp names (to prove exclusive-open behavior without
|
|
11
|
+
* a name race), override native boundaries, and observe the staged temp file before publication.
|
|
12
|
+
*/
|
|
13
|
+
export interface FsIoInternals {
|
|
14
|
+
/** Override the host platform for native-publication unit coverage. */
|
|
15
|
+
platform?: NodeJS.Platform;
|
|
16
|
+
/** Override the generated private staging-dir name (relative to the target dir). */
|
|
17
|
+
tempDirName?: (writePath: string) => string;
|
|
18
|
+
/** Override the generated temp-file name (relative to the private staging dir). */
|
|
19
|
+
tempName?: (writePath: string) => string;
|
|
20
|
+
/** Override the Win32 DACL copy boundary. */
|
|
21
|
+
copyFileDacl?: (source: string, destination: string) => Promise<void>;
|
|
22
|
+
/** Override the Win32 security-preserving replacement boundary. */
|
|
23
|
+
replaceFile?: (replaced: string, replacement: string) => Promise<void>;
|
|
24
|
+
/** Override the hard-link no-replace publication boundary. */
|
|
25
|
+
linkFile?: (existingPath: string, newPath: string) => Promise<void>;
|
|
26
|
+
/** Override target inspection after guarded publication fails. */
|
|
27
|
+
inspectPublicationTarget?: (path: string) => Promise<BigIntStats>;
|
|
28
|
+
/** Override staging-directory removal for commit-point failure coverage. */
|
|
29
|
+
removeStagingDir?: (stagingDir: string) => Promise<void>;
|
|
30
|
+
/** Test hook after the temp file is written/synced but before final chmod+publication. */
|
|
31
|
+
inspectTemp?: (paths: {
|
|
32
|
+
stagingDir: string;
|
|
33
|
+
tempPath: string;
|
|
34
|
+
}) => void | Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/** A resolved local path: the absolute path shown to callers and its realpath identity. */
|
|
37
|
+
export interface LocalTarget {
|
|
38
|
+
/** Absolute path (symlinks not resolved) — used for display. */
|
|
39
|
+
displayPath: string;
|
|
40
|
+
/** Realpath identity — used as the stable target key and the I/O path. */
|
|
41
|
+
targetKey: FsTargetKey;
|
|
42
|
+
}
|
|
43
|
+
/** Result of probing a path: null when it does not exist. */
|
|
44
|
+
export interface PathInfo {
|
|
45
|
+
version: FsVersion;
|
|
46
|
+
mode: number;
|
|
47
|
+
type: 'file' | 'directory' | 'other';
|
|
48
|
+
size: number;
|
|
49
|
+
}
|
|
50
|
+
/** Result of probing a path without following the final symlink component. */
|
|
51
|
+
export interface PathLinkInfo {
|
|
52
|
+
version: FsVersion;
|
|
53
|
+
mode: number;
|
|
54
|
+
type: 'file' | 'directory' | 'symlink' | 'other';
|
|
55
|
+
size: number;
|
|
56
|
+
}
|
|
57
|
+
/** One local directory child with a resolved target and cheap metadata. */
|
|
58
|
+
export interface LocalDirEntry {
|
|
59
|
+
name: string;
|
|
60
|
+
type: 'file' | 'directory' | 'other';
|
|
61
|
+
target: LocalTarget;
|
|
62
|
+
version?: FsVersion;
|
|
63
|
+
size?: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a path to its absolute display path and realpath identity. For a missing target,
|
|
67
|
+
* realpath the nearest existing ancestor and append the missing suffix, preserving identity
|
|
68
|
+
* across symlinked ancestors before and after creation.
|
|
69
|
+
* @param cwd - base directory a relative `path` resolves against.
|
|
70
|
+
* @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`.
|
|
71
|
+
* @returns the absolute display path plus the realpath-derived stable target key.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget>;
|
|
74
|
+
/**
|
|
75
|
+
* Probe a path for its version, mode, type, and size. Null if absent.
|
|
76
|
+
* @param absolutePath - the path to stat (typically a target key; symlinks are followed).
|
|
77
|
+
* @returns the metadata, or null when the path — or a parent segment — does not exist.
|
|
78
|
+
*/
|
|
79
|
+
export declare function probe(absolutePath: string): Promise<PathInfo | null>;
|
|
80
|
+
/**
|
|
81
|
+
* Probe a path without following the final symlink component.
|
|
82
|
+
* @param absolutePath - the path entry to inspect with `lstat` semantics.
|
|
83
|
+
* @returns path-entry metadata, or null when the entry is absent.
|
|
84
|
+
*/
|
|
85
|
+
export declare function probeNoFollow(absolutePath: string): Promise<PathLinkInfo | null>;
|
|
86
|
+
/**
|
|
87
|
+
* List direct children of a directory in stable name order. Each child includes
|
|
88
|
+
* a resolved target plus stat metadata when still available; file contents are
|
|
89
|
+
* never read.
|
|
90
|
+
* @param target - the resolved directory to list; a missing or non-directory target throws.
|
|
91
|
+
* @param signal - aborts the listing, checked between children (`FS_ABORTED`).
|
|
92
|
+
* @returns one entry per direct child, sorted by name.
|
|
93
|
+
*/
|
|
94
|
+
export declare function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]>;
|
|
95
|
+
/**
|
|
96
|
+
* Read a whole regular UTF-8 text file into a single decoded string. Rejects
|
|
97
|
+
* non-regular files, invalid UTF-8, and NUL-byte binary samples.
|
|
98
|
+
* @param target - the resolved file to read.
|
|
99
|
+
* @param signal - aborts the read (`FS_ABORTED`).
|
|
100
|
+
* @returns the full decoded text, byte-for-byte (no normalization).
|
|
101
|
+
*/
|
|
102
|
+
export declare function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string>;
|
|
103
|
+
/**
|
|
104
|
+
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
|
|
105
|
+
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
|
|
106
|
+
* cross-chunk UTF-8 decoding), but never holds the whole file in memory.
|
|
107
|
+
* @param target - the resolved file to stream.
|
|
108
|
+
* @param signal - aborts the stream, including between chunks (`FS_ABORTED`).
|
|
109
|
+
* @returns decoded text chunks in file order; chunk boundaries carry no meaning.
|
|
110
|
+
*/
|
|
111
|
+
export declare function streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string>;
|
|
112
|
+
/**
|
|
113
|
+
* Atomically replace a file through a private, synced staging file in the same directory.
|
|
114
|
+
* POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file
|
|
115
|
+
* inherits the destination directory's DACL; a replacement copies the existing target's DACL
|
|
116
|
+
* onto the empty temp before writing and preserves the target descriptor at publication.
|
|
117
|
+
* @param absolutePath - destination; missing parent directories are created.
|
|
118
|
+
* @param content - the full UTF-8 text to write.
|
|
119
|
+
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
|
|
120
|
+
* inert as a mode on Windows but identifies replacement security semantics.
|
|
121
|
+
* @param signal - cancellation checked before final publication.
|
|
122
|
+
* @param internals - Test hook for pinning temp names and observing the staged file.
|
|
123
|
+
* @param createIfAbsent - when provided, publish with a hard-link no-replace
|
|
124
|
+
* primitive; a concurrent creator's file is preserved and this write is
|
|
125
|
+
* rejected with `FS_NOT_OBSERVED` using the supplied display path.
|
|
126
|
+
*/
|
|
127
|
+
export declare function writeFileAtomic(absolutePath: string, content: string, mode: number | undefined, signal: AbortSignal | undefined, internals?: FsIoInternals, createIfAbsent?: {
|
|
128
|
+
displayPath: string;
|
|
129
|
+
}): Promise<void>;
|
|
130
|
+
/** Line ending style detected before LF normalization. */
|
|
131
|
+
export type LineEndings = 'LF' | 'CRLF';
|
|
132
|
+
/**
|
|
133
|
+
* Collapse CRLF to LF — the canonical in-memory form every edit/diff basis
|
|
134
|
+
* uses. Lone `\r` bytes (not followed by `\n`) are left untouched.
|
|
135
|
+
* @param content - decoded text in whatever line-ending style the file had.
|
|
136
|
+
* @returns the text with every `\r\n` pair replaced by `\n`.
|
|
137
|
+
*/
|
|
138
|
+
declare function normalizeLineEndings(content: string): string;
|
|
139
|
+
/**
|
|
140
|
+
* Convert LF-normalized content back to the line-ending style detected at read
|
|
141
|
+
* time, for write-back. `LF` returns the content unchanged; `CRLF` re-normalizes
|
|
142
|
+
* first so an already-CRLF sequence is never doubled to `\r\r\n`.
|
|
143
|
+
* @param content - the LF-normalized (edited) text.
|
|
144
|
+
* @param lineEndings - the original file's style, as detected by {@link readForEdit}.
|
|
145
|
+
* @returns the text in the original file's line-ending style.
|
|
146
|
+
*/
|
|
147
|
+
declare function restoreLineEndings(content: string, lineEndings: LineEndings): string;
|
|
148
|
+
/**
|
|
149
|
+
* Read and decode a file for editing: rejects binaries, returns LF-normalized
|
|
150
|
+
* content plus the original line-ending style for write-back.
|
|
151
|
+
* @param absolutePath - the file to read (typically a target key).
|
|
152
|
+
* @param displayPath - the caller-facing path used in error messages.
|
|
153
|
+
* @param signal - aborts the read (`FS_ABORTED`).
|
|
154
|
+
* @returns the LF-normalized content and the detected style to restore on write-back.
|
|
155
|
+
*/
|
|
156
|
+
export declare function readForEdit(absolutePath: string, displayPath: string, signal?: AbortSignal): Promise<{
|
|
157
|
+
content: string;
|
|
158
|
+
lineEndings: LineEndings;
|
|
159
|
+
}>;
|
|
160
|
+
/**
|
|
161
|
+
* Best-effort overwrite diff basis. Binary, invalid UTF-8, a file at/above the byte limit,
|
|
162
|
+
* or a file deleted/made unreadable after the caller's preflight returns `null` so the write
|
|
163
|
+
* still succeeds and presentation falls back to a whole-file diff. The bound is enforced on
|
|
164
|
+
* the opened descriptor rather than a prior path stat, so concurrent external replacement or
|
|
165
|
+
* size changes cannot make this helper buffer more than `maxBytes`.
|
|
166
|
+
* @param absolutePath - the file to read (typically a target key).
|
|
167
|
+
* @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis.
|
|
168
|
+
* @param signal - aborts the read (`FS_ABORTED`); cancellation propagates, unlike I/O failure.
|
|
169
|
+
* @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8,
|
|
170
|
+
* descriptor-size-changed, or unreadable file.
|
|
171
|
+
*/
|
|
172
|
+
export declare function readTextForDiff(absolutePath: string, maxBytes: number, signal?: AbortSignal): Promise<string | null>;
|
|
173
|
+
/**
|
|
174
|
+
* Apply a literal replacement to LF-normalized content. Empty or missing search text throws
|
|
175
|
+
* `FS_EDIT_NOT_FOUND`; multiple matches throw `FS_AMBIGUOUS_EDIT` unless `replaceAll` is true.
|
|
176
|
+
* @param content - the current file content, already LF-normalized.
|
|
177
|
+
* @param oldString - literal text to find; CRLF inside it is normalized to LF before
|
|
178
|
+
* matching.
|
|
179
|
+
* @param newString - literal replacement text, normalized the same way.
|
|
180
|
+
* @param replaceAll - replace every match instead of requiring exactly one.
|
|
181
|
+
* @param displayPath - the caller-facing path used in error messages.
|
|
182
|
+
* @returns the edited LF-normalized content plus how many occurrences were replaced.
|
|
183
|
+
*/
|
|
184
|
+
export declare function applyLiteralEdit(content: string, oldString: string, newString: string, replaceAll: boolean, displayPath: string): {
|
|
185
|
+
content: string;
|
|
186
|
+
replacements: number;
|
|
187
|
+
};
|
|
188
|
+
export { normalizeLineEndings, restoreLineEndings };
|
|
189
|
+
//# sourceMappingURL=fsio.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases
|
|
3
|
+
* share stale guards, and writes through a symlink update its target without replacing the link.
|
|
4
|
+
* @module @deepseek-ai/dsh-fs-local
|
|
5
|
+
*/
|
|
6
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
7
|
+
import z from '@deepseek-ai/schemastery';
|
|
8
|
+
import { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs';
|
|
9
|
+
import type { FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs';
|
|
10
|
+
import type { FsIoInternals } from './fsio.ts';
|
|
11
|
+
/** Configuration for the local filesystem backend. */
|
|
12
|
+
export interface Config {
|
|
13
|
+
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
|
14
|
+
cwd?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the
|
|
17
|
+
* runtime's safe allocation/decode maximum. Defaults to 10 MiB.
|
|
18
|
+
*/
|
|
19
|
+
diffBasisMaxBytes?: number;
|
|
20
|
+
}
|
|
21
|
+
type ResolvedConfig = Required<Config>;
|
|
22
|
+
/**
|
|
23
|
+
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
|
|
24
|
+
* (a resolution default, NOT a containment boundary — see the filesystem
|
|
25
|
+
* capability-seam Agent Note); enforce
|
|
26
|
+
* containment with a stricter backend or a `tools/execute` permission plugin.
|
|
27
|
+
*/
|
|
28
|
+
export declare class LocalFileSystem extends FileSystem {
|
|
29
|
+
static Config: z<Config>;
|
|
30
|
+
/** Validated config (schemastery applied the defaults before construction). */
|
|
31
|
+
readonly config: ResolvedConfig;
|
|
32
|
+
/** Test hook forwarded to fsio for atomic-publication boundaries. */
|
|
33
|
+
internals: FsIoInternals;
|
|
34
|
+
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
|
|
35
|
+
* window can't interleave, making concurrent writes/edits deterministically
|
|
36
|
+
* ordered (one wins, the rest see the new version and reject as stale). */
|
|
37
|
+
private locks;
|
|
38
|
+
constructor(ctx: Context, config: Config);
|
|
39
|
+
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
|
|
40
|
+
private withLock;
|
|
41
|
+
resolve(path: string, opts?: {
|
|
42
|
+
cwd?: string;
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
}): Promise<FsTarget>;
|
|
45
|
+
processPath(target: FsTarget): string;
|
|
46
|
+
fileUrl(target: FsTarget): string;
|
|
47
|
+
contains(parent: FsTarget, child: FsTarget): boolean;
|
|
48
|
+
stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>;
|
|
49
|
+
lstat(path: string, opts?: {
|
|
50
|
+
cwd?: string;
|
|
51
|
+
}, signal?: AbortSignal): Promise<FsPathInfo | undefined>;
|
|
52
|
+
readText(target: FsTarget, signal?: AbortSignal): Promise<string>;
|
|
53
|
+
streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>;
|
|
54
|
+
listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>;
|
|
55
|
+
writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>;
|
|
56
|
+
editText(target: FsTarget, edit: FsEditRequest, expected?: {
|
|
57
|
+
version: FsVersion;
|
|
58
|
+
}, signal?: AbortSignal): Promise<FsEditOutcome>;
|
|
59
|
+
private versionAfterWrite;
|
|
60
|
+
}
|
|
61
|
+
export default LocalFileSystem;
|
|
62
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-fs-local`.
|
|
3
|
+
* @module @deepseek-ai/dsh-fs-local/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "fs-local-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows security-descriptor helpers for atomic local-file replacement. Koffi loads lazily so
|
|
3
|
+
* non-Windows processes never open Win32 libraries.
|
|
4
|
+
* @module @deepseek-ai/dsh-fs-local/win32
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Read a file's self-relative DACL security descriptor.
|
|
8
|
+
* @param path - existing file whose DACL is read.
|
|
9
|
+
* @returns a descriptor buffer accepted by `SetFileSecurityW`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function readFileDaclWin32(path: string): Promise<Buffer>;
|
|
12
|
+
/**
|
|
13
|
+
* Copy an existing file's DACL onto another file and protect it from staging-parent inheritance.
|
|
14
|
+
* The destination must still be empty when confidentiality depends on this call.
|
|
15
|
+
* @param source - existing file whose DACL is copied.
|
|
16
|
+
* @param destination - existing file that receives the protected DACL.
|
|
17
|
+
*/
|
|
18
|
+
export declare function copyFileDaclWin32(source: string, destination: string): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Replace a Windows file while preserving the replaced file's ACL and other replace metadata.
|
|
21
|
+
* @param replaced - existing destination file.
|
|
22
|
+
* @param replacement - closed staging file on the same volume.
|
|
23
|
+
*/
|
|
24
|
+
export declare function replaceFileWin32(replaced: string, replacement: string): Promise<void>;
|
|
25
|
+
//# sourceMappingURL=win32.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepseek-ai/dsh-fs-local",
|
|
3
|
+
"description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)",
|
|
4
|
+
"version": "0.0.1-rc.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "restricted"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/fs/fs-local"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/invariant.js",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "BSD-3-Clause",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@deepseek-ai/dsh-fs": "^0.0.1-rc.1",
|
|
36
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
|
37
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"koffi": "^3.1.0",
|
|
41
|
+
"@deepseek-ai/schemastery": "^3.18.1-rc.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@deepseek-ai/dsh-fs": "^0.0.1-rc.1",
|
|
45
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
|
46
|
+
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
|
47
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1"
|
|
48
|
+
}
|
|
49
|
+
}
|