bfffc3c2c6
Fixes potential plugin resolution issues when switching between projects by wrapping plugin loading in Instance.provide(). This ensures each plugin resolves dependencies relative to its correct project directory instead of inheriting context from whatever instance happened to be active. Also reorganizes config loading code into focused modules (command.ts, managed.ts, plugin.ts) to make the codebase easier to maintain and test.
167 lines
5.1 KiB
TypeScript
167 lines
5.1 KiB
TypeScript
import path from "path"
|
|
import os from "os"
|
|
import z from "zod"
|
|
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
|
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
|
import { Filesystem } from "@/util"
|
|
import { Flag } from "@/flag/flag"
|
|
import { Global } from "@/global"
|
|
import { unique } from "remeda"
|
|
|
|
export async function projectFiles(name: string, directory: string, worktree?: string) {
|
|
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
|
|
}
|
|
|
|
export async function directories(directory: string, worktree?: string) {
|
|
return unique([
|
|
Global.Path.config,
|
|
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
|
? await Array.fromAsync(
|
|
Filesystem.up({
|
|
targets: [".opencode"],
|
|
start: directory,
|
|
stop: worktree,
|
|
}),
|
|
)
|
|
: []),
|
|
...(await Array.fromAsync(
|
|
Filesystem.up({
|
|
targets: [".opencode"],
|
|
start: Global.Path.home,
|
|
stop: Global.Path.home,
|
|
}),
|
|
)),
|
|
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
|
|
])
|
|
}
|
|
|
|
export function fileInDirectory(dir: string, name: string) {
|
|
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
|
|
}
|
|
|
|
export const JsonError = NamedError.create(
|
|
"ConfigJsonError",
|
|
z.object({
|
|
path: z.string(),
|
|
message: z.string().optional(),
|
|
}),
|
|
)
|
|
|
|
export const InvalidError = NamedError.create(
|
|
"ConfigInvalidError",
|
|
z.object({
|
|
path: z.string(),
|
|
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
|
message: z.string().optional(),
|
|
}),
|
|
)
|
|
|
|
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
|
|
export async function readFile(filepath: string) {
|
|
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
|
|
if (err.code === "ENOENT") return
|
|
throw new JsonError({ path: filepath }, { cause: err })
|
|
})
|
|
}
|
|
|
|
type ParseSource = string | { source: string; dir: string }
|
|
|
|
function source(input: ParseSource) {
|
|
return typeof input === "string" ? input : input.source
|
|
}
|
|
|
|
function dir(input: ParseSource) {
|
|
return typeof input === "string" ? path.dirname(input) : input.dir
|
|
}
|
|
|
|
/** Apply {env:VAR} and {file:path} substitutions to config text. */
|
|
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
|
|
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
|
return process.env[varName] || ""
|
|
})
|
|
|
|
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
|
if (!fileMatches.length) return text
|
|
|
|
const configDir = dir(input)
|
|
const configSource = source(input)
|
|
let out = ""
|
|
let cursor = 0
|
|
|
|
for (const match of fileMatches) {
|
|
const token = match[0]
|
|
const index = match.index!
|
|
out += text.slice(cursor, index)
|
|
|
|
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
|
const prefix = text.slice(lineStart, index).trimStart()
|
|
if (prefix.startsWith("//")) {
|
|
out += token
|
|
cursor = index + token.length
|
|
continue
|
|
}
|
|
|
|
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
|
if (filePath.startsWith("~/")) {
|
|
filePath = path.join(os.homedir(), filePath.slice(2))
|
|
}
|
|
|
|
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
|
const fileContent = (
|
|
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
|
|
if (missing === "empty") return ""
|
|
|
|
const errMsg = `bad file reference: "${token}"`
|
|
if (error.code === "ENOENT") {
|
|
throw new InvalidError(
|
|
{
|
|
path: configSource,
|
|
message: errMsg + ` ${resolvedPath} does not exist`,
|
|
},
|
|
{ cause: error },
|
|
)
|
|
}
|
|
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
|
|
})
|
|
).trim()
|
|
|
|
out += JSON.stringify(fileContent).slice(1, -1)
|
|
cursor = index + token.length
|
|
}
|
|
|
|
out += text.slice(cursor)
|
|
return out
|
|
}
|
|
|
|
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
|
|
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
|
|
const configSource = source(input)
|
|
text = await substitute(text, input, missing)
|
|
|
|
const errors: JsoncParseError[] = []
|
|
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
|
if (errors.length) {
|
|
const lines = text.split("\n")
|
|
const errorDetails = errors
|
|
.map((e) => {
|
|
const beforeOffset = text.substring(0, e.offset).split("\n")
|
|
const line = beforeOffset.length
|
|
const column = beforeOffset[beforeOffset.length - 1].length + 1
|
|
const problemLine = lines[line - 1]
|
|
|
|
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
|
|
if (!problemLine) return error
|
|
|
|
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
|
|
})
|
|
.join("\n")
|
|
|
|
throw new JsonError({
|
|
path: configSource,
|
|
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
|
})
|
|
}
|
|
|
|
return data
|
|
}
|