From b79d9a95f6033bc1d28f3ccd56435e1c62220113 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 14 May 2026 15:35:35 +0200 Subject: [PATCH] feat(cli): add ktx status --validate to run only ktx.yaml schema validation - New --validate flag dispatches a focused runKtxDoctor 'validate' branch that reads ktx.yaml, runs validateKtxProjectConfig, and skips LLM, connection, embedding, and query-history checks. - Plain output prints a single Config row; JSON output emits {ok: true} on success or the existing invalid_config / missing_project shapes on failure. --- packages/cli/src/commands/status-commands.ts | 64 ++++--- packages/cli/src/doctor.test.ts | 169 +++++++++++++++++++ packages/cli/src/doctor.ts | 57 +++++++ 3 files changed, 269 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/status-commands.ts b/packages/cli/src/commands/status-commands.ts index 52032e59..62c857f9 100644 --- a/packages/cli/src/commands/status-commands.ts +++ b/packages/cli/src/commands/status-commands.ts @@ -17,16 +17,51 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC .description('Check current KTX setup and project readiness') .option('--json', 'Print JSON output', false) .option('-v, --verbose', 'Show every check, including passing ones', false) + .option('--validate', 'Only validate the ktx.yaml schema; skip readiness checks', false) .option('--no-input', 'Disable interactive terminal input') - .action(async (options: { json?: boolean; verbose?: boolean; input?: boolean }, command) => { - const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor; - const explicitOrEnvProjectDir = resolveCommandProjectDirOverride(command); - const nearestProjectDir = explicitOrEnvProjectDir ? undefined : findNearestKtxProjectDir(process.cwd()); - if (!explicitOrEnvProjectDir && !nearestProjectDir) { + .action( + async ( + options: { json?: boolean; verbose?: boolean; validate?: boolean; input?: boolean }, + command, + ) => { + const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor; + const explicitOrEnvProjectDir = resolveCommandProjectDirOverride(command); + const nearestProjectDir = explicitOrEnvProjectDir ? undefined : findNearestKtxProjectDir(process.cwd()); + + if (options.validate === true) { + context.setExitCode( + await runner( + { + command: 'validate', + projectDir: resolveCommandProjectDir(command), + outputMode: outputMode(options), + ...inputMode(options), + }, + context.io, + ), + ); + return; + } + + if (!explicitOrEnvProjectDir && !nearestProjectDir) { + context.setExitCode( + await runner( + { + command: 'setup', + outputMode: outputMode(options), + verbose: options.verbose === true, + ...inputMode(options), + }, + context.io, + ), + ); + return; + } context.setExitCode( await runner( { - command: 'setup', + command: 'project', + projectDir: resolveCommandProjectDir(command), outputMode: outputMode(options), verbose: options.verbose === true, ...inputMode(options), @@ -34,19 +69,6 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC context.io, ), ); - return; - } - context.setExitCode( - await runner( - { - command: 'project', - projectDir: resolveCommandProjectDir(command), - outputMode: outputMode(options), - verbose: options.verbose === true, - ...inputMode(options), - }, - context.io, - ), - ); - }); + }, + ); } diff --git a/packages/cli/src/doctor.test.ts b/packages/cli/src/doctor.test.ts index aed2d3d3..5a9a3fdd 100644 --- a/packages/cli/src/doctor.test.ts +++ b/packages/cli/src/doctor.test.ts @@ -654,4 +654,173 @@ describe('runKtxDoctor', () => { expect(testIo.stdout()).toContain('semantic search degraded'); delete process.env.ANTHROPIC_API_KEY; }); + + describe('command: validate', () => { + it('prints a success line and exits 0 when ktx.yaml is schema-valid', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'project: warehouse', + 'connections:', + ' warehouse:', + ' driver: sqlite', + ' path: ./warehouse.db', + 'llm:', + ' provider:', + ' backend: anthropic', + '', + ].join('\n'), + 'utf-8', + ); + const testIo = makeIo(); + + await expect( + runKtxDoctor( + { command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, + testIo.io, + {}, + ), + ).resolves.toBe(0); + + const out = testIo.stdout(); + expect(out).toContain('KTX status'); + expect(out).toContain('Config'); + expect(out).toContain('ktx.yaml schema valid'); + expect(out).not.toContain('LLM'); + expect(out).not.toContain('Connections'); + expect(out).not.toContain('Pipeline'); + }); + + it('emits {ok: true} JSON when ktx.yaml is schema-valid', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'project: warehouse', + 'connections:', + ' warehouse:', + ' driver: sqlite', + ' path: ./warehouse.db', + 'llm:', + ' provider:', + ' backend: anthropic', + '', + ].join('\n'), + 'utf-8', + ); + const testIo = makeIo(); + + await expect( + runKtxDoctor( + { command: 'validate', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, + testIo.io, + {}, + ), + ).resolves.toBe(0); + + expect(JSON.parse(testIo.stdout())).toEqual({ ok: true, projectDir: tempDir }); + }); + + it('prints schema issues and exits 1 when ktx.yaml fails Zod validation', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'project: warehouse', + 'storrage:', + ' state: sqlite', + 'ingest:', + ' llm:', + ' backend: anthropic', + '', + ].join('\n'), + 'utf-8', + ); + const testIo = makeIo(); + + await expect( + runKtxDoctor( + { command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, + testIo.io, + {}, + ), + ).resolves.toBe(1); + + const out = testIo.stdout(); + expect(out).toContain('Unsupported storrage: unknown field'); + expect(out).toContain('Unsupported ingest.llm: use top-level llm.provider'); + }); + + it('emits structured JSON issues when validation fails', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + ['project: warehouse', 'storrage: {}', ''].join('\n'), + 'utf-8', + ); + const testIo = makeIo(); + + await expect( + runKtxDoctor( + { command: 'validate', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, + testIo.io, + {}, + ), + ).resolves.toBe(1); + + const parsed = JSON.parse(testIo.stdout()) as { error: string; issues: Array<{ path: string }> }; + expect(parsed.error).toBe('invalid_config'); + expect(parsed.issues.some((issue) => issue.path === 'storrage')).toBe(true); + }); + + it('prints the missing-project message and exits 1 when ktx.yaml is absent', async () => { + const testIo = makeIo(); + + await expect( + runKtxDoctor( + { command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, + testIo.io, + {}, + ), + ).resolves.toBe(1); + + expect(testIo.stdout()).toContain('No KTX project here yet.'); + }); + + it('does not invoke the Postgres query-history probe in validate mode', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'project: warehouse', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:WAREHOUSE_DATABASE_URL', + ' context:', + ' queryHistory:', + ' enabled: true', + 'llm:', + ' provider:', + ' backend: anthropic', + '', + ].join('\n'), + 'utf-8', + ); + const testIo = makeIo(); + let probeCalls = 0; + + await expect( + runKtxDoctor( + { command: 'validate', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, + testIo.io, + { + postgresQueryHistoryProbe: async () => { + probeCalls += 1; + return { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }; + }, + }, + ), + ).resolves.toBe(0); + + expect(probeCalls).toBe(0); + expect(testIo.stdout()).toContain('ktx.yaml schema valid'); + }); + }); }); diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index af50c0a6..b1845ae3 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -41,6 +41,12 @@ export type KtxDoctorArgs = outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode; verbose?: boolean; + } + | { + command: 'validate'; + projectDir: string; + outputMode: KtxDoctorOutputMode; + inputMode?: KtxDoctorInputMode; }; interface KtxDoctorIo { @@ -495,6 +501,40 @@ export function renderInvalidConfigMessage( io.stdout.write(lines.join('\n')); } +export function renderValidConfigMessage( + projectDir: string, + outputMode: KtxDoctorOutputMode, + io: KtxDoctorIo, +): void { + if (outputMode === 'json') { + io.stdout.write( + `${JSON.stringify( + { + ok: true, + projectDir, + }, + null, + 2, + )}\n`, + ); + return; + } + + const useColor = shouldUseColor(io); + const dim = (text: string) => styleDim(useColor, text); + const bold = (text: string) => styleBold(useColor, text); + const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text); + const abbreviated = abbreviateHome(projectDir) ?? projectDir; + + const lines: string[] = []; + lines.push(`${bold('KTX status')} ${dim('·')} ${abbreviated}`); + lines.push(''); + lines.push(` ${status('pass', '✓')} ${bold('Config')} ${dim('ktx.yaml schema valid')}`); + lines.push(''); + + io.stdout.write(lines.join('\n')); +} + export function renderMissingProjectMessage( projectDir: string, outputMode: KtxDoctorOutputMode, @@ -546,6 +586,23 @@ export async function runKtxDoctor( try { const runSetupChecks = deps.runSetupChecks ?? (() => runSetupDoctorChecks()); + if (args.command === 'validate') { + const configPath = join(args.projectDir, 'ktx.yaml'); + if (!(await defaultPathExists(configPath))) { + renderMissingProjectMessage(args.projectDir, args.outputMode, io); + return 1; + } + const { validateKtxProjectConfig } = await import('@ktx/context/project'); + const rawConfig = await readFile(configPath, 'utf-8'); + const validation = validateKtxProjectConfig(rawConfig); + if (!validation.ok) { + renderInvalidConfigMessage(args.projectDir, validation.issues, args.outputMode, io); + return 1; + } + renderValidConfigMessage(args.projectDir, args.outputMode, io); + return 0; + } + if (args.command === 'project') { const configPath = join(args.projectDir, 'ktx.yaml'); if (!(await defaultPathExists(configPath))) {