diff --git a/.changeset/current-directory-create.md b/.changeset/current-directory-create.md new file mode 100644 index 00000000..cf4b2803 --- /dev/null +++ b/.changeset/current-directory-create.md @@ -0,0 +1,5 @@ +--- +"@tanstack/cli": minor +--- + +Support initializing a project in the current directory from the create prompt or by passing `.` as the project name. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2a96e36c..797561d2 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -30,7 +30,7 @@ import { getTelemetryStatus, setTelemetryEnabled, } from './telemetry-config.js' -import { TelemetryClient, createTelemetryClient } from './telemetry.js' +import { createTelemetryClient } from './telemetry.js' import { promptForAddOns, promptForCreateOptions } from './options.js' import { @@ -43,6 +43,7 @@ import { createUIEnvironment } from './ui-environment.js' import { DevWatchManager } from './dev-watch.js' import type { CliOptions } from './types.js' +import type { TelemetryClient } from './telemetry.js' import type { FrameworkDefinition, Options, @@ -500,8 +501,7 @@ export function cli({ getResolvedCreateTelemetryProperties(normalizedOpts, options), ) - normalizedOpts.targetDir = - options.targetDir || resolve(process.cwd(), projectName) + normalizedOpts.targetDir = resolve(normalizedOpts.targetDir) // Create the initial app with minimal output for dev watch mode console.log(chalk.bold('\ndev-watch')) @@ -850,7 +850,11 @@ export function cli({ let cameFromPrompts = false if (finalOptions) { - intro(`Creating a new ${appName} app in ${projectName}...`) + const createLocation = + resolve(finalOptions.targetDir) === resolve(process.cwd()) + ? 'the current directory' + : finalOptions.projectName + intro(`Creating a new ${appName} app in ${createLocation}...`) } else { if (!wantsInteractiveMode) { throw new Error( @@ -880,12 +884,10 @@ export function cli({ ;(finalOptions as Options & { routerOnly?: boolean }).routerOnly = !!cliOptions.routerOnly - if (options.targetDir) { - finalOptions.targetDir = options.targetDir - } else if (finalOptions.targetDir) { + if (finalOptions.targetDir) { // Keep the normalized target dir. - } else if (projectName === '.') { - finalOptions.targetDir = resolve(process.cwd()) + } else if (options.targetDir) { + finalOptions.targetDir = resolve(options.targetDir) } else { finalOptions.targetDir = resolve(process.cwd(), finalOptions.projectName) } diff --git a/packages/cli/src/command-line.ts b/packages/cli/src/command-line.ts index 08fd3dc7..c375e079 100644 --- a/packages/cli/src/command-line.ts +++ b/packages/cli/src/command-line.ts @@ -12,8 +12,7 @@ import { } from '@tanstack/create' import { - getCurrentDirectoryName, - sanitizePackageName, + resolveProjectLocation, validateProjectName, } from './utils.js' import type { Options } from '@tanstack/create' @@ -406,21 +405,18 @@ export async function normalizeOptions( forcedDeployment?: string }, ): Promise { - let projectName = (cliOptions.projectName ?? '').trim() - let targetDir: string + const projectLocation = resolveProjectLocation({ + projectName: cliOptions.projectName, + targetDir: cliOptions.targetDir, + }) - // Handle "." as project name - use current directory - if (projectName === '.') { - projectName = sanitizePackageName(getCurrentDirectoryName()) - targetDir = resolve(process.cwd()) - } else { - targetDir = resolve(process.cwd(), projectName) - } - - if (!projectName && !opts?.disableNameCheck) { + if (!projectLocation && !opts?.disableNameCheck) { return undefined } + const projectName = projectLocation?.projectName ?? '' + const targetDir = projectLocation?.targetDir ?? resolve(process.cwd()) + if (projectName) { const { valid, error } = validateProjectName(projectName) if (!valid) { diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 4b4d7d35..26cd8a3d 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -30,8 +30,7 @@ import { } from './command-line.js' import { - getCurrentDirectoryName, - sanitizePackageName, + resolveProjectLocation, validateProjectName, } from './utils.js' import type { Options } from '@tanstack/create' @@ -68,21 +67,23 @@ export async function promptForCreateOptions( } } - // Validate project name - if (cliOptions.projectName) { - // Handle "." as project name - use sanitized current directory name - if (cliOptions.projectName === '.') { - options.projectName = sanitizePackageName(getCurrentDirectoryName()) - } else { - options.projectName = cliOptions.projectName - } - const { valid, error } = validateProjectName(options.projectName) - if (!valid) { - console.error(error) - process.exit(1) - } - } else { - options.projectName = await getProjectName() + const projectLocation = resolveProjectLocation({ + projectName: cliOptions.projectName ?? (await getProjectName()), + targetDir: cliOptions.targetDir, + emptyProjectNameIsCurrentDirectory: true, + }) + + if (!projectLocation) { + throw new Error('Project name or target directory is required') + } + + options.projectName = projectLocation.projectName + options.targetDir = projectLocation.targetDir + + const { valid, error } = validateProjectName(options.projectName) + if (!valid) { + console.error(error) + process.exit(1) } // Mode is always file-router (TanStack Start) diff --git a/packages/cli/src/telemetry.ts b/packages/cli/src/telemetry.ts index 29c202d2..ee7462f8 100644 --- a/packages/cli/src/telemetry.ts +++ b/packages/cli/src/telemetry.ts @@ -1,9 +1,9 @@ import { version as nodeVersion } from 'node:process' import { + TELEMETRY_NOTICE_VERSION, getTelemetryStatus, markTelemetryNoticeSeen, - TELEMETRY_NOTICE_VERSION, } from './telemetry-config.js' import type { StatusEvent, StatusStepType } from '@tanstack/create' diff --git a/packages/cli/src/ui-environment.ts b/packages/cli/src/ui-environment.ts index b6489412..6b4456b5 100644 --- a/packages/cli/src/ui-environment.ts +++ b/packages/cli/src/ui-environment.ts @@ -10,9 +10,7 @@ import { import chalk from 'chalk' import { createDefaultEnvironment } from '@tanstack/create' -import type { StatusEvent } from '@tanstack/create' - -import type { Environment } from '@tanstack/create' +import type { Environment, StatusEvent } from '@tanstack/create' import type { TelemetryClient } from './telemetry.js' export function createUIEnvironment( diff --git a/packages/cli/src/ui-prompts.ts b/packages/cli/src/ui-prompts.ts index 65504ad3..5cf162c7 100644 --- a/packages/cli/src/ui-prompts.ts +++ b/packages/cli/src/ui-prompts.ts @@ -15,7 +15,10 @@ import { getAllAddOns, } from '@tanstack/create' -import { validateProjectName } from './utils.js' +import { + isCurrentDirectoryProjectNameInput, + validateProjectName, +} from './utils.js' import type { AddOn, PackageManager } from '@tanstack/create' import type { Framework } from '@tanstack/create/dist/types/types.js' @@ -29,7 +32,7 @@ export async function selectFramework( frameworks.find( (f) => f.id.toLowerCase() === defaultFrameworkId.toLowerCase(), )?.id) || - frameworks[0]!.id + frameworks[0].id const selected = await select({ message: 'Select framework:', @@ -64,10 +67,10 @@ export async function selectInstall(): Promise { export async function getProjectName(): Promise { const value = await text({ message: 'What would you like to name your project?', - defaultValue: 'my-app', + placeholder: 'Leave empty to initialize in the current directory', validate(value) { - if (!value) { - return 'Please enter a name' + if (isCurrentDirectoryProjectNameInput(value)) { + return } const { valid, error } = validateProjectName(value) @@ -82,7 +85,7 @@ export async function getProjectName(): Promise { process.exit(0) } - return value + return value.trim() } export async function selectPackageManager(): Promise { @@ -284,34 +287,21 @@ export async function promptForAddOnOptions( addOnOptions[addOnId] = {} for (const [optionName, option] of Object.entries(addOn.options)) { - if (option && typeof option === 'object' && 'type' in option) { - if (option.type === 'select') { - const selectOption = option as { - type: 'select' - label: string - description?: string - default: string - options: Array<{ value: string; label: string }> - } - - const value = await select({ - message: `${addOn.name}: ${selectOption.label}`, - options: selectOption.options.map((opt) => ({ - value: opt.value, - label: opt.label, - })), - initialValue: selectOption.default, - }) - - if (isCancel(value)) { - cancel('Operation cancelled.') - process.exit(0) - } - - addOnOptions[addOnId][optionName] = value - } - // Future option types can be added here + const value = await select({ + message: `${addOn.name}: ${option.label}`, + options: option.options.map((opt) => ({ + value: opt.value, + label: opt.label, + })), + initialValue: option.default, + }) + + if (isCancel(value)) { + cancel('Operation cancelled.') + process.exit(0) } + + addOnOptions[addOnId][optionName] = value } } diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 29e031ca..ab36e87d 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,6 +1,8 @@ -import { basename } from 'node:path' +import { basename, resolve } from 'node:path' import validatePackageName from 'validate-npm-package-name' +const FALLBACK_PACKAGE_NAME = 'tanstack-app' + export function sanitizePackageName(name: string): string { return name .toLowerCase() @@ -16,6 +18,64 @@ export function getCurrentDirectoryName(): string { return basename(process.cwd()) } +export function getDirectoryPackageName(directory: string): string { + return sanitizePackageName(basename(resolve(directory))) || FALLBACK_PACKAGE_NAME +} + +export function getCurrentDirectoryPackageName(): string { + return getDirectoryPackageName(process.cwd()) +} + +export function isCurrentDirectoryProjectNameInput(name: string): boolean { + const normalized = name.trim() + return normalized === '' || normalized === '.' +} + +export function resolveProjectLocation({ + projectName, + targetDir, + emptyProjectNameIsCurrentDirectory = false, +}: { + projectName?: string + targetDir?: string + emptyProjectNameIsCurrentDirectory?: boolean +}): { projectName: string; targetDir: string } | undefined { + const normalizedProjectName = projectName?.trim() ?? '' + + if (normalizedProjectName === '.') { + return { + projectName: getCurrentDirectoryPackageName(), + targetDir: resolve(process.cwd()), + } + } + + if (normalizedProjectName) { + return { + projectName: normalizedProjectName, + targetDir: targetDir + ? resolve(targetDir) + : resolve(process.cwd(), normalizedProjectName), + } + } + + if (targetDir) { + const resolvedTargetDir = resolve(targetDir) + return { + projectName: getDirectoryPackageName(resolvedTargetDir), + targetDir: resolvedTargetDir, + } + } + + if (emptyProjectNameIsCurrentDirectory) { + return { + projectName: getCurrentDirectoryPackageName(), + targetDir: resolve(process.cwd()), + } + } + + return undefined +} + export function validateProjectName(name: string) { const { validForNewPackages, validForOldPackages, errors, warnings } = validatePackageName(name) diff --git a/packages/cli/tests/command-line.test.ts b/packages/cli/tests/command-line.test.ts index b1187cf9..4842b7f3 100644 --- a/packages/cli/tests/command-line.test.ts +++ b/packages/cli/tests/command-line.test.ts @@ -70,6 +70,15 @@ describe('normalizeOptions', () => { expect(options?.targetDir).toBe(resolve(process.cwd())) }) + it('should derive the project name from target-dir when no name is provided', async () => { + const options = await normalizeOptions({ + targetDir: 'my-target-app', + }) + + expect(options?.projectName).toBe('my-target-app') + expect(options?.targetDir).toBe(resolve(process.cwd(), 'my-target-app')) + }) + it('should always enable typescript (file-router/TanStack Start requires it)', async () => { const options = await normalizeOptions({ projectName: 'test', diff --git a/packages/cli/tests/options.test.ts b/packages/cli/tests/options.test.ts index a4b170cd..f6fb8fa0 100644 --- a/packages/cli/tests/options.test.ts +++ b/packages/cli/tests/options.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, it, expect, vi } from 'vitest' +import { resolve } from 'node:path' import { promptForCreateOptions } from '../src/options' import { @@ -9,6 +10,10 @@ import * as create from '@tanstack/create' import * as prompts from '../src/ui-prompts' import * as commandLine from '../src/command-line' +import { + getCurrentDirectoryName, + sanitizePackageName, +} from '../src/utils' import type { Framework } from '@tanstack/create' @@ -107,6 +112,30 @@ describe('promptForCreateOptions', () => { expect(options?.projectName).toBe('hello') }) + it('uses the current directory when the prompted project name is empty', async () => { + setBasicSpies() + vi.spyOn(prompts, 'getProjectName').mockImplementation(async () => '') + + const options = await promptForCreateOptions(baseCliOptions, {}) + + expect(options?.projectName).toBe( + sanitizePackageName(getCurrentDirectoryName()), + ) + expect(options?.targetDir).toBe(resolve(process.cwd())) + }) + + it('uses the current directory when the prompted project name is "."', async () => { + setBasicSpies() + vi.spyOn(prompts, 'getProjectName').mockImplementation(async () => '.') + + const options = await promptForCreateOptions(baseCliOptions, {}) + + expect(options?.projectName).toBe( + sanitizePackageName(getCurrentDirectoryName()), + ) + expect(options?.targetDir).toBe(resolve(process.cwd())) + }) + it('accept incoming project name', async () => { setBasicSpies() diff --git a/packages/cli/tests/ui-prompts.test.ts b/packages/cli/tests/ui-prompts.test.ts index e42ded69..2957d844 100644 --- a/packages/cli/tests/ui-prompts.test.ts +++ b/packages/cli/tests/ui-prompts.test.ts @@ -28,6 +28,24 @@ describe('getProjectName', () => { expect(projectName).toBe('my-app') }) + it('should allow blank and "." project names for current directory creation', async () => { + const textSpy = vi.spyOn(clack, 'text').mockImplementation(async () => '') + vi.spyOn(clack, 'isCancel').mockImplementation(() => false) + + const projectName = await getProjectName() + const textOptions = textSpy.mock.calls[0]![0] as { + placeholder?: string + validate?: (value: string) => string | undefined + } + + expect(projectName).toBe('') + expect(textOptions.placeholder).toBe( + 'Leave empty to initialize in the current directory', + ) + expect(textOptions.validate?.('')).toBeUndefined() + expect(textOptions.validate?.('.')).toBeUndefined() + }) + it('should exit on cancel', async () => { vi.spyOn(clack, 'text').mockImplementation(async () => 'Cancelled') vi.spyOn(clack, 'isCancel').mockImplementation(() => true)