Browse Source

Merge pull request #802 from mhenke/fix-781-pin-version

fix(config-io): pin installed version in plugin config entry
Alvin 2 weeks ago
parent
commit
a80782fca1

+ 8 - 0
src/cache-safety-tripwire.test.ts

@@ -66,6 +66,14 @@ const ALLOWLIST = new Map<string, string>([
     'hooks/image-hook.ts',
     'Date.now() throttles temp-image cleanup; extracted image paths are deterministic per part id.',
   ],
+  [
+    'hooks/auto-update-checker/cache.ts',
+    'Date.now() and process.pid name an on-disk quarantine directory during the atomic publish transaction; the path is filesystem bookkeeping, never serialized into prompt content.',
+  ],
+  [
+    'hooks/auto-update-checker/checker.ts',
+    'Date.now()/Math.random() compose a per-run temp token for install bookkeeping; it names local directories and never reaches the prompt prefix.',
+  ],
 ]);
 
 async function scanForViolations(): Promise<string[]> {

+ 70 - 2
src/cli/config-io.test.ts

@@ -46,11 +46,14 @@ describe('config-io', () => {
     mock.restore();
   });
 
-  function writePackageJson(dir: string): void {
+  function writePackageJson(dir: string, version?: string): void {
     mkdirSync(dir, { recursive: true });
     writeFileSync(
       join(dir, 'package.json'),
-      JSON.stringify({ name: 'oh-my-opencode-slim' }),
+      JSON.stringify({
+        name: 'oh-my-opencode-slim',
+        ...(version ? { version } : {}),
+      }),
     );
   }
 
@@ -181,6 +184,51 @@ describe('config-io', () => {
     expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
   });
 
+  test('addPluginToOpenCodeConfig leaves @latest bunx invocations unpinned', async () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@latest',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    paths.ensureConfigDir();
+    writeFileSync(configPath, JSON.stringify({ plugin: [] }));
+    writePackageJson(packageRoot, '1.2.3');
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const result = await addPluginToOpenCodeConfig();
+
+    expect(result.success).toBe(true);
+    const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
+    expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
+  });
+
+  test('addPluginToOpenCodeConfig writes the resolved version as an installer-managed tuple', async () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@beta',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    paths.ensureConfigDir();
+    writeFileSync(configPath, JSON.stringify({ plugin: [] }));
+    writePackageJson(packageRoot, '1.2.3');
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const result = await addPluginToOpenCodeConfig();
+
+    expect(result.success).toBe(true);
+    const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
+    expect(saved.plugin).toEqual([
+      [
+        'oh-my-opencode-slim@1.2.3',
+        { __ohMyOpencodeSlimManagedByInstaller: true },
+      ],
+    ]);
+  });
+
   test('addPluginToOpenCodeConfig stores local repo path for local dev paths', async () => {
     const configPath = join(tmpDir, 'opencode', 'opencode.json');
     const packageRoot = join(tmpDir, 'repo');
@@ -577,6 +625,26 @@ describe('config-io', () => {
     expect(detected.hasZaiPlan).toBe(true);
   });
 
+  test('detectCurrentConfig detects installed status for installer-managed tuple', () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    paths.ensureConfigDir();
+
+    writeFileSync(
+      configPath,
+      JSON.stringify({
+        plugin: [
+          [
+            'oh-my-opencode-slim@1.2.3',
+            { __ohMyOpencodeSlimManagedByInstaller: true },
+          ],
+        ],
+      }),
+    );
+
+    const detected = detectCurrentConfig();
+    expect(detected.isInstalled).toBe(true);
+  });
+
   test('detectCurrentConfig detects provider models in arrays', () => {
     const configPath = join(tmpDir, 'opencode', 'opencode.json');
     const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');

+ 34 - 18
src/cli/config-io.ts

@@ -10,6 +10,10 @@ import {
 } from 'node:fs';
 import { homedir } from 'node:os';
 import { dirname, join } from 'node:path';
+import {
+  INSTALLER_MANAGED_PLUGIN_OPTION,
+  type PluginEntry,
+} from '../plugin-entry';
 import { crossSpawn } from '../utils/compat';
 import {
   ensureConfigDir,
@@ -51,10 +55,6 @@ function getPlugins(config: OpenCodeConfig): unknown[] {
   return Array.isArray(config.plugin) ? config.plugin : [];
 }
 
-function getPluginEntries(config: OpenCodeConfig): string[] {
-  return getPlugins(config).filter(isString);
-}
-
 function getPluginSpec(entry: unknown): string | undefined {
   if (isString(entry)) return entry;
   if (!Array.isArray(entry)) return undefined;
@@ -136,7 +136,7 @@ function isMatchingPluginEntry(entry: unknown): boolean {
   return spec ? isPluginEntry(spec) : false;
 }
 
-function getPluginEntry(): string {
+function getPluginEntry(): PluginEntry {
   const cliEntryPath = process.argv[1];
 
   if (!cliEntryPath) {
@@ -146,10 +146,22 @@ function getPluginEntry(): string {
   try {
     const packageRoot = findPackageRoot(cliEntryPath);
 
-    if (!packageRoot || isPackageManagerInstall(packageRoot)) {
+    if (!packageRoot) {
       return PACKAGE_NAME;
     }
 
+    if (isPackageManagerInstall(packageRoot)) {
+      const version = getVersionFromPackageRoot(packageRoot);
+      const requestedTag = getRequestedPackageTag(packageRoot);
+      if (!version || !requestedTag || requestedTag === 'latest') {
+        return PACKAGE_NAME;
+      }
+      return [
+        `${PACKAGE_NAME}@${version}`,
+        { [INSTALLER_MANAGED_PLUGIN_OPTION]: true },
+      ];
+    }
+
     return packageRoot;
   } catch {
     return PACKAGE_NAME;
@@ -161,19 +173,22 @@ function getPluginEntry(): string {
  * Returns the version string (e.g. "1.2.3") if pinned, or undefined
  * if the plugin is unpinned (bare name or @latest).
  */
-function getPinnedVersionFromConfig(): string | undefined {
+function getConfiguredExactVersion(): string | undefined {
   try {
     const { config } = parseConfig(getExistingConfigPath());
     if (!config) return undefined;
+    let version: string | undefined;
     for (const entry of getPlugins(config)) {
       const spec = getPluginSpec(entry);
       if (!spec) continue;
-      if (spec === PACKAGE_NAME) return undefined;
-      if (spec.startsWith(`${PACKAGE_NAME}@`)) {
-        const version = spec.slice(PACKAGE_NAME.length + 1);
-        if (version && version !== 'latest') return version;
+      if (spec === PACKAGE_NAME) {
+        version = undefined;
+      } else if (spec.startsWith(`${PACKAGE_NAME}@`)) {
+        const candidate = spec.slice(PACKAGE_NAME.length + 1);
+        version = candidate && candidate !== 'latest' ? candidate : undefined;
       }
     }
+    return version;
   } catch {}
   return undefined;
 }
@@ -311,10 +326,10 @@ export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | nul
     return null;
   }
 
-  const pinnedVersion = getPinnedVersionFromConfig();
+  const configuredVersion = getConfiguredExactVersion();
   const runningVersion = getVersionFromPackageRoot(packageRoot);
   const requestedTag = getRequestedPackageTag(packageRoot);
-  const cacheVersion = pinnedVersion ?? requestedTag ?? runningVersion;
+  const cacheVersion = configuredVersion ?? requestedTag ?? runningVersion;
   const cacheDir = getOpenCodePluginCacheDir(cacheVersion);
 
   try {
@@ -659,11 +674,12 @@ export function detectCurrentConfig(): DetectedConfig {
   const { config } = parseConfig(getExistingConfigPath());
   if (!config) return result;
 
-  const plugins = getPluginEntries(config);
-  result.isInstalled = plugins.some((p) => isPluginEntry(p));
-  result.hasAntigravity = plugins.some((p) =>
-    p.startsWith('opencode-antigravity-auth'),
-  );
+  const plugins = getPlugins(config);
+  result.isInstalled = plugins.some((p) => isMatchingPluginEntry(p));
+  result.hasAntigravity = plugins.some((p) => {
+    const spec = getPluginSpec(p);
+    return spec?.startsWith('opencode-antigravity-auth') ?? false;
+  });
 
   // Check for providers
   const providers = config.provider as Record<string, unknown> | undefined;

+ 139 - 10
src/hooks/auto-update-checker/cache.test.ts

@@ -1,5 +1,7 @@
 import { describe, expect, mock, spyOn, test } from 'bun:test';
 import * as fs from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
 
 // Mock logger to avoid noise
 mock.module('../../utils/logger', () => ({
@@ -102,6 +104,10 @@ describe('auto-update-checker/cache', () => {
         },
       );
       const rmSyncSpy = spyOn(fs, 'rmSync').mockReturnValue(undefined);
+      const mkdirSyncSpy = spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
+      const mkdtempSyncSpy = spyOn(fs, 'mkdtempSync').mockReturnValue(
+        '/home/user/.cache/opencode/packages/.oh-my-opencode-slim@0.9.11.staging-test',
+      );
       const { preparePackageUpdate } = await import(
         `./cache?test=${importCounter++}`
       );
@@ -112,15 +118,15 @@ describe('auto-update-checker/cache', () => {
         '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim/package.json',
       );
 
-      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(result).toEqual({
+        stagingDir:
+          '/home/user/.cache/opencode/packages/.oh-my-opencode-slim@0.9.11.staging-test',
+        targetDir:
+          '/home/user/.cache/opencode/packages/oh-my-opencode-slim@0.9.11',
+      });
       expect(writtenData.length).toBeGreaterThan(0);
       expect(JSON.parse(writtenData[0])).toEqual({
+        private: true,
         dependencies: {
           'oh-my-opencode-slim': '0.9.11',
         },
@@ -130,6 +136,8 @@ describe('auto-update-checker/cache', () => {
       readSpy.mockRestore();
       writeSpy.mockRestore();
       rmSyncSpy.mockRestore();
+      mkdirSyncSpy.mockRestore();
+      mkdtempSyncSpy.mockRestore();
     });
 
     test('keeps working when dependency is already on target version', async () => {
@@ -153,9 +161,8 @@ describe('auto-update-checker/cache', () => {
 
       const result = preparePackageUpdate('1.0.1', 'oh-my-opencode-slim', null);
 
-      expect(result?.endsWith('/.cache/opencode')).toBe(true);
-      expect(writeSpy).not.toHaveBeenCalled();
-      expect(rmSyncSpy).toHaveBeenCalled();
+      expect(result).not.toBeNull();
+      expect(writeSpy).toHaveBeenCalled();
 
       existsSpy.mockRestore();
       readSpy.mockRestore();
@@ -163,4 +170,126 @@ describe('auto-update-checker/cache', () => {
       rmSyncSpy.mockRestore();
     });
   });
+
+  describe('publishPackageUpdate transaction', () => {
+    function createPackage(dir: string, version: string): void {
+      const packageDir = join(dir, 'node_modules', 'oh-my-opencode-slim');
+      fs.mkdirSync(packageDir, { recursive: true });
+      fs.writeFileSync(
+        join(packageDir, 'package.json'),
+        JSON.stringify({ name: 'oh-my-opencode-slim', version }),
+      );
+    }
+
+    function createPrepared(root: string, version: string) {
+      const parent = join(root, 'packages');
+      fs.mkdirSync(parent, { recursive: true });
+      const stagingDir = fs.mkdtempSync(join(parent, '.staging-'));
+      return {
+        stagingDir,
+        targetDir: join(parent, `oh-my-opencode-slim@${version}`),
+      };
+    }
+
+    test('publishes a verified staged package atomically', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.4');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBe(prepared.targetDir);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      expect(fs.existsSync(join(prepared.targetDir, 'node_modules'))).toBe(
+        true,
+      );
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('cleans staging when a valid concurrent target already exists', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.4');
+      createPackage(prepared.targetDir, '1.2.4');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBe(prepared.targetDir);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      expect(
+        fs
+          .readdirSync(join(root, 'packages'))
+          .some((name) => name.includes('invalid-')),
+      ).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('replaces an invalid target and removes its quarantine', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.4');
+      fs.mkdirSync(prepared.targetDir, { recursive: true });
+      fs.writeFileSync(join(prepared.targetDir, 'package.json'), '{}');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBe(prepared.targetDir);
+      expect(
+        fs
+          .readdirSync(join(root, 'packages'))
+          .some((name) => name.includes('invalid-')),
+      ).toBe(false);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('removes an unverifiable freshly published target and staging', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.3');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBeNull();
+      expect(fs.existsSync(prepared.targetDir)).toBe(false);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('restores the prior usable target when replacement verification fails', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.targetDir, '1.2.3');
+      createPackage(prepared.stagingDir, '1.2.3');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBeNull();
+      expect(
+        JSON.parse(
+          fs.readFileSync(
+            join(
+              prepared.targetDir,
+              'node_modules',
+              'oh-my-opencode-slim',
+              'package.json',
+            ),
+            'utf-8',
+          ),
+        ),
+      ).toEqual({ name: 'oh-my-opencode-slim', version: '1.2.3' });
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      expect(
+        fs
+          .readdirSync(join(root, 'packages'))
+          .some((name) => name.includes('invalid-')),
+      ).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+  });
 });

+ 103 - 119
src/hooks/auto-update-checker/cache.ts

@@ -1,114 +1,30 @@
 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 {
-  workspaces?: {
-    ''?: {
-      dependencies?: Record<string, string>;
-    };
-  };
-  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(installDir: string, packageName: string): boolean {
-  const lockPath = path.join(installDir, 'bun.lock');
-  if (!fs.existsSync(lockPath)) return false;
-
-  try {
-    const content = fs.readFileSync(lockPath, 'utf-8');
-    let lock: BunLockfile;
-
-    try {
-      lock = JSON.parse(stripJsonComments(content)) as BunLockfile;
-    } catch {
-      // If it's not valid JSON(C), it might be the new Bun text format or binary format.
-      // For now, we only support JSON-based lockfile manipulation.
-      return false;
-    }
-
-    let modified = false;
-
-    if (lock.workspaces?.['']?.dependencies?.[packageName]) {
-      delete lock.workspaces[''].dependencies[packageName];
-      modified = true;
-    }
-
-    if (lock.packages?.[packageName]) {
-      delete lock.packages[packageName];
-      modified = true;
-    }
-
-    if (modified) {
-      fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2));
-      log(`[auto-update-checker] Removed from bun.lock: ${packageName}`);
-    }
-
-    return modified;
-  } catch (err) {
-    log(`[auto-update-checker] Failed to process bun.lock:`, err);
-    return false;
-  }
+interface PreparedPackageUpdate {
+  stagingDir: string;
+  targetDir: string;
 }
 
-function ensureDependencyVersion(
-  packageJsonPath: string,
-  packageName: string,
+function getTargetInstallContext(
+  installContext: AutoUpdateInstallContext,
   version: string,
-): boolean {
-  if (!fs.existsSync(packageJsonPath)) return false;
-
-  try {
-    const content = fs.readFileSync(packageJsonPath, 'utf-8');
-    const pkgJson = JSON.parse(stripJsonComments(content)) as {
-      dependencies?: Record<string, string>;
-      [key: string]: unknown;
-    };
-
-    const dependencies = { ...(pkgJson.dependencies ?? {}) };
-    if (dependencies[packageName] === version) {
-      return true;
-    }
-
-    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;
+): AutoUpdateInstallContext {
+  const installParent = path.dirname(installContext.installDir);
+  const parentDir =
+    path.basename(installParent) === 'packages'
+      ? installParent
+      : path.join(CACHE_DIR, 'packages');
+  const installDir = path.join(parentDir, `${PACKAGE_NAME}@${version}`);
+  return { installDir, packageJsonPath: path.join(installDir, 'package.json') };
 }
 
 export function resolveInstallContext(
@@ -148,7 +64,9 @@ export function preparePackageUpdate(
   version: string,
   packageName: string = PACKAGE_NAME,
   runtimePackageJsonPath: string | null = getCurrentRuntimePackageJsonPath(),
-): string | null {
+  cacheIdentity: string = version,
+): PreparedPackageUpdate | null {
+  let stagingDir: string | null = null;
   try {
     const installContext = resolveInstallContext(runtimePackageJsonPath);
     if (!installContext) {
@@ -156,33 +74,99 @@ export function preparePackageUpdate(
       return null;
     }
 
-    const dependencyReady = ensureDependencyVersion(
-      installContext.packageJsonPath,
-      packageName,
-      version,
+    const targetContext = getTargetInstallContext(
+      installContext,
+      cacheIdentity,
     );
-    if (!dependencyReady) {
-      return null;
-    }
-
-    const packageRemoved = removeInstalledPackage(
-      installContext.installDir,
-      packageName,
+    const targetParent = path.dirname(targetContext.installDir);
+    fs.mkdirSync(targetParent, { recursive: true });
+    stagingDir = fs.mkdtempSync(
+      path.join(targetParent, `.${PACKAGE_NAME}@${cacheIdentity}.staging-`),
     );
-    const lockRemoved = removeFromBunLock(
-      installContext.installDir,
-      packageName,
+    fs.writeFileSync(
+      path.join(stagingDir, 'package.json'),
+      JSON.stringify({
+        private: true,
+        dependencies: { [packageName]: version },
+      }),
     );
 
-    if (!packageRemoved && !lockRemoved) {
-      log(
-        `[auto-update-checker] No cached package artifacts removed for ${packageName}; continuing with updated dependency spec`,
-      );
-    }
-
-    return installContext.installDir;
+    return { stagingDir, targetDir: targetContext.installDir };
   } catch (err) {
+    if (stagingDir) fs.rmSync(stagingDir, { recursive: true, force: true });
     log('[auto-update-checker] Failed to prepare package update:', err);
     return null;
   }
 }
+
+export function discardPreparedPackageUpdate(
+  prepared: PreparedPackageUpdate,
+): void {
+  fs.rmSync(prepared.stagingDir, { recursive: true, force: true });
+}
+
+export function publishPackageUpdate(
+  prepared: PreparedPackageUpdate,
+  version: string,
+): string | null {
+  try {
+    if (fs.existsSync(prepared.targetDir)) {
+      if (verifyInstalledPackage(prepared.targetDir, version)) {
+        discardPreparedPackageUpdate(prepared);
+        return prepared.targetDir;
+      }
+      const quarantineDir = `${prepared.targetDir}.invalid-${process.pid}-${Date.now()}`;
+      fs.renameSync(prepared.targetDir, quarantineDir);
+      try {
+        fs.renameSync(prepared.stagingDir, prepared.targetDir);
+        if (verifyInstalledPackage(prepared.targetDir, version)) {
+          fs.rmSync(quarantineDir, { recursive: true, force: true });
+          return prepared.targetDir;
+        }
+        fs.rmSync(prepared.targetDir, { recursive: true, force: true });
+        fs.renameSync(quarantineDir, prepared.targetDir);
+        return null;
+      } catch {
+        if (fs.existsSync(prepared.targetDir)) {
+          if (verifyInstalledPackage(prepared.targetDir, version)) {
+            discardPreparedPackageUpdate(prepared);
+            fs.rmSync(quarantineDir, { recursive: true, force: true });
+            return prepared.targetDir;
+          }
+        }
+      }
+      if (!fs.existsSync(prepared.targetDir)) {
+        fs.renameSync(quarantineDir, prepared.targetDir);
+      }
+      discardPreparedPackageUpdate(prepared);
+      return null;
+    }
+    fs.renameSync(prepared.stagingDir, prepared.targetDir);
+    if (verifyInstalledPackage(prepared.targetDir, version)) {
+      return prepared.targetDir;
+    }
+    fs.rmSync(prepared.targetDir, { recursive: true, force: true });
+    return null;
+  } catch {
+    discardPreparedPackageUpdate(prepared);
+    return null;
+  }
+}
+
+export function verifyInstalledPackage(
+  installDir: string,
+  version: string,
+  packageName: string = PACKAGE_NAME,
+): boolean {
+  try {
+    const packageJson = JSON.parse(
+      fs.readFileSync(
+        path.join(installDir, 'node_modules', packageName, 'package.json'),
+        'utf-8',
+      ),
+    ) as { name?: string; version?: string };
+    return packageJson.name === packageName && packageJson.version === version;
+  } catch {
+    return false;
+  }
+}

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

@@ -12,6 +12,8 @@ mock.module('../../cli/config-manager', () => ({
     '/mock/config/opencode.json',
     '/mock/config/opencode.jsonc',
   ],
+  getTuiConfig: () => '/mock/config/tui.json',
+  getTuiConfigJsonc: () => '/mock/config/tui.jsonc',
 }));
 
 // Cache buster for dynamic imports
@@ -155,6 +157,147 @@ describe('auto-update-checker/checker', () => {
       existsSpy.mockRestore();
       readSpy.mockRestore();
     });
+
+    test('treats only installer-managed exact tuples as updateable', async () => {
+      const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) =>
+        String(p).includes('opencode.json'),
+      );
+      const readSpy = spyOn(fs, 'readFileSync').mockReturnValue(
+        JSON.stringify({
+          plugin: [
+            'oh-my-opencode-slim@1.2.3',
+            [
+              'oh-my-opencode-slim@1.2.3',
+              { __ohMyOpencodeSlimManagedByInstaller: true },
+            ],
+          ],
+        }),
+      );
+      const { findPluginEntry } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const entry = findPluginEntry('/test');
+      expect(entry?.isPinned).toBe(false);
+      expect(entry?.isInstallerManaged).toBe(true);
+
+      const managedReadSpy = spyOn(fs, 'readFileSync').mockReturnValue(
+        JSON.stringify({
+          plugin: [
+            [
+              'oh-my-opencode-slim@1.2.3',
+              { __ohMyOpencodeSlimManagedByInstaller: true },
+            ],
+          ],
+        }),
+      );
+      const managedEntry = findPluginEntry('/test');
+      expect(managedEntry?.isPinned).toBe(false);
+      expect(managedEntry?.isInstallerManaged).toBe(true);
+
+      existsSpy.mockRestore();
+      readSpy.mockRestore();
+      managedReadSpy.mockRestore();
+    });
+  });
+
+  describe('updateInstallerManagedVersions', () => {
+    test('structurally rewrites managed tuples in OpenCode and TUI configs only', async () => {
+      const files = new Map<string, string>([
+        [
+          '/mock/config/opencode.json',
+          `{
+  // preserve this comment
+  "note": "{ [ ] }",
+  "other": { "plugin": [["oh-my-opencode-slim@0.1.0", { "__ohMyOpencodeSlimManagedByInstaller": true }]] },
+  "plugin": [["oh-my-opencode-slim@0.2.0", { "__ohMyOpencodeSlimManagedByInstaller": true }]],
+  "plugin": [
+    [ /* tuple comment */ "oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": true, "keep": "[{}]" } ],
+    ["oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": false, "__ohMyOpencodeSlimManagedByInstaller": true }],
+    ["oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": "true" }],
+    ["oh-my-opencode-slim\\u00401.2.3", { "__ohMyOpencodeSlimManagedByInstall\\u0065r": true }],
+    "oh-my-opencode-slim@1.2.3",
+    ["oh-my-opencode-slim@1.2.3", { "nested": { "__ohMyOpencodeSlimManagedByInstaller": true } }]
+  ]
+}`,
+        ],
+        [
+          '/mock/config/tui.json',
+          JSON.stringify({
+            plugin: [
+              [
+                'oh-my-opencode-slim@1.2.3',
+                { __ohMyOpencodeSlimManagedByInstaller: true },
+              ],
+            ],
+          }),
+        ],
+      ]);
+      const existsSpy = spyOn(fs, 'existsSync').mockImplementation((path) =>
+        files.has(String(path)),
+      );
+      const readSpy = spyOn(fs, 'readFileSync').mockImplementation(
+        (path) => files.get(String(path)) ?? '',
+      );
+      const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(
+        (path, data) => files.set(String(path), String(data)),
+      );
+      const renameSpy = spyOn(fs, 'renameSync').mockImplementation(
+        (from, to) => {
+          files.set(String(to), files.get(String(from)) ?? '');
+        },
+      );
+      const { updateInstallerManagedVersions } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const previousTuiConfig = process.env.OPENCODE_TUI_CONFIG;
+      process.env.OPENCODE_TUI_CONFIG = '/mock/config/tui.json';
+      expect(updateInstallerManagedVersions('/project', '1.2.4')).toBe(true);
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        'oh-my-opencode-slim@1.2.4',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@1.2.4", { "__ohMyOpencodeSlimManagedByInstall\\u0065r": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@0.1.0", { "__ohMyOpencodeSlimManagedByInstaller": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@0.2.0", { "__ohMyOpencodeSlimManagedByInstaller": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": "true" }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"keep": "[{}]"',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"note": "{ [ ] }"',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        'oh-my-opencode-slim@1.2.3',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"nested": { "__ohMyOpencodeSlimManagedByInstaller": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '// preserve this comment',
+      );
+      expect(files.get('/mock/config/tui.json')).toContain(
+        'oh-my-opencode-slim@1.2.4',
+      );
+      if (previousTuiConfig === undefined) {
+        delete process.env.OPENCODE_TUI_CONFIG;
+      } else {
+        process.env.OPENCODE_TUI_CONFIG = previousTuiConfig;
+      }
+
+      existsSpy.mockRestore();
+      readSpy.mockRestore();
+      writeSpy.mockRestore();
+      renameSpy.mockRestore();
+    });
   });
 
   describe('getLatestCompatibleVersion', () => {

+ 254 - 42
src/hooks/auto-update-checker/checker.ts

@@ -1,7 +1,12 @@
 import * as fs from 'node:fs';
 import * as path from 'node:path';
 import { fileURLToPath } from 'node:url';
-import { stripJsonComments } from '../../cli/config-manager';
+import {
+  getOpenCodeConfigPaths,
+  stripJsonComments,
+} from '../../cli/config-manager';
+import { getTuiConfig, getTuiConfigJsonc } from '../../cli/paths';
+import { INSTALLER_MANAGED_PLUGIN_OPTION } from '../../plugin-entry';
 import { log } from '../../utils/logger';
 import {
   INSTALLED_PACKAGE_JSON,
@@ -32,8 +37,182 @@ function isString(value: unknown): value is string {
   return typeof value === 'string';
 }
 
-function getPluginEntries(config: OpencodeConfig): string[] {
-  return Array.isArray(config.plugin) ? config.plugin.filter(isString) : [];
+function getPluginEntries(config: OpencodeConfig): unknown[] {
+  return Array.isArray(config.plugin) ? config.plugin : [];
+}
+
+function getPluginSpec(entry: unknown): string | null {
+  if (isString(entry)) return entry;
+  return Array.isArray(entry) && isString(entry[0]) ? entry[0] : null;
+}
+
+function isInstallerManagedEntry(entry: unknown): boolean {
+  return (
+    Array.isArray(entry) &&
+    entry.length >= 2 &&
+    entry[1] !== null &&
+    typeof entry[1] === 'object' &&
+    !Array.isArray(entry[1]) &&
+    (entry[1] as Record<string, unknown>)[INSTALLER_MANAGED_PLUGIN_OPTION] ===
+      true
+  );
+}
+
+type JsoncToken = {
+  kind: 'string' | 'literal' | 'punctuation';
+  value: string;
+  start: number;
+  end: number;
+};
+
+function tokenizeJsonc(content: string): JsoncToken[] {
+  const tokens: JsoncToken[] = [];
+  for (let index = 0; index < content.length; ) {
+    const char = content[index];
+    if (/\s/.test(char)) index++;
+    else if (content.startsWith('//', index)) {
+      index = content.indexOf('\n', index);
+      if (index === -1) break;
+    } else if (content.startsWith('/*', index)) {
+      index = content.indexOf('*/', index + 2);
+      if (index === -1) break;
+      index += 2;
+    } else if ('[]{}:,'.includes(char)) {
+      tokens.push({
+        kind: 'punctuation',
+        value: char,
+        start: index,
+        end: ++index,
+      });
+    } else if (char === '"') {
+      const start = index++;
+      while (index < content.length) {
+        if (content[index] === '\\') index += 2;
+        else if (content[index++] === '"') break;
+      }
+      const raw = content.slice(start, index);
+      try {
+        tokens.push({
+          kind: 'string',
+          value: JSON.parse(raw) as string,
+          start,
+          end: index,
+        });
+      } catch {
+        return [];
+      }
+    } else {
+      const start = index;
+      while (index < content.length && !/\s|[[\]{}:,]/.test(content[index]))
+        index++;
+      tokens.push({
+        kind: 'literal',
+        value: content.slice(start, index),
+        start,
+        end: index,
+      });
+    }
+  }
+  return tokens;
+}
+
+function matchingToken(
+  tokens: JsoncToken[],
+  start: number,
+  open: string,
+  close: string,
+): number {
+  let depth = 0;
+  for (let index = start; index < tokens.length; index++) {
+    if (tokens[index].kind === 'punctuation' && tokens[index].value === open)
+      depth++;
+    if (
+      tokens[index].kind === 'punctuation' &&
+      tokens[index].value === close &&
+      --depth === 0
+    )
+      return index;
+  }
+  return -1;
+}
+
+function hasDirectInstallerMarker(
+  tokens: JsoncToken[],
+  objectStart: number,
+): boolean {
+  const objectEnd = matchingToken(tokens, objectStart, '{', '}');
+  if (objectEnd === -1) return false;
+  let depth = 1;
+  let markerValue = false;
+  for (let index = objectStart + 1; index < objectEnd; index++) {
+    const value = tokens[index].value;
+    if (tokens[index].kind === 'punctuation' && value === '{') depth++;
+    else if (tokens[index].kind === 'punctuation' && value === '}') depth--;
+    else if (
+      depth === 1 &&
+      tokens[index].kind === 'string' &&
+      value === INSTALLER_MANAGED_PLUGIN_OPTION &&
+      tokens[index + 1]?.kind === 'punctuation' &&
+      tokens[index + 1]?.value === ':' &&
+      tokens[index + 2]
+    ) {
+      markerValue =
+        tokens[index + 2].kind === 'literal' &&
+        tokens[index + 2].value === 'true';
+    }
+  }
+  return markerValue;
+}
+
+function findManagedSpecifierRanges(content: string): Array<[number, number]> {
+  const tokens = tokenizeJsonc(content);
+  const rootStart = tokens.findIndex(
+    (token) => token.kind === 'punctuation' && token.value === '{',
+  );
+  if (rootStart === -1) return [];
+  const rootEnd = matchingToken(tokens, rootStart, '{', '}');
+  if (rootEnd === -1) return [];
+  let objectDepth = 1;
+  let plugin = -1;
+  for (let index = rootStart + 1; index < rootEnd; index++) {
+    const value = tokens[index].value;
+    if (tokens[index].kind === 'punctuation' && value === '{') objectDepth++;
+    else if (tokens[index].kind === 'punctuation' && value === '}')
+      objectDepth--;
+    else if (
+      objectDepth === 1 &&
+      value === 'plugin' &&
+      tokens[index + 1]?.kind === 'punctuation' &&
+      tokens[index + 1]?.value === ':' &&
+      tokens[index + 2]?.kind === 'punctuation' &&
+      tokens[index + 2]?.value === '['
+    ) {
+      plugin = index;
+    }
+  }
+  if (plugin === -1) return [];
+  const arrayStart = plugin + 2;
+  const arrayEnd = matchingToken(tokens, arrayStart, '[', ']');
+  if (arrayEnd === -1) return [];
+  const ranges: Array<[number, number]> = [];
+  for (let index = arrayStart + 1; index < arrayEnd; index++) {
+    if (tokens[index].kind !== 'punctuation' || tokens[index].value !== '[')
+      continue;
+    const tupleEnd = matchingToken(tokens, index, '[', ']');
+    if (tupleEnd === -1) break;
+    const specifier = tokens[index + 1];
+    if (
+      specifier?.value.startsWith(`${PACKAGE_NAME}@`) &&
+      tokens[index + 2]?.kind === 'punctuation' &&
+      tokens[index + 2]?.value === ',' &&
+      tokens[index + 3]?.kind === 'punctuation' &&
+      tokens[index + 3]?.value === '{' &&
+      hasDirectInstallerMarker(tokens, index + 3)
+    )
+      ranges.push([specifier.start + 1, specifier.end - 1]);
+    index = tupleEnd;
+  }
+  return ranges;
 }
 
 /**
@@ -153,10 +332,10 @@ export function extractChannel(version: string | null): string {
  */
 function getConfigPaths(directory: string): string[] {
   return [
-    path.join(directory, '.opencode', 'opencode.json'),
-    path.join(directory, '.opencode', 'opencode.jsonc'),
     USER_OPENCODE_CONFIG,
     USER_OPENCODE_CONFIG_JSONC,
+    path.join(directory, '.opencode', 'opencode.json'),
+    path.join(directory, '.opencode', 'opencode.jsonc'),
   ];
 }
 
@@ -172,11 +351,13 @@ function getLocalDevPath(directory: string): string | null {
       const plugins = getPluginEntries(config);
 
       for (const entry of plugins) {
-        if (entry.startsWith('file://') && entry.includes(PACKAGE_NAME)) {
+        const spec = getPluginSpec(entry);
+        if (!spec) continue;
+        if (spec.startsWith('file://') && spec.includes(PACKAGE_NAME)) {
           try {
-            return fileURLToPath(entry);
+            return fileURLToPath(spec);
           } catch {
-            return entry.replace('file://', '');
+            return spec.replace('file://', '');
           }
         }
       }
@@ -251,6 +432,7 @@ export function getCurrentRuntimePackageJsonPath(
  * Searches across all config locations to find the current installation entry for this plugin.
  */
 export function findPluginEntry(directory: string): PluginEntryInfo | null {
+  let selected: PluginEntryInfo | null = null;
   for (const configPath of getConfigPaths(directory)) {
     try {
       if (!fs.existsSync(configPath)) continue;
@@ -258,16 +440,27 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
       const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
       const plugins = getPluginEntries(config);
 
-      for (const entry of plugins) {
+      for (const rawEntry of plugins) {
+        const entry = getPluginSpec(rawEntry);
+        if (!entry) continue;
         if (entry === PACKAGE_NAME) {
-          return { entry, isPinned: false, pinnedVersion: null, configPath };
+          selected = {
+            entry,
+            isPinned: false,
+            isInstallerManaged: false,
+            pinnedVersion: null,
+            configPath,
+          };
+          continue;
         }
         if (entry.startsWith(`${PACKAGE_NAME}@`)) {
           const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1);
-          const isPinned = pinnedVersion !== 'latest';
-          return {
+          const isInstallerManaged = isInstallerManagedEntry(rawEntry);
+          const isPinned = pinnedVersion !== 'latest' && !isInstallerManaged;
+          selected = {
             entry,
             isPinned,
+            isInstallerManaged,
             pinnedVersion: isPinned ? pinnedVersion : null,
             configPath,
           };
@@ -275,7 +468,7 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
       }
     } catch {}
   }
-  return null;
+  return selected;
 }
 
 const _cachedLocalVersion: string | null = null;
@@ -324,43 +517,62 @@ export function getCachedVersion(): string | null {
  * Safely updates a pinned version in the configuration file.
  * It attempts to replace the exact plugin string to preserve comments and formatting.
  */
-export function updatePinnedVersion(
-  configPath: string,
-  oldEntry: string,
+export function updateInstallerManagedVersions(
+  directory: string,
   newVersion: string,
 ): boolean {
   try {
-    if (!fs.existsSync(configPath)) return false;
-
-    const content = fs.readFileSync(configPath, 'utf-8');
+    const paths = [
+      ...getConfigPaths(directory),
+      ...getOpenCodeConfigPaths(),
+      getTuiConfig(),
+      getTuiConfigJsonc(),
+    ]
+      .filter((value, index, values) => values.indexOf(value) === index)
+      .filter((configPath) => fs.existsSync(configPath));
     const newEntry = `${PACKAGE_NAME}@${newVersion}`;
-
-    // Check if the old entry actually exists as a quoted string
-    const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-    const entryRegex = new RegExp(`(["'])${escapedOldEntry}\\1`, 'g');
-
-    if (!entryRegex.test(content)) {
-      log(
-        `[auto-update-checker] Entry "${oldEntry}" not found in ${configPath}`,
-      );
-      return false;
-    }
-
-    // Perform the replacement
-    const updatedContent = content.replace(entryRegex, `$1${newEntry}$1`);
-
-    if (updatedContent === content) {
-      return false;
+    const updates = paths.flatMap((configPath) => {
+      const content = fs.readFileSync(configPath, 'utf-8');
+      const updated = findManagedSpecifierRanges(content)
+        .toReversed()
+        .reduce(
+          (result, [start, end]) =>
+            `${result.slice(0, start)}${newEntry}${result.slice(end)}`,
+          content,
+        );
+      const changed = updated !== content;
+      return changed
+        ? [
+            {
+              configPath,
+              content,
+              updated,
+            },
+          ]
+        : [];
+    });
+    if (updates.length === 0) return false;
+    const token = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
+    for (const update of updates)
+      fs.writeFileSync(`${update.configPath}.${token}.tmp`, update.updated);
+    const committed: typeof updates = [];
+    try {
+      for (const update of updates) {
+        fs.renameSync(`${update.configPath}.${token}.tmp`, update.configPath);
+        committed.push(update);
+      }
+    } catch (err) {
+      for (const update of committed) {
+        const restorePath = `${update.configPath}.${token}.restore`;
+        fs.writeFileSync(restorePath, update.content);
+        fs.renameSync(restorePath, update.configPath);
+      }
+      throw err;
     }
-
-    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}:`,
+      '[auto-update-checker] Failed to update installer-managed configs:',
       err,
     );
     return false;

+ 2 - 0
src/hooks/auto-update-checker/constants.ts

@@ -2,6 +2,8 @@ import * as os from 'node:os';
 import * as path from 'node:path';
 import { getOpenCodeConfigPaths } from '../../cli/config-manager';
 
+export { INSTALLER_MANAGED_PLUGIN_OPTION } from '../../plugin-entry';
+
 export const PACKAGE_NAME = 'oh-my-opencode-slim';
 export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
 export const NPM_PACKAGE_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;

+ 19 - 3
src/hooks/auto-update-checker/index.test.ts

@@ -14,11 +14,15 @@ const checkerMocks = {
   getLatestVersion: mock(async () => null),
   getLocalDevVersion: mock(() => null),
   getCurrentRuntimePackageJsonPath: mock(() => null),
+  updateInstallerManagedVersions: mock(() => true),
 };
 
 const cacheMocks = {
   preparePackageUpdate: mock(() => '/tmp/opencode'),
+  discardPreparedPackageUpdate: mock(() => {}),
+  publishPackageUpdate: mock(() => '/tmp/opencode'),
   resolveInstallContext: mock(() => ({ installDir: '/tmp/opencode' })),
+  verifyInstalledPackage: mock(() => true),
 };
 
 const skillSyncMocks = {
@@ -122,13 +126,23 @@ describe('auto-update-checker/index', () => {
     checkerMocks.getLatestVersion.mockImplementation(async () => null);
     checkerMocks.getLocalDevVersion.mockReset();
     checkerMocks.getLocalDevVersion.mockImplementation(() => null);
+    checkerMocks.updateInstallerManagedVersions.mockReset();
+    checkerMocks.updateInstallerManagedVersions.mockImplementation(() => true);
     checkerMocks.getCurrentRuntimePackageJsonPath.mockReset();
     checkerMocks.getCurrentRuntimePackageJsonPath.mockImplementation(
       () => null,
     );
 
     cacheMocks.preparePackageUpdate.mockReset();
-    cacheMocks.preparePackageUpdate.mockImplementation(() => '/tmp/opencode');
+    cacheMocks.preparePackageUpdate.mockImplementation(() => ({
+      stagingDir: '/tmp/opencode-staging',
+      targetDir: '/tmp/opencode',
+    }));
+    cacheMocks.publishPackageUpdate.mockReset();
+    cacheMocks.publishPackageUpdate.mockImplementation(() => '/tmp/opencode');
+    cacheMocks.verifyInstalledPackage.mockReset();
+    cacheMocks.verifyInstalledPackage.mockImplementation(() => true);
+    cacheMocks.discardPreparedPackageUpdate.mockReset();
     cacheMocks.resolveInstallContext.mockReset();
     cacheMocks.resolveInstallContext.mockImplementation(() => ({
       installDir: '/tmp/opencode',
@@ -231,10 +245,12 @@ describe('auto-update-checker/index', () => {
     expect(cacheMocks.preparePackageUpdate).toHaveBeenCalledWith(
       '0.9.11',
       'oh-my-opencode-slim',
+      undefined,
+      'latest',
     );
     expect(crossSpawnMock).toHaveBeenCalledWith(
       ['bun', 'install'],
-      expect.objectContaining({ cwd: '/tmp/opencode' }),
+      expect.objectContaining({ cwd: '/tmp/opencode-staging' }),
     );
     expect(skillSyncMocks.syncBundledSkillsFromPackage).toHaveBeenCalledWith(
       '/tmp/opencode/node_modules/oh-my-opencode-slim',
@@ -775,7 +791,7 @@ describe('auto-update-checker/index', () => {
 
     expect(crossSpawnMock).toHaveBeenCalledWith(
       ['bun', 'install'],
-      expect.objectContaining({ cwd: '/tmp/opencode' }),
+      expect.objectContaining({ cwd: '/tmp/opencode-staging' }),
     );
     expect(skillSyncMocks.syncBundledSkillsFromPackage).not.toHaveBeenCalled();
     expect(showToast).toHaveBeenCalledWith({

+ 40 - 6
src/hooks/auto-update-checker/index.ts

@@ -6,7 +6,13 @@ import {
 } from '../../companion/updater';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
-import { preparePackageUpdate, resolveInstallContext } from './cache';
+import {
+  discardPreparedPackageUpdate,
+  preparePackageUpdate,
+  publishPackageUpdate,
+  resolveInstallContext,
+  verifyInstalledPackage,
+} from './cache';
 import {
   extractChannel,
   findPluginEntry,
@@ -14,6 +20,7 @@ import {
   getCurrentRuntimePackageJsonPath,
   getLatestCompatibleVersion,
   getLocalDevVersion,
+  updateInstallerManagedVersions,
 } from './checker';
 import { CACHE_DIR, PACKAGE_NAME } from './constants';
 import { syncBundledSkillsFromPackage } from './skill-sync';
@@ -209,8 +216,16 @@ async function runBackgroundUpdateCheck(
     return;
   }
 
-  const installDir = preparePackageUpdate(latestVersion, PACKAGE_NAME);
-  if (!installDir) {
+  const cacheIdentity = pluginInfo.isInstallerManaged
+    ? latestVersion
+    : 'latest';
+  const prepared = preparePackageUpdate(
+    latestVersion,
+    PACKAGE_NAME,
+    undefined,
+    cacheIdentity,
+  );
+  if (!prepared) {
     showToast(
       ctx,
       `OMO-Slim ${latestVersion}`,
@@ -223,9 +238,28 @@ async function runBackgroundUpdateCheck(
     return;
   }
 
-  const installSuccess = await runBunInstallSafe(installDir);
-
-  if (installSuccess) {
+  const installSuccess =
+    (await runBunInstallSafe(prepared.stagingDir)) &&
+    verifyInstalledPackage(prepared.stagingDir, latestVersion);
+  const installDir = installSuccess
+    ? publishPackageUpdate(prepared, latestVersion)
+    : null;
+  if (!installSuccess) discardPreparedPackageUpdate(prepared);
+
+  if (installDir) {
+    if (
+      pluginInfo.isInstallerManaged &&
+      !updateInstallerManagedVersions(ctx.directory, latestVersion)
+    ) {
+      showToast(
+        ctx,
+        `OMO-Slim ${latestVersion}`,
+        'Update installed in cache, but plugin configuration could not be updated.',
+        'error',
+        8000,
+      );
+      return;
+    }
     let installedSkills: string[] = [];
     let companionUpdated = false;
     let companionWillRetry = false;

+ 1 - 0
src/hooks/auto-update-checker/types.ts

@@ -36,6 +36,7 @@ export interface AutoUpdateCheckerOptions {
 export interface PluginEntryInfo {
   entry: string;
   isPinned: boolean;
+  isInstallerManaged: boolean;
   pinnedVersion: string | null;
   configPath: string;
 }

+ 6 - 0
src/plugin-entry.ts

@@ -0,0 +1,6 @@
+export const INSTALLER_MANAGED_PLUGIN_OPTION =
+  '__ohMyOpencodeSlimManagedByInstaller';
+
+export type PluginEntry =
+  | string
+  | [string, Record<string, unknown>, ...unknown[]];