Skip to content

Commit 787e247

Browse files
committed
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)
1 parent 6f229a8 commit 787e247

File tree

7 files changed

+260
-1
lines changed

7 files changed

+260
-1
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as fs from "node:fs"
2+
import { VERSION_FILE } from "./constants"
3+
import { log } from "../../shared/logger"
4+
5+
export function invalidateCache(): boolean {
6+
try {
7+
if (fs.existsSync(VERSION_FILE)) {
8+
fs.unlinkSync(VERSION_FILE)
9+
log(`[auto-update-checker] Cache invalidated: ${VERSION_FILE}`)
10+
return true
11+
}
12+
log("[auto-update-checker] Version file not found, nothing to invalidate")
13+
return false
14+
} catch (err) {
15+
log("[auto-update-checker] Failed to invalidate cache:", err)
16+
return false
17+
}
18+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import * as fs from "node:fs"
2+
import * as path from "node:path"
3+
import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types"
4+
import {
5+
PACKAGE_NAME,
6+
NPM_REGISTRY_URL,
7+
NPM_FETCH_TIMEOUT,
8+
INSTALLED_PACKAGE_JSON,
9+
USER_OPENCODE_CONFIG,
10+
} from "./constants"
11+
import { log } from "../../shared/logger"
12+
13+
export function isLocalDevMode(directory: string): boolean {
14+
const projectConfig = path.join(directory, ".opencode", "opencode.json")
15+
16+
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
17+
try {
18+
if (!fs.existsSync(configPath)) continue
19+
const content = fs.readFileSync(configPath, "utf-8")
20+
const config = JSON.parse(content) as OpencodeConfig
21+
const plugins = config.plugin ?? []
22+
23+
for (const entry of plugins) {
24+
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
25+
return true
26+
}
27+
}
28+
} catch {
29+
continue
30+
}
31+
}
32+
33+
return false
34+
}
35+
36+
export function findPluginEntry(directory: string): string | null {
37+
const projectConfig = path.join(directory, ".opencode", "opencode.json")
38+
39+
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
40+
try {
41+
if (!fs.existsSync(configPath)) continue
42+
const content = fs.readFileSync(configPath, "utf-8")
43+
const config = JSON.parse(content) as OpencodeConfig
44+
const plugins = config.plugin ?? []
45+
46+
for (const entry of plugins) {
47+
if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) {
48+
return entry
49+
}
50+
}
51+
} catch {
52+
continue
53+
}
54+
}
55+
56+
return null
57+
}
58+
59+
export function getCachedVersion(): string | null {
60+
try {
61+
if (!fs.existsSync(INSTALLED_PACKAGE_JSON)) return null
62+
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
63+
const pkg = JSON.parse(content) as PackageJson
64+
return pkg.version ?? null
65+
} catch {
66+
return null
67+
}
68+
}
69+
70+
export async function getLatestVersion(): Promise<string | null> {
71+
const controller = new AbortController()
72+
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
73+
74+
try {
75+
const response = await fetch(NPM_REGISTRY_URL, {
76+
signal: controller.signal,
77+
headers: { Accept: "application/json" },
78+
})
79+
80+
if (!response.ok) return null
81+
82+
const data = (await response.json()) as NpmDistTags
83+
return data.latest ?? null
84+
} catch {
85+
return null
86+
} finally {
87+
clearTimeout(timeoutId)
88+
}
89+
}
90+
91+
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
92+
if (isLocalDevMode(directory)) {
93+
log("[auto-update-checker] Local dev mode detected, skipping update check")
94+
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true }
95+
}
96+
97+
const pluginEntry = findPluginEntry(directory)
98+
if (!pluginEntry) {
99+
log("[auto-update-checker] Plugin not found in config")
100+
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false }
101+
}
102+
103+
const currentVersion = getCachedVersion()
104+
if (!currentVersion) {
105+
log("[auto-update-checker] No cached version found")
106+
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false }
107+
}
108+
109+
const latestVersion = await getLatestVersion()
110+
if (!latestVersion) {
111+
log("[auto-update-checker] Failed to fetch latest version")
112+
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false }
113+
}
114+
115+
const needsUpdate = currentVersion !== latestVersion
116+
log(`[auto-update-checker] Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
117+
118+
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false }
119+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import * as path from "node:path"
2+
import * as os from "node:os"
3+
4+
export const PACKAGE_NAME = "oh-my-opencode"
5+
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
6+
export const NPM_FETCH_TIMEOUT = 5000
7+
8+
/**
9+
* OpenCode plugin cache directory
10+
* - Linux/macOS: ~/.cache/opencode/
11+
* - Windows: %LOCALAPPDATA%/opencode/
12+
*/
13+
function getCacheDir(): string {
14+
if (process.platform === "win32") {
15+
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode")
16+
}
17+
return path.join(os.homedir(), ".cache", "opencode")
18+
}
19+
20+
export const CACHE_DIR = getCacheDir()
21+
export const VERSION_FILE = path.join(CACHE_DIR, "version")
22+
export const INSTALLED_PACKAGE_JSON = path.join(
23+
CACHE_DIR,
24+
"node_modules",
25+
PACKAGE_NAME,
26+
"package.json"
27+
)
28+
29+
/**
30+
* OpenCode config file locations (priority order)
31+
*/
32+
function getUserConfigDir(): string {
33+
if (process.platform === "win32") {
34+
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
35+
}
36+
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
37+
}
38+
39+
export const USER_CONFIG_DIR = getUserConfigDir()
40+
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { PluginInput } from "@opencode-ai/plugin"
2+
import { checkForUpdate } from "./checker"
3+
import { invalidateCache } from "./cache"
4+
import { PACKAGE_NAME } from "./constants"
5+
import { log } from "../../shared/logger"
6+
7+
export function createAutoUpdateCheckerHook(ctx: PluginInput) {
8+
let hasChecked = false
9+
10+
return {
11+
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
12+
if (event.type !== "session.created") return
13+
if (hasChecked) return
14+
15+
const props = event.properties as { info?: { parentID?: string } } | undefined
16+
if (props?.info?.parentID) return
17+
18+
hasChecked = true
19+
20+
try {
21+
const result = await checkForUpdate(ctx.directory)
22+
23+
if (result.isLocalDev) {
24+
log("[auto-update-checker] Skipped: local development mode")
25+
return
26+
}
27+
28+
if (!result.needsUpdate) {
29+
log("[auto-update-checker] No update needed")
30+
return
31+
}
32+
33+
invalidateCache()
34+
35+
await ctx.client.tui
36+
.showToast({
37+
body: {
38+
title: `${PACKAGE_NAME} Update`,
39+
message: `v${result.latestVersion} available (current: v${result.currentVersion}). Restart OpenCode to apply.`,
40+
variant: "info" as const,
41+
duration: 8000,
42+
},
43+
})
44+
.catch(() => {})
45+
46+
log(`[auto-update-checker] Update notification sent: v${result.currentVersion} → v${result.latestVersion}`)
47+
} catch (err) {
48+
log("[auto-update-checker] Error during update check:", err)
49+
}
50+
},
51+
}
52+
}
53+
54+
export type { UpdateCheckResult } from "./types"
55+
export { checkForUpdate } from "./checker"
56+
export { invalidateCache } from "./cache"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface NpmDistTags {
2+
latest: string
3+
[key: string]: string
4+
}
5+
6+
export interface OpencodeConfig {
7+
plugin?: string[]
8+
[key: string]: unknown
9+
}
10+
11+
export interface PackageJson {
12+
version: string
13+
name?: string
14+
[key: string]: unknown
15+
}
16+
17+
export interface UpdateCheckResult {
18+
needsUpdate: boolean
19+
currentVersion: string | null
20+
latestVersion: string | null
21+
isLocalDev: boolean
22+
}

src/hooks/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
1111
export { createThinkModeHook } from "./think-mode";
1212
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
1313
export { createRulesInjectorHook } from "./rules-injector";
14-
export { createBackgroundNotificationHook } from "./background-notification";
14+
export { createBackgroundNotificationHook } from "./background-notification"
15+
export { createAutoUpdateCheckerHook } from "./auto-update-checker";

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
createAnthropicAutoCompactHook,
1515
createRulesInjectorHook,
1616
createBackgroundNotificationHook,
17+
createAutoUpdateCheckerHook,
1718
} from "./hooks";
1819
import {
1920
loadUserCommands,
@@ -161,6 +162,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
161162
});
162163
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
163164
const rulesInjector = createRulesInjectorHook(ctx);
165+
const autoUpdateChecker = createAutoUpdateCheckerHook(ctx);
164166

165167
updateTerminalTitle({ sessionId: "main" });
166168

@@ -243,6 +245,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
243245
},
244246

245247
event: async (input) => {
248+
await autoUpdateChecker.event(input);
246249
await claudeCodeHooks.event(input);
247250
await backgroundNotificationHook.event(input);
248251
await todoContinuationEnforcer(input);

0 commit comments

Comments
 (0)