100 lines
3.4 KiB
TypeScript
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);
|
|
}
|
|
}
|