@tanstack/cta-cli 0.46.1 → 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 ADDED
@@ -0,0 +1,15 @@
1
+ # @tanstack/cta-cli
2
+
3
+ ## 0.47.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Smakll content fixes ([`7647683`](https://github.com/TanStack/create-tsrouter-app/commit/76476838fc427d71535881b959530307ca4664a2))
8
+
9
+ - allowing for no tailwind ([#151](https://github.com/TanStack/create-tsrouter-app/pull/151))
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [[`7647683`](https://github.com/TanStack/create-tsrouter-app/commit/76476838fc427d71535881b959530307ca4664a2), [`f1f58fe`](https://github.com/TanStack/create-tsrouter-app/commit/f1f58feed7d7df1e0c5e0fc4dd3af02e11df09e5)]:
14
+ - @tanstack/cta-engine@0.47.0
15
+ - @tanstack/cta-ui@0.47.0
package/dist/cli.js CHANGED
@@ -8,11 +8,12 @@ import { SUPPORTED_PACKAGE_MANAGERS, addToApp, compileAddOn, compileStarter, cre
8
8
  import { launchUI } from '@tanstack/cta-ui';
9
9
  import { runMCPServer } from './mcp.js';
10
10
  import { promptForAddOns, promptForCreateOptions } from './options.js';
11
- import { normalizeOptions } from './command-line.js';
11
+ import { normalizeOptions, validateDevWatchOptions } from './command-line.js';
12
12
  import { createUIEnvironment } from './ui-environment.js';
13
13
  import { convertTemplateToMode } from './utils.js';
14
+ import { DevWatchManager } from './dev-watch.js';
14
15
  // This CLI assumes that all of the registered frameworks have the same set of toolchains, deployments, modes, etc.
15
- export function cli({ name, appName, forcedMode, forcedAddOns = [], defaultTemplate = 'javascript', forcedDeployment, defaultFramework, craCompatible = false, webBase, showDeploymentOptions = false, }) {
16
+ export function cli({ name, appName, forcedMode, forcedAddOns = [], defaultTemplate = 'javascript', forcedDeployment, defaultFramework, craCompatible = false, webBase, frameworkDefinitionInitializers, showDeploymentOptions = false, }) {
16
17
  const environment = createUIEnvironment(appName, false);
17
18
  const program = new Command();
18
19
  const availableFrameworks = getFrameworks().map((f) => f.name);
@@ -194,12 +195,14 @@ Remove your node_modules directory and package lock file and re-install.`);
194
195
  }
195
196
  program
196
197
  .option('--starter [url]', 'initialize this project from a starter URL', false)
198
+ .option('--no-install', 'skip installing dependencies')
197
199
  .option(`--package-manager <${SUPPORTED_PACKAGE_MANAGERS.join('|')}>`, `Explicitly tell the CLI to use this package manager`, (value) => {
198
200
  if (!SUPPORTED_PACKAGE_MANAGERS.includes(value)) {
199
201
  throw new InvalidArgumentError(`Invalid package manager: ${value}. The following are allowed: ${SUPPORTED_PACKAGE_MANAGERS.join(', ')}`);
200
202
  }
201
203
  return value;
202
- });
204
+ })
205
+ .option('--dev-watch <path>', 'Watch a framework directory for changes and auto-rebuild');
203
206
  if (deployments.size > 0) {
204
207
  program.option(`--deployment <${Array.from(deployments).join('|')}>`, `Explicitly tell the CLI to use this deployment adapter`, (value) => {
205
208
  if (!deployments.has(value)) {
@@ -218,7 +221,8 @@ Remove your node_modules directory and package lock file and re-install.`);
218
221
  }
219
222
  program
220
223
  .option('--interactive', 'interactive mode', false)
221
- .option('--tailwind', 'add Tailwind CSS', false)
224
+ .option('--tailwind', 'add Tailwind CSS')
225
+ .option('--no-tailwind', 'skip Tailwind CSS')
222
226
  .option('--add-ons [...add-ons]', 'pick from a list of available add-ons (comma separated list)', (value) => {
223
227
  let addOns = !!value;
224
228
  if (typeof value === 'string') {
@@ -309,6 +313,59 @@ Remove your node_modules directory and package lock file and re-install.`);
309
313
  appName,
310
314
  });
311
315
  }
316
+ else if (options.devWatch) {
317
+ // Validate dev watch options
318
+ const validation = validateDevWatchOptions({ ...options, projectName });
319
+ if (!validation.valid) {
320
+ console.error(validation.error);
321
+ process.exit(1);
322
+ }
323
+ // Enter dev watch mode
324
+ if (!projectName && !options.targetDir) {
325
+ console.error('Project name/target directory is required for dev watch mode');
326
+ process.exit(1);
327
+ }
328
+ if (!options.framework) {
329
+ console.error('Failed to detect framework');
330
+ process.exit(1);
331
+ }
332
+ const framework = getFrameworkByName(options.framework);
333
+ if (!framework) {
334
+ console.error('Failed to detect framework');
335
+ process.exit(1);
336
+ }
337
+ // First, create the app normally using the standard flow
338
+ const normalizedOpts = await normalizeOptions({
339
+ ...options,
340
+ projectName,
341
+ framework: framework.id,
342
+ }, defaultMode, forcedAddOns);
343
+ if (!normalizedOpts) {
344
+ throw new Error('Failed to normalize options');
345
+ }
346
+ normalizedOpts.targetDir =
347
+ options.targetDir || resolve(process.cwd(), projectName);
348
+ // Create the initial app with minimal output for dev watch mode
349
+ console.log(chalk.bold('\ndev-watch'));
350
+ console.log(chalk.gray('├─') + ' ' + `creating initial ${appName} app...`);
351
+ if (normalizedOpts.install !== false) {
352
+ console.log(chalk.gray('├─') + ' ' + chalk.yellow('⟳') + ' installing packages...');
353
+ }
354
+ const silentEnvironment = createUIEnvironment(appName, true);
355
+ await createApp(silentEnvironment, normalizedOpts);
356
+ console.log(chalk.gray('└─') + ' ' + chalk.green('✓') + ` app created`);
357
+ // Now start the dev watch mode
358
+ const manager = new DevWatchManager({
359
+ watchPath: options.devWatch,
360
+ targetDir: normalizedOpts.targetDir,
361
+ framework,
362
+ cliOptions: normalizedOpts,
363
+ packageManager: normalizedOpts.packageManager,
364
+ environment,
365
+ frameworkDefinitionInitializers,
366
+ });
367
+ await manager.start();
368
+ }
312
369
  else {
313
370
  try {
314
371
  const cliOptions = {
@@ -1,4 +1,5 @@
1
1
  import { resolve } from 'node:path';
2
+ import fs from 'node:fs';
2
3
  import { DEFAULT_PACKAGE_MANAGER, finalizeAddOns, getFrameworkById, getPackageManager, loadStarter, populateAddOnOptionsDefaults, } from '@tanstack/cta-engine';
3
4
  import { getCurrentDirectoryName, sanitizePackageName, validateProjectName, } from './utils.js';
4
5
  export async function normalizeOptions(cliOptions, forcedMode, forcedAddOns, opts) {
@@ -83,8 +84,23 @@ export async function normalizeOptions(cliOptions, forcedMode, forcedAddOns, opt
83
84
  }
84
85
  const chosenAddOns = await selectAddOns();
85
86
  if (chosenAddOns.length) {
86
- tailwind = true;
87
87
  typescript = true;
88
+ // Check if any add-on explicitly requires tailwind
89
+ const addOnsRequireTailwind = chosenAddOns.some((addOn) => addOn.tailwind === true);
90
+ // Only set tailwind to true if:
91
+ // 1. An add-on explicitly requires it, OR
92
+ // 2. User explicitly set it via CLI
93
+ if (addOnsRequireTailwind) {
94
+ tailwind = true;
95
+ }
96
+ else if (cliOptions.tailwind === true) {
97
+ tailwind = true;
98
+ }
99
+ else if (cliOptions.tailwind === false) {
100
+ tailwind = false;
101
+ }
102
+ // If cliOptions.tailwind is undefined and no add-ons require it,
103
+ // leave tailwind as is (will be prompted in interactive mode)
88
104
  }
89
105
  // Handle add-on configuration option
90
106
  let addOnOptionsFromCLI = {};
@@ -108,6 +124,7 @@ export async function normalizeOptions(cliOptions, forcedMode, forcedAddOns, opt
108
124
  getPackageManager() ||
109
125
  DEFAULT_PACKAGE_MANAGER,
110
126
  git: !!cliOptions.git,
127
+ install: cliOptions.install,
111
128
  chosenAddOns,
112
129
  addOnOptions: {
113
130
  ...populateAddOnOptionsDefaults(chosenAddOns),
@@ -116,3 +133,42 @@ export async function normalizeOptions(cliOptions, forcedMode, forcedAddOns, opt
116
133
  starter: starter,
117
134
  };
118
135
  }
136
+ export function validateDevWatchOptions(cliOptions) {
137
+ if (!cliOptions.devWatch) {
138
+ return { valid: true };
139
+ }
140
+ // Validate watch path exists
141
+ const watchPath = resolve(process.cwd(), cliOptions.devWatch);
142
+ if (!fs.existsSync(watchPath)) {
143
+ return {
144
+ valid: false,
145
+ error: `Watch path does not exist: ${watchPath}`,
146
+ };
147
+ }
148
+ // Validate it's a directory
149
+ const stats = fs.statSync(watchPath);
150
+ if (!stats.isDirectory()) {
151
+ return {
152
+ valid: false,
153
+ error: `Watch path is not a directory: ${watchPath}`,
154
+ };
155
+ }
156
+ // Ensure target directory is specified
157
+ if (!cliOptions.projectName && !cliOptions.targetDir) {
158
+ return {
159
+ valid: false,
160
+ error: 'Project name or target directory is required for dev watch mode',
161
+ };
162
+ }
163
+ // Check for framework structure
164
+ const hasAddOns = fs.existsSync(resolve(watchPath, 'add-ons'));
165
+ const hasAssets = fs.existsSync(resolve(watchPath, 'assets'));
166
+ const hasFrameworkJson = fs.existsSync(resolve(watchPath, 'framework.json'));
167
+ if (!hasAddOns && !hasAssets && !hasFrameworkJson) {
168
+ return {
169
+ valid: false,
170
+ error: `Watch path does not appear to be a valid framework directory: ${watchPath}`,
171
+ };
172
+ }
173
+ return { valid: true };
174
+ }
@@ -0,0 +1,290 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import chokidar from 'chokidar';
4
+ import chalk from 'chalk';
5
+ import { temporaryDirectory } from 'tempy';
6
+ import { createApp, getFrameworkById, registerFramework, } from '@tanstack/cta-engine';
7
+ import { FileSyncer } from './file-syncer.js';
8
+ import { createUIEnvironment } from './ui-environment.js';
9
+ class DebounceQueue {
10
+ constructor(callback, delay = 1000) {
11
+ this.delay = delay;
12
+ this.timer = null;
13
+ this.changes = new Set();
14
+ this.callback = callback;
15
+ }
16
+ add(path) {
17
+ this.changes.add(path);
18
+ if (this.timer) {
19
+ clearTimeout(this.timer);
20
+ }
21
+ this.timer = setTimeout(() => {
22
+ const currentChanges = new Set(this.changes);
23
+ this.callback(currentChanges);
24
+ this.changes.clear();
25
+ }, this.delay);
26
+ }
27
+ size() {
28
+ return this.changes.size;
29
+ }
30
+ clear() {
31
+ if (this.timer) {
32
+ clearTimeout(this.timer);
33
+ this.timer = null;
34
+ }
35
+ this.changes.clear();
36
+ }
37
+ }
38
+ export class DevWatchManager {
39
+ constructor(options) {
40
+ this.options = options;
41
+ this.watcher = null;
42
+ this.tempDir = null;
43
+ this.isBuilding = false;
44
+ this.buildCount = 0;
45
+ this.log = {
46
+ tree: (prefix, msg, isLast = false) => {
47
+ const connector = isLast ? '└─' : '├─';
48
+ console.log(chalk.gray(prefix + connector) + ' ' + msg);
49
+ },
50
+ treeItem: (prefix, msg, isLast = false) => {
51
+ const connector = isLast ? '└─' : '├─';
52
+ console.log(chalk.gray(prefix + ' ' + connector) + ' ' + msg);
53
+ },
54
+ info: (msg) => console.log(msg),
55
+ error: (msg) => console.error(chalk.red('✗') + ' ' + msg),
56
+ success: (msg) => console.log(chalk.green('✓') + ' ' + msg),
57
+ warning: (msg) => console.log(chalk.yellow('⚠') + ' ' + msg),
58
+ section: (title) => console.log('\n' + chalk.bold('▸ ' + title)),
59
+ subsection: (msg) => console.log(' ' + msg),
60
+ };
61
+ this.syncer = new FileSyncer();
62
+ this.debounceQueue = new DebounceQueue((changes) => this.rebuild(changes));
63
+ }
64
+ async start() {
65
+ // Validate watch path
66
+ if (!fs.existsSync(this.options.watchPath)) {
67
+ throw new Error(`Watch path does not exist: ${this.options.watchPath}`);
68
+ }
69
+ // Validate target directory exists (should have been created by createApp)
70
+ if (!fs.existsSync(this.options.targetDir)) {
71
+ throw new Error(`Target directory does not exist: ${this.options.targetDir}`);
72
+ }
73
+ if (this.options.cliOptions.install === false) {
74
+ throw new Error('Cannot use the --no-install flag when using --dev-watch');
75
+ }
76
+ // Log startup with tree style
77
+ console.log();
78
+ console.log(chalk.bold('dev-watch'));
79
+ this.log.tree('', `watching: ${chalk.cyan(this.options.watchPath)}`);
80
+ this.log.tree('', `target: ${chalk.cyan(this.options.targetDir)}`);
81
+ this.log.tree('', 'ready', true);
82
+ // Setup signal handlers
83
+ process.on('SIGINT', () => this.cleanup());
84
+ process.on('SIGTERM', () => this.cleanup());
85
+ // Start watching
86
+ this.startWatcher();
87
+ }
88
+ async stop() {
89
+ console.log();
90
+ this.log.info('Stopping dev watch mode...');
91
+ if (this.watcher) {
92
+ await this.watcher.close();
93
+ this.watcher = null;
94
+ }
95
+ this.debounceQueue.clear();
96
+ this.cleanup();
97
+ }
98
+ startWatcher() {
99
+ const watcherConfig = {
100
+ ignored: [
101
+ '**/node_modules/**',
102
+ '**/.git/**',
103
+ '**/dist/**',
104
+ '**/build/**',
105
+ '**/.DS_Store',
106
+ '**/*.log',
107
+ this.tempDir,
108
+ ],
109
+ persistent: true,
110
+ ignoreInitial: true,
111
+ awaitWriteFinish: {
112
+ stabilityThreshold: 100,
113
+ pollInterval: 100,
114
+ },
115
+ };
116
+ this.watcher = chokidar.watch(this.options.watchPath, watcherConfig);
117
+ this.watcher.on('add', (filePath) => this.handleChange('add', filePath));
118
+ this.watcher.on('change', (filePath) => this.handleChange('change', filePath));
119
+ this.watcher.on('unlink', (filePath) => this.handleChange('unlink', filePath));
120
+ this.watcher.on('error', (error) => this.log.error(`Watcher error: ${error.message}`));
121
+ this.watcher.on('ready', () => {
122
+ // Already shown in startup, no need to repeat
123
+ });
124
+ }
125
+ handleChange(_type, filePath) {
126
+ const relativePath = path.relative(this.options.watchPath, filePath);
127
+ // Log change only once for the first file in debounce queue
128
+ if (this.debounceQueue.size() === 0) {
129
+ this.log.section('change detected');
130
+ this.log.subsection(`└─ ${relativePath}`);
131
+ }
132
+ else {
133
+ this.log.subsection(`└─ ${relativePath}`);
134
+ }
135
+ this.debounceQueue.add(filePath);
136
+ }
137
+ async rebuild(changes) {
138
+ if (this.isBuilding) {
139
+ this.log.warning('Build already in progress, skipping...');
140
+ return;
141
+ }
142
+ this.isBuilding = true;
143
+ this.buildCount++;
144
+ const buildId = this.buildCount;
145
+ try {
146
+ this.log.section(`build #${buildId}`);
147
+ const startTime = Date.now();
148
+ if (!this.options.frameworkDefinitionInitializers) {
149
+ throw new Error('There must be framework initalizers passed to frameworkDefinitionInitializers to use --dev-watch');
150
+ }
151
+ const refreshedFrameworks = this.options.frameworkDefinitionInitializers.map((frameworkInitalizer) => frameworkInitalizer());
152
+ const refreshedFramework = refreshedFrameworks.find((f) => f.id === this.options.framework.id);
153
+ if (!refreshedFramework) {
154
+ throw new Error('Could not identify the framework');
155
+ }
156
+ // Update the chosen addons to use the latest code
157
+ const chosenAddonIds = this.options.cliOptions.chosenAddOns.map((m) => m.id);
158
+ const updatedChosenAddons = refreshedFramework.addOns.filter((f) => chosenAddonIds.includes(f.id));
159
+ // Create temp directory for this build using tempy
160
+ this.tempDir = temporaryDirectory();
161
+ // Register the scanned framework
162
+ registerFramework({
163
+ ...refreshedFramework,
164
+ id: `${refreshedFramework.id}-updated`,
165
+ });
166
+ // Get the registered framework
167
+ const registeredFramework = getFrameworkById(`${refreshedFramework.id}-updated`);
168
+ if (!registeredFramework) {
169
+ throw new Error(`Failed to register framework: ${this.options.framework.id}`);
170
+ }
171
+ // Check if package.json was modified
172
+ const packageJsonModified = Array.from(changes).some((filePath) => path.basename(filePath) === 'package.json');
173
+ const updatedOptions = {
174
+ ...this.options.cliOptions,
175
+ chosenAddOns: updatedChosenAddons,
176
+ framework: registeredFramework,
177
+ targetDir: this.tempDir,
178
+ git: false,
179
+ install: packageJsonModified,
180
+ };
181
+ // Show package installation indicator if needed
182
+ if (packageJsonModified) {
183
+ this.log.tree(' ', `${chalk.yellow('⟳')} installing packages...`);
184
+ }
185
+ // Create app in temp directory with silent environment
186
+ const silentEnvironment = createUIEnvironment(this.options.environment.appName, true);
187
+ await createApp(silentEnvironment, updatedOptions);
188
+ // Sync files to target directory
189
+ const syncResult = await this.syncer.sync(this.tempDir, this.options.targetDir);
190
+ // Clean up temp directory after sync is complete
191
+ try {
192
+ await fs.promises.rm(this.tempDir, { recursive: true, force: true });
193
+ }
194
+ catch (cleanupError) {
195
+ this.log.warning(`Failed to clean up temp directory: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
196
+ }
197
+ const elapsed = Date.now() - startTime;
198
+ // Build tree-style summary
199
+ this.log.tree(' ', `duration: ${chalk.cyan(elapsed + 'ms')}`);
200
+ if (packageJsonModified) {
201
+ this.log.tree(' ', `packages: ${chalk.green('✓ installed')}`);
202
+ }
203
+ // Always show the last item in tree without checking for files to show
204
+ const noMoreTreeItems = syncResult.updated.length === 0 &&
205
+ syncResult.created.length === 0 &&
206
+ syncResult.errors.length === 0;
207
+ if (syncResult.updated.length > 0) {
208
+ this.log.tree(' ', `updated: ${chalk.green(syncResult.updated.length + ' file' + (syncResult.updated.length > 1 ? 's' : ''))}`, syncResult.created.length === 0 && syncResult.errors.length === 0);
209
+ }
210
+ if (syncResult.created.length > 0) {
211
+ this.log.tree(' ', `created: ${chalk.green(syncResult.created.length + ' file' + (syncResult.created.length > 1 ? 's' : ''))}`, syncResult.errors.length === 0);
212
+ }
213
+ if (syncResult.errors.length > 0) {
214
+ this.log.tree(' ', `failed: ${chalk.red(syncResult.errors.length + ' file' + (syncResult.errors.length > 1 ? 's' : ''))}`, true);
215
+ }
216
+ // If nothing changed, show that
217
+ if (noMoreTreeItems) {
218
+ this.log.tree(' ', `no changes`, true);
219
+ }
220
+ // Always show changed files with diffs
221
+ if (syncResult.updated.length > 0) {
222
+ syncResult.updated.forEach((update, index) => {
223
+ const isLastFile = index === syncResult.updated.length - 1 &&
224
+ syncResult.created.length === 0;
225
+ // For files with diffs, always use ├─
226
+ const fileIsLast = isLastFile && !update.diff;
227
+ this.log.treeItem(' ', update.path, fileIsLast);
228
+ // Always show diff if available
229
+ if (update.diff) {
230
+ const diffLines = update.diff.split('\n');
231
+ const relevantLines = diffLines
232
+ .slice(4)
233
+ .filter((line) => line.startsWith('+') ||
234
+ line.startsWith('-') ||
235
+ line.startsWith('@'));
236
+ if (relevantLines.length > 0) {
237
+ // Always use │ to continue the tree line through the diff
238
+ const prefix = ' │ ';
239
+ relevantLines.forEach((line) => {
240
+ if (line.startsWith('+') && !line.startsWith('+++')) {
241
+ console.log(chalk.gray(prefix) + ' ' + chalk.green(line));
242
+ }
243
+ else if (line.startsWith('-') && !line.startsWith('---')) {
244
+ console.log(chalk.gray(prefix) + ' ' + chalk.red(line));
245
+ }
246
+ else if (line.startsWith('@')) {
247
+ console.log(chalk.gray(prefix) + ' ' + chalk.cyan(line));
248
+ }
249
+ });
250
+ }
251
+ }
252
+ });
253
+ }
254
+ // Show created files
255
+ if (syncResult.created.length > 0) {
256
+ syncResult.created.forEach((file, index) => {
257
+ const isLast = index === syncResult.created.length - 1;
258
+ this.log.treeItem(' ', `${chalk.green('+')} ${file}`, isLast);
259
+ });
260
+ }
261
+ // Always show errors
262
+ if (syncResult.errors.length > 0) {
263
+ console.log(); // Add spacing
264
+ syncResult.errors.forEach((err, index) => {
265
+ this.log.tree(' ', `${chalk.red('error:')} ${err}`, index === syncResult.errors.length - 1);
266
+ });
267
+ }
268
+ }
269
+ catch (error) {
270
+ this.log.error(`Build #${buildId} failed: ${error instanceof Error ? error.message : String(error)}`);
271
+ }
272
+ finally {
273
+ this.isBuilding = false;
274
+ }
275
+ }
276
+ cleanup() {
277
+ console.log();
278
+ console.log('Cleaning up...');
279
+ // Clean up temp directory
280
+ if (this.tempDir && fs.existsSync(this.tempDir)) {
281
+ try {
282
+ fs.rmSync(this.tempDir, { recursive: true, force: true });
283
+ }
284
+ catch (error) {
285
+ this.log.error(`Failed to clean up temp directory: ${error instanceof Error ? error.message : String(error)}`);
286
+ }
287
+ }
288
+ process.exit(0);
289
+ }
290
+ }
@@ -0,0 +1,148 @@
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
+ export class FileSyncer {
6
+ async sync(sourceDir, targetDir) {
7
+ const result = {
8
+ updated: [],
9
+ skipped: [],
10
+ created: [],
11
+ errors: [],
12
+ };
13
+ // Ensure directories exist
14
+ if (!fs.existsSync(sourceDir)) {
15
+ throw new Error(`Source directory does not exist: ${sourceDir}`);
16
+ }
17
+ if (!fs.existsSync(targetDir)) {
18
+ throw new Error(`Target directory does not exist: ${targetDir}`);
19
+ }
20
+ // Walk through source directory and sync files
21
+ await this.syncDirectory(sourceDir, targetDir, sourceDir, result);
22
+ return result;
23
+ }
24
+ async syncDirectory(currentPath, targetBase, sourceBase, result) {
25
+ const entries = await fs.promises.readdir(currentPath, {
26
+ withFileTypes: true,
27
+ });
28
+ for (const entry of entries) {
29
+ const sourcePath = path.join(currentPath, entry.name);
30
+ const relativePath = path.relative(sourceBase, sourcePath);
31
+ const targetPath = path.join(targetBase, relativePath);
32
+ // Skip certain directories
33
+ if (entry.isDirectory()) {
34
+ if (this.shouldSkipDirectory(entry.name)) {
35
+ continue;
36
+ }
37
+ // Ensure target directory exists
38
+ if (!fs.existsSync(targetPath)) {
39
+ await fs.promises.mkdir(targetPath, { recursive: true });
40
+ }
41
+ // Recursively sync subdirectory
42
+ await this.syncDirectory(sourcePath, targetBase, sourceBase, result);
43
+ }
44
+ else if (entry.isFile()) {
45
+ // Skip certain files
46
+ if (this.shouldSkipFile(entry.name)) {
47
+ continue;
48
+ }
49
+ try {
50
+ const shouldUpdate = await this.shouldUpdateFile(sourcePath, targetPath);
51
+ if (shouldUpdate) {
52
+ // Check if file exists to generate diff
53
+ let fileDiff;
54
+ const targetExists = fs.existsSync(targetPath);
55
+ if (targetExists) {
56
+ // Generate diff for existing files
57
+ const oldContent = await fs.promises.readFile(targetPath, 'utf-8');
58
+ const newContent = await fs.promises.readFile(sourcePath, 'utf-8');
59
+ const changes = diff.createPatch(relativePath, oldContent, newContent, 'Previous', 'Current');
60
+ // Only include diff if there are actual changes
61
+ if (changes && changes.split('\n').length > 5) {
62
+ fileDiff = changes;
63
+ }
64
+ }
65
+ // Copy file
66
+ await fs.promises.copyFile(sourcePath, targetPath);
67
+ // Touch file to trigger dev server reload
68
+ const now = new Date();
69
+ await fs.promises.utimes(targetPath, now, now);
70
+ if (!targetExists) {
71
+ result.created.push(relativePath);
72
+ }
73
+ else {
74
+ result.updated.push({
75
+ path: relativePath,
76
+ diff: fileDiff,
77
+ });
78
+ }
79
+ }
80
+ else {
81
+ result.skipped.push(relativePath);
82
+ }
83
+ }
84
+ catch (error) {
85
+ result.errors.push(`${relativePath}: ${error instanceof Error ? error.message : String(error)}`);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ async shouldUpdateFile(sourcePath, targetPath) {
91
+ // If target doesn't exist, definitely update
92
+ if (!fs.existsSync(targetPath)) {
93
+ return true;
94
+ }
95
+ // Compare file sizes first (quick check)
96
+ const [sourceStats, targetStats] = await Promise.all([
97
+ fs.promises.stat(sourcePath),
98
+ fs.promises.stat(targetPath),
99
+ ]);
100
+ if (sourceStats.size !== targetStats.size) {
101
+ return true;
102
+ }
103
+ // Compare MD5 hashes for content
104
+ const [sourceHash, targetHash] = await Promise.all([
105
+ this.calculateHash(sourcePath),
106
+ this.calculateHash(targetPath),
107
+ ]);
108
+ return sourceHash !== targetHash;
109
+ }
110
+ async calculateHash(filePath) {
111
+ return new Promise((resolve, reject) => {
112
+ const hash = crypto.createHash('md5');
113
+ const stream = fs.createReadStream(filePath);
114
+ stream.on('data', (data) => hash.update(data));
115
+ stream.on('end', () => resolve(hash.digest('hex')));
116
+ stream.on('error', reject);
117
+ });
118
+ }
119
+ shouldSkipDirectory(name) {
120
+ const skipDirs = [
121
+ 'node_modules',
122
+ '.git',
123
+ 'dist',
124
+ 'build',
125
+ '.next',
126
+ '.nuxt',
127
+ '.cache',
128
+ '.tmp-dev',
129
+ 'coverage',
130
+ '.turbo',
131
+ ];
132
+ return skipDirs.includes(name) || name.startsWith('.');
133
+ }
134
+ shouldSkipFile(name) {
135
+ const skipFiles = [
136
+ '.DS_Store',
137
+ 'Thumbs.db',
138
+ 'desktop.ini',
139
+ '.cta.json', // Skip .cta.json as it contains framework ID that changes each build
140
+ ];
141
+ const skipExtensions = ['.log', '.lock', '.pid', '.seed', '.sqlite'];
142
+ if (skipFiles.includes(name)) {
143
+ return true;
144
+ }
145
+ const ext = path.extname(name).toLowerCase();
146
+ return skipExtensions.includes(ext);
147
+ }
148
+ }
package/dist/options.js CHANGED
@@ -44,13 +44,6 @@ export async function promptForCreateOptions(cliOptions, { forcedAddOns = [], fo
44
44
  if (!options.typescript && options.mode === 'code-router') {
45
45
  options.typescript = await selectTypescript();
46
46
  }
47
- // Tailwind selection
48
- if (!cliOptions.tailwind && options.framework.id === 'react-cra') {
49
- options.tailwind = await selectTailwind();
50
- }
51
- else {
52
- options.tailwind = true;
53
- }
54
47
  // Package manager selection
55
48
  if (cliOptions.packageManager) {
56
49
  options.packageManager = cliOptions.packageManager;
@@ -92,9 +85,27 @@ export async function promptForCreateOptions(cliOptions, { forcedAddOns = [], fo
92
85
  }
93
86
  options.chosenAddOns = Array.from(await finalizeAddOns(options.framework, options.mode, Array.from(addOns)));
94
87
  if (options.chosenAddOns.length) {
95
- options.tailwind = true;
96
88
  options.typescript = true;
97
89
  }
90
+ // Tailwind selection
91
+ // Only treat add-ons as requiring tailwind if they explicitly have "tailwind": true
92
+ const addOnsRequireTailwind = options.chosenAddOns.some((addOn) => addOn.tailwind === true);
93
+ if (addOnsRequireTailwind) {
94
+ // If any add-on explicitly requires tailwind, enable it automatically
95
+ options.tailwind = true;
96
+ }
97
+ else if (cliOptions.tailwind !== undefined) {
98
+ // User explicitly provided a CLI flag, respect it
99
+ options.tailwind = !!cliOptions.tailwind;
100
+ }
101
+ else if (options.framework.id === 'react-cra') {
102
+ // Only show prompt for react-cra when no CLI flag and no add-ons require it
103
+ options.tailwind = await selectTailwind();
104
+ }
105
+ else {
106
+ // For other frameworks (like solid), default to true
107
+ options.tailwind = true;
108
+ }
98
109
  // Prompt for add-on options in interactive mode
99
110
  if (Array.isArray(cliOptions.addOns)) {
100
111
  // Non-interactive mode: use defaults