203 lines
6.7 KiB
JavaScript
203 lines
6.7 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn } from 'node:child_process';
|
|
import { existsSync } from 'node:fs';
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createServer } from 'node:net';
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const enabled = process.env.NIANCODE_OPENCODE_REAL_SMOKE === '1'
|
|
|| process.env.OPENCODE_REAL_SMOKE === '1';
|
|
|
|
function skip(reason) {
|
|
console.log(`[opencode-real-smoke] skipped: ${reason}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!enabled) {
|
|
skip('set NIANCODE_OPENCODE_REAL_SMOKE=1 to launch a real opencode runtime');
|
|
}
|
|
|
|
function resolveOpencodeBin() {
|
|
const explicit = process.env.NIANCODE_OPENCODE_BIN || process.env.OPENCODE_BIN;
|
|
if (explicit) return explicit;
|
|
|
|
const binName = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
|
|
const localBin = join(repoRoot, 'node_modules', '.bin', binName);
|
|
if (existsSync(localBin)) return localBin;
|
|
return binName;
|
|
}
|
|
|
|
async function resolveConfigContent() {
|
|
if (process.env.OPENCODE_CONFIG_CONTENT) return process.env.OPENCODE_CONFIG_CONTENT;
|
|
if (process.env.OPENCODE_REAL_SMOKE_CONFIG) return process.env.OPENCODE_REAL_SMOKE_CONFIG;
|
|
if (process.env.OPENCODE_REAL_SMOKE_CONFIG_FILE) {
|
|
return await readFile(resolve(process.env.OPENCODE_REAL_SMOKE_CONFIG_FILE), 'utf8');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getFreePort() {
|
|
return await new Promise((resolvePort, reject) => {
|
|
const server = createServer();
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const address = server.address();
|
|
const port = typeof address === 'object' && address ? address.port : null;
|
|
server.close(() => {
|
|
if (typeof port === 'number') {
|
|
resolvePort(port);
|
|
return;
|
|
}
|
|
reject(new Error('Unable to reserve a local smoke-test port'));
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function waitForListening(proc, timeoutMs) {
|
|
return awaitableProcessOutput(proc, timeoutMs, (text) => {
|
|
const match = text.match(/opencode server listening\s+on\s+(https?:\/\/[^\s]+)/);
|
|
return match?.[1] ?? null;
|
|
});
|
|
}
|
|
|
|
function awaitableProcessOutput(proc, timeoutMs, resolveFromText) {
|
|
return new Promise((resolveUrl, reject) => {
|
|
let settled = false;
|
|
let stderr = '';
|
|
const finish = (callback) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
callback();
|
|
};
|
|
const timer = setTimeout(() => {
|
|
finish(() => reject(new Error(`Timed out waiting for opencode server after ${timeoutMs}ms${stderr ? `\n${stderr}` : ''}`)));
|
|
}, timeoutMs);
|
|
|
|
proc.stdout?.on('data', (chunk) => {
|
|
const text = chunk.toString();
|
|
process.stdout.write(text);
|
|
const result = resolveFromText(text);
|
|
if (result) finish(() => resolveUrl(result));
|
|
});
|
|
proc.stderr?.on('data', (chunk) => {
|
|
const text = chunk.toString();
|
|
stderr += text;
|
|
process.stderr.write(text);
|
|
});
|
|
proc.on('error', (error) => finish(() => reject(error)));
|
|
proc.on('exit', (code) => {
|
|
finish(() => reject(new Error(`opencode exited before it became ready: ${code ?? 'unknown'}${stderr ? `\n${stderr}` : ''}`)));
|
|
});
|
|
});
|
|
}
|
|
|
|
function sessionIdOf(session) {
|
|
if (!session || typeof session !== 'object') return null;
|
|
const value = session.id ?? session.sessionID ?? session.sessionId;
|
|
return typeof value === 'string' && value.trim() ? value : null;
|
|
}
|
|
|
|
async function requestJson(url, init = {}) {
|
|
const response = await fetch(url, {
|
|
...init,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(init.headers ?? {}),
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(`opencode request failed ${response.status} ${response.statusText}${body ? `\n${body}` : ''}`);
|
|
}
|
|
if (response.status === 204) return null;
|
|
return await response.json();
|
|
}
|
|
|
|
async function createProjectDir() {
|
|
if (process.env.OPENCODE_REAL_SMOKE_PROJECT) {
|
|
return {
|
|
path: resolve(process.env.OPENCODE_REAL_SMOKE_PROJECT),
|
|
cleanup: async () => {},
|
|
};
|
|
}
|
|
|
|
const path = await mkdtemp(join(tmpdir(), 'niancode-opencode-smoke-'));
|
|
await writeFile(join(path, 'README.md'), '# Makelore opencode real-runtime smoke\n');
|
|
return {
|
|
path,
|
|
cleanup: async () => {
|
|
await rm(path, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
const configContent = await resolveConfigContent();
|
|
if (!configContent && process.env.OPENCODE_REAL_SMOKE_ALLOW_LOCAL_CONFIG !== '1') {
|
|
skip('provide OPENCODE_CONFIG_CONTENT, OPENCODE_REAL_SMOKE_CONFIG, or OPENCODE_REAL_SMOKE_CONFIG_FILE');
|
|
}
|
|
|
|
JSON.parse(configContent ?? '{}');
|
|
|
|
const binPath = resolveOpencodeBin();
|
|
const port = Number(process.env.OPENCODE_REAL_SMOKE_PORT) || await getFreePort();
|
|
const startupTimeoutMs = Number(process.env.OPENCODE_REAL_SMOKE_STARTUP_TIMEOUT_MS) || 20_000;
|
|
const apiTimeoutMs = Number(process.env.OPENCODE_REAL_SMOKE_API_TIMEOUT_MS) || 60_000;
|
|
const project = await createProjectDir();
|
|
const proc = spawn(binPath, ['serve', '--hostname=127.0.0.1', `--port=${port}`], {
|
|
env: {
|
|
...process.env,
|
|
...(configContent ? { OPENCODE_CONFIG_CONTENT: configContent } : {}),
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
shell: process.platform === 'win32' && /\.cmd$/i.test(binPath),
|
|
});
|
|
|
|
try {
|
|
const baseUrl = await waitForListening(proc, startupTimeoutMs);
|
|
const directoryQuery = `directory=${encodeURIComponent(project.path)}`;
|
|
const scopedHeaders = { 'x-opencode-directory': encodeURIComponent(project.path) };
|
|
|
|
const sessions = await requestJson(`${baseUrl}/session?${directoryQuery}`, {
|
|
signal: AbortSignal.timeout(apiTimeoutMs),
|
|
headers: scopedHeaders,
|
|
});
|
|
if (!Array.isArray(sessions)) {
|
|
throw new Error('Expected /session to return an array');
|
|
}
|
|
|
|
const session = await requestJson(`${baseUrl}/session?${directoryQuery}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
signal: AbortSignal.timeout(apiTimeoutMs),
|
|
headers: scopedHeaders,
|
|
});
|
|
const sessionId = sessionIdOf(session);
|
|
if (!sessionId) {
|
|
throw new Error('Created opencode session did not include an id');
|
|
}
|
|
|
|
if (process.env.OPENCODE_REAL_SMOKE_PROMPT) {
|
|
await requestJson(`${baseUrl}/session/${encodeURIComponent(sessionId)}/message?${directoryQuery}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
parts: [
|
|
{ type: 'text', text: process.env.OPENCODE_REAL_SMOKE_PROMPT },
|
|
],
|
|
}),
|
|
signal: AbortSignal.timeout(apiTimeoutMs),
|
|
headers: scopedHeaders,
|
|
});
|
|
}
|
|
|
|
console.log(`[opencode-real-smoke] ok: started ${baseUrl}, listed ${sessions.length} session(s), created ${sessionId}`);
|
|
} finally {
|
|
proc.kill();
|
|
await project.cleanup();
|
|
}
|