Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/fix-interactive-prompts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/cli': patch
---

Fix interactive mode not prompting for all options.

- Default to interactive mode. Previously, `tanstack create my-app` silently applied defaults for framework, deployment, and install. Opt out with `--yes` / `--non-interactive`.
- Add framework selection prompt when the CLI supports multiple frameworks and no `--framework` flag is passed.
- Add "install dependencies now?" prompt when `--no-install` is not passed.
- Show deployment adapter prompt by default (previously required `showDeploymentOptions: true`).
- Honor `forcedDeployment` as the default selection in the deployment prompt, so deprecated aliases keep a sensible default.
- Preserve explicit `--add-ons` arrays instead of overwriting them with the interactive sentinel.
42 changes: 26 additions & 16 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export function cli({
forcedDeployment,
defaultFramework,
frameworkDefinitionInitializers,
showDeploymentOptions = false,
showDeploymentOptions = true,
legacyAutoCreate = false,
defaultRouterOnly = false,
}: {
Expand Down Expand Up @@ -649,9 +649,11 @@ export function cli({
cliOptions.routerOnly = true
}

cliOptions.framework = getFrameworkByName(
options.framework || defaultFramework || 'React',
)!.id
if (options.framework) {
cliOptions.framework = getFrameworkByName(options.framework)!.id
} else if (defaultFramework) {
cliOptions.framework = getFrameworkByName(defaultFramework)!.id
}

const nonInteractive = !!cliOptions.nonInteractive || !!cliOptions.yes
if (cliOptions.interactive && nonInteractive) {
Expand All @@ -660,43 +662,52 @@ export function cli({
)
}

const addOnsFlagPassed = process.argv.includes('--add-ons')
const hasInteractiveTerminal =
!!process.stdin.isTTY && !!process.stdout.isTTY && !process.env.CI
const wantsInteractiveMode =
!nonInteractive &&
(cliOptions.interactive ||
(cliOptions.addOns === true && addOnsFlagPassed))
(cliOptions.interactive || hasInteractiveTerminal)

let finalOptions: Options | undefined
if (wantsInteractiveMode) {
cliOptions.addOns = true
if (cliOptions.addOns === undefined) {
cliOptions.addOns = true
}
Comment on lines +673 to +675
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserving --add-ons arrays here also suppresses later add-on config/env-var prompts.

After this change, Array.isArray(cliOptions.addOns) no longer means “non-interactive”. packages/cli/src/options.ts still uses that check to skip promptForAddOnOptions() and promptForEnvVars(), so an interactive run like tanstack create my-app --add-ons auth will silently take defaults and can miss required env vars.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/cli.ts` around lines 673 - 675, The current logic is
coercing/normalizing --add-ons in a way that causes
Array.isArray(cliOptions.addOns) to no longer reliably indicate a user-supplied
list, which suppresses promptForAddOnOptions() and promptForEnvVars(); update
the normalization so arrays are preserved: in cli.ts keep cliOptions.addOns
untouched if Array.isArray(cliOptions.addOns), only set cliOptions.addOns = true
when cliOptions.addOns === undefined (and if your parser can produce an empty
string for a bare flag, normalize that to true as well), and remove any later
code that converts arrays into booleans so promptForAddOnOptions() and
promptForEnvVars() (from packages/cli/src/options.ts) still see arrays and run
interactively when appropriate.

} else {
if (!cliOptions.framework) {
cliOptions.framework = getFrameworkByName(
defaultFramework || 'React',
)!.id
}
finalOptions = await normalizeOptions(
cliOptions,
forcedAddOns,
{ forcedDeployment },
)
}

if (nonInteractive) {
if (cliOptions.addOns === true) {
throw new Error(
'When using --non-interactive/--yes, pass explicit add-ons via --add-ons <ids>.',
)
}
if (!wantsInteractiveMode && cliOptions.addOns === true) {
throw new Error(
'When running non-interactively, pass explicit add-ons via --add-ons <ids>.',
)
}

if (finalOptions) {
intro(`Creating a new ${appName} app in ${projectName}...`)
} else {
if (nonInteractive) {
if (!wantsInteractiveMode) {
throw new Error(
'Project name is required in non-interactive mode. Pass [project-name] or --target-dir.',
)
}
intro(`Let's configure your ${appName} application`)
finalOptions = await promptForCreateOptions(cliOptions, {
forcedAddOns,
forcedDeployment,
showDeploymentOptions,
defaultFrameworkId: defaultFramework
? getFrameworkByName(defaultFramework)?.id
: undefined,
})
}

Expand Down Expand Up @@ -756,7 +767,6 @@ export function cli({
}
return value
},
defaultFramework || 'React',
)
}

Expand Down
46 changes: 36 additions & 10 deletions packages/cli/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { intro } from '@clack/prompts'
import {
finalizeAddOns,
getFrameworkById,
getFrameworks,
getPackageManager,
loadStarter,
populateAddOnOptionsDefaults,
Expand All @@ -16,7 +17,9 @@ import {
selectAddOns,
selectDeployment,
selectExamples,
selectFramework,
selectGit,
selectInstall,
selectPackageManager,
selectTemplate,
selectToolchain,
Expand All @@ -39,15 +42,31 @@ export async function promptForCreateOptions(
cliOptions: CliOptions,
{
forcedAddOns = [],
showDeploymentOptions = false,
forcedDeployment,
showDeploymentOptions = true,
defaultFrameworkId,
}: {
forcedAddOns?: Array<string>
forcedDeployment?: string
showDeploymentOptions?: boolean
defaultFrameworkId?: string
},
): Promise<Required<Options> | undefined> {
const options = {} as Required<Options>

options.framework = getFrameworkById(cliOptions.framework || 'react')!
if (cliOptions.framework) {
options.framework = getFrameworkById(cliOptions.framework)!
} else {
const availableFrameworks = getFrameworks()
if (defaultFrameworkId || availableFrameworks.length <= 1) {
options.framework = getFrameworkById(defaultFrameworkId || 'react')!
} else {
Comment on lines +61 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Auto-pick the lone available framework instead of hard-coding react.

When there is exactly one available framework and defaultFrameworkId is unset, Line 62 still resolves react. A single-framework CLI variant backed by some other framework will pick the wrong framework or crash here.

Suggested fix
-    if (defaultFrameworkId || availableFrameworks.length <= 1) {
-      options.framework = getFrameworkById(defaultFrameworkId || 'react')!
-    } else {
+    if (defaultFrameworkId) {
+      options.framework = getFrameworkById(defaultFrameworkId)!
+    } else if (availableFrameworks.length === 1) {
+      options.framework = availableFrameworks[0]!
+    } else {
       options.framework = await selectFramework(
         availableFrameworks,
         defaultFrameworkId,
       )
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (defaultFrameworkId || availableFrameworks.length <= 1) {
options.framework = getFrameworkById(defaultFrameworkId || 'react')!
} else {
if (defaultFrameworkId) {
options.framework = getFrameworkById(defaultFrameworkId)!
} else if (availableFrameworks.length === 1) {
options.framework = availableFrameworks[0]!
} else {
options.framework = await selectFramework(
availableFrameworks,
defaultFrameworkId,
)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/options.ts` around lines 61 - 63, The code currently forces
'react' when defaultFrameworkId is falsy and availableFrameworks.length <= 1;
change the logic in the block that sets options.framework so it uses the lone
available framework's id if present: use defaultFrameworkId if set, otherwise if
availableFrameworks.length === 1 use availableFrameworks[0].id, and only fall
back to a hard-coded default (or undefined) when no frameworks exist; update the
assignment that calls getFrameworkById(...) (the code that sets
options.framework) to derive the id from defaultFrameworkId ||
availableFrameworks[0]?.id instead of defaulting to 'react'.

options.framework = await selectFramework(
availableFrameworks,
defaultFrameworkId,
)
}
}

// Validate project name
if (cliOptions.projectName) {
Expand Down Expand Up @@ -130,11 +149,20 @@ export async function promptForCreateOptions(
)

// Deployment selection
const deployment = showDeploymentOptions
? routerOnly
? undefined
: await selectDeployment(options.framework, cliOptions.deployment)
: undefined
let deployment: string | undefined
if (routerOnly) {
deployment = undefined
} else if (cliOptions.deployment) {
deployment = cliOptions.deployment
} else if (showDeploymentOptions) {
deployment = await selectDeployment(
options.framework,
cliOptions.deployment,
forcedDeployment,
)
} else {
deployment = forcedDeployment
}

// Add-ons selection
const addOns: Set<string> = new Set()
Expand Down Expand Up @@ -226,9 +254,7 @@ export async function promptForCreateOptions(
envVarValues

options.git = cliOptions.git ?? (await selectGit())
if (cliOptions.install === false) {
options.install = false
}
options.install = cliOptions.install ?? (await selectInstall())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In Commander.js, what value does an option defined only as --no-install have when the user does not pass the flag?

💡 Result:

In Commander.js, an option defined only as --no-install (a negatable boolean option defined alone) has the value true when the user does not pass the flag. This is the documented behavior for negatable boolean options: "You can define a boolean option long name with a leading no- to set the option value to false when used. Defined alone, this also makes the option true by default." The option name is install, so without --no-install the value is true (install), and with --no-install it is false (no install). This behavior has been consistent since at least v4 and remains in the latest documentation (v14+). If both --install and --no-install were defined, the default would be undefined unless explicitly set.

Citations:


🏁 Script executed:

#!/bin/bash
# Check the install option definition in cli.ts
echo "=== Checking cli.ts for install flag definition ==="
if [ -f "packages/cli/src/cli.ts" ]; then
  grep -n "install" packages/cli/src/cli.ts | head -20
else
  echo "File not found: packages/cli/src/cli.ts"
fi

Repository: TanStack/cli

Length of output: 580


🏁 Script executed:

#!/bin/bash
# Check options.ts around line 257
echo "=== Checking options.ts around line 257 ==="
if [ -f "packages/cli/src/options.ts" ]; then
  wc -l packages/cli/src/options.ts
  echo ""
  sed -n '250,265p' packages/cli/src/options.ts
else
  echo "File not found: packages/cli/src/options.ts"
fi

Repository: TanStack/cli

Length of output: 552


selectInstall() will never run with only --no-install defined.

The Commander.js option at packages/cli/src/cli.ts:784 defines only --no-install. According to Commander's documented behavior, a negatable option defined alone defaults to true when omitted. This means cliOptions.install is true when the user skips the flag, so line 257's nullish coalesce (true ?? (await selectInstall())) short-circuits and never calls the prompt.

You need either:

  1. Define both --install and --no-install (default becomes undefined), or
  2. Use a different signal to detect whether the flag was explicitly provided.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/options.ts` at line 257, The prompt never runs because
cliOptions.install is always true when only a negatable --no-install is defined;
update the CLI option definition so the flag is not default-true (e.g., define
both --install and --no-install in the Command declaration in the CLI module) so
cliOptions.install will be undefined when the user omits the flag and the
existing assignment options.install = cliOptions.install ?? (await
selectInstall()) will correctly invoke selectInstall(); alternatively, if you
prefer not to change the CLI API, change the detection logic around
options.install to check a distinct presence signal from Commander (the explicit
option presence) rather than relying on the boolean value so selectInstall()
runs only when the flag was not provided.


if (starter) {
options.starter = starter
Expand Down
55 changes: 52 additions & 3 deletions packages/cli/src/ui-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,47 @@ import type { AddOn, PackageManager } from '@tanstack/create'

import type { Framework } from '@tanstack/create/dist/types/types.js'

export async function selectFramework(
frameworks: Array<Framework>,
defaultFrameworkId?: string,
): Promise<Framework> {
const initialValue =
(defaultFrameworkId &&
frameworks.find(
(f) => f.id.toLowerCase() === defaultFrameworkId.toLowerCase(),
)?.id) ||
frameworks[0]!.id

const selected = await select({
message: 'Select framework:',
options: frameworks.map((f) => ({ value: f.id, label: f.name })),
initialValue,
})

if (isCancel(selected)) {
cancel('Operation cancelled.')
process.exit(0)
}

const framework = frameworks.find((f) => f.id === selected)
if (!framework) {
throw new Error(`Unknown framework: ${selected}`)
}
return framework
}

export async function selectInstall(): Promise<boolean> {
const install = await confirm({
message: 'Would you like to install dependencies now?',
initialValue: true,
})
if (isCancel(install)) {
cancel('Operation cancelled.')
process.exit(0)
}
return install
}

export async function getProjectName(): Promise<string> {
const value = await text({
message: 'What would you like to name your project?',
Expand Down Expand Up @@ -350,6 +391,7 @@ export async function promptForEnvVars(
export async function selectDeployment(
framework: Framework,
deployment?: string,
forcedDeployment?: string,
): Promise<string | undefined> {
const deployments = new Set<AddOn>()
let initialValue: string | undefined = undefined
Expand All @@ -361,21 +403,28 @@ export async function selectDeployment(
if (deployment && addOn.id === deployment) {
return deployment
}
if (addOn.default) {
if (forcedDeployment && addOn.id === forcedDeployment) {
initialValue = addOn.id
} else if (!initialValue && addOn.default) {
initialValue = addOn.id
}
}
}

if (deployments.size === 0) {
return undefined
}

const dp = await select({
message: 'Select deployment adapter',
message: 'Select deployment adapter:',
options: [
{ value: undefined, label: 'None' },
...Array.from(deployments).map((d) => ({
value: d.id,
label: d.name,
})),
],
initialValue: initialValue,
initialValue,
})

if (isCancel(dp)) {
Expand Down
Loading