Quellcode durchsuchen

add just-bash for filesystem

James Long vor 3 Monaten
Ursprung
Commit
f2733330f7

Datei-Diff unterdrückt, da er zu groß ist
+ 262 - 181
bun.lock


+ 2 - 1
packages/opencode/package.json

@@ -37,8 +37,8 @@
   "devDependencies": {
     "@babel/core": "7.28.4",
     "@octokit/webhooks-types": "7.6.1",
-    "@opencode-ai/script": "workspace:*",
     "@opencode-ai/core": "workspace:*",
+    "@opencode-ai/script": "workspace:*",
     "@parcel/watcher-darwin-arm64": "2.5.1",
     "@parcel/watcher-darwin-x64": "2.5.1",
     "@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -61,6 +61,7 @@
     "@typescript/native-preview": "catalog:",
     "drizzle-kit": "catalog:",
     "drizzle-orm": "catalog:",
+    "just-bash": "3.0.1",
     "prettier": "3.6.2",
     "typescript": "catalog:",
     "vscode-languageserver-types": "3.17.5",

+ 132 - 226
packages/opencode/src/testing/simulation/filesystem.ts

@@ -2,36 +2,20 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { Glob } from "@opencode-ai/core/util/glob"
 import { Effect, FileSystem, Layer, Option, Stream } from "effect"
 import { badArgument, systemError, type PlatformError } from "effect/PlatformError"
+import { InMemoryFs } from "just-bash"
 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 {
   readonly root: string
   readonly files?: Record<string, string | Uint8Array>
+  readonly fs?: InMemoryFs
 }
 
-const encoder = new TextEncoder()
-const decoder = new TextDecoder()
-
-const notFound = (method: string, file: string) =>
-  systemError({
-    _tag: "NotFound",
-    module: "SimulationFileSystem",
-    method,
-    description: "No such file or directory",
-    pathOrDescriptor: file,
-  })
-
-const alreadyExists = (method: string, file: string) =>
-  systemError({
-    _tag: "AlreadyExists",
+const unsupported = (method: string) =>
+  badArgument({
     module: "SimulationFileSystem",
     method,
-    description: "Path already exists",
-    pathOrDescriptor: file,
+    description: "Operation is not supported by the simulated filesystem",
   })
 
 const permissionDenied = (method: string, file: string) =>
@@ -43,16 +27,18 @@ const permissionDenied = (method: string, file: string) =>
     pathOrDescriptor: file,
   })
 
-const unsupported = (method: string) =>
-  badArgument({
+const failure = (method: string, file: string, cause: unknown) =>
+  systemError({
+    _tag: String(cause).toLowerCase().includes("exist") ? "AlreadyExists" : "NotFound",
     module: "SimulationFileSystem",
     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) {
+  const fs = options.fs ?? new InMemoryFs()
   const root = path.resolve(options.root)
-  const entries = new Map<string, Entry>()
   const temp = { value: 0 }
 
   const normalize = (method: string, file: string): string | PlatformError => {
@@ -61,104 +47,65 @@ export function make(options: Options) {
     return permissionDenied(method, file)
   }
 
-  const touch = () => new Date(0)
-
-  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 normalizeEffect = (method: string, file: string) => {
     const normalized = normalize(method, file)
-    if (typeof normalized !== "string") return normalized
-    return entries.get(normalized) ?? notFound(method, file)
+    if (typeof normalized === "string") return Effect.succeed(normalized)
+    return Effect.fail(normalized)
   }
 
-  const descendants = (dir: string) =>
-    [...entries.keys()].filter((item) => item !== dir && AppFileSystem.contains(dir, item))
-
-  const children = (dir: string) =>
-    [...entries.keys()]
-      .filter((item) => item !== dir && path.dirname(item) === dir)
-      .sort((a, b) => path.basename(a).localeCompare(path.basename(b)))
+  const normalizePair = (method: string, fromPath: string, toPath: string) =>
+    Effect.all([normalizeEffect(method, fromPath), normalizeEffect(method, toPath)] as const)
 
-  const writeBytes = (method: string, file: string, content: Uint8Array, mode?: number) => {
-    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
-  }
+  const run = <A>(method: string, file: string, fn: (file: string) => Promise<A>) =>
+    Effect.gen(function* () {
+      const normalized = yield* normalizeEffect(method, file)
+      return yield* Effect.tryPromise({
+        try: () => fn(normalized),
+        catch: (cause) => failure(method, file, cause),
+      })
+    })
 
-  entries.set(root, { type: "directory", mode: 0o755, modified: touch() })
+  fs.mkdirSync(root, { recursive: true })
   for (const [file, content] of Object.entries(options.files ?? {})) {
     const normalized = normalize("seed", file)
-    if (typeof normalized !== "string") continue
-    ensureParentDirs(normalized)
-    entries.set(normalized, {
-      type: "file",
-      mode: 0o644,
-      modified: touch(),
-      content: typeof content === "string" ? encoder.encode(content) : content.slice(),
-    })
+    if (typeof normalized === "string") fs.writeFileSync(normalized, content, { encoding: "utf8" })
   }
 
   const base = FileSystem.make({
-    access: (file) =>
-      Effect.gen(function* () {
-        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 })
-      }),
+    access: (file) => run("access", file, async (item) => void (await fs.stat(item))),
+    chmod: (file, mode) => run("chmod", file, (item) => fs.chmod(item, mode)),
     chown: () => Effect.fail(unsupported("chown")),
     copy: (fromPath, toPath) =>
       Effect.gen(function* () {
-        const from = entry("copy", fromPath)
-        if (from instanceof Error) return yield* from
-        if (from.type === "directory") return yield* unsupported("copy")
-        yield* writeBytes("copy", toPath, from.content, from.mode)
+        const [from, to] = yield* normalizePair("copy", fromPath, toPath)
+        yield* Effect.tryPromise({
+          try: () => fs.cp(from, to, { recursive: true }),
+          catch: (cause) => failure("copy", fromPath, cause),
+        })
       }),
     copyFile: (fromPath, toPath) =>
       Effect.gen(function* () {
-        const from = entry("copyFile", fromPath)
-        if (from instanceof Error) return yield* from
-        if (from.type !== "file") return yield* notFound("copyFile", fromPath)
-        yield* writeBytes("copyFile", toPath, from.content, from.mode)
+        const [from, to] = yield* normalizePair("copyFile", fromPath, toPath)
+        yield* Effect.tryPromise({
+          try: () => fs.cp(from, to),
+          catch: (cause) => failure("copyFile", fromPath, cause),
+        })
       }),
-    link: () => Effect.fail(unsupported("link")),
-    makeDirectory: (file, methodOptions) =>
+    link: (existingPath, newPath) =>
       Effect.gen(function* () {
-        const normalized = normalize("makeDirectory", file)
-        if (typeof normalized !== "string") return yield* normalized
-        const existing = entries.get(normalized)
-        if (existing?.type === "directory") return
-        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() })
+        const [existing, next] = yield* normalizePair("link", existingPath, newPath)
+        yield* Effect.tryPromise({
+          try: () => fs.link(existing, next),
+          catch: (cause) => failure("link", existingPath, cause),
+        })
       }),
+    makeDirectory: (file, methodOptions) => run("makeDirectory", file, (item) => fs.mkdir(item, methodOptions)),
     makeTempDirectory: (methodOptions) =>
       Effect.gen(function* () {
-        const directory = methodOptions?.directory ?? root
-        const name = `${methodOptions?.prefix ?? "tmp-"}${++temp.value}`
-        const file = path.join(directory, name)
+        const directory = yield* normalizeEffect("makeTempDirectory", methodOptions?.directory ?? root)
+        const file = path.join(directory, `${methodOptions?.prefix ?? "tmp-"}${++temp.value}`)
         yield* base.makeDirectory(file, { recursive: true })
-        return path.resolve(root, file)
+        return file
       }),
     makeTempDirectoryScoped: (methodOptions) =>
       Effect.acquireRelease(
@@ -167,168 +114,133 @@ export function make(options: Options) {
       ),
     makeTempFile: (methodOptions) =>
       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 ?? ""}`)
-        yield* writeBytes("makeTempFile", file, new Uint8Array())
-        return path.resolve(root, file)
+        yield* base.writeFile(file, new Uint8Array())
+        return file
       }),
     makeTempFileScoped: (methodOptions) =>
       Effect.acquireRelease(base.makeTempFile(methodOptions), (file) => base.remove(file, { force: true }).pipe(Effect.ignore)),
     open: (file) =>
       Effect.gen(function* () {
+        const normalized = yield* normalizeEffect("open", file)
+        yield* base.access(normalized)
         let position = 0
-        const readCurrent = () => {
-          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)
+        const readCurrent = () => fs.readFileBuffer(normalized)
         return {
           [FileSystem.FileTypeId]: FileSystem.FileTypeId,
           fd: FileSystem.FileDescriptor(0),
-          stat: base.stat(file),
+          stat: base.stat(normalized),
           seek: (offset, from) =>
             Effect.sync(() => {
               position = from === "start" ? Number(offset) : position + Number(offset)
             }),
           sync: Effect.void,
           read: (buffer) =>
-            Effect.sync(() => {
-              const content = readCurrent() ?? new Uint8Array()
+            Effect.gen(function* () {
+              const content = yield* Effect.promise(readCurrent)
               const chunk = content.slice(position, position + buffer.length)
               buffer.set(chunk)
               position += chunk.length
               return FileSystem.Size(chunk.length)
             }),
           readAlloc: (size) =>
-            Effect.sync(() => {
-              const content = readCurrent() ?? new Uint8Array()
+            Effect.gen(function* () {
+              const content = yield* Effect.promise(readCurrent)
               const chunk = content.slice(position, position + Number(size))
               position += chunk.length
               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")),
           writeAll: () => Effect.fail(unsupported("file.writeAll")),
         }
       }),
     readDirectory: (file, methodOptions) =>
       Effect.gen(function* () {
-        const normalized = normalize("readDirectory", file)
-        if (typeof normalized !== "string") return yield* normalized
-        const current = entries.get(normalized)
-        if (current?.type !== "directory") return yield* notFound("readDirectory", file)
-        const items = methodOptions?.recursive ? descendants(normalized) : children(normalized)
-        return items.map((item) => path.relative(normalized, item))
-      }),
-    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)
+        const normalized = yield* normalizeEffect("readDirectory", file)
+        if (!methodOptions?.recursive) return yield* run("readDirectory", normalized, (item) => fs.readdir(item))
+        return fs
+          .getAllPaths()
+          .filter((item) => item !== normalized && AppFileSystem.contains(normalized, item))
+          .map((item) => path.relative(normalized, item))
+          .sort((a, b) => a.localeCompare(b))
       }),
+    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) =>
       Effect.gen(function* () {
-        const oldNormalized = normalize("rename", oldPath)
-        if (typeof oldNormalized !== "string") return yield* oldNormalized
-        const newNormalized = normalize("rename", newPath)
-        if (typeof newNormalized !== "string") return yield* newNormalized
-        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)
-        }
+        const [oldNormalized, newNormalized] = yield* normalizePair("rename", oldPath, newPath)
+        yield* Effect.tryPromise({
+          try: () => fs.mv(oldNormalized, newNormalized),
+          catch: (cause) => failure("rename", oldPath, cause),
+        })
       }),
     stat: (file) =>
-      Effect.gen(function* () {
-        const result = entry("stat", file)
-        if (result instanceof Error) return yield* result
+      run("stat", file, async (item) => {
+        const info = await fs.stat(item)
         return {
-          type: result.type === "directory" ? "Directory" : "File",
-          mtime: Option.some(result.modified),
-          atime: Option.some(result.modified),
-          birthtime: Option.some(result.modified),
+          type: info.isDirectory ? "Directory" : info.isSymbolicLink ? "SymbolicLink" : "File",
+          mtime: Option.some(info.mtime),
+          atime: Option.some(info.mtime),
+          birthtime: Option.some(info.mtime),
           dev: 0,
           ino: Option.none(),
-          mode: result.mode,
+          mode: info.mode,
           nlink: Option.none(),
           uid: Option.none(),
           gid: Option.none(),
           rdev: Option.none(),
-          size: FileSystem.Size(result.type === "file" ? result.content.length : 0),
+          size: FileSystem.Size(info.size),
           blksize: Option.none(),
           blocks: Option.none(),
         } satisfies FileSystem.File.Info
       }),
-    symlink: () => Effect.fail(unsupported("symlink")),
-    truncate: (file, size = 0) =>
+    symlink: (target, linkPath) =>
       Effect.gen(function* () {
-        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))
-        next.set(result.content.slice(0, next.length))
-        entries.set(path.resolve(root, file), { ...result, content: next, modified: touch() })
+        const normalized = yield* normalizeEffect("symlink", linkPath)
+        yield* Effect.tryPromise({
+          try: () => fs.symlink(target, normalized),
+          catch: (cause) => failure("symlink", linkPath, cause),
+        })
       }),
-    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 })
+    truncate: (file, size = 0) =>
+      run("truncate", file, async (item) => {
+        const next = new Uint8Array(Number(size))
+        next.set((await fs.readFileBuffer(item)).slice(0, next.length))
+        await fs.writeFile(item, next)
       }),
+    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")),
-    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) =>
     Effect.gen(function* () {
-      const cwd = path.resolve(root, globOptions?.cwd ?? root)
-      const normalized = normalize("glob", cwd)
-      if (typeof normalized !== "string") return yield* normalized
-      const matches = [...entries.entries()]
-        .filter(([, item]) => globOptions?.include === "all" || item.type === "file")
-        .map(([file]) => ({ file, relative: path.relative(normalized, file) }))
-        .filter((item) => item.relative && !item.relative.startsWith("..") && Glob.match(pattern, item.relative))
-        .map((item) => (globOptions?.absolute ? item.file : item.relative))
-        .sort((a, b) => a.localeCompare(b))
+      const cwd = yield* normalizeEffect("glob", globOptions?.cwd ?? root)
+      const matches = yield* Effect.forEach(
+        fs
+          .getAllPaths()
+          .filter((item) => item !== cwd && AppFileSystem.contains(cwd, item))
+          .sort((a, b) => a.localeCompare(b)),
+        (file) =>
+          base.stat(file).pipe(
+            Effect.map((info) => ({ file, info, relative: path.relative(cwd, file) })),
+            Effect.catch(() => Effect.succeed(undefined)),
+          ),
+      )
       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({
@@ -348,32 +260,26 @@ export function make(options: Options) {
         else yield* base.writeFile(file, content, mode ? { mode } : undefined)
       }),
     readDirectoryEntries: (file) =>
-      Effect.gen(function* () {
-        const normalized = normalize("readDirectoryEntries", file)
-        if (typeof normalized !== "string") return yield* normalized
-        const current = entries.get(normalized)
-        if (current?.type !== "directory") return yield* notFound("readDirectoryEntries", file)
-        return children(normalized).map((child) => {
-          const item = entries.get(child)
-          return {
-            name: path.basename(child),
-            type: item?.type === "directory" ? "directory" : item?.type === "file" ? "file" : "other",
-          } satisfies AppFileSystem.DirEntry
-        })
-      }),
-    findUp: (target, start, stop) =>
-      service.up({ targets: [target], start, stop }),
+      run("readDirectoryEntries", file, async (item) =>
+        (await fs.readdirWithFileTypes(item))
+          .map((entry) => ({
+            name: entry.name,
+            type: entry.isDirectory ? "directory" : entry.isSymbolicLink ? "symlink" : entry.isFile ? "file" : "other",
+          }) satisfies AppFileSystem.DirEntry)
+          .sort((a, b) => a.name.localeCompare(b.name)),
+      ),
+    findUp: (target, start, stop) => service.up({ targets: [target], start, stop }),
     up: (methodOptions) =>
       Effect.gen(function* () {
         const result: string[] = []
-        let current = path.resolve(root, methodOptions.start)
-        const stop = methodOptions.stop ? path.resolve(root, methodOptions.stop) : undefined
+        let current = yield* normalizeEffect("up", methodOptions.start)
+        const normalizedStop = methodOptions.stop ? yield* normalizeEffect("up", methodOptions.stop) : undefined
         while (true) {
           for (const target of methodOptions.targets) {
             const file = path.join(current, target)
             if (yield* base.exists(file)) result.push(file)
           }
-          if (stop === current) break
+          if (normalizedStop === current) break
           const parent = path.dirname(current)
           if (parent === current || !AppFileSystem.contains(root, parent)) break
           current = parent
@@ -383,8 +289,8 @@ export function make(options: Options) {
     globUp: (pattern, start, stop) =>
       Effect.gen(function* () {
         const result: string[] = []
-        let current = path.resolve(root, start)
-        const normalizedStop = stop ? path.resolve(root, stop) : undefined
+        let current = yield* normalizeEffect("globUp", start)
+        const normalizedStop = stop ? yield* normalizeEffect("globUp", stop) : undefined
         while (true) {
           result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true })))
           if (normalizedStop === current) break

+ 17 - 0
packages/opencode/test/testing/simulation/filesystem.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect } from "bun:test"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { Effect, Exit } from "effect"
+import { Bash, InMemoryFs } from "just-bash"
 import path from "path"
 import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
 import { testEffect } from "../../lib/effect"
@@ -53,4 +54,20 @@ describe("SimulationFileSystem", () => {
       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")
+    }),
+  )
 })

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.