This commit is contained in:
Luca Martial 2026-05-10 20:44:07 -07:00
parent 549fb35e75
commit b3dcb577d9
32 changed files with 184 additions and 1670 deletions

View file

@ -202,7 +202,7 @@ describe('renderContextBuildView', () => {
const output = renderContextBuildView(state, { styled: false });
expect(output).toContain('scanning...');
expect(output).toContain('30s');
expect(output).toContain('(30s)');
});
it('renders running target with progress bar when percentage is available', () => {
@ -217,7 +217,7 @@ describe('renderContextBuildView', () => {
expect(output).toContain('██████░░░░░░');
expect(output).toContain('50%');
expect(output).toContain('Scanning tables...');
expect(output).toContain('15s');
expect(output).toContain('(15s)');
});
it('renders completion summary when all targets are done', () => {
@ -423,6 +423,7 @@ describe('runContextBuild', () => {
expect(mockExit).toHaveBeenCalledWith(0);
expect(io.stdout()).toContain('Context build continuing in the background.');
expect(io.stdout()).toContain('Resume: ktx setup --project-dir /tmp/project');
expect(io.stdout()).toContain('Status: ktx setup context status --project-dir /tmp/project');
mockExit.mockRestore();
});

View file

@ -137,7 +137,7 @@ function targetDetail(target: ContextBuildTargetState, styled: boolean): string
const percent = extractPercent(target.detailLine);
const progressText = target.detailLine?.replace(/^\[\d+%\]\s*/, '')
?? (target.target.operation === 'scan' ? 'scanning...' : 'ingesting...');
const elapsed = target.elapsedMs > 0 ? formatDuration(target.elapsedMs) : null;
const elapsed = target.elapsedMs > 0 ? `(${formatDuration(target.elapsedMs)})` : null;
const parts: string[] = [];
if (percent !== null) {
parts.push(`${renderProgressBar(percent, styled)} ${percent}%`);
@ -318,7 +318,7 @@ export function createRepainter(io: KtxCliIo) {
if (lastLineCount > 0) {
io.stdout.write(`${ESC}[${lastLineCount}A\r`);
}
io.stdout.write(content);
io.stdout.write(content.replaceAll('\n', `${ESC}[K\n`));
io.stdout.write(`${ESC}[J`);
lastLineCount = (content.match(/\n/g) ?? []).length;
},
@ -356,7 +356,7 @@ function spawnBackgroundBuild(projectDir: string): { logPath: string } | null {
// --- Keystroke handling ---
function defaultSetupKeystroke(onDetach: () => void, onCtrlC: () => void): (() => void) | null {
export function defaultSetupKeystroke(onDetach: () => void, onCtrlC: () => void): (() => void) | null {
const stdin = process.stdin;
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
return null;
@ -445,6 +445,7 @@ export async function runContextBuild(
io.stdout.write('\n\nContext build continuing in the background.\n');
if (bg) io.stdout.write(`Log: ${bg.logPath}\n`);
io.stdout.write(`Resume: ${resumeCommand(args.projectDir)}\n`);
io.stdout.write(`Status: ktx setup context status --project-dir ${resolve(args.projectDir)}\n`);
process.exit(0);
},
() => {

View file

@ -95,29 +95,6 @@ describe('createKtxCliScanConnector', () => {
]);
});
it('does not create a standalone PostHog scan connector', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' product:',
' driver: posthog',
' api_key: phx_test',
' project_id: "157881"',
' readonly: true',
'',
].join('\n'),
'utf-8',
);
const project = await loadKtxProject({ projectDir: tempDir });
await expect(createKtxCliScanConnector(project, 'product')).rejects.toThrow(
'Connection "product" uses driver "posthog", which has no native standalone KTX scan connector',
);
});
it('throws for structural daemon-only fallback configs', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
await writeFile(

View file

@ -80,13 +80,6 @@ describe('buildPublicIngestPlan', () => {
);
});
it('does not plan PostHog connections as CLI ingest targets', () => {
const project = projectWithConnections({ product: { driver: 'posthog' } });
expect(() =>
buildPublicIngestPlan(project, { projectDir: '/tmp/project', targetConnectionId: 'product', all: false }),
).toThrow('Connection "product" uses unsupported public ingest driver "posthog"');
});
});
describe('runKtxPublicIngest', () => {

View file

@ -500,6 +500,47 @@ describe('setup context build state', () => {
expect(output).not.toContain('KTX context built: detached');
});
it('supports d to detach from the progress watch view', async () => {
await writeReadyProject(tempDir);
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-detach',
status: 'running',
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-detach'),
sourceProgress: [
{ connectionId: 'warehouse', operation: 'scan' as const, status: 'running' as const, startedAtMs: Date.now() },
],
});
const io = makeIo();
let triggerDetach: (() => void) | null = null;
await expect(
runKtxSetupContextStep(
{ projectDir: tempDir, inputMode: 'auto', autoWatch: true },
io.io,
{
sleep: async () => { triggerDetach?.(); },
watchIntervalMs: 1,
setupKeystroke: (onDetach) => {
triggerDetach = onDetach;
return () => {};
},
},
),
).resolves.toMatchObject({ status: 'detached' });
const output = io.stdout();
expect(output).toContain('Building KTX context');
expect(output).toContain('Context build continuing in the background.');
expect(output).toContain('Resume: ktx setup --project-dir');
});
it('prints JSON setup context command status with watch and resume commands', async () => {
await mkdir(join(tempDir, '.ktx', 'setup'), { recursive: true });
await writeKtxSetupContextState(tempDir, {

View file

@ -13,6 +13,7 @@ import { buildPublicIngestPlan } from './public-ingest.js';
import {
type ContextBuildSourceProgressUpdate,
createRepainter,
defaultSetupKeystroke,
renderContextBuildView,
runContextBuild,
viewStateFromSourceProgress,
@ -109,6 +110,7 @@ export interface KtxSetupContextDeps {
verifyContextReady?: (projectDir: string) => Promise<KtxSetupContextReadiness>;
sleep?: (ms: number) => Promise<void>;
watchIntervalMs?: number;
setupKeystroke?: (onDetach: () => void, onCtrlC: () => void) => (() => void) | null;
}
interface KtxSetupContextTargets {
@ -870,50 +872,80 @@ async function watchContextStatusWithProgressView(
const intervalMs = deps.watchIntervalMs ?? DEFAULT_WATCH_INTERVAL_MS;
const isTTY = io.stdout.isTTY === true;
const repainter = isTTY ? createRepainter(io) : null;
const projectDir = resolve(args.projectDir);
const viewOpts = { styled: isTTY, showHint: true, projectDir };
let state = initialState;
let frame = 0;
let lastProgressKey = '';
let detached = false;
while (true) {
const now = Date.now();
const startedAtMs = state.startedAt ? new Date(state.startedAt).getTime() : undefined;
const viewState = viewStateFromSourceProgress(state.sourceProgress ?? [], now, startedAtMs);
viewState.frame = frame;
let viewState = viewStateFromSourceProgress(state.sourceProgress ?? [], Date.now(),
state.startedAt ? new Date(state.startedAt).getTime() : undefined);
const viewOpts = {
styled: isTTY,
showHint: true,
hintText: 'ctrl+c to stop watching · build continues in background',
};
const cleanupKeystroke = (isTTY || deps.setupKeystroke)
? (deps.setupKeystroke ?? defaultSetupKeystroke)(
() => { detached = true; },
() => { detached = true; },
)
: null;
if (repainter) {
repainter.paint(renderContextBuildView(viewState, viewOpts));
} else {
const currentKey = JSON.stringify(state.sourceProgress?.map((s) => s.status));
if (currentKey !== lastProgressKey || !isActiveStatus(state.status)) {
io.stdout.write(renderContextBuildView(viewState, viewOpts));
lastProgressKey = currentKey;
let spinnerInterval: ReturnType<typeof setInterval> | null = null;
if (repainter) {
repainter.paint(renderContextBuildView(viewState, viewOpts));
spinnerInterval = setInterval(() => {
viewState.frame++;
const now = Date.now();
viewState.totalElapsedMs = viewState.startedAt !== null ? now - viewState.startedAt : 0;
for (const t of [...viewState.primarySources, ...viewState.contextSources]) {
if (t.status === 'running' && t.startedAt !== null) {
t.elapsedMs = now - t.startedAt;
}
}
}
if (!isActiveStatus(state.status)) {
return { exitCode: watchExitCode(state.status), state };
}
frame++;
await sleep(intervalMs);
try {
state = await readKtxSetupContextState(args.projectDir);
} catch {
continue;
}
if (!stateMatchesRunId(state, args.runId)) {
io.stderr.write(`KTX setup context run "${args.runId}" was not found.\n`);
return { exitCode: 1, state };
}
repainter.paint(renderContextBuildView(viewState, viewOpts));
}, 140);
}
try {
while (true) {
if (!repainter) {
const currentKey = JSON.stringify(state.sourceProgress?.map((s) => s.status));
if (currentKey !== lastProgressKey || !isActiveStatus(state.status)) {
io.stdout.write(renderContextBuildView(viewState, viewOpts));
lastProgressKey = currentKey;
}
}
if (!isActiveStatus(state.status)) {
return { exitCode: watchExitCode(state.status), state };
}
if (detached) break;
await sleep(intervalMs);
if (detached) break;
try {
state = await readKtxSetupContextState(args.projectDir);
} catch {
continue;
}
if (!stateMatchesRunId(state, args.runId)) {
io.stderr.write(`KTX setup context run "${args.runId}" was not found.\n`);
return { exitCode: 1, state };
}
const now = Date.now();
const startedAtMs = state.startedAt ? new Date(state.startedAt).getTime() : undefined;
viewState = viewStateFromSourceProgress(state.sourceProgress ?? [], now, startedAtMs);
}
} finally {
if (spinnerInterval) clearInterval(spinnerInterval);
cleanupKeystroke?.();
}
io.stdout.write('\n\nContext build continuing in the background.\n');
io.stdout.write(`Resume: ktx setup --project-dir ${projectDir}\n`);
io.stdout.write(`Status: ktx setup context status --project-dir ${projectDir}\n`);
return { exitCode: 0, state };
}
function setupResultFromWatchedState(projectDir: string, state: KtxSetupContextState): KtxSetupContextResult {

View file

@ -444,7 +444,6 @@ describe('setup sources step', () => {
);
const options = vi.mocked(testPrompts.multiselect).mock.calls[0]?.[0].options ?? [];
expect(options).toContainEqual({ value: 'notion', label: 'Notion' });
expect(options).not.toContainEqual({ value: 'posthog', label: 'PostHog' });
});
it('uses a source-specific editable connection name for new interactive connections', async () => {