@tanstack/cta-cli 0.46.2 → 0.48.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
+ }