Эх сурвалжийг харах

fix bunx plugin cache warm-up

jelasin 3 сар өмнө
parent
commit
e9eaeb3fe8

+ 4 - 2
README.md

@@ -39,8 +39,10 @@ bunx oh-my-opencode-slim@latest install
 
 The installer also registers the companion TUI plugin in OpenCode's
 `tui.json`, which adds a small sidebar showing specialist-agent status plus
-active/reusable task sessions. For manual setups, add `oh-my-opencode-slim` to
-the `plugin` array in both `opencode.json` and `tui.json`.
+active/reusable task sessions. It also warms OpenCode's plugin cache so bunx
+installs keep loading even after temporary directories are cleaned up. For
+manual setups, add `oh-my-opencode-slim` to the `plugin` array in both
+`opencode.json` and `tui.json`.
 
 ### Getting Started
 

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

@@ -0,0 +1,137 @@
+/// <reference types="bun-types" />
+
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import {
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+type SpawnResult = {
+  exited: Promise<number>;
+  stdout: () => Promise<string>;
+  stderr: () => Promise<string>;
+  kill: () => boolean;
+  exitCode: number | null;
+  proc: never;
+};
+
+const crossSpawnMock = mock((_command: string[]) => createSpawnResult());
+
+mock.module('../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+}));
+
+let importCounter = 0;
+
+function createSpawnResult(exitCode = 0): SpawnResult {
+  return {
+    exited: Promise.resolve(exitCode),
+    stdout: () => Promise.resolve(''),
+    stderr: () => Promise.resolve(''),
+    kill: () => true,
+    exitCode,
+    proc: {} as never,
+  };
+}
+
+async function importFreshConfigIo() {
+  return import(`./config-io?test=${importCounter++}`);
+}
+
+describe('warmOpenCodePluginCache', () => {
+  const originalArgv = [...process.argv];
+  const originalXdgCacheHome = process.env.XDG_CACHE_HOME;
+
+  beforeEach(() => {
+    crossSpawnMock.mockReset();
+    crossSpawnMock.mockImplementation((_command: string[]) =>
+      createSpawnResult(),
+    );
+    delete process.env.XDG_CACHE_HOME;
+  });
+
+  afterEach(() => {
+    process.argv = [...originalArgv];
+    if (originalXdgCacheHome === undefined) {
+      delete process.env.XDG_CACHE_HOME;
+    } else {
+      process.env.XDG_CACHE_HOME = originalXdgCacheHome;
+    }
+  });
+
+  test('prewarms the OpenCode cache for bunx installs', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    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@latest',
+    );
+
+    expect(result?.success).toBe(true);
+    expect(result?.configPath).toBe(expectedCacheDir);
+    expect(crossSpawnMock).toHaveBeenCalledTimes(1);
+    expect(crossSpawnMock.mock.calls[0][0]).toEqual(['bun', 'install']);
+    expect(crossSpawnMock.mock.calls[0][1]).toEqual(
+      expect.objectContaining({ cwd: 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': 'latest',
+      },
+    });
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  test('skips cache warm-up for local repo installs', async () => {
+    const tmpDir = mkdirTemp();
+    const packageRoot = join(tmpDir, 'repo');
+    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();
+
+    expect(result).toBeNull();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+});
+
+function mkdirTemp(): string {
+  return mkdtempSync(join(tmpdir(), 'opencode-cache-test-'));
+}

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

@@ -1,12 +1,15 @@
 import {
   copyFileSync,
   existsSync,
+  mkdirSync,
   readFileSync,
   renameSync,
   statSync,
   writeFileSync,
 } from 'node:fs';
+import { homedir } from 'node:os';
 import { dirname, join } from 'node:path';
+import { crossSpawn } from '../utils/compat';
 import {
   ensureConfigDir,
   ensureOpenCodeConfigDir,
@@ -79,11 +82,6 @@ function findPackageRoot(startPath: string): string | null {
   }
 }
 
-function isPackageManagerInstall(path: string): boolean {
-  const normalizedPath = normalizePathForMatch(path);
-  return normalizedPath.includes(`/node_modules/${PACKAGE_NAME}`);
-}
-
 function isLocalPackageRootEntry(entry: string): boolean {
   if (!entry || entry.startsWith('file://')) {
     return false;
@@ -104,6 +102,11 @@ function isLocalPackageRootEntry(entry: string): boolean {
   }
 }
 
+function isPackageManagerInstall(path: string): boolean {
+  const normalizedPath = normalizePathForMatch(path);
+  return normalizedPath.includes(`/node_modules/${PACKAGE_NAME}`);
+}
+
 function isPluginEntry(entry: string): boolean {
   return (
     entry === PACKAGE_NAME ||
@@ -138,6 +141,80 @@ function getPluginEntry(): string {
   }
 }
 
+function getOpenCodePluginCacheDir(): string {
+  const cacheDir =
+    process.env.XDG_CACHE_HOME?.trim() || join(homedir(), '.cache');
+  return join(cacheDir, 'opencode', 'packages', `${PACKAGE_NAME}@latest`);
+}
+
+export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | null> {
+  const cliEntryPath = process.argv[1];
+  if (!cliEntryPath) {
+    return null;
+  }
+
+  const packageRoot = findPackageRoot(cliEntryPath);
+  if (!packageRoot || !isPackageManagerInstall(packageRoot)) {
+    return null;
+  }
+
+  const cacheDir = getOpenCodePluginCacheDir();
+
+  try {
+    mkdirSync(cacheDir, { recursive: true });
+  } catch (err) {
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: `Failed to create OpenCode cache directory: ${err}`,
+    };
+  }
+
+  const packageJsonPath = join(cacheDir, 'package.json');
+  if (!existsSync(packageJsonPath)) {
+    writeFileSync(
+      packageJsonPath,
+      JSON.stringify(
+        {
+          name: `${PACKAGE_NAME}-cache`,
+          private: true,
+          dependencies: {
+            [PACKAGE_NAME]: 'latest',
+          },
+        },
+        null,
+        2,
+      ),
+    );
+  }
+
+  try {
+    const proc = crossSpawn(['bun', 'install'], {
+      cwd: cacheDir,
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    await proc.exited;
+
+    if (proc.exitCode === 0) {
+      return { success: true, configPath: cacheDir };
+    }
+
+    const stderr = (await proc.stderr()).trim();
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: stderr || `bun install exited with code ${proc.exitCode}`,
+    };
+  } catch (err) {
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: `Failed to warm OpenCode cache: ${err}`,
+    };
+  }
+}
+
 /**
  * Strip JSON comments (single-line // and multi-line) and trailing commas for JSONC support.
  */

+ 16 - 0
src/cli/install.ts

@@ -10,6 +10,7 @@ import {
   getOpenCodePath,
   getOpenCodeVersion,
   isOpenCodeInstalled,
+  warmOpenCodePluginCache,
   writeLiteConfig,
 } from './config-manager';
 import { CUSTOM_SKILLS, installCustomSkill } from './custom-skills';
@@ -153,6 +154,7 @@ async function runInstall(config: InstallConfig): Promise<number> {
   let totalSteps = 6;
   if (config.installSkills) totalSteps += 1;
   if (config.installCustomSkills) totalSteps += 1;
+  totalSteps += 1;
 
   let step = 1;
 
@@ -183,6 +185,20 @@ async function runInstall(config: InstallConfig): Promise<number> {
     }
   }
 
+  printStep(step++, totalSteps, 'Warming OpenCode plugin cache...');
+  if (config.dryRun) {
+    printInfo('Dry run mode - skipping cache warm-up');
+  } else {
+    const cacheResult = await warmOpenCodePluginCache();
+    if (cacheResult === null) {
+      printInfo('Local development install - cache warm-up not required');
+    } else if (!cacheResult.success) {
+      printInfo(`Skipped cache warm-up: ${cacheResult.error}`);
+    } else {
+      handleStepResult(cacheResult, 'OpenCode cache warmed');
+    }
+  }
+
   printStep(step++, totalSteps, 'Disabling OpenCode default agents...');
   if (config.dryRun) {
     printInfo('Dry run mode - skipping agent disabling');