add just-bash for filesystem
This commit is contained in:
parent
c41dc21292
commit
f2733330f7
4 changed files with 964 additions and 843 deletions
|
|
@ -37,8 +37,8 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.4",
|
||||||
"@octokit/webhooks-types": "7.6.1",
|
"@octokit/webhooks-types": "7.6.1",
|
||||||
"@opencode-ai/script": "workspace:*",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
|
|
@ -61,6 +61,7 @@
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"drizzle-kit": "catalog:",
|
"drizzle-kit": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
|
"just-bash": "3.0.1",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vscode-languageserver-types": "3.17.5",
|
"vscode-languageserver-types": "3.17.5",
|
||||||
|
|
|
||||||
|
|
@ -2,36 +2,20 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Glob } from "@opencode-ai/core/util/glob"
|
import { Glob } from "@opencode-ai/core/util/glob"
|
||||||
import { Effect, FileSystem, Layer, Option, Stream } from "effect"
|
import { Effect, FileSystem, Layer, Option, Stream } from "effect"
|
||||||
import { badArgument, systemError, type PlatformError } from "effect/PlatformError"
|
import { badArgument, systemError, type PlatformError } from "effect/PlatformError"
|
||||||
|
import { InMemoryFs } from "just-bash"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
type Entry =
|
|
||||||
| { readonly type: "directory"; readonly mode: number; readonly modified: Date }
|
|
||||||
| { readonly type: "file"; readonly mode: number; readonly modified: Date; readonly content: Uint8Array }
|
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
readonly root: string
|
readonly root: string
|
||||||
readonly files?: Record<string, string | Uint8Array>
|
readonly files?: Record<string, string | Uint8Array>
|
||||||
|
readonly fs?: InMemoryFs
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder()
|
const unsupported = (method: string) =>
|
||||||
const decoder = new TextDecoder()
|
badArgument({
|
||||||
|
|
||||||
const notFound = (method: string, file: string) =>
|
|
||||||
systemError({
|
|
||||||
_tag: "NotFound",
|
|
||||||
module: "SimulationFileSystem",
|
module: "SimulationFileSystem",
|
||||||
method,
|
method,
|
||||||
description: "No such file or directory",
|
description: "Operation is not supported by the simulated filesystem",
|
||||||
pathOrDescriptor: file,
|
|
||||||
})
|
|
||||||
|
|
||||||
const alreadyExists = (method: string, file: string) =>
|
|
||||||
systemError({
|
|
||||||
_tag: "AlreadyExists",
|
|
||||||
module: "SimulationFileSystem",
|
|
||||||
method,
|
|
||||||
description: "Path already exists",
|
|
||||||
pathOrDescriptor: file,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const permissionDenied = (method: string, file: string) =>
|
const permissionDenied = (method: string, file: string) =>
|
||||||
|
|
@ -43,16 +27,18 @@ const permissionDenied = (method: string, file: string) =>
|
||||||
pathOrDescriptor: file,
|
pathOrDescriptor: file,
|
||||||
})
|
})
|
||||||
|
|
||||||
const unsupported = (method: string) =>
|
const failure = (method: string, file: string, cause: unknown) =>
|
||||||
badArgument({
|
systemError({
|
||||||
|
_tag: String(cause).toLowerCase().includes("exist") ? "AlreadyExists" : "NotFound",
|
||||||
module: "SimulationFileSystem",
|
module: "SimulationFileSystem",
|
||||||
method,
|
method,
|
||||||
description: "Operation is not supported by the simulated filesystem",
|
description: cause instanceof Error ? cause.message : String(cause),
|
||||||
|
pathOrDescriptor: file,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function make(options: Options) {
|
export function make(options: Options) {
|
||||||
|
const fs = options.fs ?? new InMemoryFs()
|
||||||
const root = path.resolve(options.root)
|
const root = path.resolve(options.root)
|
||||||
const entries = new Map<string, Entry>()
|
|
||||||
const temp = { value: 0 }
|
const temp = { value: 0 }
|
||||||
|
|
||||||
const normalize = (method: string, file: string): string | PlatformError => {
|
const normalize = (method: string, file: string): string | PlatformError => {
|
||||||
|
|
@ -61,104 +47,65 @@ export function make(options: Options) {
|
||||||
return permissionDenied(method, file)
|
return permissionDenied(method, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
const touch = () => new Date(0)
|
const normalizeEffect = (method: string, file: string) => {
|
||||||
|
|
||||||
const ensureParentDirs = (file: string) => {
|
|
||||||
const parent = path.dirname(file)
|
|
||||||
if (parent === file) return
|
|
||||||
if (entries.has(parent)) return
|
|
||||||
ensureParentDirs(parent)
|
|
||||||
entries.set(parent, { type: "directory", mode: 0o755, modified: touch() })
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = (method: string, file: string) => {
|
|
||||||
const normalized = normalize(method, file)
|
const normalized = normalize(method, file)
|
||||||
if (typeof normalized !== "string") return normalized
|
if (typeof normalized === "string") return Effect.succeed(normalized)
|
||||||
return entries.get(normalized) ?? notFound(method, file)
|
return Effect.fail(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
const descendants = (dir: string) =>
|
const normalizePair = (method: string, fromPath: string, toPath: string) =>
|
||||||
[...entries.keys()].filter((item) => item !== dir && AppFileSystem.contains(dir, item))
|
Effect.all([normalizeEffect(method, fromPath), normalizeEffect(method, toPath)] as const)
|
||||||
|
|
||||||
const children = (dir: string) =>
|
const run = <A>(method: string, file: string, fn: (file: string) => Promise<A>) =>
|
||||||
[...entries.keys()]
|
Effect.gen(function* () {
|
||||||
.filter((item) => item !== dir && path.dirname(item) === dir)
|
const normalized = yield* normalizeEffect(method, file)
|
||||||
.sort((a, b) => path.basename(a).localeCompare(path.basename(b)))
|
return yield* Effect.tryPromise({
|
||||||
|
try: () => fn(normalized),
|
||||||
|
catch: (cause) => failure(method, file, cause),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const writeBytes = (method: string, file: string, content: Uint8Array, mode?: number) => {
|
fs.mkdirSync(root, { recursive: true })
|
||||||
const normalized = normalize(method, file)
|
|
||||||
if (typeof normalized !== "string") return Effect.fail(normalized)
|
|
||||||
const parent = entries.get(path.dirname(normalized))
|
|
||||||
if (!parent) return Effect.fail(notFound(method, path.dirname(file)))
|
|
||||||
if (parent.type !== "directory") return Effect.fail(notFound(method, path.dirname(file)))
|
|
||||||
entries.set(normalized, { type: "file", mode: mode ?? 0o644, modified: touch(), content: content.slice() })
|
|
||||||
return Effect.void
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.set(root, { type: "directory", mode: 0o755, modified: touch() })
|
|
||||||
for (const [file, content] of Object.entries(options.files ?? {})) {
|
for (const [file, content] of Object.entries(options.files ?? {})) {
|
||||||
const normalized = normalize("seed", file)
|
const normalized = normalize("seed", file)
|
||||||
if (typeof normalized !== "string") continue
|
if (typeof normalized === "string") fs.writeFileSync(normalized, content, { encoding: "utf8" })
|
||||||
ensureParentDirs(normalized)
|
|
||||||
entries.set(normalized, {
|
|
||||||
type: "file",
|
|
||||||
mode: 0o644,
|
|
||||||
modified: touch(),
|
|
||||||
content: typeof content === "string" ? encoder.encode(content) : content.slice(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = FileSystem.make({
|
const base = FileSystem.make({
|
||||||
access: (file) =>
|
access: (file) => run("access", file, async (item) => void (await fs.stat(item))),
|
||||||
Effect.gen(function* () {
|
chmod: (file, mode) => run("chmod", file, (item) => fs.chmod(item, mode)),
|
||||||
const result = entry("access", file)
|
|
||||||
if (result instanceof Error) return yield* result
|
|
||||||
}),
|
|
||||||
chmod: (file, mode) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const result = entry("chmod", file)
|
|
||||||
if (result instanceof Error) return yield* result
|
|
||||||
entries.set(path.resolve(root, file), { ...result, mode })
|
|
||||||
}),
|
|
||||||
chown: () => Effect.fail(unsupported("chown")),
|
chown: () => Effect.fail(unsupported("chown")),
|
||||||
copy: (fromPath, toPath) =>
|
copy: (fromPath, toPath) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const from = entry("copy", fromPath)
|
const [from, to] = yield* normalizePair("copy", fromPath, toPath)
|
||||||
if (from instanceof Error) return yield* from
|
yield* Effect.tryPromise({
|
||||||
if (from.type === "directory") return yield* unsupported("copy")
|
try: () => fs.cp(from, to, { recursive: true }),
|
||||||
yield* writeBytes("copy", toPath, from.content, from.mode)
|
catch: (cause) => failure("copy", fromPath, cause),
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
copyFile: (fromPath, toPath) =>
|
copyFile: (fromPath, toPath) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const from = entry("copyFile", fromPath)
|
const [from, to] = yield* normalizePair("copyFile", fromPath, toPath)
|
||||||
if (from instanceof Error) return yield* from
|
yield* Effect.tryPromise({
|
||||||
if (from.type !== "file") return yield* notFound("copyFile", fromPath)
|
try: () => fs.cp(from, to),
|
||||||
yield* writeBytes("copyFile", toPath, from.content, from.mode)
|
catch: (cause) => failure("copyFile", fromPath, cause),
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
link: () => Effect.fail(unsupported("link")),
|
link: (existingPath, newPath) =>
|
||||||
makeDirectory: (file, methodOptions) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const normalized = normalize("makeDirectory", file)
|
const [existing, next] = yield* normalizePair("link", existingPath, newPath)
|
||||||
if (typeof normalized !== "string") return yield* normalized
|
yield* Effect.tryPromise({
|
||||||
const existing = entries.get(normalized)
|
try: () => fs.link(existing, next),
|
||||||
if (existing?.type === "directory") return
|
catch: (cause) => failure("link", existingPath, cause),
|
||||||
if (existing) return yield* alreadyExists("makeDirectory", file)
|
})
|
||||||
if (methodOptions?.recursive) {
|
|
||||||
ensureParentDirs(normalized)
|
|
||||||
entries.set(normalized, { type: "directory", mode: methodOptions.mode ?? 0o755, modified: touch() })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const parent = entries.get(path.dirname(normalized))
|
|
||||||
if (parent?.type !== "directory") return yield* notFound("makeDirectory", path.dirname(file))
|
|
||||||
entries.set(normalized, { type: "directory", mode: methodOptions?.mode ?? 0o755, modified: touch() })
|
|
||||||
}),
|
}),
|
||||||
|
makeDirectory: (file, methodOptions) => run("makeDirectory", file, (item) => fs.mkdir(item, methodOptions)),
|
||||||
makeTempDirectory: (methodOptions) =>
|
makeTempDirectory: (methodOptions) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const directory = methodOptions?.directory ?? root
|
const directory = yield* normalizeEffect("makeTempDirectory", methodOptions?.directory ?? root)
|
||||||
const name = `${methodOptions?.prefix ?? "tmp-"}${++temp.value}`
|
const file = path.join(directory, `${methodOptions?.prefix ?? "tmp-"}${++temp.value}`)
|
||||||
const file = path.join(directory, name)
|
|
||||||
yield* base.makeDirectory(file, { recursive: true })
|
yield* base.makeDirectory(file, { recursive: true })
|
||||||
return path.resolve(root, file)
|
return file
|
||||||
}),
|
}),
|
||||||
makeTempDirectoryScoped: (methodOptions) =>
|
makeTempDirectoryScoped: (methodOptions) =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
|
|
@ -167,168 +114,133 @@ export function make(options: Options) {
|
||||||
),
|
),
|
||||||
makeTempFile: (methodOptions) =>
|
makeTempFile: (methodOptions) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const directory = methodOptions?.directory ?? root
|
const directory = yield* normalizeEffect("makeTempFile", methodOptions?.directory ?? root)
|
||||||
const file = path.join(directory, `${methodOptions?.prefix ?? "tmp-"}${++temp.value}${methodOptions?.suffix ?? ""}`)
|
const file = path.join(directory, `${methodOptions?.prefix ?? "tmp-"}${++temp.value}${methodOptions?.suffix ?? ""}`)
|
||||||
yield* writeBytes("makeTempFile", file, new Uint8Array())
|
yield* base.writeFile(file, new Uint8Array())
|
||||||
return path.resolve(root, file)
|
return file
|
||||||
}),
|
}),
|
||||||
makeTempFileScoped: (methodOptions) =>
|
makeTempFileScoped: (methodOptions) =>
|
||||||
Effect.acquireRelease(base.makeTempFile(methodOptions), (file) => base.remove(file, { force: true }).pipe(Effect.ignore)),
|
Effect.acquireRelease(base.makeTempFile(methodOptions), (file) => base.remove(file, { force: true }).pipe(Effect.ignore)),
|
||||||
open: (file) =>
|
open: (file) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const normalized = yield* normalizeEffect("open", file)
|
||||||
|
yield* base.access(normalized)
|
||||||
let position = 0
|
let position = 0
|
||||||
const readCurrent = () => {
|
const readCurrent = () => fs.readFileBuffer(normalized)
|
||||||
const result = entry("open", file)
|
|
||||||
return result instanceof Error || result.type !== "file" ? undefined : result.content
|
|
||||||
}
|
|
||||||
const current = readCurrent()
|
|
||||||
if (!current) return yield* notFound("open", file)
|
|
||||||
return {
|
return {
|
||||||
[FileSystem.FileTypeId]: FileSystem.FileTypeId,
|
[FileSystem.FileTypeId]: FileSystem.FileTypeId,
|
||||||
fd: FileSystem.FileDescriptor(0),
|
fd: FileSystem.FileDescriptor(0),
|
||||||
stat: base.stat(file),
|
stat: base.stat(normalized),
|
||||||
seek: (offset, from) =>
|
seek: (offset, from) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
position = from === "start" ? Number(offset) : position + Number(offset)
|
position = from === "start" ? Number(offset) : position + Number(offset)
|
||||||
}),
|
}),
|
||||||
sync: Effect.void,
|
sync: Effect.void,
|
||||||
read: (buffer) =>
|
read: (buffer) =>
|
||||||
Effect.sync(() => {
|
Effect.gen(function* () {
|
||||||
const content = readCurrent() ?? new Uint8Array()
|
const content = yield* Effect.promise(readCurrent)
|
||||||
const chunk = content.slice(position, position + buffer.length)
|
const chunk = content.slice(position, position + buffer.length)
|
||||||
buffer.set(chunk)
|
buffer.set(chunk)
|
||||||
position += chunk.length
|
position += chunk.length
|
||||||
return FileSystem.Size(chunk.length)
|
return FileSystem.Size(chunk.length)
|
||||||
}),
|
}),
|
||||||
readAlloc: (size) =>
|
readAlloc: (size) =>
|
||||||
Effect.sync(() => {
|
Effect.gen(function* () {
|
||||||
const content = readCurrent() ?? new Uint8Array()
|
const content = yield* Effect.promise(readCurrent)
|
||||||
const chunk = content.slice(position, position + Number(size))
|
const chunk = content.slice(position, position + Number(size))
|
||||||
position += chunk.length
|
position += chunk.length
|
||||||
return chunk.length === 0 ? Option.none() : Option.some(chunk)
|
return chunk.length === 0 ? Option.none() : Option.some(chunk)
|
||||||
}),
|
}),
|
||||||
truncate: (size) => base.truncate(file, size),
|
truncate: (size) => base.truncate(normalized, size),
|
||||||
write: () => Effect.fail(unsupported("file.write")),
|
write: () => Effect.fail(unsupported("file.write")),
|
||||||
writeAll: () => Effect.fail(unsupported("file.writeAll")),
|
writeAll: () => Effect.fail(unsupported("file.writeAll")),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
readDirectory: (file, methodOptions) =>
|
readDirectory: (file, methodOptions) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const normalized = normalize("readDirectory", file)
|
const normalized = yield* normalizeEffect("readDirectory", file)
|
||||||
if (typeof normalized !== "string") return yield* normalized
|
if (!methodOptions?.recursive) return yield* run("readDirectory", normalized, (item) => fs.readdir(item))
|
||||||
const current = entries.get(normalized)
|
return fs
|
||||||
if (current?.type !== "directory") return yield* notFound("readDirectory", file)
|
.getAllPaths()
|
||||||
const items = methodOptions?.recursive ? descendants(normalized) : children(normalized)
|
.filter((item) => item !== normalized && AppFileSystem.contains(normalized, item))
|
||||||
return items.map((item) => path.relative(normalized, item))
|
.map((item) => path.relative(normalized, item))
|
||||||
}),
|
.sort((a, b) => a.localeCompare(b))
|
||||||
readFile: (file) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const result = entry("readFile", file)
|
|
||||||
if (result instanceof Error) return yield* result
|
|
||||||
if (result.type !== "file") return yield* notFound("readFile", file)
|
|
||||||
return result.content.slice()
|
|
||||||
}),
|
|
||||||
readLink: () => Effect.fail(unsupported("readLink")),
|
|
||||||
realPath: (file) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const normalized = normalize("realPath", file)
|
|
||||||
if (typeof normalized !== "string") return yield* normalized
|
|
||||||
const current = entries.get(normalized)
|
|
||||||
if (!current) return yield* notFound("realPath", file)
|
|
||||||
return normalized
|
|
||||||
}),
|
|
||||||
remove: (file, methodOptions) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const normalized = normalize("remove", file)
|
|
||||||
if (typeof normalized !== "string") return yield* normalized
|
|
||||||
const current = entries.get(normalized)
|
|
||||||
if (!current) {
|
|
||||||
if (methodOptions?.force) return
|
|
||||||
return yield* notFound("remove", file)
|
|
||||||
}
|
|
||||||
if (current.type === "directory" && descendants(normalized).length > 0 && !methodOptions?.recursive) {
|
|
||||||
return yield* systemError({
|
|
||||||
_tag: "BadResource",
|
|
||||||
module: "SimulationFileSystem",
|
|
||||||
method: "remove",
|
|
||||||
description: "Directory is not empty",
|
|
||||||
pathOrDescriptor: file,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (const item of descendants(normalized)) entries.delete(item)
|
|
||||||
entries.delete(normalized)
|
|
||||||
}),
|
}),
|
||||||
|
readFile: (file) => run("readFile", file, (item) => fs.readFileBuffer(item)),
|
||||||
|
readLink: (file) => run("readLink", file, (item) => fs.readlink(item)),
|
||||||
|
realPath: (file) => run("realPath", file, (item) => fs.realpath(item)),
|
||||||
|
remove: (file, methodOptions) => run("remove", file, (item) => fs.rm(item, methodOptions)),
|
||||||
rename: (oldPath, newPath) =>
|
rename: (oldPath, newPath) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const oldNormalized = normalize("rename", oldPath)
|
const [oldNormalized, newNormalized] = yield* normalizePair("rename", oldPath, newPath)
|
||||||
if (typeof oldNormalized !== "string") return yield* oldNormalized
|
yield* Effect.tryPromise({
|
||||||
const newNormalized = normalize("rename", newPath)
|
try: () => fs.mv(oldNormalized, newNormalized),
|
||||||
if (typeof newNormalized !== "string") return yield* newNormalized
|
catch: (cause) => failure("rename", oldPath, cause),
|
||||||
const current = entries.get(oldNormalized)
|
})
|
||||||
if (!current) return yield* notFound("rename", oldPath)
|
|
||||||
ensureParentDirs(newNormalized)
|
|
||||||
entries.set(newNormalized, current)
|
|
||||||
entries.delete(oldNormalized)
|
|
||||||
for (const item of descendants(oldNormalized)) {
|
|
||||||
const child = entries.get(item)
|
|
||||||
if (!child) continue
|
|
||||||
entries.set(path.join(newNormalized, path.relative(oldNormalized, item)), child)
|
|
||||||
entries.delete(item)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
stat: (file) =>
|
stat: (file) =>
|
||||||
Effect.gen(function* () {
|
run("stat", file, async (item) => {
|
||||||
const result = entry("stat", file)
|
const info = await fs.stat(item)
|
||||||
if (result instanceof Error) return yield* result
|
|
||||||
return {
|
return {
|
||||||
type: result.type === "directory" ? "Directory" : "File",
|
type: info.isDirectory ? "Directory" : info.isSymbolicLink ? "SymbolicLink" : "File",
|
||||||
mtime: Option.some(result.modified),
|
mtime: Option.some(info.mtime),
|
||||||
atime: Option.some(result.modified),
|
atime: Option.some(info.mtime),
|
||||||
birthtime: Option.some(result.modified),
|
birthtime: Option.some(info.mtime),
|
||||||
dev: 0,
|
dev: 0,
|
||||||
ino: Option.none(),
|
ino: Option.none(),
|
||||||
mode: result.mode,
|
mode: info.mode,
|
||||||
nlink: Option.none(),
|
nlink: Option.none(),
|
||||||
uid: Option.none(),
|
uid: Option.none(),
|
||||||
gid: Option.none(),
|
gid: Option.none(),
|
||||||
rdev: Option.none(),
|
rdev: Option.none(),
|
||||||
size: FileSystem.Size(result.type === "file" ? result.content.length : 0),
|
size: FileSystem.Size(info.size),
|
||||||
blksize: Option.none(),
|
blksize: Option.none(),
|
||||||
blocks: Option.none(),
|
blocks: Option.none(),
|
||||||
} satisfies FileSystem.File.Info
|
} satisfies FileSystem.File.Info
|
||||||
}),
|
}),
|
||||||
symlink: () => Effect.fail(unsupported("symlink")),
|
symlink: (target, linkPath) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const normalized = yield* normalizeEffect("symlink", linkPath)
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => fs.symlink(target, normalized),
|
||||||
|
catch: (cause) => failure("symlink", linkPath, cause),
|
||||||
|
})
|
||||||
|
}),
|
||||||
truncate: (file, size = 0) =>
|
truncate: (file, size = 0) =>
|
||||||
Effect.gen(function* () {
|
run("truncate", file, async (item) => {
|
||||||
const result = entry("truncate", file)
|
|
||||||
if (result instanceof Error) return yield* result
|
|
||||||
if (result.type !== "file") return yield* notFound("truncate", file)
|
|
||||||
const next = new Uint8Array(Number(size))
|
const next = new Uint8Array(Number(size))
|
||||||
next.set(result.content.slice(0, next.length))
|
next.set((await fs.readFileBuffer(item)).slice(0, next.length))
|
||||||
entries.set(path.resolve(root, file), { ...result, content: next, modified: touch() })
|
await fs.writeFile(item, next)
|
||||||
}),
|
|
||||||
utimes: (file, _atime, mtime) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const result = entry("utimes", file)
|
|
||||||
if (result instanceof Error) return yield* result
|
|
||||||
entries.set(path.resolve(root, file), { ...result, modified: typeof mtime === "number" ? new Date(mtime) : mtime })
|
|
||||||
}),
|
}),
|
||||||
|
utimes: (file, atime, mtime) =>
|
||||||
|
run("utimes", file, (item) =>
|
||||||
|
fs.utimes(item, typeof atime === "number" ? new Date(atime) : atime, typeof mtime === "number" ? new Date(mtime) : mtime),
|
||||||
|
),
|
||||||
watch: () => Stream.fail(unsupported("watch")),
|
watch: () => Stream.fail(unsupported("watch")),
|
||||||
writeFile: (file, content, methodOptions) => writeBytes("writeFile", file, content, methodOptions?.mode),
|
writeFile: (file, content, methodOptions) =>
|
||||||
|
run("writeFile", file, async (item) => {
|
||||||
|
await fs.writeFile(item, content)
|
||||||
|
if (methodOptions?.mode) await fs.chmod(item, methodOptions.mode)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const glob = (pattern: string, globOptions?: Glob.Options) =>
|
const glob = (pattern: string, globOptions?: Glob.Options) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cwd = path.resolve(root, globOptions?.cwd ?? root)
|
const cwd = yield* normalizeEffect("glob", globOptions?.cwd ?? root)
|
||||||
const normalized = normalize("glob", cwd)
|
const matches = yield* Effect.forEach(
|
||||||
if (typeof normalized !== "string") return yield* normalized
|
fs
|
||||||
const matches = [...entries.entries()]
|
.getAllPaths()
|
||||||
.filter(([, item]) => globOptions?.include === "all" || item.type === "file")
|
.filter((item) => item !== cwd && AppFileSystem.contains(cwd, item))
|
||||||
.map(([file]) => ({ file, relative: path.relative(normalized, file) }))
|
.sort((a, b) => a.localeCompare(b)),
|
||||||
.filter((item) => item.relative && !item.relative.startsWith("..") && Glob.match(pattern, item.relative))
|
(file) =>
|
||||||
.map((item) => (globOptions?.absolute ? item.file : item.relative))
|
base.stat(file).pipe(
|
||||||
.sort((a, b) => a.localeCompare(b))
|
Effect.map((info) => ({ file, info, relative: path.relative(cwd, file) })),
|
||||||
|
Effect.catch(() => Effect.succeed(undefined)),
|
||||||
|
),
|
||||||
|
)
|
||||||
return matches
|
return matches
|
||||||
|
.filter((item) => item && (globOptions?.include === "all" || item.info.type === "File") && Glob.match(pattern, item.relative))
|
||||||
|
.map((item) => (globOptions?.absolute ? item!.file : item!.relative))
|
||||||
})
|
})
|
||||||
|
|
||||||
const service = AppFileSystem.Service.of({
|
const service = AppFileSystem.Service.of({
|
||||||
|
|
@ -348,32 +260,26 @@ export function make(options: Options) {
|
||||||
else yield* base.writeFile(file, content, mode ? { mode } : undefined)
|
else yield* base.writeFile(file, content, mode ? { mode } : undefined)
|
||||||
}),
|
}),
|
||||||
readDirectoryEntries: (file) =>
|
readDirectoryEntries: (file) =>
|
||||||
Effect.gen(function* () {
|
run("readDirectoryEntries", file, async (item) =>
|
||||||
const normalized = normalize("readDirectoryEntries", file)
|
(await fs.readdirWithFileTypes(item))
|
||||||
if (typeof normalized !== "string") return yield* normalized
|
.map((entry) => ({
|
||||||
const current = entries.get(normalized)
|
name: entry.name,
|
||||||
if (current?.type !== "directory") return yield* notFound("readDirectoryEntries", file)
|
type: entry.isDirectory ? "directory" : entry.isSymbolicLink ? "symlink" : entry.isFile ? "file" : "other",
|
||||||
return children(normalized).map((child) => {
|
}) satisfies AppFileSystem.DirEntry)
|
||||||
const item = entries.get(child)
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
return {
|
),
|
||||||
name: path.basename(child),
|
findUp: (target, start, stop) => service.up({ targets: [target], start, stop }),
|
||||||
type: item?.type === "directory" ? "directory" : item?.type === "file" ? "file" : "other",
|
|
||||||
} satisfies AppFileSystem.DirEntry
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
findUp: (target, start, stop) =>
|
|
||||||
service.up({ targets: [target], start, stop }),
|
|
||||||
up: (methodOptions) =>
|
up: (methodOptions) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
let current = path.resolve(root, methodOptions.start)
|
let current = yield* normalizeEffect("up", methodOptions.start)
|
||||||
const stop = methodOptions.stop ? path.resolve(root, methodOptions.stop) : undefined
|
const normalizedStop = methodOptions.stop ? yield* normalizeEffect("up", methodOptions.stop) : undefined
|
||||||
while (true) {
|
while (true) {
|
||||||
for (const target of methodOptions.targets) {
|
for (const target of methodOptions.targets) {
|
||||||
const file = path.join(current, target)
|
const file = path.join(current, target)
|
||||||
if (yield* base.exists(file)) result.push(file)
|
if (yield* base.exists(file)) result.push(file)
|
||||||
}
|
}
|
||||||
if (stop === current) break
|
if (normalizedStop === current) break
|
||||||
const parent = path.dirname(current)
|
const parent = path.dirname(current)
|
||||||
if (parent === current || !AppFileSystem.contains(root, parent)) break
|
if (parent === current || !AppFileSystem.contains(root, parent)) break
|
||||||
current = parent
|
current = parent
|
||||||
|
|
@ -383,8 +289,8 @@ export function make(options: Options) {
|
||||||
globUp: (pattern, start, stop) =>
|
globUp: (pattern, start, stop) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
let current = path.resolve(root, start)
|
let current = yield* normalizeEffect("globUp", start)
|
||||||
const normalizedStop = stop ? path.resolve(root, stop) : undefined
|
const normalizedStop = stop ? yield* normalizeEffect("globUp", stop) : undefined
|
||||||
while (true) {
|
while (true) {
|
||||||
result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true })))
|
result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true })))
|
||||||
if (normalizedStop === current) break
|
if (normalizedStop === current) break
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Effect, Exit } from "effect"
|
import { Effect, Exit } from "effect"
|
||||||
|
import { Bash, InMemoryFs } from "just-bash"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
|
import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
|
||||||
import { testEffect } from "../../lib/effect"
|
import { testEffect } from "../../lib/effect"
|
||||||
|
|
@ -53,4 +54,20 @@ describe("SimulationFileSystem", () => {
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const shared = new InMemoryFs()
|
||||||
|
const sharedIt = testEffect(SimulationFileSystem.layer({ root, fs: shared }))
|
||||||
|
|
||||||
|
sharedIt.effect("shares the just-bash filesystem with Bash", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const appFs = yield* AppFileSystem.Service
|
||||||
|
const bash = new Bash({ fs: shared, cwd: root })
|
||||||
|
|
||||||
|
yield* appFs.writeWithDirs(path.join(root, "from-app.txt"), "hello from app")
|
||||||
|
|
||||||
|
expect((yield* Effect.promise(() => bash.exec("cat from-app.txt"))).stdout).toBe("hello from app")
|
||||||
|
expect((yield* Effect.promise(() => bash.exec("printf 'hello from bash' > from-bash.txt"))).exitCode).toBe(0)
|
||||||
|
expect(yield* appFs.readFileString(path.join(root, "from-bash.txt"))).toBe("hello from bash")
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue