mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-22 11:51:01 +02:00
feat(cli): redesign ktx status output with grouped checks and color
Replace flat PASS/FAIL/WARN text output with a grouped, symbol-based layout (Environment, Project, Semantic search, Query history). Passing groups collapse to a single summary line; failing groups expand to show individual checks with fix hints. Adds --verbose flag to show all checks including passing ones, color support for TTY terminals, a dedicated setup-mode report that guides users toward `ktx setup`, and timing info. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dabd640cad
commit
2b5dcc0122
4 changed files with 418 additions and 74 deletions
|
|
@ -16,8 +16,9 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
|
||||||
.command('status')
|
.command('status')
|
||||||
.description('Check current KTX setup and project readiness')
|
.description('Check current KTX setup and project readiness')
|
||||||
.option('--json', 'Print JSON output', false)
|
.option('--json', 'Print JSON output', false)
|
||||||
|
.option('-v, --verbose', 'Show every check, including passing ones', false)
|
||||||
.option('--no-input', 'Disable interactive terminal input')
|
.option('--no-input', 'Disable interactive terminal input')
|
||||||
.action(async (options: { json?: boolean; input?: boolean }, command) => {
|
.action(async (options: { json?: boolean; verbose?: boolean; input?: boolean }, command) => {
|
||||||
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
|
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
|
||||||
const explicitOrEnvProjectDir = resolveCommandProjectDirOverride(command);
|
const explicitOrEnvProjectDir = resolveCommandProjectDirOverride(command);
|
||||||
const nearestProjectDir = explicitOrEnvProjectDir ? undefined : findNearestKtxProjectDir(process.cwd());
|
const nearestProjectDir = explicitOrEnvProjectDir ? undefined : findNearestKtxProjectDir(process.cwd());
|
||||||
|
|
@ -27,6 +28,7 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
|
||||||
{
|
{
|
||||||
command: 'setup',
|
command: 'setup',
|
||||||
outputMode: outputMode(options),
|
outputMode: outputMode(options),
|
||||||
|
verbose: options.verbose === true,
|
||||||
...inputMode(options),
|
...inputMode(options),
|
||||||
},
|
},
|
||||||
context.io,
|
context.io,
|
||||||
|
|
@ -40,6 +42,7 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
|
||||||
command: 'project',
|
command: 'project',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
projectDir: resolveCommandProjectDir(command),
|
||||||
outputMode: outputMode(options),
|
outputMode: outputMode(options),
|
||||||
|
verbose: options.verbose === true,
|
||||||
...inputMode(options),
|
...inputMode(options),
|
||||||
},
|
},
|
||||||
context.io,
|
context.io,
|
||||||
|
|
|
||||||
|
|
@ -57,27 +57,64 @@ async function writeProjectConfig(projectDir: string, embeddingLines: string[]):
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('formatDoctorReport', () => {
|
describe('formatDoctorReport', () => {
|
||||||
it('prints exact fixes for failing setup checks', () => {
|
it('shows the failing check and its fix in plain output', () => {
|
||||||
const checks: DoctorCheck[] = [
|
const checks: DoctorCheck[] = [
|
||||||
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
|
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127', group: 'toolchain' },
|
||||||
{
|
{
|
||||||
id: 'native-sqlite',
|
id: 'native-sqlite',
|
||||||
label: 'Native SQLite',
|
label: 'Native SQLite',
|
||||||
status: 'fail',
|
status: 'fail',
|
||||||
detail: 'Cannot load better-sqlite3',
|
detail: 'Cannot load better-sqlite3',
|
||||||
fix: 'Run: pnpm run native:rebuild',
|
fix: 'Run: pnpm run native:rebuild',
|
||||||
|
group: 'toolchain',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(formatDoctorReport({ title: 'KTX setup doctor', checks })).toBe(
|
const output = formatDoctorReport({ title: 'KTX status', checks });
|
||||||
[
|
expect(output).toContain('KTX status');
|
||||||
'KTX setup doctor',
|
expect(output).toContain('✗ Environment');
|
||||||
'PASS Node 22+: v22.16.0 ABI 127',
|
expect(output).toContain('1 of 2 need attention');
|
||||||
'FAIL Native SQLite: Cannot load better-sqlite3',
|
expect(output).toContain('✗ Native SQLite: Cannot load better-sqlite3');
|
||||||
' Fix: Run: pnpm run native:rebuild',
|
expect(output).toContain('→ Run: pnpm run native:rebuild');
|
||||||
'',
|
expect(output).toContain('1 issue to fix.');
|
||||||
].join('\n'),
|
});
|
||||||
);
|
|
||||||
|
it('lists what was checked when a group has all passing checks', () => {
|
||||||
|
const checks: DoctorCheck[] = [
|
||||||
|
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0', group: 'toolchain' },
|
||||||
|
{ id: 'pnpm', label: 'pnpm 10.20+', status: 'pass', detail: '10.28.0', group: 'toolchain' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const output = formatDoctorReport({ title: 'KTX status', checks });
|
||||||
|
expect(output).toContain('✓ Environment');
|
||||||
|
expect(output).toContain('Node 22+ · pnpm 10.20+');
|
||||||
|
expect(output).not.toContain('v22.16.0');
|
||||||
|
expect(output).toContain('Everything ready.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the underlying detail for a single-check group on the group line', () => {
|
||||||
|
const checks: DoctorCheck[] = [
|
||||||
|
{
|
||||||
|
id: 'semantic-search-embeddings',
|
||||||
|
label: 'Semantic search embeddings',
|
||||||
|
status: 'pass',
|
||||||
|
detail: 'openai/text-embedding-3-small (1536d) probe succeeded',
|
||||||
|
group: 'search',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const output = formatDoctorReport({ title: 'KTX status', checks });
|
||||||
|
expect(output).toContain('✓ Semantic search');
|
||||||
|
expect(output).toContain('openai/text-embedding-3-small (1536d) probe succeeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists every check in verbose mode', () => {
|
||||||
|
const checks: DoctorCheck[] = [
|
||||||
|
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0', group: 'toolchain' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const output = formatDoctorReport({ title: 'KTX status', checks }, { verbose: true });
|
||||||
|
expect(output).toContain('✓ Node 22+: v22.16.0');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -127,6 +164,7 @@ describe('runSetupDoctorChecks', () => {
|
||||||
status: 'fail',
|
status: 'fail',
|
||||||
detail: 'pnpm not found',
|
detail: 'pnpm not found',
|
||||||
fix: 'Run: corepack enable && corepack prepare pnpm@10.28.0 --activate',
|
fix: 'Run: corepack enable && corepack prepare pnpm@10.28.0 --activate',
|
||||||
|
group: 'toolchain',
|
||||||
});
|
});
|
||||||
expect(checks).toContainEqual({
|
expect(checks).toContainEqual({
|
||||||
id: 'package-build',
|
id: 'package-build',
|
||||||
|
|
@ -134,6 +172,7 @@ describe('runSetupDoctorChecks', () => {
|
||||||
status: 'fail',
|
status: 'fail',
|
||||||
detail: 'Missing packages/cli/dist/bin.js',
|
detail: 'Missing packages/cli/dist/bin.js',
|
||||||
fix: 'Run: pnpm run build',
|
fix: 'Run: pnpm run build',
|
||||||
|
group: 'toolchain',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -154,9 +193,11 @@ describe('runSetupDoctorChecks', () => {
|
||||||
const testIo = makeIo();
|
const testIo = makeIo();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runKtxDoctor({ command: 'setup', outputMode: 'plain', inputMode: 'disabled' }, testIo.io, {
|
runKtxDoctor(
|
||||||
runSetupChecks: async () => checks,
|
{ command: 'setup', outputMode: 'plain', inputMode: 'disabled', verbose: true },
|
||||||
}),
|
testIo.io,
|
||||||
|
{ runSetupChecks: async () => checks },
|
||||||
|
),
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
|
|
||||||
expect(checks).toContainEqual({
|
expect(checks).toContainEqual({
|
||||||
|
|
@ -165,8 +206,9 @@ describe('runSetupDoctorChecks', () => {
|
||||||
status: 'warn',
|
status: 'warn',
|
||||||
detail: 'spawn corepack ENOENT',
|
detail: 'spawn corepack ENOENT',
|
||||||
fix: 'Run: corepack enable',
|
fix: 'Run: corepack enable',
|
||||||
|
group: 'toolchain',
|
||||||
});
|
});
|
||||||
expect(testIo.stdout()).toContain('WARN Corepack: spawn corepack ENOENT');
|
expect(testIo.stdout()).toContain('⚠ Corepack: spawn corepack ENOENT');
|
||||||
expect(testIo.stderr()).toBe('');
|
expect(testIo.stderr()).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -204,12 +246,45 @@ describe('runKtxDoctor', () => {
|
||||||
),
|
),
|
||||||
).resolves.toBe(1);
|
).resolves.toBe(1);
|
||||||
|
|
||||||
expect(testIo.stdout()).toContain('KTX setup doctor');
|
expect(testIo.stdout()).toContain('KTX status');
|
||||||
expect(testIo.stdout()).toContain('FAIL TypeScript package build: Missing packages/cli/dist/bin.js');
|
expect(testIo.stdout()).toContain('No project here yet.');
|
||||||
expect(testIo.stdout()).toContain('Fix: Run: pnpm run build');
|
expect(testIo.stdout()).toContain('Before you can run');
|
||||||
|
expect(testIo.stdout()).toContain('✗ TypeScript package build: Missing packages/cli/dist/bin.js');
|
||||||
|
expect(testIo.stdout()).toContain('→ Run: pnpm run build');
|
||||||
expect(testIo.stderr()).toBe('');
|
expect(testIo.stderr()).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leads with `ktx setup` and hides toolchain warnings when no project exists', async () => {
|
||||||
|
const testIo = makeIo();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runKtxDoctor(
|
||||||
|
{ command: 'setup', outputMode: 'plain', inputMode: 'disabled' },
|
||||||
|
testIo.io,
|
||||||
|
{
|
||||||
|
runSetupChecks: async () => [
|
||||||
|
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0', group: 'toolchain' },
|
||||||
|
{
|
||||||
|
id: 'corepack',
|
||||||
|
label: 'Corepack',
|
||||||
|
status: 'warn',
|
||||||
|
detail: 'spawn corepack ENOENT',
|
||||||
|
fix: 'Run: corepack enable',
|
||||||
|
group: 'toolchain',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).resolves.toBe(0);
|
||||||
|
|
||||||
|
const out = testIo.stdout();
|
||||||
|
expect(out).toContain('No project here yet.');
|
||||||
|
expect(out).toContain('Run');
|
||||||
|
expect(out).toContain('ktx setup');
|
||||||
|
expect(out).not.toContain('Corepack');
|
||||||
|
expect(out).not.toContain('Node 22+');
|
||||||
|
});
|
||||||
|
|
||||||
it('prints JSON setup report', async () => {
|
it('prints JSON setup report', async () => {
|
||||||
const testIo = makeIo();
|
const testIo = makeIo();
|
||||||
|
|
||||||
|
|
@ -226,7 +301,7 @@ describe('runKtxDoctor', () => {
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
|
|
||||||
expect(JSON.parse(testIo.stdout())).toEqual({
|
expect(JSON.parse(testIo.stdout())).toEqual({
|
||||||
title: 'KTX setup doctor',
|
title: 'KTX status',
|
||||||
checks: [{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' }],
|
checks: [{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -261,9 +336,9 @@ describe('runKtxDoctor', () => {
|
||||||
),
|
),
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
|
|
||||||
expect(testIo.stdout()).toContain('KTX project doctor');
|
expect(testIo.stdout()).toContain('KTX status');
|
||||||
expect(testIo.stdout()).toContain('PASS Project config: warehouse');
|
expect(testIo.stdout()).toContain('· warehouse');
|
||||||
expect(testIo.stdout()).toContain('PASS Connections: 1 configured');
|
expect(testIo.stdout()).toContain('✓ Project');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes Postgres historic-SQL readiness in project doctor output', async () => {
|
it('includes Postgres historic-SQL readiness in project doctor output', async () => {
|
||||||
|
|
@ -299,7 +374,7 @@ describe('runKtxDoctor', () => {
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runKtxDoctor(
|
runKtxDoctor(
|
||||||
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled', verbose: true },
|
||||||
testIo.io,
|
testIo.io,
|
||||||
{
|
{
|
||||||
runSetupChecks: async () => [
|
runSetupChecks: async () => [
|
||||||
|
|
@ -311,9 +386,9 @@ describe('runKtxDoctor', () => {
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
|
|
||||||
expect(runHistoricSqlDoctorChecks).toHaveBeenCalledTimes(1);
|
expect(runHistoricSqlDoctorChecks).toHaveBeenCalledTimes(1);
|
||||||
expect(testIo.stdout()).toContain('PASS Postgres Historic SQL (warehouse): pg_stat_statements ready');
|
expect(testIo.stdout()).toContain('✓ Postgres Historic SQL (warehouse): pg_stat_statements ready');
|
||||||
expect(testIo.stdout()).toContain('info: pg_stat_statements.max is 1000');
|
expect(testIo.stdout()).toContain('info: pg_stat_statements.max is 1000');
|
||||||
expect(testIo.stdout()).not.toContain('Fix: Update the Postgres parameter group or config');
|
expect(testIo.stdout()).not.toContain('→ Update the Postgres parameter group or config');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns when semantic-search embeddings are not configured', async () => {
|
it('warns when semantic-search embeddings are not configured', async () => {
|
||||||
|
|
@ -332,12 +407,13 @@ describe('runKtxDoctor', () => {
|
||||||
),
|
),
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
|
|
||||||
expect(testIo.stdout()).toContain('WARN Semantic search embeddings: ingest.embeddings.backend is deterministic.');
|
expect(testIo.stdout()).toContain('⚠ Semantic search');
|
||||||
|
expect(testIo.stdout()).toContain('ingest.embeddings.backend is deterministic.');
|
||||||
expect(testIo.stdout()).toContain(
|
expect(testIo.stdout()).toContain(
|
||||||
'Semantic lane will be skipped; lexical, dictionary, and token lanes remain available.',
|
'Semantic lane will be skipped; lexical, dictionary, and token lanes remain available.',
|
||||||
);
|
);
|
||||||
expect(testIo.stdout()).toContain(
|
expect(testIo.stdout()).toContain(
|
||||||
`Fix: Run: ktx setup --project-dir ${tempDir} --no-input`,
|
`→ Run: ktx setup --project-dir ${tempDir} --no-input`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -355,7 +431,7 @@ describe('runKtxDoctor', () => {
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runKtxDoctor(
|
runKtxDoctor(
|
||||||
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled', verbose: true },
|
||||||
testIo.io,
|
testIo.io,
|
||||||
{
|
{
|
||||||
runSetupChecks: async () => [
|
runSetupChecks: async () => [
|
||||||
|
|
@ -377,7 +453,7 @@ describe('runKtxDoctor', () => {
|
||||||
{ text: 'KTX semantic search doctor probe', timeoutMs: 1234 },
|
{ text: 'KTX semantic search doctor probe', timeoutMs: 1234 },
|
||||||
);
|
);
|
||||||
expect(testIo.stdout()).toContain(
|
expect(testIo.stdout()).toContain(
|
||||||
'PASS Semantic search embeddings: sentence-transformers/all-MiniLM-L6-v2 (384d) probe succeeded',
|
'✓ Semantic search embeddings: sentence-transformers/all-MiniLM-L6-v2 (384d) probe succeeded',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -454,6 +530,7 @@ describe('runKtxDoctor', () => {
|
||||||
detail:
|
detail:
|
||||||
'sentence-transformers/all-MiniLM-L6-v2 (384d) probe failed: connect ECONNREFUSED 127.0.0.1:8765. Semantic lane will be skipped; lexical, dictionary, and token lanes remain available.',
|
'sentence-transformers/all-MiniLM-L6-v2 (384d) probe failed: connect ECONNREFUSED 127.0.0.1:8765. Semantic lane will be skipped; lexical, dictionary, and token lanes remain available.',
|
||||||
fix: `Run: ktx setup --project-dir ${tempDir} --no-input`,
|
fix: `Run: ktx setup --project-dir ${tempDir} --no-input`,
|
||||||
|
group: 'search',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const execFileAsync = promisify(execFile);
|
||||||
type DoctorStatus = 'pass' | 'warn' | 'fail';
|
type DoctorStatus = 'pass' | 'warn' | 'fail';
|
||||||
type KtxDoctorOutputMode = 'plain' | 'json';
|
type KtxDoctorOutputMode = 'plain' | 'json';
|
||||||
type KtxDoctorInputMode = 'auto' | 'disabled';
|
type KtxDoctorInputMode = 'auto' | 'disabled';
|
||||||
|
type DoctorGroup = 'toolchain' | 'project' | 'search' | 'history';
|
||||||
|
|
||||||
export interface DoctorCheck {
|
export interface DoctorCheck {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -20,6 +21,7 @@ export interface DoctorCheck {
|
||||||
status: DoctorStatus;
|
status: DoctorStatus;
|
||||||
detail: string;
|
detail: string;
|
||||||
fix?: string;
|
fix?: string;
|
||||||
|
group?: DoctorGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DoctorReport {
|
interface DoctorReport {
|
||||||
|
|
@ -28,11 +30,22 @@ interface DoctorReport {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KtxDoctorArgs =
|
export type KtxDoctorArgs =
|
||||||
| { command: 'setup'; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode }
|
| {
|
||||||
| { command: 'project'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode };
|
command: 'setup';
|
||||||
|
outputMode: KtxDoctorOutputMode;
|
||||||
|
inputMode?: KtxDoctorInputMode;
|
||||||
|
verbose?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
command: 'project';
|
||||||
|
projectDir: string;
|
||||||
|
outputMode: KtxDoctorOutputMode;
|
||||||
|
inputMode?: KtxDoctorInputMode;
|
||||||
|
verbose?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface KtxDoctorIo {
|
interface KtxDoctorIo {
|
||||||
stdout: { write(chunk: string): void };
|
stdout: { isTTY?: boolean; write(chunk: string): void };
|
||||||
stderr: { write(chunk: string): void };
|
stderr: { write(chunk: string): void };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,56 +317,291 @@ export async function runSetupDoctorChecks(deps: SetupDoctorDeps = {}): Promise<
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return checks;
|
return checks.map((entry) => ({ ...entry, group: 'toolchain' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): Promise<DoctorCheck[]> {
|
interface ProjectChecksResult {
|
||||||
|
checks: DoctorCheck[];
|
||||||
|
projectName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): Promise<ProjectChecksResult> {
|
||||||
const { loadKtxProject } = await import('@ktx/context/project');
|
const { loadKtxProject } = await import('@ktx/context/project');
|
||||||
const checks: DoctorCheck[] = [];
|
const checks: DoctorCheck[] = [];
|
||||||
|
let projectName: string | undefined;
|
||||||
|
const tag = (entry: DoctorCheck, group: DoctorGroup): DoctorCheck => ({ ...entry, group });
|
||||||
try {
|
try {
|
||||||
const project = await loadKtxProject({ projectDir });
|
const project = await loadKtxProject({ projectDir });
|
||||||
checks.push(check('pass', 'project-config', 'Project config', project.config.project));
|
projectName = project.config.project;
|
||||||
|
checks.push(tag(check('pass', 'project-config', 'Project config', project.config.project), 'project'));
|
||||||
const connectionCount = Object.keys(project.config.connections).length;
|
const connectionCount = Object.keys(project.config.connections).length;
|
||||||
checks.push(
|
checks.push(
|
||||||
connectionCount > 0
|
tag(
|
||||||
? check('pass', 'connections', 'Connections', `${connectionCount} configured`)
|
connectionCount > 0
|
||||||
: check(
|
? check('pass', 'connections', 'Connections', `${connectionCount} configured`)
|
||||||
'warn',
|
: check(
|
||||||
'connections',
|
'warn',
|
||||||
'Connections',
|
'connections',
|
||||||
'0 configured',
|
'Connections',
|
||||||
'Add a connection to ktx.yaml or run `ktx setup`',
|
'0 configured',
|
||||||
),
|
'Add a connection to ktx.yaml or run `ktx setup`',
|
||||||
|
),
|
||||||
|
'project',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
checks.push(check('pass', 'storage', 'Storage', `${project.config.storage.state}/${project.config.storage.search}`));
|
checks.push(
|
||||||
checks.push(check('pass', 'llm-provider', 'LLM provider', project.config.llm.provider.backend));
|
tag(
|
||||||
checks.push(await runSemanticSearchEmbeddingCheck(project.config.ingest.embeddings, projectDir, deps));
|
check('pass', 'storage', 'Storage', `${project.config.storage.state}/${project.config.storage.search}`),
|
||||||
|
'project',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
checks.push(tag(check('pass', 'llm-provider', 'LLM provider', project.config.llm.provider.backend), 'project'));
|
||||||
|
checks.push(tag(await runSemanticSearchEmbeddingCheck(project.config.ingest.embeddings, projectDir, deps), 'search'));
|
||||||
const runHistoricSqlDoctorChecks =
|
const runHistoricSqlDoctorChecks =
|
||||||
deps.runHistoricSqlDoctorChecks ?? (await import('./historic-sql-doctor.js')).runPostgresHistoricSqlDoctorChecks;
|
deps.runHistoricSqlDoctorChecks ?? (await import('./historic-sql-doctor.js')).runPostgresHistoricSqlDoctorChecks;
|
||||||
checks.push(...(await runHistoricSqlDoctorChecks(project, deps)));
|
const historic = await runHistoricSqlDoctorChecks(project, deps);
|
||||||
|
for (const entry of historic) {
|
||||||
|
checks.push(tag(entry, 'history'));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
checks.push(
|
checks.push(
|
||||||
check(
|
tag(
|
||||||
'fail',
|
check(
|
||||||
'project-config',
|
'fail',
|
||||||
'Project config',
|
'project-config',
|
||||||
failureMessage(error),
|
'Project config',
|
||||||
`Run: ktx init ${projectDir} --name <project-name>`,
|
failureMessage(error),
|
||||||
|
`Run: ktx init ${projectDir} --name <project-name>`,
|
||||||
|
),
|
||||||
|
'project',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return checks;
|
return { checks, projectName };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDoctorReport(report: DoctorReport): string {
|
const STATUS_SYMBOL: Record<DoctorStatus, string> = { pass: '✓', warn: '⚠', fail: '✗' };
|
||||||
const lines = [report.title];
|
|
||||||
for (const item of report.checks) {
|
const GROUP_ORDER: DoctorGroup[] = ['toolchain', 'project', 'search', 'history'];
|
||||||
lines.push(`${item.status.toUpperCase()} ${item.label}: ${item.detail}`);
|
|
||||||
if (item.fix) {
|
const GROUP_LABEL: Record<DoctorGroup, string> = {
|
||||||
lines.push(` Fix: ${item.fix}`);
|
toolchain: 'Environment',
|
||||||
|
project: 'Project',
|
||||||
|
search: 'Semantic search',
|
||||||
|
history: 'Query history',
|
||||||
|
};
|
||||||
|
|
||||||
|
function shouldUseColor(io: KtxDoctorIo): boolean {
|
||||||
|
if (io.stdout.isTTY !== true) return false;
|
||||||
|
const env = process.env;
|
||||||
|
return !env.NO_COLOR && env.TERM !== 'dumb' && !env.CI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleStatus(useColor: boolean, status: DoctorStatus, text: string): string {
|
||||||
|
if (!useColor) return text;
|
||||||
|
const code = status === 'pass' ? 32 : status === 'warn' ? 33 : 31;
|
||||||
|
return `\u001b[${code}m${text}\u001b[39m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleDim(useColor: boolean, text: string): string {
|
||||||
|
return useColor ? `\u001b[2m${text}\u001b[22m` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleBold(useColor: boolean, text: string): string {
|
||||||
|
return useColor ? `\u001b[1m${text}\u001b[22m` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupOf(entry: DoctorCheck): DoctorGroup {
|
||||||
|
return entry.group ?? 'project';
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateStatus(checks: DoctorCheck[]): DoctorStatus {
|
||||||
|
if (checks.some((c) => c.status === 'fail')) return 'fail';
|
||||||
|
if (checks.some((c) => c.status === 'warn')) return 'warn';
|
||||||
|
return 'pass';
|
||||||
|
}
|
||||||
|
|
||||||
|
function abbreviateHome(filePath: string | undefined): string | undefined {
|
||||||
|
if (!filePath) return filePath;
|
||||||
|
const home = process.env.HOME;
|
||||||
|
if (home && (filePath === home || filePath.startsWith(`${home}/`))) {
|
||||||
|
return filePath === home ? '~' : `~${filePath.slice(home.length)}`;
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSummaryWhenAllPass(entries: DoctorCheck[]): string {
|
||||||
|
if (entries.length === 1) {
|
||||||
|
const only = entries[0]!;
|
||||||
|
return only.detail || only.label;
|
||||||
|
}
|
||||||
|
return entries.map((c) => c.label).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderOptions {
|
||||||
|
verbose: boolean;
|
||||||
|
useColor: boolean;
|
||||||
|
durationMs?: number;
|
||||||
|
projectName?: string;
|
||||||
|
projectDir?: string;
|
||||||
|
command?: 'setup' | 'project';
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEXT_STEPS_PROJECT = ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'];
|
||||||
|
|
||||||
|
export function formatDoctorReport(report: DoctorReport, options: Partial<RenderOptions> = {}): string {
|
||||||
|
const opts: RenderOptions = {
|
||||||
|
verbose: options.verbose ?? false,
|
||||||
|
useColor: options.useColor ?? false,
|
||||||
|
durationMs: options.durationMs,
|
||||||
|
projectName: options.projectName,
|
||||||
|
projectDir: options.projectDir,
|
||||||
|
command: options.command,
|
||||||
|
};
|
||||||
|
return renderPlainReport(report, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSetupReport(report: DoctorReport, options: RenderOptions): string {
|
||||||
|
const { verbose, useColor } = options;
|
||||||
|
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 symbol = (s: DoctorStatus) => status(s, STATUS_SYMBOL[s]);
|
||||||
|
|
||||||
|
const fails = report.checks.filter((c) => c.status === 'fail');
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(bold(report.title));
|
||||||
|
lines.push('');
|
||||||
|
lines.push(` No project here yet.`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
if (fails.length > 0) {
|
||||||
|
lines.push(` Before you can run ${bold('ktx setup')}, fix this:`);
|
||||||
|
for (const entry of fails) {
|
||||||
|
lines.push(` ${symbol('fail')} ${entry.label}: ${entry.detail}`);
|
||||||
|
if (entry.fix) {
|
||||||
|
lines.push(` ${dim(`→ ${entry.fix}`)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
} else {
|
||||||
|
lines.push(` Run ${bold('ktx setup')} to get started.`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
lines.push(dim(' Toolchain:'));
|
||||||
|
for (const entry of report.checks) {
|
||||||
|
lines.push(` ${symbol(entry.status)} ${entry.label}: ${entry.detail}`);
|
||||||
|
if (entry.fix && entry.status !== 'pass') {
|
||||||
|
lines.push(` ${dim(`→ ${entry.fix}`)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlainReport(report: DoctorReport, options: RenderOptions): string {
|
||||||
|
if (options.command === 'setup') return renderSetupReport(report, options);
|
||||||
|
const { verbose, useColor, durationMs, projectName, projectDir } = options;
|
||||||
|
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 symbol = (s: DoctorStatus) => status(s, STATUS_SYMBOL[s]);
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const titleParts: string[] = [bold(report.title)];
|
||||||
|
if (projectName) titleParts.push(projectName);
|
||||||
|
const abbreviatedDir = abbreviateHome(projectDir);
|
||||||
|
const titleLine = titleParts.join(` ${dim('·')} `);
|
||||||
|
const dirSuffix = abbreviatedDir ? ` ${dim(`(${abbreviatedDir})`)}` : '';
|
||||||
|
lines.push(`${titleLine}${dirSuffix}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
const groups = new Map<DoctorGroup, DoctorCheck[]>();
|
||||||
|
for (const entry of report.checks) {
|
||||||
|
const group = groupOf(entry);
|
||||||
|
const bucket = groups.get(group) ?? [];
|
||||||
|
bucket.push(entry);
|
||||||
|
groups.set(group, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedGroups: DoctorGroup[] = [];
|
||||||
|
for (const g of GROUP_ORDER) {
|
||||||
|
if (groups.has(g)) orderedGroups.push(g);
|
||||||
|
}
|
||||||
|
for (const g of groups.keys()) {
|
||||||
|
if (!orderedGroups.includes(g)) orderedGroups.push(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelWidth = orderedGroups.reduce(
|
||||||
|
(max, g) => Math.max(max, (GROUP_LABEL[g] ?? g).length),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const group of orderedGroups) {
|
||||||
|
const entries = groups.get(group) ?? [];
|
||||||
|
const head = aggregateStatus(entries);
|
||||||
|
const nonPass = entries.filter((c) => c.status !== 'pass');
|
||||||
|
const label = (GROUP_LABEL[group] ?? group).padEnd(labelWidth);
|
||||||
|
|
||||||
|
if (nonPass.length === 0) {
|
||||||
|
lines.push(` ${symbol(head)} ${label} ${dim(groupSummaryWhenAllPass(entries))}`);
|
||||||
|
if (verbose) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
lines.push(` ${symbol(entry.status)} ${entry.label}: ${entry.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 1) {
|
||||||
|
const only = entries[0]!;
|
||||||
|
lines.push(` ${symbol(only.status)} ${label} ${only.detail}`);
|
||||||
|
if (only.fix) {
|
||||||
|
lines.push(` ${' '.repeat(2 + labelWidth + 4)}${dim(`→ ${only.fix}`)}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(` ${symbol(head)} ${label} ${dim(`${nonPass.length} of ${entries.length} need attention`)}`);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.status === 'pass' && !verbose) continue;
|
||||||
|
lines.push(` ${symbol(entry.status)} ${entry.label}: ${entry.detail}`);
|
||||||
|
if (entry.fix) {
|
||||||
|
lines.push(` ${dim(`→ ${entry.fix}`)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
|
const totalFail = report.checks.filter((c) => c.status === 'fail').length;
|
||||||
|
const totalWarn = report.checks.filter((c) => c.status === 'warn').length;
|
||||||
|
const durationText = durationMs !== undefined ? ` ${dim(`(${(durationMs / 1000).toFixed(2)}s)`)}` : '';
|
||||||
|
|
||||||
|
if (totalFail === 0 && totalWarn === 0) {
|
||||||
|
const hint = ` ${dim('Try:')} ${NEXT_STEPS_PROJECT.join(dim(' · '))}`;
|
||||||
|
lines.push(`${status('pass', 'Everything ready.')}${hint}${durationText}`);
|
||||||
|
} else if (totalFail === 0) {
|
||||||
|
const word = totalWarn === 1 ? 'warning' : 'warnings';
|
||||||
|
lines.push(
|
||||||
|
`${status('warn', `${totalWarn} ${word}.`)} ${dim('Run')} ktx status --verbose ${dim('for full details.')}${durationText}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const fWord = totalFail === 1 ? 'issue' : 'issues';
|
||||||
|
const warnSuffix =
|
||||||
|
totalWarn > 0
|
||||||
|
? ` ${dim('·')} ${status('warn', `${totalWarn} ${totalWarn === 1 ? 'warning' : 'warnings'}`)}`
|
||||||
|
: '';
|
||||||
|
lines.push(
|
||||||
|
`${status('fail', `${totalFail} ${fWord} to fix.`)}${warnSuffix}${durationText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,12 +609,12 @@ function hasFailures(report: DoctorReport): boolean {
|
||||||
return report.checks.some((item) => item.status === 'fail');
|
return report.checks.some((item) => item.status === 'fail');
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io: KtxDoctorIo): void {
|
function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io: KtxDoctorIo, options: RenderOptions): void {
|
||||||
if (outputMode === 'json') {
|
if (outputMode === 'json') {
|
||||||
io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
io.stdout.write(formatDoctorReport(report));
|
io.stdout.write(renderPlainReport(report, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runKtxDoctor(
|
export async function runKtxDoctor(
|
||||||
|
|
@ -374,18 +622,34 @@ export async function runKtxDoctor(
|
||||||
io: KtxDoctorIo = process,
|
io: KtxDoctorIo = process,
|
||||||
deps: KtxDoctorDeps = {},
|
deps: KtxDoctorDeps = {},
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const runSetupChecks = deps.runSetupChecks ?? (() => runSetupDoctorChecks());
|
const runSetupChecks = deps.runSetupChecks ?? (() => runSetupDoctorChecks());
|
||||||
const setupChecks = await runSetupChecks();
|
const setupChecks = await runSetupChecks();
|
||||||
const report: DoctorReport =
|
let projectName: string | undefined;
|
||||||
args.command === 'setup'
|
let projectDir: string | undefined;
|
||||||
? { title: 'KTX setup doctor', checks: setupChecks }
|
let report: DoctorReport;
|
||||||
: {
|
if (args.command === 'setup') {
|
||||||
title: 'KTX project doctor',
|
report = { title: 'KTX status', checks: setupChecks };
|
||||||
checks: [...setupChecks, ...(await runProjectChecks(args.projectDir, deps))],
|
} else {
|
||||||
};
|
const projectResult = await runProjectChecks(args.projectDir, deps);
|
||||||
|
projectName = projectResult.projectName;
|
||||||
|
projectDir = args.projectDir;
|
||||||
|
report = {
|
||||||
|
title: 'KTX status',
|
||||||
|
checks: [...setupChecks, ...projectResult.checks],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
writeReport(report, args.outputMode, io);
|
const renderOptions: RenderOptions = {
|
||||||
|
verbose: args.verbose ?? false,
|
||||||
|
useColor: shouldUseColor(io),
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
projectName,
|
||||||
|
projectDir,
|
||||||
|
command: args.command,
|
||||||
|
};
|
||||||
|
writeReport(report, args.outputMode, io, renderOptions);
|
||||||
return hasFailures(report) ? 1 : 0;
|
return hasFailures(report) ? 1 : 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
|
|
||||||
|
|
@ -1012,7 +1012,7 @@ describe('runKtxCli', () => {
|
||||||
|
|
||||||
expect(setup).not.toHaveBeenCalled();
|
expect(setup).not.toHaveBeenCalled();
|
||||||
expect(doctor).toHaveBeenCalledWith(
|
expect(doctor).toHaveBeenCalledWith(
|
||||||
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
|
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled', verbose: false },
|
||||||
statusIo.io,
|
statusIo.io,
|
||||||
);
|
);
|
||||||
expect(statusIo.stderr()).toBe('');
|
expect(statusIo.stderr()).toBe('');
|
||||||
|
|
@ -1035,7 +1035,7 @@ describe('runKtxCli', () => {
|
||||||
await expect(runKtxCli(['status', '--json', '--no-input'], statusIo.io, { doctor })).resolves.toBe(0);
|
await expect(runKtxCli(['status', '--json', '--no-input'], statusIo.io, { doctor })).resolves.toBe(0);
|
||||||
|
|
||||||
expect(doctor).toHaveBeenCalledWith(
|
expect(doctor).toHaveBeenCalledWith(
|
||||||
{ command: 'setup', outputMode: 'json', inputMode: 'disabled' },
|
{ command: 'setup', outputMode: 'json', inputMode: 'disabled', verbose: false },
|
||||||
statusIo.io,
|
statusIo.io,
|
||||||
);
|
);
|
||||||
expect(statusIo.stderr()).toBe('');
|
expect(statusIo.stderr()).toBe('');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue