cc-libs/tools/trapos-server/src/index.ts

100 lines
3.4 KiB
TypeScript

import { createApp, type Config } from "./app.js";
const config: Config = {
host: process.env.TRAPOS_SERVER_HOST ?? "0.0.0.0",
port: readPort(process.env.TRAPOS_SERVER_PORT, 4444),
serverPassword: requireSecret("TRAPOS_SERVER_PASSWORD", process.env.TRAPOS_SERVER_PASSWORD),
requestTimeoutMs: readPositiveInt(process.env.TRAPOS_SERVER_REQUEST_TIMEOUT_MS, 600_000),
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4242",
opencodeUsername: process.env.OPENCODE_USERNAME ?? "opencode",
opencodeServerPassword: requireSecret(
"OPENCODE_SERVER_PASSWORD",
process.env.OPENCODE_SERVER_PASSWORD,
),
// MCP endpoint: on by default, bound to localhost. exec-lua / write-file are as
// powerful as the linked computer, so the listener stays off the public gateway port.
mcpEnabled: process.env.MCP_ENABLED !== "0",
mcpHost: process.env.MCP_HOST ?? "127.0.0.1",
mcpPort: readPort(process.env.MCP_PORT, 4445),
mcpDefaultTimeoutMs: readPositiveInt(process.env.MCP_DEFAULT_TIMEOUT_MS, 30_000),
mcpMaxTimeoutMs: readPositiveInt(process.env.MCP_MAX_TIMEOUT_MS, 30_000),
mcpProbeTimeoutMs: readPositiveInt(process.env.MCP_PROBE_TIMEOUT_MS, 5_000),
logger: buildLogger(),
};
const { app, mcpApp } = await createApp(config);
try {
await app.listen({ host: config.host, port: config.port });
} catch (err) {
reportListenError(err, config.host, config.port, "TRAPOS_SERVER_PORT");
app.log.error(err);
process.exit(1);
}
if (mcpApp) {
try {
await mcpApp.listen({ host: config.mcpHost, port: config.mcpPort });
} catch (err) {
reportListenError(err, config.mcpHost, config.mcpPort, "MCP_PORT");
mcpApp.log.error(err);
process.exit(1);
}
}
function buildLogger(): Config["logger"] {
const level = process.env.LOG_LEVEL ?? "info";
if (process.env.LOG_PRETTY) {
return {
level,
transport: {
target: "pino-pretty",
options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
};
}
return { level };
}
// A non-empty password is mandatory: an empty TRAPOS_SERVER_PASSWORD disables auth
// on the public gateway (resolveNetwork maps any secret to "global"), and an empty
// OPENCODE_SERVER_PASSWORD drops the outbound Basic-auth header. Refuse to start
// rather than run silently unauthenticated.
function requireSecret(name: string, value: string | undefined): string {
const secret = value?.trim();
if (!secret) {
process.stderr.write(
`\n[trapos-server] ${name} is empty or unset. Set it in .env ` +
`(run \`just install\` to generate secrets). Refusing to start.\n\n`,
);
process.exit(1);
}
return secret;
}
function readPort(value: string | undefined, fallback: number): number {
return readPositiveInt(value, fallback);
}
function readPositiveInt(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
return err instanceof Error && "code" in err;
}
function reportListenError(err: unknown, host: string, port: number, portEnv: string): void {
if (isErrnoException(err) && err.code === "EADDRINUSE") {
process.stderr.write(
`\n[trapos-server] Port ${port} is already in use on ${host}.\n` +
`Another instance may be running, or set ${portEnv} to a free port.\n\n`,
);
process.exit(1);
}
}