mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
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:
commit
6c73029d0c
163 changed files with 2908 additions and 1663 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ function projectWithConnections(connections: KtxProjectConfig['connections']): K
|
|||
return {
|
||||
projectDir: '/tmp/project',
|
||||
config: {
|
||||
...buildDefaultKtxProjectConfig('warehouse'),
|
||||
...buildDefaultKtxProjectConfig(),
|
||||
connections,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export function defaultDemoProjectDir(): string {
|
|||
|
||||
function demoConfig(databasePath: string): string {
|
||||
return [
|
||||
'project: ktx-demo-orbit',
|
||||
'connections:',
|
||||
` ${DEMO_CONNECTION_ID}:`,
|
||||
' driver: sqlite',
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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`));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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:',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {}',
|
||||
|
|
|
|||
|
|
@ -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: {}',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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: {}',
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue