Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 129 additions & 102 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,28 @@ import type { OcxConfig, OcxProviderConfig } from "../types";

function createPrompt(): { ask(question: string): Promise<string>; close(): void } {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
let closed = false;
rl.on("close", () => { closed = true; });
return {
ask(question: string): Promise<string> {
return new Promise(resolve => rl.question(question, resolve));
return new Promise((resolve, reject) => {
if (closed) {
reject(new Error("stdin closed before the prompt could be answered"));
return;
}
const onClose = () => {
reject(new Error("stdin reached EOF while waiting for input"));
};
rl.once("close", onClose);
rl.question(question, answer => {
rl.off("close", onClose);
resolve(answer);
});
});
},
close() {
if (!closed) rl.close();
},
close() { rl.close(); },
};
}

Expand Down Expand Up @@ -83,115 +100,125 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()):

export async function runInit(): Promise<void> {
const prompt = createPrompt();
console.log("\n🔧 opencodex (ocx) setup\n");

const providers = buildInitProviders();
printMenu(providers);

const choice = await prompt.ask("\nSelect default provider (number): ");
const idx = parseInt(choice, 10) - 1;

let providerName: string;
let providerConfig: OcxProviderConfig;
let oauthHint = false;

if (idx >= 0 && idx < providers.length) {
const p = providers[idx];
providerName = p.id;
console.log(`\n📡 ${p.label}`);
console.log(` Base URL: ${p.baseUrl}`);

if (p.kind === "forward") {
providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "forward" };
console.log(" No API key needed — forwards your existing `codex login`.");
} else if (p.kind === "oauth") {
providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "oauth", ...(p.defaultModel ? { defaultModel: p.defaultModel } : {}) };
oauthHint = true;
} else {
// key + local: collect a key (local usually blank).
if (p.dashboardUrl) console.log(` 🔑 Get your key: ${p.dashboardUrl}`);
// Template URL with placeholders (e.g. Cloudflare's {account_id}) needs a resolved value.
let baseUrl = p.baseUrl;
if (/\{[^}]*\}/.test(baseUrl)) {
const resolved = (await prompt.ask(` Your endpoint URL (${baseUrl}): `)).trim();
if (!resolved) {
console.error(" A resolved URL is required — replace the {placeholder} with your actual value.");
process.exit(1);
try {
console.log("\n🔧 opencodex (ocx) setup\n");

const providers = buildInitProviders();
printMenu(providers);

const choice = await prompt.ask("\nSelect default provider (number): ");
const idx = parseInt(choice, 10) - 1;

let providerName: string;
let providerConfig: OcxProviderConfig;
let oauthHint = false;

if (idx >= 0 && idx < providers.length) {
const p = providers[idx];
providerName = p.id;
console.log(`\n📡 ${p.label}`);
console.log(` Base URL: ${p.baseUrl}`);

if (p.kind === "forward") {
providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "forward" };
console.log(" No API key needed — forwards your existing `codex login`.");
} else if (p.kind === "oauth") {
providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "oauth", ...(p.defaultModel ? { defaultModel: p.defaultModel } : {}) };
oauthHint = true;
} else {
// key + local: collect a key (local usually blank).
if (p.dashboardUrl) console.log(` 🔑 Get your key: ${p.dashboardUrl}`);
// Template URL with placeholders (e.g. Cloudflare's {account_id}) needs a resolved value.
let baseUrl = p.baseUrl;
if (/\{[^}]*\}/.test(baseUrl)) {
const resolved = (await prompt.ask(` Your endpoint URL (${baseUrl}): `)).trim();
if (!resolved) {
console.error(" A resolved URL is required — replace the {placeholder} with your actual value.");
process.exit(1);
}
baseUrl = resolved;
}
baseUrl = resolved;
const env = envKeyFor(p.id);
const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `;
const apiKey = (await prompt.ask(`\n${hint}`)).trim();
const modelChoice = (await prompt.ask(`Default model${p.defaultModel ? ` [${p.defaultModel}]` : " (optional)"}: `)).trim();
const defaultModel = modelChoice || p.defaultModel;
providerConfig = {
adapter: p.adapter,
baseUrl,
...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}),
...(defaultModel ? { defaultModel } : {}),
};
// Apply the catalog's models / vision classification (same enrichment as the GUI).
enrichProviderFromCatalog(p.id, providerConfig);
}
} else {
providerName = (await prompt.ask("Provider name: ")).trim();
if (!isValidProviderName(providerName)) {
console.error("Provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key.");
process.exit(1);
}
const env = envKeyFor(p.id);
const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `;
const apiKey = (await prompt.ask(`\n${hint}`)).trim();
const modelChoice = (await prompt.ask(`Default model${p.defaultModel ? ` [${p.defaultModel}]` : " (optional)"}: `)).trim();
const defaultModel = modelChoice || p.defaultModel;
const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): ");
const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat";
const apiKey = await prompt.ask("API key (optional): ");
const defaultModel = await prompt.ask("Default model: ");
providerConfig = {
adapter: p.adapter,
baseUrl,
...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}),
...(defaultModel ? { defaultModel } : {}),
adapter: adapter.trim(),
baseUrl: baseUrl.trim(),
...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
...(defaultModel.trim() ? { defaultModel: defaultModel.trim() } : {}),
};
// Apply the catalog's models / vision classification (same enrichment as the GUI).
enrichProviderFromCatalog(p.id, providerConfig);
}
} else {
providerName = (await prompt.ask("Provider name: ")).trim();
if (!isValidProviderName(providerName)) {
console.error("Provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key.");
prompt.close();
process.exit(1);
}
const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): ");
const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat";
const apiKey = await prompt.ask("API key (optional): ");
const defaultModel = await prompt.ask("Default model: ");
providerConfig = {
adapter: adapter.trim(),
baseUrl: baseUrl.trim(),
...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
...(defaultModel.trim() ? { defaultModel: defaultModel.trim() } : {}),
};
}

const portStr = await prompt.ask("\nProxy port [10100]: ");
const port = parseInt(portStr, 10) || 10100;
const portStr = await prompt.ask("\nProxy port [10100]: ");
const port = parseInt(portStr, 10) || 10100;

const config: OcxConfig = {
...getDefaultConfig(),
port,
providers: { [providerName]: providerConfig },
defaultProvider: providerName,
};
const config: OcxConfig = {
...getDefaultConfig(),
port,
providers: { [providerName]: providerConfig },
defaultProvider: providerName,
};

saveConfig(config);
// Init writes a fresh config, so a stale pre-migration backup from a previous
// installation would make the next `ocx start` crash on a stale-backup
// collision (issue #257). But only a STALE backup (unparseable, or already a
// post-migration v2 snapshot) may be deleted; a backup that still parses as a
// valid pre-migration (v1) config is a user-intentional rollback point and is
// preserved by renaming it out of the collision path (sol review 260722).
cleanupOpenAiTierBackupAfterInit();
console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);

const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: ");
if (injectAnswer.trim().toLowerCase() !== "n") {
console.log("Fetching available models from provider...");
const result = await injectCodexConfig(port, config);
console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`);
}
saveConfig(config);
// Init writes a fresh config, so a stale pre-migration backup from a previous
// installation would make the next `ocx start` crash on a stale-backup
// collision (issue #257). But only a STALE backup (unparseable, or already a
// post-migration v2 snapshot) may be deleted; a backup that still parses as a
// valid pre-migration (v1) config is a user-intentional rollback point and is
// preserved by renaming it out of the collision path (sol review 260722).
cleanupOpenAiTierBackupAfterInit();
console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);

const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: ");
if (injectAnswer.trim().toLowerCase() !== "n") {
console.log("Fetching available models from provider...");
const result = await injectCodexConfig(port, config);
console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`);
}

const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: ");
if (shimAnswer.trim().toLowerCase() !== "n") {
try {
const { installCodexShim } = await import("../codex/shim");
const result = installCodexShim();
console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`);
} catch (err) {
console.log(`⚠️ Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`);
const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: ");
if (shimAnswer.trim().toLowerCase() !== "n") {
try {
const { installCodexShim } = await import("../codex/shim");
const result = installCodexShim();
console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`);
} catch (err) {
console.log(`⚠️ Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`);
}
}
}

console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`);
prompt.close();
console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/stdin (closed|reached EOF)/i.test(message)) {
console.error(`\n❌ ${message}. Re-run \`ocx init\` in an interactive terminal.`);
process.exitCode = 1;
return;
}
throw error;
} finally {
prompt.close();
}
}
33 changes: 33 additions & 0 deletions tests/init-eof.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

describe("ocx init piped stdin (#754)", () => {
const dirs: string[] = [];
afterEach(() => {
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true });
});

test("exits cleanly when stdin closes before the first prompt answer", async () => {
const home = mkdtempSync(join(tmpdir(), "ocx-init-eof-"));
dirs.push(home);
const cli = join(import.meta.dir, "..", "src", "cli", "index.ts");
const proc = Bun.spawn({
cmd: [process.execPath, cli, "init"],
env: { ...process.env, OPENCODEX_HOME: home },
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.end();
const exit = await Promise.race([
proc.exited,
new Promise<number>((_, reject) => setTimeout(() => reject(new Error("init did not exit after stdin EOF")), 8_000)),
]);
expect(exit).toBe(1);
const stderr = await new Response(proc.stderr).text();
expect(stderr.toLowerCase()).toMatch(/stdin (closed|reached eof)/);
expect(existsSync(join(home, "config.json"))).toBe(false);
});
});
Loading