226 lines
6 KiB
JavaScript
226 lines
6 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
export async function preflightOpenCodeLiveEnvironment(input) {
|
|
const repoRoot = input.repoRoot;
|
|
const opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode';
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-live-preflight-'));
|
|
const xdgDataHome = path.join(tempRoot, 'xdg-data');
|
|
const env = {
|
|
...process.env,
|
|
XDG_DATA_HOME: xdgDataHome,
|
|
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
|
};
|
|
|
|
try {
|
|
if (!fs.existsSync(opencodeBin)) {
|
|
return skip(`OpenCode binary not found at ${opencodeBin}`);
|
|
}
|
|
|
|
const models = runOpenCodeCommand(opencodeBin, ['models'], repoRoot, env);
|
|
if (!models.ok) {
|
|
return skip(`opencode models failed: ${models.output}`);
|
|
}
|
|
|
|
const agents = runOpenCodeCommand(opencodeBin, ['agent', 'list'], repoRoot, env);
|
|
if (!agents.ok) {
|
|
return skip(`opencode agent list failed: ${agents.output}`);
|
|
}
|
|
|
|
const loopback = await canBindLoopback();
|
|
if (!loopback.ok) {
|
|
return skip(`127.0.0.1 loopback bind failed: ${loopback.reason}`);
|
|
}
|
|
|
|
const host = await canStartOpenCodeHost(opencodeBin, repoRoot, env);
|
|
if (!host.ok) {
|
|
return skip(`opencode serve health check failed: ${host.reason}`);
|
|
}
|
|
|
|
return { ok: true };
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export function exitForSkippedPreflight(result) {
|
|
if (result.ok) {
|
|
return false;
|
|
}
|
|
console.warn(`SKIPPED: ${result.reason}`);
|
|
process.exit(process.env.OPENCODE_E2E_STRICT === '1' ? 1 : 0);
|
|
}
|
|
|
|
function runOpenCodeCommand(opencodeBin, args, cwd, env) {
|
|
const result = spawnSync(opencodeBin, args, {
|
|
cwd,
|
|
env,
|
|
encoding: 'utf8',
|
|
timeout: 20_000,
|
|
maxBuffer: 256_000,
|
|
});
|
|
if (result.status === 0) {
|
|
return { ok: true, output: '' };
|
|
}
|
|
return {
|
|
ok: false,
|
|
output: compactOutput(result.stderr || result.stdout || result.error?.message || 'unknown'),
|
|
};
|
|
}
|
|
|
|
function canBindLoopback() {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
const timeout = setTimeout(() => {
|
|
server.close(() => undefined);
|
|
resolve({ ok: false, reason: 'timed out allocating loopback port' });
|
|
}, 5_000);
|
|
server.once('error', (error) => {
|
|
clearTimeout(timeout);
|
|
resolve({ ok: false, reason: error.message });
|
|
});
|
|
server.listen(0, '127.0.0.1', () => {
|
|
clearTimeout(timeout);
|
|
server.close((error) => {
|
|
resolve(error ? { ok: false, reason: error.message } : { ok: true });
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function canStartOpenCodeHost(opencodeBin, cwd, env) {
|
|
const port = await allocateLoopbackPort();
|
|
const child = spawn(opencodeBin, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], {
|
|
cwd,
|
|
env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
let output = '';
|
|
let spawnError = '';
|
|
const append = (chunk) => {
|
|
output = compactOutput(`${output}\n${chunk.toString('utf8')}`);
|
|
};
|
|
child.stdout?.on('data', append);
|
|
child.stderr?.on('data', append);
|
|
child.once('error', (error) => {
|
|
spawnError = error.message;
|
|
append(error.message);
|
|
});
|
|
|
|
try {
|
|
const deadline = Date.now() + 15_000;
|
|
while (Date.now() < deadline) {
|
|
if (spawnError) {
|
|
return { ok: false, reason: spawnError };
|
|
}
|
|
if (child.exitCode != null) {
|
|
return { ok: false, reason: output || `process exited with code ${child.exitCode}` };
|
|
}
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/global/health`);
|
|
if (response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
if (data?.healthy === true) {
|
|
return { ok: true };
|
|
}
|
|
}
|
|
} catch {
|
|
// Host is still starting.
|
|
}
|
|
await sleep(250);
|
|
}
|
|
return { ok: false, reason: output || 'timed out waiting for /global/health' };
|
|
} finally {
|
|
await stopChild(child);
|
|
}
|
|
}
|
|
|
|
async function stopChild(child) {
|
|
if (child.exitCode != null || child.killed) {
|
|
return;
|
|
}
|
|
|
|
if (process.platform === 'win32' && child.pid) {
|
|
await taskkillProcessTree(child.pid);
|
|
return;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
if (child.exitCode == null) {
|
|
child.kill('SIGKILL');
|
|
}
|
|
resolve();
|
|
}, 3_000);
|
|
child.once('close', () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
child.kill('SIGTERM');
|
|
});
|
|
}
|
|
|
|
function taskkillProcessTree(pid) {
|
|
return new Promise((resolve) => {
|
|
let done = false;
|
|
const finish = () => {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
};
|
|
const timeout = setTimeout(finish, 5_000);
|
|
timeout.unref?.();
|
|
try {
|
|
const taskkill = spawn(
|
|
path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'),
|
|
['/T', '/F', '/PID', String(pid)],
|
|
{
|
|
stdio: 'ignore',
|
|
windowsHide: true,
|
|
}
|
|
);
|
|
taskkill.once('error', finish);
|
|
taskkill.once('close', finish);
|
|
} catch {
|
|
finish();
|
|
}
|
|
});
|
|
}
|
|
|
|
function allocateLoopbackPort() {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
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 loopback port')));
|
|
return;
|
|
}
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve(address.port);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function skip(reason) {
|
|
return { ok: false, reason };
|
|
}
|
|
|
|
function compactOutput(value) {
|
|
return value.replace(/\s+/g, ' ').trim().slice(0, 1_200);
|
|
}
|