Merge branch 'main' into add-ktx-mcp-claude-desktop

This commit is contained in:
Andrey Avtomonov 2026-05-15 15:54:22 +02:00 committed by GitHub
commit 658024dcf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 275 additions and 210 deletions

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
# Playwright CLI session artifacts (snapshots, console logs, screenshots)
.playwright-cli/
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View file

@ -1,28 +1,36 @@
export function Logo() { export function Logo() {
return ( return (
<div className="flex items-center gap-2.5 group"> <div className="flex items-center gap-3.5 group">
<div className="relative flex items-center justify-center transition-transform duration-300 ease-out group-hover:rotate-[-4deg]"> <div className="relative flex items-center justify-center transition-transform duration-300 ease-out group-hover:rotate-[-4deg]">
<img <img
src="/brand/ktx-mascot.svg" src="/ktx/brand/ktx-mascot.svg"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
className="h-14 w-14 object-contain block dark:hidden" className="h-20 w-20 object-contain block dark:hidden"
/> />
<img <img
src="/brand/ktx-mascot-dark.svg" src="/ktx/brand/ktx-mascot-dark.svg"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
className="h-14 w-14 object-contain hidden dark:block" className="h-20 w-20 object-contain hidden dark:block"
/> />
</div> </div>
<div className="flex flex-col items-start leading-none">
<span
className="text-[24px] font-semibold text-fd-foreground tracking-tight"
style={{ fontFamily: "var(--font-display), var(--font-sans), sans-serif" }}
>
KTX
</span>
<span
className="mt-1 whitespace-nowrap text-[13px] font-medium text-fd-muted-foreground/80 tracking-tight"
style={{ fontFamily: "var(--font-display), var(--font-sans), sans-serif" }}
>
by Kaelio
</span>
</div>
<span <span
className="text-[17px] font-semibold text-fd-foreground tracking-tight" className="text-[19px] font-medium text-fd-muted-foreground/80 tracking-tight border-l border-fd-border pl-3 ml-1"
style={{ fontFamily: "var(--font-display), var(--font-sans), sans-serif" }}
>
KTX
</span>
<span
className="text-[14px] font-medium text-fd-muted-foreground/80 tracking-tight border-l border-fd-border pl-2 ml-0.5"
style={{ fontFamily: "var(--font-display), var(--font-sans), sans-serif" }} style={{ fontFamily: "var(--font-display), var(--font-sans), sans-serif" }}
> >
Docs Docs

View file

@ -315,14 +315,14 @@ function padVisual(text: string, width: number): string {
} }
function renderTestAll(io: KtxCliIo, rows: ReadonlyArray<ConnectionTestRow>): void { function renderTestAll(io: KtxCliIo, rows: ReadonlyArray<ConnectionTestRow>): void {
io.stdout.write(`${SYMBOLS.barStart} connection test --all\n`); io.stdout.write(`${bold('connection test --all')}\n`);
io.stdout.write(`${SYMBOLS.bar}\n`);
if (rows.length === 0) { if (rows.length === 0) {
io.stdout.write(`${SYMBOLS.barEnd} No connections configured. Run \`ktx setup\` to add one.\n`); io.stdout.write(`\n No connections configured. Run \`ktx setup\` to add one.\n\n`);
return; return;
} }
io.stdout.write('\n');
const okLabel = green('✓ ok'); const okLabel = green('✓ ok');
const failLabel = red('✗ failed'); const failLabel = red('✗ failed');
const idWidth = Math.max(...rows.map((r) => r.connectionId.length)); const idWidth = Math.max(...rows.map((r) => r.connectionId.length));
@ -334,17 +334,17 @@ function renderTestAll(io: KtxCliIo, rows: ReadonlyArray<ConnectionTestRow>): vo
const driver = dim(padVisual(row.driver, driverWidth)); const driver = dim(padVisual(row.driver, driverWidth));
const status = padVisual(row.ok ? okLabel : failLabel, statusWidth); const status = padVisual(row.ok ? okLabel : failLabel, statusWidth);
const detail = dim(row.detail); const detail = dim(row.detail);
io.stdout.write(`${SYMBOLS.bar} ${SYMBOLS.item} ${id} ${driver} ${status} ${detail}\n`); io.stdout.write(` ${id} ${driver} ${status} ${detail}\n`);
} }
const failed = rows.filter((r) => !r.ok).length; const failed = rows.filter((r) => !r.ok).length;
const passed = rows.length - failed; const passed = rows.length - failed;
io.stdout.write(`${SYMBOLS.bar}\n`); io.stdout.write('\n');
const summary = const summary =
failed === 0 failed === 0
? `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)}` ? `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)}`
: `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)} ${dim(SYMBOLS.middot)} ${red(`${failed} failed`)}`; : `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)} ${dim(SYMBOLS.middot)} ${red(`${failed} failed`)}`;
io.stdout.write(`${SYMBOLS.barEnd} ${summary}\n`); io.stdout.write(`${summary}\n`);
} }
async function runTestAll( async function runTestAll(

View file

@ -139,7 +139,7 @@ function stripAnsi(s: string): string {
} }
describe('printList — pretty mode', () => { describe('printList — pretty mode', () => {
it('renders a Clack-style header, grouped rows, and footer', () => { it('renders a bold header, grouped rows, and footer', () => {
const r = recorder(); const r = recorder();
printList<SlRow>({ printList<SlRow>({
rows: [ORDERS, USERS], rows: [ORDERS, USERS],
@ -152,13 +152,14 @@ describe('printList — pretty mode', () => {
io: r.io, io: r.io,
}); });
const out = stripAnsi(r.out()); const out = stripAnsi(r.out());
expect(out).toContain(`${SYMBOLS.barStart} sl list`); expect(out).toContain('sl list');
expect(out).toContain(`${SYMBOLS.group} warehouse`); expect(out).toContain('warehouse');
expect(out).toContain('(2 sources)'); expect(out).toContain('(2 sources)');
expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} orders\\s+5 cols ${escapeRegExp(SYMBOLS.middot)} 3 measures ${escapeRegExp(SYMBOLS.middot)} 1 join\\b`)); expect(out).toMatch(/orders\s+5 cols/);
expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} users\\s+8 cols ${escapeRegExp(SYMBOLS.middot)} 2 measures ${escapeRegExp(SYMBOLS.middot)} 2 joins\\b`)); expect(out).toMatch(new RegExp(`3 measures ${escapeRegExp(SYMBOLS.middot)} 1 join\\b`));
expect(out).toMatch(new RegExp(`2 measures ${escapeRegExp(SYMBOLS.middot)} 2 joins\\b`));
expect(out).toContain(`${SYMBOLS.emDash} User profile + auth`); expect(out).toContain(`${SYMBOLS.emDash} User profile + auth`);
expect(out).toContain(`${SYMBOLS.barEnd} 2 sources`); expect(out).toContain('2 sources');
}); });
it('renders an empty-state message when no rows', () => { it('renders an empty-state message when no rows', () => {
@ -174,11 +175,11 @@ describe('printList — pretty mode', () => {
io: r.io, io: r.io,
}); });
const out = stripAnsi(r.out()); const out = stripAnsi(r.out());
expect(out).toContain(`${SYMBOLS.barStart} sl list`); expect(out).toContain('sl list');
expect(out).toContain(`${SYMBOLS.barEnd} No semantic-layer sources found in /tmp/proj`); expect(out).toContain('No semantic-layer sources found in /tmp/proj');
}); });
it('renders empty-state with hint and zero-count footer when emptyHint is provided', () => { it('renders empty-state with hint when emptyHint is provided', () => {
const r = recorder(); const r = recorder();
printList<SlRow>({ printList<SlRow>({
rows: [], rows: [],
@ -192,9 +193,8 @@ describe('printList — pretty mode', () => {
io: r.io, io: r.io,
}); });
const out = stripAnsi(r.out()); const out = stripAnsi(r.out());
expect(out).toContain(`${SYMBOLS.bar} No sources matched "foo"`); expect(out).toContain('No sources matched "foo"');
expect(out).toContain(`${SYMBOLS.bar} Run \`ktx sl list\` to see available sources.`); expect(out).toContain('Run `ktx sl list` to see available sources.');
expect(out).toContain(`${SYMBOLS.barEnd} 0 sources`);
}); });
it('singularizes the footer when there is one row', () => { it('singularizes the footer when there is one row', () => {
@ -210,7 +210,7 @@ describe('printList — pretty mode', () => {
io: r.io, io: r.io,
}); });
const out = stripAnsi(r.out()); const out = stripAnsi(r.out());
expect(out).toContain(`${SYMBOLS.barEnd} 1 source`); expect(out).toContain('1 source');
}); });
it('uses the provided unit in pluralization and group counts', () => { it('uses the provided unit in pluralization and group counts', () => {
@ -236,10 +236,10 @@ describe('printList — pretty mode', () => {
}); });
const out = stripAnsi(r.out()); const out = stripAnsi(r.out());
expect(out).toContain('(2 pages)'); expect(out).toContain('(2 pages)');
expect(out).toContain(`${SYMBOLS.barEnd} 2 pages`); expect(out).toContain('2 pages');
}); });
it('renders a leading dim badge column with prettyFormat in pretty mode', () => { it('renders a leading badge column with prettyFormat in pretty mode', () => {
const r = recorder(); const r = recorder();
interface SearchRow { score: number; scope: string; key: string; summary: string } interface SearchRow { score: number; scope: string; key: string; summary: string }
const SEARCH_COLUMNS: ReadonlyArray<PrintListColumn<SearchRow>> = [ const SEARCH_COLUMNS: ReadonlyArray<PrintListColumn<SearchRow>> = [
@ -270,9 +270,8 @@ describe('printList — pretty mode', () => {
io: r.io, io: r.io,
}); });
const out = stripAnsi(r.out()); const out = stripAnsi(r.out());
// Badge displays as right-padded percentage before the name column. expect(out).toMatch(/87%\s+alpha\s+/);
expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} 87%\\s+alpha\\s+`)); expect(out).toMatch(/4%\s+beta\s+/);
expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} 4%\\s+beta\\s+`));
}); });
it('emits the badge column in plain mode using its plain prefix', () => { it('emits the badge column in plain mode using its plain prefix', () => {

View file

@ -18,7 +18,7 @@ export interface PrintListColumn<Row> {
dim?: boolean; dim?: boolean;
/** /**
* Pretty-mode role override. When omitted, role is auto-detected: * Pretty-mode role override. When omitted, role is auto-detected:
* - `'badge'` leading dim cell before the name column (right-padded across rows). * - `'badge'` leading cell before the name column (right-padded across rows).
* - `'name'` name column. Default: first non-grouped, non-metric, non-optional column. * - `'name'` name column. Default: first non-grouped, non-metric, non-optional column.
* - `'metric'` `"N word"` cell. Default: any column with a non-empty `plain` prefix. * - `'metric'` `"N word"` cell. Default: any column with a non-empty `plain` prefix.
* - `'suffix'` trailing em-dash optional value. Default: any column with `optional: true`. * - `'suffix'` trailing em-dash optional value. Default: any column with `optional: true`.
@ -202,20 +202,19 @@ function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
const { io, command, rows, columns, groupBy, emptyMessage, emptyHint } = args; const { io, command, rows, columns, groupBy, emptyMessage, emptyHint } = args;
const unit = args.unit ?? 'result'; const unit = args.unit ?? 'result';
io.stdout.write(`${SYMBOLS.barStart} ${command}\n`); io.stdout.write(`${bold(command)}\n`);
io.stdout.write(`${SYMBOLS.bar}\n`);
if (rows.length === 0) { if (rows.length === 0) {
io.stdout.write(`\n ${emptyMessage}\n`);
if (emptyHint !== undefined && emptyHint !== '') { if (emptyHint !== undefined && emptyHint !== '') {
io.stdout.write(`${SYMBOLS.bar} ${emptyMessage}\n`); io.stdout.write(` ${dim(emptyHint)}\n`);
io.stdout.write(`${SYMBOLS.bar} ${dim(emptyHint)}\n`);
io.stdout.write(`${SYMBOLS.barEnd} ${dim(`0 ${unit}s`)}\n`);
} else {
io.stdout.write(`${SYMBOLS.barEnd} ${emptyMessage}\n`);
} }
io.stdout.write('\n');
return; return;
} }
io.stdout.write('\n');
const resolved = resolveColumns(columns, groupBy); const resolved = resolveColumns(columns, groupBy);
const buckets = groupBy ? groupRows(rows, groupBy) : new Map<string, Row[]>([['', [...rows]]]); const buckets = groupBy ? groupRows(rows, groupBy) : new Map<string, Row[]>([['', [...rows]]]);
@ -231,14 +230,14 @@ function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
for (const [groupValue, groupRowList] of buckets) { for (const [groupValue, groupRowList] of buckets) {
if (groupBy) { if (groupBy) {
io.stdout.write( io.stdout.write(
`${SYMBOLS.bar} ${SYMBOLS.group} ${bold(groupValue)} ${dim(`(${pluralize(groupRowList.length, unit)})`)}\n`, ` ${bold(groupValue)} ${dim(`(${pluralize(groupRowList.length, unit)})`)}\n`,
); );
} }
for (const row of groupRowList) { for (const row of groupRowList) {
const segments: string[] = []; const segments: string[] = [];
resolved.badge.forEach((col, idx) => { resolved.badge.forEach((col, idx) => {
segments.push(dim(formatCellValue(col, row).padStart(badgeWidths[idx] ?? 0))); segments.push(formatCellValue(col, row).padStart(badgeWidths[idx] ?? 0));
}); });
if (resolved.name) { if (resolved.name) {
@ -265,10 +264,10 @@ function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
if (optionalSuffix.length > 0) segments.push(optionalSuffix); if (optionalSuffix.length > 0) segments.push(optionalSuffix);
const indent = groupBy ? ' ' : ' '; const indent = groupBy ? ' ' : ' ';
io.stdout.write(`${SYMBOLS.bar}${indent}${SYMBOLS.item} ${segments.join(' ')}\n`); io.stdout.write(`${indent}${segments.join(' ')}\n`);
} }
io.stdout.write('\n');
} }
io.stdout.write(`${SYMBOLS.bar}\n`); io.stdout.write(`${pluralize(rows.length, unit)}\n`);
io.stdout.write(`${SYMBOLS.barEnd} ${pluralize(rows.length, unit)}\n`);
} }

View file

@ -15,11 +15,6 @@ function detectUnicodeSupport(env: NodeJS.ProcessEnv = process.env): boolean {
const unicode = detectUnicodeSupport(); const unicode = detectUnicodeSupport();
export const SYMBOLS = { export const SYMBOLS = {
bar: unicode ? '│' : '|',
barStart: unicode ? '◇' : 'o',
barEnd: unicode ? '└' : '—',
group: unicode ? '●' : '*',
item: unicode ? '◆' : '*',
middot: unicode ? '·' : '-', middot: unicode ? '·' : '-',
emDash: unicode ? '—' : '--', emDash: unicode ? '—' : '--',
} as const; } as const;
@ -43,3 +38,7 @@ export function green(text: string): string {
export function red(text: string): string { export function red(text: string): string {
return styleText('red', text); return styleText('red', text);
} }
export function yellow(text: string): string {
return styleText('yellow', text);
}

View file

@ -198,7 +198,7 @@ describe('setup context build state', () => {
await writeKtxSetupContextState(tempDir, { await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-abc123', runId: 'setup-context-local-abc123',
status: 'running', status: 'stale',
startedAt: '2026-05-09T10:00:00.000Z', startedAt: '2026-05-09T10:00:00.000Z',
updatedAt: '2026-05-09T10:00:00.000Z', updatedAt: '2026-05-09T10:00:00.000Z',
primarySourceConnectionIds: ['warehouse'], primarySourceConnectionIds: ['warehouse'],
@ -207,6 +207,7 @@ describe('setup context build state', () => {
artifactPaths: [], artifactPaths: [],
retryableFailedTargets: [], retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'), commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
sourceProgress: [ sourceProgress: [
{ {
connectionId: 'warehouse', connectionId: 'warehouse',
@ -623,34 +624,13 @@ describe('setup context build state', () => {
expect(io.stderr()).toContain('No databases or context sources are configured for a KTX context build.'); expect(io.stderr()).toContain('No databases or context sources are configured for a KTX context build.');
}); });
it('normalizes legacy detached and paused setup context states to stale', async () => { it('starts a fresh foreground build when stale state is found', async () => {
await writeReadyProject(tempDir);
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-old',
status: 'detached' as never,
startedAt: '2026-05-09T09:00:00.000Z',
updatedAt: '2026-05-09T09:00:00.000Z',
primarySourceConnectionIds: ['warehouse'],
contextSourceConnectionIds: [],
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-old'),
});
await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({
status: 'stale',
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
});
});
it('starts a fresh foreground build when a stale running state is found', async () => {
await writeReadyProject(tempDir, { await writeReadyProject(tempDir, {
connections: { warehouse: { driver: 'postgres', readonly: true, context: { depth: 'fast' } } }, connections: { warehouse: { driver: 'postgres', readonly: true, context: { depth: 'fast' } } },
}); });
await writeKtxSetupContextState(tempDir, { await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-running', runId: 'setup-context-local-stale',
status: 'running', status: 'stale',
startedAt: '2026-05-09T09:00:00.000Z', startedAt: '2026-05-09T09:00:00.000Z',
updatedAt: '2026-05-09T09:00:00.000Z', updatedAt: '2026-05-09T09:00:00.000Z',
primarySourceConnectionIds: ['warehouse'], primarySourceConnectionIds: ['warehouse'],
@ -658,7 +638,8 @@ describe('setup context build state', () => {
reportIds: [], reportIds: [],
artifactPaths: [], artifactPaths: [],
retryableFailedTargets: [], retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-running'), commands: contextBuildCommands(tempDir, 'setup-context-local-stale'),
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
}); });
const io = makeIo(); const io = makeIo();
const runContextBuildMock = vi.fn(async () => ({ exitCode: 0 })); const runContextBuildMock = vi.fn(async () => ({ exitCode: 0 }));

View file

@ -27,10 +27,8 @@ import {
export type KtxSetupContextBuildStatus = export type KtxSetupContextBuildStatus =
| 'not_started' | 'not_started'
| 'running'
| 'completed' | 'completed'
| 'failed' | 'failed'
| 'interrupted'
| 'stale'; | 'stale';
export interface KtxSetupContextCommands { export interface KtxSetupContextCommands {
@ -84,7 +82,6 @@ export interface KtxSetupContextStepArgs {
forcePrompt?: boolean; forcePrompt?: boolean;
allowEmpty?: boolean; allowEmpty?: boolean;
prompt?: boolean; prompt?: boolean;
autoWatch?: boolean;
cliVersion?: string; cliVersion?: string;
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy; runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
} }
@ -154,14 +151,8 @@ function normalizeState(projectDir: string, value: unknown): KtxSetupContextStat
} }
const record = value as Record<string, unknown>; const record = value as Record<string, unknown>;
const rawStatus = typeof record.status === 'string' ? record.status : 'not_started'; const rawStatus = typeof record.status === 'string' ? record.status : 'not_started';
const legacyActive = rawStatus === 'detached' || rawStatus === 'paused' || rawStatus === 'running'; const status: KtxSetupContextBuildStatus =
const status: KtxSetupContextBuildStatus = legacyActive rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'not_started' || rawStatus === 'stale'
? 'stale'
: rawStatus === 'completed' ||
rawStatus === 'failed' ||
rawStatus === 'interrupted' ||
rawStatus === 'not_started' ||
rawStatus === 'stale'
? rawStatus ? rawStatus
: 'not_started'; : 'not_started';
const runId = typeof record.runId === 'string' && record.runId.length > 0 ? record.runId : undefined; const runId = typeof record.runId === 'string' && record.runId.length > 0 ? record.runId : undefined;
@ -187,11 +178,7 @@ function normalizeState(projectDir: string, value: unknown): KtxSetupContextStat
? record.retryableFailedTargets.filter((item): item is string => typeof item === 'string') ? record.retryableFailedTargets.filter((item): item is string => typeof item === 'string')
: [], : [],
commands: contextBuildCommands(projectDir, runId), commands: contextBuildCommands(projectDir, runId),
...(typeof record.failureReason === 'string' ...(typeof record.failureReason === 'string' ? { failureReason: record.failureReason } : {}),
? { failureReason: record.failureReason }
: legacyActive
? { failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.' }
: {}),
...(normalizeSourceProgress(record.sourceProgress) ? { sourceProgress: normalizeSourceProgress(record.sourceProgress) } : {}), ...(normalizeSourceProgress(record.sourceProgress) ? { sourceProgress: normalizeSourceProgress(record.sourceProgress) } : {}),
}; };
} }
@ -552,9 +539,9 @@ async function runBuild(
const now = deps.now ?? (() => new Date()); const now = deps.now ?? (() => new Date());
const runId = deps.runIdFactory?.() ?? runIdFactory(); const runId = deps.runIdFactory?.() ?? runIdFactory();
const startedAt = now().toISOString(); const startedAt = now().toISOString();
const runningState: KtxSetupContextState = { const incompleteState: KtxSetupContextState = {
runId, runId,
status: 'running', status: 'stale',
startedAt, startedAt,
updatedAt: startedAt, updatedAt: startedAt,
primarySourceConnectionIds: targets.primarySourceConnectionIds, primarySourceConnectionIds: targets.primarySourceConnectionIds,
@ -563,8 +550,9 @@ async function runBuild(
artifactPaths: [], artifactPaths: [],
retryableFailedTargets: [], retryableFailedTargets: [],
commands: contextBuildCommands(args.projectDir, runId), commands: contextBuildCommands(args.projectDir, runId),
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
}; };
await writeKtxSetupContextState(args.projectDir, runningState); await writeKtxSetupContextState(args.projectDir, incompleteState);
let lastSourceProgress: ContextBuildSourceProgressUpdate[] | undefined; let lastSourceProgress: ContextBuildSourceProgressUpdate[] | undefined;
const contextBuild = deps.runContextBuild ?? runContextBuild; const contextBuild = deps.runContextBuild ?? runContextBuild;
@ -584,7 +572,7 @@ async function runBuild(
const resolvedDir = resolve(args.projectDir); const resolvedDir = resolve(args.projectDir);
mkdirSync(join(resolvedDir, '.ktx', 'setup'), { recursive: true }); mkdirSync(join(resolvedDir, '.ktx', 'setup'), { recursive: true });
const progressState = normalizeState(resolvedDir, { const progressState = normalizeState(resolvedDir, {
...runningState, ...incompleteState,
sourceProgress: sources, sourceProgress: sources,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
@ -600,7 +588,7 @@ async function runBuild(
if (buildResult.exitCode !== 0) { if (buildResult.exitCode !== 0) {
const updatedAt = now().toISOString(); const updatedAt = now().toISOString();
await writeKtxSetupContextState(args.projectDir, { await writeKtxSetupContextState(args.projectDir, {
...runningState, ...incompleteState,
status: 'failed', status: 'failed',
updatedAt, updatedAt,
reportIds: completedReportIds, reportIds: completedReportIds,
@ -616,7 +604,7 @@ async function runBuild(
if (!readiness.ready) { if (!readiness.ready) {
const updatedAt = now().toISOString(); const updatedAt = now().toISOString();
await writeKtxSetupContextState(args.projectDir, { await writeKtxSetupContextState(args.projectDir, {
...runningState, ...incompleteState,
status: 'failed', status: 'failed',
updatedAt, updatedAt,
reportIds: completedReportIds, reportIds: completedReportIds,
@ -635,13 +623,14 @@ async function runBuild(
await markContextComplete(project.projectDir); await markContextComplete(project.projectDir);
const completedAt = now().toISOString(); const completedAt = now().toISOString();
await writeKtxSetupContextState(args.projectDir, { await writeKtxSetupContextState(args.projectDir, {
...runningState, ...incompleteState,
status: 'completed', status: 'completed',
updatedAt: completedAt, updatedAt: completedAt,
completedAt, completedAt,
reportIds: completedReportIds, reportIds: completedReportIds,
artifactPaths: completedArtifactPaths, artifactPaths: completedArtifactPaths,
retryableFailedTargets: [], retryableFailedTargets: [],
failureReason: undefined,
...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}), ...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}),
}); });
writeSuccess(project, readiness, targets, io); writeSuccess(project, readiness, targets, io);

View file

@ -7,7 +7,7 @@ import { writeKtxSetupState } from '@ktx/context/project';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js'; import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
import { contextBuildCommands, readKtxSetupContextState, writeKtxSetupContextState } from './setup-context.js'; import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
import { runDemoTour } from './setup-demo-tour.js'; import { runDemoTour } from './setup-demo-tour.js';
import { formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js'; import { formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js';
@ -279,7 +279,7 @@ describe('setup status', () => {
}); });
await writeKtxSetupContextState(tempDir, { await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-abc123', runId: 'setup-context-local-abc123',
status: 'running', status: 'stale',
startedAt: '2026-05-09T10:00:00.000Z', startedAt: '2026-05-09T10:00:00.000Z',
updatedAt: '2026-05-09T10:01:00.000Z', updatedAt: '2026-05-09T10:01:00.000Z',
primarySourceConnectionIds: ['warehouse'], primarySourceConnectionIds: ['warehouse'],
@ -288,6 +288,7 @@ describe('setup status', () => {
artifactPaths: [], artifactPaths: [],
retryableFailedTargets: [], retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'), commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
}); });
await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({ await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
@ -1622,40 +1623,6 @@ describe('setup status', () => {
expect(io.stderr()).toContain('KTX context is not ready for agents.'); expect(io.stderr()).toContain('KTX context is not ready for agents.');
}); });
it('does not offer background watch choices from setup status', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'setup:',
' database_connection_ids:',
' - warehouse',
'connections:',
' warehouse:',
' driver: postgres',
' url: env:DATABASE_URL',
'',
].join('\n'),
'utf-8',
);
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-stale',
status: 'running',
startedAt: '2026-05-09T09:00:00.000Z',
updatedAt: '2026-05-09T09:00:00.000Z',
primarySourceConnectionIds: ['warehouse'],
contextSourceConnectionIds: [],
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-stale'),
});
const status = await readKtxSetupStatus(tempDir);
expect(status.context.status).toBe('stale');
const state = await readKtxSetupContextState(tempDir);
expect(state.status).toBe('stale');
});
it('routes a ready project menu selection to agent setup', async () => { it('routes a ready project menu selection to agent setup', async () => {
const calls: string[] = []; const calls: string[] = [];
const io = makeIo(); const io = makeIo();

View file

@ -163,10 +163,7 @@ type KtxSetupFlowStatus =
| 'skipped' | 'skipped'
| 'back' | 'back'
| 'missing-input' | 'missing-input'
| 'failed' | 'failed';
| 'detached'
| 'paused'
| 'interrupted';
export interface KtxSetupEntryMenuPromptAdapter { export interface KtxSetupEntryMenuPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>; select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
@ -411,10 +408,6 @@ function setupContextReady(status: KtxSetupStatus): boolean {
return status.context.ready; return status.context.ready;
} }
function setupContextActive(status: KtxSetupStatus): boolean {
return status.context.status === 'running';
}
function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void { function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void {
io.stderr.write('KTX context is not ready for agents.\n\n'); io.stderr.write('KTX context is not ready for agents.\n\n');
io.stderr.write(`Build context first:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`); io.stderr.write(`Build context first:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`);
@ -454,27 +447,22 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
args.inputMode !== 'disabled' && args.inputMode !== 'disabled' &&
!args.agents && !args.agents &&
(io.stdout.isTTY === true || deps.entryMenuDeps?.prompts !== undefined); (io.stdout.isTTY === true || deps.entryMenuDeps?.prompts !== undefined);
let autoWatchActiveBuild = false;
setupLoop: while (true) { setupLoop: while (true) {
entryAction = undefined; entryAction = undefined;
if (canShowEntryMenu) { if (canShowEntryMenu) {
const status = await readKtxSetupStatus(args.projectDir); const status = await readKtxSetupStatus(args.projectDir);
if (setupContextActive(status)) { entryAction = (await runKtxSetupEntryMenu(status, deps.entryMenuDeps)).action;
autoWatchActiveBuild = true; if (entryAction === 'exit') {
} else { (deps.entryMenuDeps?.prompts ?? createEntryMenuPromptAdapter()).cancel('Setup cancelled.');
entryAction = (await runKtxSetupEntryMenu(status, deps.entryMenuDeps)).action; return 0;
if (entryAction === 'exit') { }
(deps.entryMenuDeps?.prompts ?? createEntryMenuPromptAdapter()).cancel('Setup cancelled.'); if (entryAction === 'status') {
return 0; io.stdout.write(formatKtxSetupStatus(status));
} return 0;
if (entryAction === 'status') { }
io.stdout.write(formatKtxSetupStatus(status)); if (entryAction === 'demo') {
return 0; return await runKtxSetupDemoFromEntryMenu(args, io, deps);
}
if (entryAction === 'demo') {
return await runKtxSetupDemoFromEntryMenu(args, io, deps);
}
} }
} }
@ -503,30 +491,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
const currentStatus = await readKtxSetupStatus(projectResult.projectDir); const currentStatus = await readKtxSetupStatus(projectResult.projectDir);
let readyAction: string | undefined; let readyAction: string | undefined;
if (args.inputMode !== 'disabled' && !agentsRequested && setupContextActive(currentStatus)) {
const contextRunner =
deps.context ?? ((contextArgs, contextIo) => runKtxSetupContextStep(contextArgs, contextIo, deps.contextDeps));
const contextResult = await contextRunner(
{
projectDir: projectResult.projectDir,
inputMode: args.inputMode,
allowEmpty: true,
...(autoWatchActiveBuild ? { autoWatch: true } : {}),
},
io,
);
autoWatchActiveBuild = false;
if (contextResult.status === 'back') {
continue;
}
if (contextResult.status === 'failed' || contextResult.status === 'missing-input') {
return 1;
}
if (contextResult.status !== 'ready') {
return 0;
}
}
if (args.inputMode !== 'disabled' && !agentsRequested) { if (args.inputMode !== 'disabled' && !agentsRequested) {
if (isKtxSetupReady(currentStatus)) { if (isKtxSetupReady(currentStatus)) {
readyAction = (await runKtxSetupReadyChangeMenu(currentStatus, deps.readyMenuDeps)).action; readyAction = (await runKtxSetupReadyChangeMenu(currentStatus, deps.readyMenuDeps)).action;

View file

@ -9,6 +9,13 @@ import type {
} from '@ktx/context/project'; } from '@ktx/context/project';
import type { PostgresPgssProbeResult } from '@ktx/context/ingest'; import type { PostgresPgssProbeResult } from '@ktx/context/ingest';
import type { DoctorCheck } from './doctor.js'; import type { DoctorCheck } from './doctor.js';
import {
bold as _bold,
dim as _dim,
green,
red,
yellow,
} from './io/symbols.js';
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js'; import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
type ProjectStatusLevel = 'ok' | 'warn' | 'fail'; type ProjectStatusLevel = 'ok' | 'warn' | 'fail';
@ -694,13 +701,11 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
const SYMBOL: Record<ProjectStatusLevel, string> = { ok: '✓', warn: '⚠', fail: '✗' }; const SYMBOL: Record<ProjectStatusLevel, string> = { ok: '✓', warn: '⚠', fail: '✗' };
function ansi(useColor: boolean, code: string, text: string, closer = '39'): string { function colorForLevel(useColor: boolean, level: ProjectStatusLevel, text: string): string {
return useColor ? `\u001b[${code}m${text}\u001b[${closer}m` : text; if (!useColor) return text;
return level === 'ok' ? green(text) : level === 'warn' ? yellow(text) : red(text);
} }
function colorFor(level: ProjectStatusLevel): string {
return level === 'ok' ? '32' : level === 'warn' ? '33' : '31';
}
function abbreviateHome(filePath: string, env: NodeJS.ProcessEnv): string { function abbreviateHome(filePath: string, env: NodeJS.ProcessEnv): string {
const home = env.HOME; const home = env.HOME;
@ -722,9 +727,9 @@ export function renderProjectStatus(status: ProjectStatus, options: RenderProjec
const verbose = options.verbose ?? false; const verbose = options.verbose ?? false;
const useColor = options.useColor ?? false; const useColor = options.useColor ?? false;
const env = options.env ?? process.env; const env = options.env ?? process.env;
const dim = (s: string) => ansi(useColor, '2', s, '22'); const dim = useColor ? _dim : (s: string) => s;
const bold = (s: string) => ansi(useColor, '1', s, '22'); const bold = useColor ? _bold : (s: string) => s;
const color = (level: ProjectStatusLevel, s: string) => ansi(useColor, colorFor(level), s); const color = (level: ProjectStatusLevel, s: string) => colorForLevel(useColor, level, s);
const sym = (level: ProjectStatusLevel) => color(level, SYMBOL[level]); const sym = (level: ProjectStatusLevel) => color(level, SYMBOL[level]);
const lines: string[] = []; const lines: string[] = [];

View file

@ -2,7 +2,12 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { constants } from 'node:fs'; import { constants } from 'node:fs';
import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from 'node:fs/promises'; import {
access as fsAccess,
readdir as fsReaddir,
stat as fsStat,
writeFile as fsWriteFile,
} from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
@ -14,6 +19,10 @@ function cliBinPath(rootDir) {
return resolve(rootDir, 'packages', 'cli', 'dist', 'bin.js'); return resolve(rootDir, 'packages', 'cli', 'dist', 'bin.js');
} }
function buildStampPath(rootDir) {
return resolve(rootDir, 'packages', 'cli', 'dist', '.ktx-build-stamp');
}
async function fileExists(path, access) { async function fileExists(path, access) {
try { try {
await access(path, constants.R_OK); await access(path, constants.R_OK);
@ -66,17 +75,17 @@ async function newestMtimeMs(path, fs) {
return newest; return newest;
} }
async function isBuildStale(rootDir, binPath, fs) { async function isBuildStale(rootDir, stampPath, fs) {
let binStats; let stampStats;
try { try {
binStats = await fs.stat(binPath); stampStats = await fs.stat(stampPath);
} catch { } catch {
return true; return true;
} }
const inputPaths = await packageBuildInputPaths(rootDir, fs.readdir); const inputPaths = await packageBuildInputPaths(rootDir, fs.readdir);
for (const inputPath of inputPaths) { for (const inputPath of inputPaths) {
if ((await newestMtimeMs(inputPath, fs)) > binStats.mtimeMs) { if ((await newestMtimeMs(inputPath, fs)) > stampStats.mtimeMs) {
return true; return true;
} }
} }
@ -137,7 +146,9 @@ export async function runWorkspaceKtx(argv, options = {}) {
stat: options.stat ?? fsStat, stat: options.stat ?? fsStat,
readdir: options.readdir ?? fsReaddir, readdir: options.readdir ?? fsReaddir,
}; };
const writeFile = options.writeFile ?? fsWriteFile;
const binPath = cliBinPath(rootDir); const binPath = cliBinPath(rootDir);
const stampPath = buildStampPath(rootDir);
const runCommand = const runCommand =
options.runCommand ?? options.runCommand ??
(options.execFile (options.execFile
@ -146,7 +157,7 @@ export async function runWorkspaceKtx(argv, options = {}) {
const commandEnv = options.env; const commandEnv = options.env;
const binExists = await fileExists(binPath, access); const binExists = await fileExists(binPath, access);
const needsBuild = !binExists || (await isBuildStale(rootDir, binPath, fs)); const needsBuild = !binExists || (await isBuildStale(rootDir, stampPath, fs));
if (needsBuild) { if (needsBuild) {
stderr.write( stderr.write(
binExists binExists
@ -160,6 +171,7 @@ export async function runWorkspaceKtx(argv, options = {}) {
); );
return buildExitCode; return buildExitCode;
} }
await writeFile(stampPath, '');
} }
return await runCommand(process.execPath, [binPath, ...cliArgv], { cwd: rootDir, env: commandEnv }); return await runCommand(process.execPath, [binPath, ...cliArgv], { cwd: rootDir, env: commandEnv });

View file

@ -4,10 +4,18 @@ import { runWorkspaceKtx } from './run-ktx.mjs';
function freshBuildFs() { function freshBuildFs() {
return { return {
stat: async (path) => ({ stat: async (path) => {
mtimeMs: path.endsWith('/packages/cli/dist/bin.js') ? 2000 : 1000, if (path.endsWith('/.ktx-build-stamp')) {
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'), return { mtimeMs: 2000, isDirectory: () => false };
}), }
if (path.endsWith('/packages/cli/dist/bin.js')) {
return { mtimeMs: 2000, isDirectory: () => false };
}
return {
mtimeMs: 1000,
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'),
};
},
readdir: async (path) => { readdir: async (path) => {
if (path.endsWith('/packages')) { if (path.endsWith('/packages')) {
return [{ name: 'cli', isDirectory: () => true }]; return [{ name: 'cli', isDirectory: () => true }];
@ -108,6 +116,7 @@ test('runWorkspaceKtx drops a leading npm argument separator', async () => {
test('runWorkspaceKtx builds the workspace CLI before running it when dist is missing', async () => { test('runWorkspaceKtx builds the workspace CLI before running it when dist is missing', async () => {
const calls = []; const calls = [];
const logs = []; const logs = [];
const writes = [];
let binExists = false; let binExists = false;
const exitCode = await runWorkspaceKtx(['setup', 'demo', '--mode', 'replay', '--no-input', '--viz'], { const exitCode = await runWorkspaceKtx(['setup', 'demo', '--mode', 'replay', '--no-input', '--viz'], {
@ -125,6 +134,9 @@ test('runWorkspaceKtx builds the workspace CLI before running it when dist is mi
} }
return { stdout: 'Replay complete\n', stderr: '' }; return { stdout: 'Replay complete\n', stderr: '' };
}, },
writeFile: async (path, contents) => {
writes.push({ path, contents });
},
stdout: { write: (chunk) => logs.push(['stdout', chunk]) }, stdout: { write: (chunk) => logs.push(['stdout', chunk]) },
stderr: { write: (chunk) => logs.push(['stderr', chunk]) }, stderr: { write: (chunk) => logs.push(['stderr', chunk]) },
}); });
@ -145,20 +157,32 @@ test('runWorkspaceKtx builds the workspace CLI before running it when dist is mi
['stdout', 'build ok\n'], ['stdout', 'build ok\n'],
['stdout', 'Replay complete\n'], ['stdout', 'Replay complete\n'],
]); ]);
assert.deepEqual(writes, [
{ path: '/workspace/ktx/packages/cli/dist/.ktx-build-stamp', contents: '' },
]);
}); });
test('runWorkspaceKtx rebuilds before running when workspace sources are newer than dist', async () => { test('runWorkspaceKtx rebuilds before running when workspace sources are newer than the build stamp', async () => {
const calls = []; const calls = [];
const logs = []; const logs = [];
const writes = [];
let sourceMtimeMs = 3000; let sourceMtimeMs = 3000;
const exitCode = await runWorkspaceKtx(['status', '--json', '--no-input'], { const exitCode = await runWorkspaceKtx(['status', '--json', '--no-input'], {
rootDir: '/workspace/ktx', rootDir: '/workspace/ktx',
access: async () => undefined, access: async () => undefined,
stat: async (path) => ({ stat: async (path) => {
mtimeMs: path.endsWith('/packages/cli/dist/bin.js') ? 2000 : sourceMtimeMs, if (path.endsWith('/.ktx-build-stamp')) {
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'), return { mtimeMs: 2000, isDirectory: () => false };
}), }
if (path.endsWith('/packages/cli/dist/bin.js')) {
return { mtimeMs: 2000, isDirectory: () => false };
}
return {
mtimeMs: sourceMtimeMs,
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'),
};
},
readdir: async (path) => { readdir: async (path) => {
if (path.endsWith('/packages')) { if (path.endsWith('/packages')) {
return [{ name: 'context', isDirectory: () => true }]; return [{ name: 'context', isDirectory: () => true }];
@ -176,6 +200,9 @@ test('runWorkspaceKtx rebuilds before running when workspace sources are newer t
} }
return { stdout: '{"status":"ready"}\n', stderr: '' }; return { stdout: '{"status":"ready"}\n', stderr: '' };
}, },
writeFile: async (path, contents) => {
writes.push({ path, contents });
},
stdout: { write: (chunk) => logs.push(['stdout', chunk]) }, stdout: { write: (chunk) => logs.push(['stdout', chunk]) },
stderr: { write: (chunk) => logs.push(['stderr', chunk]) }, stderr: { write: (chunk) => logs.push(['stderr', chunk]) },
}); });
@ -193,4 +220,116 @@ test('runWorkspaceKtx rebuilds before running when workspace sources are newer t
['stdout', 'build ok\n'], ['stdout', 'build ok\n'],
['stdout', '{"status":"ready"}\n'], ['stdout', '{"status":"ready"}\n'],
]); ]);
assert.deepEqual(writes, [
{ path: '/workspace/ktx/packages/cli/dist/.ktx-build-stamp', contents: '' },
]);
});
test('runWorkspaceKtx skips rebuild when only bin.js is older than sources but stamp is fresh', async () => {
const calls = [];
const logs = [];
const writes = [];
const exitCode = await runWorkspaceKtx(['status'], {
rootDir: '/workspace/ktx',
access: async () => undefined,
stat: async (path) => {
if (path.endsWith('/.ktx-build-stamp')) {
return { mtimeMs: 5000, isDirectory: () => false };
}
if (path.endsWith('/packages/cli/dist/bin.js')) {
return { mtimeMs: 1000, isDirectory: () => false };
}
return {
mtimeMs: 3000,
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'),
};
},
readdir: async (path) => {
if (path.endsWith('/packages')) {
return [{ name: 'cli', isDirectory: () => true }];
}
if (path.endsWith('/src')) {
return [{ name: 'setup.ts', isDirectory: () => false }];
}
return [];
},
execFile: async (command, args, options) => {
calls.push({ command, args, cwd: options.cwd });
return { stdout: 'KTX status\n', stderr: '' };
},
writeFile: async (path, contents) => {
writes.push({ path, contents });
},
stdout: { write: (chunk) => logs.push(['stdout', chunk]) },
stderr: { write: (chunk) => logs.push(['stderr', chunk]) },
});
assert.equal(exitCode, 0);
assert.deepEqual(
calls.map((call) => [call.command, call.args]),
[[process.execPath, ['/workspace/ktx/packages/cli/dist/bin.js', 'status']]],
);
assert.deepEqual(writes, []);
assert.deepEqual(logs, [['stdout', 'KTX status\n']]);
});
test('runWorkspaceKtx rebuilds when stamp is missing even if bin.js exists', async () => {
const calls = [];
const logs = [];
const writes = [];
const exitCode = await runWorkspaceKtx(['status'], {
rootDir: '/workspace/ktx',
access: async () => undefined,
stat: async (path) => {
if (path.endsWith('/.ktx-build-stamp')) {
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
}
if (path.endsWith('/packages/cli/dist/bin.js')) {
return { mtimeMs: 2000, isDirectory: () => false };
}
return {
mtimeMs: 1000,
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'),
};
},
readdir: async (path) => {
if (path.endsWith('/packages')) {
return [{ name: 'cli', isDirectory: () => true }];
}
if (path.endsWith('/src')) {
return [{ name: 'bin.ts', isDirectory: () => false }];
}
return [];
},
execFile: async (command, args, options) => {
calls.push({ command, args, cwd: options.cwd });
if (command === 'pnpm') {
return { stdout: 'build ok\n', stderr: '' };
}
return { stdout: 'KTX status\n', stderr: '' };
},
writeFile: async (path, contents) => {
writes.push({ path, contents });
},
stdout: { write: (chunk) => logs.push(['stdout', chunk]) },
stderr: { write: (chunk) => logs.push(['stderr', chunk]) },
});
assert.equal(exitCode, 0);
assert.deepEqual(
calls.map((call) => [call.command, call.args]),
[
['pnpm', ['run', 'build']],
[process.execPath, ['/workspace/ktx/packages/cli/dist/bin.js', 'status']],
],
);
assert.deepEqual(logs[0], [
'stderr',
'KTX CLI build output is stale. Rebuilding it now with `pnpm run build`...\n',
]);
assert.deepEqual(writes, [
{ path: '/workspace/ktx/packages/cli/dist/.ktx-build-stamp', contents: '' },
]);
}); });