@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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # @tanstack/cta-cli
2
+
3
+ ## 0.48.0
4
+
5
+ ### Minor Changes
6
+
7
+ - no will prompt about overriding a directory that has contents ([#289](https://github.com/TanStack/create-tsrouter-app/pull/289))
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [[`3087532`](https://github.com/TanStack/create-tsrouter-app/commit/308753249af11bf5c9e374789e973a934c753520)]:
12
+ - @tanstack/cta-engine@0.48.0
13
+ - @tanstack/cta-ui@0.48.0
14
+
15
+ ## 0.47.0
16
+
17
+ ### Minor Changes
18
+
19
+ - Smakll content fixes ([`7647683`](https://github.com/TanStack/create-tsrouter-app/commit/76476838fc427d71535881b959530307ca4664a2))
20
+
21
+ - allowing for no tailwind ([#151](https://github.com/TanStack/create-tsrouter-app/pull/151))
22
+
23
+ ### Patch Changes
24
+
25
+ - Updated dependencies [[`7647683`](https://github.com/TanStack/create-tsrouter-app/commit/76476838fc427d71535881b959530307ca4664a2), [`f1f58fe`](https://github.com/TanStack/create-tsrouter-app/commit/f1f58feed7d7df1e0c5e0fc4dd3af02e11df09e5)]:
26
+ - @tanstack/cta-engine@0.47.0
27
+ - @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)) {
@@ -209,16 +212,19 @@ Remove your node_modules directory and package lock file and re-install.`);
209
212
  });
210
213
  }
211
214
  if (toolchains.size > 0) {
212
- program.option(`--toolchain <${Array.from(toolchains).join('|')}>`, `Explicitly tell the CLI to use this toolchain`, (value) => {
215
+ program
216
+ .option(`--toolchain <${Array.from(toolchains).join('|')}>`, `Explicitly tell the CLI to use this toolchain`, (value) => {
213
217
  if (!toolchains.has(value)) {
214
218
  throw new InvalidArgumentError(`Invalid toolchain: ${value}. The following are allowed: ${Array.from(toolchains).join(', ')}`);
215
219
  }
216
220
  return value;
217
- });
221
+ })
222
+ .option('--no-toolchain', 'skip toolchain selection');
218
223
  }
219
224
  program
220
225
  .option('--interactive', 'interactive mode', false)
221
- .option('--tailwind', 'add Tailwind CSS', false)
226
+ .option('--tailwind', 'add Tailwind CSS')
227
+ .option('--no-tailwind', 'skip Tailwind CSS')
222
228
  .option('--add-ons [...add-ons]', 'pick from a list of available add-ons (comma separated list)', (value) => {
223
229
  let addOns = !!value;
224
230
  if (typeof value === 'string') {
@@ -233,7 +239,8 @@ Remove your node_modules directory and package lock file and re-install.`);
233
239
  .option('--mcp', 'run the MCP server', false)
234
240
  .option('--mcp-sse', 'run the MCP server in SSE mode', false)
235
241
  .option('--ui', 'Add with the UI')
236
- .option('--add-on-config <config>', 'JSON string with add-on configuration options');
242
+ .option('--add-on-config <config>', 'JSON string with add-on configuration options')
243
+ .option('-f, --force', 'force project creation even if the target directory is not empty', false);
237
244
  program.action(async (projectName, options) => {
238
245
  if (options.listAddOns) {
239
246
  const addOns = await getAllAddOns(getFrameworkByName(options.framework || defaultFramework || 'React'), defaultMode ||
@@ -309,6 +316,59 @@ Remove your node_modules directory and package lock file and re-install.`);
309
316
  appName,
310
317
  });
311
318
  }
319
+ else if (options.devWatch) {
320
+ // Validate dev watch options
321
+ const validation = validateDevWatchOptions({ ...options, projectName });
322
+ if (!validation.valid) {
323
+ console.error(validation.error);
324
+ process.exit(1);
325
+ }
326
+ // Enter dev watch mode
327
+ if (!projectName && !options.targetDir) {
328
+ console.error('Project name/target directory is required for dev watch mode');
329
+ process.exit(1);
330
+ }
331
+ if (!options.framework) {
332
+ console.error('Failed to detect framework');
333
+ process.exit(1);
334
+ }
335
+ const framework = getFrameworkByName(options.framework);
336
+ if (!framework) {
337
+ console.error('Failed to detect framework');
338
+ process.exit(1);
339
+ }
340
+ // First, create the app normally using the standard flow
341
+ const normalizedOpts = await normalizeOptions({
342
+ ...options,
343
+ projectName,
344
+ framework: framework.id,
345
+ }, defaultMode, forcedAddOns);
346
+ if (!normalizedOpts) {
347
+ throw new Error('Failed to normalize options');
348
+ }
349
+ normalizedOpts.targetDir =
350
+ options.targetDir || resolve(process.cwd(), projectName);
351
+ // Create the initial app with minimal output for dev watch mode
352
+ console.log(chalk.bold('\ndev-watch'));
353
+ console.log(chalk.gray('├─') + ' ' + `creating initial ${appName} app...`);
354
+ if (normalizedOpts.install !== false) {
355
+ console.log(chalk.gray('├─') + ' ' + chalk.yellow('⟳') + ' installing packages...');
356
+ }
357
+ const silentEnvironment = createUIEnvironment(appName, true);
358
+ await createApp(silentEnvironment, normalizedOpts);
359
+ console.log(chalk.gray('└─') + ' ' + chalk.green('✓') + ` app created`);
360
+ // Now start the dev watch mode
361
+ const manager = new DevWatchManager({
362
+ watchPath: options.devWatch,
363
+ targetDir: normalizedOpts.targetDir,
364
+ framework,
365
+ cliOptions: normalizedOpts,
366
+ packageManager: normalizedOpts.packageManager,
367
+ environment,
368
+ frameworkDefinitionInitializers,
369
+ });
370
+ await manager.start();
371
+ }
312
372
  else {
313
373
  try {
314
374
  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
+ }