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