Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351/** * Tests for `shard setup` — interactive first-provider config wizard. * * Covers the pure pieces (parseSetupArgs, buildSetupConfig, collectAnswers) and the * I/O wrapper (runSetupSub) with injected fs + prompt deps so no real filesystem or TTY * is touched. Expert skip (--yes), existing-config-safe, and --force paths are exercised. */import { describe, expect, it } from 'bun:test';import { parseSetupArgs, buildSetupConfig, collectAnswers, runSetupSub, PROVIDER_PRESETS, SetupHelpRequested, type PromptFn,} from '@/cli/commands/setup-cmd';import type { ShardConfig } from '@/cli/config';
// ─── parseSetupArgs ────────────────────────────────────────────────────────────
describe('shard setup — parseSetupArgs', () => { it('parses --yes / -y', () => { expect(parseSetupArgs(['--yes'])).toMatchObject({ yes: true }); expect(parseSetupArgs(['-y'])).toMatchObject({ yes: true }); });
it('parses --force', () => { expect(parseSetupArgs(['--force'])).toMatchObject({ force: true }); });
it('parses --provider / --model / --alias / --base-url', () => { const p = parseSetupArgs(['--provider', 'ollama', '--model', 'llama3.2', '--alias', 'local', '--base-url', 'http://x/v1']); expect(p).toMatchObject({ providerKey: 'ollama', modelName: 'llama3.2', modelAlias: 'local', baseURL: 'http://x/v1' }); });
it('parses = forms', () => { const p = parseSetupArgs(['--provider=ollama', '--model=m', '--alias=a', '--base-url=u', '--env-var=K', '--auth=none', '--config=/c.json']); expect(p).toMatchObject({ providerKey: 'ollama', modelName: 'm', modelAlias: 'a', baseURL: 'u', envVar: 'K', authKind: 'none', configPath: '/c.json' }); });
it('parses --auth bearer', () => { expect(parseSetupArgs(['--auth', 'bearer'])).toMatchObject({ authKind: 'bearer' }); });
it('throws on invalid --auth', () => { expect(() => parseSetupArgs(['--auth', 'bogus'])).toThrow('invalid --auth'); });
it('throws on unknown argument', () => { expect(() => parseSetupArgs(['--bogus'])).toThrow('unknown argument'); });
it('throws SetupHelpRequested on --help', () => { expect(() => parseSetupArgs(['--help'])).toThrow(SetupHelpRequested); expect(() => parseSetupArgs(['-h'])).toThrow(SetupHelpRequested); });
it('throws on --provider with no value', () => { expect(() => parseSetupArgs(['--provider'])).toThrow('invalid --provider'); });
it('defaults yes=false, force=false', () => { const p = parseSetupArgs([]); expect(p.yes).toBe(false); expect(p.force).toBe(false); });});
// ─── buildSetupConfig ──────────────────────────────────────────────────────────
describe('shard setup — buildSetupConfig', () => { it('builds a complete OpenAI starter config (bearer)', () => { const cfg = buildSetupConfig({ providerKey: 'openai', baseURL: 'https://api.openai.com/v1', auth: { kind: 'bearer', envVar: 'OPENAI_API_KEY' }, modelAlias: 'fast', modelName: 'gpt-4o-mini', }); expect(cfg.defaultProvider).toBe('openai'); expect(cfg.defaultModel).toEqual({ openai: 'fast' }); expect(cfg.providers.openai).toEqual({ baseURL: 'https://api.openai.com/v1', auth: { kind: 'bearer', envVar: 'OPENAI_API_KEY' }, }); expect(cfg.models!.fast).toEqual({ provider: 'openai', name: 'gpt-4o-mini' }); });
it('builds an Ollama config (none auth)', () => { const cfg = buildSetupConfig({ providerKey: 'ollama', baseURL: 'http://127.0.0.1:11434/v1', auth: { kind: 'none' }, modelAlias: 'local', modelName: 'llama3.2:3b', }); expect(cfg.providers.ollama!.auth).toEqual({ kind: 'none' }); expect(cfg.models!.local).toEqual({ provider: 'ollama', name: 'llama3.2:3b' }); });
it('builds a custom-provider config', () => { const cfg = buildSetupConfig({ providerKey: 'my-gw', baseURL: 'https://gw.example/v1', auth: { kind: 'bearer', envVar: 'GW_KEY' }, modelAlias: 'default', modelName: 'gpt-4o', }); expect(cfg.defaultProvider).toBe('my-gw'); expect(cfg.providers['my-gw']).toBeDefined(); expect(cfg.defaultModel).toEqual({ 'my-gw': 'default' }); });});
// ─── collectAnswers ────────────────────────────────────────────────────────────
/** Build a prompt fn from a queue of answers (consumed in order). An empty or missing * queued answer falls back to the defaultValue, mirroring the real readline prompt. */function queuedPrompt(answers: string[]): PromptFn { let i = 0; return async (_question: string, defaultValue?: string) => { const a = answers[i++]; if (a !== undefined && a.length > 0) return a; return defaultValue ?? ''; };}
describe('shard setup — collectAnswers', () => { it('--yes uses OpenAI preset defaults (no prompts)', async () => { const answers = await collectAnswers(parseSetupArgs(['--yes']), async () => 'SHOULD-NOT-CALL', false); expect(answers.providerKey).toBe('openai'); expect(answers.baseURL).toBe('https://api.openai.com/v1'); expect(answers.auth).toEqual({ kind: 'bearer', envVar: 'OPENAI_API_KEY' }); expect(answers.modelName).toBe('gpt-4o-mini'); expect(answers.modelAlias).toBe('fast'); });
it('--yes --provider ollama uses Ollama preset (none auth)', async () => { const answers = await collectAnswers(parseSetupArgs(['--yes', '--provider', 'ollama']), async () => '', false); expect(answers.providerKey).toBe('ollama'); expect(answers.auth).toEqual({ kind: 'none' }); expect(answers.modelName).toBe('llama3.2:3b'); });
it('interactive: choose Ollama (2) via menu', async () => { const prompt = queuedPrompt(['2']); // menu choice = Ollama const answers = await collectAnswers(parseSetupArgs([]), prompt, true); expect(answers.providerKey).toBe('ollama'); expect(answers.auth).toEqual({ kind: 'none' }); // Ollama preset has a model name, so no model prompt; alias IS prompted interactively. expect(answers.modelName).toBe('llama3.2:3b'); });
it('interactive: custom provider prompts for key, baseURL, model', async () => { // menu=3 (custom), providerKey, baseURL, envVar, modelName, alias const prompt = queuedPrompt(['3', 'my-gw', 'https://gw/v1', 'GW_KEY', 'gpt-4o', 'main']); const answers = await collectAnswers(parseSetupArgs([]), prompt, true); expect(answers.providerKey).toBe('my-gw'); expect(answers.baseURL).toBe('https://gw/v1'); expect(answers.auth).toEqual({ kind: 'bearer', envVar: 'GW_KEY' }); expect(answers.modelName).toBe('gpt-4o'); expect(answers.modelAlias).toBe('main'); });
it('interactive: default menu choice (enter) → OpenAI', async () => { // empty answer falls back to default '1' → OpenAI; then alias prompt. const prompt = queuedPrompt(['', 'smart']); const answers = await collectAnswers(parseSetupArgs([]), prompt, true); expect(answers.providerKey).toBe('openai'); expect(answers.modelAlias).toBe('smart'); });
it('flags override prompts (interactive but values pre-supplied)', async () => { const prompt = queuedPrompt(['UNREACHABLE']); const answers = await collectAnswers( parseSetupArgs(['--provider', 'openai', '--model', 'gpt-4o', '--alias', 'big', '--base-url', 'https://api.openai.com/v1']), prompt, true, ); // provider is a known preset → no menu. baseURL/model/alias from flags. Only alias // is prompted interactively, but it was supplied via flag so the prompt is skipped. expect(answers.modelName).toBe('gpt-4o'); expect(answers.modelAlias).toBe('big'); });});
// ─── runSetupSub (I/O wrapper) ─────────────────────────────────────────────────
/** In-memory fs for runSetupSub tests. */function memFs(initial: Record<string, string> = {}) { const files = new Map<string, string>(Object.entries(initial)); return { existsSync: (p: string) => files.has(p), mkdirSync: () => {}, writeFileSync: (p: string, d: string) => { files.set(p, d); }, read: (p: string) => files.get(p), };}
describe('shard setup — runSetupSub', () => { it('--yes writes a valid config and exits 0', async () => { const fs = memFs(); const configPath = '/tmp/x/config.json'; const code = await runSetupSub(['--yes'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }); expect(code).toBe(0); const written = JSON.parse(fs.read(configPath)!) as ShardConfig; expect(written.defaultProvider).toBe('openai'); expect(written.providers.openai!.auth).toEqual({ kind: 'bearer', envVar: 'OPENAI_API_KEY' }); });
it('existing config is safe (exit 0, no overwrite)', async () => { const configPath = '/tmp/x/config.json'; const fs = memFs({ [configPath]: '{"defaultProvider":"ollama"}' }); const code = await runSetupSub(['--yes'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }); expect(code).toBe(0); // Original content preserved. expect(fs.read(configPath)).toBe('{"defaultProvider":"ollama"}'); });
it('--force overwrites an existing config', async () => { const configPath = '/tmp/x/config.json'; const fs = memFs({ [configPath]: '{"defaultProvider":"ollama"}' }); const code = await runSetupSub(['--yes', '--force'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }); expect(code).toBe(0); const written = JSON.parse(fs.read(configPath)!) as ShardConfig; expect(written.defaultProvider).toBe('openai'); // overwritten });
it('--yes --provider ollama writes a none-auth config (no export hint)', async () => { const fs = memFs(); const configPath = '/tmp/x/config.json'; const code = await runSetupSub(['--yes', '--provider', 'ollama'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }); expect(code).toBe(0); const written = JSON.parse(fs.read(configPath)!) as ShardConfig; expect(written.providers.ollama!.auth).toEqual({ kind: 'none' }); });
it('interactive path with injected prompt writes config from queued answers', async () => { const fs = memFs(); const configPath = '/tmp/x/config.json'; // menu=2 (ollama), alias prompt → 'local-model' const prompt = queuedPrompt(['2', 'local-model']); const code = await runSetupSub([], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, prompt, env: {}, }); expect(code).toBe(0); const written = JSON.parse(fs.read(configPath)!) as ShardConfig; expect(written.defaultProvider).toBe('ollama'); expect(written.models!['local-model']).toEqual({ provider: 'ollama', name: 'llama3.2:3b' }); });
it('custom provider via flags + --yes (no prompts)', async () => { const fs = memFs(); const configPath = '/tmp/x/config.json'; const code = await runSetupSub( ['--yes', '--provider', 'my-gw', '--base-url', 'https://gw/v1', '--model', 'gpt-4o', '--alias', 'main', '--env-var', 'GW_KEY'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }, ); expect(code).toBe(0); const written = JSON.parse(fs.read(configPath)!) as ShardConfig; expect(written.defaultProvider).toBe('my-gw'); expect(written.providers['my-gw']).toEqual({ baseURL: 'https://gw/v1', auth: { kind: 'bearer', envVar: 'GW_KEY' } }); expect(written.models!.main).toEqual({ provider: 'my-gw', name: 'gpt-4o' }); });
it('--help exits 0', async () => { const code = await runSetupSub(['--help'], { env: {} }); expect(code).toBe(0); });
it('unknown flag exits 2', async () => { const code = await runSetupSub(['--bogus'], { env: {} }); expect(code).toBe(2); });
it('written config passes the loadConfig schema (round-trip)', async () => { const { loadConfig } = await import('@/cli/config'); const fs = memFs(); const configPath = '/tmp/x/config.json'; await runSetupSub(['--yes'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }); // loadConfig should accept the wizard output without throwing. const { config } = loadConfig({ configPath, env: { OPENAI_API_KEY: 'sk-test' }, deps: { readFileSync: () => fs.read(configPath)!, existsSync: () => true }, }); expect(config.defaultProvider).toBe('openai'); });});
// ─── Presets sanity ────────────────────────────────────────────────────────────
describe('shard setup — PROVIDER_PRESETS', () => { it('has openai first (default choice)', () => { expect(PROVIDER_PRESETS[0]!.key).toBe('openai'); });
it('includes ollama (none auth) and custom', () => { const keys = PROVIDER_PRESETS.map((p) => p.key); expect(keys).toContain('ollama'); expect(keys).toContain('custom'); });
it('ollama preset has none auth', () => { const ollama = PROVIDER_PRESETS.find((p) => p.key === 'ollama')!; expect(ollama.auth).toBe('none'); });});