diff --git a/README.md b/README.md index c3a4d56..4a7f6da 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,31 @@ Launch an OpenAI compliant API that uses OpenCode as the backend. +## Configuration + +Configuration can be provided using CLI flags or a `config.json` file. CLI flags take precedence over configuration file settings. Environment variables are not used for configuration. + +### CLI Flags + +- `--host `: Host address to bind to (default: `127.0.0.1`). +- `--port `: Port to listen on (default: `8000`). +- `--opencode-url `: URL of the OpenCode server. If omitted, an embedded OpenCode server is spawned automatically. +- `--embeddings-model `: Hugging Face model identifier for embeddings (default: `Xenova/bge-small-en-v1.5`). +- `--embeddings-preload `: Whether to preload the embeddings model on startup (default: `false`). +- `-c, --config `: Path to a custom JSON configuration file (default: `./config.json` if it exists). + +### Example `config.json` + +```json +{ + "host": "127.0.0.1", + "port": 8000, + "opencodeUrl": "http://127.0.0.1:4096", + "embeddingsModel": "Xenova/bge-small-en-v1.5", + "embeddingsPreload": false +} +``` + ## License opencode-to-openai © 2026 by Abhishek Kumar is licensed under CC BY-NC-ND 4.0. To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-nd/4.0/ diff --git a/src/config.test.ts b/src/config.test.ts index 29b6536..248a19e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { loadConfig } from "./config.ts"; const DEFAULTS = { @@ -10,24 +12,24 @@ const DEFAULTS = { }; describe("loadConfig", () => { - test("defaults host, port, and opencode url", () => { - expect(loadConfig({})).toEqual(DEFAULTS); + test("defaults host, port, and opencode url when no args or config file exist", () => { + expect(loadConfig([])).toEqual(DEFAULTS); }); - test("treats empty or whitespace-only OPENCODE_URL as unset", () => { - expect(loadConfig({ OPENCODE_URL: "" })).toEqual(DEFAULTS); - expect(loadConfig({ OPENCODE_URL: " " })).toEqual(DEFAULTS); - }); - - test("reads PORT, HOST, and OPENCODE_URL from env", () => { + test("reads CLI flags", () => { expect( - loadConfig({ - PORT: "9000", - HOST: "0.0.0.0", - OPENCODE_URL: "http://opencode.example:7777", - EMBEDDINGS_MODEL: "Xenova/all-MiniLM-L6-v2", - EMBEDDINGS_PRELOAD: "true", - }), + loadConfig([ + "--port", + "9000", + "--host", + "0.0.0.0", + "--opencode-url", + "http://opencode.example:7777", + "--embeddings-model", + "Xenova/all-MiniLM-L6-v2", + "--embeddings-preload", + "true", + ]), ).toEqual({ host: "0.0.0.0", port: 9000, @@ -37,15 +39,20 @@ describe("loadConfig", () => { }); }); - test("trims whitespace", () => { + test("trims whitespace from string CLI flags", () => { expect( - loadConfig({ - PORT: " 8123 ", - HOST: " localhost ", - OPENCODE_URL: " http://opencode.example:7777 ", - EMBEDDINGS_MODEL: " Xenova/all-MiniLM-L6-v2 ", - EMBEDDINGS_PRELOAD: " true ", - }), + loadConfig([ + "--port", + " 8123 ", + "--host", + " localhost ", + "--opencode-url", + " http://opencode.example:7777 ", + "--embeddings-model", + " Xenova/all-MiniLM-L6-v2 ", + "--embeddings-preload", + " true ", + ]), ).toEqual({ host: "localhost", port: 8123, @@ -56,45 +63,132 @@ describe("loadConfig", () => { }); test("accepts boundary ports", () => { - expect(loadConfig({ PORT: "0" }).port).toBe(0); - expect(loadConfig({ PORT: "65535" }).port).toBe(65_535); + expect(loadConfig(["--port", "0"]).port).toBe(0); + expect(loadConfig(["--port", "65535"]).port).toBe(65_535); }); - test("throws for a non-numeric port", () => { - expect(() => loadConfig({ PORT: "abc" })).toThrow(/invalid PORT/); + test("throws for a non-numeric port in CLI args", () => { + expect(() => loadConfig(["--port", "abc"])).toThrow(/invalid port/); }); - test("throws for an out-of-range port", () => { - expect(() => loadConfig({ PORT: "-1" })).toThrow(/invalid PORT/); - expect(() => loadConfig({ PORT: "65536" })).toThrow(/invalid PORT/); + test("throws for an out-of-range port in CLI args", () => { + expect(() => loadConfig(["--port=65536"])).toThrow(/invalid port/); }); - test("throws for a malformed OPENCODE_URL", () => { - expect(() => loadConfig({ OPENCODE_URL: "not a url" })).toThrow(/invalid OPENCODE_URL/); + test("throws for a malformed opencode-url", () => { + expect(() => loadConfig(["--opencode-url", "not a url"])).toThrow(/invalid opencodeUrl/); }); - test("throws for a non-http(s) OPENCODE_URL", () => { - expect(() => loadConfig({ OPENCODE_URL: "ftp://example.com" })).toThrow(/invalid OPENCODE_URL/); + test("throws for a non-http(s) opencode-url", () => { + expect(() => loadConfig(["--opencode-url", "ftp://example.com"])).toThrow( + /invalid opencodeUrl/, + ); }); - test("reads EMBEDDINGS_PRELOAD false", () => { - expect(loadConfig({ EMBEDDINGS_PRELOAD: "false" }).embeddingsPreload).toBe(false); + test("reads embeddings-preload false", () => { + expect(loadConfig(["--embeddings-preload", "false"]).embeddingsPreload).toBe(false); }); - test("throws for non-boolean EMBEDDINGS_PRELOAD values", () => { + test("throws for non-boolean embeddings-preload values", () => { for (const value of ["1", "0", "yes", "no", "on", "y", "n", " random "]) { - expect(() => loadConfig({ EMBEDDINGS_PRELOAD: value })).toThrow(/invalid EMBEDDINGS_PRELOAD/); + expect(() => loadConfig(["--embeddings-preload", value])).toThrow( + /invalid embeddingsPreload/, + ); } }); - test("throws for empty or whitespace-only EMBEDDINGS_PRELOAD", () => { - for (const value of ["", " ", "\t\n "]) { - expect(() => loadConfig({ EMBEDDINGS_PRELOAD: value })).toThrow(/invalid EMBEDDINGS_PRELOAD/); + test("accepts case-insensitive embeddings-preload booleans", () => { + expect(loadConfig(["--embeddings-preload", "TRUE"]).embeddingsPreload).toBe(true); + expect(loadConfig(["--embeddings-preload", "False"]).embeddingsPreload).toBe(false); + }); + + test("loads configuration from custom JSON config file", () => { + const tmpDir = join(process.cwd(), ".tmp-config-test-1"); + mkdirSync(tmpDir, { recursive: true }); + const configPath = join(tmpDir, "custom.json"); + writeFileSync( + configPath, + JSON.stringify({ + host: "10.0.0.1", + port: 4000, + opencodeUrl: "http://10.0.0.1:4096", + embeddingsModel: "custom-model", + embeddingsPreload: true, + }), + "utf8", + ); + + try { + expect(loadConfig({ configFilePath: configPath, args: [] })).toEqual({ + host: "10.0.0.1", + port: 4000, + opencodeUrl: "http://10.0.0.1:4096", + embeddingsModel: "custom-model", + embeddingsPreload: true, + }); + + expect(loadConfig(["--config", configPath])).toEqual({ + host: "10.0.0.1", + port: 4000, + opencodeUrl: "http://10.0.0.1:4096", + embeddingsModel: "custom-model", + embeddingsPreload: true, + }); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); } }); - test("accepts case-insensitive EMBEDDINGS_PRELOAD booleans", () => { - expect(loadConfig({ EMBEDDINGS_PRELOAD: "TRUE" }).embeddingsPreload).toBe(true); - expect(loadConfig({ EMBEDDINGS_PRELOAD: "False" }).embeddingsPreload).toBe(false); + test("CLI flags take precedence over config file values", () => { + const tmpDir = join(process.cwd(), ".tmp-config-test-2"); + mkdirSync(tmpDir, { recursive: true }); + const configPath = join(tmpDir, "custom.json"); + writeFileSync( + configPath, + JSON.stringify({ + host: "10.0.0.1", + port: 4000, + opencodeUrl: "http://10.0.0.1:4096", + embeddingsModel: "file-model", + embeddingsPreload: false, + }), + "utf8", + ); + + try { + const config = loadConfig([ + "--config", + configPath, + "--port", + "5000", + "--embeddings-model", + "cli-model", + "--embeddings-preload", + "true", + ]); + + expect(config).toEqual({ + host: "10.0.0.1", + port: 5000, + opencodeUrl: "http://10.0.0.1:4096", + embeddingsModel: "cli-model", + embeddingsPreload: true, + }); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test("throws on invalid JSON in config file", () => { + const tmpDir = join(process.cwd(), ".tmp-config-test-3"); + mkdirSync(tmpDir, { recursive: true }); + const configPath = join(tmpDir, "bad.json"); + writeFileSync(configPath, "{ invalid json", "utf8"); + + try { + expect(() => loadConfig(["--config", configPath])).toThrow(/invalid JSON in config file/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } }); }); diff --git a/src/config.ts b/src/config.ts index fc8ab3b..99cc567 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from "node:fs"; +import { parseArgs } from "node:util"; import { parseHttpUrl } from "./utils/net.ts"; import { parseBoolean } from "./utils/parse.ts"; @@ -9,30 +11,169 @@ export interface Config { embeddingsPreload: boolean; } +export interface LoadConfigOptions { + args?: string[]; + configFilePath?: string; +} + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 8000; +const DEFAULT_OPENCODE_URL = null; const DEFAULT_EMBEDDINGS_MODEL = "Xenova/bge-small-en-v1.5"; +const DEFAULT_EMBEDDINGS_PRELOAD = false; + +interface RawFileConfig { + host?: unknown; + port?: unknown; + opencodeUrl?: unknown; + opencode_url?: unknown; + embeddingsModel?: unknown; + embeddings_model?: unknown; + embeddingsPreload?: unknown; + embeddings_preload?: unknown; +} + +function parseJsonConfig(path: string): RawFileConfig { + let content: string; + try { + content = readFileSync(path, "utf8"); + } catch (error) { + throw new Error(`failed to read config file "${path}": ${(error as Error).message}`, { + cause: error, + }); + } + + try { + const parsed = JSON.parse(content); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("expected JSON object at root"); + } + return parsed as RawFileConfig; + } catch (error) { + throw new Error(`invalid JSON in config file "${path}": ${(error as Error).message}`, { + cause: error, + }); + } +} + +export function loadConfig(options: LoadConfigOptions | string[] = {}): Config { + const argsList = Array.isArray(options) ? options : (options.args ?? process.argv.slice(2)); + + const { values: parsedFlags } = parseArgs({ + args: argsList, + options: { + config: { type: "string", short: "c" }, + host: { type: "string" }, + port: { type: "string" }, + "opencode-url": { type: "string" }, + "embeddings-model": { type: "string" }, + "embeddings-preload": { type: "string" }, + }, + strict: true, + allowPositionals: false, + }); -export function loadConfig(env: Record = process.env): Config { - const port = Number(env.PORT?.trim() || 8000); - if (!Number.isInteger(port) || port < 0 || port > 65_535) { - throw new Error(`invalid PORT "${env.PORT}": expected an integer between 0 and 65535`); + const explicitConfigPath = + parsedFlags.config || (!Array.isArray(options) ? options.configFilePath : undefined); + + let fileConfig: RawFileConfig = {}; + if (explicitConfigPath) { + fileConfig = parseJsonConfig(explicitConfigPath); + } else if (existsSync("config.json")) { + fileConfig = parseJsonConfig("config.json"); + } + + // Merging logic: CLI flags > File Config > Defaults + + // 1. Host + const rawHost = parsedFlags.host ?? fileConfig.host; + let host = DEFAULT_HOST; + if (rawHost !== undefined && rawHost !== null) { + if (typeof rawHost !== "string") { + throw new Error(`invalid host in config file: expected string`); + } + const trimmed = rawHost.trim(); + if (trimmed.length === 0) { + throw new Error(`invalid host: expected non-empty string`); + } + host = trimmed; } - const rawOpencodeUrl = env.OPENCODE_URL?.trim(); - const opencodeUrl = rawOpencodeUrl || null; - if (opencodeUrl && !parseHttpUrl(opencodeUrl)) { - throw new Error(`invalid OPENCODE_URL "${opencodeUrl}": expected an absolute http(s) URL`); + + // 2. Port + const rawPort = parsedFlags.port ?? fileConfig.port; + let port = DEFAULT_PORT; + if (rawPort !== undefined && rawPort !== null) { + const numPort = + typeof rawPort === "number" + ? rawPort + : typeof rawPort === "string" + ? Number(rawPort.trim()) + : NaN; + + if (!Number.isInteger(numPort) || numPort < 0 || numPort > 65_535) { + throw new Error(`invalid port "${rawPort}": expected an integer between 0 and 65535`); + } + port = numPort; } - const embeddingsModel = env.EMBEDDINGS_MODEL?.trim() || DEFAULT_EMBEDDINGS_MODEL; - const rawPreload = env.EMBEDDINGS_PRELOAD?.trim(); - const parsedPreload = rawPreload === undefined ? false : parseBoolean(rawPreload); - if (parsedPreload === undefined) { - throw new Error( - `invalid EMBEDDINGS_PRELOAD "${env.EMBEDDINGS_PRELOAD}": expected "true" or "false"`, - ); + + // 3. Opencode URL + const rawOpencodeUrl = + parsedFlags["opencode-url"] ?? fileConfig.opencodeUrl ?? fileConfig.opencode_url; + let opencodeUrl: string | null = DEFAULT_OPENCODE_URL; + if (rawOpencodeUrl !== undefined && rawOpencodeUrl !== null) { + if (typeof rawOpencodeUrl !== "string") { + throw new Error(`invalid opencodeUrl in config file: expected string`); + } + const trimmed = rawOpencodeUrl.trim(); + if (trimmed.length > 0) { + if (!parseHttpUrl(trimmed)) { + throw new Error(`invalid opencodeUrl "${trimmed}": expected an absolute http(s) URL`); + } + opencodeUrl = trimmed; + } + } + + // 4. Embeddings Model + const rawEmbeddingsModel = + parsedFlags["embeddings-model"] ?? fileConfig.embeddingsModel ?? fileConfig.embeddings_model; + let embeddingsModel = DEFAULT_EMBEDDINGS_MODEL; + if (rawEmbeddingsModel !== undefined && rawEmbeddingsModel !== null) { + if (typeof rawEmbeddingsModel !== "string") { + throw new Error(`invalid embeddingsModel in config file: expected string`); + } + const trimmed = rawEmbeddingsModel.trim(); + if (trimmed.length === 0) { + throw new Error(`invalid embeddingsModel: expected non-empty string`); + } + embeddingsModel = trimmed; + } + + // 5. Embeddings Preload + const rawEmbeddingsPreload = + parsedFlags["embeddings-preload"] ?? + fileConfig.embeddingsPreload ?? + fileConfig.embeddings_preload; + let embeddingsPreload = DEFAULT_EMBEDDINGS_PRELOAD; + if (rawEmbeddingsPreload !== undefined && rawEmbeddingsPreload !== null) { + if (typeof rawEmbeddingsPreload === "boolean") { + embeddingsPreload = rawEmbeddingsPreload; + } else if (typeof rawEmbeddingsPreload === "string") { + const parsed = parseBoolean(rawEmbeddingsPreload.trim()); + if (parsed === undefined) { + throw new Error( + `invalid embeddingsPreload "${rawEmbeddingsPreload}": expected "true" or "false"`, + ); + } + embeddingsPreload = parsed; + } else { + throw new Error( + `invalid embeddingsPreload "${rawEmbeddingsPreload}": expected boolean or "true"/"false"`, + ); + } } - const embeddingsPreload = parsedPreload; return { - host: env.HOST?.trim() || "127.0.0.1", + host, port, opencodeUrl, embeddingsModel, diff --git a/src/main.test.ts b/src/main.test.ts index 7477e23..01b5dad 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -24,113 +24,66 @@ describe("src/main.ts", () => { }); test.serial("boot() starts the full application stack when run as the entrypoint", async () => { - const previousPort = process.env.PORT; - const previousHost = process.env.HOST; - const previousOpencodeUrl = process.env.OPENCODE_URL; - process.env.PORT = "0"; - process.env.HOST = "127.0.0.1"; - process.env.OPENCODE_URL = "http://localhost:4096"; + const { boot } = await import("./main.ts"); + const result = await boot(true, { + config: ["--port", "0", "--host", "127.0.0.1", "--opencode-url", "http://localhost:4096"], + }); + expect(result).not.toBeNull(); + const server = result!.server; try { - const { boot } = await import("./main.ts"); - const result = await boot(true); - expect(result).not.toBeNull(); - const server = result!.server; - try { - expect(server.port).toBeGreaterThan(0); - const response = await fetch(`http://127.0.0.1:${server.port}/v1/does-not-exist`); - expect(response.status).toBe(404); - } finally { - server.stop(); - } + expect(server.port).toBeGreaterThan(0); + const response = await fetch(`http://127.0.0.1:${server.port}/v1/does-not-exist`); + expect(response.status).toBe(404); } finally { - if (previousPort === undefined) delete process.env.PORT; - else process.env.PORT = previousPort; - if (previousHost === undefined) delete process.env.HOST; - else process.env.HOST = previousHost; - if (previousOpencodeUrl === undefined) delete process.env.OPENCODE_URL; - else process.env.OPENCODE_URL = previousOpencodeUrl; + server.stop(); } }); test.serial( - "boot() embeds a mocked server for a whitespace-only OPENCODE_URL and closes it on stop", + "boot() embeds a mocked server for an unset opencodeUrl and closes it on stop", async () => { - const previousPort = process.env.PORT; - const previousHost = process.env.HOST; - const previousOpencodeUrl = process.env.OPENCODE_URL; let closeCalls = 0; - try { - process.env.PORT = "0"; - process.env.HOST = "127.0.0.1"; - process.env.OPENCODE_URL = " "; - const { boot } = await import("./main.ts"); - const result = await boot(true, { - createOpencodeServer: async () => ({ - url: "http://127.0.0.1:4096", - close: () => { - closeCalls += 1; - }, - }), - }); - expect(result).not.toBeNull(); - const server = result!.server; - expect(server.port).toBeGreaterThan(0); - server.stop(); - expect(closeCalls).toBe(1); - } finally { - if (previousPort === undefined) delete process.env.PORT; - else process.env.PORT = previousPort; - if (previousHost === undefined) delete process.env.HOST; - else process.env.HOST = previousHost; - if (previousOpencodeUrl === undefined) delete process.env.OPENCODE_URL; - else process.env.OPENCODE_URL = previousOpencodeUrl; - } + const { boot } = await import("./main.ts"); + const result = await boot(true, { + config: ["--port", "0", "--host", "127.0.0.1"], + createOpencodeServer: async () => ({ + url: "http://127.0.0.1:4096", + close: () => { + closeCalls += 1; + }, + }), + }); + expect(result).not.toBeNull(); + const server = result!.server; + expect(server.port).toBeGreaterThan(0); + server.stop(); + expect(closeCalls).toBe(1); }, ); test.serial( "start() closes the embedded server and rethrows when a later step fails", async () => { - const previousPort = process.env.PORT; - const previousHost = process.env.HOST; - const previousOpencodeUrl = process.env.OPENCODE_URL; let closeCalls = 0; - try { - process.env.PORT = "0"; - process.env.HOST = "127.0.0.1"; - process.env.OPENCODE_URL = " "; - const { start } = await import("./main.ts"); - const error = await start({ - createOpencodeServer: async () => ({ - url: "invalid-url", - close: () => { - closeCalls += 1; - }, - }), - }).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(Error); - expect(closeCalls).toBe(1); - } finally { - if (previousPort === undefined) delete process.env.PORT; - else process.env.PORT = previousPort; - if (previousHost === undefined) delete process.env.HOST; - else process.env.HOST = previousHost; - if (previousOpencodeUrl === undefined) delete process.env.OPENCODE_URL; - else process.env.OPENCODE_URL = previousOpencodeUrl; - } + const { start } = await import("./main.ts"); + const error = await start({ + config: ["--port", "0", "--host", "127.0.0.1"], + createOpencodeServer: async () => ({ + url: "invalid-url", + close: () => { + closeCalls += 1; + }, + }), + }).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect(closeCalls).toBe(1); }, ); test.serial("start() closes the Bun server when a later step fails", async () => { - const previousPort = process.env.PORT; - const previousHost = process.env.HOST; - const previousOpencodeUrl = process.env.OPENCODE_URL; const originalServe = Bun.serve; let stopCalls = 0; try { - process.env.PORT = "0"; - process.env.HOST = "127.0.0.1"; - process.env.OPENCODE_URL = " "; Bun.serve = ((options: Parameters[0]) => { const server = originalServe(options); const originalStop = server.stop; @@ -143,6 +96,7 @@ describe("src/main.ts", () => { const { start } = await import("./main.ts"); expect( start({ + config: ["--port", "0", "--host", "127.0.0.1"], createOpencodeServer: async () => ({ url: "invalid-url", close: () => {}, @@ -152,94 +106,70 @@ describe("src/main.ts", () => { expect(stopCalls).toBe(1); } finally { Bun.serve = originalServe; - if (previousPort === undefined) delete process.env.PORT; - else process.env.PORT = previousPort; - if (previousHost === undefined) delete process.env.HOST; - else process.env.HOST = previousHost; - if (previousOpencodeUrl === undefined) delete process.env.OPENCODE_URL; - else process.env.OPENCODE_URL = previousOpencodeUrl; } }); test.serial( - "start() preloads the embeddings model before exposing the server when EMBEDDINGS_PRELOAD is enabled", + "start() preloads the embeddings model before exposing the server when embeddings-preload is enabled", async () => { - const previousPort = process.env.PORT; - const previousHost = process.env.HOST; - const previousModel = process.env.EMBEDDINGS_MODEL; - const previousPreload = process.env.EMBEDDINGS_PRELOAD; - process.env.PORT = "0"; - process.env.HOST = "127.0.0.1"; - process.env.EMBEDDINGS_MODEL = "test-model"; - process.env.EMBEDDINGS_PRELOAD = "true"; + const recording = createRecordingPipeline(stubEmbeddingsPipeline); + const { start } = await import("./main.ts"); + const { server } = await start({ + config: [ + "--port", + "0", + "--host", + "127.0.0.1", + "--embeddings-model", + "test-model", + "--embeddings-preload", + "true", + ], + createOpencodeServer: stubEmbeddedServer, + buildEmbeddingsPipeline: recording.pipeline, + }); try { - const recording = createRecordingPipeline(stubEmbeddingsPipeline); - const { start } = await import("./main.ts"); - const { server } = await start({ - createOpencodeServer: stubEmbeddedServer, - buildEmbeddingsPipeline: recording.pipeline, + expect(server.port).toBeGreaterThan(0); + expect(recording.calls).toHaveLength(1); + expect(recording.calls[0]).toEqual({ + task: "feature-extraction", + model: "test-model", + options: { dtype: "q8" }, }); - try { - expect(server.port).toBeGreaterThan(0); - expect(recording.calls).toHaveLength(1); - expect(recording.calls[0]).toEqual({ - task: "feature-extraction", - model: "test-model", - options: { dtype: "q8" }, - }); - } finally { - server.stop(); - } } finally { - if (previousPort === undefined) delete process.env.PORT; - else process.env.PORT = previousPort; - if (previousHost === undefined) delete process.env.HOST; - else process.env.HOST = previousHost; - if (previousModel === undefined) delete process.env.EMBEDDINGS_MODEL; - else process.env.EMBEDDINGS_MODEL = previousModel; - if (previousPreload === undefined) delete process.env.EMBEDDINGS_PRELOAD; - else process.env.EMBEDDINGS_PRELOAD = previousPreload; + server.stop(); } }, ); test.serial("start() rejects and closes the embedded server when the preload fails", async () => { - const previousPort = process.env.PORT; - const previousHost = process.env.HOST; - const previousModel = process.env.EMBEDDINGS_MODEL; - const previousPreload = process.env.EMBEDDINGS_PRELOAD; let closeCalls = 0; - process.env.PORT = "0"; - process.env.HOST = "127.0.0.1"; - process.env.EMBEDDINGS_MODEL = "test-model"; - process.env.EMBEDDINGS_PRELOAD = "true"; - try { - const recording = createRecordingPipeline(async () => { - throw new Error("download failed"); - }); - const { start } = await import("./main.ts"); - const error = await start({ - createOpencodeServer: async () => ({ - url: "http://127.0.0.1:4096", - close: () => { - closeCalls += 1; - }, - }), - buildEmbeddingsPipeline: recording.pipeline, - }).catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe("download failed"); - expect(recording.calls).toHaveLength(1); - expect(closeCalls).toBe(1); - } finally { - if (previousPort === undefined) delete process.env.PORT; - else process.env.PORT = previousPort; - if (previousHost === undefined) delete process.env.HOST; - else process.env.HOST = previousHost; - if (previousModel === undefined) delete process.env.EMBEDDINGS_MODEL; - else process.env.EMBEDDINGS_MODEL = previousModel; - if (previousPreload === undefined) delete process.env.EMBEDDINGS_PRELOAD; - else process.env.EMBEDDINGS_PRELOAD = previousPreload; - } + const recording = createRecordingPipeline(async () => { + throw new Error("download failed"); + }); + const { start } = await import("./main.ts"); + const error = await start({ + config: [ + "--port", + "0", + "--host", + "127.0.0.1", + "--embeddings-model", + "test-model", + "--embeddings-preload", + "true", + ], + createOpencodeServer: async () => ({ + url: "http://127.0.0.1:4096", + close: () => { + closeCalls += 1; + }, + }), + buildEmbeddingsPipeline: recording.pipeline, + }).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("download failed"); + expect(recording.calls).toHaveLength(1); + expect(closeCalls).toBe(1); }); }); diff --git a/src/main.ts b/src/main.ts index f215890..9e722ea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { createOpencodeServer, type ServerOptions } from "@opencode-ai/sdk"; import { buildRouter } from "./app.ts"; -import { loadConfig } from "./config.ts"; +import { loadConfig, type Config, type LoadConfigOptions } from "./config.ts"; import { createOpencodeHttpClient } from "./opencode/client.ts"; import { createServer } from "./server.ts"; import { @@ -12,6 +12,7 @@ import { OpencodeChatCompletionsService } from "./services/opencode/service.ts"; import { displayAddress } from "./utils/net.ts"; export interface StartOptions { + config?: Config | LoadConfigOptions | string[]; createOpencodeServer?: (options: ServerOptions) => Promise<{ url: string; close(): void }>; buildEmbeddingsPipeline?: FeatureExtractionPipeline; } @@ -21,7 +22,15 @@ export interface StartResult { } export async function start(options: StartOptions = {}): Promise { - const config = loadConfig(); + let config: Config; + if (options.config && "host" in options.config && "port" in options.config) { + config = options.config as Config; + } else if (options.config) { + config = loadConfig(options.config as LoadConfigOptions | string[]); + } else { + config = loadConfig(); + } + const createEmbeddedServer = options.createOpencodeServer ?? createOpencodeServer; let opencodeUrl = config.opencodeUrl; diff --git a/test/e2e/entrypoint.test.ts b/test/e2e/entrypoint.test.ts index dea99fd..f1621e2 100644 --- a/test/e2e/entrypoint.test.ts +++ b/test/e2e/entrypoint.test.ts @@ -17,11 +17,13 @@ describe("e2e entrypoint", () => { test( "boots src/main.ts and serves a completion from the real opencode server", async () => { - const proc = Bun.spawn([process.execPath, "src/main.ts"], { - env: { ...process.env, PORT: "0", OPENCODE_URL: opencode.url }, - stdout: "pipe", - stderr: "pipe", - }); + const proc = Bun.spawn( + [process.execPath, "src/main.ts", "--port", "0", "--opencode-url", opencode.url], + { + stdout: "pipe", + stderr: "pipe", + }, + ); try { const log = await readLogUntil(proc.stdout, /using opencode server at http:\/\//, 10_000); const match = /listening on http:\/\/([^:]+):(\d+)/.exec(log); diff --git a/test/e2e/main.embedded.test.ts b/test/e2e/main.embedded.test.ts index 7acc17a..42d6a66 100644 --- a/test/e2e/main.embedded.test.ts +++ b/test/e2e/main.embedded.test.ts @@ -3,38 +3,24 @@ import { describe, expect, test } from "bun:test"; describe("src/main.ts", () => { test.serial( - "boot() embeds a real opencode server when OPENCODE_URL is unset and closes it on stop", + "boot() embeds a real opencode server when opencodeUrl is unset and closes it on stop", async () => { - const previousPort = process.env.PORT; - const previousHost = process.env.HOST; - const previousOpencodeUrl = process.env.OPENCODE_URL; - process.env.PORT = "0"; - process.env.HOST = "127.0.0.1"; - delete process.env.OPENCODE_URL; - try { - let embedded: { url: string; close(): void } | null = null; - const { boot } = await import("../../src/main.ts"); - const result = await boot(true, { - createOpencodeServer: async (options: ServerOptions) => { - const real = await createOpencodeServer(options); - embedded = real; - return real; - }, - }); - expect(result).not.toBeNull(); - expect(embedded).not.toBeNull(); - const server = result!.server; - expect(server.port).toBeGreaterThan(0); - server.stop(); - await expectConnectionClosed(embedded!.url); - } finally { - if (previousPort === undefined) delete process.env.PORT; - else process.env.PORT = previousPort; - if (previousHost === undefined) delete process.env.HOST; - else process.env.HOST = previousHost; - if (previousOpencodeUrl === undefined) delete process.env.OPENCODE_URL; - else process.env.OPENCODE_URL = previousOpencodeUrl; - } + let embedded: { url: string; close(): void } | null = null; + const { boot } = await import("../../src/main.ts"); + const result = await boot(true, { + config: ["--port", "0", "--host", "127.0.0.1"], + createOpencodeServer: async (options: ServerOptions) => { + const real = await createOpencodeServer(options); + embedded = real; + return real; + }, + }); + expect(result).not.toBeNull(); + expect(embedded).not.toBeNull(); + const server = result!.server; + expect(server.port).toBeGreaterThan(0); + server.stop(); + await expectConnectionClosed(embedded!.url); }, ); }); @@ -48,18 +34,12 @@ describe("src/main.ts cleanup", () => { port: 0, fetch: () => new Response("occupied"), }); - const previousPort = process.env.PORT; - const previousHost = process.env.HOST; - const previousOpencodeUrl = process.env.OPENCODE_URL; let closeCalls = 0; try { - process.env.PORT = String(blocker.port); - process.env.HOST = "127.0.0.1"; - delete process.env.OPENCODE_URL; - const { start } = await import("../../src/main.ts"); expect( start({ + config: ["--port", String(blocker.port), "--host", "127.0.0.1"], createOpencodeServer: async () => ({ url: "http://127.0.0.1:59999", close: () => { @@ -71,12 +51,6 @@ describe("src/main.ts cleanup", () => { expect(closeCalls).toBe(1); } finally { blocker.stop(); - if (previousPort === undefined) delete process.env.PORT; - else process.env.PORT = previousPort; - if (previousHost === undefined) delete process.env.HOST; - else process.env.HOST = previousHost; - if (previousOpencodeUrl === undefined) delete process.env.OPENCODE_URL; - else process.env.OPENCODE_URL = previousOpencodeUrl; } }, );