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 { createThinkModeHook } from "./think-mode";
|
||||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||||
export { createRulesInjectorHook } from "./rules-injector";
|
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,
|
createAnthropicAutoCompactHook,
|
||||||
createRulesInjectorHook,
|
createRulesInjectorHook,
|
||||||
createBackgroundNotificationHook,
|
createBackgroundNotificationHook,
|
||||||
|
createAutoUpdateCheckerHook,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
import {
|
import {
|
||||||
loadUserCommands,
|
loadUserCommands,
|
||||||
@@ -161,6 +162,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
});
|
});
|
||||||
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
|
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
|
||||||
const rulesInjector = createRulesInjectorHook(ctx);
|
const rulesInjector = createRulesInjectorHook(ctx);
|
||||||
|
const autoUpdateChecker = createAutoUpdateCheckerHook(ctx);
|
||||||
|
|
||||||
updateTerminalTitle({ sessionId: "main" });
|
updateTerminalTitle({ sessionId: "main" });
|
||||||
|
|
||||||
@@ -243,6 +245,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
event: async (input) => {
|
event: async (input) => {
|
||||||
|
await autoUpdateChecker.event(input);
|
||||||
await claudeCodeHooks.event(input);
|
await claudeCodeHooks.event(input);
|
||||||
await backgroundNotificationHook.event(input);
|
await backgroundNotificationHook.event(input);
|
||||||
await todoContinuationEnforcer(input);
|
await todoContinuationEnforcer(input);
|
||||||
|
|||||||
Reference in New Issue
Block a user