From e261853451addb9d3d5d5d0fb7aae830ab492470 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 19 Dec 2025 14:05:09 +0900 Subject: [PATCH] feat(auto-update-checker): implement background auto-update with configurable pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run update check in background after startup (non-blocking) - Auto-update pinned versions in config file when newer version available - Add auto_update config option to disable auto-updating - Properly invalidate package cache after config update - Scoped regex replacement to avoid editing outside plugin array 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- assets/oh-my-opencode.schema.json | 3 + src/config/schema.ts | 1 + src/hooks/auto-update-checker/checker.ts | 63 +++++++- src/hooks/auto-update-checker/index.ts | 174 +++++++++++++++-------- src/hooks/auto-update-checker/types.ts | 1 + src/index.ts | 1 + 6 files changed, 181 insertions(+), 62 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 76ec089..b6d1505 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -1222,6 +1222,9 @@ "type": "boolean" } } + }, + "auto_update": { + "type": "boolean" } } } \ No newline at end of file diff --git a/src/config/schema.ts b/src/config/schema.ts index 91753a4..06f9dc9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -122,6 +122,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ google_auth: z.boolean().optional(), sisyphus_agent: SisyphusAgentConfigSchema.optional(), experimental: ExperimentalConfigSchema.optional(), + auto_update: z.boolean().optional(), }) export type OhMyOpenCodeConfig = z.infer diff --git a/src/hooks/auto-update-checker/checker.ts b/src/hooks/auto-update-checker/checker.ts index 34ac355..00fa088 100644 --- a/src/hooks/auto-update-checker/checker.ts +++ b/src/hooks/auto-update-checker/checker.ts @@ -97,6 +97,7 @@ export interface PluginEntryInfo { entry: string isPinned: boolean pinnedVersion: string | null + configPath: string } export function findPluginEntry(directory: string): PluginEntryInfo | null { @@ -109,12 +110,12 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null { for (const entry of plugins) { if (entry === PACKAGE_NAME) { - return { entry, isPinned: false, pinnedVersion: null } + return { entry, isPinned: false, pinnedVersion: null, configPath } } if (entry.startsWith(`${PACKAGE_NAME}@`)) { const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1) const isPinned = pinnedVersion !== "latest" - return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null } + return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath } } } } catch { @@ -149,6 +150,64 @@ export function getCachedVersion(): string | null { return null } +/** + * Updates a pinned version entry in the config file. + * Only replaces within the "plugin" array to avoid unintended edits. + * Preserves JSONC comments and formatting via string replacement. + */ +export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean { + try { + const content = fs.readFileSync(configPath, "utf-8") + const newEntry = `${PACKAGE_NAME}@${newVersion}` + + // Find the "plugin" array region to scope replacement + const pluginMatch = content.match(/"plugin"\s*:\s*\[/) + if (!pluginMatch || pluginMatch.index === undefined) { + log(`[auto-update-checker] No "plugin" array found in ${configPath}`) + return false + } + + // Find the closing bracket of the plugin array + const startIdx = pluginMatch.index + pluginMatch[0].length + let bracketCount = 1 + let endIdx = startIdx + + for (let i = startIdx; i < content.length && bracketCount > 0; i++) { + if (content[i] === "[") bracketCount++ + else if (content[i] === "]") bracketCount-- + endIdx = i + } + + const before = content.slice(0, startIdx) + const pluginArrayContent = content.slice(startIdx, endIdx) + const after = content.slice(endIdx) + + // Only replace first occurrence within plugin array + const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const regex = new RegExp(`["']${escapedOldEntry}["']`) + + if (!regex.test(pluginArrayContent)) { + log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`) + return false + } + + const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`) + const updatedContent = before + updatedPluginArray + after + + if (updatedContent === content) { + log(`[auto-update-checker] No changes made to ${configPath}`) + return false + } + + fs.writeFileSync(configPath, updatedContent, "utf-8") + log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`) + return true + } catch (err) { + log(`[auto-update-checker] Failed to update config file ${configPath}:`, err) + return false + } +} + export async function getLatestVersion(): Promise { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT) diff --git a/src/hooks/auto-update-checker/index.ts b/src/hooks/auto-update-checker/index.ts index 19b6800..0ef3244 100644 --- a/src/hooks/auto-update-checker/index.ts +++ b/src/hooks/auto-update-checker/index.ts @@ -1,5 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { checkForUpdate, getCachedVersion, getLocalDevVersion } from "./checker" +import { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion } from "./checker" import { invalidatePackage } from "./cache" import { PACKAGE_NAME } from "./constants" import { log } from "../../shared/logger" @@ -7,7 +7,7 @@ import { getConfigLoadErrors, clearConfigLoadErrors } from "../../shared/config- import type { AutoUpdateCheckerOptions } from "./types" export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) { - const { showStartupToast = true, isSisyphusEnabled = false } = options + const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options const getToastMessage = (isUpdate: boolean, latestVersion?: string): string => { if (isSisyphusEnabled) { @@ -20,21 +20,6 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat : `OpenCode is now on Steroids. oMoMoMoMo...` } - const showVersionToast = async (version: string | null): Promise => { - const displayVersion = version ?? "unknown" - await ctx.client.tui - .showToast({ - body: { - title: `OhMyOpenCode ${displayVersion}`, - message: getToastMessage(false), - variant: "info" as const, - duration: 5000, - }, - }) - .catch(() => {}) - log(`[auto-update-checker] Startup toast shown: v${displayVersion}`) - } - let hasChecked = false return { @@ -47,57 +32,79 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat hasChecked = true - try { - const result = await checkForUpdate(ctx.directory) + const cachedVersion = getCachedVersion() + const localDevVersion = getLocalDevVersion(ctx.directory) + const displayVersion = localDevVersion ?? cachedVersion - if (result.isLocalDev) { - log("[auto-update-checker] Skipped: local development mode") - if (showStartupToast) { - const version = getLocalDevVersion(ctx.directory) ?? getCachedVersion() - await showVersionToast(version) - } - return - } + if (showStartupToast) { + showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {}) + } + showConfigErrorsIfAny(ctx).catch(() => {}) - if (result.isPinned) { - log(`[auto-update-checker] Skipped: version pinned to ${result.currentVersion}`) - if (showStartupToast) { - await showVersionToast(result.currentVersion) - } - return - } - - if (!result.needsUpdate) { - log("[auto-update-checker] No update needed") - if (showStartupToast) { - await showVersionToast(result.currentVersion) - } - return - } - - invalidatePackage(PACKAGE_NAME) - - await ctx.client.tui - .showToast({ - body: { - title: `OhMyOpenCode ${result.latestVersion}`, - message: getToastMessage(true, result.latestVersion ?? undefined), - 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) + if (localDevVersion) { + log("[auto-update-checker] Skipped: local development mode") + return } - await showConfigErrorsIfAny(ctx) + runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch(err => { + log("[auto-update-checker] Background update check failed:", err) + }) }, } } +async function runBackgroundUpdateCheck( + ctx: PluginInput, + autoUpdate: boolean, + getToastMessage: (isUpdate: boolean, latestVersion?: string) => string +): Promise { + const pluginInfo = findPluginEntry(ctx.directory) + if (!pluginInfo) { + log("[auto-update-checker] Plugin not found in config") + return + } + + const cachedVersion = getCachedVersion() + const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion + if (!currentVersion) { + log("[auto-update-checker] No version found (cached or pinned)") + return + } + + const latestVersion = await getLatestVersion() + if (!latestVersion) { + log("[auto-update-checker] Failed to fetch latest version") + return + } + + if (currentVersion === latestVersion) { + log("[auto-update-checker] Already on latest version") + return + } + + log(`[auto-update-checker] Update available: ${currentVersion} → ${latestVersion}`) + + if (!autoUpdate) { + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + log("[auto-update-checker] Auto-update disabled, notification only") + return + } + + if (pluginInfo.isPinned) { + const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion) + if (updated) { + invalidatePackage(PACKAGE_NAME) + await showAutoUpdatedToast(ctx, currentVersion, latestVersion) + log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`) + } else { + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + } + } else { + invalidatePackage(PACKAGE_NAME) + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + } +} + async function showConfigErrorsIfAny(ctx: PluginInput): Promise { const errors = getConfigLoadErrors() if (errors.length === 0) return @@ -118,6 +125,53 @@ async function showConfigErrorsIfAny(ctx: PluginInput): Promise { clearConfigLoadErrors() } +async function showVersionToast(ctx: PluginInput, version: string | null, message: string): Promise { + const displayVersion = version ?? "unknown" + await ctx.client.tui + .showToast({ + body: { + title: `OhMyOpenCode ${displayVersion}`, + message, + variant: "info" as const, + duration: 5000, + }, + }) + .catch(() => {}) + log(`[auto-update-checker] Startup toast shown: v${displayVersion}`) +} + +async function showUpdateAvailableToast( + ctx: PluginInput, + latestVersion: string, + getToastMessage: (isUpdate: boolean, latestVersion?: string) => string +): Promise { + await ctx.client.tui + .showToast({ + body: { + title: `OhMyOpenCode ${latestVersion}`, + message: getToastMessage(true, latestVersion), + variant: "info" as const, + duration: 8000, + }, + }) + .catch(() => {}) + log(`[auto-update-checker] Update available toast shown: v${latestVersion}`) +} + +async function showAutoUpdatedToast(ctx: PluginInput, oldVersion: string, newVersion: string): Promise { + await ctx.client.tui + .showToast({ + body: { + title: `OhMyOpenCode Updated!`, + message: `v${oldVersion} → v${newVersion}\nRestart OpenCode to apply.`, + variant: "success" as const, + duration: 8000, + }, + }) + .catch(() => {}) + log(`[auto-update-checker] Auto-updated toast shown: v${oldVersion} → v${newVersion}`) +} + export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types" export { checkForUpdate } from "./checker" export { invalidatePackage, invalidateCache } from "./cache" diff --git a/src/hooks/auto-update-checker/types.ts b/src/hooks/auto-update-checker/types.ts index 6af75aa..550e513 100644 --- a/src/hooks/auto-update-checker/types.ts +++ b/src/hooks/auto-update-checker/types.ts @@ -25,4 +25,5 @@ export interface UpdateCheckResult { export interface AutoUpdateCheckerOptions { showStartupToast?: boolean isSisyphusEnabled?: boolean + autoUpdate?: boolean } diff --git a/src/index.ts b/src/index.ts index c8c0be3..e76c0ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -262,6 +262,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? createAutoUpdateCheckerHook(ctx, { showStartupToast: isHookEnabled("startup-toast"), isSisyphusEnabled: pluginConfig.sisyphus_agent?.disabled !== true, + autoUpdate: pluginConfig.auto_update ?? true, }) : null; const keywordDetector = isHookEnabled("keyword-detector")