mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
feat(cli): improve setup progress UX (#69)
This commit is contained in:
parent
d7147f9ca1
commit
754e4a9039
23 changed files with 1125 additions and 346 deletions
|
|
@ -2,6 +2,7 @@ import { cancel, confirm, isCancel, log, spinner } from '@clack/prompts';
|
|||
|
||||
export interface KtxCliSpinner {
|
||||
start(message: string): void;
|
||||
message(message: string): void;
|
||||
stop(message: string): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,6 +231,38 @@ describe('renderContextBuildView', () => {
|
|||
expect(output).toContain('(15s)');
|
||||
});
|
||||
|
||||
it('shows how long a running target has gone without a progress update', () => {
|
||||
const state = initViewState([
|
||||
{ connectionId: 'notion-main', driver: 'notion', operation: 'source-ingest', debugCommand: '', steps: ['source-ingest', 'memory-update'] },
|
||||
]);
|
||||
state.contextSources[0].status = 'running';
|
||||
state.contextSources[0].startedAt = 1_000;
|
||||
state.contextSources[0].elapsedMs = 113_000;
|
||||
state.contextSources[0].progressUpdatedAtMs = 46_000;
|
||||
state.contextSources[0].detailLine = '[45%] No work units to process; finalizing ingest';
|
||||
|
||||
const output = renderContextBuildView(state, { styled: false });
|
||||
|
||||
expect(output).toContain('No work units to process; finalizing ingest');
|
||||
expect(output).toContain('last update 1m08s ago');
|
||||
expect(output).toContain('(1m53s)');
|
||||
});
|
||||
|
||||
it('does not show progress age while updates are recent', () => {
|
||||
const state = initViewState([
|
||||
{ connectionId: 'notion-main', driver: 'notion', operation: 'source-ingest', debugCommand: '', steps: ['source-ingest', 'memory-update'] },
|
||||
]);
|
||||
state.contextSources[0].status = 'running';
|
||||
state.contextSources[0].startedAt = 1_000;
|
||||
state.contextSources[0].elapsedMs = 40_000;
|
||||
state.contextSources[0].progressUpdatedAtMs = 25_000;
|
||||
state.contextSources[0].detailLine = '[45%] Planning work units';
|
||||
|
||||
const output = renderContextBuildView(state, { styled: false });
|
||||
|
||||
expect(output).not.toContain('last update');
|
||||
});
|
||||
|
||||
it('renders completion summary when all targets are done', () => {
|
||||
const state = initViewState([
|
||||
{ connectionId: 'warehouse', driver: 'postgres', operation: 'scan', debugCommand: '', steps: ['scan'] },
|
||||
|
|
@ -480,7 +512,10 @@ describe('runContextBuild', () => {
|
|||
expect.objectContaining({ connectionId: 'warehouse', operation: 'scan' }),
|
||||
expect.objectContaining({ scanMode: 'enriched', detectRelationships: true }),
|
||||
expect.anything(),
|
||||
{},
|
||||
expect.objectContaining({
|
||||
scanProgress: expect.anything(),
|
||||
ingestProgress: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -563,6 +598,43 @@ describe('runContextBuild', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('publishes structured target progress without expanding the compact source rows', async () => {
|
||||
const io = makeIo({ isTTY: true });
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres' },
|
||||
});
|
||||
const progressUpdates: Array<Array<{ connectionId: string; percent?: number; message?: string }>> = [];
|
||||
const executeTarget = vi.fn(async (target, _args, _targetIo, deps) => {
|
||||
await deps.scanProgress?.update(0.37, 'Generating descriptions 3/8 tables', { transient: true });
|
||||
return successResult(target.connectionId, target.driver, target.operation);
|
||||
});
|
||||
|
||||
await runContextBuild(
|
||||
project,
|
||||
{ projectDir: '/tmp/project', inputMode: 'disabled' },
|
||||
io.io,
|
||||
{
|
||||
executeTarget,
|
||||
now: () => 1000,
|
||||
onSourceProgress: (sources) => {
|
||||
progressUpdates.push(
|
||||
sources.map((s) => ({
|
||||
connectionId: s.connectionId,
|
||||
...(s.percent !== undefined ? { percent: s.percent } : {}),
|
||||
...(s.message !== undefined ? { message: s.message } : {}),
|
||||
})),
|
||||
);
|
||||
},
|
||||
sourceProgressThrottleMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
expect(progressUpdates).toContainEqual([
|
||||
{ connectionId: 'warehouse', percent: 37, message: 'Generating descriptions 3/8 tables' },
|
||||
]);
|
||||
expect(io.stdout()).toContain('Generating descriptions 3/8 tables');
|
||||
});
|
||||
|
||||
it('returns report IDs and artifact paths parsed from target output', async () => {
|
||||
const io = makeIo();
|
||||
const project = projectWithConnections({
|
||||
|
|
@ -679,4 +751,27 @@ describe('viewStateFromSourceProgress', () => {
|
|||
expect(output).toContain('dbt-main');
|
||||
expect(output).toContain('ingesting...');
|
||||
});
|
||||
|
||||
it('renders persisted percent and message as compact source-row progress', () => {
|
||||
const state = viewStateFromSourceProgress(
|
||||
[
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
operation: 'scan',
|
||||
status: 'running',
|
||||
startedAtMs: 900,
|
||||
percent: 63,
|
||||
message: 'Building embeddings 2/4 batches',
|
||||
updatedAtMs: 950,
|
||||
},
|
||||
],
|
||||
1000,
|
||||
);
|
||||
|
||||
const output = renderContextBuildView(state, { styled: false });
|
||||
expect(output).toContain('warehouse');
|
||||
expect(output).toContain('63%');
|
||||
expect(output).toContain('Building embeddings 2/4 batches');
|
||||
expect(output.match(/warehouse/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, openSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { KtxProgressPort, KtxProgressUpdateOptions } from '@ktx/context/scan';
|
||||
import type { KtxCliIo } from './index.js';
|
||||
import type { KtxIngestProgressUpdate } from './ingest.js';
|
||||
import type {
|
||||
KtxPublicIngestArgs,
|
||||
KtxPublicIngestDeps,
|
||||
KtxPublicIngestPlanTarget,
|
||||
KtxPublicIngestProject,
|
||||
KtxPublicIngestTargetResult,
|
||||
|
|
@ -25,6 +28,7 @@ export interface ContextBuildTargetState {
|
|||
failureText: string | null;
|
||||
startedAt: number | null;
|
||||
elapsedMs: number;
|
||||
progressUpdatedAtMs: number | null;
|
||||
}
|
||||
|
||||
export interface ContextBuildViewState {
|
||||
|
|
@ -55,6 +59,9 @@ export interface ContextBuildSourceProgressUpdate {
|
|||
status: 'queued' | 'running' | 'done' | 'failed';
|
||||
startedAtMs?: number;
|
||||
elapsedMs?: number;
|
||||
percent?: number;
|
||||
message?: string;
|
||||
updatedAtMs?: number;
|
||||
summaryText?: string;
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +71,7 @@ export interface ContextBuildDeps {
|
|||
setupKeystroke?: (onDetach: () => void, onCtrlC: () => void) => (() => void) | null;
|
||||
onDetach?: () => void;
|
||||
onSourceProgress?: (sources: ContextBuildSourceProgressUpdate[]) => void;
|
||||
sourceProgressThrottleMs?: number;
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
|
@ -118,6 +126,7 @@ function extractPercent(detailLine: string | null): number | null {
|
|||
const BAR_WIDTH = 12;
|
||||
const BAR_FILLED = '█';
|
||||
const BAR_EMPTY = '░';
|
||||
const STALE_PROGRESS_UPDATE_MS = 30_000;
|
||||
|
||||
function renderProgressBar(percent: number, styled: boolean): string {
|
||||
const filled = Math.round((percent / 100) * BAR_WIDTH);
|
||||
|
|
@ -126,6 +135,19 @@ function renderProgressBar(percent: number, styled: boolean): string {
|
|||
return styled ? cyan(bar) : bar;
|
||||
}
|
||||
|
||||
function staleProgressText(target: ContextBuildTargetState, styled: boolean): string | null {
|
||||
if (target.startedAt === null || target.progressUpdatedAtMs === null || target.elapsedMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
const currentTimeMs = target.startedAt + target.elapsedMs;
|
||||
const staleMs = currentTimeMs - target.progressUpdatedAtMs;
|
||||
if (staleMs < STALE_PROGRESS_UPDATE_MS) {
|
||||
return null;
|
||||
}
|
||||
const text = `last update ${formatDuration(staleMs)} ago`;
|
||||
return styled ? dim(text) : text;
|
||||
}
|
||||
|
||||
function targetDetail(target: ContextBuildTargetState, styled: boolean): string {
|
||||
if (target.status === 'done') {
|
||||
const parts: string[] = [];
|
||||
|
|
@ -147,6 +169,8 @@ function targetDetail(target: ContextBuildTargetState, styled: boolean): string
|
|||
parts.push(`${renderProgressBar(percent, styled)} ${percent}%`);
|
||||
}
|
||||
parts.push(progressText);
|
||||
const stale = staleProgressText(target, styled);
|
||||
if (stale) parts.push(stale);
|
||||
if (elapsed) parts.push(styled ? dim(elapsed) : elapsed);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
|
@ -309,15 +333,42 @@ function createCaptureIo(onProgress: (message: string) => void, isTTY: boolean):
|
|||
|
||||
// --- Source progress helpers ---
|
||||
|
||||
function progressFieldsFromDetailLine(
|
||||
detailLine: string | null,
|
||||
updatedAtMs: number | null,
|
||||
): Pick<ContextBuildSourceProgressUpdate, 'percent' | 'message' | 'updatedAtMs'> {
|
||||
if (!detailLine) return {};
|
||||
const percent = extractPercent(detailLine);
|
||||
const message = detailLine.replace(/^\[\d+%\]\s*/, '');
|
||||
return {
|
||||
...(percent !== null ? { percent } : {}),
|
||||
...(message ? { message } : {}),
|
||||
...(updatedAtMs !== null ? { updatedAtMs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function detailLineFromProgressSource(source: ContextBuildSourceProgressUpdate): string | null {
|
||||
if (!source.message) return null;
|
||||
if (typeof source.percent === 'number' && Number.isFinite(source.percent)) {
|
||||
const percent = Math.max(0, Math.min(100, Math.round(source.percent)));
|
||||
return `[${percent}%] ${source.message}`;
|
||||
}
|
||||
return source.message;
|
||||
}
|
||||
|
||||
function collectSourceProgress(targets: ContextBuildTargetState[]): ContextBuildSourceProgressUpdate[] {
|
||||
return targets.map((t) => ({
|
||||
connectionId: t.target.connectionId,
|
||||
operation: t.target.operation,
|
||||
status: t.status,
|
||||
...(t.startedAt !== null ? { startedAtMs: t.startedAt } : {}),
|
||||
...(t.elapsedMs > 0 ? { elapsedMs: t.elapsedMs } : {}),
|
||||
...(t.summaryText ? { summaryText: t.summaryText } : {}),
|
||||
}));
|
||||
return targets.map((t) => {
|
||||
const progressFields = progressFieldsFromDetailLine(t.detailLine, t.progressUpdatedAtMs);
|
||||
return {
|
||||
connectionId: t.target.connectionId,
|
||||
operation: t.target.operation,
|
||||
status: t.status,
|
||||
...(t.startedAt !== null ? { startedAtMs: t.startedAt } : {}),
|
||||
...(t.elapsedMs > 0 ? { elapsedMs: t.elapsedMs } : {}),
|
||||
...progressFields,
|
||||
...(t.summaryText ? { summaryText: t.summaryText } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function viewStateFromSourceProgress(
|
||||
|
|
@ -328,11 +379,12 @@ export function viewStateFromSourceProgress(
|
|||
const makeTarget = (s: ContextBuildSourceProgressUpdate): ContextBuildTargetState => ({
|
||||
target: { connectionId: s.connectionId, driver: '', operation: s.operation, debugCommand: '', steps: [] },
|
||||
status: s.status,
|
||||
detailLine: null,
|
||||
detailLine: detailLineFromProgressSource(s),
|
||||
summaryText: s.summaryText ?? null,
|
||||
failureText: null,
|
||||
startedAt: s.startedAtMs ?? null,
|
||||
elapsedMs: s.status === 'running' && s.startedAtMs ? now - s.startedAtMs : (s.elapsedMs ?? 0),
|
||||
progressUpdatedAtMs: s.updatedAtMs ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -453,6 +505,7 @@ function makeTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTargetS
|
|||
failureText: null,
|
||||
startedAt: null,
|
||||
elapsedMs: 0,
|
||||
progressUpdatedAtMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -534,6 +587,34 @@ export function initViewState(targets: KtxPublicIngestPlanTarget[]): ContextBuil
|
|||
};
|
||||
}
|
||||
|
||||
function formatProgressDetail(update: Pick<KtxIngestProgressUpdate, 'percent' | 'message'>): string {
|
||||
const percent = Math.max(0, Math.min(100, Math.round(update.percent)));
|
||||
return `[${percent}%] ${update.message}`;
|
||||
}
|
||||
|
||||
function createContextBuildProgressPort(
|
||||
onProgress: (update: KtxIngestProgressUpdate) => void,
|
||||
state: { progress: number } = { progress: 0 },
|
||||
start = 0,
|
||||
weight = 1,
|
||||
): KtxProgressPort {
|
||||
return {
|
||||
async update(value: number, message?: string, options?: KtxProgressUpdateOptions): Promise<void> {
|
||||
const absoluteValue = start + Math.max(0, Math.min(1, value)) * weight;
|
||||
state.progress = Math.max(state.progress, Math.min(1, absoluteValue));
|
||||
if (!message) return;
|
||||
onProgress({
|
||||
percent: Math.max(0, Math.min(100, Math.round(state.progress * 100))),
|
||||
message,
|
||||
...(options?.transient !== undefined ? { transient: options.transient } : {}),
|
||||
});
|
||||
},
|
||||
startPhase(phaseWeight: number): KtxProgressPort {
|
||||
return createContextBuildProgressPort(onProgress, state, state.progress, weight * phaseWeight);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runContextBuild(
|
||||
project: KtxPublicIngestProject,
|
||||
args: ContextBuildArgs,
|
||||
|
|
@ -572,6 +653,19 @@ export async function runContextBuild(
|
|||
const execTarget = deps.executeTarget ?? executePublicIngestTarget;
|
||||
const reportIds = new Set<string>();
|
||||
const artifactPaths = new Set<string>();
|
||||
const sourceProgressThrottleMs = deps.sourceProgressThrottleMs ?? 750;
|
||||
let lastSourceProgressPublishedAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
const publishSourceProgress = (force = false): boolean => {
|
||||
if (!deps.onSourceProgress) return false;
|
||||
const now = nowFn();
|
||||
if (!force && now - lastSourceProgressPublishedAt < sourceProgressThrottleMs) {
|
||||
return false;
|
||||
}
|
||||
lastSourceProgressPublishedAt = now;
|
||||
deps.onSourceProgress(collectSourceProgress(orderedTargets));
|
||||
return true;
|
||||
};
|
||||
|
||||
let detached = false;
|
||||
let exiting = false;
|
||||
|
|
@ -623,20 +717,34 @@ export async function runContextBuild(
|
|||
targetState.status = 'running';
|
||||
targetState.startedAt = nowFn();
|
||||
paint(true);
|
||||
deps.onSourceProgress?.(collectSourceProgress(orderedTargets));
|
||||
publishSourceProgress(true);
|
||||
let hasPendingProgressPublish = false;
|
||||
|
||||
const updateTargetProgress = (update: KtxIngestProgressUpdate) => {
|
||||
targetState.detailLine = formatProgressDetail(update);
|
||||
targetState.progressUpdatedAtMs = nowFn();
|
||||
paint(true);
|
||||
hasPendingProgressPublish = !publishSourceProgress(false);
|
||||
};
|
||||
|
||||
const capture = createCaptureIo(
|
||||
(message) => {
|
||||
targetState.detailLine = message;
|
||||
targetState.progressUpdatedAtMs = nowFn();
|
||||
paint(true);
|
||||
hasPendingProgressPublish = !publishSourceProgress(false);
|
||||
},
|
||||
false,
|
||||
);
|
||||
const progressDeps: KtxPublicIngestDeps = {
|
||||
scanProgress: createContextBuildProgressPort(updateTargetProgress),
|
||||
ingestProgress: updateTargetProgress,
|
||||
};
|
||||
|
||||
let result: KtxPublicIngestTargetResult | null = null;
|
||||
let thrownError: unknown = null;
|
||||
try {
|
||||
result = await execTarget(targetState.target, runArgs, capture.io, {});
|
||||
result = await execTarget(targetState.target, runArgs, capture.io, progressDeps);
|
||||
} catch (error) {
|
||||
if (exiting) {
|
||||
throw error;
|
||||
|
|
@ -644,6 +752,10 @@ export async function runContextBuild(
|
|||
thrownError = error;
|
||||
}
|
||||
|
||||
if (hasPendingProgressPublish) {
|
||||
publishSourceProgress(true);
|
||||
}
|
||||
|
||||
targetState.elapsedMs = nowFn() - (targetState.startedAt ?? nowFn());
|
||||
const failed = thrownError !== null || result?.steps.some((s) => s.status === 'failed') === true;
|
||||
targetState.status = failed ? 'failed' : 'done';
|
||||
|
|
@ -669,7 +781,7 @@ export async function runContextBuild(
|
|||
if (failed) hasFailure = true;
|
||||
|
||||
paint(true);
|
||||
deps.onSourceProgress?.(collectSourceProgress(orderedTargets));
|
||||
publishSourceProgress(true);
|
||||
}
|
||||
} finally {
|
||||
if (spinnerInterval) clearInterval(spinnerInterval);
|
||||
|
|
|
|||
|
|
@ -103,6 +103,88 @@ describe('runKtxIngest', () => {
|
|||
expect(statusIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('emits structured progress for non-TTY local ingest runs', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await writeWarehouseConfig(projectDir);
|
||||
const progressEvents: Array<{ percent: number; message: string; transient?: boolean }> = [];
|
||||
const runLocal = vi.fn(async (input: RunLocalIngestOptions): Promise<LocalIngestResult> => {
|
||||
input.memoryFlow?.emit({ type: 'source_acquired', adapter: 'fake', trigger: 'manual_resync', fileCount: 2 });
|
||||
input.memoryFlow?.emit({ type: 'chunks_planned', chunkCount: 2, workUnitCount: 2, evictionCount: 0 });
|
||||
input.memoryFlow?.emit({ type: 'work_unit_started', unitKey: 'orders', skills: [], stepBudget: 4 });
|
||||
input.memoryFlow?.emit({ type: 'work_unit_step', unitKey: 'orders', stepIndex: 2, stepBudget: 4 });
|
||||
return completedLocalBundleRun(input, 'cli-local-progress-1');
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxIngest(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'fake',
|
||||
outputMode: 'plain',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
runLocalIngest: runLocal,
|
||||
jobIdFactory: () => 'cli-local-progress-1',
|
||||
progress: (event) => progressEvents.push(event),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(progressEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ percent: 5, message: 'Fetching source files for warehouse/fake' },
|
||||
{ percent: 15, message: 'Fetched 2 source files from fake' },
|
||||
{ percent: 45, message: 'Planned 2 work units' },
|
||||
expect.objectContaining({
|
||||
message: 'Processing work units: 0/2 complete, 1 active; latest orders step 2/4',
|
||||
transient: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(io.stderr()).not.toContain('[15%] Fetched 2 source files from fake');
|
||||
});
|
||||
|
||||
it('describes zero-work-unit ingest progress as finalizing instead of appearing half-planned', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await writeWarehouseConfig(projectDir);
|
||||
const progressEvents: Array<{ percent: number; message: string; transient?: boolean }> = [];
|
||||
const runLocal = vi.fn(async (input: RunLocalIngestOptions): Promise<LocalIngestResult> => {
|
||||
input.memoryFlow?.emit({ type: 'source_acquired', adapter: 'fake', trigger: 'manual_resync', fileCount: 2 });
|
||||
input.memoryFlow?.emit({ type: 'chunks_planned', chunkCount: 0, workUnitCount: 0, evictionCount: 0 });
|
||||
return completedLocalBundleRun(input, 'cli-local-zero-progress-1');
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxIngest(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'fake',
|
||||
outputMode: 'plain',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
runLocalIngest: runLocal,
|
||||
jobIdFactory: () => 'cli-local-zero-progress-1',
|
||||
progress: (event) => progressEvents.push(event),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(progressEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ percent: 80, message: 'No work units to process; finalizing ingest' },
|
||||
]),
|
||||
);
|
||||
expect(progressEvents).not.toContainEqual({ percent: 45, message: 'Planned 0 work units' });
|
||||
});
|
||||
|
||||
it('prints provider setup guidance when a skip-llm setup project runs ingest', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const setupIo = makeIo();
|
||||
|
|
@ -421,6 +503,65 @@ describe('runKtxIngest', () => {
|
|||
expect(io.stdout()).not.toContain('status=running job=metabase-child-1');
|
||||
});
|
||||
|
||||
it('emits structured progress for Metabase fan-out without writing progress to JSON output', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await writeMetabaseConfig(projectDir);
|
||||
const io = makeIo();
|
||||
const progressEvents: Array<{ percent: number; message: string }> = [];
|
||||
|
||||
await expect(
|
||||
runKtxIngest(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
adapter: 'metabase',
|
||||
outputMode: 'json',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
progress: (event) => progressEvents.push(event),
|
||||
runLocalMetabaseIngest: async (input) => {
|
||||
input.progress?.onMetabaseFanoutPlanned?.({
|
||||
metabaseConnectionId: 'prod-metabase',
|
||||
children: [{ metabaseDatabaseId: 1, targetConnectionId: 'warehouse_a' }],
|
||||
});
|
||||
input.progress?.onMetabaseChildStarted?.({
|
||||
metabaseConnectionId: 'prod-metabase',
|
||||
metabaseDatabaseId: 1,
|
||||
targetConnectionId: 'warehouse_a',
|
||||
jobId: 'metabase-child-1',
|
||||
});
|
||||
input.progress?.onMetabaseChildCompleted?.({
|
||||
metabaseConnectionId: 'prod-metabase',
|
||||
metabaseDatabaseId: 1,
|
||||
targetConnectionId: 'warehouse_a',
|
||||
jobId: 'metabase-child-1',
|
||||
status: 'done',
|
||||
});
|
||||
return {
|
||||
metabaseConnectionId: 'prod-metabase',
|
||||
status: 'all_succeeded',
|
||||
totals: { workUnits: 0, failedWorkUnits: 0 },
|
||||
children: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(progressEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ percent: 5, message: 'Checking Metabase mappings for prod-metabase' },
|
||||
{ percent: 10, message: 'Metabase prod-metabase: 1 mapped database' },
|
||||
{ percent: 25, message: 'Metabase database 1 -> warehouse_a running' },
|
||||
{ percent: 90, message: 'Metabase database 1 -> warehouse_a done' },
|
||||
]),
|
||||
);
|
||||
expect(io.stdout()).toContain('"status": "all_succeeded"');
|
||||
expect(io.stderr()).not.toContain('Metabase ingest: prod-metabase');
|
||||
});
|
||||
|
||||
it('runs Metabase scheduled ingest through the public CLI command path with real fan-out', async () => {
|
||||
const projectDir = join(tempDir, 'metabase-cli-project');
|
||||
await writeWarehouseConfig(projectDir);
|
||||
|
|
|
|||
|
|
@ -67,7 +67,13 @@ interface KtxIngestIo {
|
|||
stderr: { write(chunk: string): void };
|
||||
}
|
||||
|
||||
interface KtxIngestDeps {
|
||||
export interface KtxIngestProgressUpdate {
|
||||
percent: number;
|
||||
message: string;
|
||||
transient?: boolean;
|
||||
}
|
||||
|
||||
export interface KtxIngestDeps {
|
||||
jobIdFactory?: () => string;
|
||||
now?: () => Date;
|
||||
createAdapters?: typeof createKtxCliLocalIngestAdapters;
|
||||
|
|
@ -88,6 +94,7 @@ interface KtxIngestDeps {
|
|||
| 'logger'
|
||||
| 'pullConfigOptions'
|
||||
>;
|
||||
progress?: (update: KtxIngestProgressUpdate) => void;
|
||||
}
|
||||
|
||||
function reportStatus(report: IngestReportSnapshot): 'done' | 'error' {
|
||||
|
|
@ -145,12 +152,18 @@ function pluralize(count: number, singular: string, plural = `${singular}s`): st
|
|||
function createMetabaseFanoutProgress(
|
||||
connectionId: string,
|
||||
io: KtxIngestIo,
|
||||
onProgress?: (update: KtxIngestProgressUpdate) => void,
|
||||
): LocalMetabaseFanoutProgress {
|
||||
io.stderr.write(`Metabase ingest: ${connectionId}\n`);
|
||||
io.stderr.write('Checking mappings and scheduled-pull targets...\n');
|
||||
onProgress?.({ percent: 5, message: `Checking Metabase mappings for ${connectionId}` });
|
||||
return {
|
||||
onMetabaseFanoutPlanned(event) {
|
||||
io.stderr.write(`Targets: ${pluralize(event.children.length, 'mapped database')}\n`);
|
||||
onProgress?.({
|
||||
percent: 10,
|
||||
message: `Metabase ${event.metabaseConnectionId}: ${pluralize(event.children.length, 'mapped database')}`,
|
||||
});
|
||||
for (const child of event.children) {
|
||||
io.stderr.write(`- database=${child.metabaseDatabaseId} target=${child.targetConnectionId} status=queued\n`);
|
||||
}
|
||||
|
|
@ -159,11 +172,19 @@ function createMetabaseFanoutProgress(
|
|||
io.stderr.write(
|
||||
`- database=${event.metabaseDatabaseId} target=${event.targetConnectionId} status=running job=${event.jobId}\n`,
|
||||
);
|
||||
onProgress?.({
|
||||
percent: 25,
|
||||
message: `Metabase database ${event.metabaseDatabaseId} -> ${event.targetConnectionId} running`,
|
||||
});
|
||||
},
|
||||
onMetabaseChildCompleted(event) {
|
||||
io.stderr.write(
|
||||
`- database=${event.metabaseDatabaseId} target=${event.targetConnectionId} status=${event.status} job=${event.jobId}\n`,
|
||||
);
|
||||
onProgress?.({
|
||||
percent: 90,
|
||||
message: `Metabase database ${event.metabaseDatabaseId} -> ${event.targetConnectionId} ${event.status}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -231,6 +252,12 @@ function plainIngestEventProgress(
|
|||
case 'diff_computed':
|
||||
return { percent: 35, message: `Computed source diff ${formatDiffProgress(event)}` };
|
||||
case 'chunks_planned':
|
||||
if (event.workUnitCount === 0) {
|
||||
return {
|
||||
percent: 80,
|
||||
message: 'No work units to process; finalizing ingest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
percent: 45,
|
||||
message: `Planned ${pluralize(event.workUnitCount, 'work unit')}`,
|
||||
|
|
@ -296,34 +323,22 @@ function shouldWritePlainIngestProgress(
|
|||
return outputMode === 'plain' && io.stdout.isTTY === true && env.CI !== 'true';
|
||||
}
|
||||
|
||||
function createPlainIngestProgressRenderer(
|
||||
function createPlainIngestProgressObserver(
|
||||
args: Extract<KtxIngestArgs, { command: 'run' }>,
|
||||
io: KtxIngestIo,
|
||||
): { start(): void; update(snapshot: MemoryFlowReplayInput): void; flush(): void } {
|
||||
onProgress: (update: KtxIngestProgressUpdate) => void,
|
||||
): { start(): void; update(snapshot: MemoryFlowReplayInput): void } {
|
||||
let printedEvents = 0;
|
||||
let lastPercent = 0;
|
||||
let printedCompletion = false;
|
||||
let hasPendingTransient = false;
|
||||
|
||||
const flush = () => {
|
||||
if (!hasPendingTransient) {
|
||||
return;
|
||||
}
|
||||
io.stderr.write('\n');
|
||||
hasPendingTransient = false;
|
||||
};
|
||||
|
||||
const write = (percent: number, message: string, options?: { transient?: boolean }) => {
|
||||
const nextPercent = Math.max(lastPercent, Math.max(0, Math.min(100, percent)));
|
||||
lastPercent = nextPercent;
|
||||
const line = `[${nextPercent}%] ${message}`;
|
||||
if (options?.transient === true) {
|
||||
io.stderr.write(`\r${line}\u001b[K`);
|
||||
hasPendingTransient = true;
|
||||
return;
|
||||
}
|
||||
flush();
|
||||
io.stderr.write(`${line}\n`);
|
||||
onProgress({
|
||||
percent: nextPercent,
|
||||
message,
|
||||
...(options?.transient !== undefined ? { transient: options.transient } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -347,6 +362,41 @@ function createPlainIngestProgressRenderer(
|
|||
write(100, snapshot.status === 'done' ? 'Ingest completed' : 'Ingest failed');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPlainIngestProgressRenderer(
|
||||
args: Extract<KtxIngestArgs, { command: 'run' }>,
|
||||
io: KtxIngestIo,
|
||||
): { start(): void; update(snapshot: MemoryFlowReplayInput): void; flush(): void } {
|
||||
let hasPendingTransient = false;
|
||||
|
||||
const flush = () => {
|
||||
if (!hasPendingTransient) {
|
||||
return;
|
||||
}
|
||||
io.stderr.write('\n');
|
||||
hasPendingTransient = false;
|
||||
};
|
||||
|
||||
const observer = createPlainIngestProgressObserver(args, (update) => {
|
||||
const line = `[${update.percent}%] ${update.message}`;
|
||||
if (update.transient === true) {
|
||||
io.stderr.write(`\r${line}\u001b[K`);
|
||||
hasPendingTransient = true;
|
||||
return;
|
||||
}
|
||||
flush();
|
||||
io.stderr.write(`${line}\n`);
|
||||
});
|
||||
|
||||
return {
|
||||
start() {
|
||||
observer.start();
|
||||
},
|
||||
update(snapshot) {
|
||||
observer.update(snapshot);
|
||||
},
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
|
@ -544,7 +594,15 @@ export async function runKtxIngest(
|
|||
if (args.adapter === 'metabase') {
|
||||
const executeMetabaseFanout = deps.runLocalMetabaseIngest ?? runLocalMetabaseIngest;
|
||||
const progress =
|
||||
args.outputMode === 'json' ? undefined : createMetabaseFanoutProgress(args.connectionId, io);
|
||||
args.outputMode === 'json' && !deps.progress
|
||||
? undefined
|
||||
: createMetabaseFanoutProgress(
|
||||
args.connectionId,
|
||||
args.outputMode === 'json'
|
||||
? { ...io, stderr: { write: () => undefined } }
|
||||
: io,
|
||||
deps.progress,
|
||||
);
|
||||
const result = await executeMetabaseFanout({
|
||||
project,
|
||||
adapters: createAdapters(project, adapterOptions),
|
||||
|
|
@ -573,8 +631,13 @@ export async function runKtxIngest(
|
|||
const plainProgress = shouldWritePlainIngestProgress(runOutputMode, io, env)
|
||||
? createPlainIngestProgressRenderer(args, io)
|
||||
: null;
|
||||
const structuredProgress = deps.progress
|
||||
? createPlainIngestProgressObserver(args, deps.progress)
|
||||
: null;
|
||||
const initialMemoryFlow =
|
||||
shouldUseLiveViz || plainProgress ? initialRunMemoryFlowInput(args, jobId ?? 'pending') : undefined;
|
||||
shouldUseLiveViz || plainProgress || structuredProgress
|
||||
? initialRunMemoryFlowInput(args, jobId ?? 'pending')
|
||||
: undefined;
|
||||
let latestMemoryFlowSnapshot: MemoryFlowReplayInput | null = initialMemoryFlow ?? null;
|
||||
|
||||
if (shouldUseLiveViz && initialMemoryFlow && isTuiCapableIo(io)) {
|
||||
|
|
@ -595,11 +658,13 @@ export async function runKtxIngest(
|
|||
return;
|
||||
}
|
||||
plainProgress?.update(snapshot);
|
||||
structuredProgress?.update(snapshot);
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
plainProgress?.start();
|
||||
structuredProgress?.start();
|
||||
|
||||
try {
|
||||
const result = await executeLocalIngest({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { type KtxLocalProject, type KtxProjectConnectionConfig, loadKtxProject } from '@ktx/context/project';
|
||||
import type { KtxProgressPort } from '@ktx/context/scan';
|
||||
import type { KtxCliIo } from './index.js';
|
||||
import type { KtxIngestArgs } from './ingest.js';
|
||||
import type { KtxScanArgs } from './scan.js';
|
||||
import type { KtxIngestArgs, KtxIngestDeps, KtxIngestProgressUpdate } from './ingest.js';
|
||||
import type { KtxScanArgs, KtxScanDeps } from './scan.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
||||
profileMark('module:public-ingest');
|
||||
|
|
@ -59,8 +60,10 @@ export type KtxPublicIngestProject = Pick<KtxLocalProject, 'projectDir' | 'confi
|
|||
|
||||
export interface KtxPublicIngestDeps {
|
||||
loadProject?: (options: Parameters<typeof loadKtxProject>[0]) => Promise<KtxPublicIngestProject>;
|
||||
runScan?: (args: KtxScanArgs, io: KtxCliIo) => Promise<number>;
|
||||
runIngest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise<number>;
|
||||
runScan?: (args: KtxScanArgs, io: KtxCliIo, deps?: KtxScanDeps) => Promise<number>;
|
||||
runIngest?: (args: KtxIngestArgs, io: KtxCliIo, deps?: KtxIngestDeps) => Promise<number>;
|
||||
scanProgress?: KtxProgressPort;
|
||||
ingestProgress?: (update: KtxIngestProgressUpdate) => void;
|
||||
}
|
||||
|
||||
const sourceAdapterByDriver = new Map<string, string>([
|
||||
|
|
@ -247,33 +250,35 @@ export async function executePublicIngestTarget(
|
|||
): Promise<KtxPublicIngestTargetResult> {
|
||||
if (target.operation === 'scan') {
|
||||
const { runKtxScan } = await import('./scan.js');
|
||||
const exitCode = await (deps.runScan ?? runKtxScan)(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: args.projectDir,
|
||||
connectionId: target.connectionId,
|
||||
mode: args.scanMode ?? 'structural',
|
||||
detectRelationships: args.detectRelationships ?? false,
|
||||
dryRun: false,
|
||||
},
|
||||
io,
|
||||
);
|
||||
const scanArgs: KtxScanArgs = {
|
||||
command: 'run',
|
||||
projectDir: args.projectDir,
|
||||
connectionId: target.connectionId,
|
||||
mode: args.scanMode ?? 'structural',
|
||||
detectRelationships: args.detectRelationships ?? false,
|
||||
dryRun: false,
|
||||
};
|
||||
const runScan = deps.runScan ?? runKtxScan;
|
||||
const exitCode = deps.scanProgress
|
||||
? await runScan(scanArgs, io, { progress: deps.scanProgress })
|
||||
: await runScan(scanArgs, io);
|
||||
return markTargetResult(target, exitCode === 0 ? 'done' : 'failed');
|
||||
}
|
||||
|
||||
const { runKtxIngest } = await import('./ingest.js');
|
||||
const exitCode = await (deps.runIngest ?? runKtxIngest)(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: args.projectDir,
|
||||
connectionId: target.connectionId,
|
||||
adapter: target.adapter ?? target.driver,
|
||||
...(target.sourceDir ? { sourceDir: target.sourceDir } : {}),
|
||||
outputMode: sourceIngestOutputMode(args, io),
|
||||
inputMode: args.inputMode,
|
||||
},
|
||||
io,
|
||||
);
|
||||
const ingestArgs: KtxIngestArgs = {
|
||||
command: 'run',
|
||||
projectDir: args.projectDir,
|
||||
connectionId: target.connectionId,
|
||||
adapter: target.adapter ?? target.driver,
|
||||
...(target.sourceDir ? { sourceDir: target.sourceDir } : {}),
|
||||
outputMode: sourceIngestOutputMode(args, io),
|
||||
inputMode: args.inputMode,
|
||||
};
|
||||
const runIngest = deps.runIngest ?? runKtxIngest;
|
||||
const exitCode = deps.ingestProgress
|
||||
? await runIngest(ingestArgs, io, { progress: deps.ingestProgress })
|
||||
: await runIngest(ingestArgs, io);
|
||||
return markTargetResult(target, exitCode === 0 ? 'done' : 'failed');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -570,6 +570,59 @@ describe('runKtxScan', () => {
|
|||
expect(io.stdout()).toContain('[55%] Semantic layer comparison found 5 changes across 18 tables');
|
||||
});
|
||||
|
||||
it('uses injected structured progress without requiring TTY progress output', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const progressEvents: Array<{ progress: number; message?: string; transient?: boolean }> = [];
|
||||
const structuredProgress = {
|
||||
async update(progress: number, message?: string, options?: { transient?: boolean }) {
|
||||
progressEvents.push({
|
||||
progress,
|
||||
...(message !== undefined ? { message } : {}),
|
||||
...(options?.transient !== undefined ? { transient: options.transient } : {}),
|
||||
});
|
||||
},
|
||||
startPhase() {
|
||||
return structuredProgress;
|
||||
},
|
||||
};
|
||||
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
|
||||
await input.progress?.update(0.42, 'Generating descriptions 4/10 tables', { transient: true });
|
||||
return {
|
||||
runId: 'scan-run-1',
|
||||
status: 'done',
|
||||
done: true,
|
||||
connectionId: 'warehouse',
|
||||
mode: 'structural',
|
||||
dryRun: false,
|
||||
syncId: 'sync-1',
|
||||
report,
|
||||
};
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxScan(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
connectionId: 'warehouse',
|
||||
mode: 'structural',
|
||||
detectRelationships: false,
|
||||
dryRun: false,
|
||||
},
|
||||
io.io,
|
||||
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters, progress: structuredProgress },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(progressEvents).toContainEqual({
|
||||
progress: 0.42,
|
||||
message: 'Generating descriptions 4/10 tables',
|
||||
transient: true,
|
||||
});
|
||||
expect(io.stdout()).not.toContain('[42%] Generating descriptions 4/10 tables');
|
||||
});
|
||||
|
||||
it('updates transient TTY progress messages in place', async () => {
|
||||
const io = makeIo({ isTTY: true });
|
||||
const previousCi = process.env.CI;
|
||||
|
|
|
|||
|
|
@ -26,9 +26,10 @@ export interface KtxScanArgs {
|
|||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
}
|
||||
|
||||
interface KtxScanDeps {
|
||||
export interface KtxScanDeps {
|
||||
runLocalScan?: typeof runLocalScan;
|
||||
createLocalIngestAdapters?: typeof createKtxCliLocalIngestAdapters;
|
||||
progress?: KtxProgressPort;
|
||||
}
|
||||
|
||||
function shouldUseStyledOutput(io: KtxCliIo): boolean {
|
||||
|
|
@ -257,7 +258,8 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
|
|||
args.mode !== 'structural' || args.detectRelationships
|
||||
? await createKtxCliScanConnector(project, args.connectionId)
|
||||
: undefined;
|
||||
const progress = createCliScanProgress(io);
|
||||
const cliProgress = deps.progress ? null : createCliScanProgress(io);
|
||||
const progress = deps.progress ?? cliProgress;
|
||||
try {
|
||||
const result = await (deps.runLocalScan ?? runLocalScan)({
|
||||
project,
|
||||
|
|
@ -272,12 +274,12 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
|
|||
...(args.databaseIntrospectionUrl ? { databaseIntrospectionUrl: args.databaseIntrospectionUrl } : {}),
|
||||
...(managedDaemon ? { managedDaemon } : {}),
|
||||
}),
|
||||
progress,
|
||||
...(progress ? { progress } : {}),
|
||||
});
|
||||
progress.flush();
|
||||
cliProgress?.flush();
|
||||
writeRunSummary(result.report, args.projectDir, io);
|
||||
} finally {
|
||||
progress.flush();
|
||||
cliProgress?.flush();
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { cancel, confirm, isCancel, multiselect, select } from '@clack/prompts';
|
||||
import {
|
||||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
serializeKtxProjectConfig,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { withMenuOptionsSpacing, withMultiselectNavigation } from './prompt-navigation.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
import { withMultiselectNavigation } from './prompt-navigation.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
|
||||
export type KtxAgentTarget = 'claude-code' | 'codex' | 'cursor' | 'opencode' | 'universal';
|
||||
export type KtxAgentScope = 'project' | 'global';
|
||||
|
|
@ -238,10 +240,10 @@ export async function removeKtxAgentInstall(projectDir: string, io: KtxCliIo): P
|
|||
}
|
||||
|
||||
export interface KtxSetupAgentsPromptAdapter {
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
multiselect(options: {
|
||||
message: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
options: KtxSetupPromptOption[];
|
||||
required?: boolean;
|
||||
}): Promise<string[]>;
|
||||
cancel(message: string): void;
|
||||
|
|
@ -252,38 +254,11 @@ export interface KtxSetupAgentsDeps {
|
|||
}
|
||||
|
||||
function createPromptAdapter(): KtxSetupAgentsPromptAdapter {
|
||||
return {
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return 'back';
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
async multiselect(options) {
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
const selected = [...value] as string[];
|
||||
if (selected.length === 0 && !options.required) {
|
||||
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
if (!skipConfirmed) continue;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
return createKtxSetupPromptAdapter({
|
||||
selectCancelValue: 'back',
|
||||
multiselectCancelValue: 'back',
|
||||
confirmEmptyOptionalMultiselect: true,
|
||||
});
|
||||
}
|
||||
|
||||
const targetDisplayNames: Record<KtxAgentTarget, string> = {
|
||||
|
|
|
|||
|
|
@ -142,6 +142,16 @@ describe('setup context build state', () => {
|
|||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
|
||||
sourceProgress: [
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
operation: 'scan',
|
||||
status: 'running',
|
||||
percent: 42,
|
||||
message: 'Generating descriptions 4/10 tables',
|
||||
updatedAtMs: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const state = await readKtxSetupContextState(tempDir);
|
||||
|
|
@ -155,6 +165,16 @@ describe('setup context build state', () => {
|
|||
status: `ktx status --project-dir ${tempDir}`,
|
||||
resume: `ktx setup --project-dir ${tempDir}`,
|
||||
},
|
||||
sourceProgress: [
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
operation: 'scan',
|
||||
status: 'running',
|
||||
percent: 42,
|
||||
message: 'Generating descriptions 4/10 tables',
|
||||
updatedAtMs: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(state)).not.toContain('DATABASE_URL');
|
||||
expect(JSON.stringify(state)).not.toContain('NOTION_TOKEN');
|
||||
|
|
@ -547,6 +567,79 @@ describe('setup context build state', () => {
|
|||
expect(output).not.toContain('KTX context built: detached');
|
||||
});
|
||||
|
||||
it('re-renders the compact progress view when watched source messages change', async () => {
|
||||
await writeReadyProject(tempDir);
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-progress-message',
|
||||
status: 'detached',
|
||||
startedAt: '2026-05-09T10:00:00.000Z',
|
||||
updatedAt: '2026-05-09T10:00:00.000Z',
|
||||
primarySourceConnectionIds: ['warehouse'],
|
||||
contextSourceConnectionIds: [],
|
||||
reportIds: [],
|
||||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-progress-message'),
|
||||
sourceProgress: [
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
operation: 'scan' as const,
|
||||
status: 'running' as const,
|
||||
startedAtMs: Date.now() - 5000,
|
||||
percent: 35,
|
||||
message: 'Inspecting database schema',
|
||||
updatedAtMs: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
const io = makeIo();
|
||||
let polls = 0;
|
||||
const updateRun = async () => {
|
||||
polls++;
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-progress-message',
|
||||
status: polls === 1 ? 'detached' : 'completed',
|
||||
startedAt: '2026-05-09T10:00:00.000Z',
|
||||
updatedAt: polls === 1 ? '2026-05-09T10:00:01.000Z' : '2026-05-09T10:00:02.000Z',
|
||||
...(polls === 1 ? {} : { completedAt: '2026-05-09T10:00:02.000Z' }),
|
||||
primarySourceConnectionIds: ['warehouse'],
|
||||
contextSourceConnectionIds: [],
|
||||
reportIds: [],
|
||||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-progress-message'),
|
||||
sourceProgress: [
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
operation: 'scan' as const,
|
||||
status: polls === 1 ? ('running' as const) : ('done' as const),
|
||||
startedAtMs: Date.now() - 5000,
|
||||
elapsedMs: polls === 1 ? undefined : 6000,
|
||||
percent: polls === 1 ? 76 : undefined,
|
||||
message: polls === 1 ? 'Building embeddings 3/4 batches' : undefined,
|
||||
updatedAtMs: polls === 1 ? 2000 : undefined,
|
||||
summaryText: polls === 1 ? undefined : '42 tables',
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
await expect(
|
||||
runKtxSetupContextStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', autoWatch: true },
|
||||
io.io,
|
||||
{
|
||||
sleep: updateRun,
|
||||
watchIntervalMs: 1,
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-progress-message' });
|
||||
|
||||
expect(io.stdout()).toContain('Inspecting database schema');
|
||||
expect(io.stdout()).toContain('Building embeddings 3/4 batches');
|
||||
expect(io.stdout()).toContain('warehouse');
|
||||
});
|
||||
|
||||
it('supports d to detach from the progress watch view', async () => {
|
||||
await writeReadyProject(tempDir);
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { cancel, isCancel, select } from '@clack/prompts';
|
||||
import {
|
||||
type KtxLocalProject,
|
||||
loadKtxProject,
|
||||
|
|
@ -19,8 +18,10 @@ import {
|
|||
runContextBuild,
|
||||
viewStateFromSourceProgress,
|
||||
} from './context-build-view.js';
|
||||
import { withMenuOptionsSpacing } from './prompt-navigation.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
|
||||
export type KtxSetupContextBuildStatus =
|
||||
| 'not_started'
|
||||
|
|
@ -99,7 +100,7 @@ interface KtxSetupContextWatchArgs {
|
|||
}
|
||||
|
||||
export interface KtxSetupContextPromptAdapter {
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
cancel(message: string): void;
|
||||
}
|
||||
|
||||
|
|
@ -125,19 +126,7 @@ const SCAN_REPORT_FILE = 'scan-report.json';
|
|||
const DEFAULT_WATCH_INTERVAL_MS = 2_000;
|
||||
|
||||
function createPromptAdapter(): KtxSetupContextPromptAdapter {
|
||||
return {
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return 'back';
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
return createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
|
||||
}
|
||||
|
||||
function statePath(projectDir: string): string {
|
||||
|
|
@ -228,6 +217,9 @@ function normalizeSourceProgress(value: unknown): ContextBuildSourceProgressUpda
|
|||
status: rec.status as 'queued' | 'running' | 'done' | 'failed',
|
||||
...(typeof rec.startedAtMs === 'number' ? { startedAtMs: rec.startedAtMs } : {}),
|
||||
...(typeof rec.elapsedMs === 'number' ? { elapsedMs: rec.elapsedMs } : {}),
|
||||
...(typeof rec.percent === 'number' ? { percent: rec.percent } : {}),
|
||||
...(typeof rec.message === 'string' ? { message: rec.message } : {}),
|
||||
...(typeof rec.updatedAtMs === 'number' ? { updatedAtMs: rec.updatedAtMs } : {}),
|
||||
...(typeof rec.summaryText === 'string' ? { summaryText: rec.summaryText } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -920,7 +912,16 @@ async function watchContextStatusWithProgressView(
|
|||
try {
|
||||
while (true) {
|
||||
if (!repainter) {
|
||||
const currentKey = JSON.stringify(state.sourceProgress?.map((s) => s.status));
|
||||
const currentKey = JSON.stringify(
|
||||
state.sourceProgress?.map((s) => ({
|
||||
id: s.connectionId,
|
||||
status: s.status,
|
||||
percent: s.percent,
|
||||
message: s.message,
|
||||
summaryText: s.summaryText,
|
||||
updatedAtMs: s.updatedAtMs,
|
||||
})),
|
||||
);
|
||||
if (currentKey !== lastProgressKey || !isActiveStatus(state.status)) {
|
||||
io.stdout.write(renderContextBuildView(viewState, viewOpts));
|
||||
lastProgressKey = currentKey;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { readFile, writeFile } from 'node:fs/promises';
|
|||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { cancel, confirm, isCancel, multiselect, password, select, text } from '@clack/prompts';
|
||||
import type { HistoricSqlDialect } from '@ktx/context/ingest';
|
||||
import {
|
||||
type KtxProjectConnectionConfig,
|
||||
|
|
@ -15,10 +14,13 @@ import {
|
|||
import type { KtxTableListEntry } from '@ktx/context/scan';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { runKtxConnection } from './connection.js';
|
||||
import { withMenuOptionsSpacing, withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { runKtxScan } from './scan.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
import { writeProjectLocalSecretReference } from './setup-secrets.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
|
||||
const HISTORIC_SQL_WORK_UNIT_MAX_CONCURRENCY = 6;
|
||||
const execFileAsync = promisify(execFileCallback);
|
||||
|
|
@ -59,11 +61,11 @@ export type KtxSetupDatabasesResult =
|
|||
export interface KtxSetupDatabasesPromptAdapter {
|
||||
multiselect(options: {
|
||||
message: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
options: KtxSetupPromptOption[];
|
||||
required?: boolean;
|
||||
initialValues?: string[];
|
||||
}): Promise<string[]>;
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
text(options: { message: string; placeholder?: string; initialValue?: string }): Promise<string | undefined>;
|
||||
password(options: { message: string }): Promise<string | undefined>;
|
||||
cancel(message: string): void;
|
||||
|
|
@ -207,50 +209,11 @@ function missingConnectionDetailsPrompt(
|
|||
}
|
||||
|
||||
function createPromptAdapter(): KtxSetupDatabasesPromptAdapter {
|
||||
return {
|
||||
async multiselect(options) {
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
const selected = [...value] as string[];
|
||||
if (selected.length === 0 && !options.required) {
|
||||
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
if (!skipConfirmed) continue;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
},
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return 'back';
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
async text(options) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
text({ ...options, message: withTextInputNavigation(options.message) }),
|
||||
);
|
||||
return isCancel(value) ? undefined : String(value);
|
||||
},
|
||||
async password(options) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
password({ ...options, message: withTextInputNavigation(options.message) }),
|
||||
);
|
||||
return isCancel(value) ? undefined : String(value);
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
return createKtxSetupPromptAdapter({
|
||||
selectCancelValue: 'back',
|
||||
multiselectCancelValue: 'back',
|
||||
confirmEmptyOptionalMultiselect: true,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDriver(driver: string | undefined): KtxSetupDatabaseDriver | null {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTarge
|
|||
failureText: null,
|
||||
startedAt: null,
|
||||
elapsedMs: 0,
|
||||
progressUpdatedAtMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ describe('setup embeddings step', () => {
|
|||
message: EMBEDDING_OPTION_PROMPT_MESSAGE,
|
||||
options: [
|
||||
{ value: 'sentence-transformers', label: 'Local sentence-transformers embeddings' },
|
||||
{ value: 'openai', label: 'OpenAI embeddings (recommended)' },
|
||||
{ value: 'openai', label: 'OpenAI embeddings', hint: 'recommended' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
|
|
@ -136,6 +136,7 @@ describe('setup embeddings step', () => {
|
|||
const spinnerEvents: string[] = [];
|
||||
const spinner = vi.fn(() => ({
|
||||
start: (msg: string) => spinnerEvents.push(`start:${msg}`),
|
||||
message: (msg: string) => spinnerEvents.push(`message:${msg}`),
|
||||
stop: (msg: string) => spinnerEvents.push(`stop:${msg}`),
|
||||
error: (msg: string) => spinnerEvents.push(`error:${msg}`),
|
||||
}));
|
||||
|
|
@ -193,6 +194,7 @@ describe('setup embeddings step', () => {
|
|||
const spinnerEvents: string[] = [];
|
||||
const spinner = vi.fn(() => ({
|
||||
start: (msg: string) => spinnerEvents.push(`start:${msg}`),
|
||||
message: (msg: string) => spinnerEvents.push(`message:${msg}`),
|
||||
stop: (msg: string) => spinnerEvents.push(`stop:${msg}`),
|
||||
error: (msg: string) => spinnerEvents.push(`error:${msg}`),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { writeFile } from 'node:fs/promises';
|
||||
import { cancel, isCancel, password, select } from '@clack/prompts';
|
||||
import { resolveKtxConfigReference } from '@ktx/context/core';
|
||||
import {
|
||||
type KtxProjectConfig,
|
||||
|
|
@ -19,9 +18,12 @@ import {
|
|||
type ManagedLocalEmbeddingsDaemon,
|
||||
} from './managed-local-embeddings.js';
|
||||
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
|
||||
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
import { withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { envCredentialReference, writeProjectLocalSecretReference } from './setup-secrets.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
|
||||
export type KtxSetupEmbeddingBackend = 'openai' | 'sentence-transformers';
|
||||
|
||||
|
|
@ -46,7 +48,7 @@ export type KtxSetupEmbeddingsResult =
|
|||
| { status: 'failed'; projectDir: string };
|
||||
|
||||
export interface KtxSetupEmbeddingsPromptAdapter {
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
password(options: { message: string }): Promise<string | undefined>;
|
||||
cancel(message: string): void;
|
||||
}
|
||||
|
|
@ -85,25 +87,7 @@ const EMBEDDING_OPTION_PROMPT_CONTEXT =
|
|||
const LOCAL_EMBEDDING_HEALTH_TIMEOUT_MS = 120_000;
|
||||
|
||||
function createPromptAdapter(): KtxSetupEmbeddingsPromptAdapter {
|
||||
return {
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return 'back';
|
||||
}
|
||||
return value;
|
||||
},
|
||||
async password(options) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
password({ ...options, message: withTextInputNavigation(options.message) }),
|
||||
);
|
||||
return isCancel(value) ? undefined : value;
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
return createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
|
||||
}
|
||||
|
||||
async function hasCompletedEmbeddings(projectDir: string, config: KtxProjectConfig): Promise<boolean> {
|
||||
|
|
@ -293,7 +277,7 @@ async function chooseEmbeddingBackend(
|
|||
message: `Which embedding option should KTX use?\n\n${EMBEDDING_OPTION_PROMPT_CONTEXT}`,
|
||||
options: [
|
||||
{ value: 'sentence-transformers', label: 'Local sentence-transformers embeddings' },
|
||||
{ value: 'openai', label: 'OpenAI embeddings (recommended)' },
|
||||
{ value: 'openai', label: 'OpenAI embeddings', hint: 'recommended' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ describe('setup Anthropic model step', () => {
|
|||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which Anthropic model should KTX use?'),
|
||||
options: [
|
||||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (recommended)' },
|
||||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', hint: 'recommended' },
|
||||
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
|
||||
{ value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' },
|
||||
{ value: 'manual', label: 'Enter a model ID manually' },
|
||||
|
|
@ -763,7 +763,7 @@ describe('setup Anthropic model step', () => {
|
|||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which Anthropic model should KTX use?'),
|
||||
options: expect.arrayContaining([
|
||||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (recommended)' },
|
||||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', hint: 'recommended' },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { execFile, spawn } from 'node:child_process';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { cancel, isCancel, password, select, text } from '@clack/prompts';
|
||||
import { resolveLocalKtxLlmConfig } from '@ktx/context';
|
||||
import { resolveKtxConfigReference } from '@ktx/context/core';
|
||||
import {
|
||||
|
|
@ -13,9 +12,12 @@ import {
|
|||
} from '@ktx/context/project';
|
||||
import { type KtxLlmConfig, type KtxLlmHealthCheckResult, runKtxLlmHealthCheck } from '@ktx/llm';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
import { withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { envCredentialReference, writeProjectLocalSecretReference } from './setup-secrets.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
|
||||
export interface KtxSetupModelArgs {
|
||||
projectDir: string;
|
||||
|
|
@ -47,7 +49,7 @@ export interface AnthropicModelChoice {
|
|||
export type KtxSetupLlmBackend = 'anthropic' | 'vertex';
|
||||
|
||||
export interface KtxSetupModelPromptAdapter {
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
text(options: { message: string; placeholder?: string }): Promise<string | undefined>;
|
||||
password(options: { message: string }): Promise<string | undefined>;
|
||||
cancel(message: string): void;
|
||||
|
|
@ -145,31 +147,7 @@ interface GcloudProjectChoice {
|
|||
type GcloudCommandRunner = (args: string[], io: KtxCliIo) => Promise<GcloudAuthResult>;
|
||||
|
||||
function createPromptAdapter(): KtxSetupModelPromptAdapter {
|
||||
return {
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return 'back';
|
||||
}
|
||||
return value;
|
||||
},
|
||||
async text(options) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
text({ ...options, message: withTextInputNavigation(options.message) }),
|
||||
);
|
||||
return isCancel(value) ? undefined : value;
|
||||
},
|
||||
async password(options) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
password({ ...options, message: withTextInputNavigation(options.message) }),
|
||||
);
|
||||
return isCancel(value) ? undefined : value;
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
return createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
|
||||
}
|
||||
|
||||
function createIndentedCommandIo(io: KtxCliIo): KtxCliIo {
|
||||
|
|
@ -786,7 +764,8 @@ async function chooseModel(
|
|||
const modelOptions = [
|
||||
...selectableModels.map((model) => ({
|
||||
value: model.id,
|
||||
label: `${model.label || model.id}${model.recommended ? ' (recommended)' : ''}`,
|
||||
label: model.label || model.id,
|
||||
...(model.recommended ? { hint: 'recommended' } : {}),
|
||||
})),
|
||||
{ value: 'manual', label: 'Enter a model ID manually' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
|
|
@ -827,7 +806,8 @@ async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: Kt
|
|||
options: [
|
||||
...selectableModels.map((model) => ({
|
||||
value: model.id,
|
||||
label: `${model.label || model.id}${model.recommended ? ' (recommended)' : ''}`,
|
||||
label: model.label || model.id,
|
||||
...(model.recommended ? { hint: 'recommended' } : {}),
|
||||
})),
|
||||
{ value: 'manual', label: 'Enter a model ID manually' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { existsSync } from 'node:fs';
|
|||
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { cancel, isCancel, select, text } from '@clack/prompts';
|
||||
import {
|
||||
initKtxProject,
|
||||
type KtxLocalProject,
|
||||
|
|
@ -13,8 +12,11 @@ import {
|
|||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { gray } from './io/symbols.js';
|
||||
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
import { withTextInputNavigation } from './prompt-navigation.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
|
||||
export type KtxSetupProjectMode = 'auto' | 'new' | 'existing' | 'prompt-new';
|
||||
export type KtxSetupInputMode = 'auto' | 'disabled';
|
||||
|
|
@ -34,7 +36,7 @@ export type KtxSetupProjectResult =
|
|||
| { status: 'missing-input'; projectDir: string };
|
||||
|
||||
export interface KtxSetupProjectPromptAdapter {
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
text(options: { message: string; placeholder?: string }): Promise<string | undefined>;
|
||||
cancel(message: string): void;
|
||||
}
|
||||
|
|
@ -55,28 +57,7 @@ type PromptProjectDirResult =
|
|||
const DEFAULT_NEW_PROJECT_FOLDER_NAME = 'ktx-project';
|
||||
|
||||
function createClackSetupProjectPromptAdapter(): KtxSetupProjectPromptAdapter {
|
||||
return {
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return 'exit';
|
||||
}
|
||||
return value;
|
||||
},
|
||||
async text(options) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
text({ ...options, message: withTextInputNavigation(options.message) }),
|
||||
);
|
||||
if (isCancel(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
return createKtxSetupPromptAdapter({ selectCancelValue: 'exit' });
|
||||
}
|
||||
|
||||
function hasProjectConfig(projectDir: string): boolean {
|
||||
|
|
|
|||
205
packages/cli/src/setup-prompts.test.ts
Normal file
205
packages/cli/src/setup-prompts.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const cancelSymbol = Symbol('cancel');
|
||||
return {
|
||||
cancelSymbol,
|
||||
cancel: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
intro: vi.fn(),
|
||||
isCancel: vi.fn((value: unknown): value is symbol => value === cancelSymbol),
|
||||
log: { info: vi.fn() },
|
||||
multiselect: vi.fn(),
|
||||
note: vi.fn(),
|
||||
password: vi.fn(),
|
||||
select: vi.fn(),
|
||||
text: vi.fn(),
|
||||
withSetupInterruptConfirmation: vi.fn((prompt: () => Promise<unknown>) => prompt()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@clack/prompts', () => ({
|
||||
cancel: mocks.cancel,
|
||||
confirm: mocks.confirm,
|
||||
intro: mocks.intro,
|
||||
isCancel: mocks.isCancel,
|
||||
log: mocks.log,
|
||||
multiselect: mocks.multiselect,
|
||||
note: mocks.note,
|
||||
password: mocks.password,
|
||||
select: mocks.select,
|
||||
text: mocks.text,
|
||||
}));
|
||||
|
||||
vi.mock('./setup-interrupt.js', () => ({
|
||||
withSetupInterruptConfirmation: mocks.withSetupInterruptConfirmation,
|
||||
}));
|
||||
|
||||
describe('setup prompt adapter', () => {
|
||||
beforeEach(() => {
|
||||
mocks.cancel.mockReset();
|
||||
mocks.confirm.mockReset();
|
||||
mocks.intro.mockReset();
|
||||
mocks.isCancel.mockClear();
|
||||
mocks.log.info.mockReset();
|
||||
mocks.multiselect.mockReset();
|
||||
mocks.note.mockReset();
|
||||
mocks.password.mockReset();
|
||||
mocks.select.mockReset();
|
||||
mocks.text.mockReset();
|
||||
mocks.withSetupInterruptConfirmation.mockClear();
|
||||
});
|
||||
|
||||
it('passes select hint and disabled options through Clack and delegates cancellation handling', async () => {
|
||||
mocks.select.mockResolvedValueOnce('openai');
|
||||
const adapter = createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
|
||||
const options: KtxSetupPromptOption[] = [
|
||||
{ value: 'local', label: 'Local embeddings', disabled: true },
|
||||
{ value: 'openai', label: 'OpenAI embeddings', hint: 'recommended' },
|
||||
];
|
||||
|
||||
await expect(
|
||||
adapter.select({
|
||||
message: 'Which embedding option should KTX use?\n\nKTX uses embeddings for search.',
|
||||
options,
|
||||
}),
|
||||
).resolves.toBe('openai');
|
||||
|
||||
expect(mocks.withSetupInterruptConfirmation).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.select).toHaveBeenCalledWith({
|
||||
message: 'Which embedding option should KTX use?\n\nKTX uses embeddings for search.\n',
|
||||
options,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps select cancellation to the configured sentinel', async () => {
|
||||
mocks.select.mockResolvedValueOnce(mocks.cancelSymbol);
|
||||
const adapter = createKtxSetupPromptAdapter({
|
||||
selectCancelValue: 'exit',
|
||||
cancelOnSelectCancel: false,
|
||||
});
|
||||
|
||||
await expect(adapter.select({ message: 'What do you want to do?', options: [] })).resolves.toBe('exit');
|
||||
|
||||
expect(mocks.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('decorates text and password prompts with setup navigation copy', async () => {
|
||||
mocks.text.mockResolvedValueOnce('analytics-ktx');
|
||||
mocks.password.mockResolvedValueOnce('secret');
|
||||
const adapter = createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
|
||||
|
||||
await expect(adapter.text({ message: 'Project folder path', placeholder: './analytics-ktx' })).resolves.toBe(
|
||||
'analytics-ktx',
|
||||
);
|
||||
await expect(adapter.password({ message: 'Anthropic API key' })).resolves.toBe('secret');
|
||||
|
||||
expect(mocks.text).toHaveBeenCalledWith({
|
||||
message: 'Project folder path\n│ Press Escape to go back.\n│',
|
||||
placeholder: './analytics-ktx',
|
||||
});
|
||||
expect(mocks.password).toHaveBeenCalledWith({
|
||||
message: 'Anthropic API key\n│ Press Escape to go back.\n│',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes multiselect hint and disabled options through Clack', async () => {
|
||||
mocks.multiselect.mockResolvedValueOnce(['postgres']);
|
||||
const adapter = createKtxSetupPromptAdapter({
|
||||
selectCancelValue: 'back',
|
||||
multiselectCancelValue: 'back',
|
||||
confirmEmptyOptionalMultiselect: true,
|
||||
});
|
||||
const options: KtxSetupPromptOption[] = [
|
||||
{ value: 'postgres', label: 'PostgreSQL', hint: 'recommended' },
|
||||
{ value: 'snowflake', label: 'Snowflake', disabled: true },
|
||||
];
|
||||
|
||||
await expect(adapter.multiselect({ message: 'Which primary sources?', options, required: true })).resolves.toEqual([
|
||||
'postgres',
|
||||
]);
|
||||
|
||||
expect(mocks.multiselect).toHaveBeenCalledWith({
|
||||
message: 'Which primary sources?',
|
||||
options,
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms an empty optional multiselect and retries when skip is declined', async () => {
|
||||
mocks.multiselect.mockResolvedValueOnce([]).mockResolvedValueOnce(['postgres']);
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
const adapter = createKtxSetupPromptAdapter({
|
||||
selectCancelValue: 'back',
|
||||
multiselectCancelValue: 'back',
|
||||
confirmEmptyOptionalMultiselect: true,
|
||||
});
|
||||
|
||||
await expect(adapter.multiselect({ message: 'Which primary sources?', options: [], required: false })).resolves.toEqual([
|
||||
'postgres',
|
||||
]);
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledWith({ message: 'Nothing selected. Skip this step?', initialValue: false });
|
||||
expect(mocks.multiselect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('maps multiselect cancellation to the configured back value', async () => {
|
||||
mocks.multiselect.mockResolvedValueOnce(mocks.cancelSymbol);
|
||||
const adapter = createKtxSetupPromptAdapter({
|
||||
selectCancelValue: 'back',
|
||||
multiselectCancelValue: 'back',
|
||||
confirmEmptyOptionalMultiselect: true,
|
||||
});
|
||||
|
||||
await expect(adapter.multiselect({ message: 'Which primary sources?', options: [] })).resolves.toEqual(['back']);
|
||||
|
||||
expect(mocks.cancel).toHaveBeenCalledWith('Setup cancelled.');
|
||||
});
|
||||
|
||||
it('keeps setup intro and note plain for non-stream output', async () => {
|
||||
const { createKtxSetupUiAdapter } = await import('./setup-prompts.js');
|
||||
const chunks: string[] = [];
|
||||
const io = {
|
||||
stdout: {
|
||||
isTTY: true,
|
||||
write(chunk: string) {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
},
|
||||
stderr: { write: vi.fn() },
|
||||
};
|
||||
|
||||
const ui = createKtxSetupUiAdapter();
|
||||
ui.intro('KTX setup', io);
|
||||
ui.note(' $ ktx status', 'What you can do next', io);
|
||||
|
||||
expect(chunks.join('')).toBe('KTX setup\n\nWhat you can do next:\n $ ktx status\n');
|
||||
expect(mocks.intro).not.toHaveBeenCalled();
|
||||
expect(mocks.note).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses Clack intro and note for writable TTY output', async () => {
|
||||
const { createKtxSetupUiAdapter } = await import('./setup-prompts.js');
|
||||
const output = {
|
||||
columns: 80,
|
||||
isTTY: true,
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
};
|
||||
const io = {
|
||||
stdout: output,
|
||||
stderr: { write: vi.fn() },
|
||||
};
|
||||
|
||||
const ui = createKtxSetupUiAdapter();
|
||||
ui.intro('KTX setup', io);
|
||||
ui.note(' $ ktx status', 'What you can do next', io);
|
||||
|
||||
expect(mocks.intro).toHaveBeenCalledWith('KTX setup', { output });
|
||||
expect(mocks.note).toHaveBeenCalledWith(' $ ktx status', 'What you can do next', { output });
|
||||
});
|
||||
});
|
||||
172
packages/cli/src/setup-prompts.ts
Normal file
172
packages/cli/src/setup-prompts.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import type { Writable } from 'node:stream';
|
||||
import {
|
||||
cancel,
|
||||
confirm,
|
||||
intro,
|
||||
isCancel,
|
||||
log,
|
||||
multiselect,
|
||||
note,
|
||||
password,
|
||||
select,
|
||||
text,
|
||||
} from '@clack/prompts';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
|
||||
export interface KtxSetupPromptOption<Value extends string = string> {
|
||||
value: Value;
|
||||
label: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface KtxSetupSelectOptions<Value extends string = string> {
|
||||
message: string;
|
||||
options: Array<KtxSetupPromptOption<Value>>;
|
||||
initialValue?: Value;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
interface KtxSetupMultiselectOptions<Value extends string = string> {
|
||||
message: string;
|
||||
options: Array<KtxSetupPromptOption<Value>>;
|
||||
required?: boolean;
|
||||
initialValues?: Value[];
|
||||
maxItems?: number;
|
||||
cursorAt?: Value;
|
||||
}
|
||||
|
||||
interface KtxSetupTextOptions {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
initialValue?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
interface KtxSetupPasswordOptions {
|
||||
message: string;
|
||||
mask?: string;
|
||||
}
|
||||
|
||||
export interface KtxSetupPromptAdapter {
|
||||
select(options: KtxSetupSelectOptions): Promise<string>;
|
||||
multiselect(options: KtxSetupMultiselectOptions): Promise<string[]>;
|
||||
text(options: KtxSetupTextOptions): Promise<string | undefined>;
|
||||
password(options: KtxSetupPasswordOptions): Promise<string | undefined>;
|
||||
cancel(message: string): void;
|
||||
log(message: string): void;
|
||||
}
|
||||
|
||||
export interface KtxSetupPromptAdapterOptions {
|
||||
selectCancelValue: 'back' | 'exit';
|
||||
multiselectCancelValue?: 'back';
|
||||
confirmEmptyOptionalMultiselect?: boolean;
|
||||
cancelOnSelectCancel?: boolean;
|
||||
cancelOnMultiselectCancel?: boolean;
|
||||
cancelMessage?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETUP_CANCEL_MESSAGE = 'Setup cancelled.';
|
||||
|
||||
export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOptions): KtxSetupPromptAdapter {
|
||||
const cancelMessage = options.cancelMessage ?? DEFAULT_SETUP_CANCEL_MESSAGE;
|
||||
const cancelOnSelectCancel = options.cancelOnSelectCancel ?? true;
|
||||
const cancelOnMultiselectCancel = options.cancelOnMultiselectCancel ?? true;
|
||||
const multiselectCancelValue = options.multiselectCancelValue ?? 'back';
|
||||
|
||||
return {
|
||||
async select(promptOptions) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(promptOptions)));
|
||||
if (isCancel(value)) {
|
||||
if (cancelOnSelectCancel) {
|
||||
cancel(cancelMessage);
|
||||
}
|
||||
return options.selectCancelValue;
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
async multiselect(promptOptions) {
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(promptOptions)));
|
||||
if (isCancel(value)) {
|
||||
if (cancelOnMultiselectCancel) {
|
||||
cancel(cancelMessage);
|
||||
}
|
||||
return [multiselectCancelValue];
|
||||
}
|
||||
const selected = [...value].map(String);
|
||||
if (
|
||||
selected.length === 0 &&
|
||||
!promptOptions.required &&
|
||||
options.confirmEmptyOptionalMultiselect === true
|
||||
) {
|
||||
const skipConfirmed = await confirm({
|
||||
message: 'Nothing selected. Skip this step?',
|
||||
initialValue: false,
|
||||
});
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel(cancelMessage);
|
||||
return [multiselectCancelValue];
|
||||
}
|
||||
if (!skipConfirmed) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
},
|
||||
async text(promptOptions) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
text({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
|
||||
);
|
||||
return isCancel(value) ? undefined : String(value);
|
||||
},
|
||||
async password(promptOptions) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
password({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
|
||||
);
|
||||
return isCancel(value) ? undefined : String(value);
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
},
|
||||
log(message) {
|
||||
log.info(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface KtxSetupUiAdapter {
|
||||
intro(title: string, io: KtxCliIo): void;
|
||||
note(message: string, title: string, io: KtxCliIo): void;
|
||||
}
|
||||
|
||||
function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable {
|
||||
return (
|
||||
output.isTTY === true &&
|
||||
typeof (output as { on?: unknown }).on === 'function' &&
|
||||
typeof (output as { columns?: unknown }).columns !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
export function createKtxSetupUiAdapter(): KtxSetupUiAdapter {
|
||||
return {
|
||||
intro(title, io) {
|
||||
if (isWritableTtyOutput(io.stdout)) {
|
||||
intro(title, { output: io.stdout });
|
||||
return;
|
||||
}
|
||||
io.stdout.write(`${title}\n`);
|
||||
},
|
||||
note(message, title, io) {
|
||||
if (isWritableTtyOutput(io.stdout)) {
|
||||
note(message, title, { output: io.stdout });
|
||||
return;
|
||||
}
|
||||
io.stdout.write(`\n${title}:\n`);
|
||||
io.stdout.write(`${message}\n`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { cancel, isCancel, select } from '@clack/prompts';
|
||||
import { withMenuOptionsSpacing } from './prompt-navigation.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
import type { KtxSetupStatus } from './setup.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
|
||||
export type KtxSetupReadyAction = 'models' | 'embeddings' | 'databases' | 'sources' | 'context' | 'agents' | 'exit';
|
||||
|
||||
export interface KtxSetupReadyMenuPromptAdapter {
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
cancel(message: string): void;
|
||||
}
|
||||
|
||||
|
|
@ -30,19 +31,7 @@ export function isKtxSetupReady(status: KtxSetupStatus): boolean {
|
|||
}
|
||||
|
||||
function createPromptAdapter(): KtxSetupReadyMenuPromptAdapter {
|
||||
return {
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return 'exit';
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
return createKtxSetupPromptAdapter({ selectCancelValue: 'exit' });
|
||||
}
|
||||
|
||||
export async function runKtxSetupReadyChangeMenu(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { cancel, confirm, isCancel, log, multiselect, password, select, text } from '@clack/prompts';
|
||||
import { localConnectionTypeForConfig, resolveNotionAuthToken } from '@ktx/context/connections';
|
||||
import { resolveKtxConfigReference } from '@ktx/context/core';
|
||||
import {
|
||||
|
|
@ -29,10 +28,13 @@ import {
|
|||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { pickNotionRootPages } from './notion-page-picker.js';
|
||||
import { runKtxSourceMapping } from './source-mapping.js';
|
||||
import { withMenuOptionsSpacing, withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
|
||||
import { runKtxPublicIngest } from './public-ingest.js';
|
||||
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
import { writeProjectLocalSecretReference } from './setup-secrets.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
} from './setup-prompts.js';
|
||||
|
||||
export type KtxSetupSourceType = 'dbt' | 'metricflow' | 'metabase' | 'looker' | 'lookml' | 'notion';
|
||||
|
||||
|
|
@ -73,11 +75,11 @@ export type KtxSetupSourcesResult =
|
|||
export interface KtxSetupSourcesPromptAdapter {
|
||||
multiselect(options: {
|
||||
message: string;
|
||||
options: Array<{ value: string; label: string; hint?: string }>;
|
||||
options: KtxSetupPromptOption[];
|
||||
initialValues?: string[];
|
||||
required?: boolean;
|
||||
}): Promise<string[]>;
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
text(options: { message: string; placeholder?: string; initialValue?: string }): Promise<string | undefined>;
|
||||
password(options: { message: string }): Promise<string | undefined>;
|
||||
cancel(message: string): void;
|
||||
|
|
@ -135,53 +137,11 @@ const PRIMARY_SOURCE_DRIVERS = new Set([
|
|||
]);
|
||||
|
||||
function createPromptAdapter(): KtxSetupSourcesPromptAdapter {
|
||||
return {
|
||||
async multiselect(options) {
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
const selected = [...value] as string[];
|
||||
if (selected.length === 0 && !options.required) {
|
||||
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel('Setup cancelled.');
|
||||
return ['back'];
|
||||
}
|
||||
if (!skipConfirmed) continue;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
},
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
cancel('Setup cancelled.');
|
||||
return 'back';
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
async text(options) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
text({ ...options, message: withTextInputNavigation(options.message) }),
|
||||
);
|
||||
return isCancel(value) ? undefined : String(value);
|
||||
},
|
||||
async password(options) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
password({ ...options, message: withTextInputNavigation(options.message) }),
|
||||
);
|
||||
return isCancel(value) ? undefined : String(value);
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
},
|
||||
log(message) {
|
||||
log.info(message);
|
||||
},
|
||||
};
|
||||
return createKtxSetupPromptAdapter({
|
||||
selectCancelValue: 'back',
|
||||
multiselectCancelValue: 'back',
|
||||
confirmEmptyOptionalMultiselect: true,
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { cancel, isCancel, select } from '@clack/prompts';
|
||||
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
|
||||
import {
|
||||
ktxLocalStateDbPath,
|
||||
|
|
@ -10,7 +9,7 @@ import {
|
|||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { formatSetupNextStepLines } from './next-steps.js';
|
||||
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
import { isKtxSetupExitError } from './setup-interrupt.js';
|
||||
import {
|
||||
type KtxAgentScope,
|
||||
type KtxAgentTarget,
|
||||
|
|
@ -38,7 +37,12 @@ import {
|
|||
runKtxSetupReadyChangeMenu,
|
||||
} from './setup-ready-menu.js';
|
||||
import { type KtxSetupSourcesDeps, type KtxSetupSourceType, runKtxSetupSourcesStep } from './setup-sources.js';
|
||||
import { withMenuOptionsSpacing } from './prompt-navigation.js';
|
||||
import {
|
||||
createKtxSetupPromptAdapter,
|
||||
createKtxSetupUiAdapter,
|
||||
type KtxSetupPromptOption,
|
||||
type KtxSetupUiAdapter,
|
||||
} from './setup-prompts.js';
|
||||
import {
|
||||
readKtxSetupContextState,
|
||||
type KtxSetupContextDeps,
|
||||
|
|
@ -147,6 +151,7 @@ export interface KtxSetupDeps {
|
|||
contextDeps?: KtxSetupContextDeps;
|
||||
readyMenuDeps?: KtxSetupReadyMenuDeps;
|
||||
entryMenuDeps?: KtxSetupEntryMenuDeps;
|
||||
setupUi?: KtxSetupUiAdapter;
|
||||
}
|
||||
|
||||
const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion']);
|
||||
|
|
@ -164,7 +169,7 @@ type KtxSetupFlowStatus =
|
|||
| 'interrupted';
|
||||
|
||||
export interface KtxSetupEntryMenuPromptAdapter {
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
cancel(message: string): void;
|
||||
}
|
||||
|
||||
|
|
@ -173,18 +178,10 @@ export interface KtxSetupEntryMenuDeps {
|
|||
}
|
||||
|
||||
function createEntryMenuPromptAdapter(): KtxSetupEntryMenuPromptAdapter {
|
||||
return {
|
||||
async select(options) {
|
||||
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
|
||||
if (isCancel(value)) {
|
||||
return 'exit';
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
cancel(message) {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
return createKtxSetupPromptAdapter({
|
||||
selectCancelValue: 'exit',
|
||||
cancelOnSelectCancel: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function runKtxSetupEntryMenu(
|
||||
|
|
@ -448,7 +445,8 @@ export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSet
|
|||
}
|
||||
|
||||
async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> {
|
||||
io.stdout.write('KTX setup\n');
|
||||
const setupUi = deps.setupUi ?? createKtxSetupUiAdapter();
|
||||
setupUi.intro('KTX setup', io);
|
||||
let entryAction: KtxSetupEntryAction | undefined;
|
||||
let projectResult: Awaited<ReturnType<typeof runKtxSetupProjectStep>>;
|
||||
const canShowEntryMenu =
|
||||
|
|
@ -745,14 +743,15 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
|
||||
const status = await readKtxSetupStatus(projectResult.projectDir);
|
||||
io.stdout.write(formatKtxSetupStatus(status));
|
||||
io.stdout.write('\nWhat you can do next:\n');
|
||||
io.stdout.write(
|
||||
`${formatSetupNextStepLines({
|
||||
setupUi.note(
|
||||
formatSetupNextStepLines({
|
||||
setupReady: setupStatusReady(status),
|
||||
hasContextTargets: setupHasContextTargets(status),
|
||||
contextReady: setupContextReady(status),
|
||||
agentIntegrationReady: status.agents.some((agent) => agent.ready),
|
||||
}).join('\n')}\n`,
|
||||
}).join('\n'),
|
||||
'What you can do next',
|
||||
io,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue