Просмотр исходного кода

feat(core): add local environment driver (#41076)

Kit Langton 1 неделя назад
Родитель
Сommit
c9cbd2b1f4

+ 1 - 0
packages/core/src/environment/index.ts

@@ -12,6 +12,7 @@ export {
   WrongKind,
 } from "./files"
 export { execDefaults } from "./exec-defaults"
+export { makeLocalDriver } from "./local"
 export { makeMemoryDriver, type MemoryDriver } from "./memory"
 
 import type { Driver } from "./driver"

+ 103 - 0
packages/core/src/environment/local.ts

@@ -0,0 +1,103 @@
+import fs from "node:fs/promises"
+import path from "node:path"
+import { Effect } from "effect"
+import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
+import type { Driver } from "./driver"
+import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
+
+/**
+ * The host filesystem binding. Deliberately raw node:fs rather than effect's
+ * FileSystem service or FSUtil: the contract needs lstat semantics (stat
+ * reports "symlink") and typed directory entries, and effect's node
+ * FileSystem provides neither — its stat always follows symlinks and
+ * readDirectory returns names only. FSUtil hits the same gap and its
+ * readDirectoryEntries already bypasses to raw node readdir internally.
+ * Nothing above the environment seam touches node:fs.
+ */
+export const makeLocalDriver = (spawner: ChildProcessSpawner["Service"]): Driver => {
+  const overrides: FilesImpl = {
+    read: (value, range) =>
+      Effect.gen(function* () {
+        const info = yield* stat(value, true)
+        if (info.type !== "file") return yield* new WrongKind({ path: value, actual: info.type })
+        if (range === undefined) {
+          const bytes = yield* attempt(value, () => fs.readFile(value), true)
+          return { info, bytes }
+        }
+        const bytes = yield* attempt(
+          value,
+          async () => {
+            const handle = await fs.open(value, "r")
+            try {
+              const buffer = new Uint8Array(range.length)
+              const result = await handle.read(buffer, 0, range.length, range.offset)
+              return buffer.subarray(0, result.bytesRead)
+            } finally {
+              await handle.close()
+            }
+          },
+          true,
+        )
+        return { info, bytes }
+      }),
+    stat: (value) => stat(value, false),
+    list: (value) =>
+      Effect.gen(function* () {
+        const info = yield* stat(value, false)
+        if (info.type !== "directory") return yield* new WrongKind({ path: value, actual: info.type })
+        const entries = yield* attempt(value, () => fs.readdir(value, { withFileTypes: true }), true)
+        return entries.map((entry) => ({ name: entry.name, type: fileType(entry) }))
+      }),
+    write: (value, bytes) =>
+      attempt(value, async () => {
+        await fs.mkdir(path.dirname(value), { recursive: true })
+        await fs.writeFile(value, bytes)
+      }),
+    remove: (value) => attempt(value, () => fs.rm(value, { recursive: true, force: true })),
+    move: (from, to) =>
+      Effect.gen(function* () {
+        yield* stat(from, false)
+        const destination = yield* stat(to, false).pipe(
+          Effect.map((info) => (info.type === "directory" ? path.join(to, path.basename(from)) : to)),
+          Effect.catchIf(
+            (error) => error instanceof NotFound,
+            () => Effect.succeed(to),
+          ),
+        )
+        yield* attempt(from, () => fs.rename(from, destination))
+      }),
+    mkdir: (value) => attempt(value, () => fs.mkdir(value, { recursive: true }).then(() => undefined)),
+  }
+
+  return { spawner, overrides }
+}
+
+const stat = (value: string, follow: boolean) =>
+  attempt(value, () => (follow ? fs.stat(value) : fs.lstat(value)), true).pipe(
+    Effect.map((stats): FileInfo => ({ type: fileType(stats), size: stats.size, mtimeMs: stats.mtimeMs })),
+  )
+
+const fileType = (entry: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileType => {
+  if (entry.isFile()) return "file"
+  if (entry.isDirectory()) return "directory"
+  if (entry.isSymbolicLink()) return "symlink"
+  return "other"
+}
+
+function attempt<A>(value: string, run: () => Promise<A>): Effect.Effect<A, Failed>
+function attempt<A>(value: string, run: () => Promise<A>, missing: true): Effect.Effect<A, NotFound | Failed>
+function attempt<A>(value: string, run: () => Promise<A>, missing = false) {
+  return Effect.tryPromise({
+    try: run,
+    catch: (cause) =>
+      missing && isMissing(cause) ? new NotFound({ path: value }) : new Failed({ path: value, cause }),
+  })
+}
+
+const isMissing = (cause: unknown) =>
+  cause !== null &&
+  typeof cause === "object" &&
+  "code" in cause &&
+  (cause.code === "ENOENT" || cause.code === "ENOTDIR")
+
+export * as EnvironmentLocal from "./local"

+ 22 - 1
packages/core/test/environment.test.ts

@@ -3,7 +3,7 @@ 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 { execDefaults, Failed, makeFiles, makeLocalDriver, makeMemoryDriver } from "../src/environment/index"
 import { tmpdir } from "./fixture/tmpdir"
 import { environmentConformance } from "./lib/environment-conformance"
 
@@ -18,6 +18,27 @@ environmentConformance("memory environment", () =>
   }),
 )
 
+environmentConformance("local environment", () =>
+  Effect.gen(function* () {
+    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+    const tmp = yield* Effect.promise(() => tmpdir("opencode-local-environment-"))
+    return {
+      files: makeFiles(makeLocalDriver(spawner)),
+      root: tmp.path,
+      ...(process.platform === "win32"
+        ? {}
+        : {
+            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))),
+)
+
 environmentConformance(
   "GNU exec environment",
   () =>