diff --git a/.gitignore b/.gitignore
index 112e7faa..d1098953 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# Playwright CLI session artifacts (snapshots, console logs, screenshots)
+.playwright-cli/
+
# Python
__pycache__/
*.py[cod]
diff --git a/docs-site/components/logo.tsx b/docs-site/components/logo.tsx
index 4ab8f8ba..44ab7144 100644
--- a/docs-site/components/logo.tsx
+++ b/docs-site/components/logo.tsx
@@ -1,28 +1,36 @@
export function Logo() {
return (
-
+
+
+
+ KTX
+
+
+ by Kaelio
+
+
- KTX
-
-
Docs
diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts
index c65fc3c3..06d02922 100644
--- a/packages/cli/src/connection.ts
+++ b/packages/cli/src/connection.ts
@@ -315,14 +315,14 @@ function padVisual(text: string, width: number): string {
}
function renderTestAll(io: KtxCliIo, rows: ReadonlyArray): 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): 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(
diff --git a/packages/cli/src/io/print-list.test.ts b/packages/cli/src/io/print-list.test.ts
index 543cc71e..cb6e7947 100644
--- a/packages/cli/src/io/print-list.test.ts
+++ b/packages/cli/src/io/print-list.test.ts
@@ -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({
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({
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> = [
@@ -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', () => {
diff --git a/packages/cli/src/io/print-list.ts b/packages/cli/src/io/print-list.ts
index b05e12f2..3d8d1fba 100644
--- a/packages/cli/src/io/print-list.ts
+++ b/packages/cli/src/io/print-list.ts
@@ -18,7 +18,7 @@ export interface PrintListColumn {
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(args: PrintListArgs): 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([['', [...rows]]]);
@@ -231,14 +230,14 @@ function printListPretty(args: PrintListArgs): 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(args: PrintListArgs): 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`);
}
diff --git a/packages/cli/src/io/symbols.ts b/packages/cli/src/io/symbols.ts
index f80c2b79..ba93a436 100644
--- a/packages/cli/src/io/symbols.ts
+++ b/packages/cli/src/io/symbols.ts
@@ -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);
+}
diff --git a/packages/cli/src/setup-context.test.ts b/packages/cli/src/setup-context.test.ts
index 61a9019a..8cd81dff 100644
--- a/packages/cli/src/setup-context.test.ts
+++ b/packages/cli/src/setup-context.test.ts
@@ -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 }));
diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts
index 413230b1..de670224 100644
--- a/packages/cli/src/setup-context.ts
+++ b/packages/cli/src/setup-context.ts
@@ -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;
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);
diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts
index efc44441..ce65f353 100644
--- a/packages/cli/src/setup.test.ts
+++ b/packages/cli/src/setup.test.ts
@@ -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();
diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts
index 16ccaea9..e9caacd8 100644
--- a/packages/cli/src/setup.ts
+++ b/packages/cli/src/setup.ts
@@ -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;
@@ -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;
diff --git a/packages/cli/src/status-project.ts b/packages/cli/src/status-project.ts
index 2aab1e5c..8c2f2445 100644
--- a/packages/cli/src/status-project.ts
+++ b/packages/cli/src/status-project.ts
@@ -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 = { 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[] = [];
diff --git a/scripts/run-ktx.mjs b/scripts/run-ktx.mjs
index a283dcae..1a6ba735 100644
--- a/scripts/run-ktx.mjs
+++ b/scripts/run-ktx.mjs
@@ -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 });
diff --git a/scripts/run-ktx.test.mjs b/scripts/run-ktx.test.mjs
index 1533b67c..98035aef 100644
--- a/scripts/run-ktx.test.mjs
+++ b/scripts/run-ktx.test.mjs
@@ -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: '' },
+ ]);
});