diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 1688724d..5176ab8e 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -1,4 +1,5 @@ import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings'; +import { reservedKtxIngestConnectionIdMessage } from '@ktx/context/project'; import type { KtxCliCommandContext } from '../cli-program.js'; import { resolveCommandProjectDir } from '../cli-program.js'; import type { KtxSetupDatabaseDriver } from '../setup-databases.js'; @@ -268,6 +269,10 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) { throw new InvalidArgumentError(`Unsafe connection id: ${value}`); } + const reservedMessage = reservedKtxIngestConnectionIdMessage(value); + if (reservedMessage) { + throw new InvalidArgumentError(reservedMessage); + } return value; }) .option('--database-url ', 'URL, env:NAME, or file:/path for one new URL-style database connection') diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index d858442a..1a1c613e 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -1175,6 +1175,18 @@ describe('runKtxCli', () => { ); }); + it('rejects reserved setup database connection ids before dispatch', async () => { + const testIo = makeIo(); + const setup = vi.fn(async () => 0); + + await expect( + runKtxCli(['setup', '--new-database-connection-id', 'status', '--no-input'], testIo.io, { setup }), + ).resolves.toBe(1); + + expect(setup).not.toHaveBeenCalled(); + expect(testIo.stderr()).toContain('"status" is reserved for ktx ingest status; choose a different connection id.'); + }); + it('dispatches setup source flags', async () => { const setup = vi.fn(async () => 0); const testIo = makeIo(); diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/src/setup-databases.test.ts index 817c37d5..734eea63 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/src/setup-databases.test.ts @@ -1717,6 +1717,26 @@ describe('setup databases step', () => { expect(io.stderr()).toContain('Missing database connection id'); }); + it('rejects reserved non-interactive database connection ids', async () => { + const io = makeIo(); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'disabled', + databaseDrivers: ['postgres'], + databaseConnectionId: 'replay', + databaseUrl: 'env:DATABASE_URL', + databaseSchemas: [], + skipDatabases: false, + }, + io.io, + ); + + expect(result.status).toBe('failed'); + expect(io.stderr()).toContain('"replay" is reserved for ktx ingest replay; choose a different connection id.'); + }); + it('leaves setup incomplete when primary sources are skipped', async () => { const io = makeIo(); diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 7f353b01..3675bee7 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import type { HistoricSqlDialect } from '@ktx/context/ingest'; import { + assertKtxConnectionIdIsNotReserved, type KtxProjectConnectionConfig, loadKtxProject, markKtxSetupStateStepComplete, @@ -227,6 +228,13 @@ function unique(values: string[]): string[] { return [...new Set(values.filter((value) => value.trim().length > 0))]; } +function assertSafeDatabaseConnectionId(connectionId: string): void { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) { + throw new Error(`Unsafe connection id: ${connectionId}`); + } + assertKtxConnectionIdIsNotReserved(connectionId); +} + function historicSqlConfigRecord(connection: KtxProjectConnectionConfig | undefined): Record | null { const historicSql = connection?.historicSql; return historicSql && typeof historicSql === 'object' && !Array.isArray(historicSql) @@ -1665,10 +1673,12 @@ async function chooseConnectionIdForDriver(input: { prompts: KtxSetupDatabasesPromptAdapter; }): Promise<{ kind: 'existing' | 'new'; connectionId: string } | 'back' | 'missing-input'> { if (input.args.databaseConnectionId) { + assertSafeDatabaseConnectionId(input.args.databaseConnectionId); return { kind: 'new', connectionId: input.args.databaseConnectionId }; } if (input.args.inputMode === 'disabled') { if (!input.args.databaseConnectionId) return 'missing-input'; + assertSafeDatabaseConnectionId(input.args.databaseConnectionId); return { kind: 'new', connectionId: input.args.databaseConnectionId }; } @@ -1684,6 +1694,7 @@ async function chooseConnectionIdForDriver(input: { }); if (entered === undefined) return 'back'; const connectionId = entered.trim() || defaultId; + assertSafeDatabaseConnectionId(connectionId); return connectionId ? { kind: 'new', connectionId } : 'missing-input'; } @@ -1708,6 +1719,7 @@ async function chooseConnectionIdForDriver(input: { }); if (entered === undefined) continue; const connectionId = entered.trim() || defaultId; + assertSafeDatabaseConnectionId(connectionId); return connectionId ? { kind: 'new', connectionId } : 'missing-input'; } } @@ -1780,12 +1792,18 @@ export async function runKtxSetupDatabasesStep( for (const driver of drivers) { const project = await loadKtxProject({ projectDir: args.projectDir }); - const connectionChoice = await chooseConnectionIdForDriver({ - driver, - connections: project.config.connections, - args, - prompts, - }); + let connectionChoice: Awaited>; + try { + connectionChoice = await chooseConnectionIdForDriver({ + driver, + connections: project.config.connections, + args, + prompts, + }); + } catch (error) { + io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return { status: 'failed', projectDir: args.projectDir }; + } if (connectionChoice === 'back') { if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; returnToDriverSelection = true; diff --git a/packages/cli/src/setup-sources.test.ts b/packages/cli/src/setup-sources.test.ts index 2f622da5..e68a3c9c 100644 --- a/packages/cli/src/setup-sources.test.ts +++ b/packages/cli/src/setup-sources.test.ts @@ -254,6 +254,31 @@ describe('setup sources step', () => { }); }); + it('rejects reserved interactive source connection ids', async () => { + await addPrimarySource(); + const io = makeIo(); + + const result = await runKtxSetupSourcesStep( + { + projectDir, + inputMode: 'auto', + runInitialSourceIngest: false, + skipSources: false, + }, + io.io, + { + prompts: prompts({ + multiselect: [['notion']], + text: ['status', 'env:NOTION_TOKEN'], + select: ['env', 'all_accessible'], + }), + }, + ); + + expect(result.status).toBe('failed'); + expect(io.stderr()).toContain('"status" is reserved for ktx ingest status; choose a different connection id.'); + }); + it('uses selected Notion roots when root page ids are provided even if crawl mode says all accessible', async () => { await addPrimarySource(); const validateNotion = vi.fn(async () => ({ ok: true as const, detail: 'roots=1' })); diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index 991f56cc..4750c559 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -19,6 +19,7 @@ import { testRepoConnection, } from '@ktx/context/ingest'; import { + assertKtxConnectionIdIsNotReserved, type KtxProjectConfig, type KtxProjectConnectionConfig, loadKtxProject, @@ -201,6 +202,7 @@ function assertSafeConnectionId(connectionId: string): void { if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) { throw new Error(`Unsafe connection id: ${connectionId}`); } + assertKtxConnectionIdIsNotReserved(connectionId); } function credentialRef(value: string | undefined, label: string): string { diff --git a/packages/context/src/project/config.test.ts b/packages/context/src/project/config.test.ts index b5c22a82..137bd7b8 100644 --- a/packages/context/src/project/config.test.ts +++ b/packages/context/src/project/config.test.ts @@ -2,6 +2,17 @@ import { describe, expect, it } from 'vitest'; import { buildDefaultKtxProjectConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from './config.js'; describe('KTX project config', () => { + it.each(['status', 'replay', 'run', 'watch'])('rejects reserved ingest connection id "%s"', (connectionId) => { + expect(() => + parseKtxProjectConfig(` +project: reserved-test +connections: + ${connectionId}: + driver: postgres +`), + ).toThrow(`"${connectionId}" is reserved for ktx ingest ${connectionId}`); + }); + it('builds the default standalone project config', () => { expect(buildDefaultKtxProjectConfig('warehouse')).toEqual({ project: 'warehouse', diff --git a/packages/context/src/project/config.ts b/packages/context/src/project/config.ts index 2dc3c2a4..e655ca02 100644 --- a/packages/context/src/project/config.ts +++ b/packages/context/src/project/config.ts @@ -112,6 +112,25 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +const RESERVED_INGEST_CONNECTION_IDS = new Map([ + ['status', 'ktx ingest status'], + ['replay', 'ktx ingest replay'], + ['run', 'ktx ingest run'], + ['watch', 'ktx ingest watch'], +]); + +export function reservedKtxIngestConnectionIdMessage(connectionId: string): string | null { + const command = RESERVED_INGEST_CONNECTION_IDS.get(connectionId); + return command ? `"${connectionId}" is reserved for ${command}; choose a different connection id.` : null; +} + +export function assertKtxConnectionIdIsNotReserved(connectionId: string): void { + const message = reservedKtxIngestConnectionIdMessage(connectionId); + if (message) { + throw new Error(message); + } +} + function stringArray(value: unknown, fallback: string[]): string[] { if (!Array.isArray(value)) { return fallback; @@ -485,6 +504,12 @@ export function parseKtxProjectConfig(raw: string): KtxProjectConfig { ...(isRecord(scanEnrichment.embeddings) ? { embeddings: scanEmbeddings } : {}), }; const parsedScanRelationships = parseScanRelationshipConfig(scanRelationships, defaults.scan.relationships); + const parsedConnections = isRecord(parsed.connections) + ? (parsed.connections as Record) + : defaults.connections; + for (const connectionId of Object.keys(parsedConnections)) { + assertKtxConnectionIdIsNotReserved(connectionId); + } return { project: project.trim(), @@ -495,9 +520,7 @@ export function parseKtxProjectConfig(raw: string): KtxProjectConfig { }, } : {}), - connections: isRecord(parsed.connections) - ? (parsed.connections as Record) - : defaults.connections, + connections: parsedConnections, storage: { state: storage.state === 'sqlite' ? 'sqlite' : defaults.storage.state, search: storage.search === 'sqlite-fts5' ? 'sqlite-fts5' : defaults.storage.search, diff --git a/packages/context/src/project/index.ts b/packages/context/src/project/index.ts index 8ea92bf6..3680f6c3 100644 --- a/packages/context/src/project/index.ts +++ b/packages/context/src/project/index.ts @@ -6,7 +6,13 @@ export type { KtxSearchBackend, KtxStorageState, } from './config.js'; -export { buildDefaultKtxProjectConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from './config.js'; +export { + assertKtxConnectionIdIsNotReserved, + buildDefaultKtxProjectConfig, + parseKtxProjectConfig, + reservedKtxIngestConnectionIdMessage, + serializeKtxProjectConfig, +} from './config.js'; export type { LocalGitFileStoreDeps } from './local-git-file-store.js'; export { LocalGitFileStore } from './local-git-file-store.js'; export { ktxLocalStateDbPath } from './local-state-db.js';