diff --git a/packages/context/src/project/config.ts b/packages/context/src/project/config.ts index 6d2e8941..f1aa9d71 100644 --- a/packages/context/src/project/config.ts +++ b/packages/context/src/project/config.ts @@ -75,7 +75,7 @@ export interface KtxProjectConnectionConfig { export interface KtxProjectSetupConfig { database_connection_ids: string[]; - completed_steps: string[]; + completed_steps?: string[]; } export interface KtxProjectConfig { diff --git a/packages/context/src/project/index.ts b/packages/context/src/project/index.ts index 651e9fb8..8fd171d4 100644 --- a/packages/context/src/project/index.ts +++ b/packages/context/src/project/index.ts @@ -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'; diff --git a/packages/context/src/project/setup-config.test.ts b/packages/context/src/project/setup-config.test.ts index 212f16e1..46912d43 100644 --- a/packages/context/src/project/setup-config.test.ts +++ b/packages/context/src/project/setup-config.test.ts @@ -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', () => { diff --git a/packages/context/src/project/setup-config.ts b/packages/context/src/project/setup-config.ts index 76951ef6..a426caf7 100644 --- a/packages/context/src/project/setup-config.ts +++ b/packages/context/src/project/setup-config.ts @@ -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 { + try { + const parsed = JSON.parse(await readFile(ktxSetupStatePath(projectDir), 'utf-8')) as Record; + 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 { + 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 { + 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] } : {}), }, }; }