Skip to content

Commit 83ebd17

Browse files
feat(cli, create): close the scaffold-to-ship UX gap (#445)
Bundle of beginner-friendly UX improvements and AI-agent affordances spanning post-creation guidance, a pre-creation review screen, an auto-generated .env.example, concept-first add-on descriptions, per-host deployment quickstarts, Clerk demo parity with Better Auth, and a new `tanstack clean-demos` subcommand. See changeset for the full breakdown.
1 parent 8425b80 commit 83ebd17

44 files changed

Lines changed: 712 additions & 63 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/scaffold-shipping-ux.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@tanstack/cli': minor
3+
'@tanstack/create': minor
4+
---
5+
6+
feat(cli, create): close the gap between `tanstack create` and shipping a real app
7+
8+
A bundle of UX improvements aimed at beginners (especially those coming from Next.js) and the AI agents they pair with:
9+
10+
- **Tailored post-creation next steps.** The scaffold completion message now lists the env vars you still need to fill in `.env.local`, links the docs for each shipping-critical integration you picked (auth, database, ORM, deployment), and surfaces the Intent-wired AGENTS.md / CLAUDE.md with concrete prompt examples.
11+
- **Pre-creation review screen.** After interactive prompts, the CLI shows a categorized summary (auth, database, ORM, deploy, other) and asks for confirmation before writing files. Conflicting selections (two auth providers, two ORMs, etc.) are flagged in the same step.
12+
- **`.env.example` generation.** A checked-in `.env.example` is now derived from the env-var schemas of selected add-ons, with descriptions and a `(required)` marker. Plays nicely with add-ons that ship their own `_dot_env.example.append`.
13+
- **Better add-on descriptions.** Concept-first one-liners replace generic "Add X to your application." Reads like a menu instead of a list of brand names.
14+
- **Deployment quickstarts.** Each `--deployment` host (Netlify, Cloudflare, Railway, Nitro) now contributes its own README section explaining the actual steps to ship — push, dashboard URL, env var sync.
15+
- **Clerk demo route parity.** Clerk's scaffold now ships a proper sign-in flow (matching Better Auth's depth) using Clerk's prebuilt components, plus a richer README with route-protection patterns and a production checklist.
16+
- **Intent install passes `--map`.** The auto-invoked `intent install` now writes explicit task→skill mappings into the agent config instead of relying on runtime discovery, so agents see directly which skill matches which task.
17+
- **`tanstack clean-demos` command.** A new subcommand removes leftover `demo.*` and `example.*` files (and prunes empty `routes/demo`/`routes/example` directories) so a beginner can ship without the scaffold's training wheels.

packages/cli/src/cli.ts

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import fs from 'node:fs'
2-
import { resolve } from 'node:path'
2+
import { relative, resolve } from 'node:path'
33
import { Command, InvalidArgumentError, Option } from 'commander'
44
import { cancel, confirm, intro, isCancel, log } from '@clack/prompts'
55
import chalk from 'chalk'
@@ -17,6 +17,7 @@ import {
1717
getFrameworks,
1818
initAddOn,
1919
initStarter,
20+
isDemoFilePath,
2021
} from '@tanstack/create'
2122
import {
2223
LIBRARY_GROUPS,
@@ -290,6 +291,159 @@ export function cli({
290291
}
291292
}
292293

294+
async function confirmCreateOptions(finalOptions: Options) {
295+
const lines: Array<string> = []
296+
lines.push(` Project: ${finalOptions.projectName}`)
297+
lines.push(` Location: ${finalOptions.targetDir}`)
298+
lines.push(` Framework: ${finalOptions.framework.name}`)
299+
lines.push(` Mode: ${finalOptions.mode}`)
300+
lines.push(` Package manager: ${finalOptions.packageManager}`)
301+
if (finalOptions.starter) {
302+
lines.push(` Template: ${finalOptions.starter.name}`)
303+
}
304+
305+
const auth: Array<string> = []
306+
const database: Array<string> = []
307+
const orm: Array<string> = []
308+
const deploy: Array<string> = []
309+
const otherAddOns: Array<string> = []
310+
for (const addOn of finalOptions.chosenAddOns) {
311+
switch (addOn.category) {
312+
case 'auth':
313+
auth.push(addOn.name)
314+
break
315+
case 'database':
316+
database.push(addOn.name)
317+
break
318+
case 'orm':
319+
orm.push(addOn.name)
320+
break
321+
case 'deploy':
322+
deploy.push(addOn.name)
323+
break
324+
default:
325+
otherAddOns.push(addOn.name)
326+
}
327+
}
328+
329+
if (
330+
auth.length +
331+
database.length +
332+
orm.length +
333+
deploy.length +
334+
otherAddOns.length >
335+
0
336+
) {
337+
lines.push('')
338+
}
339+
if (auth.length > 0) {
340+
lines.push(` Auth: ${auth.join(', ')}`)
341+
}
342+
if (database.length > 0) {
343+
lines.push(` Database: ${database.join(', ')}`)
344+
}
345+
if (orm.length > 0) {
346+
lines.push(` ORM: ${orm.join(', ')}`)
347+
}
348+
if (deploy.length > 0) {
349+
lines.push(` Deploy: ${deploy.join(', ')}`)
350+
}
351+
if (otherAddOns.length > 0) {
352+
lines.push(` Other add-ons: ${otherAddOns.join(', ')}`)
353+
}
354+
355+
lines.push('')
356+
lines.push(` Initialize git: ${finalOptions.git ? 'yes' : 'no'}`)
357+
lines.push(
358+
` Install deps: ${finalOptions.install === false ? 'no' : 'yes'}`,
359+
)
360+
lines.push(` Agent skills: ${finalOptions.intent ? 'yes' : 'no'}`)
361+
362+
log.info(`About to create:\n\n${lines.join('\n')}`)
363+
364+
const conflicts = findExclusiveConflicts(finalOptions.chosenAddOns)
365+
if (conflicts.length > 0) {
366+
log.warn(
367+
`Conflicting selections detected:\n${conflicts
368+
.map((c) => ` • ${c.category}: ${c.names.join(', ')}`)
369+
.join('\n')}`,
370+
)
371+
}
372+
373+
const shouldContinue = await confirm({
374+
message: 'Continue with these settings?',
375+
initialValue: true,
376+
})
377+
378+
if (isCancel(shouldContinue) || !shouldContinue) {
379+
cancel('Operation cancelled.')
380+
process.exit(0)
381+
}
382+
}
383+
384+
const CLEAN_DEMOS_SKIP_DIRS = new Set([
385+
'node_modules',
386+
'.git',
387+
'dist',
388+
'.output',
389+
'.tanstack',
390+
'.nitro',
391+
'.wrangler',
392+
])
393+
394+
function findDemoFiles(root: string): Array<string> {
395+
const results: Array<string> = []
396+
function walk(dir: string) {
397+
let entries: Array<fs.Dirent>
398+
try {
399+
entries = fs.readdirSync(dir, { withFileTypes: true })
400+
} catch {
401+
return
402+
}
403+
for (const entry of entries) {
404+
const full = resolve(dir, entry.name)
405+
if (entry.isDirectory()) {
406+
if (CLEAN_DEMOS_SKIP_DIRS.has(entry.name)) continue
407+
walk(full)
408+
} else if (entry.isFile() && isDemoFilePath(full)) {
409+
results.push(full)
410+
}
411+
}
412+
}
413+
walk(root)
414+
return results.sort()
415+
}
416+
417+
function pruneEmptyDemoDirs(root: string) {
418+
const candidates = ['src/routes/demo', 'src/routes/example']
419+
for (const rel of candidates) {
420+
const dir = resolve(root, rel)
421+
if (!fs.existsSync(dir)) continue
422+
try {
423+
if (fs.readdirSync(dir).length === 0) {
424+
fs.rmdirSync(dir)
425+
}
426+
} catch {
427+
// ignore
428+
}
429+
}
430+
}
431+
432+
function findExclusiveConflicts(
433+
addOns: Options['chosenAddOns'],
434+
): Array<{ category: string; names: Array<string> }> {
435+
const buckets: Record<string, Array<string>> = {}
436+
for (const addOn of addOns) {
437+
for (const exclusive of addOn.exclusive || []) {
438+
buckets[exclusive] ??= []
439+
buckets[exclusive].push(addOn.name)
440+
}
441+
}
442+
return Object.entries(buckets)
443+
.filter(([_, names]) => names.length > 1)
444+
.map(([category, names]) => ({ category, names }))
445+
}
446+
293447
const availableFrameworks = getFrameworks().map((f) => f.name)
294448

295449
function resolveBuiltInDevWatchPath(frameworkId: string): string {
@@ -694,6 +848,7 @@ export function cli({
694848
)
695849
}
696850

851+
let cameFromPrompts = false
697852
if (finalOptions) {
698853
intro(`Creating a new ${appName} app in ${projectName}...`)
699854
} else {
@@ -711,6 +866,7 @@ export function cli({
711866
? getFrameworkByName(defaultFramework)?.id
712867
: undefined,
713868
})
869+
cameFromPrompts = true
714870
}
715871

716872
if (!finalOptions) {
@@ -734,6 +890,9 @@ export function cli({
734890
finalOptions.targetDir = resolve(process.cwd(), finalOptions.projectName)
735891
}
736892

893+
if (cameFromPrompts) {
894+
await confirmCreateOptions(finalOptions)
895+
}
737896
await confirmTargetDirectorySafety(finalOptions.targetDir, options.force)
738897
await createApp(environment, finalOptions)
739898
},
@@ -1388,6 +1547,87 @@ Remove your node_modules directory and package lock file and re-install.`,
13881547
}
13891548
})
13901549

1550+
// === CLEAN-DEMOS SUBCOMMAND ===
1551+
program
1552+
.command('clean-demos')
1553+
.description('Remove demo/example files from a scaffolded TanStack project')
1554+
.argument('[target-dir]', 'project directory (default: current directory)', '.')
1555+
.addOption(
1556+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1557+
)
1558+
.option('-y, --yes', 'skip confirmation prompt', false)
1559+
.option('--dry-run', 'list files without deleting', false)
1560+
.action(
1561+
async (
1562+
targetDir: string,
1563+
cmdOptions: { yes: boolean; dryRun: boolean },
1564+
) => {
1565+
try {
1566+
await runWithTelemetry(
1567+
'clean-demos',
1568+
{
1569+
properties: {
1570+
yes: cmdOptions.yes,
1571+
dry_run: cmdOptions.dryRun,
1572+
},
1573+
},
1574+
async (telemetry) => {
1575+
const root = resolve(targetDir)
1576+
if (!fs.existsSync(root)) {
1577+
throw new Error(`Directory not found: ${root}`)
1578+
}
1579+
if (!fs.existsSync(resolve(root, '.cta.json'))) {
1580+
log.warn(
1581+
`No .cta.json in ${root} — this may not be a TanStack scaffold. Continuing anyway.`,
1582+
)
1583+
}
1584+
1585+
const demoFiles = findDemoFiles(root)
1586+
telemetry.mergeProperties({ result_count: demoFiles.length })
1587+
1588+
if (demoFiles.length === 0) {
1589+
log.info('No demo or example files found.')
1590+
return
1591+
}
1592+
1593+
log.info(
1594+
`Found ${demoFiles.length} demo/example file(s):\n${demoFiles
1595+
.map((f) => ` • ${relative(root, f)}`)
1596+
.join('\n')}`,
1597+
)
1598+
1599+
if (cmdOptions.dryRun) {
1600+
log.info('(dry run — nothing deleted)')
1601+
return
1602+
}
1603+
1604+
if (!cmdOptions.yes) {
1605+
const ok = await confirm({
1606+
message: 'Delete these files?',
1607+
initialValue: false,
1608+
})
1609+
if (isCancel(ok) || !ok) {
1610+
cancel('Operation cancelled.')
1611+
process.exit(0)
1612+
}
1613+
}
1614+
1615+
for (const file of demoFiles) {
1616+
fs.rmSync(file, { force: true })
1617+
}
1618+
pruneEmptyDemoDirs(root)
1619+
log.info(
1620+
`Deleted ${demoFiles.length} file(s). Run your dev server to regenerate routeTree.gen.ts.`,
1621+
)
1622+
},
1623+
)
1624+
} catch (error) {
1625+
log.error(formatErrorMessage(error))
1626+
process.exit(1)
1627+
}
1628+
},
1629+
)
1630+
13911631
const telemetryCommand = program.command('telemetry')
13921632
telemetryCommand
13931633
.command('status')

0 commit comments

Comments
 (0)