From a72fca2b3291a750df197a632aae0856c9634fe4 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sat, 16 May 2026 11:39:43 +0200 Subject: [PATCH] fix(cli): auto-install runtime during setup (#116) * fix(cli): auto-install runtime during setup * test: align docs smoke with readme --- packages/cli/src/context-build-view.test.ts | 31 ++++++++++++++ packages/cli/src/context-build-view.ts | 1 + packages/cli/src/ingest.test.ts | 8 +++- packages/cli/src/ingest.ts | 3 +- packages/cli/src/public-ingest.test.ts | 11 ++++- packages/cli/src/public-ingest.ts | 32 ++++++++++---- packages/cli/src/scan.test.ts | 9 ++-- packages/cli/src/scan.ts | 3 +- packages/cli/src/setup.test.ts | 47 +++++++++++++++++++++ packages/cli/src/setup.ts | 5 +-- scripts/examples-docs.test.mjs | 4 +- 11 files changed, 131 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/context-build-view.test.ts b/packages/cli/src/context-build-view.test.ts index 8d6b48ad..efe2f445 100644 --- a/packages/cli/src/context-build-view.test.ts +++ b/packages/cli/src/context-build-view.test.ts @@ -994,6 +994,37 @@ describe('runContextBuild', () => { ); }); + it('threads the original runtime IO into captured target execution', async () => { + const io = makeIo({ isTTY: true }); + const project = projectWithConnections({ + warehouse: { driver: 'postgres', context: { queryHistory: { enabled: true } } }, + }); + const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation)); + + await runContextBuild( + project, + { + projectDir: '/tmp/project', + inputMode: 'auto', + cliVersion: '0.2.0', + runtimeInstallPolicy: 'auto', + }, + io.io, + { executeTarget, now: () => 1000 }, + ); + + expect(executeTarget).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'warehouse' }), + expect.objectContaining({ runtimeInstallPolicy: 'auto' }), + expect.objectContaining({ + stdout: expect.objectContaining({ isTTY: false }), + }), + expect.objectContaining({ + runtimeIo: io.io, + }), + ); + }); + it('calls onSourceProgress when sources start and finish', async () => { const io = makeIo(); const project = projectWithConnections({ diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index 6df8ad2a..c57c252d 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -1022,6 +1022,7 @@ export async function runContextBuild( const progressDeps: KtxPublicIngestDeps = { scanProgress: createContextBuildProgressPort(updateSchemaPhase), ingestProgress: updateIngestPhase, + runtimeIo: io, onPhaseStart, onPhaseEnd, }; diff --git a/packages/cli/src/ingest.test.ts b/packages/cli/src/ingest.test.ts index 52dcbc38..ad2b2494 100644 --- a/packages/cli/src/ingest.test.ts +++ b/packages/cli/src/ingest.test.ts @@ -11,7 +11,7 @@ import { } from '@ktx/context/ingest'; import { initKtxProject, ktxLocalStateDbPath, loadKtxProject } from '@ktx/context/project'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { type KtxIngestArgs, runKtxIngest } from './ingest.js'; +import { type KtxIngestArgs, type KtxIngestDeps, runKtxIngest } from './ingest.js'; import type { KtxCliLocalIngestAdaptersOptions } from './local-adapters.js'; import { CliLookerSlWritingAgentRunner, @@ -1108,6 +1108,7 @@ describe('runKtxIngest', () => { completedLocalBundleRun(input, input.jobId ?? 'local-job-1'), ); const io = makeIo(); + const runtimeIo = makeIo({ isTTY: true }); await expect( runKtxIngest( @@ -1125,6 +1126,9 @@ describe('runKtxIngest', () => { createAdapters, runLocalIngest: runLocal, jobIdFactory: () => 'local-job-1', + runtimeIo: runtimeIo.io, + } as KtxIngestDeps & { + runtimeIo: typeof runtimeIo.io; }, ), ).resolves.toBe(0); @@ -1133,7 +1137,7 @@ describe('runKtxIngest', () => { cliVersion: '0.2.0', projectDir, installPolicy: 'auto', - io: io.io, + io: runtimeIo.io, }; expect(createAdapters).toHaveBeenCalledWith( expect.objectContaining({ projectDir }), diff --git a/packages/cli/src/ingest.ts b/packages/cli/src/ingest.ts index d602833c..f2d82298 100644 --- a/packages/cli/src/ingest.ts +++ b/packages/cli/src/ingest.ts @@ -97,6 +97,7 @@ export interface KtxIngestDeps { | 'pullConfigOptions' >; progress?: (update: KtxIngestProgressUpdate) => void; + runtimeIo?: KtxIngestIo; } function reportStatus(report: IngestReportSnapshot): 'done' | 'error' { @@ -615,7 +616,7 @@ export async function runKtxIngest( (deps.runLocalIngest || deps.runLocalMetabaseIngest ? () => [] : createKtxCliLocalIngestAdapters); const executeLocalIngest = deps.runLocalIngest ?? runLocalIngest; const localIngestOptions = deps.localIngestOptions ?? {}; - const managedDaemon = managedDaemonOptionsForIngestRun(args, io); + const managedDaemon = managedDaemonOptionsForIngestRun(args, deps.runtimeIo ?? io); const operationalLogger = createCliOperationalLogger(io, args.outputMode); const adapterOptions = { ...(localIngestOptions.pullConfigOptions ?? {}), diff --git a/packages/cli/src/public-ingest.test.ts b/packages/cli/src/public-ingest.test.ts index d34a5785..2e96be4b 100644 --- a/packages/cli/src/public-ingest.test.ts +++ b/packages/cli/src/public-ingest.test.ts @@ -421,11 +421,18 @@ describe('runKtxPublicIngest', () => { it('runs query history after schema ingest with current-run window override', async () => { const io = makeIo(); + const runtimeIo = makeIo({ isTTY: true }); const project = deepReadyProject({ warehouse: { driver: 'postgres', context: { queryHistory: { enabled: true, windowDays: 90 } } }, }); const runScan = vi.fn(async () => 0); const runIngest = vi.fn>(async () => 0); + const deps = { + loadProject: vi.fn(async () => project), + runScan, + runIngest, + runtimeIo: runtimeIo.io, + } as KtxPublicIngestDeps & { runtimeIo: typeof runtimeIo.io }; await expect( runKtxPublicIngest( @@ -442,13 +449,14 @@ describe('runKtxPublicIngest', () => { queryHistoryWindowDays: 30, }, io.io, - { loadProject: vi.fn(async () => project), runScan, runIngest }, + deps, ), ).resolves.toBe(0); expect(runScan).toHaveBeenCalledWith( expect.objectContaining({ connectionId: 'warehouse', mode: 'enriched' }), expect.anything(), + expect.objectContaining({ runtimeIo: runtimeIo.io }), ); expect(runIngest).toHaveBeenCalledWith( expect.objectContaining({ @@ -461,6 +469,7 @@ describe('runKtxPublicIngest', () => { historicSqlPullConfigOverride: expect.objectContaining({ dialect: 'postgres', windowDays: 30 }), }), expect.anything(), + expect.objectContaining({ runtimeIo: runtimeIo.io }), ); }); diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts index 7916a711..5b8a34ed 100644 --- a/packages/cli/src/public-ingest.ts +++ b/packages/cli/src/public-ingest.ts @@ -94,6 +94,7 @@ export interface KtxPublicIngestDeps { ) => Promise<{ exitCode: number }>; scanProgress?: KtxProgressPort; ingestProgress?: (update: KtxIngestProgressUpdate) => void; + runtimeIo?: KtxCliIo; onPhaseStart?: (phaseKey: KtxPublicIngestPhaseKey) => void; onPhaseEnd?: (phaseKey: KtxPublicIngestPhaseKey, status: 'done' | 'failed' | 'skipped', summary?: string) => void; } @@ -719,10 +720,13 @@ export async function executePublicIngestTarget( const runScan = deps.runScan ?? runKtxScan; const capturedScanIo = deps.scanProgress ? null : createCapturedPublicIngestIo(); const scanIo = capturedScanIo ?? io; + const scanDeps = { + ...(deps.scanProgress ? { progress: deps.scanProgress } : {}), + ...(deps.runtimeIo ? { runtimeIo: deps.runtimeIo } : {}), + }; deps.onPhaseStart?.('database-schema'); - const scanExitCode = deps.scanProgress - ? await runScan(scanArgs, scanIo, { progress: deps.scanProgress }) - : await runScan(scanArgs, scanIo); + const scanExitCode = + Object.keys(scanDeps).length > 0 ? await runScan(scanArgs, scanIo, scanDeps) : await runScan(scanArgs, scanIo); if (scanExitCode !== 0) { deps.onPhaseEnd?.('database-schema', 'failed'); if (target.queryHistory?.enabled === true) { @@ -759,10 +763,15 @@ export async function executePublicIngestTarget( }; const capturedIngestIo = deps.ingestProgress ? null : createCapturedPublicIngestIo(); const ingestIo = capturedIngestIo ?? io; + const ingestDeps = { + ...(deps.ingestProgress ? { progress: deps.ingestProgress } : {}), + ...(deps.runtimeIo ? { runtimeIo: deps.runtimeIo } : {}), + }; deps.onPhaseStart?.('query-history'); - const qhExitCode = deps.ingestProgress - ? await runIngest(ingestArgs, ingestIo, { progress: deps.ingestProgress }) - : await runIngest(ingestArgs, ingestIo); + const qhExitCode = + Object.keys(ingestDeps).length > 0 + ? await runIngest(ingestArgs, ingestIo, ingestDeps) + : await runIngest(ingestArgs, ingestIo); if (qhExitCode !== 0) { deps.onPhaseEnd?.('query-history', 'failed'); return markTargetResult( @@ -795,10 +804,15 @@ export async function executePublicIngestTarget( const runIngest = deps.runIngest ?? runKtxIngest; const capturedIngestIo = deps.ingestProgress ? null : createCapturedPublicIngestIo(); const ingestIo = capturedIngestIo ?? io; + const ingestDeps = { + ...(deps.ingestProgress ? { progress: deps.ingestProgress } : {}), + ...(deps.runtimeIo ? { runtimeIo: deps.runtimeIo } : {}), + }; deps.onPhaseStart?.('source-ingest'); - const exitCode = deps.ingestProgress - ? await runIngest(ingestArgs, ingestIo, { progress: deps.ingestProgress }) - : await runIngest(ingestArgs, ingestIo); + const exitCode = + Object.keys(ingestDeps).length > 0 + ? await runIngest(ingestArgs, ingestIo, ingestDeps) + : await runIngest(ingestArgs, ingestIo); deps.onPhaseEnd?.('source-ingest', exitCode === 0 ? 'done' : 'failed'); return markTargetResult( target, diff --git a/packages/cli/src/scan.test.ts b/packages/cli/src/scan.test.ts index 5fe4a342..b08c15de 100644 --- a/packages/cli/src/scan.test.ts +++ b/packages/cli/src/scan.test.ts @@ -9,7 +9,7 @@ import type { RunLocalScanOptions, } from '@ktx/context/scan'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createCliScanProgress, runKtxScan } from './scan.js'; +import { createCliScanProgress, runKtxScan, type KtxScanDeps } from './scan.js'; const sqlServerExtractSchema = vi.hoisted(() => vi.fn(async (connectionId: string) => ({ @@ -392,6 +392,7 @@ describe('runKtxScan', () => { }), ); const io = makeIo(); + const runtimeIo = makeIo({ isTTY: true }); await expect( runKtxScan( @@ -406,7 +407,9 @@ describe('runKtxScan', () => { runtimeInstallPolicy: 'auto', }, io.io, - { runLocalScan, createLocalIngestAdapters }, + { runLocalScan, createLocalIngestAdapters, runtimeIo: runtimeIo.io } as KtxScanDeps & { + runtimeIo: typeof runtimeIo.io; + }, ), ).resolves.toBe(0); @@ -415,7 +418,7 @@ describe('runKtxScan', () => { cliVersion: '0.2.0', projectDir: tempDir, installPolicy: 'auto', - io: io.io, + io: runtimeIo.io, }, }); }); diff --git a/packages/cli/src/scan.ts b/packages/cli/src/scan.ts index 0b152d09..d7334fac 100644 --- a/packages/cli/src/scan.ts +++ b/packages/cli/src/scan.ts @@ -30,6 +30,7 @@ export interface KtxScanDeps { runLocalScan?: typeof runLocalScan; createLocalIngestAdapters?: typeof createKtxCliLocalIngestAdapters; progress?: KtxProgressPort; + runtimeIo?: KtxCliIo; } function shouldUseStyledOutput(io: KtxCliIo): boolean { @@ -313,7 +314,7 @@ export function createCliScanProgress( export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise { try { const project = await loadKtxProject({ projectDir: args.projectDir }); - const managedDaemon = managedDaemonOptionsForScanRun(args, io); + const managedDaemon = managedDaemonOptionsForScanRun(args, deps.runtimeIo ?? io); const connector = args.mode !== 'structural' || args.detectRelationships ? await createKtxCliScanConnector(project, args.connectionId) diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index 650570c7..47d1aa83 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -1051,6 +1051,53 @@ describe('setup status', () => { ); }); + it('auto-installs the managed runtime by default during setup', async () => { + const io = makeIo(); + const embeddings = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir })); + const context = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir })); + + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'new', + agents: false, + agentScope: 'project', + skipAgents: true, + inputMode: 'auto', + yes: false, + cliVersion: '0.2.0', + skipLlm: true, + skipEmbeddings: false, + databaseSchemas: [], + skipDatabases: true, + skipSources: true, + }, + io.io, + { + embeddings, + context, + }, + ), + ).resolves.toBe(1); + + expect(embeddings).toHaveBeenCalledWith( + expect.objectContaining({ + cliVersion: '0.2.0', + runtimeInstallPolicy: 'auto', + }), + io.io, + ); + expect(context).toHaveBeenCalledWith( + expect.objectContaining({ + cliVersion: '0.2.0', + runtimeInstallPolicy: 'auto', + }), + io.io, + ); + }); + it('lets Back from embedding setup return to the model step instead of exiting', async () => { const testIo = makeIo(); const modelResults = [ diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index eb554bde..a76ad6a3 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -412,10 +412,7 @@ function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void { } function setupRuntimeInstallPolicy(args: Extract): 'prompt' | 'auto' | 'never' { - if (args.yes) { - return 'auto'; - } - return args.inputMode === 'disabled' ? 'never' : 'prompt'; + return args.inputMode === 'disabled' && !args.yes ? 'never' : 'auto'; } async function commitSetupConfigChanges(projectDir: string): Promise { diff --git a/scripts/examples-docs.test.mjs b/scripts/examples-docs.test.mjs index bc96e372..2f6c9ef6 100644 --- a/scripts/examples-docs.test.mjs +++ b/scripts/examples-docs.test.mjs @@ -192,7 +192,7 @@ describe('standalone example docs', () => { const quickstart = await readText('docs-site/content/docs/getting-started/quickstart.mdx'); const packageArtifacts = await readText('examples/package-artifacts/README.md'); - assert.match(rootReadme, publicPackagePattern('npm install -g {package}')); + assert.match(rootReadme, publicPackagePattern('pnpm add --global {package}')); assert.match(quickstart, publicPackagePattern('npm install -g {package}')); assert.match(quickstart, /ktx dev runtime install --feature local-embeddings --yes/); assert.match(quickstart, /ktx dev runtime start --feature local-embeddings/); @@ -261,7 +261,7 @@ describe('standalone example docs', () => { assert.match(contextAsCode, /ktx ingest --all --no-input/); assert.match(quickstart, /schema context/); assert.match(primarySources, /context:\n queryHistory:/); - assert.match(rootReadme, /Databases configured: yes \(postgres-warehouse\)/); + assert.match(rootReadme, /`ktx ingest ` \| Build context for one connection/); assert.match(quickstart, /Databases:\n warehouse: deep context complete/); assert.match(quickstart, /Databases configured: yes \(warehouse\)/); assert.match(setupReference, /Databases configured: yes \(postgres-warehouse\)/);