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

2160 lines
78 KiB
TypeScript
Raw Normal View History

2026-05-10 23:12:26 +02:00
import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
2026-05-10 16:12:51 -07:00
import { join, relative, resolve } from 'node:path';
2026-05-10 23:12:26 +02:00
import { fileURLToPath, pathToFileURL } from 'node:url';
chore(workspace): gate dead-code with knip production mode (#196) * refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm * refactor(workspace): rewrite @ktx/llm imports to relative paths * refactor(workspace): fold internal packages into cli * chore(workspace): gate dead-code with knip production mode Turn on production-mode knip plus an autofix run in pre-commit and the `pnpm dead-code` script, document the `/** @internal */` convention for test-only exports in AGENTS.md, annotate test-only exports across the CLI with that JSDoc, and drop dead exports/wrappers the new gate surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`, `createLocalScanEnrichmentProvidersFromConfig`, `PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports). Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit production entries so cross-package barrel leaks are caught. * refactor(cli): delete internal barrel index.ts files The 34 `index.ts` re-export barrels inside `packages/cli/src/` were holdovers from the pre-fold multi-workspace structure. Post-fold-in they served no production purpose: external consumers go through the single package main entry, and in-repo callers mostly imported through them only because the path was short. Internally, knip flagged most barrel re-exports as production-dead (only reached via tests). This change: - Deletes every internal barrel except `packages/cli/src/index.ts` (the published package entry). - Rewrites ~270 source/test files to import each name directly from the file that defines it. - Moves `tools/warehouse-verification/index.ts` to `create-warehouse-verification-tools.ts` (the function it defined locally) and updates its single consumer. - Renames `search/backend-conformance.ts` → `.test-utils.ts` to match the existing test-helper file convention. - Deletes 13 dead test-only chains (dbt-descriptions/*, live-database/extracted-schema, live-database/structural-sync, relationship-* feedback/review chain) plus their tests and a cascading orphan integration test. - Updates test mocks that pointed at deleted barrel paths (notion-client, connector barrels in scan/local-scan-connectors tests) to mock the source files instead. - Points the maintainer benchmark script (`scripts/relationship-benchmark-report.mjs`) at source files instead of `dist/context/scan/index.js`. - Drops the barrel `!` entries from `knip.json`; adds explicit production entries only for the benchmark code reached via dist by the maintainer script. Net: 413 files changed, ~1.2k insertions, ~9.4k deletions. `pnpm run dead-code` (Biome + knip default + knip production) and `pnpm run type-check` are clean; 2277 tests pass. * refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly Promote the CLI workspace package to the public name `@kaelio/ktx` and drop the separate `scripts/build-public-npm-package.mjs` wrapper. The CLI package is now publishable in place (`publishConfig.access: public`, `provenance: true`), so artifact packing uses `pnpm pack` against `packages/cli/` instead of assembling a parallel package tree. Updates all workspace filter invocations, docs, tests, and release readiness checks to reference the new package name, and folds the tarball-name helper into `scripts/public-npm-release-metadata.mjs`. * docs: align "agent clients" and "data agents" terminology Replace "client agents" with "agent clients" and "database agents" with "data agents" across AGENTS.md, README.md, the docs-site copy, and the matching setup-agents test description, matching the canonical vocabulary in docs/terminology.md. Also moves packages/cli/tsconfig.json's tsBuildInfoFile from node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive node_modules reinstalls. * refactor(release): single source of truth for package version Make packages/cli/package.json the single source of truth for the @kaelio/ktx version. publicNpmPackageVersion() now reads it directly, so artifact filenames, release-readiness checks, and the Python wheel version all derive from one field. The duplicate release-policy.json.publicNpmPackageVersion is removed. Previously the two fields could drift: tarballs were named kaelio-ktx-0.4.1.tgz while internally containing @kaelio/ktx@0.0.0-private. - update-public-release-version.mjs rewrites both Python pyproject.toml files (ktx-daemon, ktx-sl) alongside the npm package.jsons, normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2). - semantic-release-config.cjs adds the two pyproject.toml files to @semantic-release/git assets so the release commit back to main carries every version source in lockstep. - The six "?? '0.0.0-private'" fallback literals across the CLI are replaced with "?? getKtxCliPackageInfo().version", and createDefaultKtxMcpServer makes its version arg required. - docs/release.md describes the actual commit-back model: the dev tree always reflects the most recent release; no sentinel pin to maintain. Verified: pnpm run artifacts:build now produces kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with @kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and 2287 vitests + 173 script tests pass. * refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and scan command entrypoints so tests can stub them, and teach resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime feature when ktx.yaml selects sentence-transformers. * chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal Both symbols are consumed only by status-project.test.ts. Annotating with /** @internal */ keeps knip's production-mode check clean without changing runtime behavior. * fix(cli): use real package metadata in print-command-tree The stubbed package name embedded a forbidden product identifier that tripped the boundary check in CI. Read the metadata from package.json instead — keeps the rendered tree unchanged and removes a duplicate source of truth. * feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer source counts, computed with `SUM(embedding_json IS NOT NULL)` over `knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to "Wiki" (canonical per `docs/terminology.md`) and rename the matching `localStats.knowledgePages` field to `localStats.wikiPages`. Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those duplicated the per-surface rows above. Disk now reports only actual byte usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` / `semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry` helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
import { localConnectionTypeForConfig } from './context/connections/local-warehouse-descriptor.js';
import { resolveNotionConnectionAuthToken } from './context/connections/notion-config.js';
import { resolveKtxConfigReference } from './context/core/config-reference.js';
import { cloneOrPull, testRepoConnection } from './context/ingest/repo-fetch.js';
import { DEFAULT_METABASE_CLIENT_CONFIG, MetabaseClient } from './context/ingest/adapters/metabase/client.js';
import { discoverMetabaseDatabases, type DiscoveredMetabaseDatabase } from './context/ingest/adapters/metabase/mapping.js';
import { loadDbtSchemaFiles } from './context/ingest/dbt-shared/schema-files.js';
import { loadProjectInfo } from './context/ingest/dbt-shared/project-vars.js';
import { type NotionApi, NotionClient } from './context/ingest/adapters/notion/notion-client.js';
import { parseLookmlStagedDir } from './context/ingest/adapters/lookml/parse.js';
import { parseMetricflowFiles } from './context/ingest/adapters/metricflow/deep-parse.js';
import { type KtxProjectConfig, type KtxProjectConnectionConfig, serializeKtxProjectConfig } from './context/project/config.js';
import { loadKtxProject } from './context/project/project.js';
import { markKtxSetupStateStepComplete } from './context/project/setup-config.js';
2026-05-10 23:51:24 +02:00
import type { KtxCliIo } from './cli-runtime.js';
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
import { createCliSpinner, errorMessage, writePrefixedLines } from './clack.js';
import { pickNotionRootPages } from './notion-page-picker.js';
import { runKtxSourceMapping } from './source-mapping.js';
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
import {
runConnectionSetupWithRecovery,
type ConfigureResult,
type RecoveryOutcome,
type ValidateResult,
} from './connection-recovery.js';
import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
2026-05-10 23:51:24 +02:00
import { runKtxPublicIngest } from './public-ingest.js';
2026-05-10 16:12:51 -07:00
import { writeProjectLocalSecretReference } from './setup-secrets.js';
feat(telemetry): anonymous posthog usage telemetry across node cli and python daemon (#205) * feat: add telemetry phase 1 * feat: add node telemetry event catalog * feat: add telemetry event helpers * feat: emit setup and connection telemetry * feat: emit connection and stack telemetry * feat: emit ingest and scan telemetry * feat: emit query telemetry * feat: emit sampled mcp telemetry * docs: expand telemetry event catalog * feat: add telemetry schema sync artifact * feat: pass telemetry project id to semantic daemon * feat: add daemon telemetry foundation * feat: emit semantic daemon telemetry * feat: emit daemon lifecycle telemetry * docs: document full telemetry event catalog * feat(telemetry): dim first-run notice * feat(telemetry): show first-run notice before command output * feat(telemetry): wire ktx PostHog project for live ingestion * docs(telemetry): drop posthog project name and host from storage section * docs(telemetry): trim to general overview and disclaimer * docs(agents): add short telemetry guidelines * feat(telemetry): enable posthog geoip enrichment * docs(telemetry): drop ip-geoip note from public overview * refactor(telemetry): drop no-op groupIdentify, rely on capture groups field * fix(telemetry): respect CI kill switch in python daemon identity * fix(sql): route table-count analysis to existing analyze-batch endpoint * fix(telemetry): emit install_first_run from notice path and derive flagsPresent from commander * fix(telemetry): read package info via getKtxCliPackageInfo to satisfy boundary check * fix(telemetry): make python identity env={} bypass os.environ and unset CI in tests * fix(telemetry): unset CI kill switch in cli-program-telemetry tests
2026-05-22 18:18:47 +02:00
import { isDemoConnection } from './telemetry/demo-detect.js';
import { emitTelemetryEvent } from './telemetry/index.js';
import {
createKtxSetupPromptAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
2026-05-10 23:12:26 +02:00
2026-05-10 23:51:24 +02:00
export type KtxSetupSourceType = 'dbt' | 'metricflow' | 'metabase' | 'looker' | 'lookml' | 'notion';
2026-05-10 23:12:26 +02:00
const DEFAULT_NOTION_MAX_KNOWLEDGE_CREATES_PER_RUN = 25;
2026-05-10 23:51:24 +02:00
export interface KtxSetupSourcesArgs {
2026-05-10 23:12:26 +02:00
projectDir: string;
inputMode: 'auto' | 'disabled';
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?: 'all_accessible' | 'selected_roots';
notionRootPageIds?: string[];
runInitialSourceIngest: boolean;
skipSources: boolean;
}
2026-05-10 23:51:24 +02:00
export type KtxSetupSourcesResult =
2026-05-10 23:12:26 +02:00
| { status: 'ready'; projectDir: string; connectionIds: string[] }
| { status: 'skipped'; projectDir: string }
| { status: 'back'; projectDir: string }
| { status: 'missing-input'; projectDir: string }
| { status: 'failed'; projectDir: string };
2026-05-10 23:51:24 +02:00
export interface KtxSetupSourcesPromptAdapter {
2026-05-10 23:12:26 +02:00
multiselect(options: {
message: string;
options: KtxSetupPromptOption[];
initialValues?: string[];
2026-05-10 23:12:26 +02:00
required?: boolean;
}): Promise<string[]>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
autocomplete(options: {
message: string;
placeholder?: string;
options: KtxSetupPromptOption[];
}): Promise<string>;
2026-05-10 23:12:26 +02:00
text(options: { message: string; placeholder?: string; initialValue?: string }): Promise<string | undefined>;
2026-05-10 16:12:51 -07:00
password(options: { message: string }): Promise<string | undefined>;
2026-05-10 23:12:26 +02:00
cancel(message: string): void;
log?(message: string): void;
}
chore(workspace): gate dead-code with knip production mode (#196) * refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm * refactor(workspace): rewrite @ktx/llm imports to relative paths * refactor(workspace): fold internal packages into cli * chore(workspace): gate dead-code with knip production mode Turn on production-mode knip plus an autofix run in pre-commit and the `pnpm dead-code` script, document the `/** @internal */` convention for test-only exports in AGENTS.md, annotate test-only exports across the CLI with that JSDoc, and drop dead exports/wrappers the new gate surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`, `createLocalScanEnrichmentProvidersFromConfig`, `PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports). Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit production entries so cross-package barrel leaks are caught. * refactor(cli): delete internal barrel index.ts files The 34 `index.ts` re-export barrels inside `packages/cli/src/` were holdovers from the pre-fold multi-workspace structure. Post-fold-in they served no production purpose: external consumers go through the single package main entry, and in-repo callers mostly imported through them only because the path was short. Internally, knip flagged most barrel re-exports as production-dead (only reached via tests). This change: - Deletes every internal barrel except `packages/cli/src/index.ts` (the published package entry). - Rewrites ~270 source/test files to import each name directly from the file that defines it. - Moves `tools/warehouse-verification/index.ts` to `create-warehouse-verification-tools.ts` (the function it defined locally) and updates its single consumer. - Renames `search/backend-conformance.ts` → `.test-utils.ts` to match the existing test-helper file convention. - Deletes 13 dead test-only chains (dbt-descriptions/*, live-database/extracted-schema, live-database/structural-sync, relationship-* feedback/review chain) plus their tests and a cascading orphan integration test. - Updates test mocks that pointed at deleted barrel paths (notion-client, connector barrels in scan/local-scan-connectors tests) to mock the source files instead. - Points the maintainer benchmark script (`scripts/relationship-benchmark-report.mjs`) at source files instead of `dist/context/scan/index.js`. - Drops the barrel `!` entries from `knip.json`; adds explicit production entries only for the benchmark code reached via dist by the maintainer script. Net: 413 files changed, ~1.2k insertions, ~9.4k deletions. `pnpm run dead-code` (Biome + knip default + knip production) and `pnpm run type-check` are clean; 2277 tests pass. * refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly Promote the CLI workspace package to the public name `@kaelio/ktx` and drop the separate `scripts/build-public-npm-package.mjs` wrapper. The CLI package is now publishable in place (`publishConfig.access: public`, `provenance: true`), so artifact packing uses `pnpm pack` against `packages/cli/` instead of assembling a parallel package tree. Updates all workspace filter invocations, docs, tests, and release readiness checks to reference the new package name, and folds the tarball-name helper into `scripts/public-npm-release-metadata.mjs`. * docs: align "agent clients" and "data agents" terminology Replace "client agents" with "agent clients" and "database agents" with "data agents" across AGENTS.md, README.md, the docs-site copy, and the matching setup-agents test description, matching the canonical vocabulary in docs/terminology.md. Also moves packages/cli/tsconfig.json's tsBuildInfoFile from node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive node_modules reinstalls. * refactor(release): single source of truth for package version Make packages/cli/package.json the single source of truth for the @kaelio/ktx version. publicNpmPackageVersion() now reads it directly, so artifact filenames, release-readiness checks, and the Python wheel version all derive from one field. The duplicate release-policy.json.publicNpmPackageVersion is removed. Previously the two fields could drift: tarballs were named kaelio-ktx-0.4.1.tgz while internally containing @kaelio/ktx@0.0.0-private. - update-public-release-version.mjs rewrites both Python pyproject.toml files (ktx-daemon, ktx-sl) alongside the npm package.jsons, normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2). - semantic-release-config.cjs adds the two pyproject.toml files to @semantic-release/git assets so the release commit back to main carries every version source in lockstep. - The six "?? '0.0.0-private'" fallback literals across the CLI are replaced with "?? getKtxCliPackageInfo().version", and createDefaultKtxMcpServer makes its version arg required. - docs/release.md describes the actual commit-back model: the dev tree always reflects the most recent release; no sentinel pin to maintain. Verified: pnpm run artifacts:build now produces kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with @kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and 2287 vitests + 173 script tests pass. * refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and scan command entrypoints so tests can stub them, and teach resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime feature when ktx.yaml selects sentence-transformers. * chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal Both symbols are consumed only by status-project.test.ts. Annotating with /** @internal */ keeps knip's production-mode check clean without changing runtime behavior. * fix(cli): use real package metadata in print-command-tree The stubbed package name embedded a forbidden product identifier that tripped the boundary check in CI. Read the metadata from package.json instead — keeps the rendered tree unchanged and removes a duplicate source of truth. * feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer source counts, computed with `SUM(embedding_json IS NOT NULL)` over `knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to "Wiki" (canonical per `docs/terminology.md`) and rename the matching `localStats.knowledgePages` field to `localStats.wikiPages`. Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those duplicated the per-surface rows above. Disk now reports only actual byte usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` / `semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry` helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
type SourceValidationResult = { ok: true; detail?: string } | { ok: false; message: string };
2026-05-10 23:12:26 +02:00
2026-05-10 23:51:24 +02:00
export interface KtxSetupSourcesDeps {
prompts?: KtxSetupSourcesPromptAdapter;
2026-05-10 23:12:26 +02:00
testGitRepo?: (args: { repoUrl: string; authToken?: string | null }) => Promise<{ ok: true } | { ok: false; error: string }>;
2026-05-10 23:51:24 +02:00
validateDbt?: (connection: KtxProjectConnectionConfig) => Promise<SourceValidationResult>;
validateMetricflow?: (connection: KtxProjectConnectionConfig) => Promise<SourceValidationResult>;
2026-05-10 23:12:26 +02:00
validateMetabase?: (projectDir: string, connectionId: string) => Promise<SourceValidationResult>;
validateLooker?: (projectDir: string, connectionId: string) => Promise<SourceValidationResult>;
2026-05-10 23:51:24 +02:00
validateLookml?: (connection: KtxProjectConnectionConfig) => Promise<SourceValidationResult>;
validateNotion?: (connection: KtxProjectConnectionConfig) => Promise<SourceValidationResult>;
pickNotionRootPages?: typeof pickNotionRootPages;
2026-05-10 16:12:51 -07:00
discoverMetabaseDatabases?: (args: {
sourceUrl: string;
sourceApiKeyRef: string;
sourceConnectionId: string;
}) => Promise<DiscoveredMetabaseDatabase[]>;
2026-05-10 23:51:24 +02:00
runMapping?: (projectDir: string, connectionId: string, io: KtxCliIo) => Promise<number>;
2026-05-10 23:12:26 +02:00
runInitialIngest?: (
projectDir: string,
connectionId: string,
2026-05-10 23:51:24 +02:00
io: KtxCliIo,
options: { inputMode: KtxSetupSourcesArgs['inputMode'] },
2026-05-10 23:12:26 +02:00
) => Promise<number>;
}
2026-05-10 23:51:24 +02:00
const SOURCE_OPTIONS: Array<{ value: KtxSetupSourceType; label: string }> = [
2026-05-10 23:12:26 +02:00
{ value: 'dbt', label: 'dbt' },
{ value: 'metabase', label: 'Metabase' },
{ value: 'notion', label: 'Notion' },
{ value: 'metricflow', label: 'MetricFlow' },
2026-05-10 23:12:26 +02:00
{ value: 'looker', label: 'Looker' },
{ value: 'lookml', label: 'LookML' },
];
const SOURCE_LABELS = Object.fromEntries(SOURCE_OPTIONS.map((option) => [option.value, option.label])) as Record<
2026-05-10 23:51:24 +02:00
KtxSetupSourceType,
2026-05-10 23:12:26 +02:00
string
>;
const PRIMARY_SOURCE_DRIVERS = new Set([
'sqlite',
'postgres',
'mysql',
'clickhouse',
'sqlserver',
'bigquery',
'snowflake',
]);
2026-05-10 23:51:24 +02:00
function createPromptAdapter(): KtxSetupSourcesPromptAdapter {
return createKtxSetupPromptAdapter({
selectCancelValue: 'back',
multiselectCancelValue: 'back',
confirmEmptyOptionalMultiselect: true,
});
2026-05-10 23:12:26 +02:00
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stringField(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
2026-05-10 23:51:24 +02:00
function sourceLabel(source: KtxSetupSourceType): string {
2026-05-10 23:12:26 +02:00
return SOURCE_LABELS[source];
}
2026-05-10 23:51:24 +02:00
function sourceAdapter(source: KtxSetupSourceType): string {
2026-05-10 23:12:26 +02:00
return source;
}
function connectionNamePrompt(label: string): string {
return `Name this ${label} connection\nktx will use this short name in commands and config. You can rename it now.`;
2026-05-10 23:12:26 +02:00
}
2026-05-10 23:51:24 +02:00
function sourceSubpathPrompt(source: KtxSetupSourceType): string {
2026-05-10 23:12:26 +02:00
if (source === 'dbt') {
return [
'Folder containing dbt_project.yml (optional)',
'Press Enter when dbt_project.yml is at the repo root.',
'For monorepos, enter a relative path like analytics/dbt.',
].join('\n');
}
return [
`${sourceLabel(source)} project folder (optional)`,
'If the project files are inside a subfolder, enter that path.',
'Press Enter if the path or repo already points at the project.',
].join('\n');
}
2026-05-10 16:12:51 -07:00
const SCAN_SKIP_DIRS = new Set(['.git', 'node_modules', '.venv', 'target', 'dbt_packages', 'dbt_modules', '__pycache__']);
async function findDbtProjectSubpaths(rootDir: string): Promise<string[]> {
const entries = await readdir(rootDir, { withFileTypes: true, recursive: true });
const subpaths: string[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name !== 'dbt_project.yml' && entry.name !== 'dbt_project.yaml') continue;
const relDir = relative(rootDir, entry.parentPath);
if (relDir.split('/').some((part) => SCAN_SKIP_DIRS.has(part))) continue;
subpaths.push(relDir);
}
return subpaths;
}
2026-05-10 23:12:26 +02:00
async function promptText(
2026-05-10 23:51:24 +02:00
prompts: KtxSetupSourcesPromptAdapter,
2026-05-10 23:12:26 +02:00
options: { message: string; placeholder?: string; initialValue?: string },
): Promise<string | undefined> {
return await prompts.text({ ...options, message: withTextInputNavigation(options.message) });
}
function assertSafeConnectionId(connectionId: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) {
throw new Error(`Unsafe connection id: ${connectionId}`);
}
}
function credentialRef(value: string | undefined, label: string): string {
const ref = value?.trim();
if (!ref) {
throw new Error(`Missing ${label}; use env:NAME or file:/absolute/path`);
}
if (!ref.startsWith('env:') && !ref.startsWith('file:')) {
throw new Error(`${label} must use env:NAME or file:/absolute/path`);
}
return ref;
}
type SourceCredentialFlag = {
field: 'sourceAuthTokenRef' | 'sourceApiKeyRef' | 'sourceClientSecretRef';
flag: string;
};
// Each connector reads exactly one credential ref; the flag name mirrors the
// ktx.yaml field it writes (auth_token_ref / api_key_ref / client_secret_ref).
const SOURCE_CREDENTIAL_FLAG: Record<KtxSetupSourceType, SourceCredentialFlag> = {
dbt: { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' },
metricflow: { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' },
lookml: { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' },
notion: { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' },
metabase: { field: 'sourceApiKeyRef', flag: '--source-api-key-ref' },
looker: { field: 'sourceClientSecretRef', flag: '--source-client-secret-ref' },
};
const ALL_SOURCE_CREDENTIAL_FLAGS: SourceCredentialFlag[] = [
{ field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' },
{ field: 'sourceApiKeyRef', flag: '--source-api-key-ref' },
{ field: 'sourceClientSecretRef', flag: '--source-client-secret-ref' },
];
// Reject a credential ref flag the chosen source does not read, so a wrong flag
// fails loudly instead of being silently dropped (KLO-724).
function assertSourceCredentialFlags(source: KtxSetupSourceType, args: KtxSetupSourcesArgs): void {
const allowed = SOURCE_CREDENTIAL_FLAG[source];
for (const { field, flag } of ALL_SOURCE_CREDENTIAL_FLAGS) {
if (args[field] && field !== allowed.field) {
throw new Error(`${flag} does not apply to --source ${source}; use ${allowed.flag}.`);
}
}
}
2026-05-10 16:12:51 -07:00
async function chooseSourceCredentialRef(input: {
prompts: KtxSetupSourcesPromptAdapter;
projectDir: string;
label: string;
envName: string;
secretFileName: string;
existingRef?: string;
2026-05-10 16:12:51 -07:00
}): Promise<string | 'back'> {
while (true) {
const choice = await input.prompts.select({
message: `How should ktx find your ${input.label}?`,
2026-05-10 16:12:51 -07:00
options: [
...(input.existingRef ? [{ value: 'keep', label: 'Keep existing credential' }] : []),
2026-05-10 16:12:51 -07:00
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
{ value: 'env', label: `Use ${input.envName} from the environment` },
2026-05-10 16:12:51 -07:00
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') return 'back';
if (choice === 'keep' && input.existingRef) return input.existingRef;
2026-05-10 16:12:51 -07:00
if (choice === 'paste') {
const value = await input.prompts.password({ message: input.label });
if (value === undefined) continue;
if (!value.trim()) continue;
2026-05-10 16:19:19 -07:00
const ref = await writeProjectLocalSecretReference({
2026-05-10 16:12:51 -07:00
projectDir: input.projectDir,
fileName: input.secretFileName,
value,
});
2026-05-10 16:19:19 -07:00
input.prompts.log?.(`Saved to .ktx/secrets/${input.secretFileName}`);
return ref;
2026-05-10 16:12:51 -07:00
}
return `env:${input.envName}`;
}
}
async function chooseGitAuthCredentialRef(input: {
prompts: KtxSetupSourcesPromptAdapter;
projectDir: string;
source: KtxSetupSourceType;
connectionId: string;
existingRef?: string;
repoUrl?: string;
testGitRepo?: (args: { repoUrl: string; authToken?: string | null }) => Promise<{ ok: true } | { ok: false; error: string }>;
2026-05-10 16:12:51 -07:00
}): Promise<string | undefined | 'back'> {
const label = input.source === 'dbt' ? 'This' : `This ${sourceLabel(input.source)}`;
while (true) {
const choice = await input.prompts.select({
message: `${label} repo requires authentication.`,
options: [
...(input.existingRef ? [{ value: 'keep', label: 'Keep existing credential' }] : []),
2026-05-10 16:12:51 -07:00
{ value: 'paste', label: 'Paste a token and save it as a local secret file' },
{ value: 'env', label: 'Use GITHUB_TOKEN from the environment' },
2026-05-10 16:12:51 -07:00
{ value: 'skip', label: 'Skip — try without authentication' },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') return 'back';
if (choice === 'keep' && input.existingRef) return input.existingRef;
2026-05-10 16:12:51 -07:00
if (choice === 'skip') return undefined;
if (choice === 'paste') {
const value = await input.prompts.password({ message: 'Git access token' });
if (value === undefined) continue;
if (!value.trim()) continue;
if (input.testGitRepo && input.repoUrl) {
const result = await input.testGitRepo({ repoUrl: input.repoUrl, authToken: value });
if (!result.ok) {
input.prompts.log?.(`Authentication failed: ${result.error}`);
continue;
}
}
2026-05-10 16:19:19 -07:00
const fileName = `${input.connectionId}-auth-token`;
const ref = await writeProjectLocalSecretReference({
2026-05-10 16:12:51 -07:00
projectDir: input.projectDir,
2026-05-10 16:19:19 -07:00
fileName,
2026-05-10 16:12:51 -07:00
value,
});
2026-05-10 16:19:19 -07:00
input.prompts.log?.(`Saved to .ktx/secrets/${fileName}`);
return ref;
2026-05-10 16:12:51 -07:00
}
return 'env:GITHUB_TOKEN';
}
}
2026-05-10 23:51:24 +02:00
function repoOrLocalSource(args: KtxSetupSourcesArgs): { sourceDir?: string; repoUrl?: string } {
2026-05-10 23:12:26 +02:00
if (args.sourcePath && args.sourceGitUrl) {
throw new Error('Choose only one source location: --source-path or --source-git-url.');
}
if (args.sourcePath) {
return { sourceDir: resolve(args.sourcePath) };
}
if (args.sourceGitUrl) {
return { repoUrl: args.sourceGitUrl };
}
throw new Error('Missing source location: pass --source-path or --source-git-url.');
}
function fileRepoUrl(sourceDir: string): string {
return pathToFileURL(sourceDir).toString();
}
2026-05-10 23:51:24 +02:00
async function writeProjectConfig(projectDir: string, config: KtxProjectConfig): Promise<void> {
const project = await loadKtxProject({ projectDir });
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
2026-05-10 23:12:26 +02:00
}
async function writeSourceConnection(
projectDir: string,
connectionId: string,
2026-05-10 23:51:24 +02:00
connection: KtxProjectConnectionConfig,
2026-05-10 23:12:26 +02:00
adapter: string,
feat(telemetry): anonymous posthog usage telemetry across node cli and python daemon (#205) * feat: add telemetry phase 1 * feat: add node telemetry event catalog * feat: add telemetry event helpers * feat: emit setup and connection telemetry * feat: emit connection and stack telemetry * feat: emit ingest and scan telemetry * feat: emit query telemetry * feat: emit sampled mcp telemetry * docs: expand telemetry event catalog * feat: add telemetry schema sync artifact * feat: pass telemetry project id to semantic daemon * feat: add daemon telemetry foundation * feat: emit semantic daemon telemetry * feat: emit daemon lifecycle telemetry * docs: document full telemetry event catalog * feat(telemetry): dim first-run notice * feat(telemetry): show first-run notice before command output * feat(telemetry): wire ktx PostHog project for live ingestion * docs(telemetry): drop posthog project name and host from storage section * docs(telemetry): trim to general overview and disclaimer * docs(agents): add short telemetry guidelines * feat(telemetry): enable posthog geoip enrichment * docs(telemetry): drop ip-geoip note from public overview * refactor(telemetry): drop no-op groupIdentify, rely on capture groups field * fix(telemetry): respect CI kill switch in python daemon identity * fix(sql): route table-count analysis to existing analyze-batch endpoint * fix(telemetry): emit install_first_run from notice path and derive flagsPresent from commander * fix(telemetry): read package info via getKtxCliPackageInfo to satisfy boundary check * fix(telemetry): make python identity env={} bypass os.environ and unset CI in tests * fix(telemetry): unset CI kill switch in cli-program-telemetry tests
2026-05-22 18:18:47 +02:00
io?: KtxCliIo,
2026-05-10 23:12:26 +02:00
): Promise<() => Promise<void>> {
assertSafeConnectionId(connectionId);
2026-05-10 23:51:24 +02:00
const project = await loadKtxProject({ projectDir });
2026-05-10 23:12:26 +02:00
const previousConnection = project.config.connections[connectionId];
const hadPreviousConnection = previousConnection !== undefined;
const shouldRemoveAdapterOnRollback = !project.config.ingest.adapters.includes(adapter);
const config = {
...project.config,
connections: {
...project.config.connections,
[connectionId]: connection,
},
ingest: {
...project.config.ingest,
adapters: project.config.ingest.adapters.includes(adapter)
? [...project.config.ingest.adapters]
: [...project.config.ingest.adapters, adapter],
},
};
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
feat(telemetry): anonymous posthog usage telemetry across node cli and python daemon (#205) * feat: add telemetry phase 1 * feat: add node telemetry event catalog * feat: add telemetry event helpers * feat: emit setup and connection telemetry * feat: emit connection and stack telemetry * feat: emit ingest and scan telemetry * feat: emit query telemetry * feat: emit sampled mcp telemetry * docs: expand telemetry event catalog * feat: add telemetry schema sync artifact * feat: pass telemetry project id to semantic daemon * feat: add daemon telemetry foundation * feat: emit semantic daemon telemetry * feat: emit daemon lifecycle telemetry * docs: document full telemetry event catalog * feat(telemetry): dim first-run notice * feat(telemetry): show first-run notice before command output * feat(telemetry): wire ktx PostHog project for live ingestion * docs(telemetry): drop posthog project name and host from storage section * docs(telemetry): trim to general overview and disclaimer * docs(agents): add short telemetry guidelines * feat(telemetry): enable posthog geoip enrichment * docs(telemetry): drop ip-geoip note from public overview * refactor(telemetry): drop no-op groupIdentify, rely on capture groups field * fix(telemetry): respect CI kill switch in python daemon identity * fix(sql): route table-count analysis to existing analyze-batch endpoint * fix(telemetry): emit install_first_run from notice path and derive flagsPresent from commander * fix(telemetry): read package info via getKtxCliPackageInfo to satisfy boundary check * fix(telemetry): make python identity env={} bypass os.environ and unset CI in tests * fix(telemetry): unset CI kill switch in cli-program-telemetry tests
2026-05-22 18:18:47 +02:00
if (io) {
await emitTelemetryEvent({
name: 'connection_added',
projectDir,
io,
fields: {
driver: String(connection.driver ?? adapter).toLowerCase(),
isDemoConnection: isDemoConnection(connectionId, connection),
},
});
}
2026-05-10 23:12:26 +02:00
return async () => {
2026-05-10 23:51:24 +02:00
const latest = await loadKtxProject({ projectDir });
2026-05-10 23:12:26 +02:00
const connections = { ...latest.config.connections };
if (hadPreviousConnection) {
connections[connectionId] = previousConnection;
} else {
delete connections[connectionId];
}
await writeProjectConfig(projectDir, {
...latest.config,
connections,
ingest: {
...latest.config.ingest,
adapters: shouldRemoveAdapterOnRollback
? latest.config.ingest.adapters.filter((candidate) => candidate !== adapter)
: latest.config.ingest.adapters,
},
});
};
}
2026-05-10 23:51:24 +02:00
async function ensureSourceAdapterEnabled(projectDir: string, source: KtxSetupSourceType): Promise<void> {
2026-05-10 23:12:26 +02:00
const adapter = sourceAdapter(source);
2026-05-10 23:51:24 +02:00
const project = await loadKtxProject({ projectDir });
2026-05-10 23:12:26 +02:00
if (project.config.ingest.adapters.includes(adapter)) {
return;
}
await writeProjectConfig(projectDir, {
...project.config,
ingest: {
...project.config.ingest,
adapters: [...project.config.ingest.adapters, adapter],
},
});
}
async function markSourcesComplete(projectDir: string): Promise<void> {
2026-05-10 23:51:24 +02:00
const project = await loadKtxProject({ projectDir });
await writeFile(project.configPath, serializeKtxProjectConfig(project.config), 'utf-8');
await markKtxSetupStateStepComplete(projectDir, 'sources');
2026-05-10 23:12:26 +02:00
}
2026-05-10 23:51:24 +02:00
function hasPrimarySource(config: KtxProjectConfig): boolean {
2026-05-10 23:12:26 +02:00
const setupPrimaryIds = config.setup?.database_connection_ids ?? [];
if (setupPrimaryIds.some((connectionId) => Object.hasOwn(config.connections, connectionId))) {
return true;
}
return Object.values(config.connections).some((connection) =>
PRIMARY_SOURCE_DRIVERS.has(String(connection.driver ?? '').toLowerCase()),
);
}
2026-05-10 23:51:24 +02:00
function buildDbtConnection(args: KtxSetupSourcesArgs): KtxProjectConnectionConfig {
2026-05-10 23:12:26 +02:00
const source = repoOrLocalSource(args);
return {
driver: 'dbt',
...(source.sourceDir ? { source_dir: source.sourceDir } : {}),
...(source.repoUrl ? { repo_url: source.repoUrl } : {}),
...(args.sourceBranch ? { branch: args.sourceBranch } : {}),
...(args.sourceSubpath ? { path: args.sourceSubpath } : {}),
...(args.sourceAuthTokenRef
? { auth_token_ref: credentialRef(args.sourceAuthTokenRef, 'dbt private repo access token') }
: {}),
...(args.sourceProfilesPath ? { profiles_path: resolve(args.sourceProfilesPath) } : {}),
...(args.sourceTarget ? { target: args.sourceTarget } : {}),
...(args.sourceProjectName ? { project_name: args.sourceProjectName } : {}),
};
}
2026-05-10 23:51:24 +02:00
function buildMetricflowConnection(args: KtxSetupSourcesArgs): KtxProjectConnectionConfig {
2026-05-10 23:12:26 +02:00
const source = repoOrLocalSource(args);
return {
driver: 'metricflow',
metricflow: {
repoUrl: source.repoUrl ?? fileRepoUrl(source.sourceDir ?? ''),
...(args.sourceBranch ? { branch: args.sourceBranch } : {}),
...(args.sourceSubpath ? { path: args.sourceSubpath } : {}),
...(args.sourceAuthTokenRef
? { auth_token_ref: credentialRef(args.sourceAuthTokenRef, 'MetricFlow auth token ref') }
: {}),
},
};
}
2026-05-10 23:51:24 +02:00
function buildMetabaseConnection(args: KtxSetupSourcesArgs): KtxProjectConnectionConfig {
2026-05-10 23:12:26 +02:00
if (!args.sourceUrl) {
throw new Error('Missing Metabase URL: pass --source-url.');
}
if (!args.sourceWarehouseConnectionId) {
throw new Error('Missing mapped warehouse: pass --source-warehouse-connection-id.');
}
if (!args.metabaseDatabaseId) {
throw new Error('Missing Metabase database id: pass --metabase-database-id.');
}
return {
driver: 'metabase',
api_url: args.sourceUrl,
api_key_ref: credentialRef(args.sourceApiKeyRef, 'Metabase API key ref'),
mappings: {
databaseMappings: { [String(args.metabaseDatabaseId)]: args.sourceWarehouseConnectionId },
syncEnabled: { [String(args.metabaseDatabaseId)]: true },
2026-05-10 23:13:17 -07:00
syncMode: 'ALL',
selections: { collections: [], items: [] },
defaultTagNames: [],
2026-05-10 23:12:26 +02:00
},
};
}
2026-05-10 23:51:24 +02:00
function buildLookerConnection(args: KtxSetupSourcesArgs): KtxProjectConnectionConfig {
2026-05-10 23:12:26 +02:00
if (!args.sourceUrl) {
throw new Error('Missing Looker base URL: pass --source-url.');
}
if (!args.sourceClientId) {
throw new Error('Missing Looker client id: pass --source-client-id.');
}
if (!args.sourceWarehouseConnectionId) {
throw new Error('Missing mapped warehouse: pass --source-warehouse-connection-id.');
}
return {
driver: 'looker',
base_url: args.sourceUrl,
client_id: args.sourceClientId,
client_secret_ref: credentialRef(args.sourceClientSecretRef, 'Looker client secret ref'),
mappings: {
connectionMappings: {
[args.sourceTarget ?? args.sourceWarehouseConnectionId]: args.sourceWarehouseConnectionId,
},
},
};
}
2026-05-10 23:51:24 +02:00
function buildLookmlConnection(args: KtxSetupSourcesArgs): KtxProjectConnectionConfig {
2026-05-10 23:12:26 +02:00
const source = repoOrLocalSource(args);
return {
driver: 'lookml',
repoUrl: source.repoUrl ?? fileRepoUrl(source.sourceDir ?? ''),
...(args.sourceBranch ? { branch: args.sourceBranch } : {}),
...(args.sourceSubpath ? { path: args.sourceSubpath } : {}),
...(args.sourceAuthTokenRef
? { auth_token_ref: credentialRef(args.sourceAuthTokenRef, 'LookML auth token ref') }
: {}),
mappings: {
expectedLookerConnectionName: args.sourceTarget ?? args.sourceWarehouseConnectionId ?? null,
},
};
}
2026-05-10 23:51:24 +02:00
function buildNotionConnection(args: KtxSetupSourcesArgs): KtxProjectConnectionConfig {
2026-05-10 23:12:26 +02:00
const rootPageIds = args.notionRootPageIds ?? [];
const crawlMode = rootPageIds.length > 0 ? 'selected_roots' : (args.notionCrawlMode ?? 'selected_roots');
2026-05-10 23:12:26 +02:00
if (crawlMode === 'selected_roots' && rootPageIds.length === 0) {
throw new Error('Notion selected_roots requires --notion-root-page-id.');
}
return {
driver: 'notion',
auth_token_ref: credentialRef(args.sourceAuthTokenRef, 'Notion token ref'),
2026-05-10 23:12:26 +02:00
crawl_mode: crawlMode,
...(rootPageIds.length > 0 ? { root_page_ids: rootPageIds } : {}),
2026-05-10 23:12:26 +02:00
root_database_ids: [],
root_data_source_ids: [],
max_pages_per_run: 1000,
max_knowledge_creates_per_run: DEFAULT_NOTION_MAX_KNOWLEDGE_CREATES_PER_RUN,
2026-05-10 23:12:26 +02:00
max_knowledge_updates_per_run: 20,
};
}
function sourcePathFromFileRepoUrl(repoUrl: string, subpath?: string): string {
const root = fileURLToPath(repoUrl);
return subpath ? join(root, subpath) : root;
}
2026-05-10 23:51:24 +02:00
function repoAuthToken(connection: KtxProjectConnectionConfig | Record<string, unknown>): string | null {
const ref = stringField(connection.auth_token_ref);
const literal = stringField(connection.auth_token);
2026-05-10 23:51:24 +02:00
return literal ?? resolveKtxConfigReference(ref, process.env) ?? null;
2026-05-10 23:12:26 +02:00
}
async function collectYamlFilesRecursive(sourceRoot: string): Promise<Array<{ content: string; path: string }>> {
const entries = await readdir(sourceRoot, { withFileTypes: true, recursive: true });
const files: Array<{ content: string; path: string }> = [];
for (const entry of entries) {
if (!entry.isFile() || !/\.ya?ml$/i.test(entry.name)) {
continue;
}
const path = join(entry.parentPath, entry.name);
files.push({ path, content: await readFile(path, 'utf-8') });
}
return files;
}
2026-05-10 23:51:24 +02:00
async function defaultValidateDbt(connection: KtxProjectConnectionConfig): Promise<SourceValidationResult> {
let sourceDir = stringField(connection.source_dir);
const repoUrl = stringField(connection.repo_url);
2026-05-10 23:12:26 +02:00
if (!sourceDir && repoUrl?.startsWith('file:')) {
sourceDir = sourcePathFromFileRepoUrl(repoUrl, stringField(connection.path));
}
if (!sourceDir && repoUrl) {
2026-05-10 23:51:24 +02:00
const cacheDir = await mkdtemp(join(tmpdir(), 'ktx-setup-dbt-'));
try {
await cloneOrPull({
repoUrl,
authToken: repoAuthToken(connection),
cacheDir,
branch: stringField(connection.branch) ?? 'main',
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return { ok: false, message: `Failed to clone ${repoUrl}: ${reason}` };
}
2026-05-10 23:12:26 +02:00
sourceDir = stringField(connection.path) ? join(cacheDir, String(connection.path)) : cacheDir;
}
if (!sourceDir) {
return { ok: false, message: 'dbt setup requires --source-path or --source-git-url.' };
}
const info = await loadProjectInfo(sourceDir);
const schemaFiles = await loadDbtSchemaFiles(sourceDir);
if (!info.projectName && typeof connection.project_name !== 'string') {
return { ok: false, message: 'dbt project metadata is missing project name.' };
}
return { ok: true, detail: `project=${info.projectName ?? connection.project_name} schemas=${schemaFiles.length}` };
}
2026-05-10 23:51:24 +02:00
async function defaultValidateMetricflow(connection: KtxProjectConnectionConfig): Promise<SourceValidationResult> {
2026-05-10 23:12:26 +02:00
const metricflow = isRecord(connection.metricflow) ? connection.metricflow : undefined;
const repoUrl = stringField(metricflow?.repoUrl);
if (!repoUrl) {
return { ok: false, message: 'MetricFlow setup requires repoUrl.' };
}
if (!repoUrl.startsWith('file:')) {
const result = await testRepoConnection({
repoUrl,
authToken: metricflow ? repoAuthToken(metricflow) : null,
});
if (!result.ok) {
return { ok: false, message: result.error };
}
return { ok: true, detail: 'repository reachable' };
}
const path = sourcePathFromFileRepoUrl(repoUrl, stringField(metricflow?.path));
const parsed = parseMetricflowFiles(await collectYamlFilesRecursive(path));
return {
ok: true,
detail: `semanticModels=${parsed.semanticModels.length} metrics=${parsed.crossModelMetrics.length}`,
};
}
async function defaultValidateLooker(projectDir: string, connectionId: string): Promise<SourceValidationResult> {
const code = await runKtxSourceMapping(
2026-05-10 23:12:26 +02:00
{ command: 'refresh', projectDir, connectionId, autoAccept: true },
{ stdout: { write() {} }, stderr: { write() {} } },
);
return code === 0
? { ok: true, detail: 'Looker mapping refreshed' }
: { ok: false, message: 'Looker validation failed' };
}
2026-05-10 23:51:24 +02:00
async function defaultValidateLookml(connection: KtxProjectConnectionConfig): Promise<SourceValidationResult> {
const repoUrl = stringField(connection.repoUrl);
2026-05-10 23:12:26 +02:00
if (!repoUrl) {
return { ok: false, message: 'LookML setup requires repoUrl.' };
}
if (!repoUrl.startsWith('file:')) {
const result = await testRepoConnection({ repoUrl, authToken: repoAuthToken(connection) });
return result.ok ? { ok: true, detail: 'repository reachable' } : { ok: false, message: result.error };
}
const parsed = await parseLookmlStagedDir(sourcePathFromFileRepoUrl(repoUrl, stringField(connection.path)));
const count = parsed.models.length + parsed.views.length + parsed.dashboards.length;
return count > 0 ? { ok: true, detail: `lookmlFiles=${count}` } : { ok: false, message: 'No LookML files found' };
}
2026-05-10 23:51:24 +02:00
async function defaultValidateNotion(connection: KtxProjectConnectionConfig): Promise<SourceValidationResult> {
const token = await resolveNotionConnectionAuthToken({
auth_token: stringField(connection.auth_token) ?? null,
auth_token_ref: stringField(connection.auth_token_ref) ?? null,
});
2026-05-10 23:12:26 +02:00
const client: NotionApi = new NotionClient(token);
await client.retrieveBotUser();
const roots = Array.isArray(connection.root_page_ids)
? connection.root_page_ids.filter((id): id is string => typeof id === 'string')
: [];
for (const root of roots) {
await client.retrievePage(root);
}
return { ok: true, detail: `roots=${roots.length}` };
}
2026-05-10 16:19:19 -07:00
interface MappingJsonOutput {
connectionId: string;
refresh: { ok: boolean; output: string[] };
validation: { ok: boolean; output: string[] };
mappings: unknown[];
}
function splitOutputLines(output: string): string[] {
return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function writeSetupPrefixedLines(write: (chunk: string) => void, output: string): void {
for (const line of output.split(/\r?\n/)) {
if (line.length > 0) {
write(`${line}\n`);
}
}
}
function createSetupPrefixedIo(io: KtxCliIo): KtxCliIo {
return {
stdout: {
isTTY: io.stdout.isTTY,
columns: io.stdout.columns,
write(chunk: string) {
writeSetupPrefixedLines((line) => io.stdout.write(line), chunk);
},
},
stderr: {
write(chunk: string) {
writeSetupPrefixedLines((line) => io.stderr.write(line), chunk);
},
},
};
}
function parseMappingListJson(output: string): unknown[] {
const trimmed = output.trim();
if (!trimmed) {
return [];
}
const parsed = JSON.parse(trimmed) as unknown;
return Array.isArray(parsed) ? parsed : [];
}
2026-05-10 16:19:19 -07:00
function summarizeMappingResult(parsed: MappingJsonOutput): string {
const mappingCount = parsed.mappings.length;
const mappingNoun = mappingCount === 1 ? 'mapping' : 'mappings';
return `Mapping validated — ${mappingCount} ${mappingNoun} configured`;
}
2026-05-10 23:51:24 +02:00
async function defaultRunMapping(projectDir: string, connectionId: string, io: KtxCliIo): Promise<number> {
const outputs = {
refresh: '',
validation: '',
list: '',
2026-05-10 16:19:19 -07:00
};
const refreshCode = await runKtxSourceMapping(
{ command: 'refresh', projectDir, connectionId, autoAccept: true },
{
stdout: { write(chunk: string) { outputs.refresh += chunk; } },
stderr: io.stderr,
},
2026-05-10 16:19:19 -07:00
);
if (refreshCode !== 0) {
return refreshCode;
2026-05-10 16:19:19 -07:00
}
const validationCode = await runKtxSourceMapping(
{ command: 'validate', projectDir, connectionId },
{
stdout: { write(chunk: string) { outputs.validation += chunk; } },
stderr: io.stderr,
},
);
if (validationCode !== 0) {
return validationCode;
}
const listCode = await runKtxSourceMapping(
{ command: 'list', projectDir, connectionId, json: true },
{
stdout: { write(chunk: string) { outputs.list += chunk; } },
stderr: io.stderr,
},
);
if (listCode !== 0) {
return listCode;
}
const parsed: MappingJsonOutput = {
connectionId,
refresh: { ok: true, output: splitOutputLines(outputs.refresh) },
validation: { ok: true, output: splitOutputLines(outputs.validation) },
mappings: parseMappingListJson(outputs.list),
};
io.stdout.write(`${summarizeMappingResult(parsed)}\n`);
2026-05-10 16:19:19 -07:00
return 0;
2026-05-10 23:12:26 +02:00
}
async function defaultRunInitialIngest(
projectDir: string,
connectionId: string,
2026-05-10 23:51:24 +02:00
io: KtxCliIo,
options: { inputMode: KtxSetupSourcesArgs['inputMode'] },
2026-05-10 23:12:26 +02:00
): Promise<number> {
2026-05-10 23:51:24 +02:00
return await runKtxPublicIngest(
2026-05-10 23:12:26 +02:00
{
command: 'run',
projectDir,
targetConnectionId: connectionId,
all: false,
json: false,
inputMode: options.inputMode,
},
io,
);
}
async function runInitialSourceIngestWithRecovery(input: {
2026-05-10 23:51:24 +02:00
args: KtxSetupSourcesArgs;
2026-05-10 23:12:26 +02:00
connectionId: string;
2026-05-10 23:51:24 +02:00
io: KtxCliIo;
prompts: KtxSetupSourcesPromptAdapter;
deps: KtxSetupSourcesDeps;
2026-05-10 23:12:26 +02:00
}): Promise<'ready' | 'continue' | 'back' | 'failed'> {
while (true) {
input.io.stdout.write(`│ Building context from ${input.connectionId}. Large sources can take a while.\n`);
2026-05-10 23:12:26 +02:00
const ingestCode = await (input.deps.runInitialIngest ?? defaultRunInitialIngest)(
input.args.projectDir,
input.connectionId,
input.io,
{
inputMode: input.args.inputMode,
},
);
if (ingestCode === 0) {
return 'ready';
}
if (input.args.inputMode === 'disabled') {
return 'failed';
}
const action = await input.prompts.select({
message: `Context build failed for ${input.connectionId}\nRetry now, continue setup and build this source later, or go back.`,
options: [
{ value: 'retry', label: 'Retry context build' },
{ value: 'continue', label: 'Continue setup and build this source later' },
{ value: 'back', label: 'Back' },
],
});
if (action === 'retry') {
continue;
}
if (action === 'continue') {
input.io.stdout.write(`│ Context source saved without a completed context build for ${input.connectionId}.\n`);
feat: merge ingest and scan * docs: add CLI component reuse guidance * docs: add unified ingest ux design * Refine unified ingest UX design after adversarial review iteration 1 * Refine unified ingest UX design after adversarial review iteration 2 * Refine unified ingest UX design after adversarial review iteration 3 * feat(cli): route public connection ingest command * feat(cli): hide standalone scan from public help * feat(cli): plan public ingest depth and query history * feat(cli): execute public database ingest facets * feat(ingest): read connection query history config * fix(cli): use public ingest wording * fix(config): stop generating ingest adapter allow lists * docs: document public ingest command * test: align ingest surface expectations * docs: add unified ingest public CLI surface plan * feat(cli): preflight deep public ingest readiness * feat(setup): store query history in connection context * feat(setup): store database context depth * feat(setup): verify context readiness by database depth * fix(setup): keep context build foreground only * fix(config): reject reserved ingest connection ids * test: close unified ingest v1 expectations * docs: add unified ingest v1 closure plan * fix(ingest): bypass adapter allow-list for public source ingest * fix(ingest): honor query history window intent * fix(ingest): hide scan internals from public database ingest * feat(ingest): use foreground view for interactive public ingest * fix(setup): use schema context and query history wording * test(cli): verify unified ingest public output * docs: add unified ingest v1 public output closure plan * fix(setup): forward query history flags * fix(setup): prompt for postgres query history * fix(status): report query history readiness * fix(ingest): remove legacy public guidance * fix(ingest): polish foreground retry copy * docs(examples): use unified query history wording * chore(ingest): finish public query history cleanup * docs: add unified ingest v1 query history status cleanup plan * test(docs): cover unified ingest public docs * docs: align ingest CLI reference with unified UX * docs: update context build guides for unified ingest * docs: update setup and primary source ingest wording * docs: stop advertising adapter-backed example ingest * docs: close unified ingest public docs gaps * docs: add unified ingest v1 docs site closure plan * fix: render unified ingest foreground warnings * fix: explain query history schema order * fix: add public ingest retry guidance * fix: align setup next steps with unified ingest * fix: remove scan wording from demo progress * test: verify unified ingest ux closure * docs: add unified ingest v1 foreground and retry closure plan * fix(cli): preserve query-history pull config in public ingest * fix(cli): omit hidden commands from docs command tree * test(cli): close unified ingest final public surface checks * docs: add unified ingest v1 final public surface closure plan * fix(cli): use public source labels in ingest reports * fix(cli): suppress low-level public ingest output * test(cli): verify unified ingest public plain output * docs: add unified ingest v1 public plain output closure plan * fix(cli): add public ingest copy sanitizers * fix(cli): sanitize public ingest progress copy * fix(cli): rename setup schema scope prompt * docs(plan): add progress copy closure; test: align setup back-nav fixture Adds the iter9 plan and updates the setup back-navigation test fixture to pass disableQueryHistory plus listSchemas/listTables stubs that the unified ingest setup step now requires. * docs(plan): add final ux labels plan with narrowed label scans * fix(cli): aggregate unsupported query-history warnings * fix(cli): align setup database labels * test(cli): fix setup database test type-check * fix(cli): remove primary-source wording from setup output * test(cli): verify unified ingest setup closure * docs(plan): add unified ingest v1 verification copy closure plan * fix(cli): remove top-level scan command * fix(cli): remove legacy ingest and wiki commands * Merge scan into ingest flow * feat(cli): split ingest progress into per-phase rows, rename work units to tasks Each database target in the unified ingest dashboard now renders one row per real subprocess (Schema, then Query history when enabled) instead of a single combined bar. Each phase has its own monotonic 0-100% bar so the progress never snaps back to zero when historic-sql starts after scan completes. Completed phases keep their final bar, summary, and elapsed time visible as an inline audit trail; queued and skipped phases are shown explicitly. Also rename user-facing "work units" / "Failed work units" to "tasks" / "Failed tasks" in ingest output and parseIngestSummary. The parser still accepts the legacy "Work units:" wording in captured output for backward compat. Internal memory-flow event names and type fields are left alone. * Fix test harness failures * Fix CI smoke checks --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-14 01:43:06 +02:00
input.io.stdout.write(`│ Run later: ktx ingest ${input.connectionId}\n`);
2026-05-10 23:12:26 +02:00
return 'continue';
}
return 'back';
}
}
type SourceLocationChoice = 'path' | 'git';
2026-05-10 23:51:24 +02:00
type SourcePromptState = KtxSetupSourcesArgs & {
2026-05-10 23:12:26 +02:00
sourceLocation?: SourceLocationChoice;
};
type SourcePromptStep = (state: SourcePromptState) => Promise<'next' | 'back'>;
2026-05-10 16:12:51 -07:00
interface WarehouseConnectionChoice {
id: string;
connectionType: string;
}
2026-05-10 23:12:26 +02:00
type InteractiveSourceConnectionChoice =
2026-05-10 23:51:24 +02:00
| { kind: 'existing'; connectionId: string; connection: KtxProjectConnectionConfig }
| { kind: 'new'; args: KtxSetupSourcesArgs }
| { kind: 'edited'; connectionId: string; args: KtxSetupSourcesArgs }
2026-05-10 23:12:26 +02:00
| 'back';
type SourceSetupChoiceResult =
| { status: 'ready'; connectionId: string }
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
| { status: Exclude<RecoveryOutcome, 'ready'> };
2026-05-10 23:12:26 +02:00
async function runSourcePromptSteps(
initialState: SourcePromptState,
stepsForState: (state: SourcePromptState) => SourcePromptStep[],
2026-05-10 23:51:24 +02:00
): Promise<KtxSetupSourcesArgs | 'back'> {
2026-05-10 23:12:26 +02:00
let stepIndex = 0;
while (true) {
const steps = stepsForState(initialState);
if (stepIndex >= steps.length) {
const { sourceLocation: _sourceLocation, ...sourceArgs } = initialState;
return sourceArgs;
}
const result = await steps[stepIndex]?.(initialState);
if (result === 'back') {
if (stepIndex === 0) {
return 'back';
}
stepIndex -= 1;
continue;
}
stepIndex += 1;
}
}
function resetRepoLocationFields(state: SourcePromptState): void {
delete state.sourcePath;
delete state.sourceGitUrl;
delete state.sourceBranch;
delete state.sourceAuthTokenRef;
delete state.sourceSubpath;
delete state.sourceProjectName;
}
function sourceLocationFromArgs(args: KtxSetupSourcesArgs): SourceLocationChoice | undefined {
if (args.sourcePath) return 'path';
if (args.sourceGitUrl) return 'git';
return undefined;
}
2026-05-10 16:12:51 -07:00
function warehouseConnectionChoices(config: KtxProjectConfig): WarehouseConnectionChoice[] {
return Object.entries(config.connections)
.filter(([, connection]) => PRIMARY_SOURCE_DRIVERS.has(String(connection.driver ?? '').toLowerCase()))
.map(([id, connection]) => ({ id, connectionType: localConnectionTypeForConfig(id, connection) }))
.sort((left, right) => left.id.localeCompare(right.id));
}
async function chooseMappedWarehouseConnectionId(input: {
projectDir: string;
prompts: KtxSetupSourcesPromptAdapter;
}): Promise<string | 'back'> {
const project = await loadKtxProject({ projectDir: input.projectDir });
const choices = warehouseConnectionChoices(project.config);
if (choices.length === 1) {
return choices[0].id;
}
if (choices.length === 0) {
const entered = await promptText(input.prompts, { message: 'Mapped warehouse connection id' });
return entered === undefined ? 'back' : entered;
}
const selected = await input.prompts.select({
message: 'Mapped warehouse connection',
options: [
...choices.map((choice) => ({
value: choice.id,
label: `${choice.id} (${choice.connectionType})`,
})),
{ value: 'back', label: 'Back' },
],
});
return selected === 'back' ? 'back' : selected;
}
async function defaultDiscoverMetabaseDatabases(input: {
sourceUrl: string;
sourceApiKeyRef: string;
}): Promise<DiscoveredMetabaseDatabase[]> {
const apiKey = resolveKtxConfigReference(input.sourceApiKeyRef, process.env);
if (!apiKey) {
throw new Error('Metabase API key ref could not be resolved');
}
const client = new MetabaseClient(
{ apiUrl: input.sourceUrl, apiKey },
DEFAULT_METABASE_CLIENT_CONFIG,
);
try {
return await discoverMetabaseDatabases(client);
} finally {
await client.cleanup();
}
}
function metabaseDatabaseLabel(database: DiscoveredMetabaseDatabase): string {
const detail = [database.engine].filter(Boolean).join(', ');
return detail ? `${database.id}: ${database.name} (${detail})` : `${database.id}: ${database.name}`;
}
async function chooseMetabaseDatabaseId(input: {
state: SourcePromptState;
prompts: KtxSetupSourcesPromptAdapter;
deps: KtxSetupSourcesDeps;
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
io: KtxCliIo;
2026-05-10 16:12:51 -07:00
}): Promise<number | 'back'> {
const sourceUrl = input.state.sourceUrl;
const sourceApiKeyRef = input.state.sourceApiKeyRef;
if (sourceUrl && sourceApiKeyRef) {
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
const discoverSpinner = createCliSpinner(input.io);
discoverSpinner.start('Discovering Metabase databases…');
2026-05-10 16:12:51 -07:00
try {
const discovered = await (input.deps.discoverMetabaseDatabases ?? defaultDiscoverMetabaseDatabases)({
sourceUrl,
sourceApiKeyRef,
sourceConnectionId: input.state.sourceConnectionId ?? 'metabase-main',
});
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
discoverSpinner.stop(`Found ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}`);
2026-05-10 16:12:51 -07:00
if (discovered.length === 1) {
return discovered[0].id;
}
if (discovered.length > 1) {
const selected = await input.prompts.autocomplete({
2026-05-10 16:12:51 -07:00
message: 'Metabase database',
placeholder: 'Type to search databases',
2026-05-10 16:12:51 -07:00
options: [
...discovered
.slice()
.sort((left, right) => left.id - right.id)
.map((database) => ({
value: String(database.id),
label: metabaseDatabaseLabel(database),
})),
{ value: 'back', label: 'Back' },
],
});
return selected === 'back' ? 'back' : Number.parseInt(selected, 10);
}
} catch {
// Discovery is a convenience. Fall back to the raw id prompt when credentials
// are unavailable locally or the Metabase API cannot be reached yet.
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
discoverSpinner.error('Could not reach Metabase — enter the database id manually');
2026-05-10 16:12:51 -07:00
}
}
const databaseId = await promptText(input.prompts, { message: 'Metabase database id' });
return databaseId === undefined ? 'back' : Number.parseInt(databaseId, 10);
}
2026-05-10 23:12:26 +02:00
function connectionIdPromptSteps(
2026-05-10 23:51:24 +02:00
args: KtxSetupSourcesArgs,
source: KtxSetupSourceType,
prompts: KtxSetupSourcesPromptAdapter,
2026-05-10 23:12:26 +02:00
defaultConnectionId: string,
): SourcePromptStep[] {
if (args.sourceConnectionId) {
return [];
}
return [
async (state) => {
const enteredConnectionId = await promptText(prompts, {
message: connectionNamePrompt(sourceLabel(source)),
placeholder: defaultConnectionId,
initialValue: defaultConnectionId,
});
if (enteredConnectionId === undefined) {
return 'back';
}
state.sourceConnectionId = enteredConnectionId.trim() || defaultConnectionId;
return 'next';
},
];
}
async function promptForInteractiveSource(
2026-05-10 23:51:24 +02:00
args: KtxSetupSourcesArgs,
source: KtxSetupSourceType,
prompts: KtxSetupSourcesPromptAdapter,
io: KtxCliIo,
deps: KtxSetupSourcesDeps,
2026-05-10 23:12:26 +02:00
defaultConnectionId = `${source}-main`,
2026-05-10 23:51:24 +02:00
testGitRepo: KtxSetupSourcesDeps['testGitRepo'] = testRepoConnection,
2026-05-10 16:12:51 -07:00
discoverMetabaseDatabaseList?: KtxSetupSourcesDeps['discoverMetabaseDatabases'],
2026-05-10 23:51:24 +02:00
): Promise<KtxSetupSourcesArgs | 'back'> {
const initialState: SourcePromptState = { ...args, source, sourceLocation: sourceLocationFromArgs(args) };
2026-05-10 23:12:26 +02:00
if (args.sourceConnectionId) {
initialState.sourceConnectionId = args.sourceConnectionId;
}
const connectionSteps = connectionIdPromptSteps(args, source, prompts, defaultConnectionId);
if (source === 'dbt' || source === 'metricflow' || source === 'lookml') {
return await runSourcePromptSteps(initialState, (state) => [
...connectionSteps,
async () => {
const selectedLocation = await prompts.select({
message: `${source} source location`,
options: [
{ value: 'git', label: 'Git URL' },
{ value: 'path', label: 'Local path' },
2026-05-10 23:12:26 +02:00
{ value: 'back', label: 'Back' },
],
});
if (selectedLocation !== 'path' && selectedLocation !== 'git') {
return 'back';
}
if (state.sourceLocation !== selectedLocation) {
resetRepoLocationFields(state);
}
state.sourceLocation = selectedLocation;
return 'next';
},
...(state.sourceLocation === 'path'
? [
async (currentState: SourcePromptState) => {
const sourcePath = await promptText(prompts, {
message: `${source} local path`,
...(currentState.sourcePath ? { initialValue: currentState.sourcePath } : {}),
});
2026-05-10 23:12:26 +02:00
if (sourcePath === undefined) return 'back';
currentState.sourcePath = sourcePath;
return 'next';
},
]
: []),
...(state.sourceLocation === 'git'
? [
async (currentState: SourcePromptState) => {
const sourceGitUrl = await promptText(prompts, {
message: `${source} git URL`,
...(currentState.sourceGitUrl ? { initialValue: currentState.sourceGitUrl } : {}),
});
2026-05-10 23:12:26 +02:00
if (sourceGitUrl === undefined) return 'back';
currentState.sourceGitUrl = sourceGitUrl;
return 'next';
},
async (currentState: SourcePromptState) => {
const branch = await promptText(prompts, {
message: `${source} git branch`,
initialValue: currentState.sourceBranch ?? 'main',
});
2026-05-10 23:12:26 +02:00
if (branch === undefined) return 'back';
currentState.sourceBranch = branch || 'main';
return 'next';
},
]
: []),
...(state.sourceLocation === 'git'
? [
async (currentState: SourcePromptState) => {
const result = await testGitRepo!({ repoUrl: currentState.sourceGitUrl! });
if (result.ok) {
delete currentState.sourceAuthTokenRef;
prompts.log?.('Repository connected.');
return 'next';
}
2026-05-10 16:12:51 -07:00
const authRef = await chooseGitAuthCredentialRef({
prompts,
projectDir: args.projectDir,
source,
connectionId: currentState.sourceConnectionId ?? `${source}-main`,
existingRef: currentState.sourceAuthTokenRef,
repoUrl: currentState.sourceGitUrl,
testGitRepo,
2026-05-10 23:12:26 +02:00
});
2026-05-10 16:12:51 -07:00
if (authRef === 'back') return 'back';
2026-05-10 23:12:26 +02:00
if (authRef) {
currentState.sourceAuthTokenRef = authRef;
} else {
delete currentState.sourceAuthTokenRef;
}
return 'next';
},
]
: []),
2026-05-10 16:12:51 -07:00
...(state.sourceLocation
? [
async (currentState: SourcePromptState) => {
if (source === 'dbt') {
let scanDir: string | undefined;
if (currentState.sourceLocation === 'path' && currentState.sourcePath) {
scanDir = currentState.sourcePath;
} else if (currentState.sourceLocation === 'git' && currentState.sourceGitUrl) {
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
const cloneSpinner = createCliSpinner(io);
cloneSpinner.start('Cloning repository to scan for dbt projects…');
2026-05-10 16:12:51 -07:00
try {
const cacheDir = await mkdtemp(join(tmpdir(), 'ktx-setup-dbt-scan-'));
const authToken = currentState.sourceAuthTokenRef
? resolveKtxConfigReference(currentState.sourceAuthTokenRef, process.env)
: null;
await cloneOrPull({
repoUrl: currentState.sourceGitUrl,
authToken,
cacheDir,
branch: currentState.sourceBranch ?? 'main',
});
scanDir = cacheDir;
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
cloneSpinner.stop('Repository cloned');
2026-05-10 16:12:51 -07:00
} catch {
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
cloneSpinner.error('Could not clone repository');
2026-05-10 16:12:51 -07:00
// Clone failed — fall through to manual prompt
}
}
if (scanDir) {
try {
const subpaths = await findDbtProjectSubpaths(scanDir);
if (subpaths.length === 1) {
const found = subpaths[0]!;
if (found) {
currentState.sourceSubpath = found;
prompts.log?.(`Found dbt_project.yml in ${found}/`);
} else {
delete currentState.sourceSubpath;
}
return 'next';
}
if (subpaths.length > 1) {
const selected = await prompts.select({
message: 'Multiple dbt projects found — which one should ktx use?',
2026-05-10 16:12:51 -07:00
options: [
...subpaths.map((p) => ({ value: p || '.', label: p || '(project root)' })),
{ value: 'back', label: 'Back' },
],
});
if (selected === 'back') return 'back';
const subpath = selected === '.' ? '' : selected;
if (subpath) {
currentState.sourceSubpath = subpath;
} else {
delete currentState.sourceSubpath;
}
return 'next';
}
} catch {
// Directory unreadable — fall through to manual prompt
}
}
}
const subpath = await promptText(prompts, {
message: sourceSubpathPrompt(source),
placeholder: 'optional',
...(currentState.sourceSubpath ? { initialValue: currentState.sourceSubpath } : {}),
2026-05-10 16:12:51 -07:00
});
if (subpath === undefined) return 'back';
if (subpath) {
currentState.sourceSubpath = subpath;
} else {
delete currentState.sourceSubpath;
}
return 'next';
},
]
: []),
2026-05-10 23:12:26 +02:00
]);
}
if (source === 'metabase') {
return await runSourcePromptSteps(initialState, () => [
...connectionSteps,
async (state) => {
const sourceUrl = await promptText(prompts, {
message: 'Metabase URL',
...(state.sourceUrl ? { initialValue: state.sourceUrl } : {}),
});
2026-05-10 23:12:26 +02:00
if (sourceUrl === undefined) return 'back';
state.sourceUrl = sourceUrl;
return 'next';
},
async (state) => {
2026-05-10 16:12:51 -07:00
const ref = await chooseSourceCredentialRef({
prompts,
projectDir: args.projectDir,
label: 'Metabase API key',
envName: 'METABASE_API_KEY',
secretFileName: `${state.sourceConnectionId ?? 'metabase-main'}-api-key`,
existingRef: state.sourceApiKeyRef,
2026-05-10 23:12:26 +02:00
});
2026-05-10 16:12:51 -07:00
if (ref === 'back') return 'back';
state.sourceApiKeyRef = ref;
2026-05-10 23:12:26 +02:00
return 'next';
},
async (state) => {
2026-05-10 16:12:51 -07:00
const sourceWarehouseConnectionId = await chooseMappedWarehouseConnectionId({
projectDir: args.projectDir,
prompts,
});
if (sourceWarehouseConnectionId === 'back') return 'back';
2026-05-10 23:12:26 +02:00
state.sourceWarehouseConnectionId = sourceWarehouseConnectionId;
return 'next';
},
async (state) => {
2026-05-10 16:12:51 -07:00
const databaseId = await chooseMetabaseDatabaseId({
state,
prompts,
deps: { discoverMetabaseDatabases: discoverMetabaseDatabaseList },
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
io,
2026-05-10 16:12:51 -07:00
});
if (databaseId === 'back') return 'back';
state.metabaseDatabaseId = databaseId;
2026-05-10 23:12:26 +02:00
return 'next';
},
]);
}
if (source === 'looker') {
return await runSourcePromptSteps(initialState, () => [
...connectionSteps,
async (state) => {
const sourceUrl = await promptText(prompts, {
message: 'Looker base URL',
...(state.sourceUrl ? { initialValue: state.sourceUrl } : {}),
});
2026-05-10 23:12:26 +02:00
if (sourceUrl === undefined) return 'back';
state.sourceUrl = sourceUrl;
return 'next';
},
async (state) => {
const sourceClientId = await promptText(prompts, {
message: 'Looker client id',
...(state.sourceClientId ? { initialValue: state.sourceClientId } : {}),
});
2026-05-10 23:12:26 +02:00
if (sourceClientId === undefined) return 'back';
state.sourceClientId = sourceClientId;
return 'next';
},
async (state) => {
2026-05-10 16:12:51 -07:00
const ref = await chooseSourceCredentialRef({
prompts,
projectDir: args.projectDir,
label: 'Looker client secret',
envName: 'LOOKER_CLIENT_SECRET',
secretFileName: `${state.sourceConnectionId ?? 'looker-main'}-client-secret`,
existingRef: state.sourceClientSecretRef,
2026-05-10 23:12:26 +02:00
});
2026-05-10 16:12:51 -07:00
if (ref === 'back') return 'back';
state.sourceClientSecretRef = ref;
2026-05-10 23:12:26 +02:00
return 'next';
},
async (state) => {
2026-05-10 16:12:51 -07:00
const sourceWarehouseConnectionId = await chooseMappedWarehouseConnectionId({
projectDir: args.projectDir,
prompts,
});
if (sourceWarehouseConnectionId === 'back') return 'back';
2026-05-10 23:12:26 +02:00
state.sourceWarehouseConnectionId = sourceWarehouseConnectionId;
return 'next';
},
async (state) => {
const lookerConnectionName = await promptText(prompts, {
message: 'Looker connection name',
placeholder: 'optional',
...(state.sourceTarget ? { initialValue: state.sourceTarget } : {}),
2026-05-10 23:12:26 +02:00
});
if (lookerConnectionName === undefined) return 'back';
if (lookerConnectionName) {
state.sourceTarget = lookerConnectionName;
} else {
delete state.sourceTarget;
}
return 'next';
},
]);
}
return await runSourcePromptSteps(initialState, (state) => [
...connectionSteps,
async (currentState) => {
2026-05-10 16:12:51 -07:00
const ref = await chooseSourceCredentialRef({
prompts,
projectDir: args.projectDir,
label: 'Notion integration token',
envName: 'NOTION_TOKEN',
secretFileName: `${currentState.sourceConnectionId ?? 'notion-main'}-token`,
existingRef: currentState.sourceAuthTokenRef,
2026-05-10 23:12:26 +02:00
});
2026-05-10 16:12:51 -07:00
if (ref === 'back') return 'back';
currentState.sourceAuthTokenRef = ref;
2026-05-10 23:12:26 +02:00
return 'next';
},
async (currentState) => {
const crawlMode = await prompts.select({
message: 'Which Notion pages should ktx ingest?',
2026-05-10 23:12:26 +02:00
options: [
{ value: 'all_accessible', label: 'All pages the integration can access' },
{ value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' },
2026-05-10 23:12:26 +02:00
{ value: 'back', label: 'Back' },
],
});
if (crawlMode === 'back') return 'back';
currentState.notionCrawlMode = crawlMode === 'all_accessible' ? 'all_accessible' : 'selected_roots';
if (currentState.notionCrawlMode === 'all_accessible') {
delete currentState.notionRootPageIds;
}
return 'next';
},
...(state.notionCrawlMode === 'selected_roots'
? [
async (currentState: SourcePromptState) => {
const connectionId = currentState.sourceConnectionId ?? 'notion-main';
const result = await (deps.pickNotionRootPages ?? pickNotionRootPages)(
{
connectionId,
connection: {
driver: 'notion',
auth_token_ref: credentialRef(currentState.sourceAuthTokenRef, 'Notion token ref'),
crawl_mode: 'selected_roots',
root_page_ids: currentState.notionRootPageIds ?? [],
root_database_ids: [],
root_data_source_ids: [],
},
},
io,
);
if (result.kind === 'back') {
return 'back';
}
if (result.kind === 'unavailable') {
io.stderr.write(`${result.message}\n`);
return 'back';
}
currentState.notionRootPageIds = result.rootPageIds;
2026-05-10 23:12:26 +02:00
return 'next';
},
]
: []),
]);
}
function existingConnectionIdsBySource(
2026-05-10 23:51:24 +02:00
connections: Record<string, KtxProjectConnectionConfig>,
source: KtxSetupSourceType,
2026-05-10 23:12:26 +02:00
): string[] {
return Object.entries(connections)
.filter(([, connection]) => String(connection.driver ?? '').toLowerCase() === source)
.map(([connectionId]) => connectionId)
.sort((left, right) => left.localeCompare(right));
}
function sourceTypeForConnection(connection: KtxProjectConnectionConfig): KtxSetupSourceType | null {
const driver = String(connection.driver ?? '').toLowerCase();
return SOURCE_OPTIONS.some((option) => option.value === driver) ? (driver as KtxSetupSourceType) : null;
}
function contextSourceEditTargets(connections: Record<string, KtxProjectConnectionConfig>): Array<{
connectionId: string;
source: KtxSetupSourceType;
}> {
return Object.entries(connections)
.map(([connectionId, connection]) => {
const source = sourceTypeForConnection(connection);
return source ? { connectionId, source } : null;
})
.filter((target): target is { connectionId: string; source: KtxSetupSourceType } => target !== null)
.sort((left, right) => left.connectionId.localeCompare(right.connectionId));
}
function sourceChecklistForConnections(connections: Record<string, KtxProjectConnectionConfig>): {
options: Array<{ value: KtxSetupSourceType; label: string; hint?: string }>;
initialValues: KtxSetupSourceType[];
} {
const initialValues: KtxSetupSourceType[] = [];
const options = SOURCE_OPTIONS.map((option) => {
const existingIds = existingConnectionIdsBySource(connections, option.value);
if (existingIds.length === 0) {
return option;
}
initialValues.push(option.value);
return { ...option, hint: `configured: ${existingIds.join(', ')}` };
});
return { options, initialValues };
}
2026-05-10 23:12:26 +02:00
function defaultConnectionIdForSource(
2026-05-10 23:51:24 +02:00
connections: Record<string, KtxProjectConnectionConfig>,
source: KtxSetupSourceType,
2026-05-10 23:12:26 +02:00
): string {
const base = `${source}-main`;
if (!connections[base]) {
return base;
}
let index = 2;
while (connections[`${base}-${index}`]) {
index += 1;
}
return `${base}-${index}`;
}
function firstStringRecordEntry(value: unknown): [string, string] | undefined {
if (!isRecord(value)) return undefined;
for (const [key, raw] of Object.entries(value)) {
if (typeof raw === 'string' && raw.trim().length > 0) {
return [key, raw.trim()];
}
}
return undefined;
}
function applyRepoSourceArgs(
args: KtxSetupSourcesArgs,
input: { repoUrl?: string; sourceDir?: string; branch?: string; subpath?: string; authTokenRef?: string },
): void {
if (input.sourceDir) {
args.sourcePath = input.sourceDir;
} else if (input.repoUrl?.startsWith('file:')) {
args.sourcePath = fileURLToPath(input.repoUrl);
} else if (input.repoUrl) {
args.sourceGitUrl = input.repoUrl;
}
if (input.branch) args.sourceBranch = input.branch;
if (input.subpath) args.sourceSubpath = input.subpath;
if (input.authTokenRef) args.sourceAuthTokenRef = input.authTokenRef;
}
function sourceArgsFromExistingConnection(input: {
args: KtxSetupSourcesArgs;
source: KtxSetupSourceType;
connectionId: string;
connection: KtxProjectConnectionConfig;
}): KtxSetupSourcesArgs {
const sourceArgs: KtxSetupSourcesArgs = {
projectDir: input.args.projectDir,
inputMode: input.args.inputMode,
source: input.source,
sourceConnectionId: input.connectionId,
runInitialSourceIngest: input.args.runInitialSourceIngest,
skipSources: input.args.skipSources,
};
if (input.source === 'dbt') {
applyRepoSourceArgs(sourceArgs, {
sourceDir: stringField(input.connection.source_dir),
repoUrl: stringField(input.connection.repo_url),
branch: stringField(input.connection.branch),
subpath: stringField(input.connection.path),
authTokenRef: stringField(input.connection.auth_token_ref),
});
const profilesPath = stringField(input.connection.profiles_path);
const target = stringField(input.connection.target);
const projectName = stringField(input.connection.project_name);
if (profilesPath) sourceArgs.sourceProfilesPath = profilesPath;
if (target) sourceArgs.sourceTarget = target;
if (projectName) sourceArgs.sourceProjectName = projectName;
return sourceArgs;
}
if (input.source === 'metricflow') {
const metricflow = isRecord(input.connection.metricflow) ? input.connection.metricflow : {};
applyRepoSourceArgs(sourceArgs, {
repoUrl: stringField(metricflow.repoUrl),
branch: stringField(metricflow.branch),
subpath: stringField(metricflow.path),
authTokenRef: stringField(metricflow.auth_token_ref),
});
return sourceArgs;
}
if (input.source === 'lookml') {
applyRepoSourceArgs(sourceArgs, {
repoUrl: stringField(input.connection.repoUrl),
branch: stringField(input.connection.branch),
subpath: stringField(input.connection.path),
authTokenRef: stringField(input.connection.auth_token_ref),
});
const mappings = isRecord(input.connection.mappings) ? input.connection.mappings : {};
const expectedLookerConnectionName = stringField(mappings.expectedLookerConnectionName);
if (expectedLookerConnectionName) sourceArgs.sourceTarget = expectedLookerConnectionName;
return sourceArgs;
}
if (input.source === 'metabase') {
sourceArgs.sourceUrl = stringField(input.connection.api_url);
sourceArgs.sourceApiKeyRef = stringField(input.connection.api_key_ref);
const mappings = isRecord(input.connection.mappings) ? input.connection.mappings : {};
const databaseMapping = firstStringRecordEntry(mappings.databaseMappings);
if (databaseMapping) {
sourceArgs.metabaseDatabaseId = Number.parseInt(databaseMapping[0], 10);
sourceArgs.sourceWarehouseConnectionId = databaseMapping[1];
}
return sourceArgs;
}
if (input.source === 'looker') {
sourceArgs.sourceUrl = stringField(input.connection.base_url);
sourceArgs.sourceClientId = stringField(input.connection.client_id);
sourceArgs.sourceClientSecretRef = stringField(input.connection.client_secret_ref);
const mappings = isRecord(input.connection.mappings) ? input.connection.mappings : {};
const connectionMapping = firstStringRecordEntry(mappings.connectionMappings);
if (connectionMapping) {
sourceArgs.sourceTarget = connectionMapping[0];
sourceArgs.sourceWarehouseConnectionId = connectionMapping[1];
}
return sourceArgs;
}
sourceArgs.sourceAuthTokenRef = stringField(input.connection.auth_token_ref);
sourceArgs.notionCrawlMode =
input.connection.crawl_mode === 'all_accessible' ? 'all_accessible' : 'selected_roots';
if (Array.isArray(input.connection.root_page_ids)) {
sourceArgs.notionRootPageIds = input.connection.root_page_ids.filter(
(pageId): pageId is string => typeof pageId === 'string',
);
}
return sourceArgs;
}
async function promptEditedSourceConnection(input: {
args: KtxSetupSourcesArgs;
source: KtxSetupSourceType;
connectionId: string;
connection: KtxProjectConnectionConfig;
prompts: KtxSetupSourcesPromptAdapter;
io: KtxCliIo;
testGitRepo?: KtxSetupSourcesDeps['testGitRepo'];
pickNotionRootPages?: KtxSetupSourcesDeps['pickNotionRootPages'];
discoverMetabaseDatabases?: KtxSetupSourcesDeps['discoverMetabaseDatabases'];
}): Promise<Extract<InteractiveSourceConnectionChoice, { kind: 'edited' }> | 'back'> {
const sourceArgs = await promptForInteractiveSource(
sourceArgsFromExistingConnection({
args: input.args,
source: input.source,
connectionId: input.connectionId,
connection: input.connection,
}),
input.source,
input.prompts,
input.io,
{
pickNotionRootPages: input.pickNotionRootPages,
discoverMetabaseDatabases: input.discoverMetabaseDatabases,
},
input.connectionId,
input.testGitRepo,
input.discoverMetabaseDatabases,
);
return sourceArgs === 'back'
? 'back'
: { kind: 'edited', connectionId: input.connectionId, args: sourceArgs };
}
async function chooseContextSourceToEdit(input: {
projectDir: string;
prompts: KtxSetupSourcesPromptAdapter;
}): Promise<{ connectionId: string; source: KtxSetupSourceType } | 'back'> {
const project = await loadKtxProject({ projectDir: input.projectDir });
const targets = contextSourceEditTargets(project.config.connections);
if (targets.length === 0) return 'back';
const choice = await input.prompts.select({
message: 'Context source to edit',
options: [
...targets.map((target) => ({
value: target.connectionId,
label: `${target.connectionId} (${sourceLabel(target.source)})`,
})),
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') return 'back';
const target = targets.find((candidate) => candidate.connectionId === choice);
return target ?? 'back';
}
2026-05-10 23:12:26 +02:00
async function chooseInteractiveSourceConnection(input: {
2026-05-10 23:51:24 +02:00
args: KtxSetupSourcesArgs;
source: KtxSetupSourceType;
connections: Record<string, KtxProjectConnectionConfig>;
prompts: KtxSetupSourcesPromptAdapter;
io: KtxCliIo;
2026-05-10 23:51:24 +02:00
testGitRepo?: KtxSetupSourcesDeps['testGitRepo'];
pickNotionRootPages?: KtxSetupSourcesDeps['pickNotionRootPages'];
2026-05-10 16:12:51 -07:00
discoverMetabaseDatabases?: KtxSetupSourcesDeps['discoverMetabaseDatabases'];
2026-05-10 23:12:26 +02:00
}): Promise<InteractiveSourceConnectionChoice> {
const existingIds = existingConnectionIdsBySource(input.connections, input.source);
const defaultConnectionId = defaultConnectionIdForSource(input.connections, input.source);
const label = sourceLabel(input.source);
if (existingIds.length === 0) {
2026-05-10 16:12:51 -07:00
const sourceArgs = await promptForInteractiveSource(
input.args,
input.source,
input.prompts,
input.io,
{
pickNotionRootPages: input.pickNotionRootPages,
discoverMetabaseDatabases: input.discoverMetabaseDatabases,
},
2026-05-10 16:12:51 -07:00
defaultConnectionId,
input.testGitRepo,
input.discoverMetabaseDatabases,
);
2026-05-10 23:12:26 +02:00
return sourceArgs === 'back' ? 'back' : { kind: 'new', args: sourceArgs };
}
while (true) {
const choice = await input.prompts.select({
message: `Configure ${label}`,
options: [
...existingIds.map((connectionId) => ({
value: `existing:${connectionId}`,
label: `Use existing ${label} connection: ${connectionId}`,
})),
...existingIds.map((connectionId) => ({
value: `edit:${connectionId}`,
label: `Edit existing ${label} connection: ${connectionId}`,
})),
2026-05-10 23:12:26 +02:00
{ value: 'new', label: `Add new ${label} connection` },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') return 'back';
if (choice.startsWith('existing:')) {
const connectionId = choice.slice('existing:'.length);
const connection = input.connections[connectionId];
if (connection) {
return { kind: 'existing', connectionId, connection };
}
continue;
}
if (choice.startsWith('edit:')) {
const connectionId = choice.slice('edit:'.length);
const connection = input.connections[connectionId];
if (!connection) {
continue;
}
const edited = await promptEditedSourceConnection({
args: input.args,
source: input.source,
connectionId,
connection,
prompts: input.prompts,
io: input.io,
testGitRepo: input.testGitRepo,
pickNotionRootPages: input.pickNotionRootPages,
discoverMetabaseDatabases: input.discoverMetabaseDatabases,
});
if (edited === 'back') {
continue;
}
return edited;
}
2026-05-10 16:12:51 -07:00
const sourceArgs = await promptForInteractiveSource(
input.args,
input.source,
input.prompts,
input.io,
{
pickNotionRootPages: input.pickNotionRootPages,
discoverMetabaseDatabases: input.discoverMetabaseDatabases,
},
2026-05-10 16:12:51 -07:00
defaultConnectionId,
input.testGitRepo,
input.discoverMetabaseDatabases,
);
2026-05-10 23:12:26 +02:00
if (sourceArgs === 'back') {
continue;
}
return { kind: 'new', args: sourceArgs };
}
}
2026-05-10 23:51:24 +02:00
function buildConnection(source: KtxSetupSourceType, args: KtxSetupSourcesArgs): KtxProjectConnectionConfig {
2026-05-10 23:12:26 +02:00
if (source === 'dbt') {
return buildDbtConnection(args);
}
if (source === 'metricflow') {
return buildMetricflowConnection(args);
}
if (source === 'metabase') {
return buildMetabaseConnection(args);
}
if (source === 'looker') {
return buildLookerConnection(args);
}
if (source === 'lookml') {
return buildLookmlConnection(args);
}
return buildNotionConnection(args);
}
async function validateSource(
2026-05-10 23:51:24 +02:00
source: KtxSetupSourceType,
args: { projectDir: string; connectionId: string; connection: KtxProjectConnectionConfig },
deps: KtxSetupSourcesDeps,
2026-05-10 23:12:26 +02:00
): Promise<SourceValidationResult> {
if (source === 'dbt') {
return await (deps.validateDbt ?? defaultValidateDbt)(args.connection);
}
if (source === 'metricflow') {
return await (deps.validateMetricflow ?? defaultValidateMetricflow)(args.connection);
}
if (source === 'metabase') {
2026-05-10 16:12:51 -07:00
return deps.validateMetabase
? await deps.validateMetabase(args.projectDir, args.connectionId)
: { ok: true, detail: 'mapping validation runs after the connection is saved' };
2026-05-10 23:12:26 +02:00
}
if (source === 'looker') {
return await (deps.validateLooker ?? defaultValidateLooker)(args.projectDir, args.connectionId);
}
if (source === 'lookml') {
return await (deps.validateLookml ?? defaultValidateLookml)(args.connection);
}
return await (deps.validateNotion ?? defaultValidateNotion)(args.connection);
}
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
async function createSourceSetupRollback(projectDir: string): Promise<() => Promise<void>> {
const project = await loadKtxProject({ projectDir });
const previousConfig = project.config;
const configPath = project.configPath;
return async () => {
await writeFile(configPath, serializeKtxProjectConfig(previousConfig), 'utf-8');
};
}
function sourceConnectionId(input: {
source: KtxSetupSourceType;
sourceChoice: Exclude<InteractiveSourceConnectionChoice, 'back'>;
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
}): string {
return input.sourceChoice.kind === 'existing' || input.sourceChoice.kind === 'edited'
? input.sourceChoice.connectionId
: (input.sourceChoice.args.sourceConnectionId ?? `${input.source}-main`);
}
async function validateSourceConnectionAndMapping(input: {
args: KtxSetupSourcesArgs;
source: KtxSetupSourceType;
connectionId: string;
connection: KtxProjectConnectionConfig;
prompts: KtxSetupSourcesPromptAdapter;
io: KtxCliIo;
deps: KtxSetupSourcesDeps;
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
}): Promise<ValidateResult> {
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
const validateSpinner = createCliSpinner(input.io);
validateSpinner.start(`Validating ${sourceLabel(input.source)} source…`);
let validation: SourceValidationResult;
try {
validation = await validateSource(
input.source,
{ projectDir: input.args.projectDir, connectionId: input.connectionId, connection: input.connection },
input.deps,
);
} catch (error) {
validateSpinner.error(`${sourceLabel(input.source)} source validation failed`);
throw error;
}
if (!validation.ok) {
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
validateSpinner.error(`${sourceLabel(input.source)} source validation failed`);
input.io.stderr.write(`${validation.message}\n`);
return { status: 'failed' };
}
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296) * fix(cli): double the height of the setup banner t crossbar * fix(cli): unify setup multi-select hints and make Tab the select key The six interactive multi-select surfaces in `ktx setup` documented three different hint voices, one had no hint at all, and they named two different select keys (Space vs Tab). Tab is the only key that can toggle selection without colliding with type-to-search input, so make it the single documented select key everywhere and compose every hint from one shared fragment vocabulary in prompt-navigation.ts. - Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat multiselects; the alias applies only to non-text prompts, leaving typed search input (schema/Notion) untouched. - Add the missing hint to the agent-targets prompt and drop the stray "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper. - Replace the schema-scope ad-hoc hint with the searchable-multiselect voice and standardize "filter" -> "search" vocabulary. - Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText seam; render the shared tree hint instead. * refactor(cli): show LLM check progress for every setup backend Rename runLlmHealthCheckWithProgress to validateModelWithProgress and wrap the Claude subscription and Codex auth probes in the same spinner progress as the Anthropic API and Vertex backends, so each backend shows consistent "Checking <provider> LLM" output during setup. * feat(cli): add ktx-orange progress spinners to setup steps Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner: an animated clack spinner in a terminal, and a static stderr-only spinner before raw-mode pickers (the table tree picker and demo tour), where the animated spinner's stdin grab would otherwise corrupt the next prompt. Wrap the slow setup waits in progress spinners: managed runtime install, embedding daemon start + first-run model download, embeddings health check, the connection-test gate, and source validation / dbt clone / Metabase discovery. Recolor every spinner frame from clack's magenta to the ktx mascot orange (#FF8A4C) via the static helper and clack's styleFrame option.
2026-06-12 16:43:10 +02:00
validateSpinner.stop(`${sourceLabel(input.source)} source validated`);
if (input.source === 'metabase' || input.source === 'looker') {
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
input.prompts.log?.(`Validating ${sourceLabel(input.source)} mapping...`);
const mappingCode = await (input.deps.runMapping ?? defaultRunMapping)(
input.args.projectDir,
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
input.connectionId,
createSetupPrefixedIo(input.io),
);
if (mappingCode !== 0) {
return { status: 'failed' };
}
}
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
return { status: 'ok' };
}
async function saveValidateAndMaybeBuildSource(input: {
args: KtxSetupSourcesArgs;
source: KtxSetupSourceType;
sourceChoice: Exclude<InteractiveSourceConnectionChoice, 'back'>;
prompts: KtxSetupSourcesPromptAdapter;
io: KtxCliIo;
deps: KtxSetupSourcesDeps;
}): Promise<SourceSetupChoiceResult> {
let latestChoice = input.sourceChoice;
let latestConnectionId = sourceConnectionId({ source: input.source, sourceChoice: latestChoice });
let latestConnection =
latestChoice.kind === 'existing'
? latestChoice.connection
: buildConnection(input.source, latestChoice.args);
let configureCount = 0;
let rollbackAfterConfigure: (() => Promise<void>) | undefined;
const outcome = await runConnectionSetupWithRecovery({
label: latestConnectionId,
interactive: input.args.inputMode !== 'disabled',
allowSkip: true,
io: input.io,
prompts: input.prompts,
snapshot: async () => {
rollbackAfterConfigure = await createSourceSetupRollback(input.args.projectDir);
return rollbackAfterConfigure;
},
configure: async (): Promise<ConfigureResult> => {
configureCount += 1;
if (latestChoice.kind === 'existing' && configureCount === 1) {
await ensureSourceAdapterEnabled(input.args.projectDir, input.source);
return 'configured';
}
const project = await loadKtxProject({ projectDir: input.args.projectDir });
const currentConnection = project.config.connections[latestConnectionId] ?? latestConnection;
const useAlreadyPromptedArgs = configureCount === 1 && latestChoice.kind !== 'existing';
const sourceArgs =
useAlreadyPromptedArgs && latestChoice.kind !== 'existing'
? latestChoice.args
: input.args.inputMode === 'disabled'
? sourceArgsFromExistingConnection({
args: input.args,
source: input.source,
connectionId: latestConnectionId,
connection: currentConnection,
})
: await promptForInteractiveSource(
sourceArgsFromExistingConnection({
args: input.args,
source: input.source,
connectionId: latestConnectionId,
connection: currentConnection,
}),
input.source,
input.prompts,
input.io,
{
pickNotionRootPages: input.deps.pickNotionRootPages,
discoverMetabaseDatabases: input.deps.discoverMetabaseDatabases,
},
latestConnectionId,
input.deps.testGitRepo,
input.deps.discoverMetabaseDatabases,
);
if (sourceArgs === 'back') {
return 'back';
}
latestConnectionId = sourceArgs.sourceConnectionId ?? latestConnectionId;
latestConnection = buildConnection(input.source, sourceArgs);
latestChoice =
latestChoice.kind === 'new'
? { kind: 'new', args: sourceArgs }
: { kind: 'edited', connectionId: latestConnectionId, args: sourceArgs };
await writeSourceConnection(
input.args.projectDir,
latestConnectionId,
latestConnection,
sourceAdapter(input.source),
input.io,
);
return 'configured';
},
validate: () =>
validateSourceConnectionAndMapping({
args: input.args,
source: input.source,
connectionId: latestConnectionId,
connection: latestConnection,
prompts: input.prompts,
io: input.io,
deps: input.deps,
}),
});
if (outcome !== 'ready') {
return { status: outcome };
}
if (input.args.runInitialSourceIngest) {
const ingestResult = await runInitialSourceIngestWithRecovery({
args: input.args,
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
connectionId: latestConnectionId,
io: input.io,
prompts: input.prompts,
deps: input.deps,
});
if (ingestResult === 'failed') {
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
await rollbackAfterConfigure?.();
return { status: 'failed' };
}
if (ingestResult === 'back') {
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
await rollbackAfterConfigure?.();
return { status: 'back' };
}
} else {
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
input.io.stdout.write(`│ Context source ${latestConnectionId} saved. It will be built during the context build step.\n`);
}
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
return { status: 'ready', connectionId: latestConnectionId };
}
2026-05-10 23:51:24 +02:00
export async function runKtxSetupSourcesStep(
args: KtxSetupSourcesArgs,
io: KtxCliIo,
deps: KtxSetupSourcesDeps = {},
): Promise<KtxSetupSourcesResult> {
2026-05-10 23:12:26 +02:00
try {
if (args.skipSources) {
await markSourcesComplete(args.projectDir);
io.stdout.write('│ Context source setup skipped.\n');
2026-05-10 23:12:26 +02:00
return { status: 'skipped', projectDir: args.projectDir };
}
if (args.source) {
assertSourceCredentialFlags(args.source, args);
}
2026-05-10 23:12:26 +02:00
const prompts = deps.prompts ?? createPromptAdapter();
2026-05-10 23:51:24 +02:00
const project = await loadKtxProject({ projectDir: args.projectDir });
2026-05-10 23:12:26 +02:00
if (!hasPrimarySource(project.config)) {
feat: merge ingest and scan * docs: add CLI component reuse guidance * docs: add unified ingest ux design * Refine unified ingest UX design after adversarial review iteration 1 * Refine unified ingest UX design after adversarial review iteration 2 * Refine unified ingest UX design after adversarial review iteration 3 * feat(cli): route public connection ingest command * feat(cli): hide standalone scan from public help * feat(cli): plan public ingest depth and query history * feat(cli): execute public database ingest facets * feat(ingest): read connection query history config * fix(cli): use public ingest wording * fix(config): stop generating ingest adapter allow lists * docs: document public ingest command * test: align ingest surface expectations * docs: add unified ingest public CLI surface plan * feat(cli): preflight deep public ingest readiness * feat(setup): store query history in connection context * feat(setup): store database context depth * feat(setup): verify context readiness by database depth * fix(setup): keep context build foreground only * fix(config): reject reserved ingest connection ids * test: close unified ingest v1 expectations * docs: add unified ingest v1 closure plan * fix(ingest): bypass adapter allow-list for public source ingest * fix(ingest): honor query history window intent * fix(ingest): hide scan internals from public database ingest * feat(ingest): use foreground view for interactive public ingest * fix(setup): use schema context and query history wording * test(cli): verify unified ingest public output * docs: add unified ingest v1 public output closure plan * fix(setup): forward query history flags * fix(setup): prompt for postgres query history * fix(status): report query history readiness * fix(ingest): remove legacy public guidance * fix(ingest): polish foreground retry copy * docs(examples): use unified query history wording * chore(ingest): finish public query history cleanup * docs: add unified ingest v1 query history status cleanup plan * test(docs): cover unified ingest public docs * docs: align ingest CLI reference with unified UX * docs: update context build guides for unified ingest * docs: update setup and primary source ingest wording * docs: stop advertising adapter-backed example ingest * docs: close unified ingest public docs gaps * docs: add unified ingest v1 docs site closure plan * fix: render unified ingest foreground warnings * fix: explain query history schema order * fix: add public ingest retry guidance * fix: align setup next steps with unified ingest * fix: remove scan wording from demo progress * test: verify unified ingest ux closure * docs: add unified ingest v1 foreground and retry closure plan * fix(cli): preserve query-history pull config in public ingest * fix(cli): omit hidden commands from docs command tree * test(cli): close unified ingest final public surface checks * docs: add unified ingest v1 final public surface closure plan * fix(cli): use public source labels in ingest reports * fix(cli): suppress low-level public ingest output * test(cli): verify unified ingest public plain output * docs: add unified ingest v1 public plain output closure plan * fix(cli): add public ingest copy sanitizers * fix(cli): sanitize public ingest progress copy * fix(cli): rename setup schema scope prompt * docs(plan): add progress copy closure; test: align setup back-nav fixture Adds the iter9 plan and updates the setup back-navigation test fixture to pass disableQueryHistory plus listSchemas/listTables stubs that the unified ingest setup step now requires. * docs(plan): add final ux labels plan with narrowed label scans * fix(cli): aggregate unsupported query-history warnings * fix(cli): align setup database labels * test(cli): fix setup database test type-check * fix(cli): remove primary-source wording from setup output * test(cli): verify unified ingest setup closure * docs(plan): add unified ingest v1 verification copy closure plan * fix(cli): remove top-level scan command * fix(cli): remove legacy ingest and wiki commands * Merge scan into ingest flow * feat(cli): split ingest progress into per-phase rows, rename work units to tasks Each database target in the unified ingest dashboard now renders one row per real subprocess (Schema, then Query history when enabled) instead of a single combined bar. Each phase has its own monotonic 0-100% bar so the progress never snaps back to zero when historic-sql starts after scan completes. Completed phases keep their final bar, summary, and elapsed time visible as an inline audit trail; queued and skipped phases are shown explicitly. Also rename user-facing "work units" / "Failed work units" to "tasks" / "Failed tasks" in ingest output and parseIngestSummary. The parser still accepts the legacy "Work units:" wording in captured output for backward compat. Internal memory-flow event names and type fields are left alone. * Fix test harness failures * Fix CI smoke checks --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-14 01:43:06 +02:00
const message = 'Connect a database before adding context sources.';
2026-05-10 23:12:26 +02:00
if (args.source) {
io.stderr.write(`${message}\n`);
return { status: 'failed', projectDir: args.projectDir };
}
if (args.inputMode !== 'disabled') {
io.stdout.write(`${message}\n`);
2026-05-10 23:12:26 +02:00
return { status: 'skipped', projectDir: args.projectDir };
}
}
while (true) {
const contextSourceChecklist = sourceChecklistForConnections(
(await loadKtxProject({ projectDir: args.projectDir })).config.connections,
);
2026-05-10 23:12:26 +02:00
const selected = args.source
? [args.source]
: args.inputMode === 'disabled'
? []
: await prompts.multiselect({
message: withMultiselectNavigation('Which context sources should ktx ingest?'),
options: contextSourceChecklist.options,
...(contextSourceChecklist.initialValues.length > 0
? { initialValues: contextSourceChecklist.initialValues }
: {}),
2026-05-10 23:12:26 +02:00
required: false,
});
if (selected.includes('back')) {
return { status: 'back', projectDir: args.projectDir };
}
if (selected.length === 0) {
if (args.inputMode === 'disabled') {
io.stderr.write('Missing context source selection: pass --source or --skip-sources.\n');
return { status: 'missing-input', projectDir: args.projectDir };
}
await markSourcesComplete(args.projectDir);
io.stdout.write('│ No context sources selected.\n');
2026-05-10 23:12:26 +02:00
return { status: 'skipped', projectDir: args.projectDir };
}
const readyConnectionIds: string[] = [];
let returnToSourceSelection = false;
2026-05-10 23:51:24 +02:00
for (const source of selected as KtxSetupSourceType[]) {
2026-05-10 23:12:26 +02:00
const sourceChoice = args.source
? ({ kind: 'new', args } as const)
: await chooseInteractiveSourceConnection({
args,
source,
2026-05-10 23:51:24 +02:00
connections: (await loadKtxProject({ projectDir: args.projectDir })).config.connections,
2026-05-10 23:12:26 +02:00
prompts,
io,
2026-05-10 23:12:26 +02:00
testGitRepo: deps.testGitRepo,
pickNotionRootPages: deps.pickNotionRootPages,
2026-05-10 16:12:51 -07:00
discoverMetabaseDatabases: deps.discoverMetabaseDatabases,
2026-05-10 23:12:26 +02:00
});
if (sourceChoice === 'back') {
if (args.source) {
return { status: 'back', projectDir: args.projectDir };
}
returnToSourceSelection = true;
break;
}
const choiceResult = await saveValidateAndMaybeBuildSource({
args,
source,
sourceChoice,
prompts,
io,
deps,
});
if (choiceResult.status === 'failed') {
if (args.source) {
return { status: 'failed', projectDir: args.projectDir };
}
prompts.log?.('Edit the connection or pick a different source to continue.');
returnToSourceSelection = true;
break;
2026-05-10 23:12:26 +02:00
}
if (choiceResult.status === 'back') {
if (args.source) {
return { status: 'back', projectDir: args.projectDir };
2026-05-10 23:12:26 +02:00
}
returnToSourceSelection = true;
break;
2026-05-10 23:12:26 +02:00
}
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
if (choiceResult.status === 'skip') {
continue;
}
if (choiceResult.status === 'ready') {
if (!readyConnectionIds.includes(choiceResult.connectionId)) {
readyConnectionIds.push(choiceResult.connectionId);
}
2026-05-10 23:12:26 +02:00
}
}
if (returnToSourceSelection) {
continue;
}
if (readyConnectionIds.length > 0 && !args.source && args.inputMode !== 'disabled') {
let restartSourceSelection = false;
while (true) {
const addMore = await prompts.select({
message: `${readyConnectionIds.length} context source${readyConnectionIds.length > 1 ? 's' : ''} configured (${readyConnectionIds.join(', ')}). Add another?`,
options: [
{ value: 'done', label: 'Done adding context sources' },
{ value: 'edit', label: 'Edit an existing context source' },
{ value: 'add', label: 'Add another context source' },
],
});
if (addMore === 'add') {
restartSourceSelection = true;
break;
}
if (addMore === 'edit') {
const editTarget = await chooseContextSourceToEdit({ projectDir: args.projectDir, prompts });
if (editTarget === 'back') {
continue;
}
const projectForEdit = await loadKtxProject({ projectDir: args.projectDir });
const connection = projectForEdit.config.connections[editTarget.connectionId];
if (!connection) {
continue;
}
const sourceChoice = await promptEditedSourceConnection({
args,
source: editTarget.source,
connectionId: editTarget.connectionId,
connection,
prompts,
io,
testGitRepo: deps.testGitRepo,
pickNotionRootPages: deps.pickNotionRootPages,
discoverMetabaseDatabases: deps.discoverMetabaseDatabases,
});
if (sourceChoice === 'back') {
continue;
}
const choiceResult = await saveValidateAndMaybeBuildSource({
args,
source: editTarget.source,
sourceChoice,
prompts,
io,
deps,
});
if (choiceResult.status === 'failed') {
prompts.log?.('Edit the connection or pick a different source to continue.');
continue;
}
if (choiceResult.status === 'back') {
continue;
}
feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test <id>` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test <id>` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes.
2026-06-03 13:08:46 +02:00
if (choiceResult.status === 'skip') {
continue;
}
if (choiceResult.status === 'ready') {
if (!readyConnectionIds.includes(choiceResult.connectionId)) {
readyConnectionIds.push(choiceResult.connectionId);
}
}
continue;
}
break;
}
if (restartSourceSelection) {
2026-05-10 23:12:26 +02:00
continue;
}
}
await markSourcesComplete(args.projectDir);
return { status: 'ready', projectDir: args.projectDir, connectionIds: readyConnectionIds };
}
} catch (error) {
test: split cli tests from source tree (#216) * feat(cli): define full warehouse dialect contract * test(cli): keep dialect edge tests focused * fix(cli): stabilize dialect contract foundation * refactor(connectors): own read-only query preparation * refactor(connectors): resolve dialects through registry * refactor(connectors): keep concrete dialect classes internal * chore(workspace): enforce dialect import boundary * refactor(cli): resolve relationship dialect at scan boundary * refactor(cli): use dialect display parsing for entity details * refactor(cli): use dialect display parsing for warehouse catalog * refactor(cli): use dialect SQL in relationship workflows * test(cli): verify solid dialect scan workflow closure * test: split cli tests from source tree * refactor(cli): standardize BigQuery scope listing * feat(sqlite): implement connector scope listing * test(connectors): cover required table listing * feat(cli): add warehouse driver registry * refactor(setup): route scope discovery through driver registry * refactor(cli): route local query execution through driver registry * refactor(historic-sql): route dialect support through driver registry * refactor(cli): test warehouse connections through driver registry * fix(cli): close driver registry type export gaps * Improve setup daemon diagnostics * refactor(setup): centralize rail-prefixed diagnostics + query-history fallback Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput into clack.ts so the setup wizard, managed daemons, and embedding/agent steps share one rail-formatted writer. setup-databases.ts also adds a "disable query history and retry" option when the schema-context build fails and query history is the likely culprit, surfaced via a new failed-query-history-unavailable status. * fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match The setup picker's KtxTableListEntry was a 2-level { schema, name }, so qualifiedTableId always wrote db.name into enabled_tables. When BigQuery, Snowflake, or SQL Server later ran fast ingest, their introspect step filtered the scope set with scopedTableNames(scope, { catalog: projectId|database, db }) — catalog was non-null on the introspect side but null in the scope refs, so every entry was rejected, the live-database adapter staged zero table files, and detect() failed with 'Adapter "live-database" did not recognize fetched source output'. Align the picker boundary with the canonical 3-level KtxTableRef: - Add catalog: string | null to KtxTableListEntry. - BigQuery/Snowflake/SQL Server listTables populate catalog from the resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null. - qualifiedTableId emits catalog.schema.name when catalog is non-null (resolveEnabledTables already accepts the 3-part shape) and schemasFromEnabledTables now goes through parseDottedTableEntry so it recovers the schema correctly from both 2-part and 3-part entries. - Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker reuse. Update listTables expectations in all seven connector tests and the setup / picker test fixtures. Add a picker regression test that covers the catalog-bearing round-trip (save + refine). * fix(cli): allow debug telemetry under opt-out env
2026-05-26 08:49:05 +02:00
writePrefixedLines((chunk) => io.stderr.write(chunk), errorMessage(error));
2026-05-10 23:12:26 +02:00
return { status: 'failed', projectDir: args.projectDir };
}
}