Add filesystem capability seam and tools
This commit is contained in:
23
packages/fs/fs-local/README.md
Normal file
23
packages/fs/fs-local/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# @deepseek-ai/dsh-fs-local
|
||||
|
||||
The **local-filesystem implementation** of the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the four `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
|
||||
|
||||
```ts ignore-check
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
// ctx.fs is now the local backend; load @deepseek-ai/dsh-tool-fs to expose read/write/edit to the model.
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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 keeps its absolute path as the key so creates still get a stable identity. `displayPath` is the absolute (un-resolved) path.
|
||||
- **`readPage`** — UTF-8 only. A fast path (`readFile`) handles files under `FAST_PATH_MAX_SIZE` (10 MB); larger files stream with a capped line buffer so a newline-free giant file can't exhaust memory. NUL-byte samples are rejected (`FS_NOT_TEXT`). Output is bounded to `READ_LIMIT` (2000) lines, `READ_MAX_BYTES` (50 KB), and `READ_MAX_LINE_LENGTH` (2000) chars per line. The `version` is `mtimeMs:size`.
|
||||
- **`createOrReplace`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. Honors the `FsExpectation`: an `observed` write must match the recorded version (else `FS_STALE_VERSION`); a `partial` write onto an existing file is rejected (`FS_PARTIAL_OBSERVATION`); an `unobserved` write onto an existing file is rejected (`FS_NOT_OBSERVED`).
|
||||
- **`applyEdit`** — atomic literal read-modify-write over the same primitive. Verifies the expected version, 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`).
|
||||
|
||||
## `cwd` is not a sandbox
|
||||
|
||||
`config.cwd` is a resolution default, not a containment boundary — absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. See [the filesystem capability-seam RFC's Risks section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#risks).
|
||||
|
||||
The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
|
||||
34
packages/fs/fs-local/package.json
Normal file
34
packages/fs/fs-local/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs-local",
|
||||
"description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
470
packages/fs/fs-local/src/fsio.ts
Normal file
470
packages/fs/fs-local/src/fsio.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept
|
||||
* separate from the service class (mirroring `dsh-bash-local`'s `run.ts`) so
|
||||
* the raw read/write/edit mechanics can be unit-tested without a Context.
|
||||
*
|
||||
* The reader uses two code paths so a single huge line can never balloon
|
||||
* memory: a **fast path** (`readFile` + in-memory split) for files under
|
||||
* {@link FAST_PATH_MAX_SIZE}, and a **streaming path** (manual newline scan
|
||||
* with a capped line buffer) for larger files. Both reject NUL-byte binary
|
||||
* samples and keep only the requested page in memory.
|
||||
*
|
||||
* Writes are atomic: content goes to a temp file opened exclusively (`wx`,
|
||||
* `0o600`, so a pre-existing path can never be clobbered and write-in-progress
|
||||
* bytes stay owner-only) inside a randomly-named private staging directory
|
||||
* (`0o700`) next to the target, then `rename`d over the target. Edits are
|
||||
* read-modify-write over the same atomic primitive.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-local/fsio
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsReadRequest, FsTextLine, FsView } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Default and maximum number of lines returned by one read. */
|
||||
export const READ_LIMIT = 2000
|
||||
|
||||
/** Maximum characters returned for a single line. */
|
||||
export const READ_MAX_LINE_LENGTH = 2000
|
||||
|
||||
/** Maximum bytes returned for selected file lines. */
|
||||
export const READ_MAX_BYTES = 50 * 1024
|
||||
|
||||
/** Files smaller than this use the in-memory fast path; larger files stream. */
|
||||
export const FAST_PATH_MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
const READ_MAX_BYTES_LABEL = `${READ_MAX_BYTES / 1024} KB`
|
||||
const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
|
||||
const BINARY_SAMPLE_BYTES = 8192
|
||||
const NUL_CHAR = String.fromCharCode(0)
|
||||
const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1
|
||||
|
||||
/**
|
||||
* Test seam: lets specs force the streaming path (via a small
|
||||
* `fastPathMaxSize`) and pin the temp-file name (to prove exclusive-open
|
||||
* behavior) without a 10 MB fixture or a name race.
|
||||
*/
|
||||
export interface FsIoInternals {
|
||||
/** Override {@link FAST_PATH_MAX_SIZE} for routing. */
|
||||
fastPathMaxSize?: number
|
||||
/** Override the generated private staging-dir name (relative to the target dir). */
|
||||
tempDirName?: (writePath: string) => string
|
||||
/** Override the generated temp-file name (relative to the private staging dir). */
|
||||
tempName?: (writePath: string) => string
|
||||
/** Test hook after the temp file is written/synced but before final chmod+rename. */
|
||||
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** A resolved local path: the absolute path shown to callers and its realpath identity. */
|
||||
export interface LocalTarget {
|
||||
/** Absolute path (symlinks not resolved) — used for display. */
|
||||
displayPath: string
|
||||
/** Realpath identity — used as the stable target key and the I/O path. */
|
||||
targetKey: string
|
||||
}
|
||||
|
||||
/** Result of probing a path: null when it does not exist. */
|
||||
export interface PathInfo {
|
||||
version: string
|
||||
mode: number
|
||||
isFile: boolean
|
||||
}
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
/* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
|
||||
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
|
||||
}
|
||||
|
||||
/** Opaque version token from a stat: mtime (ns precision) + size. */
|
||||
function versionOf(info: Stats): string {
|
||||
return `${info.mtimeMs}:${info.size}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path to its absolute display path and realpath identity. Relative
|
||||
* paths are based on `cwd`. The `targetKey` realpaths the parent directory and
|
||||
* re-appends the basename, so a not-yet-created file gets the same stable key
|
||||
* it will have after creation (the directory exists even when the file does
|
||||
* not). Two input paths reaching the same file via symlinks share one key.
|
||||
* Falls back to the absolute path when even the parent cannot be resolved.
|
||||
*/
|
||||
export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
|
||||
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
||||
const displayPath = resolve(cwd, path)
|
||||
try {
|
||||
// Prefer the file's own realpath (resolves a symlinked file to its target).
|
||||
return { displayPath, targetKey: await realpath(displayPath) }
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to parent-dir resolution. */
|
||||
if (!isENOENT(error)) throw error
|
||||
}
|
||||
try {
|
||||
// File absent: realpath the parent dir + basename so creates get a stable key.
|
||||
return { displayPath, targetKey: join(await realpath(dirname(displayPath)), basename(displayPath)) }
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- parent-dir realpath failing needs the dir itself to be missing/unreadable; fall back to the absolute path. */
|
||||
if (!isENOENT(error)) throw error
|
||||
return { displayPath, targetKey: displayPath }
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe a path for its version, mode, and regular-file status. Null if absent. */
|
||||
export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
try {
|
||||
const info = await stat(absolutePath)
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, isFile: info.isFile() }
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */
|
||||
if (!isENOENT(error)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reading ---
|
||||
|
||||
interface PageAccumulator {
|
||||
lines: FsTextLine[]
|
||||
totalLines: number
|
||||
outputBytes: number
|
||||
truncatedByBytes: boolean
|
||||
done: boolean
|
||||
}
|
||||
|
||||
function newAccumulator(): PageAccumulator {
|
||||
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
|
||||
}
|
||||
|
||||
function truncateReadLine(line: string): string {
|
||||
return line.length > READ_MAX_LINE_LENGTH
|
||||
? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}`
|
||||
: line
|
||||
}
|
||||
|
||||
function lineByteSize(line: string, currentLineCount: number): number {
|
||||
return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
function consumeLine(acc: PageAccumulator, rawLine: string, request: FsReadRequest): void {
|
||||
acc.totalLines += 1
|
||||
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
|
||||
|
||||
const text = truncateReadLine(rawLine)
|
||||
const bytes = lineByteSize(text, acc.lines.length)
|
||||
if (acc.outputBytes + bytes > READ_MAX_BYTES) {
|
||||
acc.truncatedByBytes = true
|
||||
acc.done = true
|
||||
return
|
||||
}
|
||||
acc.outputBytes += bytes
|
||||
acc.lines.push({ number: acc.totalLines, text })
|
||||
}
|
||||
|
||||
function stripCarriageReturn(line: string): string {
|
||||
return line.endsWith('\r') ? line.slice(0, -1) : line
|
||||
}
|
||||
|
||||
/** The outcome shape `readTextPage` returns (minus the offset/limit echo, which the caller adds). */
|
||||
export interface ReadPageResult {
|
||||
lines: FsTextLine[]
|
||||
totalLines: number
|
||||
truncatedByBytes: boolean
|
||||
view: FsView
|
||||
version: string
|
||||
}
|
||||
|
||||
function buildResult(acc: PageAccumulator, request: FsReadRequest, version: string, displayPath: string): ReadPageResult {
|
||||
if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) {
|
||||
throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND')
|
||||
}
|
||||
const endLine = acc.lines.at(-1)?.number ?? Math.max(0, request.offset - 1)
|
||||
const view: FsView = request.offset === 1 && !acc.truncatedByBytes && endLine >= acc.totalLines ? 'full' : 'partial'
|
||||
return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes, view, version }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a bounded UTF-8 text-file page. Rejects non-regular files and NUL-byte
|
||||
* binary samples; dispatches to the fast or streaming path by file size.
|
||||
*/
|
||||
export async function readTextPage(
|
||||
target: LocalTarget,
|
||||
request: FsReadRequest,
|
||||
signal?: AbortSignal,
|
||||
internals: FsIoInternals = {},
|
||||
): Promise<ReadPageResult> {
|
||||
throwIfAborted(signal, 'read')
|
||||
const absolutePath = target.targetKey
|
||||
let info: Stats
|
||||
try {
|
||||
info = await stat(absolutePath)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */
|
||||
if (!isENOENT(error)) throw error
|
||||
throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
}
|
||||
if (!info.isFile()) throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
|
||||
const version = versionOf(info)
|
||||
const fastPathMax = internals.fastPathMaxSize ?? FAST_PATH_MAX_SIZE
|
||||
return info.size < fastPathMax
|
||||
? readTextPageFast(target, request, version, signal)
|
||||
: readTextPageStreaming(target, request, version, signal)
|
||||
}
|
||||
|
||||
async function readTextPageFast(
|
||||
target: LocalTarget,
|
||||
request: FsReadRequest,
|
||||
version: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadPageResult> {
|
||||
const raw = await readFile(target.targetKey, signal ? { signal } : {})
|
||||
throwIfAborted(signal, 'read')
|
||||
if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
}
|
||||
|
||||
const text = raw.toString('utf8')
|
||||
const acc = newAccumulator()
|
||||
let startPos = 0
|
||||
let newlinePos: number
|
||||
while ((newlinePos = text.indexOf('\n', startPos)) !== -1) {
|
||||
consumeLine(acc, stripCarriageReturn(text.slice(startPos, newlinePos)), request)
|
||||
if (acc.done) break
|
||||
startPos = newlinePos + 1
|
||||
}
|
||||
if (!acc.done && startPos < text.length) {
|
||||
consumeLine(acc, stripCarriageReturn(text.slice(startPos)), request)
|
||||
}
|
||||
return buildResult(acc, request, version, target.displayPath)
|
||||
}
|
||||
|
||||
async function readTextPageStreaming(
|
||||
target: LocalTarget,
|
||||
request: FsReadRequest,
|
||||
version: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadPageResult> {
|
||||
const stream = createReadStream(target.targetKey, { encoding: 'utf8', ...signal ? { signal } : {} })
|
||||
const acc = newAccumulator()
|
||||
let lineBuffer = ''
|
||||
let firstChunk = true
|
||||
|
||||
function appendToLineBuffer(segment: string): void {
|
||||
if (lineBuffer.length >= LINE_BUFFER_CAP) return
|
||||
lineBuffer += segment
|
||||
if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP)
|
||||
}
|
||||
|
||||
function flushLine(): void {
|
||||
consumeLine(acc, stripCarriageReturn(lineBuffer), request)
|
||||
lineBuffer = ''
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const chunk of stream as AsyncIterable<string>) {
|
||||
if (firstChunk) {
|
||||
firstChunk = false
|
||||
if (chunk.slice(0, BINARY_SAMPLE_BYTES).includes(NUL_CHAR)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
}
|
||||
}
|
||||
let startPos = 0
|
||||
let newlinePos: number
|
||||
while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) {
|
||||
appendToLineBuffer(chunk.slice(startPos, newlinePos))
|
||||
flushLine()
|
||||
startPos = newlinePos + 1
|
||||
if (acc.done) return buildResult(acc, request, version, target.displayPath)
|
||||
}
|
||||
appendToLineBuffer(chunk.slice(startPos))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */
|
||||
if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED')
|
||||
throw error
|
||||
}
|
||||
|
||||
if (lineBuffer.length > 0) flushLine()
|
||||
return buildResult(acc, request, version, target.displayPath)
|
||||
}
|
||||
|
||||
/** Format the line-numbered body + pagination footer for a read page. */
|
||||
export function formatReadBody(result: ReadPageResult, offset: number): string {
|
||||
const endLine = result.lines.at(-1)?.number ?? Math.max(0, offset - 1)
|
||||
let footer: string
|
||||
if (result.truncatedByBytes) {
|
||||
footer = `(Output capped at ${READ_MAX_BYTES_LABEL}. Showing lines ${offset}-${endLine}. Use offset=${endLine + 1} to continue.)`
|
||||
} else if (endLine < result.totalLines) {
|
||||
footer = `(Showing lines ${offset}-${endLine} of ${result.totalLines}. Use offset=${endLine + 1} to continue.)`
|
||||
} else {
|
||||
footer = `(End of file - total ${result.totalLines} lines)`
|
||||
}
|
||||
return result.lines.length > 0
|
||||
? `${result.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
|
||||
: footer
|
||||
}
|
||||
|
||||
// --- Writing ---
|
||||
|
||||
async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise<never> {
|
||||
try {
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
} catch (cleanupError: unknown) {
|
||||
/* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */
|
||||
throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError })
|
||||
}
|
||||
throw originalError
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write `content` to `absolutePath`: create parent dirs, write to a
|
||||
* randomly-named temp file opened exclusively (`wx`, `0o600`) inside a private
|
||||
* (`0o700`) staging directory, fsync, optionally chmod to the final mode while
|
||||
* still private, then rename over the target. `mode` (when given) preserves an
|
||||
* existing file's permissions across the replace.
|
||||
*/
|
||||
export async function writeFileAtomic(
|
||||
absolutePath: string,
|
||||
content: string,
|
||||
mode: number | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
internals: FsIoInternals = {},
|
||||
): Promise<void> {
|
||||
throwIfAborted(signal, 'write')
|
||||
const directory = dirname(absolutePath)
|
||||
await mkdir(directory, { recursive: true })
|
||||
|
||||
throwIfAborted(signal, 'write')
|
||||
const stagingDirName = internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir`
|
||||
const stagingDir = join(directory, stagingDirName)
|
||||
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
|
||||
const tempPath = join(stagingDir, tempName)
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined
|
||||
let stagingCreated = false
|
||||
try {
|
||||
await mkdir(stagingDir, { mode: 0o700 })
|
||||
stagingCreated = true
|
||||
await chmod(stagingDir, 0o700)
|
||||
|
||||
handle = await open(tempPath, 'wx', 0o600)
|
||||
await handle.chmod(0o600)
|
||||
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
|
||||
await handle.sync()
|
||||
await internals.inspectTemp?.({ stagingDir, tempPath })
|
||||
if (mode !== undefined) await handle.chmod(mode)
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
|
||||
throwIfAborted(signal, 'write')
|
||||
await rename(tempPath, absolutePath)
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */
|
||||
let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error
|
||||
/* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */
|
||||
if (handle) {
|
||||
try {
|
||||
await handle.close()
|
||||
} catch (closeError: unknown) {
|
||||
failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, 'FS_NOT_FOUND', { cause: failure })
|
||||
}
|
||||
}
|
||||
if (!stagingCreated) throw failure
|
||||
return removeStagingDirOrThrow(stagingDir, failure)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Editing ---
|
||||
|
||||
/** Line ending style detected before LF normalization. */
|
||||
export type LineEndings = 'LF' | 'CRLF'
|
||||
|
||||
function normalizeLineEndings(content: string): string {
|
||||
return content.replaceAll('\r\n', '\n')
|
||||
}
|
||||
|
||||
function detectLineEndings(raw: string): LineEndings {
|
||||
const sample = raw.slice(0, 4096)
|
||||
const crlfCount = sample.split('\r\n').length - 1
|
||||
const lfCount = sample.split('\n').length - 1 - crlfCount
|
||||
return crlfCount > lfCount ? 'CRLF' : 'LF'
|
||||
}
|
||||
|
||||
function restoreLineEndings(content: string, lineEndings: LineEndings): string {
|
||||
return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n')
|
||||
}
|
||||
|
||||
function countOccurrences(content: string, needle: string): number {
|
||||
let count = 0
|
||||
let index = 0
|
||||
while (true) {
|
||||
const found = content.indexOf(needle, index)
|
||||
if (found === -1) return count
|
||||
count += 1
|
||||
index = found + needle.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and decode a file for editing: rejects binaries, returns LF-normalized
|
||||
* content plus the original line-ending style for write-back.
|
||||
*/
|
||||
export async function readForEdit(
|
||||
absolutePath: string,
|
||||
displayPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ content: string; lineEndings: LineEndings }> {
|
||||
throwIfAborted(signal, 'edit')
|
||||
const buffer = await readFile(absolutePath, signal ? { signal } : {})
|
||||
throwIfAborted(signal, 'edit')
|
||||
if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
const raw = buffer.toString('utf8')
|
||||
return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a literal replacement to LF-normalized content. Throws
|
||||
* `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and
|
||||
* `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns
|
||||
* the edited content (still LF-normalized) and the replacement count.
|
||||
*/
|
||||
export function applyLiteralEdit(
|
||||
content: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
displayPath: string,
|
||||
): { content: string; replacements: number } {
|
||||
const oldNorm = normalizeLineEndings(oldString)
|
||||
if (oldNorm.length === 0) {
|
||||
throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND')
|
||||
}
|
||||
const newNorm = normalizeLineEndings(newString)
|
||||
const replacements = countOccurrences(content, oldNorm)
|
||||
if (replacements === 0) {
|
||||
throw new FsError(`old_string was not found in "${displayPath}"`, 'FS_EDIT_NOT_FOUND')
|
||||
}
|
||||
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')
|
||||
}
|
||||
return { content: content.split(oldNorm).join(newNorm), replacements }
|
||||
}
|
||||
|
||||
export { restoreLineEndings }
|
||||
197
packages/fs/fs-local/src/index.ts
Normal file
197
packages/fs/fs-local/src/index.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Local-filesystem implementation of the `ctx.fs` seam. {@link LocalFileSystem}
|
||||
* subclasses {@link FileSystem} and backs the four primitives with the host
|
||||
* filesystem via {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution
|
||||
* uses `realpath`, so the stable `targetKey` is the real file identity (two
|
||||
* input paths reaching the same file through symlinks share one key, and writes
|
||||
* land on the link target — preserving the link).
|
||||
*
|
||||
* Future sandboxed/remote/virtual backends are sibling packages implementing
|
||||
* the same interface; loading this one populates `ctx.fs`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { FileSystem, FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsExpectation,
|
||||
FsReadOutcome,
|
||||
FsReadRequest,
|
||||
FsTarget,
|
||||
FsVersion,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
probe,
|
||||
readForEdit,
|
||||
readTextPage,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
writeFileAtomic,
|
||||
} from './fsio.ts'
|
||||
import type { FsIoInternals } from './fsio.ts'
|
||||
|
||||
export {
|
||||
FAST_PATH_MAX_SIZE,
|
||||
READ_LIMIT,
|
||||
READ_MAX_BYTES,
|
||||
READ_MAX_LINE_LENGTH,
|
||||
applyLiteralEdit,
|
||||
formatReadBody,
|
||||
probe,
|
||||
readForEdit,
|
||||
readTextPage,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
writeFileAtomic,
|
||||
} from './fsio.ts'
|
||||
export type { FsIoInternals, LineEndings, LocalTarget, PathInfo, ReadPageResult } from './fsio.ts'
|
||||
|
||||
/** Configuration for the local filesystem backend. */
|
||||
export interface Config {
|
||||
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
|
||||
* (a resolution default, NOT a containment boundary — see the filesystem
|
||||
* capability-seam RFC); enforce
|
||||
* containment with a stricter backend or a `tools/execute` permission plugin.
|
||||
*/
|
||||
export class LocalFileSystem extends FileSystem {
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string().default(process.cwd()),
|
||||
})
|
||||
|
||||
readonly config: ResolvedConfig
|
||||
/** Test seam forwarded to fsio (force streaming path, pin temp names). */
|
||||
internals: FsIoInternals = {}
|
||||
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
|
||||
* window can't interleave, making concurrent writes/edits deterministically
|
||||
* ordered (one wins, the rest see the new version and reject as stale). */
|
||||
private locks = new Map<string, Promise<unknown>>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
this.config = config as ResolvedConfig
|
||||
}
|
||||
|
||||
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
|
||||
private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> {
|
||||
const prior = this.locks.get(targetKey) ?? Promise.resolve()
|
||||
const run = prior.then(op, op)
|
||||
// Keep the chain alive but swallow this op's result/throw for the *next* waiter.
|
||||
const tail = run.then(() => undefined, () => undefined)
|
||||
this.locks.set(targetKey, tail)
|
||||
try {
|
||||
return await run
|
||||
} finally {
|
||||
if (this.locks.get(targetKey) === tail) {
|
||||
this.locks.delete(targetKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
const local = await resolveLocalTarget(this.config.cwd, path)
|
||||
return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath }
|
||||
}
|
||||
|
||||
override async readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise<FsReadOutcome> {
|
||||
const result = await readTextPage(
|
||||
{ displayPath: target.displayPath, targetKey: target.targetKey },
|
||||
request,
|
||||
signal,
|
||||
this.internals,
|
||||
)
|
||||
return {
|
||||
offset: request.offset,
|
||||
limit: request.limit,
|
||||
lines: result.lines,
|
||||
totalLines: result.totalLines,
|
||||
version: result.version,
|
||||
view: result.view,
|
||||
...result.truncatedByBytes ? { truncatedByBytes: true } : {},
|
||||
}
|
||||
}
|
||||
|
||||
override async createOrReplace(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
expected: FsExpectation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FsWriteOutcome> {
|
||||
return this.withLock(target.targetKey, async () => {
|
||||
const existing = await probe(target.targetKey)
|
||||
if (existing && !existing.isFile) {
|
||||
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
|
||||
if (expected.kind === 'observed') {
|
||||
// Stale guard: the file must still be at the version the owner observed.
|
||||
if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
|
||||
if (existing.version !== expected.version) {
|
||||
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
}
|
||||
} else if (expected.kind === 'partial') {
|
||||
if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
|
||||
throw new FsError(`cannot overwrite existing "${target.displayPath}" after only a partial read`, 'FS_PARTIAL_OBSERVATION')
|
||||
} else if (existing) {
|
||||
// Unobserved write onto an existing file: a blind overwrite — require a read first.
|
||||
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
|
||||
await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
|
||||
const after = await probe(target.targetKey)
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
version: this.versionAfterWrite(after, target),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override async applyEdit(
|
||||
target: FsTarget,
|
||||
edit: FsEditRequest,
|
||||
expected: { version: FsVersion },
|
||||
signal?: AbortSignal,
|
||||
): Promise<FsEditOutcome> {
|
||||
return this.withLock(target.targetKey, async () => {
|
||||
const existing = await probe(target.targetKey)
|
||||
if (!existing) throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
if (!existing.isFile) throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
if (existing.version !== expected.version) {
|
||||
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
}
|
||||
|
||||
const original = await readForEdit(target.targetKey, target.displayPath, signal)
|
||||
const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath)
|
||||
const content = restoreLineEndings(edited.content, original.lineEndings)
|
||||
await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals)
|
||||
|
||||
const after = await probe(target.targetKey)
|
||||
return {
|
||||
replacements: edited.replacements,
|
||||
replaceAll: edit.replaceAll,
|
||||
version: this.versionAfterWrite(after, target),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* v8 ignore next 5 -- the post-write probe finding the file absent requires a
|
||||
* concurrent unlink between rename and stat; fall back to a sentinel version. */
|
||||
private versionAfterWrite(after: { version: string } | null, target: FsTarget): string {
|
||||
if (after) return after.version
|
||||
return `missing:${target.targetKey}`
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalFileSystem
|
||||
268
packages/fs/fs-local/tests/filesystem.spec.ts
Normal file
268
packages/fs/fs-local/tests/filesystem.spec.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Tests for the local backend through the `ctx.fs` service: the full
|
||||
* read→write→edit lifecycle with the read-before-write policy, stale-version
|
||||
* guards, concurrency races, symlink identity, and HMR/disposal.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import type { FsExecContext } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
let fs: LocalFileSystem
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-fs-'))
|
||||
ctx = new Context()
|
||||
fiber = await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
fs = ctx.fs as LocalFileSystem
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const READ_ALL = { offset: 1, limit: 2000 }
|
||||
const exec = (): FsExecContext => ({ agent: { session: {} } })
|
||||
function lockCount(localFs: LocalFileSystem): number {
|
||||
return (localFs as unknown as { locks: Map<string, Promise<unknown>> }).locks.size
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers LocalFileSystem as ctx.fs with a default cwd', async () => {
|
||||
const bare = new Context()
|
||||
const bareFiber = await bare.plugin(LocalFileSystem)
|
||||
expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd())
|
||||
await bareFiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read → write → edit lifecycle', () => {
|
||||
it('creates a new file without a prior read', async () => {
|
||||
const target = await fs.resolve('new.txt')
|
||||
const outcome = await fs.write(target, 'fresh', exec())
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('updates an existing file after reading it', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
const outcome = await fs.write(target, 'new', owner)
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new')
|
||||
})
|
||||
|
||||
it('edits an existing file after reading it', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
const outcome = await fs.edit(target, { oldString: 'world', newString: 'there', replaceAll: false }, owner)
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('rejects an empty edit oldString through ctx.fs without hanging or changing the file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
|
||||
await expect(fs.edit(target, { oldString: '', newString: 'boom', replaceAll: false }, owner))
|
||||
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('propagates truncatedByBytes from a byte-capped read', async () => {
|
||||
await writeFile(join(dir, 'big.txt'), Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
|
||||
const outcome = await fs.read(await fs.resolve('big.txt'), READ_ALL, exec())
|
||||
expect(outcome.truncatedByBytes).toBe(true)
|
||||
expect(outcome.view).toBe('partial')
|
||||
})
|
||||
|
||||
it('allows a follow-up edit without re-reading (write/edit refresh state)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a b')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
await fs.edit(target, { oldString: 'a', newString: 'X', replaceAll: false }, owner)
|
||||
await fs.edit(target, { oldString: 'b', newString: 'Y', replaceAll: false }, owner)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('X Y')
|
||||
})
|
||||
|
||||
it('releases per-target mutation locks after success and failure', async () => {
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.write(target, 'created', exec())
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
|
||||
await expect(fs.write(target, 'blind overwrite', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read-before-write policy', () => {
|
||||
it('rejects a blind overwrite of an existing file (no prior read)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.write(target, 'new', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects a write after only a partial read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, { offset: 1, limit: 1 }, owner)
|
||||
await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' })
|
||||
})
|
||||
|
||||
it('rejects a write after a partial read when the file was deleted, without recreating it', async () => {
|
||||
const path = join(dir, 'a.txt')
|
||||
await writeFile(path, 'one\ntwo')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, { offset: 1, limit: 1 }, owner)
|
||||
await unlink(path)
|
||||
|
||||
await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects an edit with no prior read (FS_NOT_OBSERVED)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.edit(target, { oldString: 'old', newString: 'new', replaceAll: false }, exec()))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale-version guard + concurrency (defensive class B)', () => {
|
||||
it('rejects a write when the file changed since it was read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
// An out-of-band change after the read.
|
||||
await writeFile(join(dir, 'a.txt'), 'changed-externally')
|
||||
await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('rejects an observed write when the file was deleted after the read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
await unlink(join(dir, 'a.txt')) // file vanishes; observed write must fail (not silently create)
|
||||
await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('two concurrent edits: one wins, the other is rejected as stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.read(target, READ_ALL, owner)
|
||||
// Both edits captured the same recorded version; only one rename can match it.
|
||||
const results = await Promise.allSettled([
|
||||
fs.edit(target, { oldString: 'base', newString: 'one', replaceAll: false }, owner),
|
||||
fs.edit(target, { oldString: 'base', newString: 'two', replaceAll: false }, owner),
|
||||
])
|
||||
const fulfilled = results.filter(r => r.status === 'fulfilled')
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(fulfilled).toHaveLength(1)
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('symlink targetKey identity (defensive class F)', () => {
|
||||
it('a read via the real path authorizes an edit via the symlink path', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const owner = exec()
|
||||
await fs.read(await fs.resolve('real.txt'), READ_ALL, owner)
|
||||
// Edit through the link: same realpath → same targetKey → prior read counts.
|
||||
const linkTarget = await fs.resolve('link.txt')
|
||||
const outcome = await fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved, target written
|
||||
})
|
||||
|
||||
it('write through a symlink preserves the link and writes the real target', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const owner = exec()
|
||||
const linkTarget = await fs.resolve('link.txt')
|
||||
await fs.read(linkTarget, READ_ALL, owner)
|
||||
await fs.write(linkTarget, 'replaced', owner)
|
||||
expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('replaced')
|
||||
})
|
||||
|
||||
it('a stale change is detected across both paths', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const owner = exec()
|
||||
await fs.read(await fs.resolve('real.txt'), READ_ALL, owner)
|
||||
await writeFile(join(dir, 'real.txt'), 'changed') // out-of-band via real path
|
||||
const linkTarget = await fs.resolve('link.txt')
|
||||
await expect(fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-regular targets', () => {
|
||||
it('rejects writing onto a directory', async () => {
|
||||
const target = await fs.resolve('.') // the cwd dir
|
||||
await expect(fs.write(target, 'x', exec())).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('applyEdit rejects a target that vanished after the read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const owner = exec()
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = (await fs.read(target, READ_ALL, owner)).version
|
||||
await unlink(join(dir, 'a.txt'))
|
||||
await expect(fs.applyEdit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('applyEdit rejects a non-regular target', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.applyEdit(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: 'v' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR / disposal (defensive class D)', () => {
|
||||
it('disposing the fiber withdraws ctx.fs', async () => {
|
||||
const local = new Context()
|
||||
const fiber = await local.plugin(LocalFileSystem, { cwd: dir })
|
||||
expect(local.fs).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(local.fs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a fresh provider does not inherit recorded file state', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const local = new Context()
|
||||
const owner = exec()
|
||||
const fiber = await local.plugin(LocalFileSystem, { cwd: dir })
|
||||
await (local.fs as LocalFileSystem).read(await local.fs.resolve('a.txt'), READ_ALL, owner)
|
||||
await fiber.dispose()
|
||||
|
||||
await local.plugin(LocalFileSystem, { cwd: dir })
|
||||
const fs2 = local.fs as LocalFileSystem
|
||||
const target = await fs2.resolve('a.txt')
|
||||
// Same owner object, but state was released on disposal.
|
||||
await expect(fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
364
packages/fs/fs-local/tests/fsio.spec.ts
Normal file
364
packages/fs/fs-local/tests/fsio.spec.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Cordis-free tests for the raw local-filesystem I/O: path resolution,
|
||||
* fast/streaming reads, pagination/caps, binary rejection, atomic-write temp
|
||||
* safety, literal edit matching, and line-ending handling.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
formatReadBody,
|
||||
probe,
|
||||
readForEdit,
|
||||
readTextPage,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
writeFileAtomic,
|
||||
} from '@deepseek-ai/dsh-fs-local'
|
||||
import type { LocalTarget } from '@deepseek-ai/dsh-fs-local'
|
||||
|
||||
let dir: string
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const READ_ALL = { offset: 1, limit: 2000 }
|
||||
const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: path })
|
||||
|
||||
describe('resolveLocalTarget', () => {
|
||||
it('resolves a relative path from cwd and realpaths it', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const target = await resolveLocalTarget(dir, 'a.txt')
|
||||
expect(target.displayPath).toBe(file)
|
||||
expect(target.targetKey).toBe(await (await import('node:fs/promises')).realpath(file))
|
||||
})
|
||||
|
||||
it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => {
|
||||
const { realpath } = await import('node:fs/promises')
|
||||
const target = await resolveLocalTarget(dir, 'missing.txt')
|
||||
expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt'))
|
||||
})
|
||||
|
||||
it('two paths to the same file via a symlink share one targetKey', async () => {
|
||||
const real = join(dir, 'real.txt')
|
||||
await writeFile(real, 'hi')
|
||||
const link = join(dir, 'link.txt')
|
||||
await symlink(real, link)
|
||||
const viaReal = await resolveLocalTarget(dir, 'real.txt')
|
||||
const viaLink = await resolveLocalTarget(dir, 'link.txt')
|
||||
expect(viaLink.targetKey).toBe(viaReal.targetKey)
|
||||
expect(viaLink.displayPath).toBe(link)
|
||||
})
|
||||
|
||||
it('falls back to the absolute path when even the parent dir is absent', async () => {
|
||||
const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt')
|
||||
expect(target.targetKey).toBe(join(dir, 'no-such-dir', 'child.txt'))
|
||||
})
|
||||
|
||||
it('rejects a blank path', async () => {
|
||||
await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTextPage', () => {
|
||||
it('reads a small file with line numbers and full view', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines).toEqual([
|
||||
{ number: 1, text: 'one' },
|
||||
{ number: 2, text: 'two' },
|
||||
{ number: 3, text: 'three' },
|
||||
])
|
||||
expect(result.totalLines).toBe(3)
|
||||
expect(result.view).toBe('full')
|
||||
})
|
||||
|
||||
it('paginates with offset/limit and reports a partial view', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree\nfour')
|
||||
const result = await readTextPage(localTarget(file), { offset: 2, limit: 2 })
|
||||
expect(result.lines.map(l => l.number)).toEqual([2, 3])
|
||||
expect(result.view).toBe('partial')
|
||||
expect(formatReadBody(result, 2)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)')
|
||||
})
|
||||
|
||||
it('a whole-file read from offset 1 is a full view; offset>1 is partial', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
expect((await readTextPage(localTarget(file), { offset: 1, limit: 10 })).view).toBe('full')
|
||||
expect((await readTextPage(localTarget(file), { offset: 2, limit: 10 })).view).toBe('partial')
|
||||
})
|
||||
|
||||
it('truncates an over-long line', async () => {
|
||||
const file = join(dir, 'long.txt')
|
||||
await writeFile(file, 'x'.repeat(3000))
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)')
|
||||
})
|
||||
|
||||
it('caps output bytes and reports truncatedByBytes', async () => {
|
||||
const file = join(dir, 'big.txt')
|
||||
const lines = Array.from({ length: 2000 }, () => 'y'.repeat(100))
|
||||
await writeFile(file, lines.join('\n'))
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
expect(formatReadBody(result, 1)).toContain('Output capped at 50 KB')
|
||||
})
|
||||
|
||||
it('strips CRLF so a Windows file reads like LF', async () => {
|
||||
const file = join(dir, 'crlf.txt')
|
||||
await writeFile(file, 'one\r\ntwo\r\n')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('reads an empty file at offset 1', async () => {
|
||||
const file = join(dir, 'empty.txt')
|
||||
await writeFile(file, '')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines).toEqual([])
|
||||
expect(result.totalLines).toBe(0)
|
||||
expect(formatReadBody(result, 1)).toBe('(End of file - total 0 lines)')
|
||||
})
|
||||
|
||||
it('rejects an offset past EOF', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
await expect(readTextPage(localTarget(file), { offset: 9, limit: 1 })).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a binary file (fast path)', async () => {
|
||||
const file = join(dir, 'bin')
|
||||
await writeFile(file, Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('rejects a missing file and a directory', async () => {
|
||||
await expect(readTextPage(localTarget(join(dir, 'nope')), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(readTextPage(localTarget(dir), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one')
|
||||
await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the fast path', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal)
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
describe('streaming path (forced via a tiny fastPathMaxSize)', () => {
|
||||
const stream = { fastPathMaxSize: 1 }
|
||||
|
||||
it('reads and paginates large files the same way', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
const result = await readTextPage(localTarget(file), { offset: 2, limit: 1 }, undefined, stream)
|
||||
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
|
||||
expect(result.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
it('rejects a binary file on the streaming path', async () => {
|
||||
const file = join(dir, 'bin')
|
||||
await writeFile(file, Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('caps a newline-free giant line without unbounded buffering', async () => {
|
||||
const file = join(dir, 'one-line.txt')
|
||||
await writeFile(file, 'z'.repeat(5000))
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)')
|
||||
})
|
||||
|
||||
it('honors abort on the streaming path', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort(), stream)).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('caps output bytes mid-stream', async () => {
|
||||
const file = join(dir, 'big.txt')
|
||||
await writeFile(file, Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes a final line with no trailing newline', async () => {
|
||||
const file = join(dir, 'no-nl.txt')
|
||||
await writeFile(file, 'one\ntwo') // no trailing \n
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('handles a trailing newline (no dangling buffer at EOF)', async () => {
|
||||
const file = join(dir, 'nl.txt')
|
||||
await writeFile(file, 'one\ntwo\n') // trailing \n → empty buffer at end
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through to the stream', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal, stream)
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
it('scans across multiple stream chunks', async () => {
|
||||
// A file well past the default 64 KB stream highWaterMark yields multiple chunks,
|
||||
// exercising the non-first-chunk branch and the line-buffer cap across appends.
|
||||
const file = join(dir, 'multi.txt')
|
||||
const lines = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`)
|
||||
await writeFile(file, lines.join('\n'))
|
||||
const result = await readTextPage(localTarget(file), { offset: 1, limit: 3 }, undefined, stream)
|
||||
expect(result.lines[0]?.text.startsWith('line 0:')).toBe(true)
|
||||
expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)')
|
||||
expect(result.totalLines).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeFileAtomic — temp-file safety (defensive class A)', () => {
|
||||
it('writes through a private staging dir and owner-only temp file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
let inspected = false
|
||||
await writeFileAtomic(file, 'hello', 0o640, undefined, {
|
||||
inspectTemp: async ({ stagingDir, tempPath }) => {
|
||||
inspected = true
|
||||
expect((await stat(stagingDir)).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(tempPath)).mode & 0o777).toBe(0o600)
|
||||
},
|
||||
})
|
||||
expect(inspected).toBe(true)
|
||||
expect(await readFile(file, 'utf8')).toBe('hello')
|
||||
const info = await stat(file)
|
||||
expect(info.mode & 0o777).toBe(0o640)
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it('creates new files owner-only by default', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hello', undefined, undefined)
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('opens staging paths exclusively — a pre-existing path is never clobbered', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
const tempDirName = '.fixed-temp.tmpdir'
|
||||
await mkdir(join(dir, tempDirName))
|
||||
await writeFile(join(dir, tempDirName, 'PRECIOUS'), 'keep')
|
||||
await expect(
|
||||
writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }),
|
||||
).rejects.toMatchObject({ code: 'EEXIST' })
|
||||
// The pre-existing staging dir is intact and the target was not created.
|
||||
expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep')
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('creates parent directories as needed', async () => {
|
||||
const file = join(dir, 'nested', 'deep', 'a.txt')
|
||||
await writeFileAtomic(file, 'hi', undefined, undefined)
|
||||
expect(await readFile(file, 'utf8')).toBe('hi')
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the write', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hi', undefined, new AbortController().signal)
|
||||
expect(await readFile(file, 'utf8')).toBe('hi')
|
||||
})
|
||||
|
||||
it('aborts before writing when the signal is already aborted', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await expect(writeFileAtomic(file, 'hi', undefined, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('cleans up the temp file when the final rename fails', async () => {
|
||||
const sub = join(dir, 'occupied')
|
||||
await mkdir(sub) // rename(temp, sub) fails because sub is a non-empty/dir target
|
||||
await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error)
|
||||
// No leftover staging dirs in the directory.
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyLiteralEdit', () => {
|
||||
it('replaces a unique match', () => {
|
||||
expect(applyLiteralEdit('a b c', 'b', 'X', false, 'f')).toEqual({ content: 'a X c', replacements: 1 })
|
||||
})
|
||||
|
||||
it('rejects zero matches', () => {
|
||||
expect(() => applyLiteralEdit('a b c', 'z', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
|
||||
})
|
||||
|
||||
it('rejects an empty oldString without scanning forever', () => {
|
||||
expect(() => applyLiteralEdit('a b c', '', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
|
||||
})
|
||||
|
||||
it('rejects multiple matches without replaceAll', () => {
|
||||
expect(() => applyLiteralEdit('a a a', 'a', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_AMBIGUOUS_EDIT' }))
|
||||
})
|
||||
|
||||
it('replaces all matches with replaceAll', () => {
|
||||
expect(applyLiteralEdit('a a a', 'a', 'X', true, 'f')).toEqual({ content: 'X X X', replacements: 3 })
|
||||
})
|
||||
|
||||
it('matches across normalized line endings', () => {
|
||||
expect(applyLiteralEdit('one\ntwo', 'one\ntwo', 'x', false, 'f').replacements).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readForEdit + restoreLineEndings', () => {
|
||||
it('round-trips CRLF: matches on LF, writes back CRLF', async () => {
|
||||
const file = join(dir, 'crlf.txt')
|
||||
await writeFile(file, 'one\r\ntwo\r\n')
|
||||
const original = await readForEdit(file, file)
|
||||
expect(original.lineEndings).toBe('CRLF')
|
||||
const edited = applyLiteralEdit(original.content, 'two', 'TWO', false, file)
|
||||
expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n')
|
||||
})
|
||||
|
||||
it('rejects a binary file', async () => {
|
||||
const file = join(dir, 'bin')
|
||||
await writeFile(file, Buffer.from([0x00, 0x01]))
|
||||
await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the read', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const original = await readForEdit(file, file, new AbortController().signal)
|
||||
expect(original.content).toBe('one\ntwo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('probe', () => {
|
||||
it('returns null for a missing path and info for a file', async () => {
|
||||
expect(await probe(join(dir, 'nope'))).toBeNull()
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const info = await probe(file)
|
||||
expect(info?.isFile).toBe(true)
|
||||
expect(typeof info?.version).toBe('string')
|
||||
})
|
||||
|
||||
it('marks a directory as not a regular file', async () => {
|
||||
const sub = join(dir, 'sub')
|
||||
await mkdir(sub)
|
||||
expect((await probe(sub))?.isFile).toBe(false)
|
||||
})
|
||||
})
|
||||
15
packages/fs/fs-local/tsconfig.json
Normal file
15
packages/fs/fs-local/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../fs" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user