feat(hooks): add auto-update-checker for plugin version management

Checks npm registry for latest version on session.created, invalidates
cache and shows toast notification when update is available.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-13 01:18:39 +09:00
parent 6f229a86e3
commit 787e247a08
7 changed files with 260 additions and 1 deletions

View File

@@ -0,0 +1,18 @@
import * as fs from "node:fs"
import { VERSION_FILE } from "./constants"
import { log } from "../../shared/logger"
export function invalidateCache(): boolean {
try {
if (fs.existsSync(VERSION_FILE)) {
fs.unlinkSync(VERSION_FILE)
log(`[auto-update-checker] Cache invalidated: ${VERSION_FILE}`)
return true
}
log("[auto-update-checker] Version file not found, nothing to invalidate")
return false
} catch (err) {
log("[auto-update-checker] Failed to invalidate cache:", err)
return false
}
}

View File

@@ -0,0 +1,119 @@
import * as fs from "node:fs"
import * as path from "node:path"
import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types"
import {
PACKAGE_NAME,
NPM_REGISTRY_URL,
NPM_FETCH_TIMEOUT,
INSTALLED_PACKAGE_JSON,
USER_OPENCODE_CONFIG,
} from "./constants"
import { log } from "../../shared/logger"
export function isLocalDevMode(directory: string): boolean {
const projectConfig = path.join(directory, ".opencode", "opencode.json")
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
try {
if (!fs.existsSync(configPath)) continue
const content = fs.readFileSync(configPath, "utf-8")
const config = JSON.parse(content) as OpencodeConfig
const plugins = config.plugin ?? []
for (const entry of plugins) {
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
return true
}
}
} catch {
continue
}
}
return false
}
export function findPluginEntry(directory: string): string | null {
const projectConfig = path.join(directory, ".opencode", "opencode.json")
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
try {
if (!fs.existsSync(configPath)) continue
const content = fs.readFileSync(configPath, "utf-8")
const config = JSON.parse(content) as OpencodeConfig
const plugins = config.plugin ?? []
for (const entry of plugins) {
if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) {
return entry
}
}
} catch {
continue
}
}
return null
}
export function getCachedVersion(): string | null {
try {
if (!fs.existsSync(INSTALLED_PACKAGE_JSON)) return null
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
const pkg = JSON.parse(content) as PackageJson
return pkg.version ?? null
} catch {
return null
}
}
export async function getLatestVersion(): 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.latest ?? null
} catch {
return null
} finally {
clearTimeout(timeoutId)
}
}
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
if (isLocalDevMode(directory)) {
log("[auto-update-checker] Local dev mode detected, skipping update check")
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true }
}
const pluginEntry = findPluginEntry(directory)
if (!pluginEntry) {
log("[auto-update-checker] Plugin not found in config")
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false }
}
const currentVersion = getCachedVersion()
if (!currentVersion) {
log("[auto-update-checker] No cached version found")
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false }
}
const latestVersion = await getLatestVersion()
if (!latestVersion) {
log("[auto-update-checker] Failed to fetch latest version")
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false }
}
const needsUpdate = currentVersion !== latestVersion
log(`[auto-update-checker] Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false }
}

View File

@@ -0,0 +1,40 @@
import * as path from "node:path"
import * as os from "node:os"
export const PACKAGE_NAME = "oh-my-opencode"
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
export const NPM_FETCH_TIMEOUT = 5000
/**
* OpenCode plugin cache directory
* - Linux/macOS: ~/.cache/opencode/
* - Windows: %LOCALAPPDATA%/opencode/
*/
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 VERSION_FILE = path.join(CACHE_DIR, "version")
export const INSTALLED_PACKAGE_JSON = path.join(
CACHE_DIR,
"node_modules",
PACKAGE_NAME,
"package.json"
)
/**
* OpenCode config file locations (priority order)
*/
function getUserConfigDir(): string {
if (process.platform === "win32") {
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
}
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")

View File

@@ -0,0 +1,56 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { checkForUpdate } from "./checker"
import { invalidateCache } from "./cache"
import { PACKAGE_NAME } from "./constants"
import { log } from "../../shared/logger"
export function createAutoUpdateCheckerHook(ctx: PluginInput) {
let hasChecked = false
return {
event: async ({ 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
try {
const result = await checkForUpdate(ctx.directory)
if (result.isLocalDev) {
log("[auto-update-checker] Skipped: local development mode")
return
}
if (!result.needsUpdate) {
log("[auto-update-checker] No update needed")
return
}
invalidateCache()
await ctx.client.tui
.showToast({
body: {
title: `${PACKAGE_NAME} Update`,
message: `v${result.latestVersion} available (current: v${result.currentVersion}). Restart OpenCode to apply.`,
variant: "info" as const,
duration: 8000,
},
})
.catch(() => {})
log(`[auto-update-checker] Update notification sent: v${result.currentVersion} → v${result.latestVersion}`)
} catch (err) {
log("[auto-update-checker] Error during update check:", err)
}
},
}
}
export type { UpdateCheckResult } from "./types"
export { checkForUpdate } from "./checker"
export { invalidateCache } from "./cache"

View File

@@ -0,0 +1,22 @@
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 UpdateCheckResult {
needsUpdate: boolean
currentVersion: string | null
latestVersion: string | null
isLocalDev: boolean
}

View File

@@ -11,4 +11,5 @@ export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
export { createThinkModeHook } from "./think-mode";
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
export { createRulesInjectorHook } from "./rules-injector";
export { createBackgroundNotificationHook } from "./background-notification";
export { createBackgroundNotificationHook } from "./background-notification"
export { createAutoUpdateCheckerHook } from "./auto-update-checker";

View File

@@ -14,6 +14,7 @@ import {
createAnthropicAutoCompactHook,
createRulesInjectorHook,
createBackgroundNotificationHook,
createAutoUpdateCheckerHook,
} from "./hooks";
import {
loadUserCommands,
@@ -161,6 +162,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
});
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
const rulesInjector = createRulesInjectorHook(ctx);
const autoUpdateChecker = createAutoUpdateCheckerHook(ctx);
updateTerminalTitle({ sessionId: "main" });
@@ -243,6 +245,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
event: async (input) => {
await autoUpdateChecker.event(input);
await claudeCodeHooks.event(input);
await backgroundNotificationHook.event(input);
await todoContinuationEnforcer(input);