@tanstack/cta-cli 0.46.2 → 0.47.0

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.
@@ -0,0 +1,430 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import chokidar from 'chokidar'
5
+ import chalk from 'chalk'
6
+ import { temporaryDirectory } from 'tempy'
7
+ import {
8
+ createApp,
9
+ getFrameworkById,
10
+ registerFramework,
11
+ } from '@tanstack/cta-engine'
12
+ import { FileSyncer } from './file-syncer.js'
13
+ import { createUIEnvironment } from './ui-environment.js'
14
+ import type {
15
+ Environment,
16
+ Framework,
17
+ FrameworkDefinition,
18
+ Options,
19
+ } from '@tanstack/cta-engine'
20
+ import type { FSWatcher } from 'chokidar'
21
+
22
+ export interface DevWatchOptions {
23
+ watchPath: string
24
+ targetDir: string
25
+ framework: Framework
26
+ cliOptions: Options
27
+ packageManager: string
28
+ environment: Environment
29
+ frameworkDefinitionInitializers?: Array<() => FrameworkDefinition>
30
+ }
31
+
32
+ interface ChangeEvent {
33
+ type: 'add' | 'change' | 'unlink'
34
+ path: string
35
+ relativePath: string
36
+ timestamp: number
37
+ }
38
+
39
+ class DebounceQueue {
40
+ private timer: NodeJS.Timeout | null = null
41
+ private changes: Set<string> = new Set()
42
+ private callback: (changes: Set<string>) => void
43
+
44
+ constructor(
45
+ callback: (changes: Set<string>) => void,
46
+ private delay: number = 1000,
47
+ ) {
48
+ this.callback = callback
49
+ }
50
+
51
+ add(path: string): void {
52
+ this.changes.add(path)
53
+
54
+ if (this.timer) {
55
+ clearTimeout(this.timer)
56
+ }
57
+
58
+ this.timer = setTimeout(() => {
59
+ const currentChanges = new Set(this.changes)
60
+ this.callback(currentChanges)
61
+ this.changes.clear()
62
+ }, this.delay)
63
+ }
64
+
65
+ size(): number {
66
+ return this.changes.size
67
+ }
68
+
69
+ clear(): void {
70
+ if (this.timer) {
71
+ clearTimeout(this.timer)
72
+ this.timer = null
73
+ }
74
+ this.changes.clear()
75
+ }
76
+ }
77
+
78
+ export class DevWatchManager {
79
+ private watcher: FSWatcher | null = null
80
+ private debounceQueue: DebounceQueue
81
+ private syncer: FileSyncer
82
+ private tempDir: string | null = null
83
+ private isBuilding = false
84
+ private buildCount = 0
85
+
86
+ constructor(private options: DevWatchOptions) {
87
+ this.syncer = new FileSyncer()
88
+ this.debounceQueue = new DebounceQueue((changes) => this.rebuild(changes))
89
+ }
90
+
91
+ async start(): Promise<void> {
92
+ // Validate watch path
93
+ if (!fs.existsSync(this.options.watchPath)) {
94
+ throw new Error(`Watch path does not exist: ${this.options.watchPath}`)
95
+ }
96
+
97
+ // Validate target directory exists (should have been created by createApp)
98
+ if (!fs.existsSync(this.options.targetDir)) {
99
+ throw new Error(
100
+ `Target directory does not exist: ${this.options.targetDir}`,
101
+ )
102
+ }
103
+
104
+ if (this.options.cliOptions.install === false) {
105
+ throw new Error('Cannot use the --no-install flag when using --dev-watch')
106
+ }
107
+
108
+ // Log startup with tree style
109
+ console.log()
110
+ console.log(chalk.bold('dev-watch'))
111
+ this.log.tree('', `watching: ${chalk.cyan(this.options.watchPath)}`)
112
+ this.log.tree('', `target: ${chalk.cyan(this.options.targetDir)}`)
113
+ this.log.tree('', 'ready', true)
114
+
115
+ // Setup signal handlers
116
+ process.on('SIGINT', () => this.cleanup())
117
+ process.on('SIGTERM', () => this.cleanup())
118
+
119
+ // Start watching
120
+ this.startWatcher()
121
+ }
122
+
123
+ async stop(): Promise<void> {
124
+ console.log()
125
+ this.log.info('Stopping dev watch mode...')
126
+
127
+ if (this.watcher) {
128
+ await this.watcher.close()
129
+ this.watcher = null
130
+ }
131
+
132
+ this.debounceQueue.clear()
133
+ this.cleanup()
134
+ }
135
+
136
+ private startWatcher(): void {
137
+ const watcherConfig = {
138
+ ignored: [
139
+ '**/node_modules/**',
140
+ '**/.git/**',
141
+ '**/dist/**',
142
+ '**/build/**',
143
+ '**/.DS_Store',
144
+ '**/*.log',
145
+ this.tempDir!,
146
+ ],
147
+ persistent: true,
148
+ ignoreInitial: true,
149
+ awaitWriteFinish: {
150
+ stabilityThreshold: 100,
151
+ pollInterval: 100,
152
+ },
153
+ }
154
+
155
+ this.watcher = chokidar.watch(this.options.watchPath, watcherConfig)
156
+
157
+ this.watcher.on('add', (filePath) => this.handleChange('add', filePath))
158
+ this.watcher.on('change', (filePath) =>
159
+ this.handleChange('change', filePath),
160
+ )
161
+ this.watcher.on('unlink', (filePath) =>
162
+ this.handleChange('unlink', filePath),
163
+ )
164
+ this.watcher.on('error', (error) =>
165
+ this.log.error(`Watcher error: ${error.message}`),
166
+ )
167
+
168
+ this.watcher.on('ready', () => {
169
+ // Already shown in startup, no need to repeat
170
+ })
171
+ }
172
+
173
+ private handleChange(_type: ChangeEvent['type'], filePath: string): void {
174
+ const relativePath = path.relative(this.options.watchPath, filePath)
175
+ // Log change only once for the first file in debounce queue
176
+ if (this.debounceQueue.size() === 0) {
177
+ this.log.section('change detected')
178
+ this.log.subsection(`└─ ${relativePath}`)
179
+ } else {
180
+ this.log.subsection(`└─ ${relativePath}`)
181
+ }
182
+ this.debounceQueue.add(filePath)
183
+ }
184
+
185
+ private async rebuild(changes: Set<string>): Promise<void> {
186
+ if (this.isBuilding) {
187
+ this.log.warning('Build already in progress, skipping...')
188
+ return
189
+ }
190
+
191
+ this.isBuilding = true
192
+ this.buildCount++
193
+ const buildId = this.buildCount
194
+
195
+ try {
196
+ this.log.section(`build #${buildId}`)
197
+ const startTime = Date.now()
198
+
199
+ if (!this.options.frameworkDefinitionInitializers) {
200
+ throw new Error(
201
+ 'There must be framework initalizers passed to frameworkDefinitionInitializers to use --dev-watch',
202
+ )
203
+ }
204
+
205
+ const refreshedFrameworks =
206
+ this.options.frameworkDefinitionInitializers.map(
207
+ (frameworkInitalizer) => frameworkInitalizer(),
208
+ )
209
+
210
+ const refreshedFramework = refreshedFrameworks.find(
211
+ (f) => f.id === this.options.framework.id,
212
+ )
213
+
214
+ if (!refreshedFramework) {
215
+ throw new Error('Could not identify the framework')
216
+ }
217
+
218
+ // Update the chosen addons to use the latest code
219
+ const chosenAddonIds = this.options.cliOptions.chosenAddOns.map(
220
+ (m) => m.id,
221
+ )
222
+ const updatedChosenAddons = refreshedFramework.addOns.filter((f) =>
223
+ chosenAddonIds.includes(f.id),
224
+ )
225
+
226
+ // Create temp directory for this build using tempy
227
+ this.tempDir = temporaryDirectory()
228
+
229
+ // Register the scanned framework
230
+ registerFramework({
231
+ ...refreshedFramework,
232
+ id: `${refreshedFramework.id}-updated`,
233
+ })
234
+
235
+ // Get the registered framework
236
+ const registeredFramework = getFrameworkById(
237
+ `${refreshedFramework.id}-updated`,
238
+ )
239
+ if (!registeredFramework) {
240
+ throw new Error(
241
+ `Failed to register framework: ${this.options.framework.id}`,
242
+ )
243
+ }
244
+
245
+ // Check if package.json was modified
246
+ const packageJsonModified = Array.from(changes).some(
247
+ (filePath) => path.basename(filePath) === 'package.json',
248
+ )
249
+
250
+ const updatedOptions: Options = {
251
+ ...this.options.cliOptions,
252
+ chosenAddOns: updatedChosenAddons,
253
+ framework: registeredFramework,
254
+ targetDir: this.tempDir,
255
+ git: false,
256
+ install: packageJsonModified,
257
+ }
258
+
259
+ // Show package installation indicator if needed
260
+ if (packageJsonModified) {
261
+ this.log.tree(' ', `${chalk.yellow('⟳')} installing packages...`)
262
+ }
263
+
264
+ // Create app in temp directory with silent environment
265
+ const silentEnvironment = createUIEnvironment(
266
+ this.options.environment.appName,
267
+ true,
268
+ )
269
+ await createApp(silentEnvironment, updatedOptions)
270
+
271
+ // Sync files to target directory
272
+ const syncResult = await this.syncer.sync(
273
+ this.tempDir,
274
+ this.options.targetDir,
275
+ )
276
+
277
+ // Clean up temp directory after sync is complete
278
+ try {
279
+ await fs.promises.rm(this.tempDir, { recursive: true, force: true })
280
+ } catch (cleanupError) {
281
+ this.log.warning(
282
+ `Failed to clean up temp directory: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
283
+ )
284
+ }
285
+
286
+ const elapsed = Date.now() - startTime
287
+
288
+ // Build tree-style summary
289
+ this.log.tree(' ', `duration: ${chalk.cyan(elapsed + 'ms')}`)
290
+
291
+ if (packageJsonModified) {
292
+ this.log.tree(' ', `packages: ${chalk.green('✓ installed')}`)
293
+ }
294
+
295
+ // Always show the last item in tree without checking for files to show
296
+ const noMoreTreeItems =
297
+ syncResult.updated.length === 0 &&
298
+ syncResult.created.length === 0 &&
299
+ syncResult.errors.length === 0
300
+
301
+ if (syncResult.updated.length > 0) {
302
+ this.log.tree(
303
+ ' ',
304
+ `updated: ${chalk.green(syncResult.updated.length + ' file' + (syncResult.updated.length > 1 ? 's' : ''))}`,
305
+ syncResult.created.length === 0 && syncResult.errors.length === 0,
306
+ )
307
+ }
308
+ if (syncResult.created.length > 0) {
309
+ this.log.tree(
310
+ ' ',
311
+ `created: ${chalk.green(syncResult.created.length + ' file' + (syncResult.created.length > 1 ? 's' : ''))}`,
312
+ syncResult.errors.length === 0,
313
+ )
314
+ }
315
+ if (syncResult.errors.length > 0) {
316
+ this.log.tree(
317
+ ' ',
318
+ `failed: ${chalk.red(syncResult.errors.length + ' file' + (syncResult.errors.length > 1 ? 's' : ''))}`,
319
+ true,
320
+ )
321
+ }
322
+
323
+ // If nothing changed, show that
324
+ if (noMoreTreeItems) {
325
+ this.log.tree(' ', `no changes`, true)
326
+ }
327
+
328
+ // Always show changed files with diffs
329
+ if (syncResult.updated.length > 0) {
330
+ syncResult.updated.forEach((update, index) => {
331
+ const isLastFile =
332
+ index === syncResult.updated.length - 1 &&
333
+ syncResult.created.length === 0
334
+
335
+ // For files with diffs, always use ├─
336
+ const fileIsLast = isLastFile && !update.diff
337
+ this.log.treeItem(' ', update.path, fileIsLast)
338
+
339
+ // Always show diff if available
340
+ if (update.diff) {
341
+ const diffLines = update.diff.split('\n')
342
+ const relevantLines = diffLines
343
+ .slice(4)
344
+ .filter(
345
+ (line) =>
346
+ line.startsWith('+') ||
347
+ line.startsWith('-') ||
348
+ line.startsWith('@'),
349
+ )
350
+
351
+ if (relevantLines.length > 0) {
352
+ // Always use │ to continue the tree line through the diff
353
+ const prefix = ' │ '
354
+ relevantLines.forEach((line) => {
355
+ if (line.startsWith('+') && !line.startsWith('+++')) {
356
+ console.log(chalk.gray(prefix) + ' ' + chalk.green(line))
357
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
358
+ console.log(chalk.gray(prefix) + ' ' + chalk.red(line))
359
+ } else if (line.startsWith('@')) {
360
+ console.log(chalk.gray(prefix) + ' ' + chalk.cyan(line))
361
+ }
362
+ })
363
+ }
364
+ }
365
+ })
366
+ }
367
+
368
+ // Show created files
369
+ if (syncResult.created.length > 0) {
370
+ syncResult.created.forEach((file, index) => {
371
+ const isLast = index === syncResult.created.length - 1
372
+ this.log.treeItem(' ', `${chalk.green('+')} ${file}`, isLast)
373
+ })
374
+ }
375
+
376
+ // Always show errors
377
+ if (syncResult.errors.length > 0) {
378
+ console.log() // Add spacing
379
+ syncResult.errors.forEach((err, index) => {
380
+ this.log.tree(
381
+ ' ',
382
+ `${chalk.red('error:')} ${err}`,
383
+ index === syncResult.errors.length - 1,
384
+ )
385
+ })
386
+ }
387
+ } catch (error) {
388
+ this.log.error(
389
+ `Build #${buildId} failed: ${error instanceof Error ? error.message : String(error)}`,
390
+ )
391
+ } finally {
392
+ this.isBuilding = false
393
+ }
394
+ }
395
+
396
+ private cleanup(): void {
397
+ console.log()
398
+ console.log('Cleaning up...')
399
+
400
+ // Clean up temp directory
401
+ if (this.tempDir && fs.existsSync(this.tempDir)) {
402
+ try {
403
+ fs.rmSync(this.tempDir, { recursive: true, force: true })
404
+ } catch (error) {
405
+ this.log.error(
406
+ `Failed to clean up temp directory: ${error instanceof Error ? error.message : String(error)}`,
407
+ )
408
+ }
409
+ }
410
+
411
+ process.exit(0)
412
+ }
413
+
414
+ private log = {
415
+ tree: (prefix: string, msg: string, isLast = false) => {
416
+ const connector = isLast ? '└─' : '├─'
417
+ console.log(chalk.gray(prefix + connector) + ' ' + msg)
418
+ },
419
+ treeItem: (prefix: string, msg: string, isLast = false) => {
420
+ const connector = isLast ? '└─' : '├─'
421
+ console.log(chalk.gray(prefix + ' ' + connector) + ' ' + msg)
422
+ },
423
+ info: (msg: string) => console.log(msg),
424
+ error: (msg: string) => console.error(chalk.red('✗') + ' ' + msg),
425
+ success: (msg: string) => console.log(chalk.green('✓') + ' ' + msg),
426
+ warning: (msg: string) => console.log(chalk.yellow('⚠') + ' ' + msg),
427
+ section: (title: string) => console.log('\n' + chalk.bold('▸ ' + title)),
428
+ subsection: (msg: string) => console.log(' ' + msg),
429
+ }
430
+ }
@@ -0,0 +1,205 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import * as diff from 'diff'
5
+
6
+ export interface FileUpdate {
7
+ path: string
8
+ diff?: string
9
+ }
10
+
11
+ export interface SyncResult {
12
+ updated: Array<FileUpdate>
13
+ skipped: Array<string>
14
+ created: Array<string>
15
+ errors: Array<string>
16
+ }
17
+
18
+ export class FileSyncer {
19
+ async sync(sourceDir: string, targetDir: string): Promise<SyncResult> {
20
+ const result: SyncResult = {
21
+ updated: [],
22
+ skipped: [],
23
+ created: [],
24
+ errors: [],
25
+ }
26
+
27
+ // Ensure directories exist
28
+ if (!fs.existsSync(sourceDir)) {
29
+ throw new Error(`Source directory does not exist: ${sourceDir}`)
30
+ }
31
+ if (!fs.existsSync(targetDir)) {
32
+ throw new Error(`Target directory does not exist: ${targetDir}`)
33
+ }
34
+
35
+ // Walk through source directory and sync files
36
+ await this.syncDirectory(sourceDir, targetDir, sourceDir, result)
37
+
38
+ return result
39
+ }
40
+
41
+ private async syncDirectory(
42
+ currentPath: string,
43
+ targetBase: string,
44
+ sourceBase: string,
45
+ result: SyncResult,
46
+ ): Promise<void> {
47
+ const entries = await fs.promises.readdir(currentPath, {
48
+ withFileTypes: true,
49
+ })
50
+
51
+ for (const entry of entries) {
52
+ const sourcePath = path.join(currentPath, entry.name)
53
+ const relativePath = path.relative(sourceBase, sourcePath)
54
+ const targetPath = path.join(targetBase, relativePath)
55
+
56
+ // Skip certain directories
57
+ if (entry.isDirectory()) {
58
+ if (this.shouldSkipDirectory(entry.name)) {
59
+ continue
60
+ }
61
+
62
+ // Ensure target directory exists
63
+ if (!fs.existsSync(targetPath)) {
64
+ await fs.promises.mkdir(targetPath, { recursive: true })
65
+ }
66
+
67
+ // Recursively sync subdirectory
68
+ await this.syncDirectory(sourcePath, targetBase, sourceBase, result)
69
+ } else if (entry.isFile()) {
70
+ // Skip certain files
71
+ if (this.shouldSkipFile(entry.name)) {
72
+ continue
73
+ }
74
+
75
+ try {
76
+ const shouldUpdate = await this.shouldUpdateFile(
77
+ sourcePath,
78
+ targetPath,
79
+ )
80
+
81
+ if (shouldUpdate) {
82
+ // Check if file exists to generate diff
83
+ let fileDiff: string | undefined
84
+ const targetExists = fs.existsSync(targetPath)
85
+
86
+ if (targetExists) {
87
+ // Generate diff for existing files
88
+ const oldContent = await fs.promises.readFile(targetPath, 'utf-8')
89
+ const newContent = await fs.promises.readFile(sourcePath, 'utf-8')
90
+
91
+ const changes = diff.createPatch(
92
+ relativePath,
93
+ oldContent,
94
+ newContent,
95
+ 'Previous',
96
+ 'Current',
97
+ )
98
+
99
+ // Only include diff if there are actual changes
100
+ if (changes && changes.split('\n').length > 5) {
101
+ fileDiff = changes
102
+ }
103
+ }
104
+
105
+ // Copy file
106
+ await fs.promises.copyFile(sourcePath, targetPath)
107
+
108
+ // Touch file to trigger dev server reload
109
+ const now = new Date()
110
+ await fs.promises.utimes(targetPath, now, now)
111
+
112
+ if (!targetExists) {
113
+ result.created.push(relativePath)
114
+ } else {
115
+ result.updated.push({
116
+ path: relativePath,
117
+ diff: fileDiff,
118
+ })
119
+ }
120
+ } else {
121
+ result.skipped.push(relativePath)
122
+ }
123
+ } catch (error) {
124
+ result.errors.push(
125
+ `${relativePath}: ${error instanceof Error ? error.message : String(error)}`,
126
+ )
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ private async shouldUpdateFile(
133
+ sourcePath: string,
134
+ targetPath: string,
135
+ ): Promise<boolean> {
136
+ // If target doesn't exist, definitely update
137
+ if (!fs.existsSync(targetPath)) {
138
+ return true
139
+ }
140
+
141
+ // Compare file sizes first (quick check)
142
+ const [sourceStats, targetStats] = await Promise.all([
143
+ fs.promises.stat(sourcePath),
144
+ fs.promises.stat(targetPath),
145
+ ])
146
+
147
+ if (sourceStats.size !== targetStats.size) {
148
+ return true
149
+ }
150
+
151
+ // Compare MD5 hashes for content
152
+ const [sourceHash, targetHash] = await Promise.all([
153
+ this.calculateHash(sourcePath),
154
+ this.calculateHash(targetPath),
155
+ ])
156
+
157
+ return sourceHash !== targetHash
158
+ }
159
+
160
+ private async calculateHash(filePath: string): Promise<string> {
161
+ return new Promise((resolve, reject) => {
162
+ const hash = crypto.createHash('md5')
163
+ const stream = fs.createReadStream(filePath)
164
+
165
+ stream.on('data', (data) => hash.update(data))
166
+ stream.on('end', () => resolve(hash.digest('hex')))
167
+ stream.on('error', reject)
168
+ })
169
+ }
170
+
171
+ private shouldSkipDirectory(name: string): boolean {
172
+ const skipDirs = [
173
+ 'node_modules',
174
+ '.git',
175
+ 'dist',
176
+ 'build',
177
+ '.next',
178
+ '.nuxt',
179
+ '.cache',
180
+ '.tmp-dev',
181
+ 'coverage',
182
+ '.turbo',
183
+ ]
184
+
185
+ return skipDirs.includes(name) || name.startsWith('.')
186
+ }
187
+
188
+ private shouldSkipFile(name: string): boolean {
189
+ const skipFiles = [
190
+ '.DS_Store',
191
+ 'Thumbs.db',
192
+ 'desktop.ini',
193
+ '.cta.json', // Skip .cta.json as it contains framework ID that changes each build
194
+ ]
195
+
196
+ const skipExtensions = ['.log', '.lock', '.pid', '.seed', '.sqlite']
197
+
198
+ if (skipFiles.includes(name)) {
199
+ return true
200
+ }
201
+
202
+ const ext = path.extname(name).toLowerCase()
203
+ return skipExtensions.includes(ext)
204
+ }
205
+ }
package/src/options.ts CHANGED
@@ -86,13 +86,6 @@ export async function promptForCreateOptions(
86
86
  options.typescript = await selectTypescript()
87
87
  }
88
88
 
89
- // Tailwind selection
90
- if (!cliOptions.tailwind && options.framework.id === 'react-cra') {
91
- options.tailwind = await selectTailwind()
92
- } else {
93
- options.tailwind = true
94
- }
95
-
96
89
  // Package manager selection
97
90
  if (cliOptions.packageManager) {
98
91
  options.packageManager = cliOptions.packageManager
@@ -159,10 +152,29 @@ export async function promptForCreateOptions(
159
152
  )
160
153
 
161
154
  if (options.chosenAddOns.length) {
162
- options.tailwind = true
163
155
  options.typescript = true
164
156
  }
165
157
 
158
+ // Tailwind selection
159
+ // Only treat add-ons as requiring tailwind if they explicitly have "tailwind": true
160
+ const addOnsRequireTailwind = options.chosenAddOns.some(
161
+ (addOn) => addOn.tailwind === true,
162
+ )
163
+
164
+ if (addOnsRequireTailwind) {
165
+ // If any add-on explicitly requires tailwind, enable it automatically
166
+ options.tailwind = true
167
+ } else if (cliOptions.tailwind !== undefined) {
168
+ // User explicitly provided a CLI flag, respect it
169
+ options.tailwind = !!cliOptions.tailwind
170
+ } else if (options.framework.id === 'react-cra') {
171
+ // Only show prompt for react-cra when no CLI flag and no add-ons require it
172
+ options.tailwind = await selectTailwind()
173
+ } else {
174
+ // For other frameworks (like solid), default to true
175
+ options.tailwind = true
176
+ }
177
+
166
178
  // Prompt for add-on options in interactive mode
167
179
  if (Array.isArray(cliOptions.addOns)) {
168
180
  // Non-interactive mode: use defaults
@@ -177,6 +189,7 @@ export async function promptForCreateOptions(
177
189
  // Merge user options with defaults
178
190
  options.addOnOptions = { ...defaultOptions, ...userOptions }
179
191
  }
192
+
180
193
  options.git = cliOptions.git || (await selectGit())
181
194
 
182
195
  return options
package/src/types.ts CHANGED
@@ -20,5 +20,7 @@ export interface CliOptions {
20
20
  targetDir?: string
21
21
  interactive?: boolean
22
22
  ui?: boolean
23
+ devWatch?: string
24
+ install?: boolean
23
25
  addOnConfig?: string
24
26
  }