diff --git a/src/hooks/auto-update-checker/cache.ts b/src/hooks/auto-update-checker/cache.ts new file mode 100644 index 0000000..855843a --- /dev/null +++ b/src/hooks/auto-update-checker/cache.ts @@ -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 + } +} diff --git a/src/hooks/auto-update-checker/checker.ts b/src/hooks/auto-update-checker/checker.ts new file mode 100644 index 0000000..95babfa --- /dev/null +++ b/src/hooks/auto-update-checker/checker.ts @@ -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 { + 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 { + 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 } +} diff --git a/src/hooks/auto-update-checker/constants.ts b/src/hooks/auto-update-checker/constants.ts new file mode 100644 index 0000000..15c0b63 --- /dev/null +++ b/src/hooks/auto-update-checker/constants.ts @@ -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") diff --git a/src/hooks/auto-update-checker/index.ts b/src/hooks/auto-update-checker/index.ts new file mode 100644 index 0000000..1fd2223 --- /dev/null +++ b/src/hooks/auto-update-checker/index.ts @@ -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" diff --git a/src/hooks/auto-update-checker/types.ts b/src/hooks/auto-update-checker/types.ts new file mode 100644 index 0000000..a9a7b19 --- /dev/null +++ b/src/hooks/auto-update-checker/types.ts @@ -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 +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 07ee565..64d1c6e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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"; diff --git a/src/index.ts b/src/index.ts index b5fc398..e3b4602 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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);