mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
Merge remote-tracking branch 'origin/main' into clean-ktx-dev-cli
# Conflicts: # packages/cli/src/setup-sources.ts
This commit is contained in:
commit
42cb3cb64b
28 changed files with 891 additions and 253 deletions
|
|
@ -28,12 +28,12 @@ describe('prompt navigation helpers', () => {
|
|||
'Name this PostgreSQL connection\nKTX will use this short name in commands and config. You can rename it now.',
|
||||
),
|
||||
).toBe(
|
||||
'Name this PostgreSQL connection\n\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.\n',
|
||||
'Name this PostgreSQL connection\n│\n│ KTX will use this short name in commands and config. You can rename it now.\n│ Press Escape to go back.\n│',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a blank separator before compact text input values', () => {
|
||||
expect(withTextInputNavigation('Project folder path')).toBe('Project folder path\nPress Escape to go back.\n');
|
||||
expect(withTextInputNavigation('Project folder path')).toBe('Project folder path\n│ Press Escape to go back.\n│');
|
||||
});
|
||||
|
||||
it('normalizes already hinted text input prompts without duplicating the hint', () => {
|
||||
|
|
@ -42,7 +42,19 @@ describe('prompt navigation helpers', () => {
|
|||
'Name this PostgreSQL connection\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.',
|
||||
),
|
||||
).toBe(
|
||||
'Name this PostgreSQL connection\n\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.\n',
|
||||
'Name this PostgreSQL connection\n│\n│ KTX will use this short name in commands and config. You can rename it now.\n│ Press Escape to go back.\n│',
|
||||
);
|
||||
});
|
||||
|
||||
it('is idempotent when text input navigation is applied twice', () => {
|
||||
const once = withTextInputNavigation('Project folder path');
|
||||
expect(withTextInputNavigation(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('is idempotent when text input navigation with body is applied twice', () => {
|
||||
const once = withTextInputNavigation(
|
||||
'Name this PostgreSQL connection\nKTX will use this short name in commands and config.',
|
||||
);
|
||||
expect(withTextInputNavigation(once)).toBe(once);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,26 @@ function removeTrailingBlankLines(message: string): string {
|
|||
return message.replace(/\n+$/, '');
|
||||
}
|
||||
|
||||
function prefixContinuationLines(message: string): string {
|
||||
const lines = message.split('\n');
|
||||
if (lines.length <= 1) return message;
|
||||
const [title, ...body] = lines;
|
||||
let trailingEmptyCount = 0;
|
||||
while (trailingEmptyCount < body.length && body[body.length - 1 - trailingEmptyCount] === '') {
|
||||
trailingEmptyCount++;
|
||||
}
|
||||
const contentBody = trailingEmptyCount > 0 ? body.slice(0, -trailingEmptyCount) : body;
|
||||
const trailingBody = trailingEmptyCount > 0 ? body.slice(-trailingEmptyCount) : [];
|
||||
return [
|
||||
title,
|
||||
...contentBody.map((line) => {
|
||||
const stripped = line.replace(/^│\s*/, '');
|
||||
return stripped === '' ? '│' : `│ ${stripped}`;
|
||||
}),
|
||||
...trailingBody,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function withTextInputBodySpacing(message: string): string {
|
||||
const normalized = removeTrailingBlankLines(message);
|
||||
if (!normalized.includes('\n')) {
|
||||
|
|
@ -39,7 +59,9 @@ export function withMultiselectNavigation(message: string): string {
|
|||
export function withTextInputNavigation(message: string): string {
|
||||
const messageWithoutHint = removeTrailingBlankLines(message)
|
||||
.split('\n')
|
||||
.filter((line) => line !== TEXT_INPUT_NAVIGATION_HINT)
|
||||
.filter((line) => !line.includes(TEXT_INPUT_NAVIGATION_HINT))
|
||||
.map((line) => line.replace(/^│\s*/, ''))
|
||||
.join('\n');
|
||||
return `${withTextInputBodySpacing(messageWithoutHint)}\n${TEXT_INPUT_NAVIGATION_HINT}\n`;
|
||||
const full = `${withTextInputBodySpacing(messageWithoutHint)}\n${TEXT_INPUT_NAVIGATION_HINT}`;
|
||||
return `${prefixContinuationLines(full)}\n│`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readKtxSetupState } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
formatInstallSummary,
|
||||
|
|
@ -89,7 +90,7 @@ describe('setup agents', () => {
|
|||
projectDir: tempDir,
|
||||
installs: [{ target: 'universal', scope: 'project', mode: 'cli' }],
|
||||
});
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('agents');
|
||||
expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['agents'] });
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
|
|
@ -143,7 +144,7 @@ describe('setup agents', () => {
|
|||
await expect(readKtxAgentInstallManifest(tempDir)).resolves.toEqual(null);
|
||||
});
|
||||
|
||||
it('uses prompts in interactive mode and supports Back', async () => {
|
||||
it('treats cancel as skip in interactive mode', async () => {
|
||||
const io = makeIo();
|
||||
const prompts = {
|
||||
select: vi.fn(async () => 'back'),
|
||||
|
|
@ -165,7 +166,7 @@ describe('setup agents', () => {
|
|||
io.io,
|
||||
{ prompts },
|
||||
),
|
||||
).resolves.toEqual({ status: 'back', projectDir: tempDir });
|
||||
).resolves.toEqual({ status: 'skipped', projectDir: tempDir });
|
||||
});
|
||||
|
||||
it('explains how to select multiple agent targets in interactive mode', async () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { cancel, isCancel, multiselect, select } from '@clack/prompts';
|
||||
import { cancel, confirm, isCancel, multiselect, select } from '@clack/prompts';
|
||||
import {
|
||||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
|
|
@ -277,12 +277,23 @@ function createPromptAdapter(): KtxSetupAgentsPromptAdapter {
|
|||
return String(value);
|
||||
},
|
||||
async multiselect(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
const selected = [...value] as string[];
|
||||
if (selected.length === 0 && !options.required) {
|
||||
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
if (!skipConfirmed) continue;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
return [...value] as string[];
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
|
|
@ -375,7 +386,7 @@ export async function runKtxSetupAgentsStep(
|
|||
deps: KtxSetupAgentsDeps = {},
|
||||
): Promise<KtxSetupAgentsResult> {
|
||||
if (args.skipAgents) {
|
||||
io.stdout.write('Agent integration skipped.\n');
|
||||
io.stdout.write('│ Agent integration skipped.\n');
|
||||
return { status: 'skipped', projectDir: args.projectDir };
|
||||
}
|
||||
if (!args.agents && args.inputMode === 'disabled') {
|
||||
|
|
@ -391,10 +402,9 @@ export async function runKtxSetupAgentsStep(
|
|||
options: [
|
||||
{ value: 'cli', label: 'CLI tools and skills' },
|
||||
{ value: 'skip', label: 'Skip' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
})) as KtxAgentInstallMode | 'skip' | 'back');
|
||||
if (mode === 'back') return { status: 'back', projectDir: args.projectDir };
|
||||
if (mode === 'back') return { status: 'skipped', projectDir: args.projectDir };
|
||||
if (mode === 'skip') return { status: 'skipped', projectDir: args.projectDir };
|
||||
|
||||
const targets =
|
||||
|
|
|
|||
|
|
@ -58,10 +58,10 @@ function connectionNamePrompt(label: string): string {
|
|||
function textInputPrompt(message: string): string {
|
||||
const normalized = message.replace(/\n+$/, '');
|
||||
if (!normalized.includes('\n')) {
|
||||
return `${normalized}\nPress Escape to go back.\n`;
|
||||
return `${normalized}\n│ Press Escape to go back.\n│`;
|
||||
}
|
||||
const [title, ...bodyLines] = normalized.split('\n');
|
||||
return `${title}\n\n${bodyLines.join('\n')}\nPress Escape to go back.\n`;
|
||||
return `${title}\n│\n│ ${bodyLines.join('\n│ ')}\n│ Press Escape to go back.\n│`;
|
||||
}
|
||||
|
||||
const legacyHistoricSqlServiceAccountPatternsKey = ['serviceAccount', 'UserPatterns'].join('');
|
||||
|
|
@ -142,8 +142,8 @@ describe('setup databases step', () => {
|
|||
expect(prompts.select).toHaveBeenCalledWith({
|
||||
message: 'How do you want to connect to PostgreSQL?',
|
||||
options: [
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'url', label: 'Paste a connection URL' },
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
|
|
@ -154,6 +154,43 @@ describe('setup databases step', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('offers connection URL paste first for URL-capable primary sources', async () => {
|
||||
const cases: Array<{ driver: KtxSetupDatabaseDriver; label: string }> = [
|
||||
{ driver: 'postgres', label: 'PostgreSQL' },
|
||||
{ driver: 'mysql', label: 'MySQL' },
|
||||
{ driver: 'clickhouse', label: 'ClickHouse' },
|
||||
{ driver: 'sqlserver', label: 'SQL Server' },
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const prompts = makePromptAdapter({
|
||||
selectValues: ['back'],
|
||||
});
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
databaseDrivers: [testCase.driver],
|
||||
skipDatabases: false,
|
||||
databaseSchemas: [],
|
||||
},
|
||||
makeIo().io,
|
||||
{ prompts },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('back');
|
||||
expect(prompts.select).toHaveBeenCalledWith({
|
||||
message: `How do you want to connect to ${testCase.label}?`,
|
||||
options: [
|
||||
{ value: 'url', label: 'Paste a connection URL' },
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('lets Back leave database setup when the driver came from flags', async () => {
|
||||
const prompts = makePromptAdapter({ selectValues: ['back'] });
|
||||
|
||||
|
|
@ -488,8 +525,8 @@ describe('setup databases step', () => {
|
|||
expect(prompts.select).toHaveBeenNthCalledWith(1, {
|
||||
message: 'How do you want to connect to PostgreSQL?',
|
||||
options: [
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'url', label: 'Paste a connection URL' },
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
|
|
@ -534,7 +571,6 @@ describe('setup databases step', () => {
|
|||
options: [
|
||||
{ value: 'continue', label: 'Continue to knowledge sources' },
|
||||
{ value: 'add', label: 'Add another primary source' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
expect(testConnection).not.toHaveBeenCalled();
|
||||
|
|
@ -585,7 +621,6 @@ describe('setup databases step', () => {
|
|||
options: [
|
||||
{ value: 'continue', label: 'Continue to knowledge sources' },
|
||||
{ value: 'add', label: 'Add another primary source' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
expect(testConnection).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -620,7 +655,6 @@ describe('setup databases step', () => {
|
|||
options: [
|
||||
{ value: 'continue', label: 'Continue to knowledge sources' },
|
||||
{ value: 'add', label: 'Add another primary source' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
|
|
@ -655,7 +689,6 @@ describe('setup databases step', () => {
|
|||
options: [
|
||||
{ value: 'continue', label: 'Continue to knowledge sources' },
|
||||
{ value: 'add', label: 'Add another primary source' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
@ -698,7 +731,6 @@ describe('setup databases step', () => {
|
|||
options: [
|
||||
{ value: 'continue', label: 'Continue to knowledge sources' },
|
||||
{ value: 'add', label: 'Add another primary source' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
@ -918,10 +950,11 @@ describe('setup databases step', () => {
|
|||
[
|
||||
'◇ Testing postgres-warehouse',
|
||||
'│ ✓ Connection test passed',
|
||||
'│ Driver: PostgreSQL · Tables: 2',
|
||||
'│ Driver: PostgreSQL',
|
||||
'│',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(io.stdout()).not.toContain('Tables: 2');
|
||||
expect(io.stdout()).toContain(
|
||||
[
|
||||
'◇ Scanning postgres-warehouse',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { writeFile } from 'node:fs/promises';
|
||||
import { cancel, isCancel, multiselect, password, select, text } from '@clack/prompts';
|
||||
import { cancel, confirm, isCancel, multiselect, password, select, text } from '@clack/prompts';
|
||||
import type { HistoricSqlDialect } from '@ktx/context/ingest';
|
||||
import {
|
||||
type KtxProjectConnectionConfig,
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
setKtxSetupDatabaseConnectionIds,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxTableListEntry } from '@ktx/context/scan';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { runKtxConnection } from './connection.js';
|
||||
import { withMenuOptionsSpacing, withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
|
||||
|
|
@ -83,6 +84,7 @@ export interface KtxSetupDatabasesDeps {
|
|||
testConnection?: (projectDir: string, connectionId: string, io: KtxCliIo) => Promise<number>;
|
||||
scanConnection?: (projectDir: string, connectionId: string, io: KtxCliIo) => Promise<number>;
|
||||
listSchemas?: (projectDir: string, connectionId: string) => Promise<string[]>;
|
||||
listTables?: (projectDir: string, connectionId: string) => Promise<KtxTableListEntry[]>;
|
||||
historicSqlProbe?: KtxSetupHistoricSqlProbe;
|
||||
}
|
||||
|
||||
|
|
@ -203,12 +205,23 @@ function missingConnectionDetailsPrompt(
|
|||
function createPromptAdapter(): KtxSetupDatabasesPromptAdapter {
|
||||
return {
|
||||
async multiselect(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
const selected = [...value] as string[];
|
||||
if (selected.length === 0 && !options.required) {
|
||||
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
if (!skipConfirmed) continue;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
return [...value] as string[];
|
||||
},
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
|
|
@ -364,6 +377,89 @@ async function defaultListSchemas(projectDir: string, connectionId: string): Pro
|
|||
return [];
|
||||
}
|
||||
|
||||
function configuredSchemas(connection: KtxProjectConnectionConfig | undefined, driver: KtxSetupDatabaseDriver): string[] | undefined {
|
||||
if (!connection) return undefined;
|
||||
const spec = SCOPE_DISCOVERY_SPECS[driver];
|
||||
if (!spec) return undefined;
|
||||
const values = configuredScopeValues(connection, spec);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
async function defaultListTables(projectDir: string, connectionId: string): Promise<KtxTableListEntry[]> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const connection = project.config.connections[connectionId];
|
||||
const driver = normalizeDriver(connection?.driver);
|
||||
const schemas = driver ? configuredSchemas(connection, driver) : undefined;
|
||||
|
||||
if (driver === 'postgres') {
|
||||
const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('@ktx/connector-postgres');
|
||||
if (!isKtxPostgresConnectionConfig(connection)) return [];
|
||||
const connector = new KtxPostgresScanConnector({ connectionId, connection });
|
||||
try {
|
||||
return await connector.listTables(schemas);
|
||||
} finally {
|
||||
await connector.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (driver === 'mysql') {
|
||||
const { KtxMysqlScanConnector, isKtxMysqlConnectionConfig } = await import('@ktx/connector-mysql');
|
||||
if (!isKtxMysqlConnectionConfig(connection)) return [];
|
||||
const connector = new KtxMysqlScanConnector({ connectionId, connection });
|
||||
try {
|
||||
return await connector.listTables(schemas);
|
||||
} finally {
|
||||
await connector.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (driver === 'sqlserver') {
|
||||
const { KtxSqlServerScanConnector, isKtxSqlServerConnectionConfig } = await import('@ktx/connector-sqlserver');
|
||||
if (!isKtxSqlServerConnectionConfig(connection)) return [];
|
||||
const connector = new KtxSqlServerScanConnector({ connectionId, connection });
|
||||
try {
|
||||
return await connector.listTables(schemas);
|
||||
} finally {
|
||||
await connector.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (driver === 'bigquery') {
|
||||
const { KtxBigQueryScanConnector, isKtxBigQueryConnectionConfig } = await import('@ktx/connector-bigquery');
|
||||
if (!isKtxBigQueryConnectionConfig(connection)) return [];
|
||||
const connector = new KtxBigQueryScanConnector({ connectionId, connection });
|
||||
try {
|
||||
return await connector.listTables(schemas);
|
||||
} finally {
|
||||
await connector.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (driver === 'snowflake') {
|
||||
const { KtxSnowflakeScanConnector, isKtxSnowflakeConnectionConfig } = await import('@ktx/connector-snowflake');
|
||||
if (!isKtxSnowflakeConnectionConfig(connection)) return [];
|
||||
const connector = new KtxSnowflakeScanConnector({ connectionId, connection });
|
||||
try {
|
||||
return await connector.listTables(schemas);
|
||||
} finally {
|
||||
await connector.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (driver === 'clickhouse') {
|
||||
const { KtxClickHouseScanConnector, isKtxClickHouseConnectionConfig } = await import('@ktx/connector-clickhouse');
|
||||
if (!isKtxClickHouseConnectionConfig(connection)) return [];
|
||||
const connector = new KtxClickHouseScanConnector({ connectionId, connection });
|
||||
try {
|
||||
return await connector.listTables(schemas);
|
||||
} finally {
|
||||
await connector.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function existingConnectionIdsByDriver(
|
||||
connections: Record<string, KtxProjectConnectionConfig>,
|
||||
driver: KtxSetupDatabaseDriver,
|
||||
|
|
@ -400,7 +496,6 @@ function configuredPrimarySourcesPrompt(connectionIds: string[]): {
|
|||
options: [
|
||||
{ value: 'continue', label: 'Continue to knowledge sources' },
|
||||
{ value: 'add', label: 'Add another primary source' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -615,8 +710,8 @@ async function buildUrlConnectionConfig(input: {
|
|||
const choice = await input.prompts.select({
|
||||
message: `How do you want to connect to ${label}?`,
|
||||
options: [
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'url', label: 'Paste a connection URL' },
|
||||
{ value: 'fields', label: 'Enter connection details (host, port, database, user)' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
|
|
@ -975,6 +1070,22 @@ async function writeScopeConfig(input: {
|
|||
});
|
||||
}
|
||||
|
||||
async function clearScopeConfig(projectDir: string, connectionId: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection) return;
|
||||
const driver = normalizeDriver(connection.driver);
|
||||
if (!driver) return;
|
||||
const spec = SCOPE_DISCOVERY_SPECS[driver];
|
||||
if (!spec) return;
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(connection).filter(
|
||||
([key]) => key !== spec.configArrayField && key !== spec.configSingleField && key !== 'enabled_tables',
|
||||
),
|
||||
) as KtxProjectConnectionConfig;
|
||||
await writeConnectionConfig({ projectDir, connectionId, connection: cleaned });
|
||||
}
|
||||
|
||||
async function maybeConfigureSchemaScope(input: {
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
|
|
@ -1061,6 +1172,130 @@ async function maybeConfigureSchemaScope(input: {
|
|||
return true;
|
||||
}
|
||||
|
||||
async function maybeConfigureTableScope(input: {
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
args: KtxSetupDatabasesArgs;
|
||||
prompts: KtxSetupDatabasesPromptAdapter;
|
||||
io: KtxCliIo;
|
||||
deps: KtxSetupDatabasesDeps;
|
||||
}): Promise<boolean> {
|
||||
const project = await loadKtxProject({ projectDir: input.projectDir });
|
||||
const connection = project.config.connections[input.connectionId];
|
||||
const driver = normalizeDriver(connection?.driver);
|
||||
if (!driver || driver === 'sqlite') return true;
|
||||
|
||||
const existingTables = connection?.enabled_tables;
|
||||
if (Array.isArray(existingTables) && existingTables.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (input.args.inputMode === 'disabled') {
|
||||
return true;
|
||||
}
|
||||
|
||||
writeSetupSection(input.io, 'Discovering tables', [
|
||||
`Connecting to ${input.connectionId}…`,
|
||||
]);
|
||||
|
||||
let discovered: KtxTableListEntry[];
|
||||
try {
|
||||
discovered = await (input.deps.listTables ?? defaultListTables)(
|
||||
input.projectDir,
|
||||
input.connectionId,
|
||||
);
|
||||
} catch (error) {
|
||||
input.io.stderr.write(
|
||||
`Could not discover tables for ${input.connectionId}; continuing without table filter. ` +
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (discovered.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const allQualified = discovered.map((t) => `${t.schema}.${t.name}`);
|
||||
|
||||
if (discovered.length === 1) {
|
||||
await writeConnectionConfig({
|
||||
projectDir: input.projectDir,
|
||||
connectionId: input.connectionId,
|
||||
connection: { ...connection!, enabled_tables: allQualified },
|
||||
});
|
||||
writeSetupSection(input.io, `Tables enabled for ${input.connectionId}`, [
|
||||
`✓ ${allQualified[0]}`,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
const bySchema = new Map<string, KtxTableListEntry[]>();
|
||||
for (const entry of discovered) {
|
||||
const existing = bySchema.get(entry.schema) ?? [];
|
||||
existing.push(entry);
|
||||
bySchema.set(entry.schema, existing);
|
||||
}
|
||||
const schemaList = [...bySchema.keys()].sort();
|
||||
const schemaSummary = schemaList.map((s) => `${s} (${bySchema.get(s)!.length})`).join(', ');
|
||||
|
||||
let selected: string[] | null = null;
|
||||
|
||||
while (selected === null) {
|
||||
const action = await input.prompts.select({
|
||||
message: `Tables found in selected schemas\n` +
|
||||
`${discovered.length} tables across ${schemaList.length} ${schemaList.length === 1 ? 'schema' : 'schemas'}: ${schemaSummary}`,
|
||||
options: [
|
||||
{ value: 'all', label: 'Enable all tables' },
|
||||
{ value: 'customize', label: 'Customize which tables to enable' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
|
||||
if (action === 'back') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action === 'all') {
|
||||
selected = allQualified;
|
||||
} else {
|
||||
const choices = await input.prompts.multiselect({
|
||||
message: withMultiselectNavigation(
|
||||
`Tables to enable for ${input.connectionId}\n` +
|
||||
`Deselect any tables agents should not use.`,
|
||||
),
|
||||
options: discovered.map((t) => {
|
||||
const qualified = `${t.schema}.${t.name}`;
|
||||
const suffix = t.kind === 'view' ? ' (view)' : '';
|
||||
return { value: qualified, label: `${qualified}${suffix}` };
|
||||
}),
|
||||
initialValues: allQualified,
|
||||
required: true,
|
||||
});
|
||||
|
||||
if (choices.includes('back')) {
|
||||
continue;
|
||||
}
|
||||
if (choices.length === 0) {
|
||||
input.io.stdout.write('│ KTX needs at least one table enabled. Select a table or press Escape to go back.\n');
|
||||
continue;
|
||||
}
|
||||
selected = choices;
|
||||
}
|
||||
}
|
||||
|
||||
await writeConnectionConfig({
|
||||
projectDir: input.projectDir,
|
||||
connectionId: input.connectionId,
|
||||
connection: { ...connection!, enabled_tables: selected },
|
||||
});
|
||||
|
||||
writeSetupSection(input.io, `Tables enabled for ${input.connectionId}`, [
|
||||
`✓ ${selected.length}/${discovered.length} tables enabled`,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function ensureHistoricSqlIngestDefaults(projectDir: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const adapters = project.config.ingest.adapters.includes('historic-sql')
|
||||
|
|
@ -1115,7 +1350,7 @@ async function maybeRunHistoricSqlSetupProbe(input: {
|
|||
return;
|
||||
}
|
||||
|
||||
input.io.stdout.write('Historic SQL probe...\n');
|
||||
input.io.stdout.write('│ Historic SQL probe...\n');
|
||||
const probe = input.deps.historicSqlProbe ?? defaultHistoricSqlProbe;
|
||||
const result = await probe({
|
||||
projectDir: input.projectDir,
|
||||
|
|
@ -1123,10 +1358,10 @@ async function maybeRunHistoricSqlSetupProbe(input: {
|
|||
dialect: 'postgres',
|
||||
});
|
||||
for (const line of result.lines) {
|
||||
input.io.stdout.write(`${line}\n`);
|
||||
input.io.stdout.write(`│${line}\n`);
|
||||
}
|
||||
if (!result.ok) {
|
||||
input.io.stdout.write('Setup written; first ingest run will fail until fixed.\n');
|
||||
input.io.stdout.write('│ Setup written; first ingest run will fail until fixed.\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1184,13 +1419,19 @@ async function validateAndScanConnection(input: {
|
|||
const testOutput = testIo.stdoutText();
|
||||
const outputDriver = normalizeDriver(readOutputValue(testOutput, 'Driver'));
|
||||
const driverDisplay = outputDriver ? driverLabel(outputDriver) : (configuredDriverLabel ?? 'Unknown driver');
|
||||
const tableCount = Number(readOutputValue(testOutput, 'Tables') ?? NaN);
|
||||
const testLines = ['✓ Connection test passed'];
|
||||
testLines.push(`Driver: ${driverDisplay}${Number.isFinite(tableCount) ? ` · Tables: ${tableCount}` : ''}`);
|
||||
const testLines = ['✓ Connection test passed', `Driver: ${driverDisplay}`];
|
||||
writeSetupSection(input.io, `Testing ${input.connectionId}`, testLines);
|
||||
|
||||
if (!(await maybeConfigureSchemaScope(input))) {
|
||||
return false;
|
||||
while (true) {
|
||||
if (!(await maybeConfigureSchemaScope(input))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await maybeConfigureTableScope(input)) {
|
||||
break;
|
||||
}
|
||||
|
||||
await clearScopeConfig(input.projectDir, input.connectionId);
|
||||
}
|
||||
|
||||
await maybeRunHistoricSqlSetupProbe({
|
||||
|
|
@ -1261,7 +1502,7 @@ async function chooseDrivers(
|
|||
return 'back';
|
||||
}
|
||||
|
||||
io.stdout.write('KTX cannot work without at least one primary source. Select a source or press Escape to go back.\n');
|
||||
io.stdout.write('│ KTX cannot work without at least one primary source. Select a source or press Escape to go back.\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1325,7 +1566,7 @@ export async function runKtxSetupDatabasesStep(
|
|||
deps: KtxSetupDatabasesDeps = {},
|
||||
): Promise<KtxSetupDatabasesResult> {
|
||||
if (args.skipDatabases) {
|
||||
io.stdout.write('Primary source setup skipped. KTX cannot work until you add a primary source.\n');
|
||||
io.stdout.write('│ Primary source setup skipped. KTX cannot work until you add a primary source.\n');
|
||||
return { status: 'skipped', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
@ -1361,13 +1602,10 @@ export async function runKtxSetupDatabasesStep(
|
|||
while (true) {
|
||||
if (showConfiguredPrimaryMenu) {
|
||||
const action = await prompts.select(configuredPrimarySourcesPrompt(selectedConnectionIds));
|
||||
if (action === 'continue') {
|
||||
if (action === 'continue' || action === 'back') {
|
||||
await markDatabasesComplete(args.projectDir, selectedConnectionIds);
|
||||
return { status: 'ready', projectDir: args.projectDir, connectionIds: selectedConnectionIds };
|
||||
}
|
||||
if (action === 'back') {
|
||||
return { status: 'back', projectDir: args.projectDir };
|
||||
}
|
||||
}
|
||||
showConfiguredPrimaryMenu = false;
|
||||
|
||||
|
|
@ -1382,7 +1620,7 @@ export async function runKtxSetupDatabasesStep(
|
|||
if (drivers === 'missing-input') return { status: 'missing-input', projectDir: args.projectDir };
|
||||
if (drivers.length === 0) {
|
||||
await markDatabasesComplete(args.projectDir, []);
|
||||
io.stdout.write('KTX cannot work without a primary source.\n');
|
||||
io.stdout.write('│ KTX cannot work without a primary source.\n');
|
||||
return { status: 'skipped', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ describe('runDemoTour', () => {
|
|||
},
|
||||
);
|
||||
expect(result).toBe(0);
|
||||
// Navigation called once for databases step, then exits
|
||||
// Navigation called once for intro, then exits on back
|
||||
expect(navigation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
|
@ -218,10 +218,11 @@ describe('runDemoTour', () => {
|
|||
let callCount = 0;
|
||||
const navigation = vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
// First call (databases): forward
|
||||
// Second call (sources): back
|
||||
// Third call (databases again): back (exit)
|
||||
if (callCount === 1) return Promise.resolve('forward');
|
||||
// First call (intro): forward
|
||||
// Second call (databases): forward
|
||||
// Third call (sources): back
|
||||
// Fourth call (databases again): back (exit)
|
||||
if (callCount <= 2) return Promise.resolve('forward');
|
||||
return Promise.resolve('back');
|
||||
});
|
||||
|
||||
|
|
@ -235,7 +236,7 @@ describe('runDemoTour', () => {
|
|||
},
|
||||
);
|
||||
expect(result).toBe(0);
|
||||
expect(navigation).toHaveBeenCalledTimes(3);
|
||||
expect(navigation).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('handles agent step returning back', async () => {
|
||||
|
|
@ -243,10 +244,10 @@ describe('runDemoTour', () => {
|
|||
let navCount = 0;
|
||||
const navigation = vi.fn().mockImplementation(() => {
|
||||
navCount++;
|
||||
// Forward through databases, sources, context
|
||||
// Forward through intro, databases, sources, context
|
||||
// Then back from context (after agents returns back)
|
||||
// Then back from sources, then back from databases (exit)
|
||||
if (navCount <= 3) return Promise.resolve('forward');
|
||||
if (navCount <= 4) return Promise.resolve('forward');
|
||||
return Promise.resolve('back');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -62,12 +62,15 @@ function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTarge
|
|||
// Pure rendering functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderDemoBanner(): string {
|
||||
export function renderDemoBanner(projectDir?: string): string {
|
||||
const lines = [
|
||||
'',
|
||||
`┌ ${cyan('Demo mode')} — data has been pre-processed and KTX context is already built.`,
|
||||
'│ This walkthrough illustrates the setup steps. Selections are pre-filled and read-only.',
|
||||
];
|
||||
if (projectDir) {
|
||||
lines.push(`│ Project directory: ${dim(projectDir)}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -144,16 +147,15 @@ export async function waitForDemoNavigation(
|
|||
};
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
const char = data.toString();
|
||||
if (char === '\r' || char === '\n') {
|
||||
cleanup();
|
||||
resolve('forward');
|
||||
} else if (char === '\x1b') {
|
||||
cleanup();
|
||||
resolve('back');
|
||||
} else if (char === '\x03') {
|
||||
if (data[0] === 0x03) {
|
||||
cleanup();
|
||||
reject(new KtxSetupExitError());
|
||||
} else if (data[0] === 0x0d || data[0] === 0x0a) {
|
||||
cleanup();
|
||||
resolve('forward');
|
||||
} else if (data[0] === 0x1b) {
|
||||
cleanup();
|
||||
resolve('back');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -171,8 +173,9 @@ export async function renderDemoCard(
|
|||
io: KtxCliIo,
|
||||
stdin?: NodeJS.ReadStream,
|
||||
waitNav: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'> = waitForDemoNavigation,
|
||||
projectDir?: string,
|
||||
): Promise<'forward' | 'back'> {
|
||||
io.stdout.write(renderDemoBanner() + '\n\n');
|
||||
io.stdout.write(renderDemoBanner(projectDir) + '\n\n');
|
||||
io.stdout.write(renderDemoCardContent(title, selections) + '\n');
|
||||
return waitNav(stdin);
|
||||
}
|
||||
|
|
@ -337,6 +340,11 @@ export async function runDemoTour(
|
|||
const projectDir = defaultDemoProjectDir();
|
||||
await ensureProject({ projectDir, force: false });
|
||||
|
||||
io.stdout.write(renderDemoBanner(projectDir) + '\n');
|
||||
io.stdout.write(`\n│ ${dim('Press Enter to continue, Escape to go back')}\n└\n`);
|
||||
const introDirection = await waitNav();
|
||||
if (introDirection === 'back') return 0;
|
||||
|
||||
let stepIndex = 0;
|
||||
|
||||
while (stepIndex < DEMO_STEPS.length) {
|
||||
|
|
@ -344,11 +352,11 @@ export async function runDemoTour(
|
|||
let direction: 'forward' | 'back';
|
||||
|
||||
if (step === 'databases') {
|
||||
direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav);
|
||||
direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav, projectDir);
|
||||
} else if (step === 'sources') {
|
||||
direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav);
|
||||
direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav, projectDir);
|
||||
} else if (step === 'context') {
|
||||
io.stdout.write(renderDemoBanner() + '\n\n');
|
||||
io.stdout.write(renderDemoBanner(projectDir) + '\n\n');
|
||||
if (deps.skipReplayAnimation) {
|
||||
direction = await waitNav();
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,12 @@ describe('setup embeddings step', () => {
|
|||
const healthCheck = vi.fn(async () => ({ ok: true as const }));
|
||||
const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] });
|
||||
const ensureLocalEmbeddings = vi.fn(async () => managedDaemon());
|
||||
const spinnerEvents: string[] = [];
|
||||
const spinner = vi.fn(() => ({
|
||||
start: (msg: string) => spinnerEvents.push(`start:${msg}`),
|
||||
stop: (msg: string) => spinnerEvents.push(`stop:${msg}`),
|
||||
error: (msg: string) => spinnerEvents.push(`error:${msg}`),
|
||||
}));
|
||||
|
||||
const result = await runKtxSetupEmbeddingsStep(
|
||||
{
|
||||
|
|
@ -143,7 +149,7 @@ describe('setup embeddings step', () => {
|
|||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts, env: {}, healthCheck, ensureLocalEmbeddings },
|
||||
{ prompts, env: {}, healthCheck, ensureLocalEmbeddings, spinner },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
|
|
@ -168,8 +174,8 @@ describe('setup embeddings step', () => {
|
|||
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
|
||||
expect(io.stdout()).toContain(
|
||||
'Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
|
||||
expect(spinnerEvents).toContainEqual(
|
||||
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
|
||||
);
|
||||
expect(io.stdout()).toContain('Embeddings ready: yes');
|
||||
});
|
||||
|
|
@ -184,6 +190,12 @@ describe('setup embeddings step', () => {
|
|||
resolveHealthCheck = resolve;
|
||||
}),
|
||||
);
|
||||
const spinnerEvents: string[] = [];
|
||||
const spinner = vi.fn(() => ({
|
||||
start: (msg: string) => spinnerEvents.push(`start:${msg}`),
|
||||
stop: (msg: string) => spinnerEvents.push(`stop:${msg}`),
|
||||
error: (msg: string) => spinnerEvents.push(`error:${msg}`),
|
||||
}));
|
||||
|
||||
const result = runKtxSetupEmbeddingsStep(
|
||||
{
|
||||
|
|
@ -194,12 +206,12 @@ describe('setup embeddings step', () => {
|
|||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts, env: {}, healthCheck, ensureLocalEmbeddings: vi.fn(async () => managedDaemon()) },
|
||||
{ prompts, env: {}, healthCheck, ensureLocalEmbeddings: vi.fn(async () => managedDaemon()), spinner },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(io.stdout()).toContain(
|
||||
'\r- Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
|
||||
expect(spinnerEvents).toContainEqual(
|
||||
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from '@ktx/context/project';
|
||||
import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { createClackSpinner, type KtxCliSpinner } from './clack.js';
|
||||
import {
|
||||
ensureManagedLocalEmbeddingsDaemon,
|
||||
managedLocalEmbeddingHealthConfig,
|
||||
|
|
@ -61,6 +62,7 @@ export interface KtxSetupEmbeddingsDeps {
|
|||
installPolicy: KtxManagedPythonInstallPolicy;
|
||||
io: KtxCliIo;
|
||||
}) => Promise<ManagedLocalEmbeddingsDaemon>;
|
||||
spinner?: () => KtxCliSpinner;
|
||||
}
|
||||
|
||||
type BackendChoice = KtxSetupEmbeddingBackend | 'back';
|
||||
|
|
@ -83,14 +85,6 @@ const EMBEDDING_OPTION_PROMPT_CONTEXT =
|
|||
'KTX uses embeddings for semantic search over semantic-layer sources, wiki context, schema metadata, ' +
|
||||
'and relationship evidence.';
|
||||
const LOCAL_EMBEDDING_HEALTH_TIMEOUT_MS = 120_000;
|
||||
const HEALTH_CHECK_SPINNER_FRAMES = ['-', '\\', '|', '/'] as const;
|
||||
const HEALTH_CHECK_SPINNER_INTERVAL_MS = 120;
|
||||
const CLEAR_CURRENT_LINE = '\x1b[2K\r';
|
||||
|
||||
interface HealthCheckProgress {
|
||||
succeed(message: string): void;
|
||||
fail(message: string): void;
|
||||
}
|
||||
|
||||
function createPromptAdapter(): KtxSetupEmbeddingsPromptAdapter {
|
||||
return {
|
||||
|
|
@ -260,7 +254,7 @@ async function chooseCredentialRef(
|
|||
}
|
||||
if (choice === 'paste') {
|
||||
io.stdout.write(
|
||||
`${[
|
||||
`│ ${[
|
||||
`KTX will save the key in .ktx/secrets/${backend}-api-key with local file permissions,`,
|
||||
'then write a file: reference in ktx.yaml.',
|
||||
].join(' ')}\n`,
|
||||
|
|
@ -350,42 +344,17 @@ function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string,
|
|||
return `Checking ${backend} embeddings (${model}, ${dimensions} dimensions).`;
|
||||
}
|
||||
|
||||
function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckProgress {
|
||||
if (io.stdout.isTTY !== true) {
|
||||
io.stdout.write(`${message}\n`);
|
||||
const noop = () => undefined;
|
||||
return {
|
||||
succeed: noop,
|
||||
fail: noop,
|
||||
};
|
||||
}
|
||||
|
||||
let frameIndex = 0;
|
||||
let stopped = false;
|
||||
const writeFrame = () => {
|
||||
io.stdout.write(`${CLEAR_CURRENT_LINE}${HEALTH_CHECK_SPINNER_FRAMES[frameIndex]} ${message}`);
|
||||
};
|
||||
writeFrame();
|
||||
const interval = setInterval(() => {
|
||||
frameIndex = (frameIndex + 1) % HEALTH_CHECK_SPINNER_FRAMES.length;
|
||||
writeFrame();
|
||||
}, HEALTH_CHECK_SPINNER_INTERVAL_MS);
|
||||
|
||||
const stop = (finalMessage: string) => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
clearInterval(interval);
|
||||
io.stdout.write(`${CLEAR_CURRENT_LINE}${finalMessage}\n`);
|
||||
};
|
||||
|
||||
function startHealthCheckProgress(
|
||||
spinner: KtxCliSpinner,
|
||||
message: string,
|
||||
): { succeed(msg: string): void; fail(msg: string): void } {
|
||||
spinner.start(message);
|
||||
return {
|
||||
succeed(message) {
|
||||
stop(message);
|
||||
succeed(msg: string) {
|
||||
spinner.stop(msg);
|
||||
},
|
||||
fail(message) {
|
||||
stop(message);
|
||||
fail(msg: string) {
|
||||
spinner.error(msg);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -396,7 +365,7 @@ export async function runKtxSetupEmbeddingsStep(
|
|||
deps: KtxSetupEmbeddingsDeps = {},
|
||||
): Promise<KtxSetupEmbeddingsResult> {
|
||||
if (args.skipEmbeddings) {
|
||||
io.stdout.write('Embeddings setup skipped.\n');
|
||||
io.stdout.write('│ Embeddings setup skipped.\n');
|
||||
return { status: 'skipped', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
@ -408,7 +377,7 @@ export async function runKtxSetupEmbeddingsStep(
|
|||
!args.embeddingApiKeyEnv &&
|
||||
!args.embeddingApiKeyFile
|
||||
) {
|
||||
io.stdout.write(`Embeddings ready: yes (${project.config.ingest.embeddings.model})\n`);
|
||||
io.stdout.write(`│ Embeddings ready: yes (${project.config.ingest.embeddings.model})\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
@ -474,7 +443,8 @@ export async function runKtxSetupEmbeddingsStep(
|
|||
dimensions,
|
||||
credentialValue,
|
||||
});
|
||||
const progress = startHealthCheckProgress(io, healthCheckStartText(selectedBackend, model, dimensions));
|
||||
const healthSpinner = (deps.spinner ?? createClackSpinner)();
|
||||
const progress = startHealthCheckProgress(healthSpinner, healthCheckStartText(selectedBackend, model, dimensions));
|
||||
let health: KtxEmbeddingHealthCheckResult;
|
||||
try {
|
||||
health = await healthCheck(healthConfig);
|
||||
|
|
@ -495,7 +465,7 @@ export async function runKtxSetupEmbeddingsStep(
|
|||
credentialRef,
|
||||
}),
|
||||
);
|
||||
io.stdout.write(`Embeddings ready: yes (${model}, ${dimensions} dimensions)\n`);
|
||||
io.stdout.write(`│ Embeddings ready: yes (${model}, ${dimensions} dimensions)\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -312,7 +312,7 @@ describe('setup Anthropic model step', () => {
|
|||
expect(result.status).toBe('ready');
|
||||
expect(prompts.select).not.toHaveBeenCalledWith(expect.objectContaining({ message: 'Paste Anthropic API key now?' }));
|
||||
expect(prompts.password).toHaveBeenCalledWith({
|
||||
message: 'Anthropic API key\nPress Escape to go back.\n',
|
||||
message: 'Anthropic API key\n│ Press Escape to go back.\n│',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -464,7 +464,7 @@ describe('setup Anthropic model step', () => {
|
|||
);
|
||||
expect(prompts.text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Anthropic model ID\nPress Escape to go back.\n',
|
||||
message: 'Anthropic model ID\n│ Press Escape to go back.\n│',
|
||||
placeholder: 'claude-sonnet-4-6',
|
||||
}),
|
||||
);
|
||||
|
|
@ -629,7 +629,7 @@ describe('setup Anthropic model step', () => {
|
|||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(prompts.password).toHaveBeenCalledWith({
|
||||
message: 'Anthropic API key\nPress Escape to go back.\n',
|
||||
message: 'Anthropic API key\n│ Press Escape to go back.\n│',
|
||||
});
|
||||
await expect(readFile(join(tempDir, '.ktx/secrets/anthropic-api-key'), 'utf-8')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ async function chooseCredentialRef(
|
|||
const prompts = deps.prompts ?? createPromptAdapter();
|
||||
if (args.showPromptInstructions !== false) {
|
||||
io.stdout.write(
|
||||
'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n',
|
||||
'│ Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n',
|
||||
);
|
||||
}
|
||||
while (true) {
|
||||
|
|
@ -272,7 +272,7 @@ async function chooseCredentialRef(
|
|||
}
|
||||
if (choice === 'paste') {
|
||||
io.stdout.write(
|
||||
'KTX will save the key in .ktx/secrets/anthropic-api-key with local file permissions, then write a file: reference in ktx.yaml.\n',
|
||||
'│ KTX will save the key in .ktx/secrets/anthropic-api-key with local file permissions, then write a file: reference in ktx.yaml.\n',
|
||||
);
|
||||
const value = await prompts.password({ message: withTextInputNavigation('Anthropic API key') });
|
||||
if (value === undefined) {
|
||||
|
|
@ -394,7 +394,7 @@ export async function runKtxSetupAnthropicModelStep(
|
|||
deps: KtxSetupModelDeps = {},
|
||||
): Promise<KtxSetupModelResult> {
|
||||
if (args.skipLlm) {
|
||||
io.stdout.write('LLM setup skipped.\n');
|
||||
io.stdout.write('│ LLM setup skipped.\n');
|
||||
return { status: 'skipped', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
@ -406,7 +406,7 @@ export async function runKtxSetupAnthropicModelStep(
|
|||
!args.anthropicApiKeyFile &&
|
||||
!args.anthropicModel
|
||||
) {
|
||||
io.stdout.write(`LLM ready: yes (${project.config.llm.models.default})\n`);
|
||||
io.stdout.write(`│ LLM ready: yes (${project.config.llm.models.default})\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
@ -439,7 +439,7 @@ export async function runKtxSetupAnthropicModelStep(
|
|||
const health = await healthCheck(buildHealthConfig(credential.value, model.model));
|
||||
if (health.ok) {
|
||||
await persistLlmConfig(args.projectDir, credential.ref, model.model);
|
||||
io.stdout.write(`LLM ready: yes (${model.model})\n`);
|
||||
io.stdout.write(`│ LLM ready: yes (${model.model})\n`);
|
||||
return { status: 'ready', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -142,10 +142,11 @@ describe('setup project step', () => {
|
|||
expect(result.projectDir).toBe(projectDir);
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Which KTX project should setup use?',
|
||||
message: 'Where should KTX create the project?',
|
||||
options: [
|
||||
expect.objectContaining({ value: 'current', label: 'Use current directory' }),
|
||||
expect.objectContaining({ value: 'new', label: 'Create a new project folder' }),
|
||||
expect.objectContaining({ value: 'current', label: 'Current directory' }),
|
||||
expect.objectContaining({ value: 'new-default', label: 'New subfolder (./ktx-project)' }),
|
||||
expect.objectContaining({ value: 'new-custom', label: 'Custom path' }),
|
||||
expect.objectContaining({ value: 'exit', label: 'Exit' }),
|
||||
],
|
||||
}),
|
||||
|
|
@ -159,7 +160,7 @@ describe('setup project step', () => {
|
|||
it('offers an absolute default destination for a new project folder', async () => {
|
||||
const startDir = join(tempDir, 'start');
|
||||
const projectDir = join(startDir, 'ktx-project');
|
||||
const prompts = makePromptAdapter({ choices: ['new', 'default', 'create'] });
|
||||
const prompts = makePromptAdapter({ choices: ['new-default', 'create'] });
|
||||
const testIo = makeIo({ stdoutIsTty: true });
|
||||
|
||||
const result = await runKtxSetupProjectStep(
|
||||
|
|
@ -171,33 +172,28 @@ describe('setup project step', () => {
|
|||
expect(result.status).toBe('ready');
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
1,
|
||||
expect.objectContaining({
|
||||
message: 'Where should KTX create the project?',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
value: 'default',
|
||||
label: `Create the default project folder: ${projectDir}`,
|
||||
}),
|
||||
expect.objectContaining({ value: 'custom', label: 'Enter a custom path' }),
|
||||
expect.objectContaining({ value: 'back', label: 'Back' }),
|
||||
],
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'new-default', label: 'New subfolder (./ktx-project)' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
2,
|
||||
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(testIo.stdout()).toContain(`KTX will create:\n ${projectDir}`);
|
||||
expect(testIo.stdout()).toContain(`│ KTX will create:\n│ ${projectDir}`);
|
||||
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('prompts for a custom path and resolves it inside the current setup directory', async () => {
|
||||
const startDir = join(tempDir, 'start');
|
||||
const projectDir = join(startDir, 'analytics-ktx');
|
||||
const prompts = makePromptAdapter({ choices: ['new', 'custom', 'create'], textValue: 'analytics-ktx' });
|
||||
const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: 'analytics-ktx' });
|
||||
|
||||
const result = await runKtxSetupProjectStep(
|
||||
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
|
||||
|
|
@ -209,7 +205,7 @@ describe('setup project step', () => {
|
|||
expect(result.projectDir).toBe(projectDir);
|
||||
expect(prompts.text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Project folder path\nPress Escape to go back.\n',
|
||||
message: 'Project folder path\n│ Press Escape to go back.\n│',
|
||||
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
|
||||
}),
|
||||
);
|
||||
|
|
@ -220,7 +216,7 @@ describe('setup project step', () => {
|
|||
const startDir = join(tempDir, 'start');
|
||||
const homeDir = join(tempDir, 'home');
|
||||
const projectDir = join(homeDir, 'analytics-ktx');
|
||||
const prompts = makePromptAdapter({ choices: ['new', 'custom', 'create'], textValue: '~/analytics-ktx' });
|
||||
const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: '~/analytics-ktx' });
|
||||
|
||||
const result = await runKtxSetupProjectStep(
|
||||
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
|
||||
|
|
@ -238,7 +234,7 @@ describe('setup project step', () => {
|
|||
const homeDir = join(tempDir, 'home');
|
||||
const customProjectDir = join(homeDir, 'analytics-ktx');
|
||||
const prompts = makePromptAdapter({
|
||||
choices: ['new', 'custom', 'back', 'exit'],
|
||||
choices: ['new-custom', 'back', 'exit'],
|
||||
textValue: '~/analytics-ktx',
|
||||
});
|
||||
|
||||
|
|
@ -251,7 +247,7 @@ describe('setup project step', () => {
|
|||
expect(result.status).toBe('cancelled');
|
||||
expect(result.projectDir).toBe(startDir);
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
2,
|
||||
expect.objectContaining({
|
||||
message: `Create KTX project at ${customProjectDir}?`,
|
||||
options: [
|
||||
|
|
@ -262,15 +258,15 @@ describe('setup project step', () => {
|
|||
}),
|
||||
);
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({ message: 'Which KTX project should setup use?' }),
|
||||
3,
|
||||
expect.objectContaining({ message: 'Where should KTX create the project?' }),
|
||||
);
|
||||
await expect(stat(join(customProjectDir, 'ktx.yaml'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an empty new folder path without creating a project in the process cwd', async () => {
|
||||
const startDir = join(tempDir, 'start');
|
||||
const prompts = makePromptAdapter({ choices: ['new', 'custom'], textValue: ' ' });
|
||||
const prompts = makePromptAdapter({ choices: ['new-custom'], textValue: ' ' });
|
||||
const initProject = vi.fn(async () => {
|
||||
throw new Error('initProject should not run for an empty path');
|
||||
});
|
||||
|
|
@ -295,7 +291,7 @@ describe('setup project step', () => {
|
|||
const projectDir = join(startDir, 'analytics-ktx');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
await writeFile(join(projectDir, 'README.md'), 'Existing project notes\n', 'utf-8');
|
||||
const prompts = makePromptAdapter({ choices: ['new', 'custom', 'use-existing'], textValue: 'analytics-ktx' });
|
||||
const prompts = makePromptAdapter({ choices: ['new-custom', 'use-existing'], textValue: 'analytics-ktx' });
|
||||
|
||||
const result = await runKtxSetupProjectStep(
|
||||
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
|
||||
|
|
@ -306,7 +302,7 @@ describe('setup project step', () => {
|
|||
expect(result.status).toBe('ready');
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
2,
|
||||
expect.objectContaining({
|
||||
message: `That folder already exists and is not empty: ${projectDir}`,
|
||||
options: expect.arrayContaining([
|
||||
|
|
|
|||
|
|
@ -113,6 +113,55 @@ async function existingFolderState(
|
|||
}
|
||||
}
|
||||
|
||||
type ConfirmProjectDirResult =
|
||||
| { status: 'confirmed'; confirmedCreation: boolean }
|
||||
| { status: 'choose-another' }
|
||||
| { status: 'back' }
|
||||
| { status: 'cancelled' }
|
||||
| { status: 'not-directory' };
|
||||
|
||||
async function confirmProjectDir(
|
||||
selectedDir: string,
|
||||
io: KtxCliIo,
|
||||
prompts: KtxSetupProjectPromptAdapter,
|
||||
): Promise<ConfirmProjectDirResult> {
|
||||
const state = await existingFolderState(selectedDir);
|
||||
|
||||
if (state === 'not-directory') {
|
||||
io.stderr.write(`Project folder path exists and is not a directory: ${selectedDir}\n`);
|
||||
return { status: 'not-directory' };
|
||||
}
|
||||
|
||||
if (state === 'non-empty-directory') {
|
||||
const action = await prompts.select({
|
||||
message: `That folder already exists and is not empty: ${selectedDir}`,
|
||||
options: [
|
||||
{ value: 'use-existing', label: 'Yes, create KTX files there' },
|
||||
{ value: 'choose-another', label: 'Choose another folder' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (action === 'choose-another') return { status: 'choose-another' };
|
||||
if (action === 'back') return { status: 'back' };
|
||||
if (action !== 'use-existing') return { status: 'cancelled' };
|
||||
return { status: 'confirmed', confirmedCreation: true };
|
||||
}
|
||||
|
||||
io.stdout.write(`│ KTX will create:\n│ ${selectedDir}\n`);
|
||||
const action = await prompts.select({
|
||||
message: `Create KTX project at ${selectedDir}?`,
|
||||
options: [
|
||||
{ value: 'create', label: 'Create project' },
|
||||
{ value: 'choose-another', label: 'Choose another folder' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (action === 'choose-another') return { status: 'choose-another' };
|
||||
if (action === 'back') return { status: 'back' };
|
||||
if (action !== 'create') return { status: 'cancelled' };
|
||||
return { status: 'confirmed', confirmedCreation: true };
|
||||
}
|
||||
|
||||
async function normalizeSetupGitignore(projectDir: string): Promise<void> {
|
||||
const gitignorePath = join(projectDir, '.ktx/.gitignore');
|
||||
await mkdir(join(projectDir, '.ktx'), { recursive: true });
|
||||
|
|
@ -143,7 +192,7 @@ async function loadExistingProject(projectDir: string, deps: KtxSetupProjectDeps
|
|||
}
|
||||
|
||||
function printProjectSummary(io: KtxCliIo, projectDir: string): void {
|
||||
io.stdout.write(`Project: ${projectDir}\n`);
|
||||
io.stdout.write(`│ Project: ${projectDir}\n`);
|
||||
}
|
||||
|
||||
async function promptForNewProjectDir(
|
||||
|
|
@ -155,8 +204,6 @@ async function promptForNewProjectDir(
|
|||
const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME);
|
||||
|
||||
while (true) {
|
||||
io.stdout.write(`Relative paths are resolved from:\n ${projectDir}\n`);
|
||||
io.stdout.write(`Home paths are resolved from:\n ${homeDir}\n`);
|
||||
const destinationChoice = await prompts.select({
|
||||
message: 'Where should KTX create the project?',
|
||||
options: [
|
||||
|
|
@ -193,55 +240,12 @@ async function promptForNewProjectDir(
|
|||
return { status: 'cancelled', projectDir };
|
||||
}
|
||||
|
||||
const state = await existingFolderState(selectedDir);
|
||||
let confirmedCreation = false;
|
||||
if (state === 'not-directory') {
|
||||
io.stderr.write(`Project folder path exists and is not a directory: ${selectedDir}\n`);
|
||||
return { status: 'missing-input', projectDir };
|
||||
}
|
||||
if (state === 'non-empty-directory') {
|
||||
const existingAction = await prompts.select({
|
||||
message: `That folder already exists and is not empty: ${selectedDir}`,
|
||||
options: [
|
||||
{ value: 'use-existing', label: 'Yes, create KTX files there' },
|
||||
{ value: 'choose-another', label: 'Choose another folder' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (existingAction === 'choose-another') {
|
||||
continue;
|
||||
}
|
||||
if (existingAction === 'back') {
|
||||
return { status: 'back', projectDir };
|
||||
}
|
||||
if (existingAction !== 'use-existing') {
|
||||
return { status: 'cancelled', projectDir };
|
||||
}
|
||||
confirmedCreation = true;
|
||||
}
|
||||
|
||||
io.stdout.write(`KTX will create:\n ${selectedDir}\n`);
|
||||
if (state !== 'non-empty-directory') {
|
||||
const createAction = await prompts.select({
|
||||
message: `Create KTX project at ${selectedDir}?`,
|
||||
options: [
|
||||
{ value: 'create', label: 'Create project' },
|
||||
{ value: 'choose-another', label: 'Choose another folder' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (createAction === 'choose-another') {
|
||||
continue;
|
||||
}
|
||||
if (createAction === 'back') {
|
||||
return { status: 'back', projectDir };
|
||||
}
|
||||
if (createAction !== 'create') {
|
||||
return { status: 'cancelled', projectDir };
|
||||
}
|
||||
confirmedCreation = true;
|
||||
}
|
||||
return { status: 'selected', projectDir: selectedDir, confirmedCreation };
|
||||
const confirmed = await confirmProjectDir(selectedDir, io, prompts);
|
||||
if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir };
|
||||
if (confirmed.status === 'choose-another') continue;
|
||||
if (confirmed.status === 'back') return { status: 'back', projectDir };
|
||||
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
|
||||
return { status: 'selected', projectDir: selectedDir, confirmedCreation: confirmed.confirmedCreation };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -323,15 +327,17 @@ export async function runKtxSetupProjectStep(
|
|||
}
|
||||
|
||||
const prompts = deps.prompts ?? createClackSetupProjectPromptAdapter();
|
||||
const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME);
|
||||
io.stdout.write(
|
||||
'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n',
|
||||
'│ Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n',
|
||||
);
|
||||
while (true) {
|
||||
const choice = await prompts.select({
|
||||
message: 'Which KTX project should setup use?',
|
||||
message: 'Where should KTX create the project?',
|
||||
options: [
|
||||
{ value: 'current', label: 'Use current directory' },
|
||||
{ value: 'new', label: 'Create a new project folder' },
|
||||
{ value: 'current', label: 'Current directory' },
|
||||
{ value: 'new-default', label: 'New subfolder (./ktx-project)' },
|
||||
{ value: 'new-custom', label: 'Custom path' },
|
||||
...(args.allowBack ? [{ value: 'back', label: 'Back' }] : []),
|
||||
...(args.allowBack ? [] : [{ value: 'exit', label: 'Exit' }]),
|
||||
],
|
||||
|
|
@ -346,27 +352,51 @@ export async function runKtxSetupProjectStep(
|
|||
return { status: 'cancelled', projectDir };
|
||||
}
|
||||
|
||||
let selectedDir = projectDir;
|
||||
let confirmedCreation = false;
|
||||
if (choice === 'new') {
|
||||
const selected = await promptForNewProjectDir(projectDir, homeDir, io, prompts);
|
||||
if (selected.status === 'back') {
|
||||
continue;
|
||||
}
|
||||
if (selected.status !== 'selected') {
|
||||
return selected;
|
||||
}
|
||||
selectedDir = selected.projectDir;
|
||||
confirmedCreation = selected.confirmedCreation;
|
||||
if (choice === 'current') {
|
||||
const project = await createProject(projectDir, deps);
|
||||
printProjectSummary(io, projectDir);
|
||||
return { status: 'ready', projectDir, project };
|
||||
}
|
||||
|
||||
if (choice !== 'current' && choice !== 'new') {
|
||||
prompts.cancel('Setup cancelled.');
|
||||
return { status: 'cancelled', projectDir };
|
||||
if (choice === 'new-default') {
|
||||
const confirmed = await confirmProjectDir(defaultProjectDir, io, prompts);
|
||||
if (confirmed.status === 'choose-another' || confirmed.status === 'back') continue;
|
||||
if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir };
|
||||
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
|
||||
const project = await createProject(defaultProjectDir, deps);
|
||||
printProjectSummary(io, defaultProjectDir);
|
||||
return {
|
||||
status: 'ready',
|
||||
projectDir: defaultProjectDir,
|
||||
project,
|
||||
confirmedCreation: confirmed.confirmedCreation,
|
||||
};
|
||||
}
|
||||
|
||||
const project = await createProject(selectedDir, deps);
|
||||
printProjectSummary(io, selectedDir);
|
||||
return { status: 'ready', projectDir: selectedDir, project, confirmedCreation };
|
||||
if (choice === 'new-custom') {
|
||||
const rawPath = await prompts.text({
|
||||
message: withTextInputNavigation('Project folder path'),
|
||||
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
|
||||
});
|
||||
if (rawPath === undefined) continue;
|
||||
const trimmed = rawPath.trim();
|
||||
if (trimmed.length === 0) {
|
||||
io.stderr.write(
|
||||
'Enter a relative path like ./analytics-ktx, a home path like ~/analytics-ktx, or an absolute path.\n',
|
||||
);
|
||||
return { status: 'missing-input', projectDir };
|
||||
}
|
||||
const customDir = resolveFromProjectDir(projectDir, trimmed, homeDir);
|
||||
const confirmed = await confirmProjectDir(customDir, io, prompts);
|
||||
if (confirmed.status === 'choose-another' || confirmed.status === 'back') continue;
|
||||
if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir };
|
||||
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
|
||||
const project = await createProject(customDir, deps);
|
||||
printProjectSummary(io, customDir);
|
||||
return { status: 'ready', projectDir: customDir, project, confirmedCreation: confirmed.confirmedCreation };
|
||||
}
|
||||
|
||||
prompts.cancel('Setup cancelled.');
|
||||
return { status: 'cancelled', projectDir };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,10 +66,10 @@ function connectionNamePrompt(label: string): string {
|
|||
function textInputPrompt(message: string): string {
|
||||
const normalized = message.replace(/\n+$/, '');
|
||||
if (!normalized.includes('\n')) {
|
||||
return `${normalized}\nPress Escape to go back.\n`;
|
||||
return `${normalized}\n│ Press Escape to go back.\n│`;
|
||||
}
|
||||
const [title, ...bodyLines] = normalized.split('\n');
|
||||
return `${title}\n\n${bodyLines.join('\n')}\nPress Escape to go back.\n`;
|
||||
return `${title}\n│\n│ ${bodyLines.join('\n│ ')}\n│ Press Escape to go back.\n│`;
|
||||
}
|
||||
|
||||
describe('setup sources step', () => {
|
||||
|
|
@ -664,7 +664,7 @@ describe('setup sources step', () => {
|
|||
expect(runInitialIngest).toHaveBeenCalledTimes(1);
|
||||
expect((await readConfig()).connections['dbt-main']).toMatchObject({ driver: 'dbt', source_dir: '/repo/dbt' });
|
||||
expect(io.stdout()).toContain('Context source saved without a completed context build for dbt-main.');
|
||||
expect(io.stdout()).toContain('Run later: ktx ingest dbt-main');
|
||||
expect(io.stdout()).toContain('Run later: ktx ingest run --connection-id dbt-main --adapter <adapter>');
|
||||
});
|
||||
|
||||
it('retries initial source ingest from the failure menu', async () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { cancel, isCancel, log, multiselect, password, select, text } from '@clack/prompts';
|
||||
import { cancel, confirm, isCancel, log, multiselect, password, select, text } from '@clack/prompts';
|
||||
import { localConnectionTypeForConfig, resolveNotionAuthToken } from '@ktx/context/connections';
|
||||
import { resolveKtxConfigReference } from '@ktx/context/core';
|
||||
import {
|
||||
|
|
@ -136,12 +136,23 @@ const PRIMARY_SOURCE_DRIVERS = new Set([
|
|||
function createPromptAdapter(): KtxSetupSourcesPromptAdapter {
|
||||
return {
|
||||
async multiselect(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
const selected = [...value] as string[];
|
||||
if (selected.length === 0 && !options.required) {
|
||||
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
if (!skipConfirmed) continue;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
return [...value] as string[];
|
||||
},
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
|
|
@ -699,7 +710,7 @@ async function runInitialSourceIngestWithRecovery(input: {
|
|||
deps: KtxSetupSourcesDeps;
|
||||
}): Promise<'ready' | 'continue' | 'back' | 'failed'> {
|
||||
while (true) {
|
||||
input.io.stdout.write(`Building context from ${input.connectionId}. Large sources can take a while.\n`);
|
||||
input.io.stdout.write(`│ Building context from ${input.connectionId}. Large sources can take a while.\n`);
|
||||
const ingestCode = await (input.deps.runInitialIngest ?? defaultRunInitialIngest)(
|
||||
input.args.projectDir,
|
||||
input.connectionId,
|
||||
|
|
@ -727,8 +738,8 @@ async function runInitialSourceIngestWithRecovery(input: {
|
|||
continue;
|
||||
}
|
||||
if (action === 'continue') {
|
||||
input.io.stdout.write(`Context source saved without a completed context build for ${input.connectionId}.\n`);
|
||||
input.io.stdout.write(`Run later: ktx ingest run --connection-id ${input.connectionId} --adapter <adapter>\n`);
|
||||
input.io.stdout.write(`│ Context source saved without a completed context build for ${input.connectionId}.\n`);
|
||||
input.io.stdout.write(`│ Run later: ktx ingest run --connection-id ${input.connectionId} --adapter <adapter>\n`);
|
||||
return 'continue';
|
||||
}
|
||||
return 'back';
|
||||
|
|
@ -1355,7 +1366,7 @@ export async function runKtxSetupSourcesStep(
|
|||
try {
|
||||
if (args.skipSources) {
|
||||
await markSourcesComplete(args.projectDir);
|
||||
io.stdout.write('Context source setup skipped.\n');
|
||||
io.stdout.write('│ Context source setup skipped.\n');
|
||||
return { status: 'skipped', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
@ -1368,7 +1379,7 @@ export async function runKtxSetupSourcesStep(
|
|||
return { status: 'failed', projectDir: args.projectDir };
|
||||
}
|
||||
if (args.inputMode !== 'disabled') {
|
||||
io.stdout.write(`${message}\n`);
|
||||
io.stdout.write(`│ ${message}\n`);
|
||||
return { status: 'skipped', projectDir: args.projectDir };
|
||||
}
|
||||
}
|
||||
|
|
@ -1392,7 +1403,7 @@ export async function runKtxSetupSourcesStep(
|
|||
return { status: 'missing-input', projectDir: args.projectDir };
|
||||
}
|
||||
await markSourcesComplete(args.projectDir);
|
||||
io.stdout.write('No context sources selected.\n');
|
||||
io.stdout.write('│ No context sources selected.\n');
|
||||
return { status: 'skipped', projectDir: args.projectDir };
|
||||
}
|
||||
|
||||
|
|
@ -1465,7 +1476,7 @@ export async function runKtxSetupSourcesStep(
|
|||
break;
|
||||
}
|
||||
} else {
|
||||
io.stdout.write(`Context source ${connectionId} saved. It will be built during the context build step.\n`);
|
||||
io.stdout.write(`│ Context source ${connectionId} saved. It will be built during the context build step.\n`);
|
||||
}
|
||||
readyConnectionIds.push(connectionId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -715,7 +715,7 @@ describe('setup status', () => {
|
|||
|
||||
expect(projectPrompts.text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Project folder path\nPress Escape to go back.\n',
|
||||
message: 'Project folder path\n│ Press Escape to go back.\n│',
|
||||
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
type KtxSchemaColumn,
|
||||
type KtxSchemaSnapshot,
|
||||
type KtxSchemaTable,
|
||||
type KtxTableListEntry,
|
||||
type KtxTableRef,
|
||||
type KtxTableSampleInput,
|
||||
type KtxTableSampleResult,
|
||||
|
|
@ -63,6 +64,7 @@ export interface KtxBigQueryQueryJob {
|
|||
|
||||
export interface KtxBigQueryTableRef {
|
||||
id?: string;
|
||||
metadata?: { type?: string };
|
||||
get(): Promise<
|
||||
[
|
||||
{
|
||||
|
|
@ -369,6 +371,25 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
|
|||
return datasets.map((dataset) => dataset.id).filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
async listTables(datasetIds?: string[]): Promise<KtxTableListEntry[]> {
|
||||
const filterDatasets = datasetIds ?? (await this.listDatasets());
|
||||
const entries: KtxTableListEntry[] = [];
|
||||
for (const datasetId of filterDatasets) {
|
||||
const dataset = this.getClient().dataset(datasetId);
|
||||
const [tables] = await dataset.getTables();
|
||||
for (const table of tables) {
|
||||
if (!table.id) continue;
|
||||
entries.push({
|
||||
schema: datasetId,
|
||||
name: table.id,
|
||||
kind: table.metadata?.type === 'VIEW' ? 'view' : 'table',
|
||||
});
|
||||
}
|
||||
}
|
||||
entries.sort((a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name));
|
||||
return entries;
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.client = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type KtxSchemaTable,
|
||||
type KtxTableRef,
|
||||
type KtxTableSampleInput,
|
||||
type KtxTableListEntry,
|
||||
type KtxTableSampleResult,
|
||||
} from '@ktx/context/scan';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
|
@ -128,6 +129,12 @@ interface ClickHouseDatabaseRow {
|
|||
name: string;
|
||||
}
|
||||
|
||||
interface ClickHouseTableListRow {
|
||||
database: string;
|
||||
name: string;
|
||||
engine: string;
|
||||
}
|
||||
|
||||
interface ClickHouseCompactResponse {
|
||||
meta?: Array<{ name: string; type: string }>;
|
||||
data?: unknown[][];
|
||||
|
|
@ -417,6 +424,25 @@ export class KtxClickHouseScanConnector implements KtxScanConnector {
|
|||
return rows.map((row) => row.name);
|
||||
}
|
||||
|
||||
async listTables(schemas?: string[]): Promise<KtxTableListEntry[]> {
|
||||
const filterSchemas = schemas ?? (await this.listSchemas());
|
||||
if (filterSchemas.length === 0) return [];
|
||||
const rows = await this.queryEachRow<ClickHouseTableListRow>(
|
||||
`
|
||||
SELECT database, name, engine
|
||||
FROM system.tables
|
||||
WHERE database IN ({schemas:Array(String)})
|
||||
ORDER BY database, name
|
||||
`,
|
||||
{ schemas: filterSchemas },
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
schema: row.database,
|
||||
name: row.name,
|
||||
kind: row.engine === 'View' || row.engine === 'MaterializedView' ? ('view' as const) : ('table' as const),
|
||||
}));
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.client) {
|
||||
await this.client.close();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type KtxScanContext,
|
||||
type KtxScanInput,
|
||||
type KtxSchemaColumn,
|
||||
type KtxTableListEntry,
|
||||
type KtxSchemaForeignKey,
|
||||
type KtxSchemaSnapshot,
|
||||
type KtxSchemaTable,
|
||||
|
|
@ -129,6 +130,12 @@ interface MysqlSchemaRow extends RowDataPacket {
|
|||
SCHEMA_NAME: string;
|
||||
}
|
||||
|
||||
interface MysqlTableListRow extends RowDataPacket {
|
||||
TABLE_SCHEMA: string;
|
||||
TABLE_NAME: string;
|
||||
TABLE_TYPE: string;
|
||||
}
|
||||
|
||||
interface MysqlCountRow extends RowDataPacket {
|
||||
count?: unknown;
|
||||
cardinality?: unknown;
|
||||
|
|
@ -466,6 +473,27 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
|
|||
return rows.map((row) => row.SCHEMA_NAME);
|
||||
}
|
||||
|
||||
async listTables(schemas?: string[]): Promise<KtxTableListEntry[]> {
|
||||
const filterSchemas = schemas ?? (await this.listSchemas());
|
||||
if (filterSchemas.length === 0) return [];
|
||||
const placeholders = filterSchemas.map(() => '?').join(', ');
|
||||
const rows = await this.queryRaw<MysqlTableListRow>(
|
||||
`
|
||||
SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA IN (${placeholders})
|
||||
AND TABLE_TYPE IN ('BASE TABLE', 'VIEW')
|
||||
ORDER BY TABLE_SCHEMA, TABLE_NAME
|
||||
`,
|
||||
filterSchemas,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
schema: row.TABLE_SCHEMA,
|
||||
name: row.TABLE_NAME,
|
||||
kind: row.TABLE_TYPE === 'VIEW' ? ('view' as const) : ('table' as const),
|
||||
}));
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.pool) {
|
||||
await this.pool.end();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
type KtxSchemaForeignKey,
|
||||
type KtxSchemaSnapshot,
|
||||
type KtxSchemaTable,
|
||||
type KtxTableListEntry,
|
||||
type KtxTableRef,
|
||||
type KtxTableSampleInput,
|
||||
type KtxTableSampleResult,
|
||||
|
|
@ -179,6 +180,12 @@ interface PostgresSchemaRow {
|
|||
schema_name: string;
|
||||
}
|
||||
|
||||
interface PostgresTableListRow {
|
||||
schema_name: string;
|
||||
table_name: string;
|
||||
table_kind: string;
|
||||
}
|
||||
|
||||
interface PostgresCountRow {
|
||||
count?: unknown;
|
||||
cardinality?: unknown;
|
||||
|
|
@ -523,6 +530,27 @@ export class KtxPostgresScanConnector implements KtxScanConnector {
|
|||
return rows.map((row) => row.schema_name);
|
||||
}
|
||||
|
||||
async listTables(schemas?: string[]): Promise<KtxTableListEntry[]> {
|
||||
const filterSchemas = schemas ?? (await this.listSchemas());
|
||||
if (filterSchemas.length === 0) return [];
|
||||
const rows = await this.queryRaw<PostgresTableListRow>(
|
||||
`
|
||||
SELECT n.nspname AS schema_name, c.relname AS table_name, c.relkind AS table_kind
|
||||
FROM pg_catalog.pg_class c
|
||||
JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid
|
||||
WHERE n.nspname = ANY($1)
|
||||
AND c.relkind IN ('r', 'v')
|
||||
ORDER BY n.nspname, c.relname
|
||||
`,
|
||||
[filterSchemas],
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
schema: row.schema_name,
|
||||
name: row.table_name,
|
||||
kind: row.table_kind === 'v' ? ('view' as const) : ('table' as const),
|
||||
}));
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.pool) {
|
||||
await this.pool.end();
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ function fakeDriverFactory(): KtxSnowflakeDriverFactory {
|
|||
},
|
||||
]),
|
||||
listSchemas: vi.fn(async () => ['PUBLIC', 'MART']),
|
||||
listTables: vi.fn(async () => [
|
||||
{ schema: 'PUBLIC', name: 'ORDERS', kind: 'table' as const },
|
||||
{ schema: 'PUBLIC', name: 'ORDER_SUMMARY', kind: 'view' as const },
|
||||
]),
|
||||
cleanup: vi.fn(async () => undefined),
|
||||
};
|
||||
return { createDriver: vi.fn(() => driver) };
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type KtxSchemaTable,
|
||||
type KtxTableRef,
|
||||
type KtxTableSampleInput,
|
||||
type KtxTableListEntry,
|
||||
type KtxTableSampleResult,
|
||||
} from '@ktx/context/scan';
|
||||
import * as snowflake from 'snowflake-sdk';
|
||||
|
|
@ -75,6 +76,7 @@ export interface KtxSnowflakeDriver {
|
|||
query(sql: string, params?: unknown): Promise<KtxQueryResult>;
|
||||
getSchemaMetadata(schemaName?: string): Promise<KtxSnowflakeRawTableMetadata[]>;
|
||||
listSchemas(): Promise<string[]>;
|
||||
listTables(schemas?: string[]): Promise<KtxTableListEntry[]>;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -344,6 +346,31 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver {
|
|||
return result.rows.map((row) => String(row[1])).filter((name) => name !== 'INFORMATION_SCHEMA');
|
||||
}
|
||||
|
||||
async listTables(schemas?: string[]): Promise<KtxTableListEntry[]> {
|
||||
const filterSchemas = schemas ?? (await this.listSchemas());
|
||||
if (filterSchemas.length === 0) return [];
|
||||
const entries: KtxTableListEntry[] = [];
|
||||
for (const schemaName of filterSchemas) {
|
||||
const result = await this.query(
|
||||
`
|
||||
SELECT TABLE_NAME, TABLE_TYPE
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_CATALOG = ?
|
||||
ORDER BY TABLE_NAME
|
||||
`,
|
||||
[schemaName, this.resolved.database],
|
||||
);
|
||||
for (const row of result.rows) {
|
||||
entries.push({
|
||||
schema: schemaName,
|
||||
name: String(row[0]),
|
||||
kind: String(row[1]) === 'VIEW' ? 'view' : 'table',
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
const closers = this.closeSdkOptions;
|
||||
this.closeSdkOptions = [];
|
||||
|
|
@ -594,6 +621,10 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector {
|
|||
return this.getDriver().listSchemas();
|
||||
}
|
||||
|
||||
listTables(schemas?: string[]): Promise<KtxTableListEntry[]> {
|
||||
return this.getDriver().listTables(schemas);
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.driverInstance) {
|
||||
await this.driverInstance.cleanup();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
type KtxSchemaForeignKey,
|
||||
type KtxSchemaSnapshot,
|
||||
type KtxSchemaTable,
|
||||
type KtxTableListEntry,
|
||||
type KtxTableRef,
|
||||
type KtxTableSampleInput,
|
||||
type KtxTableSampleResult,
|
||||
|
|
@ -441,6 +442,32 @@ export class KtxSqlServerScanConnector implements KtxScanConnector {
|
|||
return rows.map((row) => row.schema_name);
|
||||
}
|
||||
|
||||
async listTables(schemas?: string[]): Promise<KtxTableListEntry[]> {
|
||||
const filterSchemas = schemas ?? (await this.listSchemas());
|
||||
if (filterSchemas.length === 0) return [];
|
||||
const params: Record<string, unknown> = {};
|
||||
const placeholders = filterSchemas.map((s, i) => {
|
||||
params[`schema${i}`] = s;
|
||||
return `@schema${i}`;
|
||||
});
|
||||
const rows = await this.queryRaw<{ schema_name: string; table_name: string; table_type: string }>(
|
||||
`
|
||||
SELECT s.name AS schema_name, o.name AS table_name, o.type_desc AS table_type
|
||||
FROM sys.objects o
|
||||
JOIN sys.schemas s ON o.schema_id = s.schema_id
|
||||
WHERE o.type IN ('U', 'V')
|
||||
AND s.name IN (${placeholders.join(', ')})
|
||||
ORDER BY s.name, o.name
|
||||
`,
|
||||
params,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
schema: row.schema_name,
|
||||
name: row.table_name,
|
||||
kind: row.table_type === 'VIEW' ? ('view' as const) : ('table' as const),
|
||||
}));
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.pool) {
|
||||
await this.pool.close();
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export type {
|
|||
LocalScanStatusResponse,
|
||||
RunLocalScanOptions,
|
||||
} from './local-scan.js';
|
||||
export { getLocalScanReport, getLocalScanStatus, runLocalScan } from './local-scan.js';
|
||||
export { filterSnapshotTables, getLocalScanReport, getLocalScanStatus, resolveEnabledTables, runLocalScan } from './local-scan.js';
|
||||
export type { ReadLocalScanStructuralSnapshotInput } from './local-structural-artifacts.js';
|
||||
export { readLocalScanStructuralSnapshot } from './local-structural-artifacts.js';
|
||||
export type {
|
||||
|
|
@ -393,6 +393,7 @@ export type {
|
|||
KtxSchemaTable,
|
||||
KtxSchemaTableKind,
|
||||
KtxStructuralSyncStats,
|
||||
KtxTableListEntry,
|
||||
KtxTableRef,
|
||||
KtxTableSampleInput,
|
||||
KtxTableSampleResult,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import YAML from 'yaml';
|
||||
import type { SourceAdapter } from '../ingest/index.js';
|
||||
import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../project/index.js';
|
||||
import { getLocalScanReport, getLocalScanStatus, runLocalScan } from './local-scan.js';
|
||||
import type { KtxQueryResult, KtxReadOnlyQueryInput } from './types.js';
|
||||
import { filterSnapshotTables, getLocalScanReport, getLocalScanStatus, resolveEnabledTables, runLocalScan } from './local-scan.js';
|
||||
import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxSchemaSnapshot, KtxSchemaTable } from './types.js';
|
||||
|
||||
function relationshipSqlResult(
|
||||
input: KtxReadOnlyQueryInput,
|
||||
|
|
@ -1492,3 +1492,79 @@ describe('local scan', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEnabledTables', () => {
|
||||
it('returns null when no enabled_tables field', () => {
|
||||
expect(resolveEnabledTables({ driver: 'postgres' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty array', () => {
|
||||
expect(resolveEnabledTables({ driver: 'postgres', enabled_tables: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns Set of enabled table names', () => {
|
||||
const result = resolveEnabledTables({
|
||||
driver: 'postgres',
|
||||
enabled_tables: ['public.users', 'public.orders'],
|
||||
});
|
||||
expect(result).toBeInstanceOf(Set);
|
||||
expect(result!.size).toBe(2);
|
||||
expect(result!.has('public.users')).toBe(true);
|
||||
expect(result!.has('public.orders')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null for undefined connection', () => {
|
||||
expect(resolveEnabledTables(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSnapshotTables', () => {
|
||||
function makeSnapshot(tables: Array<{ db: string; name: string }>): KtxSchemaSnapshot {
|
||||
return {
|
||||
connectionId: 'test',
|
||||
driver: 'postgres',
|
||||
extractedAt: '2026-01-01T00:00:00Z',
|
||||
scope: {},
|
||||
metadata: {},
|
||||
tables: tables.map(
|
||||
(t): KtxSchemaTable => ({
|
||||
catalog: null,
|
||||
db: t.db,
|
||||
name: t.name,
|
||||
kind: 'table',
|
||||
comment: null,
|
||||
estimatedRows: null,
|
||||
columns: [],
|
||||
foreignKeys: [],
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
it('keeps only enabled tables', () => {
|
||||
const snapshot = makeSnapshot([
|
||||
{ db: 'public', name: 'users' },
|
||||
{ db: 'public', name: 'orders' },
|
||||
{ db: 'public', name: 'logs' },
|
||||
]);
|
||||
const enabled = new Set(['public.users', 'public.orders']);
|
||||
const filtered = filterSnapshotTables(snapshot, enabled);
|
||||
expect(filtered.tables).toHaveLength(2);
|
||||
expect(filtered.tables.map((t) => t.name)).toEqual(['users', 'orders']);
|
||||
});
|
||||
|
||||
it('returns empty tables when none match', () => {
|
||||
const snapshot = makeSnapshot([{ db: 'public', name: 'users' }]);
|
||||
const enabled = new Set(['public.orders']);
|
||||
const filtered = filterSnapshotTables(snapshot, enabled);
|
||||
expect(filtered.tables).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves other snapshot fields', () => {
|
||||
const snapshot = makeSnapshot([{ db: 'public', name: 'users' }]);
|
||||
const enabled = new Set(['public.users']);
|
||||
const filtered = filterSnapshotTables(snapshot, enabled);
|
||||
expect(filtered.connectionId).toBe('test');
|
||||
expect(filtered.driver).toBe('postgres');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,10 +29,13 @@ import type {
|
|||
KtxConnectionDriver,
|
||||
KtxProgressPort,
|
||||
KtxScanConnector,
|
||||
KtxScanContext,
|
||||
KtxScanEnrichmentStateSummary,
|
||||
KtxScanInput,
|
||||
KtxScanMode,
|
||||
KtxScanReport,
|
||||
KtxScanTrigger,
|
||||
KtxSchemaSnapshot,
|
||||
} from './types.js';
|
||||
|
||||
export interface RunLocalScanOptions {
|
||||
|
|
@ -313,17 +316,45 @@ async function readScanReport(
|
|||
}
|
||||
}
|
||||
|
||||
export function resolveEnabledTables(connection: Record<string, unknown> | undefined): Set<string> | null {
|
||||
const raw = connection?.enabled_tables;
|
||||
if (!Array.isArray(raw) || raw.length === 0) return null;
|
||||
return new Set(raw.filter((v): v is string => typeof v === 'string'));
|
||||
}
|
||||
|
||||
export function filterSnapshotTables(snapshot: KtxSchemaSnapshot, enabledTables: Set<string>): KtxSchemaSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
tables: snapshot.tables.filter((table) => {
|
||||
const key = table.db ? `${table.db}.${table.name}` : table.name;
|
||||
return enabledTables.has(key);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createFilteredConnector(connector: KtxScanConnector, enabledTables: Set<string>): KtxScanConnector {
|
||||
return {
|
||||
...connector,
|
||||
async introspect(input: KtxScanInput, ctx: KtxScanContext): Promise<KtxSchemaSnapshot> {
|
||||
const snapshot = await connector.introspect(input, ctx);
|
||||
return filterSnapshotTables(snapshot, enabledTables);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runLocalScan(options: RunLocalScanOptions): Promise<LocalScanRunResult> {
|
||||
const mode = options.mode ?? 'structural';
|
||||
assertSupportedMode(mode);
|
||||
await options.progress?.update(0.05, 'Preparing scan');
|
||||
const connector = await resolveScanConnector(options, mode);
|
||||
const rawConnector = await resolveScanConnector(options, mode);
|
||||
|
||||
const connection = options.project.config.connections[options.connectionId];
|
||||
if (!connection) {
|
||||
throw new Error(`Connection "${options.connectionId}" is not configured in ktx.yaml`);
|
||||
}
|
||||
const driver = normalizeDriver(connection.driver);
|
||||
const enabledTables = resolveEnabledTables(connection);
|
||||
const connector = rawConnector && enabledTables ? createFilteredConnector(rawConnector, enabledTables) : rawConnector;
|
||||
const adapters =
|
||||
options.adapters ??
|
||||
createDefaultLocalIngestAdapters(options.project, { databaseIntrospectionUrl: options.databaseIntrospectionUrl });
|
||||
|
|
@ -372,13 +403,28 @@ export async function runLocalScan(options: RunLocalScanOptions): Promise<LocalS
|
|||
let enrichmentState: KtxScanEnrichmentStateSummary = completedKtxScanEnrichmentStateSummary();
|
||||
if (!reusedExistingScanArtifacts && !report.dryRun && report.artifactPaths.rawSourcesDir) {
|
||||
await options.progress?.update(0.7, 'Writing schema artifacts');
|
||||
const structuralSnapshot = await readLocalScanStructuralSnapshot({
|
||||
const rawSnapshot = await readLocalScanStructuralSnapshot({
|
||||
project: options.project,
|
||||
connectionId: options.connectionId,
|
||||
driver,
|
||||
rawSourcesDir: report.artifactPaths.rawSourcesDir,
|
||||
extractedAtFallback: report.createdAt,
|
||||
});
|
||||
const structuralSnapshot = enabledTables ? filterSnapshotTables(rawSnapshot, enabledTables) : rawSnapshot;
|
||||
if (enabledTables && structuralSnapshot.tables.length < rawSnapshot.tables.length) {
|
||||
const excluded = rawSnapshot.tables.length - structuralSnapshot.tables.length;
|
||||
let remaining = excluded;
|
||||
const ds = report.diffSummary;
|
||||
const subFrom = (field: 'tablesAdded' | 'tablesUnchanged' | 'tablesModified') => {
|
||||
const take = Math.min(remaining, ds[field]);
|
||||
ds[field] -= take;
|
||||
remaining -= take;
|
||||
};
|
||||
subFrom('tablesAdded');
|
||||
subFrom('tablesUnchanged');
|
||||
subFrom('tablesModified');
|
||||
await options.progress?.update(0.6, scanChangeSummary(report.diffSummary));
|
||||
}
|
||||
const manifestArtifacts = await writeLocalScanManifestShards({
|
||||
project: options.project,
|
||||
connectionId: options.connectionId,
|
||||
|
|
|
|||
|
|
@ -277,6 +277,12 @@ export interface KtxQueryResult {
|
|||
rowCount: number | null;
|
||||
}
|
||||
|
||||
export interface KtxTableListEntry {
|
||||
schema: string;
|
||||
name: string;
|
||||
kind: 'table' | 'view';
|
||||
}
|
||||
|
||||
export interface KtxScanConnector {
|
||||
id: string;
|
||||
driver: KtxConnectionDriver;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue