源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import net from "node:net";
|
||||
import { realpathSync, statSync } from "node:fs";
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
function resolveBasicAuthHeader() {
|
||||
const password = process.env.OPENCODE_SERVER_PASSWORD?.trim() ?? "";
|
||||
if (!password) return undefined;
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME?.trim() || "opencode";
|
||||
const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
|
||||
return `Basic ${encoded}`;
|
||||
}
|
||||
|
||||
export function makeClient({ baseUrl, directory }) {
|
||||
const authorization = resolveBasicAuthHeader();
|
||||
return createOpencodeClient({
|
||||
baseUrl,
|
||||
directory,
|
||||
headers: authorization ? { Authorization: authorization } : undefined,
|
||||
responseStyle: "data",
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function findFreePort() {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const addr = server.address();
|
||||
|
||||
if (!addr || typeof addr === "string") {
|
||||
server.close();
|
||||
throw new Error("Failed to allocate a free port");
|
||||
}
|
||||
|
||||
const port = addr.port;
|
||||
server.close();
|
||||
return port;
|
||||
}
|
||||
|
||||
export async function spawnOpencodeServe({
|
||||
directory,
|
||||
hostname = "127.0.0.1",
|
||||
port,
|
||||
corsOrigins = [],
|
||||
env = {},
|
||||
}) {
|
||||
assert.ok(directory && directory.trim(), "directory is required");
|
||||
assert.ok(Number.isInteger(port) && port > 0, "port must be a positive integer");
|
||||
|
||||
const cwd = realpathSync(directory);
|
||||
const args = ["serve", "--hostname", hostname, "--port", String(port)];
|
||||
for (const origin of corsOrigins) {
|
||||
args.push("--cors", origin);
|
||||
}
|
||||
|
||||
const child = spawn("opencode", args, {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
// Make it explicit we're a non-TUI client.
|
||||
OPENCODE_CLIENT: "openwork-test",
|
||||
},
|
||||
});
|
||||
|
||||
const baseUrl = `http://${hostname}:${port}`;
|
||||
|
||||
// If the process dies early or never becomes healthy, surface its output so
|
||||
// CI failures are diagnosable instead of showing an empty stderr.
|
||||
let stderr = "";
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
|
||||
async function waitForExit(ms) {
|
||||
return Promise.race([
|
||||
once(child, "exit").then(() => true),
|
||||
new Promise((r) => setTimeout(() => r(false), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
baseUrl,
|
||||
child,
|
||||
async close() {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const exited = await waitForExit(2500);
|
||||
if (exited) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force kill.
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await waitForExit(2500);
|
||||
},
|
||||
getStderr() {
|
||||
return stderr;
|
||||
},
|
||||
getStdout() {
|
||||
return stdout;
|
||||
},
|
||||
getExitInfo() {
|
||||
return { exitCode: child.exitCode, signalCode: child.signalCode };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForHealthy(
|
||||
client,
|
||||
{ timeoutMs = 30_000, pollMs = 250, requestTimeoutMs = 5_000 } = {},
|
||||
) {
|
||||
const start = Date.now();
|
||||
let lastError;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
// Bound each individual request: a single fetch to a port that is not
|
||||
// yet accepting connections can otherwise block for the OS-level connect
|
||||
// timeout (tens of seconds on macOS), blowing past `timeoutMs` because
|
||||
// the loop can only re-check the deadline between awaits.
|
||||
const health = await client.global.health({
|
||||
signal: AbortSignal.timeout(requestTimeoutMs),
|
||||
});
|
||||
assert.equal(health.healthy, true);
|
||||
assert.ok(typeof health.version === "string");
|
||||
return health;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
await new Promise((r) => setTimeout(r, pollMs));
|
||||
}
|
||||
}
|
||||
|
||||
const msg = lastError instanceof Error ? lastError.message : String(lastError);
|
||||
throw new Error(`Timed out waiting for /global/health: ${msg}`);
|
||||
}
|
||||
|
||||
export function normalizeEvent(raw) {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
if (typeof raw.type === "string") {
|
||||
return { type: raw.type, properties: raw.properties };
|
||||
}
|
||||
|
||||
if (raw.payload && typeof raw.payload === "object" && typeof raw.payload.type === "string") {
|
||||
return { type: raw.payload.type, properties: raw.payload.properties };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const args = new Map();
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const item = argv[i];
|
||||
if (!item.startsWith("--")) continue;
|
||||
const key = item.slice(2);
|
||||
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
||||
args.set(key, value);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function canWriteWorkspace(directory) {
|
||||
try {
|
||||
const stat = statSync(directory);
|
||||
return stat && stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user