core: dynamically resolve formatter executable paths at runtime

Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
This commit is contained in:
Dax Raad 2026-03-09 21:58:25 -04:00
commit 528daf5490
6 changed files with 137 additions and 117 deletions

View file

@ -1,6 +1,6 @@
node_modules node_modules
plans
package.json package.json
bun.lock bun.lock
.gitignore .gitignore
package-lock.json package-lock.json
plans

View file

@ -1,4 +1,3 @@
import semver from "semver"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Process } from "../util/process" import { Process } from "../util/process"
@ -34,17 +33,4 @@ export namespace PackageRegistry {
if (!value) return null if (!value) return null
return value return value
} }
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
} }

View file

@ -29,7 +29,6 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event" import { Event } from "../server/event"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Account } from "@/account" import { Account } from "@/account"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
@ -325,7 +324,7 @@ export namespace Config {
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") { if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir) const isOutdated = await Npm.outdated("@opencode-ai/plugin", depVersion)
if (!isOutdated) return false if (!isOutdated) return false
log.info("Cached version is outdated, proceeding with install", { log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin", pkg: "@opencode-ai/plugin",

View file

@ -1,40 +1,40 @@
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Npm } from "@/npm"
export interface Info { export interface Info {
name: string name: string
command: string[]
environment?: Record<string, string> environment?: Record<string, string>
extensions: string[] extensions: string[]
enabled(): Promise<boolean> enabled(): Promise<string[] | false>
} }
export const gofmt: Info = { export const gofmt: Info = {
name: "gofmt", name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"], extensions: [".go"],
async enabled() { async enabled() {
return which("gofmt") !== null const p = which("gofmt")
if (p === null) return false
return [p, "-w", "$FILE"]
}, },
} }
export const mix: Info = { export const mix: Info = {
name: "mix", name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() { async enabled() {
return which("mix") !== null const p = which("mix")
if (p === null) return false
return [p, "format", "$FILE"]
}, },
} }
export const prettier: Info = { export const prettier: Info = {
name: "prettier", name: "prettier",
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@ -73,8 +73,9 @@ export const prettier: Info = {
dependencies?: Record<string, string> dependencies?: Record<string, string>
devDependencies?: Record<string, string> devDependencies?: Record<string, string>
}>(item) }>(item)
if (json.dependencies?.prettier) return true if (json.dependencies?.prettier || json.devDependencies?.prettier) {
if (json.devDependencies?.prettier) return true return [await Npm.which("prettier"), "--write", "$FILE"]
}
} }
return false return false
}, },
@ -82,7 +83,6 @@ export const prettier: Info = {
export const oxfmt: Info = { export const oxfmt: Info = {
name: "oxfmt", name: "oxfmt",
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@ -95,8 +95,9 @@ export const oxfmt: Info = {
dependencies?: Record<string, string> dependencies?: Record<string, string>
devDependencies?: Record<string, string> devDependencies?: Record<string, string>
}>(item) }>(item)
if (json.dependencies?.oxfmt) return true if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
if (json.devDependencies?.oxfmt) return true return [await Npm.which("oxfmt"), "$FILE"]
}
} }
return false return false
}, },
@ -104,7 +105,6 @@ export const oxfmt: Info = {
export const biome: Info = { export const biome: Info = {
name: "biome", name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@ -141,7 +141,7 @@ export const biome: Info = {
for (const config of configs) { for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) { if (found.length > 0) {
return true return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"]
} }
} }
return false return false
@ -150,47 +150,49 @@ export const biome: Info = {
export const zig: Info = { export const zig: Info = {
name: "zig", name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"], extensions: [".zig", ".zon"],
async enabled() { async enabled() {
return which("zig") !== null const p = which("zig")
if (p === null) return false
return [p, "fmt", "$FILE"]
}, },
} }
export const clang: Info = { export const clang: Info = {
name: "clang-format", name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() { async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree) const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
return items.length > 0 if (items.length === 0) return false
return ["clang-format", "-i", "$FILE"]
}, },
} }
export const ktlint: Info = { export const ktlint: Info = {
name: "ktlint", name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"], extensions: [".kt", ".kts"],
async enabled() { async enabled() {
return which("ktlint") !== null const p = which("ktlint")
if (p === null) return false
return [p, "-F", "$FILE"]
}, },
} }
export const ruff: Info = { export const ruff: Info = {
name: "ruff", name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
async enabled() { async enabled() {
if (!which("ruff")) return false const p = which("ruff")
if (p === null) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"] const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) { for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) { if (found.length > 0) {
if (config === "pyproject.toml") { if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0]) const content = await Filesystem.readText(found[0])
if (content.includes("[tool.ruff]")) return true if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
} else { } else {
return true return [p, "format", "$FILE"]
} }
} }
} }
@ -199,7 +201,7 @@ export const ruff: Info = {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) { if (found.length > 0) {
const content = await Filesystem.readText(found[0]) const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return true if (content.includes("ruff")) return [p, "format", "$FILE"]
} }
} }
return false return false
@ -208,14 +210,13 @@ export const ruff: Info = {
export const rlang: Info = { export const rlang: Info = {
name: "air", name: "air",
command: ["air", "format", "$FILE"],
extensions: [".R"], extensions: [".R"],
async enabled() { async enabled() {
const airPath = which("air") const airPath = which("air")
if (airPath == null) return false if (airPath == null) return false
try { try {
const proc = Process.spawn(["air", "--help"], { const proc = Process.spawn([airPath, "--help"], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}) })
@ -227,7 +228,10 @@ export const rlang: Info = {
const firstLine = output.split("\n")[0] const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language") const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter") const hasFormatter = firstLine.includes("formatter")
return hasR && hasFormatter if (hasR && hasFormatter) {
return [airPath, "format", "$FILE"]
}
return false
} catch (error) { } catch (error) {
return false return false
} }
@ -236,14 +240,14 @@ export const rlang: Info = {
export const uvformat: Info = { export const uvformat: Info = {
name: "uv", name: "uv",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
async enabled() { async enabled() {
if (await ruff.enabled()) return false if (await ruff.enabled()) return false
if (which("uv") !== null) { const uvPath = which("uv")
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) if (uvPath !== null) {
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited const code = await proc.exited
return code === 0 if (code === 0) return [uvPath, "format", "--", "$FILE"]
} }
return false return false
}, },
@ -251,108 +255,118 @@ export const uvformat: Info = {
export const rubocop: Info = { export const rubocop: Info = {
name: "rubocop", name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() { async enabled() {
return which("rubocop") !== null const path = which("rubocop")
if (path === null) return false
return [path, "--autocorrect", "$FILE"]
}, },
} }
export const standardrb: Info = { export const standardrb: Info = {
name: "standardrb", name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() { async enabled() {
return which("standardrb") !== null const path = which("standardrb")
if (path === null) return false
return [path, "--fix", "$FILE"]
}, },
} }
export const htmlbeautifier: Info = { export const htmlbeautifier: Info = {
name: "htmlbeautifier", name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"], extensions: [".erb", ".html.erb"],
async enabled() { async enabled() {
return which("htmlbeautifier") !== null const path = which("htmlbeautifier")
if (path === null) return false
return [path, "$FILE"]
}, },
} }
export const dart: Info = { export const dart: Info = {
name: "dart", name: "dart",
command: ["dart", "format", "$FILE"],
extensions: [".dart"], extensions: [".dart"],
async enabled() { async enabled() {
return which("dart") !== null const path = which("dart")
if (path === null) return false
return [path, "format", "$FILE"]
}, },
} }
export const ocamlformat: Info = { export const ocamlformat: Info = {
name: "ocamlformat", name: "ocamlformat",
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"], extensions: [".ml", ".mli"],
async enabled() { async enabled() {
if (!which("ocamlformat")) return false const path = which("ocamlformat")
if (!path) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree) const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
return items.length > 0 if (items.length === 0) return false
return [path, "-i", "$FILE"]
}, },
} }
export const terraform: Info = { export const terraform: Info = {
name: "terraform", name: "terraform",
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"], extensions: [".tf", ".tfvars"],
async enabled() { async enabled() {
return which("terraform") !== null const path = which("terraform")
if (path === null) return false
return [path, "fmt", "$FILE"]
}, },
} }
export const latexindent: Info = { export const latexindent: Info = {
name: "latexindent", name: "latexindent",
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"], extensions: [".tex"],
async enabled() { async enabled() {
return which("latexindent") !== null const path = which("latexindent")
if (path === null) return false
return [path, "-w", "-s", "$FILE"]
}, },
} }
export const gleam: Info = { export const gleam: Info = {
name: "gleam", name: "gleam",
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"], extensions: [".gleam"],
async enabled() { async enabled() {
return which("gleam") !== null const path = which("gleam")
if (path === null) return false
return [path, "format", "$FILE"]
}, },
} }
export const shfmt: Info = { export const shfmt: Info = {
name: "shfmt", name: "shfmt",
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"], extensions: [".sh", ".bash"],
async enabled() { async enabled() {
return which("shfmt") !== null const path = which("shfmt")
if (path === null) return false
return [path, "-w", "$FILE"]
}, },
} }
export const nixfmt: Info = { export const nixfmt: Info = {
name: "nixfmt", name: "nixfmt",
command: ["nixfmt", "$FILE"],
extensions: [".nix"], extensions: [".nix"],
async enabled() { async enabled() {
return which("nixfmt") !== null const path = which("nixfmt")
if (path === null) return false
return [path, "$FILE"]
}, },
} }
export const rustfmt: Info = { export const rustfmt: Info = {
name: "rustfmt", name: "rustfmt",
command: ["rustfmt", "$FILE"],
extensions: [".rs"], extensions: [".rs"],
async enabled() { async enabled() {
return which("rustfmt") !== null const path = which("rustfmt")
if (path === null) return false
return [path, "$FILE"]
}, },
} }
export const pint: Info = { export const pint: Info = {
name: "pint", name: "pint",
command: ["./vendor/bin/pint", "$FILE"],
extensions: [".php"], extensions: [".php"],
async enabled() { async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree) const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
@ -361,8 +375,9 @@ export const pint: Info = {
require?: Record<string, string> require?: Record<string, string>
"require-dev"?: Record<string, string> "require-dev"?: Record<string, string>
}>(item) }>(item)
if (json.require?.["laravel/pint"]) return true if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
if (json["require-dev"]?.["laravel/pint"]) return true return ["./vendor/bin/pint", "$FILE"]
}
} }
return false return false
}, },
@ -370,27 +385,30 @@ export const pint: Info = {
export const ormolu: Info = { export const ormolu: Info = {
name: "ormolu", name: "ormolu",
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"], extensions: [".hs"],
async enabled() { async enabled() {
return which("ormolu") !== null const path = which("ormolu")
if (path === null) return false
return [path, "-i", "$FILE"]
}, },
} }
export const cljfmt: Info = { export const cljfmt: Info = {
name: "cljfmt", name: "cljfmt",
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"], extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() { async enabled() {
return which("cljfmt") !== null const path = which("cljfmt")
if (path === null) return false
return [path, "fix", "--quiet", "$FILE"]
}, },
} }
export const dfmt: Info = { export const dfmt: Info = {
name: "dfmt", name: "dfmt",
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"], extensions: [".d"],
async enabled() { async enabled() {
return which("dfmt") !== null const path = which("dfmt")
if (path === null) return false
return [path, "-i", "$FILE"]
}, },
} }

View file

@ -25,14 +25,14 @@ export namespace Format {
export type Status = z.infer<typeof Status> export type Status = z.infer<typeof Status>
const state = Instance.state(async () => { const state = Instance.state(async () => {
const enabled: Record<string, boolean> = {} const cache: Record<string, string[] | false> = {}
const cfg = await Config.get() const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {} const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) { if (cfg.formatter === false) {
log.info("all formatters are disabled") log.info("all formatters are disabled")
return { return {
enabled, cache,
formatters, formatters,
} }
} }
@ -46,43 +46,41 @@ export namespace Format {
continue continue
} }
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [], extensions: [],
...item, ...item,
}) })
if (result.command.length === 0) continue result.enabled = async () => item.command ?? false
result.enabled = async () => true
result.name = name result.name = name
formatters[name] = result formatters[name] = result
} }
return { return {
enabled, cache,
formatters, formatters,
} }
}) })
async function isEnabled(item: Formatter.Info) { async function resolveCommand(item: Formatter.Info) {
const s = await state() const s = await state()
let status = s.enabled[item.name] let command = s.cache[item.name]
if (status === undefined) { if (command === undefined) {
status = await item.enabled() log.info("resolving command", { name: item.name })
s.enabled[item.name] = status command = await item.enabled()
s.cache[item.name] = command
} }
return status return command
} }
async function getFormatter(ext: string) { async function getFormatter(ext: string) {
const formatters = await state().then((x) => x.formatters) const formatters = await state().then((x) => x.formatters)
const result = [] const result: { info: Formatter.Info; command: string[] }[] = []
for (const item of Object.values(formatters)) { for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue const command = await resolveCommand(item)
if (!command) continue
log.info("enabled", { name: item.name, ext }) log.info("enabled", { name: item.name, ext })
result.push(item) result.push({ info: item, command })
} }
return result return result
} }
@ -91,11 +89,11 @@ export namespace Format {
const s = await state() const s = await state()
const result: Status[] = [] const result: Status[] = []
for (const formatter of Object.values(s.formatters)) { for (const formatter of Object.values(s.formatters)) {
const enabled = await isEnabled(formatter) const command = await resolveCommand(formatter)
result.push({ result.push({
name: formatter.name, name: formatter.name,
extensions: formatter.extensions, extensions: formatter.extensions,
enabled, enabled: !!command,
}) })
} }
return result return result
@ -108,29 +106,27 @@ export namespace Format {
log.info("formatting", { file }) log.info("formatting", { file })
const ext = path.extname(file) const ext = path.extname(file)
for (const item of await getFormatter(ext)) { for (const { info, command } of await getFormatter(ext)) {
log.info("running", { command: item.command }) const replaced = command.map((x) => x.replace("$FILE", file))
log.info("running", { replaced })
try { try {
const proc = Process.spawn( const proc = Process.spawn(replaced, {
item.command.map((x) => x.replace("$FILE", file)), cwd: Instance.directory,
{ env: { ...process.env, ...info.environment },
cwd: Instance.directory, stdout: "ignore",
env: { ...process.env, ...item.environment }, stderr: "ignore",
stdout: "ignore", })
stderr: "ignore",
},
)
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) if (exit !== 0)
log.error("failed", { log.error("failed", {
command: item.command, command,
...item.environment, ...info.environment,
}) })
} catch (error) { } catch (error) {
log.error("failed to format file", { log.error("failed to format file", {
error, error,
command: item.command, command,
...item.environment, ...info.environment,
file, file,
}) })
} }

View file

@ -1,3 +1,4 @@
import semver from "semver"
import z from "zod" import z from "zod"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global" import { Global } from "../global"
@ -21,6 +22,26 @@ export namespace Npm {
return path.join(Global.Path.cache, "packages", pkg) return path.join(Global.Path.cache, "packages", pkg)
} }
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
export async function add(pkg: string) { export async function add(pkg: string) {
using _ = await Lock.write("npm-install") using _ = await Lock.write("npm-install")
log.info("installing package using npm arborist", { log.info("installing package using npm arborist", {