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", {