mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
fix(cli): validate --provider and make non-TTY report deterministic (#501)
Two CLI polish fixes found during a full command sweep: - Validate --provider against the known provider set (report, status, today, month, export, optimize, compare, models). An unknown provider previously produced a silently empty report; it now exits 1 with a clear message listing valid values, matching how --period and --format already behave. Provider names are exposed via a lazy allProviderNames() helper so importing the providers module never depends on every provider object being defined. - Make the non-interactive report/today/month render deterministic: yield a tick so ink flushes the one-shot frame to stdout before unmounting, instead of unmounting synchronously which could race the flush and drop the output. Adds CLI provider-validation tests (rejection, valid name, all sentinel, and a drift guard that allProviderNames covers every loadable provider).
This commit is contained in:
parent
15536ed71b
commit
c5bf09921b
4 changed files with 110 additions and 0 deletions
|
|
@ -1085,6 +1085,10 @@ export async function renderDashboard(period: Period = 'week', provider: string
|
|||
await waitUntilExit()
|
||||
} else {
|
||||
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} label={label} dayMode={initialDay != null} />, { 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<void>(resolve => setTimeout(resolve, 0))
|
||||
unmount()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/main.ts
18
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<void> {
|
||||
await loadPricing()
|
||||
const { range, label } = getDateRange(period)
|
||||
|
|
@ -421,6 +431,7 @@ program
|
|||
.option('--refresh <seconds>', '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<typeof parseDayFlag> = 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 <seconds>', '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 <seconds>', '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 <name>', '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 <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 <period>', 'Analysis period: today, week, 30days, month, all', 'all')
|
||||
.option('--provider <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 <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()
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,22 @@ async function loadCrush(): Promise<Provider | null> {
|
|||
|
||||
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<Provider[]> {
|
||||
const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw] = await Promise.all([
|
||||
loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(),
|
||||
|
|
|
|||
72
tests/cli-provider-validation.test.ts
Normal file
72
tests/cli-provider-validation.test.ts
Normal file
|
|
@ -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<string> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue