From 528daf5490a17605d6d5b8fdc013c0c8eb9ca608 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 9 Mar 2026 21:58:25 -0400 Subject: [PATCH] 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. --- .opencode/.gitignore | 4 +- packages/opencode/src/bun/registry.ts | 14 -- packages/opencode/src/config/config.ts | 3 +- packages/opencode/src/format/formatter.ts | 148 ++++++++++++---------- packages/opencode/src/format/index.ts | 64 +++++----- packages/opencode/src/npm/index.ts | 21 +++ 6 files changed, 137 insertions(+), 117 deletions(-) diff --git a/.opencode/.gitignore b/.opencode/.gitignore index e7c46c0033..d3bf7f8d3b 100644 --- a/.opencode/.gitignore +++ b/.opencode/.gitignore @@ -1,6 +1,6 @@ node_modules +plans package.json bun.lock .gitignore -package-lock.json -plans +package-lock.json \ No newline at end of file diff --git a/packages/opencode/src/bun/registry.ts b/packages/opencode/src/bun/registry.ts index 1fc8531442..b593ce4aea 100644 --- a/packages/opencode/src/bun/registry.ts +++ b/packages/opencode/src/bun/registry.ts @@ -1,4 +1,3 @@ -import semver from "semver" import { text } from "node:stream/consumers" import { Log } from "../util/log" import { Process } from "../util/process" @@ -34,17 +33,4 @@ export namespace PackageRegistry { if (!value) return null return value } - - export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise { - 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) - } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 95e2b9c748..4be232350b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -29,7 +29,6 @@ import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" import { Glob } from "../util/glob" -import { PackageRegistry } from "@/bun/registry" import { iife } from "@/util/iife" import { Account } from "@/account" import { ConfigPaths } from "./paths" @@ -325,7 +324,7 @@ export namespace Config { const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION 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 log.info("Cached version is outdated, proceeding with install", { pkg: "@opencode-ai/plugin", diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 9e96b2305c..5424520cab 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,40 +1,40 @@ import { text } from "node:stream/consumers" -import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" import { Process } from "../util/process" import { which } from "../util/which" import { Flag } from "@/flag/flag" +import { Npm } from "@/npm" export interface Info { name: string - command: string[] environment?: Record extensions: string[] - enabled(): Promise + enabled(): Promise } export const gofmt: Info = { name: "gofmt", - command: ["gofmt", "-w", "$FILE"], extensions: [".go"], async enabled() { - return which("gofmt") !== null + const p = which("gofmt") + if (p === null) return false + return [p, "-w", "$FILE"] }, } export const mix: Info = { name: "mix", - command: ["mix", "format", "$FILE"], extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], async enabled() { - return which("mix") !== null + const p = which("mix") + if (p === null) return false + return [p, "format", "$FILE"] }, } export const prettier: Info = { name: "prettier", - command: [BunProc.which(), "x", "prettier", "--write", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -73,8 +73,9 @@ export const prettier: Info = { dependencies?: Record devDependencies?: Record }>(item) - if (json.dependencies?.prettier) return true - if (json.devDependencies?.prettier) return true + if (json.dependencies?.prettier || json.devDependencies?.prettier) { + return [await Npm.which("prettier"), "--write", "$FILE"] + } } return false }, @@ -82,7 +83,6 @@ export const prettier: Info = { export const oxfmt: Info = { name: "oxfmt", - command: [BunProc.which(), "x", "oxfmt", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -95,8 +95,9 @@ export const oxfmt: Info = { dependencies?: Record devDependencies?: Record }>(item) - if (json.dependencies?.oxfmt) return true - if (json.devDependencies?.oxfmt) return true + if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) { + return [await Npm.which("oxfmt"), "$FILE"] + } } return false }, @@ -104,7 +105,6 @@ export const oxfmt: Info = { export const biome: Info = { name: "biome", - command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -141,7 +141,7 @@ export const biome: Info = { for (const config of configs) { const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) if (found.length > 0) { - return true + return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"] } } return false @@ -150,47 +150,49 @@ export const biome: Info = { export const zig: Info = { name: "zig", - command: ["zig", "fmt", "$FILE"], extensions: [".zig", ".zon"], async enabled() { - return which("zig") !== null + const p = which("zig") + if (p === null) return false + return [p, "fmt", "$FILE"] }, } export const clang: Info = { name: "clang-format", - command: ["clang-format", "-i", "$FILE"], extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], async enabled() { 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 = { name: "ktlint", - command: ["ktlint", "-F", "$FILE"], extensions: [".kt", ".kts"], async enabled() { - return which("ktlint") !== null + const p = which("ktlint") + if (p === null) return false + return [p, "-F", "$FILE"] }, } export const ruff: Info = { name: "ruff", - command: ["ruff", "format", "$FILE"], extensions: [".py", ".pyi"], async enabled() { - if (!which("ruff")) return false + const p = which("ruff") + if (p === null) return false const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"] for (const config of configs) { const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) if (found.length > 0) { if (config === "pyproject.toml") { const content = await Filesystem.readText(found[0]) - if (content.includes("[tool.ruff]")) return true + if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"] } 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) if (found.length > 0) { const content = await Filesystem.readText(found[0]) - if (content.includes("ruff")) return true + if (content.includes("ruff")) return [p, "format", "$FILE"] } } return false @@ -208,14 +210,13 @@ export const ruff: Info = { export const rlang: Info = { name: "air", - command: ["air", "format", "$FILE"], extensions: [".R"], async enabled() { const airPath = which("air") if (airPath == null) return false try { - const proc = Process.spawn(["air", "--help"], { + const proc = Process.spawn([airPath, "--help"], { stdout: "pipe", stderr: "pipe", }) @@ -227,7 +228,10 @@ export const rlang: Info = { const firstLine = output.split("\n")[0] const hasR = firstLine.includes("R language") const hasFormatter = firstLine.includes("formatter") - return hasR && hasFormatter + if (hasR && hasFormatter) { + return [airPath, "format", "$FILE"] + } + return false } catch (error) { return false } @@ -236,14 +240,14 @@ export const rlang: Info = { export const uvformat: Info = { name: "uv", - command: ["uv", "format", "--", "$FILE"], extensions: [".py", ".pyi"], async enabled() { if (await ruff.enabled()) return false - if (which("uv") !== null) { - const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) + const uvPath = which("uv") + if (uvPath !== null) { + const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" }) const code = await proc.exited - return code === 0 + if (code === 0) return [uvPath, "format", "--", "$FILE"] } return false }, @@ -251,108 +255,118 @@ export const uvformat: Info = { export const rubocop: Info = { name: "rubocop", - command: ["rubocop", "--autocorrect", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async enabled() { - return which("rubocop") !== null + const path = which("rubocop") + if (path === null) return false + return [path, "--autocorrect", "$FILE"] }, } export const standardrb: Info = { name: "standardrb", - command: ["standardrb", "--fix", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async enabled() { - return which("standardrb") !== null + const path = which("standardrb") + if (path === null) return false + return [path, "--fix", "$FILE"] }, } export const htmlbeautifier: Info = { name: "htmlbeautifier", - command: ["htmlbeautifier", "$FILE"], extensions: [".erb", ".html.erb"], async enabled() { - return which("htmlbeautifier") !== null + const path = which("htmlbeautifier") + if (path === null) return false + return [path, "$FILE"] }, } export const dart: Info = { name: "dart", - command: ["dart", "format", "$FILE"], extensions: [".dart"], async enabled() { - return which("dart") !== null + const path = which("dart") + if (path === null) return false + return [path, "format", "$FILE"] }, } export const ocamlformat: Info = { name: "ocamlformat", - command: ["ocamlformat", "-i", "$FILE"], extensions: [".ml", ".mli"], 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) - return items.length > 0 + if (items.length === 0) return false + return [path, "-i", "$FILE"] }, } export const terraform: Info = { name: "terraform", - command: ["terraform", "fmt", "$FILE"], extensions: [".tf", ".tfvars"], async enabled() { - return which("terraform") !== null + const path = which("terraform") + if (path === null) return false + return [path, "fmt", "$FILE"] }, } export const latexindent: Info = { name: "latexindent", - command: ["latexindent", "-w", "-s", "$FILE"], extensions: [".tex"], async enabled() { - return which("latexindent") !== null + const path = which("latexindent") + if (path === null) return false + return [path, "-w", "-s", "$FILE"] }, } export const gleam: Info = { name: "gleam", - command: ["gleam", "format", "$FILE"], extensions: [".gleam"], async enabled() { - return which("gleam") !== null + const path = which("gleam") + if (path === null) return false + return [path, "format", "$FILE"] }, } export const shfmt: Info = { name: "shfmt", - command: ["shfmt", "-w", "$FILE"], extensions: [".sh", ".bash"], async enabled() { - return which("shfmt") !== null + const path = which("shfmt") + if (path === null) return false + return [path, "-w", "$FILE"] }, } export const nixfmt: Info = { name: "nixfmt", - command: ["nixfmt", "$FILE"], extensions: [".nix"], async enabled() { - return which("nixfmt") !== null + const path = which("nixfmt") + if (path === null) return false + return [path, "$FILE"] }, } export const rustfmt: Info = { name: "rustfmt", - command: ["rustfmt", "$FILE"], extensions: [".rs"], async enabled() { - return which("rustfmt") !== null + const path = which("rustfmt") + if (path === null) return false + return [path, "$FILE"] }, } export const pint: Info = { name: "pint", - command: ["./vendor/bin/pint", "$FILE"], extensions: [".php"], async enabled() { const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree) @@ -361,8 +375,9 @@ export const pint: Info = { require?: Record "require-dev"?: Record }>(item) - if (json.require?.["laravel/pint"]) return true - if (json["require-dev"]?.["laravel/pint"]) return true + if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) { + return ["./vendor/bin/pint", "$FILE"] + } } return false }, @@ -370,27 +385,30 @@ export const pint: Info = { export const ormolu: Info = { name: "ormolu", - command: ["ormolu", "-i", "$FILE"], extensions: [".hs"], async enabled() { - return which("ormolu") !== null + const path = which("ormolu") + if (path === null) return false + return [path, "-i", "$FILE"] }, } export const cljfmt: Info = { name: "cljfmt", - command: ["cljfmt", "fix", "--quiet", "$FILE"], extensions: [".clj", ".cljs", ".cljc", ".edn"], async enabled() { - return which("cljfmt") !== null + const path = which("cljfmt") + if (path === null) return false + return [path, "fix", "--quiet", "$FILE"] }, } export const dfmt: Info = { name: "dfmt", - command: ["dfmt", "-i", "$FILE"], extensions: [".d"], async enabled() { - return which("dfmt") !== null + const path = which("dfmt") + if (path === null) return false + return [path, "-i", "$FILE"] }, } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index b849f778ec..6e5a87bad4 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -25,14 +25,14 @@ export namespace Format { export type Status = z.infer const state = Instance.state(async () => { - const enabled: Record = {} + const cache: Record = {} const cfg = await Config.get() const formatters: Record = {} if (cfg.formatter === false) { log.info("all formatters are disabled") return { - enabled, + cache, formatters, } } @@ -46,43 +46,41 @@ export namespace Format { continue } const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { - command: [], extensions: [], ...item, }) - if (result.command.length === 0) continue - - result.enabled = async () => true + result.enabled = async () => item.command ?? false result.name = name formatters[name] = result } return { - enabled, + cache, formatters, } }) - async function isEnabled(item: Formatter.Info) { + async function resolveCommand(item: Formatter.Info) { const s = await state() - let status = s.enabled[item.name] - if (status === undefined) { - status = await item.enabled() - s.enabled[item.name] = status + let command = s.cache[item.name] + if (command === undefined) { + log.info("resolving command", { name: item.name }) + command = await item.enabled() + s.cache[item.name] = command } - return status + return command } async function getFormatter(ext: string) { const formatters = await state().then((x) => x.formatters) - const result = [] + const result: { info: Formatter.Info; command: string[] }[] = [] for (const item of Object.values(formatters)) { - log.info("checking", { name: item.name, ext }) 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 }) - result.push(item) + result.push({ info: item, command }) } return result } @@ -91,11 +89,11 @@ export namespace Format { const s = await state() const result: Status[] = [] for (const formatter of Object.values(s.formatters)) { - const enabled = await isEnabled(formatter) + const command = await resolveCommand(formatter) result.push({ name: formatter.name, extensions: formatter.extensions, - enabled, + enabled: !!command, }) } return result @@ -108,29 +106,27 @@ export namespace Format { log.info("formatting", { file }) const ext = path.extname(file) - for (const item of await getFormatter(ext)) { - log.info("running", { command: item.command }) + for (const { info, command } of await getFormatter(ext)) { + const replaced = command.map((x) => x.replace("$FILE", file)) + log.info("running", { replaced }) try { - const proc = Process.spawn( - item.command.map((x) => x.replace("$FILE", file)), - { - cwd: Instance.directory, - env: { ...process.env, ...item.environment }, - stdout: "ignore", - stderr: "ignore", - }, - ) + const proc = Process.spawn(replaced, { + cwd: Instance.directory, + env: { ...process.env, ...info.environment }, + stdout: "ignore", + stderr: "ignore", + }) const exit = await proc.exited if (exit !== 0) log.error("failed", { - command: item.command, - ...item.environment, + command, + ...info.environment, }) } catch (error) { log.error("failed to format file", { error, - command: item.command, - ...item.environment, + command, + ...info.environment, file, }) } diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 5185cda2d7..c151d7c836 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -1,3 +1,4 @@ +import semver from "semver" import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Global } from "../global" @@ -21,6 +22,26 @@ export namespace Npm { return path.join(Global.Path.cache, "packages", pkg) } + export async function outdated(pkg: string, cachedVersion: string): Promise { + 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) { using _ = await Lock.write("npm-install") log.info("installing package using npm arborist", {