mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
Merge origin/main into schema select UX branch
This commit is contained in:
commit
b70bf6bd67
148 changed files with 8577 additions and 526 deletions
|
|
@ -91,22 +91,17 @@ describe('demo assets', () => {
|
|||
expect(manifest.sources.bi.explores).toBeGreaterThanOrEqual(2);
|
||||
expect(manifest.sources.bi.dashboards).toBeGreaterThanOrEqual(2);
|
||||
expect(manifest.sources.notion.pages).toBeGreaterThanOrEqual(5);
|
||||
expect(manifest.generated.semanticLayer.sourceCount).toBeGreaterThanOrEqual(5);
|
||||
expect(manifest.generated.knowledge.pageCount).toBeGreaterThanOrEqual(10);
|
||||
expect(manifest.generated.semanticLayer.sourceCount).toBeGreaterThanOrEqual(40);
|
||||
expect(manifest.generated.knowledge.pageCount).toBeGreaterThanOrEqual(20);
|
||||
expect(manifest.generated.links.linkCount).toBeGreaterThanOrEqual(10);
|
||||
|
||||
const dbStat = await stat(packagedDemoAssetPath('demo.db'));
|
||||
expect(dbStat.size).toBeGreaterThan(0);
|
||||
expect(dbStat.size).toBeLessThan(10 * 1024 * 1024);
|
||||
|
||||
await expect(access(packagedDemoAssetPath('raw-sources/warehouse/accounts.csv'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('raw-sources/dbt/schema.yml'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('raw-sources/bi/revenue_exec.dashboard.lookml'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('raw-sources/notion/revenue-reporting-policy.md'))).resolves.toBeUndefined();
|
||||
expect(manifest.generated.semanticLayer.path).toBe('semantic-layer/orbit_demo');
|
||||
|
||||
await expect(access(packagedDemoAssetPath('semantic-layer/orbit_demo/accounts.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('knowledge/global/arr-contract-first.md'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('semantic-layer/dbt-main/mart_arr_daily.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('semantic-layer/postgres-warehouse/mart_account_activity.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('knowledge/global/orbit-company-overview.md'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('links/provenance.json'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('reports/seeded-demo-report.json'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,14 +45,9 @@ const REQUIRED_SEEDED_ASSET_PATHS = [
|
|||
'demo.db',
|
||||
'manifest.json',
|
||||
DEMO_REPLAY_FILE,
|
||||
join('raw-sources', 'warehouse', 'accounts.csv'),
|
||||
join('raw-sources', 'dbt', 'schema.yml'),
|
||||
join('raw-sources', 'bi', 'revenue_exec.dashboard.lookml'),
|
||||
join('raw-sources', 'notion', 'revenue-reporting-policy.md'),
|
||||
join('semantic-layer', 'orbit_demo', 'accounts.yaml'),
|
||||
join('knowledge', 'global', 'arr-contract-first.md'),
|
||||
join('links', 'provenance.json'),
|
||||
join('reports', 'seeded-demo-report.json'),
|
||||
join('semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'),
|
||||
join('semantic-layer', 'postgres-warehouse', 'mart_account_activity.yaml'),
|
||||
join('knowledge', 'global', 'orbit-company-overview.md'),
|
||||
] as const;
|
||||
|
||||
function assetDir(): string {
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ describe('seeded demo inspect contract', () => {
|
|||
notion: { label: 'Notion', path: 'raw-sources/notion', pageCount: 8 },
|
||||
},
|
||||
generatedOutputs: {
|
||||
semanticLayer: { path: 'semantic-layer/orbit_demo', manifestSourceCount: 6, fileCount: 6 },
|
||||
knowledge: { path: 'knowledge/global', manifestPageCount: 10, fileCount: 10 },
|
||||
semanticLayer: { path: 'semantic-layer', manifestSourceCount: 46, fileCount: 46 },
|
||||
knowledge: { path: 'knowledge/global', manifestPageCount: 28, fileCount: 28 },
|
||||
links: { path: 'links/provenance.json', manifestLinkCount: 23, linkCount: 23 },
|
||||
reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 },
|
||||
replays: { primaryPath: 'replays/replay.memory-flow.v1.json', latestPath: 'replays/latest.memory-flow.v1.json' },
|
||||
|
|
@ -83,8 +83,8 @@ describe('seeded demo inspect contract', () => {
|
|||
expect(output).toContain('dbt: 3 models, 8 source tables');
|
||||
expect(output).toContain('BI: 5 explores, 2 dashboards');
|
||||
expect(output).toContain('Notion: 8 pages');
|
||||
expect(output).toContain('Semantic-layer sources: 6 manifest, 6 files');
|
||||
expect(output).toContain('Knowledge pages: 10 manifest, 10 files');
|
||||
expect(output).toContain('Semantic-layer sources: 46 manifest, 46 files');
|
||||
expect(output).toContain('Knowledge pages: 28 manifest, 28 files');
|
||||
expect(output).toContain('Evidence links: 23 manifest, 23 links');
|
||||
expect(output).toContain('Report: reports/seeded-demo-report.json');
|
||||
expect(output).toContain('Replay: replays/replay.memory-flow.v1.json');
|
||||
|
|
|
|||
|
|
@ -71,12 +71,9 @@ const REQUIRED_SEEDED_PROJECT_PATHS = [
|
|||
'state.sqlite',
|
||||
'manifest.json',
|
||||
join('replays', 'replay.memory-flow.v1.json'),
|
||||
join('raw-sources', 'warehouse', 'accounts.csv'),
|
||||
join('raw-sources', 'dbt', 'schema.yml'),
|
||||
join('raw-sources', 'bi', 'revenue_exec.dashboard.lookml'),
|
||||
join('raw-sources', 'notion', 'revenue-reporting-policy.md'),
|
||||
join('semantic-layer', 'orbit_demo', 'accounts.yaml'),
|
||||
join('knowledge', 'global', 'arr-contract-first.md'),
|
||||
join('semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'),
|
||||
join('semantic-layer', 'postgres-warehouse', 'mart_account_activity.yaml'),
|
||||
join('knowledge', 'global', 'orbit-company-overview.md'),
|
||||
join('links', 'provenance.json'),
|
||||
join('reports', 'seeded-demo-report.json'),
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -19,11 +19,9 @@ describe('demo seeded mode', () => {
|
|||
await expect(access(join(projectDir, 'demo.db'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'ktx.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'manifest.json'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'semantic-layer/orbit_demo/accounts.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'knowledge/global/arr-contract-first.md'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'raw-sources/dbt/schema.yml'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'raw-sources/bi/revenue_exec.dashboard.lookml'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'raw-sources/notion/revenue-reporting-policy.md'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'semantic-layer/dbt-main/mart_arr_daily.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'semantic-layer/postgres-warehouse/mart_account_activity.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'knowledge/global/orbit-company-overview.md'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'links/provenance.json'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'reports/seeded-demo-report.json'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
|
@ -88,8 +86,8 @@ describe('demo seeded mode', () => {
|
|||
|
||||
it('SL YAML validates correctly', async () => {
|
||||
await ensureSeededDemoProject({ projectDir, force: false });
|
||||
const slYaml = await readFile(join(projectDir, 'semantic-layer/orbit_demo/accounts.yaml'), 'utf-8');
|
||||
expect(slYaml).toContain('name: accounts');
|
||||
const slYaml = await readFile(join(projectDir, 'semantic-layer/dbt-main/mart_arr_daily.yaml'), 'utf-8');
|
||||
expect(slYaml).toContain('name: mart_arr_daily');
|
||||
expect(slYaml).toContain('grain:');
|
||||
expect(slYaml).toContain('columns:');
|
||||
expect(slYaml).toContain('measures:');
|
||||
|
|
@ -98,11 +96,11 @@ describe('demo seeded mode', () => {
|
|||
|
||||
it('wiki pages have valid frontmatter', async () => {
|
||||
await ensureSeededDemoProject({ projectDir, force: false });
|
||||
const wiki = await readFile(join(projectDir, 'knowledge/global/arr-contract-first.md'), 'utf-8');
|
||||
const wiki = await readFile(join(projectDir, 'knowledge/global/orbit-company-overview.md'), 'utf-8');
|
||||
expect(wiki).toContain('---');
|
||||
expect(wiki).toContain('summary:');
|
||||
expect(wiki).toContain('tags:');
|
||||
expect(wiki).toContain('sl_refs:');
|
||||
expect(wiki).toContain('refs:');
|
||||
expect(wiki).toContain('usage_mode: auto');
|
||||
});
|
||||
|
||||
|
|
|
|||
270
packages/cli/src/setup-demo-tour.test.ts
Normal file
270
packages/cli/src/setup-demo-tour.test.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { KtxSetupAgentsResult } from './setup-agents.js';
|
||||
import {
|
||||
buildDemoReplayTimeline,
|
||||
DEMO_REPLAY_TARGETS,
|
||||
renderDemoAgentTransition,
|
||||
renderDemoBanner,
|
||||
renderDemoCardContent,
|
||||
renderDemoCompletionSummary,
|
||||
runDemoTour,
|
||||
} from './setup-demo-tour.js';
|
||||
|
||||
/** Strip ANSI escape sequences for plain-text assertions. */
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('renderDemoBanner', () => {
|
||||
it('contains "Demo mode"', () => {
|
||||
const plain = stripAnsi(renderDemoBanner());
|
||||
expect(plain).toContain('Demo mode');
|
||||
});
|
||||
|
||||
it('mentions pre-processed data', () => {
|
||||
const plain = stripAnsi(renderDemoBanner());
|
||||
expect(plain).toContain('pre-processed');
|
||||
});
|
||||
|
||||
it('mentions read-only', () => {
|
||||
const plain = stripAnsi(renderDemoBanner());
|
||||
expect(plain).toContain('read-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDemoCardContent', () => {
|
||||
it('contains the title', () => {
|
||||
const plain = stripAnsi(renderDemoCardContent('Database connection', ['Postgres']));
|
||||
expect(plain).toContain('Database connection');
|
||||
});
|
||||
|
||||
it('contains each selection', () => {
|
||||
const plain = stripAnsi(renderDemoCardContent('Sources', ['dbt', 'metabase']));
|
||||
expect(plain).toContain('dbt');
|
||||
expect(plain).toContain('metabase');
|
||||
});
|
||||
|
||||
it('contains navigation hints', () => {
|
||||
const plain = stripAnsi(renderDemoCardContent('Title', ['a']));
|
||||
expect(plain).toContain('Press Enter to continue');
|
||||
expect(plain).toContain('Escape to go back');
|
||||
});
|
||||
|
||||
it('works with multiple selections', () => {
|
||||
const result = renderDemoCardContent('Pick', ['one', 'two', 'three']);
|
||||
const plain = stripAnsi(result);
|
||||
expect(plain).toContain('one');
|
||||
expect(plain).toContain('two');
|
||||
expect(plain).toContain('three');
|
||||
// Each selection gets a ▸ bullet
|
||||
const bullets = (plain.match(/▸/g) ?? []).length;
|
||||
expect(bullets).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDemoAgentTransition', () => {
|
||||
it('contains "Demo project is ready"', () => {
|
||||
const plain = stripAnsi(renderDemoAgentTransition());
|
||||
expect(plain).toContain('Demo project is ready');
|
||||
});
|
||||
|
||||
it('mentions connecting an agent', () => {
|
||||
const plain = stripAnsi(renderDemoAgentTransition());
|
||||
expect(plain).toContain('connect your agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDemoCompletionSummary', () => {
|
||||
const projectDir = '/tmp/ktx-demo-123';
|
||||
|
||||
it('includes the project path', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
|
||||
expect(plain).toContain(projectDir);
|
||||
});
|
||||
|
||||
it('includes a temp directory warning', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
|
||||
expect(plain).toContain('temporary directory');
|
||||
});
|
||||
|
||||
it('points to ktx setup for real data', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
|
||||
expect(plain).toContain('ktx setup');
|
||||
});
|
||||
|
||||
it('shows agent-connected message when installed', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
|
||||
expect(plain).toContain('agent is connected');
|
||||
});
|
||||
|
||||
it('includes star headline', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
|
||||
expect(plain).toContain('★ KTX demo is ready');
|
||||
});
|
||||
|
||||
it('shows manual instructions when agent not installed', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, false));
|
||||
expect(plain).toContain('--agents');
|
||||
expect(plain).toContain(`--project-dir ${projectDir}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDemoReplayTimeline', () => {
|
||||
const timeline = buildDemoReplayTimeline();
|
||||
const connectionIds = new Set(timeline.map((e) => e.connectionId));
|
||||
|
||||
it('produces events for all 4 targets', () => {
|
||||
expect(connectionIds.size).toBe(4);
|
||||
expect(connectionIds).toContain('postgres-warehouse');
|
||||
expect(connectionIds).toContain('dbt-main');
|
||||
expect(connectionIds).toContain('metabase-main');
|
||||
expect(connectionIds).toContain('notion-main');
|
||||
});
|
||||
|
||||
it('all targets end as done', () => {
|
||||
for (const id of connectionIds) {
|
||||
const events = timeline.filter((e) => e.connectionId === id);
|
||||
const last = events[events.length - 1];
|
||||
expect(last.status).toBe('done');
|
||||
}
|
||||
});
|
||||
|
||||
it('events are sorted by delayMs', () => {
|
||||
for (let i = 1; i < timeline.length; i++) {
|
||||
expect(timeline[i].delayMs).toBeGreaterThanOrEqual(timeline[i - 1].delayMs);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEMO_REPLAY_TARGETS', () => {
|
||||
it('has 1 primary source', () => {
|
||||
expect(DEMO_REPLAY_TARGETS.primarySources).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('has 3 context sources', () => {
|
||||
expect(DEMO_REPLAY_TARGETS.contextSources).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('primary source is a scan operation', () => {
|
||||
expect(DEMO_REPLAY_TARGETS.primarySources[0].operation).toBe('scan');
|
||||
});
|
||||
|
||||
it('context sources are source-ingest operations', () => {
|
||||
for (const source of DEMO_REPLAY_TARGETS.contextSources) {
|
||||
expect(source.operation).toBe('source-ingest');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('runDemoTour', () => {
|
||||
function createMockIo() {
|
||||
const chunks: string[] = [];
|
||||
return {
|
||||
io: {
|
||||
stdout: { isTTY: true, columns: 80, write: (chunk: string) => { chunks.push(chunk); } },
|
||||
stderr: { write: () => {} },
|
||||
},
|
||||
chunks,
|
||||
};
|
||||
}
|
||||
|
||||
it('returns 0 on successful tour with agent installed', async () => {
|
||||
const { io, chunks } = createMockIo();
|
||||
const mockAgents = vi.fn().mockResolvedValue({
|
||||
status: 'ready',
|
||||
projectDir: '/tmp/test',
|
||||
installs: [{ target: 'claude-code', scope: 'project', mode: 'both' }],
|
||||
} satisfies KtxSetupAgentsResult);
|
||||
|
||||
const navigation = vi.fn().mockResolvedValue('forward');
|
||||
|
||||
const result = await runDemoTour(
|
||||
{ inputMode: 'auto' },
|
||||
io,
|
||||
{
|
||||
agents: mockAgents,
|
||||
waitForNavigation: navigation,
|
||||
skipReplayAnimation: true,
|
||||
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
|
||||
},
|
||||
);
|
||||
expect(result).toBe(0);
|
||||
expect(mockAgents).toHaveBeenCalled();
|
||||
// Should have rendered completion summary
|
||||
const allOutput = chunks.join('');
|
||||
expect(allOutput).toContain('agent is connected');
|
||||
});
|
||||
|
||||
it('handles back navigation from first step by exiting', async () => {
|
||||
const { io } = createMockIo();
|
||||
const navigation = vi.fn().mockResolvedValue('back');
|
||||
|
||||
const result = await runDemoTour(
|
||||
{ inputMode: 'auto' },
|
||||
io,
|
||||
{
|
||||
waitForNavigation: navigation,
|
||||
skipReplayAnimation: true,
|
||||
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
|
||||
},
|
||||
);
|
||||
expect(result).toBe(0);
|
||||
// Navigation called once for databases step, then exits
|
||||
expect(navigation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('goes back from sources to databases', async () => {
|
||||
const { io } = createMockIo();
|
||||
let callCount = 0;
|
||||
const navigation = vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
// First call (databases): forward
|
||||
// Second call (sources): back
|
||||
// Third call (databases again): back (exit)
|
||||
if (callCount === 1) return Promise.resolve('forward');
|
||||
return Promise.resolve('back');
|
||||
});
|
||||
|
||||
const result = await runDemoTour(
|
||||
{ inputMode: 'auto' },
|
||||
io,
|
||||
{
|
||||
waitForNavigation: navigation,
|
||||
skipReplayAnimation: true,
|
||||
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
|
||||
},
|
||||
);
|
||||
expect(result).toBe(0);
|
||||
expect(navigation).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('handles agent step returning back', async () => {
|
||||
const { io } = createMockIo();
|
||||
let navCount = 0;
|
||||
const navigation = vi.fn().mockImplementation(() => {
|
||||
navCount++;
|
||||
// Forward through databases, sources, context
|
||||
// Then back from context (after agents returns back)
|
||||
// Then back from sources, then back from databases (exit)
|
||||
if (navCount <= 3) return Promise.resolve('forward');
|
||||
return Promise.resolve('back');
|
||||
});
|
||||
|
||||
const mockAgents = vi.fn().mockResolvedValue({
|
||||
status: 'back',
|
||||
projectDir: '/tmp/test',
|
||||
} satisfies KtxSetupAgentsResult);
|
||||
|
||||
const result = await runDemoTour(
|
||||
{ inputMode: 'auto' },
|
||||
io,
|
||||
{
|
||||
agents: mockAgents,
|
||||
waitForNavigation: navigation,
|
||||
skipReplayAnimation: true,
|
||||
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
|
||||
},
|
||||
);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
391
packages/cli/src/setup-demo-tour.ts
Normal file
391
packages/cli/src/setup-demo-tour.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import type {
|
||||
ContextBuildTargetState,
|
||||
ContextBuildViewState,
|
||||
} from './context-build-view.js';
|
||||
import { createRepainter, renderContextBuildView } from './context-build-view.js';
|
||||
import { defaultDemoProjectDir, ensureSeededDemoProject } from './demo-assets.js';
|
||||
import type { KtxPublicIngestPlanTarget } from './public-ingest.js';
|
||||
import type { KtxSetupAgentsResult } from './setup-agents.js';
|
||||
import { runKtxSetupAgentsStep } from './setup-agents.js';
|
||||
import { KtxSetupExitError } from './setup-interrupt.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ANSI helpers (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ESC = String.fromCharCode(0x1b);
|
||||
|
||||
function cyan(text: string): string {
|
||||
return `${ESC}[36m${text}${ESC}[39m`;
|
||||
}
|
||||
|
||||
function dim(text: string): string {
|
||||
return `${ESC}[2m${text}${ESC}[22m`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demo target helpers (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createDemoTarget(
|
||||
connectionId: string,
|
||||
operation: 'scan' | 'source-ingest',
|
||||
driver: string,
|
||||
): KtxPublicIngestPlanTarget {
|
||||
const adapter = operation === 'source-ingest' ? driver : undefined;
|
||||
return {
|
||||
connectionId,
|
||||
driver,
|
||||
operation,
|
||||
...(adapter ? { adapter } : {}),
|
||||
debugCommand: `ktx setup context build --target ${connectionId}`,
|
||||
steps: operation === 'scan'
|
||||
? ['scan', 'enrich', 'memory-update']
|
||||
: ['source-ingest', 'enrich', 'memory-update'],
|
||||
};
|
||||
}
|
||||
|
||||
function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTargetState {
|
||||
return {
|
||||
target,
|
||||
status: 'queued',
|
||||
detailLine: null,
|
||||
summaryText: null,
|
||||
failureText: null,
|
||||
startedAt: null,
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure rendering functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderDemoBanner(): string {
|
||||
const lines = [
|
||||
'',
|
||||
`┌ ${cyan('Demo mode')} — data has been pre-processed and KTX context is already built.`,
|
||||
'│ This walkthrough illustrates the setup steps. Selections are pre-filled and read-only.',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderDemoCardContent(title: string, selections: string[]): string {
|
||||
const lines = [
|
||||
`┌ ${title}`,
|
||||
'│',
|
||||
...selections.map((s) => `│ ${cyan('▸')} ${s}`),
|
||||
'│',
|
||||
`│ ${dim('Press Enter to continue, Escape to go back')}`,
|
||||
'└',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderDemoAgentTransition(): string {
|
||||
const lines = [
|
||||
'┌ Demo project is ready — let\'s connect your agent',
|
||||
'│',
|
||||
'│ Your KTX context has been built with demo data.',
|
||||
'│ Select an agent to start using it.',
|
||||
'└',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderDemoCompletionSummary(projectDir: string, agentInstalled: boolean): string {
|
||||
const lines: string[] = [
|
||||
'',
|
||||
`${cyan('★')} KTX demo is ready`,
|
||||
'',
|
||||
];
|
||||
|
||||
if (agentInstalled) {
|
||||
lines.push(' Your agent is connected to a demo KTX project.');
|
||||
} else {
|
||||
lines.push(' Demo project created. Connect an agent to start using it:');
|
||||
lines.push(` $ ${cyan(`ktx setup --agents --project-dir ${projectDir}`)}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
` ${dim('⚠')} This project is in a temporary directory and will be`,
|
||||
' cleaned up by your system. To set up KTX with your own',
|
||||
' data, run: ktx setup',
|
||||
'',
|
||||
` Project: ${projectDir}`,
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keypress navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function waitForDemoNavigation(
|
||||
stdin?: NodeJS.ReadStream,
|
||||
): Promise<'forward' | 'back'> {
|
||||
const input = stdin ?? process.stdin;
|
||||
const hadRawMode = input.isRaw ?? false;
|
||||
|
||||
return new Promise<'forward' | 'back'>((resolve, reject) => {
|
||||
if (typeof input.setRawMode === 'function') {
|
||||
input.setRawMode(true);
|
||||
}
|
||||
input.resume();
|
||||
|
||||
const cleanup = () => {
|
||||
input.off('data', onData);
|
||||
if (typeof input.setRawMode === 'function') {
|
||||
input.setRawMode(hadRawMode);
|
||||
}
|
||||
};
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
const char = data.toString();
|
||||
if (char === '\r' || char === '\n') {
|
||||
cleanup();
|
||||
resolve('forward');
|
||||
} else if (char === '\x1b') {
|
||||
cleanup();
|
||||
resolve('back');
|
||||
} else if (char === '\x03') {
|
||||
cleanup();
|
||||
reject(new KtxSetupExitError());
|
||||
}
|
||||
};
|
||||
|
||||
input.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function renderDemoCard(
|
||||
title: string,
|
||||
selections: string[],
|
||||
io: KtxCliIo,
|
||||
stdin?: NodeJS.ReadStream,
|
||||
waitNav: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'> = waitForDemoNavigation,
|
||||
): Promise<'forward' | 'back'> {
|
||||
io.stdout.write(renderDemoBanner() + '\n\n');
|
||||
io.stdout.write(renderDemoCardContent(title, selections) + '\n');
|
||||
return waitNav(stdin);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context build replay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DemoReplayEvent {
|
||||
delayMs: number;
|
||||
connectionId: string;
|
||||
status: 'running' | 'done';
|
||||
detailLine: string | null;
|
||||
summaryText: string | null;
|
||||
}
|
||||
|
||||
export const DEMO_REPLAY_TARGETS = {
|
||||
primarySources: [
|
||||
createDemoTarget('postgres-warehouse', 'scan', 'postgres'),
|
||||
],
|
||||
contextSources: [
|
||||
createDemoTarget('dbt-main', 'source-ingest', 'dbt'),
|
||||
createDemoTarget('metabase-main', 'source-ingest', 'metabase'),
|
||||
createDemoTarget('notion-main', 'source-ingest', 'notion'),
|
||||
],
|
||||
} as const;
|
||||
|
||||
export function buildDemoReplayTimeline(): DemoReplayEvent[] {
|
||||
return [
|
||||
// postgres-warehouse: scan
|
||||
{ delayMs: 0, connectionId: 'postgres-warehouse', status: 'running', detailLine: null, summaryText: null },
|
||||
{ delayMs: 1200, connectionId: 'postgres-warehouse', status: 'running', detailLine: '[50%] scanning tables...', summaryText: null },
|
||||
{ delayMs: 2400, connectionId: 'postgres-warehouse', status: 'done', detailLine: null, summaryText: '56 tables scanned' },
|
||||
// dbt-main
|
||||
{ delayMs: 2400, connectionId: 'dbt-main', status: 'running', detailLine: null, summaryText: null },
|
||||
{ delayMs: 3600, connectionId: 'dbt-main', status: 'running', detailLine: '[60%] ingesting models...', summaryText: null },
|
||||
{ delayMs: 4400, connectionId: 'dbt-main', status: 'done', detailLine: null, summaryText: '34 models ingested' },
|
||||
// metabase-main
|
||||
{ delayMs: 4400, connectionId: 'metabase-main', status: 'running', detailLine: null, summaryText: null },
|
||||
{ delayMs: 5600, connectionId: 'metabase-main', status: 'done', detailLine: null, summaryText: '80 cards ingested' },
|
||||
// notion-main
|
||||
{ delayMs: 5600, connectionId: 'notion-main', status: 'running', detailLine: null, summaryText: null },
|
||||
{ delayMs: 6800, connectionId: 'notion-main', status: 'done', detailLine: null, summaryText: '9 pages ingested' },
|
||||
];
|
||||
}
|
||||
|
||||
function renderDemoContextCompletionSummary(): string {
|
||||
const lines = [
|
||||
'',
|
||||
`${cyan('★')} KTX finished building context`,
|
||||
'',
|
||||
' KTX created:',
|
||||
` ${cyan('📊')} 46 semantic layer definitions`,
|
||||
` ${cyan('📝')} 28 knowledge pages`,
|
||||
'',
|
||||
` ${dim('Press Enter to continue, Escape to go back')}`,
|
||||
'',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function runDemoContextReplay(
|
||||
io: KtxCliIo,
|
||||
stdin?: NodeJS.ReadStream,
|
||||
): Promise<'forward' | 'back'> {
|
||||
const allPrimary = DEMO_REPLAY_TARGETS.primarySources.map(createTargetState);
|
||||
const allContext = DEMO_REPLAY_TARGETS.contextSources.map(createTargetState);
|
||||
|
||||
const state: ContextBuildViewState = {
|
||||
primarySources: allPrimary,
|
||||
contextSources: allContext,
|
||||
frame: 0,
|
||||
startedAt: Date.now(),
|
||||
totalElapsedMs: 0,
|
||||
};
|
||||
|
||||
const allTargets = [...allPrimary, ...allContext];
|
||||
const timeline = buildDemoReplayTimeline();
|
||||
|
||||
const repainter = createRepainter(io);
|
||||
const paint = () => repainter.paint(renderContextBuildView(state, { styled: true }));
|
||||
|
||||
paint();
|
||||
|
||||
let eventIndex = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const frameInterval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
state.frame++;
|
||||
state.totalElapsedMs = elapsed;
|
||||
|
||||
// Apply all events up to the current elapsed time
|
||||
while (eventIndex < timeline.length && timeline[eventIndex].delayMs <= elapsed) {
|
||||
const event = timeline[eventIndex];
|
||||
const target = allTargets.find((t) => t.target.connectionId === event.connectionId);
|
||||
if (target) {
|
||||
target.status = event.status;
|
||||
target.detailLine = event.detailLine;
|
||||
if (event.summaryText !== null) {
|
||||
target.summaryText = event.summaryText;
|
||||
}
|
||||
if (event.status === 'running' && target.startedAt === null) {
|
||||
target.startedAt = Date.now();
|
||||
}
|
||||
if (event.status === 'done') {
|
||||
target.elapsedMs = target.startedAt !== null ? Date.now() - target.startedAt : 0;
|
||||
}
|
||||
}
|
||||
eventIndex++;
|
||||
}
|
||||
|
||||
// Update running target elapsed times
|
||||
for (const t of allTargets) {
|
||||
if (t.status === 'running' && t.startedAt !== null) {
|
||||
t.elapsedMs = Date.now() - t.startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
paint();
|
||||
|
||||
// Check if all events have been applied
|
||||
if (eventIndex >= timeline.length) {
|
||||
clearInterval(frameInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
|
||||
// Final paint with all done
|
||||
paint();
|
||||
|
||||
// Show completion summary and wait for navigation
|
||||
io.stdout.write(renderDemoContextCompletionSummary() + '\n');
|
||||
return waitForDemoNavigation(stdin);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demo tour orchestrator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DemoStep = 'databases' | 'sources' | 'context' | 'agents';
|
||||
|
||||
const DEMO_STEPS: DemoStep[] = ['databases', 'sources', 'context', 'agents'];
|
||||
|
||||
export interface DemoTourDeps {
|
||||
agents?: (args: Parameters<typeof runKtxSetupAgentsStep>[0], io: KtxCliIo) => Promise<KtxSetupAgentsResult>;
|
||||
waitForNavigation?: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'>;
|
||||
ensureProject?: typeof ensureSeededDemoProject;
|
||||
skipReplayAnimation?: boolean;
|
||||
}
|
||||
|
||||
export async function runDemoTour(
|
||||
args: { inputMode: 'auto' | 'disabled' },
|
||||
io: KtxCliIo,
|
||||
deps: DemoTourDeps = {},
|
||||
): Promise<number> {
|
||||
const waitNav = deps.waitForNavigation ?? waitForDemoNavigation;
|
||||
const ensureProject = deps.ensureProject ?? ensureSeededDemoProject;
|
||||
|
||||
const projectDir = defaultDemoProjectDir();
|
||||
await ensureProject({ projectDir, force: false });
|
||||
|
||||
let stepIndex = 0;
|
||||
|
||||
while (stepIndex < DEMO_STEPS.length) {
|
||||
const step = DEMO_STEPS[stepIndex]!;
|
||||
let direction: 'forward' | 'back';
|
||||
|
||||
if (step === 'databases') {
|
||||
direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav);
|
||||
} else if (step === 'sources') {
|
||||
direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav);
|
||||
} else if (step === 'context') {
|
||||
io.stdout.write(renderDemoBanner() + '\n\n');
|
||||
if (deps.skipReplayAnimation) {
|
||||
direction = await waitNav();
|
||||
} else {
|
||||
direction = await runDemoContextReplay(io);
|
||||
}
|
||||
} else {
|
||||
// agents step — real interactive
|
||||
io.stdout.write(renderDemoAgentTransition() + '\n');
|
||||
const agentsRunner = deps.agents ?? runKtxSetupAgentsStep;
|
||||
const agentsResult = await agentsRunner(
|
||||
{
|
||||
projectDir,
|
||||
inputMode: args.inputMode,
|
||||
yes: false,
|
||||
agents: true,
|
||||
scope: 'project',
|
||||
mode: 'both',
|
||||
skipAgents: false,
|
||||
},
|
||||
io,
|
||||
);
|
||||
const agentInstalled = agentsResult.status === 'ready';
|
||||
if (agentsResult.status === 'back') {
|
||||
direction = 'back';
|
||||
} else {
|
||||
io.stdout.write(renderDemoCompletionSummary(projectDir, agentInstalled) + '\n');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'back') {
|
||||
if (stepIndex === 0) return 0;
|
||||
stepIndex -= 1;
|
||||
} else {
|
||||
stepIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -6,8 +6,13 @@ import { promisify } from 'node:util';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
|
||||
import { runDemoTour } from './setup-demo-tour.js';
|
||||
import { readKtxSetupStatus, runKtxSetup } from './setup.js';
|
||||
|
||||
vi.mock('./setup-demo-tour.js', () => ({
|
||||
runDemoTour: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function makeIo() {
|
||||
|
|
@ -351,10 +356,10 @@ describe('setup status', () => {
|
|||
expect(labels).toEqual([
|
||||
'Set up KTX for my data',
|
||||
'Check setup status',
|
||||
'Try KTX with packaged demo data',
|
||||
'Explore a pre-built KTX project',
|
||||
'Exit',
|
||||
]);
|
||||
expect(labels.indexOf('Try KTX with packaged demo data')).toBe(labels.length - 2);
|
||||
expect(labels.indexOf('Explore a pre-built KTX project')).toBe(labels.length - 2);
|
||||
return 'exit';
|
||||
});
|
||||
const cancel = vi.fn();
|
||||
|
|
@ -400,7 +405,7 @@ describe('setup status', () => {
|
|||
'Create a new KTX project',
|
||||
'Connect a coding agent to KTX',
|
||||
'Check setup status',
|
||||
'Try KTX with packaged demo data',
|
||||
'Explore a pre-built KTX project',
|
||||
'Exit',
|
||||
]);
|
||||
return 'exit';
|
||||
|
|
@ -695,9 +700,8 @@ describe('setup status', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('runs the seeded demo when the first setup intent menu chooses packaged demo data', async () => {
|
||||
it('runs the demo tour when the first setup intent menu chooses demo', async () => {
|
||||
const testIo = makeIo();
|
||||
const demo = vi.fn(async (_args: { projectDir: string }, _io: unknown) => 0);
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
|
|
@ -718,19 +722,15 @@ describe('setup status', () => {
|
|||
showEntryMenu: true,
|
||||
},
|
||||
testIo.io,
|
||||
{ entryMenuDeps: { prompts: { select: vi.fn(async () => 'demo'), cancel: vi.fn() } }, demo },
|
||||
{ entryMenuDeps: { prompts: { select: vi.fn(async () => 'demo'), cancel: vi.fn() } } },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(demo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'seeded',
|
||||
outputMode: 'viz',
|
||||
inputMode: 'auto',
|
||||
}),
|
||||
expect(runDemoTour).toHaveBeenCalledWith(
|
||||
{ inputMode: 'auto' },
|
||||
testIo.io,
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(demo.mock.calls[0]?.[0].projectDir).toMatch(/ktx-demo-/);
|
||||
});
|
||||
|
||||
it('creates a project through run mode when --new is selected', async () => {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { join, resolve } from 'node:path';
|
|||
import { cancel, isCancel, select } from '@clack/prompts';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import type { KtxDemoArgs } from './demo.js';
|
||||
import { defaultDemoProjectDir } from './demo-assets.js';
|
||||
import { formatSetupNextStepLines } from './next-steps.js';
|
||||
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
import {
|
||||
|
|
@ -148,7 +146,6 @@ export interface KtxSetupDeps {
|
|||
removeAgents?: typeof removeKtxAgentInstall;
|
||||
readyMenuDeps?: KtxSetupReadyMenuDeps;
|
||||
entryMenuDeps?: KtxSetupEntryMenuDeps;
|
||||
demo?: (args: KtxDemoArgs, io: KtxCliIo) => Promise<number>;
|
||||
}
|
||||
|
||||
const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion']);
|
||||
|
|
@ -200,13 +197,13 @@ async function runKtxSetupEntryMenu(
|
|||
{ value: 'new-project', label: 'Create a new KTX project' },
|
||||
{ value: 'agents', label: 'Connect a coding agent to KTX' },
|
||||
{ value: 'status', label: 'Check setup status' },
|
||||
{ value: 'demo', label: 'Try KTX with packaged demo data' },
|
||||
{ value: 'demo', label: 'Explore a pre-built KTX project' },
|
||||
{ value: 'exit', label: 'Exit' },
|
||||
]
|
||||
: [
|
||||
{ value: 'setup', label: 'Set up KTX for my data' },
|
||||
{ value: 'status', label: 'Check setup status' },
|
||||
{ value: 'demo', label: 'Try KTX with packaged demo data' },
|
||||
{ value: 'demo', label: 'Explore a pre-built KTX project' },
|
||||
{ value: 'exit', label: 'Exit' },
|
||||
];
|
||||
const action = (await prompts.select({
|
||||
|
|
@ -221,15 +218,11 @@ async function runKtxSetupDemoFromEntryMenu(
|
|||
io: KtxCliIo,
|
||||
deps: KtxSetupDeps,
|
||||
): Promise<number> {
|
||||
const runner = deps.demo ?? (await import('./demo.js')).runKtxDemo;
|
||||
return await runner(
|
||||
{
|
||||
command: 'seeded',
|
||||
projectDir: defaultDemoProjectDir(),
|
||||
outputMode: 'viz',
|
||||
inputMode: args.inputMode,
|
||||
},
|
||||
const { runDemoTour } = await import('./setup-demo-tour.js');
|
||||
return await runDemoTour(
|
||||
{ inputMode: args.inputMode },
|
||||
io,
|
||||
{ agents: deps.agents },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue