@tanstack/cli 0.60.1 → 0.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -18,8 +18,13 @@ import {
18
18
  initAddOn,
19
19
  initStarter,
20
20
  } from '@tanstack/create'
21
-
22
- import { runMCPServer } from './mcp.js'
21
+ import {
22
+ LIBRARY_GROUPS,
23
+ fetchDocContent,
24
+ fetchLibraries,
25
+ fetchPartners,
26
+ searchTanStackDocs,
27
+ } from './discovery.js'
23
28
 
24
29
  import { promptForAddOns, promptForCreateOptions } from './options.js'
25
30
  import {
@@ -206,6 +211,35 @@ export function cli({
206
211
 
207
212
  // Mode is always file-router (TanStack Start)
208
213
  const defaultMode = 'file-router'
214
+ const categoryAliases: Record<string, string> = {
215
+ db: 'database',
216
+ postgres: 'database',
217
+ sql: 'database',
218
+ login: 'auth',
219
+ authentication: 'auth',
220
+ hosting: 'deployment',
221
+ deploy: 'deployment',
222
+ serverless: 'deployment',
223
+ errors: 'monitoring',
224
+ logging: 'monitoring',
225
+ content: 'cms',
226
+ 'api-keys': 'api',
227
+ grid: 'data-grid',
228
+ review: 'code-review',
229
+ courses: 'learning',
230
+ }
231
+
232
+ function printJson(data: unknown) {
233
+ console.log(JSON.stringify(data, null, 2))
234
+ }
235
+
236
+ function parsePositiveInteger(value: string) {
237
+ const parsed = Number(value)
238
+ if (!Number.isInteger(parsed) || parsed < 1) {
239
+ throw new InvalidArgumentError('Value must be a positive integer')
240
+ }
241
+ return parsed
242
+ }
209
243
 
210
244
  program
211
245
  .name(name)
@@ -229,8 +263,29 @@ export function cli({
229
263
  getFrameworkByName(options.framework || defaultFramework || 'React')!,
230
264
  defaultMode,
231
265
  )
266
+ const visibleAddOns = addOns.filter((a) => !forcedAddOns.includes(a.id))
267
+ if (options.json) {
268
+ printJson(
269
+ visibleAddOns.map((addOn) => ({
270
+ id: addOn.id,
271
+ name: addOn.name,
272
+ description: addOn.description,
273
+ type: addOn.type,
274
+ category: addOn.category,
275
+ phase: addOn.phase,
276
+ modes: addOn.modes,
277
+ link: addOn.link,
278
+ warning: addOn.warning,
279
+ exclusive: addOn.exclusive,
280
+ dependsOn: addOn.dependsOn,
281
+ options: addOn.options,
282
+ })),
283
+ )
284
+ return
285
+ }
286
+
232
287
  let hasConfigurableAddOns = false
233
- for (const addOn of addOns.filter((a) => !forcedAddOns.includes(a.id))) {
288
+ for (const addOn of visibleAddOns) {
234
289
  const hasOptions =
235
290
  addOn.options && Object.keys(addOn.options).length > 0
236
291
  const optionMarker = hasOptions ? '*' : ' '
@@ -261,6 +316,34 @@ export function cli({
261
316
  process.exit(1)
262
317
  }
263
318
 
319
+ if (options.json) {
320
+ const files = await addOn.getFiles()
321
+ printJson({
322
+ id: addOn.id,
323
+ name: addOn.name,
324
+ description: addOn.description,
325
+ type: addOn.type,
326
+ category: addOn.category,
327
+ phase: addOn.phase,
328
+ modes: addOn.modes,
329
+ link: addOn.link,
330
+ warning: addOn.warning,
331
+ exclusive: addOn.exclusive,
332
+ dependsOn: addOn.dependsOn,
333
+ options: addOn.options,
334
+ routes: addOn.routes,
335
+ packageAdditions: addOn.packageAdditions,
336
+ shadcnComponents: addOn.shadcnComponents,
337
+ integrations: addOn.integrations,
338
+ readme: addOn.readme,
339
+ files,
340
+ author: addOn.author,
341
+ version: addOn.version,
342
+ license: addOn.license,
343
+ })
344
+ return
345
+ }
346
+
264
347
  console.log(
265
348
  `${chalk.bold.cyan('Add-on Details:')} ${chalk.bold(addOn.name)}`,
266
349
  )
@@ -283,7 +366,7 @@ export function cli({
283
366
  if (addOn.options && Object.keys(addOn.options).length > 0) {
284
367
  console.log(`\n${chalk.bold.yellow('Configuration Options:')}`)
285
368
  for (const [optionName, option] of Object.entries(addOn.options)) {
286
- if (option && typeof option === 'object' && 'type' in option) {
369
+ if ('type' in option) {
287
370
  const opt = option as any
288
371
  console.log(` ${chalk.bold(optionName)}:`)
289
372
  console.log(` Label: ${opt.label}`)
@@ -525,6 +608,7 @@ export function cli({
525
608
  '--addon-details <addon-id>',
526
609
  'show detailed information about a specific add-on',
527
610
  )
611
+ .option('--json', 'output JSON for automation', false)
528
612
  .option('--git', 'create a git repository')
529
613
  .option('--no-git', 'do not create a git repository')
530
614
  .option(
@@ -581,18 +665,266 @@ export function cli({
581
665
  await startDevWatchMode(projectName, devOptions)
582
666
  })
583
667
 
584
- // === MCP SUBCOMMAND ===
668
+ // === LIBRARIES SUBCOMMAND ===
585
669
  program
586
- .command('mcp')
587
- .description('Run the MCP (Model Context Protocol) server')
588
- .option('--sse', 'Run in SSE mode instead of stdio', false)
589
- .action(async (options: { sse: boolean }) => {
590
- await runMCPServer(options.sse, {
591
- forcedAddOns,
592
- appName,
593
- })
670
+ .command('libraries')
671
+ .description('List TanStack libraries')
672
+ .option(
673
+ '--group <group>',
674
+ `filter by group (${LIBRARY_GROUPS.join(', ')})`,
675
+ )
676
+ .option('--json', 'output JSON for automation', false)
677
+ .action(async (options: { group?: string; json: boolean }) => {
678
+ try {
679
+ const data = await fetchLibraries()
680
+ let libraries = data.libraries
681
+
682
+ if (
683
+ options.group &&
684
+ Object.prototype.hasOwnProperty.call(data.groups, options.group)
685
+ ) {
686
+ const groupIds = data.groups[options.group]
687
+ libraries = libraries.filter((lib) => groupIds.includes(lib.id))
688
+ }
689
+
690
+ const groupName = options.group
691
+ ? data.groupNames[options.group] || options.group
692
+ : 'All Libraries'
693
+
694
+ const payload = {
695
+ group: groupName,
696
+ count: libraries.length,
697
+ libraries: libraries.map((lib) => ({
698
+ id: lib.id,
699
+ name: lib.name,
700
+ tagline: lib.tagline,
701
+ description: lib.description,
702
+ frameworks: lib.frameworks,
703
+ latestVersion: lib.latestVersion,
704
+ docsUrl: lib.docsUrl,
705
+ githubUrl: lib.githubUrl,
706
+ })),
707
+ }
708
+
709
+ if (options.json) {
710
+ printJson(payload)
711
+ return
712
+ }
713
+
714
+ console.log(chalk.bold(groupName))
715
+ for (const lib of payload.libraries) {
716
+ console.log(
717
+ `${chalk.bold(lib.id)} (${lib.latestVersion}) - ${lib.tagline}`,
718
+ )
719
+ }
720
+ } catch (error) {
721
+ log.error(error instanceof Error ? error.message : String(error))
722
+ process.exit(1)
723
+ }
594
724
  })
595
725
 
726
+ // === DOC SUBCOMMAND ===
727
+ program
728
+ .command('doc')
729
+ .description('Fetch a TanStack documentation page')
730
+ .argument('<library>', 'library ID (eg. query, router, table)')
731
+ .argument('<path>', 'documentation path (eg. framework/react/overview)')
732
+ .option('--docs-version <version>', 'docs version (default: latest)', 'latest')
733
+ .option('--json', 'output JSON for automation', false)
734
+ .action(
735
+ async (
736
+ libraryId: string,
737
+ path: string,
738
+ options: { docsVersion: string; json: boolean },
739
+ ) => {
740
+ try {
741
+ const data = await fetchLibraries()
742
+ const library = data.libraries.find((l) => l.id === libraryId)
743
+
744
+ if (!library) {
745
+ throw new Error(
746
+ `Library "${libraryId}" not found. Use \`tanstack libraries\` to see available libraries.`,
747
+ )
748
+ }
749
+
750
+ if (
751
+ options.docsVersion !== 'latest' &&
752
+ !library.availableVersions.includes(options.docsVersion)
753
+ ) {
754
+ throw new Error(
755
+ `Version "${options.docsVersion}" not found for ${library.name}. Available: ${library.availableVersions.join(', ')}`,
756
+ )
757
+ }
758
+
759
+ const branch =
760
+ options.docsVersion === 'latest' ||
761
+ options.docsVersion === library.latestVersion
762
+ ? library.latestBranch || 'main'
763
+ : options.docsVersion
764
+
765
+ const docsRoot = library.docsRoot || 'docs'
766
+ const filePath = `${docsRoot}/${path}.md`
767
+ const content = await fetchDocContent(library.repo, branch, filePath)
768
+
769
+ if (!content) {
770
+ throw new Error(
771
+ `Document not found: ${library.name} / ${path} (version: ${options.docsVersion})`,
772
+ )
773
+ }
774
+
775
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
776
+ let title = path.split('/').pop() || 'Untitled'
777
+ let docContent = content
778
+
779
+ if (frontmatterMatch && frontmatterMatch[1]) {
780
+ const frontmatter = frontmatterMatch[1]
781
+ const titleMatch = frontmatter.match(
782
+ /title:\s*['"]?([^'"\n]+)['"]?/,
783
+ )
784
+ if (titleMatch && titleMatch[1]) {
785
+ title = titleMatch[1]
786
+ }
787
+ docContent = content.slice(frontmatterMatch[0].length).trim()
788
+ }
789
+
790
+ const payload = {
791
+ title,
792
+ content: docContent,
793
+ url: `https://tanstack.com/${libraryId}/${options.docsVersion}/docs/${path}`,
794
+ library: library.name,
795
+ version:
796
+ options.docsVersion === 'latest'
797
+ ? library.latestVersion
798
+ : options.docsVersion,
799
+ }
800
+
801
+ if (options.json) {
802
+ printJson(payload)
803
+ return
804
+ }
805
+
806
+ console.log(chalk.bold(payload.title))
807
+ console.log(chalk.blue(payload.url))
808
+ console.log('')
809
+ console.log(payload.content)
810
+ } catch (error) {
811
+ log.error(error instanceof Error ? error.message : String(error))
812
+ process.exit(1)
813
+ }
814
+ },
815
+ )
816
+
817
+ // === SEARCH-DOCS SUBCOMMAND ===
818
+ program
819
+ .command('search-docs')
820
+ .description('Search TanStack documentation')
821
+ .argument('<query>', 'search query')
822
+ .option('--library <id>', 'filter to specific library')
823
+ .option('--framework <name>', 'filter to specific framework')
824
+ .option('--limit <n>', 'max results (default: 10, max: 50)', parsePositiveInteger, 10)
825
+ .option('--json', 'output JSON for automation', false)
826
+ .action(
827
+ async (
828
+ query: string,
829
+ options: {
830
+ library?: string
831
+ framework?: string
832
+ limit: number
833
+ json: boolean
834
+ },
835
+ ) => {
836
+ try {
837
+ const payload = await searchTanStackDocs({
838
+ query,
839
+ library: options.library,
840
+ framework: options.framework,
841
+ limit: options.limit,
842
+ })
843
+
844
+ if (options.json) {
845
+ printJson(payload)
846
+ return
847
+ }
848
+
849
+ for (const result of payload.results) {
850
+ console.log(
851
+ `${chalk.bold(result.title)} [${result.library}]\n${chalk.blue(result.url)}\n${result.snippet}\n`,
852
+ )
853
+ }
854
+ } catch (error) {
855
+ log.error(error instanceof Error ? error.message : String(error))
856
+ process.exit(1)
857
+ }
858
+ },
859
+ )
860
+
861
+ // === ECOSYSTEM SUBCOMMAND ===
862
+ program
863
+ .command('ecosystem')
864
+ .description('List TanStack ecosystem partners')
865
+ .option('--category <category>', 'filter by category')
866
+ .option('--library <id>', 'filter by TanStack library')
867
+ .option('--json', 'output JSON for automation', false)
868
+ .action(
869
+ async (options: { category?: string; library?: string; json: boolean }) => {
870
+ try {
871
+ const data = await fetchPartners()
872
+
873
+ let resolvedCategory: string | undefined
874
+ if (options.category) {
875
+ const normalized = options.category.toLowerCase().trim()
876
+ resolvedCategory = categoryAliases[normalized] || normalized
877
+ if (!data.categories.includes(resolvedCategory)) {
878
+ resolvedCategory = undefined
879
+ }
880
+ }
881
+
882
+ const library = options.library?.toLowerCase().trim()
883
+ const partners = data.partners
884
+ .filter((partner) =>
885
+ resolvedCategory ? partner.category === resolvedCategory : true,
886
+ )
887
+ .filter((partner) =>
888
+ library ? partner.libraries.some((l) => l === library) : true,
889
+ )
890
+ .map((partner) => ({
891
+ id: partner.id,
892
+ name: partner.name,
893
+ tagline: partner.tagline,
894
+ description: partner.description,
895
+ category: partner.category,
896
+ categoryLabel: partner.categoryLabel,
897
+ url: partner.url,
898
+ libraries: partner.libraries,
899
+ }))
900
+
901
+ const payload = {
902
+ query: {
903
+ category: options.category,
904
+ categoryResolved: resolvedCategory,
905
+ library: options.library,
906
+ },
907
+ count: partners.length,
908
+ partners,
909
+ }
910
+
911
+ if (options.json) {
912
+ printJson(payload)
913
+ return
914
+ }
915
+
916
+ for (const partner of partners) {
917
+ console.log(
918
+ `${chalk.bold(partner.name)} [${partner.category}] - ${partner.description}\n${chalk.blue(partner.url)}`,
919
+ )
920
+ }
921
+ } catch (error) {
922
+ log.error(error instanceof Error ? error.message : String(error))
923
+ process.exit(1)
924
+ }
925
+ },
926
+ )
927
+
596
928
  // === PIN-VERSIONS SUBCOMMAND ===
597
929
  program
598
930
  .command('pin-versions')
@@ -52,6 +52,14 @@ function slugifyStarterName(value: string) {
52
52
  .replace(/^-+|-+$/g, '')
53
53
  }
54
54
 
55
+ function humanizeStarterId(value: string) {
56
+ return value
57
+ .split(/[-_]/g)
58
+ .filter(Boolean)
59
+ .map((part) => part[0].toUpperCase() + part.slice(1))
60
+ .join(' ')
61
+ }
62
+
55
63
  function isLikelyStarterUrlOrPath(value: string) {
56
64
  return (
57
65
  /^https?:\/\//i.test(value) ||
@@ -88,7 +96,10 @@ function getStarterIdsFromUrl(starterUrl: string) {
88
96
  return ids
89
97
  }
90
98
 
91
- function resolveMonorepoStarterById(starterId: string) {
99
+ function resolveMonorepoStarterById(
100
+ starterId: string,
101
+ preferredFramework?: string,
102
+ ) {
92
103
  const normalized = starterId.toLowerCase().trim()
93
104
  const idVariants = Array.from(
94
105
  new Set([
@@ -106,8 +117,12 @@ function resolveMonorepoStarterById(starterId: string) {
106
117
  resolve(cwd, '../../..'),
107
118
  ]
108
119
 
120
+ const frameworkOrder = preferredFramework
121
+ ? [preferredFramework, ...['react', 'solid'].filter((f) => f !== preferredFramework)]
122
+ : ['react', 'solid']
123
+
109
124
  for (const root of rootCandidates) {
110
- for (const framework of ['react', 'solid']) {
125
+ for (const framework of frameworkOrder) {
111
126
  for (const id of idVariants) {
112
127
  const templatePath = resolve(root, 'examples', framework, id, 'template.json')
113
128
  if (fs.existsSync(templatePath)) {
@@ -125,7 +140,10 @@ function resolveMonorepoStarterById(starterId: string) {
125
140
  return undefined
126
141
  }
127
142
 
128
- async function resolveStarterSpecifier(starterSpecifier: string) {
143
+ export async function resolveStarterSpecifier(
144
+ starterSpecifier: string,
145
+ preferredFramework?: string,
146
+ ) {
129
147
  const normalized = starterSpecifier.trim()
130
148
 
131
149
  if (!normalized || isLikelyStarterUrlOrPath(normalized)) {
@@ -135,7 +153,7 @@ async function resolveStarterSpecifier(starterSpecifier: string) {
135
153
  const registry = await getRawRegistry()
136
154
  if (registry && registry.starters?.length) {
137
155
  const lookup = normalized.toLowerCase()
138
- const match = registry.starters.find((starter) => {
156
+ const matches = registry.starters.filter((starter) => {
139
157
  const candidateIds = new Set<string>()
140
158
  candidateIds.add(starter.name.toLowerCase())
141
159
  candidateIds.add(slugifyStarterName(starter.name))
@@ -147,12 +165,25 @@ async function resolveStarterSpecifier(starterSpecifier: string) {
147
165
  return candidateIds.has(lookup)
148
166
  })
149
167
 
150
- if (match) {
151
- return match.url
168
+ const frameworkMatch = preferredFramework
169
+ ? matches.find(
170
+ (starter) => starter.framework.toLowerCase() === preferredFramework,
171
+ )
172
+ : undefined
173
+
174
+ if (frameworkMatch) {
175
+ return frameworkMatch.url
176
+ }
177
+
178
+ if (matches.length > 0) {
179
+ return matches[0].url
152
180
  }
153
181
  }
154
182
 
155
- const monorepoStarterPath = resolveMonorepoStarterById(normalized)
183
+ const monorepoStarterPath = resolveMonorepoStarterById(
184
+ normalized,
185
+ preferredFramework,
186
+ )
156
187
  if (monorepoStarterPath) {
157
188
  return monorepoStarterPath
158
189
  }
@@ -180,6 +211,110 @@ async function resolveStarterSpecifier(starterSpecifier: string) {
180
211
  )
181
212
  }
182
213
 
214
+ export async function listTemplateChoices(preferredFramework?: string): Promise<
215
+ Array<{
216
+ id: string
217
+ name: string
218
+ description?: string
219
+ framework: string
220
+ }>
221
+ > {
222
+ const frameworkFilter = preferredFramework?.toLowerCase()
223
+ const deduped = new Map<
224
+ string,
225
+ { id: string; name: string; description?: string; framework: string }
226
+ >()
227
+
228
+ const registry = await getRawRegistry()
229
+ for (const starter of registry?.starters || []) {
230
+ const framework = starter.framework.toLowerCase()
231
+ if (frameworkFilter && framework !== frameworkFilter) {
232
+ continue
233
+ }
234
+
235
+ const ids = Array.from(getStarterIdsFromUrl(starter.url))
236
+ const id = ids[0] || slugifyStarterName(starter.name)
237
+ if (!id) {
238
+ continue
239
+ }
240
+
241
+ const key = `${framework}:${id}`
242
+ if (!deduped.has(key)) {
243
+ deduped.set(key, {
244
+ id,
245
+ name: starter.name,
246
+ description: starter.description,
247
+ framework,
248
+ })
249
+ }
250
+ }
251
+
252
+ const cwd = process.cwd()
253
+ const rootCandidates = [
254
+ cwd,
255
+ resolve(cwd, '..'),
256
+ resolve(cwd, '../..'),
257
+ resolve(cwd, '../../..'),
258
+ ]
259
+
260
+ const frameworks = frameworkFilter ? [frameworkFilter] : ['react', 'solid']
261
+
262
+ for (const root of rootCandidates) {
263
+ for (const framework of frameworks) {
264
+ const frameworkDir = resolve(root, 'examples', framework)
265
+ if (!fs.existsSync(frameworkDir) || !fs.statSync(frameworkDir).isDirectory()) {
266
+ continue
267
+ }
268
+
269
+ for (const entry of fs.readdirSync(frameworkDir, { withFileTypes: true })) {
270
+ if (!entry.isDirectory()) {
271
+ continue
272
+ }
273
+
274
+ const id = entry.name
275
+ const key = `${framework}:${id}`
276
+ if (deduped.has(key)) {
277
+ continue
278
+ }
279
+
280
+ const templatePath = resolve(frameworkDir, id, 'template.json')
281
+ const starterPath = resolve(frameworkDir, id, 'starter.json')
282
+ if (!fs.existsSync(templatePath) && !fs.existsSync(starterPath)) {
283
+ continue
284
+ }
285
+
286
+ let name = humanizeStarterId(id)
287
+ let description: string | undefined
288
+
289
+ const templateInfoPath = resolve(frameworkDir, id, 'template-info.json')
290
+ if (fs.existsSync(templateInfoPath)) {
291
+ try {
292
+ const info = JSON.parse(fs.readFileSync(templateInfoPath, 'utf8')) as {
293
+ name?: string
294
+ description?: string
295
+ }
296
+ if (info.name) {
297
+ name = info.name
298
+ }
299
+ description = info.description
300
+ } catch {
301
+ // Ignore malformed template-info files and use fallback values.
302
+ }
303
+ }
304
+
305
+ deduped.set(key, {
306
+ id,
307
+ name,
308
+ description,
309
+ framework,
310
+ })
311
+ }
312
+ }
313
+ }
314
+
315
+ return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name))
316
+ }
317
+
183
318
  export function validateLegacyCreateFlags(cliOptions: CliOptions): {
184
319
  warnings: Array<string>
185
320
  error?: string
@@ -317,8 +452,12 @@ export async function normalizeOptions(
317
452
  cliOptions.starter = cliOptions.templateId
318
453
  }
319
454
 
455
+ const preferredFramework = (cliOptions.framework || 'react').toLowerCase()
456
+
320
457
  const starter = !routerOnly && cliOptions.starter
321
- ? await loadStarter(await resolveStarterSpecifier(cliOptions.starter))
458
+ ? await loadStarter(
459
+ await resolveStarterSpecifier(cliOptions.starter, preferredFramework),
460
+ )
322
461
  : undefined
323
462
 
324
463
  // TypeScript and Tailwind are always enabled with TanStack Start