diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 243aa8d..d942f9d 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1085,6 +1085,10 @@ export async function renderDashboard(period: Period = 'week', provider: string await waitUntilExit() } else { const { unmount } = render(, { patchConsole: false }) + // Non-interactive one-shot output: ink schedules the frame through a + // throttled render, so yield a tick to let it flush to stdout before + // unmounting. Unmounting synchronously can race the flush and drop output. + await new Promise(resolve => setTimeout(resolve, 0)) unmount() } } diff --git a/src/main.ts b/src/main.ts index 1fad67b..f92c07f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { installMenubarApp } from './menubar-installer.js' import { exportCsv, exportJson, type PeriodExport } from './export.js' import { loadPricing, setModelAliases, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js' import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js' +import { allProviderNames } from './providers/index.js' import { convertCost } from './currency.js' import { renderStatusBar } from './format.js' import { toDateString } from './daily-cache.js' @@ -122,6 +123,15 @@ function assertFormat(value: string, allowed: readonly string[], command: string } } +function assertProvider(value: string, command: string): void { + const names = allProviderNames() + if (value === 'all' || names.includes(value)) return + process.stderr.write( + `codeburn ${command}: unknown provider "${value}". Valid values: all, ${names.join(', ')}.\n` + ) + process.exit(1) +} + async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise { await loadPricing() const { range, label } = getDateRange(period) @@ -421,6 +431,7 @@ program .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'report') + assertProvider(opts.provider, 'report') let customRange: DateRange | null = null let daySelection: ReturnType = null try { @@ -473,6 +484,7 @@ program .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)') .action(async (opts) => { assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status') + assertProvider(opts.provider, 'status') if (opts.day && (opts.from || opts.to)) { process.stderr.write('error: --day cannot be combined with --from or --to\n') process.exit(1) @@ -553,6 +565,7 @@ program .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'today') + assertProvider(opts.provider, 'today') if (opts.format === 'json') { await runJsonReport('today', opts.provider, opts.project, opts.exclude) return @@ -570,6 +583,7 @@ program .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'month') + assertProvider(opts.provider, 'month') if (opts.format === 'json') { await runJsonReport('month', opts.provider, opts.project, opts.exclude) return @@ -589,6 +603,7 @@ program .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) .action(async (opts) => { assertFormat(opts.format, ['csv', 'json'], 'export') + assertProvider(opts.provider, 'export') await loadPricing() const pf = opts.provider const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) @@ -1036,6 +1051,7 @@ program .option('--format ', 'Output format: text, json', 'text') .action(async (opts) => { assertFormat(opts.format, ['text', 'json'], 'optimize') + assertProvider(opts.provider, 'optimize') await loadPricing() const { range, label } = getDateRange(opts.period) const projects = await parseAllSessions(range, opts.provider) @@ -1048,6 +1064,7 @@ program .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'all') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .action(async (opts) => { + assertProvider(opts.provider, 'compare') await loadPricing() const { range } = getDateRange(opts.period) await renderCompare(range, opts.provider) @@ -1067,6 +1084,7 @@ program .option('--no-totals', 'Suppress the footer totals row') .option('--format ', 'Output format: table, markdown, json, csv', 'table') .action(async (opts) => { + assertProvider(opts.provider, 'models') const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js') await loadPricing() diff --git a/src/providers/index.ts b/src/providers/index.ts index 93af972..f52886b 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -154,6 +154,22 @@ async function loadCrush(): Promise { const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode] +// Lazily loaded providers, listed by name so --provider validation works even +// when an optional module fails to load. Must stay in sync with getAllProviders. +const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway'] + +// Canonical set of every provider name (core + lazy), used to validate the +// --provider CLI flag. Computed lazily so importing this module never depends on +// every provider object being defined at load time (e.g. under test mocks). +let allProviderNamesCache: string[] | undefined +export function allProviderNames(): readonly string[] { + allProviderNamesCache ??= [ + ...coreProviders.map(p => p.name), + ...lazyProviderNames, + ].sort() + return allProviderNamesCache +} + export async function getAllProviders(): Promise { const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw] = await Promise.all([ loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), diff --git a/tests/cli-provider-validation.test.ts b/tests/cli-provider-validation.test.ts new file mode 100644 index 0000000..4fa9089 --- /dev/null +++ b/tests/cli-provider-validation.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { afterEach, describe, expect, it } from 'vitest' + +import { allProviderNames, getAllProviders } from '../src/providers/index.js' + +let homes: string[] = [] + +afterEach(async () => { + while (homes.length > 0) { + const h = homes.pop() + if (h) await rm(h, { recursive: true, force: true }) + } +}) + +async function makeHome(): Promise { + const home = await mkdtemp(join(tmpdir(), 'codeburn-provider-cli-')) + homes.push(home) + return home +} + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +describe('allProviderNames', () => { + it('is non-empty, sorted, and excludes the "all" sentinel', () => { + const names = allProviderNames() + expect(names.length).toBeGreaterThan(0) + expect([...names]).toEqual([...names].sort()) + expect(names).not.toContain('all') + }) + + it('covers every loadable provider (guards against lazy-list drift)', async () => { + const loaded = (await getAllProviders()).map(p => p.name) + const names = allProviderNames() + for (const name of loaded) { + expect(names).toContain(name) + } + }) +}) + +describe('codeburn --provider validation', () => { + it('rejects an unknown provider with a clear error and exit 1', async () => { + const home = await makeHome() + const res = runCli(['report', '--provider', 'claud', '-p', 'today'], home) + expect(res.status).toBe(1) + expect(res.stderr).toContain('unknown provider "claud"') + expect(res.stderr).toContain('Valid values: all,') + }) + + it('accepts a valid provider', async () => { + const home = await makeHome() + const res = runCli(['status', '--provider', 'claude', '--format', 'json'], home) + expect(res.status).toBe(0) + expect(res.stderr).not.toContain('unknown provider') + }) + + it('accepts the "all" sentinel', async () => { + const home = await makeHome() + const res = runCli(['status', '--provider', 'all', '--format', 'json'], home) + expect(res.status).toBe(0) + }) +})