mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
fix: improve setup wizard behavior
This commit is contained in:
parent
33a142f769
commit
1c30abc51d
13 changed files with 337 additions and 38 deletions
|
|
@ -31,6 +31,9 @@ ktx dev <subcommand> [options]
|
|||
|
||||
## `dev schema`
|
||||
|
||||
`ktx dev schema` does not require a `ktx.yaml` file or a configured project
|
||||
directory. Use it from any directory to generate editor or agent schema files.
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--output <file>` | Write the schema to a file instead of stdout | — |
|
||||
|
|
|
|||
|
|
@ -63,9 +63,9 @@ Setup supports three LLM provider paths:
|
|||
|
||||
| Provider | Use when | Credential model |
|
||||
|----------|----------|------------------|
|
||||
| Anthropic API | You have an Anthropic API key | `ANTHROPIC_API_KEY` or a local `file:` secret |
|
||||
| Claude subscription (Pro/Max) | You want KTX to use your local Claude Code session | Claude Code local authentication |
|
||||
| Anthropic API key | You have an Anthropic API key | `ANTHROPIC_API_KEY` or a local `file:` secret |
|
||||
| Google Vertex AI for Anthropic Claude | Your organization runs Claude through Google Cloud | Application Default Credentials plus Vertex project and location |
|
||||
| Claude Code | You want KTX to use your local Claude Code session | Claude Code local authentication |
|
||||
|
||||
For Anthropic API, setup can read the key from the environment or save a pasted
|
||||
key to `.ktx/secrets/anthropic-api-key`. `ktx.yaml` stores an `env:` or `file:`
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { cancel, confirm, isCancel, log, spinner } from '@clack/prompts';
|
||||
|
||||
const ESC = String.fromCharCode(0x1b);
|
||||
|
||||
export interface KtxCliSpinner {
|
||||
start(message: string): void;
|
||||
message(message: string): void;
|
||||
|
|
@ -7,6 +9,10 @@ export interface KtxCliSpinner {
|
|||
error(message: string): void;
|
||||
}
|
||||
|
||||
export interface KtxCliSpinnerIo {
|
||||
stderr: { write(chunk: string): void };
|
||||
}
|
||||
|
||||
export interface KtxCliPromptAdapter {
|
||||
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
|
||||
cancel(message: string): void;
|
||||
|
|
@ -31,6 +37,31 @@ export function createClackSpinner(): KtxCliSpinner {
|
|||
return spinner();
|
||||
}
|
||||
|
||||
function magenta(text: string): string {
|
||||
return `${ESC}[35m${text}${ESC}[39m`;
|
||||
}
|
||||
|
||||
function red(text: string): string {
|
||||
return `${ESC}[31m${text}${ESC}[39m`;
|
||||
}
|
||||
|
||||
export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner {
|
||||
return {
|
||||
start(message) {
|
||||
io.stderr.write(`${magenta('◐')} ${message}\n`);
|
||||
},
|
||||
message(message) {
|
||||
io.stderr.write(`${magenta('│')} ${message}\n`);
|
||||
},
|
||||
stop(message) {
|
||||
io.stderr.write(`${magenta('◇')} ${message}\n`);
|
||||
},
|
||||
error(message) {
|
||||
io.stderr.write(`${red('■')} ${message}\n`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createClackPromptAdapter(): KtxCliPromptAdapter {
|
||||
return {
|
||||
async confirm(options) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ type CommandPathNode = CommandWithGlobalOptions & {
|
|||
};
|
||||
|
||||
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'sql', 'status', 'mcp']);
|
||||
const PROJECT_INDEPENDENT_DEV_COMMANDS = new Set(['runtime', 'schema']);
|
||||
const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']);
|
||||
const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']);
|
||||
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
|
||||
|
|
@ -172,7 +173,7 @@ function isProjectAwareCommand(path: string[]): boolean {
|
|||
|
||||
const rootCommand = path[1];
|
||||
if (rootCommand === 'dev') {
|
||||
return path[2] !== undefined && path[2] !== 'runtime';
|
||||
return path[2] !== undefined && !PROJECT_INDEPENDENT_DEV_COMMANDS.has(path[2]);
|
||||
}
|
||||
return rootCommand !== undefined && PROJECT_AWARE_ROOT_COMMANDS.has(rootCommand);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,35 @@ describe('dev Commander tree', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('prints config schema without requiring a KTX project directory', async () => {
|
||||
const { mkdtemp, rm } = await import('node:fs/promises');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-dev-schema-'));
|
||||
const missingProjectDir = join(tempDir, 'missing-project');
|
||||
const originalProjectDir = process.env.KTX_PROJECT_DIR;
|
||||
const testIo = makeIo();
|
||||
|
||||
try {
|
||||
process.env.KTX_PROJECT_DIR = missingProjectDir;
|
||||
|
||||
await expect(runKtxCli(['dev', 'schema'], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(testIo.stdout())).toMatchObject({
|
||||
title: 'ktx.yaml',
|
||||
type: 'object',
|
||||
});
|
||||
expect(testIo.stderr()).toBe('');
|
||||
} finally {
|
||||
if (originalProjectDir === undefined) {
|
||||
delete process.env.KTX_PROJECT_DIR;
|
||||
} else {
|
||||
process.env.KTX_PROJECT_DIR = originalProjectDir;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects removed dev command groups', async () => {
|
||||
for (const argv of [
|
||||
['dev', 'doctor', 'setup'],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createManagedPythonSemanticLayerComputePort,
|
||||
ensureManagedPythonCommandRuntime,
|
||||
managedRuntimeInstallCommand,
|
||||
runtimeInstallPolicyFromFlags,
|
||||
} from './managed-python-command.js';
|
||||
|
|
@ -103,6 +104,17 @@ function installResult(features: KtxRuntimeFeature[] = ['core']): ManagedPythonR
|
|||
};
|
||||
}
|
||||
|
||||
function makeSpinnerEvents() {
|
||||
const events: string[] = [];
|
||||
const spinner = vi.fn(() => ({
|
||||
start: (msg: string) => events.push(`start:${msg}`),
|
||||
message: (msg: string) => events.push(`message:${msg}`),
|
||||
stop: (msg: string) => events.push(`stop:${msg}`),
|
||||
error: (msg: string) => events.push(`error:${msg}`),
|
||||
}));
|
||||
return { events, spinner };
|
||||
}
|
||||
|
||||
describe('managedRuntimeInstallCommand', () => {
|
||||
it('prints the exact command for each managed runtime feature', () => {
|
||||
expect(managedRuntimeInstallCommand('core')).toBe('ktx dev runtime install --yes');
|
||||
|
|
@ -128,6 +140,51 @@ describe('runtimeInstallPolicyFromFlags', () => {
|
|||
});
|
||||
|
||||
describe('createManagedPythonSemanticLayerComputePort', () => {
|
||||
it('uses non-animated runtime setup status by default', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
ensureManagedPythonCommandRuntime({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io: io.io,
|
||||
readStatus: vi.fn(async () => missingStatus()),
|
||||
installRuntime: vi.fn(async () => installResult(['local-embeddings'])),
|
||||
feature: 'local-embeddings',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
layout: { versionDir: '/runtime/0.2.0' },
|
||||
});
|
||||
|
||||
expect(io.stderr()).toContain('Installing KTX Python runtime (local-embeddings) with uv...');
|
||||
expect(io.stderr()).toContain('KTX Python runtime ready: /runtime/0.2.0');
|
||||
expect(io.stderr().match(/Installing KTX Python runtime/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows runtime installation progress with the CLI spinner', async () => {
|
||||
const io = makeIo();
|
||||
const { events, spinner } = makeSpinnerEvents();
|
||||
|
||||
const options = {
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto' as const,
|
||||
io: io.io,
|
||||
readStatus: vi.fn(async () => missingStatus()),
|
||||
installRuntime: vi.fn(async () => installResult(['local-embeddings'])),
|
||||
feature: 'local-embeddings' as const,
|
||||
spinner,
|
||||
};
|
||||
|
||||
await expect(ensureManagedPythonCommandRuntime(options)).resolves.toMatchObject({
|
||||
layout: { versionDir: '/runtime/0.2.0' },
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
'start:Installing KTX Python runtime (local-embeddings) with uv...',
|
||||
'stop:KTX Python runtime ready: /runtime/0.2.0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the managed ktx-daemon executable when the runtime is ready', async () => {
|
||||
const io = makeIo();
|
||||
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
|
||||
|
|
@ -170,6 +227,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
|
|||
|
||||
it('installs the core runtime without prompting when policy is auto', async () => {
|
||||
const io = makeIo();
|
||||
const { events, spinner } = makeSpinnerEvents();
|
||||
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
|
||||
const createPythonCompute = vi.fn(() => compute);
|
||||
const installRuntime = vi.fn(async () => installResult());
|
||||
|
|
@ -182,6 +240,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
|
|||
readStatus: vi.fn(async () => missingStatus()),
|
||||
installRuntime,
|
||||
createPythonCompute,
|
||||
spinner,
|
||||
}),
|
||||
).resolves.toBe(compute);
|
||||
|
||||
|
|
@ -190,12 +249,15 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
|
|||
features: ['core'],
|
||||
force: false,
|
||||
});
|
||||
expect(io.stderr()).toContain('Installing KTX Python runtime (core) with uv');
|
||||
expect(io.stderr()).toContain('KTX Python runtime ready: /runtime/0.2.0');
|
||||
expect(events).toEqual([
|
||||
'start:Installing KTX Python runtime (core) with uv...',
|
||||
'stop:KTX Python runtime ready: /runtime/0.2.0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prompts before installing when policy is prompt', async () => {
|
||||
const io = makeIo();
|
||||
const { events, spinner } = makeSpinnerEvents();
|
||||
const confirmInstall = vi.fn(async () => true);
|
||||
const installRuntime = vi.fn(async () => installResult());
|
||||
|
||||
|
|
@ -207,6 +269,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
|
|||
installRuntime,
|
||||
createPythonCompute: vi.fn(() => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() })),
|
||||
confirmInstall,
|
||||
spinner,
|
||||
});
|
||||
|
||||
expect(confirmInstall).toHaveBeenCalledWith(
|
||||
|
|
@ -218,10 +281,12 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
|
|||
features: ['core'],
|
||||
force: false,
|
||||
});
|
||||
expect(events).toContainEqual('start:Installing KTX Python runtime (core) with uv...');
|
||||
});
|
||||
|
||||
it('uses injected runtime confirmation instead of reading process TTY directly', async () => {
|
||||
const io = makeIo();
|
||||
const { events, spinner } = makeSpinnerEvents();
|
||||
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
|
||||
const installRuntime = vi.fn(async (): Promise<ManagedPythonRuntimeInstallResult> => installResult());
|
||||
const confirmInstall = vi.fn(async () => true);
|
||||
|
|
@ -235,6 +300,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
|
|||
installRuntime,
|
||||
confirmInstall,
|
||||
createPythonCompute: () => compute,
|
||||
spinner,
|
||||
}),
|
||||
).resolves.toBe(compute);
|
||||
|
||||
|
|
@ -242,7 +308,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
|
|||
'KTX needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?',
|
||||
io.io,
|
||||
);
|
||||
expect(io.stderr()).toContain('Installing KTX Python runtime (core) with uv...');
|
||||
expect(events).toContainEqual('start:Installing KTX Python runtime (core) with uv...');
|
||||
});
|
||||
|
||||
it('can decide default runtime prompting from injected io capabilities', async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createPythonSemanticLayerComputePort, type KtxSemanticLayerComputePort } from '@ktx/context/daemon';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { createClackPromptAdapter } from './clack.js';
|
||||
import { createClackPromptAdapter, createStaticCliSpinner, type KtxCliSpinner } from './clack.js';
|
||||
import {
|
||||
installManagedPythonRuntime,
|
||||
readManagedPythonRuntimeStatus,
|
||||
|
|
@ -37,6 +37,7 @@ export interface ManagedPythonCommandDeps {
|
|||
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
|
||||
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
|
||||
confirmInstall?: (message: string, io: KtxCliIo) => Promise<boolean>;
|
||||
spinner?: () => KtxCliSpinner;
|
||||
}
|
||||
|
||||
export interface ManagedPythonCommandOptions extends ManagedPythonCommandDeps {
|
||||
|
|
@ -101,14 +102,20 @@ export async function ensureManagedPythonCommandRuntime(
|
|||
}
|
||||
}
|
||||
|
||||
options.io.stderr.write(`Installing KTX Python runtime (${feature}) with uv...\n`);
|
||||
const installed = await installRuntime({
|
||||
cliVersion: options.cliVersion,
|
||||
features: [feature],
|
||||
force: false,
|
||||
});
|
||||
options.io.stderr.write(`KTX Python runtime ready: ${installed.layout.versionDir}\n`);
|
||||
return { layout: installed.layout, manifest: installed.manifest };
|
||||
const progress = (options.spinner ?? (() => createStaticCliSpinner(options.io)))();
|
||||
progress.start(`Installing KTX Python runtime (${feature}) with uv...`);
|
||||
try {
|
||||
const installed = await installRuntime({
|
||||
cliVersion: options.cliVersion,
|
||||
features: [feature],
|
||||
force: false,
|
||||
});
|
||||
progress.stop(`KTX Python runtime ready: ${installed.layout.versionDir}`);
|
||||
return { layout: installed.layout, manifest: installed.manifest };
|
||||
} catch (error) {
|
||||
progress.error(`KTX Python runtime install failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createManagedPythonSemanticLayerComputePort(
|
||||
|
|
@ -122,6 +129,7 @@ export async function createManagedPythonSemanticLayerComputePort(
|
|||
...(options.readStatus ? { readStatus: options.readStatus } : {}),
|
||||
...(options.installRuntime ? { installRuntime: options.installRuntime } : {}),
|
||||
...(options.confirmInstall ? { confirmInstall: options.confirmInstall } : {}),
|
||||
...(options.spinner ? { spinner: options.spinner } : {}),
|
||||
});
|
||||
const createPythonCompute = options.createPythonCompute ?? createPythonSemanticLayerComputePort;
|
||||
return createPythonCompute({
|
||||
|
|
|
|||
|
|
@ -176,12 +176,33 @@ describe('setup embeddings step', () => {
|
|||
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
|
||||
expect(spinnerEvents).toContainEqual(
|
||||
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
|
||||
);
|
||||
expect(spinnerEvents).toContainEqual('start:Testing local embeddings (all-MiniLM-L6-v2)');
|
||||
expect(io.stdout()).toContain('Embeddings ready: yes');
|
||||
});
|
||||
|
||||
it('uses a short non-animated local embeddings health-check status by default', async () => {
|
||||
const io = makeIo();
|
||||
const healthCheck = vi.fn(async () => ({ ok: true as const }));
|
||||
const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] });
|
||||
|
||||
const result = await runKtxSetupEmbeddingsStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
skipEmbeddings: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts, env: {}, healthCheck, ensureLocalEmbeddings: vi.fn(async () => managedDaemon()) },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(io.stderr()).toContain('Testing local embeddings (all-MiniLM-L6-v2)');
|
||||
expect(io.stderr()).not.toContain('First run may take up to 60 seconds');
|
||||
expect(io.stderr().match(/Testing local embeddings/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows live progress while local sentence-transformers embeddings are being tested', async () => {
|
||||
const io = makeIo();
|
||||
const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] });
|
||||
|
|
@ -213,9 +234,7 @@ describe('setup embeddings step', () => {
|
|||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(spinnerEvents).toContainEqual(
|
||||
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
|
||||
);
|
||||
expect(spinnerEvents).toContainEqual('start:Testing local embeddings (all-MiniLM-L6-v2)');
|
||||
});
|
||||
|
||||
expect(resolveHealthCheck).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '@ktx/context/project';
|
||||
import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { createClackSpinner, type KtxCliSpinner } from './clack.js';
|
||||
import { createStaticCliSpinner, type KtxCliSpinner } from './clack.js';
|
||||
import {
|
||||
ensureManagedLocalEmbeddingsDaemon,
|
||||
managedLocalEmbeddingHealthConfig,
|
||||
|
|
@ -316,10 +316,7 @@ async function promptAfterLocalEmbeddingFailure(
|
|||
|
||||
function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string, dimensions: number): string {
|
||||
if (backend === LOCAL_EMBEDDING_BACKEND) {
|
||||
return [
|
||||
`Testing local sentence-transformers embeddings (${model}, ${dimensions} dimensions).`,
|
||||
'First run may take up to 60 seconds.',
|
||||
].join(' ');
|
||||
return `Testing local embeddings (${model})`;
|
||||
}
|
||||
return `Checking ${backend} embeddings (${model}, ${dimensions} dimensions).`;
|
||||
}
|
||||
|
|
@ -424,7 +421,7 @@ export async function runKtxSetupEmbeddingsStep(
|
|||
dimensions,
|
||||
credentialValue,
|
||||
});
|
||||
const healthSpinner = (deps.spinner ?? createClackSpinner)();
|
||||
const healthSpinner = (deps.spinner ?? (() => createStaticCliSpinner(io)))();
|
||||
const progress = startHealthCheckProgress(healthSpinner, healthCheckStartText(selectedBackend, model, dimensions));
|
||||
let health: KtxEmbeddingHealthCheckResult;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ describe('setup Anthropic model step', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('offers Vertex AI as an Anthropic model provider option', async () => {
|
||||
it('offers Anthropic provider paths in the preferred order', async () => {
|
||||
const prompts = makePromptAdapter({ providerChoice: 'back' });
|
||||
|
||||
const result = await runKtxSetupAnthropicModelStep(
|
||||
|
|
@ -177,10 +177,12 @@ describe('setup Anthropic model step', () => {
|
|||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which LLM provider should KTX use?'),
|
||||
options: expect.arrayContaining([
|
||||
options: [
|
||||
{ value: 'claude-code', label: 'Claude subscription (Pro/Max)' },
|
||||
{ value: 'anthropic', label: 'Anthropic API key' },
|
||||
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -509,12 +509,12 @@ async function chooseBackend(
|
|||
}
|
||||
const choice = await prompts.select({
|
||||
message: 'Which LLM provider should KTX use?',
|
||||
options: [
|
||||
{ value: 'anthropic', label: 'Anthropic API' },
|
||||
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
|
||||
{ value: 'claude-code', label: 'Local Claude Code session' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
options: [
|
||||
{ value: 'claude-code', label: 'Claude subscription (Pro/Max)' },
|
||||
{ value: 'anthropic', label: 'Anthropic API key' },
|
||||
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (choice === 'back') {
|
||||
return { status: 'back' };
|
||||
|
|
|
|||
137
packages/cli/src/setup-sources-notion.test.ts
Normal file
137
packages/cli/src/setup-sources-notion.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
initKtxProject,
|
||||
type KtxProjectConnectionConfig,
|
||||
parseKtxProjectConfig,
|
||||
serializeKtxProjectConfig,
|
||||
} from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
runKtxSetupSourcesStep,
|
||||
type KtxSetupSourcesPromptAdapter,
|
||||
} from './setup-sources.js';
|
||||
|
||||
const notionMocks = vi.hoisted(() => ({
|
||||
tokens: [] as string[],
|
||||
retrieveBotUser: vi.fn(async () => ({ name: 'Docs Bot' })),
|
||||
retrievePage: vi.fn(async () => ({ id: 'page-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('@ktx/context/ingest', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@ktx/context/ingest')>();
|
||||
return {
|
||||
...actual,
|
||||
NotionClient: vi.fn().mockImplementation(function NotionClient(token: string) {
|
||||
notionMocks.tokens.push(token);
|
||||
return {
|
||||
retrieveBotUser: notionMocks.retrieveBotUser,
|
||||
retrievePage: notionMocks.retrievePage,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
isTTY: true,
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function prompts(values: { multiselect?: string[][]; select?: string[] }): KtxSetupSourcesPromptAdapter {
|
||||
const multiselectValues = [...(values.multiselect ?? [])];
|
||||
const selectValues = [...(values.select ?? [])];
|
||||
return {
|
||||
multiselect: vi.fn(async () => multiselectValues.shift() ?? []),
|
||||
select: vi.fn(async () => selectValues.shift() ?? 'back'),
|
||||
text: vi.fn(async () => ''),
|
||||
password: vi.fn(async () => undefined),
|
||||
cancel: vi.fn(),
|
||||
log: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('setup sources Notion validation', () => {
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
notionMocks.tokens.length = 0;
|
||||
notionMocks.retrieveBotUser.mockClear();
|
||||
notionMocks.retrievePage.mockClear();
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-sources-notion-'));
|
||||
projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function readConfig() {
|
||||
return parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
}
|
||||
|
||||
async function writeConfigConnection(connectionId: string, connection: KtxProjectConnectionConfig) {
|
||||
const config = await readConfig();
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
serializeKtxProjectConfig({
|
||||
...config,
|
||||
connections: {
|
||||
...config.connections,
|
||||
warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' },
|
||||
[connectionId]: connection,
|
||||
},
|
||||
setup: {
|
||||
...config.setup,
|
||||
database_connection_ids: ['warehouse'],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
it('validates an existing Notion source that uses an inline auth token', async () => {
|
||||
await writeConfigConnection('notion', {
|
||||
driver: 'notion',
|
||||
auth_token: 'ntn_inline_token',
|
||||
crawl_mode: 'all_accessible',
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetupSourcesStep(
|
||||
{ projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false },
|
||||
io.io,
|
||||
{
|
||||
prompts: prompts({
|
||||
multiselect: [['notion']],
|
||||
select: ['existing:notion'],
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ status: 'ready', projectDir, connectionIds: ['notion'] });
|
||||
|
||||
expect(notionMocks.tokens).toEqual(['ntn_inline_token']);
|
||||
expect(notionMocks.retrieveBotUser).toHaveBeenCalledOnce();
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,10 @@ import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { localConnectionTypeForConfig, resolveNotionAuthToken } from '@ktx/context/connections';
|
||||
import {
|
||||
localConnectionTypeForConfig,
|
||||
resolveNotionConnectionAuthToken,
|
||||
} from '@ktx/context/connections';
|
||||
import { resolveKtxConfigReference } from '@ktx/context/core';
|
||||
import {
|
||||
cloneOrPull,
|
||||
|
|
@ -620,7 +623,10 @@ async function defaultValidateLookml(connection: KtxProjectConnectionConfig): Pr
|
|||
}
|
||||
|
||||
async function defaultValidateNotion(connection: KtxProjectConnectionConfig): Promise<SourceValidationResult> {
|
||||
const token = await resolveNotionAuthToken(String(connection.auth_token_ref));
|
||||
const token = await resolveNotionConnectionAuthToken({
|
||||
auth_token: stringField(connection.auth_token) ?? null,
|
||||
auth_token_ref: stringField(connection.auth_token_ref) ?? null,
|
||||
});
|
||||
const client: NotionApi = new NotionClient(token);
|
||||
await client.retrieveBotUser();
|
||||
const roots = Array.isArray(connection.root_page_ids)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue