Просмотр исходного кода

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.
Dax Raad 1 месяц назад
Родитель
Сommit
528daf5490

+ 2 - 2
.opencode/.gitignore

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

+ 0 - 14
packages/opencode/src/bun/registry.ts

@@ -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)
-  }
 }
 }

+ 1 - 2
packages/opencode/src/config/config.ts

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

+ 83 - 65
packages/opencode/src/format/formatter.ts

@@ -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.devDependencies?.prettier) return true
+      if (json.dependencies?.prettier || json.devDependencies?.prettier) {
+        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.devDependencies?.oxfmt) return true
+      if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
+        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 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
       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-dev"]?.["laravel/pint"]) return true
+      if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
+        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"]
   },
   },
 }
 }

+ 30 - 34
packages/opencode/src/format/index.ts

@@ -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 () => true
+      result.enabled = async () => item.command ?? false
       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]
-    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) {
   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)) {
-        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 {
         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
           const exit = await proc.exited
           if (exit !== 0)
           if (exit !== 0)
             log.error("failed", {
             log.error("failed", {
-              command: item.command,
-              ...item.environment,
+              command,
+              ...info.environment,
             })
             })
         } catch (error) {
         } catch (error) {
           log.error("failed to format file", {
           log.error("failed to format file", {
             error,
             error,
-            command: item.command,
-            ...item.environment,
+            command,
+            ...info.environment,
             file,
             file,
           })
           })
         }
         }

+ 21 - 0
packages/opencode/src/npm/index.ts

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