Compare commits

...

6 Commits

Author SHA1 Message Date
Kit Langton 800ac64694 test(opencode): migrate file fixtures to app filesystem 2026-05-23 19:48:38 -04:00
Kit Langton ed95ba73f4 test(opencode): restore config coverage parity 2026-05-23 19:39:03 -04:00
Kit Langton 8890121da0 refactor(core): centralize service use helper 2026-05-23 19:32:04 -04:00
Kit Langton 2fc0793f1f test(opencode): use filesystem service accessors 2026-05-23 15:01:01 -04:00
Kit Langton 22f79f05cf fix(desktop): declare scheduled primitive dependency 2026-05-23 14:55:11 -04:00
Kit Langton 4655dc84c7 test(opencode): simplify config effect tests 2026-05-23 14:54:00 -04:00
26 changed files with 526 additions and 775 deletions
@@ -15,6 +15,7 @@ type ServiceUse<Identifier, Shape> = {
}
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
const cache = new Map<string, (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>>()
// This is the only dynamic boundary: TypeScript knows the accessor shape,
// but Proxy property names are runtime values.
const access = new Proxy(
@@ -22,7 +23,9 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
{
get: (_, key) => {
if (typeof key !== "string") return undefined
return (...args: unknown[]) =>
const cached = cache.get(key)
if (cached) return cached
const accessor = (...args: unknown[]) =>
tag.use((service) => {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
const method = service[key as keyof Shape]
@@ -30,6 +33,8 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
})
cache.set(key, accessor)
return accessor
},
},
)
+4 -1
View File
@@ -3,9 +3,10 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"
export namespace AppFileSystem {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
@@ -39,6 +40,8 @@ export namespace AppFileSystem {
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export const use = serviceUse(Service)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
+1 -1
View File
@@ -1,5 +1,5 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import {
FetchHttpClient,
HttpClient,
+1 -1
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Config } from "@/config/config"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
+1 -1
View File
@@ -5,7 +5,7 @@ import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Identifier } from "@/id/id"
import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref"
+1 -1
View File
@@ -1,5 +1,5 @@
import * as Log from "@opencode-ai/core/util/log"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path"
import { pathToFileURL } from "url"
import os from "os"
@@ -1,5 +1,5 @@
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Context, Effect, Layer } from "effect"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
type State = Record<string, string | undefined>
+1 -1
View File
@@ -1,5 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "path"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Context, Schema } from "effect"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import { InstanceState } from "@/effect/instance-state"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { errorMessage } from "@/util/error"
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "path"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Context, Option, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
+1 -1
View File
@@ -1,5 +1,5 @@
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
@@ -1,5 +1,5 @@
import { GlobalBus } from "@/bus/global"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceRef } from "@/effect/instance-ref"
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
+1 -1
View File
@@ -20,7 +20,7 @@ import { AppProcess } from "@opencode-ai/core/process"
import { Project as ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "project" })
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
+1 -1
View File
@@ -7,7 +7,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "@opencode-ai/core/models-dev"
import { Auth } from "../auth"
+1 -1
View File
@@ -16,7 +16,7 @@ import { Effect, Layer, Context, Schema } from "effect"
import * as DateTime from "effect/DateTime"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow, usable } from "./overflow"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session-event"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Provider } from "@/provider/provider"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer } from "effect"
import * as Stream from "effect/Stream"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Slug } from "@opencode-ai/core/util/slug"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path"
import { BackgroundJob } from "@/background/job"
import { BusEvent } from "@/bus/bus-event"
+1 -1
View File
@@ -1,5 +1,5 @@
import type * as SDK from "@opencode-ai/sdk/v2"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Account } from "@/account/account"
+1 -1
View File
@@ -12,7 +12,7 @@ import { EventID } from "./schema"
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
import type { DeepMutable } from "@opencode-ai/core/schema"
import { EventV2 } from "@opencode-ai/core/event"
import { serviceUse } from "@/effect/service-use"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EffectBridge } from "@/effect/bridge"
+431 -676
View File
@@ -15,21 +15,17 @@ import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Env } from "../../src/env"
import {
provideTestInstance,
provideTmpdirInstance,
TestInstance,
tmpdir,
tmpdirScoped,
withTestInstance,
provideInstanceEffect,
testInstanceStoreLayer,
} from "../fixture/fixture"
import { InstanceRuntime } from "@/project/instance-runtime"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
const infra = CrossSpawnSpawner.defaultLayer.pipe(
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
)
import path from "path"
import fs from "fs/promises"
import { pathToFileURL } from "url"
@@ -41,6 +37,11 @@ import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
const infra = CrossSpawnSpawner.defaultLayer.pipe(
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
)
const testFlock = EffectFlock.defaultLayer
const unexpectedHttp = HttpClient.make((request) =>
@@ -92,18 +93,21 @@ const configLayer = (
) =>
Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(options.auth ?? AuthTest.empty),
Layer.provide(options.account ?? AccountTest.empty),
Layer.provideMerge(infra),
Layer.provide(NpmTest.noop),
Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)),
Layer.provideMerge(AppFileSystem.defaultLayer),
)
const layer = configLayer()
const it = testEffect(layer)
const configIt = (options?: Parameters<typeof configLayer>[0]) => testEffect(configLayer(options))
const schemaConfig = (config: object) => ({ $schema: "https://opencode.ai/config.json", ...config })
const provideCurrentInstance = <A, E, R>(effect: Effect.Effect<A, E, R>, ctx: InstanceContext) =>
effect.pipe(Effect.provideService(InstanceRef, ctx))
@@ -112,28 +116,19 @@ const load = (ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)),
)
const saveGlobal = (config: Config.Info) =>
Effect.runPromise(
Config.use.updateGlobal(config).pipe(
Effect.map((result) => result.info),
const clearEffect = (wait = false) =>
Config.use
.invalidate()
.pipe(
Effect.scoped,
Effect.provide(layer),
),
)
const clear = async (wait = false) => {
await Effect.runPromise(Config.use.invalidate().pipe(Effect.scoped, Effect.provide(layer)))
if (wait) await InstanceRuntime.disposeAllInstances()
}
const listDirs = (ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.directories(), ctx)).pipe(
Effect.scoped,
Effect.provide(layer),
),
)
Effect.andThen(wait ? Effect.promise(() => InstanceRuntime.disposeAllInstances()) : Effect.void),
)
const clear = (wait = false) => Effect.runPromise(clearEffect(wait))
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
const originalTestToken = process.env.TEST_TOKEN
const originalConsoleToken = process.env.OPENCODE_CONSOLE_TOKEN
beforeEach(async () => {
await clear(true)
@@ -143,25 +138,97 @@ afterEach(async () => {
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
if (originalTestToken === undefined) delete process.env.TEST_TOKEN
else process.env.TEST_TOKEN = originalTestToken
if (originalConsoleToken === undefined) delete process.env.OPENCODE_CONSOLE_TOKEN
else process.env.OPENCODE_CONSOLE_TOKEN = originalConsoleToken
await clear(true)
})
async function writeManagedSettings(settings: object, filename = "opencode.json") {
await fs.mkdir(managedConfigDir, { recursive: true })
await Filesystem.write(path.join(managedConfigDir, filename), JSON.stringify(settings))
}
const writeManagedSettingsEffect = (settings: object, filename?: string) =>
Effect.promise(() => writeManagedSettings(settings, filename))
AppFileSystem.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") =>
Effect.promise(() => writeConfig(dir, config, name))
const mkdirEffect = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const writeTextEffect = (file: string, content: string) => Effect.promise(() => Filesystem.write(file, content))
AppFileSystem.use.writeWithDirs(path.join(dir, name), JSON.stringify(config))
const withInstanceDir = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.provideService(TestInstance, { directory: dir }),
provideInstanceEffect(dir),
Effect.provide(testInstanceStoreLayer),
Effect.provide(CrossSpawnSpawner.defaultLayer),
)
const withGlobalConfigDir = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.gen(function* () {
const previous = Global.Path.config
;(Global.Path as { config: string }).config = dir
yield* clearEffect(true)
return previous
}),
() => effect,
(previous) =>
Effect.gen(function* () {
;(Global.Path as { config: string }).config = previous
yield* clearEffect(true)
}),
)
const withGlobalConfig = <A, E, R>(
input: { config?: object; name?: string },
fn: (input: { dir: string }) => Effect.Effect<A, E, R>,
) =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
if (input.config) yield* writeConfigEffect(dir, schemaConfig(input.config), input.name)
return yield* withGlobalConfigDir(dir, fn({ dir }))
})
const withConfigTree = <A, E, R>(
input: { global?: object; project?: object; local?: object },
effect: Effect.Effect<A, E, R>,
) =>
Effect.gen(function* () {
const root = yield* tmpdirScoped()
const global = yield* tmpdirScoped()
const directory = path.join(root, "project")
yield* Effect.all(
[
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,
),
{ concurrency: "unbounded" },
)
return yield* withGlobalConfigDir(global, withInstanceDir(directory, effect))
})
const wellKnown = (input: {
authUrl?: string
config?: unknown
remoteConfig?: { url: string; headers?: Record<string, string> }
remote?: unknown
wellKnown?: unknown
}) => {
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
const client = remoteConfigClient({
seen,
wellKnown: input.wellKnown ?? {
...(input.config !== undefined ? { config: input.config } : {}),
...(input.remoteConfig !== undefined ? { remote_config: input.remoteConfig } : {}),
},
remote: input.remote,
})
return {
seen,
it: configIt({ auth: wellKnownAuth(input.authUrl ?? "https://example.com"), client }),
}
}
function withProcessEnv<A, E, R>(key: string, value: string | undefined, effect: Effect.Effect<A, E, R>) {
return withProcessEnvs({ [key]: value }, effect)
@@ -224,53 +291,33 @@ it.instance("loads config with defaults when no files exist", () =>
}),
)
test("creates global jsonc config with schema when no global configs exist", async () => {
await using tmp = await tmpdir()
const prev = Global.Path.config
;(Global.Path as { config: string }).config = tmp.path
await clear(true)
it.effect("creates global jsonc config with schema when no global configs exist", () =>
withGlobalConfig({}, ({ dir }) =>
Effect.gen(function* () {
yield* Config.use.get().pipe(provideInstanceEffect(dir))
try {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await load(ctx)
},
})
const content = yield* AppFileSystem.use.readFileString(path.join(dir, "opencode.jsonc"))
expect(content).toContain('"$schema": "https://opencode.ai/config.json"')
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
),
)
const content = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
expect(content).toContain('"$schema": "https://opencode.ai/config.json"')
} finally {
;(Global.Path as { config: string }).config = prev
await clear(true)
}
})
it.effect("does not create global config when OPENCODE_CONFIG_DIR is set", () =>
Effect.gen(function* () {
const custom = yield* tmpdirScoped()
yield* withGlobalConfig({}, ({ dir }) =>
withProcessEnv(
"OPENCODE_CONFIG_DIR",
custom,
Effect.gen(function* () {
yield* Config.use.get().pipe(provideInstanceEffect(dir))
test("does not create global config when OPENCODE_CONFIG_DIR is set", async () => {
await using tmp = await tmpdir()
await using custom = await tmpdir()
const prevConfig = Global.Path.config
const prevEnv = process.env.OPENCODE_CONFIG_DIR
;(Global.Path as { config: string }).config = tmp.path
process.env.OPENCODE_CONFIG_DIR = custom.path
await clear(true)
try {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await load(ctx)
},
})
expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc"))).toBe(false)
} finally {
;(Global.Path as { config: string }).config = prevConfig
if (prevEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = prevEnv
await clear(true)
}
})
expect(yield* AppFileSystem.use.existsSafe(path.join(dir, "opencode.jsonc"))).toBe(false)
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
),
)
}),
)
it.instance(
"loads JSON config file",
@@ -302,70 +349,36 @@ 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* Effect.promise(() =>
Filesystem.readJson<{ shell?: string }>(path.join(test.directory, "config.json")),
)
expect(writtenConfig.shell).toBe("")
const writtenConfig = yield* AppFileSystem.use.readJson(path.join(test.directory, "config.json"))
expect(writtenConfig).toMatchObject({ shell: "" })
}),
)
test("updates global config and omits empty shell key in json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
shell: "bash",
})
},
})
it.effect("updates global config and omits empty shell key in json", () =>
withGlobalConfig({ config: { shell: "bash" } }, ({ dir }) =>
Effect.gen(function* () {
yield* Config.use.updateGlobal({ shell: "" })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = tmp.path
await clear(true)
const writtenConfig = yield* AppFileSystem.use.readJson(path.join(dir, "opencode.json"))
expect(writtenConfig).not.toHaveProperty("shell")
}),
),
)
try {
await saveGlobal({ shell: "" })
it.effect("updates global config and omits empty shell key in jsonc", () =>
withGlobalConfig({ config: { shell: "bash", model: "test/model" }, name: "opencode.jsonc" }, ({ dir }) =>
Effect.gen(function* () {
yield* Config.use.updateGlobal({ shell: "" })
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "opencode.json"))
expect("shell" in writtenConfig).toBe(false)
} finally {
;(Global.Path as { config: string }).config = prev
await clear(true)
}
})
test("updates global config and omits empty shell key in jsonc", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.jsonc"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
shell: "bash",
model: "test/model",
}),
)
},
})
const prev = Global.Path.config
;(Global.Path as { config: string }).config = tmp.path
await clear(true)
try {
await saveGlobal({ shell: "" })
const file = path.join(tmp.path, "opencode.jsonc")
const writtenConfig = await Filesystem.readText(file)
const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(writtenConfig, file), file)
expect(writtenConfig).not.toContain('"shell"')
expect(parsed.shell).toBeUndefined()
expect(parsed.model).toBe("test/model")
} finally {
;(Global.Path as { config: string }).config = prev
await clear(true)
}
})
const file = path.join(dir, "opencode.jsonc")
const writtenConfig = yield* AppFileSystem.use.readFileString(file)
const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(writtenConfig, file), file)
expect(writtenConfig).not.toContain('"shell"')
expect(parsed.shell).toBeUndefined()
expect(parsed.model).toBe("test/model")
}),
),
)
it.instance(
"loads formatter boolean config",
@@ -422,16 +435,14 @@ it.instance("ignores legacy tui keys in opencode config", () =>
it.instance("loads JSONC config file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Filesystem.write(
path.join(test.directory, "opencode.jsonc"),
`{
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, "opencode.jsonc"),
`{
// This is a comment
"$schema": "https://opencode.ai/config.json",
"model": "test/model",
"username": "testuser"
}`,
),
)
const config = yield* Config.use.get()
expect(config.model).toBe("test/model")
@@ -484,19 +495,15 @@ 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* Effect.promise(() =>
Filesystem.write(
path.join(test.directory, "opencode.json"),
JSON.stringify({
username: "{env:PRESERVE_VAR}",
}),
),
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, "opencode.json"),
JSON.stringify({ username: "{env:PRESERVE_VAR}" }),
)
const config = yield* Config.use.get()
expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved
const content = yield* Effect.promise(() => Filesystem.readText(path.join(test.directory, "opencode.json")))
const content = yield* AppFileSystem.use.readFileString(path.join(test.directory, "opencode.json"))
expect(content).toContain("{env:PRESERVE_VAR}")
expect(content).not.toContain("secret_value")
expect(content).toContain("$schema")
@@ -507,7 +514,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* Effect.promise(() => Filesystem.write(path.join(test.directory, "included.txt"), "test-user"))
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "test-user")
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.txt}",
@@ -520,9 +527,7 @@ it.instance("handles file inclusion substitution", () =>
it.instance("handles file inclusion with replacement tokens", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Filesystem.write(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`"),
)
yield* AppFileSystem.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}",
@@ -532,10 +537,8 @@ it.instance("handles file inclusion with replacement tokens", () =>
}),
)
test("resolves env templates in account config with account token", async () => {
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
const fakeAccount = Layer.mock(Account.Service)({
const accountTokenIt = configIt({
account: Layer.mock(Account.Service)({
active: () =>
Effect.succeed(
Option.some({
@@ -567,28 +570,16 @@ test("resolves env templates in account config with account token", async () =>
}),
),
token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
})
const layer = configLayer({ account: fakeAccount })
try {
await provideTmpdirInstance(() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
const config = yield* svc.get()
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
}),
),
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
} finally {
if (originalControlToken !== undefined) {
process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
} else {
delete process.env["OPENCODE_CONSOLE_TOKEN"]
}
}
}),
})
accountTokenIt.instance("resolves env templates in account config with account token", () =>
Effect.gen(function* () {
const config = yield* Config.use.get()
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
}),
)
it.instance("validates config schema and throws on invalid fields", () =>
Effect.gen(function* () {
const test = yield* TestInstance
@@ -604,7 +595,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* Effect.promise(() => Filesystem.write(path.join(test.directory, "opencode.json"), "{ invalid json }"))
yield* AppFileSystem.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)
}),
@@ -719,8 +710,7 @@ it.instance("migrates mode field to agent field", () =>
it.instance("loads config from .opencode directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* mkdirEffect(path.join(test.directory, ".opencode", "agent"))
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "agent", "test.md"),
`---
model: test/model
@@ -742,8 +732,7 @@ Test agent prompt`,
it.instance("agent markdown permission config preserves user key order", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* mkdirEffect(path.join(test.directory, ".opencode", "agent"))
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "agent", "ordered.md"),
`---
permission:
@@ -762,8 +751,7 @@ Ordered permissions`,
it.instance("loads agents from .opencode/agents (plural)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* mkdirEffect(path.join(test.directory, ".opencode", "agents", "nested"))
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "agents", "helper.md"),
`---
model: test/model
@@ -772,7 +760,7 @@ mode: subagent
Helper agent prompt`,
)
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "agents", "nested", "child.md"),
`---
model: test/model
@@ -802,8 +790,7 @@ Nested agent prompt`,
it.instance("loads commands from .opencode/command (singular)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* mkdirEffect(path.join(test.directory, ".opencode", "command", "nested"))
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "command", "hello.md"),
`---
description: Test command
@@ -811,7 +798,7 @@ description: Test command
Hello from singular command`,
)
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "command", "nested", "child.md"),
`---
description: Nested command
@@ -836,8 +823,7 @@ Nested command template`,
it.instance("loads commands from .opencode/commands (plural)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* mkdirEffect(path.join(test.directory, ".opencode", "commands", "nested"))
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "commands", "hello.md"),
`---
description: Test command
@@ -845,7 +831,7 @@ description: Test command
Hello from plural commands`,
)
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "commands", "nested", "child.md"),
`---
description: Nested command
@@ -874,10 +860,8 @@ it.instance("updates config and writes to file", () =>
svc.update(ConfigParse.schema(Config.Info, { model: "updated/model" }, "test:config")),
)
const writtenConfig = yield* Effect.promise(() =>
Filesystem.readJson<{ model: string }>(path.join(test.directory, "config.json")),
)
expect(writtenConfig.model).toBe("updated/model")
const writtenConfig = yield* AppFileSystem.use.readJson(path.join(test.directory, "config.json"))
expect(writtenConfig).toMatchObject({ model: "updated/model" })
}),
)
@@ -888,79 +872,37 @@ it.instance("gets config directories", () =>
}),
)
test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", async () => {
if (process.platform === "win32") return
it.effect("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
await using tmp = await tmpdir<string>({
init: async (dir) => {
const ro = path.join(dir, "readonly")
await fs.mkdir(ro, { recursive: true })
await fs.chmod(ro, 0o555)
return ro
},
dispose: async (dir) => {
const ro = path.join(dir, "readonly")
await fs.chmod(ro, 0o755).catch(() => {})
return ro
},
})
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))
const prev = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = tmp.extra
yield* withProcessEnv("OPENCODE_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir)))
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
)
try {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await load(ctx)
},
})
} finally {
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = prev
}
})
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)
test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
await using tmp = await tmpdir<string>({
init: async (dir) => {
const cfg = path.join(dir, "configdir")
await fs.mkdir(cfg, { recursive: true })
return cfg
},
})
yield* withProcessEnv(
"OPENCODE_CONFIG_DIR",
configDir,
Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe(
provideInstanceEffect(dir),
),
)
const prev = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = tmp.extra
const testLayer = configLayer()
try {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await Effect.runPromise(
Config.Service.use((svc) => svc.get().pipe(Effect.provideService(InstanceRef, ctx))).pipe(
Effect.scoped,
Effect.provide(testLayer),
),
)
await Effect.runPromise(
Config.Service.use((svc) => svc.waitForDependencies().pipe(Effect.provideService(InstanceRef, ctx))).pipe(
Effect.scoped,
Effect.provide(testLayer),
),
)
},
})
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
} finally {
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = prev
}
})
expect(yield* AppFileSystem.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json")
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
)
// Note: deduplication and serialization of npm installs is now handled by the
// core Npm.Service (via EffectFlock). Those behaviors are tested in the core
@@ -970,12 +912,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* mkdirEffect(pluginDir)
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, "package.json"),
JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
)
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
@@ -988,7 +929,7 @@ it.instance("resolves scoped npm plugins in config", () =>
2,
),
)
yield* writeTextEffect(path.join(pluginDir, "index.js"), "export default {}\n")
yield* AppFileSystem.use.writeWithDirs(path.join(pluginDir, "index.js"), "export default {}\n")
yield* writeConfigEffect(test.directory, { plugin: ["@scope/plugin"] })
const config = yield* Config.use.get()
@@ -996,57 +937,48 @@ it.instance("resolves scoped npm plugins in config", () =>
}),
)
test("merges plugin arrays from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Create a nested project structure with local .opencode config
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
// Global config with plugins
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["global-plugin-1", "global-plugin-2"],
}),
)
// Local .opencode config with different plugins
await Filesystem.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["local-plugin-1"],
}),
)
it.effect("merges plugin arrays from global and local configs", () =>
withConfigTree(
{
global: { plugin: ["global-plugin-1", "global-plugin-2"] },
local: { plugin: ["local-plugin-1"] },
},
})
Effect.gen(function* () {
const plugins = (yield* Config.use.get()).plugin ?? []
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async (ctx) => {
const config = await load(ctx)
const plugins = config.plugin ?? []
// Should contain both global and local plugins
expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
expect(
plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin")).length,
).toBeGreaterThanOrEqual(3)
}),
),
)
// Should have all 3 plugins (not replaced, but merged)
const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
expect(pluginNames.length).toBeGreaterThanOrEqual(3)
it.effect("global config remains global when project config is disabled", () =>
withConfigTree(
{
global: { model: "global/model", plugin: ["global-plugin"] },
project: { model: "project/model" },
local: { model: "local/model" },
},
})
})
withProcessEnv(
"OPENCODE_DISABLE_PROJECT_CONFIG",
"true",
Effect.gen(function* () {
const config = yield* Config.use.get()
expect(config.model).toBe("global/model")
expect(config.plugin_origins?.find((item) => item.spec === "global-plugin")?.scope).toBe("global")
}),
),
),
)
it.instance("does not error when only custom agent is a subagent", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* mkdirEffect(path.join(test.directory, ".opencode", "agent"))
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "agent", "helper.md"),
`---
model: test/model
@@ -1065,183 +997,78 @@ Helper subagent prompt`,
}),
)
test("merges instructions arrays from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["global-instructions.md", "shared-rules.md"],
}),
)
await Filesystem.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["local-instructions.md"],
}),
)
it.effect("merges instructions arrays from global and local configs", () =>
withConfigTree(
{
global: { instructions: ["global-instructions.md", "shared-rules.md"] },
local: { instructions: ["local-instructions.md"] },
},
})
Effect.gen(function* () {
expect((yield* Config.use.get()).instructions).toEqual([
"global-instructions.md",
"shared-rules.md",
"local-instructions.md",
])
}),
),
)
await withTestInstance({
directory: path.join(tmp.path, "project"),
fn: async (ctx) => {
const config = await load(ctx)
const instructions = config.instructions ?? []
expect(instructions).toContain("global-instructions.md")
expect(instructions).toContain("shared-rules.md")
expect(instructions).toContain("local-instructions.md")
expect(instructions.length).toBe(3)
it.effect("deduplicates duplicate instructions from global and local configs", () =>
withConfigTree(
{
global: { instructions: ["duplicate.md", "global-only.md"] },
local: { instructions: ["duplicate.md", "local-only.md"] },
},
})
})
Effect.gen(function* () {
expect((yield* Config.use.get()).instructions).toEqual(["duplicate.md", "global-only.md", "local-only.md"])
}),
),
)
test("deduplicates duplicate instructions from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["duplicate.md", "global-only.md"],
}),
)
await Filesystem.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["duplicate.md", "local-only.md"],
}),
)
it.effect("deduplicates duplicate plugins from global and local configs", () =>
withConfigTree(
{
global: { plugin: ["duplicate-plugin", "global-plugin-1"] },
local: { plugin: ["duplicate-plugin", "local-plugin-1"] },
},
})
Effect.gen(function* () {
const plugins = (yield* Config.use.get()).plugin ?? []
await withTestInstance({
directory: path.join(tmp.path, "project"),
fn: async (ctx) => {
const config = await load(ctx)
const instructions = config.instructions ?? []
expect(instructions).toContain("global-only.md")
expect(instructions).toContain("local-only.md")
expect(instructions).toContain("duplicate.md")
const duplicates = instructions.filter((i) => i === "duplicate.md")
expect(duplicates.length).toBe(1)
expect(instructions.length).toBe(3)
},
})
})
test("deduplicates duplicate plugins from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Create a nested project structure with local .opencode config
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
// Global config with plugins
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["duplicate-plugin", "global-plugin-1"],
}),
)
// Local .opencode config with some overlapping plugins
await Filesystem.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["duplicate-plugin", "local-plugin-1"],
}),
)
},
})
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async (ctx) => {
const config = await load(ctx)
const plugins = config.plugin ?? []
// Should contain all unique plugins
expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
expect(plugins.filter((p) => p.includes("duplicate-plugin")).length).toBe(1)
expect(
plugins.filter(
(p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
).length,
).toBe(3)
}),
),
)
// Should deduplicate the duplicate plugin
const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
expect(duplicatePlugins.length).toBe(1)
// Should have exactly 3 unique plugins
const pluginNames = plugins.filter(
(p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
)
expect(pluginNames.length).toBe(3)
it.effect("keeps plugin origins aligned with merged plugin list", () =>
withConfigTree(
{
global: { plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"] },
local: { plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"] },
},
})
})
test("keeps plugin origins aligned with merged plugin list", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const project = path.join(dir, "project")
const local = path.join(project, ".opencode")
await fs.mkdir(local, { recursive: true })
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
}),
)
await Filesystem.write(
path.join(local, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
}),
)
},
})
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async (ctx) => {
const cfg = await load(ctx)
const plugins = cfg.plugin ?? []
const origins = cfg.plugin_origins ?? []
Effect.gen(function* () {
const config = yield* Config.use.get()
const plugins = config.plugin ?? []
const origins = config.plugin_origins ?? []
const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
expect(names).toContain("shared-plugin@2.0.0")
expect(names).not.toContain("shared-plugin@1.0.0")
expect(names).toContain("global-only@1.0.0")
expect(names).toContain("local-only@1.0.0")
expect(origins.map((item) => item.spec)).toEqual(plugins)
const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
expect(hit?.scope).toBe("local")
},
})
})
expect(origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")?.scope).toBe(
"local",
)
}),
),
)
// Legacy tools migration tests
@@ -1326,6 +1153,16 @@ it.instance(
{ config: { autoupdate: true, disabled_providers: [] } },
)
it.instance("managed jsonc settings override managed json settings", () =>
Effect.gen(function* () {
yield* writeManagedSettingsEffect({ model: "managed/json" })
yield* writeManagedSettingsEffect({ model: "managed/jsonc" }, "opencode.jsonc")
const config = yield* Config.use.get()
expect(config.model).toBe("managed/jsonc")
}),
)
it.instance(
"missing managed settings file is not an error",
Effect.gen(function* () {
@@ -1562,7 +1399,7 @@ it.instance("local .opencode config can override MCP from project config", () =>
},
},
})
yield* mkdirEffect(path.join(test.directory, ".opencode"))
yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode"))
yield* writeConfigEffect(
path.join(test.directory, ".opencode"),
{
@@ -1583,64 +1420,40 @@ it.instance("local .opencode config can override MCP from project config", () =>
}),
)
test("project config overrides remote well-known config", async () => {
const seen: { wellKnown?: string } = {}
const client = remoteConfigClient({
seen,
wellKnown: {
config: {
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
},
},
})
await provideTmpdirInstance(
() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
const config = yield* svc.get()
expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode")
expect(config.mcp?.jira?.enabled).toBe(true)
}),
),
{
git: true,
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
},
).pipe(
Effect.scoped,
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
Effect.runPromise,
)
const remoteProjectOverride = wellKnown({
config: {
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
},
})
test("wellknown URL with trailing slash is normalized", async () => {
const seen: { wellKnown?: string } = {}
const client = remoteConfigClient({
seen,
wellKnown: {
config: {
mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
},
},
})
remoteProjectOverride.it.instance(
"project config overrides remote well-known config",
() =>
Effect.gen(function* () {
const config = yield* Config.use.get()
expect(remoteProjectOverride.seen.wellKnown).toBe("https://example.com/.well-known/opencode")
expect(config.mcp?.jira?.enabled).toBe(true)
}),
{
git: true,
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
},
)
await provideTmpdirInstance(
() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
yield* svc.get()
expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode")
}),
),
{ git: true },
).pipe(
Effect.scoped,
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com/"), client })),
Effect.runPromise,
)
const trailingSlashWellKnown = wellKnown({
authUrl: "https://example.com/",
config: {
mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
},
})
trailingSlashWellKnown.it.instance("wellknown URL with trailing slash is normalized", () =>
Effect.gen(function* () {
yield* Config.use.get()
expect(trailingSlashWellKnown.seen.wellKnown).toBe("https://example.com/.well-known/opencode")
}),
)
test("remote well-known config can use FetchHttpClient layer", async () => {
let fetchedUrl: string | undefined
const server = Bun.serve({
@@ -1690,142 +1503,100 @@ test("remote well-known config can use FetchHttpClient layer", async () => {
}
})
test("wellknown remote_config supports templated env vars in headers", async () => {
const originalToken = process.env.TEST_TOKEN
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
const client = remoteConfigClient({
seen,
wellKnown: {
remote_config: {
url: "https://config.example.com/opencode.json",
headers: {
Authorization: "Bearer {env:TEST_TOKEN}",
},
},
},
remote: {
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
},
})
try {
await provideTmpdirInstance(
() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
const config = yield* svc.get()
expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode")
expect(seen.remote).toBe("https://config.example.com/opencode.json")
expect(seen.authorization).toBe("Bearer test-token")
expect(config.mcp?.confluence?.enabled).toBe(true)
}),
),
{ git: true },
).pipe(
Effect.scoped,
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
Effect.runPromise,
)
} finally {
if (originalToken === undefined) delete process.env.TEST_TOKEN
else process.env.TEST_TOKEN = originalToken
}
const templatedHeaderWellKnown = wellKnown({
remoteConfig: {
url: "https://config.example.com/opencode.json",
headers: { Authorization: "Bearer {env:TEST_TOKEN}" },
},
remote: {
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
},
})
test("wellknown token env substitution does not mutate process env", async () => {
const originalToken = process.env.TEST_TOKEN
process.env.TEST_TOKEN = "preexisting-token"
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
const client = remoteConfigClient({
seen,
wellKnown: {
remote_config: {
url: "https://config.example.com/opencode.json",
headers: {
Authorization: "Bearer {env:TEST_TOKEN}",
},
},
},
remote: {
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
},
})
templatedHeaderWellKnown.it.instance("wellknown remote_config supports templated env vars in headers", () =>
Effect.gen(function* () {
const config = yield* Config.use.get()
expect(templatedHeaderWellKnown.seen.wellKnown).toBe("https://example.com/.well-known/opencode")
expect(templatedHeaderWellKnown.seen.remote).toBe("https://config.example.com/opencode.json")
expect(templatedHeaderWellKnown.seen.authorization).toBe("Bearer test-token")
expect(config.mcp?.confluence?.enabled).toBe(true)
}),
)
try {
const config = await provideTmpdirInstance(() => Config.Service.use((svc) => svc.get()), {
git: true,
config: { username: "{env:TEST_TOKEN}" },
}).pipe(
Effect.scoped,
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
Effect.runPromise,
)
expect(seen.authorization).toBe("Bearer test-token")
expect(config.username).toBe("test-token")
expect(process.env.TEST_TOKEN).toBe("preexisting-token")
} finally {
if (originalToken === undefined) delete process.env.TEST_TOKEN
else process.env.TEST_TOKEN = originalToken
}
const remotePrecedenceWellKnown = wellKnown({
config: {
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: false } },
},
remoteConfig: { url: "https://config.example.com/{env:TEST_TOKEN}/opencode.json" },
remote: {
config: { mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } } },
},
})
test("wellknown config null is treated as absent", async () => {
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
const client = remoteConfigClient({
seen,
wellKnown: {
config: null,
remote_config: {
url: "https://config.example.com/opencode.json",
},
},
remote: {
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
},
})
remotePrecedenceWellKnown.it.instance(
"wellknown remote_config url tokens and nested config override embedded config",
() =>
Effect.gen(function* () {
const config = yield* Config.use.get()
expect(remotePrecedenceWellKnown.seen.remote).toBe("https://config.example.com/test-token/opencode.json")
expect(config.mcp?.confluence?.enabled).toBe(true)
}),
)
await provideTmpdirInstance(
() =>
Config.Service.use((svc) =>
Effect.gen(function* () {
const config = yield* svc.get()
expect(seen.remote).toBe("https://config.example.com/opencode.json")
expect(config.mcp?.confluence?.enabled).toBe(true)
}),
),
{ git: true },
).pipe(
Effect.scoped,
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
Effect.runPromise,
)
const envIsolationWellKnown = wellKnown({
remoteConfig: {
url: "https://config.example.com/opencode.json",
headers: { Authorization: "Bearer {env:TEST_TOKEN}" },
},
remote: {
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
},
})
test("wellknown remote_config rejects non-object config responses", async () => {
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
const client = remoteConfigClient({
seen,
wellKnown: {
remote_config: {
url: "https://config.example.com/opencode.json",
},
},
remote: "not an object",
})
envIsolationWellKnown.it.instance(
"wellknown token env substitution does not mutate process env",
() =>
Effect.gen(function* () {
process.env.TEST_TOKEN = "preexisting-token"
const config = yield* Config.use.get()
expect(envIsolationWellKnown.seen.authorization).toBe("Bearer test-token")
expect(config.username).toBe("test-token")
expect(process.env.TEST_TOKEN).toBe("preexisting-token")
}),
{ git: true, config: { username: "{env:TEST_TOKEN}" } },
)
const exit = await provideTmpdirInstance(() => Config.Service.use((svc) => svc.get()).pipe(Effect.exit), {
git: true,
}).pipe(
Effect.scoped,
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
Effect.runPromise,
)
expect(seen.remote).toBe("https://config.example.com/opencode.json")
expect(Exit.isFailure(exit)).toBe(true)
const nullConfigWellKnown = wellKnown({
wellKnown: {
config: null,
remote_config: { url: "https://config.example.com/opencode.json" },
},
remote: {
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
},
})
nullConfigWellKnown.it.instance("wellknown config null is treated as absent", () =>
Effect.gen(function* () {
const config = yield* Config.use.get()
expect(nullConfigWellKnown.seen.remote).toBe("https://config.example.com/opencode.json")
expect(config.mcp?.confluence?.enabled).toBe(true)
}),
)
const invalidRemoteWellKnown = wellKnown({
remoteConfig: { url: "https://config.example.com/opencode.json" },
remote: "not an object",
})
invalidRemoteWellKnown.it.instance("wellknown remote_config rejects non-object config responses", () =>
Effect.gen(function* () {
const exit = yield* Config.use.get().pipe(Effect.exit)
expect(invalidRemoteWellKnown.seen.remote).toBe("https://config.example.com/opencode.json")
expect(Exit.isFailure(exit)).toBe(true)
}),
)
describe("resolvePluginSpec", () => {
test("keeps package specs unchanged", async () => {
await using tmp = await tmpdir()
@@ -1942,37 +1713,22 @@ describe("deduplicatePluginOrigins", () => {
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
})
test("loads auto-discovered local plugins as file urls", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
const pluginDir = path.join(opencodeDir, "plugin")
await fs.mkdir(pluginDir, { recursive: true })
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["my-plugin@1.0.0"],
}),
it.effect("loads auto-discovered local plugins as file urls", () =>
withConfigTree(
{ global: { plugin: ["my-plugin@1.0.0"] } },
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "plugin", "my-plugin.js"),
"export default {}",
)
await Filesystem.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
},
})
await provideTestInstance({
directory: path.join(tmp.path, "project"),
fn: async (ctx) => {
const config = await load(ctx)
const plugins = config.plugin ?? []
const plugins = (yield* Config.use.get()).plugin ?? []
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true)
},
})
})
}),
),
)
})
describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
@@ -1997,8 +1753,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
"true",
Effect.gen(function* () {
const test = yield* TestInstance
yield* mkdirEffect(path.join(test.directory, ".opencode", "command"))
yield* writeTextEffect(
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "command", "test-cmd.md"),
"# Test Command\nThis is a test command.",
)
@@ -2027,7 +1782,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
{ OPENCODE_CONFIG_DIR: undefined, OPENCODE_DISABLE_PROJECT_CONFIG: "true" },
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeTextEffect(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions")
yield* AppFileSystem.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()
@@ -2092,7 +1847,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
it.instance("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeTextEffect(path.join(test.directory, "api_key.txt"), "secret_key_from_file")
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "api_key.txt"), "secret_key_from_file")
yield* withProcessEnv(
"OPENCODE_CONFIG_CONTENT",
JSON.stringify({
+63 -75
View File
@@ -3,7 +3,6 @@ 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"
@@ -48,6 +47,11 @@ const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(d
const gitCommit = (directory: string, message: string) =>
Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet())
const writeFixtureFile = (directory: string, file: string, content: string | Uint8Array) =>
AppFileSystem.use.writeWithDirs(path.join(directory, file), content)
const removeFixtureFile = (directory: string, file: string) => AppFileSystem.use.remove(path.join(directory, file))
const failureMessage = <A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const exit = yield* self.pipe(Effect.exit)
@@ -72,7 +76,7 @@ describe("file/index Filesystem patterns", () => {
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"))
yield* writeFixtureFile(test.directory, "test.txt", "Hello World")
const result = yield* read("test.txt")
expect(result.type).toBe("text")
@@ -91,9 +95,7 @@ describe("file/index Filesystem patterns", () => {
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"),
)
yield* writeFixtureFile(test.directory, "test.txt", " content with spaces \n\n")
const result = yield* read("test.txt")
expect(result.content).toBe("content with spaces")
@@ -103,7 +105,7 @@ describe("file/index Filesystem patterns", () => {
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"))
yield* writeFixtureFile(test.directory, "empty.txt", "")
const result = yield* read("empty.txt")
expect(result.type).toBe("text")
@@ -114,9 +116,7 @@ describe("file/index Filesystem patterns", () => {
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"),
)
yield* writeFixtureFile(test.directory, "multiline.txt", "line1\nline2\nline3")
const result = yield* read("multiline.txt")
expect(result.content).toBe("line1\nline2\nline3")
@@ -129,7 +129,7 @@ describe("file/index Filesystem patterns", () => {
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))
yield* writeFixtureFile(test.directory, "image.png", binaryContent)
const result = yield* read("image.png")
expect(result.type).toBe("text")
@@ -142,9 +142,7 @@ describe("file/index Filesystem patterns", () => {
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])),
)
yield* writeFixtureFile(test.directory, "binary.so", Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
const result = yield* read("binary.so")
expect(result.type).toBe("binary")
@@ -158,7 +156,7 @@ describe("file/index Filesystem patterns", () => {
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"))
yield* AppFileSystem.use.writeWithDirs(filepath, '{"key": "value"}')
expect(AppFileSystem.mimeType(filepath)).toContain("application/json")
@@ -179,7 +177,7 @@ describe("file/index Filesystem patterns", () => {
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])))
yield* AppFileSystem.use.writeWithDirs(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))
expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime)
}
}),
@@ -288,9 +286,7 @@ describe("file/index Filesystem patterns", () => {
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"),
)
yield* writeFixtureFile(test.directory, "test.ts", "export const value = 1")
const result = yield* read("test.ts")
expect(result.type).toBe("text")
@@ -301,9 +297,7 @@ describe("file/index Filesystem patterns", () => {
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"),
)
yield* writeFixtureFile(test.directory, "test.mts", "export const value = 1")
const result = yield* read("test.mts")
expect(result.type).toBe("text")
@@ -314,9 +308,7 @@ describe("file/index Filesystem patterns", () => {
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"),
)
yield* writeFixtureFile(test.directory, "test.sh", "#!/usr/bin/env bash\necho hello")
const result = yield* read("test.sh")
expect(result.type).toBe("text")
@@ -327,7 +319,7 @@ describe("file/index Filesystem patterns", () => {
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"))
yield* writeFixtureFile(test.directory, "Dockerfile", "FROM alpine:3.20")
const result = yield* read("Dockerfile")
expect(result.type).toBe("text")
@@ -338,7 +330,7 @@ describe("file/index Filesystem patterns", () => {
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"))
yield* writeFixtureFile(test.directory, "test.txt", "simple text")
const result = yield* read("test.txt")
expect(result.encoding).toBeUndefined()
@@ -349,9 +341,7 @@ describe("file/index Filesystem patterns", () => {
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])),
)
yield* writeFixtureFile(test.directory, "test.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0]))
const result = yield* read("test.jpg")
expect(result.encoding).toBe("base64")
@@ -381,10 +371,10 @@ describe("file/index Filesystem patterns", () => {
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* AppFileSystem.use.writeWithDirs(filepath, "original\n")
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8"))
yield* AppFileSystem.use.writeWithDirs(filepath, "modified\nextra line\n")
const result = yield* status()
const entry = result.find((file) => file.path === "file.txt")
@@ -401,9 +391,7 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"),
)
yield* writeFixtureFile(test.directory, "new.txt", "line1\nline2\nline3\n")
const result = yield* status()
const entry = result.find((file) => file.path === "new.txt")
@@ -421,10 +409,10 @@ describe("file/index Filesystem patterns", () => {
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* AppFileSystem.use.writeWithDirs(filepath, "content\n")
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.rm(filepath))
yield* AppFileSystem.use.remove(filepath)
const result = yield* status()
const entries = result.filter((file) => file.path === "gone.txt")
@@ -438,14 +426,14 @@ describe("file/index Filesystem patterns", () => {
() =>
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* writeFixtureFile(test.directory, "keep.txt", "keep\n")
yield* writeFixtureFile(test.directory, "remove.txt", "remove\n")
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"))
yield* writeFixtureFile(test.directory, "keep.txt", "changed\n")
yield* removeFixtureFile(test.directory, "remove.txt")
yield* writeFixtureFile(test.directory, "brand-new.txt", "hello\n")
const result = yield* status()
expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true)
@@ -476,13 +464,15 @@ describe("file/index Filesystem patterns", () => {
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* AppFileSystem.use.writeWithDirs(
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))),
yield* AppFileSystem.use.writeWithDirs(
filepath,
Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)),
)
const result = yield* status()
@@ -502,11 +492,9 @@ describe("file/index Filesystem patterns", () => {
() =>
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"),
)
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "subdir"))
yield* writeFixtureFile(test.directory, "file.txt", "content")
yield* writeFixtureFile(test.directory, path.join("subdir", "nested.txt"), "nested")
const nodes = yield* list()
expect(nodes.length).toBeGreaterThanOrEqual(2)
@@ -527,10 +515,10 @@ describe("file/index Filesystem patterns", () => {
() =>
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"))
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "beta"))
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "alpha"))
yield* writeFixtureFile(test.directory, "zz.txt", "")
yield* writeFixtureFile(test.directory, "aa.txt", "")
const nodes = yield* list()
const dirs = nodes.filter((node) => node.type === "directory")
@@ -551,8 +539,8 @@ describe("file/index Filesystem patterns", () => {
() =>
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"))
yield* writeFixtureFile(test.directory, ".DS_Store", "")
yield* writeFixtureFile(test.directory, "visible.txt", "")
const names = (yield* list()).map((node) => node.name)
expect(names).not.toContain(".git")
@@ -567,10 +555,10 @@ describe("file/index Filesystem patterns", () => {
() =>
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")))
yield* writeFixtureFile(test.directory, ".gitignore", "*.log\nbuild/\n")
yield* writeFixtureFile(test.directory, "app.log", "log data")
yield* writeFixtureFile(test.directory, "main.ts", "code")
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "build"))
const nodes = yield* list()
expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true)
@@ -585,9 +573,9 @@ describe("file/index Filesystem patterns", () => {
() =>
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"))
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "sub"))
yield* writeFixtureFile(test.directory, path.join("sub", "a.txt"), "")
yield* writeFixtureFile(test.directory, path.join("sub", "b.txt"), "")
const nodes = yield* list("sub")
expect(nodes.length).toBe(2)
@@ -609,7 +597,7 @@ describe("file/index Filesystem patterns", () => {
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"))
yield* writeFixtureFile(test.directory, "file.txt", "hi")
const nodes = yield* list()
expect(nodes.length).toBeGreaterThanOrEqual(1)
@@ -755,7 +743,7 @@ describe("file/index Filesystem patterns", () => {
yield* init()
expect(yield* search({ query: "fresh", type: "file" })).toEqual([])
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8"))
yield* writeFixtureFile(test.directory, "fresh.ts", "fresh")
expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts")
}),
@@ -770,10 +758,10 @@ describe("file/index Filesystem patterns", () => {
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* AppFileSystem.use.writeWithDirs(filepath, "original content\n")
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8"))
yield* AppFileSystem.use.writeWithDirs(filepath, "modified content\n")
const result = yield* read("file.txt")
expect(result.type).toBe("text")
@@ -793,10 +781,10 @@ describe("file/index Filesystem patterns", () => {
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* AppFileSystem.use.writeWithDirs(filepath, "before\n")
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8"))
yield* AppFileSystem.use.writeWithDirs(filepath, "after\n")
yield* gitAddAll(test.directory)
const result = yield* read("staged.txt")
@@ -812,7 +800,7 @@ describe("file/index Filesystem patterns", () => {
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* AppFileSystem.use.writeWithDirs(filepath, "unchanged\n")
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
@@ -832,14 +820,14 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const one = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8"))
yield* writeFixtureFile(one.directory, "a.ts", "one")
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* writeFixtureFile(two.directory, "b.ts", "two")
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")
@@ -853,14 +841,14 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8"))
yield* writeFixtureFile(test.directory, "before.ts", "before")
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* writeFixtureFile(test.directory, "after.ts", "after")
yield* removeFixtureFile(test.directory, "before.ts")
yield* init()
expect(yield* search({ query: "after", type: "file" })).toContain("after.ts")