ktx/packages/cli/src/commands/setup-commands.ts

441 lines
18 KiB
TypeScript
Raw Normal View History

2026-05-10 23:12:26 +02:00
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
2026-05-10 23:51:24 +02:00
import type { KtxCliCommandContext } from '../cli-program.js';
2026-05-10 23:12:26 +02:00
import { resolveCommandProjectDir } from '../cli-program.js';
2026-05-10 23:51:24 +02:00
import type { KtxSetupDatabaseDriver } from '../setup-databases.js';
import type { KtxSetupLlmBackend } from '../setup-models.js';
2026-05-10 23:51:24 +02:00
import type { KtxSetupSourceType } from '../setup-sources.js';
2026-05-10 23:12:26 +02:00
async function runSetupArgs(
2026-05-10 23:51:24 +02:00
context: KtxCliCommandContext,
2026-05-10 23:12:26 +02:00
args: Parameters<NonNullable<typeof context.deps.setup>>[0],
) {
2026-05-10 23:51:24 +02:00
const runner = context.deps.setup ?? (await import('../setup.js')).runKtxSetup;
2026-05-10 23:12:26 +02:00
context.setExitCode(await runner(args, context.io));
}
function positiveInteger(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Expected a positive integer, received ${value}`);
}
return parsed;
}
function embeddingBackend(value: string): 'openai' | 'sentence-transformers' {
if (value === 'openai' || value === 'sentence-transformers') {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
}
function llmBackend(value: string): KtxSetupLlmBackend {
if (value === 'anthropic' || value === 'vertex') {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
}
2026-05-10 23:51:24 +02:00
function databaseDriver(value: string): KtxSetupDatabaseDriver {
2026-05-10 23:12:26 +02:00
if (
value === 'sqlite' ||
value === 'postgres' ||
value === 'mysql' ||
value === 'clickhouse' ||
value === 'sqlserver' ||
value === 'bigquery' ||
value === 'snowflake'
) {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
}
2026-05-10 23:51:24 +02:00
function sourceType(value: string): KtxSetupSourceType {
2026-05-10 23:12:26 +02:00
if (
value === 'dbt' ||
value === 'metricflow' ||
value === 'metabase' ||
value === 'looker' ||
value === 'lookml' ||
value === 'notion'
) {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
}
function agentScope(value: string): 'project' | 'global' {
if (value === 'project' || value === 'global') {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
}
function positiveNumber(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new InvalidArgumentError(`Expected a positive integer, received ${value}`);
}
return parsed;
}
function optionWasSpecified(command: Command, optionName: string): boolean {
const commandWithSources = command as Command & {
getOptionValueSource?: (name: string) => string | undefined;
getOptionValueSourceWithGlobals?: (name: string) => string | undefined;
};
const source =
commandWithSources.getOptionValueSourceWithGlobals?.(optionName) ??
commandWithSources.getOptionValueSource?.(optionName);
return source !== undefined && source !== 'default';
}
function shouldShowSetupEntryMenu(
options: {
new?: boolean;
existing?: boolean;
agents?: boolean;
target?: string;
global?: boolean;
project?: boolean;
skipAgents?: boolean;
yes?: boolean;
input?: boolean;
llmBackend?: KtxSetupLlmBackend;
2026-05-10 23:12:26 +02:00
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
anthropicModel?: string;
vertexProject?: string;
vertexLocation?: string;
2026-05-10 23:12:26 +02:00
skipLlm?: boolean;
embeddingBackend?: string;
embeddingApiKeyEnv?: string;
embeddingApiKeyFile?: string;
skipEmbeddings?: boolean;
2026-05-10 23:51:24 +02:00
database?: KtxSetupDatabaseDriver[];
2026-05-10 23:12:26 +02:00
databaseConnectionId?: string[];
newDatabaseConnectionId?: string;
databaseUrl?: string;
databaseSchema?: string[];
enableHistoricSql?: boolean;
disableHistoricSql?: boolean;
historicSqlWindowDays?: number;
historicSqlMinExecutions?: number;
2026-05-10 23:12:26 +02:00
historicSqlServiceAccountPattern?: string[];
historicSqlRedactionPattern?: string[];
skipDatabases?: boolean;
2026-05-10 23:51:24 +02:00
source?: KtxSetupSourceType;
2026-05-10 23:12:26 +02:00
sourceConnectionId?: string;
sourcePath?: string;
sourceGitUrl?: string;
sourceBranch?: string;
sourceSubpath?: string;
sourceAuthTokenRef?: string;
sourceUrl?: string;
sourceApiKeyRef?: string;
sourceClientId?: string;
sourceClientSecretRef?: string;
sourceWarehouseConnectionId?: string;
sourceProjectName?: string;
sourceProfilesPath?: string;
sourceTarget?: string;
metabaseDatabaseId?: number;
notionCrawlMode?: string;
notionRootPageId?: string[];
skipInitialSourceIngest?: boolean;
skipSources?: boolean;
},
command: Command,
): boolean {
if (options.database && options.database.length > 0) {
return false;
}
if (options.databaseConnectionId && options.databaseConnectionId.length > 0) {
return false;
}
if (options.databaseSchema && options.databaseSchema.length > 0) {
return false;
}
if (options.historicSqlServiceAccountPattern && options.historicSqlServiceAccountPattern.length > 0) {
return false;
}
if (options.historicSqlRedactionPattern && options.historicSqlRedactionPattern.length > 0) {
return false;
}
if (options.notionRootPageId && options.notionRootPageId.length > 0) {
return false;
}
return ![
'new',
'existing',
'agents',
'target',
'global',
'project',
'skipAgents',
'yes',
'input',
'llmBackend',
2026-05-10 23:12:26 +02:00
'anthropicApiKeyEnv',
'anthropicApiKeyFile',
'anthropicModel',
'vertexProject',
'vertexLocation',
2026-05-10 23:12:26 +02:00
'skipLlm',
'embeddingBackend',
'embeddingApiKeyEnv',
'embeddingApiKeyFile',
'skipEmbeddings',
'newDatabaseConnectionId',
'databaseUrl',
'enableHistoricSql',
'disableHistoricSql',
'historicSqlWindowDays',
'historicSqlMinExecutions',
2026-05-10 23:12:26 +02:00
'skipDatabases',
'source',
'sourceConnectionId',
'sourcePath',
'sourceGitUrl',
'sourceBranch',
'sourceSubpath',
'sourceAuthTokenRef',
'sourceUrl',
'sourceApiKeyRef',
'sourceClientId',
'sourceClientSecretRef',
'sourceWarehouseConnectionId',
'sourceProjectName',
'sourceProfilesPath',
'sourceTarget',
'metabaseDatabaseId',
'notionCrawlMode',
'skipInitialSourceIngest',
'skipSources',
].some((optionName) => optionWasSpecified(command, optionName));
}
2026-05-10 23:51:24 +02:00
export function registerSetupCommands(program: Command, context: KtxCliCommandContext): void {
2026-05-10 23:12:26 +02:00
const setup = program
.command('setup')
2026-05-10 23:51:24 +02:00
.description('Set up or resume a local KTX project')
.option('--project-dir <path>', 'KTX project directory')
.option('--new', 'Create a new KTX project before setup', false)
.option('--existing', 'Use an existing KTX project', false)
2026-05-10 23:12:26 +02:00
.option('--agents', 'Install agent integration only', false)
.addOption(
new Option('--target <target>', 'Agent target').choices([
'claude-code',
'codex',
'cursor',
'opencode',
'universal',
]),
)
.addOption(new Option('--agent-scope <scope>', 'Agent install scope').argParser(agentScope).default('project'))
.option('--project', 'Install agent integration into the project scope', false)
.option('--global', 'Install agent integration into the global target scope', false)
.option('--skip-agents', 'Leave agent integration incomplete for now', false)
.option('--yes', 'Accept safe defaults in non-interactive setup', false)
.option('--no-input', 'Disable interactive terminal input')
.addOption(new Option('--llm-backend <backend>', 'LLM backend').argParser(llmBackend))
2026-05-10 23:12:26 +02:00
.option('--anthropic-api-key-env <name>', 'Environment variable containing the Anthropic API key')
.option('--anthropic-api-key-file <path>', 'File containing the Anthropic API key')
.option('--anthropic-model <model>', 'Anthropic model ID to validate and save')
.option('--vertex-project <project>', 'Google Vertex AI project ID, env:NAME, or file:/path')
.option('--vertex-location <location>', 'Google Vertex AI location, env:NAME, or file:/path')
2026-05-10 23:12:26 +02:00
.addOption(new Option('--skip-llm', 'Leave LLM setup incomplete for now').hideHelp().default(false))
.addOption(new Option('--embedding-backend <backend>', 'Embedding backend').argParser(embeddingBackend))
.option('--embedding-api-key-env <name>', 'Environment variable containing the embedding provider API key')
.option('--embedding-api-key-file <path>', 'File containing the embedding provider API key')
.addOption(new Option('--skip-embeddings', 'Leave embedding setup incomplete for now').hideHelp().default(false))
.option(
'--database <driver>',
'Database driver to configure; repeatable',
2026-05-10 23:51:24 +02:00
(value, previous: KtxSetupDatabaseDriver[]) => {
2026-05-10 23:12:26 +02:00
return [...previous, databaseDriver(value)];
},
2026-05-10 23:51:24 +02:00
[] as KtxSetupDatabaseDriver[],
2026-05-10 23:12:26 +02:00
)
.option(
'--database-connection-id <id>',
'Existing selected connection id or new connection id',
(value, previous: string[]) => [...previous, value],
[],
)
.option('--new-database-connection-id <id>', 'Connection id for one new database connection', (value) => {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
}
return value;
})
.option('--database-url <url>', 'URL, env:NAME, or file:/path for one new URL-style database connection')
.option(
'--database-schema <schema>',
'Database schema to include; repeatable',
(value, previous: string[]) => [...previous, value],
[],
)
.option('--enable-historic-sql', 'Enable Historic SQL when the selected database supports it', false)
.option('--disable-historic-sql', 'Disable Historic SQL for the selected database', false)
.option('--historic-sql-window-days <number>', 'Historic SQL query-history window', positiveInteger)
.option('--historic-sql-min-executions <number>', 'Minimum Historic SQL executions for a template', positiveInteger)
2026-05-10 23:12:26 +02:00
.option(
'--historic-sql-service-account-pattern <pattern>',
'Historic SQL service-account regex; repeatable',
(value, previous: string[]) => [...previous, value],
[],
)
.option(
'--historic-sql-redaction-pattern <pattern>',
'Historic SQL SQL-literal redaction regex; repeatable',
(value, previous: string[]) => [...previous, value],
[],
)
2026-05-10 23:51:24 +02:00
.option('--skip-databases', 'Leave database setup incomplete; KTX cannot work until a primary source is added', false)
2026-05-10 23:12:26 +02:00
.addOption(new Option('--source <type>', 'Source connector type').argParser(sourceType))
.option('--source-connection-id <id>', 'Connection id for source setup')
.option('--source-path <path>', 'Local source path for dbt, MetricFlow, or LookML')
.option('--source-git-url <url>', 'Git URL for dbt, MetricFlow, or LookML')
.option('--source-branch <branch>', 'Git branch for source setup')
.option('--source-subpath <path>', 'Repo subpath for source setup')
.option('--source-auth-token-ref <ref>', 'env: or file: credential ref for source repo auth')
.option('--source-url <url>', 'Source service URL for Metabase or Looker')
.option('--source-api-key-ref <ref>', 'env: or file: API key ref for Metabase or Notion')
.option('--source-client-id <id>', 'Looker client id')
.option('--source-client-secret-ref <ref>', 'env: or file: Looker client secret ref')
.option('--source-warehouse-connection-id <id>', 'Mapped warehouse connection id')
.option('--source-project-name <name>', 'dbt project name override')
.option('--source-profiles-path <path>', 'dbt profiles path')
.option('--source-target <target>', 'dbt target or source-specific mapping target')
.option('--metabase-database-id <id>', 'Metabase database id to map', positiveNumber)
.addOption(
new Option('--notion-crawl-mode <mode>', 'Notion crawl mode').choices(['all_accessible', 'selected_roots']),
)
.option(
'--notion-root-page-id <id>',
'Notion root page id; repeatable',
(value, previous: string[]) => [...previous, value],
[],
)
.option('--skip-initial-source-ingest', 'Validate source setup without building source context during setup', false)
.option('--skip-sources', 'Mark optional source setup complete with no sources', false)
.showHelpAfterError();
setup.hook('preAction', (_thisCommand, actionCommand) => {
context.writeDebug?.('setup', actionCommand);
});
setup.action(async (options, command) => {
if (options.anthropicApiKeyEnv && options.anthropicApiKeyFile) {
context.io.stderr.write(
'Choose only one Anthropic credential source: --anthropic-api-key-env or --anthropic-api-key-file.\n',
);
context.setExitCode(1);
return;
}
if (options.llmBackend === 'vertex' && (options.anthropicApiKeyEnv || options.anthropicApiKeyFile)) {
context.io.stderr.write('Anthropic API key flags are only valid with --llm-backend anthropic.\n');
context.setExitCode(1);
return;
}
if (options.llmBackend === 'anthropic' && (options.vertexProject || options.vertexLocation)) {
context.io.stderr.write('Vertex AI flags are only valid with --llm-backend vertex.\n');
context.setExitCode(1);
return;
}
2026-05-10 23:12:26 +02:00
if (options.embeddingApiKeyEnv && options.embeddingApiKeyFile) {
context.io.stderr.write(
'Choose only one embedding credential source: --embedding-api-key-env or --embedding-api-key-file.\n',
);
context.setExitCode(1);
return;
}
if (options.enableHistoricSql && options.disableHistoricSql) {
context.io.stderr.write(
'Choose only one Historic SQL action: --enable-historic-sql or --disable-historic-sql.\n',
);
context.setExitCode(1);
return;
}
if (options.sourcePath && options.sourceGitUrl) {
context.io.stderr.write('Choose only one source location: --source-path or --source-git-url.\n');
context.setExitCode(1);
return;
}
if (options.skipSources && options.source) {
context.io.stderr.write('Choose either --source or --skip-sources.\n');
context.setExitCode(1);
return;
}
const mode = options.new ? 'new' : options.existing ? 'existing' : 'auto';
const resolvedAgentScope = options.global ? 'global' : options.agentScope;
await runSetupArgs(context, {
command: 'run',
projectDir: resolveCommandProjectDir(command),
mode,
agents: options.agents === true,
...(options.target ? { target: options.target } : {}),
agentScope: resolvedAgentScope,
skipAgents: options.skipAgents === true,
inputMode: options.input === false ? 'disabled' : 'auto',
yes: options.yes === true,
feat: npm-managed Python runtime for @kaelio/ktx (#7) * docs: add npm managed python runtime design * build: add bundled python runtime wheel builder * build: make local embedding dependencies optional * build: bundle python runtime wheel in cli artifacts * build: track bundled python runtime release artifact * test: verify bundled python runtime wheel * docs: add plan for bundled python runtime wheel * test: cover managed python runtime lifecycle * feat: add managed python runtime installer * feat: add runtime command runner * feat: expose runtime management commands * test: verify managed python runtime commands * docs: add plan for managed python runtime installer * feat: add managed python command helper * feat: use managed runtime for sl query compute * feat: route sl query managed runtime policy * docs: add plan for managed runtime sl query integration * feat: add managed runtime daemon metadata * feat: manage python daemon lifecycle * feat: add runtime daemon start stop commands * fix: verify managed runtime daemon lifecycle * docs: add plan for managed runtime daemon lifecycle * feat: add managed local embeddings config marker * feat: add managed local embeddings daemon helper * feat: use managed runtime for local embedding setup * feat: pass managed runtime policy through setup * docs: add plan for managed local embeddings runtime * feat: read CLI package metadata dynamically * feat: assemble public kaelio ktx npm package * feat: release one public kaelio ktx npm artifact * test: cover public kaelio ktx package invocations * chore: verify public kaelio ktx package artifacts * docs: add plan for public kaelio ktx npm package * test: verify managed runtime in public package smoke * test: finalize managed runtime release smoke * docs: add plan for managed runtime release smoke * test: specify local embeddings release smoke * feat: add local embeddings runtime smoke * chore: register local embeddings smoke * fix: verify local embeddings smoke * fix: restore artifact smoke python env helper * docs: add plan for managed local embeddings release smoke * refactor: share managed runtime install policy parsing * feat: use managed runtime for agent semantic queries * feat: use managed runtime for MCP semantic compute * docs: add plan for managed agent and MCP semantic runtime * feat(cli): add managed daemon HTTP helpers * feat(cli): route local adapters through managed daemon * feat(cli): use managed daemon for ingest helpers * feat(cli): pass managed daemon options to scan * feat(context): pass MCP ingest pull config options * feat(cli): pass managed daemon options to serve ingest * test: verify managed local ingest daemon runtime * docs: add plan for managed local ingest daemon runtime * docs: align managed runtime examples * docs: add plan for managed runtime docs cleanup * test: cover published package runtime smoke commands * test: validate published package smoke outputs * docs: add plan for published package runtime smoke * build: stamp public npm package version * release: add npm public release policy * release: add guarded npm publish script * release: document public npm release handoff * docs: add plan for public npm release handoff * test: cover managed runtime prune in package smoke * docs: document managed runtime prune * docs: add plan for managed runtime prune smoke and docs * chore: encode uv runtime prerequisite policy * fix: clarify missing uv runtime error * docs: document uv runtime prerequisite * docs: add plan for uv runtime prerequisite contract * refactor: limit release artifacts to public package runtime * chore: align release policy with bundled runtime wheel * docs: describe single public runtime artifact surface * test: verify single public runtime artifact contract * docs: add plan for single public runtime artifact cleanup * fix: align local embeddings smoke with public version * docs: add plan for local embeddings smoke public version * release: soft-launch as @kaelio/ktx@0.1.0-rc.0 on next tag Publish target moves to the pre-release version 0.1.0-rc.0 under the next dist-tag so npm install @kaelio/ktx (which resolves to latest) does not pick up the soft-launch build. Users opt in via @kaelio/ktx@next. * Fix release script boundary checks * Remove PostHog from public package bundle
2026-05-11 15:50:34 +02:00
cliVersion: context.packageInfo.version,
...(options.llmBackend ? { llmBackend: options.llmBackend } : {}),
2026-05-10 23:12:26 +02:00
...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}),
...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}),
...(options.anthropicModel ? { anthropicModel: options.anthropicModel } : {}),
...(options.vertexProject ? { vertexProject: options.vertexProject } : {}),
...(options.vertexLocation ? { vertexLocation: options.vertexLocation } : {}),
2026-05-10 23:12:26 +02:00
skipLlm: options.skipLlm === true,
...(options.embeddingBackend ? { embeddingBackend: options.embeddingBackend } : {}),
...(options.embeddingApiKeyEnv ? { embeddingApiKeyEnv: options.embeddingApiKeyEnv } : {}),
...(options.embeddingApiKeyFile ? { embeddingApiKeyFile: options.embeddingApiKeyFile } : {}),
skipEmbeddings: options.skipEmbeddings === true,
...(options.database.length > 0 ? { databaseDrivers: options.database } : {}),
...(options.databaseConnectionId.length > 0 ? { databaseConnectionIds: options.databaseConnectionId } : {}),
...(options.newDatabaseConnectionId ? { databaseConnectionId: options.newDatabaseConnectionId } : {}),
...(options.databaseUrl ? { databaseUrl: options.databaseUrl } : {}),
databaseSchemas: options.databaseSchema,
...(options.enableHistoricSql ? { enableHistoricSql: true } : {}),
...(options.disableHistoricSql ? { disableHistoricSql: true } : {}),
...(options.historicSqlWindowDays !== undefined ? { historicSqlWindowDays: options.historicSqlWindowDays } : {}),
...(options.historicSqlMinExecutions !== undefined
? { historicSqlMinExecutions: options.historicSqlMinExecutions }
: {}),
2026-05-10 23:12:26 +02:00
...(options.historicSqlServiceAccountPattern.length > 0
? { historicSqlServiceAccountPatterns: options.historicSqlServiceAccountPattern }
: {}),
...(options.historicSqlRedactionPattern.length > 0
? { historicSqlRedactionPatterns: options.historicSqlRedactionPattern }
: {}),
skipDatabases: options.skipDatabases === true,
...(options.source ? { source: options.source } : {}),
...(options.sourceConnectionId ? { sourceConnectionId: options.sourceConnectionId } : {}),
...(options.sourcePath ? { sourcePath: options.sourcePath } : {}),
...(options.sourceGitUrl ? { sourceGitUrl: options.sourceGitUrl } : {}),
...(options.sourceBranch ? { sourceBranch: options.sourceBranch } : {}),
...(options.sourceSubpath ? { sourceSubpath: options.sourceSubpath } : {}),
...(options.sourceAuthTokenRef ? { sourceAuthTokenRef: options.sourceAuthTokenRef } : {}),
...(options.sourceUrl ? { sourceUrl: options.sourceUrl } : {}),
...(options.sourceApiKeyRef ? { sourceApiKeyRef: options.sourceApiKeyRef } : {}),
...(options.sourceClientId ? { sourceClientId: options.sourceClientId } : {}),
...(options.sourceClientSecretRef ? { sourceClientSecretRef: options.sourceClientSecretRef } : {}),
...(options.sourceWarehouseConnectionId
? { sourceWarehouseConnectionId: options.sourceWarehouseConnectionId }
: {}),
...(options.sourceProjectName ? { sourceProjectName: options.sourceProjectName } : {}),
...(options.sourceProfilesPath ? { sourceProfilesPath: options.sourceProfilesPath } : {}),
...(options.sourceTarget ? { sourceTarget: options.sourceTarget } : {}),
...(options.metabaseDatabaseId !== undefined ? { metabaseDatabaseId: options.metabaseDatabaseId } : {}),
...(options.notionCrawlMode ? { notionCrawlMode: options.notionCrawlMode } : {}),
...(options.notionRootPageId.length > 0 ? { notionRootPageIds: options.notionRootPageId } : {}),
runInitialSourceIngest: false,
skipSources: options.skipSources === true,
showEntryMenu: shouldShowSetupEntryMenu(options, command),
});
});
}