diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index a5a065e8..322db2aa 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -90,7 +90,7 @@ describe('setup agents', () => { projectDir: tempDir, installs: [{ target: 'universal', scope: 'project', mode: 'cli' }], }); - expect((await readKtxSetupState(tempDir)).completed_steps).toContain('agents'); + expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['agents'] }); expect(io.stderr()).toBe(''); }); diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 97d8e610..151967aa 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { cancel, isCancel, multiselect, select } from '@clack/prompts'; +import { cancel, confirm, isCancel, multiselect, select } from '@clack/prompts'; import { loadKtxProject, markKtxSetupStateStepComplete, @@ -277,12 +277,23 @@ function createPromptAdapter(): KtxSetupAgentsPromptAdapter { return String(value); }, async multiselect(options) { - const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options))); - if (isCancel(value)) { - cancel('Setup cancelled.'); - return ['back']; + while (true) { + const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options))); + if (isCancel(value)) { + cancel('Setup cancelled.'); + return ['back']; + } + const selected = [...value] as string[]; + if (selected.length === 0 && !options.required) { + const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false }); + if (isCancel(skipConfirmed)) { + cancel('Setup cancelled.'); + return ['back']; + } + if (!skipConfirmed) continue; + } + return selected; } - return [...value] as string[]; }, cancel(message) { cancel(message); diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/src/setup-databases.test.ts index 231aae84..fe480b13 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/src/setup-databases.test.ts @@ -142,8 +142,8 @@ describe('setup databases step', () => { expect(prompts.select).toHaveBeenCalledWith({ message: 'How do you want to connect to PostgreSQL?', options: [ - { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'url', label: 'Paste a connection URL' }, + { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'back', label: 'Back' }, ], }); @@ -154,6 +154,43 @@ describe('setup databases step', () => { ); }); + it('offers connection URL paste first for URL-capable primary sources', async () => { + const cases: Array<{ driver: KtxSetupDatabaseDriver; label: string }> = [ + { driver: 'postgres', label: 'PostgreSQL' }, + { driver: 'mysql', label: 'MySQL' }, + { driver: 'clickhouse', label: 'ClickHouse' }, + { driver: 'sqlserver', label: 'SQL Server' }, + ]; + + for (const testCase of cases) { + const prompts = makePromptAdapter({ + selectValues: ['back'], + }); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'auto', + databaseDrivers: [testCase.driver], + skipDatabases: false, + databaseSchemas: [], + }, + makeIo().io, + { prompts }, + ); + + expect(result.status).toBe('back'); + expect(prompts.select).toHaveBeenCalledWith({ + message: `How do you want to connect to ${testCase.label}?`, + options: [ + { value: 'url', label: 'Paste a connection URL' }, + { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, + { value: 'back', label: 'Back' }, + ], + }); + } + }); + it('lets Back leave database setup when the driver came from flags', async () => { const prompts = makePromptAdapter({ selectValues: ['back'] }); @@ -488,8 +525,8 @@ describe('setup databases step', () => { expect(prompts.select).toHaveBeenNthCalledWith(1, { message: 'How do you want to connect to PostgreSQL?', options: [ - { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'url', label: 'Paste a connection URL' }, + { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'back', label: 'Back' }, ], }); @@ -913,10 +950,11 @@ describe('setup databases step', () => { [ '◇ Testing postgres-warehouse', '│ ✓ Connection test passed', - '│ Driver: PostgreSQL · Tables: 2', + '│ Driver: PostgreSQL', '│', ].join('\n'), ); + expect(io.stdout()).not.toContain('Tables: 2'); expect(io.stdout()).toContain( [ '◇ Scanning postgres-warehouse', diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index b90ad9c5..d71b7225 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -1,5 +1,5 @@ import { writeFile } from 'node:fs/promises'; -import { cancel, isCancel, multiselect, password, select, text } from '@clack/prompts'; +import { cancel, confirm, isCancel, multiselect, password, select, text } from '@clack/prompts'; import type { HistoricSqlDialect } from '@ktx/context/ingest'; import { type KtxProjectConnectionConfig, @@ -205,12 +205,23 @@ function missingConnectionDetailsPrompt( function createPromptAdapter(): KtxSetupDatabasesPromptAdapter { return { async multiselect(options) { - const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options))); - if (isCancel(value)) { - cancel('Setup cancelled.'); - return ['back']; + while (true) { + const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options))); + if (isCancel(value)) { + cancel('Setup cancelled.'); + return ['back']; + } + const selected = [...value] as string[]; + if (selected.length === 0 && !options.required) { + const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false }); + if (isCancel(skipConfirmed)) { + cancel('Setup cancelled.'); + return ['back']; + } + if (!skipConfirmed) continue; + } + return selected; } - return [...value] as string[]; }, async select(options) { const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options))); @@ -699,8 +710,8 @@ async function buildUrlConnectionConfig(input: { const choice = await input.prompts.select({ message: `How do you want to connect to ${label}?`, options: [ - { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'url', label: 'Paste a connection URL' }, + { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'back', label: 'Back' }, ], }); @@ -1408,9 +1419,7 @@ async function validateAndScanConnection(input: { const testOutput = testIo.stdoutText(); const outputDriver = normalizeDriver(readOutputValue(testOutput, 'Driver')); const driverDisplay = outputDriver ? driverLabel(outputDriver) : (configuredDriverLabel ?? 'Unknown driver'); - const tableCount = Number(readOutputValue(testOutput, 'Tables') ?? NaN); - const testLines = ['✓ Connection test passed']; - testLines.push(`Driver: ${driverDisplay}${Number.isFinite(tableCount) ? ` · Tables: ${tableCount}` : ''}`); + const testLines = ['✓ Connection test passed', `Driver: ${driverDisplay}`]; writeSetupSection(input.io, `Testing ${input.connectionId}`, testLines); while (true) { diff --git a/packages/cli/src/setup-demo-tour.test.ts b/packages/cli/src/setup-demo-tour.test.ts index d3f7e3f4..a8b63974 100644 --- a/packages/cli/src/setup-demo-tour.test.ts +++ b/packages/cli/src/setup-demo-tour.test.ts @@ -209,7 +209,7 @@ describe('runDemoTour', () => { }, ); expect(result).toBe(0); - // Navigation called once for databases step, then exits + // Navigation called once for intro, then exits on back expect(navigation).toHaveBeenCalledTimes(1); }); @@ -218,10 +218,11 @@ describe('runDemoTour', () => { let callCount = 0; const navigation = vi.fn().mockImplementation(() => { callCount++; - // First call (databases): forward - // Second call (sources): back - // Third call (databases again): back (exit) - if (callCount === 1) return Promise.resolve('forward'); + // First call (intro): forward + // Second call (databases): forward + // Third call (sources): back + // Fourth call (databases again): back (exit) + if (callCount <= 2) return Promise.resolve('forward'); return Promise.resolve('back'); }); @@ -235,7 +236,7 @@ describe('runDemoTour', () => { }, ); expect(result).toBe(0); - expect(navigation).toHaveBeenCalledTimes(3); + expect(navigation).toHaveBeenCalledTimes(4); }); it('handles agent step returning back', async () => { @@ -243,10 +244,10 @@ describe('runDemoTour', () => { let navCount = 0; const navigation = vi.fn().mockImplementation(() => { navCount++; - // Forward through databases, sources, context + // Forward through intro, databases, sources, context // Then back from context (after agents returns back) // Then back from sources, then back from databases (exit) - if (navCount <= 3) return Promise.resolve('forward'); + if (navCount <= 4) return Promise.resolve('forward'); return Promise.resolve('back'); }); diff --git a/packages/cli/src/setup-demo-tour.ts b/packages/cli/src/setup-demo-tour.ts index f3d71f70..fe211e67 100644 --- a/packages/cli/src/setup-demo-tour.ts +++ b/packages/cli/src/setup-demo-tour.ts @@ -62,12 +62,15 @@ function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTarge // Pure rendering functions // --------------------------------------------------------------------------- -export function renderDemoBanner(): string { +export function renderDemoBanner(projectDir?: string): string { const lines = [ '', `┌ ${cyan('Demo mode')} — data has been pre-processed and KTX context is already built.`, '│ This walkthrough illustrates the setup steps. Selections are pre-filled and read-only.', ]; + if (projectDir) { + lines.push(`│ Project directory: ${dim(projectDir)}`); + } return lines.join('\n'); } @@ -144,16 +147,15 @@ export async function waitForDemoNavigation( }; const onData = (data: Buffer) => { - const char = data.toString(); - if (char === '\r' || char === '\n') { - cleanup(); - resolve('forward'); - } else if (char === '\x1b') { - cleanup(); - resolve('back'); - } else if (char === '\x03') { + if (data[0] === 0x03) { cleanup(); reject(new KtxSetupExitError()); + } else if (data[0] === 0x0d || data[0] === 0x0a) { + cleanup(); + resolve('forward'); + } else if (data[0] === 0x1b) { + cleanup(); + resolve('back'); } }; @@ -171,8 +173,9 @@ export async function renderDemoCard( io: KtxCliIo, stdin?: NodeJS.ReadStream, waitNav: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'> = waitForDemoNavigation, + projectDir?: string, ): Promise<'forward' | 'back'> { - io.stdout.write(renderDemoBanner() + '\n\n'); + io.stdout.write(renderDemoBanner(projectDir) + '\n\n'); io.stdout.write(renderDemoCardContent(title, selections) + '\n'); return waitNav(stdin); } @@ -337,6 +340,11 @@ export async function runDemoTour( const projectDir = defaultDemoProjectDir(); await ensureProject({ projectDir, force: false }); + io.stdout.write(renderDemoBanner(projectDir) + '\n'); + io.stdout.write(`\n│ ${dim('Press Enter to continue, Escape to go back')}\n└\n`); + const introDirection = await waitNav(); + if (introDirection === 'back') return 0; + let stepIndex = 0; while (stepIndex < DEMO_STEPS.length) { @@ -344,11 +352,11 @@ export async function runDemoTour( let direction: 'forward' | 'back'; if (step === 'databases') { - direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav); + direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav, projectDir); } else if (step === 'sources') { - direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav); + direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav, projectDir); } else if (step === 'context') { - io.stdout.write(renderDemoBanner() + '\n\n'); + io.stdout.write(renderDemoBanner(projectDir) + '\n\n'); if (deps.skipReplayAnimation) { direction = await waitNav(); } else { diff --git a/packages/cli/src/setup-project.test.ts b/packages/cli/src/setup-project.test.ts index 5562c59c..9c01402c 100644 --- a/packages/cli/src/setup-project.test.ts +++ b/packages/cli/src/setup-project.test.ts @@ -142,10 +142,11 @@ describe('setup project step', () => { expect(result.projectDir).toBe(projectDir); expect(prompts.select).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Which KTX project should setup use?', + message: 'Where should KTX create the project?', options: [ - expect.objectContaining({ value: 'current', label: 'Use current directory' }), - expect.objectContaining({ value: 'new', label: 'Create a new project folder' }), + expect.objectContaining({ value: 'current', label: 'Current directory' }), + expect.objectContaining({ value: 'new-default', label: 'New subfolder (./ktx-project)' }), + expect.objectContaining({ value: 'new-custom', label: 'Custom path' }), expect.objectContaining({ value: 'exit', label: 'Exit' }), ], }), @@ -159,7 +160,7 @@ describe('setup project step', () => { it('offers an absolute default destination for a new project folder', async () => { const startDir = join(tempDir, 'start'); const projectDir = join(startDir, 'ktx-project'); - const prompts = makePromptAdapter({ choices: ['new', 'default', 'create'] }); + const prompts = makePromptAdapter({ choices: ['new-default', 'create'] }); const testIo = makeIo({ stdoutIsTty: true }); const result = await runKtxSetupProjectStep( @@ -171,21 +172,16 @@ describe('setup project step', () => { expect(result.status).toBe('ready'); expect(result.projectDir).toBe(projectDir); expect(prompts.select).toHaveBeenNthCalledWith( - 2, + 1, expect.objectContaining({ message: 'Where should KTX create the project?', - options: [ - expect.objectContaining({ - value: 'default', - label: `Create the default project folder: ${projectDir}`, - }), - expect.objectContaining({ value: 'custom', label: 'Enter a custom path' }), - expect.objectContaining({ value: 'back', label: 'Back' }), - ], + options: expect.arrayContaining([ + expect.objectContaining({ value: 'new-default', label: 'New subfolder (./ktx-project)' }), + ]), }), ); expect(prompts.select).toHaveBeenNthCalledWith( - 3, + 2, expect.objectContaining({ message: `Create KTX project at ${projectDir}?` }), ); expect(prompts.text).not.toHaveBeenCalled(); @@ -197,7 +193,7 @@ describe('setup project step', () => { it('prompts for a custom path and resolves it inside the current setup directory', async () => { const startDir = join(tempDir, 'start'); const projectDir = join(startDir, 'analytics-ktx'); - const prompts = makePromptAdapter({ choices: ['new', 'custom', 'create'], textValue: 'analytics-ktx' }); + const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: 'analytics-ktx' }); const result = await runKtxSetupProjectStep( { projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false }, @@ -220,7 +216,7 @@ describe('setup project step', () => { const startDir = join(tempDir, 'start'); const homeDir = join(tempDir, 'home'); const projectDir = join(homeDir, 'analytics-ktx'); - const prompts = makePromptAdapter({ choices: ['new', 'custom', 'create'], textValue: '~/analytics-ktx' }); + const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: '~/analytics-ktx' }); const result = await runKtxSetupProjectStep( { projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false }, @@ -238,7 +234,7 @@ describe('setup project step', () => { const homeDir = join(tempDir, 'home'); const customProjectDir = join(homeDir, 'analytics-ktx'); const prompts = makePromptAdapter({ - choices: ['new', 'custom', 'back', 'exit'], + choices: ['new-custom', 'back', 'exit'], textValue: '~/analytics-ktx', }); @@ -251,7 +247,7 @@ describe('setup project step', () => { expect(result.status).toBe('cancelled'); expect(result.projectDir).toBe(startDir); expect(prompts.select).toHaveBeenNthCalledWith( - 3, + 2, expect.objectContaining({ message: `Create KTX project at ${customProjectDir}?`, options: [ @@ -262,15 +258,15 @@ describe('setup project step', () => { }), ); expect(prompts.select).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ message: 'Which KTX project should setup use?' }), + 3, + expect.objectContaining({ message: 'Where should KTX create the project?' }), ); await expect(stat(join(customProjectDir, 'ktx.yaml'))).rejects.toThrow(); }); it('rejects an empty new folder path without creating a project in the process cwd', async () => { const startDir = join(tempDir, 'start'); - const prompts = makePromptAdapter({ choices: ['new', 'custom'], textValue: ' ' }); + const prompts = makePromptAdapter({ choices: ['new-custom'], textValue: ' ' }); const initProject = vi.fn(async () => { throw new Error('initProject should not run for an empty path'); }); @@ -295,7 +291,7 @@ describe('setup project step', () => { const projectDir = join(startDir, 'analytics-ktx'); await mkdir(projectDir, { recursive: true }); await writeFile(join(projectDir, 'README.md'), 'Existing project notes\n', 'utf-8'); - const prompts = makePromptAdapter({ choices: ['new', 'custom', 'use-existing'], textValue: 'analytics-ktx' }); + const prompts = makePromptAdapter({ choices: ['new-custom', 'use-existing'], textValue: 'analytics-ktx' }); const result = await runKtxSetupProjectStep( { projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false }, @@ -306,7 +302,7 @@ describe('setup project step', () => { expect(result.status).toBe('ready'); expect(result.projectDir).toBe(projectDir); expect(prompts.select).toHaveBeenNthCalledWith( - 3, + 2, expect.objectContaining({ message: `That folder already exists and is not empty: ${projectDir}`, options: expect.arrayContaining([ diff --git a/packages/cli/src/setup-project.ts b/packages/cli/src/setup-project.ts index 175adaf7..18512b03 100644 --- a/packages/cli/src/setup-project.ts +++ b/packages/cli/src/setup-project.ts @@ -113,6 +113,55 @@ async function existingFolderState( } } +type ConfirmProjectDirResult = + | { status: 'confirmed'; confirmedCreation: boolean } + | { status: 'choose-another' } + | { status: 'back' } + | { status: 'cancelled' } + | { status: 'not-directory' }; + +async function confirmProjectDir( + selectedDir: string, + io: KtxCliIo, + prompts: KtxSetupProjectPromptAdapter, +): Promise { + const state = await existingFolderState(selectedDir); + + if (state === 'not-directory') { + io.stderr.write(`Project folder path exists and is not a directory: ${selectedDir}\n`); + return { status: 'not-directory' }; + } + + if (state === 'non-empty-directory') { + const action = await prompts.select({ + message: `That folder already exists and is not empty: ${selectedDir}`, + options: [ + { value: 'use-existing', label: 'Yes, create KTX files there' }, + { value: 'choose-another', label: 'Choose another folder' }, + { value: 'back', label: 'Back' }, + ], + }); + if (action === 'choose-another') return { status: 'choose-another' }; + if (action === 'back') return { status: 'back' }; + if (action !== 'use-existing') return { status: 'cancelled' }; + return { status: 'confirmed', confirmedCreation: true }; + } + + io.stdout.write(`│ KTX will create:\n│ ${selectedDir}\n`); + const action = await prompts.select({ + message: `Create KTX project at ${selectedDir}?`, + options: [ + { value: 'create', label: 'Create project' }, + { value: 'choose-another', label: 'Choose another folder' }, + { value: 'back', label: 'Back' }, + ], + }); + if (action === 'choose-another') return { status: 'choose-another' }; + if (action === 'back') return { status: 'back' }; + if (action !== 'create') return { status: 'cancelled' }; + return { status: 'confirmed', confirmedCreation: true }; +} + async function normalizeSetupGitignore(projectDir: string): Promise { const gitignorePath = join(projectDir, '.ktx/.gitignore'); await mkdir(join(projectDir, '.ktx'), { recursive: true }); @@ -155,8 +204,6 @@ async function promptForNewProjectDir( const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME); while (true) { - io.stdout.write(`│ Relative paths are resolved from:\n│ ${projectDir}\n`); - io.stdout.write(`│ Home paths are resolved from:\n│ ${homeDir}\n`); const destinationChoice = await prompts.select({ message: 'Where should KTX create the project?', options: [ @@ -193,55 +240,12 @@ async function promptForNewProjectDir( return { status: 'cancelled', projectDir }; } - const state = await existingFolderState(selectedDir); - let confirmedCreation = false; - if (state === 'not-directory') { - io.stderr.write(`Project folder path exists and is not a directory: ${selectedDir}\n`); - return { status: 'missing-input', projectDir }; - } - if (state === 'non-empty-directory') { - const existingAction = await prompts.select({ - message: `That folder already exists and is not empty: ${selectedDir}`, - options: [ - { value: 'use-existing', label: 'Yes, create KTX files there' }, - { value: 'choose-another', label: 'Choose another folder' }, - { value: 'back', label: 'Back' }, - ], - }); - if (existingAction === 'choose-another') { - continue; - } - if (existingAction === 'back') { - return { status: 'back', projectDir }; - } - if (existingAction !== 'use-existing') { - return { status: 'cancelled', projectDir }; - } - confirmedCreation = true; - } - - io.stdout.write(`│ KTX will create:\n│ ${selectedDir}\n`); - if (state !== 'non-empty-directory') { - const createAction = await prompts.select({ - message: `Create KTX project at ${selectedDir}?`, - options: [ - { value: 'create', label: 'Create project' }, - { value: 'choose-another', label: 'Choose another folder' }, - { value: 'back', label: 'Back' }, - ], - }); - if (createAction === 'choose-another') { - continue; - } - if (createAction === 'back') { - return { status: 'back', projectDir }; - } - if (createAction !== 'create') { - return { status: 'cancelled', projectDir }; - } - confirmedCreation = true; - } - return { status: 'selected', projectDir: selectedDir, confirmedCreation }; + const confirmed = await confirmProjectDir(selectedDir, io, prompts); + if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir }; + if (confirmed.status === 'choose-another') continue; + if (confirmed.status === 'back') return { status: 'back', projectDir }; + if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir }; + return { status: 'selected', projectDir: selectedDir, confirmedCreation: confirmed.confirmedCreation }; } } @@ -323,15 +327,17 @@ export async function runKtxSetupProjectStep( } const prompts = deps.prompts ?? createClackSetupProjectPromptAdapter(); + const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME); io.stdout.write( '│ Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n', ); while (true) { const choice = await prompts.select({ - message: 'Which KTX project should setup use?', + message: 'Where should KTX create the project?', options: [ - { value: 'current', label: 'Use current directory' }, - { value: 'new', label: 'Create a new project folder' }, + { value: 'current', label: 'Current directory' }, + { value: 'new-default', label: 'New subfolder (./ktx-project)' }, + { value: 'new-custom', label: 'Custom path' }, ...(args.allowBack ? [{ value: 'back', label: 'Back' }] : []), ...(args.allowBack ? [] : [{ value: 'exit', label: 'Exit' }]), ], @@ -346,27 +352,51 @@ export async function runKtxSetupProjectStep( return { status: 'cancelled', projectDir }; } - let selectedDir = projectDir; - let confirmedCreation = false; - if (choice === 'new') { - const selected = await promptForNewProjectDir(projectDir, homeDir, io, prompts); - if (selected.status === 'back') { - continue; - } - if (selected.status !== 'selected') { - return selected; - } - selectedDir = selected.projectDir; - confirmedCreation = selected.confirmedCreation; + if (choice === 'current') { + const project = await createProject(projectDir, deps); + printProjectSummary(io, projectDir); + return { status: 'ready', projectDir, project }; } - if (choice !== 'current' && choice !== 'new') { - prompts.cancel('Setup cancelled.'); - return { status: 'cancelled', projectDir }; + if (choice === 'new-default') { + const confirmed = await confirmProjectDir(defaultProjectDir, io, prompts); + if (confirmed.status === 'choose-another' || confirmed.status === 'back') continue; + if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir }; + if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir }; + const project = await createProject(defaultProjectDir, deps); + printProjectSummary(io, defaultProjectDir); + return { + status: 'ready', + projectDir: defaultProjectDir, + project, + confirmedCreation: confirmed.confirmedCreation, + }; } - const project = await createProject(selectedDir, deps); - printProjectSummary(io, selectedDir); - return { status: 'ready', projectDir: selectedDir, project, confirmedCreation }; + if (choice === 'new-custom') { + const rawPath = await prompts.text({ + message: withTextInputNavigation('Project folder path'), + placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx', + }); + if (rawPath === undefined) continue; + const trimmed = rawPath.trim(); + if (trimmed.length === 0) { + io.stderr.write( + 'Enter a relative path like ./analytics-ktx, a home path like ~/analytics-ktx, or an absolute path.\n', + ); + return { status: 'missing-input', projectDir }; + } + const customDir = resolveFromProjectDir(projectDir, trimmed, homeDir); + const confirmed = await confirmProjectDir(customDir, io, prompts); + if (confirmed.status === 'choose-another' || confirmed.status === 'back') continue; + if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir }; + if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir }; + const project = await createProject(customDir, deps); + printProjectSummary(io, customDir); + return { status: 'ready', projectDir: customDir, project, confirmedCreation: confirmed.confirmedCreation }; + } + + prompts.cancel('Setup cancelled.'); + return { status: 'cancelled', projectDir }; } } diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index dc010b0a..695fc1c1 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -2,7 +2,7 @@ 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 { cancel, isCancel, log, multiselect, password, select, text } from '@clack/prompts'; +import { cancel, confirm, isCancel, log, multiselect, password, select, text } from '@clack/prompts'; import { localConnectionTypeForConfig, resolveNotionAuthToken } from '@ktx/context/connections'; import { resolveKtxConfigReference } from '@ktx/context/core'; import { @@ -136,12 +136,23 @@ const PRIMARY_SOURCE_DRIVERS = new Set([ function createPromptAdapter(): KtxSetupSourcesPromptAdapter { return { async multiselect(options) { - const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options))); - if (isCancel(value)) { - cancel('Setup cancelled.'); - return ['back']; + while (true) { + const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options))); + if (isCancel(value)) { + cancel('Setup cancelled.'); + return ['back']; + } + const selected = [...value] as string[]; + if (selected.length === 0 && !options.required) { + const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false }); + if (isCancel(skipConfirmed)) { + cancel('Setup cancelled.'); + return ['back']; + } + if (!skipConfirmed) continue; + } + return selected; } - return [...value] as string[]; }, async select(options) { const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));