Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"license": "MIT",
"private": true,
"scripts": {
"prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"build": "bun run script/build.ts",
Expand Down
197 changes: 197 additions & 0 deletions packages/opencode/src/filesystem/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { NodeFileSystem } from "@effect/platform-node"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "../util/glob"

export namespace AppFileSystem {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
method: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}

export type Error = PlatformError | FileSystemError

export interface Interface extends FileSystem.FileSystem {
readonly isDir: (path: string) => Effect.Effect<boolean, Error>
readonly isFile: (path: string) => Effect.Effect<boolean, Error>
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
readonly globMatch: (pattern: string, filepath: string) => boolean
}

export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileSystem") {}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem

const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "Directory"
})

const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "File"
})

const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
const text = yield* fs.readFileString(path)
return JSON.parse(text)
})

const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
const content = JSON.stringify(data, null, 2)
yield* fs.writeFileString(path, content)
if (mode) yield* fs.chmod(path, mode)
})

const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
yield* fs.makeDirectory(path, { recursive: true })
})

const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
path: string,
content: string | Uint8Array,
mode?: number,
) {
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)

yield* write.pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() =>
Effect.gen(function* () {
yield* fs.makeDirectory(dirname(path), { recursive: true })
yield* write
}),
),
)
if (mode) yield* fs.chmod(path, mode)
})

const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
return yield* Effect.tryPromise({
try: () => Glob.scan(pattern, options),
catch: (cause) => new FileSystemError({ method: "glob", cause }),
})
})

const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})

const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
const result: string[] = []
let current = options.start
while (true) {
for (const target of options.targets) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
}
if (options.stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})

const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
Effect.catch(() => Effect.succeed([] as string[])),
)
result.push(...matches)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})

return Service.of({
...fs,
isDir,
isFile,
readJson,
writeJson,
ensureDir,
writeWithDirs,
findUp,
up,
globUp,
glob,
globMatch: Glob.match,
})
}),
)

export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))

// Pure helpers that don't need Effect (path manipulation, sync operations)
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}

export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
try {
return realpathSync.native(p)
} catch {
return p
}
}

export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p))
try {
return normalizePath(realpathSync(resolved))
} catch (e: any) {
if (e?.code === "ENOENT") return normalizePath(resolved)
throw e
}
}

export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
}

export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}

export function contains(parent: string, child: string) {
return !relative(parent, child).startsWith("..")
}
}
17 changes: 7 additions & 10 deletions packages/opencode/src/skill/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
import { NodePath } from "@effect/platform-node"
import { Effect, Layer, Path, Schema, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AppFileSystem } from "@/filesystem"
import { Global } from "../global"
import { Log } from "../util/log"

Expand All @@ -24,12 +25,12 @@ export namespace Discovery {

export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}

export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Path.Path | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
const fs = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")
Expand All @@ -40,11 +41,7 @@ export namespace Discovery {
return yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((res) => res.arrayBuffer),
Effect.flatMap((body) =>
fs
.makeDirectory(path.dirname(dest), { recursive: true })
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
),
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
Effect.as(true),
Effect.catch((err) =>
Effect.sync(() => {
Expand Down Expand Up @@ -113,7 +110,7 @@ export namespace Discovery {

export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
}
21 changes: 11 additions & 10 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { AppFileSystem } from "@/filesystem"
import { Config } from "../config/config"
import { Global } from "../global"
import { Log } from "../util/log"
Expand Down Expand Up @@ -85,12 +86,12 @@ export namespace Snapshot {
export const layer: Layer.Layer<
Service,
never,
InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const ctx = yield* InstanceContext
const fs = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = ctx.directory
const worktree = ctx.worktree
Expand Down Expand Up @@ -124,9 +125,8 @@ export namespace Snapshot {
),
)

// Snapshot-specific error handling on top of AppFileSystem
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))

Expand All @@ -148,12 +148,12 @@ export namespace Snapshot {
const sync = Effect.fnUntraced(function* () {
const file = yield* excludes()
const target = path.join(gitdir, "info", "exclude")
yield* mkdir(path.join(gitdir, "info"))
yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
if (!file) {
yield* write(target, "")
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
return
}
yield* write(target, yield* read(file))
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
})

const add = Effect.fnUntraced(function* () {
Expand All @@ -178,7 +178,7 @@ export namespace Snapshot {
const track = Effect.fn("Snapshot.track")(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(gitdir)
yield* mkdir(gitdir)
yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
Expand Down Expand Up @@ -342,7 +342,8 @@ export namespace Snapshot {

export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
Layer.provide(NodePath.layer),
)
}
11 changes: 6 additions & 5 deletions packages/opencode/src/tool/truncate-effect.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { AppFileSystem } from "@/filesystem"
import { PermissionNext } from "../permission"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
Expand Down Expand Up @@ -44,7 +45,7 @@ export namespace TruncateEffect {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service

const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
Expand Down Expand Up @@ -101,7 +102,7 @@ export namespace TruncateEffect {
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())

yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)

const hint = hasTaskTool(agent)
Expand Down Expand Up @@ -132,5 +133,5 @@ export namespace TruncateEffect {
}),
)

export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
}
Loading
Loading