瀏覽代碼

fix: harden plugin cache warm-up

alvinreal 3 月之前
父節點
當前提交
f0c7b0b259
共有 2 個文件被更改,包括 245 次插入38 次删除
  1. 162 4
      src/cli/cache.test.ts
  2. 83 34
      src/cli/config-io.ts

+ 162 - 4
src/cli/cache.test.ts

@@ -28,7 +28,13 @@ type SpawnResult = {
   proc: never;
 };
 
-const crossSpawnMock = mock((_command: string[]) => createSpawnResult());
+type SpawnOptions = {
+  cwd?: string;
+};
+
+const crossSpawnMock = mock((_command: string[], _options?: SpawnOptions) =>
+  createSpawnResult(),
+);
 
 mock.module('../utils/compat', () => ({
   crossSpawn: crossSpawnMock,
@@ -57,8 +63,11 @@ describe('warmOpenCodePluginCache', () => {
 
   beforeEach(() => {
     crossSpawnMock.mockReset();
-    crossSpawnMock.mockImplementation((_command: string[]) =>
-      createSpawnResult(),
+    crossSpawnMock.mockImplementation(
+      (_command: string[], options?: SpawnOptions) => {
+        writeCachedPluginPackage(options?.cwd);
+        return createSpawnResult();
+      },
     );
     delete process.env.XDG_CACHE_HOME;
   });
@@ -103,7 +112,11 @@ describe('warmOpenCodePluginCache', () => {
     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][0]).toEqual([
+      'bun',
+      'install',
+      '--ignore-scripts',
+    ]);
     expect(crossSpawnMock.mock.calls[0][1]).toEqual(
       expect.objectContaining({ cwd: expectedCacheDir }),
     );
@@ -120,6 +133,140 @@ describe('warmOpenCodePluginCache', () => {
     rmSync(tmpDir, { recursive: true, force: true });
   });
 
+  test('repairs a stale OpenCode cache manifest', 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 expectedCacheDir = join(
+      cacheHome,
+      'opencode',
+      'packages',
+      'oh-my-opencode-slim@latest',
+    );
+    mkdirSync(expectedCacheDir, { recursive: true });
+    writeFileSync(
+      join(expectedCacheDir, 'package.json'),
+      JSON.stringify({
+        name: 'stale-cache',
+        scripts: { postinstall: 'should-not-run' },
+        dependencies: { other: '1.0.0' },
+      }),
+    );
+
+    const { warmOpenCodePluginCache } = await importFreshConfigIo();
+    const result = await warmOpenCodePluginCache();
+
+    expect(result?.success).toBe(true);
+    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('fails when bun install does not create the cached plugin package', 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');
+    crossSpawnMock.mockImplementation(() => createSpawnResult());
+
+    const { warmOpenCodePluginCache } = await importFreshConfigIo();
+    const result = await warmOpenCodePluginCache();
+
+    expect(result).toEqual({
+      success: false,
+      configPath: join(
+        cacheHome,
+        'opencode',
+        'packages',
+        'oh-my-opencode-slim@latest',
+      ),
+      error: `Cached plugin package not found at ${join(
+        cacheHome,
+        'opencode',
+        'packages',
+        'oh-my-opencode-slim@latest',
+        'node_modules',
+        'oh-my-opencode-slim',
+        'package.json',
+      )}`,
+    });
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  test('returns a failed result when bun install fails', 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');
+    crossSpawnMock.mockImplementation(() => ({
+      ...createSpawnResult(1),
+      stderr: () => Promise.resolve('registry unavailable'),
+    }));
+
+    const { warmOpenCodePluginCache } = await importFreshConfigIo();
+    const result = await warmOpenCodePluginCache();
+
+    expect(result).toEqual({
+      success: false,
+      configPath: join(
+        cacheHome,
+        'opencode',
+        'packages',
+        'oh-my-opencode-slim@latest',
+      ),
+      error: 'registry unavailable',
+    });
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+
   test('returns a failed result when cache package.json cannot be written', async () => {
     const tmpDir = mkdirTemp();
     const cacheHome = join(tmpDir, 'cache');
@@ -196,3 +343,14 @@ describe('warmOpenCodePluginCache', () => {
 function mkdirTemp(): string {
   return mkdtempSync(join(tmpdir(), 'opencode-cache-test-'));
 }
+
+function writeCachedPluginPackage(cacheDir?: string): void {
+  if (!cacheDir) return;
+
+  const pluginRoot = join(cacheDir, 'node_modules', 'oh-my-opencode-slim');
+  mkdirSync(pluginRoot, { recursive: true });
+  writeFileSync(
+    join(pluginRoot, 'package.json'),
+    JSON.stringify({ name: 'oh-my-opencode-slim' }),
+  );
+}

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

@@ -147,6 +147,75 @@ function getOpenCodePluginCacheDir(): string {
   return join(cacheDir, 'opencode', 'packages', `${PACKAGE_NAME}@latest`);
 }
 
+function writeOpenCodePluginCacheManifest(
+  cacheDir: string,
+): ConfigMergeResult | null {
+  try {
+    writeFileSync(
+      join(cacheDir, 'package.json'),
+      JSON.stringify(
+        {
+          name: `${PACKAGE_NAME}-cache`,
+          private: true,
+          dependencies: {
+            [PACKAGE_NAME]: 'latest',
+          },
+        },
+        null,
+        2,
+      ),
+    );
+    return null;
+  } catch (err) {
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: `Failed to write cache package.json: ${err}`,
+    };
+  }
+}
+
+function verifyOpenCodePluginCache(cacheDir: string): ConfigMergeResult | null {
+  const pluginPackageJsonPath = join(
+    cacheDir,
+    'node_modules',
+    PACKAGE_NAME,
+    'package.json',
+  );
+
+  if (!existsSync(pluginPackageJsonPath)) {
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: `Cached plugin package not found at ${pluginPackageJsonPath}`,
+    };
+  }
+
+  try {
+    const packageJson = JSON.parse(
+      readFileSync(pluginPackageJsonPath, 'utf-8'),
+    ) as {
+      name?: string;
+    };
+
+    if (packageJson.name !== PACKAGE_NAME) {
+      return {
+        success: false,
+        configPath: cacheDir,
+        error: `Cached plugin package has unexpected name: ${packageJson.name}`,
+      };
+    }
+  } catch (err) {
+    return {
+      success: false,
+      configPath: cacheDir,
+      error: `Failed to verify cached plugin package: ${err}`,
+    };
+  }
+
+  return null;
+}
+
 export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | null> {
   const cliEntryPath = process.argv[1];
   if (!cliEntryPath) {
@@ -170,50 +239,30 @@ export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | nul
     };
   }
 
-  const packageJsonPath = join(cacheDir, 'package.json');
-  if (!existsSync(packageJsonPath)) {
-    try {
-      writeFileSync(
-        packageJsonPath,
-        JSON.stringify(
-          {
-            name: `${PACKAGE_NAME}-cache`,
-            private: true,
-            dependencies: {
-              [PACKAGE_NAME]: 'latest',
-            },
-          },
-          null,
-          2,
-        ),
-      );
-    } catch (err) {
-      return {
-        success: false,
-        configPath: cacheDir,
-        error: `Failed to write cache package.json: ${err}`,
-      };
-    }
-  }
+  const manifestError = writeOpenCodePluginCacheManifest(cacheDir);
+  if (manifestError) return manifestError;
 
   try {
-    const proc = crossSpawn(['bun', 'install'], {
+    const proc = crossSpawn(['bun', 'install', '--ignore-scripts'], {
       cwd: cacheDir,
       stdout: 'pipe',
       stderr: 'pipe',
     });
     await proc.exited;
 
-    if (proc.exitCode === 0) {
-      return { success: true, configPath: cacheDir };
+    if (proc.exitCode !== 0) {
+      const stderr = (await proc.stderr()).trim();
+      return {
+        success: false,
+        configPath: cacheDir,
+        error: stderr || `bun install exited with code ${proc.exitCode}`,
+      };
     }
 
-    const stderr = (await proc.stderr()).trim();
-    return {
-      success: false,
-      configPath: cacheDir,
-      error: stderr || `bun install exited with code ${proc.exitCode}`,
-    };
+    const verificationError = verifyOpenCodePluginCache(cacheDir);
+    if (verificationError) return verificationError;
+
+    return { success: true, configPath: cacheDir };
   } catch (err) {
     return {
       success: false,