mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
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.
This commit is contained in:
parent
6102be488e
commit
b79d9a95f6
3 changed files with 269 additions and 21 deletions
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue