mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
Merge branch 'main' into add-ktx-mcp-claude-desktop
This commit is contained in:
commit
658024dcf3
13 changed files with 275 additions and 210 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +1,6 @@
|
|||
# Playwright CLI session artifacts (snapshots, console logs, screenshots)
|
||||
.playwright-cli/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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: '' },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue