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: '' }, + ]); });