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:
18
src/hooks/auto-update-checker/cache.ts
Normal file
18
src/hooks/auto-update-checker/cache.ts
Normal 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
|
||||
}
|
||||
}
|
||||
119
src/hooks/auto-update-checker/checker.ts
Normal file
119
src/hooks/auto-update-checker/checker.ts
Normal 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 }
|
||||
}
|
||||
40
src/hooks/auto-update-checker/constants.ts
Normal file
40
src/hooks/auto-update-checker/constants.ts
Normal 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")
|
||||
56
src/hooks/auto-update-checker/index.ts
Normal file
56
src/hooks/auto-update-checker/index.ts
Normal 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"
|
||||
22
src/hooks/auto-update-checker/types.ts
Normal file
22
src/hooks/auto-update-checker/types.ts
Normal 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
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user