diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc85ef74..f5ef0e2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,38 @@ - Run `pnpm build` and `pnpm test` to make sure the changes work - Check your work and PR +# Framework Development with --dev-watch + +The `--dev-watch` command provides real-time feedback while developing frameworks, add-ons, and starters. It watches for changes in your framework files and automatically rebuilds them. + +## Using --dev-watch + +To start developing a framework with live rebuilding: + +```bash +node [root of the monorepo]/cli/create-tsrouter-app/dist/index.js --dev-watch ./frameworks/react-cra test-app --template typescript --package-manager bun --tailwind --add-ons shadcn +``` + +This command will: + +- Watch the selected folder for changes (the folder with the add-ons in it) +- Automatically rebuild your app / install packages in the target folder when changes are detected (in this case it will install the shadcn addon) +- Show build output, diffs detected and any errors in real-time + +## Example Workflow + +1. Start the dev watch mode: + + ```bash + pnpm dev # Build in watch mode + rm -rf test-app && node cli/create-tsrouter-app/dist/index.js --dev-watch ./frameworks/react-cra test-app --template typescript --package-manager bun --tailwind --add-ons shadcn + cd my-test-app && pnpm run dev # run the tsrouter vite app + ``` + +2. Select the framework you want to work on from the displayed list + +3. Make changes to the add-ons - they will be automatically rebuilt and your vite app will reflect the changes + # Testing Add-ons and Starters Create the add-on or starter using the CLI. Then serve it locally from the project directory using `npx static-server`. diff --git a/cli/create-start-app/src/index.ts b/cli/create-start-app/src/index.ts index 337ef6a5..562aafa9 100755 --- a/cli/create-start-app/src/index.ts +++ b/cli/create-start-app/src/index.ts @@ -1,8 +1,14 @@ #!/usr/bin/env node import { cli } from '@tanstack/cta-cli' -import { register as registerReactCra } from '@tanstack/cta-framework-react-cra' -import { register as registerSolid } from '@tanstack/cta-framework-solid' +import { + createFrameworkDefinition as createReactCraFrameworkDefinitionInitalizer, + register as registerReactCra, +} from '@tanstack/cta-framework-react-cra' +import { + createFrameworkDefinition as createSolidFrameworkDefinitionInitalizer, + register as registerSolid, +} from '@tanstack/cta-framework-solid' registerReactCra() registerSolid() @@ -13,4 +19,8 @@ cli({ forcedMode: 'file-router', forcedAddOns: ['start'], craCompatible: true, + frameworkDefinitionInitializers: [ + createReactCraFrameworkDefinitionInitalizer, + createSolidFrameworkDefinitionInitalizer, + ], }) diff --git a/frameworks/react-cra/add-ons/tRPC/assets/src/routes/api.trpc.$.tsx b/frameworks/react-cra/add-ons/tRPC/assets/src/routes/api.trpc.$.tsx index 796f699f..15478dd1 100644 --- a/frameworks/react-cra/add-ons/tRPC/assets/src/routes/api.trpc.$.tsx +++ b/frameworks/react-cra/add-ons/tRPC/assets/src/routes/api.trpc.$.tsx @@ -1,3 +1,4 @@ +import { createServerFileRoute } from '@tanstack/react-start/server' import { fetchRequestHandler } from '@trpc/server/adapters/fetch' import { trpcRouter } from '@/integrations/trpc/router' diff --git a/packages/cta-cli/package.json b/packages/cta-cli/package.json index 423517da..a6843fb6 100644 --- a/packages/cta-cli/package.json +++ b/packages/cta-cli/package.json @@ -37,13 +37,17 @@ "@tanstack/cta-engine": "workspace:*", "@tanstack/cta-ui": "workspace:*", "chalk": "^5.4.1", + "chokidar": "^3.6.0", "commander": "^13.1.0", + "diff": "^7.0.0", "express": "^4.21.2", "semver": "^7.7.2", + "tempy": "^3.1.0", "zod": "^3.24.2" }, "devDependencies": { "@tanstack/config": "^0.16.2", + "@types/diff": "^5.2.0", "@types/express": "^5.0.1", "@types/node": "^22.13.4", "@types/semver": "^7.7.0", diff --git a/packages/cta-cli/src/cli.ts b/packages/cta-cli/src/cli.ts index 23d72b5c..226f7739 100644 --- a/packages/cta-cli/src/cli.ts +++ b/packages/cta-cli/src/cli.ts @@ -13,7 +13,6 @@ import { createApp, createSerializedOptions, getAllAddOns, - getFrameworkById, getFrameworkByName, getFrameworks, initAddOn, @@ -25,13 +24,18 @@ import { launchUI } from '@tanstack/cta-ui' import { runMCPServer } from './mcp.js' import { promptForAddOns, promptForCreateOptions } from './options.js' -import { normalizeOptions } from './command-line.js' +import { normalizeOptions, validateDevWatchOptions } from './command-line.js' import { createUIEnvironment } from './ui-environment.js' import { convertTemplateToMode } from './utils.js' +import { DevWatchManager } from './dev-watch.js' import type { CliOptions, TemplateOptions } from './types.js' -import type { Options, PackageManager } from '@tanstack/cta-engine' +import type { + FrameworkDefinition, + Options, + PackageManager, +} from '@tanstack/cta-engine' // This CLI assumes that all of the registered frameworks have the same set of toolchains, modes, etc. @@ -44,6 +48,7 @@ export function cli({ defaultFramework, craCompatible = false, webBase, + frameworkDefinitionInitializers, }: { name: string appName: string @@ -53,6 +58,7 @@ export function cli({ defaultFramework?: string craCompatible?: boolean webBase?: string + frameworkDefinitionInitializers?: Array<() => FrameworkDefinition> }) { const environment = createUIEnvironment(appName, false) @@ -280,6 +286,7 @@ Remove your node_modules directory and package lock file and re-install.`, 'initialize this project from a starter URL', false, ) + .option('--no-install', 'skip installing dependencies') .option( `--package-manager <${SUPPORTED_PACKAGE_MANAGERS.join('|')}>`, `Explicitly tell the CLI to use this package manager`, @@ -294,6 +301,10 @@ Remove your node_modules directory and package lock file and re-install.`, return value as PackageManager }, ) + .option( + '--dev-watch ', + 'Watch a framework directory for changes and auto-rebuild', + ) if (toolchains.size > 0) { program.option( @@ -352,6 +363,73 @@ Remove your node_modules directory and package lock file and re-install.`, forcedAddOns, appName, }) + } else if (options.devWatch) { + // Validate dev watch options + const validation = validateDevWatchOptions({ ...options, projectName }) + if (!validation.valid) { + console.error(validation.error) + process.exit(1) + } + + // Enter dev watch mode + if (!projectName && !options.targetDir) { + console.error( + 'Project name/target directory is required for dev watch mode', + ) + process.exit(1) + } + + if (!options.framework) { + console.error('Failed to detect framework') + process.exit(1) + } + + const framework = getFrameworkByName(options.framework) + if (!framework) { + console.error('Failed to detect framework') + process.exit(1) + } + + // First, create the app normally using the standard flow + const normalizedOpts = await normalizeOptions( + { + ...options, + projectName, + framework: framework.id, + }, + defaultMode, + forcedAddOns, + ) + + if (!normalizedOpts) { + throw new Error('Failed to normalize options') + } + + normalizedOpts.targetDir = + options.targetDir || resolve(process.cwd(), projectName) + + // Create the initial app with minimal output for dev watch mode + console.log(chalk.bold('\ndev-watch')) + console.log(chalk.gray('├─') + ' ' + `creating initial ${appName} app...`) + if (normalizedOpts.install !== false) { + console.log(chalk.gray('├─') + ' ' + chalk.yellow('⟳') + ' installing packages...') + } + const silentEnvironment = createUIEnvironment(appName, true) + await createApp(silentEnvironment, normalizedOpts) + console.log(chalk.gray('└─') + ' ' + chalk.green('✓') + ` app created`) + + // Now start the dev watch mode + const manager = new DevWatchManager({ + watchPath: options.devWatch, + targetDir: normalizedOpts.targetDir, + framework, + cliOptions: normalizedOpts, + packageManager: normalizedOpts.packageManager, + environment, + frameworkDefinitionInitializers, + }) + + await manager.start() } else { try { const cliOptions = { diff --git a/packages/cta-cli/src/command-line.ts b/packages/cta-cli/src/command-line.ts index 6bafe31b..f1b42f6c 100644 --- a/packages/cta-cli/src/command-line.ts +++ b/packages/cta-cli/src/command-line.ts @@ -1,4 +1,5 @@ import { resolve } from 'node:path' +import fs from 'node:fs' import { DEFAULT_PACKAGE_MANAGER, @@ -114,7 +115,57 @@ export async function normalizeOptions( getPackageManager() || DEFAULT_PACKAGE_MANAGER, git: !!cliOptions.git, + install: cliOptions.install, chosenAddOns, starter: starter, } } + +export function validateDevWatchOptions(cliOptions: CliOptions): { + valid: boolean + error?: string +} { + if (!cliOptions.devWatch) { + return { valid: true } + } + + // Validate watch path exists + const watchPath = resolve(process.cwd(), cliOptions.devWatch) + if (!fs.existsSync(watchPath)) { + return { + valid: false, + error: `Watch path does not exist: ${watchPath}`, + } + } + + // Validate it's a directory + const stats = fs.statSync(watchPath) + if (!stats.isDirectory()) { + return { + valid: false, + error: `Watch path is not a directory: ${watchPath}`, + } + } + + // Ensure target directory is specified + if (!cliOptions.projectName && !cliOptions.targetDir) { + return { + valid: false, + error: 'Project name or target directory is required for dev watch mode', + } + } + + // Check for framework structure + const hasAddOns = fs.existsSync(resolve(watchPath, 'add-ons')) + const hasAssets = fs.existsSync(resolve(watchPath, 'assets')) + const hasFrameworkJson = fs.existsSync(resolve(watchPath, 'framework.json')) + + if (!hasAddOns && !hasAssets && !hasFrameworkJson) { + return { + valid: false, + error: `Watch path does not appear to be a valid framework directory: ${watchPath}`, + } + } + + return { valid: true } +} diff --git a/packages/cta-cli/src/dev-watch.ts b/packages/cta-cli/src/dev-watch.ts new file mode 100644 index 00000000..f92b1364 --- /dev/null +++ b/packages/cta-cli/src/dev-watch.ts @@ -0,0 +1,430 @@ +import fs from 'node:fs' +import path from 'node:path' + +import chokidar from 'chokidar' +import chalk from 'chalk' +import { temporaryDirectory } from 'tempy' +import { + createApp, + getFrameworkById, + registerFramework, +} from '@tanstack/cta-engine' +import { FileSyncer } from './file-syncer.js' +import { createUIEnvironment } from './ui-environment.js' +import type { + Environment, + Framework, + FrameworkDefinition, + Options, +} from '@tanstack/cta-engine' +import type { FSWatcher } from 'chokidar' + +export interface DevWatchOptions { + watchPath: string + targetDir: string + framework: Framework + cliOptions: Options + packageManager: string + environment: Environment + frameworkDefinitionInitializers?: Array<() => FrameworkDefinition> +} + +interface ChangeEvent { + type: 'add' | 'change' | 'unlink' + path: string + relativePath: string + timestamp: number +} + +class DebounceQueue { + private timer: NodeJS.Timeout | null = null + private changes: Set = new Set() + private callback: (changes: Set) => void + + constructor( + callback: (changes: Set) => void, + private delay: number = 1000, + ) { + this.callback = callback + } + + add(path: string): void { + this.changes.add(path) + + if (this.timer) { + clearTimeout(this.timer) + } + + this.timer = setTimeout(() => { + const currentChanges = new Set(this.changes) + this.callback(currentChanges) + this.changes.clear() + }, this.delay) + } + + size(): number { + return this.changes.size + } + + clear(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + this.changes.clear() + } +} + +export class DevWatchManager { + private watcher: FSWatcher | null = null + private debounceQueue: DebounceQueue + private syncer: FileSyncer + private tempDir: string | null = null + private isBuilding = false + private buildCount = 0 + + constructor(private options: DevWatchOptions) { + this.syncer = new FileSyncer() + this.debounceQueue = new DebounceQueue((changes) => this.rebuild(changes)) + } + + async start(): Promise { + // Validate watch path + if (!fs.existsSync(this.options.watchPath)) { + throw new Error(`Watch path does not exist: ${this.options.watchPath}`) + } + + // Validate target directory exists (should have been created by createApp) + if (!fs.existsSync(this.options.targetDir)) { + throw new Error( + `Target directory does not exist: ${this.options.targetDir}`, + ) + } + + if (this.options.cliOptions.install === false) { + throw new Error('Cannot use the --no-install flag when using --dev-watch') + } + + // Log startup with tree style + console.log() + console.log(chalk.bold('dev-watch')) + this.log.tree('', `watching: ${chalk.cyan(this.options.watchPath)}`) + this.log.tree('', `target: ${chalk.cyan(this.options.targetDir)}`) + this.log.tree('', 'ready', true) + + // Setup signal handlers + process.on('SIGINT', () => this.cleanup()) + process.on('SIGTERM', () => this.cleanup()) + + // Start watching + this.startWatcher() + } + + async stop(): Promise { + console.log() + this.log.info('Stopping dev watch mode...') + + if (this.watcher) { + await this.watcher.close() + this.watcher = null + } + + this.debounceQueue.clear() + this.cleanup() + } + + private startWatcher(): void { + const watcherConfig = { + ignored: [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + '**/.DS_Store', + '**/*.log', + this.tempDir!, + ], + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 100, + }, + } + + this.watcher = chokidar.watch(this.options.watchPath, watcherConfig) + + this.watcher.on('add', (filePath) => this.handleChange('add', filePath)) + this.watcher.on('change', (filePath) => + this.handleChange('change', filePath), + ) + this.watcher.on('unlink', (filePath) => + this.handleChange('unlink', filePath), + ) + this.watcher.on('error', (error) => + this.log.error(`Watcher error: ${error.message}`), + ) + + this.watcher.on('ready', () => { + // Already shown in startup, no need to repeat + }) + } + + private handleChange(_type: ChangeEvent['type'], filePath: string): void { + const relativePath = path.relative(this.options.watchPath, filePath) + // Log change only once for the first file in debounce queue + if (this.debounceQueue.size() === 0) { + this.log.section('change detected') + this.log.subsection(`└─ ${relativePath}`) + } else { + this.log.subsection(`└─ ${relativePath}`) + } + this.debounceQueue.add(filePath) + } + + private async rebuild(changes: Set): Promise { + if (this.isBuilding) { + this.log.warning('Build already in progress, skipping...') + return + } + + this.isBuilding = true + this.buildCount++ + const buildId = this.buildCount + + try { + this.log.section(`build #${buildId}`) + const startTime = Date.now() + + if (!this.options.frameworkDefinitionInitializers) { + throw new Error( + 'There must be framework initalizers passed to frameworkDefinitionInitializers to use --dev-watch', + ) + } + + const refreshedFrameworks = + this.options.frameworkDefinitionInitializers.map( + (frameworkInitalizer) => frameworkInitalizer(), + ) + + const refreshedFramework = refreshedFrameworks.find( + (f) => f.id === this.options.framework.id, + ) + + if (!refreshedFramework) { + throw new Error('Could not identify the framework') + } + + // Update the chosen addons to use the latest code + const chosenAddonIds = this.options.cliOptions.chosenAddOns.map( + (m) => m.id, + ) + const updatedChosenAddons = refreshedFramework.addOns.filter((f) => + chosenAddonIds.includes(f.id), + ) + + // Create temp directory for this build using tempy + this.tempDir = temporaryDirectory() + + // Register the scanned framework + registerFramework({ + ...refreshedFramework, + id: `${refreshedFramework.id}-updated`, + }) + + // Get the registered framework + const registeredFramework = getFrameworkById( + `${refreshedFramework.id}-updated`, + ) + if (!registeredFramework) { + throw new Error( + `Failed to register framework: ${this.options.framework.id}`, + ) + } + + // Check if package.json was modified + const packageJsonModified = Array.from(changes).some( + (filePath) => path.basename(filePath) === 'package.json', + ) + + const updatedOptions: Options = { + ...this.options.cliOptions, + chosenAddOns: updatedChosenAddons, + framework: registeredFramework, + targetDir: this.tempDir, + git: false, + install: packageJsonModified, + } + + // Show package installation indicator if needed + if (packageJsonModified) { + this.log.tree(' ', `${chalk.yellow('⟳')} installing packages...`) + } + + // Create app in temp directory with silent environment + const silentEnvironment = createUIEnvironment( + this.options.environment.appName, + true, + ) + await createApp(silentEnvironment, updatedOptions) + + // Sync files to target directory + const syncResult = await this.syncer.sync( + this.tempDir, + this.options.targetDir, + ) + + // Clean up temp directory after sync is complete + try { + await fs.promises.rm(this.tempDir, { recursive: true, force: true }) + } catch (cleanupError) { + this.log.warning( + `Failed to clean up temp directory: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, + ) + } + + const elapsed = Date.now() - startTime + + // Build tree-style summary + this.log.tree(' ', `duration: ${chalk.cyan(elapsed + 'ms')}`) + + if (packageJsonModified) { + this.log.tree(' ', `packages: ${chalk.green('✓ installed')}`) + } + + // Always show the last item in tree without checking for files to show + const noMoreTreeItems = + syncResult.updated.length === 0 && + syncResult.created.length === 0 && + syncResult.errors.length === 0 + + if (syncResult.updated.length > 0) { + this.log.tree( + ' ', + `updated: ${chalk.green(syncResult.updated.length + ' file' + (syncResult.updated.length > 1 ? 's' : ''))}`, + syncResult.created.length === 0 && syncResult.errors.length === 0, + ) + } + if (syncResult.created.length > 0) { + this.log.tree( + ' ', + `created: ${chalk.green(syncResult.created.length + ' file' + (syncResult.created.length > 1 ? 's' : ''))}`, + syncResult.errors.length === 0, + ) + } + if (syncResult.errors.length > 0) { + this.log.tree( + ' ', + `failed: ${chalk.red(syncResult.errors.length + ' file' + (syncResult.errors.length > 1 ? 's' : ''))}`, + true, + ) + } + + // If nothing changed, show that + if (noMoreTreeItems) { + this.log.tree(' ', `no changes`, true) + } + + // Always show changed files with diffs + if (syncResult.updated.length > 0) { + syncResult.updated.forEach((update, index) => { + const isLastFile = + index === syncResult.updated.length - 1 && + syncResult.created.length === 0 + + // For files with diffs, always use ├─ + const fileIsLast = isLastFile && !update.diff + this.log.treeItem(' ', update.path, fileIsLast) + + // Always show diff if available + if (update.diff) { + const diffLines = update.diff.split('\n') + const relevantLines = diffLines + .slice(4) + .filter( + (line) => + line.startsWith('+') || + line.startsWith('-') || + line.startsWith('@'), + ) + + if (relevantLines.length > 0) { + // Always use │ to continue the tree line through the diff + const prefix = ' │ ' + relevantLines.forEach((line) => { + if (line.startsWith('+') && !line.startsWith('+++')) { + console.log(chalk.gray(prefix) + ' ' + chalk.green(line)) + } else if (line.startsWith('-') && !line.startsWith('---')) { + console.log(chalk.gray(prefix) + ' ' + chalk.red(line)) + } else if (line.startsWith('@')) { + console.log(chalk.gray(prefix) + ' ' + chalk.cyan(line)) + } + }) + } + } + }) + } + + // Show created files + if (syncResult.created.length > 0) { + syncResult.created.forEach((file, index) => { + const isLast = index === syncResult.created.length - 1 + this.log.treeItem(' ', `${chalk.green('+')} ${file}`, isLast) + }) + } + + // Always show errors + if (syncResult.errors.length > 0) { + console.log() // Add spacing + syncResult.errors.forEach((err, index) => { + this.log.tree( + ' ', + `${chalk.red('error:')} ${err}`, + index === syncResult.errors.length - 1, + ) + }) + } + } catch (error) { + this.log.error( + `Build #${buildId} failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } finally { + this.isBuilding = false + } + } + + private cleanup(): void { + console.log() + console.log('Cleaning up...') + + // Clean up temp directory + if (this.tempDir && fs.existsSync(this.tempDir)) { + try { + fs.rmSync(this.tempDir, { recursive: true, force: true }) + } catch (error) { + this.log.error( + `Failed to clean up temp directory: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + process.exit(0) + } + + private log = { + tree: (prefix: string, msg: string, isLast = false) => { + const connector = isLast ? '└─' : '├─' + console.log(chalk.gray(prefix + connector) + ' ' + msg) + }, + treeItem: (prefix: string, msg: string, isLast = false) => { + const connector = isLast ? '└─' : '├─' + console.log(chalk.gray(prefix + ' ' + connector) + ' ' + msg) + }, + info: (msg: string) => console.log(msg), + error: (msg: string) => console.error(chalk.red('✗') + ' ' + msg), + success: (msg: string) => console.log(chalk.green('✓') + ' ' + msg), + warning: (msg: string) => console.log(chalk.yellow('⚠') + ' ' + msg), + section: (title: string) => console.log('\n' + chalk.bold('▸ ' + title)), + subsection: (msg: string) => console.log(' ' + msg), + } +} diff --git a/packages/cta-cli/src/file-syncer.ts b/packages/cta-cli/src/file-syncer.ts new file mode 100644 index 00000000..2068c662 --- /dev/null +++ b/packages/cta-cli/src/file-syncer.ts @@ -0,0 +1,205 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import * as diff from 'diff' + +export interface FileUpdate { + path: string + diff?: string +} + +export interface SyncResult { + updated: Array + skipped: Array + created: Array + errors: Array +} + +export class FileSyncer { + async sync(sourceDir: string, targetDir: string): Promise { + const result: SyncResult = { + updated: [], + skipped: [], + created: [], + errors: [], + } + + // Ensure directories exist + if (!fs.existsSync(sourceDir)) { + throw new Error(`Source directory does not exist: ${sourceDir}`) + } + if (!fs.existsSync(targetDir)) { + throw new Error(`Target directory does not exist: ${targetDir}`) + } + + // Walk through source directory and sync files + await this.syncDirectory(sourceDir, targetDir, sourceDir, result) + + return result + } + + private async syncDirectory( + currentPath: string, + targetBase: string, + sourceBase: string, + result: SyncResult, + ): Promise { + const entries = await fs.promises.readdir(currentPath, { + withFileTypes: true, + }) + + for (const entry of entries) { + const sourcePath = path.join(currentPath, entry.name) + const relativePath = path.relative(sourceBase, sourcePath) + const targetPath = path.join(targetBase, relativePath) + + // Skip certain directories + if (entry.isDirectory()) { + if (this.shouldSkipDirectory(entry.name)) { + continue + } + + // Ensure target directory exists + if (!fs.existsSync(targetPath)) { + await fs.promises.mkdir(targetPath, { recursive: true }) + } + + // Recursively sync subdirectory + await this.syncDirectory(sourcePath, targetBase, sourceBase, result) + } else if (entry.isFile()) { + // Skip certain files + if (this.shouldSkipFile(entry.name)) { + continue + } + + try { + const shouldUpdate = await this.shouldUpdateFile( + sourcePath, + targetPath, + ) + + if (shouldUpdate) { + // Check if file exists to generate diff + let fileDiff: string | undefined + const targetExists = fs.existsSync(targetPath) + + if (targetExists) { + // Generate diff for existing files + const oldContent = await fs.promises.readFile(targetPath, 'utf-8') + const newContent = await fs.promises.readFile(sourcePath, 'utf-8') + + const changes = diff.createPatch( + relativePath, + oldContent, + newContent, + 'Previous', + 'Current', + ) + + // Only include diff if there are actual changes + if (changes && changes.split('\n').length > 5) { + fileDiff = changes + } + } + + // Copy file + await fs.promises.copyFile(sourcePath, targetPath) + + // Touch file to trigger dev server reload + const now = new Date() + await fs.promises.utimes(targetPath, now, now) + + if (!targetExists) { + result.created.push(relativePath) + } else { + result.updated.push({ + path: relativePath, + diff: fileDiff, + }) + } + } else { + result.skipped.push(relativePath) + } + } catch (error) { + result.errors.push( + `${relativePath}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + } + + private async shouldUpdateFile( + sourcePath: string, + targetPath: string, + ): Promise { + // If target doesn't exist, definitely update + if (!fs.existsSync(targetPath)) { + return true + } + + // Compare file sizes first (quick check) + const [sourceStats, targetStats] = await Promise.all([ + fs.promises.stat(sourcePath), + fs.promises.stat(targetPath), + ]) + + if (sourceStats.size !== targetStats.size) { + return true + } + + // Compare MD5 hashes for content + const [sourceHash, targetHash] = await Promise.all([ + this.calculateHash(sourcePath), + this.calculateHash(targetPath), + ]) + + return sourceHash !== targetHash + } + + private async calculateHash(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('md5') + const stream = fs.createReadStream(filePath) + + stream.on('data', (data) => hash.update(data)) + stream.on('end', () => resolve(hash.digest('hex'))) + stream.on('error', reject) + }) + } + + private shouldSkipDirectory(name: string): boolean { + const skipDirs = [ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.nuxt', + '.cache', + '.tmp-dev', + 'coverage', + '.turbo', + ] + + return skipDirs.includes(name) || name.startsWith('.') + } + + private shouldSkipFile(name: string): boolean { + const skipFiles = [ + '.DS_Store', + 'Thumbs.db', + 'desktop.ini', + '.cta.json', // Skip .cta.json as it contains framework ID that changes each build + ] + + const skipExtensions = ['.log', '.lock', '.pid', '.seed', '.sqlite'] + + if (skipFiles.includes(name)) { + return true + } + + const ext = path.extname(name).toLowerCase() + return skipExtensions.includes(ext) + } +} diff --git a/packages/cta-cli/src/types.ts b/packages/cta-cli/src/types.ts index 8f7d58e3..d357ca3e 100644 --- a/packages/cta-cli/src/types.ts +++ b/packages/cta-cli/src/types.ts @@ -18,4 +18,6 @@ export interface CliOptions { targetDir?: string interactive?: boolean ui?: boolean + devWatch?: string + install?: boolean } diff --git a/packages/cta-engine/src/create-app.ts b/packages/cta-engine/src/create-app.ts index a39e2ed7..3aeee442 100644 --- a/packages/cta-engine/src/create-app.ts +++ b/packages/cta-engine/src/create-app.ts @@ -20,8 +20,10 @@ async function writeFiles(environment: Environment, options: Options) { async function writeFileBundle(bundle: FileBundleHandler) { const files = await bundle.getFiles() + for (const file of files) { const contents = await bundle.getFileContents(file) + const isBinaryFile = isBase64(contents) if (isBinaryFile) { await environment.writeFileBase64( @@ -133,19 +135,30 @@ async function runCommandsAndInstallDependencies( } // Install dependencies - s.start(`Installing dependencies via ${options.packageManager}...`) - environment.startStep({ - id: 'install-dependencies', - type: 'package-manager', - message: `Installing dependencies via ${options.packageManager}...`, - }) - await packageManagerInstall( - environment, - options.targetDir, - options.packageManager, - ) - environment.finishStep('install-dependencies', 'Installed dependencies') - s.stop(`Installed dependencies`) + if (options.install !== false) { + s.start(`Installing dependencies via ${options.packageManager}...`) + environment.startStep({ + id: 'install-dependencies', + type: 'package-manager', + message: `Installing dependencies via ${options.packageManager}...`, + }) + await packageManagerInstall( + environment, + options.targetDir, + options.packageManager, + ) + environment.finishStep('install-dependencies', 'Installed dependencies') + s.stop(`Installed dependencies`) + } else { + s.start(`Skipping dependency installation...`) + environment.startStep({ + id: 'skip-dependencies', + type: 'info', + message: `Skipping dependency installation...`, + }) + environment.finishStep('skip-dependencies', 'Dependency installation skipped') + s.stop(`Dependency installation skipped`) + } for (const phase of ['setup', 'add-on', 'example']) { for (const addOn of options.chosenAddOns.filter( diff --git a/packages/cta-engine/src/types.ts b/packages/cta-engine/src/types.ts index 5f4173f4..57c7c3a3 100644 --- a/packages/cta-engine/src/types.ts +++ b/packages/cta-engine/src/types.ts @@ -141,6 +141,7 @@ export interface Options { packageManager: PackageManager git: boolean + install?: boolean chosenAddOns: Array starter?: Starter | undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e18cfca..c1bca6a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,15 +222,24 @@ importers: chalk: specifier: ^5.4.1 version: 5.4.1 + chokidar: + specifier: ^3.6.0 + version: 3.6.0 commander: specifier: ^13.1.0 version: 13.1.0 + diff: + specifier: ^7.0.0 + version: 7.0.0 express: specifier: ^4.21.2 version: 4.21.2 semver: specifier: ^7.7.2 version: 7.7.2 + tempy: + specifier: ^3.1.0 + version: 3.1.0 zod: specifier: ^3.24.2 version: 3.24.3 @@ -238,6 +247,9 @@ importers: '@tanstack/config': specifier: ^0.16.2 version: 0.16.3(@types/node@22.15.3)(esbuild@0.25.8)(eslint@9.25.1(jiti@2.5.1))(rollup@4.46.2)(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.3)(jiti@2.5.1)(lightningcss@1.29.2)(terser@5.39.0)(tsx@4.19.4)(yaml@2.7.1)) + '@types/diff': + specifier: ^5.2.0 + version: 5.2.3 '@types/express': specifier: ^5.0.1 version: 5.0.1 @@ -2152,6 +2164,9 @@ packages: '@types/cors@2.8.17': resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + '@types/diff@5.2.3': + resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} + '@types/ejs@3.1.5': resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} @@ -2568,6 +2583,10 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -2614,6 +2633,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -2685,6 +2708,10 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -2802,6 +2829,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-random-string@4.0.0: + resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} + engines: {node: '>=12'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2894,6 +2925,10 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@7.0.0: + resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} + engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -3485,6 +3520,10 @@ packages: resolution: {integrity: sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==} engines: {node: '>=0.10.0'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -3536,6 +3575,10 @@ packages: resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==} engines: {node: '>=0.10.0'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -3975,6 +4018,10 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4272,6 +4319,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + rechoir@0.8.0: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} @@ -4549,6 +4600,14 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + temp-dir@3.0.0: + resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} + engines: {node: '>=14.16'} + + tempy@3.1.0: + resolution: {integrity: sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==} + engines: {node: '>=14.16'} + terser@5.39.0: resolution: {integrity: sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==} engines: {node: '>=10'} @@ -4658,6 +4717,14 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@1.4.0: + resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} + engines: {node: '>=10'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -4718,6 +4785,10 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unique-string@3.0.0: + resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} + engines: {node: '>=12'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -6657,6 +6728,8 @@ snapshots: dependencies: '@types/node': 22.15.3 + '@types/diff@5.2.3': {} + '@types/ejs@3.1.5': {} '@types/estree@1.0.7': {} @@ -7125,6 +7198,11 @@ snapshots: ansi-styles@6.2.1: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -7165,6 +7243,8 @@ snapshots: base64-js@1.5.1: {} + binary-extensions@2.3.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -7265,6 +7345,18 @@ snapshots: check-error@2.1.1: {} + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chownr@3.0.0: {} class-variance-authority@0.7.1: @@ -7371,6 +7463,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-random-string@4.0.0: + dependencies: + type-fest: 1.4.0 + cssesc@3.0.0: {} cssstyle@4.3.1: @@ -7427,6 +7523,8 @@ snapshots: diff-sequences@29.6.3: {} + diff@7.0.0: {} + dom-accessibility-api@0.5.16: {} dot-prop@5.3.0: @@ -8148,6 +8246,10 @@ snapshots: is-relative: 1.0.0 is-windows: 1.0.2 + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -8180,6 +8282,8 @@ snapshots: dependencies: is-unc-path: 1.0.0 + is-stream@3.0.0: {} + is-stream@4.0.1: {} is-text-path@2.0.0: @@ -8572,6 +8676,8 @@ snapshots: node-releases@2.0.19: {} + normalize-path@3.0.0: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -8897,6 +9003,10 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + rechoir@0.8.0: dependencies: resolve: 1.22.10 @@ -9234,6 +9344,15 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + temp-dir@3.0.0: {} + + tempy@3.1.0: + dependencies: + is-stream: 3.0.0 + temp-dir: 3.0.0 + type-fest: 2.19.0 + unique-string: 3.0.0 + terser@5.39.0: dependencies: '@jridgewell/source-map': 0.3.6 @@ -9325,6 +9444,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@1.4.0: {} + + type-fest@2.19.0: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -9378,6 +9501,10 @@ snapshots: unicorn-magic@0.3.0: {} + unique-string@3.0.0: + dependencies: + crypto-random-string: 4.0.0 + universalify@0.1.2: {} universalify@2.0.1: {}