Browse Source

fix(auto-update): refresh host-managed package cache (#289)

## 背景
- OpenCode 现在会把 registry 插件安装到 ~/.cache/opencode/packages/<specifier>/node_modules/...。
- 现有 auto-update 仍按 legacy ~/.cache/opencode 根目录做失效和安装,导致提示更新成功但实际激活插件未更新。

## 变更
- 按当前运行包位置解析实际 install root,优先处理宿主管理的 packages 缓存包装目录。
- 更新对应 wrapper package.json 中的依赖版本,清理该 install root 下的 node_modules 包目录和 bun.lock 后再运行 bun install。
- 保留 legacy cache 兼容路径,并在 runtime path 已命中时避免静默回退到错误目录。
- 补充 packages 缓存模型与 fallback 行为的回归测试。

## 验证
- bun test src/hooks/auto-update-checker
- bun run typecheck
- bun run build

## 风险与兼容性
- 仅调整 auto-update-checker 的安装根目录解析与重装准备逻辑。
- 本地 file/dev 插件路径仍维持现有跳过 auto-update 的行为。
huilang021x 20 hours ago
parent
commit
851dcbdbff

+ 107 - 28
src/hooks/auto-update-checker/cache.test.ts

@@ -18,70 +18,149 @@ mock.module('../../cli/config-manager', () => ({
 let importCounter = 0;
 
 describe('auto-update-checker/cache', () => {
-  describe('invalidatePackage', () => {
-    test('returns false when nothing to invalidate', async () => {
+  describe('resolveInstallContext', () => {
+    test('detects OpenCode packages install root from runtime package path', async () => {
+      const existsSpy = spyOn(fs, 'existsSync').mockImplementation(
+        (p: string) =>
+          p ===
+          '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/package.json',
+      );
+      const { resolveInstallContext } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      const context = resolveInstallContext(
+        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim/package.json',
+      );
+
+      expect(context).toEqual({
+        installDir:
+          '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest',
+        packageJsonPath:
+          '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/package.json',
+      });
+
+      existsSpy.mockRestore();
+    });
+
+    test('does not fall back to legacy cache when runtime path is active but wrapper root is invalid', async () => {
+      const existsSpy = spyOn(fs, 'existsSync').mockImplementation(() => false);
+      const { resolveInstallContext } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      const context = resolveInstallContext(
+        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim/package.json',
+      );
+
+      expect(context).toBeNull();
+
+      existsSpy.mockRestore();
+    });
+  });
+
+  describe('preparePackageUpdate', () => {
+    test('returns null when no install context is available', async () => {
       const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
-      const { invalidatePackage } = await import(
+      const { preparePackageUpdate } = await import(
         `./cache?test=${importCounter++}`
       );
 
-      const result = invalidatePackage();
-      expect(result).toBe(false);
+      const result = preparePackageUpdate('1.0.1');
+      expect(result).toBeNull();
 
       existsSpy.mockRestore();
     });
 
-    test('returns true and removes directory if node_modules path exists', async () => {
+    test('updates packages wrapper dependency and removes installed package', async () => {
       const existsSpy = spyOn(fs, 'existsSync').mockImplementation(
-        (p: string) => p.includes('node_modules'),
+        (p: string) =>
+          p ===
+            '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/package.json' ||
+          p ===
+            '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim',
+      );
+      const readSpy = spyOn(fs, 'readFileSync').mockImplementation(
+        (p: string) => {
+          if (
+            p ===
+            '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/package.json'
+          ) {
+            return JSON.stringify({
+              dependencies: {
+                'oh-my-opencode-slim': '0.9.1',
+              },
+            });
+          }
+          return '';
+        },
+      );
+      const writtenData: string[] = [];
+      const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(
+        (_path: string, data: string) => {
+          writtenData.push(data);
+        },
       );
       const rmSyncSpy = spyOn(fs, 'rmSync').mockReturnValue(undefined);
-      const { invalidatePackage } = await import(
+      const { preparePackageUpdate } = await import(
         `./cache?test=${importCounter++}`
       );
 
-      const result = invalidatePackage();
+      const result = preparePackageUpdate(
+        '0.9.11',
+        'oh-my-opencode-slim',
+        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim/package.json',
+      );
 
-      expect(rmSyncSpy).toHaveBeenCalled();
-      expect(result).toBe(true);
+      expect(result).toBe(
+        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest',
+      );
+      expect(rmSyncSpy).toHaveBeenCalledWith(
+        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim',
+        { recursive: true, force: true },
+      );
+      expect(writtenData.length).toBeGreaterThan(0);
+      expect(JSON.parse(writtenData[0])).toEqual({
+        dependencies: {
+          'oh-my-opencode-slim': '0.9.11',
+        },
+      });
 
       existsSpy.mockRestore();
+      readSpy.mockRestore();
+      writeSpy.mockRestore();
       rmSyncSpy.mockRestore();
     });
 
-    test('removes dependency from package.json if present', async () => {
+    test('keeps working when dependency is already on target version', async () => {
       const existsSpy = spyOn(fs, 'existsSync').mockImplementation(
-        (p: string) => p.includes('package.json'),
+        (p: string) =>
+          p.endsWith('/.cache/opencode/package.json') ||
+          p.endsWith('/.cache/opencode/node_modules/oh-my-opencode-slim'),
       );
       const readSpy = spyOn(fs, 'readFileSync').mockReturnValue(
         JSON.stringify({
           dependencies: {
-            'oh-my-opencode-slim': '1.0.0',
-            'other-pkg': '1.0.0',
+            'oh-my-opencode-slim': '1.0.1',
           },
         }),
       );
-      const writtenData: string[] = [];
-      const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(
-        (_path: string, data: string) => {
-          writtenData.push(data);
-        },
-      );
-      const { invalidatePackage } = await import(
+      const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(() => {});
+      const rmSyncSpy = spyOn(fs, 'rmSync').mockReturnValue(undefined);
+      const { preparePackageUpdate } = await import(
         `./cache?test=${importCounter++}`
       );
 
-      const result = invalidatePackage();
+      const result = preparePackageUpdate('1.0.1', 'oh-my-opencode-slim', null);
 
-      expect(result).toBe(true);
-      expect(writtenData.length).toBeGreaterThan(0);
-      const savedJson = JSON.parse(writtenData[0]);
-      expect(savedJson.dependencies['oh-my-opencode-slim']).toBeUndefined();
-      expect(savedJson.dependencies['other-pkg']).toBe('1.0.0');
+      expect(result?.endsWith('/.cache/opencode')).toBe(true);
+      expect(writeSpy).not.toHaveBeenCalled();
+      expect(rmSyncSpy).toHaveBeenCalled();
 
       existsSpy.mockRestore();
       readSpy.mockRestore();
       writeSpy.mockRestore();
+      rmSyncSpy.mockRestore();
     });
   });
 });

+ 115 - 42
src/hooks/auto-update-checker/cache.ts

@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
 import * as path from 'node:path';
 import { stripJsonComments } from '../../cli/config-manager';
 import { log } from '../../utils/logger';
+import { getCurrentRuntimePackageJsonPath } from './checker';
 import { CACHE_DIR, PACKAGE_NAME } from './constants';
 
 interface BunLockfile {
@@ -13,13 +14,18 @@ interface BunLockfile {
   packages?: Record<string, unknown>;
 }
 
+interface AutoUpdateInstallContext {
+  installDir: string;
+  packageJsonPath: string;
+}
+
 /**
  * Removes a package from the bun.lock file if it's in JSON format.
  * Note: Newer Bun versions (1.1+) use a custom text format for bun.lock.
  * This function handles JSON-based lockfiles gracefully.
  */
-function removeFromBunLock(packageName: string): boolean {
-  const lockPath = path.join(CACHE_DIR, 'bun.lock');
+function removeFromBunLock(installDir: string, packageName: string): boolean {
+  const lockPath = path.join(installDir, 'bun.lock');
   if (!fs.existsSync(lockPath)) return false;
 
   try {
@@ -58,58 +64,125 @@ function removeFromBunLock(packageName: string): boolean {
   }
 }
 
-/**
- * Invalidates the current package by removing its directory and dependency entries.
- * This forces a clean state before running a fresh install.
- * @param packageName The name of the package to invalidate.
- */
-export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
-  try {
-    const pkgDir = path.join(CACHE_DIR, 'node_modules', packageName);
-    const pkgJsonPath = path.join(CACHE_DIR, 'package.json');
+function ensureDependencyVersion(
+  packageJsonPath: string,
+  packageName: string,
+  version: string,
+): boolean {
+  if (!fs.existsSync(packageJsonPath)) return false;
 
-    let packageRemoved = false;
-    let dependencyRemoved = false;
-    let lockRemoved = false;
+  try {
+    const content = fs.readFileSync(packageJsonPath, 'utf-8');
+    const pkgJson = JSON.parse(stripJsonComments(content)) as {
+      dependencies?: Record<string, string>;
+      [key: string]: unknown;
+    };
 
-    if (fs.existsSync(pkgDir)) {
-      fs.rmSync(pkgDir, { recursive: true, force: true });
-      log(`[auto-update-checker] Package removed: ${pkgDir}`);
-      packageRemoved = true;
+    const dependencies = { ...(pkgJson.dependencies ?? {}) };
+    if (dependencies[packageName] === version) {
+      return true;
     }
 
-    if (fs.existsSync(pkgJsonPath)) {
-      try {
-        const content = fs.readFileSync(pkgJsonPath, 'utf-8');
-        const pkgJson = JSON.parse(stripJsonComments(content));
-        if (pkgJson.dependencies?.[packageName]) {
-          delete pkgJson.dependencies[packageName];
-          fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
-          log(
-            `[auto-update-checker] Dependency removed from package.json: ${packageName}`,
-          );
-          dependencyRemoved = true;
-        }
-      } catch (err) {
-        log(
-          `[auto-update-checker] Failed to update package.json for invalidation:`,
-          err,
-        );
+    dependencies[packageName] = version;
+    pkgJson.dependencies = dependencies;
+    fs.writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2));
+    log(
+      `[auto-update-checker] Updated dependency in package.json: ${packageName} → ${version}`,
+    );
+    return true;
+  } catch (err) {
+    log(
+      `[auto-update-checker] Failed to update package.json dependency for auto-update:`,
+      err,
+    );
+    return false;
+  }
+}
+
+function removeInstalledPackage(
+  installDir: string,
+  packageName: string,
+): boolean {
+  const pkgDir = path.join(installDir, 'node_modules', packageName);
+  if (!fs.existsSync(pkgDir)) return false;
+
+  fs.rmSync(pkgDir, { recursive: true, force: true });
+  log(`[auto-update-checker] Package removed: ${pkgDir}`);
+  return true;
+}
+
+export function resolveInstallContext(
+  runtimePackageJsonPath: string | null = getCurrentRuntimePackageJsonPath(),
+): AutoUpdateInstallContext | null {
+  if (runtimePackageJsonPath) {
+    const packageDir = path.dirname(runtimePackageJsonPath);
+    const nodeModulesDir = path.dirname(packageDir);
+
+    if (
+      path.basename(packageDir) === PACKAGE_NAME &&
+      path.basename(nodeModulesDir) === 'node_modules'
+    ) {
+      const installDir = path.dirname(nodeModulesDir);
+      const packageJsonPath = path.join(installDir, 'package.json');
+      if (fs.existsSync(packageJsonPath)) {
+        return { installDir, packageJsonPath };
       }
     }
 
-    lockRemoved = removeFromBunLock(packageName);
+    return null;
+  }
+
+  const legacyPackageJsonPath = path.join(CACHE_DIR, 'package.json');
+  if (fs.existsSync(legacyPackageJsonPath)) {
+    return { installDir: CACHE_DIR, packageJsonPath: legacyPackageJsonPath };
+  }
+
+  return null;
+}
+
+/**
+ * Prepares the current install root for a clean re-install of the target version.
+ * Returns the install directory to run `bun install` in.
+ */
+export function preparePackageUpdate(
+  version: string,
+  packageName: string = PACKAGE_NAME,
+  runtimePackageJsonPath: string | null = getCurrentRuntimePackageJsonPath(),
+): string | null {
+  try {
+    const installContext = resolveInstallContext(runtimePackageJsonPath);
+    if (!installContext) {
+      log('[auto-update-checker] No install context found for auto-update');
+      return null;
+    }
+
+    const dependencyReady = ensureDependencyVersion(
+      installContext.packageJsonPath,
+      packageName,
+      version,
+    );
+    if (!dependencyReady) {
+      return null;
+    }
 
-    if (!packageRemoved && !dependencyRemoved && !lockRemoved) {
+    const packageRemoved = removeInstalledPackage(
+      installContext.installDir,
+      packageName,
+    );
+    const lockRemoved = removeFromBunLock(
+      installContext.installDir,
+      packageName,
+    );
+
+    if (!packageRemoved && !lockRemoved) {
       log(
-        `[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`,
+        `[auto-update-checker] No cached package artifacts removed for ${packageName}; continuing with updated dependency spec`,
       );
-      return false;
     }
 
-    return true;
+    return installContext.installDir;
   } catch (err) {
-    log('[auto-update-checker] Failed to invalidate package:', err);
-    return false;
+    log('[auto-update-checker] Failed to prepare package update:', err);
+    return null;
   }
 }

+ 7 - 0
src/hooks/auto-update-checker/checker.test.ts

@@ -73,6 +73,12 @@ describe('auto-update-checker/checker', () => {
           return false;
         },
       );
+      const statSpy = spyOn(fs, 'statSync').mockImplementation(
+        () =>
+          ({
+            isDirectory: () => true,
+          }) as unknown as fs.Stats,
+      );
       const readSpy = spyOn(fs, 'readFileSync').mockImplementation(
         (p: string) => {
           if (p.includes('opencode.json')) {
@@ -97,6 +103,7 @@ describe('auto-update-checker/checker', () => {
       expect(getLocalDevVersion('/test')).toBe('1.2.3-dev');
 
       existsSpy.mockRestore();
+      statSpy.mockRestore();
       readSpy.mockRestore();
     });
   });

+ 20 - 6
src/hooks/auto-update-checker/checker.ts

@@ -139,6 +139,21 @@ export function getLocalDevVersion(directory: string): string | null {
 }
 
 /**
+ * Resolves the package.json for the currently running plugin bundle.
+ */
+export function getCurrentRuntimePackageJsonPath(
+  currentModuleUrl: string = import.meta.url,
+): string | null {
+  try {
+    const currentDir = path.dirname(fileURLToPath(currentModuleUrl));
+    return findPackageJsonUp(currentDir);
+  } catch (err) {
+    log('[auto-update-checker] Failed to resolve runtime package path:', err);
+    return null;
+  }
+}
+
+/**
  * Searches across all config locations to find the current installation entry for this plugin.
  */
 export function findPluginEntry(directory: string): PluginEntryInfo | null {
@@ -179,8 +194,9 @@ export function getCachedVersion(): string | null {
   if (cachedPackageVersion) return cachedPackageVersion;
 
   try {
-    if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
-      const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, 'utf-8');
+    const runtimePackageJsonPath = getCurrentRuntimePackageJsonPath();
+    if (runtimePackageJsonPath && fs.existsSync(runtimePackageJsonPath)) {
+      const content = fs.readFileSync(runtimePackageJsonPath, 'utf-8');
       const pkg = JSON.parse(content) as PackageJson;
       if (pkg.version) {
         cachedPackageVersion = pkg.version;
@@ -192,10 +208,8 @@ export function getCachedVersion(): string | null {
   }
 
   try {
-    const currentDir = path.dirname(fileURLToPath(import.meta.url));
-    const pkgPath = findPackageJsonUp(currentDir);
-    if (pkgPath) {
-      const content = fs.readFileSync(pkgPath, 'utf-8');
+    if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
+      const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, 'utf-8');
       const pkg = JSON.parse(content) as PackageJson;
       if (pkg.version) {
         cachedPackageVersion = pkg.version;

+ 2 - 1
src/hooks/auto-update-checker/index.test.ts

@@ -14,7 +14,8 @@ mock.module('./checker', () => ({
 }));
 
 mock.module('./cache', () => ({
-  invalidatePackage: mock(() => false),
+  preparePackageUpdate: mock(() => '/tmp/opencode'),
+  resolveInstallContext: mock(() => ({ installDir: '/tmp/opencode' })),
 }));
 
 // Cache buster for dynamic imports

+ 17 - 7
src/hooks/auto-update-checker/index.ts

@@ -1,6 +1,6 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import { log } from '../../utils/logger';
-import { invalidatePackage } from './cache';
+import { preparePackageUpdate, resolveInstallContext } from './cache';
 import {
   extractChannel,
   findPluginEntry,
@@ -140,9 +140,20 @@ async function runBackgroundUpdateCheck(
     return;
   }
 
-  invalidatePackage(PACKAGE_NAME);
+  const installDir = preparePackageUpdate(latestVersion, PACKAGE_NAME);
+  if (!installDir) {
+    showToast(
+      ctx,
+      `OMO-Slim ${latestVersion}`,
+      `v${latestVersion} available. Auto-update could not prepare the active install.`,
+      'info',
+      8000,
+    );
+    log('[auto-update-checker] Failed to prepare install root for auto-update');
+    return;
+  }
 
-  const installSuccess = await runBunInstallSafe();
+  const installSuccess = await runBunInstallSafe(installDir);
 
   if (installSuccess) {
     showToast(
@@ -168,18 +179,17 @@ async function runBackgroundUpdateCheck(
 }
 
 export function getAutoUpdateInstallDir(): string {
-  return CACHE_DIR;
+  return resolveInstallContext()?.installDir ?? CACHE_DIR;
 }
 
 /**
  * Spawns a background process to run 'bun install'.
  * Includes a 60-second timeout to prevent stalling OpenCode.
- * @param ctx The plugin input context.
+ * @param installDir The directory whose package manager context should be refreshed.
  * @returns True if the installation succeeded within the timeout.
  */
-async function runBunInstallSafe(): Promise<boolean> {
+async function runBunInstallSafe(installDir: string): Promise<boolean> {
   try {
-    const installDir = getAutoUpdateInstallDir();
     const proc = Bun.spawn(['bun', 'install'], {
       cwd: installDir,
       stdout: 'pipe',