From 1c30abc51d5bb7724800dbe6f9a183b7be27ed17 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sun, 17 May 2026 18:37:43 +0200 Subject: [PATCH] fix: improve setup wizard behavior --- .../content/docs/cli-reference/ktx-dev.mdx | 3 + .../docs/getting-started/quickstart.mdx | 4 +- packages/cli/src/clack.ts | 31 ++++ packages/cli/src/cli-program.ts | 3 +- packages/cli/src/dev.test.ts | 29 ++++ .../cli/src/managed-python-command.test.ts | 72 ++++++++- packages/cli/src/managed-python-command.ts | 26 ++-- packages/cli/src/setup-embeddings.test.ts | 31 +++- packages/cli/src/setup-embeddings.ts | 9 +- packages/cli/src/setup-models.test.ts | 8 +- packages/cli/src/setup-models.ts | 12 +- packages/cli/src/setup-sources-notion.test.ts | 137 ++++++++++++++++++ packages/cli/src/setup-sources.ts | 10 +- 13 files changed, 337 insertions(+), 38 deletions(-) create mode 100644 packages/cli/src/setup-sources-notion.test.ts diff --git a/docs-site/content/docs/cli-reference/ktx-dev.mdx b/docs-site/content/docs/cli-reference/ktx-dev.mdx index f124055a..50d4ea77 100644 --- a/docs-site/content/docs/cli-reference/ktx-dev.mdx +++ b/docs-site/content/docs/cli-reference/ktx-dev.mdx @@ -31,6 +31,9 @@ ktx dev [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 ` | Write the schema to a file instead of stdout | — | diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 09957e70..0118522c 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -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:` diff --git a/packages/cli/src/clack.ts b/packages/cli/src/clack.ts index ad0dfd44..ce6f5872 100644 --- a/packages/cli/src/clack.ts +++ b/packages/cli/src/clack.ts @@ -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; 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) { diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 971234c3..34b13854 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -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); } diff --git a/packages/cli/src/dev.test.ts b/packages/cli/src/dev.test.ts index bda96692..95adb20c 100644 --- a/packages/cli/src/dev.test.ts +++ b/packages/cli/src/dev.test.ts @@ -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'], diff --git a/packages/cli/src/managed-python-command.test.ts b/packages/cli/src/managed-python-command.test.ts index abde67f1..a63f162e 100644 --- a/packages/cli/src/managed-python-command.test.ts +++ b/packages/cli/src/managed-python-command.test.ts @@ -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 => 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 () => { diff --git a/packages/cli/src/managed-python-command.ts b/packages/cli/src/managed-python-command.ts index 097bb0ac..11e794ff 100644 --- a/packages/cli/src/managed-python-command.ts +++ b/packages/cli/src/managed-python-command.ts @@ -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; installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise; confirmInstall?: (message: string, io: KtxCliIo) => Promise; + 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({ diff --git a/packages/cli/src/setup-embeddings.test.ts b/packages/cli/src/setup-embeddings.test.ts index aadb8a9f..12ab947f 100644 --- a/packages/cli/src/setup-embeddings.test.ts +++ b/packages/cli/src/setup-embeddings.test.ts @@ -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(); diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts index 4e92670c..3e2e47db 100644 --- a/packages/cli/src/setup-embeddings.ts +++ b/packages/cli/src/setup-embeddings.ts @@ -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 { diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/src/setup-models.test.ts index e997eb82..4c25d092 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/src/setup-models.test.ts @@ -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' }, - ]), + ], }), ); }); diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index e8727f47..cd8aa848 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -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' }; diff --git a/packages/cli/src/setup-sources-notion.test.ts b/packages/cli/src/setup-sources-notion.test.ts new file mode 100644 index 00000000..b85eefff --- /dev/null +++ b/packages/cli/src/setup-sources-notion.test.ts @@ -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(); + 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(''); + }); +}); diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index 7b4425f0..164ec2e8 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -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 { - 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)