Merge remote-tracking branch 'origin/main' into explore-research-agent-tools

# Conflicts:
#	packages/cli/src/print-command-tree.test.ts
#	packages/context/skills/sl_capture/SKILL.md
This commit is contained in:
Andrey Avtomonov 2026-05-14 22:05:00 +02:00
commit 6c73029d0c
163 changed files with 2908 additions and 1663 deletions

View file

@ -21,7 +21,7 @@ export interface KtxCliCommandContext {
deps: KtxCliDeps;
packageInfo: KtxCliPackageInfo;
setExitCode: (code: number) => void;
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise<number>;
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
writeDebug?: (command: string, commandContext: CommandWithGlobalOptions) => void;
}
@ -33,14 +33,14 @@ export interface OutputModeOptions {
}
interface KtxCommanderProgramOptions {
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise<number>;
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
}
export interface BuildKtxProgramOptions {
io: KtxCliIo;
deps: KtxCliDeps;
packageInfo: KtxCliPackageInfo;
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise<number>;
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
setExitCode?: (code: number) => void;
}

View file

@ -65,14 +65,10 @@ export function packageInfoFromJson(packageJson: unknown): KtxCliPackageInfo {
};
}
async function runInit(
args: { projectDir: string; projectName?: string; force: boolean },
io: KtxCliIo,
): Promise<number> {
async function runInit(args: { projectDir: string; force: boolean }, io: KtxCliIo): Promise<number> {
const { initKtxProject } = await import('@ktx/context/project');
const result = await initKtxProject({
projectDir: args.projectDir,
projectName: args.projectName,
force: args.force,
});
@ -83,7 +79,7 @@ async function runInit(
}
export async function runInitForCommander(
args: { projectDir: string; projectName?: string; force: boolean },
args: { projectDir: string; force: boolean },
io: KtxCliIo,
): Promise<number> {
return await runInit(args, io);

View file

@ -33,12 +33,24 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm
connection
.command('test')
.description('Test a configured connection')
.argument('<connectionId>', 'KTX connection id')
.action(async (connectionId: string, _options: unknown, command) => {
.argument('[connectionId]', 'KTX connection id (omit when --all is set)')
.option('--all', 'Test every configured connection and print a summary list')
.action(async (connectionId: string | undefined, options: { all?: boolean }, command) => {
const all = options.all === true;
if (all && connectionId !== undefined) {
command.error('error: --all cannot be combined with a connection id argument');
}
if (!all && connectionId === undefined) {
command.error('error: missing required argument <connectionId> (or pass --all)');
}
if (all) {
await runConnectionArgs(context, { command: 'test-all', projectDir: resolveCommandProjectDir(command) });
return;
}
await runConnectionArgs(context, {
command: 'test',
projectDir: resolveCommandProjectDir(command),
connectionId,
connectionId: connectionId as string,
});
});
}

View file

@ -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,
),
);
});
},
);
}

View file

@ -1,12 +1,16 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { MetabaseRuntimeClient } from '@ktx/context/ingest';
import type { LookerClient, MetabaseRuntimeClient, NotionClient } from '@ktx/context/ingest';
import { initKtxProject, parseKtxProjectConfig, serializeKtxProjectConfig } from '@ktx/context/project';
import type { KtxConnectionDriver, KtxScanConnector, KtxSchemaSnapshot } from '@ktx/context/scan';
import type { KtxConnectionDriver, KtxScanConnector } from '@ktx/context/scan';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runKtxConnection } from './connection.js';
function stripAnsi(s: string): string {
return s.replace(/\[[0-9;]*m/g, '');
}
function makeIo() {
let stdout = '';
let stderr = '';
@ -28,28 +32,11 @@ function makeIo() {
};
}
function snapshotFor(driver: KtxConnectionDriver, tableNames: string[]): KtxSchemaSnapshot {
return {
connectionId: 'warehouse',
driver,
extractedAt: '2026-04-29T00:00:00.000Z',
scope: {},
metadata: {},
tables: tableNames.map((name) => ({
catalog: null,
db: null,
name,
kind: 'table',
comment: null,
estimatedRows: null,
columns: [],
foreignKeys: [],
})),
};
}
function nativeConnector(driver: KtxConnectionDriver, tableNames: string[]) {
const introspect = vi.fn(async () => snapshotFor(driver, tableNames));
function nativeConnector(
driver: KtxConnectionDriver,
testResult: { success: true } | { success: false; error: string } = { success: true },
) {
const testConnection = vi.fn(async () => testResult);
const cleanup = vi.fn(async () => undefined);
const connector: KtxScanConnector = {
id: `${driver}:warehouse`,
@ -65,10 +52,13 @@ function nativeConnector(driver: KtxConnectionDriver, tableNames: string[]) {
formalForeignKeys: false,
estimatedRowCounts: false,
},
introspect,
introspect: vi.fn(async () => {
throw new Error('introspect should not be called from connection test');
}),
testConnection,
cleanup,
};
return { connector, introspect, cleanup };
return { connector, testConnection, cleanup };
}
describe('runKtxConnection', () => {
@ -92,7 +82,7 @@ describe('runKtxConnection', () => {
it('lists configured connections without resolving secrets', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' },
docs: { driver: 'notion', auth_token_ref: 'env:NOTION_TOKEN', crawl_mode: 'all_accessible' },
@ -110,7 +100,7 @@ describe('runKtxConnection', () => {
it('prints an empty-state message that points at setup instead of removed connection add', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const io = makeIo();
await expect(runKtxConnection({ command: 'list', projectDir }, io.io)).resolves.toBe(0);
@ -119,13 +109,13 @@ describe('runKtxConnection', () => {
expect(io.stdout()).not.toContain('ktx connection add');
});
it('tests a configured connection through the native scan connector', async () => {
it('tests a native connection by calling connector.testConnection (not introspect)', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
});
const { connector, introspect, cleanup } = nativeConnector('sqlite', ['customers', 'orders']);
const { connector, testConnection, cleanup } = nativeConnector('sqlite');
const createScanConnector = vi.fn(async () => connector);
const io = makeIo();
@ -136,25 +126,36 @@ describe('runKtxConnection', () => {
).resolves.toBe(0);
expect(createScanConnector).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'warehouse');
expect(introspect).toHaveBeenCalledWith(
{
connectionId: 'warehouse',
driver: 'sqlite',
mode: 'structural',
dryRun: true,
detectRelationships: false,
},
{ runId: 'connection-test-warehouse' },
);
expect(testConnection).toHaveBeenCalledTimes(1);
expect(connector.introspect).not.toHaveBeenCalled();
expect(cleanup).toHaveBeenCalledTimes(1);
expect(io.stdout()).toContain('Connection test passed: warehouse');
expect(io.stdout()).toContain('Driver: sqlite');
expect(io.stdout()).toContain('Tables: 2');
expect(io.stdout()).toContain('Status: ok');
});
it('reports the connector error and still cleans up when native testConnection fails', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
});
const { connector, cleanup } = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' });
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
createScanConnector: vi.fn(async () => connector),
}),
).resolves.toBe(1);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(io.stderr()).toContain('database file is unreadable');
});
it('tests a configured Metabase connection through the Metabase runtime client', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
prod_metabase: {
driver: 'metabase',
@ -198,41 +199,305 @@ describe('runKtxConnection', () => {
expect(io.stderr()).toBe('');
});
it('cleans up the native scan connector when connection testing fails', async () => {
it('tests a Looker connection through the Looker client', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
});
const cleanup = vi.fn(async () => undefined);
const connector: KtxScanConnector = {
id: 'sqlite:warehouse',
driver: 'sqlite',
capabilities: {
structuralIntrospection: true,
tableSampling: false,
columnSampling: false,
columnStats: false,
readOnlySql: false,
nestedAnalysis: false,
eventStreamDiscovery: false,
formalForeignKeys: false,
estimatedRowCounts: false,
bi_looker: {
driver: 'looker',
base_url: 'https://looker.example.test',
client_id: 'cid',
client_secret: 'csecret', // pragma: allowlist secret
},
introspect: vi.fn(async () => {
throw new Error('database file is unreadable');
}),
cleanup,
};
});
const testConnection = vi.fn(async () => ({
success: true as const,
metadata: { displayName: 'Alice Analyst', userId: '42' },
}));
const createLookerClient = vi.fn(async (): Promise<Pick<LookerClient, 'testConnection'>> => ({ testConnection }));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
createScanConnector: vi.fn(async () => connector),
}),
runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }),
).resolves.toBe(0);
expect(createLookerClient).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'bi_looker');
expect(testConnection).toHaveBeenCalledTimes(1);
expect(io.stdout()).toContain('Connection test passed: bi_looker');
expect(io.stdout()).toContain('Driver: looker');
expect(io.stdout()).toContain('User: Alice Analyst');
});
it('falls back to userId when Looker metadata has no display name', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
bi_looker: {
driver: 'looker',
base_url: 'https://looker.example.test',
client_id: 'cid',
client_secret: 'csecret', // pragma: allowlist secret
},
});
const createLookerClient = vi.fn(async (): Promise<Pick<LookerClient, 'testConnection'>> => ({
testConnection: vi.fn(async () => ({
success: true as const,
metadata: { displayName: null, userId: '42' },
})),
}));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }),
).resolves.toBe(0);
expect(io.stdout()).toContain('User: 42');
});
it('reports the Looker error when testConnection fails', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
bi_looker: {
driver: 'looker',
base_url: 'https://looker.example.test',
client_id: 'cid',
client_secret: 'csecret', // pragma: allowlist secret
},
});
const createLookerClient = vi.fn(async (): Promise<Pick<LookerClient, 'testConnection'>> => ({
testConnection: vi.fn(async () => ({ success: false as const, error: 'invalid client_id' })),
}));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }),
).resolves.toBe(1);
expect(io.stderr()).toContain('Looker connection test failed: invalid client_id');
});
it('tests a Notion connection by retrieving the bot user', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
docs: {
driver: 'notion',
auth_token: 'secret_token', // pragma: allowlist secret
crawl_mode: 'all_accessible',
},
});
const retrieveBotUser = vi.fn(async () => ({ id: 'bot-1', name: 'Analytics Bot' }));
const createNotionClient = vi.fn(async (): Promise<Pick<NotionClient, 'retrieveBotUser'>> => ({ retrieveBotUser }));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'docs' }, io.io, { createNotionClient }),
).resolves.toBe(0);
expect(createNotionClient).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'docs');
expect(retrieveBotUser).toHaveBeenCalledTimes(1);
expect(io.stdout()).toContain('Connection test passed: docs');
expect(io.stdout()).toContain('Driver: notion');
expect(io.stdout()).toContain('Bot: Analytics Bot');
});
it('falls back to bot id when Notion bot has no name', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
docs: {
driver: 'notion',
auth_token: 'secret_token', // pragma: allowlist secret
crawl_mode: 'all_accessible',
},
});
const createNotionClient = vi.fn(async (): Promise<Pick<NotionClient, 'retrieveBotUser'>> => ({
retrieveBotUser: vi.fn(async () => ({ id: 'bot-1', name: null })),
}));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'docs' }, io.io, { createNotionClient }),
).resolves.toBe(0);
expect(io.stdout()).toContain('Bot: bot-1');
});
it('tests a dbt connection via testRepoConnection (success)', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
process.env.DBT_TOKEN = 'gh_token_abc'; // pragma: allowlist secret
await writeConnections(projectDir, {
'dbt-main': {
driver: 'dbt',
repo_url: 'https://github.com/example/dbt-project',
auth_token_ref: 'env:DBT_TOKEN',
},
});
const testRepoConnection = vi.fn(async () => ({ ok: true as const }));
const io = makeIo();
try {
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'dbt-main' }, io.io, { testRepoConnection }),
).resolves.toBe(0);
expect(testRepoConnection).toHaveBeenCalledWith({
repoUrl: 'https://github.com/example/dbt-project',
authToken: 'gh_token_abc',
});
expect(io.stdout()).toContain('Connection test passed: dbt-main');
expect(io.stdout()).toContain('Driver: dbt');
expect(io.stdout()).toContain('Repo: https://github.com/example/dbt-project');
} finally {
delete process.env.DBT_TOKEN;
}
});
it('reports the git error when testRepoConnection fails for dbt', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
'dbt-main': {
driver: 'dbt',
repo_url: 'https://github.com/example/dbt-project',
},
});
const testRepoConnection = vi.fn(async () => ({ ok: false as const, error: 'fatal: auth failed' }));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'dbt-main' }, io.io, { testRepoConnection }),
).resolves.toBe(1);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(io.stderr()).toContain('database file is unreadable');
expect(testRepoConnection).toHaveBeenCalledWith({
repoUrl: 'https://github.com/example/dbt-project',
authToken: null,
});
expect(io.stderr()).toContain('dbt repository check failed: fatal: auth failed');
});
it('tests a LookML connection via testRepoConnection with camelCase repoUrl', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
lookml_main: {
driver: 'lookml',
repoUrl: 'https://github.com/example/lookml',
},
});
const testRepoConnection = vi.fn(async () => ({ ok: true as const }));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'lookml_main' }, io.io, { testRepoConnection }),
).resolves.toBe(0);
expect(testRepoConnection).toHaveBeenCalledWith({
repoUrl: 'https://github.com/example/lookml',
authToken: null,
});
expect(io.stdout()).toContain('Driver: lookml');
expect(io.stdout()).toContain('Repo: https://github.com/example/lookml');
});
it('tests a MetricFlow connection via the nested metricflow block', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
mf_main: {
driver: 'metricflow',
metricflow: { repoUrl: 'https://github.com/example/metricflow' },
},
});
const testRepoConnection = vi.fn(async () => ({ ok: true as const }));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'mf_main' }, io.io, { testRepoConnection }),
).resolves.toBe(0);
expect(testRepoConnection).toHaveBeenCalledWith({
repoUrl: 'https://github.com/example/metricflow',
authToken: null,
});
expect(io.stdout()).toContain('Driver: metricflow');
});
it('--all: prints a single coherent list with one row per connection', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
docs: { driver: 'notion', auth_token: 'secret_token', crawl_mode: 'all_accessible' }, // pragma: allowlist secret
});
const { connector } = nativeConnector('sqlite');
const createScanConnector = vi.fn(async () => connector);
const createNotionClient = vi.fn(async (): Promise<Pick<NotionClient, 'retrieveBotUser'>> => ({
retrieveBotUser: vi.fn(async () => ({ id: 'bot-1', name: 'Docs Bot' })),
}));
const io = makeIo();
await expect(
runKtxConnection({ command: 'test-all', projectDir }, io.io, { createScanConnector, createNotionClient }),
).resolves.toBe(0);
const out = stripAnsi(io.stdout());
expect(out).toContain('connection test --all');
expect(out).toMatch(/docs\s+notion\s+✓ ok\s+Bot: Docs Bot/);
expect(out).toMatch(/warehouse\s+sqlite\s+✓ ok\s+Status: ok/);
expect(out).toContain('2 tested');
expect(out).toContain('2 passed');
expect(out).not.toContain('failed');
expect(io.stderr()).toBe('');
});
it('--all: marks failing connections, keeps passing ones, and returns non-zero', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlite' },
broken: { driver: 'sqlite' },
});
const okConnector = nativeConnector('sqlite').connector;
const failConnector = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' }).connector;
const createScanConnector = vi.fn(async (_p, connectionId: string) =>
connectionId === 'broken' ? failConnector : okConnector,
);
const io = makeIo();
await expect(
runKtxConnection({ command: 'test-all', projectDir }, io.io, { createScanConnector }),
).resolves.toBe(1);
const out = stripAnsi(io.stdout());
expect(out).toMatch(/broken\s+sqlite\s+✗ failed\s+database file is unreadable/);
expect(out).toMatch(/warehouse\s+sqlite\s+✓ ok\s+Status: ok/);
expect(out).toContain('1 passed');
expect(out).toContain('1 failed');
expect(io.stderr()).toBe('');
});
it('--all: shows an empty-state message when no connections are configured', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
const io = makeIo();
await expect(runKtxConnection({ command: 'test-all', projectDir }, io.io)).resolves.toBe(0);
const out = stripAnsi(io.stdout());
expect(out).toContain('connection test --all');
expect(out).toContain('No connections configured. Run `ktx setup` to add one.');
});
it('rejects unknown drivers with a helpful error', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
mystery: { driver: 'duckdb' },
});
const io = makeIo();
await expect(
runKtxConnection({ command: 'test', projectDir, connectionId: 'mystery' }, io.io),
).resolves.toBe(1);
expect(io.stderr()).toContain('uses driver "duckdb"');
expect(io.stderr()).toContain('Supported:');
});
});

View file

@ -1,12 +1,21 @@
import {
DEFAULT_METABASE_CLIENT_CONFIG,
DefaultLookerConnectionClientFactory,
DefaultMetabaseConnectionClientFactory,
type LookerClient,
type MetabaseRuntimeClient,
type NotionBotInfo,
NotionClient,
createLocalLookerCredentialResolver,
metabaseRuntimeConfigFromLocalConnection,
testRepoConnection,
} from '@ktx/context/ingest';
import { parseNotionConnectionConfig, resolveNotionConnectionAuthToken } from '@ktx/context/connections';
import { resolveKtxConfigReference } from '@ktx/context/core';
import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project';
import type { KtxScanConnector } from '@ktx/context/scan';
import type { KtxCliIo } from './index.js';
import { bold, dim, green, red, SYMBOLS } from './io/symbols.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { profileMark } from './startup-profile.js';
@ -14,18 +23,37 @@ profileMark('module:connection');
export type KtxConnectionArgs =
| { command: 'list'; projectDir: string }
| { command: 'test'; projectDir: string; connectionId: string };
| { command: 'test'; projectDir: string; connectionId: string }
| { command: 'test-all'; projectDir: string };
interface KtxConnectionDeps {
type MetabaseTestPort = Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>;
type LookerTestPort = Pick<LookerClient, 'testConnection'>;
type NotionTestPort = Pick<NotionClient, 'retrieveBotUser'>;
type TestRepoConnection = typeof testRepoConnection;
export interface KtxConnectionDeps {
createScanConnector?: typeof createKtxCliScanConnector;
createMetabaseClient?: typeof createDefaultMetabaseClient;
createMetabaseClient?: (project: KtxLocalProject, connectionId: string) => Promise<MetabaseTestPort>;
createLookerClient?: (project: KtxLocalProject, connectionId: string) => Promise<LookerTestPort>;
createNotionClient?: (project: KtxLocalProject, connectionId: string) => Promise<NotionTestPort>;
testRepoConnection?: TestRepoConnection;
}
async function cleanupConnector(connector: KtxScanConnector | null): Promise<void> {
if (connector?.cleanup) {
await connector.cleanup();
}
}
const SUPPORTED_TEST_DRIVERS = [
'sqlite',
'postgres',
'mysql',
'clickhouse',
'sqlserver',
'bigquery',
'snowflake',
'metabase',
'looker',
'notion',
'dbt',
'metricflow',
'lookml',
];
function normalizedConnectionDriver(project: KtxLocalProject, connectionId: string): string {
return String(project.config.connections[connectionId]?.driver ?? '')
@ -37,33 +65,29 @@ async function testNativeConnection(
project: KtxLocalProject,
connectionId: string,
createScanConnector: typeof createKtxCliScanConnector,
): Promise<{ driver: string; tableCount: number }> {
): Promise<{ driver: string }> {
let connector: KtxScanConnector | null = null;
try {
connector = await createScanConnector(project, connectionId);
const snapshot = await connector.introspect(
{
connectionId,
driver: connector.driver,
mode: 'structural',
dryRun: true,
detectRelationships: false,
},
{ runId: `connection-test-${connectionId}` },
);
return {
driver: connector.driver,
tableCount: snapshot.tables.length,
};
if (!connector.testConnection) {
throw new Error(`Connector for "${connectionId}" does not implement testConnection`);
}
const result = await connector.testConnection();
if (!result.success) {
throw new Error(result.error ?? 'connection test failed');
}
return { driver: connector.driver };
} finally {
await cleanupConnector(connector);
if (connector?.cleanup) {
await connector.cleanup();
}
}
}
async function createDefaultMetabaseClient(
project: KtxLocalProject,
connectionId: string,
): Promise<Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>> {
): Promise<MetabaseTestPort> {
const factory = new DefaultMetabaseConnectionClientFactory(
(metabaseConnectionId) =>
metabaseRuntimeConfigFromLocalConnection(
@ -78,30 +102,282 @@ async function createDefaultMetabaseClient(
async function testMetabaseConnection(
project: KtxLocalProject,
connectionId: string,
createMetabaseClient: typeof createDefaultMetabaseClient,
): Promise<{ driver: 'metabase'; databaseCount: number }> {
let client: Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'> | null = null;
createClient: (project: KtxLocalProject, connectionId: string) => Promise<MetabaseTestPort>,
): Promise<{ databaseCount: number }> {
let client: MetabaseTestPort | null = null;
try {
client = await createMetabaseClient(project, connectionId);
client = await createClient(project, connectionId);
const testResult = await client.testConnection();
if (!testResult.success) {
throw new Error(
`Metabase connection test failed: ${testResult.error ?? testResult.message ?? 'unknown error'}`,
);
throw new Error(`Metabase connection test failed: ${testResult.error ?? testResult.message ?? 'unknown error'}`);
}
const databases = await client.getDatabases();
const databaseCount = databases.filter((database) => database.is_sample !== true).length;
if (databaseCount === 0) {
throw new Error('Metabase auth worked but no usable databases were returned');
}
return { driver: 'metabase', databaseCount };
return { databaseCount };
} finally {
await client?.cleanup();
}
}
async function createDefaultLookerClient(
project: KtxLocalProject,
connectionId: string,
): Promise<LookerTestPort> {
const factory = new DefaultLookerConnectionClientFactory(createLocalLookerCredentialResolver(project));
return (await factory.createClient(connectionId)) as unknown as LookerTestPort;
}
async function testLookerConnection(
project: KtxLocalProject,
connectionId: string,
createClient: (project: KtxLocalProject, connectionId: string) => Promise<LookerTestPort>,
): Promise<{ user: string }> {
const client = await createClient(project, connectionId);
const result = await client.testConnection();
if (!result.success) {
throw new Error(`Looker connection test failed: ${result.error ?? 'unknown error'}`);
}
const metadata = (result.metadata ?? {}) as { displayName?: string | null; userId?: string };
const user = (metadata.displayName ?? metadata.userId ?? 'unknown').trim() || 'unknown';
return { user };
}
async function createDefaultNotionClient(
project: KtxLocalProject,
connectionId: string,
): Promise<NotionTestPort> {
const connection = project.config.connections[connectionId];
if (!connection) {
throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
}
const parsed = parseNotionConnectionConfig(connection);
const token = await resolveNotionConnectionAuthToken(parsed);
return new NotionClient(token);
}
function describeNotionBot(bot: NotionBotInfo): string {
const name = typeof bot.name === 'string' ? bot.name.trim() : '';
if (name) return name;
const id = typeof bot.id === 'string' ? bot.id.trim() : '';
return id || 'unknown';
}
async function testNotionConnection(
project: KtxLocalProject,
connectionId: string,
createClient: (project: KtxLocalProject, connectionId: string) => Promise<NotionTestPort>,
): Promise<{ bot: string }> {
const client = await createClient(project, connectionId);
const bot = await client.retrieveBotUser();
return { bot: describeNotionBot(bot) };
}
interface GitConnectionFields {
repoUrl: string;
authToken: string | null;
}
function extractGitConnectionFields(
project: KtxLocalProject,
connectionId: string,
driver: string,
): GitConnectionFields {
const connection = project.config.connections[connectionId];
if (!connection) {
throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
}
const stringField = (value: unknown): string | null =>
typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
const record =
driver === 'metricflow' && typeof connection.metricflow === 'object' && connection.metricflow !== null
? (connection.metricflow as Record<string, unknown>)
: (connection as Record<string, unknown>);
const repoUrl = driver === 'dbt' ? stringField(record.repo_url) : stringField(record.repoUrl);
if (!repoUrl) {
const field = driver === 'dbt' ? 'repo_url' : 'repoUrl';
throw new Error(`Connection "${connectionId}" (driver: ${driver}) is missing ${field}`);
}
const literalToken = stringField(record.auth_token);
const ref = stringField(record.auth_token_ref);
const resolvedRef = ref ? resolveKtxConfigReference(ref, process.env) : null;
return { repoUrl, authToken: literalToken ?? resolvedRef ?? null };
}
async function testGitRepoConnection(
project: KtxLocalProject,
connectionId: string,
driver: string,
runTest: TestRepoConnection,
): Promise<{ repoUrl: string }> {
const { repoUrl, authToken } = extractGitConnectionFields(project, connectionId, driver);
const result = await runTest({ repoUrl, authToken });
if (!result.ok) {
throw new Error(`${driver} repository check failed: ${result.error}`);
}
return { repoUrl };
}
interface DriverTestOutcome {
driver: string;
detailKey: string;
detailValue: string;
}
async function testConnectionByDriver(
project: KtxLocalProject,
connectionId: string,
deps: KtxConnectionDeps,
): Promise<DriverTestOutcome> {
const driver = normalizedConnectionDriver(project, connectionId);
if (!driver) {
throw new Error(`Connection "${connectionId}" has no \`driver\` field in ktx.yaml`);
}
if (driver === 'metabase') {
const result = await testMetabaseConnection(
project,
connectionId,
deps.createMetabaseClient ?? createDefaultMetabaseClient,
);
return { driver, detailKey: 'Databases', detailValue: String(result.databaseCount) };
}
if (driver === 'looker') {
const result = await testLookerConnection(
project,
connectionId,
deps.createLookerClient ?? createDefaultLookerClient,
);
return { driver, detailKey: 'User', detailValue: result.user };
}
if (driver === 'notion') {
const result = await testNotionConnection(
project,
connectionId,
deps.createNotionClient ?? createDefaultNotionClient,
);
return { driver, detailKey: 'Bot', detailValue: result.bot };
}
if (driver === 'dbt' || driver === 'metricflow' || driver === 'lookml') {
const result = await testGitRepoConnection(
project,
connectionId,
driver,
deps.testRepoConnection ?? testRepoConnection,
);
return { driver, detailKey: 'Repo', detailValue: result.repoUrl };
}
if (
driver === 'sqlite' ||
driver === 'sqlite3' ||
driver === 'postgres' ||
driver === 'postgresql' ||
driver === 'mysql' ||
driver === 'clickhouse' ||
driver === 'sqlserver' ||
driver === 'bigquery' ||
driver === 'snowflake'
) {
const result = await testNativeConnection(
project,
connectionId,
deps.createScanConnector ?? createKtxCliScanConnector,
);
return { driver: result.driver, detailKey: 'Status', detailValue: 'ok' };
}
throw new Error(
`Connection "${connectionId}" uses driver "${driver}", which has no test implementation in ktx. Supported: ${SUPPORTED_TEST_DRIVERS.join(', ')}.`,
);
}
interface ConnectionTestRow {
connectionId: string;
driver: string;
ok: boolean;
detail: string;
}
function visualWidth(text: string): number {
// styleText wraps content in ANSI escape sequences; strip them before measuring.
return text.replace(/\[[0-9;]*m/g, '').length;
}
function padVisual(text: string, width: number): string {
const pad = width - visualWidth(text);
return pad > 0 ? `${text}${' '.repeat(pad)}` : text;
}
function renderTestAll(io: KtxCliIo, rows: ReadonlyArray<ConnectionTestRow>): void {
io.stdout.write(`${SYMBOLS.barStart} connection test --all\n`);
io.stdout.write(`${SYMBOLS.bar}\n`);
if (rows.length === 0) {
io.stdout.write(`${SYMBOLS.barEnd} No connections configured. Run \`ktx setup\` to add one.\n`);
return;
}
const okLabel = green('✓ ok');
const failLabel = red('✗ failed');
const idWidth = Math.max(...rows.map((r) => r.connectionId.length));
const driverWidth = Math.max(...rows.map((r) => r.driver.length));
const statusWidth = Math.max(visualWidth(okLabel), visualWidth(failLabel));
for (const row of rows) {
const id = bold(padVisual(row.connectionId, idWidth));
const driver = dim(padVisual(row.driver, driverWidth));
const status = padVisual(row.ok ? okLabel : failLabel, statusWidth);
const detail = dim(row.detail);
io.stdout.write(`${SYMBOLS.bar} ${SYMBOLS.item} ${id} ${driver} ${status} ${detail}\n`);
}
const failed = rows.filter((r) => !r.ok).length;
const passed = rows.length - failed;
io.stdout.write(`${SYMBOLS.bar}\n`);
const summary =
failed === 0
? `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)}`
: `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)} ${dim(SYMBOLS.middot)} ${red(`${failed} failed`)}`;
io.stdout.write(`${SYMBOLS.barEnd} ${summary}\n`);
}
async function runTestAll(
project: KtxLocalProject,
io: KtxCliIo,
deps: KtxConnectionDeps,
): Promise<number> {
const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b));
const rows = await Promise.all(
entries.map(async ([connectionId, connection]): Promise<ConnectionTestRow> => {
const declaredDriver = String(connection.driver ?? '').trim().toLowerCase() || 'unknown';
try {
const outcome = await testConnectionByDriver(project, connectionId, deps);
return {
connectionId,
driver: outcome.driver || declaredDriver,
ok: true,
detail: `${outcome.detailKey}: ${outcome.detailValue}`,
};
} catch (error) {
return {
connectionId,
driver: declaredDriver,
ok: false,
detail: error instanceof Error ? error.message : String(error),
};
}
}),
);
renderTestAll(io, rows);
return rows.some((row) => !row.ok) ? 1 : 0;
}
export async function runKtxConnection(
args: KtxConnectionArgs,
io: KtxCliIo = process,
@ -127,26 +403,14 @@ export async function runKtxConnection(
return 0;
}
if (normalizedConnectionDriver(project, args.connectionId) === 'metabase') {
const result = await testMetabaseConnection(
project,
args.connectionId,
deps.createMetabaseClient ?? createDefaultMetabaseClient,
);
io.stdout.write(`Connection test passed: ${args.connectionId}\n`);
io.stdout.write(`Driver: ${result.driver}\n`);
io.stdout.write(`Databases: ${result.databaseCount}\n`);
return 0;
if (args.command === 'test-all') {
return await runTestAll(project, io, deps);
}
const result = await testNativeConnection(
project,
args.connectionId,
deps.createScanConnector ?? createKtxCliScanConnector,
);
const { driver, detailKey, detailValue } = await testConnectionByDriver(project, args.connectionId, deps);
io.stdout.write(`Connection test passed: ${args.connectionId}\n`);
io.stdout.write(`Driver: ${result.driver}\n`);
io.stdout.write(`Tables: ${result.tableCount}\n`);
io.stdout.write(`Driver: ${driver}\n`);
io.stdout.write(`${detailKey}: ${detailValue}\n`);
return 0;
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);

View file

@ -40,7 +40,7 @@ function projectWithConnections(connections: KtxProjectConfig['connections']): K
return {
projectDir: '/tmp/project',
config: {
...buildDefaultKtxProjectConfig('warehouse'),
...buildDefaultKtxProjectConfig(),
connections,
},
};

View file

@ -52,7 +52,6 @@ export function defaultDemoProjectDir(): string {
function demoConfig(databasePath: string): string {
return [
'project: ktx-demo-orbit',
'connections:',
` ${DEMO_CONNECTION_ID}:`,
' driver: sqlite',

View file

@ -72,10 +72,10 @@ describe('dev Commander tree', () => {
const testIo = makeIo();
try {
await expect(runKtxCli(['dev', 'init', projectDir, '--name', 'warehouse'], testIo.io)).resolves.toBe(0);
await expect(runKtxCli(['dev', 'init', projectDir], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain(`Initialized KTX project at ${projectDir}`);
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.toContain('project: warehouse');
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.not.toContain('project:');
expect(testIo.stderr()).toBe('');
} finally {
await rm(tempDir, { recursive: true, force: true });
@ -92,7 +92,7 @@ describe('dev Commander tree', () => {
try {
await expect(
runKtxCli(['--project-dir', projectDir, 'dev', 'init', '--name', 'global-init'], testIo.io),
runKtxCli(['--project-dir', projectDir, 'dev', 'init'], testIo.io),
).resolves.toBe(0);
expect(testIo.stdout()).toContain(`Initialized KTX project at ${projectDir}`);

View file

@ -25,19 +25,17 @@ export function registerDevCommands(program: Command, context: KtxCliCommandCont
.command('init')
.description('Initialize a Git-backed KTX project directory for maintenance scripts')
.argument('[directory]', 'Project directory')
.option('--name <name>', 'Project name written to ktx.yaml')
.option('--force', 'Rewrite ktx.yaml and scaffold files in an existing project', false)
.action(
async (
projectDir: string | undefined,
commandOptions: { name?: string; force?: boolean },
commandOptions: { force?: boolean },
command: CommandWithGlobalOptions,
) => {
context.setExitCode(
await context.runInit(
{
projectDir: projectDir ? resolve(projectDir) : resolveCommandProjectDir(command),
...(commandOptions.name ? { projectName: commandOptions.name } : {}),
force: commandOptions.force === true,
},
context.io,
@ -46,5 +44,23 @@ export function registerDevCommands(program: Command, context: KtxCliCommandCont
},
);
dev
.command('schema')
.description('Print a JSON Schema describing ktx.yaml (for editors and LLM agents)')
.option('--output <file>', 'Write the schema to a file instead of stdout')
.action(async (options: { output?: string }) => {
const { generateKtxProjectConfigJsonSchema } = await import('@ktx/context/project');
const json = `${JSON.stringify(generateKtxProjectConfigJsonSchema(), null, 2)}\n`;
if (options.output) {
const { writeFile } = await import('node:fs/promises');
const target = resolve(options.output);
await writeFile(target, json, 'utf8');
context.io.stdout.write(`Wrote ${target}\n`);
} else {
context.io.stdout.write(json);
}
context.setExitCode(0);
});
registerRuntimeCommands(dev, context);
}

View file

@ -1,6 +1,6 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
formatDoctorReport,
@ -324,12 +324,98 @@ describe('runKtxDoctor', () => {
expect(parsed.projectDir).toBe(tempDir);
});
it('prints schema issues and exits 1 when ktx.yaml fails Zod validation', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'storrage:',
' state: sqlite',
'ingest:',
' llm:',
' backend: anthropic',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const out = testIo.stdout();
expect(out).toContain('KTX status');
expect(out).toContain('Config');
expect(out).toContain('Unsupported storrage: unknown field');
expect(out).toContain('Unsupported ingest.llm: use top-level llm.provider');
expect(out).toContain('ktx.yaml');
});
it('emits structured JSON when ktx.yaml fails Zod validation', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
['storrage: {}', ''].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(1);
const parsed = JSON.parse(testIo.stdout()) as {
error: string;
projectDir: string;
issues: Array<{ path: string; message: string }>;
};
expect(parsed.error).toBe('invalid_config');
expect(parsed.projectDir).toBe(tempDir);
expect(parsed.issues.some((issue) => issue.path === 'storrage')).toBe(true);
});
it('shows a Config row labelled "ktx.yaml schema valid" on the happy path', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'connections:',
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'llm:',
' provider:',
' backend: anthropic',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{},
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('ktx.yaml schema valid');
delete process.env.ANTHROPIC_API_KEY;
});
it('runs project checks against a valid ktx.yaml', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -363,7 +449,7 @@ describe('runKtxDoctor', () => {
const out = testIo.stdout();
expect(out).toContain('KTX status');
expect(out).toContain('· warehouse');
expect(out).toContain(`· ${basename(tempDir)}`);
expect(out).toContain('Connections (1)');
expect(out).toContain('LLM');
expect(out).toContain('anthropic');
@ -379,7 +465,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -439,7 +524,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -469,7 +553,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -531,7 +614,6 @@ describe('runKtxDoctor', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -565,4 +647,169 @@ 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'),
[
'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'),
[
'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'),
[
'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'),
['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'),
[
'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');
});
});
});

View file

@ -1,9 +1,10 @@
import { execFile } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import { access, readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import type { KtxConfigIssue } from '@ktx/context/project';
import type { BuildProjectStatusOptions } from './status-project.js';
const execFileAsync = promisify(execFile);
@ -40,6 +41,12 @@ export type KtxDoctorArgs =
outputMode: KtxDoctorOutputMode;
inputMode?: KtxDoctorInputMode;
verbose?: boolean;
}
| {
command: 'validate';
projectDir: string;
outputMode: KtxDoctorOutputMode;
inputMode?: KtxDoctorInputMode;
};
interface KtxDoctorIo {
@ -450,6 +457,84 @@ function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io:
io.stdout.write(renderPlainReport(report, options));
}
export function renderInvalidConfigMessage(
projectDir: string,
issues: KtxConfigIssue[],
outputMode: KtxDoctorOutputMode,
io: KtxDoctorIo,
): void {
if (outputMode === 'json') {
io.stdout.write(
`${JSON.stringify(
{
error: 'invalid_config',
projectDir,
issues,
},
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('fail', '✗')} ${bold('Config')} ktx.yaml has ${issues.length} schema issue${issues.length === 1 ? '' : 's'}`);
for (const issue of issues) {
lines.push(` ${status('fail', '✗')} ${issue.message}`);
if (issue.fix) {
lines.push(` ${dim(`${issue.fix}`)}`);
}
}
lines.push('');
lines.push(` ${dim('Fix the issues in')} ${join(abbreviated, 'ktx.yaml')} ${dim('and rerun')} ${bold('ktx status')}.`);
lines.push('');
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,
@ -501,16 +586,39 @@ 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))) {
renderMissingProjectMessage(args.projectDir, args.outputMode, io);
return 1;
}
const { loadKtxProject } = await import('@ktx/context/project');
const { loadKtxProject, validateKtxProjectConfig } = await import('@ktx/context/project');
const { buildProjectStatus, renderProjectStatus } = await import('./status-project.js');
const rawConfig = await readFile(configPath, 'utf-8');
const validation = validateKtxProjectConfig(rawConfig);
if (!validation.ok) {
renderInvalidConfigMessage(args.projectDir, validation.issues, args.outputMode, io);
return 1;
}
const project = await loadKtxProject({ projectDir: args.projectDir });
const projectStatus = await buildProjectStatus(project, deps);
const projectStatus = await buildProjectStatus(project, { ...deps, configIssues: validation.issues });
const verbose = args.verbose ?? false;
const toolchainChecks = verbose ? await runSetupChecks() : undefined;
if (args.outputMode === 'json') {

View file

@ -102,7 +102,7 @@ describe('runKtxCli', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-'));
await writeFile(join(tempDir, 'ktx.yaml'), 'project: cli-dispatch-fixture\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), '{}\n', 'utf-8');
});
afterEach(async () => {
@ -503,7 +503,7 @@ describe('runKtxCli', () => {
it('keeps representative JSON command stdout parseable', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const commands = [
['--project-dir', projectDir, 'status', '--json'],
['--project-dir', projectDir, 'sl', 'list', '--json'],
@ -581,7 +581,7 @@ describe('runKtxCli', () => {
try {
delete process.env.KTX_PROJECT_DIR;
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
process.chdir(tempDir);
await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0);
@ -1515,7 +1515,7 @@ describe('runKtxCli', () => {
it('dispatches public connection subcommands through the existing connection implementation', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-'));
await writeFile(join(tempDir, 'ktx.yaml'), 'project: connection-dispatch\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), '{}\n', 'utf-8');
const connection = vi.fn(async () => 0);
await expect(
@ -1550,7 +1550,7 @@ describe('runKtxCli', () => {
expect(helpIo.stdout()).toContain('Usage: ktx connection');
expect(helpIo.stdout()).toContain('list');
expect(helpIo.stdout()).toContain('test <connectionId>');
expect(helpIo.stdout()).toContain('test [options] [connectionId]');
for (const removed of ['add', 'remove', 'map', 'mapping', 'metabase', 'notion']) {
expect(helpIo.stdout()).not.toMatch(new RegExp(`\\b${removed}\\b`));
}

View file

@ -106,7 +106,6 @@ export async function writeWarehouseConfig(projectDir: string): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' prod-metabase:',
' driver: metabase',
@ -126,7 +125,6 @@ export async function writeMetabaseConfig(projectDir: string): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -488,7 +486,6 @@ export async function runPublicMetabaseSyncModeCase(tempDir: string, input: Sync
await writeFile(
join(projectDir, 'ktx.yaml'),
[
`project: metabase-sync-mode-${input.name}`,
'connections:',
' prod-metabase:',
' driver: metabase',

View file

@ -278,7 +278,7 @@ describe('runKtxIngest', () => {
{
databasesDeps: {
testConnection: async (_projectDir, _connectionId, io) => {
io.stdout.write('Driver: postgres\nTables: 1\n');
io.stdout.write('Driver: postgres\nStatus: ok\n');
return 0;
},
scanConnection: async () => 0,
@ -633,7 +633,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: metabase-cli',
'connections:',
' prod-metabase:',
' driver: metabase',
@ -1099,7 +1098,7 @@ describe('runKtxIngest', () => {
it('passes managed daemon options to adapters and pull-config options when no explicit daemon URL is set', async () => {
const projectDir = join(tempDir, 'managed-daemon-ingest-project');
await initKtxProject({ projectDir, projectName: 'managed-daemon-ingest-project' });
await initKtxProject({ projectDir });
await writeWarehouseConfig(projectDir);
const createdAdapters: SourceAdapter[] = [
{ source: 'fake', skillNames: [], detect: async () => true, chunk: async () => ({ workUnits: [] }) },
@ -1159,7 +1158,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: historic-sql-project',
'connections:',
' warehouse:',
' driver: postgres',
@ -1224,7 +1222,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: historic-sql-progress-project',
'connections:',
' warehouse:',
' driver: postgres',
@ -1353,7 +1350,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: historic-sql-step-progress-project',
'connections:',
' warehouse:',
' driver: postgres',
@ -1446,7 +1442,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: historic-sql-concurrent-progress-project',
'connections:',
' warehouse:',
' driver: postgres',
@ -1596,7 +1591,6 @@ describe('runKtxIngest', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: looker-cli',
'connections:',
' prod-looker:',
' driver: looker',

View file

@ -35,3 +35,11 @@ export function bold(text: string): string {
export function gray(text: string): string {
return styleText('gray', text);
}
export function green(text: string): string {
return styleText('green', text);
}
export function red(text: string): string {
return styleText('red', text);
}

View file

@ -53,7 +53,7 @@ describe('runKtxKnowledge', () => {
it('writes, reads, lists, and searches wiki pages', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const writeIo = makeIo();
await expect(
@ -95,7 +95,7 @@ describe('runKtxKnowledge', () => {
it('prints wiki list, search, and read as public JSON envelopes', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await expect(
runKtxKnowledge(
@ -154,7 +154,7 @@ describe('runKtxKnowledge', () => {
it('rejects slash-delimited write keys with a flat-key suggestion', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const writeIo = makeIo();
await expect(
@ -183,7 +183,7 @@ describe('runKtxKnowledge', () => {
it('explains empty search results for a project without wiki pages', async () => {
const projectDir = join(tempDir, 'empty-project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
const searchIo = makeIo();
await expect(
@ -197,7 +197,7 @@ describe('runKtxKnowledge', () => {
it('uses configured embeddings for semantic wiki search', async () => {
const projectDir = join(tempDir, 'semantic-project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await expect(
runKtxKnowledge(

View file

@ -43,7 +43,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -74,7 +73,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -106,7 +104,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' bq:',
' driver: bigquery',
@ -139,7 +136,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' sf:',
' driver: snowflake',
@ -175,7 +171,6 @@ describe('CLI local ingest adapters', () => {
await writeProject(
tempDir,
[
'project: warehouse',
'connections:',
' bq:',
' driver: bigquery',

View file

@ -39,11 +39,10 @@ describe('createKtxCliScanConnector', () => {
});
it('creates a native sqlite connector from standalone config', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -61,11 +60,10 @@ describe('createKtxCliScanConnector', () => {
});
it('passes canonical BigQuery YAML scan limits through to the connector', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: bigquery',
@ -95,11 +93,10 @@ describe('createKtxCliScanConnector', () => {
});
it('throws for structural daemon-only fallback configs', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: duckdb',
@ -116,11 +113,10 @@ describe('createKtxCliScanConnector', () => {
});
it('throws a clear error when the connection block has no driver field', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' type: postgres',

View file

@ -16,7 +16,7 @@ describe('renderKtxCommandTree', () => {
expect(topLevel).toContain(expected);
}
expect(output).toContain('│ └── test <connectionId>');
expect(output).toContain('│ └── test [connectionId]');
expect(output).toContain('│ ├── status Show KTX MCP daemon status');
expect(output).not.toContain('│ ├── add');
expect(output).not.toContain('│ ├── remove');

View file

@ -6,7 +6,7 @@ import { runKtxCli, type KtxCliDeps } from './index.js';
async function makeFixtureProject(prefix: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), prefix));
await writeFile(join(dir, 'ktx.yaml'), 'project: project-dir-fixture\n', 'utf-8');
await writeFile(join(dir, 'ktx.yaml'), '{}\n', 'utf-8');
return dir;
}
@ -138,7 +138,7 @@ describe('project directory defaults', () => {
const projectDir = join(root, 'warehouse');
const nestedDir = join(projectDir, 'nested', 'deeper');
await mkdir(nestedDir, { recursive: true });
await writeFile(join(projectDir, 'ktx.yaml'), 'project: warehouse\n', 'utf-8');
await writeFile(join(projectDir, 'ktx.yaml'), '{}\n', 'utf-8');
const expectedProjectDir = await realpath(projectDir);
const publicIngest = vi.fn(async () => 0);

View file

@ -48,7 +48,7 @@ describe('resolveKtxProjectDir', () => {
const project = join(tempDir, 'warehouse');
const nested = join(project, 'nested', 'deeper');
await mkdir(nested, { recursive: true });
await writeFile(join(project, 'ktx.yaml'), 'project: warehouse\n', 'utf-8');
await writeFile(join(project, 'ktx.yaml'), '{}\n', 'utf-8');
expect(resolveKtxProjectDir({ env: {}, cwd: nested })).toBe(resolve(project));
expect(findNearestKtxProjectDir(nested)).toBe(resolve(project));

View file

@ -41,7 +41,7 @@ function projectWithConnections(connections: KtxProjectConfig['connections']): K
return {
projectDir: '/tmp/project',
config: {
...buildDefaultKtxProjectConfig('warehouse'),
...buildDefaultKtxProjectConfig(),
connections,
},
};
@ -51,7 +51,7 @@ function deepReadyProject(
connections: KtxProjectConfig['connections'],
relationshipsEnabled = true,
): KtxPublicIngestProject {
const config = buildDefaultKtxProjectConfig('warehouse');
const config = buildDefaultKtxProjectConfig();
return {
projectDir: '/tmp/project',
config: {

View file

@ -316,7 +316,7 @@ describe('runKtxScan', () => {
});
it('runs structural scans and prints a dev-friendly plain summary', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
@ -377,7 +377,7 @@ describe('runKtxScan', () => {
});
it('passes managed daemon options to local ingest adapters when no explicit daemon URL is set', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const createLocalIngestAdapters = vi.fn(() => []);
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
@ -421,7 +421,7 @@ describe('runKtxScan', () => {
});
it('explains warnings, capability gaps, and relationships in human scan summaries', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
@ -472,7 +472,7 @@ describe('runKtxScan', () => {
});
it('prints review-only relationship summaries and validation capability warnings', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const reviewOnlyReport: KtxScanReport = {
...reportWithAttention,
capabilityGaps: [],
@ -525,7 +525,7 @@ describe('runKtxScan', () => {
});
it('passes a scan progress port and prints TTY progress messages', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.15, 'Inspecting database schema');
await input.progress?.update(0.55, 'Semantic layer comparison found 5 changes across 18 tables');
@ -572,7 +572,7 @@ describe('runKtxScan', () => {
});
it('uses injected structured progress without requiring TTY progress output', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const progressEvents: Array<{ progress: number; message?: string; transient?: boolean }> = [];
const structuredProgress = {
async update(progress: number, message?: string, options?: { transient?: boolean }) {
@ -674,7 +674,7 @@ describe('runKtxScan', () => {
});
it('flushes transient TTY progress messages before printing scan failures', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.42, 'Generating descriptions 3/35 tables', { transient: true });
throw new Error('scan failed');
@ -711,7 +711,7 @@ describe('runKtxScan', () => {
});
it('does not print live progress messages for non-TTY output', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.15, 'Inspecting database schema');
return {
@ -747,7 +747,7 @@ describe('runKtxScan', () => {
});
it('uses terminal-aware visual styling only for TTY output', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
@ -807,7 +807,7 @@ describe('runKtxScan', () => {
});
it('honors NO_COLOR for TTY scan summaries', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
runId: 'scan-run-1',
@ -853,11 +853,10 @@ describe('runKtxScan', () => {
it('passes native CLI adapters into local scan runs for mysql configs', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: mysql',
@ -901,11 +900,10 @@ describe('runKtxScan', () => {
it('creates a native connector for standalone relationship scans', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-relationships-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -955,11 +953,10 @@ describe('runKtxScan', () => {
it('routes standalone postgres scans through the native connector before daemon fallback', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-postgres-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1021,11 +1018,10 @@ describe('runKtxScan', () => {
it('passes native CLI adapters into local scan runs for clickhouse configs', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-clickhouse-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: clickhouse',
@ -1072,11 +1068,10 @@ describe('runKtxScan', () => {
it('passes native CLI adapters into local scan runs for sqlserver configs', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-sqlserver-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlserver',
@ -1138,11 +1133,10 @@ describe('runKtxScan', () => {
it('passes native CLI adapters into local scan runs for bigquery configs', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-bigquery-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: bigquery',
@ -1203,11 +1197,10 @@ describe('runKtxScan', () => {
it('passes native CLI adapters into local scan runs for snowflake configs', async () => {
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-snowflake-'));
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempProject });
await writeFile(
join(tempProject, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: snowflake',

View file

@ -30,7 +30,7 @@ describe('setup agents', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-'));
await mkdir(join(tempDir, '.ktx', 'agents'), { recursive: true });
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
});
afterEach(async () => {

View file

@ -50,7 +50,7 @@ type ReadyProjectOverrides = Omit<Partial<KtxProjectConfig>, 'ingest' | 'llm' |
};
async function writeReadyProject(projectDir: string, overrides: ReadyProjectOverrides = {}) {
const defaults = buildDefaultKtxProjectConfig('revenue');
const defaults = buildDefaultKtxProjectConfig();
const readyConfig: KtxProjectConfig = {
...defaults,
setup: { database_connection_ids: ['warehouse'] },
@ -595,7 +595,6 @@ describe('setup context build state', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'connections: {}',
'llm:',
' provider:',

View file

@ -119,7 +119,7 @@ describe('setup databases step', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-databases-'));
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
});
afterEach(async () => {
@ -242,7 +242,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -575,7 +574,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -622,7 +620,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -770,7 +767,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -815,7 +811,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -864,7 +859,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -936,7 +930,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1010,7 +1003,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1079,7 +1071,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1146,7 +1137,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1361,7 +1351,7 @@ describe('setup databases step', () => {
const testConnection = vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => {
commandIo.stdout.write('Connection test passed: postgres-warehouse\n');
commandIo.stdout.write('Driver: postgres\n');
commandIo.stdout.write('Tables: 2\n');
commandIo.stdout.write('Status: ok\n');
return 0;
});
const scanConnection = vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => {
@ -1646,7 +1636,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -1939,7 +1928,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -2019,7 +2007,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' analytics:',
' driver: bigquery',
@ -2074,7 +2061,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
@ -2123,7 +2109,6 @@ describe('setup databases step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',

View file

@ -60,7 +60,7 @@ describe('setup embeddings step', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-embeddings-'));
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
});
afterEach(async () => {
@ -446,11 +446,10 @@ describe('setup embeddings step', () => {
it('preserves already completed embeddings setup when no embedding args request changes', async () => {
await mkdir(join(tempDir, '.ktx'), { recursive: true });
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse', force: true });
await initKtxProject({ projectDir: tempDir, force: true });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'setup:',
' database_connection_ids: []',
'connections: {}',

View file

@ -92,7 +92,7 @@ describe('setup Anthropic model step', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-models-'));
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await initKtxProject({ projectDir: tempDir });
});
afterEach(async () => {
@ -1049,11 +1049,10 @@ describe('setup Anthropic model step', () => {
it('preserves already completed llm setup when no model args request changes', async () => {
await mkdir(join(tempDir, '.ktx'), { recursive: true });
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse', force: true });
await initKtxProject({ projectDir: tempDir, force: true });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'setup:',
' database_connection_ids: []',
'connections: {}',
@ -1099,7 +1098,6 @@ describe('setup Anthropic model step', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'setup:',
' database_connection_ids: []',
'connections: {}',

View file

@ -76,11 +76,10 @@ describe('setup project step', () => {
it('loads an existing project with --existing and drops config setup progress', async () => {
const projectDir = join(tempDir, 'warehouse');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await initKtxProject({ projectDir });
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -196,7 +195,7 @@ describe('setup project step', () => {
expect.objectContaining({ message: `Create KTX project at ${projectDir}?` }),
);
expect(prompts.text).not.toHaveBeenCalled();
expect(result.status === 'ready' ? result.project.config.project : '').toBe('ktx-project');
expect(result.status === 'ready' ? result.project.configPath : '').toBe(join(projectDir, 'ktx.yaml'));
expect(testIo.stdout()).toContain(`│ KTX will create:\n│ ${projectDir}`);
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
});

View file

@ -1,7 +1,7 @@
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join, resolve } from 'node:path';
import { join, resolve } from 'node:path';
import {
initKtxProject,
type KtxLocalProject,
@ -156,7 +156,7 @@ async function persistProjectStep(project: KtxLocalProject): Promise<KtxLocalPro
async function createProject(projectDir: string, deps: KtxSetupProjectDeps): Promise<KtxLocalProject> {
const initProject = deps.initProject ?? initKtxProject;
const initialized = await initProject({ projectDir, projectName: basename(projectDir) || 'ktx-project' });
const initialized = await initProject({ projectDir });
return await persistProjectStep(initialized);
}

View file

@ -79,7 +79,7 @@ describe('setup sources step', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-sources-'));
projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'sources' });
await initKtxProject({ projectDir });
});
afterEach(async () => {

View file

@ -68,7 +68,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'llm:',
' provider:',
' backend: anthropic',
@ -109,7 +108,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'llm:',
' provider:',
...fixture.providerLines,
@ -129,7 +127,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -162,7 +159,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -183,7 +179,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -206,7 +201,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids: []',
'connections:',
@ -230,7 +224,7 @@ describe('setup status', () => {
it('reports agent status from the install manifest', async () => {
await mkdir(join(tempDir, '.ktx', 'agents'), { recursive: true });
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
await writeFile(
join(tempDir, '.ktx/agents/install-manifest.json'),
JSON.stringify(
@ -256,7 +250,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -309,7 +302,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -370,7 +362,7 @@ describe('setup status', () => {
});
it('prints the readiness checklist for an existing project', async () => {
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const rendered = formatKtxSetupStatus(await readKtxSetupStatus(tempDir));
@ -503,7 +495,7 @@ describe('setup status', () => {
),
).resolves.toBe(0);
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
await expect(
runKtxSetup(
@ -589,7 +581,7 @@ describe('setup status', () => {
});
it('lets Back from new project creation return to the first setup intent menu', async () => {
const existingConfig = 'project: revenue\nconnections: {}\n';
const existingConfig = 'connections: {}\n';
await writeFile(join(tempDir, 'ktx.yaml'), existingConfig, 'utf-8');
const entryChoices = ['new-project', 'exit'];
@ -645,7 +637,7 @@ describe('setup status', () => {
const existingProjectDir = join(tempDir, 'existing');
const newProjectDir = join(tempDir, 'fresh');
await mkdir(existingProjectDir, { recursive: true });
const existingConfig = 'project: revenue\nconnections: {}\n';
const existingConfig = 'connections: {}\n';
await writeFile(join(existingProjectDir, 'ktx.yaml'), existingConfig, 'utf-8');
const projectChoices = ['custom', 'create'];
@ -722,7 +714,7 @@ describe('setup status', () => {
const existingProjectDir = join(tempDir, 'existing');
const newProjectDir = join(tempDir, 'fresh');
await mkdir(existingProjectDir, { recursive: true });
await writeFile(join(existingProjectDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await writeFile(join(existingProjectDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const projectChoices = ['custom', 'create'];
const projectPrompts = {
@ -1147,7 +1139,7 @@ describe('setup status', () => {
});
it('lets Back from the first setup step return to the entry menu instead of exiting', async () => {
await writeFile(join(tempDir, 'ktx.yaml'), 'project: test\nconnections: {}\n', 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const testIo = makeIo();
const entryChoices = ['setup', 'exit'];
@ -1254,7 +1246,7 @@ describe('setup status', () => {
it('runs sources after database setup', async () => {
const calls: string[] = [];
const io = makeIo();
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
await expect(
runKtxSetup(
@ -1315,7 +1307,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -1374,7 +1365,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'connections:',
' warehouse:',
' driver: postgres',
@ -1430,7 +1420,7 @@ describe('setup status', () => {
it('runs context after sources and before agents in full setup', async () => {
const calls: string[] = [];
const io = makeIo();
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
await expect(
runKtxSetup(
@ -1543,7 +1533,7 @@ describe('setup status', () => {
it('runs agent setup after context succeeds in --agents mode', async () => {
const calls: string[] = [];
const io = makeIo();
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
await expect(
runKtxSetup(
@ -1596,7 +1586,7 @@ describe('setup status', () => {
projectDir: tempDir,
installs: [{ target: 'codex' as const, scope: 'project' as const, mode: 'cli' as const }],
}));
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
await expect(
runKtxSetup(
@ -1633,7 +1623,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
@ -1671,7 +1660,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids: []',
'connections: {}',
@ -1778,7 +1766,6 @@ describe('setup status', () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids: []',
'connections: {}',

View file

@ -1,5 +1,5 @@
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { basename, join, resolve } from 'node:path';
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
import {
ktxLocalStateDbPath,
@ -317,7 +317,7 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
})) ?? [];
return {
project: { path: resolvedProjectDir, ready: true, name: project.config.project },
project: { path: resolvedProjectDir, ready: true, name: basename(project.projectDir) || project.projectDir },
llm,
embeddings,
databases: databaseIds.map((connectionId) => ({

View file

@ -44,7 +44,7 @@ async function seedSlSource(input: {
sourceName?: string;
yaml?: string;
}): Promise<void> {
const project = await initKtxProject({ projectDir: input.projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir: input.projectDir });
await project.fileStore.writeFile(
`semantic-layer/${input.connectionId ?? 'warehouse'}/${input.sourceName ?? 'orders'}.yaml`,
input.yaml ?? ORDERS_YAML,
@ -139,7 +139,7 @@ describe('runKtxSl', () => {
it('fails validation when a table-backed source declares columns absent from a matching warehouse manifest', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
await project.fileStore.writeFile(
'semantic-layer/postgres-warehouse/_schema/orbit_analytics.yaml',
`tables:
@ -189,7 +189,7 @@ joins: []
it('runs sl query and prints SQL output', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
project.config.connections.warehouse = { driver: 'postgres' };
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
@ -246,7 +246,7 @@ joins: []
it('runs sl query from a JSON query file', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
project.config.connections.warehouse = { driver: 'postgres' };
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
@ -313,7 +313,7 @@ joins: []
it('creates default sl query compute through the managed runtime helper', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
project.config.connections.warehouse = { driver: 'postgres' };
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
@ -374,7 +374,7 @@ joins: []
it('executes sl query through the injected query executor', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
project.config.connections.warehouse = { driver: 'postgres', url: 'postgres://example/db' };
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
@ -459,7 +459,7 @@ joins: []
it('executes sl query against a local SQLite connection through the default executor', async () => {
const projectDir = join(tempDir, 'project');
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
const project = await initKtxProject({ projectDir });
const dbPath = join(projectDir, 'warehouse.db');
const db = new Database(dbPath);
db.exec(`
@ -475,7 +475,6 @@ joins: []
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',

View file

@ -80,7 +80,6 @@ async function writeSqliteScanConfig(projectDir: string, dbPath: string, enrich
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
@ -195,7 +194,7 @@ describe('standalone built ktx CLI smoke', () => {
expectProjectStderr(connectionTest, projectDir);
expect(connectionTest.stdout).toContain('Connection test passed: warehouse');
expect(connectionTest.stdout).toContain('Driver: sqlite');
expect(connectionTest.stdout).toContain('Tables: 2');
expect(connectionTest.stdout).toContain('Status: ok');
const ingest = await runBuiltCli(['ingest', 'warehouse', '--project-dir', projectDir, '--fast', '--no-input']);
expectProjectStderr(ingest, projectDir);
@ -218,7 +217,6 @@ describe('standalone built ktx CLI smoke', () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: gateway-smoke',
'llm:',
' provider:',
' backend: gateway',

View file

@ -1,4 +1,6 @@
import { basename } from 'node:path';
import type {
KtxConfigIssue,
KtxLocalProject,
KtxProjectConfig,
KtxProjectConnectionConfig,
@ -56,6 +58,12 @@ interface StorageStatus {
gitAuthor: string;
}
interface ConfigStatus {
status: ProjectStatusLevel;
detail: string;
issues: KtxConfigIssue[];
}
interface WarningItem {
message: string;
fix?: string;
@ -72,6 +80,7 @@ function hasOwnField(value: Record<string, unknown>, key: string): boolean {
export interface ProjectStatus {
projectName: string;
projectDir: string;
config: ConfigStatus;
llm: LlmStatus;
embeddings: EmbeddingsStatus;
storage: StorageStatus;
@ -281,9 +290,9 @@ function buildConnectionStatus(
return warn('repoUrl not set', 'Rerun `ktx setup`');
}
case 'metabase': {
const url = (conn as Record<string, unknown>).url ?? (conn as Record<string, unknown>).base_url;
const url = (conn as Record<string, unknown>).api_url;
if (typeof url === 'string' && url.length > 0) return ok(`url: ${url}`);
return warn('url not set', 'Rerun `ktx setup`');
return warn('api_url not set', 'Rerun `ktx setup`');
}
case 'looker':
case 'lookml': {
@ -610,12 +619,26 @@ function buildVerdict(
export interface BuildProjectStatusOptions {
env?: NodeJS.ProcessEnv;
postgresQueryHistoryProbe?: PostgresQueryHistoryProbe;
configIssues?: KtxConfigIssue[];
}
function buildConfigStatus(issues: KtxConfigIssue[] | undefined): ConfigStatus {
const list = issues ?? [];
if (list.length === 0) {
return { status: 'ok', detail: 'ktx.yaml schema valid', issues: [] };
}
return {
status: 'warn',
detail: `${list.length} issue${list.length === 1 ? '' : 's'} in ktx.yaml`,
issues: list,
};
}
export async function buildProjectStatus(project: KtxLocalProject, options: BuildProjectStatusOptions = {}): Promise<ProjectStatus> {
const env = options.env ?? process.env;
const config = project.config;
const configStatus = buildConfigStatus(options.configIssues);
const llm = buildLlmStatus(config.llm, env);
const embeddings = buildEmbeddingsStatus(config.ingest.embeddings, env);
const storage = buildStorageStatus(config);
@ -628,8 +651,9 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
const { verdict, reason, nextActions } = buildVerdict(llm, embeddings, connections, queryHistory, warnings);
return {
projectName: config.project,
projectName: basename(project.projectDir) || project.projectDir,
projectDir: project.projectDir,
config: configStatus,
llm,
embeddings,
storage,
@ -719,6 +743,13 @@ export function renderProjectStatus(status: ProjectStatus, options: RenderProjec
lines.push(` ${label('Embeddings')} ${embedDetail} ${sym(status.embeddings.status)} ${dim(status.embeddings.detail)}`);
lines.push(` ${label('Storage')} ${dim(`${status.storage.state} (state) · ${status.storage.search} (search)`)}`);
lines.push(` ${label('Config')} ${sym(status.config.status)} ${dim(status.config.detail)}`);
if (status.config.issues.length > 0) {
for (const issue of status.config.issues) {
lines.push(` ${color('warn', SYMBOL.warn)} ${issue.message}`);
if (issue.fix) lines.push(` ${dim(`${issue.fix}`)}`);
}
}
lines.push('');
// Connections