mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
fix(config): reject reserved ingest connection ids
This commit is contained in:
parent
ca61f3e08e
commit
23dba892cd
9 changed files with 132 additions and 10 deletions
|
|
@ -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>', 'URL, env:NAME, or file:/path for one new URL-style database connection')
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<ReturnType<typeof chooseConnectionIdForDriver>>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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' }));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -112,6 +112,25 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
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<string, KtxProjectConnectionConfig>)
|
||||
: 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<string, KtxProjectConnectionConfig>)
|
||||
: defaults.connections,
|
||||
connections: parsedConnections,
|
||||
storage: {
|
||||
state: storage.state === 'sqlite' ? 'sqlite' : defaults.storage.state,
|
||||
search: storage.search === 'sqlite-fts5' ? 'sqlite-fts5' : defaults.storage.search,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue