refactor(core): consolidate filesystem services (#30447)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Deferred, Effect, Layer } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
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 { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
@@ -30,7 +30,7 @@ import { httpApiLayer } from "./httpapi-layer"
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
|
||||
Database.defaultLayer,
|
||||
@@ -50,7 +50,7 @@ type Captured = { status: number; data?: unknown; error?: unknown }
|
||||
type ProjectFixture = { sdk: Sdk; directory: string }
|
||||
type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
|
||||
type TestServices =
|
||||
| AppFileSystem.Service
|
||||
| FSUtil.Service
|
||||
| ChildProcessSpawner.ChildProcessSpawner
|
||||
| InstanceStore.Service
|
||||
| HttpServer.HttpServer
|
||||
@@ -262,7 +262,7 @@ function withFakeLlmProject<A, E>(
|
||||
}
|
||||
|
||||
function writeStandardFiles(dir: string) {
|
||||
return AppFileSystem.Service.use((fs) =>
|
||||
return FSUtil.Service.use((fs) =>
|
||||
Effect.all([
|
||||
fs.writeWithDirs(path.join(dir, "hello.txt"), "hello"),
|
||||
fs.writeWithDirs(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n"),
|
||||
@@ -271,7 +271,7 @@ function writeStandardFiles(dir: string) {
|
||||
}
|
||||
|
||||
function writeProjectSkill(dir: string) {
|
||||
return AppFileSystem.Service.use((fs) =>
|
||||
return FSUtil.Service.use((fs) =>
|
||||
fs.writeWithDirs(
|
||||
path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"),
|
||||
`---
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
@@ -42,7 +42,7 @@ const testStateLayer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer, RuntimeFlags.layer()))
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, RuntimeFlags.layer()))
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
@@ -89,7 +89,7 @@ function uiApp(input?: {
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
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) =>
|
||||
@@ -99,7 +99,7 @@ function uiApp(input?: {
|
||||
).pipe(
|
||||
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
|
||||
Layer.provide([
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
input?.client ?? httpClient(new Response("ui")),
|
||||
RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }),
|
||||
HttpServer.layerServices,
|
||||
@@ -132,7 +132,7 @@ function routeOrderingApp() {
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
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("GET", "/session/:sessionID", () =>
|
||||
@@ -144,7 +144,7 @@ function routeOrderingApp() {
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide([
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
|
||||
httpClient(new Response("ui"), (request) => {
|
||||
proxiedUrl = request.url
|
||||
@@ -210,7 +210,7 @@ describe("HttpApi UI fallback", () => {
|
||||
let proxiedUrl: string | undefined
|
||||
|
||||
const response = yield* Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), {
|
||||
@@ -260,7 +260,7 @@ describe("HttpApi UI fallback", () => {
|
||||
it.live("strips upstream transfer-encoding header from proxied assets", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), {
|
||||
@@ -303,7 +303,7 @@ describe("HttpApi UI fallback", () => {
|
||||
Effect.gen(function* () {
|
||||
let readPath: string | undefined
|
||||
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const response = yield* serveEmbeddedUIEffect(
|
||||
"/assets/app.js",
|
||||
{
|
||||
@@ -330,7 +330,7 @@ describe("HttpApi UI fallback", () => {
|
||||
Effect.gen(function* () {
|
||||
const script = 'document.documentElement.dataset.theme = "dark"'
|
||||
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const response = yield* serveEmbeddedUIEffect(
|
||||
"/",
|
||||
{
|
||||
|
||||
@@ -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 { Effect, Layer } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import path from "path"
|
||||
@@ -24,9 +24,7 @@ afterEach(async () => {
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(AppFileSystem.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer))
|
||||
|
||||
function request(directory: string, url: string, init: RequestInit = {}) {
|
||||
return requestInDirectory(url, directory, init)
|
||||
@@ -57,7 +55,7 @@ describe("project.initGit endpoint", () => {
|
||||
it.instance("initializes git and reloads immediately", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const events = yield* collectGlobalEvents()
|
||||
|
||||
const init = yield* request(tmp.directory, "/project/git/init", {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Effect, FileSystem, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
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 { Instruction } from "../../src/session/instruction"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
@@ -24,7 +24,7 @@ const configLayer = TestConfig.layer()
|
||||
const instructionLayer = (global: Partial<Global.Interface>, flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Instruction.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Global.layerWith(global)),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||
@@ -272,7 +272,7 @@ const modelsFixture = Filesystem.readJson<Record<string, ModelsDev.Provider>>(
|
||||
function recordedNativeLLMLayer(scenario: RecordedScenario) {
|
||||
const auth = authLayer(scenario)
|
||||
const provider = Provider.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(auth),
|
||||
|
||||
@@ -28,7 +28,7 @@ import { Session } from "@/session/session"
|
||||
import { SessionMessageTable } from "@opencode-ai/core/session/sql"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
@@ -47,7 +47,7 @@ import { ToolRegistry } from "@/tool/registry"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
import { Reference } from "../../src/reference/reference"
|
||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||
@@ -179,7 +179,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
|
||||
ProviderSvc.defaultLayer,
|
||||
lsp,
|
||||
mcp,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
BackgroundJob.defaultLayer,
|
||||
status,
|
||||
Database.defaultLayer,
|
||||
@@ -296,12 +296,12 @@ function providerCfg(url: string) {
|
||||
}
|
||||
|
||||
const writeText = Effect.fn("test.writeText")(function* (file: string, text: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* fs.writeWithDirs(file, text)
|
||||
})
|
||||
|
||||
const ensureDir = Effect.fn("test.ensureDir")(function* (dir: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* fs.ensureDir(dir)
|
||||
})
|
||||
|
||||
@@ -1619,7 +1619,7 @@ unixNoLLMServer(
|
||||
Effect.gen(function* () {
|
||||
const { prompt, chat } = yield* boot()
|
||||
const { directory: dir } = yield* TestInstance
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const afs = yield* FSUtil.Service
|
||||
const ready = path.join(dir, ".trap-ready")
|
||||
|
||||
const sh = yield* prompt
|
||||
|
||||
@@ -56,9 +56,9 @@ import { SessionStatus } from "../../src/session/status"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
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 { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
import { Reference } from "../../src/reference/reference"
|
||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||
@@ -127,7 +127,7 @@ function makeHttp() {
|
||||
ProviderSvc.defaultLayer,
|
||||
lsp,
|
||||
mcp,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
BackgroundJob.defaultLayer,
|
||||
status,
|
||||
Database.defaultLayer,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { which } from "../../src/util/which"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
|
||||
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
|
||||
const prev = process.env.SHELL
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, beforeAll, afterAll } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Discovery } from "../../src/skill/discovery"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -14,7 +14,7 @@ let downloadCount = 0
|
||||
|
||||
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
|
||||
const cacheDir = path.join(Global.Path.cache, "skills")
|
||||
const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, FSUtil.defaultLayer))
|
||||
|
||||
beforeAll(async () => {
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
@@ -52,7 +52,7 @@ afterAll(async () => {
|
||||
describe("Discovery.pull", () => {
|
||||
it.live("downloads skills from cloudflare url", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
@@ -66,7 +66,7 @@ describe("Discovery.pull", () => {
|
||||
|
||||
it.live("url without trailing slash works", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
@@ -96,7 +96,7 @@ describe("Discovery.pull", () => {
|
||||
|
||||
it.live("downloads reference files alongside SKILL.md", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
// find a skill dir that should have reference files (e.g. agents-sdk)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { Config } from "../../src/config/config"
|
||||
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 { Global } from "@opencode-ai/core/global"
|
||||
import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -22,7 +22,7 @@ const itWithoutClaudeCodeSkills = testEffect(
|
||||
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.layer({ disableClaudeCodeSkills: true })),
|
||||
),
|
||||
@@ -36,7 +36,7 @@ const itWithoutExternalSkills = testEffect(
|
||||
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.layer({ disableExternalSkills: true })),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
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 fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer, testInstanceStoreLayer))
|
||||
const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer))
|
||||
|
||||
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
|
||||
// with path.join (which produces \ on Windows) then normalizes back to /.
|
||||
@@ -37,12 +37,12 @@ const exec = (cwd: string, command: string[]) =>
|
||||
})
|
||||
|
||||
const write = (file: string, content: string | Uint8Array) =>
|
||||
AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content))
|
||||
const readText = (file: string) => AppFileSystem.Service.use((fs) => fs.readFileString(file))
|
||||
const exists = (file: string) => AppFileSystem.Service.use((fs) => fs.existsSafe(file))
|
||||
const mkdirp = (dir: string) => AppFileSystem.Service.use((fs) => fs.ensureDir(dir))
|
||||
FSUtil.Service.use((fs) => fs.writeWithDirs(file, content))
|
||||
const readText = (file: string) => FSUtil.Service.use((fs) => fs.readFileString(file))
|
||||
const exists = (file: string) => FSUtil.Service.use((fs) => fs.existsSafe(file))
|
||||
const mkdirp = (dir: string) => FSUtil.Service.use((fs) => fs.ensureDir(dir))
|
||||
const rm = (file: string) =>
|
||||
AppFileSystem.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.ignore))
|
||||
FSUtil.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.ignore))
|
||||
|
||||
const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) {
|
||||
const unique = Math.random().toString(36).slice(2)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, 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 { Git } from "../../src/git"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -11,11 +11,11 @@ import { testEffect } from "../lib/effect"
|
||||
|
||||
const dir = path.join(Global.Path.data, "storage")
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Storage.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Storage.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const scope = Effect.fnUntraced(function* () {
|
||||
const root = ["storage_test", crypto.randomUUID()]
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const svc = yield* Storage.Service
|
||||
yield* Effect.addFinalizer(() =>
|
||||
fs.remove(path.join(dir, ...root), { recursive: true, force: true }).pipe(Effect.ignore),
|
||||
@@ -24,10 +24,10 @@ const scope = Effect.fnUntraced(function* () {
|
||||
})
|
||||
|
||||
// remap(root) rewrites any path under Global.Path.data to live under `root` instead.
|
||||
// Used by remappedFs to build an AppFileSystem that Storage thinks is the real global
|
||||
// Used by remappedFs to build an FSUtil that Storage thinks is the real global
|
||||
// data dir but actually targets a tmp dir — letting migration tests stage legacy layouts.
|
||||
// NOTE: only the 6 methods below are intercepted. If Storage starts using a different
|
||||
// AppFileSystem method that touches Global.Path.data, add it here.
|
||||
// FSUtil method that touches Global.Path.data, add it here.
|
||||
function remap(root: string, file: string) {
|
||||
if (file === Global.Path.data) return root
|
||||
if (file.startsWith(Global.Path.data + path.sep)) return path.join(root, path.relative(Global.Path.data, file))
|
||||
@@ -36,10 +36,10 @@ 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,
|
||||
isDir: (file) => fs.isDir(remap(root, file)),
|
||||
readJson: (file) => fs.readJson(remap(root, file)),
|
||||
@@ -50,11 +50,11 @@ function remappedFs(root: string) {
|
||||
fs.glob(pattern, options?.cwd ? { ...options, cwd: remap(root, options.cwd) } : options),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
}
|
||||
|
||||
// Layer.fresh forces a new Storage instance — without it, Effect's in-test layer cache
|
||||
// returns the outer testEffect's Storage (which uses the real AppFileSystem), not a new
|
||||
// returns the outer testEffect's Storage (which uses the real FSUtil), not a new
|
||||
// one built on top of remappedFs.
|
||||
const remappedStorage = (root: string) =>
|
||||
Layer.fresh(Storage.layer.pipe(Layer.provide(remappedFs(root)), Layer.provide(Git.defaultLayer)))
|
||||
@@ -191,7 +191,7 @@ describe("Storage", () => {
|
||||
|
||||
it.live("migration 2 runs when marker contents are invalid", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const storage = path.join(tmp, "storage")
|
||||
const diffs = [
|
||||
@@ -235,7 +235,7 @@ describe("Storage", () => {
|
||||
|
||||
it.live("migration 1 tolerates malformed legacy records", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const storage = path.join(tmp, "storage")
|
||||
const legacy = path.join(tmp, "project", "legacy")
|
||||
@@ -277,7 +277,7 @@ describe("Storage", () => {
|
||||
|
||||
it.live("failed migrations do not advance the marker", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const storage = path.join(tmp, "storage")
|
||||
const legacy = path.join(tmp, "project", "legacy")
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as fs from "fs/promises"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Format } from "../../src/format"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
@@ -16,7 +16,7 @@ import { testEffect } from "../lib/effect"
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
LSP.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
EventV2Bridge.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { EditTool } from "../../src/tool/edit"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Format } from "../../src/format"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
@@ -13,7 +13,7 @@ import { Truncate } from "@/tool/truncate"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as Tool from "../../src/tool/tool"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test-edit-session"),
|
||||
@@ -32,7 +32,7 @@ afterEach(async () => {
|
||||
|
||||
const layer = Layer.mergeAll(
|
||||
LSP.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
EventV2Bridge.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
@@ -64,12 +64,12 @@ const fail = Effect.fn("EditToolTest.fail")(function* (args: Tool.InferParameter
|
||||
})
|
||||
|
||||
const put = Effect.fn("EditToolTest.put")(function* (p: string, content: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* fs.writeWithDirs(p, content)
|
||||
})
|
||||
|
||||
const load = Effect.fn("EditToolTest.load")(function* (p: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
return yield* fs.readFileString(p)
|
||||
})
|
||||
|
||||
@@ -78,11 +78,11 @@ const loadRaw = Effect.fn("EditToolTest.loadRaw")(function* (p: string) {
|
||||
})
|
||||
|
||||
const makeDirectory = Effect.fn("EditToolTest.makeDirectory")(function* (p: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* fs.makeDirectory(p)
|
||||
})
|
||||
|
||||
const onceBus = Effect.fn("EditToolTest.onceBus")(function* (def: typeof FileWatcher.Event.Updated) {
|
||||
const onceBus = Effect.fn("EditToolTest.onceBus")(function* (def: typeof Watcher.Event.Updated) {
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const deferred = yield* Deferred.make<void>()
|
||||
const unsub = yield* events.listen((event) => {
|
||||
@@ -138,7 +138,7 @@ describe("tool.edit", () => {
|
||||
it.instance("emits add event for new files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const updated = yield* onceBus(FileWatcher.Event.Updated)
|
||||
const updated = yield* onceBus(Watcher.Event.Updated)
|
||||
|
||||
yield* run({ filePath: path.join(test.directory, "new.txt"), oldString: "", newString: "content" })
|
||||
yield* Deferred.await(updated)
|
||||
@@ -230,7 +230,7 @@ describe("tool.edit", () => {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "file.txt")
|
||||
yield* put(filepath, "original")
|
||||
const updated = yield* onceBus(FileWatcher.Event.Updated)
|
||||
const updated = yield* onceBus(Watcher.Event.Updated)
|
||||
|
||||
yield* run({ filePath: filepath, oldString: "original", newString: "modified" })
|
||||
yield* Deferred.await(updated)
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { GlobTool } from "../../src/tool/glob"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
@@ -30,7 +30,7 @@ const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Layer.mergeAll(
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
@@ -148,7 +148,7 @@ describe("tool.glob", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo")
|
||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||
|
||||
@@ -11,8 +11,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { RepositoryCache } from "@/reference/repository-cache"
|
||||
@@ -33,7 +33,7 @@ const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Layer.mergeAll(
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
@@ -220,7 +220,7 @@ describe("tool.grep", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestInstance
|
||||
const appfs = yield* AppFileSystem.Service
|
||||
const appfs = yield* FSUtil.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-grep-reference", "repo")
|
||||
yield* appfs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.addFinalizer(() => appfs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
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 { LSP } from "@/lsp/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
@@ -56,13 +56,7 @@ const lsp = Layer.succeed(
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
lsp,
|
||||
),
|
||||
Layer.mergeAll(Agent.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, lsp),
|
||||
)
|
||||
|
||||
const init = Effect.fn("LspToolTest.init")(function* () {
|
||||
@@ -79,7 +73,7 @@ const run = Effect.fn("LspToolTest.run")(function* (
|
||||
})
|
||||
|
||||
const put = Effect.fn("LspToolTest.put")(function* (file: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* fs.writeWithDirs(file, "export const x = 1\n")
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Layer, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
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 { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
@@ -54,7 +54,7 @@ const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
@@ -133,11 +133,11 @@ const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]
|
||||
})
|
||||
})
|
||||
const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* fs.writeWithDirs(p, content)
|
||||
})
|
||||
const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
return yield* fs.readFileString(p)
|
||||
})
|
||||
const asks = () => {
|
||||
@@ -266,7 +266,7 @@ describe("tool.read external_directory permission", () => {
|
||||
|
||||
references.live("does not ask for external_directory permission when reading configured references", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo")
|
||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||
@@ -379,12 +379,12 @@ describe("tool.read truncation", () => {
|
||||
const content = `${"x".repeat(80)}\n`.repeat(50_000)
|
||||
yield* put(filepath, content)
|
||||
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const counter = { bytes: 0 }
|
||||
const result = yield* run({ filePath: filepath }).pipe(
|
||||
Effect.provideService(
|
||||
AppFileSystem.Service,
|
||||
AppFileSystem.Service.of({
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
stream: (file, options) =>
|
||||
fs.stream(file, options).pipe(
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Tool } from "@/tool/tool"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Question } from "@/question"
|
||||
import { Todo } from "@/session/todo"
|
||||
@@ -26,7 +26,7 @@ import { Instruction } from "@/session/instruction"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Format } from "@/format"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Reference } from "@/reference/reference"
|
||||
@@ -63,7 +63,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) =>
|
||||
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),
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Agent } from "../../src/agent/agent"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
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 { Plugin } from "../../src/plugin"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Tool } from "@/tool/tool"
|
||||
@@ -23,7 +23,7 @@ import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
const shellLayer = Layer.mergeAll(
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
@@ -1223,7 +1223,7 @@ describe("tool.shell truncation", () => {
|
||||
const filepath = (result.metadata as { outputPath?: string }).outputPath
|
||||
expect(filepath).toBeTruthy()
|
||||
|
||||
const saved = yield* (yield* AppFileSystem.Service).readFileString(filepath!)
|
||||
const saved = yield* (yield* FSUtil.Service).readFileString(filepath!)
|
||||
const lines = saved.trim().split(/\r?\n/)
|
||||
expect(lines.length).toBe(lineCount)
|
||||
expect(lines[0]).toBe("1")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -14,13 +14,13 @@ import { TestConfig } from "../fixture/config"
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, FSUtil.defaultLayer))
|
||||
|
||||
const configuredLayer = (cfg: Config.Info) =>
|
||||
Layer.mergeAll(
|
||||
Truncate.defaultLayer,
|
||||
NodeFileSystem.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
TestConfig.layer({ get: () => Effect.succeed(cfg) }),
|
||||
)
|
||||
const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg))
|
||||
@@ -30,7 +30,7 @@ describe("Truncate", () => {
|
||||
it.live("truncates large json file by bytes", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Truncate.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
|
||||
const result = yield* svc.output(content)
|
||||
|
||||
@@ -164,7 +164,7 @@ describe("Truncate", () => {
|
||||
it.live("large single-line file truncates with byte message", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Truncate.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
|
||||
const result = yield* svc.output(content)
|
||||
|
||||
@@ -187,7 +187,7 @@ describe("Truncate", () => {
|
||||
expect(result.outputPath).toBeDefined()
|
||||
expect(result.outputPath).toContain("tool_")
|
||||
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const written = yield* fsys.readFileString(result.outputPath!)
|
||||
expect(written).toBe(lines)
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { WriteTool } from "../../src/tool/write"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { Format } from "../../src/format"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
@@ -33,7 +33,7 @@ afterEach(async () => {
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
LSP.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
EventV2Bridge.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { which } from "../../src/util/which"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
async function cmd(dir: string, name: string, exec = true) {
|
||||
const ext = process.platform === "win32" ? ".cmd" : ""
|
||||
const file = path.join(dir, name + ext)
|
||||
const body = process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"
|
||||
await fs.writeFile(file, body)
|
||||
if (process.platform !== "win32") {
|
||||
await fs.chmod(file, exec ? 0o755 : 0o644)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
function env(PATH: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
PATH,
|
||||
PATHEXT: process.env["PATHEXT"],
|
||||
}
|
||||
}
|
||||
|
||||
function envPath(Path: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
Path,
|
||||
PathExt: process.env["PathExt"] ?? process.env["PATHEXT"],
|
||||
}
|
||||
}
|
||||
|
||||
function same(a: string | null, b: string) {
|
||||
if (process.platform === "win32") {
|
||||
expect(a?.toLowerCase()).toBe(b.toLowerCase())
|
||||
return
|
||||
}
|
||||
|
||||
expect(a).toBe(b)
|
||||
}
|
||||
|
||||
describe("util.which", () => {
|
||||
test("returns null when command is missing", () => {
|
||||
expect(which("opencode-missing-command-for-test")).toBeNull()
|
||||
})
|
||||
|
||||
test("finds a command from PATH override", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const bin = path.join(tmp.path, "bin")
|
||||
await fs.mkdir(bin)
|
||||
const file = await cmd(bin, "tool")
|
||||
|
||||
same(which("tool", env(bin)), file)
|
||||
})
|
||||
|
||||
test("uses first PATH match", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const a = path.join(tmp.path, "a")
|
||||
const b = path.join(tmp.path, "b")
|
||||
await fs.mkdir(a)
|
||||
await fs.mkdir(b)
|
||||
const first = await cmd(a, "dupe")
|
||||
await cmd(b, "dupe")
|
||||
|
||||
same(which("dupe", env([a, b].join(path.delimiter))), first)
|
||||
})
|
||||
|
||||
test("returns null for non-executable file on unix", async () => {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
const bin = path.join(tmp.path, "bin")
|
||||
await fs.mkdir(bin)
|
||||
await cmd(bin, "noexec", false)
|
||||
|
||||
expect(which("noexec", env(bin))).toBeNull()
|
||||
})
|
||||
|
||||
test("uses PATHEXT on windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
const bin = path.join(tmp.path, "bin")
|
||||
await fs.mkdir(bin)
|
||||
const file = path.join(bin, "pathext.CMD")
|
||||
await fs.writeFile(file, "@echo off\r\n")
|
||||
|
||||
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
|
||||
})
|
||||
|
||||
test("uses Windows Path casing fallback", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
const bin = path.join(tmp.path, "bin")
|
||||
await fs.mkdir(bin)
|
||||
const file = await cmd(bin, "mixed")
|
||||
|
||||
same(which("mixed", envPath(bin)), file)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user