mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-22 18:56:10 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b2843679 | ||
|
|
868c1fc596 | ||
|
|
e8d632834c |
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
import { PositiveInt, RelativePath } from "./schema"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Entry, Match } from "./filesystem/schema"
|
||||
export { Entry, Match, Submatch } from "./filesystem/schema"
|
||||
import { Entry, Match, PathError } from "./filesystem/schema"
|
||||
export { Entry, Match, PathError, Submatch } from "./filesystem/schema"
|
||||
|
||||
export const ReadInput = Schema.Struct({
|
||||
path: RelativePath,
|
||||
@@ -58,8 +58,10 @@ export const Event = {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
readonly read: (
|
||||
input: ReadInput,
|
||||
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, PathError | FSUtil.Error>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[], PathError | FSUtil.Error>
|
||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
|
||||
@@ -77,9 +79,9 @@ const baseLayer = Layer.effect(
|
||||
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
||||
const absolute = path.resolve(location.directory, input ?? ".")
|
||||
if (!FSUtil.contains(location.directory, absolute))
|
||||
return yield* Effect.die(new Error("Path escapes the location"))
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
return yield* new PathError({ path: input ?? ".", reason: "lexical_escape" })
|
||||
const real = yield* fs.realPath(absolute)
|
||||
if (!FSUtil.contains(root, real)) return yield* new PathError({ path: input ?? ".", reason: "symlink_escape" })
|
||||
return { absolute, real, directory: location.directory, root }
|
||||
})
|
||||
return Service.of({
|
||||
@@ -88,19 +90,18 @@ const baseLayer = Layer.effect(
|
||||
grep: search.grep,
|
||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
const info = yield* fs.stat(target.real)
|
||||
if (info.type !== "File") return yield* new PathError({ path: input.path, reason: "not_file" })
|
||||
return {
|
||||
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
|
||||
content: yield* fs.readFile(target.real),
|
||||
mime: FSUtil.mimeType(target.real),
|
||||
}
|
||||
}),
|
||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
const info = yield* fs.stat(target.real)
|
||||
if (info.type !== "Directory") return yield* new PathError({ path: input.path ?? ".", reason: "not_directory" })
|
||||
return yield* fs.readDirectoryEntries(target.real).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.flatMap((item) => {
|
||||
|
||||
@@ -21,3 +21,8 @@ export class Match extends Schema.Class<Match>("FileSystem.Match")({
|
||||
text: Schema.String,
|
||||
submatches: Schema.Array(Submatch),
|
||||
}) {}
|
||||
|
||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("FileSystem.PathError", {
|
||||
path: Schema.String,
|
||||
reason: Schema.Literals(["lexical_escape", "symlink_escape", "not_file", "not_directory"]),
|
||||
}) {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -31,6 +31,13 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
|
||||
describe("FileSystem", () => {
|
||||
const expectFail = (exit: Exit.Exit<unknown, unknown>) => {
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isSuccess(exit)) return
|
||||
expect(Cause.hasFails(exit.cause)).toBe(true)
|
||||
expect(Cause.hasDies(exit.cause)).toBe(false)
|
||||
}
|
||||
|
||||
it.live("reads text and binary files", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -60,13 +67,39 @@ describe("FileSystem", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects lexical escapes", () =>
|
||||
it.live("fails for missing paths", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* (yield* FileSystem.Service)
|
||||
.read({ path: RelativePath.make("../outside.txt") })
|
||||
const exit = yield* (yield* FileSystem.Service)
|
||||
.read({ path: RelativePath.make("missing.txt") })
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
expectFail(exit)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("fails for wrong path kinds", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
||||
const service = yield* FileSystem.Service
|
||||
expectFail(yield* service.read({ path: RelativePath.make("src") }).pipe(Effect.exit))
|
||||
expectFail(yield* service.list({ path: RelativePath.make("README.md") }).pipe(Effect.exit))
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("fails for lexical and symlink escapes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const outside = path.join(directory, "..", "outside.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(outside, "outside"))
|
||||
yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "linked.txt")))
|
||||
const service = yield* FileSystem.Service
|
||||
expectFail(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit))
|
||||
expectFail(yield* service.read({ path: RelativePath.make("linked.txt") }).pipe(Effect.exit))
|
||||
yield* Effect.promise(() => fs.rm(outside))
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { CliError, effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
@@ -13,6 +14,8 @@ const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.provide(LocationServiceMap.layer),
|
||||
)
|
||||
|
||||
const fileError = (error: FileSystem.PathError | FSUtil.Error) => new CliError({ message: error.message })
|
||||
|
||||
const FileSearchCommand = effectCmd({
|
||||
command: "search <query>",
|
||||
describe: "search files by query",
|
||||
@@ -38,7 +41,9 @@ const FileReadCommand = effectCmd({
|
||||
description: "File path to read",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
||||
const file = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
|
||||
const file = yield* filesystem(
|
||||
FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })),
|
||||
).pipe(Effect.mapError(fileError))
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{ content: Buffer.from(file.content).toString("base64"), encoding: "base64", mime: file.mime },
|
||||
@@ -59,7 +64,9 @@ const FileListCommand = effectCmd({
|
||||
description: "File path to list",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
|
||||
const files = yield* filesystem(FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })))
|
||||
const files = yield* filesystem(
|
||||
FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })),
|
||||
).pipe(Effect.mapError(fileError))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -10,6 +10,10 @@ import ignore from "ignore"
|
||||
import path from "path"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { InvalidRequestError } from "../errors"
|
||||
|
||||
const invalidRequest = (error: FileSystem.PathError | FSUtil.Error) =>
|
||||
new InvalidRequestError({ message: error.message, kind: error._tag })
|
||||
|
||||
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -79,7 +83,9 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
||||
.readFileString(path.join(location.project.directory, ".ignore"))
|
||||
.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
if (ignorefile) ignored.add(ignorefile)
|
||||
return (yield* fs.list({ path: RelativePath.make(ctx.query.path) })).map((item) => ({
|
||||
return (yield* fs
|
||||
.list({ path: RelativePath.make(ctx.query.path) })
|
||||
.pipe(Effect.mapError(invalidRequest))).map((item) => ({
|
||||
name: path.basename(item.path),
|
||||
path: item.path,
|
||||
absolute: path.resolve(location.directory, item.path),
|
||||
@@ -101,6 +107,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
||||
return yield* filesystem(
|
||||
FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
|
||||
).pipe(
|
||||
Effect.mapError(invalidRequest),
|
||||
Effect.flatMap((item) =>
|
||||
Effect.gen(function* () {
|
||||
const text = item.content.includes(0)
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import type { FileSystemEntry } from "@opencode-ai/sdk/v2/types"
|
||||
import type { Effect } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
|
||||
export type FileSystemError =
|
||||
| PlatformError
|
||||
| {
|
||||
readonly _tag: "FileSystemError"
|
||||
readonly method: string
|
||||
readonly cause?: unknown
|
||||
}
|
||||
| {
|
||||
readonly _tag: "FileSystem.PathError"
|
||||
readonly path: string
|
||||
readonly reason: "lexical_escape" | "symlink_escape" | "not_file" | "not_directory"
|
||||
}
|
||||
|
||||
export interface FileSystem {
|
||||
read(input: { readonly path: string }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||
list(input?: { readonly path?: string }): Effect.Effect<FileSystemEntry[]>
|
||||
read(input: {
|
||||
readonly path: string
|
||||
}): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, FileSystemError>
|
||||
list(input?: { readonly path?: string }): Effect.Effect<FileSystemEntry[], FileSystemError>
|
||||
find(input: {
|
||||
readonly query: string
|
||||
readonly type?: "file" | "directory"
|
||||
|
||||
@@ -7,7 +7,7 @@ export type { AISDK, AISDKHooks } from "./aisdk.js"
|
||||
export type { Catalog, CatalogDraft, CatalogProviderRecord } from "./catalog.js"
|
||||
export type { Command, CommandDraft } from "./command.js"
|
||||
export type { Event, EventMap } from "./event.js"
|
||||
export type { FileSystem } from "./filesystem.js"
|
||||
export type { FileSystem, FileSystemError } from "./filesystem.js"
|
||||
export type { Integration, IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "./integration.js"
|
||||
export type { Location } from "./location.js"
|
||||
export type { Npm } from "./npm.js"
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { InvalidRequestError } from "../errors"
|
||||
import { response } from "../groups/location"
|
||||
|
||||
const invalidRequest = (error: FileSystem.PathError | FSUtil.Error) =>
|
||||
new InvalidRequestError({ message: error.message, kind: error._tag })
|
||||
|
||||
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers
|
||||
.handleRaw("fs.read", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const file = yield* (yield* FileSystem.Service).read({
|
||||
path: RelativePath.make(
|
||||
decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)),
|
||||
),
|
||||
})
|
||||
const file = yield* (yield* FileSystem.Service)
|
||||
.read({
|
||||
path: RelativePath.make(
|
||||
decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)),
|
||||
),
|
||||
})
|
||||
.pipe(Effect.mapError(invalidRequest))
|
||||
return HttpServerResponse.uint8Array(file.content, { contentType: file.mime })
|
||||
}),
|
||||
)
|
||||
@@ -23,7 +30,7 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler
|
||||
response(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.Service
|
||||
return yield* fs.list(ctx.query)
|
||||
return yield* fs.list(ctx.query).pipe(Effect.mapError(invalidRequest))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user