refactor(core): consolidate filesystem services (#30447)
This commit is contained in:
parent
b93963e462
commit
604a5f781f
153 changed files with 2542 additions and 4301 deletions
|
|
@ -90,9 +90,9 @@ Within each file, migrate git paths first where applicable.
|
|||
|
||||
Migrate git-centric call sites to `Process.git*` helpers:
|
||||
|
||||
- `src/file/index.ts`
|
||||
- `../core/src/filesystem.ts`
|
||||
- `src/project/vcs.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `../core/src/filesystem/watcher.ts`
|
||||
- `src/storage/storage.ts`
|
||||
- `src/cli/cmd/pr.ts`
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ Migrate residual non-git usages:
|
|||
|
||||
- `src/cli/cmd/tui/util/clipboard.ts`
|
||||
- `src/util/archive.ts`
|
||||
- `src/file/ripgrep.ts`
|
||||
- `../core/src/filesystem/ripgrep.ts`
|
||||
- `src/tool/bash.ts`
|
||||
- `src/cli/cmd/uninstall.ts`
|
||||
|
||||
|
|
|
|||
|
|
@ -41,14 +41,6 @@
|
|||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/http-recorder": "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",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/babel__core": "7.20.5",
|
||||
|
|
@ -58,7 +50,6 @@
|
|||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
|
|
@ -139,7 +130,6 @@
|
|||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"htmlparser2": "8.0.2",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
|
|
@ -161,7 +151,6 @@
|
|||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"ws": "8.21.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ Caller-heavy batch, all merged:
|
|||
|
||||
1. `src/config/config.ts`
|
||||
2. `src/provider/provider.ts`
|
||||
3. `src/file/index.ts`
|
||||
3. `../core/src/filesystem.ts`
|
||||
4. `src/lsp/index.ts`
|
||||
5. `src/mcp/index.ts`
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ Usually no.
|
|||
Prefer the direct form when there is only one expression:
|
||||
|
||||
```ts
|
||||
await AppRuntime.runPromise(File.Service.use((svc) => svc.read(path)))
|
||||
await Effect.runPromise(FileSystem.Service.use((svc) => svc.read({ path })))
|
||||
```
|
||||
|
||||
Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching.
|
||||
|
|
@ -179,7 +179,7 @@ These were the recurring mistakes and useful corrections from the first two batc
|
|||
|
||||
1. Tests should usually provide the specific service layer, not `AppRuntime`.
|
||||
2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`.
|
||||
3. Instance-scoped services may need both the service layer and the right instance fixture. `File` tests, for example, needed `provideInstance(...)` plus `File.defaultLayer`.
|
||||
3. Location-scoped services may need both the service layer and the right location fixture. `FileSystem` tests, for example, provide `Location.Service` plus `FileSystem.locationLayer`.
|
||||
4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form.
|
||||
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
|
||||
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
|
||||
|
|
@ -201,7 +201,7 @@ Most of the original facade-removal backlog is already done. The practical remai
|
|||
- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
|
||||
- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
|
||||
- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
|
||||
- [x] `src/file/index.ts` (`File`) - facades removed and merged
|
||||
- [x] `../core/src/filesystem.ts` (`FileSystem`) - legacy opencode service removed
|
||||
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
|
||||
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
|
||||
- [x] `src/config/config.ts` (`Config`) - facades removed and merged
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ Intentional boundaries:
|
|||
In effectified code, yield existing services instead of dropping to ad hoc
|
||||
platform APIs.
|
||||
|
||||
- Use `AppFileSystem.Service` instead of raw `fs/promises` for app file IO.
|
||||
- Use `FSUtil.Service` instead of raw `fs/promises` for app file IO.
|
||||
- Use `AppProcess.Service` instead of direct `ChildProcessSpawner.spawn` or
|
||||
legacy process helpers.
|
||||
- Use `HttpClient.HttpClient` instead of raw `fetch` inside Effect code.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
|
|||
|
||||
- [ ] `config/paths.ts` - split pure helpers from effectful helpers.
|
||||
Keep `fileInDirectory(...)` as a plain function.
|
||||
- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `AppFileSystem.Service` directly.
|
||||
- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `FSUtil.Service` directly.
|
||||
Initial service surface should cover:
|
||||
- `projectFiles(...)`
|
||||
- `directories(...)`
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ genuinely outside `AppLayer`.
|
|||
|
||||
## Platform Edges
|
||||
|
||||
- Use `AppFileSystem.Service` instead of raw filesystem APIs in
|
||||
- Use `FSUtil.Service` instead of raw filesystem APIs in
|
||||
effectified services.
|
||||
- Use `AppProcess.Service` instead of raw process wrappers.
|
||||
- Use `HttpClient.HttpClient` instead of raw `fetch` in Effect code.
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ P6 OA
|
|||
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
|
||||
process wrappers.
|
||||
Shrinks: direct spawn callsites and legacy process helpers.
|
||||
- `FS` AppFileSystem migration — prefer `AppFileSystem.Service` over raw
|
||||
- `FS` FSUtil migration — prefer `FSUtil.Service` over raw
|
||||
filesystem APIs.
|
||||
Shrinks: direct `fs` / `Bun.file` service callsites where inappropriate.
|
||||
- `RT` Runtime/facade cleanup — remove service-local `makeRuntime`
|
||||
|
|
@ -229,7 +229,7 @@ Current rules:
|
|||
|
||||
## Lower Priority Tracks
|
||||
|
||||
- `PROC` / `FS` — continue AppProcess and AppFileSystem migrations as
|
||||
- `PROC` / `FS` — continue AppProcess and FSUtil migrations as
|
||||
focused PRs when touching relevant files.
|
||||
- `RT` — remove service-local runtime facades only when they are not an
|
||||
intentional boundary.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ The current exported tools in `src/tool` all use `Tool.define(...)` with Effect-
|
|||
So the remaining work is no longer "convert tools to Effect at all". The remaining work is mostly:
|
||||
|
||||
1. remove Promise and raw platform bridges inside individual tool bodies
|
||||
2. swap tool internals to Effect-native services like `AppFileSystem`, `HttpClient`, and `ChildProcessSpawner`
|
||||
2. swap tool internals to Effect-native services like `FSUtil`, `HttpClient`, and `ChildProcessSpawner`
|
||||
3. keep tests and callers aligned with `yield* info.init()` and real service graphs
|
||||
|
||||
## Current shape
|
||||
|
|
@ -67,11 +67,11 @@ Most exported tools are already on the intended Effect-native shape. The remaini
|
|||
|
||||
Current spot cleanups worth tracking:
|
||||
|
||||
- [x] `read.ts` — streams through `AppFileSystem.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
|
||||
- [x] `read.ts` — streams through `FSUtil.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
|
||||
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
|
||||
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
|
||||
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
|
||||
- [x] `patch/index.ts` — apply path now returns `Effect` over `AppFileSystem.Service`; the parser and chunk replacer stay pure
|
||||
- [x] `patch/index.ts` — apply path now returns `Effect` over `FSUtil.Service`; the parser and chunk replacer stay pure
|
||||
|
||||
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import path from "path"
|
|||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Au
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("Auth.all")(function* () {
|
||||
|
|
@ -91,6 +91,6 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export * as Auth from "."
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { File } from "../../../file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })),
|
||||
Effect.provide(LocationServiceMap.layer),
|
||||
)
|
||||
|
||||
const FileSearchCommand = effectCmd({
|
||||
command: "search <query>",
|
||||
describe: "search files by query",
|
||||
|
|
@ -15,8 +23,8 @@ const FileSearchCommand = effectCmd({
|
|||
description: "Search query",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
|
||||
const results = yield* File.Service.use((svc) => svc.search({ query: args.query }))
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
const results = yield* filesystem(FileSystem.Service.use((svc) => svc.find({ query: args.query })))
|
||||
process.stdout.write(results.map((item) => item.path).join(EOL) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
@ -30,21 +38,11 @@ const FileReadCommand = effectCmd({
|
|||
description: "File path to read",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
||||
const content = yield* File.Service.use((svc) => svc.read(args.path))
|
||||
const content = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
const FileStatusCommand = effectCmd({
|
||||
command: "status",
|
||||
describe: "show file status information",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.file.status")(function* () {
|
||||
const status = yield* File.Service.use((svc) => svc.status())
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
const FileListCommand = effectCmd({
|
||||
command: "list <path>",
|
||||
describe: "list files in a directory",
|
||||
|
|
@ -55,7 +53,7 @@ const FileListCommand = effectCmd({
|
|||
description: "File path to list",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
|
||||
const files = yield* File.Service.use((svc) => svc.list(args.path))
|
||||
const files = yield* filesystem(FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
|
@ -81,7 +79,6 @@ export const FileCommand = cmd({
|
|||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(FileReadCommand)
|
||||
.command(FileStatusCommand)
|
||||
.command(FileListCommand)
|
||||
.command(FileSearchCommand)
|
||||
.command(FileTreeCommand)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { EOL } from "os"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Ripgrep } from "../../../file/ripgrep"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { InstanceRef } from "@/effect/instance-ref"
|
|||
import { ShareNext } from "@/share/share-next"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ export const ImportCommand = effectCmd({
|
|||
|
||||
const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) {
|
||||
const share = yield* ShareNext.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
let exportData: ExportData | undefined
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
|
||||
// variant and the persisted file.
|
||||
import path from "path"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
|
@ -135,12 +135,12 @@ function state(value: unknown): ModelState {
|
|||
}
|
||||
}
|
||||
|
||||
function createLayer(fs = AppFileSystem.defaultLayer) {
|
||||
function createLayer(fs = FSUtil.defaultLayer) {
|
||||
return Layer.fresh(
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const file = yield* AppFileSystem.Service
|
||||
const file = yield* FSUtil.Service
|
||||
|
||||
const read = Effect.fn("RunVariant.read")(function* () {
|
||||
return yield* file.readJson(MODEL_FILE).pipe(
|
||||
|
|
@ -196,7 +196,7 @@ function createLayer(fs = AppFileSystem.defaultLayer) {
|
|||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function createVariantRuntime(fs = AppFileSystem.defaultLayer): VariantRuntime {
|
||||
export function createVariantRuntime(fs = FSUtil.defaultLayer): VariantRuntime {
|
||||
const runtime = makeRuntime(Service, createLayer(fs))
|
||||
return {
|
||||
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Process } from "@/util/process"
|
|||
import { NotFoundError } from "@/storage/storage"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "../../util/which"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from
|
|||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { CurrentWorkingDirectory } from "./cwd"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
|
|
@ -97,7 +97,7 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
|
|||
}
|
||||
|
||||
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const afs = yield* FSUtil.Service
|
||||
let appliedOrder = 0
|
||||
|
||||
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
||||
|
|
@ -295,7 +295,7 @@ export const layer = Layer.effect(
|
|||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
|
|||
|
||||
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
|
||||
const getWhich = lazy(async () => {
|
||||
const { which } = await import("../../../../util/which")
|
||||
const { which } = await import("@opencode-ai/core/util/which")
|
||||
return which
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { existsSync } from "fs"
|
|||
import { Account } from "@/account/account"
|
||||
import { isRecord } from "@/util/record"
|
||||
import type { ConsoleState } from "./console-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
|
@ -384,7 +384,7 @@ export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoEr
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const authSvc = yield* Auth.Service
|
||||
const accountSvc = yield* Account.Service
|
||||
const env = yield* Env.Service
|
||||
|
|
@ -796,7 +796,7 @@ export const layer = Layer.effect(
|
|||
},
|
||||
}
|
||||
},
|
||||
Effect.provideService(AppFileSystem.Service, fs),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
)
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
|
|
@ -876,7 +876,7 @@ export const layer = Layer.effect(
|
|||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||
import { Global } from "@opencode-ai/core/global"
|
||||
import { unique } from "remeda"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
|
||||
export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
|
||||
name: string,
|
||||
directory: string,
|
||||
worktree?: string,
|
||||
) {
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const afs = yield* FSUtil.Service
|
||||
return (yield* afs.up({
|
||||
targets: [`${name}.jsonc`, `${name}.json`],
|
||||
start: directory,
|
||||
|
|
@ -21,7 +21,7 @@ export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
|
|||
})
|
||||
|
||||
export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const afs = yield* FSUtil.Service
|
||||
return unique([
|
||||
Global.Path.config,
|
||||
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { Auth } from "@/auth"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
|
|
@ -177,7 +177,7 @@ export const layer = Layer.effect(
|
|||
const events = yield* EventV2Bridge.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const { db } = yield* Database.Service
|
||||
const connections = new Map<WorkspaceV2.ID, ConnectionStatus>()
|
||||
const syncFibers = yield* FiberMap.make<WorkspaceV2.ID, void, SyncLoopError>()
|
||||
|
|
@ -1005,7 +1005,7 @@ export const defaultLayer = layer.pipe(
|
|||
Layer.provide(SessionPrompt.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(Vcs.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
|
|
|
|||
|
|
@ -2,15 +2,13 @@ import { Layer, ManagedRuntime } from "effect"
|
|||
import { attach } from "./run-service"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Auth } from "@/auth"
|
||||
import { Account } from "@/account/account"
|
||||
import { Config } from "@/config/config"
|
||||
import { Git } from "@/git"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { File } from "@/file"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Plugin } from "@/plugin"
|
||||
|
|
@ -59,15 +57,13 @@ import { EventV2Bridge } from "@/event-v2-bridge"
|
|||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
Npm.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
Storage.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@ import { Layer, ManagedRuntime } from "effect"
|
|||
|
||||
import { Plugin } from "@/plugin"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Format } from "@/format"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { File } from "@/file"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Config } from "@/config/config"
|
||||
|
|
@ -18,8 +16,6 @@ export const BootstrapLayer = Layer.mergeAll(
|
|||
ShareNext.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
).pipe(Layer.provide(Observability.layer))
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
|
||||
const FOLDERS = new Set([
|
||||
"node_modules",
|
||||
"bower_components",
|
||||
".pnpm-store",
|
||||
"vendor",
|
||||
".npm",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
".next",
|
||||
"target",
|
||||
"bin",
|
||||
"obj",
|
||||
".git",
|
||||
".svn",
|
||||
".hg",
|
||||
".vscode",
|
||||
".idea",
|
||||
".turbo",
|
||||
".output",
|
||||
"desktop",
|
||||
".sst",
|
||||
".cache",
|
||||
".webkit-cache",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
"mypy_cache",
|
||||
".history",
|
||||
".gradle",
|
||||
])
|
||||
|
||||
const FILES = [
|
||||
"**/*.swp",
|
||||
"**/*.swo",
|
||||
|
||||
"**/*.pyc",
|
||||
|
||||
// OS
|
||||
"**/.DS_Store",
|
||||
"**/Thumbs.db",
|
||||
|
||||
// Logs & temp
|
||||
"**/logs/**",
|
||||
"**/tmp/**",
|
||||
"**/temp/**",
|
||||
"**/*.log",
|
||||
|
||||
// Coverage/test outputs
|
||||
"**/coverage/**",
|
||||
"**/.nyc_output/**",
|
||||
]
|
||||
|
||||
export const PATTERNS = [...FILES, ...FOLDERS]
|
||||
|
||||
export function match(
|
||||
filepath: string,
|
||||
opts?: {
|
||||
extra?: string[]
|
||||
whitelist?: string[]
|
||||
},
|
||||
) {
|
||||
for (const pattern of opts?.whitelist || []) {
|
||||
if (Glob.match(pattern, filepath)) return false
|
||||
}
|
||||
|
||||
const parts = filepath.split(/[/\\]/)
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (FOLDERS.has(parts[i])) return true
|
||||
}
|
||||
|
||||
const extra = opts?.extra || []
|
||||
for (const pattern of [...FILES, ...extra]) {
|
||||
if (Glob.match(pattern, filepath)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export * as FileIgnore from "./ignore"
|
||||
|
|
@ -1,654 +0,0 @@
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer, Context, Schema, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { containsPath } from "../project/instance-context"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { NonNegativeInt, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
path: Schema.String,
|
||||
added: NonNegativeInt,
|
||||
removed: NonNegativeInt,
|
||||
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||
}).annotate({ identifier: "File" })
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
export const Node = Schema.Struct({
|
||||
name: Schema.String,
|
||||
path: Schema.String,
|
||||
absolute: Schema.String,
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
ignored: Schema.Boolean,
|
||||
}).annotate({ identifier: "FileNode" })
|
||||
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
|
||||
|
||||
const Hunk = Schema.Struct({
|
||||
oldStart: NonNegativeInt,
|
||||
oldLines: NonNegativeInt,
|
||||
newStart: NonNegativeInt,
|
||||
newLines: NonNegativeInt,
|
||||
lines: Schema.Array(Schema.String),
|
||||
})
|
||||
|
||||
const Patch = Schema.Struct({
|
||||
oldFileName: Schema.String,
|
||||
newFileName: Schema.String,
|
||||
oldHeader: Schema.optional(Schema.String),
|
||||
newHeader: Schema.optional(Schema.String),
|
||||
hunks: Schema.Array(Hunk),
|
||||
index: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const Content = Schema.Struct({
|
||||
type: Schema.Literals(["text", "binary"]),
|
||||
content: Schema.String,
|
||||
diff: Schema.optional(Schema.String),
|
||||
patch: Schema.optional(Patch),
|
||||
encoding: Schema.optional(Schema.Literal("base64")),
|
||||
mimeType: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "FileContent" })
|
||||
export type Content = DeepMutable<Schema.Schema.Type<typeof Content>>
|
||||
|
||||
export const Event = {
|
||||
Edited: EventV2.define({
|
||||
type: "file.edited",
|
||||
schema: {
|
||||
file: Schema.String,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
const binary = new Set([
|
||||
"exe",
|
||||
"dll",
|
||||
"pdb",
|
||||
"bin",
|
||||
"so",
|
||||
"dylib",
|
||||
"o",
|
||||
"a",
|
||||
"lib",
|
||||
"wav",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"oga",
|
||||
"ogv",
|
||||
"ogx",
|
||||
"flac",
|
||||
"aac",
|
||||
"wma",
|
||||
"m4a",
|
||||
"weba",
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"mkv",
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"gzip",
|
||||
"bz",
|
||||
"bz2",
|
||||
"bzip",
|
||||
"bzip2",
|
||||
"7z",
|
||||
"rar",
|
||||
"xz",
|
||||
"lz",
|
||||
"z",
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"dmg",
|
||||
"iso",
|
||||
"img",
|
||||
"vmdk",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"eot",
|
||||
"sqlite",
|
||||
"db",
|
||||
"mdb",
|
||||
"apk",
|
||||
"ipa",
|
||||
"aab",
|
||||
"xapk",
|
||||
"app",
|
||||
"pkg",
|
||||
"deb",
|
||||
"rpm",
|
||||
"snap",
|
||||
"flatpak",
|
||||
"appimage",
|
||||
"msi",
|
||||
"msp",
|
||||
"jar",
|
||||
"war",
|
||||
"ear",
|
||||
"class",
|
||||
"kotlin_module",
|
||||
"dex",
|
||||
"vdex",
|
||||
"odex",
|
||||
"oat",
|
||||
"art",
|
||||
"wasm",
|
||||
"wat",
|
||||
"bc",
|
||||
"ll",
|
||||
"s",
|
||||
"ko",
|
||||
"sys",
|
||||
"drv",
|
||||
"efi",
|
||||
"rom",
|
||||
"com",
|
||||
])
|
||||
|
||||
const image = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"ico",
|
||||
"tif",
|
||||
"tiff",
|
||||
"svg",
|
||||
"svgz",
|
||||
"avif",
|
||||
"apng",
|
||||
"jxl",
|
||||
"heic",
|
||||
"heif",
|
||||
"raw",
|
||||
"cr2",
|
||||
"nef",
|
||||
"arw",
|
||||
"dng",
|
||||
"orf",
|
||||
"raf",
|
||||
"pef",
|
||||
"x3f",
|
||||
])
|
||||
|
||||
const text = new Set([
|
||||
"ts",
|
||||
"tsx",
|
||||
"mts",
|
||||
"cts",
|
||||
"mtsx",
|
||||
"ctsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"ps1",
|
||||
"psm1",
|
||||
"cmd",
|
||||
"bat",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"md",
|
||||
"mdx",
|
||||
"txt",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"less",
|
||||
"graphql",
|
||||
"gql",
|
||||
"sql",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"env",
|
||||
])
|
||||
|
||||
const textName = new Set([
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".npmrc",
|
||||
".nvmrc",
|
||||
".prettierrc",
|
||||
".eslintrc",
|
||||
])
|
||||
|
||||
const mime: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
webp: "image/webp",
|
||||
ico: "image/x-icon",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
svg: "image/svg+xml",
|
||||
svgz: "image/svg+xml",
|
||||
avif: "image/avif",
|
||||
apng: "image/apng",
|
||||
jxl: "image/jxl",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
}
|
||||
|
||||
type Entry = { files: string[]; dirs: string[] }
|
||||
|
||||
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
||||
const name = (file: string) => path.basename(file).toLowerCase()
|
||||
const isImageByExtension = (file: string) => image.has(ext(file))
|
||||
const isTextByExtension = (file: string) => text.has(ext(file))
|
||||
const isTextByName = (file: string) => textName.has(name(file))
|
||||
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
||||
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
||||
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
||||
|
||||
function shouldEncode(mimeType: string) {
|
||||
const type = mimeType.toLowerCase()
|
||||
log.debug("shouldEncode", { type })
|
||||
if (!type) return false
|
||||
if (type.startsWith("text/")) return false
|
||||
if (type.includes("charset=")) return false
|
||||
const top = type.split("/", 2)[0]
|
||||
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
||||
}
|
||||
|
||||
const hidden = (item: string) => {
|
||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
||||
}
|
||||
|
||||
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
||||
if (prefer) return items
|
||||
const visible: string[] = []
|
||||
const hiddenItems: string[] = []
|
||||
for (const item of items) {
|
||||
if (hidden(item)) hiddenItems.push(item)
|
||||
else visible.push(item)
|
||||
}
|
||||
return [...visible, ...hiddenItems]
|
||||
}
|
||||
|
||||
interface State {
|
||||
cache: Entry
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Info[]>
|
||||
readonly read: (file: string) => Effect.Effect<Content>
|
||||
readonly list: (dir?: string) => Effect.Effect<Node[]>
|
||||
readonly search: (input: {
|
||||
query: string
|
||||
limit?: number
|
||||
dirs?: boolean
|
||||
type?: "file" | "directory"
|
||||
}) => Effect.Effect<string[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
const git = yield* Git.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("File.state")(() =>
|
||||
Effect.succeed({
|
||||
cache: { files: [], dirs: [] } as Entry,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const scan = Effect.fn("File.scan")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.directory === path.parse(ctx.directory).root) return
|
||||
const isGlobalHome = ctx.directory === Global.Path.home && ctx.project.id === "global"
|
||||
const next: Entry = { files: [], dirs: [] }
|
||||
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
const top = yield* appFs.readDirectoryEntries(ctx.directory).pipe(Effect.orElseSucceed(() => []))
|
||||
|
||||
for (const entry of top) {
|
||||
if (entry.type !== "directory") continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
const base = path.join(ctx.directory, entry.name)
|
||||
const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
|
||||
for (const child of children) {
|
||||
if (child.type !== "directory") continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const files = yield* rg.files({ cwd: ctx.directory }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
const seen = new Set<string>()
|
||||
for (const file of files) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.cache = next
|
||||
})
|
||||
|
||||
let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||
|
||||
const ensure = Effect.fn("File.ensure")(function* () {
|
||||
yield* cachedScan
|
||||
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||
})
|
||||
|
||||
const gitText = Effect.fnUntraced(function* (args: string[]) {
|
||||
return (yield* git.run(args, { cwd: (yield* InstanceState.context).directory })).text()
|
||||
})
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
yield* ensure().pipe(Effect.forkIn(scope))
|
||||
})
|
||||
|
||||
const status = Effect.fn("File.status")(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.project.vcs !== "git") return []
|
||||
|
||||
const diffOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--numstat",
|
||||
"HEAD",
|
||||
])
|
||||
|
||||
const changed: Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
])
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
const content = yield* appFs
|
||||
.readFileString(path.join(ctx.directory, file))
|
||||
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
|
||||
if (content === undefined) continue
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deletedOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=D",
|
||||
"HEAD",
|
||||
])
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(ctx.directory, item.path)
|
||||
return {
|
||||
...item,
|
||||
path: path.relative(ctx.directory, full),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
|
||||
using _ = log.time("read", { file })
|
||||
const ctx = yield* InstanceState.context
|
||||
const full = path.join(ctx.directory, file)
|
||||
|
||||
if (!containsPath(full, ctx)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
if (isImageByExtension(file)) {
|
||||
const exists = yield* appFs.existsSafe(full)
|
||||
if (exists) {
|
||||
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
return {
|
||||
type: "text" as const,
|
||||
content: Buffer.from(bytes).toString("base64"),
|
||||
mimeType: getImageMimeType(file),
|
||||
encoding: "base64" as const,
|
||||
}
|
||||
}
|
||||
return { type: "text" as const, content: "" }
|
||||
}
|
||||
|
||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||
|
||||
if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
|
||||
|
||||
const exists = yield* appFs.existsSafe(full)
|
||||
if (!exists) return { type: "text" as const, content: "" }
|
||||
|
||||
const mimeType = AppFileSystem.mimeType(full)
|
||||
const encode = knownText ? false : shouldEncode(mimeType)
|
||||
|
||||
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
|
||||
|
||||
if (encode) {
|
||||
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
return {
|
||||
type: "text" as const,
|
||||
content: Buffer.from(bytes).toString("base64"),
|
||||
mimeType,
|
||||
encoding: "base64" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const content = yield* appFs.readFileString(full).pipe(
|
||||
Effect.map((s) => s.trim()),
|
||||
Effect.catch(() => Effect.succeed("")),
|
||||
)
|
||||
|
||||
if (ctx.project.vcs === "git") {
|
||||
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
|
||||
if (!diff.trim()) {
|
||||
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = yield* git.show(ctx.directory, "HEAD", file)
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
|
||||
}
|
||||
return { type: "text" as const, content }
|
||||
}
|
||||
|
||||
return { type: "text" as const, content }
|
||||
})
|
||||
|
||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const exclude = [".git", ".DS_Store"]
|
||||
let ignored = (_: string) => false
|
||||
if (ctx.project.vcs === "git") {
|
||||
const ig = ignore()
|
||||
const gitignore = path.join(ctx.worktree, ".gitignore")
|
||||
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
if (gitignoreText) ig.add(gitignoreText)
|
||||
const ignoreFile = path.join(ctx.worktree, ".ignore")
|
||||
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
if (ignoreText) ig.add(ignoreText)
|
||||
ignored = ig.ignores.bind(ig)
|
||||
}
|
||||
|
||||
const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
|
||||
if (!containsPath(resolved, ctx)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
|
||||
|
||||
const nodes: Node[] = []
|
||||
for (const entry of entries) {
|
||||
if (exclude.includes(entry.name)) continue
|
||||
const absolute = path.join(resolved, entry.name)
|
||||
const file = path.relative(ctx.directory, absolute)
|
||||
const type = entry.type === "directory" ? "directory" : "file"
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: file,
|
||||
absolute,
|
||||
type,
|
||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||
})
|
||||
}
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
const search = Effect.fn("File.search")(function* (input: {
|
||||
query: string
|
||||
limit?: number
|
||||
dirs?: boolean
|
||||
type?: "file" | "directory"
|
||||
}) {
|
||||
yield* ensure()
|
||||
const { cache } = yield* InstanceState.get(state)
|
||||
|
||||
const query = input.query.trim()
|
||||
const limit = input.limit ?? 100
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
|
||||
if (!query) {
|
||||
if (kind === "file") return cache.files.slice(0, limit)
|
||||
return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||
}
|
||||
|
||||
const items = kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
|
||||
|
||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||
|
||||
log.info("search", { query, kind, results: output.length })
|
||||
return output
|
||||
})
|
||||
|
||||
log.info("init")
|
||||
return Service.of({ init, status, read, list, search })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
|
||||
export * as File from "."
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import path from "path"
|
||||
import os from "os"
|
||||
|
||||
const home = os.homedir()
|
||||
|
||||
// macOS directories that trigger TCC (Transparency, Consent, and Control)
|
||||
// permission prompts when accessed by a non-sandboxed process.
|
||||
const DARWIN_HOME = [
|
||||
// Media
|
||||
"Music",
|
||||
"Pictures",
|
||||
"Movies",
|
||||
// User-managed folders synced via iCloud / subject to TCC
|
||||
"Downloads",
|
||||
"Desktop",
|
||||
"Documents",
|
||||
// Other system-managed
|
||||
"Public",
|
||||
"Applications",
|
||||
"Library",
|
||||
]
|
||||
|
||||
const DARWIN_LIBRARY = [
|
||||
"Application Support/AddressBook",
|
||||
"Calendars",
|
||||
"Mail",
|
||||
"Messages",
|
||||
"Safari",
|
||||
"Cookies",
|
||||
"Application Support/com.apple.TCC",
|
||||
"PersonalizationPortrait",
|
||||
"Metadata/CoreSpotlight",
|
||||
"Suggestions",
|
||||
]
|
||||
|
||||
const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
|
||||
|
||||
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
|
||||
|
||||
/** Directory basenames to skip when scanning the home directory. */
|
||||
export function names(): ReadonlySet<string> {
|
||||
if (process.platform === "darwin") return new Set(DARWIN_HOME)
|
||||
if (process.platform === "win32") return new Set(WIN32_HOME)
|
||||
return new Set()
|
||||
}
|
||||
|
||||
/** Absolute paths that should never be watched, stated, or scanned. */
|
||||
export function paths(): string[] {
|
||||
if (process.platform === "darwin")
|
||||
return [
|
||||
...DARWIN_HOME.map((n) => path.join(home, n)),
|
||||
...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)),
|
||||
...DARWIN_ROOT,
|
||||
]
|
||||
if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
|
||||
return []
|
||||
}
|
||||
|
||||
export * as Protected from "./protected"
|
||||
|
|
@ -1,484 +0,0 @@
|
|||
import path from "path"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
|
||||
import { which } from "@/util/which"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "ripgrep" })
|
||||
const VERSION = "15.1.0"
|
||||
const PLATFORM = {
|
||||
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
|
||||
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
|
||||
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
|
||||
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
|
||||
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
|
||||
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
|
||||
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
|
||||
} as const
|
||||
|
||||
const TimeStats = Schema.Struct({
|
||||
secs: NonNegativeInt,
|
||||
nanos: NonNegativeInt,
|
||||
human: Schema.String,
|
||||
})
|
||||
|
||||
const Stats = Schema.Struct({
|
||||
elapsed: TimeStats,
|
||||
searches: NonNegativeInt,
|
||||
searches_with_match: NonNegativeInt,
|
||||
bytes_searched: NonNegativeInt,
|
||||
bytes_printed: NonNegativeInt,
|
||||
matched_lines: NonNegativeInt,
|
||||
matches: NonNegativeInt,
|
||||
})
|
||||
|
||||
const PathText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
const Begin = Schema.Struct({
|
||||
type: Schema.Literal("begin"),
|
||||
data: Schema.Struct({
|
||||
path: PathText,
|
||||
}),
|
||||
})
|
||||
|
||||
export const SearchMatch = Schema.Struct({
|
||||
path: PathText,
|
||||
lines: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
line_number: NonNegativeInt,
|
||||
absolute_offset: NonNegativeInt,
|
||||
submatches: Schema.Array(
|
||||
Schema.Struct({
|
||||
match: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const Match = Schema.Struct({
|
||||
type: Schema.Literal("match"),
|
||||
data: SearchMatch,
|
||||
})
|
||||
|
||||
const End = Schema.Struct({
|
||||
type: Schema.Literal("end"),
|
||||
data: Schema.Struct({
|
||||
path: PathText,
|
||||
binary_offset: Schema.NullOr(NonNegativeInt),
|
||||
stats: Stats,
|
||||
}),
|
||||
})
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
type: Schema.Literal("summary"),
|
||||
data: Schema.Struct({
|
||||
elapsed_total: TimeStats,
|
||||
stats: Stats,
|
||||
}),
|
||||
})
|
||||
|
||||
const Result = Schema.Union([Begin, Match, End, Summary])
|
||||
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
|
||||
|
||||
export type Result = Schema.Schema.Type<typeof Result>
|
||||
export type Match = Schema.Schema.Type<typeof Match>
|
||||
export type Item = Match["data"]
|
||||
export type Begin = Schema.Schema.Type<typeof Begin>
|
||||
export type End = Schema.Schema.Type<typeof End>
|
||||
export type Summary = Schema.Schema.Type<typeof Summary>
|
||||
export type Row = Match["data"]
|
||||
|
||||
export interface SearchResult {
|
||||
items: Item[]
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export interface FilesInput {
|
||||
cwd: string
|
||||
glob?: string[]
|
||||
hidden?: boolean
|
||||
follow?: boolean
|
||||
maxDepth?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface SearchInput {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
file?: string[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface TreeInput {
|
||||
cwd: string
|
||||
limit?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
|
||||
readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
|
||||
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
function env() {
|
||||
const env = sanitizedProcessEnv()
|
||||
delete env.RIPGREP_CONFIG_PATH
|
||||
return env
|
||||
}
|
||||
|
||||
function aborted(signal?: AbortSignal) {
|
||||
const err = signal?.reason
|
||||
if (err instanceof Error) return err
|
||||
const out = new Error("Aborted")
|
||||
out.name = "AbortError"
|
||||
return out
|
||||
}
|
||||
|
||||
function waitForAbort(signal?: AbortSignal) {
|
||||
if (!signal) return Effect.never
|
||||
if (signal.aborted) return Effect.fail(aborted(signal))
|
||||
return Effect.callback<never, Error>((resume) => {
|
||||
const onabort = () => resume(Effect.fail(aborted(signal)))
|
||||
signal.addEventListener("abort", onabort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", onabort))
|
||||
})
|
||||
}
|
||||
|
||||
function error(stderr: string, code: number) {
|
||||
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
|
||||
err.name = "RipgrepError"
|
||||
return err
|
||||
}
|
||||
|
||||
function clean(file: string) {
|
||||
return path.normalize(file.replace(/^\.[\\/]/, ""))
|
||||
}
|
||||
|
||||
function row(data: Row): Row {
|
||||
return {
|
||||
...data,
|
||||
path: {
|
||||
...data.path,
|
||||
text: clean(data.path.text),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parse(line: string) {
|
||||
return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
|
||||
}
|
||||
|
||||
function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
|
||||
Queue.failCauseUnsafe(queue, Cause.fail(err))
|
||||
}
|
||||
|
||||
function filesArgs(input: FilesInput) {
|
||||
const args = ["--no-config", "--files", "--glob=!.git/*"]
|
||||
if (input.follow) args.push("--follow")
|
||||
if (input.hidden !== false) args.push("--hidden")
|
||||
if (input.hidden === false) args.push("--glob=!.*")
|
||||
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
||||
if (input.glob) {
|
||||
for (const glob of input.glob) args.push(`--glob=${glob}`)
|
||||
}
|
||||
args.push(".")
|
||||
return args
|
||||
}
|
||||
|
||||
function searchArgs(input: SearchInput) {
|
||||
const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
|
||||
if (input.follow) args.push("--follow")
|
||||
if (input.glob) {
|
||||
for (const glob of input.glob) args.push(`--glob=${glob}`)
|
||||
}
|
||||
if (input.limit) args.push(`--max-count=${input.limit}`)
|
||||
args.push("--", input.pattern, ...(input.file ?? ["."]))
|
||||
return args
|
||||
}
|
||||
|
||||
function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
|
||||
return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
|
||||
}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner | HttpClient.HttpClient> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const [stdout, stderr, code] = yield* Effect.all(
|
||||
[
|
||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { stdout, stderr, code }
|
||||
}, Effect.scoped)
|
||||
|
||||
const extract = Effect.fnUntraced(function* (
|
||||
archive: string,
|
||||
config: (typeof PLATFORM)[keyof typeof PLATFORM],
|
||||
target: string,
|
||||
) {
|
||||
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
|
||||
|
||||
if (config.extension === "zip") {
|
||||
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
|
||||
const result = yield* run(shell, [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
|
||||
])
|
||||
if (result.code !== 0) {
|
||||
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
|
||||
}
|
||||
}
|
||||
|
||||
if (config.extension === "tar.gz") {
|
||||
const result = yield* run("tar", ["-xzf", archive, "-C", dir])
|
||||
if (result.code !== 0) {
|
||||
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = path.join(
|
||||
dir,
|
||||
`ripgrep-${VERSION}-${config.platform}`,
|
||||
process.platform === "win32" ? "rg.exe" : "rg",
|
||||
)
|
||||
if (!(yield* fs.isFile(extracted))) {
|
||||
return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
|
||||
}
|
||||
|
||||
yield* fs.copyFile(extracted, target)
|
||||
if (process.platform === "win32") return
|
||||
yield* fs.chmod(target, 0o755)
|
||||
}, Effect.scoped)
|
||||
|
||||
const filepath = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
|
||||
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
|
||||
|
||||
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
|
||||
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
|
||||
|
||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
const config = PLATFORM[platformKey]
|
||||
if (!config) {
|
||||
return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
|
||||
}
|
||||
|
||||
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
|
||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
|
||||
const archive = path.join(Global.Path.bin, filename)
|
||||
|
||||
log.info("downloading ripgrep", { url })
|
||||
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
|
||||
|
||||
const bytes = yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((response) => response.arrayBuffer),
|
||||
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
|
||||
)
|
||||
if (bytes.byteLength === 0) {
|
||||
return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
|
||||
}
|
||||
|
||||
yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
|
||||
yield* extract(archive, config, target)
|
||||
yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
|
||||
return target
|
||||
}),
|
||||
)
|
||||
|
||||
const check = Effect.fnUntraced(function* (cwd: string) {
|
||||
if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
|
||||
return yield* Effect.fail(
|
||||
Object.assign(new Error(`No such file or directory: '${cwd}'`), {
|
||||
code: "ENOENT",
|
||||
errno: -2,
|
||||
path: cwd,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
|
||||
const binary = yield* filepath
|
||||
return ChildProcess.make(binary, args, {
|
||||
cwd,
|
||||
env: env(),
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
})
|
||||
})
|
||||
|
||||
const files: Interface["files"] = (input) =>
|
||||
Stream.callback<string, PlatformError | Error>((queue) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forkScoped(
|
||||
Effect.gen(function* () {
|
||||
yield* check(input.cwd)
|
||||
const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
|
||||
const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
|
||||
const stdout = yield* Stream.decodeText(handle.stdout).pipe(
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const code = yield* raceAbort(handle.exitCode, input.signal)
|
||||
yield* Fiber.join(stdout)
|
||||
if (code === 0 || code === 1) {
|
||||
Queue.endUnsafe(queue)
|
||||
return
|
||||
}
|
||||
fail(queue, error(yield* Fiber.join(stderr), code))
|
||||
}).pipe(
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
fail(queue, err)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
|
||||
yield* check(input.cwd)
|
||||
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
|
||||
|
||||
const [items, stderr, code] = yield* Effect.all(
|
||||
[
|
||||
Stream.decodeText(handle.stdout).pipe(
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
Stream.mapEffect(parse),
|
||||
Stream.filter((item): item is Match => item.type === "match"),
|
||||
Stream.map((item) => row(item.data)),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
if (code !== 0 && code !== 1 && code !== 2) {
|
||||
return yield* Effect.fail(error(stderr, code))
|
||||
}
|
||||
|
||||
return {
|
||||
items: code === 1 ? [] : items,
|
||||
partial: code === 2,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return yield* raceAbort(program, input.signal)
|
||||
})
|
||||
|
||||
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
|
||||
log.info("tree", input)
|
||||
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
|
||||
|
||||
interface Node {
|
||||
name: string
|
||||
children: Map<string, Node>
|
||||
}
|
||||
|
||||
function child(node: Node, name: string) {
|
||||
const item = node.children.get(name)
|
||||
if (item) return item
|
||||
const next = { name, children: new Map() }
|
||||
node.children.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function count(node: Node): number {
|
||||
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
|
||||
}
|
||||
|
||||
const root: Node = { name: "", children: new Map() }
|
||||
for (const file of list) {
|
||||
if (file.includes(".opencode")) continue
|
||||
const parts = file.split(path.sep)
|
||||
if (parts.length < 2) continue
|
||||
let node = root
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
node = child(node, part)
|
||||
}
|
||||
}
|
||||
|
||||
const total = count(root)
|
||||
const limit = input.limit ?? total
|
||||
const lines: string[] = []
|
||||
const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((node) => ({ node, path: node.name }))
|
||||
|
||||
let used = 0
|
||||
for (let i = 0; i < queue.length && used < limit; i++) {
|
||||
const item = queue[i]
|
||||
lines.push(item.path)
|
||||
used++
|
||||
queue.push(
|
||||
...Array.from(item.node.children.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((node) => ({ node, path: `${item.path}/${node.name}` })),
|
||||
)
|
||||
}
|
||||
|
||||
if (total > used) lines.push(`[${total - used} truncated]`)
|
||||
return lines.join("\n")
|
||||
})
|
||||
|
||||
return Service.of({ files, tree, search })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Ripgrep from "./ripgrep"
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
import { Cause, Effect, Layer, Context, Schema } from "effect"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { readdir, realpath } from "fs/promises"
|
||||
import path from "path"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Git } from "@/git"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Config } from "@/config/config"
|
||||
import { FileIgnore } from "./ignore"
|
||||
import { Protected } from "./protected"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
const log = Log.create({ service: "file.watcher" })
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
|
||||
export const Event = {
|
||||
Updated: EventV2.define({
|
||||
type: "file.watcher.updated",
|
||||
schema: {
|
||||
file: Schema.String,
|
||||
event: Schema.Literals(["add", "change", "unlink"]),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
} catch (error) {
|
||||
log.error("failed to load watcher binding", { error })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function getBackend() {
|
||||
if (process.platform === "win32") return "windows"
|
||||
if (process.platform === "darwin") return "fs-events"
|
||||
if (process.platform === "linux") return "inotify"
|
||||
}
|
||||
|
||||
function protecteds(dir: string) {
|
||||
return Protected.paths().filter((item) => {
|
||||
const rel = path.relative(dir, item)
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||
})
|
||||
}
|
||||
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const git = yield* Git.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("FileWatcher.state")(
|
||||
function* () {
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
||||
|
||||
const ctx = yield* InstanceState.context
|
||||
|
||||
log.info("init", { directory: ctx.directory })
|
||||
|
||||
const backend = getBackend()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
|
||||
return
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return
|
||||
|
||||
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
||||
)
|
||||
|
||||
const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
|
||||
// if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "add" }))
|
||||
if (evt.type === "update") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "change" }))
|
||||
if (evt.type === "delete") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "unlink" }))
|
||||
}
|
||||
})
|
||||
|
||||
const subscribe = (dir: string, ignore: string[]) => {
|
||||
const pending = w.subscribe(dir, cb, { ignore, backend })
|
||||
return Effect.gen(function* () {
|
||||
const sub = yield* Effect.promise(() => pending)
|
||||
subs.push(sub)
|
||||
}).pipe(
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const cfg = yield* config.get()
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* Effect.forkScoped(
|
||||
subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]),
|
||||
)
|
||||
}
|
||||
|
||||
if (ctx.project.vcs === "git") {
|
||||
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: ctx.worktree,
|
||||
})
|
||||
const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
|
||||
const vcsDir = resolved ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) : undefined
|
||||
if (
|
||||
vcsDir &&
|
||||
!cfgIgnores.includes(".git") &&
|
||||
!cfgIgnores.includes(vcsDir) &&
|
||||
(!resolved || !cfgIgnores.includes(resolved))
|
||||
) {
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
)
|
||||
yield* Effect.forkScoped(subscribe(vcsDir, ignore))
|
||||
}
|
||||
}
|
||||
},
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("FileWatcher.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
)
|
||||
|
||||
export * as FileWatcher from "./watcher"
|
||||
|
|
@ -2,7 +2,7 @@ import { Npm } from "@opencode-ai/core/npm"
|
|||
import type { InstanceContext } from "../project/instance-context"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { which } from "../util/which"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
|
||||
export interface Context extends Pick<InstanceContext, "directory" | "worktree"> {
|
||||
experimentalOxfmt: boolean
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Filesystem } from "@/util/filesystem"
|
|||
import type { InstanceContext } from "../project/instance-context"
|
||||
import { Archive } from "@/util/archive"
|
||||
import { Process } from "@/util/process"
|
||||
import { which } from "../util/which"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
import { Module } from "@opencode-ai/core/util/module"
|
||||
import { spawn } from "./launch"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import path from "path"
|
|||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect, Layer, Context, Option, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
|
||||
export const Tokens = Schema.Struct({
|
||||
|
|
@ -59,7 +59,7 @@ export const use = serviceUse(Service)
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const flock = yield* EffectFlock.Service
|
||||
|
||||
const read = Effect.fn("McpAuth.read")(function* () {
|
||||
|
|
@ -166,9 +166,6 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export * as McpAuth from "./auth"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
|||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
import { McpOAuthCallback } from "./oauth-callback"
|
||||
import { McpAuth } from "./auth"
|
||||
|
|
@ -975,7 +975,7 @@ export const defaultLayer = layer.pipe(
|
|||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
)
|
||||
|
||||
export * as MCP from "."
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Effect, Schema } from "effect"
|
||||
import * as path from "path"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import * as Bom from "../util/bom"
|
||||
|
||||
|
|
@ -519,7 +519,7 @@ export const applyHunksToFiles = Effect.fn("Patch.applyHunksToFiles")(function*
|
|||
return yield* Effect.fail(new Error("No files were modified."))
|
||||
}
|
||||
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
const added: string[] = []
|
||||
const modified: string[] = []
|
||||
|
|
@ -574,7 +574,7 @@ type MaybeApplyPatchVerifiedResult =
|
|||
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
|
||||
| { type: MaybeApplyPatchVerified.NotApplyPatch }
|
||||
|
||||
// Effectful verified-parse: needs AppFileSystem.Service to read existing files
|
||||
// Effectful verified-parse: needs FSUtil.Service to read existing files
|
||||
export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatchVerified")(function* (
|
||||
argv: string[],
|
||||
cwd: string,
|
||||
|
|
@ -596,7 +596,7 @@ export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatc
|
|||
|
||||
switch (result.type) {
|
||||
case MaybeApplyPatch.Body: {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const args = result.args
|
||||
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
|
||||
const changes = new Map<string, ApplyPatchFileChange>()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { Plugin } from "../plugin"
|
||||
import { Format } from "../format"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { File } from "../file"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import * as Project from "./project"
|
||||
import * as Vcs from "./vcs"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
|
|
@ -23,8 +21,6 @@ export const layer = Layer.effect(
|
|||
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
|
||||
// so it can depend on bootstrap without importing this implementation graph.
|
||||
const config = yield* Config.Service
|
||||
const file = yield* File.Service
|
||||
const fileWatcher = yield* FileWatcher.Service
|
||||
const format = yield* Format.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
|
@ -44,7 +40,7 @@ export const layer = Layer.effect(
|
|||
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||
// its per-instance state scope. We just await materialization here.
|
||||
yield* Effect.forEach(
|
||||
[reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
|
||||
[reference, lsp, shareNext, format, vcs, snapshot, project],
|
||||
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||
|
|
@ -57,8 +53,6 @@ export const layer = Layer.effect(
|
|||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide([
|
||||
Config.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { LocalContext } from "@/util/local-context"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import type * as Project from "./project"
|
||||
|
||||
export interface InstanceContext {
|
||||
|
|
@ -16,9 +16,9 @@ export const context = LocalContext.create<InstanceContext>("instance")
|
|||
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
||||
*/
|
||||
export function containsPath(filepath: string, ctx: InstanceContext): boolean {
|
||||
if (AppFileSystem.contains(ctx.directory, filepath)) return true
|
||||
if (FSUtil.contains(ctx.directory, filepath)) return true
|
||||
// Non-git projects set worktree to "/" which would match ANY absolute path.
|
||||
// Skip worktree check in this case to preserve external_directory permissions.
|
||||
if (ctx.worktree === "/") return false
|
||||
return AppFileSystem.contains(ctx.worktree, filepath)
|
||||
return FSUtil.contains(ctx.worktree, filepath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
|||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { type InstanceContext } from "./instance-context"
|
||||
import { InstanceBootstrap } from "./bootstrap-service"
|
||||
|
|
@ -104,7 +104,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
|||
})
|
||||
|
||||
const load = (input: LoadInput): Effect.Effect<InstanceContext> => {
|
||||
const directory = AppFileSystem.resolve(input.directory)
|
||||
const directory = FSUtil.resolve(input.directory)
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = cache.get(directory)
|
||||
|
|
@ -122,7 +122,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
|||
}
|
||||
|
||||
const reload = (input: LoadInput): Effect.Effect<InstanceContext> => {
|
||||
const directory = AppFileSystem.resolve(input.directory)
|
||||
const directory = FSUtil.resolve(input.directory)
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const previous = cache.get(directory)
|
||||
|
|
@ -153,7 +153,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
|||
})
|
||||
|
||||
const disposeDirectory = Effect.fn("InstanceStore.disposeDirectory")(function* (input: string) {
|
||||
const directory = AppFileSystem.resolve(input)
|
||||
const directory = FSUtil.resolve(input)
|
||||
const entry = cache.get(directory)
|
||||
if (!entry) return
|
||||
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
|||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { which } from "../util/which"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
import { Command } from "@/command"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
|
|
@ -135,7 +135,7 @@ type GitResult = { code: number; text: string; stderr: string }
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const projectV2 = yield* ProjectV2.Service
|
||||
|
|
@ -326,7 +326,7 @@ export const layer = Layer.effect(
|
|||
|
||||
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
|
||||
const base64 = Buffer.from(buffer).toString("base64")
|
||||
const mime = AppFileSystem.mimeType(shortest)
|
||||
const mime = FSUtil.mimeType(shortest)
|
||||
const url = `data:${mime};base64,${base64}`
|
||||
yield* update({ projectID: input.id, icon: { url } }).pipe(
|
||||
Effect.catchTag("Project.NotFoundError", () => Effect.void),
|
||||
|
|
@ -468,7 +468,7 @@ export const defaultLayer = layer.pipe(
|
|||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Git } from "@/git"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
|
|
@ -328,9 +328,9 @@ export const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Serv
|
|||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
|
||||
const unsubscribe = yield* events.listen((event) => {
|
||||
if (event.type !== FileWatcher.Event.Updated.type || event.location?.directory !== ctx.directory)
|
||||
if (event.type !== Watcher.Event.Updated.type || event.location?.directory !== ctx.directory)
|
||||
return Effect.void
|
||||
const data = event.data as EventV2.Data<typeof FileWatcher.Event.Updated>
|
||||
const data = event.data as EventV2.Data<typeof Watcher.Event.Updated>
|
||||
if (!data.file.endsWith("HEAD")) return Effect.void
|
||||
return Effect.gen(function* () {
|
||||
const next = yield* get()
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { Effect, Layer, Context, Schema, Types } from "effect"
|
|||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectPromise } from "@/effect/promise"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { ProviderTransform } from "./transform"
|
||||
|
|
@ -1183,7 +1183,7 @@ function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelI
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const config = yield* Config.Service
|
||||
const auth = yield* Auth.Service
|
||||
const env = yield* Env.Service
|
||||
|
|
@ -1861,7 +1861,7 @@ export const layer = Layer.effect(
|
|||
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import path from "path"
|
||||
import { Effect, Context, Layer, Scope } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigReference } from "@/config/reference"
|
||||
|
|
@ -83,11 +83,11 @@ function branchLabel(branch: string | undefined) {
|
|||
|
||||
function normalizedTarget(target?: string) {
|
||||
if (!target) return
|
||||
return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
|
||||
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
||||
}
|
||||
|
||||
function containsReferencePath(referencePath: string, target: string) {
|
||||
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
||||
return FSUtil.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
||||
}
|
||||
|
||||
function uniqueGitReferences(references: Resolved[]) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Git } from "@/git"
|
||||
import {
|
||||
|
|
@ -168,7 +168,7 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi
|
|||
const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* (
|
||||
input: EnsureInput,
|
||||
services: {
|
||||
fs: AppFileSystem.Interface
|
||||
fs: FSUtil.Interface
|
||||
git: Git.Interface
|
||||
},
|
||||
) {
|
||||
|
|
@ -298,10 +298,10 @@ const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(funct
|
|||
)
|
||||
})
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
return Service.of({
|
||||
|
|
@ -313,7 +313,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Serv
|
|||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { File } from "@/file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
|
@ -37,6 +38,47 @@ export const FindSymbolQuery = Schema.Struct({
|
|||
query: Schema.String,
|
||||
})
|
||||
|
||||
export const LegacyEntry = Schema.Struct({
|
||||
name: Schema.String,
|
||||
path: Schema.String,
|
||||
absolute: Schema.String,
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
ignored: Schema.Boolean,
|
||||
}).annotate({ identifier: "FileNode" })
|
||||
|
||||
export const LegacyContent = Schema.Struct({
|
||||
type: Schema.Literals(["text", "binary"]),
|
||||
content: Schema.String,
|
||||
diff: Schema.optional(Schema.String),
|
||||
patch: Schema.optional(
|
||||
Schema.Struct({
|
||||
oldFileName: Schema.String,
|
||||
newFileName: Schema.String,
|
||||
oldHeader: Schema.optional(Schema.String),
|
||||
newHeader: Schema.optional(Schema.String),
|
||||
hunks: Schema.Array(
|
||||
Schema.Struct({
|
||||
oldStart: NonNegativeInt,
|
||||
oldLines: NonNegativeInt,
|
||||
newStart: NonNegativeInt,
|
||||
newLines: NonNegativeInt,
|
||||
lines: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
index: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
encoding: Schema.optional(Schema.Literal("base64")),
|
||||
mimeType: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "FileContent" })
|
||||
|
||||
export const LegacyStatus = Schema.Struct({
|
||||
path: Schema.String,
|
||||
added: NonNegativeInt,
|
||||
removed: NonNegativeInt,
|
||||
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||
}).annotate({ identifier: "File" })
|
||||
|
||||
export const FilePaths = {
|
||||
findText: "/find",
|
||||
findFile: "/find/file",
|
||||
|
|
@ -82,7 +124,7 @@ export const FileApi = HttpApi.make("file")
|
|||
),
|
||||
HttpApiEndpoint.get("list", FilePaths.list, {
|
||||
query: FileQuery,
|
||||
success: described(Schema.Array(File.Node), "Files and directories"),
|
||||
success: described(Schema.Array(LegacyEntry), "Files and directories"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "file.list",
|
||||
|
|
@ -92,7 +134,7 @@ export const FileApi = HttpApi.make("file")
|
|||
),
|
||||
HttpApiEndpoint.get("content", FilePaths.content, {
|
||||
query: FileQuery,
|
||||
success: described(File.Content, "File content"),
|
||||
success: described(LegacyContent, "File content"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "file.read",
|
||||
|
|
@ -102,7 +144,7 @@ export const FileApi = HttpApi.make("file")
|
|||
),
|
||||
HttpApiEndpoint.get("status", FilePaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(File.Info), "File status"),
|
||||
success: described(Schema.Array(LegacyStatus), "File status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "file.status",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
|
@ -21,7 +21,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
|
|||
.add(
|
||||
HttpApiEndpoint.get("read", "/api/fs/read", {
|
||||
query: ReadQuery,
|
||||
success: LocationFileSystem.Content,
|
||||
success: FileSystem.Content,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
|
|
@ -35,7 +35,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
|
|||
.add(
|
||||
HttpApiEndpoint.get("list", "/api/fs/list", {
|
||||
query: ListQuery,
|
||||
success: Schema.Array(LocationFileSystem.Entry),
|
||||
success: Schema.Array(FileSystem.Entry),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -42,7 +42,7 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service<
|
|||
| PluginBoot.Service
|
||||
| PermissionV2.Service
|
||||
| ProjectReference.Service
|
||||
| LocationFileSystem.Service
|
||||
| FileSystem.Service
|
||||
}
|
||||
>()("@opencode/ExperimentalHttpApiV2Location") {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { File } from "@/file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { Effect } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
|
||||
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* File.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
|
||||
const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(locations.get({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
|
||||
)
|
||||
})
|
||||
|
||||
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
|
||||
return (yield* ripgrep
|
||||
|
|
@ -19,12 +29,15 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
|
||||
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
|
||||
}) {
|
||||
return yield* svc.search({
|
||||
query: ctx.query.query,
|
||||
limit: ctx.query.limit ?? 10,
|
||||
dirs: ctx.query.dirs !== "false",
|
||||
type: ctx.query.type,
|
||||
})
|
||||
return (yield* filesystem(
|
||||
FileSystem.Service.use((fs) =>
|
||||
fs.find({
|
||||
query: ctx.query.query,
|
||||
limit: ctx.query.limit ?? 10,
|
||||
type: ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined),
|
||||
}),
|
||||
),
|
||||
)).map((item) => item.path)
|
||||
})
|
||||
|
||||
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
|
||||
|
|
@ -32,15 +45,42 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||
})
|
||||
|
||||
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
|
||||
return yield* svc.list(ctx.query.path)
|
||||
const directory = (yield* InstanceState.context).directory
|
||||
return yield* filesystem(
|
||||
FileSystem.Service.use((fs) =>
|
||||
fs.list({ path: RelativePath.make(ctx.query.path) }).pipe(
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
name: path.basename(item.path),
|
||||
path: item.path,
|
||||
absolute: path.join(directory, item.path),
|
||||
type: item.type,
|
||||
ignored: fs.isIgnored(item.path, item.type),
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
|
||||
return yield* svc.read(ctx.query.path)
|
||||
const directory = (yield* InstanceState.context).directory
|
||||
const file = path.resolve(directory, ctx.query.path)
|
||||
if (!FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
if (!(yield* FSUtil.Service.use((fs) => fs.existsSafe(file)))) return { type: "text" as const, content: "" }
|
||||
return yield* filesystem(
|
||||
FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
|
||||
).pipe(
|
||||
Effect.map((item) => ({
|
||||
type: item.type,
|
||||
content: item.type === "text" ? item.content.trim() : item.content,
|
||||
...(item.type === "binary" ? { encoding: item.encoding, mimeType: item.mime } : {}),
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
const status = Effect.fn("FileHttpApi.status")(function* () {
|
||||
return yield* svc.status()
|
||||
return []
|
||||
})
|
||||
|
||||
return handlers
|
||||
|
|
@ -51,4 +91,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||
.handle("content", content)
|
||||
.handle("status", status)
|
||||
}),
|
||||
)
|
||||
).pipe(Layer.provide(LocationServiceMap.layer))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
|
|
@ -6,7 +6,7 @@ import { InstanceHttpApi } from "../../api"
|
|||
export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers
|
||||
.handle("read", (ctx) => LocationFileSystem.Service.use((fs) => fs.read(ctx.query)))
|
||||
.handle("list", (ctx) => LocationFileSystem.Service.use((fs) => fs.list(ctx.query)))
|
||||
.handle("read", (ctx) => FileSystem.Service.use((fs) => fs.read(ctx.query)))
|
||||
.handle("list", (ctx) => FileSystem.Service.use((fs) => fs.list(ctx.query)))
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,16 +9,14 @@ import {
|
|||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Account } from "@/account/account"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Auth } from "@/auth"
|
||||
import { Config } from "@/config/config"
|
||||
import { Command } from "@/command"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
import { File } from "@/file"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { Format } from "@/format"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
|
|
@ -166,7 +164,7 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe
|
|||
|
||||
const uiRoute = HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
yield* router.add("*", "/*", (request) =>
|
||||
|
|
@ -198,8 +196,6 @@ export function createRoutes(
|
|||
Auth.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
|
|
@ -232,7 +228,7 @@ export function createRoutes(
|
|||
Vcs.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.appLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
FetchHttpClient.layer,
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createHash } from "node:crypto"
|
||||
|
|
@ -53,7 +53,7 @@ function notFound() {
|
|||
}
|
||||
|
||||
function embeddedUIResponse(file: string, body: Uint8Array) {
|
||||
const mime = AppFileSystem.mimeType(file)
|
||||
const mime = FSUtil.mimeType(file)
|
||||
const headers = new Headers({ "content-type": mime })
|
||||
if (mime.startsWith("text/html")) {
|
||||
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
|
||||
|
|
@ -63,7 +63,7 @@ function embeddedUIResponse(file: string, body: Uint8Array) {
|
|||
|
||||
export function serveEmbeddedUIEffect(
|
||||
requestPath: string,
|
||||
fs: AppFileSystem.Interface,
|
||||
fs: FSUtil.Interface,
|
||||
embeddedWebUI: Record<string, string>,
|
||||
) {
|
||||
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
|
|
@ -77,7 +77,7 @@ export function serveEmbeddedUIEffect(
|
|||
|
||||
export function serveUIEffect(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean },
|
||||
services: { fs: FSUtil.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const embeddedWebUI = yield* Effect.promise(() => embeddedUI(services.disableEmbeddedWebUi))
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Config } from "@/config/config"
|
|||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
|
|
@ -31,14 +31,14 @@ function extract(messages: SessionLegacy.WithParts[]) {
|
|||
|
||||
export interface Interface {
|
||||
readonly clear: (messageID: MessageID) => Effect.Effect<void>
|
||||
readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
|
||||
readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
|
||||
readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
|
||||
readonly systemPaths: () => Effect.Effect<Set<string>, FSUtil.Error>
|
||||
readonly system: () => Effect.Effect<string[], FSUtil.Error>
|
||||
readonly find: (dir: string) => Effect.Effect<string | undefined, FSUtil.Error>
|
||||
readonly resolve: (
|
||||
messages: SessionLegacy.WithParts[],
|
||||
filepath: string,
|
||||
messageID: MessageID,
|
||||
) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
|
||||
) => Effect.Effect<{ filepath: string; content: string }[], FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
|
||||
|
|
@ -46,12 +46,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
|
|||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
|
||||
FSUtil.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
|
|
@ -225,7 +225,7 @@ export const layer: Layer.Layer<
|
|||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import { SessionStatus } from "./status"
|
|||
import { LLM } from "./llm"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Image } from "@/image/image"
|
||||
import { decodeDataUrl } from "@/util/data-url"
|
||||
|
|
@ -112,7 +112,7 @@ export const layer = Layer.effect(
|
|||
const commands = yield* Command.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
|
@ -178,7 +178,7 @@ export const layer = Layer.effect(
|
|||
|
||||
const target = name.slice(slash + 1)
|
||||
const targetPath = path.resolve(reference.path, target)
|
||||
if (!AppFileSystem.contains(reference.path, targetPath)) {
|
||||
if (!FSUtil.contains(reference.path, targetPath)) {
|
||||
parts.push(
|
||||
referenceTextPart({
|
||||
reference,
|
||||
|
|
@ -777,7 +777,7 @@ export const layer = Layer.effect(
|
|||
|
||||
const reference = yield* references.get(name.slice(0, slash))
|
||||
if (!reference || reference.kind === "invalid") return
|
||||
if (!AppFileSystem.contains(reference.path, filepath)) return
|
||||
if (!FSUtil.contains(reference.path, filepath)) return
|
||||
|
||||
const target = path.relative(reference.path, filepath).split(path.sep).join("/")
|
||||
if (!target || target.startsWith("../") || target === "..") return
|
||||
|
|
@ -1343,7 +1343,7 @@ export const layer = Layer.effect(
|
|||
const isLastStep = step >= maxSteps
|
||||
msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
|
||||
Effect.provideService(RuntimeFlags.Service, flags),
|
||||
Effect.provideService(AppFileSystem.Service, fsys),
|
||||
Effect.provideService(FSUtil.Service, fsys),
|
||||
Effect.provideService(Session.Service, sessions),
|
||||
)
|
||||
|
||||
|
|
@ -1656,7 +1656,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import path from "path"
|
|||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { PartID } from "./schema"
|
||||
|
|
@ -18,7 +18,7 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
|
|||
session: Session.Info
|
||||
}) {
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const sessions = yield* Session.Service
|
||||
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
|
||||
if (!userMessage) return input.messages
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { which } from "@/util/which"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node"
|
|||
import { Effect, Layer, Path, Schema, Context } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
|
|
@ -24,92 +24,91 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
|
||||
|
||||
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* AppFileSystem.Service
|
||||
const path = yield* Path.Path
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
const cache = path.join(Global.Path.cache, "skills")
|
||||
export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const log = Log.create({ service: "skill-discovery" })
|
||||
const fs = yield* FSUtil.Service
|
||||
const path = yield* Path.Path
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
const cache = path.join(Global.Path.cache, "skills")
|
||||
|
||||
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
|
||||
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
||||
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
|
||||
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
||||
|
||||
return yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((res) => res.arrayBuffer),
|
||||
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
|
||||
Effect.as(true),
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to download", { url, err })
|
||||
return false
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
|
||||
const base = url.endsWith("/") ? url : `${url}/`
|
||||
const index = new URL("index.json", base).href
|
||||
const host = base.slice(0, -1)
|
||||
|
||||
log.info("fetching index", { url: index })
|
||||
|
||||
const data = yield* HttpClientRequest.get(index).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
http.execute,
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to fetch index", { url: index, err })
|
||||
return null
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (!data) return []
|
||||
|
||||
const list = data.skills.filter((skill) => {
|
||||
if (!skill.files.includes("SKILL.md")) {
|
||||
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
||||
return yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((res) => res.arrayBuffer),
|
||||
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
|
||||
Effect.as(true),
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to download", { url, err })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const dirs = yield* Effect.forEach(
|
||||
list,
|
||||
(skill) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(cache, skill.name)
|
||||
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
|
||||
const base = url.endsWith("/") ? url : `${url}/`
|
||||
const index = new URL("index.json", base).href
|
||||
const host = base.slice(0, -1)
|
||||
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||
{
|
||||
concurrency: fileConcurrency,
|
||||
},
|
||||
)
|
||||
log.info("fetching index", { url: index })
|
||||
|
||||
const md = path.join(root, "SKILL.md")
|
||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||
}),
|
||||
{ concurrency: skillConcurrency },
|
||||
)
|
||||
const data = yield* HttpClientRequest.get(index).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
http.execute,
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to fetch index", { url: index, err })
|
||||
return null
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return dirs.filter((dir): dir is string => dir !== null)
|
||||
if (!data) return []
|
||||
|
||||
const list = data.skills.filter((skill) => {
|
||||
if (!skill.files.includes("SKILL.md")) {
|
||||
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return Service.of({ pull })
|
||||
}),
|
||||
)
|
||||
const dirs = yield* Effect.forEach(
|
||||
list,
|
||||
(skill) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(cache, skill.name)
|
||||
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||
{
|
||||
concurrency: fileConcurrency,
|
||||
},
|
||||
)
|
||||
|
||||
const md = path.join(root, "SKILL.md")
|
||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||
}),
|
||||
{ concurrency: skillConcurrency },
|
||||
)
|
||||
|
||||
return dirs.filter((dir): dir is string => dir !== null)
|
||||
})
|
||||
|
||||
return Service.of({ pull })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
|
|||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Permission } from "@/permission"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigMarkdown } from "@/config/markdown"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
|
@ -173,7 +173,7 @@ const scan = Effect.fnUntraced(function* (
|
|||
const discoverSkills = Effect.fnUntraced(function* (
|
||||
config: Config.Interface,
|
||||
discovery: Discovery.Interface,
|
||||
fsys: AppFileSystem.Interface,
|
||||
fsys: FSUtil.Interface,
|
||||
global: Global.Interface,
|
||||
disableExternalSkills: boolean,
|
||||
disableClaudeCodeSkills: boolean,
|
||||
|
|
@ -253,7 +253,7 @@ export const layer = Layer.effect(
|
|||
const discovery = yield* Discovery.Service
|
||||
const config = yield* Config.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const discovered = yield* InstanceState.make(
|
||||
|
|
@ -322,7 +322,7 @@ export const defaultLayer = layer.pipe(
|
|||
Layer.provide(Discovery.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,18 +1,14 @@
|
|||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Git } from "@/git"
|
||||
|
||||
const log = Log.create({ service: "storage" })
|
||||
|
||||
type Migration = (
|
||||
dir: string,
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
) => Effect.Effect<void, AppFileSystem.Error>
|
||||
type Migration = (dir: string, fs: FSUtil.Interface, git: Git.Interface) => Effect.Effect<void, FSUtil.Error>
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("NotFoundError", {
|
||||
message: Schema.String,
|
||||
|
|
@ -22,7 +18,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Not
|
|||
}
|
||||
}
|
||||
|
||||
export type Error = AppFileSystem.Error | NotFoundError
|
||||
export type Error = FSUtil.Error | NotFoundError
|
||||
|
||||
const RootFile = Schema.Struct({
|
||||
path: Schema.optional(
|
||||
|
|
@ -57,11 +53,11 @@ const decodeMessage = Schema.decodeUnknownOption(MessageFile)
|
|||
const decodeSummary = Schema.decodeUnknownOption(SummaryFile)
|
||||
|
||||
export interface Interface {
|
||||
readonly remove: (key: string[]) => Effect.Effect<void, AppFileSystem.Error>
|
||||
readonly remove: (key: string[]) => Effect.Effect<void, FSUtil.Error>
|
||||
readonly read: <T>(key: string[]) => Effect.Effect<T, Error>
|
||||
readonly update: <T>(key: string[], fn: (draft: T) => void) => Effect.Effect<T, Error>
|
||||
readonly write: <T>(key: string[], content: T) => Effect.Effect<void, AppFileSystem.Error>
|
||||
readonly list: (prefix: string[]) => Effect.Effect<string[][], AppFileSystem.Error>
|
||||
readonly write: <T>(key: string[], content: T) => Effect.Effect<void, FSUtil.Error>
|
||||
readonly list: (prefix: string[]) => Effect.Effect<string[][], FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Storage") {}
|
||||
|
|
@ -85,7 +81,7 @@ function parseMigration(text: string) {
|
|||
}
|
||||
|
||||
const MIGRATIONS: Migration[] = [
|
||||
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) {
|
||||
Effect.fn("Storage.migration.1")(function* (dir: string, fs: FSUtil.Interface, git: Git.Interface) {
|
||||
const project = path.resolve(dir, "../project")
|
||||
if (!(yield* fs.isDir(project))) return
|
||||
const projectDirs = yield* fs.glob("*", {
|
||||
|
|
@ -185,7 +181,7 @@ const MIGRATIONS: Migration[] = [
|
|||
}
|
||||
}
|
||||
}),
|
||||
Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) {
|
||||
Effect.fn("Storage.migration.2")(function* (dir: string, fs: FSUtil.Interface) {
|
||||
for (const item of yield* fs.glob("session/*/*.json", {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
|
|
@ -219,7 +215,7 @@ const MIGRATIONS: Migration[] = [
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const locks = yield* RcMap.make({
|
||||
lookup: () => TxReentrantLock.make(),
|
||||
|
|
@ -251,7 +247,7 @@ export const layer = Layer.effect(
|
|||
const fail = (target: string): Effect.Effect<never, NotFoundError> =>
|
||||
Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` }))
|
||||
|
||||
const wrap = <A>(target: string, body: Effect.Effect<A, AppFileSystem.Error>) =>
|
||||
const wrap = <A>(target: string, body: Effect.Effect<A, FSUtil.Error>) =>
|
||||
body.pipe(Effect.catchIf(missing, () => fail(target)))
|
||||
|
||||
const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) {
|
||||
|
|
@ -261,7 +257,7 @@ export const layer = Layer.effect(
|
|||
const withResolved = <A, E>(
|
||||
key: string[],
|
||||
fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect<A, E>,
|
||||
): Effect.Effect<A, E | AppFileSystem.Error> =>
|
||||
): Effect.Effect<A, E | FSUtil.Error> =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const target = file((yield* state).dir, key)
|
||||
|
|
@ -328,6 +324,6 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
export * as Storage from "./storage"
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@ import * as path from "path"
|
|||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Patch } from "../patch"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { trimDiff } from "./edit"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import DESCRIPTION from "./apply_patch.txt"
|
||||
import { File } from "../file"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Format } from "../format"
|
||||
import * as Bom from "@/util/bom"
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ export const ApplyPatchTool = Tool.define(
|
|||
"apply_patch",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const afs = yield* FSUtil.Service
|
||||
const format = yield* Format.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
|
|
@ -253,13 +253,13 @@ export const ApplyPatchTool = Tool.define(
|
|||
if (yield* format.file(edited)) {
|
||||
yield* Bom.syncFile(afs, edited, change.bom)
|
||||
}
|
||||
yield* events.publish(File.Event.Edited, { file: edited })
|
||||
yield* events.publish(FileSystem.Event.Edited, { file: edited })
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
for (const update of updates) {
|
||||
yield* events.publish(FileWatcher.Event.Updated, update)
|
||||
yield* events.publish(Watcher.Event.Updated, update)
|
||||
}
|
||||
|
||||
// Notify LSP of file changes and collect diagnostics
|
||||
|
|
@ -286,7 +286,7 @@ export const ApplyPatchTool = Tool.define(
|
|||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? [])
|
||||
const block = LSP.Diagnostic.report(target, diagnostics[FSUtil.normalizePath(target)] ?? [])
|
||||
if (!block) continue
|
||||
const rel = path.relative(instance.worktree, target).replaceAll("\\", "/")
|
||||
output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ import * as Tool from "./tool"
|
|||
import { LSP } from "@/lsp/lsp"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import DESCRIPTION from "./edit.txt"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Format } from "../format"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import * as Bom from "@/util/bom"
|
||||
|
||||
function normalizeLineEndings(text: string): string {
|
||||
|
|
@ -35,7 +35,7 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
|
|||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
function lock(filePath: string) {
|
||||
const resolvedFilePath = AppFileSystem.resolve(filePath)
|
||||
const resolvedFilePath = FSUtil.resolve(filePath)
|
||||
const hit = locks.get(resolvedFilePath)
|
||||
if (hit) return hit
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ export const EditTool = Tool.define(
|
|||
"edit",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const afs = yield* FSUtil.Service
|
||||
const format = yield* Format.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
|
|
@ -108,8 +108,8 @@ export const EditTool = Tool.define(
|
|||
if (yield* format.file(filePath)) {
|
||||
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
|
||||
}
|
||||
yield* events.publish(File.Event.Edited, { file: filePath })
|
||||
yield* events.publish(FileWatcher.Event.Updated, {
|
||||
yield* events.publish(FileSystem.Event.Edited, { file: filePath })
|
||||
yield* events.publish(Watcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
|
|
@ -152,8 +152,8 @@ export const EditTool = Tool.define(
|
|||
if (yield* format.file(filePath)) {
|
||||
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
|
||||
}
|
||||
yield* events.publish(File.Event.Edited, { file: filePath })
|
||||
yield* events.publish(FileWatcher.Event.Updated, {
|
||||
yield* events.publish(FileSystem.Event.Edited, { file: filePath })
|
||||
yield* events.publish(Watcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
|
|
@ -192,7 +192,7 @@ export const EditTool = Tool.define(
|
|||
let output = "Edit applied successfully."
|
||||
yield* lsp.touchFile(filePath, "document")
|
||||
const diagnostics = yield* lsp.diagnostics()
|
||||
const normalizedFilePath = AppFileSystem.normalizePath(filePath)
|
||||
const normalizedFilePath = FSUtil.normalizePath(filePath)
|
||||
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
|
||||
if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
|||
import { InstanceState } from "@/effect/instance-state"
|
||||
import type * as Tool from "./tool"
|
||||
import { containsPath } from "../project/instance-context"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
|
||||
type Kind = "file" | "directory"
|
||||
|
||||
|
|
@ -23,14 +23,14 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
|
|||
if (options?.bypass) return
|
||||
|
||||
const ins = yield* InstanceState.context
|
||||
const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
|
||||
const full = process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
||||
if (containsPath(full, ins)) return
|
||||
|
||||
const kind = options?.kind ?? "file"
|
||||
const dir = kind === "directory" ? full : path.dirname(full)
|
||||
const glob =
|
||||
process.platform === "win32"
|
||||
? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
|
||||
? FSUtil.normalizePathPattern(path.join(dir, "*"))
|
||||
: path.join(dir, "*").replaceAll("\\", "/")
|
||||
|
||||
yield* ctx.ask({
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import path from "path"
|
|||
import { Effect, Option, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
|
@ -20,7 +20,7 @@ export const GlobTool = Tool.define(
|
|||
"glob",
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const reference = yield* Reference.Service
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import path from "path"
|
|||
import { Schema } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
|
@ -24,7 +24,7 @@ export const Parameters = Schema.Struct({
|
|||
export const GrepTool = Tool.define(
|
||||
"grep",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
const reference = yield* Reference.Service
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ export const GrepTool = Tool.define(
|
|||
kind: requestedInfo?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
const search = AppFileSystem.resolve(requested)
|
||||
const search = FSUtil.resolve(requested)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
||||
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
||||
|
|
@ -79,9 +79,7 @@ export const GrepTool = Tool.define(
|
|||
if (result.items.length === 0) return empty
|
||||
|
||||
const rows = result.items.map((item) => ({
|
||||
path: AppFileSystem.resolve(
|
||||
path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text),
|
||||
),
|
||||
path: FSUtil.resolve(path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text)),
|
||||
line: item.line_number,
|
||||
text: item.lines.text,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import DESCRIPTION from "./lsp.txt"
|
|||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { pathToFileURL } from "url"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
|
||||
const operations = [
|
||||
"goToDefinition",
|
||||
|
|
@ -38,7 +38,7 @@ export const LspTool = Tool.define(
|
|||
"lsp",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Effect, Option, Schema, Scope, Stream } from "effect"
|
|||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import * as path from "path"
|
||||
import * as Tool from "./tool"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import DESCRIPTION from "./read.txt"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
|
@ -39,7 +39,7 @@ export const Parameters = Schema.Struct({
|
|||
export const ReadTool = Tool.define(
|
||||
"read",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const instruction = yield* Instruction.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const reference = yield* Reference.Service
|
||||
|
|
@ -208,7 +208,7 @@ export const ReadTool = Tool.define(
|
|||
filepath = path.resolve(instance.directory, filepath)
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
filepath = AppFileSystem.normalizePath(filepath)
|
||||
filepath = FSUtil.normalizePath(filepath)
|
||||
}
|
||||
yield* reference.ensure(filepath)
|
||||
const title = path.relative(instance.worktree, filepath)
|
||||
|
|
@ -265,7 +265,7 @@ export const ReadTool = Tool.define(
|
|||
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
|
||||
const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
|
||||
|
||||
const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))
|
||||
const mime = sniffAttachmentMime(sample, FSUtil.mimeType(filepath))
|
||||
const isImage = SUPPORTED_IMAGE_MIMES.has(mime)
|
||||
|
||||
if (isImage || isPdfAttachment(mime)) {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { Effect, Layer, Context } from "effect"
|
|||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { Format } from "../format"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
|
|
@ -42,7 +42,7 @@ import { Question } from "../question"
|
|||
import { Todo } from "../session/todo"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill"
|
||||
|
|
@ -96,7 +96,7 @@ export const layer: Layer.Layer<
|
|||
| Reference.Service
|
||||
| LSP.Service
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
| FSUtil.Service
|
||||
| EventV2Bridge.Service
|
||||
| HttpClient.HttpClient
|
||||
| ChildProcessSpawner
|
||||
|
|
@ -380,7 +380,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { InstanceState } from "@/effect/instance-state"
|
|||
import { lazy } from "@/util/lazy"
|
||||
import { Language, type Node } from "web-tree-sitter"
|
||||
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
|
@ -266,7 +266,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole
|
|||
const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
|
||||
if (scan.dirs.size > 0) {
|
||||
const globs = Array.from(scan.dirs).map((dir) => {
|
||||
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
|
||||
if (process.platform === "win32") return FSUtil.normalizePathPattern(path.join(dir, "*"))
|
||||
return path.join(dir, "*")
|
||||
})
|
||||
yield* ctx.ask({
|
||||
|
|
@ -336,7 +336,7 @@ export const ShellTool = Tool.define(
|
|||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const trunc = yield* Truncate.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
|
@ -348,16 +348,16 @@ export const ShellTool = Tool.define(
|
|||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const file = lines[0]?.trim()
|
||||
if (!file) return
|
||||
return AppFileSystem.normalizePath(file)
|
||||
return FSUtil.normalizePath(file)
|
||||
})
|
||||
|
||||
const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
|
||||
if (process.platform === "win32") {
|
||||
if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
|
||||
if (Shell.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) {
|
||||
const file = yield* cygpath(shell, text)
|
||||
if (file) return file
|
||||
}
|
||||
return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))
|
||||
return FSUtil.normalizePath(path.resolve(root, FSUtil.windowsPath(text)))
|
||||
}
|
||||
return path.resolve(root, text)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import path from "path"
|
|||
import { pathToFileURL } from "url"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { Skill } from "../skill"
|
||||
import * as Tool from "./tool"
|
||||
import DESCRIPTION from "./skill.txt"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node"
|
|||
import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { evaluate } from "@/permission/evaluate"
|
||||
import { Config } from "@/config/config"
|
||||
import { Identifier } from "../id/id"
|
||||
|
|
@ -50,7 +50,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Tr
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||
const cutoff = Identifier.timestamp(
|
||||
|
|
@ -155,6 +155,6 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer))
|
||||
|
||||
export * as Truncate from "./truncate"
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import { LSP } from "@/lsp/lsp"
|
|||
import { createTwoFilesPatch } from "diff"
|
||||
import DESCRIPTION from "./write.txt"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Format } from "../format"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { trimDiff } from "./edit"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
|
|
@ -28,7 +28,7 @@ export const WriteTool = Tool.define(
|
|||
"write",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const format = yield* Format.Service
|
||||
|
||||
|
|
@ -65,8 +65,8 @@ export const WriteTool = Tool.define(
|
|||
if (yield* format.file(filepath)) {
|
||||
yield* Bom.syncFile(fs, filepath, desiredBom)
|
||||
}
|
||||
yield* events.publish(File.Event.Edited, { file: filepath })
|
||||
yield* events.publish(FileWatcher.Event.Updated, {
|
||||
yield* events.publish(FileSystem.Event.Edited, { file: filepath })
|
||||
yield* events.publish(Watcher.Event.Updated, {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
})
|
||||
|
|
@ -74,7 +74,7 @@ export const WriteTool = Tool.define(
|
|||
let output = "Wrote file successfully."
|
||||
yield* lsp.touchFile(filepath, "document")
|
||||
const diagnostics = yield* lsp.diagnostics()
|
||||
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
|
||||
const normalizedFilepath = FSUtil.normalizePath(filepath)
|
||||
let projectDiagnosticsCount = 0
|
||||
for (const [file, issues] of Object.entries(diagnostics)) {
|
||||
const current = file === normalizedFilepath
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Effect } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
|
||||
const BOM_CODE = 0xfeff
|
||||
const BOM = String.fromCharCode(BOM_CODE)
|
||||
|
|
@ -15,15 +15,11 @@ export function join(text: string, bom: boolean) {
|
|||
return BOM + stripped
|
||||
}
|
||||
|
||||
export const readFile = Effect.fn("Bom.readFile")(function* (fs: AppFileSystem.Interface, filePath: string) {
|
||||
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filePath: string) {
|
||||
return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath)))
|
||||
})
|
||||
|
||||
export const syncFile = Effect.fn("Bom.syncFile")(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
filePath: string,
|
||||
bom: boolean,
|
||||
) {
|
||||
export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filePath: string, bom: boolean) {
|
||||
const current = yield* readFile(fs, filePath)
|
||||
if (current.bom === bom) return current.text
|
||||
yield* fs.writeWithDirs(filePath, join(current.text, bom))
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import whichPkg from "which"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
||||
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
|
||||
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
|
||||
const result = whichPkg.sync(cmd, {
|
||||
nothrow: true,
|
||||
path: full,
|
||||
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
|
||||
})
|
||||
return typeof result === "string" ? result : null
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import { Git } from "@/git"
|
|||
import { Effect, Layer, Path, Schema, Scope, Context } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ type GitResult = { code: number; text: string; stderr: string }
|
|||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
| AppFileSystem.Service
|
||||
| FSUtil.Service
|
||||
| Path.Path
|
||||
| AppProcess.Service
|
||||
| Git.Service
|
||||
|
|
@ -160,7 +160,7 @@ export const layer: Layer.Layer<
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const { db } = yield* Database.Service
|
||||
|
|
@ -636,7 +636,7 @@ export const appLayer = layer.pipe(
|
|||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import path from "path"
|
||||
|
|
@ -18,14 +18,14 @@ import { SkillTest } from "../fake/skill"
|
|||
import { testEffect } from "../lib/effect"
|
||||
import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
|
||||
|
||||
// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin
|
||||
// up — those services hang during scope teardown on Windows and aren't needed
|
||||
// `it.instance` skips InstanceBootstrap so LSP / MCP don't spin up — those
|
||||
// services hang during scope teardown on Windows and aren't needed
|
||||
// to verify plugin → config hook → Agent.list.
|
||||
const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href
|
||||
|
||||
const provider = ProviderTest.fake()
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { afterEach, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppFileSystem.defaultLayer)
|
||||
const it = testEffect(FSUtil.defaultLayer)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
|
|
@ -14,7 +14,7 @@ afterEach(async () => {
|
|||
|
||||
it.live("effect-cmd.ts does not restore legacy instance ALS", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url)))
|
||||
expect(source).not.toContain("restore(ctx")
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import path from "path"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
|
@ -98,7 +98,7 @@ function userMessage(
|
|||
}
|
||||
}
|
||||
|
||||
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, NodeFileSystem.layer))
|
||||
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
function remap(root: string, file: string) {
|
||||
if (file === Global.Path.state) {
|
||||
|
|
@ -114,16 +114,16 @@ function remap(root: string, file: string) {
|
|||
|
||||
function remappedFs(root: string) {
|
||||
return Layer.effect(
|
||||
AppFileSystem.Service,
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
return AppFileSystem.Service.of({
|
||||
const fs = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
readJson: (file) => fs.readJson(remap(root, file)),
|
||||
writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
}
|
||||
|
||||
describe("run variant shared", () => {
|
||||
|
|
@ -160,7 +160,7 @@ describe("run variant shared", () => {
|
|||
it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
|
||||
Effect.gen(function* () {
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const root = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(root, "model.json")
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ describe("run variant shared", () => {
|
|||
it.live("repairs malformed saved variant state on the next write", () =>
|
||||
Effect.gen(function* () {
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const root = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(root, "model.json")
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type { InstanceContext } from "../../src/project/instance-context"
|
|||
import { Auth } from "../../src/auth"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Env } from "../../src/env"
|
||||
import {
|
||||
provideTmpdirInstance,
|
||||
|
|
@ -100,7 +100,7 @@ const configLayer = (
|
|||
Layer.provideMerge(infra),
|
||||
Layer.provide(NpmTest.noop),
|
||||
Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)),
|
||||
Layer.provideMerge(AppFileSystem.defaultLayer),
|
||||
Layer.provideMerge(FSUtil.defaultLayer),
|
||||
)
|
||||
|
||||
const layer = configLayer()
|
||||
|
|
@ -145,14 +145,14 @@ afterEach(async () => {
|
|||
})
|
||||
|
||||
const writeManagedSettingsEffect = (settings: object, filename?: string) =>
|
||||
AppFileSystem.use.writeWithDirs(path.join(managedConfigDir, filename ?? "opencode.json"), JSON.stringify(settings))
|
||||
FSUtil.use.writeWithDirs(path.join(managedConfigDir, filename ?? "opencode.json"), JSON.stringify(settings))
|
||||
|
||||
async function writeConfig(dir: string, config: object, name = "opencode.json") {
|
||||
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
|
||||
}
|
||||
|
||||
const writeConfigEffect = (dir: string, config: object, name = "opencode.json") =>
|
||||
AppFileSystem.use.writeWithDirs(path.join(dir, name), JSON.stringify(config))
|
||||
FSUtil.use.writeWithDirs(path.join(dir, name), JSON.stringify(config))
|
||||
|
||||
const withInstanceDir = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
|
|
@ -201,9 +201,7 @@ const withConfigTree = <A, E, R>(
|
|||
input.global ? writeConfigEffect(global, schemaConfig(input.global)) : undefined,
|
||||
input.project ? writeConfigEffect(directory, schemaConfig(input.project)) : undefined,
|
||||
input.local ? writeConfigEffect(path.join(directory, ".opencode"), schemaConfig(input.local)) : undefined,
|
||||
].filter(
|
||||
(effect): effect is Effect.Effect<void, AppFileSystem.Error, AppFileSystem.Service> => effect !== undefined,
|
||||
),
|
||||
].filter((effect): effect is Effect.Effect<void, FSUtil.Error, FSUtil.Service> => effect !== undefined),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return yield* withGlobalConfigDir(global, withInstanceDir(directory, effect))
|
||||
|
|
@ -311,7 +309,7 @@ it.effect("creates global jsonc config with schema when no global configs exist"
|
|||
Effect.gen(function* () {
|
||||
yield* Config.use.get().pipe(provideInstanceEffect(dir))
|
||||
|
||||
const content = yield* AppFileSystem.use.readFileString(path.join(dir, "opencode.jsonc"))
|
||||
const content = yield* FSUtil.use.readFileString(path.join(dir, "opencode.jsonc"))
|
||||
expect(content).toContain('"$schema": "https://opencode.ai/config.json"')
|
||||
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
||||
),
|
||||
|
|
@ -327,7 +325,7 @@ it.effect("does not create global config when OPENCODE_CONFIG_DIR is set", () =>
|
|||
Effect.gen(function* () {
|
||||
yield* Config.use.get().pipe(provideInstanceEffect(dir))
|
||||
|
||||
expect(yield* AppFileSystem.use.existsSafe(path.join(dir, "opencode.jsonc"))).toBe(false)
|
||||
expect(yield* FSUtil.use.existsSafe(path.join(dir, "opencode.jsonc"))).toBe(false)
|
||||
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
||||
),
|
||||
)
|
||||
|
|
@ -364,7 +362,7 @@ it.instance("updates config and preserves empty shell sentinel", () =>
|
|||
|
||||
yield* Config.Service.use((svc) => svc.update(ConfigParse.schema(Config.Info, { shell: "" }, "test:config")))
|
||||
|
||||
const writtenConfig = yield* AppFileSystem.use.readJson(path.join(test.directory, "config.json"))
|
||||
const writtenConfig = yield* FSUtil.use.readJson(path.join(test.directory, "config.json"))
|
||||
expect(writtenConfig).toMatchObject({ shell: "" })
|
||||
}),
|
||||
)
|
||||
|
|
@ -374,7 +372,7 @@ it.effect("updates global config and omits empty shell key in json", () =>
|
|||
Effect.gen(function* () {
|
||||
yield* Config.use.updateGlobal({ shell: "" })
|
||||
|
||||
const writtenConfig = yield* AppFileSystem.use.readJson(path.join(dir, "opencode.json"))
|
||||
const writtenConfig = yield* FSUtil.use.readJson(path.join(dir, "opencode.json"))
|
||||
expect(writtenConfig).not.toHaveProperty("shell")
|
||||
}),
|
||||
),
|
||||
|
|
@ -386,7 +384,7 @@ it.effect("updates global config and omits empty shell key in jsonc", () =>
|
|||
yield* Config.use.updateGlobal({ shell: "" })
|
||||
|
||||
const file = path.join(dir, "opencode.jsonc")
|
||||
const writtenConfig = yield* AppFileSystem.use.readFileString(file)
|
||||
const writtenConfig = yield* FSUtil.use.readFileString(file)
|
||||
const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(writtenConfig, file), file)
|
||||
expect(writtenConfig).not.toContain('"shell"')
|
||||
expect(parsed.shell).toBeUndefined()
|
||||
|
|
@ -450,7 +448,7 @@ it.instance("ignores legacy tui keys in opencode config", () =>
|
|||
it.instance("loads JSONC config file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, "opencode.jsonc"),
|
||||
`{
|
||||
// This is a comment
|
||||
|
|
@ -510,7 +508,7 @@ it.instance("preserves env variables when adding $schema to config", () =>
|
|||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
// Config without $schema - should trigger auto-add
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, "opencode.json"),
|
||||
JSON.stringify({ username: "{env:PRESERVE_VAR}" }),
|
||||
)
|
||||
|
|
@ -518,7 +516,7 @@ it.instance("preserves env variables when adding $schema to config", () =>
|
|||
expect(config.username).toBe("secret_value")
|
||||
|
||||
// Read the file to verify the env variable was preserved
|
||||
const content = yield* AppFileSystem.use.readFileString(path.join(test.directory, "opencode.json"))
|
||||
const content = yield* FSUtil.use.readFileString(path.join(test.directory, "opencode.json"))
|
||||
expect(content).toContain("{env:PRESERVE_VAR}")
|
||||
expect(content).not.toContain("secret_value")
|
||||
expect(content).toContain("$schema")
|
||||
|
|
@ -529,7 +527,7 @@ it.instance("preserves env variables when adding $schema to config", () =>
|
|||
it.instance("handles file inclusion substitution", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "test-user")
|
||||
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "included.txt"), "test-user")
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{file:included.txt}",
|
||||
|
|
@ -542,7 +540,7 @@ it.instance("handles file inclusion substitution", () =>
|
|||
it.instance("handles file inclusion with replacement tokens", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`")
|
||||
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`")
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{file:included.md}",
|
||||
|
|
@ -610,7 +608,7 @@ it.instance("validates config schema and throws on invalid fields", () =>
|
|||
it.instance("throws error for invalid JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "opencode.json"), "{ invalid json }")
|
||||
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "opencode.json"), "{ invalid json }")
|
||||
const exit = yield* Config.use.get().pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
|
|
@ -725,7 +723,7 @@ it.instance("migrates mode field to agent field", () =>
|
|||
it.instance("loads config from .opencode directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "agent", "test.md"),
|
||||
`---
|
||||
model: test/model
|
||||
|
|
@ -747,7 +745,7 @@ Test agent prompt`,
|
|||
it.instance("agent markdown permission config preserves user key order", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "agent", "ordered.md"),
|
||||
`---
|
||||
permission:
|
||||
|
|
@ -766,7 +764,7 @@ Ordered permissions`,
|
|||
it.instance("loads agents from .opencode/agents (plural)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "agents", "helper.md"),
|
||||
`---
|
||||
model: test/model
|
||||
|
|
@ -775,7 +773,7 @@ mode: subagent
|
|||
Helper agent prompt`,
|
||||
)
|
||||
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "agents", "nested", "child.md"),
|
||||
`---
|
||||
model: test/model
|
||||
|
|
@ -805,7 +803,7 @@ Nested agent prompt`,
|
|||
it.instance("loads commands from .opencode/command (singular)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "command", "hello.md"),
|
||||
`---
|
||||
description: Test command
|
||||
|
|
@ -813,7 +811,7 @@ description: Test command
|
|||
Hello from singular command`,
|
||||
)
|
||||
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "command", "nested", "child.md"),
|
||||
`---
|
||||
description: Nested command
|
||||
|
|
@ -838,7 +836,7 @@ Nested command template`,
|
|||
it.instance("loads commands from .opencode/commands (plural)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "commands", "hello.md"),
|
||||
`---
|
||||
description: Test command
|
||||
|
|
@ -846,7 +844,7 @@ description: Test command
|
|||
Hello from plural commands`,
|
||||
)
|
||||
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "commands", "nested", "child.md"),
|
||||
`---
|
||||
description: Nested command
|
||||
|
|
@ -875,7 +873,7 @@ it.instance("updates config and writes to file", () =>
|
|||
svc.update(ConfigParse.schema(Config.Info, { model: "updated/model" }, "test:config")),
|
||||
)
|
||||
|
||||
const writtenConfig = yield* AppFileSystem.use.readJson(path.join(test.directory, "config.json"))
|
||||
const writtenConfig = yield* FSUtil.use.readJson(path.join(test.directory, "config.json"))
|
||||
expect(writtenConfig).toMatchObject({ model: "updated/model" })
|
||||
}),
|
||||
)
|
||||
|
|
@ -893,9 +891,9 @@ it.effect("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR
|
|||
|
||||
const dir = yield* tmpdirScoped()
|
||||
const readonly = path.join(dir, "readonly")
|
||||
yield* AppFileSystem.use.ensureDir(readonly)
|
||||
yield* AppFileSystem.use.chmod(readonly, 0o555)
|
||||
yield* Effect.addFinalizer(() => AppFileSystem.use.chmod(readonly, 0o755).pipe(Effect.ignore))
|
||||
yield* FSUtil.use.ensureDir(readonly)
|
||||
yield* FSUtil.use.chmod(readonly, 0o555)
|
||||
yield* Effect.addFinalizer(() => FSUtil.use.chmod(readonly, 0o755).pipe(Effect.ignore))
|
||||
|
||||
yield* withProcessEnv("OPENCODE_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir)))
|
||||
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
||||
|
|
@ -905,7 +903,7 @@ it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () =>
|
|||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const configDir = path.join(dir, "configdir")
|
||||
yield* AppFileSystem.use.ensureDir(configDir)
|
||||
yield* FSUtil.use.ensureDir(configDir)
|
||||
|
||||
yield* withProcessEnv(
|
||||
"OPENCODE_CONFIG_DIR",
|
||||
|
|
@ -915,7 +913,7 @@ it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () =>
|
|||
),
|
||||
)
|
||||
|
||||
expect(yield* AppFileSystem.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json")
|
||||
expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json")
|
||||
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
||||
)
|
||||
|
||||
|
|
@ -927,11 +925,11 @@ it.instance("resolves scoped npm plugins in config", () =>
|
|||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const pluginDir = path.join(test.directory, "node_modules", "@scope", "plugin")
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, "package.json"),
|
||||
JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
|
||||
)
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
|
|
@ -944,7 +942,7 @@ it.instance("resolves scoped npm plugins in config", () =>
|
|||
2,
|
||||
),
|
||||
)
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(pluginDir, "index.js"), "export default {}\n")
|
||||
yield* FSUtil.use.writeWithDirs(path.join(pluginDir, "index.js"), "export default {}\n")
|
||||
yield* writeConfigEffect(test.directory, { plugin: ["@scope/plugin"] })
|
||||
|
||||
const config = yield* Config.use.get()
|
||||
|
|
@ -993,7 +991,7 @@ it.effect("global config remains global when project config is disabled", () =>
|
|||
it.instance("does not error when only custom agent is a subagent", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "agent", "helper.md"),
|
||||
`---
|
||||
model: test/model
|
||||
|
|
@ -1414,7 +1412,7 @@ it.instance("local .opencode config can override MCP from project config", () =>
|
|||
},
|
||||
},
|
||||
})
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode"))
|
||||
yield* FSUtil.use.ensureDir(path.join(test.directory, ".opencode"))
|
||||
yield* writeConfigEffect(
|
||||
path.join(test.directory, ".opencode"),
|
||||
{
|
||||
|
|
@ -1503,7 +1501,7 @@ test("remote well-known config can use FetchHttpClient layer", async () => {
|
|||
Layer.mergeAll(
|
||||
Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(wellKnownAuth(server.url.origin)),
|
||||
Layer.provide(AccountTest.empty),
|
||||
|
|
@ -1736,7 +1734,7 @@ describe("deduplicatePluginOrigins", () => {
|
|||
{ global: { plugin: ["my-plugin@1.0.0"] } },
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "plugin", "my-plugin.js"),
|
||||
"export default {}",
|
||||
)
|
||||
|
|
@ -1771,7 +1769,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
|||
"true",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
yield* FSUtil.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "command", "test-cmd.md"),
|
||||
"# Test Command\nThis is a test command.",
|
||||
)
|
||||
|
|
@ -1800,7 +1798,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
|||
{ OPENCODE_CONFIG_DIR: undefined, OPENCODE_DISABLE_PROJECT_CONFIG: "true" },
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions")
|
||||
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions")
|
||||
// The relative instruction should be skipped without error
|
||||
const config = yield* Config.use.get()
|
||||
expect(config).toBeDefined()
|
||||
|
|
@ -1865,7 +1863,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
|||
it.instance("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "api_key.txt"), "secret_key_from_file")
|
||||
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "api_key.txt"), "secret_key_from_file")
|
||||
yield* withProcessEnv(
|
||||
"OPENCODE_CONFIG_CONTENT",
|
||||
JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { expect } from "bun:test"
|
|||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
|
|
@ -11,7 +11,7 @@ import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
|
|||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Config.defaultLayer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Config.defaultLayer, FSUtil.defaultLayer))
|
||||
const winIt = process.platform === "win32" ? it.instance : it.instance.skip
|
||||
|
||||
const globalConfigFiles = ["opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc"].map((file) =>
|
||||
|
|
@ -19,7 +19,7 @@ const globalConfigFiles = ["opencode.json", "opencode.jsonc", "tui.json", "tui.j
|
|||
)
|
||||
|
||||
const cleanState = Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
delete process.env.OPENCODE_CONFIG
|
||||
delete process.env.OPENCODE_TUI_CONFIG
|
||||
yield* Effect.forEach(globalConfigFiles, (file) => fs.remove(file, { force: true }).pipe(Effect.ignore), {
|
||||
|
|
@ -75,7 +75,7 @@ const getTuiConfig = (directory: string) =>
|
|||
it.instance("keeps server and tui plugin merge semantics aligned", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
const local = path.join(test.directory, ".opencode")
|
||||
yield* fs.makeDirectory(local, { recursive: true })
|
||||
|
|
@ -114,7 +114,7 @@ it.instance("keeps server and tui plugin merge semantics aligned", () =>
|
|||
it.instance("loads tui config with the same precedence order as server config paths", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" })
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project" })
|
||||
|
|
@ -133,7 +133,7 @@ it.instance("loads tui config with the same precedence order as server config pa
|
|||
it.instance("resolves attention config defaults and overrides", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
|
||||
expect((yield* getTuiConfig(test.directory)).attention).toEqual({
|
||||
|
|
@ -181,7 +181,7 @@ it.instance("resolves attention config defaults and overrides", () =>
|
|||
it.instance("migrates tui-specific keys from opencode.json when tui.json does not exist", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
const source = path.join(test.directory, "opencode.json")
|
||||
yield* fs.writeJson(source, {
|
||||
|
|
@ -211,7 +211,7 @@ it.instance("migrates tui-specific keys from opencode.json when tui.json does no
|
|||
it.instance("migrates project legacy tui keys even when global tui.json already exists", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" })
|
||||
yield* fs.writeJson(path.join(test.directory, "opencode.json"), {
|
||||
|
|
@ -234,7 +234,7 @@ it.instance("migrates project legacy tui keys even when global tui.json already
|
|||
it.instance("drops unknown legacy tui keys during migration", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "opencode.json"), {
|
||||
theme: "migrated-theme",
|
||||
|
|
@ -255,7 +255,7 @@ it.instance("drops unknown legacy tui keys during migration", () =>
|
|||
it.instance("skips migration when opencode.jsonc is syntactically invalid", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeFileString(
|
||||
path.join(test.directory, "opencode.jsonc"),
|
||||
|
|
@ -281,7 +281,7 @@ it.instance("skips migration when opencode.jsonc is syntactically invalid", () =
|
|||
it.instance("skips migration when tui.json already exists", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "opencode.json"), { theme: "legacy" })
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), { diff_style: "stacked" })
|
||||
|
|
@ -300,7 +300,7 @@ it.instance("skips migration when tui.json already exists", () =>
|
|||
it.instance("continues loading tui config when legacy source cannot be stripped", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
const source = path.join(test.directory, "opencode.json")
|
||||
yield* fs.writeJson(source, { theme: "readonly-theme" })
|
||||
|
|
@ -325,7 +325,7 @@ it.instance("continues loading tui config when legacy source cannot be stripped"
|
|||
it.instance("migration backup preserves JSONC comments", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeFileString(
|
||||
path.join(test.directory, "opencode.jsonc"),
|
||||
|
|
@ -352,7 +352,7 @@ it.instance("migration backup preserves JSONC comments", () =>
|
|||
it.instance("migrates legacy tui keys across multiple opencode.json levels", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
const nested = path.join(test.directory, "apps", "client")
|
||||
yield* fs.makeDirectory(nested, { recursive: true })
|
||||
|
|
@ -370,7 +370,7 @@ it.instance("migrates legacy tui keys across multiple opencode.json levels", ()
|
|||
it.instance("flattens nested tui key inside tui.json", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
theme: "outer",
|
||||
|
|
@ -388,7 +388,7 @@ it.instance("flattens nested tui key inside tui.json", () =>
|
|||
it.instance("top-level keys in tui.json take precedence over nested tui key", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
diff_style: "auto",
|
||||
|
|
@ -405,7 +405,7 @@ it.instance("top-level keys in tui.json take precedence over nested tui key", ()
|
|||
it.instance("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
const custom = path.join(test.directory, "custom-tui.json")
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project", diff_style: "auto" })
|
||||
|
|
@ -427,7 +427,7 @@ it.instance("project config takes precedence over OPENCODE_TUI_CONFIG (matches O
|
|||
it.instance("merges keybind overrides across precedence layers", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { keybinds: { app_exit: "ctrl+q" } })
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { theme_list: "ctrl+k" } })
|
||||
|
|
@ -442,7 +442,7 @@ it.instance("merges keybind overrides across precedence layers", () =>
|
|||
it.instance("ignores unknown keybind names without dropping valid overrides from the same file", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
|
||||
keybinds: {
|
||||
|
|
@ -461,7 +461,7 @@ it.instance("ignores unknown keybind names without dropping valid overrides from
|
|||
it.instance("resolves keybind lookup from canonical keybinds", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
keybinds: {
|
||||
|
|
@ -506,7 +506,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () =>
|
|||
it.instance("keybinds accept OpenTUI binding specs", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
keybinds: {
|
||||
|
|
@ -546,7 +546,7 @@ winIt("defaults Ctrl+Z to input undo on Windows", () =>
|
|||
winIt("keeps explicit input undo overrides on Windows", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { input_undo: "ctrl+y" } })
|
||||
|
||||
|
|
@ -560,7 +560,7 @@ winIt("keeps explicit input undo overrides on Windows", () =>
|
|||
winIt("ignores terminal suspend bindings on Windows", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { terminal_suspend: "alt+z" } })
|
||||
|
||||
|
|
@ -590,7 +590,7 @@ it.instance("ignores explicit keybind terminal suspend binding on Windows", () =
|
|||
withPlatform(
|
||||
"win32",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
keybinds: {
|
||||
|
|
@ -610,7 +610,7 @@ it.instance("keeps explicit configured keybind input undo on Windows", () =>
|
|||
withPlatform(
|
||||
"win32",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
keybinds: {
|
||||
|
|
@ -628,7 +628,7 @@ it.instance("keeps explicit configured keybind input undo on Windows", () =>
|
|||
it.instance("OPENCODE_TUI_CONFIG provides settings when no project config exists", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
const custom = path.join(test.directory, "custom-tui.json")
|
||||
yield* fs.writeJson(custom, { theme: "from-env", diff_style: "stacked" })
|
||||
|
|
@ -649,7 +649,7 @@ it.instance("OPENCODE_TUI_CONFIG provides settings when no project config exists
|
|||
it.instance("does not derive tui path from OPENCODE_CONFIG", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
const customDir = path.join(test.directory, "custom")
|
||||
yield* fs.makeDirectory(customDir, { recursive: true })
|
||||
|
|
@ -674,7 +674,7 @@ it.instance("applies env and file substitutions in tui.json", () =>
|
|||
"TUI_THEME_TEST",
|
||||
"env-theme",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q")
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
|
|
@ -693,7 +693,7 @@ it.instance("applies env and file substitutions in tui.json", () =>
|
|||
it.instance("applies file substitutions when first identical token is in a commented line", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeFileString(path.join(test.directory, "theme.txt"), "resolved-theme")
|
||||
yield* fs.writeFileString(
|
||||
|
|
@ -713,7 +713,7 @@ it.instance("applies file substitutions when first identical token is in a comme
|
|||
it.instance("loads .opencode/tui.json", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tui.json"),
|
||||
|
|
@ -729,7 +729,7 @@ it.instance("loads .opencode/tui.json", () =>
|
|||
it.instance("supports tuple plugin specs with options in tui.json", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
|
||||
|
|
@ -751,7 +751,7 @@ it.instance("supports tuple plugin specs with options in tui.json", () =>
|
|||
it.instance("deduplicates tuple plugin specs by name with higher precedence winning", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
|
||||
plugin: [["acme-plugin@1.0.0", { source: "global" }]],
|
||||
|
|
@ -787,7 +787,7 @@ it.instance("deduplicates tuple plugin specs by name with higher precedence winn
|
|||
it.instance("tracks global and local plugin metadata in merged tui config", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { plugin: ["global-plugin@1.0.0"] })
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), { plugin: ["local-plugin@2.0.0"] })
|
||||
|
|
@ -813,7 +813,7 @@ it.instance("tracks global and local plugin metadata in merged tui config", () =
|
|||
it.instance("merges plugin_enabled flags across config layers", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
|
||||
plugin_enabled: {
|
||||
|
|
@ -841,7 +841,7 @@ it.instance("merges plugin_enabled flags across config layers", () =>
|
|||
it.instance("silently skips malformed tui.json - load failures degrade to {}", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeFileString(path.join(test.directory, "tui.json"), '{ "theme": "broken",')
|
||||
yield* fs.writeWithDirs(path.join(test.directory, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
|
||||
|
|
@ -855,7 +855,7 @@ it.instance("silently skips malformed tui.json - load failures degrade to {}", (
|
|||
it.instance("silently skips non-ENOENT read failures (e.g. tui.json is a directory) - fallback layer still loads", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.makeDirectory(path.join(test.directory, "tui.json"), { recursive: true })
|
||||
yield* fs.writeWithDirs(path.join(test.directory, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { NodeHttpServer } from "@effect/platform-node"
|
|||
import { Effect, Exit, Fiber, Layer, Schema } from "effect"
|
||||
import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
|
|
@ -55,7 +55,7 @@ const workspaceLayer = (experimentalWorkspaces: boolean) =>
|
|||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
|
||||
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
import { $ } from "bun"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const it =
|
||||
process.platform === "win32"
|
||||
? (await import("../lib/effect")).testEffect((await import("../../src/file")).File.defaultLayer)
|
||||
: undefined
|
||||
|
||||
describe("file fsmonitor", () => {
|
||||
if (!it) {
|
||||
test.skip("status does not start fsmonitor for readonly git checks", () => {})
|
||||
test.skip("read does not start fsmonitor for git diffs", () => {})
|
||||
return
|
||||
}
|
||||
|
||||
it.instance(
|
||||
"status does not start fsmonitor for readonly git checks",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { File } = yield* Effect.promise(() => import("../../src/file"))
|
||||
const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture"))
|
||||
const directory = (yield* TestInstance).directory
|
||||
const target = path.join(directory, "tracked.txt")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(target, "base\n"))
|
||||
yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow())
|
||||
yield* Effect.promise(() => fs.writeFile(target, "next\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "new.txt"), "new\n"))
|
||||
|
||||
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
|
||||
expect(before.exitCode).not.toBe(0)
|
||||
|
||||
yield* File.use.status()
|
||||
|
||||
const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
|
||||
expect(after.exitCode).not.toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"read does not start fsmonitor for git diffs",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { File } = yield* Effect.promise(() => import("../../src/file"))
|
||||
const { TestInstance } = yield* Effect.promise(() => import("../fixture/fixture"))
|
||||
const directory = (yield* TestInstance).directory
|
||||
const target = path.join(directory, "tracked.txt")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(target, "base\n"))
|
||||
yield* Effect.promise(() => $`git add tracked.txt`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git commit -m init`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(directory).quiet())
|
||||
yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(directory).quiet().nothrow())
|
||||
yield* Effect.promise(() => fs.writeFile(target, "next\n"))
|
||||
|
||||
const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
|
||||
expect(before.exitCode).not.toBe(0)
|
||||
|
||||
yield* File.use.read("tracked.txt")
|
||||
|
||||
const after = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(directory).quiet().nothrow())
|
||||
expect(after.exitCode).not.toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { test, expect } from "bun:test"
|
||||
import { FileIgnore } from "../../src/file/ignore"
|
||||
|
||||
test("match nested and non-nested", () => {
|
||||
expect(FileIgnore.match("node_modules/index.js")).toBe(true)
|
||||
expect(FileIgnore.match("node_modules")).toBe(true)
|
||||
expect(FileIgnore.match("node_modules/")).toBe(true)
|
||||
expect(FileIgnore.match("node_modules/bar")).toBe(true)
|
||||
expect(FileIgnore.match("node_modules/bar/")).toBe(true)
|
||||
})
|
||||
|
|
@ -1,872 +0,0 @@
|
|||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { $ } from "bun"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.mergeAll(File.defaultLayer, AppFileSystem.defaultLayer))
|
||||
|
||||
const init = Effect.fn("FileTest.init")(function* () {
|
||||
const file = yield* File.Service
|
||||
return yield* file.init()
|
||||
})
|
||||
|
||||
const status = Effect.fn("FileTest.status")(function* () {
|
||||
const file = yield* File.Service
|
||||
return yield* file.status()
|
||||
})
|
||||
|
||||
const read = Effect.fn("FileTest.read")(function* (input: string) {
|
||||
const file = yield* File.Service
|
||||
return yield* file.read(input)
|
||||
})
|
||||
|
||||
const list = Effect.fn("FileTest.list")(function* (dir?: string) {
|
||||
const file = yield* File.Service
|
||||
return yield* file.list(dir)
|
||||
})
|
||||
|
||||
const search = Effect.fn("FileTest.search")(function* (input: {
|
||||
query: string
|
||||
limit?: number
|
||||
dirs?: boolean
|
||||
type?: "file" | "directory"
|
||||
}) {
|
||||
const file = yield* File.Service
|
||||
return yield* file.search(input)
|
||||
})
|
||||
|
||||
const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(directory).quiet())
|
||||
const gitCommit = (directory: string, message: string) =>
|
||||
Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet())
|
||||
|
||||
const failureMessage = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* self.pipe(Effect.exit)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
throw new Error("expected effect to fail")
|
||||
})
|
||||
|
||||
const setupSearchableRepo = Effect.fn("FileTest.setupSearchableRepo")(function* (directory: string) {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
yield* fsys.writeWithDirs(path.join(directory, "index.ts"), "code")
|
||||
yield* fsys.writeWithDirs(path.join(directory, "utils.ts"), "utils")
|
||||
yield* fsys.writeWithDirs(path.join(directory, "readme.md"), "readme")
|
||||
yield* fsys.writeWithDirs(path.join(directory, "src", "main.ts"), "main")
|
||||
yield* fsys.writeWithDirs(path.join(directory, ".hidden", "secret.ts"), "secret")
|
||||
})
|
||||
|
||||
describe("file/index Filesystem patterns", () => {
|
||||
describe("read() - text content", () => {
|
||||
it.instance("reads text file via Filesystem.readText()", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "Hello World", "utf-8"))
|
||||
|
||||
const result = yield* read("test.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("Hello World")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("reads with Filesystem.exists() check", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* read("nonexistent.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("trims whitespace from text content", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.txt"), " content with spaces \n\n", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* read("test.txt")
|
||||
expect(result.content).toBe("content with spaces")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("handles empty text file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "empty.txt"), "", "utf-8"))
|
||||
|
||||
const result = yield* read("empty.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("handles multi-line text files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* read("multiline.txt")
|
||||
expect(result.content).toBe("line1\nline2\nline3")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("read() - binary content", () => {
|
||||
it.instance("reads binary file via Filesystem.readArrayBuffer()", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "image.png"), binaryContent))
|
||||
|
||||
const result = yield* read("image.png")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.encoding).toBe("base64")
|
||||
expect(result.mimeType).toBe("image/png")
|
||||
expect(result.content).toBe(binaryContent.toString("base64"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("returns empty for binary non-image files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
|
||||
)
|
||||
|
||||
const result = yield* read("binary.so")
|
||||
expect(result.type).toBe("binary")
|
||||
expect(result.content).toBe("")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("read() - Filesystem.mimeType()", () => {
|
||||
it.instance("detects MIME type via Filesystem.mimeType()", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "test.json")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8"))
|
||||
|
||||
expect(AppFileSystem.mimeType(filepath)).toContain("application/json")
|
||||
|
||||
const result = yield* read("test.json")
|
||||
expect(result.type).toBe("text")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("handles various image MIME types", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const testCases = [
|
||||
{ ext: "jpg", mime: "image/jpeg" },
|
||||
{ ext: "png", mime: "image/png" },
|
||||
{ ext: "gif", mime: "image/gif" },
|
||||
{ ext: "webp", mime: "image/webp" },
|
||||
]
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const filepath = path.join(test.directory, `test.${testCase.ext}`)
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00])))
|
||||
expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("list() - Filesystem.exists() and readText()", () => {
|
||||
it.instance(
|
||||
"reads .gitignore via AppFileSystem.existsSafe() and readFileString()",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
const gitignorePath = path.join(test.directory, ".gitignore")
|
||||
yield* fsys.writeFileString(gitignorePath, "node_modules\ndist\n")
|
||||
|
||||
expect(yield* fsys.existsSafe(gitignorePath)).toBe(true)
|
||||
expect(yield* fsys.readFileString(gitignorePath)).toContain("node_modules")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"reads .ignore file similarly",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
const ignorePath = path.join(test.directory, ".ignore")
|
||||
yield* fsys.writeFileString(ignorePath, "*.log\n.env\n")
|
||||
|
||||
expect(yield* fsys.existsSafe(ignorePath)).toBe(true)
|
||||
expect(yield* fsys.readFileString(ignorePath)).toContain("*.log")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"handles missing .gitignore gracefully",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
const gitignorePath = path.join(test.directory, ".gitignore")
|
||||
expect(yield* fsys.existsSafe(gitignorePath)).toBe(false)
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(Array.isArray(nodes)).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
describe("File.changed() - AppFileSystem.readFileString() for untracked files", () => {
|
||||
it.instance(
|
||||
"reads untracked files via AppFileSystem.readFileString()",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
const untrackedPath = path.join(test.directory, "untracked.txt")
|
||||
yield* fsys.writeFileString(untrackedPath, "new content\nwith multiple lines")
|
||||
|
||||
const content = yield* fsys.readFileString(untrackedPath)
|
||||
expect(content.split("\n").length).toBe(2)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
describe("Error handling", () => {
|
||||
it.instance("handles errors gracefully in AppFileSystem.readFileString()", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fsys.writeFileString(path.join(test.directory, "readonly.txt"), "content")
|
||||
|
||||
const nonExistentPath = path.join(test.directory, "does-not-exist.txt")
|
||||
expect(Exit.isFailure(yield* fsys.readFileString(nonExistentPath).pipe(Effect.exit))).toBe(true)
|
||||
|
||||
const result = yield* read("does-not-exist.txt")
|
||||
expect(result.content).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("handles errors in AppFileSystem.readFile()", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
const nonExistentPath = path.join(test.directory, "does-not-exist.bin")
|
||||
const buffer = yield* fsys.readFile(nonExistentPath).pipe(Effect.orElseSucceed(() => new Uint8Array(0)))
|
||||
expect(buffer.byteLength).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("returns empty array buffer on error for images", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* read("broken.png")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("shouldEncode() logic", () => {
|
||||
it.instance("treats .ts files as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* read("test.ts")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("export const value = 1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("treats .mts files as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* read("test.mts")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("export const value = 1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("treats .sh files as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* read("test.sh")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("treats Dockerfile as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "Dockerfile"), "FROM alpine:3.20", "utf-8"))
|
||||
|
||||
const result = yield* read("Dockerfile")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("FROM alpine:3.20")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("returns encoding info for text files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "simple text", "utf-8"))
|
||||
|
||||
const result = yield* read("test.txt")
|
||||
expect(result.encoding).toBeUndefined()
|
||||
expect(result.type).toBe("text")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("returns base64 encoding for images", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0])),
|
||||
)
|
||||
|
||||
const result = yield* read("test.jpg")
|
||||
expect(result.encoding).toBe("base64")
|
||||
expect(result.mimeType).toBe("image/jpeg")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Path security", () => {
|
||||
it.instance("throws for paths outside project directory", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* failureMessage(read("../outside.txt"))).toContain("Access denied")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("throws for paths outside project directory", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* failureMessage(read("../outside.txt"))).toContain("Access denied")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("status()", () => {
|
||||
it.instance(
|
||||
"detects modified file",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "file.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "original\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8"))
|
||||
|
||||
const result = yield* status()
|
||||
const entry = result.find((file) => file.path === "file.txt")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("modified")
|
||||
expect(entry!.added).toBeGreaterThan(0)
|
||||
expect(entry!.removed).toBeGreaterThan(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"detects untracked file as added",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"),
|
||||
)
|
||||
|
||||
const result = yield* status()
|
||||
const entry = result.find((file) => file.path === "new.txt")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("added")
|
||||
expect(entry!.added).toBe(4)
|
||||
expect(entry!.removed).toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"detects deleted file",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "gone.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "content\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* Effect.promise(() => fs.rm(filepath))
|
||||
|
||||
const result = yield* status()
|
||||
const entries = result.filter((file) => file.path === "gone.txt")
|
||||
expect(entries.some((entry) => entry.status === "deleted")).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"detects mixed changes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "keep\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "remove.txt"), "remove\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "initial")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "changed\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.rm(path.join(test.directory, "remove.txt")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "brand-new.txt"), "hello\n", "utf-8"))
|
||||
|
||||
const result = yield* status()
|
||||
expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true)
|
||||
expect(result.some((file) => file.path === "remove.txt" && file.status === "deleted")).toBe(true)
|
||||
expect(result.some((file) => file.path === "brand-new.txt" && file.status === "added")).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance("returns empty for non-git project", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* status()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns empty for clean repo",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* status()).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"parses binary numstat as 0",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "data.bin")
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index))),
|
||||
)
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add binary")
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256))),
|
||||
)
|
||||
|
||||
const result = yield* status()
|
||||
const entry = result.find((file) => file.path === "data.bin")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("modified")
|
||||
expect(entry!.added).toBe(0)
|
||||
expect(entry!.removed).toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
describe("list()", () => {
|
||||
it.instance(
|
||||
"returns files and directories with correct shape",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8"))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8"),
|
||||
)
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(2)
|
||||
for (const node of nodes) {
|
||||
expect(node).toHaveProperty("name")
|
||||
expect(node).toHaveProperty("path")
|
||||
expect(node).toHaveProperty("absolute")
|
||||
expect(node).toHaveProperty("type")
|
||||
expect(node).toHaveProperty("ignored")
|
||||
expect(["file", "directory"]).toContain(node.type)
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"sorts directories before files, alphabetical within each",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "beta")))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "alpha")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "zz.txt"), "", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "aa.txt"), "", "utf-8"))
|
||||
|
||||
const nodes = yield* list()
|
||||
const dirs = nodes.filter((node) => node.type === "directory")
|
||||
const files = nodes.filter((node) => node.type === "file")
|
||||
const firstFile = nodes.findIndex((node) => node.type === "file")
|
||||
const lastDir = nodes.findLastIndex((node) => node.type === "directory")
|
||||
if (lastDir >= 0 && firstFile >= 0) {
|
||||
expect(lastDir).toBeLessThan(firstFile)
|
||||
}
|
||||
expect(dirs.map((dir) => dir.name)).toEqual(dirs.map((dir) => dir.name).toSorted())
|
||||
expect(files.map((file) => file.name)).toEqual(files.map((file) => file.name).toSorted())
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"excludes .git and .DS_Store",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".DS_Store"), "", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "visible.txt"), "", "utf-8"))
|
||||
|
||||
const names = (yield* list()).map((node) => node.name)
|
||||
expect(names).not.toContain(".git")
|
||||
expect(names).not.toContain(".DS_Store")
|
||||
expect(names).toContain("visible.txt")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"marks gitignored files as ignored",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".gitignore"), "*.log\nbuild/\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "app.log"), "log data", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "main.ts"), "code", "utf-8"))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "build")))
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true)
|
||||
expect(nodes.find((node) => node.name === "main.ts")?.ignored).toBe(false)
|
||||
expect(nodes.find((node) => node.name === "build")?.ignored).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"lists subdirectory contents",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "sub")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "a.txt"), "", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "b.txt"), "", "utf-8"))
|
||||
|
||||
const nodes = yield* list("sub")
|
||||
expect(nodes.length).toBe(2)
|
||||
expect(nodes.map((node) => node.name).sort()).toEqual(["a.txt", "b.txt"])
|
||||
expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"throws for paths outside project directory",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* failureMessage(list("../outside"))).toContain("Access denied")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance("works without git", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "hi", "utf-8"))
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
for (const node of nodes) {
|
||||
expect(node.ignored).toBe(false)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("search()", () => {
|
||||
it.instance(
|
||||
"empty query returns files",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* setupSearchableRepo(test.directory)
|
||||
yield* init()
|
||||
|
||||
const result = yield* search({ query: "", type: "file" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"search works before explicit init",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* setupSearchableRepo(test.directory)
|
||||
|
||||
const result = yield* search({ query: "main", type: "file" })
|
||||
expect(result.some((file) => file.includes("main"))).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"empty query returns dirs sorted with hidden last",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* setupSearchableRepo(test.directory)
|
||||
yield* init()
|
||||
|
||||
const result = yield* search({ query: "", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
const firstHidden = result.findIndex((dir) =>
|
||||
dir.split("/").some((part) => part.startsWith(".") && part.length > 1),
|
||||
)
|
||||
const lastVisible = result.findLastIndex(
|
||||
(dir) => !dir.split("/").some((part) => part.startsWith(".") && part.length > 1),
|
||||
)
|
||||
if (firstHidden >= 0 && lastVisible >= 0) {
|
||||
expect(firstHidden).toBeGreaterThan(lastVisible)
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"fuzzy matches file names",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* setupSearchableRepo(test.directory)
|
||||
yield* init()
|
||||
|
||||
const result = yield* search({ query: "main", type: "file" })
|
||||
expect(result.some((file) => file.includes("main"))).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"type filter returns only files",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* setupSearchableRepo(test.directory)
|
||||
yield* init()
|
||||
|
||||
const result = yield* search({ query: "", type: "file" })
|
||||
for (const file of result) {
|
||||
expect(file.endsWith("/")).toBe(false)
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"type filter returns only directories",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* setupSearchableRepo(test.directory)
|
||||
yield* init()
|
||||
|
||||
const result = yield* search({ query: "", type: "directory" })
|
||||
for (const dir of result) {
|
||||
expect(dir.endsWith("/")).toBe(true)
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"respects limit",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* setupSearchableRepo(test.directory)
|
||||
yield* init()
|
||||
|
||||
const result = yield* search({ query: "", type: "file", limit: 2 })
|
||||
expect(result.length).toBeLessThanOrEqual(2)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"query starting with dot prefers hidden files",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* setupSearchableRepo(test.directory)
|
||||
yield* init()
|
||||
|
||||
const result = yield* search({ query: ".hidden", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0]).toContain(".hidden")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"search refreshes after init when files change",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* setupSearchableRepo(test.directory)
|
||||
yield* init()
|
||||
expect(yield* search({ query: "fresh", type: "file" })).toEqual([])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8"))
|
||||
|
||||
expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
describe("read() - diff/patch", () => {
|
||||
it.instance(
|
||||
"returns diff and patch for modified tracked file",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "file.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "original content\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8"))
|
||||
|
||||
const result = yield* read("file.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("modified content")
|
||||
expect(result.diff).toBeDefined()
|
||||
expect(result.diff).toContain("original content")
|
||||
expect(result.diff).toContain("modified content")
|
||||
expect(result.patch).toBeDefined()
|
||||
expect(result.patch!.hunks.length).toBeGreaterThan(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns diff for staged changes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "staged.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
|
||||
const result = yield* read("staged.txt")
|
||||
expect(result.diff).toBeDefined()
|
||||
expect(result.patch).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns no diff for unmodified file",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "clean.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "unchanged\n", "utf-8"))
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
|
||||
const result = yield* read("clean.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("unchanged")
|
||||
expect(result.diff).toBeUndefined()
|
||||
expect(result.patch).toBeUndefined()
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
describe("InstanceState isolation", () => {
|
||||
it.instance(
|
||||
"two directories get independent file caches",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const one = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8"))
|
||||
yield* init()
|
||||
expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts")
|
||||
expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts")
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const two = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(two.directory, "b.ts"), "two", "utf-8"))
|
||||
yield* init()
|
||||
expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts")
|
||||
expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts")
|
||||
}).pipe(withTmpdirInstance({ git: true }))
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"disposal gives fresh state on next access",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8"))
|
||||
yield* init()
|
||||
expect(yield* search({ query: "before", type: "file" })).toContain("before.ts")
|
||||
|
||||
yield* Effect.promise(() => disposeAllInstances())
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "after.ts"), "after", "utf-8"))
|
||||
yield* Effect.promise(() => fs.rm(path.join(test.directory, "before.ts")))
|
||||
|
||||
yield* init()
|
||||
expect(yield* search({ query: "after", type: "file" })).toContain("after.ts")
|
||||
expect(yield* search({ query: "before", type: "file" })).not.toContain("before.ts")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
import { expect, describe } from "bun:test"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { File } from "../../src/file"
|
||||
import { InstanceState } from "../../src/effect/instance-state"
|
||||
import { containsPath } from "../../src/project/instance-context"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(File.defaultLayer)
|
||||
const read = (file: string) => File.use.read(file)
|
||||
const list = (dir?: string) => File.use.list(dir)
|
||||
const expectAccessDenied = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* effect.pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) throw new Error("expected access denied")
|
||||
expect(Cause.squash(exit.cause)).toHaveProperty("message", "Access denied: path escapes project directory")
|
||||
})
|
||||
|
||||
describe("Filesystem.contains", () => {
|
||||
it.effect("allows paths within project", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Filesystem.contains("/project", "/project/src")).toBe(true)
|
||||
expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true)
|
||||
expect(Filesystem.contains("/project", "/project")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("blocks ../ traversal", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Filesystem.contains("/project", "/project/../etc")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("blocks absolute paths outside project", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/tmp/file")).toBe(false)
|
||||
expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("handles prefix collision edge cases", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
/*
|
||||
* Integration tests for read() and list() path traversal protection.
|
||||
*
|
||||
* These tests verify the HTTP API code path is protected. The HTTP endpoints
|
||||
* in server.ts (GET /file/content, GET /file) call read()/list()
|
||||
* directly - they do NOT go through ReadTool or the agent permission layer.
|
||||
*
|
||||
* This is a SEPARATE code path from ReadTool, which has its own checks.
|
||||
*/
|
||||
describe("File.read path traversal protection", () => {
|
||||
it.instance("rejects ../ traversal attempting to read /etc/passwd", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Bun.write(path.join(test.directory, "allowed.txt"), "allowed content"))
|
||||
yield* expectAccessDenied(read("../../../etc/passwd"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects deeply nested traversal", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* expectAccessDenied(read("src/nested/../../../../../../../etc/passwd"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("allows valid paths within project", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Bun.write(path.join(test.directory, "valid.txt"), "valid content"))
|
||||
|
||||
const result = yield* read("valid.txt")
|
||||
expect(result.content).toBe("valid content")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("File.list path traversal protection", () => {
|
||||
it.instance("rejects ../ traversal attempting to list /etc", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* expectAccessDenied(list("../../../etc"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("allows valid subdirectory listing", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Bun.write(path.join(test.directory, "subdir", "file.txt"), "content"))
|
||||
|
||||
const result = yield* list("subdir")
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("containsPath", () => {
|
||||
it.instance(
|
||||
"returns true for path inside directory",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceState.context
|
||||
expect(containsPath(path.join(test.directory, "foo.txt"), ctx)).toBe(true)
|
||||
expect(containsPath(path.join(test.directory, "src", "file.ts"), ctx)).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns true for path inside worktree but outside directory (monorepo subdirectory scenario)",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const subdir = path.join(test.directory, "packages", "lib")
|
||||
yield* Effect.promise(() => fs.mkdir(subdir, { recursive: true }))
|
||||
const ctx = { ...(yield* InstanceState.context), directory: subdir }
|
||||
|
||||
// .opencode at worktree root, but we're running from packages/lib
|
||||
expect(containsPath(path.join(test.directory, ".opencode", "state"), ctx)).toBe(true)
|
||||
// sibling package should also be accessible
|
||||
expect(containsPath(path.join(test.directory, "packages", "other", "file.ts"), ctx)).toBe(true)
|
||||
// worktree root itself
|
||||
expect(containsPath(test.directory, ctx)).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns false for path outside both directory and worktree",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
expect(containsPath("/etc/passwd", ctx)).toBe(false)
|
||||
expect(containsPath("/tmp/other-project", ctx)).toBe(false)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns false for path with .. escaping worktree",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceState.context
|
||||
expect(containsPath(path.join(test.directory, "..", "escape.txt"), ctx)).toBe(false)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"handles directory === worktree (running from repo root)",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceState.context
|
||||
expect(ctx.directory).toBe(ctx.worktree)
|
||||
expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true)
|
||||
expect(containsPath("/etc/passwd", ctx)).toBe(false)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance("non-git project does not allow arbitrary paths via worktree='/'", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceState.context
|
||||
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
|
||||
expect(containsPath(path.join(test.directory, "file.txt"), ctx)).toBe(true)
|
||||
expect(containsPath("/etc/passwd", ctx)).toBe(false)
|
||||
expect(containsPath("/tmp/other", ctx)).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Ripgrep.defaultLayer)
|
||||
|
||||
const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
|
||||
(dir) =>
|
||||
Effect.promise(() =>
|
||||
fs.rm(dir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
}),
|
||||
).pipe(Effect.ignore),
|
||||
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
|
||||
|
||||
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
|
||||
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
const collectFiles = (input: Ripgrep.FilesInput) =>
|
||||
Ripgrep.Service.use((rg) =>
|
||||
rg.files(input).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
),
|
||||
)
|
||||
|
||||
const withRipgrepConfig = <A, E, R>(value: string, effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const prev = process.env["RIPGREP_CONFIG_PATH"]
|
||||
process.env["RIPGREP_CONFIG_PATH"] = value
|
||||
return prev
|
||||
}),
|
||||
() => effect,
|
||||
(prev) =>
|
||||
Effect.sync(() => {
|
||||
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
|
||||
else process.env["RIPGREP_CONFIG_PATH"] = prev
|
||||
}),
|
||||
)
|
||||
|
||||
describe("file.ripgrep", () => {
|
||||
it.live("defaults to include hidden", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "visible.txt"), "hello")
|
||||
yield* mkdir(path.join(dir, ".opencode"))
|
||||
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir })
|
||||
expect(files.includes("visible.txt")).toBe(true)
|
||||
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("hidden false excludes hidden", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "visible.txt"), "hello")
|
||||
yield* mkdir(path.join(dir, ".opencode"))
|
||||
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, hidden: false })
|
||||
expect(files.includes("visible.txt")).toBe(true)
|
||||
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search returns empty when nothing matches", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n"))
|
||||
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search returns match metadata with normalized path", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mkdir(path.join(dir, "src"))
|
||||
yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
|
||||
expect(result.items[0]?.line_number).toBe(1)
|
||||
expect(result.items[0]?.lines.text).toContain("needle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search returns matched rows with glob filter", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||
yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toContain("match.ts")
|
||||
expect(result.items[0]?.lines.text).toContain("needle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search supports explicit file targets", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||
yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const file = path.join(dir, "match.ts")
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", file: [file] })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe(file)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files returns empty when glob matches no files", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mkdir(path.join(dir, "packages", "console"))
|
||||
yield* write(path.join(dir, "packages", "console", "package.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] })
|
||||
expect(files).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files returns stream of filenames", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "a.txt"), "hello")
|
||||
yield* write(path.join(dir, "b.txt"), "world")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort()))
|
||||
expect(files).toEqual(["a.txt", "b.txt"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files respects glob filter", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "keep.ts"), "yes")
|
||||
yield* write(path.join(dir, "skip.txt"), "no")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] })
|
||||
expect(files).toEqual(["keep.ts"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files dies on nonexistent directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Ripgrep.Service.use((rg) =>
|
||||
rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
|
||||
).pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
|
||||
|
||||
const result = yield* withRipgrepConfig(
|
||||
path.join(dir, "missing-ripgreprc"),
|
||||
Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
|
||||
)
|
||||
expect(result.items).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
|
||||
|
||||
const result = yield* withRipgrepConfig(
|
||||
path.join(dir, "missing-ripgreprc"),
|
||||
Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
|
||||
)
|
||||
expect(result.items).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { realpath } from "fs/promises"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { ConfigProvider, Deferred, Duration, Effect, Layer, Option } from "effect"
|
||||
import { TestInstance, provideInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Git } from "../../src/git"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
|
||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const watcherConfigLayer = ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
||||
}),
|
||||
)
|
||||
|
||||
const watcherLayer = FileWatcher.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(watcherConfigLayer),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Git.defaultLayer))
|
||||
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
|
||||
/** Run `body` with a live FileWatcher service. */
|
||||
function withWatcher<A, E, R>(directory: string, body: Effect.Effect<A, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* FileWatcher.Service
|
||||
yield* watcher.init()
|
||||
yield* ready(directory)
|
||||
return yield* body
|
||||
}).pipe(Effect.provide(watcherLayer), provideInstance(directory), Effect.scoped)
|
||||
}
|
||||
|
||||
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
||||
let done = false
|
||||
|
||||
const on = (evt: GlobalEvent) => {
|
||||
if (done) return
|
||||
if (evt.directory !== directory) return
|
||||
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
||||
if (!check(evt.payload.properties)) return
|
||||
hit(evt.payload.properties)
|
||||
}
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
|
||||
return () => {
|
||||
if (done) return
|
||||
done = true
|
||||
GlobalBus.off("event", on)
|
||||
}
|
||||
}
|
||||
|
||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const deferred = yield* Deferred.make<WatcherEvent>()
|
||||
const cleanup = yield* Effect.sync(() => {
|
||||
let off = () => {}
|
||||
off = listen(directory, check, (evt) => {
|
||||
off()
|
||||
Effect.runFork(Deferred.succeed(deferred, evt))
|
||||
})
|
||||
return off
|
||||
})
|
||||
return { cleanup, deferred }
|
||||
})
|
||||
}
|
||||
|
||||
function maybeNextUpdate<E>(
|
||||
directory: string,
|
||||
check: (evt: WatcherEvent) => boolean,
|
||||
trigger: Effect.Effect<void, E>,
|
||||
timeout: Duration.Input = "5 seconds",
|
||||
) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(directory, check),
|
||||
({ deferred }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trigger
|
||||
return yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeout))
|
||||
}),
|
||||
({ cleanup }) => Effect.sync(cleanup),
|
||||
)
|
||||
}
|
||||
|
||||
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
||||
return Effect.gen(function* () {
|
||||
const result = yield* maybeNextUpdate(directory, check, trigger)
|
||||
if (Option.isSome(result)) return result.value
|
||||
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
|
||||
})
|
||||
}
|
||||
|
||||
function eventuallyUpdate<E>(
|
||||
directory: string,
|
||||
check: (evt: WatcherEvent) => boolean,
|
||||
trigger: () => Effect.Effect<void, E>,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
while (true) {
|
||||
const result = yield* maybeNextUpdate(directory, check, trigger(), "250 millis")
|
||||
if (Option.isSome(result)) return result.value
|
||||
}
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Effect that asserts no matching event arrives within `ms`. */
|
||||
function noUpdate<E>(
|
||||
directory: string,
|
||||
check: (evt: WatcherEvent) => boolean,
|
||||
trigger: Effect.Effect<void, E>,
|
||||
ms = 500,
|
||||
) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(directory, check),
|
||||
({ deferred }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trigger
|
||||
const result = yield* Deferred.await(deferred).pipe(
|
||||
Effect.map((evt) => Option.some(evt)),
|
||||
Effect.timeoutOrElse({ duration: `${ms} millis`, orElse: () => Effect.succeed(Option.none()) }),
|
||||
)
|
||||
expect(result).toEqual(Option.none())
|
||||
}),
|
||||
({ cleanup }) => Effect.sync(cleanup),
|
||||
)
|
||||
}
|
||||
|
||||
function ready(directory: string) {
|
||||
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
yield* eventuallyUpdate(
|
||||
directory,
|
||||
(evt) => evt.file === file,
|
||||
() => fs.writeFileString(file, `ready-${Math.random()}`),
|
||||
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
|
||||
|
||||
if (!(yield* fs.existsSafe(head))) return
|
||||
|
||||
const realHead = yield* Effect.promise(() => realpath(head).catch(() => head))
|
||||
const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text()
|
||||
yield* eventuallyUpdate(
|
||||
directory,
|
||||
(evt) => (evt.file === head || evt.file === realHead) && evt.event !== "unlink",
|
||||
() => {
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
return fs
|
||||
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
|
||||
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`)))
|
||||
},
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeWatcher("FileWatcher", () => {
|
||||
it.instance(
|
||||
"publishes root create, update, and delete events",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const file = path.join(test.directory, "watch.txt")
|
||||
const cases = [
|
||||
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
|
||||
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
|
||||
{ event: "unlink" as const, trigger: fs.remove(file) },
|
||||
]
|
||||
|
||||
yield* withWatcher(
|
||||
test.directory,
|
||||
Effect.forEach(cases, ({ event, trigger }) =>
|
||||
nextUpdate(test.directory, (evt) => evt.file === file && evt.event === event, trigger).pipe(
|
||||
Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance("watches non-git roots", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const file = path.join(test.directory, "plain.txt")
|
||||
|
||||
yield* withWatcher(
|
||||
test.directory,
|
||||
nextUpdate(test.directory, (e) => e.file === file && e.event === "add", fs.writeFileString(file, "plain")).pipe(
|
||||
Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" }))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"cleanup stops publishing events",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const file = path.join(test.directory, "after-dispose.txt")
|
||||
|
||||
// Start and immediately stop the watcher (withWatcher disposes on exit).
|
||||
yield* withWatcher(test.directory, Effect.void)
|
||||
|
||||
// Now write a file - no watcher should be listening.
|
||||
yield* noUpdate(test.directory, (e) => e.file === file, fs.writeFileString(file, "gone")).pipe(
|
||||
provideInstance(test.directory),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"ignores .git/index changes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const gitIndex = path.join(test.directory, ".git", "index")
|
||||
const edit = path.join(test.directory, "tracked.txt")
|
||||
|
||||
yield* withWatcher(
|
||||
test.directory,
|
||||
noUpdate(
|
||||
test.directory,
|
||||
(e) => e.file === gitIndex,
|
||||
fs.writeFileString(edit, "a").pipe(Effect.andThen(git.run(["add", "."], { cwd: test.directory }))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"publishes .git/HEAD events",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const head = path.join(test.directory, ".git", "HEAD")
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* git.run(["branch", branch], { cwd: test.directory })
|
||||
|
||||
yield* withWatcher(
|
||||
test.directory,
|
||||
nextUpdate(
|
||||
test.directory,
|
||||
(evt) => evt.file === head && evt.event !== "unlink",
|
||||
fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
|
||||
).pipe(
|
||||
Effect.tap((evt) =>
|
||||
Effect.sync(() => {
|
||||
expect(evt.file).toBe(head)
|
||||
expect(["add", "change"]).toContain(evt.event)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
// Symlink support varies by platform; skip where unavailable
|
||||
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
|
||||
|
||||
describeSymlink("symlinked .git", () => {
|
||||
it.instance(
|
||||
"publishes .git/HEAD events through a symlinked .git directory",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const dir = test.directory
|
||||
const actualGit = path.join(dir, "..", "tmp_actual_git_" + Math.random().toString(36).slice(2))
|
||||
|
||||
// Move .git to a sibling directory and replace with a symlink
|
||||
yield* Effect.promise(() => import("fs")).pipe(
|
||||
Effect.flatMap((nodeFs) =>
|
||||
Effect.all([
|
||||
Effect.promise(() => nodeFs.promises.rename(path.join(dir, ".git"), actualGit)),
|
||||
Effect.promise(() => nodeFs.promises.symlink(actualGit, path.join(dir, ".git"))),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.acquireRelease(Effect.succeed(actualGit), (p) =>
|
||||
Effect.promise(() =>
|
||||
import("fs").then((f) => f.promises.rm(p, { recursive: true, force: true }).catch(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
const head = path.join(dir, ".git", "HEAD")
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* git.run(["branch", branch], { cwd: dir })
|
||||
|
||||
yield* withWatcher(
|
||||
dir,
|
||||
nextUpdate(
|
||||
dir,
|
||||
(evt) => evt.file === path.join(actualGit, "HEAD") && evt.event !== "unlink",
|
||||
fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
|
||||
).pipe(
|
||||
Effect.tap((evt) =>
|
||||
Effect.sync(() => {
|
||||
expect(evt.file).toBe(path.join(actualGit, "HEAD"))
|
||||
expect(["add", "change"]).toContain(evt.event)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import path from "path"
|
||||
|
||||
const live = AppFileSystem.layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
const live = FSUtil.layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
const { effect: it } = testEffect(live)
|
||||
|
||||
describe("AppFileSystem", () => {
|
||||
describe("FSUtil", () => {
|
||||
describe("isDir", () => {
|
||||
it(
|
||||
"returns true for directories",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
expect(yield* fs.isDir(tmp)).toBe(true)
|
||||
}),
|
||||
|
|
@ -22,7 +22,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns false for files",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "test.txt")
|
||||
yield* fs.writeFileString(file, "hello")
|
||||
|
|
@ -33,7 +33,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns false for non-existent paths",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
|
@ -43,7 +43,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns true for files",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "test.txt")
|
||||
yield* fs.writeFileString(file, "hello")
|
||||
|
|
@ -54,7 +54,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns false for directories",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
expect(yield* fs.isFile(tmp)).toBe(false)
|
||||
}),
|
||||
|
|
@ -65,7 +65,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"round-trips JSON data",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "data.json")
|
||||
const data = { name: "test", count: 42, nested: { ok: true } }
|
||||
|
|
@ -82,7 +82,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"creates nested directories",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const nested = path.join(tmp, "a", "b", "c")
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"is idempotent",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const dir = path.join(tmp, "existing")
|
||||
yield* fs.makeDirectory(dir)
|
||||
|
|
@ -113,7 +113,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"creates parent directories if missing",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "deep", "nested", "file.txt")
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"writes directly when parent exists",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "direct.txt")
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"writes Uint8Array content",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "binary.bin")
|
||||
const content = new Uint8Array([0x00, 0x01, 0x02, 0x03])
|
||||
|
|
@ -156,7 +156,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"finds target in start directory",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
yield* fs.writeFileString(path.join(tmp, "target.txt"), "found")
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"finds target in parent directories",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
yield* fs.writeFileString(path.join(tmp, "marker"), "root")
|
||||
const child = path.join(tmp, "a", "b")
|
||||
|
|
@ -182,7 +182,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns empty array when not found",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const result = yield* fs.findUp("nonexistent", tmp, tmp)
|
||||
expect(result).toEqual([])
|
||||
|
|
@ -194,7 +194,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"finds multiple targets walking up",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
yield* fs.writeFileString(path.join(tmp, "a.txt"), "a")
|
||||
yield* fs.writeFileString(path.join(tmp, "b.txt"), "b")
|
||||
|
|
@ -215,7 +215,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"finds files matching pattern",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
yield* fs.writeFileString(path.join(tmp, "a.ts"), "a")
|
||||
yield* fs.writeFileString(path.join(tmp, "b.ts"), "b")
|
||||
|
|
@ -229,7 +229,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"supports absolute paths",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
yield* fs.writeFileString(path.join(tmp, "file.txt"), "hello")
|
||||
|
||||
|
|
@ -243,7 +243,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"matches patterns",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
|
||||
expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
|
||||
expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
|
||||
|
|
@ -255,7 +255,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"finds files walking up directories",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
yield* fs.writeFileString(path.join(tmp, "root.md"), "root")
|
||||
const child = path.join(tmp, "a", "b")
|
||||
|
|
@ -273,7 +273,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"exists works",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "exists.txt")
|
||||
yield* fs.writeFileString(file, "yes")
|
||||
|
|
@ -286,7 +286,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"remove works",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* fs.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "delete-me.txt")
|
||||
yield* fs.writeFileString(file, "bye")
|
||||
|
|
@ -300,20 +300,20 @@ describe("AppFileSystem", () => {
|
|||
|
||||
describe("pure helpers", () => {
|
||||
test("mimeType returns correct types", () => {
|
||||
expect(AppFileSystem.mimeType("file.json")).toBe("application/json")
|
||||
expect(AppFileSystem.mimeType("image.png")).toBe("image/png")
|
||||
expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream")
|
||||
expect(FSUtil.mimeType("file.json")).toBe("application/json")
|
||||
expect(FSUtil.mimeType("image.png")).toBe("image/png")
|
||||
expect(FSUtil.mimeType("unknown.qzx")).toBe("application/octet-stream")
|
||||
})
|
||||
|
||||
test("contains checks path containment", () => {
|
||||
expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true)
|
||||
expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false)
|
||||
expect(FSUtil.contains("/a/b", "/a/b/c")).toBe(true)
|
||||
expect(FSUtil.contains("/a/b", "/a/c")).toBe(false)
|
||||
})
|
||||
|
||||
test("overlaps detects overlapping paths", () => {
|
||||
expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true)
|
||||
expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true)
|
||||
expect(AppFileSystem.overlaps("/a", "/b")).toBe(false)
|
||||
expect(FSUtil.overlaps("/a/b", "/a/b/c")).toBe(true)
|
||||
expect(FSUtil.overlaps("/a/b/c", "/a/b")).toBe(true)
|
||||
expect(FSUtil.overlaps("/a", "/b")).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Layer } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
|
|
@ -23,7 +23,7 @@ export const workspaceLayerWithRuntimeFlags = (overrides: Partial<RuntimeFlags.I
|
|||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(overrides)),
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
// without changing the fixture. Long-lived commands like `serve` will need a
|
||||
// different return shape — see the TODO at the bottom of OpencodeCli.
|
||||
import { test, type TestOptions } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
|
|
@ -182,7 +182,7 @@ export function withCliFixture<A, E>(
|
|||
): Effect.Effect<A, E | unknown, Scope.Scope> {
|
||||
return Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const appProc = yield* AppProcess.Service
|
||||
|
||||
// FileSystem.makeTempDirectoryScoped handles both creation and scope-tied
|
||||
|
|
@ -408,7 +408,7 @@ export function withCliFixture<A, E>(
|
|||
// and hit endpoints on `opencode.serve()` without rolling their own fetch.
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.layer, AppFileSystem.defaultLayer, AppProcess.defaultLayer),
|
||||
Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.layer, FSUtil.defaultLayer, AppProcess.defaultLayer),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { McpAuth } from "../../src/mcp/auth"
|
||||
|
||||
|
|
@ -11,11 +11,11 @@ function authFile() {
|
|||
let sawOverlap = false
|
||||
|
||||
const layer = Layer.effect(
|
||||
AppFileSystem.Service,
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
return AppFileSystem.Service.of({
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
readJson: (file) =>
|
||||
file.endsWith("mcp-auth.json")
|
||||
|
|
@ -24,7 +24,7 @@ function authFile() {
|
|||
if (!raw) throw new Error("mcp-auth.json missing")
|
||||
return JSON.parse(raw)
|
||||
},
|
||||
catch: (cause) => new AppFileSystem.FileSystemError({ method: "readJson", cause }),
|
||||
catch: (cause) => new FSUtil.FileSystemError({ method: "readJson", cause }),
|
||||
})
|
||||
: fs.readJson(file),
|
||||
writeJson: (file, value, mode) =>
|
||||
|
|
@ -41,12 +41,12 @@ function authFile() {
|
|||
: fs.writeJson(file, value, mode),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
return { layer, raw: () => raw }
|
||||
}
|
||||
|
||||
function authService(layer: Layer.Layer<AppFileSystem.Service>) {
|
||||
function authService(layer: Layer.Layer<FSUtil.Service>) {
|
||||
return McpAuth.Service.use((auth) => Effect.succeed(auth)).pipe(
|
||||
Effect.provide(McpAuth.layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(layer))),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
|||
const { Config } = await import("../../src/config/config")
|
||||
const { McpAuth } = await import("../../src/mcp/auth")
|
||||
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
||||
const { AppFileSystem } = await import("@opencode-ai/core/filesystem")
|
||||
const { FSUtil } = await import("@opencode-ai/core/fs-util")
|
||||
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
|
||||
|
||||
const mcpTest = testEffect(
|
||||
|
|
@ -126,7 +126,7 @@ const mcpTest = testEffect(
|
|||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
),
|
||||
McpAuth.defaultLayer,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
|||
const { Config } = await import("../../src/config/config")
|
||||
const { McpAuth } = await import("../../src/mcp/auth")
|
||||
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
||||
const { AppFileSystem } = await import("@opencode-ai/core/filesystem")
|
||||
const { FSUtil } = await import("@opencode-ai/core/fs-util")
|
||||
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
|
||||
const mcpTest = testEffect(
|
||||
MCP.layer.pipe(
|
||||
|
|
@ -118,7 +118,7 @@ const mcpTest = testEffect(
|
|||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
),
|
||||
)
|
||||
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import * as fs from "fs/promises"
|
|||
import * as path from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { Patch } from "../../src/patch"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppFileSystem.defaultLayer)
|
||||
const it = testEffect(FSUtil.defaultLayer)
|
||||
|
||||
describe("Patch namespace", () => {
|
||||
let tempDir: string
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ import { testEffect } from "../lib/effect"
|
|||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer))
|
||||
|
||||
function layer(directory: string, plugins: string[]) {
|
||||
return ProviderAuth.layer.pipe(
|
||||
|
|
@ -49,7 +49,7 @@ describe("plugin.auth-override", () => {
|
|||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const pluginDir = path.join(tmp.directory, ".opencode", "plugin")
|
||||
|
||||
yield* fs.writeWithDirs(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import fs from "fs/promises"
|
|||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
|
@ -20,9 +20,7 @@ afterEach(async () => {
|
|||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, testInstanceStoreLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer))
|
||||
|
||||
function withTmp<T, A, E, R>(
|
||||
init: (dir: string) => Promise<T>,
|
||||
|
|
@ -839,7 +837,7 @@ describe("plugin.loader.shared", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* load(tmp.path)
|
||||
expect(
|
||||
(yield* (yield* AppFileSystem.Service).readJson(tmp.extra.mark)) as { source: string; enabled: boolean },
|
||||
(yield* (yield* FSUtil.Service).readJson(tmp.extra.mark)) as { source: string; enabled: boolean },
|
||||
).toEqual({
|
||||
source: "tuple",
|
||||
enabled: true,
|
||||
|
|
@ -962,7 +960,7 @@ export default {
|
|||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.extra.mod, "package.json")
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const json = (yield* fsys.readJson(file)) as Record<string, unknown>
|
||||
const list = readPackageThemes("acme-plugin", {
|
||||
dir: tmp.extra.mod,
|
||||
|
|
@ -971,8 +969,8 @@ export default {
|
|||
})
|
||||
|
||||
expect(list).toEqual([
|
||||
AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
|
||||
AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
|
||||
FSUtil.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
|
||||
FSUtil.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
|
||||
])
|
||||
}),
|
||||
),
|
||||
|
|
@ -1036,7 +1034,7 @@ export default {
|
|||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
target: tmp.extra.mod,
|
||||
themes: [AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
themes: [FSUtil.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
},
|
||||
])
|
||||
expect(missing).toHaveLength(0)
|
||||
|
|
@ -1099,7 +1097,7 @@ export default {
|
|||
expect(loaded).toEqual([
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
themes: [AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
themes: [FSUtil.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
|
|
@ -1120,7 +1118,7 @@ export default {
|
|||
},
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const json = (yield* fsys.readJson(tmp.extra.file)) as Record<string, unknown>
|
||||
expect(() =>
|
||||
readPackageThemes("acme", {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
|||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
|
|
@ -21,7 +21,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"
|
|||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
|
|
@ -29,7 +29,7 @@ import { NpmTest } from "../fake/npm"
|
|||
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
|
|
@ -51,7 +51,7 @@ const workspaceLayer = Workspace.layer.pipe(
|
|||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
|||
import { Cause, Effect, Exit, Layer, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
|
|
@ -76,7 +76,7 @@ function projectLayerWithFailure(failArg: string) {
|
|||
Layer.provide(mockGitFailure(failArg)),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
|
|
@ -88,7 +88,7 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
|
|||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { parsePatch } from "diff"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
tmpdirScoped,
|
||||
} from "../fixture/fixture"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Git } from "../../src/git"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
|
@ -27,7 +27,7 @@ const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
|
|||
const layer = Layer.mergeAll(
|
||||
Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(EventV2Bridge.defaultLayer)),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
)
|
||||
const it = testEffect(layer)
|
||||
const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer))
|
||||
|
|
@ -38,11 +38,11 @@ const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) {
|
|||
})
|
||||
|
||||
const write = Effect.fn("VcsTest.write")(function* (file: string, content: string) {
|
||||
yield* AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content))
|
||||
yield* FSUtil.Service.use((fs) => fs.writeWithDirs(file, content))
|
||||
})
|
||||
|
||||
const remove = Effect.fn("VcsTest.remove")(function* (file: string) {
|
||||
yield* AppFileSystem.Service.use((fs) => fs.remove(file))
|
||||
yield* FSUtil.Service.use((fs) => fs.remove(file))
|
||||
})
|
||||
|
||||
const symlink = (target: string, file: string) => Effect.promise(() => fs.symlink(target, file))
|
||||
|
|
@ -73,7 +73,7 @@ const publishHeadChangeUntil = Effect.fn("VcsTest.publishHeadChangeUntil")(funct
|
|||
) {
|
||||
const events = yield* EventV2Bridge.Service
|
||||
for (let i = 0; i < 50; i++) {
|
||||
yield* events.publish(FileWatcher.Event.Updated, { file: head, event: "change" })
|
||||
yield* events.publish(Watcher.Event.Updated, { file: head, event: "change" })
|
||||
if (yield* Deferred.isDone(pending)) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||
|
|
@ -10,7 +10,7 @@ import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/f
|
|||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(Worktree.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer),
|
||||
Layer.mergeAll(Worktree.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer),
|
||||
)
|
||||
const wintest = process.platform !== "win32" ? it.instance : it.instance.skip
|
||||
|
||||
|
|
@ -265,7 +265,7 @@ describe("Worktree", () => {
|
|||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const svc = yield* Worktree.Service
|
||||
const parent = path.join(path.dirname(test.directory), `${path.basename(test.directory)}-parent`)
|
||||
const target = path.join(parent, path.basename(test.directory))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { mkdir, unlink } from "fs/promises"
|
|||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { disposeAllInstances, provideInstanceEffect, tmpdirScoped, TestInstance } from "../fixture/fixture"
|
||||
|
|
@ -57,7 +57,7 @@ afterEach(async () => {
|
|||
|
||||
const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Provider.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "../../src/config/config"
|
||||
|
|
@ -25,11 +25,11 @@ const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||
)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()),
|
||||
Layer.mergeAll(FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()),
|
||||
)
|
||||
const references = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
referenceLayer({ experimentalReferences: true }),
|
||||
|
|
@ -69,11 +69,11 @@ const git = Effect.fn("ReferenceTest.git")(function* (cwd: string, args: string[
|
|||
})
|
||||
|
||||
const waitForContent = (
|
||||
fs: AppFileSystem.Interface,
|
||||
fs: FSUtil.Interface,
|
||||
file: string,
|
||||
content: string,
|
||||
attempts = 50,
|
||||
): Effect.Effect<void, AppFileSystem.Error> =>
|
||||
): Effect.Effect<void, FSUtil.Error> =>
|
||||
Effect.gen(function* () {
|
||||
if ((yield* fs.readFileStringSafe(file)) === content) return
|
||||
if (attempts <= 0) throw new Error(`timed out waiting for ${file}`)
|
||||
|
|
@ -201,7 +201,7 @@ describe("reference", () => {
|
|||
provideTmpdirInstance(
|
||||
(_dir) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo")
|
||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||
|
|
@ -245,7 +245,7 @@ describe("reference", () => {
|
|||
|
||||
references.live("refreshes configured git references on new instance init", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo")
|
||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ describe("file HttpApi", () => {
|
|||
expect(await content.json()).toMatchObject({ type: "text", content: "hello" })
|
||||
|
||||
expect(status.status).toBe(200)
|
||||
expect(await status.json()).toContainEqual({ path: "hello.txt", added: 1, removed: 0, status: "added" })
|
||||
expect(await status.json()).toEqual([])
|
||||
})
|
||||
|
||||
test("serves search endpoints", async () => {
|
||||
|
|
|
|||
|
|
@ -25,11 +25,6 @@ function app() {
|
|||
type TestApp = ReturnType<typeof app>
|
||||
type TestHandler = ReturnType<typeof HttpApiApp.webHandler>
|
||||
|
||||
const handlerScoped = Effect.acquireRelease(
|
||||
Effect.sync(() => HttpApiApp.webHandler()),
|
||||
(handler) => Effect.promise(() => handler.dispose()).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const request = Effect.fnUntraced(function* (
|
||||
handler: TestHandler,
|
||||
route: string,
|
||||
|
|
@ -69,7 +64,7 @@ describe("mcp HttpApi", () => {
|
|||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const handler = yield* handlerScoped
|
||||
const handler = HttpApiApp.webHandler()
|
||||
const response = yield* request(handler, McpPaths.status, tmp.directory)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
|
@ -93,7 +88,7 @@ describe("mcp HttpApi", () => {
|
|||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const handler = yield* handlerScoped
|
||||
const handler = HttpApiApp.webHandler()
|
||||
const added = yield* request(handler, McpPaths.status, tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
|
|
@ -139,7 +134,7 @@ describe("mcp HttpApi", () => {
|
|||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const handler = yield* handlerScoped
|
||||
const handler = HttpApiApp.webHandler()
|
||||
const start = yield* request(handler, "/mcp/demo/auth", tmp.directory, { method: "POST" })
|
||||
expect(start.status).toBe(400)
|
||||
|
||||
|
|
@ -202,7 +197,7 @@ describe("mcp HttpApi", () => {
|
|||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const handler = yield* handlerScoped
|
||||
const handler = HttpApiApp.webHandler()
|
||||
|
||||
for (const input of [
|
||||
{ method: "POST", route: "/mcp/missing/auth" },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
|
@ -18,7 +18,7 @@ const testStateLayer = Layer.effectDiscard(
|
|||
),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer, httpApiLayer))
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, httpApiLayer))
|
||||
const projectOptions = { config: { formatter: false, lsp: false } }
|
||||
const providerID = "test-oauth-parity"
|
||||
const oauthURL = "https://example.com/oauth"
|
||||
|
|
@ -107,7 +107,7 @@ function requestCallback(input: { providerID: string; method: number; headers: H
|
|||
|
||||
function writeProviderAuthPlugin(dir: string) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||
|
||||
yield* fs.writeWithDirs(
|
||||
|
|
@ -142,7 +142,7 @@ function writeProviderAuthPlugin(dir: string) {
|
|||
|
||||
function writeProviderAuthValidationPlugin(dir: string) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||
|
||||
yield* fs.writeWithDirs(
|
||||
|
|
@ -184,7 +184,7 @@ function writeProviderAuthValidationPlugin(dir: string) {
|
|||
|
||||
function writeFunctionOptionsPlugin(dir: string) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||
|
||||
yield* fs.writeWithDirs(
|
||||
|
|
@ -216,7 +216,7 @@ function writeFunctionOptionsPlugin(dir: string) {
|
|||
|
||||
function writeProviderModelsMutationPlugin(dir: string) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||
|
||||
yield* fs.writeWithDirs(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue