import { spawn, spawnSync } from 'node:child_process'; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, } from 'node:fs'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); const distEntry = path.join(repoRoot, 'dist', 'index.js'); function fail(message: string): never { throw new Error(message); } function run( command: string, args: string[], options: { cwd?: string; env?: Record } = {}, ) { const result = spawnSync(command, args, { cwd: options.cwd ?? repoRoot, env: { ...process.env, ...options.env, }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); if (result.status !== 0) { const detail = [result.stdout, result.stderr].filter(Boolean).join('\n'); fail( `Command failed: ${command} ${args.join(' ')}${detail ? `\n${detail}` : ''}`, ); } return result.stdout.trim(); } function parsePackJson(output: string) { const start = output.indexOf('['); const end = output.lastIndexOf(']'); if (start === -1 || end === -1 || end < start) { fail(`Could not locate npm pack JSON output:\n${output}`); } return JSON.parse(output.slice(start, end + 1)) as Array<{ filename?: string; }>; } function packArtifact() { const output = run('npm', ['pack', '--json', '--ignore-scripts']); const parsed = parsePackJson(output); const tarball = parsed[0]?.filename; if (!tarball) fail(`npm pack did not return a tarball filename:\n${output}`); return path.join(repoRoot, tarball); } async function getFreePort() { const server = createServer(); return await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { server.close(); reject(new Error('Failed to allocate free port')); return; } const { port } = address; server.close((error) => { if (error) reject(error); else resolve(port); }); }); }); } async function waitForHealth(url: string, timeoutMs: number) { const deadline = Date.now() + timeoutMs; let lastError = 'health check did not succeed'; while (Date.now() < deadline) { try { const response = await fetch(url, { signal: AbortSignal.timeout(2_000), }); if (response.ok) return; lastError = `health check returned ${response.status}`; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } await new Promise((resolve) => setTimeout(resolve, 250)); } fail(`OpenCode server did not become healthy: ${lastError}`); } function formatCapturedLogs(stdout: string, stderr: string): string { const combined = [stdout.trim(), stderr.trim()].filter(Boolean).join('\n'); if (!combined) return 'No stdout/stderr captured.'; const lines = combined.split(/\r?\n/); return lines.slice(-200).join('\n'); } async function stopProcess(child: ReturnType) { if (child.exitCode !== null) return; child.kill('SIGTERM'); const exited = await Promise.race([ new Promise((resolve) => child.once('exit', () => resolve(true))), new Promise((resolve) => setTimeout(() => resolve(false), 5000)), ]); if (!exited && child.exitCode === null) { child.kill('SIGKILL'); await new Promise((resolve) => child.once('exit', resolve)); } } function assertNoPluginLoadErrors(logs: string) { const badPatterns = [ /failed to load plugin/i, /cannot find module/i, /error=.*failed to load plugin/i, ]; const match = badPatterns.find((pattern) => pattern.test(logs)); if (!match) return; const relevantLines = logs .split(/\r?\n/) .filter((line) => /plugin|failed to load|cannot find module|error=/i.test(line), ) .slice(-20) .join('\n'); fail( `OpenCode logs contain plugin load errors:${relevantLines ? `\n${relevantLines}` : ''}`, ); } function omitOpencodeEnv(env: NodeJS.ProcessEnv) { return Object.fromEntries( Object.entries(env).filter(([key]) => !key.startsWith('OPENCODE_')), ); } async function verifyHostSmoke(tarballPath: string) { const tempRoot = mkdtempSync(path.join(tmpdir(), 'omos-opencode-smoke-')); const homeDir = path.join(tempRoot, 'home'); const configDir = path.join(tempRoot, 'config'); const cacheDir = path.join(tempRoot, 'cache'); const dataDir = path.join(tempRoot, 'data'); const hostDir = path.join(tempRoot, 'host'); const workspaceDir = path.join(tempRoot, 'workspace'); const tarballTarget = path.join(tempRoot, path.basename(tarballPath)); const port = await getFreePort(); const healthTimeoutMs = process.platform === 'darwin' ? 60_000 : 30_000; try { console.log('Packing plugin tarball into isolated test root...'); copyFileSync(tarballPath, tarballTarget); for (const dir of [ homeDir, configDir, cacheDir, dataDir, hostDir, workspaceDir, ]) { mkdirSync(dir, { recursive: true }); } const pluginDir = path.join(configDir, 'plugins'); mkdirSync(pluginDir, { recursive: true }); writeFileSync( path.join(hostDir, 'package.json'), JSON.stringify( { name: 'verify-opencode-host-smoke', private: true }, null, 2, ), ); console.log('Installing opencode-ai into isolated test root...'); run('bun', ['add', 'opencode-ai@latest'], { cwd: hostDir }); const opencodeBin = path.join(hostDir, 'node_modules', '.bin', 'opencode'); if (!existsSync(opencodeBin)) { fail(`Expected opencode binary at ${opencodeBin}`); } writeFileSync( path.join(configDir, 'package.json'), JSON.stringify( { type: 'module', dependencies: { 'oh-my-opencode-slim': `file:${tarballTarget}`, }, }, null, 2, ), ); writeFileSync( path.join(pluginDir, 'load-oh-my-opencode-slim.js'), "export { default } from 'oh-my-opencode-slim';\n", ); const config = JSON.stringify({ $schema: 'https://opencode.ai/config.json', autoupdate: false, share: 'disabled', snapshot: false, }); const env = { HOME: homeDir, XDG_CONFIG_HOME: configDir, XDG_CACHE_HOME: cacheDir, XDG_DATA_HOME: dataDir, OPENCODE_TEST_HOME: homeDir, OPENCODE_CONFIG_DIR: configDir, OPENCODE_CONFIG_CONTENT: config, OPENCODE_DISABLE_AUTOUPDATE: 'true', OPENCODE_DISABLE_MODELS_FETCH: 'true', OPENCODE_DISABLE_DEFAULT_PLUGINS: 'true', }; console.log('Starting opencode serve with packaged plugin...'); const child = spawn( opencodeBin, [ 'serve', '--print-logs', '--log-level', 'DEBUG', '--hostname', '127.0.0.1', '--port', String(port), ], { cwd: workspaceDir, env: { ...omitOpencodeEnv(process.env), ...env, }, stdio: ['ignore', 'pipe', 'pipe'], }, ); let stdout = ''; let stderr = ''; child.stdout?.on('data', (chunk) => { stdout += String(chunk); }); child.stderr?.on('data', (chunk) => { stderr += String(chunk); }); const exitPromise = new Promise((_, reject) => { child.once('exit', (code, signal) => { reject( new Error( `opencode serve exited before smoke test completed (code=${code}, signal=${signal})\n${stdout}\n${stderr}`, ), ); }); }); try { await Promise.race([ waitForHealth( `http://127.0.0.1:${port}/global/health`, healthTimeoutMs, ), exitPromise, ]); } catch (error) { const message = error instanceof Error ? error.message : String(error); fail( `${message}\nCaptured OpenCode logs:\n${formatCapturedLogs(stdout, stderr)}`, ); } await new Promise((resolve) => setTimeout(resolve, 1500)); assertNoPluginLoadErrors(`${stdout}\n${stderr}`); await stopProcess(child); } finally { rmSync(tempRoot, { recursive: true, force: true }); } } function cleanupTarball(tarballPath: string) { rmSync(tarballPath, { force: true }); } async function main() { if (!existsSync(distEntry)) { fail( 'dist/index.js is missing. Run `bun run build` before verify:host-smoke.', ); } const tarballPath = packArtifact(); try { await verifyHostSmoke(tarballPath); } finally { cleanupTarball(tarballPath); } console.log('OpenCode host smoke verification passed.'); } await main(); process.exit(0);