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
__pycache__/
*.py[cod]

View file

@ -1,28 +1,36 @@
export function Logo() {
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]">
<img
src="/brand/ktx-mascot.svg"
src="/ktx/brand/ktx-mascot.svg"
alt=""
aria-hidden="true"
className="h-14 w-14 object-contain block dark:hidden"
className="h-20 w-20 object-contain block dark:hidden"
/>
<img
src="/brand/ktx-mascot-dark.svg"
src="/ktx/brand/ktx-mascot-dark.svg"
alt=""
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 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
className="text-[17px] font-semibold text-fd-foreground tracking-tight"
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"
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" }}
>
Docs

View file

@ -315,14 +315,14 @@ function padVisual(text: string, width: number): string {
}
function renderTestAll(io: KtxCliIo, rows: ReadonlyArray<ConnectionTestRow>): void {
io.stdout.write(`${SYMBOLS.barStart} connection test --all\n`);
io.stdout.write(`${SYMBOLS.bar}\n`);
io.stdout.write(`${bold('connection test --all')}\n`);
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;
}
io.stdout.write('\n');
const okLabel = green('✓ ok');
const failLabel = red('✗ failed');
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 status = padVisual(row.ok ? okLabel : failLabel, statusWidth);
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 passed = rows.length - failed;
io.stdout.write(`${SYMBOLS.bar}\n`);
io.stdout.write('\n');
const summary =
failed === 0
? `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)}`
: `${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(

View file

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

View file

@ -18,7 +18,7 @@ export interface PrintListColumn<Row> {
dim?: boolean;
/**
* 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.
* - `'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`.
@ -202,20 +202,19 @@ function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
const { io, command, rows, columns, groupBy, emptyMessage, emptyHint } = args;
const unit = args.unit ?? 'result';
io.stdout.write(`${SYMBOLS.barStart} ${command}\n`);
io.stdout.write(`${SYMBOLS.bar}\n`);
io.stdout.write(`${bold(command)}\n`);
if (rows.length === 0) {
io.stdout.write(`\n ${emptyMessage}\n`);
if (emptyHint !== undefined && emptyHint !== '') {
io.stdout.write(`${SYMBOLS.bar} ${emptyMessage}\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(` ${dim(emptyHint)}\n`);
}
io.stdout.write('\n');
return;
}
io.stdout.write('\n');
const resolved = resolveColumns(columns, groupBy);
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) {
if (groupBy) {
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) {
const segments: string[] = [];
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) {
@ -265,10 +264,10 @@ function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
if (optionalSuffix.length > 0) segments.push(optionalSuffix);
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(`${SYMBOLS.barEnd} ${pluralize(rows.length, unit)}\n`);
io.stdout.write(`${pluralize(rows.length, unit)}\n`);
}

View file

@ -15,11 +15,6 @@ function detectUnicodeSupport(env: NodeJS.ProcessEnv = process.env): boolean {
const unicode = detectUnicodeSupport();
export const SYMBOLS = {
bar: unicode ? '│' : '|',
barStart: unicode ? '◇' : 'o',
barEnd: unicode ? '└' : '—',
group: unicode ? '●' : '*',
item: unicode ? '◆' : '*',
middot: unicode ? '·' : '-',
emDash: unicode ? '—' : '--',
} as const;
@ -43,3 +38,7 @@ export function green(text: string): string {
export function red(text: string): string {
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, {
runId: 'setup-context-local-abc123',
status: 'running',
status: 'stale',
startedAt: '2026-05-09T10:00:00.000Z',
updatedAt: '2026-05-09T10:00:00.000Z',
primarySourceConnectionIds: ['warehouse'],
@ -207,6 +207,7 @@ describe('setup context build state', () => {
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
sourceProgress: [
{
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.');
});
it('normalizes legacy detached and paused setup context states to stale', 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 () => {
it('starts a fresh foreground build when stale state is found', async () => {
await writeReadyProject(tempDir, {
connections: { warehouse: { driver: 'postgres', readonly: true, context: { depth: 'fast' } } },
});
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-running',
status: 'running',
runId: 'setup-context-local-stale',
status: 'stale',
startedAt: '2026-05-09T09:00:00.000Z',
updatedAt: '2026-05-09T09:00:00.000Z',
primarySourceConnectionIds: ['warehouse'],
@ -658,7 +638,8 @@ describe('setup context build state', () => {
reportIds: [],
artifactPaths: [],
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 runContextBuildMock = vi.fn(async () => ({ exitCode: 0 }));

View file

@ -27,10 +27,8 @@ import {
export type KtxSetupContextBuildStatus =
| 'not_started'
| 'running'
| 'completed'
| 'failed'
| 'interrupted'
| 'stale';
export interface KtxSetupContextCommands {
@ -84,7 +82,6 @@ export interface KtxSetupContextStepArgs {
forcePrompt?: boolean;
allowEmpty?: boolean;
prompt?: boolean;
autoWatch?: boolean;
cliVersion?: string;
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
}
@ -154,14 +151,8 @@ function normalizeState(projectDir: string, value: unknown): KtxSetupContextStat
}
const record = value as Record<string, unknown>;
const rawStatus = typeof record.status === 'string' ? record.status : 'not_started';
const legacyActive = rawStatus === 'detached' || rawStatus === 'paused' || rawStatus === 'running';
const status: KtxSetupContextBuildStatus = legacyActive
? 'stale'
: rawStatus === 'completed' ||
rawStatus === 'failed' ||
rawStatus === 'interrupted' ||
rawStatus === 'not_started' ||
rawStatus === 'stale'
const status: KtxSetupContextBuildStatus =
rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'not_started' || rawStatus === 'stale'
? rawStatus
: 'not_started';
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')
: [],
commands: contextBuildCommands(projectDir, runId),
...(typeof record.failureReason === 'string'
? { failureReason: record.failureReason }
: legacyActive
? { failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.' }
: {}),
...(typeof record.failureReason === 'string' ? { failureReason: record.failureReason } : {}),
...(normalizeSourceProgress(record.sourceProgress) ? { sourceProgress: normalizeSourceProgress(record.sourceProgress) } : {}),
};
}
@ -552,9 +539,9 @@ async function runBuild(
const now = deps.now ?? (() => new Date());
const runId = deps.runIdFactory?.() ?? runIdFactory();
const startedAt = now().toISOString();
const runningState: KtxSetupContextState = {
const incompleteState: KtxSetupContextState = {
runId,
status: 'running',
status: 'stale',
startedAt,
updatedAt: startedAt,
primarySourceConnectionIds: targets.primarySourceConnectionIds,
@ -563,8 +550,9 @@ async function runBuild(
artifactPaths: [],
retryableFailedTargets: [],
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;
const contextBuild = deps.runContextBuild ?? runContextBuild;
@ -584,7 +572,7 @@ async function runBuild(
const resolvedDir = resolve(args.projectDir);
mkdirSync(join(resolvedDir, '.ktx', 'setup'), { recursive: true });
const progressState = normalizeState(resolvedDir, {
...runningState,
...incompleteState,
sourceProgress: sources,
updatedAt: new Date().toISOString(),
});
@ -600,7 +588,7 @@ async function runBuild(
if (buildResult.exitCode !== 0) {
const updatedAt = now().toISOString();
await writeKtxSetupContextState(args.projectDir, {
...runningState,
...incompleteState,
status: 'failed',
updatedAt,
reportIds: completedReportIds,
@ -616,7 +604,7 @@ async function runBuild(
if (!readiness.ready) {
const updatedAt = now().toISOString();
await writeKtxSetupContextState(args.projectDir, {
...runningState,
...incompleteState,
status: 'failed',
updatedAt,
reportIds: completedReportIds,
@ -635,13 +623,14 @@ async function runBuild(
await markContextComplete(project.projectDir);
const completedAt = now().toISOString();
await writeKtxSetupContextState(args.projectDir, {
...runningState,
...incompleteState,
status: 'completed',
updatedAt: completedAt,
completedAt,
reportIds: completedReportIds,
artifactPaths: completedArtifactPaths,
retryableFailedTargets: [],
failureReason: undefined,
...(lastSourceProgress ? { sourceProgress: lastSourceProgress } : {}),
});
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 { 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 { formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js';
@ -279,7 +279,7 @@ describe('setup status', () => {
});
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-abc123',
status: 'running',
status: 'stale',
startedAt: '2026-05-09T10:00:00.000Z',
updatedAt: '2026-05-09T10:01:00.000Z',
primarySourceConnectionIds: ['warehouse'],
@ -288,6 +288,7 @@ describe('setup status', () => {
artifactPaths: [],
retryableFailedTargets: [],
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({
@ -1622,40 +1623,6 @@ describe('setup status', () => {
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 () => {
const calls: string[] = [];
const io = makeIo();

View file

@ -163,10 +163,7 @@ type KtxSetupFlowStatus =
| 'skipped'
| 'back'
| 'missing-input'
| 'failed'
| 'detached'
| 'paused'
| 'interrupted';
| 'failed';
export interface KtxSetupEntryMenuPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
@ -411,10 +408,6 @@ function setupContextReady(status: KtxSetupStatus): boolean {
return status.context.ready;
}
function setupContextActive(status: KtxSetupStatus): boolean {
return status.context.status === 'running';
}
function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void {
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`);
@ -454,27 +447,22 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
args.inputMode !== 'disabled' &&
!args.agents &&
(io.stdout.isTTY === true || deps.entryMenuDeps?.prompts !== undefined);
let autoWatchActiveBuild = false;
setupLoop: while (true) {
entryAction = undefined;
if (canShowEntryMenu) {
const status = await readKtxSetupStatus(args.projectDir);
if (setupContextActive(status)) {
autoWatchActiveBuild = true;
} else {
entryAction = (await runKtxSetupEntryMenu(status, deps.entryMenuDeps)).action;
if (entryAction === 'exit') {
(deps.entryMenuDeps?.prompts ?? createEntryMenuPromptAdapter()).cancel('Setup cancelled.');
return 0;
}
if (entryAction === 'status') {
io.stdout.write(formatKtxSetupStatus(status));
return 0;
}
if (entryAction === 'demo') {
return await runKtxSetupDemoFromEntryMenu(args, io, deps);
}
entryAction = (await runKtxSetupEntryMenu(status, deps.entryMenuDeps)).action;
if (entryAction === 'exit') {
(deps.entryMenuDeps?.prompts ?? createEntryMenuPromptAdapter()).cancel('Setup cancelled.');
return 0;
}
if (entryAction === 'status') {
io.stdout.write(formatKtxSetupStatus(status));
return 0;
}
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);
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 (isKtxSetupReady(currentStatus)) {
readyAction = (await runKtxSetupReadyChangeMenu(currentStatus, deps.readyMenuDeps)).action;

View file

@ -9,6 +9,13 @@ import type {
} from '@ktx/context/project';
import type { PostgresPgssProbeResult } from '@ktx/context/ingest';
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';
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: '✗' };
function ansi(useColor: boolean, code: string, text: string, closer = '39'): string {
return useColor ? `\u001b[${code}m${text}\u001b[${closer}m` : text;
function colorForLevel(useColor: boolean, level: ProjectStatusLevel, text: string): string {
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 {
const home = env.HOME;
@ -722,9 +727,9 @@ export function renderProjectStatus(status: ProjectStatus, options: RenderProjec
const verbose = options.verbose ?? false;
const useColor = options.useColor ?? false;
const env = options.env ?? process.env;
const dim = (s: string) => ansi(useColor, '2', s, '22');
const bold = (s: string) => ansi(useColor, '1', s, '22');
const color = (level: ProjectStatusLevel, s: string) => ansi(useColor, colorFor(level), s);
const dim = useColor ? _dim : (s: string) => s;
const bold = useColor ? _bold : (s: string) => s;
const color = (level: ProjectStatusLevel, s: string) => colorForLevel(useColor, level, s);
const sym = (level: ProjectStatusLevel) => color(level, SYMBOL[level]);
const lines: string[] = [];

View file

@ -2,7 +2,12 @@
import { spawn } from 'node:child_process';
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 { fileURLToPath, pathToFileURL } from 'node:url';
@ -14,6 +19,10 @@ function cliBinPath(rootDir) {
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) {
try {
await access(path, constants.R_OK);
@ -66,17 +75,17 @@ async function newestMtimeMs(path, fs) {
return newest;
}
async function isBuildStale(rootDir, binPath, fs) {
let binStats;
async function isBuildStale(rootDir, stampPath, fs) {
let stampStats;
try {
binStats = await fs.stat(binPath);
stampStats = await fs.stat(stampPath);
} catch {
return true;
}
const inputPaths = await packageBuildInputPaths(rootDir, fs.readdir);
for (const inputPath of inputPaths) {
if ((await newestMtimeMs(inputPath, fs)) > binStats.mtimeMs) {
if ((await newestMtimeMs(inputPath, fs)) > stampStats.mtimeMs) {
return true;
}
}
@ -137,7 +146,9 @@ export async function runWorkspaceKtx(argv, options = {}) {
stat: options.stat ?? fsStat,
readdir: options.readdir ?? fsReaddir,
};
const writeFile = options.writeFile ?? fsWriteFile;
const binPath = cliBinPath(rootDir);
const stampPath = buildStampPath(rootDir);
const runCommand =
options.runCommand ??
(options.execFile
@ -146,7 +157,7 @@ export async function runWorkspaceKtx(argv, options = {}) {
const commandEnv = options.env;
const binExists = await fileExists(binPath, access);
const needsBuild = !binExists || (await isBuildStale(rootDir, binPath, fs));
const needsBuild = !binExists || (await isBuildStale(rootDir, stampPath, fs));
if (needsBuild) {
stderr.write(
binExists
@ -160,6 +171,7 @@ export async function runWorkspaceKtx(argv, options = {}) {
);
return buildExitCode;
}
await writeFile(stampPath, '');
}
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() {
return {
stat: async (path) => ({
mtimeMs: path.endsWith('/packages/cli/dist/bin.js') ? 2000 : 1000,
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'),
}),
stat: async (path) => {
if (path.endsWith('/.ktx-build-stamp')) {
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) => {
if (path.endsWith('/packages')) {
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 () => {
const calls = [];
const logs = [];
const writes = [];
let binExists = false;
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: '' };
},
writeFile: async (path, contents) => {
writes.push({ path, contents });
},
stdout: { write: (chunk) => logs.push(['stdout', 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', '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 logs = [];
const writes = [];
let sourceMtimeMs = 3000;
const exitCode = await runWorkspaceKtx(['status', '--json', '--no-input'], {
rootDir: '/workspace/ktx',
access: async () => undefined,
stat: async (path) => ({
mtimeMs: path.endsWith('/packages/cli/dist/bin.js') ? 2000 : sourceMtimeMs,
isDirectory: () => path.endsWith('/src') || path.endsWith('/packages'),
}),
stat: async (path) => {
if (path.endsWith('/.ktx-build-stamp')) {
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) => {
if (path.endsWith('/packages')) {
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: '' };
},
writeFile: async (path, contents) => {
writes.push({ path, contents });
},
stdout: { write: (chunk) => logs.push(['stdout', 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', '{"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: '' },
]);
});