@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.
- package/CHANGELOG.md +15 -0
- package/dist/cli.js +61 -4
- package/dist/command-line.js +57 -1
- package/dist/dev-watch.js +290 -0
- package/dist/file-syncer.js +148 -0
- package/dist/options.js +19 -8
- package/dist/types/cli.d.ts +3 -1
- package/dist/types/command-line.d.ts +4 -0
- package/dist/types/dev-watch.d.ts +27 -0
- package/dist/types/file-syncer.d.ts +18 -0
- package/dist/types/types.d.ts +2 -0
- package/package.json +8 -3
- package/src/cli.ts +83 -3
- package/src/command-line.ts +69 -1
- package/src/dev-watch.ts +430 -0
- package/src/file-syncer.ts +205 -0
- package/src/options.ts +21 -8
- package/src/types.ts +2 -0
- package/tests/command-line.test.ts +6 -2
- package/tests/options.test.ts +5 -0
package/src/dev-watch.ts
ADDED
|
@@ -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
|