mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
fix(cli): remove ktx setup subcommands
This commit is contained in:
parent
80f298d652
commit
7db91caca6
47 changed files with 171 additions and 5010 deletions
10
README.md
10
README.md
|
|
@ -71,16 +71,6 @@ KTX context built: yes
|
|||
Agent integration ready: yes (claude-code:project)
|
||||
```
|
||||
|
||||
Run the packaged demo without installing globally:
|
||||
|
||||
```bash
|
||||
npx @kaelio/ktx setup demo --no-input
|
||||
npx @kaelio/ktx setup demo inspect
|
||||
```
|
||||
|
||||
The default demo uses packaged sample data and prebuilt context. It does not
|
||||
require API keys, network access, or an LLM provider.
|
||||
|
||||
Generate SQL from a semantic-layer source:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -11,25 +11,6 @@ Interactive wizard that walks you through configuring LLM credentials, embedding
|
|||
ktx setup [options]
|
||||
```
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Subcommand | Description |
|
||||
|-----------|-------------|
|
||||
| `setup demo` | Run the packaged KTX demo from setup |
|
||||
| `setup demo init` | Initialize the packaged demo project |
|
||||
| `setup demo reset` | Reset the packaged demo project |
|
||||
| `setup demo replay` | Replay the packaged demo memory-flow |
|
||||
| `setup demo scan` | Run the packaged demo scan |
|
||||
| `setup demo inspect` | Inspect packaged demo outputs |
|
||||
| `setup demo doctor` | Check packaged demo readiness |
|
||||
| `setup demo ingest` | Run packaged demo ingest |
|
||||
| `setup context build` | Build agent-ready KTX context for setup |
|
||||
| `setup context watch [runId]` | Watch a setup-managed context build |
|
||||
| `setup context status [runId]` | Print setup-managed context build status |
|
||||
| `setup context stop [runId]` | Request a pause for a setup-managed context build |
|
||||
| `setup remove` | Remove setup-managed local integrations |
|
||||
| `setup status` | Show setup readiness for the resolved KTX project |
|
||||
|
||||
## Options
|
||||
|
||||
### General
|
||||
|
|
@ -119,17 +100,6 @@ ktx setup [options]
|
|||
| `--skip-initial-source-ingest` | Validate source setup without building source context during setup | `false` |
|
||||
| `--skip-sources` | Mark optional source setup complete with no sources | `false` |
|
||||
|
||||
### Subcommand Options
|
||||
|
||||
| Flag | Subcommand | Description | Default |
|
||||
|------|-----------|-------------|---------|
|
||||
| `--json` | `status`, `context status` | Print JSON output | `false` |
|
||||
| `--no-input` | `context build`, `context watch` | Disable interactive terminal input | — |
|
||||
| `--force` | `context stop` | Request the pause without interactive confirmation | `false` |
|
||||
| `--agents` | `remove` | Remove setup-managed agent integration files | `false` |
|
||||
| `--mode <mode>` | `demo` | Demo mode: `seeded`, `replay`, or `full` | `seeded` |
|
||||
| `--plain` | `demo` | Print plain text output | `false` |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
|
|
@ -161,21 +131,13 @@ ktx setup --source dbt --source-path ./my-dbt-project
|
|||
ktx setup --skip-sources --skip-agents
|
||||
|
||||
# Check setup readiness
|
||||
ktx setup status
|
||||
|
||||
# Build context after setup
|
||||
ktx setup context build
|
||||
|
||||
# Watch a running context build
|
||||
ktx setup context watch
|
||||
|
||||
# Run the packaged demo
|
||||
ktx setup demo
|
||||
ktx status
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Interactive setup renders prompts and progress messages. `ktx setup status` is the best command for agents because it summarizes readiness in one response.
|
||||
Interactive setup renders prompts and progress messages. Use `ktx status` to
|
||||
check setup and context readiness after setup exits.
|
||||
|
||||
```text
|
||||
KTX project: /home/user/analytics
|
||||
|
|
|
|||
|
|
@ -156,8 +156,8 @@ Run: setup-context-local-abc123
|
|||
Project: /home/user/analytics
|
||||
|
||||
Detach: press d to leave this running.
|
||||
Resume: ktx setup context watch setup-context-local-abc123
|
||||
Status: ktx setup context status setup-context-local-abc123
|
||||
Resume: ktx setup --project-dir /home/user/analytics
|
||||
Status: ktx status --project-dir /home/user/analytics
|
||||
```
|
||||
|
||||
When the build completes, KTX verifies that agent-ready context was produced:
|
||||
|
|
@ -241,7 +241,7 @@ Agent integration ready: yes (claude-code:project)
|
|||
| OpenAI embedding check fails | `OPENAI_API_KEY` is missing when OpenAI embeddings are selected | Export `OPENAI_API_KEY`, or rerun setup and choose local sentence-transformers embeddings |
|
||||
| Local embeddings hang or fail | The managed Python runtime cannot start or the local model runtime is unavailable | Install `uv`, run `ktx dev runtime doctor`, then run `ktx dev runtime install --feature local-embeddings --yes` and rerun setup |
|
||||
| Database connection test fails | Credentials, network access, warehouse, database, or schema value is wrong | Test the same URL with the database's native client, then rerun `ktx connection add ... --force` or rerun setup |
|
||||
| `KTX context built: no` in `ktx status` | Setup saved configuration but did not build context | Run `ktx setup context build` or rerun `ktx setup` and choose to build context now |
|
||||
| `KTX context built: no` in `ktx status` | Setup saved configuration but did not build context | Run `ktx setup` and choose to build context now |
|
||||
| Agent integration is incomplete | Setup skipped the agents step or the target was not installed | Run `ktx setup --agents --target codex --project` using the target you need |
|
||||
|
||||
## Next steps
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ describe('agent semantic-layer search readiness guidance', () => {
|
|||
code: 'agent_sl_search_missing_project',
|
||||
message: 'Semantic-layer search needs an initialized KTX project at /tmp/ktx-search.',
|
||||
nextSteps: [
|
||||
'ktx demo',
|
||||
'ktx setup --project-dir /tmp/ktx-search',
|
||||
'ktx status --project-dir /tmp/ktx-search',
|
||||
'ktx ingest <connection>',
|
||||
'ktx agent sl list --json --query "gross revenue" --project-dir /tmp/ktx-search',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ function projectSearchCommand(projectDir: string, query: string | undefined): st
|
|||
|
||||
function baseNextSteps(projectDir: string, query: string | undefined): string[] {
|
||||
return [
|
||||
'ktx demo',
|
||||
`ktx setup --project-dir ${projectDir}`,
|
||||
`ktx status --project-dir ${projectDir}`,
|
||||
'ktx ingest <connection>',
|
||||
projectSearchCommand(projectDir, query),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -326,8 +326,8 @@ describe('runKtxAgent', () => {
|
|||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KTX project at ${tempDir}.`,
|
||||
nextSteps: [
|
||||
'ktx demo',
|
||||
`ktx setup --project-dir ${tempDir}`,
|
||||
`ktx status --project-dir ${tempDir}`,
|
||||
'ktx ingest <connection>',
|
||||
`ktx agent sl list --json --query "gross revenue" --project-dir ${tempDir}`,
|
||||
],
|
||||
|
|
@ -353,8 +353,8 @@ describe('runKtxAgent', () => {
|
|||
code: 'agent_sl_search_no_connections',
|
||||
message: `Semantic-layer search found no configured connections in ${tempDir}.`,
|
||||
nextSteps: [
|
||||
'ktx demo',
|
||||
`ktx setup --project-dir ${tempDir}`,
|
||||
`ktx status --project-dir ${tempDir}`,
|
||||
'ktx ingest <connection>',
|
||||
`ktx agent sl list --json --query "revenue" --project-dir ${tempDir}`,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { KtxConnectionMetabaseSetupArgs } from './commands/connection-metab
|
|||
import type { KtxConnectionNotionArgs } from './commands/connection-notion.js';
|
||||
import type { KtxAgentArgs } from './agent.js';
|
||||
import type { KtxConnectionArgs } from './connection.js';
|
||||
import type { KtxDemoArgs } from './demo.js';
|
||||
import type { KtxDoctorArgs } from './doctor.js';
|
||||
import type { KtxIngestArgs } from './ingest.js';
|
||||
import type { KtxKnowledgeArgs } from './knowledge.js';
|
||||
|
|
@ -36,7 +35,6 @@ export interface KtxCliDeps {
|
|||
connection?: (args: KtxConnectionArgs, io: KtxCliIo) => Promise<number>;
|
||||
connectionNotion?: (args: KtxConnectionNotionArgs, io: KtxCliIo) => Promise<number>;
|
||||
connectionMetabaseSetup?: (args: KtxConnectionMetabaseSetupArgs, io: KtxCliIo) => Promise<number>;
|
||||
demo?: (args: KtxDemoArgs, io: KtxCliIo) => Promise<number>;
|
||||
doctor?: (args: KtxDoctorArgs, io: KtxCliIo) => Promise<number>;
|
||||
ingest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise<number>;
|
||||
publicIngest?: (args: KtxPublicIngestArgs, io: KtxCliIo) => Promise<number>;
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveDemoCommandOptions } from './demo-commands.js';
|
||||
|
||||
describe('resolveDemoCommandOptions', () => {
|
||||
it('lets parent --no-input override a child default from optsWithGlobals', () => {
|
||||
const rootCommand = {
|
||||
opts: () => ({}),
|
||||
};
|
||||
const setupCommand = {
|
||||
parent: rootCommand,
|
||||
opts: () => ({ input: false }),
|
||||
getOptionValueSource: (name: string) => (name === 'input' ? 'cli' : undefined),
|
||||
};
|
||||
const demoCommand = {
|
||||
parent: setupCommand,
|
||||
opts: () => ({ input: true, mode: 'seeded' }),
|
||||
optsWithGlobals: () => ({ input: true, mode: 'seeded' }),
|
||||
getOptionValueSource: (name: string) => (name === 'input' ? 'default' : name === 'mode' ? 'default' : undefined),
|
||||
};
|
||||
|
||||
expect(resolveDemoCommandOptions<{ input: boolean; mode: string }>(demoCommand)).toEqual({
|
||||
input: false,
|
||||
mode: 'seeded',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
import { type Command, Option } from '@commander-js/extra-typings';
|
||||
import {
|
||||
type CommandWithGlobalOptions,
|
||||
type KtxCliCommandContext,
|
||||
resolveCommandProjectDirOverride,
|
||||
} from '../cli-program.js';
|
||||
import {
|
||||
type KtxDemoArgs,
|
||||
type KtxDemoInputMode,
|
||||
type KtxDemoMode,
|
||||
type KtxDemoOutputMode,
|
||||
} from '../demo.js';
|
||||
import { defaultDemoProjectDir } from '../demo-assets.js';
|
||||
import { resolveProjectDir } from '../project-dir.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/demo-commands');
|
||||
|
||||
interface DemoOptions {
|
||||
plain?: boolean;
|
||||
json?: boolean;
|
||||
input?: boolean;
|
||||
projectDir?: string;
|
||||
}
|
||||
|
||||
function demoOutputMode(options: { plain?: boolean; json?: boolean }): KtxDemoOutputMode {
|
||||
if (options.json === true) {
|
||||
return 'json';
|
||||
}
|
||||
if (options.plain === true) {
|
||||
return 'plain';
|
||||
}
|
||||
return 'viz';
|
||||
}
|
||||
|
||||
function demoDoctorOutputMode(options: { json?: boolean }): 'plain' | 'json' {
|
||||
return options.json === true ? 'json' : 'plain';
|
||||
}
|
||||
|
||||
function demoInspectOutputMode(options: { plain?: boolean; json?: boolean }): KtxDemoOutputMode {
|
||||
if (options.json === true) {
|
||||
return 'json';
|
||||
}
|
||||
return 'plain';
|
||||
}
|
||||
|
||||
function demoInputMode(options: { input?: boolean }): { inputMode?: KtxDemoInputMode } {
|
||||
return options.input === false ? { inputMode: 'disabled' } : {};
|
||||
}
|
||||
|
||||
function demoProjectDir(options: { projectDir?: string }, command: CommandWithGlobalOptions): string {
|
||||
return resolveProjectDir(
|
||||
options.projectDir ?? resolveCommandProjectDirOverride(command),
|
||||
defaultDemoProjectDir(),
|
||||
);
|
||||
}
|
||||
|
||||
type CommandOptionSourceReader = {
|
||||
getOptionValueSource?: (name: string) => string | undefined;
|
||||
parent?: unknown;
|
||||
};
|
||||
|
||||
function inheritedOptionSource(command: CommandOptionSourceReader, key: string): string | undefined {
|
||||
let current = command.parent as (CommandOptionSourceReader & { opts?: () => Record<string, unknown> }) | undefined;
|
||||
while (current) {
|
||||
const source = current.getOptionValueSource?.(key);
|
||||
if (source !== undefined) {
|
||||
return source;
|
||||
}
|
||||
current = current.parent as (CommandOptionSourceReader & { opts?: () => Record<string, unknown> }) | undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function definedOptions(
|
||||
options: Record<string, unknown>,
|
||||
inherited: Record<string, unknown> = {},
|
||||
command?: CommandOptionSourceReader,
|
||||
): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(options).filter(([key, value]) => {
|
||||
if (value === undefined) return false;
|
||||
if (key === 'input' && value === true && inherited.input === false) return false;
|
||||
if (
|
||||
key === 'mode' &&
|
||||
command?.getOptionValueSource?.(key) === 'default' &&
|
||||
inherited[key] !== undefined &&
|
||||
inherited[key] !== value &&
|
||||
inheritedOptionSource(command, key) === 'cli'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDemoCommandOptions<T>(command: { opts: () => T; optsWithGlobals?: () => T; parent?: unknown }): T {
|
||||
const chain: Array<{ opts?: () => Record<string, unknown>; parent?: unknown }> = [];
|
||||
let current = command.parent as { opts?: () => Record<string, unknown>; parent?: unknown } | undefined;
|
||||
while (current) {
|
||||
chain.unshift(current);
|
||||
current = current.parent as { opts?: () => Record<string, unknown>; parent?: unknown } | undefined;
|
||||
}
|
||||
const inherited = Object.assign({}, ...chain.map((parent) => definedOptions(parent.opts?.() ?? {})));
|
||||
|
||||
if (command.optsWithGlobals) {
|
||||
const withGlobals = {
|
||||
...inherited,
|
||||
...definedOptions(command.optsWithGlobals() as Record<string, unknown>, inherited, command),
|
||||
};
|
||||
return {
|
||||
...withGlobals,
|
||||
...definedOptions(command.opts() as Record<string, unknown>, withGlobals, command),
|
||||
} as T;
|
||||
}
|
||||
|
||||
return { ...inherited, ...definedOptions(command.opts() as Record<string, unknown>, inherited, command) } as T;
|
||||
}
|
||||
|
||||
async function runDemoArgs(context: KtxCliCommandContext, args: KtxDemoArgs): Promise<void> {
|
||||
const runner = context.deps.demo ?? (await import('../demo.js')).runKtxDemo;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerDemoCommands(
|
||||
program: Command,
|
||||
context: KtxCliCommandContext,
|
||||
options: { description?: string } = {},
|
||||
): void {
|
||||
const demo = program
|
||||
.command('demo')
|
||||
.description(options.description ?? 'Run the pre-seeded KTX demo or a full LLM-backed demo')
|
||||
.addOption(
|
||||
new Option('--mode <mode>', 'Demo mode: seeded (default), replay, or full')
|
||||
.choices(['seeded', 'replay', 'full'])
|
||||
.default('seeded'),
|
||||
)
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.addOption(new Option('--plain', 'Print plain text output instead of the visual demo').conflicts('json'))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.showHelpAfterError()
|
||||
.action(async (options: { mode: 'seeded' | 'replay' | 'full' } & DemoOptions, command) => {
|
||||
const resolvedOptions = resolveDemoCommandOptions<typeof options>(command);
|
||||
await runDemoArgs(context, {
|
||||
command: resolvedOptions.mode,
|
||||
projectDir: demoProjectDir(resolvedOptions, command),
|
||||
outputMode: demoOutputMode(resolvedOptions),
|
||||
...demoInputMode(resolvedOptions),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('init')
|
||||
.description('Initialize the packaged demo project')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.option('--force', 'Recreate an existing demo project', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => { projectDir?: string; force?: boolean; input?: boolean } }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'init',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
force: options.force === true,
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('reset')
|
||||
.description('Reset the packaged demo project')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.option('--force', 'Recreate the demo project without prompting', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => { projectDir?: string; force?: boolean; input?: boolean } }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'reset',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
force: options.force === true,
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('replay')
|
||||
.description('Replay the packaged demo memory-flow')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.addOption(new Option('--plain', 'Print plain text output instead of the visual demo').conflicts('json'))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => DemoOptions }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'replay',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
outputMode: demoOutputMode(options),
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('scan')
|
||||
.description('Run the packaged demo scan')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => { projectDir?: string; input?: boolean } }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'scan',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('inspect')
|
||||
.description('Inspect packaged demo outputs')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.addOption(new Option('--plain', 'Print plain text output').conflicts('json'))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => DemoOptions }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'inspect',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
outputMode: demoInspectOutputMode(options),
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('doctor')
|
||||
.description('Check packaged demo readiness')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.addOption(new Option('--plain', 'Print plain text output').conflicts('json'))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => DemoOptions }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'doctor',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
outputMode: demoDoctorOutputMode(options),
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('ingest')
|
||||
.description('Run packaged demo ingest')
|
||||
.addOption(
|
||||
new Option('--mode <mode>', 'Demo ingest mode: full or seeded')
|
||||
.choices(['full', 'seeded'])
|
||||
.default('full'),
|
||||
)
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.addOption(new Option('--plain', 'Print plain text output instead of the visual demo').conflicts('json'))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => { mode: KtxDemoMode } & DemoOptions }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'ingest',
|
||||
mode: options.mode,
|
||||
projectDir: demoProjectDir(options, command),
|
||||
outputMode: demoOutputMode(options),
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ async function runDoctorArgs(context: KtxCliCommandContext, args: KtxDoctorArgs)
|
|||
export function registerDoctorCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const doctor = program
|
||||
.command('doctor')
|
||||
.description('Check KTX setup, project, and demo readiness')
|
||||
.description('Check KTX setup and project readiness')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (options: { json?: boolean; input?: boolean }, command) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { KtxCliCommandContext } from '../cli-program.js';
|
|||
import { resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KtxSetupDatabaseDriver } from '../setup-databases.js';
|
||||
import type { KtxSetupSourceType } from '../setup-sources.js';
|
||||
import { registerDemoCommands } from './demo-commands.js';
|
||||
|
||||
async function runSetupArgs(
|
||||
context: KtxCliCommandContext,
|
||||
|
|
@ -414,98 +413,4 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
|
|||
showEntryMenu: shouldShowSetupEntryMenu(options, command),
|
||||
});
|
||||
});
|
||||
|
||||
registerDemoCommands(setup, context, { description: 'Run the packaged KTX demo from setup' });
|
||||
|
||||
const setupContext = setup.command('context').description('Build, inspect, and recover setup-managed KTX context');
|
||||
|
||||
function setupContextInputMode(command: {
|
||||
optsWithGlobals?: () => unknown;
|
||||
opts?: () => unknown;
|
||||
}): 'auto' | 'disabled' {
|
||||
const options = command.optsWithGlobals?.() as { input?: boolean } | undefined;
|
||||
return options?.input === false ? 'disabled' : 'auto';
|
||||
}
|
||||
|
||||
setupContext
|
||||
.command('build')
|
||||
.description('Build agent-ready KTX context for setup')
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (options: { input?: boolean }, command) => {
|
||||
await runSetupArgs(context, {
|
||||
command: 'context-build',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
inputMode: options.input === false ? 'disabled' : setupContextInputMode(command),
|
||||
});
|
||||
});
|
||||
|
||||
setupContext
|
||||
.command('watch')
|
||||
.description('Watch a setup-managed context build')
|
||||
.argument('[runId]', 'Setup context build run id')
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (runId: string | undefined, options: { input?: boolean }, command) => {
|
||||
await runSetupArgs(context, {
|
||||
command: 'context-watch',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
inputMode: options.input === false ? 'disabled' : setupContextInputMode(command),
|
||||
});
|
||||
});
|
||||
|
||||
setupContext
|
||||
.command('status')
|
||||
.description('Print setup-managed context build status')
|
||||
.argument('[runId]', 'Setup context build run id')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (runId: string | undefined, options: { json?: boolean }, command) => {
|
||||
await runSetupArgs(context, {
|
||||
command: 'context-status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
setupContext
|
||||
.command('stop')
|
||||
.description('Request a pause for a setup-managed context build')
|
||||
.argument('[runId]', 'Setup context build run id')
|
||||
.option('--force', 'Request the pause without an interactive confirmation', false)
|
||||
.action(async (runId: string | undefined, _options: { force?: boolean }, command) => {
|
||||
await runSetupArgs(context, {
|
||||
command: 'context-stop',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
setup
|
||||
.command('remove')
|
||||
.description('Remove setup-managed local integrations')
|
||||
.option('--agents', 'Remove setup-managed agent integration files', false)
|
||||
.action(async (options: { agents?: boolean }, command) => {
|
||||
const parentOptions = command.parent?.opts() as { agents?: boolean } | undefined;
|
||||
if (options.agents !== true && parentOptions?.agents !== true) {
|
||||
context.io.stderr.write('Choose what to remove: --agents.\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
await runSetupArgs(context, {
|
||||
command: 'remove-agents',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
});
|
||||
});
|
||||
|
||||
setup
|
||||
.command('status')
|
||||
.description('Show setup readiness for the resolved KTX project')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (options: { json?: boolean }, command) => {
|
||||
await runSetupArgs(context, {
|
||||
command: 'status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -508,7 +508,7 @@ describe('runContextBuild', () => {
|
|||
expect(mockExit).toHaveBeenCalledWith(0);
|
||||
expect(io.stdout()).toContain('Context build continuing in the background.');
|
||||
expect(io.stdout()).toContain('Resume: ktx setup --project-dir /tmp/project');
|
||||
expect(io.stdout()).toContain('Status: ktx setup context status --project-dir /tmp/project');
|
||||
expect(io.stdout()).toContain('Status: ktx status --project-dir /tmp/project');
|
||||
mockExit.mockRestore();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -412,7 +412,7 @@ function spawnBackgroundBuild(projectDir: string): { logPath: string } | null {
|
|||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[entryScript, 'setup', 'context', 'build', '--project-dir', resolvedDir, '--no-input'],
|
||||
[entryScript, 'setup', '--project-dir', resolvedDir, '--no-input'],
|
||||
{ detached: true, stdio: ['ignore', logFd, logFd] },
|
||||
);
|
||||
child.unref();
|
||||
|
|
@ -590,7 +590,7 @@ export async function runContextBuild(
|
|||
io.stdout.write('\n\nContext build continuing in the background.\n');
|
||||
if (bg) io.stdout.write(`Log: ${bg.logPath}\n`);
|
||||
io.stdout.write(`Resume: ${resumeCommand(args.projectDir)}\n`);
|
||||
io.stdout.write(`Status: ktx setup context status --project-dir ${resolve(args.projectDir)}\n`);
|
||||
io.stdout.write(`Status: ktx status --project-dir ${resolve(args.projectDir)}\n`);
|
||||
exiting = true;
|
||||
process.exit(0);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { access, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { access, readFile, rm, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -6,16 +6,11 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||
import {
|
||||
DEMO_ADAPTER,
|
||||
DEMO_CONNECTION_ID,
|
||||
DEMO_FULL_JOB_ID,
|
||||
DEMO_REPLAY_FILE,
|
||||
defaultDemoProjectDir,
|
||||
ensureDemoProject,
|
||||
inspectDemoProjectState,
|
||||
loadPackagedDemoReplay,
|
||||
loadProjectDemoReplay,
|
||||
resetDemoProject,
|
||||
ensureSeededDemoProject,
|
||||
} from './demo-assets.js';
|
||||
import { writeDemoReplay } from './demo-replay-store.js';
|
||||
|
||||
const packagedDemoSource = 'packaged-orbit-demo';
|
||||
|
||||
|
|
@ -44,7 +39,6 @@ describe('demo assets', () => {
|
|||
expect(DEMO_CONNECTION_ID).toBe('orbit_demo');
|
||||
expect(DEMO_ADAPTER).toBe('live-database');
|
||||
expect(DEMO_REPLAY_FILE).toBe('replay.memory-flow.v1.json');
|
||||
expect(DEMO_FULL_JOB_ID).toBe('demo-full-ingest');
|
||||
});
|
||||
|
||||
it('ships the seeded demo bundle required by the May 6 PRD', async () => {
|
||||
|
|
@ -131,137 +125,12 @@ describe('demo assets', () => {
|
|||
await expect(ensureDemoProject({ projectDir, force: true })).resolves.toMatchObject({ projectDir });
|
||||
});
|
||||
|
||||
it('loads packaged and copied demo replays', async () => {
|
||||
const packaged = await loadPackagedDemoReplay();
|
||||
expect(packaged.runId).toBe('demo-seeded-orbit');
|
||||
expect(packaged.connectionId).toBe('orbit_demo');
|
||||
expect(packaged.metadata?.mode).toBe('seeded');
|
||||
it('copies the seeded project assets used by the setup wizard tour', async () => {
|
||||
await ensureSeededDemoProject({ projectDir, force: false });
|
||||
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
const copied = await loadProjectDemoReplay(projectDir);
|
||||
expect(copied).toEqual(packaged);
|
||||
});
|
||||
|
||||
it('loads the latest local replay before the packaged replay', async () => {
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
await writeDemoReplay(
|
||||
projectDir,
|
||||
{
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: 'captured',
|
||||
timing: 'captured',
|
||||
capturedAt: '2026-05-01T10:00:03.000Z',
|
||||
sourceReportId: null,
|
||||
sourceReportPath: 'raw-sources/orbit_demo/live-database/sync/scan-report.json',
|
||||
fallbackReason: null,
|
||||
},
|
||||
runId: 'demo-full-run',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
status: 'done',
|
||||
sourceDir: null,
|
||||
syncId: 'sync',
|
||||
reportPath: 'raw-sources/orbit_demo/live-database/sync/scan-report.json',
|
||||
errors: [],
|
||||
events: [{ type: 'report_created', runId: 'scan-run' }],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
},
|
||||
{ label: 'full' },
|
||||
);
|
||||
|
||||
await expect(loadProjectDemoReplay(projectDir)).resolves.toMatchObject({
|
||||
runId: 'demo-full-run',
|
||||
metadata: { mode: 'full', origin: 'captured' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports missing, ready, and corrupted demo project state', async () => {
|
||||
await expect(inspectDemoProjectState(projectDir)).resolves.toEqual({
|
||||
status: 'missing',
|
||||
projectDir,
|
||||
missing: ['ktx.yaml', 'demo.db', 'state.sqlite', 'replays/replay.memory-flow.v1.json'],
|
||||
});
|
||||
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
await expect(inspectDemoProjectState(projectDir)).resolves.toEqual({
|
||||
status: 'ready',
|
||||
projectDir,
|
||||
missing: [],
|
||||
});
|
||||
|
||||
await rm(join(projectDir, 'demo.db'), { force: true });
|
||||
await expect(inspectDemoProjectState(projectDir)).resolves.toEqual({
|
||||
status: 'corrupt',
|
||||
projectDir,
|
||||
missing: ['demo.db'],
|
||||
});
|
||||
});
|
||||
|
||||
it('requires explicit force for demo reset and recreates packaged assets', async () => {
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
await rm(join(projectDir, 'demo.db'), { force: true });
|
||||
|
||||
await expect(resetDemoProject({ projectDir, force: false })).rejects.toThrow(
|
||||
`ktx setup demo reset is destructive; pass --force to recreate ${projectDir}`,
|
||||
);
|
||||
|
||||
await expect(resetDemoProject({ projectDir, force: true })).resolves.toMatchObject({ projectDir });
|
||||
await expect(access(join(projectDir, 'demo.db'))).resolves.toBeUndefined();
|
||||
await expect(inspectDemoProjectState(projectDir)).resolves.toMatchObject({ status: 'ready' });
|
||||
});
|
||||
|
||||
it('preserves a user-edited ktx.yaml across reset --force', async () => {
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
const customConfig = [
|
||||
'project: ktx-demo-orbit',
|
||||
'connections:',
|
||||
` ${DEMO_CONNECTION_ID}:`,
|
||||
' driver: sqlite',
|
||||
` path: ${JSON.stringify(join(projectDir, 'demo.db'))}`,
|
||||
' readonly: true',
|
||||
'storage:',
|
||||
' state: sqlite',
|
||||
' search: sqlite-fts5',
|
||||
' git:',
|
||||
' auto_commit: true',
|
||||
' author: ktx <ktx@example.com>',
|
||||
'llm:',
|
||||
' provider:',
|
||||
' backend: vertex',
|
||||
' vertex:',
|
||||
' project: example-gcp-project',
|
||||
' location: us-east5',
|
||||
' models:',
|
||||
' default: claude-sonnet-4-6',
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
` - ${DEMO_ADAPTER}`,
|
||||
' embeddings:',
|
||||
' backend: none',
|
||||
' dimensions: 8',
|
||||
' workUnits:',
|
||||
' stepBudget: 40',
|
||||
' maxConcurrency: 1',
|
||||
' failureMode: continue',
|
||||
'',
|
||||
].join('\n');
|
||||
await writeFile(join(projectDir, 'ktx.yaml'), customConfig, 'utf-8');
|
||||
|
||||
await resetDemoProject({ projectDir, force: true });
|
||||
|
||||
const preserved = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
|
||||
expect(preserved).toBe(customConfig);
|
||||
expect(preserved).toContain('backend: vertex');
|
||||
expect(preserved).not.toContain('backend: anthropic');
|
||||
await expect(inspectDemoProjectState(projectDir)).resolves.toMatchObject({ status: 'ready' });
|
||||
});
|
||||
|
||||
it('still writes the default ktx.yaml on reset when none exists', async () => {
|
||||
await resetDemoProject({ projectDir, force: true });
|
||||
const config = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
|
||||
expect(config).toContain('backend: anthropic');
|
||||
await expect(access(join(projectDir, 'semantic-layer', 'dbt-main', 'mart_arr_daily.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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { access, copyFile, cp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
|
||||
import { loadDemoReplayFile, loadLatestDemoReplay } from './demo-replay-store.js';
|
||||
|
||||
interface DemoProjectResult {
|
||||
projectDir: string;
|
||||
|
|
@ -19,25 +17,9 @@ interface EnsureDemoProjectOptions {
|
|||
force: boolean;
|
||||
}
|
||||
|
||||
type DemoProjectStateStatus = 'missing' | 'ready' | 'corrupt';
|
||||
|
||||
interface DemoProjectState {
|
||||
status: DemoProjectStateStatus;
|
||||
projectDir: string;
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
export const DEMO_CONNECTION_ID = 'orbit_demo';
|
||||
export const DEMO_ADAPTER = 'live-database';
|
||||
export const DEMO_REPLAY_FILE = 'replay.memory-flow.v1.json';
|
||||
export const DEMO_FULL_JOB_ID = 'demo-full-ingest';
|
||||
|
||||
const REQUIRED_BASE_PROJECT_PATHS = [
|
||||
'ktx.yaml',
|
||||
'demo.db',
|
||||
'state.sqlite',
|
||||
join('replays', DEMO_REPLAY_FILE),
|
||||
] as const;
|
||||
|
||||
const REQUIRED_PACKAGED_BASE_ASSET_PATHS = ['demo.db', 'manifest.json', DEMO_REPLAY_FILE] as const;
|
||||
|
||||
|
|
@ -68,49 +50,6 @@ export function defaultDemoProjectDir(): string {
|
|||
return join(tmpdir(), `ktx-demo-${suffix}`);
|
||||
}
|
||||
|
||||
export async function inspectDemoProjectState(projectDir: string): Promise<DemoProjectState> {
|
||||
const root = resolve(projectDir);
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const relativePath of REQUIRED_BASE_PROJECT_PATHS) {
|
||||
if (!(await exists(join(root, relativePath)))) {
|
||||
missing.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === REQUIRED_BASE_PROJECT_PATHS.length) {
|
||||
return { status: 'missing', projectDir: root, missing };
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
return { status: 'corrupt', projectDir: root, missing };
|
||||
}
|
||||
|
||||
return { status: 'ready', projectDir: root, missing: [] };
|
||||
}
|
||||
|
||||
export async function resetDemoProject(options: EnsureDemoProjectOptions): Promise<DemoProjectResult> {
|
||||
const projectDir = resolve(options.projectDir);
|
||||
if (!options.force) {
|
||||
throw new Error(`ktx setup demo reset is destructive; pass --force to recreate ${projectDir}`);
|
||||
}
|
||||
|
||||
const preservedConfig = await readExistingConfig(join(projectDir, 'ktx.yaml'));
|
||||
const result = await ensureDemoProject({ projectDir, force: true });
|
||||
if (preservedConfig !== null) {
|
||||
await writeFile(result.configPath, preservedConfig, 'utf-8');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readExistingConfig(configPath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(configPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function demoConfig(databasePath: string): string {
|
||||
return [
|
||||
'project: ktx-demo-orbit',
|
||||
|
|
@ -243,34 +182,3 @@ export async function ensureSeededDemoProject(options: EnsureDemoProjectOptions)
|
|||
await copySeededAssetDirectories(result.projectDir);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function loadPackagedDemoReplay(): Promise<MemoryFlowReplayInput> {
|
||||
const replay = await loadDemoReplayFile(join(assetDir(), DEMO_REPLAY_FILE));
|
||||
return {
|
||||
...replay,
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: replay.metadata?.mode ?? 'seeded',
|
||||
origin: 'packaged',
|
||||
timing: replay.metadata?.timing ?? 'prebuilt',
|
||||
capturedAt: replay.metadata?.capturedAt ?? null,
|
||||
sourceReportId: replay.metadata?.sourceReportId ?? 'demo-seeded-report',
|
||||
sourceReportPath: replay.metadata?.sourceReportPath ?? `reports/seeded-demo-report.json`,
|
||||
fallbackReason: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadProjectDemoReplay(projectDir: string): Promise<MemoryFlowReplayInput> {
|
||||
const latest = await loadLatestDemoReplay(projectDir);
|
||||
if (latest) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
const replayPath = join(resolve(projectDir), 'replays', DEMO_REPLAY_FILE);
|
||||
if (!(await exists(replayPath))) {
|
||||
await mkdir(dirname(replayPath), { recursive: true });
|
||||
await copyPackagedReplay(resolve(projectDir));
|
||||
}
|
||||
return loadPackagedDemoReplay();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { IngestReportSnapshot, LocalIngestResult, RunLocalIngestOptions } from '@ktx/context/ingest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEMO_ADAPTER, DEMO_CONNECTION_ID, DEMO_FULL_JOB_ID, ensureDemoProject } from './demo-assets.js';
|
||||
import {
|
||||
assertFullDemoCredentials,
|
||||
buildFullDemoReplay,
|
||||
formatFullDemoSummary,
|
||||
fullDemoCredentialStatus,
|
||||
runDemoFull,
|
||||
} from './demo-full.js';
|
||||
|
||||
function fakeFullReport(): IngestReportSnapshot {
|
||||
return {
|
||||
id: 'report-full',
|
||||
runId: 'run-full',
|
||||
jobId: DEMO_FULL_JOB_ID,
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
sourceKey: DEMO_ADAPTER,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
body: {
|
||||
syncId: 'sync-full',
|
||||
diffSummary: { added: 7, modified: 0, deleted: 0, unchanged: 0 },
|
||||
commitSha: null,
|
||||
workUnits: [
|
||||
{
|
||||
unitKey: 'accounts',
|
||||
rawFiles: ['accounts.schema.json'],
|
||||
status: 'success',
|
||||
actions: [
|
||||
{ target: 'wiki', type: 'created', key: 'knowledge/accounts.md', detail: 'account lifecycle context' },
|
||||
{ target: 'sl', type: 'created', key: 'orbit_demo.accounts', detail: 'accounts semantic source' },
|
||||
],
|
||||
touchedSlSources: [{ connectionId: 'orbit_demo', sourceName: 'orbit_demo.accounts' }],
|
||||
},
|
||||
],
|
||||
failedWorkUnits: [],
|
||||
reconciliationSkipped: false,
|
||||
conflictsResolved: [],
|
||||
evictionsApplied: [],
|
||||
unmappedFallbacks: [],
|
||||
evictionInputs: [],
|
||||
unresolvedCards: [],
|
||||
supersededBy: null,
|
||||
overrideOf: null,
|
||||
provenanceRows: [
|
||||
{
|
||||
rawPath: 'accounts.schema.json',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/accounts.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
{
|
||||
rawPath: 'accounts.schema.json',
|
||||
artifactKind: 'sl',
|
||||
artifactKey: 'orbit_demo.accounts',
|
||||
actionType: 'source_created',
|
||||
},
|
||||
],
|
||||
toolTranscripts: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('full demo helpers', () => {
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-demo-full-'));
|
||||
projectDir = join(tempDir, 'demo');
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fails full mode with exact Anthropic env guidance when the key is missing', async () => {
|
||||
const project = await import('@ktx/context/project').then((mod) => mod.loadKtxProject({ projectDir }));
|
||||
|
||||
expect(() => assertFullDemoCredentials(project, {})).toThrow(
|
||||
'ktx setup demo --mode full needs ANTHROPIC_API_KEY. Export ANTHROPIC_API_KEY and rerun `ktx setup demo --mode full --no-input`, or run `ktx setup demo --mode seeded --no-input` without credentials.',
|
||||
);
|
||||
});
|
||||
|
||||
it('respects an existing gateway provider project for full mode', async () => {
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: ktx-demo-orbit',
|
||||
'connections:',
|
||||
' orbit_demo:',
|
||||
' driver: sqlite',
|
||||
` path: ${JSON.stringify(join(projectDir, 'demo.db'))}`,
|
||||
'llm:',
|
||||
' provider:',
|
||||
' backend: gateway',
|
||||
' models:',
|
||||
' default: anthropic/claude-sonnet-4-6',
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
' - live-database',
|
||||
' embeddings:',
|
||||
' backend: none',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const project = await import('@ktx/context/project').then((mod) => mod.loadKtxProject({ projectDir }));
|
||||
|
||||
expect(() => assertFullDemoCredentials(project, {})).not.toThrow();
|
||||
expect(fullDemoCredentialStatus(project, {})).toEqual({ status: 'ready' });
|
||||
});
|
||||
|
||||
it('reports full-demo credential status without throwing', async () => {
|
||||
const project = await import('@ktx/context/project').then((mod) => mod.loadKtxProject({ projectDir }));
|
||||
|
||||
expect(fullDemoCredentialStatus(project, {})).toEqual({ status: 'missing-anthropic-key' });
|
||||
expect(fullDemoCredentialStatus(project, { ANTHROPIC_API_KEY: 'sk-ant-test' })).toEqual({ status: 'ready' }); // pragma: allowlist secret
|
||||
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: ktx-demo-orbit',
|
||||
'connections:',
|
||||
' orbit_demo:',
|
||||
' driver: sqlite',
|
||||
` path: ${JSON.stringify(join(projectDir, 'demo.db'))}`,
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
' - live-database',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const disabledProject = await import('@ktx/context/project').then((mod) => mod.loadKtxProject({ projectDir }));
|
||||
expect(fullDemoCredentialStatus(disabledProject, {})).toEqual({ status: 'unsupported-provider', provider: 'none' });
|
||||
});
|
||||
|
||||
it('runs scan first and then full ingest with the canonical demo connection', async () => {
|
||||
const report = fakeFullReport();
|
||||
const runLocalScan = vi.fn().mockResolvedValue({
|
||||
report: {
|
||||
runId: 'scan-run',
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
driver: 'sqlite',
|
||||
mode: 'structural',
|
||||
syncId: 'sync-scan',
|
||||
diffSummary: { tablesAdded: 7, tablesModified: 0, tablesDeleted: 0, tablesUnchanged: 0 },
|
||||
artifactPaths: { rawSourcesDir: 'raw-sources/orbit_demo/live-database/sync-scan', manifestShards: [], reportPath: 'scan-report.json' },
|
||||
},
|
||||
});
|
||||
const runLocalIngest = vi.fn(async (options: RunLocalIngestOptions): Promise<LocalIngestResult> => {
|
||||
expect(options.adapter).toBe(DEMO_ADAPTER);
|
||||
expect(options.connectionId).toBe(DEMO_CONNECTION_ID);
|
||||
expect(options.jobId).toBe(DEMO_FULL_JOB_ID);
|
||||
expect(options.memoryFlow?.snapshot()).toMatchObject({ runId: DEMO_FULL_JOB_ID, status: 'running' });
|
||||
options.memoryFlow?.emit({ type: 'source_acquired', adapter: DEMO_ADAPTER, trigger: 'demo_full', fileCount: 7 });
|
||||
return { result: { ok: true } as never, report };
|
||||
});
|
||||
const snapshots: unknown[] = [];
|
||||
|
||||
const result = await runDemoFull({
|
||||
projectDir,
|
||||
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
|
||||
runLocalScan,
|
||||
runLocalIngest,
|
||||
onMemoryFlowChange: (snapshot) => snapshots.push(snapshot),
|
||||
});
|
||||
|
||||
expect(runLocalScan).toHaveBeenCalledTimes(1);
|
||||
expect(runLocalIngest).toHaveBeenCalledTimes(1);
|
||||
expect(result.report).toBe(report);
|
||||
expect(result.replay.runId).toBe('run-full');
|
||||
expect(snapshots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds replay and plain summary from the full report', () => {
|
||||
const report = fakeFullReport();
|
||||
const replay = buildFullDemoReplay(report);
|
||||
const summary = formatFullDemoSummary(report);
|
||||
|
||||
expect(replay).toMatchObject({
|
||||
runId: 'run-full',
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
adapter: DEMO_ADAPTER,
|
||||
status: 'done',
|
||||
});
|
||||
expect(summary).toContain('Full demo ingest: done');
|
||||
expect(summary).toContain('Saved memory: 1 wiki, 1 semantic layer');
|
||||
expect(summary).toContain('Provenance rows: 2');
|
||||
expect(summary).toContain('Next: ktx setup demo inspect');
|
||||
expect(summary).toContain('Shows the files, semantic-layer sources, and memory KTX just produced.');
|
||||
expect(summary).toContain('Next: ktx setup demo replay');
|
||||
expect(summary).toContain('Replays the same visual story without calling the LLM again.');
|
||||
expect(summary).not.toContain('--viz');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
import { resolveKtxConfigReference } from '@ktx/context/core';
|
||||
import {
|
||||
createMemoryFlowLiveBuffer,
|
||||
ingestReportToMemoryFlowReplay,
|
||||
runLocalIngest,
|
||||
type IngestReportSnapshot,
|
||||
type LocalIngestResult,
|
||||
type MemoryFlowReplayInput,
|
||||
type RunLocalIngestOptions,
|
||||
} from '@ktx/context/ingest';
|
||||
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||
import { runLocalScan, type LocalScanRunResult } from '@ktx/context/scan';
|
||||
import { DEMO_ADAPTER, DEMO_CONNECTION_ID, DEMO_FULL_JOB_ID, ensureDemoProject } from './demo-assets.js';
|
||||
import { runDemoScan } from './demo-scan.js';
|
||||
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
|
||||
import { formatNextStepLines } from './next-steps.js';
|
||||
|
||||
interface DemoFullOptions {
|
||||
projectDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runLocalScan?: typeof runLocalScan;
|
||||
runLocalIngest?: typeof runLocalIngest;
|
||||
onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void;
|
||||
}
|
||||
|
||||
export interface DemoFullResult {
|
||||
project: KtxLocalProject;
|
||||
scan: LocalScanRunResult;
|
||||
ingest: LocalIngestResult;
|
||||
report: IngestReportSnapshot;
|
||||
replay: MemoryFlowReplayInput;
|
||||
}
|
||||
|
||||
type FullDemoCredentialStatus =
|
||||
| { status: 'ready' }
|
||||
| { status: 'missing-anthropic-key' }
|
||||
| { status: 'unsupported-provider'; provider: string };
|
||||
|
||||
async function ensureDemoProjectForReuse(projectDir: string): Promise<void> {
|
||||
await ensureDemoProject({ projectDir, force: false }).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes('Demo project already exists')) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function savedCounts(report: IngestReportSnapshot): { wikiCount: number; slCount: number } {
|
||||
const actions = report.body.workUnits.flatMap((workUnit) => workUnit.actions);
|
||||
return {
|
||||
wikiCount: actions.filter((action) => action.target === 'wiki').length,
|
||||
slCount: actions.filter((action) => action.target === 'sl').length,
|
||||
};
|
||||
}
|
||||
|
||||
export function fullDemoCredentialStatus(
|
||||
project: KtxLocalProject,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): FullDemoCredentialStatus {
|
||||
const llm = project.config.llm;
|
||||
if (llm.provider.backend === 'none') {
|
||||
return { status: 'unsupported-provider', provider: llm.provider.backend };
|
||||
}
|
||||
|
||||
if (llm.provider.backend === 'anthropic' && !resolveKtxConfigReference(llm.provider.anthropic?.api_key, env)) {
|
||||
return { status: 'missing-anthropic-key' };
|
||||
}
|
||||
|
||||
return { status: 'ready' };
|
||||
}
|
||||
|
||||
export function assertFullDemoCredentials(project: KtxLocalProject, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const llm = project.config.llm;
|
||||
const status = fullDemoCredentialStatus(project, env);
|
||||
if (status.status === 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === 'unsupported-provider') {
|
||||
throw new Error(
|
||||
'ktx setup demo --mode full requires llm.provider.backend: anthropic, vertex, or gateway. Run `ktx setup demo init --force --no-input` to recreate the demo config, or run `ktx setup demo --mode seeded --no-input` without credentials.',
|
||||
);
|
||||
}
|
||||
|
||||
if (llm.provider.backend === 'anthropic') {
|
||||
throw new Error(
|
||||
'ktx setup demo --mode full needs ANTHROPIC_API_KEY. Export ANTHROPIC_API_KEY and rerun `ktx setup demo --mode full --no-input`, or run `ktx setup demo --mode seeded --no-input` without credentials.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFullDemoReplay(report: IngestReportSnapshot): MemoryFlowReplayInput {
|
||||
return ingestReportToMemoryFlowReplay(report, { provenanceRowCount: report.body.provenanceRows.length });
|
||||
}
|
||||
|
||||
function initialFullReplay(projectDir: string): MemoryFlowReplayInput {
|
||||
return {
|
||||
runId: DEMO_FULL_JOB_ID,
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
adapter: DEMO_ADAPTER,
|
||||
status: 'running',
|
||||
sourceDir: `${projectDir}/raw-sources/${DEMO_CONNECTION_ID}/${DEMO_ADAPTER}`,
|
||||
syncId: 'pending',
|
||||
errors: [],
|
||||
events: [],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDemoFull(options: DemoFullOptions): Promise<DemoFullResult> {
|
||||
await ensureDemoProjectForReuse(options.projectDir);
|
||||
const project = await loadKtxProject({ projectDir: options.projectDir });
|
||||
assertFullDemoCredentials(project, options.env);
|
||||
|
||||
const { result: scan } = await runDemoScan({
|
||||
projectDir: project.projectDir,
|
||||
jobId: 'demo-full-scan',
|
||||
...(options.runLocalScan ? { runLocalScan: options.runLocalScan } : {}),
|
||||
});
|
||||
|
||||
const memoryFlow = options.onMemoryFlowChange
|
||||
? createMemoryFlowLiveBuffer(initialFullReplay(project.projectDir), { onChange: options.onMemoryFlowChange })
|
||||
: undefined;
|
||||
const executeLocalIngest = options.runLocalIngest ?? runLocalIngest;
|
||||
const ingest = await executeLocalIngest({
|
||||
project,
|
||||
adapters: createKtxCliLocalIngestAdapters(project),
|
||||
adapter: DEMO_ADAPTER,
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
trigger: 'manual_resync',
|
||||
jobId: DEMO_FULL_JOB_ID,
|
||||
...(memoryFlow ? { memoryFlow } : {}),
|
||||
} satisfies RunLocalIngestOptions);
|
||||
|
||||
return {
|
||||
project,
|
||||
scan,
|
||||
ingest,
|
||||
report: ingest.report,
|
||||
replay: buildFullDemoReplay(ingest.report),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatFullDemoSummary(report: IngestReportSnapshot): string {
|
||||
const counts = savedCounts(report);
|
||||
return [
|
||||
'Full demo ingest: done',
|
||||
`Report: ${report.id}`,
|
||||
`Run: ${report.runId}`,
|
||||
`Job: ${report.jobId}`,
|
||||
`Sync: ${report.body.syncId}`,
|
||||
`Saved memory: ${counts.wikiCount} wiki, ${counts.slCount} semantic layer`,
|
||||
`Provenance rows: ${report.body.provenanceRows.length}`,
|
||||
'Next: ktx setup demo inspect',
|
||||
' Shows the files, semantic-layer sources, and memory KTX just produced.',
|
||||
'Next: ktx setup demo replay',
|
||||
' Replays the same visual story without calling the LLM again.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const ADAPTER_PREFIXES = ['live_database_', 'metabase_', 'looker_', 'lookml_', 'metricflow_', 'notion_', 'historic_sql_', 'dbt_descriptions_'];
|
||||
|
||||
function humanizeUnitKeyForReport(unitKey: string): string {
|
||||
let key = unitKey.replace(/-/g, '_');
|
||||
for (const prefix of ADAPTER_PREFIXES) {
|
||||
if (key.startsWith(prefix)) { key = key.slice(prefix.length); break; }
|
||||
}
|
||||
return key.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function formatCleanDemoSummary(report: IngestReportSnapshot, projectDir: string): string {
|
||||
const counts = savedCounts(report);
|
||||
const workUnits = report.body.workUnits;
|
||||
const conflictCount = report.body.conflictsResolved.length;
|
||||
const areasAnalyzed = workUnits.filter((wu) => wu.actions.length > 0).length;
|
||||
|
||||
const lines: string[] = ['', '★ KTX finished ingesting your data', ''];
|
||||
|
||||
if (areasAnalyzed > 0) {
|
||||
lines.push(` ✓ Analyzed ${areasAnalyzed} business area${areasAnalyzed === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (!report.body.reconciliationSkipped) {
|
||||
lines.push(` ✓ Reconciled — ${conflictCount > 0 ? `${conflictCount} conflict${conflictCount === 1 ? '' : 's'} resolved` : 'no conflicts'}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
if (counts.slCount > 0 || counts.wikiCount > 0) {
|
||||
lines.push(' KTX created:');
|
||||
if (counts.slCount > 0) lines.push(` 📊 ${counts.slCount} query definition${counts.slCount === 1 ? '' : 's'} — so agents can write accurate SQL for your data`);
|
||||
if (counts.wikiCount > 0) lines.push(` 📝 ${counts.wikiCount} knowledge page${counts.wikiCount === 1 ? '' : 's'} — so agents understand your business context`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const memoryFlow = report.body.memoryFlow;
|
||||
if (memoryFlow) {
|
||||
for (const detail of memoryFlow.details.actions) {
|
||||
if (!detail.summary) continue;
|
||||
const icon = detail.target === 'sl' ? '📊' : '📝';
|
||||
lines.push(` ${icon} ${detail.summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(' What to do next:');
|
||||
lines.push(...formatNextStepLines());
|
||||
lines.push('');
|
||||
lines.push(` Your KTX project files are at: ${projectDir}`);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ensureDemoProject } from './demo-assets.js';
|
||||
import {
|
||||
chooseDemoProjectForInteractiveRun,
|
||||
createTestDemoPromptAdapter,
|
||||
resolveFullCredentialDecision,
|
||||
} from './demo-interaction.js';
|
||||
|
||||
function io(isTTY: boolean) {
|
||||
return {
|
||||
stdin: { isTTY },
|
||||
stdout: { isTTY, write: vi.fn() },
|
||||
stderr: { write: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe('demo interaction decisions', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-demo-interaction-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reuses a valid project without prompting in no-input mode', async () => {
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
chooseDemoProjectForInteractiveRun({
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
io: io(false),
|
||||
prompts: createTestDemoPromptAdapter({ choices: [] }),
|
||||
}),
|
||||
).resolves.toEqual({ action: 'use', projectDir: tempDir, reset: false });
|
||||
});
|
||||
|
||||
it('fails corrupted projects in no-input mode with reset guidance', async () => {
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
await rm(join(tempDir, 'demo.db'), { force: true });
|
||||
|
||||
await expect(
|
||||
chooseDemoProjectForInteractiveRun({
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
io: io(false),
|
||||
prompts: createTestDemoPromptAdapter({ choices: [] }),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`Demo project is not ready at ${tempDir}: missing demo.db. Run ktx setup demo reset --project-dir ${tempDir} --force --no-input`,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets interactive users reset a corrupted project', async () => {
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
await rm(join(tempDir, 'demo.db'), { force: true });
|
||||
|
||||
await expect(
|
||||
chooseDemoProjectForInteractiveRun({
|
||||
projectDir: tempDir,
|
||||
io: io(true),
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['reset'], confirms: [true] }),
|
||||
}),
|
||||
).resolves.toEqual({ action: 'use', projectDir: tempDir, reset: true });
|
||||
});
|
||||
|
||||
it('lets interactive users choose another project directory', async () => {
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
const otherDir = join(tempDir, 'other-demo');
|
||||
|
||||
await expect(
|
||||
chooseDemoProjectForInteractiveRun({
|
||||
projectDir: tempDir,
|
||||
io: io(true),
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['other'], texts: [otherDir] }),
|
||||
}),
|
||||
).resolves.toEqual({ action: 'use', projectDir: otherDir, reset: false });
|
||||
});
|
||||
|
||||
it('uses a pasted Anthropic key only for the returned process env', async () => {
|
||||
// pragma: allowlist secret
|
||||
const prompts = createTestDemoPromptAdapter({ choices: ['process_key'], passwords: ['sk-ant-process'] });
|
||||
|
||||
await expect(
|
||||
resolveFullCredentialDecision({
|
||||
needsAnthropicKey: true,
|
||||
inputMode: 'auto',
|
||||
io: io(true),
|
||||
env: {},
|
||||
prompts,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: 'full',
|
||||
env: { ANTHROPIC_API_KEY: 'sk-ant-process' }, // pragma: allowlist secret
|
||||
});
|
||||
});
|
||||
|
||||
it('lets interactive users explicitly choose seeded mode when the key is missing', async () => {
|
||||
await expect(
|
||||
resolveFullCredentialDecision({
|
||||
needsAnthropicKey: true,
|
||||
inputMode: 'auto',
|
||||
io: io(true),
|
||||
env: {},
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['seeded'] }),
|
||||
}),
|
||||
).resolves.toEqual({ action: 'run-mode', mode: 'seeded' });
|
||||
});
|
||||
|
||||
it('does not prompt when input is disabled', async () => {
|
||||
await expect(
|
||||
resolveFullCredentialDecision({
|
||||
needsAnthropicKey: true,
|
||||
inputMode: 'disabled',
|
||||
io: io(false),
|
||||
env: {},
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['seeded'] }),
|
||||
}),
|
||||
).resolves.toEqual({ action: 'full', env: {} });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
import { cancel, confirm, isCancel, password, select, text } from '@clack/prompts';
|
||||
import type { Option as ClackOption } from '@clack/prompts';
|
||||
import { resolve } from 'node:path';
|
||||
import { inspectDemoProjectState } from './demo-assets.js';
|
||||
import type { KtxDemoInputMode } from './demo.js';
|
||||
import { withMenuOptionsSpacing } from './prompt-navigation.js';
|
||||
|
||||
type DemoPromptOption<T extends string> = ClackOption<T>;
|
||||
|
||||
export interface DemoPromptAdapter {
|
||||
select<T extends string>(options: { message: string; options: Array<DemoPromptOption<T>> }): Promise<T>;
|
||||
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
|
||||
password(options: { message: string }): Promise<string>;
|
||||
text(options: { message: string; placeholder?: string }): Promise<string>;
|
||||
cancel(message: string): void;
|
||||
}
|
||||
|
||||
interface DemoInteractiveIo {
|
||||
stdin?: { isTTY?: boolean };
|
||||
stdout: { isTTY?: boolean };
|
||||
}
|
||||
|
||||
type DemoProjectDecision =
|
||||
| { action: 'use'; projectDir: string; reset: boolean }
|
||||
| { action: 'cancel' };
|
||||
|
||||
type FullCredentialDecision =
|
||||
| { action: 'full'; env: NodeJS.ProcessEnv }
|
||||
| { action: 'run-mode'; mode: 'seeded' | 'replay' }
|
||||
| { action: 'cancel' };
|
||||
|
||||
function isInteractive(inputMode: KtxDemoInputMode | undefined, io: DemoInteractiveIo): boolean {
|
||||
return inputMode !== 'disabled' && io.stdin?.isTTY === true && io.stdout.isTTY === true;
|
||||
}
|
||||
|
||||
function cloneEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return { ...env };
|
||||
}
|
||||
|
||||
function ensureNotCancelled<T>(value: T | symbol, prompts: Pick<DemoPromptAdapter, 'cancel'>): T {
|
||||
if (isCancel(value)) {
|
||||
prompts.cancel('Demo cancelled.');
|
||||
throw new Error('Demo cancelled.');
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function createClackDemoPromptAdapter(): DemoPromptAdapter {
|
||||
return {
|
||||
async select<T extends string>(options: { message: string; options: Array<DemoPromptOption<T>> }): Promise<T> {
|
||||
return ensureNotCancelled(await select(withMenuOptionsSpacing(options)), this);
|
||||
},
|
||||
async confirm(options: { message: string; initialValue?: boolean }): Promise<boolean> {
|
||||
return ensureNotCancelled(await confirm(options), this);
|
||||
},
|
||||
async password(options: { message: string }): Promise<string> {
|
||||
return ensureNotCancelled(await password(options), this);
|
||||
},
|
||||
async text(options: { message: string; placeholder?: string }): Promise<string> {
|
||||
return ensureNotCancelled(await text(options), this);
|
||||
},
|
||||
cancel(message: string): void {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestDemoPromptAdapter(options: {
|
||||
choices?: string[];
|
||||
confirms?: boolean[];
|
||||
passwords?: string[];
|
||||
texts?: string[];
|
||||
}): DemoPromptAdapter {
|
||||
const choices = [...(options.choices ?? [])];
|
||||
const confirms = [...(options.confirms ?? [])];
|
||||
const passwords = [...(options.passwords ?? [])];
|
||||
const texts = [...(options.texts ?? [])];
|
||||
|
||||
return {
|
||||
async select<T extends string>(): Promise<T> {
|
||||
return choices.shift() as T;
|
||||
},
|
||||
async confirm(): Promise<boolean> {
|
||||
return confirms.shift() ?? false;
|
||||
},
|
||||
async password(): Promise<string> {
|
||||
return passwords.shift() ?? '';
|
||||
},
|
||||
async text(): Promise<string> {
|
||||
return texts.shift() ?? '';
|
||||
},
|
||||
cancel(): void {
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function chooseDemoProjectForInteractiveRun(options: {
|
||||
projectDir: string;
|
||||
inputMode?: KtxDemoInputMode;
|
||||
io: DemoInteractiveIo;
|
||||
prompts?: DemoPromptAdapter;
|
||||
}): Promise<DemoProjectDecision> {
|
||||
const prompts = options.prompts ?? createClackDemoPromptAdapter();
|
||||
const projectDir = resolve(options.projectDir);
|
||||
const state = await inspectDemoProjectState(projectDir);
|
||||
|
||||
if (!isInteractive(options.inputMode, options.io)) {
|
||||
if (state.status === 'corrupt') {
|
||||
throw new Error(
|
||||
`Demo project is not ready at ${projectDir}: missing ${state.missing.join(', ')}. Run ktx setup demo reset --project-dir ${projectDir} --force --no-input`,
|
||||
);
|
||||
}
|
||||
return { action: 'use', projectDir, reset: false };
|
||||
}
|
||||
|
||||
if (state.status === 'missing') {
|
||||
return { action: 'use', projectDir, reset: false };
|
||||
}
|
||||
|
||||
const choices =
|
||||
state.status === 'ready'
|
||||
? [
|
||||
{ value: 'reuse', label: 'Reuse existing demo project' },
|
||||
{ value: 'reset', label: 'Reset demo project' },
|
||||
{ value: 'other', label: 'Choose another directory' },
|
||||
{ value: 'cancel', label: 'Cancel' },
|
||||
]
|
||||
: [
|
||||
{ value: 'reset', label: 'Reset corrupted demo project', hint: `Missing ${state.missing.join(', ')}` },
|
||||
{ value: 'other', label: 'Choose another directory' },
|
||||
{ value: 'cancel', label: 'Cancel' },
|
||||
];
|
||||
|
||||
const choice = await prompts.select({
|
||||
message: state.status === 'ready' ? `Demo project exists at ${projectDir}` : `Demo project is not ready at ${projectDir}`,
|
||||
options: choices,
|
||||
});
|
||||
|
||||
if (choice === 'cancel') {
|
||||
prompts.cancel('Demo cancelled.');
|
||||
return { action: 'cancel' };
|
||||
}
|
||||
|
||||
if (choice === 'other') {
|
||||
const nextProjectDir = await prompts.text({
|
||||
message: 'Demo project directory',
|
||||
placeholder: projectDir,
|
||||
});
|
||||
return { action: 'use', projectDir: resolve(nextProjectDir), reset: false };
|
||||
}
|
||||
|
||||
if (choice === 'reset') {
|
||||
const confirmed = await prompts.confirm({
|
||||
message: `Recreate ${projectDir}? Existing demo artifacts under that directory will be removed.`,
|
||||
initialValue: false,
|
||||
});
|
||||
return confirmed ? { action: 'use', projectDir, reset: true } : { action: 'cancel' };
|
||||
}
|
||||
|
||||
return { action: 'use', projectDir, reset: false };
|
||||
}
|
||||
|
||||
export async function resolveFullCredentialDecision(options: {
|
||||
needsAnthropicKey: boolean;
|
||||
inputMode?: KtxDemoInputMode;
|
||||
io: DemoInteractiveIo;
|
||||
env: NodeJS.ProcessEnv;
|
||||
prompts?: DemoPromptAdapter;
|
||||
}): Promise<FullCredentialDecision> {
|
||||
const env = cloneEnv(options.env);
|
||||
if (!options.needsAnthropicKey || env.ANTHROPIC_API_KEY) {
|
||||
return { action: 'full', env };
|
||||
}
|
||||
|
||||
if (!isInteractive(options.inputMode, options.io)) {
|
||||
return { action: 'full', env };
|
||||
}
|
||||
|
||||
const prompts = options.prompts ?? createClackDemoPromptAdapter();
|
||||
const choice = await prompts.select({
|
||||
message: 'Anthropic credentials are missing for the full demo',
|
||||
options: [
|
||||
{ value: 'process_key', label: 'Enter key for this process only' },
|
||||
{ value: 'seeded', label: 'Run pre-seeded demo without LLM' },
|
||||
{ value: 'replay', label: 'Run packaged replay' },
|
||||
{ value: 'cancel', label: 'Cancel' },
|
||||
],
|
||||
});
|
||||
|
||||
if (choice === 'cancel') {
|
||||
prompts.cancel('Demo cancelled.');
|
||||
return { action: 'cancel' };
|
||||
}
|
||||
|
||||
if (choice === 'seeded' || choice === 'replay') {
|
||||
return { action: 'run-mode', mode: choice };
|
||||
}
|
||||
|
||||
const key = await prompts.password({ message: 'ANTHROPIC_API_KEY' });
|
||||
return { action: 'full', env: { ...env, ANTHROPIC_API_KEY: key } };
|
||||
}
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
import type { MemoryFlowEvent, MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPlainProgressEmitter, formatMemoryFlowEventLine } from './demo-progress.js';
|
||||
|
||||
function snapshot(events: MemoryFlowEvent[]): MemoryFlowReplayInput {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
status: 'running',
|
||||
sourceDir: null,
|
||||
syncId: 'sync-1',
|
||||
errors: [],
|
||||
events,
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatMemoryFlowEventLine', () => {
|
||||
it('formats source_acquired in plain English with adapter and file count', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'source_acquired',
|
||||
adapter: 'live-database',
|
||||
trigger: 'manual_resync',
|
||||
fileCount: 7,
|
||||
}),
|
||||
).toBe('[connect] Connected live-database - 7 database files (manual_resync)');
|
||||
});
|
||||
|
||||
it('formats diff_computed as a comma-separated breakdown', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'diff_computed',
|
||||
added: 3,
|
||||
modified: 1,
|
||||
deleted: 0,
|
||||
unchanged: 4,
|
||||
}),
|
||||
).toBe('[diff] Tables: +3 new, ~1 changed, =4 unchanged');
|
||||
});
|
||||
|
||||
it('formats diff_computed as "no changes" when every counter is zero', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'diff_computed',
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
unchanged: 0,
|
||||
}),
|
||||
).toBe('[diff] Tables: no changes');
|
||||
});
|
||||
|
||||
it('formats chunks_planned without removals as a single readable sentence', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'chunks_planned',
|
||||
chunkCount: 7,
|
||||
workUnitCount: 5,
|
||||
evictionCount: 0,
|
||||
}),
|
||||
).toBe('[plan] Grouped 5 tables into 7 business areas');
|
||||
});
|
||||
|
||||
it('formats chunks_planned with removals when evictions are non-zero', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'chunks_planned',
|
||||
chunkCount: 7,
|
||||
workUnitCount: 5,
|
||||
evictionCount: 2,
|
||||
}),
|
||||
).toBe('[plan] Grouped 5 tables into 7 business areas (2 removals)');
|
||||
});
|
||||
|
||||
it('formats work_unit_started in human terms', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'work_unit_started',
|
||||
unitKey: 'revenue-policy',
|
||||
skills: ['sl_expert', 'wiki_writer'],
|
||||
stepBudget: 40,
|
||||
}),
|
||||
).toBe('[analyze] Reviewing "revenue-policy" - budget 40 agent steps');
|
||||
});
|
||||
|
||||
it('suppresses noisy work_unit_step events', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'work_unit_step',
|
||||
unitKey: 'revenue-policy',
|
||||
stepIndex: 3,
|
||||
stepBudget: 40,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('formats candidate_action with friendly target and arrow', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-policy',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'warehouse.revenue',
|
||||
}),
|
||||
).toBe('[draft] revenue-policy -> semantic-layer: created warehouse.revenue');
|
||||
});
|
||||
|
||||
it('formats work_unit_finished with status-aware tag', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'work_unit_finished',
|
||||
unitKey: 'revenue-policy',
|
||||
status: 'success',
|
||||
}),
|
||||
).toBe('[done] revenue-policy reviewed');
|
||||
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'work_unit_finished',
|
||||
unitKey: 'revenue-policy',
|
||||
status: 'failed',
|
||||
reason: 'budget exhausted',
|
||||
}),
|
||||
).toBe('[fail] revenue-policy needs attention - budget exhausted');
|
||||
});
|
||||
|
||||
it('formats reconciliation_finished with friendly counter wording', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'reconciliation_finished',
|
||||
conflictCount: 0,
|
||||
fallbackCount: 0,
|
||||
}),
|
||||
).toBe('[validate] Reconciled drafts - no conflicts, nothing flagged for review');
|
||||
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'reconciliation_finished',
|
||||
conflictCount: 2,
|
||||
fallbackCount: 1,
|
||||
}),
|
||||
).toBe('[validate] Reconciled drafts - 2 conflicts, 1 item flagged for review');
|
||||
});
|
||||
|
||||
it('formats saved with optional shortened commit sha and pluralized memory count', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'saved',
|
||||
commitSha: 'abc1234567890', // pragma: allowlist secret
|
||||
wikiCount: 2,
|
||||
slCount: 5,
|
||||
}),
|
||||
).toBe('[memory] Saved 7 memories (2 wiki, 5 semantic-layer) - commit abc1234');
|
||||
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'saved',
|
||||
commitSha: null,
|
||||
wikiCount: 0,
|
||||
slCount: 1,
|
||||
}),
|
||||
).toBe('[memory] Saved 1 memory (0 wiki, 1 semantic-layer)');
|
||||
});
|
||||
|
||||
it('formats report_created with run id', () => {
|
||||
expect(
|
||||
formatMemoryFlowEventLine({
|
||||
type: 'report_created',
|
||||
runId: 'run-xyz',
|
||||
}),
|
||||
).toBe('[report] Run report ready: run-xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPlainProgressEmitter', () => {
|
||||
it('writes one line per new event and never re-emits prior events', () => {
|
||||
const written: string[] = [];
|
||||
const io = {
|
||||
stdout: { write: (chunk: string) => written.push(chunk), isTTY: false },
|
||||
stderr: { write: () => undefined },
|
||||
};
|
||||
const emit = createPlainProgressEmitter(io);
|
||||
|
||||
emit(
|
||||
snapshot([
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 7 },
|
||||
{ type: 'diff_computed', added: 0, modified: 0, deleted: 0, unchanged: 7 },
|
||||
]),
|
||||
);
|
||||
|
||||
emit(
|
||||
snapshot([
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 7 },
|
||||
{ type: 'diff_computed', added: 0, modified: 0, deleted: 0, unchanged: 7 },
|
||||
{ type: 'work_unit_started', unitKey: 'revenue-policy', skills: ['sl_expert'], stepBudget: 40 },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(written).toEqual([
|
||||
'[connect] Connected live-database - 7 database files (manual_resync)\n',
|
||||
'[diff] Tables: =7 unchanged\n',
|
||||
'[analyze] Reviewing "revenue-policy" - budget 40 agent steps\n',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips suppressed events without advancing visible output', () => {
|
||||
const written: string[] = [];
|
||||
const io = {
|
||||
stdout: { write: (chunk: string) => written.push(chunk), isTTY: false },
|
||||
stderr: { write: () => undefined },
|
||||
};
|
||||
const emit = createPlainProgressEmitter(io);
|
||||
|
||||
emit(
|
||||
snapshot([
|
||||
{ type: 'work_unit_step', unitKey: 'a', stepIndex: 1, stepBudget: 40 },
|
||||
{ type: 'work_unit_step', unitKey: 'a', stepIndex: 2, stepBudget: 40 },
|
||||
{ type: 'work_unit_finished', unitKey: 'a', status: 'success' },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(written).toEqual(['[done] a reviewed\n']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import type { MemoryFlowEvent, MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
|
||||
import type { KtxDemoIo } from './demo.js';
|
||||
|
||||
function plural(n: number, one: string, many = `${one}s`): string {
|
||||
return `${n} ${n === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
function formatDiff(added: number, modified: number, deleted: number, unchanged: number): string {
|
||||
const parts: string[] = [];
|
||||
if (added > 0) parts.push(`+${added} new`);
|
||||
if (modified > 0) parts.push(`~${modified} changed`);
|
||||
if (deleted > 0) parts.push(`-${deleted} removed`);
|
||||
if (unchanged > 0) parts.push(`=${unchanged} unchanged`);
|
||||
return parts.length > 0 ? parts.join(', ') : 'no changes';
|
||||
}
|
||||
|
||||
export function formatMemoryFlowEventLine(event: MemoryFlowEvent): string | null {
|
||||
switch (event.type) {
|
||||
case 'source_acquired':
|
||||
return `[connect] Connected ${event.adapter} - ${plural(event.fileCount, 'database file')} (${event.trigger})`;
|
||||
case 'scope_detected':
|
||||
return event.fingerprint
|
||||
? `[scope] Scope locked: ${event.fingerprint}`
|
||||
: '[scope] Reviewing the whole warehouse (no scope filter)';
|
||||
case 'raw_snapshot_written':
|
||||
return `[snapshot] Captured snapshot ${event.syncId} - ${plural(event.rawFileCount, 'file')}`;
|
||||
case 'diff_computed':
|
||||
return `[diff] Tables: ${formatDiff(event.added, event.modified, event.deleted, event.unchanged)}`;
|
||||
case 'chunks_planned':
|
||||
return event.evictionCount > 0
|
||||
? `[plan] Grouped ${plural(event.workUnitCount, 'table')} into ${plural(event.chunkCount, 'business area')} (${plural(event.evictionCount, 'removal')})`
|
||||
: `[plan] Grouped ${plural(event.workUnitCount, 'table')} into ${plural(event.chunkCount, 'business area')}`;
|
||||
case 'stage_skipped':
|
||||
return `[skip] ${event.stage} skipped: ${event.reason}`;
|
||||
case 'work_unit_started':
|
||||
return `[analyze] Reviewing "${event.unitKey}" - budget ${plural(event.stepBudget, 'agent step')}`;
|
||||
case 'work_unit_step':
|
||||
return null;
|
||||
case 'candidate_action': {
|
||||
const target = event.target === 'sl' ? 'semantic-layer' : 'wiki';
|
||||
return `[draft] ${event.unitKey} -> ${target}: ${event.action} ${event.key}`;
|
||||
}
|
||||
case 'work_unit_finished':
|
||||
if (event.status === 'success') {
|
||||
return `[done] ${event.unitKey} reviewed`;
|
||||
}
|
||||
return `[fail] ${event.unitKey} needs attention${event.reason ? ` - ${event.reason}` : ''}`;
|
||||
case 'reconciliation_finished': {
|
||||
const conflicts = event.conflictCount === 0 ? 'no conflicts' : plural(event.conflictCount, 'conflict');
|
||||
const fallbacks = event.fallbackCount === 0 ? 'nothing flagged for review' : `${plural(event.fallbackCount, 'item')} flagged for review`;
|
||||
return `[validate] Reconciled drafts - ${conflicts}, ${fallbacks}`;
|
||||
}
|
||||
case 'saved': {
|
||||
const total = event.wikiCount + event.slCount;
|
||||
const commit = event.commitSha ? ` - commit ${event.commitSha.slice(0, 7)}` : '';
|
||||
return `[memory] Saved ${plural(total, 'memory', 'memories')} (${event.wikiCount} wiki, ${event.slCount} semantic-layer)${commit}`;
|
||||
}
|
||||
case 'provenance_recorded':
|
||||
return `[trace] Recorded provenance for ${plural(event.rowCount, 'row')}`;
|
||||
case 'report_created':
|
||||
return `[report] Run report ready: ${event.runId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlainProgressEmitter(io: KtxDemoIo): (snapshot: MemoryFlowReplayInput) => void {
|
||||
let printed = 0;
|
||||
return (snapshot) => {
|
||||
while (printed < snapshot.events.length) {
|
||||
const event = snapshot.events[printed++];
|
||||
if (!event) continue;
|
||||
const line = formatMemoryFlowEventLine(event);
|
||||
if (line !== null) {
|
||||
io.stdout.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { mkdtemp, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { type MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEMO_LATEST_REPLAY_FILE, loadLatestDemoReplay, writeDemoReplay } from './demo-replay-store.js';
|
||||
|
||||
function replay(overrides: Partial<MemoryFlowReplayInput> = {}): MemoryFlowReplayInput {
|
||||
return {
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: 'captured',
|
||||
timing: 'captured',
|
||||
capturedAt: '2026-05-01T10:00:03.000Z',
|
||||
sourceReportId: 'report-1',
|
||||
sourceReportPath: 'report-1',
|
||||
fallbackReason: null,
|
||||
},
|
||||
runId: 'run-1',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
status: 'done',
|
||||
sourceDir: null,
|
||||
syncId: 'sync-1',
|
||||
reportId: 'report-1',
|
||||
reportPath: 'report-1',
|
||||
errors: [],
|
||||
events: [{ type: 'report_created', runId: 'run-1', reportPath: 'report-1', emittedAt: '2026-05-01T10:00:03.000Z' }],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('demo replay store', () => {
|
||||
it('writes a versioned replay file and updates latest', async () => {
|
||||
const projectDir = await mkdtemp(join(tmpdir(), 'ktx-demo-replay-store-'));
|
||||
|
||||
const saved = await writeDemoReplay(projectDir, replay(), { label: 'full' });
|
||||
|
||||
expect(saved.replayPath).toMatch(/replays[/\\]full-run-1.memory-flow.v1.json$/);
|
||||
expect(saved.latestReplayPath).toBe(join(projectDir, 'replays', DEMO_LATEST_REPLAY_FILE));
|
||||
expect(await loadLatestDemoReplay(projectDir)).toMatchObject({
|
||||
runId: 'run-1',
|
||||
metadata: { mode: 'full', origin: 'captured', timing: 'captured' },
|
||||
});
|
||||
|
||||
const wrapper = JSON.parse(await readFile(saved.latestReplayPath, 'utf-8')) as {
|
||||
memoryFlowReplaySchemaVersion?: number;
|
||||
};
|
||||
expect(wrapper.memoryFlowReplaySchemaVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null when no latest local replay exists', async () => {
|
||||
const projectDir = await mkdtemp(join(tmpdir(), 'ktx-demo-replay-store-empty-'));
|
||||
|
||||
await expect(loadLatestDemoReplay(projectDir)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { parseMemoryFlowReplayInput, type MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
|
||||
|
||||
interface StoredMemoryFlowReplayFile {
|
||||
memoryFlowReplaySchemaVersion: 1;
|
||||
replay: unknown;
|
||||
}
|
||||
|
||||
interface SavedDemoReplay {
|
||||
replayPath: string;
|
||||
latestReplayPath: string;
|
||||
}
|
||||
|
||||
export const DEMO_LATEST_REPLAY_FILE = 'latest.memory-flow.v1.json';
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function safeReplayName(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'replay';
|
||||
}
|
||||
|
||||
function demoReplayFileName(input: MemoryFlowReplayInput, label: string): string {
|
||||
return `${safeReplayName(label)}-${safeReplayName(input.runId)}.memory-flow.v1.json`;
|
||||
}
|
||||
|
||||
function wrapReplay(input: MemoryFlowReplayInput): StoredMemoryFlowReplayFile {
|
||||
return { memoryFlowReplaySchemaVersion: 1, replay: input };
|
||||
}
|
||||
|
||||
export async function loadDemoReplayFile(path: string): Promise<MemoryFlowReplayInput> {
|
||||
const parsed = JSON.parse(await readFile(path, 'utf-8')) as StoredMemoryFlowReplayFile;
|
||||
if (parsed.memoryFlowReplaySchemaVersion !== 1) {
|
||||
throw new Error(`Unsupported demo replay schema version in ${path}`);
|
||||
}
|
||||
return parseMemoryFlowReplayInput(parsed.replay);
|
||||
}
|
||||
|
||||
export async function loadLatestDemoReplay(projectDir: string): Promise<MemoryFlowReplayInput | null> {
|
||||
const latestPath = join(resolve(projectDir), 'replays', DEMO_LATEST_REPLAY_FILE);
|
||||
if (!(await exists(latestPath))) {
|
||||
return null;
|
||||
}
|
||||
return loadDemoReplayFile(latestPath);
|
||||
}
|
||||
|
||||
export async function writeDemoReplay(
|
||||
projectDir: string,
|
||||
input: MemoryFlowReplayInput,
|
||||
options: { label: 'full' | 'deterministic' | 'seeded' },
|
||||
): Promise<SavedDemoReplay> {
|
||||
const replayDir = join(resolve(projectDir), 'replays');
|
||||
await mkdir(replayDir, { recursive: true });
|
||||
const replayPath = join(replayDir, demoReplayFileName(input, options.label));
|
||||
const latestReplayPath = join(replayDir, DEMO_LATEST_REPLAY_FILE);
|
||||
const body = `${JSON.stringify(wrapReplay(input), null, 2)}\n`;
|
||||
await writeFile(replayPath, body, 'utf-8');
|
||||
await copyFile(replayPath, latestReplayPath);
|
||||
return { replayPath, latestReplayPath };
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { findLatestDemoScanReport, runDemoScan } from './demo-scan.js';
|
||||
|
||||
describe('demo scan helpers', () => {
|
||||
const projectDir = join(tmpdir(), `ktx-demo-scan-${process.pid}`);
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs the packaged SQLite demo scan and finds the latest scan report', async () => {
|
||||
const { result } = await runDemoScan({
|
||||
projectDir,
|
||||
jobId: 'demo-scan-test',
|
||||
now: () => new Date('2026-05-06T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result.report).toMatchObject({
|
||||
connectionId: 'orbit_demo',
|
||||
driver: 'sqlite',
|
||||
runId: 'demo-scan-test',
|
||||
mode: 'structural',
|
||||
dryRun: false,
|
||||
});
|
||||
expect(result.report.artifactPaths.reportPath).toContain('raw-sources/orbit_demo/live-database/');
|
||||
await expect(findLatestDemoScanReport(projectDir)).resolves.toMatchObject({ runId: 'demo-scan-test' });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
import { getLocalIngestStatus, type IngestReportSnapshot, type MemoryFlowReplayInput } from '@ktx/context/ingest';
|
||||
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||
import { runLocalScan, type KtxScanReport, type LocalScanRunResult } from '@ktx/context/scan';
|
||||
import { DEMO_ADAPTER, DEMO_CONNECTION_ID, DEMO_FULL_JOB_ID, ensureDemoProject } from './demo-assets.js';
|
||||
import { loadLatestDemoReplay } from './demo-replay-store.js';
|
||||
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
|
||||
|
||||
interface DemoScanOptions {
|
||||
projectDir: string;
|
||||
jobId?: string;
|
||||
now?: () => Date;
|
||||
runLocalScan?: typeof runLocalScan;
|
||||
}
|
||||
|
||||
interface DemoScanResult {
|
||||
project: KtxLocalProject;
|
||||
result: LocalScanRunResult;
|
||||
}
|
||||
|
||||
interface DemoInspectSummary {
|
||||
projectDir: string;
|
||||
scanReport: KtxScanReport | null;
|
||||
fullReport: IngestReportSnapshot | null;
|
||||
semanticLayerFileCount: number;
|
||||
knowledgeFileCount: number;
|
||||
replayFileCount: number;
|
||||
latestReplay: MemoryFlowReplayInput | null;
|
||||
}
|
||||
|
||||
interface DemoInspectDeps {
|
||||
findFullReport?: (project: KtxLocalProject) => Promise<IngestReportSnapshot | null>;
|
||||
}
|
||||
|
||||
async function ensureDemoProjectForReuse(projectDir: string): Promise<void> {
|
||||
await ensureDemoProject({ projectDir, force: false }).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes('Demo project already exists')) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadReadyDemoProject(projectDir: string): Promise<KtxLocalProject> {
|
||||
try {
|
||||
return await loadKtxProject({ projectDir });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Demo project is not ready at ${projectDir}: ${reason}. Run ktx setup demo init --project-dir ${projectDir} --force --no-input to recreate it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function reportDiff(report: KtxScanReport): string {
|
||||
return `+${report.diffSummary.tablesAdded}/~${report.diffSummary.tablesModified}/-${report.diffSummary.tablesDeleted}/=${report.diffSummary.tablesUnchanged}`;
|
||||
}
|
||||
|
||||
function jsonReport(raw: string, path: string): KtxScanReport {
|
||||
try {
|
||||
return JSON.parse(raw) as KtxScanReport;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid demo scan report at ${path}: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function countFiles(project: KtxLocalProject, root: string, predicate: (path: string) => boolean): Promise<number> {
|
||||
const { files } = await project.fileStore.listFiles(root, true);
|
||||
return files.filter(predicate).length;
|
||||
}
|
||||
|
||||
async function findFullDemoReport(project: KtxLocalProject): Promise<IngestReportSnapshot | null> {
|
||||
return getLocalIngestStatus(project, DEMO_FULL_JOB_ID);
|
||||
}
|
||||
|
||||
function savedCounts(report: IngestReportSnapshot): { wikiCount: number; slCount: number } {
|
||||
const actions = report.body.workUnits.flatMap((workUnit) => workUnit.actions);
|
||||
return {
|
||||
wikiCount: actions.filter((action) => action.target === 'wiki').length,
|
||||
slCount: actions.filter((action) => action.target === 'sl').length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDemoScan(options: DemoScanOptions): Promise<DemoScanResult> {
|
||||
await ensureDemoProjectForReuse(options.projectDir);
|
||||
const project = await loadReadyDemoProject(options.projectDir);
|
||||
const executeScan = options.runLocalScan ?? runLocalScan;
|
||||
const result = await executeScan({
|
||||
project,
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
mode: 'structural',
|
||||
trigger: 'cli',
|
||||
jobId: options.jobId ?? 'demo-scan',
|
||||
now: options.now,
|
||||
adapters: createKtxCliLocalIngestAdapters(project),
|
||||
});
|
||||
|
||||
return { project, result };
|
||||
}
|
||||
|
||||
export async function findLatestDemoScanReport(projectDir: string): Promise<KtxScanReport | null> {
|
||||
const project = await loadReadyDemoProject(projectDir);
|
||||
const root = `raw-sources/${DEMO_CONNECTION_ID}/${DEMO_ADAPTER}`;
|
||||
const { files } = await project.fileStore.listFiles(root, true);
|
||||
const latest = files
|
||||
.filter((path) => path.endsWith('/scan-report.json'))
|
||||
.sort()
|
||||
.at(-1);
|
||||
if (!latest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reportPath = `${root}/${latest}`;
|
||||
const report = await project.fileStore.readFile(reportPath);
|
||||
return jsonReport(report.content, reportPath);
|
||||
}
|
||||
|
||||
export async function inspectDemoProject(
|
||||
projectDir: string,
|
||||
projectOverride?: KtxLocalProject,
|
||||
deps: DemoInspectDeps = {},
|
||||
): Promise<DemoInspectSummary> {
|
||||
const project = projectOverride ?? (await loadReadyDemoProject(projectDir));
|
||||
const scanReport = await findLatestDemoScanReport(project.projectDir);
|
||||
const fullReport = await (deps.findFullReport ?? findFullDemoReport)(project);
|
||||
const semanticLayerFileCount = await countFiles(
|
||||
project,
|
||||
`semantic-layer/${DEMO_CONNECTION_ID}`,
|
||||
(path) => path.endsWith('.yaml') || path.endsWith('.yml'),
|
||||
);
|
||||
const knowledgeFileCount = await countFiles(project, 'knowledge', (path) => path.endsWith('.md'));
|
||||
const replayFileCount = await countFiles(project, 'replays', (path) => path.endsWith('.json'));
|
||||
const latestReplay = await loadLatestDemoReplay(project.projectDir);
|
||||
|
||||
return {
|
||||
projectDir: project.projectDir,
|
||||
scanReport,
|
||||
fullReport,
|
||||
semanticLayerFileCount,
|
||||
knowledgeFileCount,
|
||||
replayFileCount,
|
||||
latestReplay,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDemoScanSummary(report: KtxScanReport): string {
|
||||
return [
|
||||
'Demo scan: done',
|
||||
`Connection: ${report.connectionId}`,
|
||||
`Driver: ${report.driver}`,
|
||||
`Mode: ${report.mode}`,
|
||||
`Tables: ${reportDiff(report)}`,
|
||||
`Semantic-layer artifacts: ${report.artifactPaths.manifestShards.length}`,
|
||||
`Report: ${report.artifactPaths.reportPath ?? 'none'}`,
|
||||
'Next: ktx setup demo inspect',
|
||||
' Shows the files and semantic-layer draft created from the database scan.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function replayLine(replay: MemoryFlowReplayInput | null): string {
|
||||
if (!replay?.metadata) {
|
||||
return 'Latest replay: packaged demo replay';
|
||||
}
|
||||
return `Latest replay: ${replay.metadata.mode} (${replay.metadata.origin}, ${replay.metadata.timing})`;
|
||||
}
|
||||
|
||||
export function formatDemoInspect(summary: DemoInspectSummary): string {
|
||||
const report = summary.scanReport;
|
||||
const fullReport = summary.fullReport;
|
||||
const fullCounts = fullReport ? savedCounts(fullReport) : null;
|
||||
const scanLines = report
|
||||
? [
|
||||
'Scan artifacts: yes',
|
||||
`Connection: ${report.connectionId}`,
|
||||
`Driver: ${report.driver}`,
|
||||
`Tables: ${reportDiff(report)}`,
|
||||
`Report: ${report.artifactPaths.reportPath ?? 'none'}`,
|
||||
]
|
||||
: ['Scan artifacts: none'];
|
||||
|
||||
const memoryLines = fullReport
|
||||
? [
|
||||
'Memory synthesis: ran',
|
||||
`Full report: ${fullReport.id}`,
|
||||
`Full run: ${fullReport.runId}`,
|
||||
`Saved memory: ${fullCounts?.wikiCount ?? 0} wiki, ${fullCounts?.slCount ?? 0} semantic layer`,
|
||||
`Provenance rows: ${fullReport.body.provenanceRows.length}`,
|
||||
]
|
||||
: [report ? 'Memory synthesis: full mode not run' : 'Memory synthesis: not run'];
|
||||
const next = fullReport
|
||||
? [
|
||||
`Next: ktx ingest watch ${fullReport.runId} --project-dir ${summary.projectDir}`,
|
||||
' Opens the captured run timeline and lets you inspect what happened.',
|
||||
'Next: ktx setup demo replay',
|
||||
' Replays the same visual story without calling the LLM again.',
|
||||
]
|
||||
: report
|
||||
? [
|
||||
'Next: ktx setup demo --mode full',
|
||||
' Runs the full AI-backed pass with your LLM provider.',
|
||||
'Next: ktx setup demo replay',
|
||||
' Replays the packaged visual story without calling the LLM.',
|
||||
]
|
||||
: [
|
||||
'Next: ktx setup demo --no-input',
|
||||
' Runs the pre-seeded demo without calling the LLM.',
|
||||
'Next: ktx setup demo --mode full',
|
||||
' Runs the full AI-backed pass with your LLM provider.',
|
||||
];
|
||||
|
||||
return [
|
||||
`Demo project: ${summary.projectDir}`,
|
||||
...scanLines,
|
||||
`Semantic-layer files: ${summary.semanticLayerFileCount}`,
|
||||
`Knowledge files: ${summary.knowledgeFileCount}`,
|
||||
`Replay files: ${summary.replayFileCount}`,
|
||||
replayLine(summary.latestReplay),
|
||||
...memoryLines,
|
||||
...next,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { access, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { runDemoSeeded } from './demo-seeded.js';
|
||||
import { formatSeededInspect, inspectSeededProject } from './demo-seeded-inspect.js';
|
||||
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
|
||||
|
||||
describe('seeded demo inspect contract', () => {
|
||||
const projectDir = join(tmpdir(), `ktx-demo-seeded-inspect-${process.pid}`);
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports the PRD source inventory, generated outputs, status, metadata, and next commands', async () => {
|
||||
await runDemoSeeded({ projectDir });
|
||||
const inspect = await inspectSeededProject(projectDir);
|
||||
|
||||
expect(inspect).toMatchObject({
|
||||
projectDir,
|
||||
mode: 'seeded',
|
||||
status: { status: 'ready', missing: [] },
|
||||
modeMetadata: {
|
||||
mode: 'seeded',
|
||||
source: 'packaged demo project',
|
||||
generatedContext: 'prebuilt from bundled assets',
|
||||
llmCalls: 'none',
|
||||
origin: 'packaged',
|
||||
timing: 'prebuilt',
|
||||
sourceReportId: 'demo-seeded-report',
|
||||
sourceReportPath: 'reports/seeded-demo-report.json',
|
||||
},
|
||||
sourceBundle: {
|
||||
warehouse: {
|
||||
label: 'Warehouse',
|
||||
path: 'demo.db',
|
||||
tableCount: 8,
|
||||
totalRows: 11234,
|
||||
rowCounts: {
|
||||
accounts: 210,
|
||||
arr_movements: 720,
|
||||
contracts: 320,
|
||||
invoices: 3000,
|
||||
plans: 4,
|
||||
purchase_requests: 5200,
|
||||
support_tickets: 520,
|
||||
users: 1260,
|
||||
},
|
||||
},
|
||||
dbt: { label: 'dbt', path: 'raw-sources/dbt', modelCount: 3, sourceTableCount: 8 },
|
||||
bi: { label: 'BI', path: 'raw-sources/bi', exploreCount: 5, dashboardCount: 2 },
|
||||
notion: { label: 'Notion', path: 'raw-sources/notion', pageCount: 8 },
|
||||
},
|
||||
generatedOutputs: {
|
||||
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' },
|
||||
},
|
||||
nextCommands: KTX_NEXT_STEP_DIRECT_COMMANDS,
|
||||
});
|
||||
|
||||
expect(inspect.generatedOutputs.replays.fileCount).toBeGreaterThanOrEqual(3);
|
||||
await expect(access(join(projectDir, inspect.generatedOutputs.reports.primaryPath))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, inspect.generatedOutputs.replays.primaryPath))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, inspect.generatedOutputs.replays.latestPath))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('formats seeded inspect from the normalized contract', async () => {
|
||||
await runDemoSeeded({ projectDir });
|
||||
const output = formatSeededInspect(await inspectSeededProject(projectDir));
|
||||
|
||||
expect(output).toContain(`Demo project: ${projectDir}`);
|
||||
expect(output).toContain('Status: ready');
|
||||
expect(output).toContain('Mode: seeded (pre-seeded demo project)');
|
||||
expect(output).toContain('Source: packaged demo project');
|
||||
expect(output).toContain('Generated context: prebuilt from bundled assets');
|
||||
expect(output).toContain('LLM calls: none');
|
||||
expect(output).toContain('Warehouse: 8 tables, 11,234 rows');
|
||||
expect(output).toContain('Rows: accounts 210, arr_movements 720, contracts 320, invoices 3000');
|
||||
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: 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');
|
||||
expect(output).toContain('Latest replay: seeded (packaged, prebuilt)');
|
||||
expect(output).toContain(' $ ktx agent tools --json');
|
||||
expect(output).toContain(' $ ktx agent context --json');
|
||||
expect(output).not.toContain('ktx serve --mcp stdio --user-id local');
|
||||
expect(output).not.toContain('ktx ask');
|
||||
expect(output).not.toContain('deterministic mode');
|
||||
});
|
||||
|
||||
it('reports missing seeded paths without reading stale counts as ready', async () => {
|
||||
await runDemoSeeded({ projectDir });
|
||||
await rm(join(projectDir, 'links', 'provenance.json'));
|
||||
|
||||
const inspect = await inspectSeededProject(projectDir);
|
||||
|
||||
expect(inspect.status).toEqual({ status: 'corrupt', missing: ['links/provenance.json'] });
|
||||
expect(formatSeededInspect(inspect)).toContain('Status: corrupt');
|
||||
expect(formatSeededInspect(inspect)).toContain('Missing: links/provenance.json');
|
||||
});
|
||||
|
||||
it('keeps provenance link counts tied to the project file', async () => {
|
||||
await runDemoSeeded({ projectDir });
|
||||
|
||||
const inspect = await inspectSeededProject(projectDir);
|
||||
const raw = await readFile(join(projectDir, 'links', 'provenance.json'), 'utf-8');
|
||||
const links = JSON.parse(raw) as unknown[];
|
||||
|
||||
expect(inspect.generatedOutputs.links.linkCount).toBe(links.length);
|
||||
expect(inspect.generatedOutputs.links.linkCount).toBe(23);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access, readFile, readdir } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
|
||||
import { loadPackagedDemoReplay } from './demo-assets.js';
|
||||
import { DEMO_LATEST_REPLAY_FILE, loadLatestDemoReplay } from './demo-replay-store.js';
|
||||
import { KTX_NEXT_STEP_COMMAND_WIDTH, KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
|
||||
|
||||
type SeededInspectReadiness = 'missing' | 'ready' | 'corrupt';
|
||||
|
||||
export interface DemoSeededManifest {
|
||||
demoAssetSchemaVersion: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
mode: string;
|
||||
source?: string;
|
||||
sources: {
|
||||
warehouse: { label: string; path?: string; tables: number; rowCounts: Record<string, number> };
|
||||
dbt: { label: string; path?: string; models: number; sourceTables: number };
|
||||
bi: { label: string; path?: string; explores: number; dashboards: number };
|
||||
notion: { label: string; path?: string; pages: number };
|
||||
};
|
||||
generated: {
|
||||
semanticLayer: { path?: string; sourceCount: number };
|
||||
knowledge: { path?: string; pageCount: number };
|
||||
links: { path?: string; linkCount: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface SeededInspectSummary {
|
||||
projectDir: string;
|
||||
mode: 'seeded';
|
||||
manifest: DemoSeededManifest;
|
||||
status: { status: SeededInspectReadiness; missing: string[] };
|
||||
sourceBundle: {
|
||||
warehouse: {
|
||||
label: string;
|
||||
path: string;
|
||||
tableCount: number;
|
||||
rowCounts: Record<string, number>;
|
||||
totalRows: number;
|
||||
};
|
||||
dbt: { label: string; path: string; modelCount: number; sourceTableCount: number };
|
||||
bi: { label: string; path: string; exploreCount: number; dashboardCount: number };
|
||||
notion: { label: string; path: string; pageCount: number };
|
||||
};
|
||||
generatedOutputs: {
|
||||
semanticLayer: { path: string; manifestSourceCount: number; fileCount: number };
|
||||
knowledge: { path: string; manifestPageCount: number; fileCount: number };
|
||||
links: { path: string; manifestLinkCount: number; linkCount: number };
|
||||
reports: { primaryPath: string; fileCount: number };
|
||||
replays: { primaryPath: string; latestPath: string; fileCount: number };
|
||||
};
|
||||
modeMetadata: {
|
||||
mode: 'seeded';
|
||||
source: 'packaged demo project';
|
||||
generatedContext: 'prebuilt from bundled assets';
|
||||
llmCalls: 'none';
|
||||
origin: string;
|
||||
timing: string;
|
||||
sourceReportId: string | null;
|
||||
sourceReportPath: string | null;
|
||||
};
|
||||
nextCommands: Array<{ command: string; description: string }>;
|
||||
latestReplay: MemoryFlowReplayInput | null;
|
||||
}
|
||||
|
||||
const REQUIRED_SEEDED_PROJECT_PATHS = [
|
||||
'ktx.yaml',
|
||||
'demo.db',
|
||||
'state.sqlite',
|
||||
'manifest.json',
|
||||
join('replays', 'replay.memory-flow.v1.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'),
|
||||
join('links', 'provenance.json'),
|
||||
join('reports', 'seeded-demo-report.json'),
|
||||
] as const;
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSeededManifest(projectDir: string): Promise<DemoSeededManifest> {
|
||||
const raw = await readFile(join(projectDir, 'manifest.json'), 'utf-8');
|
||||
return JSON.parse(raw) as DemoSeededManifest;
|
||||
}
|
||||
|
||||
async function listFilesInDir(dir: string, ext?: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(dir, { recursive: true });
|
||||
return entries
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.filter((entry) => !ext || entry.endsWith(ext))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectSeededProjectStatus(projectDir: string): Promise<{ status: SeededInspectReadiness; missing: string[] }> {
|
||||
const missing: string[] = [];
|
||||
for (const relativePath of REQUIRED_SEEDED_PROJECT_PATHS) {
|
||||
if (!(await exists(join(projectDir, relativePath)))) {
|
||||
missing.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === REQUIRED_SEEDED_PROJECT_PATHS.length) {
|
||||
return { status: 'missing', missing };
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
return { status: 'corrupt', missing };
|
||||
}
|
||||
return { status: 'ready', missing: [] };
|
||||
}
|
||||
|
||||
async function loadLinksCount(projectDir: string): Promise<number> {
|
||||
try {
|
||||
const raw = await readFile(join(projectDir, 'links', 'provenance.json'), 'utf-8');
|
||||
const links = JSON.parse(raw) as unknown[];
|
||||
return links.length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSeededReplay(projectDir: string): Promise<MemoryFlowReplayInput | null> {
|
||||
const latest = await loadLatestDemoReplay(projectDir);
|
||||
if (latest) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
try {
|
||||
return await loadPackagedDemoReplay();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceBundleFromManifest(manifest: DemoSeededManifest): SeededInspectSummary['sourceBundle'] {
|
||||
const warehouse = manifest.sources.warehouse;
|
||||
const rowCounts = Object.fromEntries(Object.entries(warehouse.rowCounts).sort(([a], [b]) => a.localeCompare(b)));
|
||||
const totalRows = Object.values(rowCounts).reduce((total, count) => total + count, 0);
|
||||
|
||||
return {
|
||||
warehouse: {
|
||||
label: warehouse.label,
|
||||
path: warehouse.path ?? 'demo.db',
|
||||
tableCount: warehouse.tables,
|
||||
rowCounts,
|
||||
totalRows,
|
||||
},
|
||||
dbt: {
|
||||
label: manifest.sources.dbt.label,
|
||||
path: manifest.sources.dbt.path ?? 'raw-sources/dbt',
|
||||
modelCount: manifest.sources.dbt.models,
|
||||
sourceTableCount: manifest.sources.dbt.sourceTables,
|
||||
},
|
||||
bi: {
|
||||
label: manifest.sources.bi.label,
|
||||
path: manifest.sources.bi.path ?? 'raw-sources/bi',
|
||||
exploreCount: manifest.sources.bi.explores,
|
||||
dashboardCount: manifest.sources.bi.dashboards,
|
||||
},
|
||||
notion: {
|
||||
label: manifest.sources.notion.label,
|
||||
path: manifest.sources.notion.path ?? 'raw-sources/notion',
|
||||
pageCount: manifest.sources.notion.pages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nextCommands(): SeededInspectSummary['nextCommands'] {
|
||||
return [...KTX_NEXT_STEP_DIRECT_COMMANDS];
|
||||
}
|
||||
|
||||
function modeMetadataFromReplay(replay: MemoryFlowReplayInput | null): SeededInspectSummary['modeMetadata'] {
|
||||
return {
|
||||
mode: 'seeded',
|
||||
source: 'packaged demo project',
|
||||
generatedContext: 'prebuilt from bundled assets',
|
||||
llmCalls: 'none',
|
||||
origin: replay?.metadata?.origin ?? 'packaged',
|
||||
timing: replay?.metadata?.timing ?? 'prebuilt',
|
||||
sourceReportId: replay?.metadata?.sourceReportId ?? 'demo-seeded-report',
|
||||
sourceReportPath: replay?.metadata?.sourceReportPath ?? 'reports/seeded-demo-report.json',
|
||||
};
|
||||
}
|
||||
|
||||
export async function inspectSeededProject(projectDir: string): Promise<SeededInspectSummary> {
|
||||
const root = resolve(projectDir);
|
||||
const manifest = await loadSeededManifest(root);
|
||||
const latestReplay = await loadSeededReplay(root);
|
||||
const semanticLayerPath = manifest.generated.semanticLayer.path ?? 'semantic-layer/orbit_demo';
|
||||
const knowledgePath = manifest.generated.knowledge.path ?? 'knowledge/global';
|
||||
const linksPath = join(manifest.generated.links.path ?? 'links', 'provenance.json');
|
||||
const reportFiles = await listFilesInDir(join(root, 'reports'), '.json');
|
||||
const replayFiles = await listFilesInDir(join(root, 'replays'), '.json');
|
||||
|
||||
return {
|
||||
projectDir: root,
|
||||
mode: 'seeded',
|
||||
manifest,
|
||||
status: await inspectSeededProjectStatus(root),
|
||||
sourceBundle: sourceBundleFromManifest(manifest),
|
||||
generatedOutputs: {
|
||||
semanticLayer: {
|
||||
path: semanticLayerPath,
|
||||
manifestSourceCount: manifest.generated.semanticLayer.sourceCount,
|
||||
fileCount: (await listFilesInDir(join(root, semanticLayerPath), '.yaml')).length,
|
||||
},
|
||||
knowledge: {
|
||||
path: knowledgePath,
|
||||
manifestPageCount: manifest.generated.knowledge.pageCount,
|
||||
fileCount: (await listFilesInDir(join(root, knowledgePath), '.md')).length,
|
||||
},
|
||||
links: {
|
||||
path: linksPath,
|
||||
manifestLinkCount: manifest.generated.links.linkCount,
|
||||
linkCount: await loadLinksCount(root),
|
||||
},
|
||||
reports: {
|
||||
primaryPath: reportFiles[0] ? join('reports', reportFiles[0]) : 'reports/seeded-demo-report.json',
|
||||
fileCount: reportFiles.length,
|
||||
},
|
||||
replays: {
|
||||
primaryPath: join('replays', 'replay.memory-flow.v1.json'),
|
||||
latestPath: join('replays', DEMO_LATEST_REPLAY_FILE),
|
||||
fileCount: replayFiles.length,
|
||||
},
|
||||
},
|
||||
modeMetadata: modeMetadataFromReplay(latestReplay),
|
||||
nextCommands: nextCommands(),
|
||||
latestReplay,
|
||||
};
|
||||
}
|
||||
|
||||
function rowCountPreview(rowCounts: Record<string, number>): string {
|
||||
return Object.entries(rowCounts)
|
||||
.map(([name, count]) => `${name} ${count}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function replayLine(summary: SeededInspectSummary): string {
|
||||
const metadata = summary.latestReplay?.metadata ?? summary.modeMetadata;
|
||||
return `Latest replay: ${metadata.mode} (${metadata.origin}, ${metadata.timing})`;
|
||||
}
|
||||
|
||||
export function formatSeededInspect(summary: SeededInspectSummary): string {
|
||||
const source = summary.sourceBundle;
|
||||
const generated = summary.generatedOutputs;
|
||||
const lines = [`Demo project: ${summary.projectDir}`, `Status: ${summary.status.status}`];
|
||||
|
||||
if (summary.status.missing.length > 0) {
|
||||
lines.push(`Missing: ${summary.status.missing.join(', ')}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`Mode: seeded (pre-seeded demo project)`,
|
||||
`Source: ${summary.modeMetadata.source}`,
|
||||
`Generated context: ${summary.modeMetadata.generatedContext}`,
|
||||
`LLM calls: ${summary.modeMetadata.llmCalls}`,
|
||||
'',
|
||||
'Source bundle:',
|
||||
` Warehouse: ${source.warehouse.tableCount} tables, ${source.warehouse.totalRows.toLocaleString()} rows`,
|
||||
` Rows: ${rowCountPreview(source.warehouse.rowCounts)}`,
|
||||
` dbt: ${source.dbt.modelCount} models, ${source.dbt.sourceTableCount} source tables`,
|
||||
` BI: ${source.bi.exploreCount} explores, ${source.bi.dashboardCount} dashboards`,
|
||||
` Notion: ${source.notion.pageCount} pages`,
|
||||
'',
|
||||
'Generated context:',
|
||||
` Semantic-layer sources: ${generated.semanticLayer.manifestSourceCount} manifest, ${generated.semanticLayer.fileCount} files`,
|
||||
` Knowledge pages: ${generated.knowledge.manifestPageCount} manifest, ${generated.knowledge.fileCount} files`,
|
||||
` Evidence links: ${generated.links.manifestLinkCount} manifest, ${generated.links.linkCount} links`,
|
||||
'',
|
||||
`Report: ${generated.reports.primaryPath}`,
|
||||
`Replay: ${generated.replays.primaryPath}`,
|
||||
replayLine(summary),
|
||||
'',
|
||||
'What to do next:',
|
||||
);
|
||||
|
||||
for (const command of summary.nextCommands) {
|
||||
lines.push(` $ ${command.command.padEnd(KTX_NEXT_STEP_COMMAND_WIDTH)} ${command.description}`);
|
||||
}
|
||||
|
||||
lines.push('', `Your KTX project files are at: ${summary.projectDir}`, '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import { access, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { ensureSeededDemoProject } from './demo-assets.js';
|
||||
import { runDemoSeeded } from './demo-seeded.js';
|
||||
|
||||
describe('demo seeded mode', () => {
|
||||
const projectDir = join(tmpdir(), `ktx-demo-seeded-${process.pid}`);
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('hydrates a complete seeded project with all asset directories', async () => {
|
||||
const result = await ensureSeededDemoProject({ projectDir, force: false });
|
||||
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
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/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();
|
||||
});
|
||||
|
||||
it('does not load or call any LLM provider in seeded mode', async () => {
|
||||
const result = await runDemoSeeded({ projectDir });
|
||||
|
||||
expect(result.replay.metadata?.mode).toBe('seeded');
|
||||
expect(result.replay.metadata?.timing).toBe('prebuilt');
|
||||
expect(result.inspect.mode).toBe('seeded');
|
||||
|
||||
const config = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
|
||||
expect(config).toContain('api_key: env:ANTHROPIC_API_KEY');
|
||||
expect(config).not.toContain('sk-ant-');
|
||||
});
|
||||
|
||||
it('creates the project under /tmp by default', async () => {
|
||||
const result = await runDemoSeeded({ projectDir });
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
});
|
||||
|
||||
it('replay metadata identifies mode honestly', async () => {
|
||||
const result = await runDemoSeeded({ projectDir });
|
||||
|
||||
expect(result.replay.metadata).toMatchObject({
|
||||
mode: 'seeded',
|
||||
origin: 'packaged',
|
||||
timing: 'prebuilt',
|
||||
});
|
||||
expect(result.replay.runId).toBe('demo-seeded-orbit');
|
||||
});
|
||||
|
||||
it('packaged seeded replay is honest and shows every source family', async () => {
|
||||
const result = await runDemoSeeded({ projectDir });
|
||||
const sourceEvents = result.replay.events.filter((event) => event.type === 'source_acquired');
|
||||
const adapters = sourceEvents.map((event) => event.adapter).sort();
|
||||
|
||||
expect(result.replay.metadata).toMatchObject({
|
||||
mode: 'seeded',
|
||||
origin: 'packaged',
|
||||
timing: 'prebuilt',
|
||||
sourceReportPath: 'reports/seeded-demo-report.json',
|
||||
});
|
||||
expect(adapters).toEqual(['dbt_descriptions', 'live-database', 'looker', 'notion']);
|
||||
expect(result.replay.events).not.toContainEqual(
|
||||
expect.objectContaining({ type: 'stage_skipped', reason: expect.stringContaining('deterministic') }),
|
||||
);
|
||||
expect(JSON.stringify(result.replay)).not.toContain('LLM ran');
|
||||
});
|
||||
|
||||
it('seeded animation shows all demo source families', async () => {
|
||||
const result = await runDemoSeeded({ projectDir });
|
||||
const adapters = result.replay.events
|
||||
.filter((e) => e.type === 'source_acquired')
|
||||
.map((e) => (e as { adapter: string }).adapter);
|
||||
|
||||
expect(adapters).toContain('live-database');
|
||||
expect(adapters).toContain('dbt_descriptions');
|
||||
expect(adapters).toContain('looker');
|
||||
expect(adapters).toContain('notion');
|
||||
});
|
||||
|
||||
it('SL YAML validates correctly', async () => {
|
||||
await ensureSeededDemoProject({ projectDir, force: false });
|
||||
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:');
|
||||
expect(slYaml).toContain('joins:');
|
||||
});
|
||||
|
||||
it('wiki pages have valid frontmatter', async () => {
|
||||
await ensureSeededDemoProject({ projectDir, force: false });
|
||||
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('refs:');
|
||||
expect(wiki).toContain('usage_mode: auto');
|
||||
});
|
||||
|
||||
it('links are searchable through provenance file', async () => {
|
||||
await ensureSeededDemoProject({ projectDir, force: false });
|
||||
const raw = await readFile(join(projectDir, 'links/provenance.json'), 'utf-8');
|
||||
const links = JSON.parse(raw) as Array<{ id: string; artifactKind: string }>;
|
||||
expect(links.length).toBe(23);
|
||||
expect(links.some((l) => l.artifactKind === 'wiki')).toBe(true);
|
||||
expect(links.some((l) => l.artifactKind === 'sl')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import type { MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
|
||||
import {
|
||||
ensureSeededDemoProject,
|
||||
loadPackagedDemoReplay,
|
||||
} from './demo-assets.js';
|
||||
import { writeDemoReplay } from './demo-replay-store.js';
|
||||
import { inspectSeededProject, type SeededInspectSummary } from './demo-seeded-inspect.js';
|
||||
|
||||
export {
|
||||
formatSeededInspect,
|
||||
inspectSeededProject,
|
||||
type DemoSeededManifest,
|
||||
type SeededInspectSummary,
|
||||
} from './demo-seeded-inspect.js';
|
||||
|
||||
export interface DemoSeededResult {
|
||||
projectDir: string;
|
||||
replay: MemoryFlowReplayInput;
|
||||
inspect: SeededInspectSummary;
|
||||
}
|
||||
|
||||
export async function runDemoSeeded(options: {
|
||||
projectDir: string;
|
||||
}): Promise<DemoSeededResult> {
|
||||
const result = await ensureSeededDemoProject({ projectDir: options.projectDir, force: false });
|
||||
|
||||
const replay = await loadPackagedDemoReplay();
|
||||
const replayWithDir: MemoryFlowReplayInput = {
|
||||
...replay,
|
||||
sourceDir: result.projectDir,
|
||||
};
|
||||
|
||||
await writeDemoReplay(result.projectDir, replayWithDir, { label: 'seeded' });
|
||||
const inspect = await inspectSeededProject(result.projectDir);
|
||||
|
||||
return {
|
||||
projectDir: result.projectDir,
|
||||
replay: replayWithDir,
|
||||
inspect,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,766 +0,0 @@
|
|||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { IngestReportSnapshot, MemoryFlowReplayInput } from '@ktx/context/ingest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKtxDemo } from './demo.js';
|
||||
import { DEMO_FULL_JOB_ID, defaultDemoProjectDir, ensureDemoProject } from './demo-assets.js';
|
||||
import type { DemoFullResult } from './demo-full.js';
|
||||
import { createTestDemoPromptAdapter } from './demo-interaction.js';
|
||||
import type { renderMemoryFlowTui } from './memory-flow-tui.js';
|
||||
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
|
||||
import { resetVizFallbackWarningsForTest } from './viz-fallback.js';
|
||||
|
||||
const SEEDED_DEMO_SEMANTIC_SOURCE_COUNT = 46;
|
||||
const SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT = 28;
|
||||
|
||||
function makeIo(options: { isTTY?: boolean; columns?: number; rawMode?: boolean } = {}) {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdin: {
|
||||
isTTY: options.isTTY ?? false,
|
||||
...(options.rawMode === false ? {} : { setRawMode: vi.fn() }),
|
||||
},
|
||||
stdout: {
|
||||
isTTY: options.isTTY ?? false,
|
||||
columns: options.columns ?? 140,
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeFullResult(projectDir: string): DemoFullResult {
|
||||
const report: IngestReportSnapshot = {
|
||||
id: 'report-full',
|
||||
runId: 'run-full',
|
||||
jobId: DEMO_FULL_JOB_ID,
|
||||
connectionId: 'orbit_demo',
|
||||
sourceKey: 'live-database',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
body: {
|
||||
syncId: 'sync-full',
|
||||
diffSummary: { added: 7, modified: 0, deleted: 0, unchanged: 0 },
|
||||
commitSha: null,
|
||||
workUnits: [
|
||||
{
|
||||
unitKey: 'accounts',
|
||||
rawFiles: ['accounts.schema.json'],
|
||||
status: 'success',
|
||||
actions: [
|
||||
{ target: 'wiki', type: 'created', key: 'knowledge/accounts.md', detail: 'account lifecycle context' },
|
||||
{ target: 'sl', type: 'created', key: 'orbit_demo.accounts', detail: 'accounts semantic source' },
|
||||
],
|
||||
touchedSlSources: [{ connectionId: 'orbit_demo', sourceName: 'orbit_demo.accounts' }],
|
||||
},
|
||||
],
|
||||
failedWorkUnits: [],
|
||||
reconciliationSkipped: false,
|
||||
conflictsResolved: [],
|
||||
evictionsApplied: [],
|
||||
unmappedFallbacks: [],
|
||||
evictionInputs: [],
|
||||
unresolvedCards: [],
|
||||
supersededBy: null,
|
||||
overrideOf: null,
|
||||
provenanceRows: [
|
||||
{
|
||||
rawPath: 'accounts.schema.json',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/accounts.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
],
|
||||
toolTranscripts: [],
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
project: { projectDir } as never,
|
||||
scan: { report: { runId: 'scan-run' } } as never,
|
||||
ingest: { result: { ok: true }, report } as never,
|
||||
report,
|
||||
replay: {
|
||||
runId: 'run-full',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
status: 'done',
|
||||
sourceDir: `${projectDir}/raw-sources/orbit_demo/live-database/sync-full`,
|
||||
syncId: 'sync-full',
|
||||
errors: [],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_full', fileCount: 7 },
|
||||
{ type: 'saved', commitSha: null, wikiCount: 1, slCount: 1 },
|
||||
{ type: 'provenance_recorded', rowCount: 1 },
|
||||
{ type: 'report_created', runId: 'run-full', reportPath: 'report-full' },
|
||||
],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKtxDemo', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetVizFallbackWarningsForTest();
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-demo-command-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('initializes the demo project', async () => {
|
||||
const io = makeIo();
|
||||
await expect(
|
||||
runKtxDemo({ command: 'init', projectDir: tempDir, force: false, inputMode: 'disabled' }, io.io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain(`Demo project: ${tempDir}`);
|
||||
expect(io.stdout()).toContain('Config:');
|
||||
expect(io.stdout()).toContain('Replay:');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('renders the packaged replay in no-input viz mode', async () => {
|
||||
const io = makeIo({ isTTY: true });
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'replay', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' },
|
||||
io.io,
|
||||
{ env: { ...process.env, TERM: 'xterm-256color' } },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('KTX memory flow Warehouse + dbt + BI + Docs done');
|
||||
expect(io.stdout()).toContain('Saved 16 memories');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('routes interactive packaged replay viz through the stored TUI renderer', async () => {
|
||||
const io = makeIo({ isTTY: true });
|
||||
const renderStoredMemoryFlow = vi.fn<typeof renderMemoryFlowTui>(async () => true);
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'replay', projectDir: tempDir, outputMode: 'viz' },
|
||||
io.io,
|
||||
{ env: { ...process.env, TERM: 'xterm-256color' }, renderStoredMemoryFlow },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(renderStoredMemoryFlow).toHaveBeenCalledTimes(1);
|
||||
expect(renderStoredMemoryFlow.mock.calls[0]?.[0]).toMatchObject({
|
||||
runId: 'demo-seeded-orbit',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
});
|
||||
expect(renderStoredMemoryFlow.mock.calls[0]?.[2]).toEqual({ speedMultiplier: 0.125 });
|
||||
expect(io.stdout()).toContain('KTX finished ingesting your data');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('routes interactive seeded demo viz through the stored TUI renderer at eighth speed', async () => {
|
||||
const io = makeIo({ isTTY: true });
|
||||
const renderStoredMemoryFlow = vi.fn<typeof renderMemoryFlowTui>(async () => true);
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'seeded', projectDir: tempDir, outputMode: 'viz' },
|
||||
io.io,
|
||||
{ env: { ...process.env, TERM: 'xterm-256color' }, renderStoredMemoryFlow },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(renderStoredMemoryFlow).toHaveBeenCalledTimes(1);
|
||||
expect(renderStoredMemoryFlow.mock.calls[0]?.[2]).toEqual({ speedMultiplier: 0.125 });
|
||||
expect(io.stdout()).toContain('KTX finished ingesting your data');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to plain replay output when interactive replay viz lacks stdin raw mode', async () => {
|
||||
const io = makeIo({ isTTY: true, rawMode: false });
|
||||
const renderStoredMemoryFlow = vi.fn(async (_input: MemoryFlowReplayInput, _io: unknown) => true);
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'replay', projectDir: tempDir, outputMode: 'viz' },
|
||||
io.io,
|
||||
{ env: { ...process.env, TERM: 'xterm-256color' }, renderStoredMemoryFlow },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(renderStoredMemoryFlow).not.toHaveBeenCalled();
|
||||
expect(io.stdout()).toContain('Memory-flow summary: done');
|
||||
expect(io.stdout()).toContain('Connection: orbit_demo');
|
||||
expect(io.stdout()).toContain('ktx sl list');
|
||||
expect(io.stdout()).toContain('ktx wiki list');
|
||||
expect(io.stdout()).not.toContain('ktx serve --mcp stdio --user-id local');
|
||||
expect(io.stdout()).not.toContain('KTX memory flow');
|
||||
expect(io.stderr()).toContain(
|
||||
'Visualization requested but stdin raw mode is unavailable; printing plain output.',
|
||||
);
|
||||
});
|
||||
|
||||
it('degrades default visual demo replay to a plain memory-flow summary when stdout is redirected', async () => {
|
||||
const testIo = makeIo({ isTTY: false });
|
||||
|
||||
await expect(
|
||||
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' }, testIo.io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Memory-flow summary: done');
|
||||
expect(testIo.stdout()).toContain('Connection: orbit_demo');
|
||||
expect(testIo.stdout()).toContain('ktx sl list');
|
||||
expect(testIo.stdout()).toContain('ktx wiki list');
|
||||
expect(testIo.stdout()).not.toContain('ktx serve --mcp stdio --user-id local');
|
||||
expect(testIo.stdout()).not.toContain('KTX memory flow');
|
||||
expect(testIo.stderr()).toContain(
|
||||
'Visualization requested but stdout is not an interactive terminal; printing plain output.',
|
||||
);
|
||||
});
|
||||
|
||||
it('prints JSON replay output when requested', async () => {
|
||||
const io = makeIo();
|
||||
await expect(
|
||||
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, io.io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({ runId: 'demo-seeded-orbit', connectionId: 'orbit_demo' });
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('runs the packaged SQLite demo scan', async () => {
|
||||
const io = makeIo();
|
||||
await expect(runKtxDemo({ command: 'scan', projectDir: tempDir, inputMode: 'disabled' }, io.io)).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Demo scan: done');
|
||||
expect(io.stdout()).toContain('Connection: orbit_demo');
|
||||
expect(io.stdout()).toContain('Driver: sqlite');
|
||||
expect(io.stdout()).toContain('Report: raw-sources/orbit_demo/live-database/');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('runs seeded mode with pre-seeded assets and inspect summary', async () => {
|
||||
const io = makeIo({ isTTY: true });
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
||||
io.io,
|
||||
{ env: { ...process.env, TERM: 'xterm-256color' } },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Mode: seeded');
|
||||
expect(io.stdout()).toContain('LLM calls: none');
|
||||
expect(io.stdout()).toContain('Semantic-layer sources:');
|
||||
expect(io.stdout()).toContain('Knowledge pages:');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('uses seeded mode as the default demo and creates a temp project when no project-dir is supplied', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'seeded', projectDir: defaultDemoProjectDir(), outputMode: 'plain', inputMode: 'disabled' },
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Mode: seeded');
|
||||
expect(io.stdout()).toContain('Source: packaged demo project');
|
||||
expect(io.stdout()).toContain('Generated context: prebuilt from bundled assets');
|
||||
expect(io.stdout()).toContain('LLM calls: none');
|
||||
expect(io.stdout()).toContain('Your KTX project files are at:');
|
||||
expect(io.stdout()).toContain(join(tmpdir(), 'ktx-demo-'));
|
||||
expect(io.stdout()).not.toContain('ktx serve --mcp stdio');
|
||||
expect(io.stdout()).not.toContain(['ktx', 'mcp'].join(' '));
|
||||
expect(io.stdout()).not.toContain('deterministic');
|
||||
});
|
||||
|
||||
it('degrades default visual seeded demo to plain output when TERM is dumb', async () => {
|
||||
const testIo = makeIo({ isTTY: true, columns: 120 });
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'seeded', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' },
|
||||
testIo.io,
|
||||
{ env: { ...process.env, TERM: 'dumb' } },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Mode: seeded');
|
||||
expect(testIo.stdout()).toContain('LLM calls: none');
|
||||
expect(testIo.stderr()).toContain(
|
||||
'Visualization requested but TERM=dumb does not support the visual renderer; printing plain output.',
|
||||
);
|
||||
});
|
||||
|
||||
it('prints demo inspect as plain text and JSON', async () => {
|
||||
const seededIo = makeIo();
|
||||
await expect(
|
||||
runKtxDemo({ command: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, seededIo.io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const plainIo = makeIo();
|
||||
await expect(
|
||||
runKtxDemo({ command: 'inspect', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, plainIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(plainIo.stdout()).toContain('Mode: seeded');
|
||||
expect(plainIo.stdout()).toContain('Semantic-layer sources:');
|
||||
|
||||
const jsonIo = makeIo();
|
||||
await expect(
|
||||
runKtxDemo({ command: 'inspect', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, jsonIo.io),
|
||||
).resolves.toBe(0);
|
||||
const parsed = JSON.parse(jsonIo.stdout());
|
||||
expect(parsed).toMatchObject({
|
||||
projectDir: tempDir,
|
||||
mode: 'seeded',
|
||||
status: { status: 'ready', missing: [] },
|
||||
sourceBundle: {
|
||||
warehouse: { tableCount: 8, totalRows: 11234 },
|
||||
dbt: { modelCount: 3, sourceTableCount: 8 },
|
||||
bi: { exploreCount: 5, dashboardCount: 2 },
|
||||
notion: { pageCount: 8 },
|
||||
},
|
||||
generatedOutputs: {
|
||||
semanticLayer: {
|
||||
manifestSourceCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT,
|
||||
fileCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT,
|
||||
},
|
||||
knowledge: {
|
||||
manifestPageCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT,
|
||||
fileCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT,
|
||||
},
|
||||
links: { manifestLinkCount: 23, linkCount: 23 },
|
||||
reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 },
|
||||
},
|
||||
modeMetadata: {
|
||||
mode: 'seeded',
|
||||
source: 'packaged demo project',
|
||||
generatedContext: 'prebuilt from bundled assets',
|
||||
llmCalls: 'none',
|
||||
},
|
||||
nextCommands: KTX_NEXT_STEP_DIRECT_COMMANDS,
|
||||
});
|
||||
expect(parsed.generatedOutputs.replays.fileCount).toBeGreaterThanOrEqual(3);
|
||||
expect(jsonIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('routes top-level full mode and prints memory-flow plus final summary', async () => {
|
||||
const testIo = makeIo({ isTTY: true });
|
||||
const runFullDemo = vi.fn().mockResolvedValue(fakeFullResult(tempDir));
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
runKtxDemo({ command: 'full', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' }, testIo.io, {
|
||||
env: {},
|
||||
runFullDemo,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runFullDemo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectDir: tempDir,
|
||||
env: {},
|
||||
onMemoryFlowChange: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(testIo.stdout()).toContain('KTX memory flow orbit_demo/live-database done');
|
||||
expect(testIo.stdout()).toContain('Full demo ingest: done');
|
||||
expect(testIo.stdout()).toContain('Next: ktx setup demo inspect');
|
||||
expect(testIo.stdout()).toContain('Shows the files, semantic-layer sources, and memory KTX just produced.');
|
||||
});
|
||||
|
||||
it('streams live memory-flow snapshots for full demo viz and then prints final summary', async () => {
|
||||
const testIo = makeIo({ isTTY: true, columns: 120 });
|
||||
const liveSession = {
|
||||
update: vi.fn(),
|
||||
close: vi.fn(),
|
||||
isClosed: vi.fn(() => false),
|
||||
};
|
||||
const startLiveMemoryFlow = vi.fn(async (_input: MemoryFlowReplayInput, _io: unknown) => liveSession);
|
||||
const runFullDemo = vi.fn(
|
||||
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
|
||||
options.onMemoryFlowChange?.({
|
||||
...fakeFullResult(tempDir).replay,
|
||||
status: 'running',
|
||||
events: [{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_full', fileCount: 7 }],
|
||||
});
|
||||
return fakeFullResult(tempDir);
|
||||
},
|
||||
);
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
runKtxDemo({ command: 'full', projectDir: tempDir, outputMode: 'viz' }, testIo.io, {
|
||||
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['reuse'] }),
|
||||
runFullDemo,
|
||||
startLiveMemoryFlow,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(startLiveMemoryFlow).toHaveBeenCalledTimes(1);
|
||||
expect(liveSession.update).toHaveBeenCalledTimes(1);
|
||||
expect(liveSession.close).toHaveBeenCalledTimes(1);
|
||||
expect(testIo.stdout()).not.toContain('Memory-flow summary: done');
|
||||
expect(testIo.stdout()).toContain('KTX finished ingesting your data');
|
||||
expect(testIo.stdout()).toContain('ktx sl list');
|
||||
expect(testIo.stdout()).toContain('ktx wiki list');
|
||||
expect(testIo.stdout()).not.toContain('ktx serve --mcp stdio --user-id local');
|
||||
expect(testIo.stdout()).not.toContain(['ktx', 'ask'].join(' '));
|
||||
expect(testIo.stdout()).not.toContain(['ktx', 'mcp'].join(' '));
|
||||
});
|
||||
|
||||
it('uses plain progress for full demo viz when stdin raw mode is unavailable', async () => {
|
||||
const testIo = makeIo({ isTTY: true, rawMode: false, columns: 120 });
|
||||
const liveSession = {
|
||||
update: vi.fn(),
|
||||
close: vi.fn(),
|
||||
isClosed: vi.fn(() => false),
|
||||
};
|
||||
const startLiveMemoryFlow = vi.fn(async (_input: MemoryFlowReplayInput, _io: unknown) => liveSession);
|
||||
const runFullDemo = vi.fn(
|
||||
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
|
||||
options.onMemoryFlowChange?.({
|
||||
...fakeFullResult(tempDir).replay,
|
||||
status: 'running',
|
||||
events: [{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_full', fileCount: 7 }],
|
||||
});
|
||||
return fakeFullResult(tempDir);
|
||||
},
|
||||
);
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
runKtxDemo({ command: 'full', projectDir: tempDir, outputMode: 'viz' }, testIo.io, {
|
||||
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['reuse'] }),
|
||||
runFullDemo,
|
||||
startLiveMemoryFlow,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(startLiveMemoryFlow).not.toHaveBeenCalled();
|
||||
expect(runFullDemo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onMemoryFlowChange: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(testIo.stdout()).toContain('[connect] Connected live-database - 7 database files (demo_full)');
|
||||
expect(testIo.stdout()).toContain('Full demo ingest: done');
|
||||
expect(testIo.stdout()).not.toContain('KTX memory flow');
|
||||
expect(testIo.stderr()).toContain(
|
||||
'Visualization requested but stdin raw mode is unavailable; printing plain output.',
|
||||
);
|
||||
});
|
||||
|
||||
it('streams plain-text progress lines for full demo when no live TUI is active', async () => {
|
||||
const testIo = makeIo();
|
||||
const runFullDemo = vi.fn(
|
||||
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
|
||||
const baseSnapshot = fakeFullResult(tempDir).replay;
|
||||
options.onMemoryFlowChange?.({
|
||||
...baseSnapshot,
|
||||
status: 'running',
|
||||
events: [{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 7 }],
|
||||
});
|
||||
options.onMemoryFlowChange?.({
|
||||
...baseSnapshot,
|
||||
status: 'running',
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 7 },
|
||||
{ type: 'diff_computed', added: 0, modified: 0, deleted: 0, unchanged: 7 },
|
||||
],
|
||||
});
|
||||
return fakeFullResult(tempDir);
|
||||
},
|
||||
);
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'full', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
||||
testIo.io,
|
||||
{ env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, runFullDemo }, // pragma: allowlist secret
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const stdout = testIo.stdout();
|
||||
expect(stdout).toContain('[connect] Connected live-database - 7 database files (manual_resync)');
|
||||
expect(stdout).toContain('[diff] Tables: =7 unchanged');
|
||||
expect(stdout).toContain('Full demo ingest: done');
|
||||
});
|
||||
|
||||
it('skips plain progress lines for json output mode', async () => {
|
||||
const testIo = makeIo();
|
||||
const runFullDemo = vi.fn(
|
||||
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
|
||||
expect(options.onMemoryFlowChange).toBeUndefined();
|
||||
return fakeFullResult(tempDir);
|
||||
},
|
||||
);
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'full', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
|
||||
testIo.io,
|
||||
{ env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, runFullDemo }, // pragma: allowlist secret
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(testIo.stdout()).not.toContain('[connect]');
|
||||
expect(testIo.stdout()).not.toContain('[snapshot]');
|
||||
});
|
||||
|
||||
it('routes demo ingest full mode', async () => {
|
||||
const testIo = makeIo();
|
||||
const runFullDemo = vi.fn().mockResolvedValue(fakeFullResult(tempDir));
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'ingest', mode: 'full', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
||||
testIo.io,
|
||||
{ env: {}, runFullDemo },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Full demo ingest: done');
|
||||
});
|
||||
|
||||
it('saves full-demo replay output for the next demo replay command', async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-demo-full-replay-'));
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'full', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
||||
io.io,
|
||||
{
|
||||
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
|
||||
runFullDemo: vi.fn(async () => fakeFullResult(tempDir)),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const replayIo = makeIo();
|
||||
await expect(
|
||||
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, replayIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(replayIo.stdout())).toMatchObject({
|
||||
runId: 'run-full',
|
||||
metadata: { mode: 'full', origin: 'captured' },
|
||||
});
|
||||
});
|
||||
|
||||
it('routes demo ingest seeded mode through the seeded path', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'ingest', mode: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
||||
testIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Mode: seeded');
|
||||
expect(testIo.stdout()).toContain('LLM calls: none');
|
||||
});
|
||||
|
||||
it('routes demo doctor through the doctor module', async () => {
|
||||
const testIo = makeIo();
|
||||
const runDoctor = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{
|
||||
command: 'doctor',
|
||||
projectDir: tempDir,
|
||||
outputMode: 'plain',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
testIo.io,
|
||||
{ runDoctor },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runDoctor).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'demo',
|
||||
projectDir: tempDir,
|
||||
outputMode: 'plain',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('resets the demo project only when force is explicit', async () => {
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
await rm(join(tempDir, 'demo.db'), { force: true });
|
||||
|
||||
const rejected = makeIo();
|
||||
await expect(
|
||||
runKtxDemo({ command: 'reset', projectDir: tempDir, force: false, inputMode: 'disabled' }, rejected.io),
|
||||
).resolves.toBe(1);
|
||||
expect(rejected.stderr()).toContain(`ktx setup demo reset is destructive; pass --force to recreate ${tempDir}`);
|
||||
|
||||
const accepted = makeIo();
|
||||
await expect(
|
||||
runKtxDemo({ command: 'reset', projectDir: tempDir, force: true, inputMode: 'disabled' }, accepted.io),
|
||||
).resolves.toBe(0);
|
||||
expect(accepted.stdout()).toContain(`Demo project reset: ${tempDir}`);
|
||||
});
|
||||
|
||||
it('rehydrates seeded assets after reset --force', async () => {
|
||||
const resetIo = makeIo();
|
||||
await expect(
|
||||
runKtxDemo({ command: 'reset', projectDir: tempDir, force: true, inputMode: 'disabled' }, resetIo.io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const seededIo = makeIo();
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
|
||||
seededIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(seededIo.stdout()).toContain('Status: ready');
|
||||
expect(seededIo.stdout()).toContain(
|
||||
`Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} files`,
|
||||
);
|
||||
expect(seededIo.stdout()).toContain(
|
||||
`Knowledge pages: ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} manifest, ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} files`,
|
||||
);
|
||||
expect(seededIo.stdout()).not.toContain('Status: corrupt');
|
||||
expect(seededIo.stdout()).not.toContain(
|
||||
`Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, 0 files`,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails corrupted demo projects in no-input mode with reset guidance', async () => {
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
await rm(join(tempDir, 'demo.db'), { force: true });
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, testIo.io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stderr()).toContain(`Demo project is not ready at ${tempDir}: missing demo.db`);
|
||||
expect(testIo.stderr()).toContain(`ktx setup demo reset --project-dir ${tempDir} --force --no-input`);
|
||||
});
|
||||
|
||||
it('uses a process-local Anthropic key from the interactive prompt', async () => {
|
||||
const testIo = makeIo({ isTTY: true, columns: 120 });
|
||||
const runFullDemo = vi.fn().mockResolvedValue(fakeFullResult(tempDir));
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'full', projectDir: tempDir, outputMode: 'plain' },
|
||||
testIo.io,
|
||||
{
|
||||
env: {},
|
||||
prompts: createTestDemoPromptAdapter({
|
||||
choices: ['reuse', 'process_key'],
|
||||
passwords: ['sk-ant-process'], // pragma: allowlist secret
|
||||
}),
|
||||
runFullDemo,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runFullDemo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectDir: tempDir,
|
||||
env: { ANTHROPIC_API_KEY: 'sk-ant-process' }, // pragma: allowlist secret
|
||||
onMemoryFlowChange: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('api_key: env:ANTHROPIC_API_KEY');
|
||||
});
|
||||
|
||||
it('routes an interactive missing-key choice to seeded mode', async () => {
|
||||
const testIo = makeIo({ isTTY: true, columns: 120 });
|
||||
const runFullDemo = vi.fn();
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'full', projectDir: tempDir, outputMode: 'plain' },
|
||||
testIo.io,
|
||||
{
|
||||
env: {},
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['reuse', 'seeded'] }),
|
||||
runFullDemo,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runFullDemo).not.toHaveBeenCalled();
|
||||
expect(testIo.stdout()).toContain('Mode: seeded');
|
||||
expect(testIo.stdout()).toContain('LLM calls: none');
|
||||
expect(testIo.stdout()).not.toContain('deterministic');
|
||||
});
|
||||
|
||||
it('routes missing full-mode credentials to seeded when the interactive user chooses the no-LLM demo', async () => {
|
||||
const testIo = makeIo({ isTTY: true });
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'full', projectDir: tempDir, outputMode: 'plain' },
|
||||
testIo.io,
|
||||
{
|
||||
env: { ...process.env, ANTHROPIC_API_KEY: '' },
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['seeded'] }),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Mode: seeded');
|
||||
expect(testIo.stdout()).toContain('LLM calls: none');
|
||||
expect(testIo.stdout()).not.toContain('deterministic');
|
||||
});
|
||||
|
||||
it('routes an interactive missing-key choice to replay mode', async () => {
|
||||
const testIo = makeIo({ isTTY: true, columns: 120 });
|
||||
const runFullDemo = vi.fn();
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
runKtxDemo(
|
||||
{ command: 'full', projectDir: tempDir, outputMode: 'viz' },
|
||||
testIo.io,
|
||||
{
|
||||
env: {},
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['reuse', 'replay'] }),
|
||||
runFullDemo,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runFullDemo).not.toHaveBeenCalled();
|
||||
expect(testIo.stdout()).toContain('KTX memory flow');
|
||||
expect(testIo.stdout()).toContain('done');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,544 +0,0 @@
|
|||
import {
|
||||
buildMemoryFlowViewModel,
|
||||
formatMemoryFlowFinalSummary,
|
||||
renderMemoryFlowReplay,
|
||||
type MemoryFlowReplayInput,
|
||||
} from '@ktx/context/ingest/memory-flow';
|
||||
import { resolveKtxConfigReference } from '@ktx/context/core';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import {
|
||||
DEMO_ADAPTER,
|
||||
DEMO_CONNECTION_ID,
|
||||
DEMO_FULL_JOB_ID,
|
||||
ensureDemoProject,
|
||||
loadProjectDemoReplay,
|
||||
resetDemoProject,
|
||||
} from './demo-assets.js';
|
||||
import { writeDemoReplay } from './demo-replay-store.js';
|
||||
import {
|
||||
formatDemoInspect,
|
||||
formatDemoScanSummary,
|
||||
inspectDemoProject,
|
||||
runDemoScan,
|
||||
} from './demo-scan.js';
|
||||
import {
|
||||
formatSeededInspect,
|
||||
inspectSeededProject,
|
||||
runDemoSeeded,
|
||||
} from './demo-seeded.js';
|
||||
import { buildFullDemoReplay, formatCleanDemoSummary, formatFullDemoSummary, fullDemoCredentialStatus, runDemoFull } from './demo-full.js';
|
||||
import { createPlainProgressEmitter } from './demo-progress.js';
|
||||
import {
|
||||
chooseDemoProjectForInteractiveRun,
|
||||
createClackDemoPromptAdapter,
|
||||
resolveFullCredentialDecision,
|
||||
type DemoPromptAdapter,
|
||||
} from './demo-interaction.js';
|
||||
import type { KtxDoctorArgs } from './doctor.js';
|
||||
import {
|
||||
renderMemoryFlowTui,
|
||||
startLiveMemoryFlowTui,
|
||||
type KtxMemoryFlowTuiIo,
|
||||
type MemoryFlowTuiLiveSession,
|
||||
} from './memory-flow-tui.js';
|
||||
import {
|
||||
rendererUnavailableVizFallback,
|
||||
resolveVizFallback,
|
||||
warnVizFallbackOnce,
|
||||
} from './viz-fallback.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
import { formatNextStepLines } from './next-steps.js';
|
||||
|
||||
profileMark('module:demo');
|
||||
|
||||
export type KtxDemoOutputMode = 'plain' | 'json' | 'viz';
|
||||
export type KtxDemoInputMode = 'auto' | 'disabled';
|
||||
export type KtxDemoMode = 'full' | 'seeded';
|
||||
|
||||
export type KtxDemoArgs =
|
||||
| { command: 'init'; projectDir: string; force: boolean; inputMode?: KtxDemoInputMode }
|
||||
| { command: 'reset'; projectDir: string; force: boolean; inputMode?: KtxDemoInputMode }
|
||||
| { command: 'replay'; projectDir: string; outputMode: KtxDemoOutputMode; inputMode?: KtxDemoInputMode }
|
||||
| { command: 'scan'; projectDir: string; inputMode?: KtxDemoInputMode }
|
||||
| { command: 'inspect'; projectDir: string; outputMode: KtxDemoOutputMode; inputMode?: KtxDemoInputMode }
|
||||
| { command: 'doctor'; projectDir: string; outputMode: Exclude<KtxDemoOutputMode, 'viz'>; inputMode?: KtxDemoInputMode }
|
||||
| { command: 'seeded'; projectDir: string; outputMode: KtxDemoOutputMode; inputMode?: KtxDemoInputMode }
|
||||
| { command: 'full'; projectDir: string; outputMode: KtxDemoOutputMode; inputMode?: KtxDemoInputMode }
|
||||
| {
|
||||
command: 'ingest';
|
||||
mode: KtxDemoMode;
|
||||
projectDir: string;
|
||||
outputMode: KtxDemoOutputMode;
|
||||
inputMode?: KtxDemoInputMode;
|
||||
};
|
||||
|
||||
export interface KtxDemoIo {
|
||||
stdin?: KtxMemoryFlowTuiIo['stdin'];
|
||||
stdout: { isTTY?: boolean; columns?: number; write(chunk: string): void };
|
||||
stderr: { write(chunk: string): void };
|
||||
}
|
||||
|
||||
interface KtxDemoDeps {
|
||||
runFullDemo?: typeof runDemoFull;
|
||||
runDoctor?: (args: KtxDoctorArgs, io: KtxDemoIo) => Promise<number>;
|
||||
renderStoredMemoryFlow?: typeof renderMemoryFlowTui;
|
||||
startLiveMemoryFlow?: typeof startLiveMemoryFlowTui;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
prompts?: DemoPromptAdapter;
|
||||
}
|
||||
|
||||
const ADAPTER_PREFIXES = ['live_database_', 'metabase_', 'looker_', 'lookml_', 'metricflow_', 'notion_', 'historic_sql_', 'dbt_descriptions_'];
|
||||
const DEMO_TUI_SPEED_MULTIPLIER = 0.125;
|
||||
|
||||
function humanizeUnitKeyPlain(unitKey: string): string {
|
||||
let key = unitKey.replace(/-/g, '_');
|
||||
for (const prefix of ADAPTER_PREFIXES) {
|
||||
if (key.startsWith(prefix)) { key = key.slice(prefix.length); break; }
|
||||
}
|
||||
return key.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function formatReplaySummary(input: MemoryFlowReplayInput): string {
|
||||
let slCount = 0;
|
||||
let wikiCount = 0;
|
||||
let chunkCount = 0;
|
||||
const unitResults: Array<{ unitKey: string; artifacts: Array<{ icon: string; text: string; hasSummary: boolean }> }> = [];
|
||||
let currentUnit: { unitKey: string; artifacts: Array<{ icon: string; text: string; hasSummary: boolean }> } | null = null;
|
||||
let conflictCount = 0;
|
||||
|
||||
for (const e of input.events) {
|
||||
if (e.type === 'chunks_planned') {
|
||||
chunkCount = e.chunkCount;
|
||||
} else if (e.type === 'work_unit_started') {
|
||||
currentUnit = { unitKey: e.unitKey, artifacts: [] };
|
||||
} else if (e.type === 'candidate_action') {
|
||||
if (e.target === 'sl') slCount++;
|
||||
else wikiCount++;
|
||||
const detail = input.details.actions.find((a) => a.key === e.key && a.unitKey === e.unitKey);
|
||||
const icon = e.target === 'sl' ? '📊' : '📝';
|
||||
const name = e.key.split('.').pop()?.replace(/[_-]/g, ' ') ?? e.key;
|
||||
const text = detail?.summary ?? name;
|
||||
currentUnit?.artifacts.push({ icon, text, hasSummary: !!detail?.summary });
|
||||
} else if (e.type === 'work_unit_finished' && currentUnit) {
|
||||
unitResults.push(currentUnit);
|
||||
currentUnit = null;
|
||||
} else if (e.type === 'reconciliation_finished') {
|
||||
conflictCount = e.conflictCount;
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = ['', '★ KTX finished ingesting your data', ''];
|
||||
|
||||
if (chunkCount > 0) {
|
||||
lines.push(` ✓ Analyzed ${chunkCount} business area${chunkCount === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
lines.push(` ✓ Reconciled — ${conflictCount > 0 ? `${conflictCount} conflict${conflictCount === 1 ? '' : 's'} resolved` : 'no conflicts'}`);
|
||||
lines.push('');
|
||||
|
||||
if (slCount > 0 || wikiCount > 0) {
|
||||
lines.push(' KTX created:');
|
||||
if (slCount > 0) lines.push(` 📊 ${slCount} query definition${slCount === 1 ? '' : 's'} — so agents can write accurate SQL for your data`);
|
||||
if (wikiCount > 0) lines.push(` 📝 ${wikiCount} knowledge page${wikiCount === 1 ? '' : 's'} — so agents understand your business context`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const described = unitResults.flatMap((u) => u.artifacts).filter((a) => a.hasSummary);
|
||||
for (const a of described) {
|
||||
lines.push(` ${a.icon} ${a.text}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(' What to do next:');
|
||||
lines.push(...formatNextStepLines());
|
||||
if (input.sourceDir) {
|
||||
lines.push('');
|
||||
lines.push(` Your KTX project files are at: ${input.sourceDir}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatPlainReplaySummary(input: MemoryFlowReplayInput): string {
|
||||
return [formatMemoryFlowFinalSummary(input).trimEnd(), '', 'What to do next:', ...formatNextStepLines(), ''].join('\n');
|
||||
}
|
||||
|
||||
function writeReplay(input: MemoryFlowReplayInput, outputMode: KtxDemoOutputMode, io: KtxDemoIo): void {
|
||||
if (outputMode === 'json') {
|
||||
io.stdout.write(`${JSON.stringify(input, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputMode === 'plain') {
|
||||
io.stdout.write(formatPlainReplaySummary(input));
|
||||
return;
|
||||
}
|
||||
|
||||
const view = buildMemoryFlowViewModel(input);
|
||||
io.stdout.write(renderMemoryFlowReplay(view, { terminalWidth: io.stdout.columns ?? process.stdout.columns }));
|
||||
}
|
||||
|
||||
async function writeStoredReplay(
|
||||
input: MemoryFlowReplayInput,
|
||||
outputMode: KtxDemoOutputMode,
|
||||
inputMode: KtxDemoArgs['inputMode'],
|
||||
io: KtxDemoIo,
|
||||
deps: KtxDemoDeps,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
const resolvedOutputMode = effectiveDemoOutputMode(outputMode, io, env, {
|
||||
requireInput: inputMode !== 'disabled',
|
||||
});
|
||||
if (resolvedOutputMode !== 'viz') {
|
||||
writeReplay(input, resolvedOutputMode, io);
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputMode !== 'disabled') {
|
||||
const renderStoredMemoryFlow = deps.renderStoredMemoryFlow ?? renderMemoryFlowTui;
|
||||
if (
|
||||
isTuiCapableDemoIo(io) &&
|
||||
(await renderStoredMemoryFlow(input, io, { speedMultiplier: DEMO_TUI_SPEED_MULTIPLIER }))
|
||||
) {
|
||||
io.stdout.write(formatReplaySummary(input));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
writeReplay(input, resolvedOutputMode, io);
|
||||
}
|
||||
|
||||
function writeInspect(
|
||||
summary: Awaited<ReturnType<typeof inspectDemoProject>>,
|
||||
outputMode: KtxDemoOutputMode,
|
||||
io: KtxDemoIo,
|
||||
): void {
|
||||
if (outputMode === 'json') {
|
||||
io.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
io.stdout.write(formatDemoInspect(summary));
|
||||
}
|
||||
|
||||
function writeFullDemo(
|
||||
result: Awaited<ReturnType<typeof runDemoFull>>,
|
||||
outputMode: KtxDemoOutputMode,
|
||||
io: KtxDemoIo,
|
||||
options: { liveWasRendered?: boolean; projectDir?: string } = {},
|
||||
): void {
|
||||
if (outputMode === 'json') {
|
||||
io.stdout.write(`${JSON.stringify({ report: result.report, replay: result.replay }, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputMode === 'viz' && options.liveWasRendered !== true) {
|
||||
writeReplay(buildFullDemoReplay(result.report), outputMode, io);
|
||||
io.stdout.write('\n');
|
||||
}
|
||||
|
||||
if (outputMode === 'viz' && options.liveWasRendered) {
|
||||
io.stdout.write(formatCleanDemoSummary(result.report, options.projectDir ?? ''));
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputMode === 'viz') {
|
||||
io.stdout.write(formatMemoryFlowFinalSummary(buildFullDemoReplay(result.report)));
|
||||
}
|
||||
|
||||
io.stdout.write(formatFullDemoSummary(result.report));
|
||||
}
|
||||
|
||||
function replayWithFullMetadata(result: Awaited<ReturnType<typeof runDemoFull>>): MemoryFlowReplayInput {
|
||||
if (result.replay.metadata) {
|
||||
return result.replay;
|
||||
}
|
||||
|
||||
return {
|
||||
...result.replay,
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: 'captured',
|
||||
timing: 'captured',
|
||||
capturedAt: result.report.createdAt,
|
||||
sourceReportId: result.report.id,
|
||||
sourceReportPath: result.report.id,
|
||||
fallbackReason: null,
|
||||
},
|
||||
reportId: result.replay.reportId ?? result.report.id,
|
||||
reportPath: result.replay.reportPath ?? result.report.id,
|
||||
};
|
||||
}
|
||||
|
||||
function pickMemoryFlowProgress(
|
||||
liveSession: MemoryFlowTuiLiveSession | null,
|
||||
outputMode: KtxDemoOutputMode,
|
||||
io: KtxDemoIo,
|
||||
): ((snapshot: MemoryFlowReplayInput) => void) | undefined {
|
||||
if (liveSession) {
|
||||
return (snapshot: MemoryFlowReplayInput) => {
|
||||
if (!liveSession.isClosed()) {
|
||||
liveSession.update(snapshot);
|
||||
}
|
||||
};
|
||||
}
|
||||
if (outputMode === 'json') {
|
||||
return undefined;
|
||||
}
|
||||
return createPlainProgressEmitter(io);
|
||||
}
|
||||
|
||||
function isTuiCapableDemoIo(io: KtxDemoIo): io is KtxDemoIo & KtxMemoryFlowTuiIo {
|
||||
return (
|
||||
io.stdin?.isTTY === true &&
|
||||
io.stdout.isTTY === true &&
|
||||
typeof io.stdin.setRawMode === 'function' &&
|
||||
typeof io.stdout.write === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
interface EffectiveDemoOutputModeOptions {
|
||||
requireInput?: boolean;
|
||||
}
|
||||
|
||||
function effectiveDemoOutputMode(
|
||||
outputMode: KtxDemoOutputMode,
|
||||
io: KtxDemoIo,
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: EffectiveDemoOutputModeOptions = {},
|
||||
): KtxDemoOutputMode {
|
||||
if (outputMode !== 'viz') {
|
||||
return outputMode;
|
||||
}
|
||||
|
||||
const fallback = resolveVizFallback(io, env, { requireInput: options.requireInput ?? false });
|
||||
if (!fallback.shouldDegrade) {
|
||||
return outputMode;
|
||||
}
|
||||
|
||||
warnVizFallbackOnce(io, fallback);
|
||||
return 'plain';
|
||||
}
|
||||
|
||||
function initialFullDemoMemoryFlowInput(projectDir: string): MemoryFlowReplayInput {
|
||||
return {
|
||||
runId: DEMO_FULL_JOB_ID,
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
adapter: DEMO_ADAPTER,
|
||||
status: 'running',
|
||||
sourceDir: `${projectDir}/raw-sources/${DEMO_CONNECTION_ID}/${DEMO_ADAPTER}`,
|
||||
syncId: 'pending',
|
||||
errors: [],
|
||||
events: [],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureDemoProjectForCommand(projectDir: string): Promise<void> {
|
||||
await ensureDemoProject({ projectDir, force: false }).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes('Demo project already exists')) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareProjectForDemoCommand(args: KtxDemoArgs, io: KtxDemoIo, deps: KtxDemoDeps): Promise<string | null> {
|
||||
if (args.command === 'init' || args.command === 'reset' || args.command === 'doctor') {
|
||||
return args.projectDir;
|
||||
}
|
||||
|
||||
const prompts = deps.prompts ?? createClackDemoPromptAdapter();
|
||||
const decision = await chooseDemoProjectForInteractiveRun({
|
||||
projectDir: args.projectDir,
|
||||
inputMode: args.inputMode,
|
||||
io,
|
||||
prompts,
|
||||
});
|
||||
|
||||
if (decision.action === 'cancel') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (decision.reset) {
|
||||
await resetDemoProject({ projectDir: decision.projectDir, force: true });
|
||||
}
|
||||
|
||||
return decision.projectDir;
|
||||
}
|
||||
|
||||
async function runReplayDemo(
|
||||
projectDir: string,
|
||||
outputMode: KtxDemoOutputMode,
|
||||
inputMode: KtxDemoArgs['inputMode'],
|
||||
io: KtxDemoIo,
|
||||
deps: KtxDemoDeps,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<number> {
|
||||
await ensureDemoProjectForCommand(projectDir);
|
||||
await writeStoredReplay(await loadProjectDemoReplay(projectDir), outputMode, inputMode, io, deps, env);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function runSeededDemo(
|
||||
projectDir: string,
|
||||
outputMode: KtxDemoOutputMode,
|
||||
inputMode: KtxDemoArgs['inputMode'],
|
||||
io: KtxDemoIo,
|
||||
deps: KtxDemoDeps,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<number> {
|
||||
const result = await runDemoSeeded({ projectDir });
|
||||
const resolvedOutputMode = effectiveDemoOutputMode(outputMode, io, env, {
|
||||
requireInput: inputMode !== 'disabled',
|
||||
});
|
||||
|
||||
if (resolvedOutputMode === 'json') {
|
||||
io.stdout.write(`${JSON.stringify({ replay: result.replay, inspect: result.inspect }, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (resolvedOutputMode === 'viz') {
|
||||
await writeStoredReplay(result.replay, resolvedOutputMode, inputMode, io, deps, env);
|
||||
} else {
|
||||
writeReplay(result.replay, resolvedOutputMode, io);
|
||||
io.stdout.write('\n');
|
||||
io.stdout.write(formatSeededInspect(result.inspect));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runKtxDemo(args: KtxDemoArgs, io: KtxDemoIo = process, deps: KtxDemoDeps = {}): Promise<number> {
|
||||
try {
|
||||
if (args.command === 'init') {
|
||||
const result = await ensureDemoProject({ projectDir: args.projectDir, force: args.force });
|
||||
io.stdout.write(`Demo project: ${result.projectDir}\n`);
|
||||
io.stdout.write(`Config: ${result.configPath}\n`);
|
||||
io.stdout.write(`Database: ${result.databasePath}\n`);
|
||||
io.stdout.write(`Replay: ${result.replayPath}\n`);
|
||||
io.stdout.write('Next: ktx setup demo --no-input\n');
|
||||
io.stdout.write(' Runs the pre-seeded demo without calling the LLM.\n');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'reset') {
|
||||
const result = await resetDemoProject({ projectDir: args.projectDir, force: args.force });
|
||||
io.stdout.write(`Demo project reset: ${result.projectDir}\n`);
|
||||
io.stdout.write(`Config: ${result.configPath}\n`);
|
||||
io.stdout.write(`Database: ${result.databasePath}\n`);
|
||||
io.stdout.write(`Replay: ${result.replayPath}\n`);
|
||||
io.stdout.write('Next: ktx setup demo --mode full\n');
|
||||
io.stdout.write(' Runs the full AI-backed pass with your LLM provider.\n');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const preparedProjectDir = await prepareProjectForDemoCommand(args, io, deps);
|
||||
if (preparedProjectDir === null) {
|
||||
return 1;
|
||||
}
|
||||
const env = deps.env ?? process.env;
|
||||
|
||||
if (args.command === 'scan') {
|
||||
const { result } = await runDemoScan({ projectDir: preparedProjectDir });
|
||||
io.stdout.write(formatDemoScanSummary(result.report));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'seeded' || (args.command === 'ingest' && args.mode === 'seeded')) {
|
||||
return await runSeededDemo(preparedProjectDir, args.outputMode, args.inputMode, io, deps, env);
|
||||
}
|
||||
|
||||
if (args.command === 'full' || (args.command === 'ingest' && args.mode === 'full')) {
|
||||
const executeFullDemo = deps.runFullDemo ?? runDemoFull;
|
||||
await ensureDemoProjectForCommand(preparedProjectDir);
|
||||
const project = await loadKtxProject({ projectDir: preparedProjectDir });
|
||||
const credentialStatus = fullDemoCredentialStatus(project, env);
|
||||
const credentialDecision = await resolveFullCredentialDecision({
|
||||
needsAnthropicKey:
|
||||
credentialStatus.status === 'missing-anthropic-key' &&
|
||||
project.config.llm.provider.backend === 'anthropic' &&
|
||||
!resolveKtxConfigReference(project.config.llm.provider.anthropic?.api_key, env),
|
||||
inputMode: args.inputMode,
|
||||
io,
|
||||
env,
|
||||
prompts: deps.prompts ?? createClackDemoPromptAdapter(),
|
||||
});
|
||||
|
||||
if (credentialDecision.action === 'cancel') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (credentialDecision.action === 'run-mode') {
|
||||
return credentialDecision.mode === 'seeded'
|
||||
? await runSeededDemo(preparedProjectDir, args.outputMode, args.inputMode, io, deps, env)
|
||||
: await runReplayDemo(preparedProjectDir, args.outputMode, args.inputMode, io, deps, env);
|
||||
}
|
||||
|
||||
let liveSession: MemoryFlowTuiLiveSession | null = null;
|
||||
let liveWasRendered = false;
|
||||
const startLiveMemoryFlow = deps.startLiveMemoryFlow ?? startLiveMemoryFlowTui;
|
||||
let fullOutputMode = effectiveDemoOutputMode(args.outputMode, io, env, {
|
||||
requireInput: args.inputMode !== 'disabled',
|
||||
});
|
||||
const shouldUseLiveViz = fullOutputMode === 'viz' && args.inputMode !== 'disabled';
|
||||
|
||||
if (shouldUseLiveViz && isTuiCapableDemoIo(io)) {
|
||||
liveSession = await startLiveMemoryFlow(initialFullDemoMemoryFlowInput(preparedProjectDir), io);
|
||||
liveWasRendered = liveSession !== null;
|
||||
} else if (shouldUseLiveViz) {
|
||||
warnVizFallbackOnce(io, rendererUnavailableVizFallback());
|
||||
fullOutputMode = 'plain';
|
||||
}
|
||||
|
||||
const onMemoryFlowChange = pickMemoryFlowProgress(liveSession, fullOutputMode, io);
|
||||
const result = await executeFullDemo({
|
||||
projectDir: preparedProjectDir,
|
||||
env: credentialDecision.env,
|
||||
...(onMemoryFlowChange ? { onMemoryFlowChange } : {}),
|
||||
});
|
||||
await writeDemoReplay(preparedProjectDir, replayWithFullMetadata(result), { label: 'full' });
|
||||
liveSession?.close();
|
||||
writeFullDemo(result, fullOutputMode, io, { liveWasRendered, projectDir: preparedProjectDir });
|
||||
if (fullOutputMode !== 'json' && !liveWasRendered) {
|
||||
io.stdout.write(formatDemoInspect(await inspectDemoProject(preparedProjectDir)));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'inspect') {
|
||||
const seededInspect = await inspectSeededProject(preparedProjectDir).catch(() => null);
|
||||
if (seededInspect?.mode === 'seeded') {
|
||||
if (args.outputMode === 'json') {
|
||||
io.stdout.write(`${JSON.stringify(seededInspect, null, 2)}\n`);
|
||||
} else {
|
||||
io.stdout.write(formatSeededInspect(seededInspect));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
writeInspect(await inspectDemoProject(preparedProjectDir), args.outputMode, io);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'doctor') {
|
||||
const { runKtxDoctor } = await import('./doctor.js');
|
||||
const executeDoctor = deps.runDoctor ?? runKtxDoctor;
|
||||
return await executeDoctor(
|
||||
{
|
||||
command: 'demo',
|
||||
projectDir: args.projectDir,
|
||||
outputMode: args.outputMode,
|
||||
...(args.inputMode ? { inputMode: args.inputMode } : {}),
|
||||
},
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
return await runReplayDemo(preparedProjectDir, args.outputMode, args.inputMode, io, deps, env);
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,8 +29,7 @@ interface DoctorReport {
|
|||
|
||||
export type KtxDoctorArgs =
|
||||
| { command: 'setup'; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode }
|
||||
| { command: 'project'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode }
|
||||
| { command: 'demo'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode };
|
||||
| { command: 'project'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode };
|
||||
|
||||
interface KtxDoctorIo {
|
||||
stdout: { write(chunk: string): void };
|
||||
|
|
@ -323,7 +322,7 @@ async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): P
|
|||
'connections',
|
||||
'Connections',
|
||||
'0 configured',
|
||||
'Add a connection to ktx.yaml or run `ktx setup demo init`',
|
||||
'Add a connection to ktx.yaml or run `ktx setup`',
|
||||
),
|
||||
);
|
||||
checks.push(check('pass', 'storage', 'Storage', `${project.config.storage.state}/${project.config.storage.search}`));
|
||||
|
|
@ -346,94 +345,6 @@ async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): P
|
|||
return checks;
|
||||
}
|
||||
|
||||
async function runDemoProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): Promise<DoctorCheck[]> {
|
||||
const env = deps.env ?? process.env;
|
||||
const { DEMO_CONNECTION_ID, DEMO_REPLAY_FILE } = await import('./demo-assets.js');
|
||||
const { loadKtxProject } = await import('@ktx/context/project');
|
||||
const checks: DoctorCheck[] = [];
|
||||
const requiredPaths = [
|
||||
['demo-config', 'Demo config', 'ktx.yaml'],
|
||||
['demo-database', 'Demo dataset', 'demo.db'],
|
||||
['demo-state', 'Demo state database', 'state.sqlite'],
|
||||
['demo-replay', 'Demo replay', join('replays', DEMO_REPLAY_FILE)],
|
||||
['demo-raw-sources', 'Demo raw sources directory', 'raw-sources'],
|
||||
['demo-semantic-layer', 'Demo semantic-layer directory', 'semantic-layer'],
|
||||
['demo-knowledge', 'Demo knowledge directory', 'knowledge'],
|
||||
] as const;
|
||||
|
||||
for (const [id, label, relativePath] of requiredPaths) {
|
||||
const absolutePath = join(projectDir, relativePath);
|
||||
checks.push(
|
||||
(await defaultPathExists(absolutePath))
|
||||
? check('pass', id, label, relativePath)
|
||||
: check(
|
||||
'fail',
|
||||
id,
|
||||
label,
|
||||
`Missing ${relativePath}`,
|
||||
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const connection = project.config.connections[DEMO_CONNECTION_ID];
|
||||
checks.push(
|
||||
connection?.driver === 'sqlite'
|
||||
? check('pass', 'demo-connection', 'Demo connection', `${DEMO_CONNECTION_ID} uses sqlite`)
|
||||
: check(
|
||||
'fail',
|
||||
'demo-connection',
|
||||
'Demo connection',
|
||||
`${DEMO_CONNECTION_ID} is missing or is not sqlite`,
|
||||
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
|
||||
),
|
||||
);
|
||||
const provider = project.config.llm.provider.backend;
|
||||
checks.push(
|
||||
provider === 'anthropic' || provider === 'vertex' || provider === 'gateway'
|
||||
? check('pass', 'demo-llm-provider', 'Demo LLM provider', provider)
|
||||
: check(
|
||||
'fail',
|
||||
'demo-llm-provider',
|
||||
'Demo LLM provider',
|
||||
provider,
|
||||
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
|
||||
),
|
||||
);
|
||||
if (provider === 'anthropic' && !env.ANTHROPIC_API_KEY) {
|
||||
checks.push(
|
||||
check(
|
||||
'warn',
|
||||
'anthropic-credentials',
|
||||
'Anthropic credentials',
|
||||
'ANTHROPIC_API_KEY is not set',
|
||||
'Export ANTHROPIC_API_KEY to run `ktx setup demo --mode full --no-input`',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
checks.push(check('pass', 'anthropic-credentials', 'Anthropic credentials', 'Configured for current provider'));
|
||||
}
|
||||
checks.push(await runSemanticSearchEmbeddingCheck(project.config.ingest.embeddings, projectDir, deps));
|
||||
const runHistoricSqlDoctorChecks =
|
||||
deps.runHistoricSqlDoctorChecks ?? (await import('./historic-sql-doctor.js')).runPostgresHistoricSqlDoctorChecks;
|
||||
checks.push(...(await runHistoricSqlDoctorChecks(project, deps)));
|
||||
} catch (error) {
|
||||
checks.push(
|
||||
check(
|
||||
'fail',
|
||||
'demo-config-parse',
|
||||
'Demo config parse',
|
||||
failureMessage(error),
|
||||
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function formatDoctorReport(report: DoctorReport): string {
|
||||
const lines = [report.title];
|
||||
for (const item of report.checks) {
|
||||
|
|
@ -469,15 +380,10 @@ export async function runKtxDoctor(
|
|||
const report: DoctorReport =
|
||||
args.command === 'setup'
|
||||
? { title: 'KTX setup doctor', checks: setupChecks }
|
||||
: args.command === 'demo'
|
||||
? {
|
||||
title: 'KTX demo doctor',
|
||||
checks: [...setupChecks, ...(await runDemoProjectChecks(args.projectDir, deps))],
|
||||
}
|
||||
: {
|
||||
title: 'KTX project doctor',
|
||||
checks: [...setupChecks, ...(await runProjectChecks(args.projectDir, deps))],
|
||||
};
|
||||
: {
|
||||
title: 'KTX project doctor',
|
||||
checks: [...setupChecks, ...(await runProjectChecks(args.projectDir, deps))],
|
||||
};
|
||||
|
||||
writeReport(report, args.outputMode, io);
|
||||
return hasFailures(report) ? 1 : 0;
|
||||
|
|
|
|||
|
|
@ -342,14 +342,15 @@ describe('runKtxCli', () => {
|
|||
expect(io.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
|
||||
});
|
||||
|
||||
it('exposes demo under setup help instead of root help', async () => {
|
||||
it('documents setup as a bare command without subcommands', async () => {
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(runKtxCli(['setup', '--help'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx setup [options] [command]');
|
||||
expect(testIo.stdout()).toContain('demo');
|
||||
expect(testIo.stdout()).toContain('Run the packaged KTX demo from setup');
|
||||
expect(testIo.stdout()).toContain('Usage: ktx setup [options]');
|
||||
expect(testIo.stdout()).not.toContain('Commands:');
|
||||
expect(testIo.stdout()).not.toContain('setup demo');
|
||||
expect(testIo.stdout()).not.toContain('setup context');
|
||||
expect(testIo.stdout()).not.toContain('--skip-llm');
|
||||
expect(testIo.stdout()).not.toContain('--skip-embeddings');
|
||||
expect(testIo.stdout()).not.toContain('--embedding-model');
|
||||
|
|
@ -373,13 +374,14 @@ describe('runKtxCli', () => {
|
|||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
const commands = [
|
||||
['--project-dir', projectDir, 'setup', 'status', '--json'],
|
||||
['--project-dir', projectDir, 'status', '--json'],
|
||||
['--project-dir', projectDir, 'sl', 'list', '--json'],
|
||||
];
|
||||
|
||||
for (const argv of commands) {
|
||||
const testIo = makeIo();
|
||||
await expect(runKtxCli(argv, testIo.io)).resolves.toBe(0);
|
||||
const code = await runKtxCli(argv, testIo.io);
|
||||
expect([0, 1]).toContain(code);
|
||||
|
||||
expect(() => JSON.parse(testIo.stdout())).not.toThrow();
|
||||
expect(testIo.stderr()).toBe('');
|
||||
|
|
@ -747,139 +749,33 @@ describe('runKtxCli', () => {
|
|||
|
||||
it('rejects standalone demo commands', async () => {
|
||||
const testIo = makeIo();
|
||||
const demo = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(runKtxCli(['demo', '--mode', 'replay', '--no-input'], testIo.io, { demo })).resolves.toBe(1);
|
||||
await expect(runKtxCli(['demo', '--mode', 'replay', '--no-input'], testIo.io)).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stderr()).toMatch(/unknown command|error:/i);
|
||||
expect(demo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches setup demo commands', async () => {
|
||||
const testIo = makeIo();
|
||||
const demo = vi.fn().mockResolvedValue(0);
|
||||
it('rejects removed setup subcommands', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const cases = [
|
||||
['setup', 'demo', '--mode', 'replay', '--no-input'],
|
||||
['setup', '--no-input', 'demo', '--mode', 'seeded'],
|
||||
['setup', 'demo', 'ingest', '--mode', 'full', '--no-input'],
|
||||
['setup', 'context', 'build'],
|
||||
['setup', 'context', 'watch', 'setup-context-local-1'],
|
||||
['setup', 'context', 'status', 'setup-context-local-1', '--json'],
|
||||
['setup', 'context', 'stop', 'setup-context-local-1'],
|
||||
['setup', 'remove', '--agents'],
|
||||
['setup', 'status', '--json'],
|
||||
];
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', '--mode', 'replay', '--no-input'], testIo.io, { demo }),
|
||||
).resolves.toBe(0);
|
||||
for (const args of cases) {
|
||||
const testIo = makeIo();
|
||||
await expect(runKtxCli(['--project-dir', tempDir, ...args], testIo.io, { setup })).resolves.toBe(1);
|
||||
expect(testIo.stderr()).toMatch(/unknown command|error:/i);
|
||||
}
|
||||
|
||||
expect(demo).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'replay',
|
||||
projectDir: tempDir,
|
||||
outputMode: 'viz',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
|
||||
demo.mockClear();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', '--mode', 'seeded', '--no-input'], testIo.io, {
|
||||
demo,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
expect(demo).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'seeded',
|
||||
projectDir: tempDir,
|
||||
outputMode: 'viz',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
|
||||
demo.mockClear();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', '--no-input', 'demo', '--mode', 'seeded'], testIo.io, {
|
||||
demo,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
expect(demo).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'seeded',
|
||||
projectDir: tempDir,
|
||||
outputMode: 'viz',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
|
||||
demo.mockClear();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', 'inspect', '--no-input'], testIo.io, { demo }),
|
||||
).resolves.toBe(0);
|
||||
expect(demo).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'inspect',
|
||||
projectDir: tempDir,
|
||||
outputMode: 'plain',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches demo ingest argv', async () => {
|
||||
const testIo = makeIo();
|
||||
const demo = vi.fn().mockResolvedValue(0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', 'ingest', '--mode', 'full', '--no-input'], testIo.io, {
|
||||
demo,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(demo).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'ingest',
|
||||
mode: 'full',
|
||||
projectDir: tempDir,
|
||||
outputMode: 'viz',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
|
||||
demo.mockClear();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', '--no-input', 'demo', 'ingest', '--mode', 'seeded'], testIo.io, {
|
||||
demo,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(demo).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'ingest',
|
||||
mode: 'seeded',
|
||||
projectDir: tempDir,
|
||||
outputMode: 'viz',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
|
||||
demo.mockClear();
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'setup', 'demo', 'ingest', '--mode', 'full', '--no-input', '--plain'],
|
||||
testIo.io,
|
||||
{
|
||||
demo,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(demo).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'ingest',
|
||||
mode: 'full',
|
||||
projectDir: tempDir,
|
||||
outputMode: 'plain',
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
testIo.io,
|
||||
);
|
||||
expect(setup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints public ingest help without invoking ingest execution', async () => {
|
||||
|
|
@ -1067,21 +963,16 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`);
|
||||
});
|
||||
|
||||
it('keeps setup status on the setup runner and routes top-level status through doctor', async () => {
|
||||
it('routes top-level status through doctor', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const doctor = vi.fn(async () => 0);
|
||||
const setupIo = makeIo();
|
||||
const statusIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', 'status', '--json'], setupIo.io, { setup }),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'status', '--json', '--no-input'], statusIo.io, { setup, doctor }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(setup).toHaveBeenNthCalledWith(1, { command: 'status', projectDir: tempDir, json: true }, setupIo.io);
|
||||
expect(setup).toHaveBeenCalledTimes(1);
|
||||
expect(setup).not.toHaveBeenCalled();
|
||||
expect(doctor).toHaveBeenCalledWith(
|
||||
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
|
||||
statusIo.io,
|
||||
|
|
@ -1121,54 +1012,6 @@ describe('runKtxCli', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('dispatches setup context recovery commands through the setup runner', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const buildIo = makeIo();
|
||||
const watchIo = makeIo();
|
||||
const statusIo = makeIo();
|
||||
const stopIo = makeIo();
|
||||
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'build'], buildIo.io, { setup })).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'watch', 'setup-context-local-1'], watchIo.io, {
|
||||
setup,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'status', 'setup-context-local-1', '--json'], statusIo.io, {
|
||||
setup,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'stop', 'setup-context-local-1'], stopIo.io, {
|
||||
setup,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(setup).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ command: 'context-build', projectDir: tempDir, inputMode: 'auto' },
|
||||
buildIo.io,
|
||||
);
|
||||
expect(setup).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ command: 'context-watch', projectDir: tempDir, runId: 'setup-context-local-1', inputMode: 'auto' },
|
||||
watchIo.io,
|
||||
);
|
||||
expect(setup).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
{ command: 'context-status', projectDir: tempDir, runId: 'setup-context-local-1', json: true },
|
||||
statusIo.io,
|
||||
);
|
||||
expect(setup).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
{ command: 'context-stop', projectDir: tempDir, runId: 'setup-context-local-1' },
|
||||
stopIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches Anthropic setup flags to the setup runner', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const setupIo = makeIo();
|
||||
|
|
@ -1366,10 +1209,9 @@ describe('runKtxCli', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('dispatches setup agent flags and removal', async () => {
|
||||
it('dispatches setup agent flags', async () => {
|
||||
const setup = vi.fn(async () => 0);
|
||||
const setupIo = makeIo();
|
||||
const removeIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
|
|
@ -1388,12 +1230,8 @@ describe('runKtxCli', () => {
|
|||
{ setup },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'setup', 'remove', '--agents'], removeIo.io, { setup }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(setup).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect(setup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'run',
|
||||
agents: true,
|
||||
|
|
@ -1404,7 +1242,6 @@ describe('runKtxCli', () => {
|
|||
}),
|
||||
setupIo.io,
|
||||
);
|
||||
expect(setup).toHaveBeenNthCalledWith(2, { command: 'remove-agents', projectDir: tempDir }, removeIo.io);
|
||||
});
|
||||
|
||||
it('rejects source-path with source-git-url', async () => {
|
||||
|
|
@ -2345,21 +2182,17 @@ describe('runKtxCli', () => {
|
|||
|
||||
it('rejects mutually exclusive output modes before invoking runners', async () => {
|
||||
const ingest = vi.fn(async () => 0);
|
||||
const demo = vi.fn(async () => 0);
|
||||
|
||||
for (const argv of [
|
||||
['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'fake', '--json', '--plain'],
|
||||
['dev', 'ingest', 'status', 'run-1', '--json', '--viz'],
|
||||
['setup', 'demo', '--json', '--plain'],
|
||||
['setup', 'demo', 'replay', '--json', '--plain'],
|
||||
]) {
|
||||
const testIo = makeIo();
|
||||
await expect(runKtxCli(argv, testIo.io, { ingest, demo })).resolves.toBe(1);
|
||||
await expect(runKtxCli(argv, testIo.io, { ingest })).resolves.toBe(1);
|
||||
expect(testIo.stderr()).toMatch(/conflict|cannot be used/i);
|
||||
}
|
||||
|
||||
expect(ingest).not.toHaveBeenCalled();
|
||||
expect(demo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects mutually exclusive credential and scan mode options before invoking runners', async () => {
|
||||
|
|
|
|||
|
|
@ -12,17 +12,13 @@ describe('KTX demo next steps', () => {
|
|||
it('uses supported context-build commands before agent usage', () => {
|
||||
expect(KTX_CONTEXT_BUILD_COMMANDS).toEqual([
|
||||
{
|
||||
command: 'ktx setup context build',
|
||||
description: 'Build agent-ready context from configured primary and context sources',
|
||||
command: 'ktx setup',
|
||||
description: 'Build or resume agent-ready context from configured sources',
|
||||
},
|
||||
{
|
||||
command: 'ktx status',
|
||||
description: 'Check setup and context readiness',
|
||||
},
|
||||
{
|
||||
command: 'ktx setup context status',
|
||||
description: 'Check the setup-managed context build state',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -98,9 +94,8 @@ describe('KTX demo next steps', () => {
|
|||
|
||||
expect(rendered).toContain('Build KTX context next.');
|
||||
expect(rendered).toContain('primary-source scans and context-source ingests');
|
||||
expect(rendered).toContain('ktx setup context build');
|
||||
expect(rendered).toContain('ktx setup');
|
||||
expect(rendered).toContain('ktx status');
|
||||
expect(rendered).toContain('ktx setup context status');
|
||||
expect(rendered).not.toContain('ktx agent context --json');
|
||||
expect(rendered).not.toContain('ktx serve --mcp');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
export const KTX_CONTEXT_BUILD_COMMANDS = [
|
||||
{
|
||||
command: 'ktx setup context build',
|
||||
description: 'Build agent-ready context from configured primary and context sources',
|
||||
command: 'ktx setup',
|
||||
description: 'Build or resume agent-ready context from configured sources',
|
||||
},
|
||||
{
|
||||
command: 'ktx status',
|
||||
description: 'Check setup and context readiness',
|
||||
},
|
||||
{
|
||||
command: 'ktx setup context status',
|
||||
description: 'Check the setup-managed context build state',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const KTX_NEXT_STEP_DIRECT_COMMANDS = [
|
||||
|
|
|
|||
|
|
@ -31,14 +31,13 @@ describe('project directory defaults', () => {
|
|||
process.env.KTX_PROJECT_DIR = '/tmp/ktx-env-project';
|
||||
|
||||
const connection = vi.fn(async () => 0);
|
||||
const demo = vi.fn(async () => 0);
|
||||
const doctor = vi.fn(async () => 0);
|
||||
const ingest = vi.fn(async () => 0);
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const scan = vi.fn(async () => 0);
|
||||
const setup = vi.fn(async () => 0);
|
||||
const agent = vi.fn(async () => 0);
|
||||
const deps: KtxCliDeps = { agent, connection, demo, doctor, ingest, publicIngest, scan, setup };
|
||||
const deps: KtxCliDeps = { agent, connection, doctor, ingest, publicIngest, scan, setup };
|
||||
|
||||
const cases: Array<{
|
||||
argv: string[];
|
||||
|
|
@ -52,12 +51,6 @@ describe('project directory defaults', () => {
|
|||
expected: { command: 'list', projectDir: '/tmp/ktx-env-project' },
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['setup', 'demo', 'scan', '--no-input'],
|
||||
spy: demo,
|
||||
expected: { command: 'scan', projectDir: '/tmp/ktx-env-project' },
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['status', '--no-input'],
|
||||
spy: doctor,
|
||||
|
|
@ -71,9 +64,9 @@ describe('project directory defaults', () => {
|
|||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['setup', 'status'],
|
||||
argv: ['setup', '--no-input'],
|
||||
spy: setup,
|
||||
expected: { command: 'status', projectDir: '/tmp/ktx-env-project' },
|
||||
expected: { command: 'run', projectDir: '/tmp/ktx-env-project' },
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import {
|
||||
contextBuildCommands,
|
||||
readKtxSetupContextState,
|
||||
runKtxSetupContextCommand,
|
||||
runKtxSetupContextStep,
|
||||
writeKtxSetupContextState,
|
||||
} from './setup-context.js';
|
||||
|
|
@ -154,8 +153,8 @@ describe('setup context build state', () => {
|
|||
primarySourceConnectionIds: ['warehouse'],
|
||||
contextSourceConnectionIds: ['docs'],
|
||||
commands: {
|
||||
watch: `ktx setup context watch setup-context-local-abc123 --project-dir ${tempDir}`,
|
||||
status: `ktx setup context status setup-context-local-abc123 --project-dir ${tempDir}`,
|
||||
watch: `ktx setup --project-dir ${tempDir}`,
|
||||
status: `ktx status --project-dir ${tempDir}`,
|
||||
resume: `ktx setup --project-dir ${tempDir}`,
|
||||
},
|
||||
});
|
||||
|
|
@ -588,109 +587,4 @@ describe('setup context build state', () => {
|
|||
expect(output).toContain('Context build continuing in the background.');
|
||||
expect(output).toContain('Resume: ktx setup --project-dir');
|
||||
});
|
||||
|
||||
it('prints JSON setup context command status with watch and resume commands', async () => {
|
||||
await mkdir(join(tempDir, '.ktx', 'setup'), { recursive: true });
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-abc123',
|
||||
status: 'detached',
|
||||
startedAt: '2026-05-09T10:00:00.000Z',
|
||||
updatedAt: '2026-05-09T10:01:00.000Z',
|
||||
primarySourceConnectionIds: ['warehouse'],
|
||||
contextSourceConnectionIds: ['docs'],
|
||||
reportIds: [],
|
||||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupContextCommand(
|
||||
{ command: 'status', projectDir: tempDir, runId: 'setup-context-local-abc123', json: true },
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({
|
||||
ready: false,
|
||||
status: 'detached',
|
||||
runId: 'setup-context-local-abc123',
|
||||
watchCommand: `ktx setup context watch setup-context-local-abc123 --project-dir ${tempDir}`,
|
||||
statusCommand: `ktx setup context status setup-context-local-abc123 --project-dir ${tempDir}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('watches setup context command status until the run reaches a terminal state', async () => {
|
||||
await mkdir(join(tempDir, '.ktx', 'setup'), { recursive: true });
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-watch',
|
||||
status: 'running',
|
||||
startedAt: '2026-05-09T10:00:00.000Z',
|
||||
updatedAt: '2026-05-09T10:00:00.000Z',
|
||||
primarySourceConnectionIds: ['warehouse'],
|
||||
contextSourceConnectionIds: ['docs'],
|
||||
reportIds: [],
|
||||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-watch'),
|
||||
});
|
||||
const io = makeIo();
|
||||
const completeRun = async () => {
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-watch',
|
||||
status: 'completed',
|
||||
startedAt: '2026-05-09T10:00:00.000Z',
|
||||
updatedAt: '2026-05-09T10:02:00.000Z',
|
||||
completedAt: '2026-05-09T10:02:00.000Z',
|
||||
primarySourceConnectionIds: ['warehouse'],
|
||||
contextSourceConnectionIds: ['docs'],
|
||||
reportIds: [],
|
||||
artifactPaths: [],
|
||||
retryableFailedTargets: [],
|
||||
commands: contextBuildCommands(tempDir, 'setup-context-local-watch'),
|
||||
});
|
||||
};
|
||||
|
||||
await expect(
|
||||
runKtxSetupContextCommand(
|
||||
{ command: 'watch', projectDir: tempDir, runId: 'setup-context-local-watch', inputMode: 'disabled' },
|
||||
io.io,
|
||||
{ sleep: completeRun, watchIntervalMs: 1 },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(io.stdout()).toContain('KTX context built: running');
|
||||
expect(io.stdout()).toContain('KTX context built: yes');
|
||||
});
|
||||
|
||||
it('runs direct build commands without asking for setup confirmation first', async () => {
|
||||
await writeReadyProject(tempDir);
|
||||
const io = makeIo();
|
||||
const runContextBuildMock = vi.fn(async () => ({ exitCode: 0, detached: false }));
|
||||
|
||||
await expect(
|
||||
runKtxSetupContextCommand(
|
||||
{ command: 'build', projectDir: tempDir, inputMode: 'auto' },
|
||||
io.io,
|
||||
{
|
||||
prompts: {
|
||||
select: vi.fn(async () => {
|
||||
throw new Error('direct build should not prompt');
|
||||
}),
|
||||
cancel: vi.fn(),
|
||||
},
|
||||
runIdFactory: () => 'setup-context-local-direct',
|
||||
runContextBuild: runContextBuildMock,
|
||||
verifyContextReady: vi.fn(async () => ({
|
||||
ready: true,
|
||||
agentContextReady: true,
|
||||
semanticSearchReady: true,
|
||||
details: [],
|
||||
})),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runContextBuildMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,11 +91,11 @@ export interface KtxSetupContextStepArgs {
|
|||
autoWatch?: boolean;
|
||||
}
|
||||
|
||||
export type KtxSetupContextCommandArgs =
|
||||
| { command: 'build'; projectDir: string; inputMode: 'auto' | 'disabled' }
|
||||
| { command: 'watch'; projectDir: string; runId?: string; inputMode: 'auto' | 'disabled' }
|
||||
| { command: 'status'; projectDir: string; runId?: string; json: boolean }
|
||||
| { command: 'stop'; projectDir: string; runId?: string };
|
||||
interface KtxSetupContextWatchArgs {
|
||||
projectDir: string;
|
||||
runId?: string;
|
||||
inputMode: 'auto' | 'disabled';
|
||||
}
|
||||
|
||||
export interface KtxSetupContextPromptAdapter {
|
||||
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
|
||||
|
|
@ -154,12 +154,11 @@ async function pathExists(path: string): Promise<boolean> {
|
|||
|
||||
export function contextBuildCommands(projectDir: string, runId?: string): KtxSetupContextCommands {
|
||||
const resolvedProjectDir = resolve(projectDir);
|
||||
const runIdArg = runId ? ` ${runId}` : '';
|
||||
return {
|
||||
build: `ktx setup context build --project-dir ${resolvedProjectDir}`,
|
||||
watch: `ktx setup context watch${runIdArg} --project-dir ${resolvedProjectDir}`,
|
||||
status: `ktx setup context status${runIdArg} --project-dir ${resolvedProjectDir}`,
|
||||
stop: `ktx setup context stop${runIdArg} --project-dir ${resolvedProjectDir}`,
|
||||
build: `ktx setup --project-dir ${resolvedProjectDir}`,
|
||||
watch: `ktx setup --project-dir ${resolvedProjectDir}`,
|
||||
status: `ktx status --project-dir ${resolvedProjectDir}`,
|
||||
stop: `ktx setup --project-dir ${resolvedProjectDir}`,
|
||||
resume: `ktx setup --project-dir ${resolvedProjectDir}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -498,7 +497,7 @@ function writeSkippedContext(projectDir: string, io: KtxCliIo): void {
|
|||
io.stdout.write('\nKTX is configured, but context has not been built yet.\n\n');
|
||||
io.stdout.write('Agents were not connected because KTX has not prepared searchable context for them.\n\n');
|
||||
io.stdout.write(`Resume setup:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`);
|
||||
io.stdout.write(`Build context directly:\n ktx setup context build --project-dir ${resolve(projectDir)}\n\n`);
|
||||
io.stdout.write(`Build context:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`);
|
||||
io.stdout.write(`Check status:\n ktx status --project-dir ${resolve(projectDir)}\n`);
|
||||
}
|
||||
|
||||
|
|
@ -726,7 +725,6 @@ export async function runKtxSetupContextStep(
|
|||
if (args.autoWatch) {
|
||||
const watched = await watchContextStatus(
|
||||
{
|
||||
command: 'watch',
|
||||
projectDir: args.projectDir,
|
||||
...(existingState.runId ? { runId: existingState.runId } : {}),
|
||||
inputMode: args.inputMode,
|
||||
|
|
@ -752,7 +750,6 @@ export async function runKtxSetupContextStep(
|
|||
if (choice === 'watch') {
|
||||
const watched = await watchContextStatus(
|
||||
{
|
||||
command: 'watch',
|
||||
projectDir: args.projectDir,
|
||||
...(existingState.runId ? { runId: existingState.runId } : {}),
|
||||
inputMode: args.inputMode,
|
||||
|
|
@ -833,10 +830,6 @@ function defaultSleep(ms: number): Promise<void> {
|
|||
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
||||
}
|
||||
|
||||
function statusPayload(state: KtxSetupContextState): KtxSetupContextStatusSummary {
|
||||
return setupContextStatusFromState(state, { completedStep: state.status === 'completed' });
|
||||
}
|
||||
|
||||
function writeContextStatus(state: KtxSetupContextState, io: KtxCliIo): void {
|
||||
io.stdout.write(`KTX context built: ${state.status === 'completed' ? 'yes' : state.status.replaceAll('_', ' ')}\n`);
|
||||
if (state.runId) {
|
||||
|
|
@ -850,7 +843,7 @@ function writeContextStatus(state: KtxSetupContextState, io: KtxCliIo): void {
|
|||
}
|
||||
|
||||
async function watchContextStatus(
|
||||
args: Extract<KtxSetupContextCommandArgs, { command: 'watch' }>,
|
||||
args: KtxSetupContextWatchArgs,
|
||||
initialState: KtxSetupContextState,
|
||||
io: KtxCliIo,
|
||||
deps: KtxSetupContextDeps,
|
||||
|
|
@ -862,7 +855,7 @@ async function watchContextStatus(
|
|||
}
|
||||
|
||||
async function watchContextStatusText(
|
||||
args: Extract<KtxSetupContextCommandArgs, { command: 'watch' }>,
|
||||
args: KtxSetupContextWatchArgs,
|
||||
initialState: KtxSetupContextState,
|
||||
io: KtxCliIo,
|
||||
deps: KtxSetupContextDeps,
|
||||
|
|
@ -894,7 +887,7 @@ async function watchContextStatusText(
|
|||
}
|
||||
|
||||
async function watchContextStatusWithProgressView(
|
||||
args: Extract<KtxSetupContextCommandArgs, { command: 'watch' }>,
|
||||
args: KtxSetupContextWatchArgs,
|
||||
initialState: KtxSetupContextState,
|
||||
io: KtxCliIo,
|
||||
deps: KtxSetupContextDeps,
|
||||
|
|
@ -975,7 +968,7 @@ async function watchContextStatusWithProgressView(
|
|||
|
||||
io.stdout.write('\n\nContext build continuing in the background.\n');
|
||||
io.stdout.write(`Resume: ktx setup --project-dir ${projectDir}\n`);
|
||||
io.stdout.write(`Status: ktx setup context status --project-dir ${projectDir}\n`);
|
||||
io.stdout.write(`Status: ktx status --project-dir ${projectDir}\n`);
|
||||
return { exitCode: 0, state };
|
||||
}
|
||||
|
||||
|
|
@ -991,51 +984,3 @@ function setupResultFromWatchedState(projectDir: string, state: KtxSetupContextS
|
|||
}
|
||||
return { status: 'failed', projectDir };
|
||||
}
|
||||
|
||||
export async function runKtxSetupContextCommand(
|
||||
args: KtxSetupContextCommandArgs,
|
||||
io: KtxCliIo,
|
||||
deps: KtxSetupContextDeps = {},
|
||||
): Promise<number> {
|
||||
if (args.command === 'build') {
|
||||
const result = await runKtxSetupContextStep(
|
||||
{ projectDir: args.projectDir, inputMode: args.inputMode, prompt: false },
|
||||
io,
|
||||
deps,
|
||||
);
|
||||
return result.status === 'ready' || result.status === 'skipped' ? 0 : 1;
|
||||
}
|
||||
|
||||
const state = await readKtxSetupContextState(args.projectDir);
|
||||
if (!stateMatchesRunId(state, args.runId)) {
|
||||
io.stderr.write(`KTX setup context run "${args.runId}" was not found.\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (args.command === 'status') {
|
||||
if (args.json) {
|
||||
io.stdout.write(`${JSON.stringify(statusPayload(state), null, 2)}\n`);
|
||||
} else {
|
||||
writeContextStatus(state, io);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'watch') {
|
||||
return (await watchContextStatus(args, state, io, deps)).exitCode;
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
const nextState: KtxSetupContextState = {
|
||||
...state,
|
||||
status: state.status === 'completed' ? 'completed' : 'paused',
|
||||
updatedAt,
|
||||
};
|
||||
await writeKtxSetupContextState(args.projectDir, nextState);
|
||||
io.stdout.write(
|
||||
state.status === 'completed'
|
||||
? 'KTX context build already completed.\n'
|
||||
: 'KTX context build pause requested. Resume with setup when ready.\n',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ function createDemoTarget(
|
|||
driver,
|
||||
operation,
|
||||
...(adapter ? { adapter } : {}),
|
||||
debugCommand: `ktx setup context build --target ${connectionId}`,
|
||||
debugCommand: `ktx setup --project-dir <project-dir>`,
|
||||
steps: operation === 'scan'
|
||||
? ['scan', 'enrich', 'memory-update']
|
||||
: ['source-ingest', 'enrich', 'memory-update'],
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
|
||||
import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
|
||||
import { runDemoTour } from './setup-demo-tour.js';
|
||||
import { readKtxSetupStatus, runKtxSetup } from './setup.js';
|
||||
import { formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js';
|
||||
|
||||
vi.mock('./setup-demo-tour.js', () => ({
|
||||
runDemoTour: vi.fn(async () => 0),
|
||||
|
|
@ -310,8 +310,8 @@ describe('setup status', () => {
|
|||
ready: false,
|
||||
status: 'running',
|
||||
runId: 'setup-context-local-abc123',
|
||||
watchCommand: `ktx setup context watch setup-context-local-abc123 --project-dir ${tempDir}`,
|
||||
statusCommand: `ktx setup context status setup-context-local-abc123 --project-dir ${tempDir}`,
|
||||
watchCommand: `ktx setup --project-dir ${tempDir}`,
|
||||
statusCommand: `ktx status --project-dir ${tempDir}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -363,44 +363,36 @@ describe('setup status', () => {
|
|||
);
|
||||
|
||||
const status = await readKtxSetupStatus(tempDir);
|
||||
const io = makeIo();
|
||||
await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: false }, io.io)).resolves.toBe(0);
|
||||
const rendered = formatKtxSetupStatus(status);
|
||||
|
||||
expect(status.llm).toMatchObject({ backend: 'vertex', ready: true, model: 'claude-sonnet-4-6' });
|
||||
expect(status.context).toMatchObject({ ready: true, status: 'completed' });
|
||||
expect(io.stdout()).toContain('LLM ready: yes (claude-sonnet-4-6)');
|
||||
expect(io.stdout()).toContain('KTX context built: yes');
|
||||
expect(rendered).toContain('LLM ready: yes (claude-sonnet-4-6)');
|
||||
expect(rendered).toContain('KTX context built: yes');
|
||||
});
|
||||
|
||||
it('prints plain and JSON setup status', async () => {
|
||||
const plainIo = makeIo();
|
||||
const jsonIo = makeIo();
|
||||
it('formats plain and JSON setup status payloads', async () => {
|
||||
const status = await readKtxSetupStatus(tempDir);
|
||||
const rendered = formatKtxSetupStatus(status);
|
||||
|
||||
await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: false }, plainIo.io)).resolves.toBe(0);
|
||||
await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: true }, jsonIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(plainIo.stdout()).toContain(`No KTX project found at ${tempDir}.`);
|
||||
expect(plainIo.stdout()).toContain('Check another project: ktx --project-dir <folder> setup status');
|
||||
expect(plainIo.stdout()).toContain('Or from that folder: ktx setup status');
|
||||
expect(plainIo.stdout()).toContain('Create a new KTX project here: ktx setup');
|
||||
expect(plainIo.stdout()).not.toContain('Project ready: no');
|
||||
expect(JSON.parse(jsonIo.stdout())).toMatchObject({ project: { path: tempDir, ready: false } });
|
||||
expect(plainIo.stderr()).toBe('');
|
||||
expect(jsonIo.stderr()).toBe('');
|
||||
expect(rendered).toContain(`No KTX project found at ${tempDir}.`);
|
||||
expect(rendered).toContain('Check another project: ktx --project-dir <folder> status');
|
||||
expect(rendered).toContain('Or from that folder: ktx status');
|
||||
expect(rendered).toContain('Create a new KTX project here: ktx setup');
|
||||
expect(rendered).not.toContain('Project ready: no');
|
||||
expect(JSON.parse(JSON.stringify(status))).toMatchObject({ project: { path: tempDir, ready: false } });
|
||||
});
|
||||
|
||||
it('prints the readiness checklist for an existing project', async () => {
|
||||
const testIo = makeIo();
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
|
||||
|
||||
await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: false }, testIo.io)).resolves.toBe(0);
|
||||
const rendered = formatKtxSetupStatus(await readKtxSetupStatus(tempDir));
|
||||
|
||||
expect(testIo.stdout()).toContain(`KTX project: ${tempDir}`);
|
||||
expect(testIo.stdout()).toContain('Project ready: yes');
|
||||
expect(testIo.stdout()).toContain('LLM ready: no');
|
||||
expect(testIo.stdout()).toContain('KTX context built: no');
|
||||
expect(testIo.stdout()).not.toContain('No KTX project found.');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
expect(rendered).toContain(`KTX project: ${tempDir}`);
|
||||
expect(rendered).toContain('Project ready: yes');
|
||||
expect(rendered).toContain('LLM ready: no');
|
||||
expect(rendered).toContain('KTX context built: no');
|
||||
expect(rendered).not.toContain('No KTX project found.');
|
||||
});
|
||||
|
||||
it('prints the setup shell intro for auto-created run mode', async () => {
|
||||
|
|
@ -2030,17 +2022,6 @@ describe('setup status', () => {
|
|||
expect(agents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes agent integrations through setup remove command', async () => {
|
||||
const io = makeIo();
|
||||
const removeAgents = vi.fn(async () => 0);
|
||||
|
||||
await expect(runKtxSetup({ command: 'remove-agents', projectDir: tempDir }, io.io, { removeAgents })).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
expect(removeAgents).toHaveBeenCalledWith(tempDir, io.io);
|
||||
});
|
||||
|
||||
it('does not run embedding setup when the model step fails', async () => {
|
||||
const testIo = makeIo();
|
||||
const model = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir }));
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
type KtxAgentTarget,
|
||||
type KtxSetupAgentsDeps,
|
||||
readKtxAgentInstallManifest,
|
||||
removeKtxAgentInstall,
|
||||
runKtxSetupAgentsStep,
|
||||
} from './setup-agents.js';
|
||||
import {
|
||||
|
|
@ -32,8 +31,6 @@ import { type KtxSetupSourcesDeps, type KtxSetupSourceType, runKtxSetupSourcesSt
|
|||
import { withMenuOptionsSpacing } from './prompt-navigation.js';
|
||||
import {
|
||||
readKtxSetupContextState,
|
||||
runKtxSetupContextCommand,
|
||||
type KtxSetupContextCommandArgs,
|
||||
type KtxSetupContextDeps,
|
||||
type KtxSetupContextResult,
|
||||
runKtxSetupContextStep,
|
||||
|
|
@ -105,13 +102,7 @@ export type KtxSetupArgs =
|
|||
runInitialSourceIngest?: boolean;
|
||||
skipSources?: boolean;
|
||||
showEntryMenu?: boolean;
|
||||
}
|
||||
| { command: 'status'; projectDir: string; json: boolean }
|
||||
| { command: 'context-build'; projectDir: string; inputMode: 'auto' | 'disabled' }
|
||||
| { command: 'context-watch'; projectDir: string; runId?: string; inputMode: 'auto' | 'disabled' }
|
||||
| { command: 'context-status'; projectDir: string; runId?: string; json: boolean }
|
||||
| { command: 'context-stop'; projectDir: string; runId?: string }
|
||||
| { command: 'remove-agents'; projectDir: string };
|
||||
};
|
||||
|
||||
export interface KtxSetupDeps {
|
||||
project?: KtxSetupProjectDeps;
|
||||
|
|
@ -142,7 +133,6 @@ export interface KtxSetupDeps {
|
|||
agentsDeps?: KtxSetupAgentsDeps;
|
||||
context?: (args: Parameters<typeof runKtxSetupContextStep>[0], io: KtxCliIo) => Promise<KtxSetupContextResult>;
|
||||
contextDeps?: KtxSetupContextDeps;
|
||||
removeAgents?: typeof removeKtxAgentInstall;
|
||||
readyMenuDeps?: KtxSetupReadyMenuDeps;
|
||||
entryMenuDeps?: KtxSetupEntryMenuDeps;
|
||||
}
|
||||
|
|
@ -358,8 +348,8 @@ export function formatKtxSetupStatus(status: KtxSetupStatus): string {
|
|||
return [
|
||||
`No KTX project found at ${status.project.path}.`,
|
||||
'',
|
||||
'Check another project: ktx --project-dir <folder> setup status',
|
||||
'Or from that folder: ktx setup status',
|
||||
'Check another project: ktx --project-dir <folder> status',
|
||||
'Or from that folder: ktx status',
|
||||
'Create a new KTX project here: ktx setup',
|
||||
'',
|
||||
].join('\n');
|
||||
|
|
@ -383,9 +373,7 @@ export function formatKtxSetupStatus(status: KtxSetupStatus): string {
|
|||
lines.push(`Resume: ${status.context.watchCommand}`);
|
||||
}
|
||||
if (!status.context.ready && status.context.status === 'failed' && status.context.detail) {
|
||||
lines.push(
|
||||
`Retry: ${status.context.retryCommand ?? `ktx setup context build --project-dir ${status.project.path}`}`,
|
||||
);
|
||||
lines.push(`Retry: ${status.context.retryCommand ?? `ktx setup --project-dir ${status.project.path}`}`);
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
|
|
@ -420,7 +408,7 @@ function setupContextActive(status: KtxSetupStatus): boolean {
|
|||
|
||||
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 context build --project-dir ${resolve(projectDir)}\n\n`);
|
||||
io.stderr.write(`Build context first:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`);
|
||||
io.stderr.write(`Then install agent integration:\n ktx setup --agents --project-dir ${resolve(projectDir)}\n`);
|
||||
}
|
||||
|
||||
|
|
@ -448,43 +436,6 @@ export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSet
|
|||
}
|
||||
|
||||
async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> {
|
||||
if (args.command === 'remove-agents') {
|
||||
return await (deps.removeAgents ?? removeKtxAgentInstall)(args.projectDir, io);
|
||||
}
|
||||
|
||||
if (
|
||||
args.command === 'context-build' ||
|
||||
args.command === 'context-watch' ||
|
||||
args.command === 'context-status' ||
|
||||
args.command === 'context-stop'
|
||||
) {
|
||||
const commandArgs: KtxSetupContextCommandArgs =
|
||||
args.command === 'context-build'
|
||||
? { command: 'build', projectDir: args.projectDir, inputMode: args.inputMode }
|
||||
: args.command === 'context-watch'
|
||||
? {
|
||||
command: 'watch',
|
||||
projectDir: args.projectDir,
|
||||
...(args.runId ? { runId: args.runId } : {}),
|
||||
inputMode: args.inputMode,
|
||||
}
|
||||
: args.command === 'context-status'
|
||||
? {
|
||||
command: 'status',
|
||||
projectDir: args.projectDir,
|
||||
...(args.runId ? { runId: args.runId } : {}),
|
||||
json: args.json,
|
||||
}
|
||||
: { command: 'stop', projectDir: args.projectDir, ...(args.runId ? { runId: args.runId } : {}) };
|
||||
return await runKtxSetupContextCommand(commandArgs, io, deps.contextDeps);
|
||||
}
|
||||
|
||||
if (args.command === 'status') {
|
||||
const status = await readKtxSetupStatus(args.projectDir);
|
||||
io.stdout.write(args.json ? `${JSON.stringify(status, null, 2)}\n` : formatKtxSetupStatus(status));
|
||||
return 0;
|
||||
}
|
||||
|
||||
io.stdout.write('KTX setup\n');
|
||||
let entryAction: KtxSetupEntryAction | undefined;
|
||||
let projectResult: Awaited<ReturnType<typeof runKtxSetupProjectStep>>;
|
||||
|
|
|
|||
|
|
@ -199,76 +199,6 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('runs the default pre-seeded demo without credentials', async () => {
|
||||
const projectDir = join(tempDir, 'demo-project');
|
||||
const result = await runBuiltCli(
|
||||
['setup', 'demo', '--project-dir', projectDir, '--plain', '--no-input'],
|
||||
{
|
||||
env: { ...process.env, ANTHROPIC_API_KEY: '' },
|
||||
},
|
||||
);
|
||||
|
||||
expectProjectStderr(result, projectDir);
|
||||
expect(result.stdout).toContain('Mode: seeded');
|
||||
expect(result.stdout).toContain('Source: packaged demo project');
|
||||
expect(result.stdout).toContain('LLM calls: none');
|
||||
expect(result.stdout).toContain('Warehouse:');
|
||||
expect(result.stdout).toContain('dbt:');
|
||||
expect(result.stdout).toContain('BI:');
|
||||
expect(result.stdout).toContain('Notion:');
|
||||
expect(result.stdout).toContain('Semantic-layer sources:');
|
||||
expect(result.stdout).toContain('Knowledge pages:');
|
||||
expect(result.stdout).not.toContain('ktx serve --mcp stdio');
|
||||
expect(result.stdout).not.toContain(['--mode', 'deterministic'].join(' '));
|
||||
});
|
||||
|
||||
it('runs hybrid agent search against the seeded demo through the built binary', async () => {
|
||||
const projectDir = join(tempDir, 'seeded-hybrid-search-project');
|
||||
|
||||
const seeded = await runBuiltCli(['setup', 'demo', '--project-dir', projectDir, '--plain', '--no-input'], {
|
||||
env: { ...process.env, ANTHROPIC_API_KEY: '' },
|
||||
});
|
||||
expectProjectStderr(seeded, projectDir);
|
||||
expect(seeded.stdout).toContain('Mode: seeded');
|
||||
|
||||
const wikiSearch = await runBuiltCli([
|
||||
'agent',
|
||||
'wiki',
|
||||
'search',
|
||||
'ARR contract',
|
||||
'--json',
|
||||
'--limit',
|
||||
'5',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
]);
|
||||
expect(wikiSearch).toMatchObject({ code: 0, stderr: '' });
|
||||
const wikiJson = parseJsonOutput<{
|
||||
results: Array<{ key: string; score: number; matchReasons?: string[] }>;
|
||||
totalFound: number;
|
||||
}>(wikiSearch.stdout);
|
||||
expect(wikiJson.totalFound).toBeGreaterThan(0);
|
||||
expect(wikiJson.results.some((result) => result.matchReasons?.length)).toBe(true);
|
||||
|
||||
const slSearch = await runBuiltCli([
|
||||
'agent',
|
||||
'sl',
|
||||
'list',
|
||||
'--json',
|
||||
'--query',
|
||||
'ARR',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
]);
|
||||
expect(slSearch).toMatchObject({ code: 0, stderr: '' });
|
||||
const slJson = parseJsonOutput<{
|
||||
sources: Array<{ connectionId: string; name: string; score?: number; matchReasons?: string[] }>;
|
||||
totalSources: number;
|
||||
}>(slSearch.stdout);
|
||||
expect(slJson.totalSources).toBeGreaterThan(0);
|
||||
expect(slJson.sources.some((source) => source.matchReasons?.length)).toBe(true);
|
||||
});
|
||||
|
||||
it('prints guided JSON for agent semantic-layer search outside a project through the built binary', async () => {
|
||||
const projectDir = join(tempDir, 'missing-search-project');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
|
|
@ -296,8 +226,8 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KTX project at ${projectDir}.`,
|
||||
nextSteps: [
|
||||
'ktx demo',
|
||||
`ktx setup --project-dir ${projectDir}`,
|
||||
`ktx status --project-dir ${projectDir}`,
|
||||
'ktx ingest <connection>',
|
||||
`ktx agent sl list --json --query "revenue" --project-dir ${projectDir}`,
|
||||
],
|
||||
|
|
@ -305,37 +235,6 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('runs the pre-seeded demo and inspect without credentials', async () => {
|
||||
const projectDir = join(tempDir, 'seeded-demo-project');
|
||||
|
||||
const seeded = await runBuiltCli(['setup', 'demo', '--mode', 'seeded', '--project-dir', projectDir, '--no-input']);
|
||||
expect(seeded.code).toBe(0);
|
||||
expect(seeded.stdout).toContain('Mode: seeded');
|
||||
expect(seeded.stdout).toContain('LLM calls: none');
|
||||
expect(seeded.stdout).toContain('Semantic-layer sources:');
|
||||
expect(seeded.stdout).toContain('Knowledge pages:');
|
||||
|
||||
const inspect = await runBuiltCli(['setup', 'demo', 'inspect', '--project-dir', projectDir, '--no-input']);
|
||||
expectProjectStderr(inspect, projectDir);
|
||||
expect(inspect.stdout).toContain('Mode: seeded');
|
||||
expect(inspect.stdout).toContain('Status: ready');
|
||||
expect(inspect.stdout).toContain('Warehouse: 8 tables, 11,234 rows');
|
||||
expect(inspect.stdout).toContain('Rows: accounts 210, arr_movements 720');
|
||||
expect(inspect.stdout).toContain('dbt: 3 models, 8 source tables');
|
||||
expect(inspect.stdout).toContain('BI: 5 explores, 2 dashboards');
|
||||
expect(inspect.stdout).toContain('Notion: 8 pages');
|
||||
expect(inspect.stdout).toContain('Semantic-layer sources:');
|
||||
expect(inspect.stdout).toContain('Knowledge pages:');
|
||||
expect(inspect.stdout).toContain('Evidence links:');
|
||||
expect(inspect.stdout).toContain('Report: reports/seeded-demo-report.json');
|
||||
expect(inspect.stdout).toContain('Replay: replays/replay.memory-flow.v1.json');
|
||||
expect(inspect.stdout).toContain('Latest replay: seeded (packaged, prebuilt)');
|
||||
expect(inspect.stdout).toContain('ktx agent tools --json');
|
||||
expect(inspect.stdout).toContain('ktx agent context --json');
|
||||
expect(inspect.stdout).not.toContain('ktx ask "your question here"');
|
||||
expect(inspect.stdout).not.toContain('ktx serve --mcp stdio');
|
||||
});
|
||||
|
||||
it('runs doctor setup through the built binary', async () => {
|
||||
const result = await runBuiltCli(['status', '--no-input']);
|
||||
|
||||
|
|
@ -346,90 +245,6 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
expect([0, 1]).toContain(result.code);
|
||||
});
|
||||
|
||||
it('reports missing Anthropic credentials for full demo through the built binary', async () => {
|
||||
const projectDir = join(tempDir, 'full-demo-missing-key');
|
||||
|
||||
const result = await runBuiltCli(['setup', 'demo', '--mode', 'full', '--project-dir', projectDir, '--no-input'], {
|
||||
env: { ...process.env, ANTHROPIC_API_KEY: '' },
|
||||
});
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('ktx setup demo --mode full needs ANTHROPIC_API_KEY');
|
||||
expect(result.stderr).toContain('ktx setup demo --mode seeded --no-input');
|
||||
});
|
||||
|
||||
it('requires force for demo reset through the built binary', async () => {
|
||||
const projectDir = join(tempDir, 'reset-demo-project');
|
||||
|
||||
const init = await runBuiltCli(['setup', 'demo', 'init', '--project-dir', projectDir, '--no-input']);
|
||||
expectProjectStderr(init, projectDir);
|
||||
|
||||
const withoutForce = await runBuiltCli(['setup', 'demo', 'reset', '--project-dir', projectDir, '--no-input']);
|
||||
expect(withoutForce.code).toBe(1);
|
||||
expect(withoutForce.stderr).toContain(
|
||||
`ktx setup demo reset is destructive; pass --force to recreate ${projectDir}`,
|
||||
);
|
||||
|
||||
const withForce = await runBuiltCli([
|
||||
'setup',
|
||||
'demo',
|
||||
'reset',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--force',
|
||||
'--no-input',
|
||||
]);
|
||||
expectProjectStderr(withForce, projectDir);
|
||||
expect(withForce.stdout).toContain(`Demo project reset: ${projectDir}`);
|
||||
});
|
||||
|
||||
it('reports corrupted demo state with reset guidance through the built binary', async () => {
|
||||
const projectDir = join(tempDir, 'corrupt-demo-project');
|
||||
|
||||
const init = await runBuiltCli(['setup', 'demo', 'init', '--project-dir', projectDir, '--no-input']);
|
||||
expectProjectStderr(init, projectDir);
|
||||
await rm(join(projectDir, 'demo.db'), { force: true });
|
||||
|
||||
const replay = await runBuiltCli(['setup', 'demo', '--mode', 'replay', '--project-dir', projectDir, '--no-input']);
|
||||
expect(replay.code).toBe(1);
|
||||
expect(replay.stderr).toContain(`Demo project is not ready at ${projectDir}: missing demo.db`);
|
||||
expect(replay.stderr).toContain(`ktx setup demo reset --project-dir ${projectDir} --force --no-input`);
|
||||
});
|
||||
|
||||
it('runs demo doctor through the built binary', async () => {
|
||||
const projectDir = join(tempDir, 'doctor-demo-project');
|
||||
|
||||
const init = await runBuiltCli(['setup', 'demo', 'init', '--project-dir', projectDir, '--no-input']);
|
||||
expectProjectStderr(init, projectDir);
|
||||
|
||||
const result = await runBuiltCli(['setup', 'demo', 'doctor', '--project-dir', projectDir, '--no-input']);
|
||||
expect(result.stdout).toContain('KTX demo doctor');
|
||||
expect(result.stdout).toContain('Demo dataset');
|
||||
expect(result.stdout).toContain('Demo replay');
|
||||
expect(result.stdout).toContain('Demo LLM provider');
|
||||
expect(result.stderr).toBe(`Project: ${projectDir}\n`);
|
||||
expect([0, 1]).toContain(result.code);
|
||||
});
|
||||
|
||||
it('runs demo ingest seeded mode through the built binary', async () => {
|
||||
const projectDir = join(tempDir, 'seeded-ingest-alias');
|
||||
|
||||
const result = await runBuiltCli([
|
||||
'setup',
|
||||
'demo',
|
||||
'ingest',
|
||||
'--mode',
|
||||
'seeded',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--no-input',
|
||||
]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('Mode: seeded');
|
||||
expect(result.stdout).toContain('LLM calls: none');
|
||||
});
|
||||
|
||||
it('runs structural and enriched scans through the built binary with manifest artifacts', async () => {
|
||||
const projectDir = join(tempDir, 'scan-project');
|
||||
const init = await runSetupNewProject(projectDir);
|
||||
|
|
|
|||
|
|
@ -618,8 +618,8 @@ try {
|
|||
const missingProjectError = parseJsonFailure('ktx agent sl list missing project', missingProjectSearch);
|
||||
assert.equal(missingProjectError.error.code, 'agent_sl_search_missing_project');
|
||||
assert.deepEqual(missingProjectError.error.nextSteps, [
|
||||
'ktx demo',
|
||||
'ktx setup --project-dir ' + missingProjectDir,
|
||||
'ktx status --project-dir ' + missingProjectDir,
|
||||
'ktx ingest <connection>',
|
||||
'ktx agent sl list --json --query "revenue" --project-dir ' + missingProjectDir,
|
||||
]);
|
||||
|
|
@ -675,8 +675,8 @@ try {
|
|||
const emptySearchError = parseJsonFailure('ktx agent sl list no connections', emptySearch);
|
||||
assert.equal(emptySearchError.error.code, 'agent_sl_search_no_connections');
|
||||
assert.deepEqual(emptySearchError.error.nextSteps, [
|
||||
'ktx demo',
|
||||
'ktx setup --project-dir ' + emptyProjectDir,
|
||||
'ktx status --project-dir ' + emptyProjectDir,
|
||||
'ktx ingest <connection>',
|
||||
'ktx agent sl list --json --query "revenue" --project-dir ' + emptyProjectDir,
|
||||
]);
|
||||
|
|
@ -766,8 +766,8 @@ try {
|
|||
const noSourceSearchError = parseJsonFailure('ktx agent sl list no indexed sources', noSourceSearch);
|
||||
assert.equal(noSourceSearchError.error.code, 'agent_sl_search_no_indexed_sources');
|
||||
assert.deepEqual(noSourceSearchError.error.nextSteps, [
|
||||
'ktx demo',
|
||||
'ktx setup --project-dir ' + projectDir,
|
||||
'ktx status --project-dir ' + projectDir,
|
||||
'ktx ingest <connection>',
|
||||
'ktx agent sl list --json --query "revenue" --project-dir ' + projectDir,
|
||||
]);
|
||||
|
|
@ -1004,7 +1004,7 @@ try {
|
|||
`;
|
||||
}
|
||||
|
||||
export function npmDemoSmokeSource() {
|
||||
export function npmCliSmokeSource() {
|
||||
return `
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
|
|
@ -1046,18 +1046,8 @@ function requireStdout(label, result, pattern) {
|
|||
assert.match(result.stdout, pattern, label + ' stdout did not match ' + pattern);
|
||||
}
|
||||
|
||||
function requireProjectStderr(label, result, projectDir) {
|
||||
assert.equal(
|
||||
result.code,
|
||||
0,
|
||||
label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr,
|
||||
);
|
||||
assert.equal(result.stderr, 'Project: ' + projectDir + '\\n', label + ' wrote unexpected stderr');
|
||||
}
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-packed-demo-smoke-'));
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-cli-smoke-'));
|
||||
try {
|
||||
const projectDir = join(root, 'demo-project');
|
||||
const packageJson = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8'));
|
||||
assert.deepEqual(Object.keys(packageJson.dependencies), ['@kaelio/ktx']);
|
||||
|
||||
|
|
@ -1066,61 +1056,10 @@ try {
|
|||
requireStdout('ktx --help', help, /Usage: ktx/);
|
||||
requireStdout('ktx --help', help, /setup/);
|
||||
|
||||
const seeded = await run(
|
||||
'pnpm',
|
||||
['exec', 'ktx', 'setup', 'demo', '--project-dir', projectDir, '--no-input', '--plain'],
|
||||
);
|
||||
requireSuccess('ktx setup demo seeded', seeded);
|
||||
requireStdout('ktx setup demo seeded', seeded, /Mode: seeded/);
|
||||
requireStdout('ktx setup demo seeded', seeded, /Source: packaged demo project/);
|
||||
requireStdout('ktx setup demo seeded', seeded, /LLM calls: none/);
|
||||
requireStdout('ktx setup demo seeded', seeded, /ktx agent context --json/);
|
||||
assert.doesNotMatch(seeded.stdout, new RegExp(['--mode', 'deterministic'].join(' ')));
|
||||
assert.doesNotMatch(seeded.stdout, /KTX memory flow/);
|
||||
requireProjectStderr('ktx setup demo seeded', seeded, projectDir);
|
||||
|
||||
const demoWikiSearch = await run('pnpm', [
|
||||
'exec',
|
||||
'ktx',
|
||||
'agent',
|
||||
'wiki',
|
||||
'search',
|
||||
'ARR contract',
|
||||
'--json',
|
||||
'--limit',
|
||||
'5',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
]);
|
||||
requireSuccess('ktx seeded demo agent wiki search', demoWikiSearch);
|
||||
const demoWikiSearchJson = JSON.parse(demoWikiSearch.stdout);
|
||||
assert.ok(demoWikiSearchJson.totalFound > 0, 'seeded demo wiki search should find results');
|
||||
assert.ok(
|
||||
demoWikiSearchJson.results.some((result) => Array.isArray(result.matchReasons) && result.matchReasons.length > 0),
|
||||
'seeded demo wiki search should expose match reasons',
|
||||
);
|
||||
process.stdout.write('ktx seeded demo agent wiki search verified\\n');
|
||||
|
||||
const demoSlSearch = await run('pnpm', [
|
||||
'exec',
|
||||
'ktx',
|
||||
'agent',
|
||||
'sl',
|
||||
'list',
|
||||
'--json',
|
||||
'--query',
|
||||
'ARR',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
]);
|
||||
requireSuccess('ktx seeded demo agent sl search', demoSlSearch);
|
||||
const demoSlSearchJson = JSON.parse(demoSlSearch.stdout);
|
||||
assert.ok(demoSlSearchJson.totalSources > 0, 'seeded demo semantic-layer search should find sources');
|
||||
assert.ok(
|
||||
demoSlSearchJson.sources.some((source) => Array.isArray(source.matchReasons) && source.matchReasons.length > 0),
|
||||
'seeded demo semantic-layer search should expose match reasons',
|
||||
);
|
||||
process.stdout.write('ktx seeded demo agent sl search verified\\n');
|
||||
const setupHelp = await run('pnpm', ['exec', 'ktx', 'setup', '--help']);
|
||||
requireSuccess('ktx setup --help', setupHelp);
|
||||
requireStdout('ktx setup --help', setupHelp, /Usage: ktx setup/);
|
||||
requireStdout('ktx setup --help', setupHelp, /--no-input/);
|
||||
|
||||
const doctor = await run('pnpm', ['exec', 'ktx', 'status', '--no-input']);
|
||||
assert.ok([0, 1].includes(doctor.code), 'ktx status setup exit code must be 0 or 1');
|
||||
|
|
@ -1174,28 +1113,28 @@ async function verifyNpmArtifacts(layout, tmpRoot) {
|
|||
);
|
||||
await writeFile(join(projectDir, 'verify-npm.mjs'), npmVerifySource());
|
||||
await writeFile(join(projectDir, 'verify-installed-cli.mjs'), npmRuntimeSmokeSource());
|
||||
await writeFile(join(projectDir, 'verify-installed-demo.mjs'), npmDemoSmokeSource());
|
||||
await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource());
|
||||
|
||||
await runCommand('pnpm', ['install'], { cwd: projectDir });
|
||||
await runCommand('pnpm', ['rebuild', 'better-sqlite3'], { cwd: projectDir });
|
||||
await runCommand('node', ['verify-npm.mjs'], { cwd: projectDir });
|
||||
await runCommand('pnpm', ['exec', 'ktx', '--version'], { cwd: projectDir });
|
||||
await runCommand('node', ['verify-installed-cli.mjs'], { cwd: projectDir });
|
||||
await runCommand('node', ['verify-installed-demo.mjs'], { cwd: projectDir });
|
||||
await runCommand('node', ['verify-installed-cli-commands.mjs'], { cwd: projectDir });
|
||||
}
|
||||
|
||||
async function verifyNpmDemoArtifacts(layout, tmpRoot) {
|
||||
async function verifyNpmCliArtifacts(layout, tmpRoot) {
|
||||
for (const packageInfo of NPM_ARTIFACT_PACKAGES) {
|
||||
await assertPathExists(layout.npmTarballs[packageInfo.name], `${packageInfo.name} tarball`);
|
||||
}
|
||||
|
||||
const projectDir = join(tmpRoot, 'npm-demo-clean-install');
|
||||
const projectDir = join(tmpRoot, 'npm-cli-clean-install');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
await writeFile(join(projectDir, 'package.json'), `${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`);
|
||||
await writeFile(join(projectDir, 'verify-installed-demo.mjs'), npmDemoSmokeSource());
|
||||
await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource());
|
||||
|
||||
await runCommand('pnpm', ['install'], { cwd: projectDir });
|
||||
await runCommand('node', ['verify-installed-demo.mjs'], { cwd: projectDir });
|
||||
await runCommand('node', ['verify-installed-cli-commands.mjs'], { cwd: projectDir });
|
||||
}
|
||||
|
||||
async function verifyArtifacts(layout) {
|
||||
|
|
@ -1209,12 +1148,12 @@ async function verifyArtifacts(layout) {
|
|||
}
|
||||
}
|
||||
|
||||
async function verifyDemoArtifacts(layout) {
|
||||
async function verifyCliArtifacts(layout) {
|
||||
await verifyArtifactManifest(layout);
|
||||
|
||||
const tmpRoot = await mkdtemp(join(tmpdir(), 'ktx-demo-artifacts-'));
|
||||
const tmpRoot = await mkdtemp(join(tmpdir(), 'ktx-cli-artifacts-'));
|
||||
try {
|
||||
await verifyNpmDemoArtifacts(layout, tmpRoot);
|
||||
await verifyNpmCliArtifacts(layout, tmpRoot);
|
||||
} finally {
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -1233,7 +1172,7 @@ async function main() {
|
|||
return;
|
||||
}
|
||||
if (command === 'verify-demo') {
|
||||
await verifyDemoArtifacts(layout);
|
||||
await verifyCliArtifacts(layout);
|
||||
return;
|
||||
}
|
||||
if (command === 'verify-manifest') {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
copyRuntimeWheelAssets,
|
||||
findPythonArtifacts,
|
||||
NPM_ARTIFACT_PACKAGES,
|
||||
npmDemoSmokeSource,
|
||||
npmCliSmokeSource,
|
||||
npmRuntimeSmokeSource,
|
||||
npmSmokePackageJson,
|
||||
npmVerifySource,
|
||||
|
|
@ -386,9 +386,9 @@ describe('verifyNpmArtifacts', () => {
|
|||
it('does not prepare an external Python environment for the npm smoke', async () => {
|
||||
const source = await readFile(new URL('./package-artifacts.mjs', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('async function verifyNpmArtifacts');
|
||||
const end = source.indexOf('async function verifyNpmDemoArtifacts');
|
||||
const end = source.indexOf('async function verifyNpmCliArtifacts');
|
||||
assert.ok(start > 0, 'verifyNpmArtifacts function must exist');
|
||||
assert.ok(end > start, 'verifyNpmDemoArtifacts must follow verifyNpmArtifacts');
|
||||
assert.ok(end > start, 'verifyNpmCliArtifacts must follow verifyNpmArtifacts');
|
||||
|
||||
const body = source.slice(start, end);
|
||||
assert.doesNotMatch(body, /uv', \['venv', '\.venv'\]/);
|
||||
|
|
@ -508,21 +508,16 @@ describe('verification snippets', () => {
|
|||
assert.match(source, /ktx dev ingest provider guard verified/);
|
||||
});
|
||||
|
||||
describe('npmDemoSmokeSource', () => {
|
||||
it('exercises the public packed-demo first-run contract', () => {
|
||||
const source = npmDemoSmokeSource();
|
||||
describe('npmCliSmokeSource', () => {
|
||||
it('exercises supported public package CLI commands', () => {
|
||||
const source = npmCliSmokeSource();
|
||||
|
||||
assert.match(source, /pnpm', \['exec', 'ktx', '--help'\]/);
|
||||
assert.match(source, /'demo', '--project-dir', projectDir, '--no-input', '--plain'/);
|
||||
assert.match(source, /Mode: seeded/);
|
||||
assert.match(source, /Source: packaged demo project/);
|
||||
assert.match(source, /LLM calls: none/);
|
||||
assert.match(source, /ktx agent context --json/);
|
||||
assert.match(source, /pnpm', \['exec', 'ktx', 'setup', '--help'\]/);
|
||||
assert.match(source, /Usage: ktx setup/);
|
||||
assert.doesNotMatch(source, new RegExp(["'demo'", "'--mode'", "'deterministic'"].join(', ')));
|
||||
assert.match(source, /'status', '--no-input'/);
|
||||
assert.match(source, /'--plain'/);
|
||||
assert.match(source, /function requireProjectStderr/);
|
||||
assert.match(source, /requireProjectStderr\('ktx setup demo seeded', seeded, projectDir\)/);
|
||||
assert.doesNotMatch(source, /function requireProjectStderr/);
|
||||
assert.match(source, /Object\.keys\(packageJson\.dependencies\)/);
|
||||
assert.match(source, /'@kaelio\/ktx'/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { dirname, join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
|
|
@ -33,26 +32,6 @@ function registryEnv(config) {
|
|||
return config.registry ? { npm_config_registry: config.registry } : {};
|
||||
}
|
||||
|
||||
function runtimeCommandEnv(config, runtimeRoot) {
|
||||
return { ...registryEnv(config), KTX_RUNTIME_ROOT: runtimeRoot };
|
||||
}
|
||||
|
||||
function semanticQueryArgs(projectDir) {
|
||||
return [
|
||||
'sl',
|
||||
'query',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--connection-id',
|
||||
'orbit_demo',
|
||||
'--measure',
|
||||
'contracts.contract_count',
|
||||
'--format',
|
||||
'sql',
|
||||
'--yes',
|
||||
];
|
||||
}
|
||||
|
||||
function normalizePolicyConfig(policyConfig = {}) {
|
||||
if (policyConfig === null || policyConfig === undefined) {
|
||||
return { packageName: null, version: DEFAULT_VERSION_TAG, registry: null };
|
||||
|
|
@ -150,24 +129,22 @@ export function buildPublishedPackageNpxCommand(config, args, label = 'published
|
|||
|
||||
export function buildPublishedPackageSmokeCommands(
|
||||
config,
|
||||
projectDir,
|
||||
runtimeRoot = join(dirname(projectDir), 'managed-runtime'),
|
||||
_projectDir,
|
||||
) {
|
||||
const runtimeEnv = runtimeCommandEnv(config, runtimeRoot);
|
||||
const packageEnv = registryEnv(config);
|
||||
const queryArgs = semanticQueryArgs(projectDir);
|
||||
|
||||
return [
|
||||
buildPublishedPackageNpxCommand(config, ['--version'], 'published package npx version'),
|
||||
buildPublishedPackageNpxCommand(
|
||||
config,
|
||||
['setup', 'demo', '--project-dir', projectDir, '--no-input', '--plain'],
|
||||
'published package setup demo',
|
||||
{ KTX_RUNTIME_ROOT: runtimeRoot },
|
||||
['setup', '--help'],
|
||||
'published package npx setup help',
|
||||
),
|
||||
buildPublishedPackageNpxCommand(
|
||||
config,
|
||||
['status', '--help'],
|
||||
'published package npx status help',
|
||||
),
|
||||
buildPublishedPackageNpxCommand(config, queryArgs, 'published package npx sl query', {
|
||||
KTX_RUNTIME_ROOT: runtimeRoot,
|
||||
}),
|
||||
{
|
||||
label: 'published package local install',
|
||||
command: 'pnpm',
|
||||
|
|
@ -181,10 +158,10 @@ export function buildPublishedPackageSmokeCommands(
|
|||
env: packageEnv,
|
||||
},
|
||||
{
|
||||
label: 'published package local sl query',
|
||||
label: 'published package local status help',
|
||||
command: 'npx',
|
||||
args: ['ktx', ...queryArgs],
|
||||
env: runtimeEnv,
|
||||
args: ['ktx', 'status', '--help'],
|
||||
env: packageEnv,
|
||||
},
|
||||
{
|
||||
label: 'published package global install',
|
||||
|
|
@ -199,10 +176,10 @@ export function buildPublishedPackageSmokeCommands(
|
|||
env: packageEnv,
|
||||
},
|
||||
{
|
||||
label: 'published package global sl query',
|
||||
label: 'published package global status help',
|
||||
command: 'ktx',
|
||||
args: queryArgs,
|
||||
env: runtimeEnv,
|
||||
args: ['status', '--help'],
|
||||
env: packageEnv,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,20 +29,10 @@ const VERSION_LABELS = new Set([
|
|||
'published package global version',
|
||||
]);
|
||||
|
||||
const SEMANTIC_QUERY_LABELS = new Set([
|
||||
'published package npx sl query',
|
||||
'published package local sl query',
|
||||
'published package global sl query',
|
||||
]);
|
||||
|
||||
export function isPublishedPackageVersionLabel(label) {
|
||||
return VERSION_LABELS.has(label);
|
||||
}
|
||||
|
||||
export function isPublishedPackageSemanticQueryLabel(label) {
|
||||
return SEMANTIC_QUERY_LABELS.has(label);
|
||||
}
|
||||
|
||||
function scriptRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
|
@ -100,10 +90,6 @@ export async function runPublishedPackageSmoke(config) {
|
|||
if (isPublishedPackageVersionLabel(command.label)) {
|
||||
assert.match(result.stdout, /@kaelio\/ktx /);
|
||||
}
|
||||
if (isPublishedPackageSemanticQueryLabel(command.label)) {
|
||||
assert.match(result.stdout, /SELECT/i);
|
||||
assert.match(result.stdout, /contracts/i);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write('published package invocation smoke verified\n');
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { describe, it } from 'node:test';
|
|||
import {
|
||||
buildPublishedPackageNpxCommand,
|
||||
buildPublishedPackageSmokeCommands,
|
||||
isPublishedPackageSemanticQueryLabel,
|
||||
isPublishedPackageVersionLabel,
|
||||
publishedPackageSpec,
|
||||
readPublishedPackageSmokeConfig,
|
||||
|
|
@ -149,16 +148,11 @@ describe('published package smoke config', () => {
|
|||
});
|
||||
|
||||
describe('published package smoke output validation labels', () => {
|
||||
it('classifies version and semantic query commands', () => {
|
||||
it('classifies version commands', () => {
|
||||
assert.equal(isPublishedPackageVersionLabel('published package npx version'), true);
|
||||
assert.equal(isPublishedPackageVersionLabel('published package local version'), true);
|
||||
assert.equal(isPublishedPackageVersionLabel('published package global version'), true);
|
||||
assert.equal(isPublishedPackageVersionLabel('published package setup demo'), false);
|
||||
|
||||
assert.equal(isPublishedPackageSemanticQueryLabel('published package npx sl query'), true);
|
||||
assert.equal(isPublishedPackageSemanticQueryLabel('published package local sl query'), true);
|
||||
assert.equal(isPublishedPackageSemanticQueryLabel('published package global sl query'), true);
|
||||
assert.equal(isPublishedPackageSemanticQueryLabel('published package local install'), false);
|
||||
assert.equal(isPublishedPackageVersionLabel('published package npx setup help'), false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -199,45 +193,16 @@ describe('published package smoke command construction', () => {
|
|||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package setup demo',
|
||||
label: 'published package npx setup help',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@kaelio/ktx@latest',
|
||||
'setup',
|
||||
'demo',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
'--no-input',
|
||||
'--plain',
|
||||
],
|
||||
env: {
|
||||
npm_config_registry: 'https://registry.npmjs.org/',
|
||||
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
|
||||
},
|
||||
args: ['--yes', '@kaelio/ktx@latest', 'setup', '--help'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package npx sl query',
|
||||
label: 'published package npx status help',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'--yes',
|
||||
'@kaelio/ktx@latest',
|
||||
'sl',
|
||||
'query',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
'--connection-id',
|
||||
'orbit_demo',
|
||||
'--measure',
|
||||
'contracts.contract_count',
|
||||
'--format',
|
||||
'sql',
|
||||
'--yes',
|
||||
],
|
||||
env: {
|
||||
npm_config_registry: 'https://registry.npmjs.org/',
|
||||
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
|
||||
},
|
||||
args: ['--yes', '@kaelio/ktx@latest', 'status', '--help'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package local install',
|
||||
|
|
@ -252,26 +217,10 @@ describe('published package smoke command construction', () => {
|
|||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package local sl query',
|
||||
label: 'published package local status help',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'ktx',
|
||||
'sl',
|
||||
'query',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
'--connection-id',
|
||||
'orbit_demo',
|
||||
'--measure',
|
||||
'contracts.contract_count',
|
||||
'--format',
|
||||
'sql',
|
||||
'--yes',
|
||||
],
|
||||
env: {
|
||||
npm_config_registry: 'https://registry.npmjs.org/',
|
||||
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
|
||||
},
|
||||
args: ['ktx', 'status', '--help'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package global install',
|
||||
|
|
@ -286,25 +235,10 @@ describe('published package smoke command construction', () => {
|
|||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
{
|
||||
label: 'published package global sl query',
|
||||
label: 'published package global status help',
|
||||
command: 'ktx',
|
||||
args: [
|
||||
'sl',
|
||||
'query',
|
||||
'--project-dir',
|
||||
'/tmp/ktx-smoke/demo',
|
||||
'--connection-id',
|
||||
'orbit_demo',
|
||||
'--measure',
|
||||
'contracts.contract_count',
|
||||
'--format',
|
||||
'sql',
|
||||
'--yes',
|
||||
],
|
||||
env: {
|
||||
npm_config_registry: 'https://registry.npmjs.org/',
|
||||
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
|
||||
},
|
||||
args: ['status', '--help'],
|
||||
env: { npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
},
|
||||
],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue