Skip to content

Commit 6f3ef17

Browse files
Support current directory project creation (#458)
1 parent 691f045 commit 6f3ef17

11 files changed

Lines changed: 185 additions & 77 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/cli": minor
3+
---
4+
5+
Support initializing a project in the current directory from the create prompt or by passing `.` as the project name.

packages/cli/src/cli.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
getTelemetryStatus,
3131
setTelemetryEnabled,
3232
} from './telemetry-config.js'
33-
import { TelemetryClient, createTelemetryClient } from './telemetry.js'
33+
import { createTelemetryClient } from './telemetry.js'
3434

3535
import { promptForAddOns, promptForCreateOptions } from './options.js'
3636
import {
@@ -43,6 +43,7 @@ import { createUIEnvironment } from './ui-environment.js'
4343
import { DevWatchManager } from './dev-watch.js'
4444

4545
import type { CliOptions } from './types.js'
46+
import type { TelemetryClient } from './telemetry.js'
4647
import type {
4748
FrameworkDefinition,
4849
Options,
@@ -500,8 +501,7 @@ export function cli({
500501
getResolvedCreateTelemetryProperties(normalizedOpts, options),
501502
)
502503

503-
normalizedOpts.targetDir =
504-
options.targetDir || resolve(process.cwd(), projectName)
504+
normalizedOpts.targetDir = resolve(normalizedOpts.targetDir)
505505

506506
// Create the initial app with minimal output for dev watch mode
507507
console.log(chalk.bold('\ndev-watch'))
@@ -850,7 +850,11 @@ export function cli({
850850

851851
let cameFromPrompts = false
852852
if (finalOptions) {
853-
intro(`Creating a new ${appName} app in ${projectName}...`)
853+
const createLocation =
854+
resolve(finalOptions.targetDir) === resolve(process.cwd())
855+
? 'the current directory'
856+
: finalOptions.projectName
857+
intro(`Creating a new ${appName} app in ${createLocation}...`)
854858
} else {
855859
if (!wantsInteractiveMode) {
856860
throw new Error(
@@ -880,12 +884,10 @@ export function cli({
880884
;(finalOptions as Options & { routerOnly?: boolean }).routerOnly =
881885
!!cliOptions.routerOnly
882886

883-
if (options.targetDir) {
884-
finalOptions.targetDir = options.targetDir
885-
} else if (finalOptions.targetDir) {
887+
if (finalOptions.targetDir) {
886888
// Keep the normalized target dir.
887-
} else if (projectName === '.') {
888-
finalOptions.targetDir = resolve(process.cwd())
889+
} else if (options.targetDir) {
890+
finalOptions.targetDir = resolve(options.targetDir)
889891
} else {
890892
finalOptions.targetDir = resolve(process.cwd(), finalOptions.projectName)
891893
}

packages/cli/src/command-line.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ import {
1212
} from '@tanstack/create'
1313

1414
import {
15-
getCurrentDirectoryName,
16-
sanitizePackageName,
15+
resolveProjectLocation,
1716
validateProjectName,
1817
} from './utils.js'
1918
import type { Options } from '@tanstack/create'
@@ -406,21 +405,18 @@ export async function normalizeOptions(
406405
forcedDeployment?: string
407406
},
408407
): Promise<Options | undefined> {
409-
let projectName = (cliOptions.projectName ?? '').trim()
410-
let targetDir: string
408+
const projectLocation = resolveProjectLocation({
409+
projectName: cliOptions.projectName,
410+
targetDir: cliOptions.targetDir,
411+
})
411412

412-
// Handle "." as project name - use current directory
413-
if (projectName === '.') {
414-
projectName = sanitizePackageName(getCurrentDirectoryName())
415-
targetDir = resolve(process.cwd())
416-
} else {
417-
targetDir = resolve(process.cwd(), projectName)
418-
}
419-
420-
if (!projectName && !opts?.disableNameCheck) {
413+
if (!projectLocation && !opts?.disableNameCheck) {
421414
return undefined
422415
}
423416

417+
const projectName = projectLocation?.projectName ?? ''
418+
const targetDir = projectLocation?.targetDir ?? resolve(process.cwd())
419+
424420
if (projectName) {
425421
const { valid, error } = validateProjectName(projectName)
426422
if (!valid) {

packages/cli/src/options.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ import {
3030
} from './command-line.js'
3131

3232
import {
33-
getCurrentDirectoryName,
34-
sanitizePackageName,
33+
resolveProjectLocation,
3534
validateProjectName,
3635
} from './utils.js'
3736
import type { Options } from '@tanstack/create'
@@ -68,21 +67,23 @@ export async function promptForCreateOptions(
6867
}
6968
}
7069

71-
// Validate project name
72-
if (cliOptions.projectName) {
73-
// Handle "." as project name - use sanitized current directory name
74-
if (cliOptions.projectName === '.') {
75-
options.projectName = sanitizePackageName(getCurrentDirectoryName())
76-
} else {
77-
options.projectName = cliOptions.projectName
78-
}
79-
const { valid, error } = validateProjectName(options.projectName)
80-
if (!valid) {
81-
console.error(error)
82-
process.exit(1)
83-
}
84-
} else {
85-
options.projectName = await getProjectName()
70+
const projectLocation = resolveProjectLocation({
71+
projectName: cliOptions.projectName ?? (await getProjectName()),
72+
targetDir: cliOptions.targetDir,
73+
emptyProjectNameIsCurrentDirectory: true,
74+
})
75+
76+
if (!projectLocation) {
77+
throw new Error('Project name or target directory is required')
78+
}
79+
80+
options.projectName = projectLocation.projectName
81+
options.targetDir = projectLocation.targetDir
82+
83+
const { valid, error } = validateProjectName(options.projectName)
84+
if (!valid) {
85+
console.error(error)
86+
process.exit(1)
8687
}
8788

8889
// Mode is always file-router (TanStack Start)

packages/cli/src/telemetry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { version as nodeVersion } from 'node:process'
22

33
import {
4+
TELEMETRY_NOTICE_VERSION,
45
getTelemetryStatus,
56
markTelemetryNoticeSeen,
6-
TELEMETRY_NOTICE_VERSION,
77
} from './telemetry-config.js'
88

99
import type { StatusEvent, StatusStepType } from '@tanstack/create'

packages/cli/src/ui-environment.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ import {
1010
import chalk from 'chalk'
1111

1212
import { createDefaultEnvironment } from '@tanstack/create'
13-
import type { StatusEvent } from '@tanstack/create'
14-
15-
import type { Environment } from '@tanstack/create'
13+
import type { Environment, StatusEvent } from '@tanstack/create'
1614
import type { TelemetryClient } from './telemetry.js'
1715

1816
export function createUIEnvironment(

packages/cli/src/ui-prompts.ts

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import {
1515
getAllAddOns,
1616
} from '@tanstack/create'
1717

18-
import { validateProjectName } from './utils.js'
18+
import {
19+
isCurrentDirectoryProjectNameInput,
20+
validateProjectName,
21+
} from './utils.js'
1922
import type { AddOn, PackageManager } from '@tanstack/create'
2023

2124
import type { Framework } from '@tanstack/create/dist/types/types.js'
@@ -29,7 +32,7 @@ export async function selectFramework(
2932
frameworks.find(
3033
(f) => f.id.toLowerCase() === defaultFrameworkId.toLowerCase(),
3134
)?.id) ||
32-
frameworks[0]!.id
35+
frameworks[0].id
3336

3437
const selected = await select({
3538
message: 'Select framework:',
@@ -64,10 +67,10 @@ export async function selectInstall(): Promise<boolean> {
6467
export async function getProjectName(): Promise<string> {
6568
const value = await text({
6669
message: 'What would you like to name your project?',
67-
defaultValue: 'my-app',
70+
placeholder: 'Leave empty to initialize in the current directory',
6871
validate(value) {
69-
if (!value) {
70-
return 'Please enter a name'
72+
if (isCurrentDirectoryProjectNameInput(value)) {
73+
return
7174
}
7275

7376
const { valid, error } = validateProjectName(value)
@@ -82,7 +85,7 @@ export async function getProjectName(): Promise<string> {
8285
process.exit(0)
8386
}
8487

85-
return value
88+
return value.trim()
8689
}
8790

8891
export async function selectPackageManager(): Promise<PackageManager> {
@@ -284,34 +287,21 @@ export async function promptForAddOnOptions(
284287
addOnOptions[addOnId] = {}
285288

286289
for (const [optionName, option] of Object.entries(addOn.options)) {
287-
if (option && typeof option === 'object' && 'type' in option) {
288-
if (option.type === 'select') {
289-
const selectOption = option as {
290-
type: 'select'
291-
label: string
292-
description?: string
293-
default: string
294-
options: Array<{ value: string; label: string }>
295-
}
296-
297-
const value = await select({
298-
message: `${addOn.name}: ${selectOption.label}`,
299-
options: selectOption.options.map((opt) => ({
300-
value: opt.value,
301-
label: opt.label,
302-
})),
303-
initialValue: selectOption.default,
304-
})
305-
306-
if (isCancel(value)) {
307-
cancel('Operation cancelled.')
308-
process.exit(0)
309-
}
310-
311-
addOnOptions[addOnId][optionName] = value
312-
}
313-
// Future option types can be added here
290+
const value = await select({
291+
message: `${addOn.name}: ${option.label}`,
292+
options: option.options.map((opt) => ({
293+
value: opt.value,
294+
label: opt.label,
295+
})),
296+
initialValue: option.default,
297+
})
298+
299+
if (isCancel(value)) {
300+
cancel('Operation cancelled.')
301+
process.exit(0)
314302
}
303+
304+
addOnOptions[addOnId][optionName] = value
315305
}
316306
}
317307

packages/cli/src/utils.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { basename } from 'node:path'
1+
import { basename, resolve } from 'node:path'
22
import validatePackageName from 'validate-npm-package-name'
33

4+
const FALLBACK_PACKAGE_NAME = 'tanstack-app'
5+
46
export function sanitizePackageName(name: string): string {
57
return name
68
.toLowerCase()
@@ -16,6 +18,64 @@ export function getCurrentDirectoryName(): string {
1618
return basename(process.cwd())
1719
}
1820

21+
export function getDirectoryPackageName(directory: string): string {
22+
return sanitizePackageName(basename(resolve(directory))) || FALLBACK_PACKAGE_NAME
23+
}
24+
25+
export function getCurrentDirectoryPackageName(): string {
26+
return getDirectoryPackageName(process.cwd())
27+
}
28+
29+
export function isCurrentDirectoryProjectNameInput(name: string): boolean {
30+
const normalized = name.trim()
31+
return normalized === '' || normalized === '.'
32+
}
33+
34+
export function resolveProjectLocation({
35+
projectName,
36+
targetDir,
37+
emptyProjectNameIsCurrentDirectory = false,
38+
}: {
39+
projectName?: string
40+
targetDir?: string
41+
emptyProjectNameIsCurrentDirectory?: boolean
42+
}): { projectName: string; targetDir: string } | undefined {
43+
const normalizedProjectName = projectName?.trim() ?? ''
44+
45+
if (normalizedProjectName === '.') {
46+
return {
47+
projectName: getCurrentDirectoryPackageName(),
48+
targetDir: resolve(process.cwd()),
49+
}
50+
}
51+
52+
if (normalizedProjectName) {
53+
return {
54+
projectName: normalizedProjectName,
55+
targetDir: targetDir
56+
? resolve(targetDir)
57+
: resolve(process.cwd(), normalizedProjectName),
58+
}
59+
}
60+
61+
if (targetDir) {
62+
const resolvedTargetDir = resolve(targetDir)
63+
return {
64+
projectName: getDirectoryPackageName(resolvedTargetDir),
65+
targetDir: resolvedTargetDir,
66+
}
67+
}
68+
69+
if (emptyProjectNameIsCurrentDirectory) {
70+
return {
71+
projectName: getCurrentDirectoryPackageName(),
72+
targetDir: resolve(process.cwd()),
73+
}
74+
}
75+
76+
return undefined
77+
}
78+
1979
export function validateProjectName(name: string) {
2080
const { validForNewPackages, validForOldPackages, errors, warnings } =
2181
validatePackageName(name)

packages/cli/tests/command-line.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ describe('normalizeOptions', () => {
7070
expect(options?.targetDir).toBe(resolve(process.cwd()))
7171
})
7272

73+
it('should derive the project name from target-dir when no name is provided', async () => {
74+
const options = await normalizeOptions({
75+
targetDir: 'my-target-app',
76+
})
77+
78+
expect(options?.projectName).toBe('my-target-app')
79+
expect(options?.targetDir).toBe(resolve(process.cwd(), 'my-target-app'))
80+
})
81+
7382
it('should always enable typescript (file-router/TanStack Start requires it)', async () => {
7483
const options = await normalizeOptions({
7584
projectName: 'test',

0 commit comments

Comments
 (0)