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..4d18861c 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 }); @@ -193,55 +242,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 +329,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 +354,53 @@ 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') { + io.stdout.write(`│ Relative paths are resolved from:\n│ ${projectDir}\n`); + io.stdout.write(`│ Home paths are resolved from:\n│ ${homeDir}\n`); + 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 }; } }