fix(cli): auto-install runtime during setup (#116)

* fix(cli): auto-install runtime during setup

* test: align docs smoke with readme
This commit is contained in:
Andrey Avtomonov 2026-05-16 11:39:43 +02:00 committed by GitHub
parent 42b688e934
commit a72fca2b32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 131 additions and 23 deletions

View file

@ -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({

View file

@ -1022,6 +1022,7 @@ export async function runContextBuild(
const progressDeps: KtxPublicIngestDeps = {
scanProgress: createContextBuildProgressPort(updateSchemaPhase),
ingestProgress: updateIngestPhase,
runtimeIo: io,
onPhaseStart,
onPhaseEnd,
};

View file

@ -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 }),

View file

@ -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 ?? {}),

View file

@ -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<NonNullable<KtxPublicIngestDeps['runIngest']>>(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 }),
);
});

View file

@ -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,

View file

@ -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,
},
});
});

View file

@ -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<number> {
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)

View file

@ -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 = [

View file

@ -412,10 +412,7 @@ function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void {
}
function setupRuntimeInstallPolicy(args: Extract<KtxSetupArgs, { command: 'run' }>): '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<void> {

View file

@ -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 <id>` \| 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\)/);