diff --git a/src/config.test.ts b/src/config.test.ts index 28a62a08..a48173fb 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -227,6 +227,8 @@ describe("loadConfig", () => { expect(result.task).toBe("do it"); expect(result.providerError).toMatch(/missing/); expect(result.globalSettingsPath).toBe(NO_SETTINGS); + expect(result.cliConfigPath).toBeUndefined(); + expect(result.programmaticSettingsPath).toBe(true); } } finally { await rm(cwd, { recursive: true, force: true }); @@ -267,6 +269,8 @@ describe("loadConfig", () => { expect(result.configured).toBe(false); if (result.configured === false) { expect(result.globalSettingsPath).toBe(configPath); + expect(result.cliConfigPath).toBe(configPath); + expect(result.programmaticSettingsPath).toBe(false); } } finally { await rm(cwd, { recursive: true, force: true }); diff --git a/src/config/index.ts b/src/config/index.ts index 3a44057b..cc78dcf9 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -422,6 +422,10 @@ export interface UnconfiguredConfig { director?: DirectorId; // Path where the onboarding flow should write the new settings. globalSettingsPath: string; + /** Original CLI path, present only when --config selected the write target. */ + cliConfigPath?: string; + /** Whether the caller requested an OAuth-isolated programmatic settings load. */ + programmaticSettingsPath: boolean; // The original error message, used for non-TUI (exec) error output. providerError: string; /** @@ -739,6 +743,8 @@ export async function loadConfig( command, ...(director !== undefined ? { director } : {}), globalSettingsPath: effectiveSettingsPath, + ...(configPath !== undefined ? { cliConfigPath: configPath } : {}), + programmaticSettingsPath: options.globalSettingsPath !== undefined, providerError: err instanceof Error ? err.message : String(err), // Keep diagnostics even when provider setup fails early so junk local // files still reach stderr (exec) / banner (TUI after onboarding). diff --git a/src/tui/onboarding.test.ts b/src/tui/onboarding.test.ts new file mode 100644 index 00000000..af7435cb --- /dev/null +++ b/src/tui/onboarding.test.ts @@ -0,0 +1,238 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { Config, UnconfiguredConfig } from "../config/index.js"; +import type { ProviderSetupConfig } from "./provider-setup.js"; +import { withMockedModule } from "../../tests/helpers/mock-module.js"; + +let testHome = ""; +let setup: (config: ProviderSetupConfig) => Promise = async () => {}; +let tuiConfig: Config | undefined; + +await withMockedModule(import.meta.resolve("node:os"), (real: typeof import("node:os")) => ({ + ...real, + homedir: () => testHome, +})); +await withMockedModule( + import.meta.resolve("./provider-setup.js"), + (real: typeof import("./provider-setup.js")) => ({ + ...real, + runProviderSetup: async (config: ProviderSetupConfig) => { + await setup(config); + return true; + }, + }), +); +await withMockedModule( + import.meta.resolve("./runner.js"), + (real: typeof import("./runner.js")) => ({ + ...real, + runTUI: async (config: Config) => { + tuiConfig = config; + return 0; + }, + }), +); + +const { loadConfig } = await import("../config/index.js"); +const { runOnboarding } = await import("./onboarding.js"); + +async function unconfiguredConfig( + cwd: string, + paths: { cliConfigPath?: string; programmaticConfigPath?: string }, +): Promise { + const argv = ["--cwd", cwd]; + if (paths.cliConfigPath !== undefined) argv.push("--config", paths.cliConfigPath); + + const config = await loadConfig(argv, { + ...(paths.programmaticConfigPath !== undefined + ? { globalSettingsPath: paths.programmaticConfigPath } + : {}), + allowUnconfigured: true, + }); + if (config.configured) throw new Error("Expected onboarding config"); + return config; +} + +async function writeXAIAuthProfile(home: string, profile: string): Promise { + await mkdir(join(home, ".corbits"), { recursive: true }); + await writeFile( + join(home, ".corbits", "xai-auth.json"), + JSON.stringify({ + profiles: { + [profile]: { + name: profile, + tokens: { + access: `${profile}-access-token`, + refresh: `${profile}-refresh-token`, + expiresAt: Date.now() + 3_600_000, + }, + createdAt: Date.now(), + }, + }, + }), + ); +} + +afterEach(() => { + setup = async () => {}; + tuiConfig = undefined; +}); + +describe("runOnboarding settings source", () => { + test("reloads CLI --config with the selected OAuth profile projection", async () => { + testHome = await mkdtemp(join(tmpdir(), "corbits-onboarding-oauth-home-")); + const cwd = await mkdtemp(join(tmpdir(), "corbits-onboarding-oauth-cwd-")); + const configPath = join(cwd, "custom-settings.json"); + try { + await writeFile(configPath, JSON.stringify({ providers: {} })); + const config = await unconfiguredConfig(cwd, { cliConfigPath: configPath }); + + setup = async ({ onSubmit }) => { + await writeXAIAuthProfile(testHome, "work"); + await onSubmit( + { + name: "xai/work", + baseURL: "https://api.x.ai/v1", + apiKey: "", + model: "grok-4", + oauthProfile: "work", + }, + () => {}, + { + skipValidation: true, + oauth: { kind: "xai", profile: "work", providerName: "xai/work" }, + }, + ); + }; + + expect(await runOnboarding(config)).toBe(0); + expect(tuiConfig?.providerName).toBe("xai/work"); + expect(tuiConfig?.model).toBe("grok-4"); + expect(tuiConfig?.globalSettingsPath).toBe(configPath); + expect(tuiConfig?.providers.some((provider) => provider.name === "xai/work")).toBe(true); + + const persisted = JSON.parse(await readFile(configPath, "utf8")) as { + defaultProvider?: string; + providers?: Record; + }; + expect(persisted.defaultProvider).toBe("xai/work"); + expect(persisted.providers).toEqual({}); + } finally { + await rm(testHome, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("keeps API-key onboarding writes and reloads on CLI --config", async () => { + testHome = await mkdtemp(join(tmpdir(), "corbits-onboarding-key-home-")); + const cwd = await mkdtemp(join(tmpdir(), "corbits-onboarding-key-cwd-")); + const configPath = join(cwd, "custom-settings.json"); + try { + await writeFile(configPath, JSON.stringify({ providers: {} })); + const config = await unconfiguredConfig(cwd, { cliConfigPath: configPath }); + + setup = async ({ onSubmit }) => { + await onSubmit( + { + name: "custom", + baseURL: "https://provider.example.com/v1", + apiKey: "test-key", + model: "test-model", + oauthProfile: "", + }, + () => {}, + { skipValidation: true }, + ); + }; + + expect(await runOnboarding(config)).toBe(0); + expect(tuiConfig?.providerName).toBe("custom"); + expect(tuiConfig?.model).toBe("test-model"); + expect(tuiConfig?.globalSettingsPath).toBe(configPath); + + const persisted = JSON.parse(await readFile(configPath, "utf8")) as { + providers?: Record; + }; + expect(persisted.providers).toHaveProperty("custom"); + } finally { + await rm(testHome, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("keeps OAuth profiles isolated when CLI and programmatic paths are both supplied", async () => { + testHome = await mkdtemp(join(tmpdir(), "corbits-onboarding-both-home-")); + const cwd = await mkdtemp(join(tmpdir(), "corbits-onboarding-both-cwd-")); + const cliConfigPath = join(cwd, "cli-settings.json"); + const programmaticConfigPath = join(cwd, "programmatic-settings.json"); + try { + await writeXAIAuthProfile(testHome, "hidden"); + await writeFile(cliConfigPath, JSON.stringify({ providers: {} })); + await writeFile(programmaticConfigPath, JSON.stringify({ providers: {} })); + const config = await unconfiguredConfig(cwd, { + cliConfigPath, + programmaticConfigPath, + }); + expect(config.cliConfigPath).toBe(cliConfigPath); + expect(config.programmaticSettingsPath).toBe(true); + + setup = async ({ onSubmit }) => { + await onSubmit( + { + name: "isolated", + baseURL: "https://isolated.example.com/v1", + apiKey: "isolated-key", + model: "isolated-model", + oauthProfile: "", + }, + () => {}, + { skipValidation: true }, + ); + }; + + expect(await runOnboarding(config)).toBe(0); + expect(tuiConfig?.providerName).toBe("isolated"); + expect(tuiConfig?.providers.map((provider) => provider.name)).toEqual(["isolated"]); + expect(tuiConfig?.globalSettingsPath).toBe(cliConfigPath); + } finally { + await rm(testHome, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("keeps a default-path programmatic override isolated after reload", async () => { + testHome = await mkdtemp(join(tmpdir(), "corbits-onboarding-isolated-home-")); + const cwd = await mkdtemp(join(tmpdir(), "corbits-onboarding-isolated-cwd-")); + const configPath = join(testHome, ".corbits", "settings.json"); + try { + await writeXAIAuthProfile(testHome, "hidden"); + await writeFile(configPath, JSON.stringify({ providers: {} })); + const config = await unconfiguredConfig(cwd, { programmaticConfigPath: configPath }); + + setup = async ({ onSubmit }) => { + await onSubmit( + { + name: "isolated", + baseURL: "https://isolated.example.com/v1", + apiKey: "isolated-key", + model: "isolated-model", + oauthProfile: "", + }, + () => {}, + { skipValidation: true }, + ); + }; + + expect(await runOnboarding(config)).toBe(0); + expect(tuiConfig?.providerName).toBe("isolated"); + expect(tuiConfig?.providers.map((provider) => provider.name)).toEqual(["isolated"]); + expect(tuiConfig?.globalSettingsPath).toBe(configPath); + } finally { + await rm(testHome, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/src/tui/onboarding.ts b/src/tui/onboarding.ts index 61a46394..9d341667 100644 --- a/src/tui/onboarding.ts +++ b/src/tui/onboarding.ts @@ -37,18 +37,14 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise } const argv: string[] = ["--cwd", config.cwd]; + if (config.cliConfigPath !== undefined) argv.push("--config", config.cliConfigPath); if (config.dangerouslySkipPermissions) argv.push("--dangerously-skip-permissions"); if (config.force) argv.push("--force"); if (config.task.length > 0) argv.push(config.task); - // An explicit globalSettingsPath tells loadConfig it is on a controlled - // settings source and suppresses the home-level OAuth profile projection. - // Passing the default path would therefore hide a provider the operator just - // signed into, so it is only forwarded when it really is an override. - const overridesSettingsPath = settingsPath !== globalSettingsPath(); - const newConfig = await loadConfig( - argv, - overridesSettingsPath ? { globalSettingsPath: settingsPath } : {}, - ); + // Preserve programmatic isolation independently of the path that won settings + // precedence; CLI --config remains the write and reload target when both exist. + const loadOptions = config.programmaticSettingsPath ? { globalSettingsPath: settingsPath } : {}; + const newConfig = await loadConfig(argv, loadOptions); return runTUI(newConfig); }