@tanstack/cta-cli 0.10.0-alpha.16

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 ADDED
@@ -0,0 +1,260 @@
1
+ import { Command, InvalidArgumentError } from 'commander'
2
+ import { intro, log } from '@clack/prompts'
3
+ import chalk from 'chalk'
4
+
5
+ import {
6
+ SUPPORTED_PACKAGE_MANAGERS,
7
+ addToApp,
8
+ createApp,
9
+ getAllAddOns,
10
+ getFrameworkById,
11
+ getFrameworkByName,
12
+ getFrameworks,
13
+ } from '@tanstack/cta-engine'
14
+ import { initAddOn } from '@tanstack/cta-custom-add-on'
15
+
16
+ import { runMCPServer } from '@tanstack/cta-mcp'
17
+
18
+ import { launchUI } from '@tanstack/cta-ui'
19
+
20
+ import { normalizeOptions, promptForOptions } from './options.js'
21
+
22
+ import { createUIEnvironment } from './ui-environment.js'
23
+
24
+ import type {
25
+ Mode,
26
+ PackageManager,
27
+ TemplateOptions,
28
+ } from '@tanstack/cta-engine'
29
+
30
+ import type { CliOptions } from './types.js'
31
+
32
+ async function listAddOns(
33
+ options: CliOptions,
34
+ {
35
+ forcedMode,
36
+ forcedAddOns = [],
37
+ }: {
38
+ forcedMode?: TemplateOptions
39
+ forcedAddOns?: Array<string>
40
+ },
41
+ ) {
42
+ const addOns = await getAllAddOns(
43
+ getFrameworkById(options.framework || 'react-cra')!,
44
+ forcedMode || options.template || 'typescript',
45
+ )
46
+ for (const addOn of addOns.filter((a) => !forcedAddOns.includes(a.id))) {
47
+ console.log(`${chalk.bold(addOn.id)}: ${addOn.description}`)
48
+ }
49
+ }
50
+
51
+ export function cli({
52
+ name,
53
+ appName,
54
+ forcedMode,
55
+ forcedAddOns,
56
+ }: {
57
+ name: string
58
+ appName: string
59
+ forcedMode?: Mode
60
+ forcedAddOns?: Array<string>
61
+ }) {
62
+ const environment = createUIEnvironment()
63
+
64
+ const program = new Command()
65
+
66
+ const availableFrameworks = getFrameworks().map((f) => f.name)
67
+
68
+ const toolchains = new Set<string>()
69
+ for (const framework of getFrameworks()) {
70
+ for (const addOn of framework.getAddOns()) {
71
+ if (addOn.type === 'toolchain') {
72
+ toolchains.add(addOn.id)
73
+ }
74
+ }
75
+ }
76
+
77
+ program.name(name).description(`CLI to create a new ${appName} application`)
78
+
79
+ program
80
+ .command('add')
81
+ .argument('add-on', 'Name of the add-on (or add-ons separated by commas)')
82
+ .action(async (addOn: string) => {
83
+ await addToApp(
84
+ addOn.split(',').map((addon) => addon.trim()),
85
+ {
86
+ silent: false,
87
+ },
88
+ environment,
89
+ )
90
+ })
91
+
92
+ const addOnCommand = program.command('add-on')
93
+
94
+ addOnCommand
95
+ .command('update')
96
+ .description('Create or update an add-on from the current project')
97
+ .action(async () => {
98
+ await initAddOn('add-on', environment)
99
+ })
100
+ addOnCommand
101
+ .command('ui')
102
+ .description('Show the add-on developer UI')
103
+ .action(async () => {
104
+ launchUI()
105
+ })
106
+
107
+ program
108
+ .command('update-starter')
109
+ .description('Create or update a project starter from the current project')
110
+ .action(async () => {
111
+ await initAddOn('starter', environment)
112
+ })
113
+
114
+ program.argument('[project-name]', 'name of the project')
115
+
116
+ if (!forcedMode) {
117
+ program.option<'typescript' | 'javascript' | 'file-router'>(
118
+ '--template <type>',
119
+ 'project template (typescript, javascript, file-router)',
120
+ (value) => {
121
+ if (
122
+ value !== 'typescript' &&
123
+ value !== 'javascript' &&
124
+ value !== 'file-router'
125
+ ) {
126
+ throw new InvalidArgumentError(
127
+ `Invalid template: ${value}. Only the following are allowed: typescript, javascript, file-router`,
128
+ )
129
+ }
130
+ return value
131
+ },
132
+ )
133
+ }
134
+
135
+ program
136
+ .option<string>(
137
+ '--framework <type>',
138
+ `project framework (${availableFrameworks.join(', ')})`,
139
+ (value) => {
140
+ if (!availableFrameworks.includes(value)) {
141
+ throw new InvalidArgumentError(
142
+ `Invalid framework: ${value}. Only the following are allowed: ${availableFrameworks.join(', ')}`,
143
+ )
144
+ }
145
+ return value
146
+ },
147
+ 'react',
148
+ )
149
+ .option(
150
+ '--starter [url]',
151
+ 'initialize this project from a starter URL',
152
+ false,
153
+ )
154
+ .option<PackageManager>(
155
+ `--package-manager <${SUPPORTED_PACKAGE_MANAGERS.join('|')}>`,
156
+ `Explicitly tell the CLI to use this package manager`,
157
+ (value) => {
158
+ if (!SUPPORTED_PACKAGE_MANAGERS.includes(value as PackageManager)) {
159
+ throw new InvalidArgumentError(
160
+ `Invalid package manager: ${value}. The following are allowed: ${SUPPORTED_PACKAGE_MANAGERS.join(
161
+ ', ',
162
+ )}`,
163
+ )
164
+ }
165
+ return value as PackageManager
166
+ },
167
+ )
168
+ .option<string>(
169
+ `--toolchain <${Array.from(toolchains).join('|')}>`,
170
+ `Explicitly tell the CLI to use this toolchain`,
171
+ (value) => {
172
+ if (!toolchains.has(value)) {
173
+ throw new InvalidArgumentError(
174
+ `Invalid toolchain: ${value}. The following are allowed: ${Array.from(
175
+ toolchains,
176
+ ).join(', ')}`,
177
+ )
178
+ }
179
+ return value
180
+ },
181
+ )
182
+ .option('--tailwind', 'add Tailwind CSS', false)
183
+ .option<Array<string> | boolean>(
184
+ '--add-ons [...add-ons]',
185
+ 'pick from a list of available add-ons (comma separated list)',
186
+ (value: string) => {
187
+ let addOns: Array<string> | boolean = !!value
188
+ if (typeof value === 'string') {
189
+ addOns = value.split(',').map((addon) => addon.trim())
190
+ }
191
+ return addOns
192
+ },
193
+ )
194
+ .option('--list-add-ons', 'list all available add-ons', false)
195
+ .option('--no-git', 'do not create a git repository')
196
+ .option(
197
+ '--target-dir <path>',
198
+ 'the target directory for the application root',
199
+ )
200
+ .option('--mcp', 'run the MCP server', false)
201
+ .option('--mcp-sse', 'run the MCP server in SSE mode', false)
202
+
203
+ program.action(async (projectName: string, options: CliOptions) => {
204
+ if (options.listAddOns) {
205
+ await listAddOns(options, {
206
+ forcedMode: forcedMode as TemplateOptions,
207
+ forcedAddOns,
208
+ })
209
+ } else if (options.mcp || options.mcpSse) {
210
+ await runMCPServer(!!options.mcpSse, {
211
+ forcedMode: forcedMode as TemplateOptions,
212
+ forcedAddOns,
213
+ appName,
214
+ })
215
+ } else {
216
+ try {
217
+ const cliOptions = {
218
+ projectName,
219
+ ...options,
220
+ } as CliOptions
221
+
222
+ cliOptions.framework = getFrameworkByName(
223
+ options.framework || 'react',
224
+ )!.id
225
+
226
+ if (forcedMode) {
227
+ cliOptions.template = forcedMode as TemplateOptions
228
+ }
229
+
230
+ let finalOptions = await normalizeOptions(
231
+ cliOptions,
232
+ forcedMode,
233
+ forcedAddOns,
234
+ )
235
+ if (finalOptions) {
236
+ intro(`Creating a new ${appName} app in ${projectName}...`)
237
+ } else {
238
+ intro(`Let's configure your ${appName} application`)
239
+ finalOptions = await promptForOptions(cliOptions, {
240
+ forcedMode: forcedMode as TemplateOptions,
241
+ forcedAddOns,
242
+ })
243
+ }
244
+ await createApp(finalOptions, {
245
+ environment: createUIEnvironment(),
246
+ cwd: options.targetDir || undefined,
247
+ name,
248
+ appName,
249
+ })
250
+ } catch (error) {
251
+ log.error(
252
+ error instanceof Error ? error.message : 'An unknown error occurred',
253
+ )
254
+ process.exit(1)
255
+ }
256
+ }
257
+ })
258
+
259
+ program.parse()
260
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { cli } from './cli.js'
package/src/options.ts ADDED
@@ -0,0 +1,452 @@
1
+ import {
2
+ cancel,
3
+ confirm,
4
+ isCancel,
5
+ multiselect,
6
+ select,
7
+ text,
8
+ } from '@clack/prompts'
9
+
10
+ import {
11
+ CODE_ROUTER,
12
+ DEFAULT_PACKAGE_MANAGER,
13
+ FILE_ROUTER,
14
+ SUPPORTED_PACKAGE_MANAGERS,
15
+ finalizeAddOns,
16
+ getAllAddOns,
17
+ getFrameworkById,
18
+ getPackageManager,
19
+ loadRemoteAddOn,
20
+ } from '@tanstack/cta-engine'
21
+
22
+ import type {
23
+ AddOn,
24
+ Mode,
25
+ Options,
26
+ Starter,
27
+ TemplateOptions,
28
+ Variable,
29
+ } from '@tanstack/cta-engine'
30
+
31
+ import type { CliOptions } from './types.js'
32
+
33
+ // If all CLI options are provided, use them directly
34
+ export async function normalizeOptions(
35
+ cliOptions: CliOptions,
36
+ forcedMode?: Mode,
37
+ forcedAddOns?: Array<string>,
38
+ ): Promise<Options | undefined> {
39
+ // in some cases, if you use windows/powershell, the argument for addons
40
+ // if sepparated by comma is not really passed as an array, but as a string
41
+ // with spaces, We need to normalize this edge case.
42
+ if (Array.isArray(cliOptions.addOns) && cliOptions.addOns.length === 1) {
43
+ const parseSeparatedArgs = cliOptions.addOns[0].split(' ')
44
+ if (parseSeparatedArgs.length > 1) {
45
+ cliOptions.addOns = parseSeparatedArgs
46
+ }
47
+ }
48
+
49
+ if (cliOptions.projectName) {
50
+ let typescript =
51
+ cliOptions.template === 'typescript' ||
52
+ cliOptions.template === 'file-router' ||
53
+ cliOptions.framework === 'solid'
54
+
55
+ let tailwind = !!cliOptions.tailwind
56
+ if (cliOptions.framework === 'solid') {
57
+ tailwind = true
58
+ }
59
+
60
+ let mode: typeof FILE_ROUTER | typeof CODE_ROUTER =
61
+ cliOptions.template === 'file-router' ? FILE_ROUTER : CODE_ROUTER
62
+
63
+ const starter = cliOptions.starter
64
+ ? ((await loadRemoteAddOn(cliOptions.starter)) as Starter)
65
+ : undefined
66
+
67
+ if (starter) {
68
+ tailwind = starter.tailwind
69
+ typescript = starter.typescript
70
+ cliOptions.framework = starter.framework
71
+ mode = starter.mode
72
+ }
73
+
74
+ let addOns = false
75
+ let chosenAddOns: Array<AddOn> = []
76
+ if (
77
+ Array.isArray(cliOptions.addOns) ||
78
+ starter?.dependsOn ||
79
+ forcedAddOns ||
80
+ cliOptions.toolchain
81
+ ) {
82
+ addOns = true
83
+ let finalAddOns = Array.from(
84
+ new Set([...(starter?.dependsOn || []), ...(forcedAddOns || [])]),
85
+ )
86
+ if (cliOptions.addOns && Array.isArray(cliOptions.addOns)) {
87
+ finalAddOns = Array.from(
88
+ new Set([
89
+ ...(forcedAddOns || []),
90
+ ...finalAddOns,
91
+ ...cliOptions.addOns,
92
+ ]),
93
+ )
94
+ }
95
+ const framework = getFrameworkById(cliOptions.framework || 'react-cra')!
96
+
97
+ if (cliOptions.toolchain) {
98
+ finalAddOns.push(cliOptions.toolchain)
99
+ }
100
+
101
+ chosenAddOns = await finalizeAddOns(
102
+ framework,
103
+ forcedMode || cliOptions.template === 'file-router'
104
+ ? FILE_ROUTER
105
+ : CODE_ROUTER,
106
+ finalAddOns,
107
+ )
108
+ tailwind = true
109
+ typescript = true
110
+ }
111
+
112
+ return {
113
+ // TODO: This is a bit to fix the default framework
114
+ framework: getFrameworkById(cliOptions.framework || 'react-cra')!,
115
+ projectName: cliOptions.projectName,
116
+ typescript,
117
+ tailwind,
118
+ packageManager:
119
+ cliOptions.packageManager ||
120
+ getPackageManager() ||
121
+ DEFAULT_PACKAGE_MANAGER,
122
+ mode,
123
+ git: !!cliOptions.git,
124
+ addOns,
125
+ chosenAddOns,
126
+ variableValues: {},
127
+ starter,
128
+ }
129
+ }
130
+ }
131
+
132
+ async function collectVariables(
133
+ variables: Array<Variable>,
134
+ ): Promise<Record<string, string | number | boolean>> {
135
+ const responses: Record<string, string | number | boolean> = {}
136
+ for (const variable of variables) {
137
+ if (variable.type === 'string') {
138
+ const response = await text({
139
+ message: variable.description,
140
+ initialValue: variable.default,
141
+ })
142
+ if (isCancel(response)) {
143
+ cancel('Operation cancelled.')
144
+ process.exit(0)
145
+ }
146
+ responses[variable.name] = response
147
+ } else if (variable.type === 'number') {
148
+ const response = await text({
149
+ message: variable.description,
150
+ initialValue: variable.default.toString(),
151
+ })
152
+ if (isCancel(response)) {
153
+ cancel('Operation cancelled.')
154
+ process.exit(0)
155
+ }
156
+ responses[variable.name] = Number(response)
157
+ } else {
158
+ const response = await confirm({
159
+ message: variable.description,
160
+ initialValue: variable.default === true,
161
+ })
162
+ if (isCancel(response)) {
163
+ cancel('Operation cancelled.')
164
+ process.exit(0)
165
+ }
166
+ responses[variable.name] = response
167
+ }
168
+ }
169
+ return responses
170
+ }
171
+
172
+ export async function promptForOptions(
173
+ cliOptions: CliOptions,
174
+ {
175
+ forcedAddOns = [],
176
+ forcedMode,
177
+ }: {
178
+ forcedAddOns?: Array<string>
179
+ forcedMode?: TemplateOptions
180
+ },
181
+ ): Promise<Required<Options>> {
182
+ const options = {} as Required<Options>
183
+
184
+ const framework = getFrameworkById(cliOptions.framework || 'react-cra')!
185
+ options.framework = framework
186
+ // TODO: This is a bit of a hack to ensure that the framework is solid
187
+ if (options.framework.id === 'solid') {
188
+ options.typescript = true
189
+ options.tailwind = true
190
+ }
191
+
192
+ if (cliOptions.addOns) {
193
+ options.typescript = true
194
+ }
195
+
196
+ if (!cliOptions.projectName) {
197
+ const value = await text({
198
+ message: 'What would you like to name your project?',
199
+ defaultValue: 'my-app',
200
+ validate(value) {
201
+ if (!value) {
202
+ return 'Please enter a name'
203
+ }
204
+ },
205
+ })
206
+ if (isCancel(value)) {
207
+ cancel('Operation cancelled.')
208
+ process.exit(0)
209
+ }
210
+ options.projectName = value
211
+ }
212
+
213
+ // Router type selection
214
+ if (!cliOptions.template && !forcedMode) {
215
+ const routerType = await select({
216
+ message: 'Select the router type:',
217
+ options: [
218
+ {
219
+ value: FILE_ROUTER,
220
+ label: 'File Router - File-based routing structure',
221
+ },
222
+ {
223
+ value: CODE_ROUTER,
224
+ label: 'Code Router - Traditional code-based routing',
225
+ },
226
+ ],
227
+ initialValue: FILE_ROUTER,
228
+ })
229
+ if (isCancel(routerType)) {
230
+ cancel('Operation cancelled.')
231
+ process.exit(0)
232
+ }
233
+ options.mode = routerType as typeof CODE_ROUTER | typeof FILE_ROUTER
234
+ } else if (forcedMode) {
235
+ options.mode = forcedMode === 'file-router' ? FILE_ROUTER : CODE_ROUTER
236
+ options.typescript = options.mode === FILE_ROUTER
237
+ } else {
238
+ options.mode =
239
+ cliOptions.template === 'file-router' ? FILE_ROUTER : CODE_ROUTER
240
+ if (options.mode === FILE_ROUTER) {
241
+ options.typescript = true
242
+ }
243
+ }
244
+
245
+ // TypeScript selection (if using Code Router)
246
+ if (!options.typescript) {
247
+ if (options.mode === CODE_ROUTER) {
248
+ const typescriptEnable = await confirm({
249
+ message: 'Would you like to use TypeScript?',
250
+ initialValue: true,
251
+ })
252
+ if (isCancel(typescriptEnable)) {
253
+ cancel('Operation cancelled.')
254
+ process.exit(0)
255
+ }
256
+ options.typescript = typescriptEnable
257
+ } else {
258
+ options.typescript = true
259
+ }
260
+ }
261
+
262
+ // Tailwind selection
263
+ if (!cliOptions.tailwind && options.framework.id === 'react-cra') {
264
+ const tailwind = await confirm({
265
+ message: 'Would you like to use Tailwind CSS?',
266
+ initialValue: true,
267
+ })
268
+ if (isCancel(tailwind)) {
269
+ cancel('Operation cancelled.')
270
+ process.exit(0)
271
+ }
272
+ options.tailwind = tailwind
273
+ } else {
274
+ // TODO: This is a bit of a hack to ensure that the framework is solid
275
+ options.tailwind = options.framework.id === 'solid' || !!cliOptions.tailwind
276
+ }
277
+
278
+ // Package manager selection
279
+ if (cliOptions.packageManager === undefined) {
280
+ const detectedPackageManager = getPackageManager()
281
+ if (!detectedPackageManager) {
282
+ const pm = await select({
283
+ message: 'Select package manager:',
284
+ options: SUPPORTED_PACKAGE_MANAGERS.map((pm) => ({
285
+ value: pm,
286
+ label: pm,
287
+ })),
288
+ initialValue: DEFAULT_PACKAGE_MANAGER,
289
+ })
290
+ if (isCancel(pm)) {
291
+ cancel('Operation cancelled.')
292
+ process.exit(0)
293
+ }
294
+ options.packageManager = pm
295
+ } else {
296
+ options.packageManager = detectedPackageManager
297
+ }
298
+ } else {
299
+ options.packageManager = cliOptions.packageManager
300
+ }
301
+
302
+ // Toolchain selection
303
+ let toolchain: AddOn | undefined = undefined
304
+ if (cliOptions.toolchain === undefined) {
305
+ const toolchains = new Set<AddOn>()
306
+ for (const addOn of framework.getAddOns()) {
307
+ if (addOn.type === 'toolchain') {
308
+ toolchains.add(addOn)
309
+ }
310
+ }
311
+
312
+ const tc = await select<AddOn | undefined>({
313
+ message: 'Select toolchain',
314
+ options: [
315
+ {
316
+ value: undefined,
317
+ label: 'None',
318
+ },
319
+ ...Array.from(toolchains).map((tc) => ({
320
+ value: tc,
321
+ label: tc.name,
322
+ })),
323
+ ],
324
+ initialValue: undefined,
325
+ })
326
+ if (isCancel(tc)) {
327
+ cancel('Operation cancelled.')
328
+ process.exit(0)
329
+ }
330
+ toolchain = tc
331
+ } else {
332
+ for (const addOn of framework.getAddOns()) {
333
+ if (addOn.type === 'toolchain' && addOn.id === cliOptions.toolchain) {
334
+ toolchain = addOn
335
+ }
336
+ }
337
+ }
338
+
339
+ options.chosenAddOns = toolchain ? [toolchain] : []
340
+ if (Array.isArray(cliOptions.addOns)) {
341
+ options.chosenAddOns = await finalizeAddOns(
342
+ options.framework,
343
+ options.mode,
344
+ Array.from(
345
+ new Set([...cliOptions.addOns, ...forcedAddOns, toolchain?.id]),
346
+ ).filter(Boolean) as Array<string>,
347
+ )
348
+ options.tailwind = true
349
+ } else if (cliOptions.addOns) {
350
+ // Select any add-ons
351
+ const allAddOns = await getAllAddOns(options.framework, options.mode)
352
+ const addOns = allAddOns.filter((addOn) => addOn.type === 'add-on')
353
+ let selectedAddOns: Array<string> = []
354
+ if (options.typescript && addOns.length > 0) {
355
+ const value = await multiselect({
356
+ message: 'What add-ons would you like for your project:',
357
+ options: addOns
358
+ .filter((addOn) => !forcedAddOns.includes(addOn.id))
359
+ .map((addOn) => ({
360
+ value: addOn.id,
361
+ label: addOn.name,
362
+ hint: addOn.description,
363
+ })),
364
+ required: false,
365
+ })
366
+
367
+ if (isCancel(value)) {
368
+ cancel('Operation cancelled.')
369
+ process.exit(0)
370
+ }
371
+ selectedAddOns = value
372
+ }
373
+
374
+ // Select any examples
375
+ let selectedExamples: Array<string> = []
376
+ const examples = allAddOns.filter((addOn) => addOn.type === 'example')
377
+ if (options.typescript && examples.length > 0) {
378
+ const value = await multiselect({
379
+ message: 'Would you like any examples?',
380
+ options: examples
381
+ .filter((addOn) => !forcedAddOns.includes(addOn.id))
382
+ .map((addOn) => ({
383
+ value: addOn.id,
384
+ label: addOn.name,
385
+ hint: addOn.description,
386
+ })),
387
+ required: false,
388
+ })
389
+
390
+ if (isCancel(value)) {
391
+ cancel('Operation cancelled.')
392
+ process.exit(0)
393
+ }
394
+ selectedExamples = value
395
+ }
396
+
397
+ if (
398
+ selectedAddOns.length > 0 ||
399
+ selectedExamples.length > 0 ||
400
+ forcedAddOns.length > 0 ||
401
+ toolchain
402
+ ) {
403
+ options.chosenAddOns = await finalizeAddOns(
404
+ options.framework,
405
+ options.mode,
406
+ Array.from(
407
+ new Set([
408
+ ...selectedAddOns,
409
+ ...selectedExamples,
410
+ ...forcedAddOns,
411
+ toolchain?.id,
412
+ ]),
413
+ ).filter(Boolean) as Array<string>,
414
+ )
415
+ options.tailwind = true
416
+ }
417
+ } else if (forcedAddOns.length > 0) {
418
+ options.chosenAddOns = await finalizeAddOns(
419
+ options.framework,
420
+ options.mode,
421
+ Array.from(new Set([...forcedAddOns, toolchain?.id])).filter(
422
+ Boolean,
423
+ ) as Array<string>,
424
+ )
425
+ }
426
+
427
+ // Collect variables
428
+ const variables: Array<Variable> = []
429
+ for (const addOn of options.chosenAddOns) {
430
+ for (const variable of addOn.variables ?? []) {
431
+ variables.push(variable)
432
+ }
433
+ }
434
+ options.variableValues = await collectVariables(variables)
435
+
436
+ // Git selection
437
+ if (cliOptions.git === undefined) {
438
+ const git = await confirm({
439
+ message: 'Would you like to initialize a new git repository?',
440
+ initialValue: true,
441
+ })
442
+ if (isCancel(git)) {
443
+ cancel('Operation cancelled.')
444
+ process.exit(0)
445
+ }
446
+ options.git = git
447
+ } else {
448
+ options.git = !!cliOptions.git
449
+ }
450
+
451
+ return options
452
+ }