mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
merge: resolve conflict with main's box-drawing formatting
Keep the confirmProjectDir helper extraction from this branch while adopting the │ box-drawing prefixes added on main. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
ea10dd9f39
39 changed files with 1583 additions and 1313 deletions
|
|
@ -10,18 +10,18 @@
|
|||
"test": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"fumadocs-core": "15.7.13",
|
||||
"fumadocs-mdx": "11.10.1",
|
||||
"fumadocs-ui": "15.7.13",
|
||||
"next": "^15",
|
||||
"fumadocs-core": "16.8.10",
|
||||
"fumadocs-mdx": "15.0.4",
|
||||
"fumadocs-ui": "16.8.10",
|
||||
"next": "^16",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"typescript": "^5.9",
|
||||
"typescript": "^6.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"description": "Workspace root for ktx packages",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"packageManager": "pnpm@11.1.1",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"pnpm": ">=10.20.0"
|
||||
|
|
@ -36,9 +36,9 @@
|
|||
"type-check": "pnpm --filter './packages/*' run type-check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"@types/node": "^25.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "1.3.0",
|
||||
"@clack/prompts": "1.4.0",
|
||||
"@commander-js/extra-typings": "14.0.0",
|
||||
"@ktx/connector-bigquery": "workspace:*",
|
||||
"@ktx/connector-clickhouse": "workspace:*",
|
||||
|
|
@ -46,18 +46,18 @@
|
|||
"@ktx/context": "workspace:*",
|
||||
"@ktx/llm": "workspace:*",
|
||||
"commander": "14.0.3",
|
||||
"ink": "^7.0.1",
|
||||
"react": "^19.2.5",
|
||||
"ink": "^7.0.2",
|
||||
"react": "^19.2.6",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -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│`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ 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 { loadKtxProject, markKtxSetupStepComplete, serializeKtxProjectConfig } from '@ktx/context/project';
|
||||
import {
|
||||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { withMenuOptionsSpacing, withMultiselectNavigation } from './prompt-navigation.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
|
|
@ -360,7 +365,8 @@ async function installTarget(input: {
|
|||
|
||||
async function markAgentsComplete(projectDir: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(markKtxSetupStepComplete(project.config, 'agents')), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(project.config)), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'agents');
|
||||
}
|
||||
|
||||
export async function runKtxSetupAgentsStep(
|
||||
|
|
@ -369,7 +375,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') {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import { cancel, isCancel, select } from '@clack/prompts';
|
|||
import {
|
||||
type KtxLocalProject,
|
||||
loadKtxProject,
|
||||
markKtxSetupStepComplete,
|
||||
ktxSetupCompletedSteps,
|
||||
markKtxSetupStateStepComplete,
|
||||
readKtxSetupState,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { buildPublicIngestPlan } from './public-ingest.js';
|
||||
|
|
@ -467,11 +470,8 @@ async function defaultVerifyContextReady(projectDir: string): Promise<KtxSetupCo
|
|||
|
||||
async function markContextComplete(projectDir: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
project.configPath,
|
||||
serializeKtxProjectConfig(markKtxSetupStepComplete(project.config, 'context')),
|
||||
'utf-8',
|
||||
);
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(project.config)), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'context');
|
||||
}
|
||||
|
||||
function writeBuildHeader(projectDir: string, runId: string, io: KtxCliIo): void {
|
||||
|
|
@ -714,7 +714,8 @@ export async function runKtxSetupContextStep(
|
|||
try {
|
||||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||
const existingState = await readKtxSetupContextState(args.projectDir);
|
||||
if (project.config.setup?.completed_steps.includes('context') === true && existingState.status === 'completed') {
|
||||
const completedSteps = ktxSetupCompletedSteps(project.config, await readKtxSetupState(args.projectDir));
|
||||
if (completedSteps.includes('context') && existingState.status === 'completed') {
|
||||
return { status: 'ready', projectDir: args.projectDir, runId: existingState.runId ?? 'setup-context-completed' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { initKtxProject, parseKtxProjectConfig } from '@ktx/context/project';
|
||||
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type KtxSetupDatabaseDriver,
|
||||
|
|
@ -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('');
|
||||
|
|
@ -1091,8 +1091,9 @@ describe('setup databases step', () => {
|
|||
});
|
||||
expect(config.setup).toEqual({
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: ['databases'],
|
||||
completed_steps: [],
|
||||
});
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('databases');
|
||||
expect(io.stdout()).toContain('Primary source ready');
|
||||
expect(io.stdout()).not.toContain('DATABASE_URL=');
|
||||
});
|
||||
|
|
@ -1129,8 +1130,9 @@ describe('setup databases step', () => {
|
|||
});
|
||||
expect(config.setup).toEqual({
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: ['databases'],
|
||||
completed_steps: [],
|
||||
});
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('databases');
|
||||
});
|
||||
|
||||
it('selects multiple existing connections and validates each before recording setup ids', async () => {
|
||||
|
|
@ -1178,7 +1180,8 @@ describe('setup databases step', () => {
|
|||
expect(scanConnection).toHaveBeenCalledTimes(2);
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.database_connection_ids).toEqual(['warehouse', 'analytics']);
|
||||
expect(config.setup?.completed_steps).toContain('databases');
|
||||
expect(config.setup?.completed_steps).toEqual([]);
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('databases');
|
||||
});
|
||||
|
||||
it('keeps the connection config but does not mark databases complete when scanning fails', async () => {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import type { HistoricSqlDialect } from '@ktx/context/ingest';
|
|||
import {
|
||||
type KtxProjectConnectionConfig,
|
||||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
serializeKtxProjectConfig,
|
||||
setKtxSetupDatabaseConnectionIds,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { runKtxConnection } from './connection.js';
|
||||
|
|
@ -923,7 +925,7 @@ async function writeConnectionConfig(input: {
|
|||
[input.connectionId]: input.connection,
|
||||
},
|
||||
};
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
|
||||
|
||||
const historicSql =
|
||||
typeof input.connection.historicSql === 'object' &&
|
||||
|
|
@ -1076,25 +1078,28 @@ async function ensureHistoricSqlIngestDefaults(projectDir: string): Promise<void
|
|||
}
|
||||
await writeFile(
|
||||
project.configPath,
|
||||
serializeKtxProjectConfig({
|
||||
...project.config,
|
||||
ingest: {
|
||||
...project.config.ingest,
|
||||
adapters,
|
||||
workUnits: {
|
||||
...project.config.ingest.workUnits,
|
||||
maxConcurrency,
|
||||
serializeKtxProjectConfig(
|
||||
stripKtxSetupCompletedSteps({
|
||||
...project.config,
|
||||
ingest: {
|
||||
...project.config.ingest,
|
||||
adapters,
|
||||
workUnits: {
|
||||
...project.config.ingest.workUnits,
|
||||
maxConcurrency,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
async function markDatabasesComplete(projectDir: string, connectionIds: string[]): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const config = setKtxSetupDatabaseConnectionIds(project.config, unique(connectionIds), { complete: true });
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
const config = setKtxSetupDatabaseConnectionIds(project.config, unique(connectionIds));
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'databases');
|
||||
}
|
||||
|
||||
async function maybeRunHistoricSqlSetupProbe(input: {
|
||||
|
|
@ -1110,7 +1115,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,
|
||||
|
|
@ -1118,10 +1123,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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1256,7 +1261,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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1320,7 +1325,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 };
|
||||
}
|
||||
|
||||
|
|
@ -1377,7 +1382,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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initKtxProject, parseKtxProjectConfig } from '@ktx/context/project';
|
||||
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type KtxSetupEmbeddingsPromptAdapter, runKtxSetupEmbeddingsStep } from './setup-embeddings.js';
|
||||
|
||||
|
|
@ -166,7 +166,8 @@ describe('setup embeddings step', () => {
|
|||
sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' },
|
||||
});
|
||||
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
|
||||
expect(config.setup?.completed_steps).toContain('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.',
|
||||
);
|
||||
|
|
@ -198,7 +199,7 @@ describe('setup embeddings step', () => {
|
|||
|
||||
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.',
|
||||
'\r│ - Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -238,7 +239,8 @@ describe('setup embeddings step', () => {
|
|||
sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' },
|
||||
});
|
||||
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
|
||||
expect(config.setup?.completed_steps).toContain('embeddings');
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
|
||||
});
|
||||
|
||||
it('fails non-interactive local setup when the managed local embeddings runtime is missing', async () => {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import { resolveKtxConfigReference } from '@ktx/context/core';
|
|||
import {
|
||||
type KtxProjectConfig,
|
||||
type KtxProjectEmbeddingConfig,
|
||||
ktxSetupCompletedSteps,
|
||||
loadKtxProject,
|
||||
markKtxSetupStepComplete,
|
||||
markKtxSetupStateStepComplete,
|
||||
readKtxSetupState,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
|
|
@ -111,9 +114,9 @@ function createPromptAdapter(): KtxSetupEmbeddingsPromptAdapter {
|
|||
};
|
||||
}
|
||||
|
||||
function hasCompletedEmbeddings(config: KtxProjectConfig): boolean {
|
||||
async function hasCompletedEmbeddings(projectDir: string, config: KtxProjectConfig): Promise<boolean> {
|
||||
return (
|
||||
config.setup?.completed_steps.includes('embeddings') === true &&
|
||||
ktxSetupCompletedSteps(config, await readKtxSetupState(projectDir)).includes('embeddings') &&
|
||||
config.ingest.embeddings.backend !== 'none' &&
|
||||
config.ingest.embeddings.backend !== 'deterministic' &&
|
||||
typeof config.ingest.embeddings.model === 'string' &&
|
||||
|
|
@ -187,7 +190,7 @@ function embeddingBackendDisplayName(backend: KtxSetupEmbeddingBackend): string
|
|||
|
||||
async function persistEmbeddingConfig(projectDir: string, embeddings: KtxProjectEmbeddingConfig): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const config = markKtxSetupStepComplete(
|
||||
const config = stripKtxSetupCompletedSteps(
|
||||
{
|
||||
...project.config,
|
||||
ingest: {
|
||||
|
|
@ -202,9 +205,9 @@ async function persistEmbeddingConfig(projectDir: string, embeddings: KtxProject
|
|||
},
|
||||
},
|
||||
},
|
||||
'embeddings',
|
||||
);
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'embeddings');
|
||||
}
|
||||
|
||||
async function chooseCredentialRef(
|
||||
|
|
@ -257,7 +260,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`,
|
||||
|
|
@ -349,7 +352,7 @@ function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string,
|
|||
|
||||
function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckProgress {
|
||||
if (io.stdout.isTTY !== true) {
|
||||
io.stdout.write(`${message}\n`);
|
||||
io.stdout.write(`│ ${message}\n`);
|
||||
const noop = () => undefined;
|
||||
return {
|
||||
succeed: noop,
|
||||
|
|
@ -360,7 +363,7 @@ function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckPro
|
|||
let frameIndex = 0;
|
||||
let stopped = false;
|
||||
const writeFrame = () => {
|
||||
io.stdout.write(`${CLEAR_CURRENT_LINE}${HEALTH_CHECK_SPINNER_FRAMES[frameIndex]} ${message}`);
|
||||
io.stdout.write(`${CLEAR_CURRENT_LINE}│ ${HEALTH_CHECK_SPINNER_FRAMES[frameIndex]} ${message}`);
|
||||
};
|
||||
writeFrame();
|
||||
const interval = setInterval(() => {
|
||||
|
|
@ -374,7 +377,7 @@ function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckPro
|
|||
}
|
||||
stopped = true;
|
||||
clearInterval(interval);
|
||||
io.stdout.write(`${CLEAR_CURRENT_LINE}${finalMessage}\n`);
|
||||
io.stdout.write(`${CLEAR_CURRENT_LINE}│ ${finalMessage}\n`);
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -393,19 +396,19 @@ 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 };
|
||||
}
|
||||
|
||||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||
if (
|
||||
args.forcePrompt !== true &&
|
||||
hasCompletedEmbeddings(project.config) &&
|
||||
(await hasCompletedEmbeddings(args.projectDir, project.config)) &&
|
||||
!args.embeddingBackend &&
|
||||
!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 };
|
||||
}
|
||||
|
||||
|
|
@ -492,7 +495,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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initKtxProject, parseKtxProjectConfig } from '@ktx/context/project';
|
||||
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
BUNDLED_ANTHROPIC_MODELS,
|
||||
|
|
@ -160,7 +160,8 @@ describe('setup Anthropic model step', () => {
|
|||
promptCaching: { enabled: true },
|
||||
});
|
||||
expect(config.scan.enrichment.mode).toBe('llm');
|
||||
expect(config.setup?.completed_steps).toContain('llm');
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm');
|
||||
expect(io.stdout()).toContain('LLM ready: yes');
|
||||
expect(io.stdout()).not.toContain('sk-ant-test');
|
||||
});
|
||||
|
|
@ -198,7 +199,8 @@ describe('setup Anthropic model step', () => {
|
|||
},
|
||||
models: { default: 'claude-sonnet-4-6' },
|
||||
});
|
||||
expect(config.setup?.completed_steps).toContain('llm');
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm');
|
||||
expect(io.stdout()).not.toContain('sk-ant-file');
|
||||
});
|
||||
|
||||
|
|
@ -310,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│',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -462,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',
|
||||
}),
|
||||
);
|
||||
|
|
@ -551,7 +553,8 @@ describe('setup Anthropic model step', () => {
|
|||
expect(io.stderr()).toContain('Choose a different credential source or model, or Back.');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.llm.models.default).toBe('claude-sonnet-4-6');
|
||||
expect(config.setup?.completed_steps).toContain('llm');
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm');
|
||||
expect(io.stderr()).not.toContain('sk-ant-test');
|
||||
});
|
||||
|
||||
|
|
@ -626,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',
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import {
|
|||
type KtxProjectConfig,
|
||||
type KtxProjectLlmConfig,
|
||||
loadKtxProject,
|
||||
markKtxSetupStepComplete,
|
||||
markKtxSetupStateStepComplete,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import { type KtxLlmConfig, type KtxLlmHealthCheckResult, runKtxLlmHealthCheck } from '@ktx/llm';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
|
|
@ -254,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) {
|
||||
|
|
@ -271,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) {
|
||||
|
|
@ -361,7 +362,7 @@ async function chooseModel(
|
|||
|
||||
async function persistLlmConfig(projectDir: string, credentialRef: string, model: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const config = markKtxSetupStepComplete(
|
||||
const config = stripKtxSetupCompletedSteps(
|
||||
{
|
||||
...project.config,
|
||||
llm: buildProjectLlmConfig(project.config.llm, credentialRef, model),
|
||||
|
|
@ -373,9 +374,9 @@ async function persistLlmConfig(projectDir: string, credentialRef: string, model
|
|||
},
|
||||
},
|
||||
},
|
||||
'llm',
|
||||
);
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'llm');
|
||||
}
|
||||
|
||||
function buildInteractiveRetryArgs(args: KtxSetupModelArgs): KtxSetupModelArgs {
|
||||
|
|
@ -393,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 };
|
||||
}
|
||||
|
||||
|
|
@ -405,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 };
|
||||
}
|
||||
|
||||
|
|
@ -438,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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initKtxProject, parseKtxProjectConfig } from '@ktx/context/project';
|
||||
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type KtxSetupProjectPromptAdapter, runKtxSetupProjectStep } from './setup-project.js';
|
||||
|
||||
|
|
@ -60,7 +60,8 @@ describe('setup project step', () => {
|
|||
expect(result.status).toBe('ready');
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.completed_steps).toEqual(['project']);
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['project'] });
|
||||
await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined();
|
||||
await expect(readFile(join(projectDir, '.ktx/.gitignore'), 'utf-8')).resolves.toContain('secrets/');
|
||||
expect(testIo.stdout()).toContain(`Project: ${projectDir}`);
|
||||
|
|
@ -93,8 +94,9 @@ describe('setup project step', () => {
|
|||
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup).toEqual({
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: ['llm', 'project'],
|
||||
completed_steps: [],
|
||||
});
|
||||
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['llm', 'project'] });
|
||||
});
|
||||
|
||||
it('creates a missing auto-mode project only when --yes is present in no-input mode', async () => {
|
||||
|
|
@ -151,7 +153,8 @@ describe('setup project step', () => {
|
|||
);
|
||||
expect(prompts.text).not.toHaveBeenCalled();
|
||||
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.completed_steps).toEqual(['project']);
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['project'] });
|
||||
});
|
||||
|
||||
it('offers an absolute default destination for a new project folder', async () => {
|
||||
|
|
@ -183,7 +186,7 @@ describe('setup project step', () => {
|
|||
);
|
||||
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();
|
||||
});
|
||||
|
||||
|
|
@ -202,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',
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,11 +5,15 @@ import { basename, join, resolve } from 'node:path';
|
|||
import { cancel, isCancel, select, text } from '@clack/prompts';
|
||||
import {
|
||||
initKtxProject,
|
||||
ktxSetupCompletedSteps,
|
||||
type KtxLocalProject,
|
||||
loadKtxProject,
|
||||
markKtxSetupStepComplete,
|
||||
markKtxSetupStateStepComplete,
|
||||
mergeKtxSetupGitignoreEntries,
|
||||
readKtxSetupState,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
writeKtxSetupState,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
|
||||
|
|
@ -143,7 +147,7 @@ async function confirmProjectDir(
|
|||
return { status: 'confirmed', confirmedCreation: true };
|
||||
}
|
||||
|
||||
io.stdout.write(`KTX will create:\n ${selectedDir}\n`);
|
||||
io.stdout.write(`│ KTX will create:\n│ ${selectedDir}\n`);
|
||||
const action = await prompts.select({
|
||||
message: `Create KTX project at ${selectedDir}?`,
|
||||
options: [
|
||||
|
|
@ -166,8 +170,11 @@ async function normalizeSetupGitignore(projectDir: string): Promise<void> {
|
|||
}
|
||||
|
||||
async function persistProjectStep(project: KtxLocalProject): Promise<KtxLocalProject> {
|
||||
const config = markKtxSetupStepComplete(project.config, 'project');
|
||||
const completedSteps = ktxSetupCompletedSteps(project.config, await readKtxSetupState(project.projectDir));
|
||||
const config = stripKtxSetupCompletedSteps(project.config);
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
await writeKtxSetupState(project.projectDir, { completed_steps: completedSteps });
|
||||
await markKtxSetupStateStepComplete(project.projectDir, 'project');
|
||||
await normalizeSetupGitignore(project.projectDir);
|
||||
return await loadKtxProject({ projectDir: project.projectDir });
|
||||
}
|
||||
|
|
@ -185,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(
|
||||
|
|
@ -197,8 +204,8 @@ 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`);
|
||||
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: [
|
||||
|
|
@ -324,7 +331,7 @@ 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({
|
||||
|
|
@ -369,8 +376,8 @@ export async function runKtxSetupProjectStep(
|
|||
}
|
||||
|
||||
if (choice === 'new-custom') {
|
||||
io.stdout.write(`Relative paths are resolved from:\n ${projectDir}\n`);
|
||||
io.stdout.write(`Home paths are resolved from:\n ${homeDir}\n`);
|
||||
io.stdout.write(`│ Relative paths are resolved from:\n│ ${projectDir}\n`);
|
||||
io.stdout.write(`│ Home paths are resolved from:\n│ ${homeDir}\n`);
|
||||
const rawPath = await prompts.text({
|
||||
message: withTextInputNavigation('Project folder path'),
|
||||
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
initKtxProject,
|
||||
type KtxProjectConnectionConfig,
|
||||
parseKtxProjectConfig,
|
||||
readKtxSetupState,
|
||||
serializeKtxProjectConfig,
|
||||
} from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
|
@ -65,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', () => {
|
||||
|
|
@ -136,7 +137,8 @@ describe('setup sources step', () => {
|
|||
projectDir,
|
||||
});
|
||||
|
||||
expect((await readConfig()).setup?.completed_steps).toContain('sources');
|
||||
expect((await readConfig()).setup?.completed_steps).toEqual(undefined);
|
||||
expect((await readKtxSetupState(projectDir)).completed_steps).toContain('sources');
|
||||
expect(io.stdout()).toContain('Context source setup skipped.');
|
||||
});
|
||||
|
||||
|
|
@ -169,7 +171,8 @@ describe('setup sources step', () => {
|
|||
source_dir: '/repo/dbt',
|
||||
project_name: 'analytics',
|
||||
});
|
||||
expect(config.setup?.completed_steps).toContain('sources');
|
||||
expect(config.setup?.completed_steps).toEqual([]);
|
||||
expect((await readKtxSetupState(projectDir)).completed_steps).toContain('sources');
|
||||
expect(runInitialIngest).toHaveBeenCalledWith(projectDir, 'analytics_dbt', io.io, { inputMode: 'disabled' });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ import {
|
|||
type KtxProjectConfig,
|
||||
type KtxProjectConnectionConfig,
|
||||
loadKtxProject,
|
||||
markKtxSetupStepComplete,
|
||||
markKtxSetupStateStepComplete,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { runKtxConnectionMapping } from './commands/connection-mapping.js';
|
||||
|
|
@ -333,7 +334,7 @@ function fileRepoUrl(sourceDir: string): string {
|
|||
|
||||
async function writeProjectConfig(projectDir: string, config: KtxProjectConfig): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
|
||||
}
|
||||
|
||||
async function writeSourceConnection(
|
||||
|
|
@ -360,7 +361,7 @@ async function writeSourceConnection(
|
|||
: [...project.config.ingest.adapters, adapter],
|
||||
},
|
||||
};
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
|
||||
return async () => {
|
||||
const latest = await loadKtxProject({ projectDir });
|
||||
const connections = { ...latest.config.connections };
|
||||
|
|
@ -399,11 +400,8 @@ async function ensureSourceAdapterEnabled(projectDir: string, source: KtxSetupSo
|
|||
|
||||
async function markSourcesComplete(projectDir: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
project.configPath,
|
||||
serializeKtxProjectConfig(markKtxSetupStepComplete(project.config, 'sources')),
|
||||
'utf-8',
|
||||
);
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(project.config)), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'sources');
|
||||
}
|
||||
|
||||
function hasPrimarySource(config: KtxProjectConfig): boolean {
|
||||
|
|
@ -701,7 +699,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,
|
||||
|
|
@ -729,8 +727,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 ${input.connectionId}\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 ${input.connectionId}\n`);
|
||||
return 'continue';
|
||||
}
|
||||
return 'back';
|
||||
|
|
@ -1357,7 +1355,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 };
|
||||
}
|
||||
|
||||
|
|
@ -1370,7 +1368,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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1394,7 +1392,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 };
|
||||
}
|
||||
|
||||
|
|
@ -1467,7 +1465,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',
|
||||
}),
|
||||
);
|
||||
|
|
@ -839,7 +839,10 @@ describe('setup status', () => {
|
|||
).resolves.toBe(0);
|
||||
|
||||
await expect(stat(join(tempDir, 'ktx.yaml'))).resolves.toBeDefined();
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('completed_steps:');
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
await expect(readFile(join(tempDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toBe(
|
||||
`${JSON.stringify({ completed_steps: ['project', 'sources'] }, null, 2)}\n`,
|
||||
);
|
||||
expect(testIo.stdout()).toContain('KTX setup');
|
||||
expect(testIo.stdout()).toContain(`Project: ${tempDir}`);
|
||||
expect(testIo.stdout()).toContain('Project ready: yes');
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import { existsSync } from 'node:fs';
|
|||
import { join, resolve } from 'node:path';
|
||||
import { cancel, isCancel, select } from '@clack/prompts';
|
||||
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
|
||||
import { ktxLocalStateDbPath, loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||
import {
|
||||
ktxLocalStateDbPath,
|
||||
ktxSetupCompletedSteps,
|
||||
loadKtxProject,
|
||||
readKtxSetupState,
|
||||
type KtxLocalProject,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { formatSetupNextStepLines } from './next-steps.js';
|
||||
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
|
|
@ -291,7 +297,7 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
|
|||
};
|
||||
embeddings.ready = embeddingsReady(embeddings);
|
||||
|
||||
const completedSteps = project.config.setup?.completed_steps ?? [];
|
||||
const completedSteps = ktxSetupCompletedSteps(project.config, await readKtxSetupState(resolvedProjectDir));
|
||||
const contextState = await readKtxSetupContextState(resolvedProjectDir);
|
||||
const setupContextStatus = setupContextStatusFromState(contextState, {
|
||||
completedStep: completedSteps.includes('context'),
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@
|
|||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/bigquery": "^8.1.1",
|
||||
"@google-cloud/bigquery": "^8.3.1",
|
||||
"@ktx/context": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"@types/node": "^25.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@
|
|||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@clickhouse/client": "^1.18.4",
|
||||
"@ktx/context": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"@types/node": "^25.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -27,12 +27,12 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@ktx/context": "workspace:*",
|
||||
"mysql2": "^3.18.1"
|
||||
"mysql2": "^3.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"@types/node": "^25.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@ktx/context": "workspace:*",
|
||||
"pg": "^8.19.0"
|
||||
"pg": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -27,12 +27,12 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@ktx/context": "workspace:*",
|
||||
"snowflake-sdk": "^2.3.4"
|
||||
"snowflake-sdk": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"@types/node": "^25.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@ktx/context": "workspace:*",
|
||||
"better-sqlite3": "^12.6.2"
|
||||
"better-sqlite3": "^12.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"@types/node": "^25.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@ktx/context": "workspace:*",
|
||||
"mssql": "^12.2.0"
|
||||
"mssql": "^12.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mssql": "^9.1.8",
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"@types/mssql": "^12.3.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -130,30 +130,30 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@ktx/llm": "workspace:*",
|
||||
"@looker/sdk": "^26.6.1",
|
||||
"@looker/sdk-node": "^26.6.1",
|
||||
"@looker/sdk": "^26.8.0",
|
||||
"@looker/sdk-node": "^26.8.0",
|
||||
"@looker/sdk-rtl": "^21.6.5",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@notionhq/client": "^5.20.0",
|
||||
"ai": "^6.0.168",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"handlebars": "^4.7.8",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@notionhq/client": "^5.21.0",
|
||||
"ai": "^6.0.180",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"lookml-parser": "7.1.0",
|
||||
"minimatch": "^10.2.4",
|
||||
"minimatch": "^10.2.5",
|
||||
"p-limit": "^7.3.0",
|
||||
"pg": "^8.19.0",
|
||||
"simple-git": "3.32.2",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.1.13"
|
||||
"pg": "^8.20.0",
|
||||
"simple-git": "3.36.0",
|
||||
"yaml": "^2.9.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electric-sql/pglite": "^0.4.5",
|
||||
"@electric-sql/pglite-socket": "^0.1.5",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
import { type SimpleGit, simpleGit } from 'simple-git';
|
||||
|
||||
const PRE_COMMIT_GIT_ENV = [
|
||||
const SANITIZED_GIT_ENV_KEYS = [
|
||||
'EDITOR',
|
||||
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
||||
'GIT_CONFIG',
|
||||
'GIT_CONFIG_COUNT',
|
||||
'GIT_CONFIG_GLOBAL',
|
||||
'GIT_CONFIG_PARAMETERS',
|
||||
'GIT_CONFIG_SYSTEM',
|
||||
'GIT_DIR',
|
||||
'GIT_EDITOR',
|
||||
'GIT_EXEC_PATH',
|
||||
'GIT_INDEX_FILE',
|
||||
'GIT_OBJECT_DIRECTORY',
|
||||
'GIT_PAGER',
|
||||
'GIT_PREFIX',
|
||||
'GIT_QUARANTINE_PATH',
|
||||
'GIT_SEQUENCE_EDITOR',
|
||||
'GIT_SSH',
|
||||
'GIT_SSH_COMMAND',
|
||||
'GIT_TEMPLATE_DIR',
|
||||
'GIT_WORK_TREE',
|
||||
'PAGER',
|
||||
'SSH_ASKPASS',
|
||||
'VISUAL',
|
||||
] as const;
|
||||
|
||||
export function createSimpleGit(baseDir?: string): SimpleGit {
|
||||
const env = { ...process.env };
|
||||
for (const key of PRE_COMMIT_GIT_ENV) {
|
||||
for (const key of SANITIZED_GIT_ENV_KEYS) {
|
||||
delete env[key];
|
||||
}
|
||||
return simpleGit(baseDir).env(env);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export interface KtxProjectConnectionConfig {
|
|||
|
||||
export interface KtxProjectSetupConfig {
|
||||
database_connection_ids: string[];
|
||||
completed_steps: string[];
|
||||
completed_steps?: string[];
|
||||
}
|
||||
|
||||
export interface KtxProjectConfig {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ export { initKtxProject, loadKtxProject } from './project.js';
|
|||
export type { KtxSetupStep } from './setup-config.js';
|
||||
export {
|
||||
KTX_SETUP_STEPS,
|
||||
markKtxSetupStepComplete,
|
||||
ktxSetupCompletedSteps,
|
||||
ktxSetupStatePath,
|
||||
markKtxSetupStateStepComplete,
|
||||
mergeKtxSetupGitignoreEntries,
|
||||
readKtxSetupState,
|
||||
setKtxSetupDatabaseConnectionIds,
|
||||
stripKtxSetupCompletedSteps,
|
||||
writeKtxSetupState,
|
||||
} from './setup-config.js';
|
||||
|
|
|
|||
|
|
@ -1,43 +1,40 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildDefaultKtxProjectConfig } from './config.js';
|
||||
import {
|
||||
markKtxSetupStepComplete,
|
||||
ktxSetupCompletedSteps,
|
||||
markKtxSetupStateStepComplete,
|
||||
mergeKtxSetupGitignoreEntries,
|
||||
readKtxSetupState,
|
||||
setKtxSetupDatabaseConnectionIds,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from './setup-config.js';
|
||||
|
||||
describe('KTX setup config helpers', () => {
|
||||
it('marks setup steps complete without duplicating existing state', () => {
|
||||
const config = buildDefaultKtxProjectConfig('warehouse');
|
||||
let tempDir: string;
|
||||
|
||||
const withProject = markKtxSetupStepComplete(config, 'project');
|
||||
const withProjectAgain = markKtxSetupStepComplete(withProject, 'project');
|
||||
const withLlm = markKtxSetupStepComplete(withProjectAgain, 'llm');
|
||||
const withContext = markKtxSetupStepComplete(withLlm, 'context');
|
||||
|
||||
expect(withProject.setup).toEqual({
|
||||
database_connection_ids: [],
|
||||
completed_steps: ['project'],
|
||||
});
|
||||
expect(withProjectAgain.setup?.completed_steps).toEqual(['project']);
|
||||
expect(withLlm.setup?.completed_steps).toEqual(['project', 'llm']);
|
||||
expect(withContext.setup?.completed_steps).toEqual(['project', 'llm', 'context']);
|
||||
expect(config.setup).toBeUndefined();
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-state-'));
|
||||
});
|
||||
|
||||
it('preserves database connection ids while marking a step complete', () => {
|
||||
const config = {
|
||||
...buildDefaultKtxProjectConfig('warehouse'),
|
||||
setup: {
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: ['databases'],
|
||||
},
|
||||
};
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
expect(markKtxSetupStepComplete(config, 'project').setup).toEqual({
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: ['databases', 'project'],
|
||||
it('marks setup steps complete in local state without duplicating existing state', async () => {
|
||||
await markKtxSetupStateStepComplete(tempDir, 'project');
|
||||
await markKtxSetupStateStepComplete(tempDir, 'project');
|
||||
await markKtxSetupStateStepComplete(tempDir, 'llm');
|
||||
await markKtxSetupStateStepComplete(tempDir, 'context');
|
||||
|
||||
expect(await readKtxSetupState(tempDir)).toEqual({
|
||||
completed_steps: ['project', 'llm', 'context'],
|
||||
});
|
||||
await expect(readFile(join(tempDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toBe(
|
||||
`${JSON.stringify({ completed_steps: ['project', 'llm', 'context'] }, null, 2)}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('sets setup database connection ids without duplicates', () => {
|
||||
|
|
@ -47,22 +44,38 @@ describe('KTX setup config helpers', () => {
|
|||
|
||||
expect(withDatabases.setup).toEqual({
|
||||
database_connection_ids: ['warehouse', 'analytics'],
|
||||
completed_steps: [],
|
||||
});
|
||||
expect(config.setup).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks databases complete only when requested', () => {
|
||||
const config = markKtxSetupStepComplete(buildDefaultKtxProjectConfig('warehouse'), 'project');
|
||||
it('strips setup completed steps while preserving database connection ids', () => {
|
||||
const config = {
|
||||
...buildDefaultKtxProjectConfig('warehouse'),
|
||||
setup: {
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: ['project', 'databases'],
|
||||
},
|
||||
};
|
||||
|
||||
const withDatabases = setKtxSetupDatabaseConnectionIds(config, ['warehouse'], { complete: true });
|
||||
const withDatabasesAgain = setKtxSetupDatabaseConnectionIds(withDatabases, ['warehouse'], { complete: true });
|
||||
|
||||
expect(withDatabases.setup).toEqual({
|
||||
expect(stripKtxSetupCompletedSteps(config).setup).toEqual({
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: ['project', 'databases'],
|
||||
});
|
||||
expect(withDatabasesAgain.setup).toEqual(withDatabases.setup);
|
||||
});
|
||||
|
||||
it('combines legacy config setup steps with local state for reads', () => {
|
||||
const config = {
|
||||
...buildDefaultKtxProjectConfig('warehouse'),
|
||||
setup: {
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: ['project', 'databases'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(ktxSetupCompletedSteps(config, { completed_steps: ['databases', 'sources'] })).toEqual([
|
||||
'project',
|
||||
'databases',
|
||||
'sources',
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges setup-local gitignore entries without removing existing lines', () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { KtxProjectConfig } from './config.js';
|
||||
|
||||
export const KTX_SETUP_STEPS = ['project', 'llm', 'embeddings', 'databases', 'sources', 'context', 'agents'] as const;
|
||||
|
||||
export type KtxSetupStep = (typeof KTX_SETUP_STEPS)[number];
|
||||
|
||||
export interface KtxSetupState {
|
||||
completed_steps: KtxSetupStep[];
|
||||
}
|
||||
|
||||
const SETUP_GITIGNORE_ENTRIES = [
|
||||
'cache/',
|
||||
'db.sqlite',
|
||||
|
|
@ -14,14 +20,67 @@ const SETUP_GITIGNORE_ENTRIES = [
|
|||
'agents/',
|
||||
] as const;
|
||||
|
||||
export function markKtxSetupStepComplete(config: KtxProjectConfig, step: KtxSetupStep): KtxProjectConfig {
|
||||
const databaseConnectionIds = config.setup?.database_connection_ids ?? [];
|
||||
const completedSteps = config.setup?.completed_steps ?? [];
|
||||
function isKtxSetupStep(value: unknown): value is KtxSetupStep {
|
||||
return typeof value === 'string' && (KTX_SETUP_STEPS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function uniqueSetupSteps(steps: unknown): KtxSetupStep[] {
|
||||
if (!Array.isArray(steps)) {
|
||||
return [];
|
||||
}
|
||||
return [...new Set(steps.filter(isKtxSetupStep))];
|
||||
}
|
||||
|
||||
export function ktxSetupStatePath(projectDir: string): string {
|
||||
return join(projectDir, '.ktx', 'setup', 'state.json');
|
||||
}
|
||||
|
||||
export async function readKtxSetupState(projectDir: string): Promise<KtxSetupState> {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(ktxSetupStatePath(projectDir), 'utf-8')) as Record<string, unknown>;
|
||||
return { completed_steps: uniqueSetupSteps(parsed.completed_steps) };
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return { completed_steps: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeKtxSetupState(projectDir: string, state: KtxSetupState): Promise<void> {
|
||||
await mkdir(join(projectDir, '.ktx', 'setup'), { recursive: true });
|
||||
await writeFile(
|
||||
ktxSetupStatePath(projectDir),
|
||||
`${JSON.stringify({ completed_steps: uniqueSetupSteps(state.completed_steps) }, null, 2)}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
export async function markKtxSetupStateStepComplete(projectDir: string, step: KtxSetupStep): Promise<KtxSetupState> {
|
||||
const state = await readKtxSetupState(projectDir);
|
||||
const completedSteps = state.completed_steps.includes(step) ? state.completed_steps : [...state.completed_steps, step];
|
||||
const nextState = { completed_steps: completedSteps };
|
||||
await writeKtxSetupState(projectDir, nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
export function ktxSetupCompletedSteps(config: KtxProjectConfig, state: KtxSetupState): KtxSetupStep[] {
|
||||
return uniqueSetupSteps([...(config.setup?.completed_steps ?? []), ...state.completed_steps]);
|
||||
}
|
||||
|
||||
export function stripKtxSetupCompletedSteps(config: KtxProjectConfig): KtxProjectConfig {
|
||||
if (!config.setup) {
|
||||
return config;
|
||||
}
|
||||
const databaseConnectionIds = config.setup.database_connection_ids ?? [];
|
||||
if (databaseConnectionIds.length === 0) {
|
||||
const { setup: _setup, ...withoutSetup } = config;
|
||||
return withoutSetup;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
setup: {
|
||||
database_connection_ids: [...databaseConnectionIds],
|
||||
completed_steps: completedSteps.includes(step) ? [...completedSteps] : [...completedSteps, step],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -29,20 +88,14 @@ export function markKtxSetupStepComplete(config: KtxProjectConfig, step: KtxSetu
|
|||
export function setKtxSetupDatabaseConnectionIds(
|
||||
config: KtxProjectConfig,
|
||||
connectionIds: string[],
|
||||
options: { complete?: boolean } = {},
|
||||
): KtxProjectConfig {
|
||||
const uniqueConnectionIds = [...new Set(connectionIds.filter((connectionId) => connectionId.trim().length > 0))];
|
||||
const completedSteps = config.setup?.completed_steps ?? [];
|
||||
const nextCompletedSteps =
|
||||
options.complete === true && !completedSteps.includes('databases')
|
||||
? [...completedSteps, 'databases']
|
||||
: [...completedSteps];
|
||||
|
||||
return {
|
||||
...config,
|
||||
setup: {
|
||||
database_connection_ids: uniqueConnectionIds,
|
||||
completed_steps: nextCompletedSteps,
|
||||
...(config.setup?.completed_steps ? { completed_steps: [...config.setup.completed_steps] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,16 +26,16 @@
|
|||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/anthropic": "3.0.77",
|
||||
"@ai-sdk/devtools": "0.0.17",
|
||||
"@ai-sdk/google-vertex": "^4.0.112",
|
||||
"ai": "^6.0.168",
|
||||
"openai": "^6.25.0"
|
||||
"@ai-sdk/google-vertex": "^4.0.128",
|
||||
"ai": "^6.0.180",
|
||||
"openai": "^6.37.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"@types/node": "^25.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
2244
pnpm-lock.yaml
generated
2244
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,4 +11,8 @@ injectWorkspacePackages: true
|
|||
syncInjectedDepsAfterScripts:
|
||||
- build
|
||||
shamefullyHoist: false
|
||||
verifyDepsBeforeRun: install
|
||||
verifyDepsBeforeRun: false
|
||||
allowBuilds:
|
||||
better-sqlite3: true
|
||||
esbuild: true
|
||||
sharp: true
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { join } from 'node:path';
|
|||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
npmSmokePackageJson,
|
||||
npmSmokePnpmWorkspaceYaml,
|
||||
packageArtifactLayout,
|
||||
} from './package-artifacts.mjs';
|
||||
|
||||
|
|
@ -280,6 +281,7 @@ async function prepareCleanInstall(layout, cleanInstallDir) {
|
|||
await assertPathExists(layout.cliTarball, '@ktx/cli tarball');
|
||||
await mkdir(cleanInstallDir, { recursive: true });
|
||||
await writeFile(join(cleanInstallDir, 'package.json'), `${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`);
|
||||
await writeFile(join(cleanInstallDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
|
||||
await run('pnpm', ['install'], { cwd: cleanInstallDir, timeout: 120_000 }).then((result) =>
|
||||
requireSuccess('pnpm install clean artifact project', result),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
PUBLIC_NPM_PACKAGE_NAME,
|
||||
PUBLIC_NPM_PACKAGE_VERSION,
|
||||
} from './build-public-npm-package.mjs';
|
||||
import { npmSmokePnpmWorkspaceYaml } from './package-artifacts.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -249,6 +250,7 @@ async function writeSmokePackage(projectDir, tarballPath) {
|
|||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await writeFile(join(projectDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
|
||||
}
|
||||
|
||||
export async function runLocalEmbeddingsRuntimeSmoke(options = {}) {
|
||||
|
|
|
|||
|
|
@ -462,12 +462,13 @@ export function npmSmokePackageJson(layout) {
|
|||
devDependencies: {
|
||||
'better-sqlite3': '^12.6.2',
|
||||
},
|
||||
pnpm: {
|
||||
onlyBuiltDependencies: ['better-sqlite3'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function npmSmokePnpmWorkspaceYaml() {
|
||||
return ['packages:', ' - "."', 'allowBuilds:', ' better-sqlite3: true', ''].join('\n');
|
||||
}
|
||||
|
||||
export function npmVerifySource() {
|
||||
return `
|
||||
const cli = await import('@kaelio/ktx');
|
||||
|
|
@ -1111,6 +1112,7 @@ async function verifyNpmArtifacts(layout, tmpRoot) {
|
|||
join(projectDir, 'package.json'),
|
||||
`${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`,
|
||||
);
|
||||
await writeFile(join(projectDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
|
||||
await writeFile(join(projectDir, 'verify-npm.mjs'), npmVerifySource());
|
||||
await writeFile(join(projectDir, 'verify-installed-cli.mjs'), npmRuntimeSmokeSource());
|
||||
await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource());
|
||||
|
|
@ -1131,6 +1133,7 @@ async function verifyNpmCliArtifacts(layout, tmpRoot) {
|
|||
const projectDir = join(tmpRoot, 'npm-cli-clean-install');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
await writeFile(join(projectDir, 'package.json'), `${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`);
|
||||
await writeFile(join(projectDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml());
|
||||
await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource());
|
||||
|
||||
await runCommand('pnpm', ['install'], { cwd: projectDir });
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
npmCliSmokeSource,
|
||||
npmRuntimeSmokeSource,
|
||||
npmSmokePackageJson,
|
||||
npmSmokePnpmWorkspaceYaml,
|
||||
npmVerifySource,
|
||||
packageArtifactLayout,
|
||||
packageReleaseMetadata,
|
||||
|
|
@ -422,6 +423,10 @@ describe('verification snippets', () => {
|
|||
assert.deepEqual(packageJson.devDependencies, {
|
||||
'better-sqlite3': '^12.6.2',
|
||||
});
|
||||
assert.equal(
|
||||
npmSmokePnpmWorkspaceYaml(),
|
||||
['packages:', ' - "."', 'allowBuilds:', ' better-sqlite3: true', ''].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes manifest verification as a package artifact command', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue