Browse Source

feat: add auto-update-checker hook

Migrated from oh-my-opencode full version with simplifications:
- Shows startup toast with current version
- Background NPM version check
- Auto-updates plugin config and invalidates cache
- Supports dist-tags (latest, beta, alpha, next)
- Detects local dev mode and skips updates

Simplified per code review:
- Removed unused exports (checkForUpdate, UpdateCheckResult)
- Replaced 50-call spinner animation with single toast
- Inlined getWindowsAppdataDir helper
- Removed dead code (isLocalDevMode export, getLogFilePath)
Alvin Unreal 2 months ago
parent
commit
43087c494f

+ 87 - 0
src/hooks/auto-update-checker/cache.ts

@@ -0,0 +1,87 @@
+import * as fs from "node:fs"
+import * as path from "node:path"
+import { CACHE_DIR, PACKAGE_NAME } from "./constants"
+import { log } from "../../shared/logger"
+
+interface BunLockfile {
+  workspaces?: {
+    ""?: {
+      dependencies?: Record<string, string>
+    }
+  }
+  packages?: Record<string, unknown>
+}
+
+function stripTrailingCommas(json: string): string {
+  return json.replace(/,(\s*[}\]])/g, "$1")
+}
+
+function removeFromBunLock(packageName: string): boolean {
+  const lockPath = path.join(CACHE_DIR, "bun.lock")
+  if (!fs.existsSync(lockPath)) return false
+
+  try {
+    const content = fs.readFileSync(lockPath, "utf-8")
+    const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile
+    let modified = false
+
+    if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
+      delete lock.workspaces[""].dependencies[packageName]
+      modified = true
+    }
+
+    if (lock.packages?.[packageName]) {
+      delete lock.packages[packageName]
+      modified = true
+    }
+
+    if (modified) {
+      fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2))
+      log(`[auto-update-checker] Removed from bun.lock: ${packageName}`)
+    }
+
+    return modified
+  } catch {
+    return false
+  }
+}
+
+export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
+  try {
+    const pkgDir = path.join(CACHE_DIR, "node_modules", packageName)
+    const pkgJsonPath = path.join(CACHE_DIR, "package.json")
+
+    let packageRemoved = false
+    let dependencyRemoved = false
+    let lockRemoved = false
+
+    if (fs.existsSync(pkgDir)) {
+      fs.rmSync(pkgDir, { recursive: true, force: true })
+      log(`[auto-update-checker] Package removed: ${pkgDir}`)
+      packageRemoved = true
+    }
+
+    if (fs.existsSync(pkgJsonPath)) {
+      const content = fs.readFileSync(pkgJsonPath, "utf-8")
+      const pkgJson = JSON.parse(content)
+      if (pkgJson.dependencies?.[packageName]) {
+        delete pkgJson.dependencies[packageName]
+        fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
+        log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`)
+        dependencyRemoved = true
+      }
+    }
+
+    lockRemoved = removeFromBunLock(packageName)
+
+    if (!packageRemoved && !dependencyRemoved && !lockRemoved) {
+      log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`)
+      return false
+    }
+
+    return true
+  } catch (err) {
+    log("[auto-update-checker] Failed to invalidate package:", err)
+    return false
+  }
+}

+ 258 - 0
src/hooks/auto-update-checker/checker.ts

@@ -0,0 +1,258 @@
+import * as fs from "node:fs"
+import * as path from "node:path"
+import { fileURLToPath } from "node:url"
+import * as os from "node:os"
+import type { NpmDistTags, OpencodeConfig, PackageJson } from "./types"
+import {
+  PACKAGE_NAME,
+  NPM_REGISTRY_URL,
+  NPM_FETCH_TIMEOUT,
+  INSTALLED_PACKAGE_JSON,
+  USER_OPENCODE_CONFIG,
+  USER_OPENCODE_CONFIG_JSONC,
+  USER_CONFIG_DIR,
+} from "./constants"
+import { log } from "../../shared/logger"
+
+function isPrereleaseVersion(version: string): boolean {
+  return version.includes("-")
+}
+
+function isDistTag(version: string): boolean {
+  return !/^\d/.test(version)
+}
+
+export function extractChannel(version: string | null): string {
+  if (!version) return "latest"
+  
+  if (isDistTag(version)) return version
+  
+  if (isPrereleaseVersion(version)) {
+    const prereleasePart = version.split("-")[1]
+    if (prereleasePart) {
+      const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/)
+      if (channelMatch) return channelMatch[1]
+    }
+  }
+  
+  return "latest"
+}
+
+function stripJsonComments(json: string): string {
+  return json
+    .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? "" : m))
+    .replace(/,(\s*[}\]])/g, "$1")
+}
+
+function getConfigPaths(directory: string): string[] {
+  const paths = [
+    path.join(directory, ".opencode", "opencode.json"),
+    path.join(directory, ".opencode", "opencode.jsonc"),
+    USER_OPENCODE_CONFIG,
+    USER_OPENCODE_CONFIG_JSONC,
+  ]
+  
+  if (process.platform === "win32") {
+    const crossPlatformDir = path.join(os.homedir(), ".config")
+    const appdataDir = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
+    
+    if (appdataDir) {
+      const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir
+      const alternateConfig = path.join(alternateDir, "opencode", "opencode.json")
+      const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc")
+      
+      if (!paths.includes(alternateConfig)) paths.push(alternateConfig)
+      if (!paths.includes(alternateConfigJsonc)) paths.push(alternateConfigJsonc)
+    }
+  }
+  
+  return paths
+}
+
+function getLocalDevPath(directory: string): string | null {
+  for (const configPath of getConfigPaths(directory)) {
+    try {
+      if (!fs.existsSync(configPath)) continue
+      const content = fs.readFileSync(configPath, "utf-8")
+      const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
+      const plugins = config.plugin ?? []
+
+      for (const entry of plugins) {
+        if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
+          try {
+            return fileURLToPath(entry)
+          } catch {
+            return entry.replace("file://", "")
+          }
+        }
+      }
+    } catch {
+      continue
+    }
+  }
+  return null
+}
+
+function findPackageJsonUp(startPath: string): string | null {
+  try {
+    const stat = fs.statSync(startPath)
+    let dir = stat.isDirectory() ? startPath : path.dirname(startPath)
+    
+    for (let i = 0; i < 10; i++) {
+      const pkgPath = path.join(dir, "package.json")
+      if (fs.existsSync(pkgPath)) {
+        try {
+          const content = fs.readFileSync(pkgPath, "utf-8")
+          const pkg = JSON.parse(content) as PackageJson
+          if (pkg.name === PACKAGE_NAME) return pkgPath
+        } catch { /* empty */ }
+      }
+      const parent = path.dirname(dir)
+      if (parent === dir) break
+      dir = parent
+    }
+  } catch { /* empty */ }
+  return null
+}
+
+export function getLocalDevVersion(directory: string): string | null {
+  const localPath = getLocalDevPath(directory)
+  if (!localPath) return null
+
+  try {
+    const pkgPath = findPackageJsonUp(localPath)
+    if (!pkgPath) return null
+    const content = fs.readFileSync(pkgPath, "utf-8")
+    const pkg = JSON.parse(content) as PackageJson
+    return pkg.version ?? null
+  } catch {
+    return null
+  }
+}
+
+export interface PluginEntryInfo {
+  entry: string
+  isPinned: boolean
+  pinnedVersion: string | null
+  configPath: string
+}
+
+export function findPluginEntry(directory: string): PluginEntryInfo | null {
+  for (const configPath of getConfigPaths(directory)) {
+    try {
+      if (!fs.existsSync(configPath)) continue
+      const content = fs.readFileSync(configPath, "utf-8")
+      const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
+      const plugins = config.plugin ?? []
+
+      for (const entry of plugins) {
+        if (entry === PACKAGE_NAME) {
+          return { entry, isPinned: false, pinnedVersion: null, configPath }
+        }
+        if (entry.startsWith(`${PACKAGE_NAME}@`)) {
+          const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
+          const isPinned = pinnedVersion !== "latest"
+          return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath }
+        }
+      }
+    } catch {
+      continue
+    }
+  }
+  return null
+}
+
+export function getCachedVersion(): string | null {
+  try {
+    if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
+      const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
+      const pkg = JSON.parse(content) as PackageJson
+      if (pkg.version) return pkg.version
+    }
+  } catch { /* empty */ }
+
+  try {
+    const currentDir = path.dirname(fileURLToPath(import.meta.url))
+    const pkgPath = findPackageJsonUp(currentDir)
+    if (pkgPath) {
+      const content = fs.readFileSync(pkgPath, "utf-8")
+      const pkg = JSON.parse(content) as PackageJson
+      if (pkg.version) return pkg.version
+    }
+  } catch (err) {
+    log("[auto-update-checker] Failed to resolve version from current directory:", err)
+  }
+
+  return null
+}
+
+export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
+  try {
+    const content = fs.readFileSync(configPath, "utf-8")
+    const newEntry = `${PACKAGE_NAME}@${newVersion}`
+    
+    const pluginMatch = content.match(/"plugin"\s*:\s*\[/)
+    if (!pluginMatch || pluginMatch.index === undefined) {
+      log(`[auto-update-checker] No "plugin" array found in ${configPath}`)
+      return false
+    }
+    
+    const startIdx = pluginMatch.index + pluginMatch[0].length
+    let bracketCount = 1
+    let endIdx = startIdx
+    
+    for (let i = startIdx; i < content.length && bracketCount > 0; i++) {
+      if (content[i] === "[") bracketCount++
+      else if (content[i] === "]") bracketCount--
+      endIdx = i
+    }
+    
+    const before = content.slice(0, startIdx)
+    const pluginArrayContent = content.slice(startIdx, endIdx)
+    const after = content.slice(endIdx)
+    
+    const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+    const regex = new RegExp(`["']${escapedOldEntry}["']`)
+    
+    if (!regex.test(pluginArrayContent)) {
+      log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`)
+      return false
+    }
+    
+    const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`)
+    const updatedContent = before + updatedPluginArray + after
+    
+    if (updatedContent === content) {
+      log(`[auto-update-checker] No changes made to ${configPath}`)
+      return false
+    }
+    
+    fs.writeFileSync(configPath, updatedContent, "utf-8")
+    log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`)
+    return true
+  } catch (err) {
+    log(`[auto-update-checker] Failed to update config file ${configPath}:`, err)
+    return false
+  }
+}
+
+export async function getLatestVersion(channel: string = "latest"): Promise<string | null> {
+  const controller = new AbortController()
+  const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
+
+  try {
+    const response = await fetch(NPM_REGISTRY_URL, {
+      signal: controller.signal,
+      headers: { Accept: "application/json" },
+    })
+
+    if (!response.ok) return null
+
+    const data = (await response.json()) as NpmDistTags
+    return data[channel] ?? data.latest ?? null
+  } catch {
+    return null
+  } finally {
+    clearTimeout(timeoutId)
+  }
+}

+ 43 - 0
src/hooks/auto-update-checker/constants.ts

@@ -0,0 +1,43 @@
+import * as path from "node:path"
+import * as os from "node:os"
+import * as fs from "node:fs"
+
+export const PACKAGE_NAME = "oh-my-opencode-slim"
+export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
+export const NPM_FETCH_TIMEOUT = 5000
+
+function getCacheDir(): string {
+  if (process.platform === "win32") {
+    return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode")
+  }
+  return path.join(os.homedir(), ".cache", "opencode")
+}
+
+export const CACHE_DIR = getCacheDir()
+export const INSTALLED_PACKAGE_JSON = path.join(
+  CACHE_DIR,
+  "node_modules",
+  PACKAGE_NAME,
+  "package.json"
+)
+
+function getUserConfigDir(): string {
+  if (process.platform === "win32") {
+    const crossPlatformDir = path.join(os.homedir(), ".config")
+    const appdataDir = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
+    
+    const crossPlatformConfig = path.join(crossPlatformDir, "opencode", "opencode.json")
+    const crossPlatformConfigJsonc = path.join(crossPlatformDir, "opencode", "opencode.jsonc")
+    
+    if (fs.existsSync(crossPlatformConfig) || fs.existsSync(crossPlatformConfigJsonc)) {
+      return crossPlatformDir
+    }
+    
+    return appdataDir
+  }
+  return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
+}
+
+export const USER_CONFIG_DIR = getUserConfigDir()
+export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
+export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc")

+ 143 - 0
src/hooks/auto-update-checker/index.ts

@@ -0,0 +1,143 @@
+import type { PluginInput } from "@opencode-ai/plugin"
+import { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion, extractChannel } from "./checker"
+import { invalidatePackage } from "./cache"
+import { PACKAGE_NAME } from "./constants"
+import { log } from "../../shared/logger"
+import type { AutoUpdateCheckerOptions } from "./types"
+
+export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) {
+  const { showStartupToast = true, autoUpdate = true } = options
+
+  let hasChecked = false
+
+  return {
+    event: ({ event }: { event: { type: string; properties?: unknown } }) => {
+      if (event.type !== "session.created") return
+      if (hasChecked) return
+
+      const props = event.properties as { info?: { parentID?: string } } | undefined
+      if (props?.info?.parentID) return
+
+      hasChecked = true
+
+      setTimeout(async () => {
+        const cachedVersion = getCachedVersion()
+        const localDevVersion = getLocalDevVersion(ctx.directory)
+        const displayVersion = localDevVersion ?? cachedVersion
+
+        if (localDevVersion) {
+          if (showStartupToast) {
+            showToast(ctx, `OMO-Slim ${displayVersion} (dev)`, "Running in local development mode.", "info")
+          }
+          log("[auto-update-checker] Local development mode")
+          return
+        }
+
+        if (showStartupToast) {
+          showToast(ctx, `OMO-Slim ${displayVersion ?? "unknown"}`, "oh-my-opencode-slim is active.", "info")
+        }
+
+        runBackgroundUpdateCheck(ctx, autoUpdate).catch(err => {
+          log("[auto-update-checker] Background update check failed:", err)
+        })
+      }, 0)
+    },
+  }
+}
+
+async function runBackgroundUpdateCheck(ctx: PluginInput, autoUpdate: boolean): Promise<void> {
+  const pluginInfo = findPluginEntry(ctx.directory)
+  if (!pluginInfo) {
+    log("[auto-update-checker] Plugin not found in config")
+    return
+  }
+
+  const cachedVersion = getCachedVersion()
+  const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion
+  if (!currentVersion) {
+    log("[auto-update-checker] No version found (cached or pinned)")
+    return
+  }
+
+  const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
+  const latestVersion = await getLatestVersion(channel)
+  if (!latestVersion) {
+    log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
+    return
+  }
+
+  if (currentVersion === latestVersion) {
+    log("[auto-update-checker] Already on latest version for channel:", channel)
+    return
+  }
+
+  log(`[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`)
+
+  if (!autoUpdate) {
+    showToast(ctx, `OMO-Slim ${latestVersion}`, `v${latestVersion} available. Restart to apply.`, "info", 8000)
+    log("[auto-update-checker] Auto-update disabled, notification only")
+    return
+  }
+
+  if (pluginInfo.isPinned) {
+    const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion)
+    if (!updated) {
+      showToast(ctx, `OMO-Slim ${latestVersion}`, `v${latestVersion} available. Restart to apply.`, "info", 8000)
+      log("[auto-update-checker] Failed to update pinned version in config")
+      return
+    }
+    log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`)
+  }
+
+  invalidatePackage(PACKAGE_NAME)
+
+  const installSuccess = await runBunInstallSafe(ctx)
+
+  if (installSuccess) {
+    showToast(ctx, "OMO-Slim Updated!", `v${currentVersion} → v${latestVersion}\nRestart OpenCode to apply.`, "success", 8000)
+    log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`)
+  } else {
+    showToast(ctx, `OMO-Slim ${latestVersion}`, `v${latestVersion} available. Restart to apply.`, "info", 8000)
+    log("[auto-update-checker] bun install failed; update not installed")
+  }
+}
+
+async function runBunInstallSafe(ctx: PluginInput): Promise<boolean> {
+  try {
+    const proc = Bun.spawn(["bun", "install"], {
+      cwd: ctx.directory,
+      stdout: "pipe",
+      stderr: "pipe",
+    })
+    
+    const timeoutPromise = new Promise<"timeout">((resolve) =>
+      setTimeout(() => resolve("timeout"), 60_000)
+    )
+    const exitPromise = proc.exited.then(() => "completed" as const)
+    const result = await Promise.race([exitPromise, timeoutPromise])
+    
+    if (result === "timeout") {
+      try { proc.kill() } catch { /* empty */ }
+      return false
+    }
+    
+    return proc.exitCode === 0
+  } catch (err) {
+    log("[auto-update-checker] bun install error:", err)
+    return false
+  }
+}
+
+function showToast(
+  ctx: PluginInput,
+  title: string,
+  message: string,
+  variant: "info" | "success" | "error" = "info",
+  duration = 3000
+): void {
+  ctx.client.tui.showToast({
+    body: { title, message, variant, duration },
+  }).catch(() => {})
+}
+
+export type { AutoUpdateCheckerOptions } from "./types"

+ 20 - 0
src/hooks/auto-update-checker/types.ts

@@ -0,0 +1,20 @@
+export interface NpmDistTags {
+  latest: string
+  [key: string]: string
+}
+
+export interface OpencodeConfig {
+  plugin?: string[]
+  [key: string]: unknown
+}
+
+export interface PackageJson {
+  version: string
+  name?: string
+  [key: string]: unknown
+}
+
+export interface AutoUpdateCheckerOptions {
+  showStartupToast?: boolean
+  autoUpdate?: boolean
+}

+ 2 - 0
src/hooks/index.ts

@@ -0,0 +1,2 @@
+export { createAutoUpdateCheckerHook } from "./auto-update-checker"
+export type { AutoUpdateCheckerOptions } from "./auto-update-checker"

+ 11 - 0
src/index.ts

@@ -14,6 +14,7 @@ import {
 } from "./tools";
 } from "./tools";
 import { loadPluginConfig } from "./config";
 import { loadPluginConfig } from "./config";
 import { createBuiltinMcps } from "./mcp";
 import { createBuiltinMcps } from "./mcp";
+import { createAutoUpdateCheckerHook } from "./hooks";
 
 
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const config = loadPluginConfig(ctx.directory);
   const config = loadPluginConfig(ctx.directory);
@@ -22,6 +23,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const backgroundTools = createBackgroundTools(ctx, backgroundManager);
   const backgroundTools = createBackgroundTools(ctx, backgroundManager);
   const mcps = createBuiltinMcps(config.disabled_mcps);
   const mcps = createBuiltinMcps(config.disabled_mcps);
 
 
+  // Initialize auto-update checker hook
+  const autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
+    showStartupToast: true,
+    autoUpdate: true,
+  });
+
   return {
   return {
     name: "oh-my-opencode-slim",
     name: "oh-my-opencode-slim",
 
 
@@ -59,6 +66,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         Object.assign(configMcp, mcps);
         Object.assign(configMcp, mcps);
       }
       }
     },
     },
+
+    event: async (input) => {
+      await autoUpdateChecker.event(input);
+    },
   };
   };
 };
 };
 
 

+ 1 - 0
src/shared/index.ts

@@ -1 +1,2 @@
 export { extractZip } from "./zip-extractor"
 export { extractZip } from "./zip-extractor"
+export { log } from "./logger"

+ 15 - 0
src/shared/logger.ts

@@ -0,0 +1,15 @@
+import * as fs from "fs"
+import * as os from "os"
+import * as path from "path"
+
+const logFile = path.join(os.tmpdir(), "oh-my-opencode-slim.log")
+
+export function log(message: string, data?: unknown): void {
+  try {
+    const timestamp = new Date().toISOString()
+    const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}\n`
+    fs.appendFileSync(logFile, logEntry)
+  } catch {
+    // Silently ignore logging errors
+  }
+}