mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-21 05:10:58 +08:00
chore: delete filetime module (#22999)
This commit is contained in:
@@ -594,7 +594,6 @@ OPENCODE_DISABLE_CLAUDE_CODE
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
OPENCODE_DISABLE_FILETIME_CHECK
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD
|
||||
OPENCODE_DISABLE_MODELS_FETCH
|
||||
OPENCODE_DISABLE_PRUNE
|
||||
|
||||
@@ -9,7 +9,7 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
|
||||
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
|
||||
|
||||
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
|
||||
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
|
||||
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
|
||||
@@ -195,7 +195,6 @@ This checklist is only about the service shape migration. Many of these services
|
||||
- [x] `Config` — `config/config.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime)
|
||||
- [x] `File` — `file/index.ts`
|
||||
- [x] `FileTime` — `file/time.ts`
|
||||
- [x] `FileWatcher` — `file/watcher.ts`
|
||||
- [x] `Format` — `format/index.ts`
|
||||
- [x] `Installation` — `installation/index.ts`
|
||||
@@ -301,7 +300,6 @@ For each service, the migration is roughly:
|
||||
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
|
||||
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
||||
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
|
||||
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Account } from "@/account/account"
|
||||
import { Config } from "@/config"
|
||||
import { Git } from "@/git"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { File } from "@/file"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Storage } from "@/storage"
|
||||
@@ -58,7 +57,6 @@ export const AppLayer = Layer.mergeAll(
|
||||
Config.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
Storage.defaultLayer,
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Log } from "../util"
|
||||
|
||||
const log = Log.create({ service: "file.time" })
|
||||
|
||||
export type Stamp = {
|
||||
readonly read: Date
|
||||
readonly mtime: number | undefined
|
||||
readonly size: number | undefined
|
||||
}
|
||||
|
||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||
const value = reads.get(sessionID)
|
||||
if (value) return value
|
||||
|
||||
const next = new Map<string, Stamp>()
|
||||
reads.set(sessionID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
interface State {
|
||||
reads: Map<SessionID, Map<string, Stamp>>
|
||||
locks: Map<string, Semaphore.Semaphore>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
|
||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
|
||||
return {
|
||||
read: yield* DateTime.nowAsDate,
|
||||
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
|
||||
size: info ? Number(info.size) : undefined,
|
||||
}
|
||||
})
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("FileTime.state")(() =>
|
||||
Effect.succeed({
|
||||
reads: new Map<SessionID, Map<string, Stamp>>(),
|
||||
locks: new Map<string, Semaphore.Semaphore>(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
|
||||
filepath = AppFileSystem.normalizePath(filepath)
|
||||
const locks = (yield* InstanceState.get(state)).locks
|
||||
const lock = locks.get(filepath)
|
||||
if (lock) return lock
|
||||
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(filepath, next)
|
||||
return next
|
||||
})
|
||||
|
||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||
file = AppFileSystem.normalizePath(file)
|
||||
const reads = (yield* InstanceState.get(state)).reads
|
||||
log.info("read", { sessionID, file })
|
||||
session(reads, sessionID).set(file, yield* stamp(file))
|
||||
})
|
||||
|
||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||
file = AppFileSystem.normalizePath(file)
|
||||
const reads = (yield* InstanceState.get(state)).reads
|
||||
return reads.get(sessionID)?.get(file)?.read
|
||||
})
|
||||
|
||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||
if (disableCheck) return
|
||||
filepath = AppFileSystem.normalizePath(filepath)
|
||||
|
||||
const reads = (yield* InstanceState.get(state)).reads
|
||||
const time = reads.get(sessionID)?.get(filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
|
||||
const next = yield* stamp(filepath)
|
||||
const changed = next.mtime !== time.mtime || next.size !== time.size
|
||||
if (!changed) return
|
||||
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
})
|
||||
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
|
||||
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
|
||||
})
|
||||
|
||||
return Service.of({ read, get, assert, withLock })
|
||||
}),
|
||||
).pipe(Layer.orDie)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
export * as FileTime from "./time"
|
||||
@@ -70,7 +70,6 @@ export const Flag = {
|
||||
OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
|
||||
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
||||
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
OPENCODE_DISABLE_FILETIME_CHECK: Config.boolean("OPENCODE_DISABLE_FILETIME_CHECK").pipe(Config.withDefault(false)),
|
||||
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
|
||||
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
|
||||
|
||||
@@ -23,7 +23,6 @@ import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||
import { ToolRegistry } from "../tool"
|
||||
import { MCP } from "../mcp"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
@@ -94,7 +93,6 @@ export const layer = Layer.effect(
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const filetime = yield* FileTime.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
@@ -1183,7 +1181,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
]
|
||||
}
|
||||
|
||||
yield* filetime.read(input.sessionID, filepath)
|
||||
return [
|
||||
{
|
||||
messageID: info.id,
|
||||
@@ -1684,7 +1681,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
Layer.provide(MCP.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(ToolRegistry.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
|
||||
@@ -14,7 +14,6 @@ import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Bus } from "../bus"
|
||||
import { Format } from "../format"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
@@ -44,7 +43,6 @@ export const EditTool = Tool.define(
|
||||
"edit",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const filetime = yield* FileTime.Service
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const format = yield* Format.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -70,52 +68,11 @@ export const EditTool = Tool.define(
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
yield* filetime.withLock(filePath, () =>
|
||||
Effect.gen(function* () {
|
||||
if (params.oldString === "") {
|
||||
const existed = yield* afs.existsSafe(filePath)
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
yield* afs.writeWithDirs(filePath, params.newString)
|
||||
yield* format.file(filePath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filePath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
yield* filetime.read(ctx.sessionID, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) throw new Error(`File ${filePath} not found`)
|
||||
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
|
||||
yield* filetime.assert(ctx.sessionID, filePath)
|
||||
contentOld = yield* afs.readFileString(filePath)
|
||||
|
||||
const ending = detectLineEnding(contentOld)
|
||||
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
|
||||
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
|
||||
|
||||
contentNew = replace(contentOld, old, next, params.replaceAll)
|
||||
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
if (params.oldString === "") {
|
||||
const existed = yield* afs.existsSafe(filePath)
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
@@ -125,26 +82,62 @@ export const EditTool = Tool.define(
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
yield* afs.writeWithDirs(filePath, contentNew)
|
||||
yield* afs.writeWithDirs(filePath, params.newString)
|
||||
yield* format.file(filePath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filePath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
contentNew = yield* afs.readFileString(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
yield* filetime.read(ctx.sessionID, filePath)
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) throw new Error(`File ${filePath} not found`)
|
||||
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
|
||||
contentOld = yield* afs.readFileString(filePath)
|
||||
|
||||
const ending = detectLineEnding(contentOld)
|
||||
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
|
||||
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
|
||||
|
||||
contentNew = replace(contentOld, old, next, params.replaceAll)
|
||||
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
yield* afs.writeWithDirs(filePath, contentNew)
|
||||
yield* format.file(filePath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filePath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
contentNew = yield* afs.readFileString(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.orDie)
|
||||
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file: filePath,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createInterface } from "readline"
|
||||
import * as Tool from "./tool"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileTime } from "../file/time"
|
||||
import DESCRIPTION from "./read.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
@@ -31,7 +30,6 @@ export const ReadTool = Tool.define(
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const instruction = yield* Instruction.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const time = yield* FileTime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
||||
@@ -75,9 +73,8 @@ export const ReadTool = Tool.define(
|
||||
).pipe(Effect.map((items: string[]) => items.sort((a, b) => a.localeCompare(b))))
|
||||
})
|
||||
|
||||
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string, sessionID: Tool.Context["sessionID"]) {
|
||||
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
|
||||
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
yield* time.read(sessionID, filepath)
|
||||
})
|
||||
|
||||
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||
@@ -196,7 +193,7 @@ export const ReadTool = Tool.define(
|
||||
}
|
||||
output += "\n</content>"
|
||||
|
||||
yield* warm(filepath, ctx.sessionID)
|
||||
yield* warm(filepath)
|
||||
|
||||
if (loaded.length > 0) {
|
||||
output += `\n\n<system-reminder>\n${loaded.map((item) => item.content).join("\n\n")}\n</system-reminder>`
|
||||
|
||||
@@ -39,7 +39,6 @@ import { InstanceState } from "@/effect"
|
||||
import { Question } from "../question"
|
||||
import { Todo } from "../session/todo"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Bus } from "../bus"
|
||||
@@ -80,7 +79,6 @@ export const layer: Layer.Layer<
|
||||
| Session.Service
|
||||
| Provider.Service
|
||||
| LSP.Service
|
||||
| FileTime.Service
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
| Bus.Service
|
||||
@@ -329,7 +327,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Format } from "../format"
|
||||
import { FileTime } from "../file/time"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { trimDiff } from "./edit"
|
||||
@@ -22,7 +21,6 @@ export const WriteTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const filetime = yield* FileTime.Service
|
||||
const bus = yield* Bus.Service
|
||||
const format = yield* Format.Service
|
||||
|
||||
@@ -41,7 +39,6 @@ export const WriteTool = Tool.define(
|
||||
|
||||
const exists = yield* fs.existsSafe(filepath)
|
||||
const contentOld = exists ? yield* fs.readFileString(filepath) : ""
|
||||
if (exists) yield* filetime.assert(ctx.sessionID, filepath)
|
||||
|
||||
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
|
||||
yield* ctx.ask({
|
||||
@@ -61,7 +58,6 @@ export const WriteTool = Tool.define(
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
})
|
||||
yield* filetime.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = "Wrote file successfully."
|
||||
yield* lsp.touchFile(filepath, true)
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const id = SessionID.make("ses_00000000000000000000000001")
|
||||
|
||||
const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8"))
|
||||
|
||||
const touch = (file: string, time: number) =>
|
||||
Effect.promise(() => {
|
||||
const date = new Date(time)
|
||||
return fs.utimes(file, date, date)
|
||||
})
|
||||
|
||||
const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file))
|
||||
|
||||
const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file))
|
||||
|
||||
const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file))
|
||||
|
||||
const lock = <A>(file: string, fn: () => Effect.Effect<A>) => FileTime.Service.use((svc) => svc.withLock(file, fn))
|
||||
|
||||
const fail = Effect.fn("FileTimeTest.fail")(function* <A, E, R>(self: Effect.Effect<A, E, R>) {
|
||||
const exit = yield* self.pipe(Effect.exit)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const err = Cause.squash(exit.cause)
|
||||
return err instanceof Error ? err : new Error(String(err))
|
||||
}
|
||||
throw new Error("expected file time effect to fail")
|
||||
})
|
||||
|
||||
describe("file/time", () => {
|
||||
describe("read() and get()", () => {
|
||||
it.live("stores read timestamp", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
|
||||
const before = yield* get(id, file)
|
||||
expect(before).toBeUndefined()
|
||||
|
||||
yield* read(id, file)
|
||||
|
||||
const after = yield* get(id, file)
|
||||
expect(after).toBeInstanceOf(Date)
|
||||
expect(after!.getTime()).toBeGreaterThan(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("tracks separate timestamps per session", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
|
||||
const one = SessionID.make("ses_00000000000000000000000002")
|
||||
const two = SessionID.make("ses_00000000000000000000000003")
|
||||
yield* read(one, file)
|
||||
yield* read(two, file)
|
||||
|
||||
const first = yield* get(one, file)
|
||||
const second = yield* get(two, file)
|
||||
|
||||
expect(first).toBeDefined()
|
||||
expect(second).toBeDefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("updates timestamp on subsequent reads", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
|
||||
yield* read(id, file)
|
||||
const first = yield* get(id, file)
|
||||
|
||||
yield* read(id, file)
|
||||
const second = yield* get(id, file)
|
||||
|
||||
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("isolates reads by directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const one = yield* tmpdirScoped()
|
||||
const two = yield* tmpdirScoped()
|
||||
const shared = yield* tmpdirScoped()
|
||||
const file = path.join(shared, "file.txt")
|
||||
yield* put(file, "content")
|
||||
|
||||
yield* provideInstance(one)(read(id, file))
|
||||
const result = yield* provideInstance(two)(get(id, file))
|
||||
expect(result).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("assert()", () => {
|
||||
it.live("passes when file has not been modified", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
yield* touch(file, 1_000)
|
||||
|
||||
yield* read(id, file)
|
||||
yield* check(id, file)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("throws when file was not read first", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
|
||||
const err = yield* fail(check(id, file))
|
||||
expect(err.message).toContain("You must read file")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("throws when file was modified after read", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
yield* touch(file, 1_000)
|
||||
|
||||
yield* read(id, file)
|
||||
yield* put(file, "modified content")
|
||||
yield* touch(file, 2_000)
|
||||
|
||||
const err = yield* fail(check(id, file))
|
||||
expect(err.message).toContain("modified since it was last read")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("includes timestamps in error message", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
yield* touch(file, 1_000)
|
||||
|
||||
yield* read(id, file)
|
||||
yield* put(file, "modified")
|
||||
yield* touch(file, 2_000)
|
||||
|
||||
const err = yield* fail(check(id, file))
|
||||
expect(err.message).toContain("Last modification:")
|
||||
expect(err.message).toContain("Last read:")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("withLock()", () => {
|
||||
it.live("executes function within lock", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
let hit = false
|
||||
|
||||
yield* lock(file, () =>
|
||||
Effect.sync(() => {
|
||||
hit = true
|
||||
return "result"
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hit).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns function result", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
const result = yield* lock(file, () => Effect.succeed("success"))
|
||||
expect(result).toBe("success")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent operations on same file", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
const order: number[] = []
|
||||
const hold = yield* Deferred.make<void>()
|
||||
const ready = yield* Deferred.make<void>()
|
||||
|
||||
const one = yield* lock(file, () =>
|
||||
Effect.gen(function* () {
|
||||
order.push(1)
|
||||
yield* Deferred.succeed(ready, void 0)
|
||||
yield* Deferred.await(hold)
|
||||
order.push(2)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Deferred.await(ready)
|
||||
|
||||
const two = yield* lock(file, () =>
|
||||
Effect.sync(() => {
|
||||
order.push(3)
|
||||
order.push(4)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Deferred.succeed(hold, void 0)
|
||||
yield* Fiber.join(one)
|
||||
yield* Fiber.join(two)
|
||||
|
||||
expect(order).toEqual([1, 2, 3, 4])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows concurrent operations on different files", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const onefile = path.join(dir, "file1.txt")
|
||||
const twofile = path.join(dir, "file2.txt")
|
||||
let one = false
|
||||
let two = false
|
||||
const hold = yield* Deferred.make<void>()
|
||||
const ready = yield* Deferred.make<void>()
|
||||
|
||||
const a = yield* lock(onefile, () =>
|
||||
Effect.gen(function* () {
|
||||
one = true
|
||||
yield* Deferred.succeed(ready, void 0)
|
||||
yield* Deferred.await(hold)
|
||||
expect(two).toBe(true)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Deferred.await(ready)
|
||||
|
||||
const b = yield* lock(twofile, () =>
|
||||
Effect.sync(() => {
|
||||
two = true
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Fiber.join(b)
|
||||
yield* Deferred.succeed(hold, void 0)
|
||||
yield* Fiber.join(a)
|
||||
|
||||
expect(one).toBe(true)
|
||||
expect(two).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("releases lock even if function throws", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
const err = yield* fail(lock(file, () => Effect.die(new Error("Test error"))))
|
||||
expect(err.message).toContain("Test error")
|
||||
|
||||
let hit = false
|
||||
yield* lock(file, () =>
|
||||
Effect.sync(() => {
|
||||
hit = true
|
||||
}),
|
||||
)
|
||||
expect(hit).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("path normalization", () => {
|
||||
it.live("read with forward slashes, assert with backslashes", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
yield* touch(file, 1_000)
|
||||
|
||||
const forward = file.replaceAll("\\", "/")
|
||||
yield* read(id, forward)
|
||||
yield* check(id, file)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("read with backslashes, assert with forward slashes", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
yield* touch(file, 1_000)
|
||||
|
||||
const forward = file.replaceAll("\\", "/")
|
||||
yield* read(id, file)
|
||||
yield* check(id, forward)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("get returns timestamp regardless of slash direction", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
|
||||
const forward = file.replaceAll("\\", "/")
|
||||
yield* read(id, forward)
|
||||
|
||||
const result = yield* get(id, file)
|
||||
expect(result).toBeInstanceOf(Date)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("withLock serializes regardless of slash direction", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
const forward = file.replaceAll("\\", "/")
|
||||
const order: number[] = []
|
||||
const hold = yield* Deferred.make<void>()
|
||||
const ready = yield* Deferred.make<void>()
|
||||
|
||||
const one = yield* lock(file, () =>
|
||||
Effect.gen(function* () {
|
||||
order.push(1)
|
||||
yield* Deferred.succeed(ready, void 0)
|
||||
yield* Deferred.await(hold)
|
||||
order.push(2)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Deferred.await(ready)
|
||||
|
||||
const two = yield* lock(forward, () =>
|
||||
Effect.sync(() => {
|
||||
order.push(3)
|
||||
order.push(4)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Deferred.succeed(hold, void 0)
|
||||
yield* Fiber.join(one)
|
||||
yield* Fiber.join(two)
|
||||
|
||||
expect(order).toEqual([1, 2, 3, 4])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("stat() Filesystem.stat pattern", () => {
|
||||
it.live("reads file modification time via Filesystem.stat()", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "content")
|
||||
yield* touch(file, 1_000)
|
||||
|
||||
yield* read(id, file)
|
||||
|
||||
const stat = Filesystem.stat(file)
|
||||
expect(stat?.mtime).toBeInstanceOf(Date)
|
||||
expect(stat!.mtime.getTime()).toBeGreaterThan(0)
|
||||
|
||||
yield* check(id, file)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("detects modification via stat mtime", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "file.txt")
|
||||
yield* put(file, "original")
|
||||
yield* touch(file, 1_000)
|
||||
|
||||
yield* read(id, file)
|
||||
|
||||
const first = Filesystem.stat(file)
|
||||
|
||||
yield* put(file, "modified")
|
||||
yield* touch(file, 2_000)
|
||||
|
||||
const second = Filesystem.stat(file)
|
||||
expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
|
||||
|
||||
yield* fail(check(id, file))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,6 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
import { Config } from "../../src/config"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
@@ -148,16 +147,6 @@ const lsp = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const filetime = Layer.succeed(
|
||||
FileTime.Service,
|
||||
FileTime.Service.of({
|
||||
read: () => Effect.void,
|
||||
get: () => Effect.succeed(undefined),
|
||||
assert: () => Effect.void,
|
||||
withLock: (_filepath, fn) => fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
||||
const run = SessionRunState.layer.pipe(Layer.provide(status))
|
||||
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||
@@ -173,7 +162,6 @@ function makeHttp() {
|
||||
Plugin.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
ProviderSvc.defaultLayer,
|
||||
filetime,
|
||||
lsp,
|
||||
mcp,
|
||||
AppFileSystem.defaultLayer,
|
||||
|
||||
@@ -33,7 +33,6 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
import { Config } from "../../src/config"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
@@ -102,16 +101,6 @@ const lsp = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const filetime = Layer.succeed(
|
||||
FileTime.Service,
|
||||
FileTime.Service.of({
|
||||
read: () => Effect.void,
|
||||
get: () => Effect.succeed(undefined),
|
||||
assert: () => Effect.void,
|
||||
withLock: (_filepath, fn) => fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
||||
const run = SessionRunState.layer.pipe(Layer.provide(status))
|
||||
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||
@@ -128,7 +117,6 @@ function makeHttp() {
|
||||
Plugin.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
ProviderSvc.defaultLayer,
|
||||
filetime,
|
||||
lsp,
|
||||
mcp,
|
||||
AppFileSystem.defaultLayer,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { EditTool } from "../../src/tool/edit"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
@@ -38,7 +37,6 @@ async function touch(file: string, time: number) {
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
LSP.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
Bus.layer,
|
||||
@@ -59,9 +57,6 @@ const resolve = () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const readFileTime = (sessionID: SessionID, filepath: string) =>
|
||||
runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath)))
|
||||
|
||||
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
|
||||
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
|
||||
|
||||
@@ -173,8 +168,6 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
@@ -202,8 +195,6 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
@@ -254,8 +245,6 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
@@ -273,65 +262,6 @@ describe("tool.edit", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when file was not read first (FileTime)", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "content",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("You must read file")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when file has been modified since read", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Read first
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
// Simulate external modification
|
||||
await fs.writeFile(filepath, "modified externally", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
|
||||
// Try to edit with the new content
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "modified externally",
|
||||
newString: "edited",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("modified since it was last read")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("replaces all occurrences with replaceAll option", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
@@ -340,8 +270,6 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
@@ -369,8 +297,6 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const updated = await onceBus(FileWatcher.Event.Updated)
|
||||
@@ -406,8 +332,6 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
@@ -434,8 +358,6 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
@@ -487,8 +409,6 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, dirpath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
@@ -514,8 +434,6 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
@@ -587,7 +505,6 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const filePath = path.join(tmp.path, "test.txt")
|
||||
await readFileTime(ctx.sessionID, filePath)
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
@@ -730,8 +647,6 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
|
||||
// Two concurrent edits
|
||||
@@ -746,9 +661,6 @@ describe("tool.edit", () => {
|
||||
),
|
||||
)
|
||||
|
||||
// Need to read again since FileTime tracks per-session
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const promise2 = Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
|
||||
@@ -4,7 +4,6 @@ import path from "path"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
@@ -40,7 +39,6 @@ const it = testEffect(
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { WriteTool } from "../../src/tool/write"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Format } from "../../src/format"
|
||||
import { Truncate } from "../../src/tool"
|
||||
@@ -36,7 +35,6 @@ const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
LSP.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
Bus.layer,
|
||||
Format.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
@@ -58,11 +56,6 @@ const run = Effect.fn("WriteToolTest.run")(function* (
|
||||
return yield* tool.execute(args, next)
|
||||
})
|
||||
|
||||
const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {
|
||||
const ft = yield* FileTime.Service
|
||||
yield* ft.read(sessionID as any, filepath)
|
||||
})
|
||||
|
||||
describe("tool.write", () => {
|
||||
describe("new file creation", () => {
|
||||
it.live("writes content to new file", () =>
|
||||
@@ -110,8 +103,6 @@ describe("tool.write", () => {
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
|
||||
yield* markRead(ctx.sessionID, filepath)
|
||||
|
||||
const result = yield* run({ filePath: filepath, content: "new content" })
|
||||
|
||||
expect(result.output).toContain("Wrote file successfully")
|
||||
@@ -128,8 +119,6 @@ describe("tool.write", () => {
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "file.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
|
||||
yield* markRead(ctx.sessionID, filepath)
|
||||
|
||||
const result = yield* run({ filePath: filepath, content: "new" })
|
||||
|
||||
expect(result.metadata).toHaveProperty("filepath", filepath)
|
||||
@@ -231,8 +220,6 @@ describe("tool.write", () => {
|
||||
const readonlyPath = path.join(dir, "readonly.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
|
||||
yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
|
||||
yield* markRead(ctx.sessionID, readonlyPath)
|
||||
|
||||
const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
|
||||
@@ -573,7 +573,6 @@ opencode upgrade v0.1.48
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | تعطيل تحميل `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | تعطيل جلب النماذج من مصادر بعيدة |
|
||||
| `OPENCODE_FAKE_VCS` | string | مزود VCS وهمي لأغراض الاختبار |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | تعطيل التحقق من وقت الملف لتحسين الأداء |
|
||||
| `OPENCODE_CLIENT` | string | معرّف العميل (الافتراضي `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | تفعيل أدوات بحث الويب من Exa |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | تفعيل المصادقة الأساسية لخادمي `serve`/`web` |
|
||||
|
||||
@@ -571,7 +571,6 @@ OpenCode se može konfigurirati pomoću varijabli okruženja.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Onemogući učitavanje `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Onemogući dohvaćanje modela iz udaljenih izvora |
|
||||
| `OPENCODE_FAKE_VCS` | string | Lažni VCS provajder za potrebe testiranja |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Onemogući provjeru vremena datoteke radi optimizacije |
|
||||
| `OPENCODE_CLIENT` | string | Identifikator klijenta (zadano na `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | Omogući Exa alate za web pretraživanje |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | Omogući osnovnu autentifikaciju za `serve`/`web` |
|
||||
|
||||
@@ -575,7 +575,6 @@ OpenCode can be configured using environment variables.
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disable fetching models from remote sources |
|
||||
| `OPENCODE_DISABLE_MOUSE` | boolean | Disable mouse capture in the TUI |
|
||||
| `OPENCODE_FAKE_VCS` | string | Fake VCS provider for testing purposes |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disable file time checking for optimization |
|
||||
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | Enable Exa web search tools |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | Enable basic auth for `serve`/`web` |
|
||||
|
||||
@@ -574,7 +574,6 @@ OpenCode kan konfigureres ved hjælp af miljøvariabler.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Deaktiver indlæsning af `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Deaktivering af modeller fra eksterne kilder |
|
||||
| `OPENCODE_FAKE_VCS` | string | Falsk VCS-udbyder til testformål |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Deaktiver filtidskontrol for optimering |
|
||||
| `OPENCODE_CLIENT` | string | Klient-id (standard til `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | Aktiver Exa-websøgeværktøjer |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | Aktiver grundlæggende godkendelse for `serve`/`web` |
|
||||
|
||||
@@ -573,7 +573,6 @@ OpenCode kann mithilfe von Umgebungsvariablen konfiguriert werden.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolescher Wert | Deaktivieren Sie das Laden von `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolescher Wert | Deaktivieren Sie das Abrufen von Modellen aus Remote-Quellen |
|
||||
| `OPENCODE_FAKE_VCS` | Zeichenfolge | Gefälschter VCS-Anbieter zu Testzwecken |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolescher Wert | Dateizeitprüfung zur Optimierung deaktivieren |
|
||||
| `OPENCODE_CLIENT` | Zeichenfolge | Client-ID (standardmäßig `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolescher Wert | Exa-Websuchtools aktivieren |
|
||||
| `OPENCODE_SERVER_PASSWORD` | Zeichenfolge | Aktivieren Sie die Basisauthentifizierung für `serve`/`web` |
|
||||
|
||||
@@ -573,7 +573,6 @@ OpenCode se puede configurar mediante variables de entorno.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | booleano | Deshabilitar la carga `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | booleano | Deshabilitar la recuperación de modelos desde fuentes remotas |
|
||||
| `OPENCODE_FAKE_VCS` | cadena | Proveedor de VCS falso para fines de prueba |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | booleano | Deshabilite la verificación del tiempo del archivo para optimizarlo |
|
||||
| `OPENCODE_CLIENT` | cadena | Identificador de cliente (por defecto `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | booleano | Habilitar las herramientas de búsqueda web de Exa |
|
||||
| `OPENCODE_SERVER_PASSWORD` | cadena | Habilite la autenticación básica para `serve`/`web` |
|
||||
|
||||
@@ -574,7 +574,6 @@ OpenCode peut être configuré à l'aide de variables d'environnement.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | booléen | Désactiver le chargement de `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | booléen | Désactiver la récupération de modèles à partir de sources distantes |
|
||||
| `OPENCODE_FAKE_VCS` | chaîne | Faux fournisseur VCS à des fins de test |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | booléen | Désactiver la vérification de l'heure des fichiers pour l'optimisation |
|
||||
| `OPENCODE_CLIENT` | chaîne | Identifiant du client (par défaut `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | booléen | Activer les outils de recherche Web Exa |
|
||||
| `OPENCODE_SERVER_PASSWORD` | chaîne | Activer l'authentification de base pour `serve`/`web` |
|
||||
|
||||
@@ -574,7 +574,6 @@ OpenCode può essere configurato tramite variabili d'ambiente.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disabilita caricamento di `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disabilita fetch dei modelli da fonti remote |
|
||||
| `OPENCODE_FAKE_VCS` | string | Provider VCS finto per scopi di test |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disabilita controllo file time per ottimizzazione |
|
||||
| `OPENCODE_CLIENT` | string | Identificatore client (default `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | Abilita gli strumenti di web search Exa |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | Abilita basic auth per `serve`/`web` |
|
||||
|
||||
@@ -573,7 +573,6 @@ OpenCode は環境変数を使用して構成できます。
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | ブール値 | `.claude/skills` のロードを無効にする |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | ブール値 | リモートソースからのモデルの取得を無効にする |
|
||||
| `OPENCODE_FAKE_VCS` | 文字列 | テスト目的の偽の VCS プロバイダー |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | ブール値 | 最適化のためにファイル時間チェックを無効にする |
|
||||
| `OPENCODE_CLIENT` | 文字列 | クライアント識別子 (デフォルトは `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | ブール値 | Exa Web 検索ツールを有効にする |
|
||||
| `OPENCODE_SERVER_PASSWORD` | 文字列 | `serve`/`web` の基本認証を有効にする |
|
||||
|
||||
@@ -573,7 +573,6 @@ OpenCode는 환경 변수로도 구성할 수 있습니다.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | `.claude/skills` 로드 비활성화 |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | 원격 소스에서 모델 목록 가져오기 비활성화 |
|
||||
| `OPENCODE_FAKE_VCS` | string | 테스트용 가짜 VCS provider |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | 최적화를 위한 파일 시간 검사 비활성화 |
|
||||
| `OPENCODE_CLIENT` | string | 클라이언트 식별자(기본값: `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | Exa 웹 검색 도구 활성화 |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | `serve`/`web` 기본 인증 활성화 |
|
||||
|
||||
@@ -574,7 +574,6 @@ OpenCode kan konfigureres ved hjelp av miljøvariabler.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolsk | Deaktiver innlasting av `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolsk | Deaktiver henting av modeller fra eksterne kilder |
|
||||
| `OPENCODE_FAKE_VCS` | streng | Falsk VCS-leverandør for testformål |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolsk | Deaktiver filtidskontroll for optimalisering |
|
||||
| `OPENCODE_CLIENT` | streng | Klientidentifikator (standard til `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolsk | Aktiver Exa-nettsøkeverktøy |
|
||||
| `OPENCODE_SERVER_PASSWORD` | streng | Aktiver grunnleggende autentisering for `serve`/`web` |
|
||||
|
||||
@@ -574,7 +574,6 @@ OpenCode można skonfigurować za pomocą zmiennych środowiskowych.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Wyłącz ładowanie `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Wyłącz pobieranie modeli ze źródeł zewnętrznych |
|
||||
| `OPENCODE_FAKE_VCS` | string | Fałszywy dostawca VCS do celów testowych |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Wyłącz sprawdzanie czasu modyfikacji plików (optymalizacja) |
|
||||
| `OPENCODE_CLIENT` | string | Identyfikator klienta (domyślnie `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | Włącz narzędzie wyszukiwania internetowego Exa |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | Włącz uwierzytelnianie podstawowe dla `serve`/`web` |
|
||||
|
||||
@@ -573,7 +573,6 @@ O opencode pode ser configurado usando variáveis de ambiente.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Desabilitar carregamento de `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Desabilitar busca de modelos de fontes remotas |
|
||||
| `OPENCODE_FAKE_VCS` | string | Provedor VCS falso para fins de teste |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Desabilitar verificação de tempo de arquivo para otimização |
|
||||
| `OPENCODE_CLIENT` | string | Identificador do cliente (padrão é `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | Habilitar ferramentas de busca web Exa |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | Habilitar autenticação básica para `serve`/`web` |
|
||||
|
||||
@@ -574,7 +574,6 @@ opencode можно настроить с помощью переменных с
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | логическое значение | Отключить загрузку `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | логическое значение | Отключить получение моделей из удаленных источников |
|
||||
| `OPENCODE_FAKE_VCS` | строка | Поддельный поставщик VCS для целей тестирования |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | логическое значение | Отключить проверку времени файла для оптимизации |
|
||||
| `OPENCODE_CLIENT` | строка | Идентификатор клиента (по умолчанию `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | логическое значение | Включить инструменты веб-поиска Exa |
|
||||
| `OPENCODE_SERVER_PASSWORD` | строка | Включить базовую аутентификацию для `serve`/`web` |
|
||||
|
||||
@@ -575,7 +575,6 @@ OpenCode สามารถกำหนดค่าโดยใช้ตัว
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | Boolean | ปิดใช้งานการนำเข้า `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | Boolean | ปิดใช้งานการดึงรายการโมเดลจากระยะไกล |
|
||||
| `OPENCODE_FAKE_VCS` | String | เปิดใช้งาน VCS จำลองสำหรับการทดสอบ |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | Boolean | ปิดใช้งานการตรวจสอบเวลาแก้ไขไฟล์ |
|
||||
| `OPENCODE_CLIENT` | String | ตัวระบุไคลเอนต์ (ค่าเริ่มต้นคือ `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | Boolean | เปิดใช้งานการใช้ Exa แทน ls หากมี |
|
||||
| `OPENCODE_SERVER_PASSWORD` | String | รหัสผ่านสำหรับการตรวจสอบสิทธิ์พื้นฐาน `serve`/`web` |
|
||||
|
||||
@@ -574,7 +574,6 @@ opencode ortam değişkenleri kullanılarak yapılandırılabilir.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | `.claude/skills` yüklemesini devre dışı bırak |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Uzak kaynaklardan model getirmeyi devre dışı bırakın |
|
||||
| `OPENCODE_FAKE_VCS` | string | Test amaçlı sahte VCS sağlayıcısı |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Optimizasyon için dosya süresi kontrolünü devre dışı bırakın |
|
||||
| `OPENCODE_CLIENT` | string | Client kimliği (varsayılan: `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | Exa web arama araçlarını etkinleştir |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | `serve`/`web` için temel kimlik doğrulamayı etkinleştirin |
|
||||
|
||||
@@ -574,7 +574,6 @@ OpenCode 可以通过环境变量进行配置。
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | 禁用加载 `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | 禁用从远程源获取模型 |
|
||||
| `OPENCODE_FAKE_VCS` | string | 用于测试目的的模拟 VCS 提供商 |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | 禁用文件时间检查优化 |
|
||||
| `OPENCODE_CLIENT` | string | 客户端标识符(默认为 `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | 启用 Exa 网络搜索工具 |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | 为 `serve`/`web` 启用基本认证 |
|
||||
|
||||
@@ -574,7 +574,6 @@ OpenCode 可以透過環境變數進行設定。
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | 停用載入 `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | 停用從遠端來源擷取模型 |
|
||||
| `OPENCODE_FAKE_VCS` | string | 用於測試目的的模擬 VCS 供應商 |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | 停用檔案時間檢查最佳化 |
|
||||
| `OPENCODE_CLIENT` | string | 用戶端識別碼(預設為 `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | 啟用 Exa 網路搜尋工具 |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | 為 `serve`/`web` 啟用基本認證 |
|
||||
|
||||
Reference in New Issue
Block a user