From 3bb0d7fda03472f3717ab2f33c56ec357a139f25 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 7 Aug 2026 10:28:27 -0400 Subject: [PATCH] feat(core): add workspace environment foundation (#40967) --- packages/core/package.json | 1 + packages/core/src/environment/driver.ts | 9 + .../core/src/environment/exec-defaults.ts | 192 ++++++++++++++++++ packages/core/src/environment/files.ts | 53 +++++ packages/core/src/environment/index.ts | 24 +++ packages/core/src/environment/memory.ts | 168 +++++++++++++++ packages/core/test/environment.test.ts | 39 ++++ .../core/test/lib/environment-conformance.ts | 159 +++++++++++++++ 8 files changed, 645 insertions(+) create mode 100644 packages/core/src/environment/driver.ts create mode 100644 packages/core/src/environment/exec-defaults.ts create mode 100644 packages/core/src/environment/files.ts create mode 100644 packages/core/src/environment/index.ts create mode 100644 packages/core/src/environment/memory.ts create mode 100644 packages/core/test/environment.test.ts create mode 100644 packages/core/test/lib/environment-conformance.ts diff --git a/packages/core/package.json b/packages/core/package.json index 240c6667d56..d8ad4bb578a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,7 @@ "opencode": "./bin/opencode" }, "exports": { + "./environment": "./src/environment/index.ts", "./session/runner": "./src/session/runner/index.ts", "./instructions": "./src/instructions/index.ts", "./*": "./src/*.ts" diff --git a/packages/core/src/environment/driver.ts b/packages/core/src/environment/driver.ts new file mode 100644 index 00000000000..3c0a98cdbea --- /dev/null +++ b/packages/core/src/environment/driver.ts @@ -0,0 +1,9 @@ +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import type { FilesImpl } from "./files" + +export interface Driver { + readonly spawner: ChildProcessSpawner["Service"] + readonly overrides?: Partial +} + +export * as EnvironmentDriver from "./driver" diff --git a/packages/core/src/environment/exec-defaults.ts b/packages/core/src/environment/exec-defaults.ts new file mode 100644 index 00000000000..b5340f81969 --- /dev/null +++ b/packages/core/src/environment/exec-defaults.ts @@ -0,0 +1,192 @@ +import { Effect, Stream } from "effect" +import { ChildProcess } from "effect/unstable/process" +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { collectStream } from "@opencode-ai/util/process" +import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files" + +/** + * Files derived from spawning processes: one process per intent, "$1" is + * always the target path. Scripts report classification through an exit-code + * protocol (44/45/46) so failures never require parsing localized error text; + * LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and + * findutils in the target image — BSD and busybox userlands will not work. + * Malformed output from these scripts is our own bug and dies as a defect. + */ + +const MAX_DATA_BYTES = 64 * 1024 * 1024 +const MAX_ERROR_BYTES = 64 * 1024 +const NOT_FOUND = 44 +const WRONG_KIND = 45 +const FAILED = 46 +const TAB = "\t" + +const loadMetadata = (flags = "") => ` +metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || { + case "$metadata" in + *'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;; + *) printf '%s' "$metadata" >&2; exit ${FAILED} ;; + esac +} +` + +const statScript = ` +${loadMetadata()} +printf '%s\n' "$metadata" +` + +const readScript = ` +${loadMetadata("-L")} +kind=\${metadata%%${TAB}*} +if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then + printf '%s' "$kind" >&2 + exit ${WRONG_KIND} +fi +printf '%s\n' "$metadata" +if [ "$2" = range ]; then + dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none +else + cat -- "$1" +fi +` + +const listScript = ` +${loadMetadata()} +kind=\${metadata%%${TAB}*} +if [ "$kind" != directory ]; then + printf '%s' "$kind" >&2 + exit ${WRONG_KIND} +fi +find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0' +` + +const moveScript = ` +${loadMetadata()} +mv -- "$1" "$2" +` + +interface Result { + readonly exitCode: number + readonly stdout: Uint8Array + readonly stderr: Uint8Array +} + +export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => { + const run = ( + path: string, + script: string, + args: ReadonlyArray = [], + stdin?: Uint8Array, + ): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], { + env: { LC_ALL: "C" }, + extendEnv: true, + stdin: stdin === undefined ? undefined : Stream.make(stdin), + }) + const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause }))) + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStream(handle.stdout, MAX_DATA_BYTES), + collectStream(handle.stderr, MAX_ERROR_BYTES), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError((cause) => new Failed({ path, cause }))) + if (stdout.truncated || stderr.truncated) { + return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") }) + } + return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer } + }), + ) + + const classify = ( + path: string, + result: Result, + success: (stdout: Uint8Array) => A, + ): Effect.Effect => { + if (result.exitCode === 0) return Effect.sync(() => success(result.stdout)) + if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path })) + if (result.exitCode === WRONG_KIND) { + return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) })) + } + return Effect.fail(processFailure(path, result)) + } + + const complete = (path: string, result: Result) => + result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result)) + + return { + stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))), + read: (path, range) => + run( + path, + readScript, + range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)], + ).pipe( + Effect.flatMap((result) => + classify(path, result, (stdout) => { + const newline = stdout.indexOf(10) + if (newline < 0) throw new Error("Missing read metadata header") + return { + info: parseInfo(stdout.slice(0, newline)), + bytes: stdout.slice(newline + 1), + } + }), + ), + ), + write: (path, bytes) => + run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe( + Effect.flatMap((result) => complete(path, result)), + ), + list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))), + remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))), + move: (from, to) => + run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))), + mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))), + } +} + +/** `classify` for scripts whose protocol never reports WrongKind. */ +const classifyPlain = ( + path: string, + result: Result, + success: (stdout: Uint8Array) => A, +): Effect.Effect => { + if (result.exitCode === 0) return Effect.sync(() => success(result.stdout)) + if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path })) + return Effect.fail(processFailure(path, result)) +} + +const processFailure = (path: string, result: Result) => + new Failed({ + path, + cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`), + }) + +const parseInfo = (bytes: Uint8Array): FileInfo => { + const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB) + const size = Number(rawSize) + const mtimeMs = Number(rawMtime) * 1_000 + if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output") + return { type: parseType(rawType), size, mtimeMs } +} + +const parseType = (value: string): FileType => { + if (value === "regular file" || value === "regular empty file" || value === "f") return "file" + if (value === "directory" || value === "d") return "directory" + if (value === "symbolic link" || value === "l") return "symlink" + return "other" +} + +const parseList = (bytes: Uint8Array) => { + const fields = new TextDecoder().decode(bytes).split("\0") + fields.pop() + if (fields.length % 2 !== 0) throw new Error("Invalid find output") + return Array.from({ length: fields.length / 2 }, (_, index) => ({ + name: fields[index * 2 + 1], + type: parseType(fields[index * 2]), + })) +} + +export * as EnvironmentExecDefaults from "./exec-defaults" diff --git a/packages/core/src/environment/files.ts b/packages/core/src/environment/files.ts new file mode 100644 index 00000000000..49d879e91a4 --- /dev/null +++ b/packages/core/src/environment/files.ts @@ -0,0 +1,53 @@ +import { Effect, Schema } from "effect" + +export const FileType = Schema.Literals(["file", "directory", "symlink", "other"]) +export type FileType = typeof FileType.Type + +export interface FileInfo { + readonly type: FileType + readonly size: number + readonly mtimeMs: number +} + +export interface DirEntry { + readonly name: string + readonly type: FileType +} + +export class NotFound extends Schema.TaggedErrorClass()("Environment.NotFound", { + path: Schema.String, +}) {} + +export class WrongKind extends Schema.TaggedErrorClass()("Environment.WrongKind", { + path: Schema.String, + actual: FileType, +}) {} + +export class Failed extends Schema.TaggedErrorClass()("Environment.Failed", { + path: Schema.String, + cause: Schema.Defect(), +}) {} + +export interface FilesImpl { + /** + * Reads a file, following a final symlink so `info` describes the target whose bytes are returned. + * The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with + * `Failed`, so callers must use ranges for larger files. + */ + readonly read: ( + path: string, + range?: { readonly offset: number; readonly length: number }, + ) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed> + readonly write: (path: string, bytes: Uint8Array) => Effect.Effect + /** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */ + readonly stat: (path: string) => Effect.Effect + /** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */ + readonly list: (path: string) => Effect.Effect, NotFound | WrongKind | Failed> + readonly remove: (path: string) => Effect.Effect + readonly move: (from: string, to: string) => Effect.Effect + readonly mkdir: (path: string) => Effect.Effect +} + +export interface Files extends FilesImpl {} + +export * as EnvironmentFiles from "./files" diff --git a/packages/core/src/environment/index.ts b/packages/core/src/environment/index.ts new file mode 100644 index 00000000000..08463dbc729 --- /dev/null +++ b/packages/core/src/environment/index.ts @@ -0,0 +1,24 @@ +export * as Environment from "./index" + +export { type Driver } from "./driver" +export { + type DirEntry, + Failed, + type FileInfo, + type Files, + type FilesImpl, + type FileType, + NotFound, + WrongKind, +} from "./files" +export { execDefaults } from "./exec-defaults" +export { makeMemoryDriver, type MemoryDriver } from "./memory" + +import type { Driver } from "./driver" +import { execDefaults } from "./exec-defaults" +import type { Files } from "./files" + +export const makeFiles = (driver: Driver): Files => ({ + ...execDefaults(driver.spawner), + ...driver.overrides, +}) diff --git a/packages/core/src/environment/memory.ts b/packages/core/src/environment/memory.ts new file mode 100644 index 00000000000..619dccf16a9 --- /dev/null +++ b/packages/core/src/environment/memory.ts @@ -0,0 +1,168 @@ +import path from "node:path" +import { Effect, PlatformError } from "effect" +import { make } from "effect/unstable/process/ChildProcessSpawner" +import type { Driver } from "./driver" +import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files" + +type Node = + | { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number } + | { readonly type: "directory"; readonly mtimeMs: number } + | { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number } + +export interface MemoryDriver extends Driver { + readonly symlink: (target: string, path: string) => Effect.Effect +} + +export const makeMemoryDriver = (): MemoryDriver => { + const nodes = new Map([["/", { type: "directory", mtimeMs: Date.now() }]]) + const key = (value: string) => path.posix.resolve("/", value) + const info = (node: Node): FileInfo => ({ + type: node.type, + size: + node.type === "file" + ? node.bytes.length + : node.type === "symlink" + ? new TextEncoder().encode(node.target).length + : 0, + mtimeMs: node.mtimeMs, + }) + const resolveKey = (value: string, followFinal: boolean, seen = new Set()): string | undefined => { + const normalized = key(value) + const parts = normalized.split("/").filter(Boolean) + const base = "/" + const walk = (current: string, index: number): string | undefined => { + if (index === parts.length) return current + const part = parts[index] + const candidate = path.posix.join(current, part) + const node = nodes.get(candidate) + if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1) + if (seen.has(candidate)) return undefined + seen.add(candidate) + const target = path.posix.resolve(path.posix.dirname(candidate), node.target) + return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen) + } + return walk(base, 0) + } + const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value)) + const requireParent = (value: string) => { + const parentPath = path.posix.dirname(key(value)) + const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath) + if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`) + if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`) + } + const mkdirSync = (value: string) => { + const target = resolveKey(value, false) ?? key(value) + const existing = nodes.get(target) + if (existing?.type === "directory") return + if (existing) throw new Error(`Path is not a directory: ${value}`) + const parent = path.posix.dirname(target) + if (parent !== target) mkdirSync(parent) + nodes.set(target, { type: "directory", mtimeMs: Date.now() }) + } + const failed = (value: string, cause: unknown) => new Failed({ path: value, cause }) + const overrides: FilesImpl = { + stat: (value) => { + const node = lookup(value) + return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value })) + }, + read: (value, range) => { + const original = lookup(value) + if (!original) return Effect.fail(new NotFound({ path: value })) + if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" })) + const resolved = resolveKey(value, true) + const node = resolved === undefined ? undefined : nodes.get(resolved) + if (!node) return Effect.fail(new NotFound({ path: value })) + if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type })) + const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length) + return Effect.succeed({ info: info(node), bytes: bytes.slice() }) + }, + write: (value, bytes) => + Effect.try({ + try: () => { + mkdirSync(path.posix.dirname(key(value))) + const existing = lookup(value) + if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`) + const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false) + if (!target) throw new Error(`Cannot resolve symlink: ${value}`) + requireParent(target) + nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() }) + }, + catch: (cause) => failed(value, cause), + }), + list: (value) => { + const target = resolveKey(value, false) ?? key(value) + const node = nodes.get(target) + if (!node) return Effect.fail(new NotFound({ path: value })) + if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type })) + const entries = [...nodes.entries()] + .filter(([entry]) => entry !== target && path.posix.dirname(entry) === target) + .map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType })) + .sort((a, b) => a.name.localeCompare(b.name)) + return Effect.succeed(entries) + }, + remove: (value) => + Effect.sync(() => { + const target = resolveKey(value, false) ?? key(value) + for (const entry of nodes.keys()) { + if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry) + } + }), + move: (from, to) => { + const source = resolveKey(from, false) ?? key(from) + const node = nodes.get(source) + if (!node) return Effect.fail(new NotFound({ path: from })) + return Effect.try({ + try: () => { + const requested = resolveKey(to, false) ?? key(to) + const destination = + nodes.get(requested)?.type === "directory" + ? path.posix.join(requested, path.posix.basename(source)) + : requested + if (node.type === "directory" && destination.startsWith(`${source}/`)) { + throw new Error(`Cannot move a directory into itself: ${from}`) + } + const existing = nodes.get(destination) + if (node.type === "directory" && existing && existing.type !== "directory") { + throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`) + } + requireParent(destination) + const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`)) + for (const [entry] of moved) nodes.delete(entry) + for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child) + }, + catch: (cause) => failed(from, cause), + }) + }, + mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }), + } + + const spawner = make((command) => + Effect.suspend(() => { + const description = command._tag === "StandardCommand" ? command.command : "pipeline" + return Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "EnvironmentMemory", + method: "spawn", + pathOrDescriptor: description, + cause: failed(description, new Error("The memory driver cannot spawn processes")), + }), + ) + }), + ) + + return { + spawner, + overrides, + symlink: (target, value) => + Effect.try({ + try: () => { + requireParent(value) + nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() }) + }, + catch: (cause) => failed(value, cause), + }), + } +} + +export * as EnvironmentMemory from "./memory" diff --git a/packages/core/test/environment.test.ts b/packages/core/test/environment.test.ts new file mode 100644 index 00000000000..0803cb5528e --- /dev/null +++ b/packages/core/test/environment.test.ts @@ -0,0 +1,39 @@ +import fs from "node:fs/promises" +import { Effect } from "effect" +import { ChildProcessSpawner } from "effect/unstable/process" +import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index" +import { tmpdir } from "./fixture/tmpdir" +import { environmentConformance } from "./lib/environment-conformance" + +environmentConformance("memory environment", () => + Effect.sync(() => { + const driver = makeMemoryDriver() + return { + files: makeFiles(driver), + root: `/workspace-${crypto.randomUUID()}`, + symlink: driver.symlink, + } + }), +) + +environmentConformance( + "GNU exec environment", + () => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-")) + return { + files: execDefaults(spawner), + root: tmp.path, + symlink: (target: string, link: string) => + Effect.tryPromise({ + try: () => fs.symlink(target, link), + catch: (cause) => new Failed({ path: link, cause }), + }), + dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()), + } + }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), + process.platform !== "linux", +) diff --git a/packages/core/test/lib/environment-conformance.ts b/packages/core/test/lib/environment-conformance.ts new file mode 100644 index 00000000000..aca4a3a0e3d --- /dev/null +++ b/packages/core/test/lib/environment-conformance.ts @@ -0,0 +1,159 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index" +import { it } from "./effect" + +export interface EnvironmentHarness { + readonly files: Files + readonly root: string + readonly symlink?: (target: string, path: string) => Effect.Effect + readonly dispose?: Effect.Effect +} + +export const environmentConformance = ( + name: string, + makeHarness: () => Effect.Effect, + skip = false, +) => { + const check = (title: string, body: (harness: EnvironmentHarness) => Effect.Effect) => + it.live(title, () => + Effect.gen(function* () { + const harness = yield* Effect.acquireRelease(makeHarness(), (harness) => + Effect.gen(function* () { + yield* Effect.ignore(harness.files.remove(harness.root)) + if (harness.dispose) yield* harness.dispose + }), + ) + yield* harness.files.mkdir(harness.root) + return yield* body(harness) + }), + ) + + const bytes = (value: string) => new TextEncoder().encode(value) + const text = (value: Uint8Array) => new TextDecoder().decode(value) + const suite = skip ? describe.skip : describe + + suite(name, () => { + check("writes, stats, and reads a file with its info", (harness) => + Effect.gen(function* () { + const target = `${harness.root}/hello.txt` + yield* harness.files.write(target, bytes("hello")) + const result = yield* harness.files.read(target) + expect(text(result.bytes)).toBe("hello") + expect(result.info.type).toBe("file") + expect(result.info.size).toBe(5) + expect(yield* harness.files.stat(target)).toEqual(result.info) + }), + ) + + check("reports missing paths", (harness) => + Effect.gen(function* () { + const target = `${harness.root}/missing` + expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound) + expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound) + expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound) + expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound) + }), + ) + + check("reports the actual kind", (harness) => + Effect.gen(function* () { + const directory = `${harness.root}/directory` + const file = `${harness.root}/file` + yield* harness.files.mkdir(directory) + yield* harness.files.write(file, bytes("data")) + const readError = yield* Effect.flip(harness.files.read(directory)) + const listError = yield* Effect.flip(harness.files.list(file)) + expect(readError).toBeInstanceOf(WrongKind) + expect((readError as WrongKind).actual).toBe("directory") + expect(listError).toBeInstanceOf(WrongKind) + expect((listError as WrongKind).actual).toBe("file") + }), + ) + + check("write creates parent directories", (harness) => + Effect.gen(function* () { + const target = `${harness.root}/one/two/file` + yield* harness.files.write(target, bytes("nested")) + yield* harness.files.write(`${harness.root}/empty`, new Uint8Array()) + expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory") + expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 }) + expect(text((yield* harness.files.read(target)).bytes)).toBe("nested") + }), + ) + + check("reads byte ranges", (harness) => + Effect.gen(function* () { + const target = `${harness.root}/range` + yield* harness.files.write(target, bytes("0123456789")) + expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345") + expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89") + expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("") + }), + ) + + check("lists immediate entries with their kinds", (harness) => + Effect.gen(function* () { + yield* harness.files.write(`${harness.root}/file name`, bytes("data")) + yield* harness.files.mkdir(`${harness.root}/directory`) + yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested")) + const entries = yield* harness.files.list(harness.root) + expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([ + { name: "directory", type: "directory" }, + { name: "file name", type: "file" }, + ]) + }), + ) + + check("reports symlinks without resolving them", (harness) => + Effect.gen(function* () { + if (!harness.symlink) return + yield* harness.files.write(`${harness.root}/target`, bytes("target")) + yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link")) + yield* harness.symlink("target", `${harness.root}/link`) + yield* harness.symlink("target-dir", `${harness.root}/link-dir`) + expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink") + expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" }) + expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link") + const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`)) + expect(listError).toBeInstanceOf(WrongKind) + expect((listError as WrongKind).actual).toBe("symlink") + }), + ) + + check("follows symlinks when reading", (harness) => + Effect.gen(function* () { + if (!harness.symlink) return + yield* harness.files.write(`${harness.root}/target`, bytes("target content")) + yield* harness.files.mkdir(`${harness.root}/directory`) + yield* harness.symlink("target", `${harness.root}/file-link`) + yield* harness.symlink("directory", `${harness.root}/directory-link`) + yield* harness.symlink("missing", `${harness.root}/dangling-link`) + + const result = yield* harness.files.read(`${harness.root}/file-link`) + expect(text(result.bytes)).toBe("target content") + expect(result.info.type).toBe("file") + expect(result.info.size).toBe(bytes("target content").length) + + const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`)) + expect(directoryError).toBeInstanceOf(WrongKind) + expect((directoryError as WrongKind).actual).toBe("directory") + expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound) + }), + ) + + check("moves files and removes trees idempotently", (harness) => + Effect.gen(function* () { + const source = `${harness.root}/source/file` + const destination = `${harness.root}/destination` + yield* harness.files.write(source, bytes("moved")) + yield* harness.files.move(source, destination) + expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved") + expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound) + yield* harness.files.remove(`${harness.root}/source`) + yield* harness.files.remove(`${harness.root}/source`) + expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound) + }), + ) + }) +}