Browse Source

Merge pull request #515 from omar-mohamed-khallaf/master

feat: version-aware plugin cache directories
Alvin 2 months ago
parent
commit
2f3e7671c3
2 changed files with 315 additions and 5 deletions
  1. 261 0
      src/cli/cache.test.ts
  2. 54 5
      src/cli/config-io.ts

+ 261 - 0
src/cli/cache.test.ts

@@ -40,6 +40,25 @@ mock.module('../utils/compat', () => ({
   crossSpawn: crossSpawnMock,
   crossSpawn: crossSpawnMock,
 }));
 }));
 
 
+const nonexistentPath = '/nonexistent/opencode.json';
+mock.module('./paths', () => ({
+  getConfigDir: () => '/nonexistent',
+  getConfigSearchDirs: () => ['/nonexistent'],
+  getOpenCodeConfigPaths: () => [],
+  getConfigJson: () => nonexistentPath,
+  getConfigJsonc: () => nonexistentPath,
+  getLiteConfig: () => nonexistentPath,
+  getLiteConfigJsonc: () => nonexistentPath,
+  getTuiConfig: () => nonexistentPath,
+  getTuiConfigJsonc: () => nonexistentPath,
+  getExistingLiteConfigPath: () => nonexistentPath,
+  getExistingTuiConfigPath: () => nonexistentPath,
+  getExistingConfigPath: () => nonexistentPath,
+  ensureConfigDir: () => {},
+  ensureTuiConfigDir: () => {},
+  ensureOpenCodeConfigDir: () => {},
+}));
+
 let importCounter = 0;
 let importCounter = 0;
 
 
 function createSpawnResult(exitCode = 0): SpawnResult {
 function createSpawnResult(exitCode = 0): SpawnResult {
@@ -338,6 +357,248 @@ describe('warmOpenCodePluginCache', () => {
 
 
     rmSync(tmpDir, { recursive: true, force: true });
     rmSync(tmpDir, { recursive: true, force: true });
   });
   });
+
+  test('uses pinned version in cache dir and manifest when config has a pinned version', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    // Set up a config file with a pinned version
+    const configDir = join(tmpDir, 'config');
+    mkdirSync(configDir, { recursive: true });
+    const configPath = join(configDir, 'opencode.json');
+    writeFileSync(
+      configPath,
+      JSON.stringify({
+        plugin: ['oh-my-opencode-slim@1.2.3'],
+      }),
+    );
+
+    // Override getExistingConfigPath to return our test config
+    const pathsMod = await import('./paths');
+    const configPathSpy = spyOn(
+      pathsMod,
+      'getExistingConfigPath',
+    ).mockReturnValue(configPath);
+
+    try {
+      const packageRoot = join(
+        tmpDir,
+        'bunx-1000-oh-my-opencode-slim@latest',
+        'node_modules',
+        'oh-my-opencode-slim',
+      );
+      mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+      writeFileSync(
+        join(packageRoot, 'package.json'),
+        JSON.stringify({ name: 'oh-my-opencode-slim' }),
+      );
+      process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+      const { warmOpenCodePluginCache } = await importFreshConfigIo();
+      const result = await warmOpenCodePluginCache();
+
+      const expectedCacheDir = join(
+        cacheHome,
+        'opencode',
+        'packages',
+        'oh-my-opencode-slim@1.2.3',
+      );
+
+      expect(result?.success).toBe(true);
+      expect(result?.configPath).toBe(expectedCacheDir);
+      expect(
+        JSON.parse(
+          readFileSync(join(expectedCacheDir, 'package.json'), 'utf-8'),
+        ),
+      ).toEqual({
+        name: 'oh-my-opencode-slim-cache',
+        private: true,
+        dependencies: {
+          'oh-my-opencode-slim': '1.2.3',
+        },
+      });
+    } finally {
+      configPathSpy.mockRestore();
+      rmSync(tmpDir, { recursive: true, force: true });
+    }
+  });
+
+  test('uses running version from package.json when config is unpinned (bunx @beta scenario)', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    // Simulate bunx @beta: package.json has a beta version, config has no pinned version
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@beta',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+    writeFileSync(
+      join(packageRoot, 'package.json'),
+      JSON.stringify({ name: 'oh-my-opencode-slim', version: '2.0.0-beta.13' }),
+    );
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    // Config mock returns no pinned version (nonexistent config path)
+    const { warmOpenCodePluginCache } = await importFreshConfigIo();
+    const result = await warmOpenCodePluginCache();
+
+    const expectedCacheDir = join(
+      cacheHome,
+      'opencode',
+      'packages',
+      'oh-my-opencode-slim@2.0.0-beta.13',
+    );
+
+    expect(result?.success).toBe(true);
+    expect(result?.configPath).toBe(expectedCacheDir);
+    expect(
+      JSON.parse(readFileSync(join(expectedCacheDir, 'package.json'), 'utf-8')),
+    ).toEqual({
+      name: 'oh-my-opencode-slim-cache',
+      private: true,
+      dependencies: {
+        'oh-my-opencode-slim': '2.0.0-beta.13',
+      },
+    });
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  test('uses pinned version from array-format plugin entry', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    // Config uses array tuple format: [spec, options]
+    const configDir = join(tmpDir, 'config');
+    mkdirSync(configDir, { recursive: true });
+    const configPath = join(configDir, 'opencode.json');
+    writeFileSync(
+      configPath,
+      JSON.stringify({
+        plugin: [['oh-my-opencode-slim@1.2.3', { someOption: true }]],
+      }),
+    );
+
+    const pathsMod = await import('./paths');
+    const configPathSpy = spyOn(
+      pathsMod,
+      'getExistingConfigPath',
+    ).mockReturnValue(configPath);
+
+    try {
+      const packageRoot = join(
+        tmpDir,
+        'bunx-1000-oh-my-opencode-slim@latest',
+        'node_modules',
+        'oh-my-opencode-slim',
+      );
+      mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+      writeFileSync(
+        join(packageRoot, 'package.json'),
+        JSON.stringify({ name: 'oh-my-opencode-slim' }),
+      );
+      process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+      const { warmOpenCodePluginCache } = await importFreshConfigIo();
+      const result = await warmOpenCodePluginCache();
+
+      const expectedCacheDir = join(
+        cacheHome,
+        'opencode',
+        'packages',
+        'oh-my-opencode-slim@1.2.3',
+      );
+
+      expect(result?.success).toBe(true);
+      expect(result?.configPath).toBe(expectedCacheDir);
+      expect(
+        JSON.parse(
+          readFileSync(join(expectedCacheDir, 'package.json'), 'utf-8'),
+        ),
+      ).toEqual({
+        name: 'oh-my-opencode-slim-cache',
+        private: true,
+        dependencies: {
+          'oh-my-opencode-slim': '1.2.3',
+        },
+      });
+    } finally {
+      configPathSpy.mockRestore();
+      rmSync(tmpDir, { recursive: true, force: true });
+    }
+  });
+
+  test('pinned config version takes precedence over running version', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    // Running version is beta
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@beta',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+    writeFileSync(
+      join(packageRoot, 'package.json'),
+      JSON.stringify({ name: 'oh-my-opencode-slim', version: '2.0.0-beta.13' }),
+    );
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    // Config has a pinned stable version
+    const configDir = join(tmpDir, 'config');
+    mkdirSync(configDir, { recursive: true });
+    const configPath = join(configDir, 'opencode.json');
+    writeFileSync(
+      configPath,
+      JSON.stringify({
+        plugin: ['oh-my-opencode-slim@1.2.3'],
+      }),
+    );
+
+    const pathsMod = await import('./paths');
+    const configPathSpy = spyOn(
+      pathsMod,
+      'getExistingConfigPath',
+    ).mockReturnValue(configPath);
+
+    try {
+      const { warmOpenCodePluginCache } = await importFreshConfigIo();
+      const result = await warmOpenCodePluginCache();
+
+      const expectedCacheDir = join(
+        cacheHome,
+        'opencode',
+        'packages',
+        'oh-my-opencode-slim@1.2.3',
+      );
+
+      expect(result?.success).toBe(true);
+      expect(result?.configPath).toBe(expectedCacheDir);
+      expect(
+        JSON.parse(
+          readFileSync(join(expectedCacheDir, 'package.json'), 'utf-8'),
+        ),
+      ).toEqual({
+        name: 'oh-my-opencode-slim-cache',
+        private: true,
+        dependencies: {
+          'oh-my-opencode-slim': '1.2.3',
+        },
+      });
+    } finally {
+      configPathSpy.mockRestore();
+      rmSync(tmpDir, { recursive: true, force: true });
+    }
+  });
 });
 });
 
 
 function mkdirTemp(): string {
 function mkdirTemp(): string {

+ 54 - 5
src/cli/config-io.ts

@@ -141,14 +141,57 @@ function getPluginEntry(): string {
   }
   }
 }
 }
 
 
-function getOpenCodePluginCacheDir(): string {
+/**
+ * Reads the OpenCode config to find the pinned version for this plugin.
+ * 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 {
+  try {
+    const { config } = parseConfig(getExistingConfigPath());
+    if (!config) return 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;
+      }
+    }
+  } catch {}
+  return undefined;
+}
+
+/**
+ * Reads the version from the package.json at the given package root.
+ * Used as a fallback when the config entry is unpinned (e.g. bunx @beta install).
+ */
+function getVersionFromPackageRoot(packageRoot: string): string | undefined {
+  try {
+    const packageJsonPath = join(packageRoot, 'package.json');
+    if (!existsSync(packageJsonPath)) return undefined;
+    const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as {
+      version?: string;
+    };
+    return pkg.version;
+  } catch {
+    return undefined;
+  }
+}
+
+function getOpenCodePluginCacheDir(version?: string): string {
   const cacheDir =
   const cacheDir =
     process.env.XDG_CACHE_HOME?.trim() || join(homedir(), '.cache');
     process.env.XDG_CACHE_HOME?.trim() || join(homedir(), '.cache');
-  return join(cacheDir, 'opencode', 'packages', `${PACKAGE_NAME}@latest`);
+  const suffix = version
+    ? `${PACKAGE_NAME}@${version}`
+    : `${PACKAGE_NAME}@latest`;
+  return join(cacheDir, 'opencode', 'packages', suffix);
 }
 }
 
 
 function writeOpenCodePluginCacheManifest(
 function writeOpenCodePluginCacheManifest(
   cacheDir: string,
   cacheDir: string,
+  version: string = 'latest',
 ): ConfigMergeResult | null {
 ): ConfigMergeResult | null {
   try {
   try {
     writeFileSync(
     writeFileSync(
@@ -158,7 +201,7 @@ function writeOpenCodePluginCacheManifest(
           name: `${PACKAGE_NAME}-cache`,
           name: `${PACKAGE_NAME}-cache`,
           private: true,
           private: true,
           dependencies: {
           dependencies: {
-            [PACKAGE_NAME]: 'latest',
+            [PACKAGE_NAME]: version,
           },
           },
         },
         },
         null,
         null,
@@ -227,7 +270,10 @@ export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | nul
     return null;
     return null;
   }
   }
 
 
-  const cacheDir = getOpenCodePluginCacheDir();
+  const pinnedVersion = getPinnedVersionFromConfig();
+  const runningVersion = getVersionFromPackageRoot(packageRoot);
+  const cacheVersion = pinnedVersion ?? runningVersion;
+  const cacheDir = getOpenCodePluginCacheDir(cacheVersion);
 
 
   try {
   try {
     mkdirSync(cacheDir, { recursive: true });
     mkdirSync(cacheDir, { recursive: true });
@@ -239,7 +285,10 @@ export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | nul
     };
     };
   }
   }
 
 
-  const manifestError = writeOpenCodePluginCacheManifest(cacheDir);
+  const manifestError = writeOpenCodePluginCacheManifest(
+    cacheDir,
+    cacheVersion,
+  );
   if (manifestError) return manifestError;
   if (manifestError) return manifestError;
 
 
   try {
   try {