@tanstack/cli
Version:
TanStack CLI
392 lines (391 loc) • 17.8 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import chokidar from 'chokidar';
import chalk from 'chalk';
import { temporaryDirectory } from 'tempy';
import { createApp, finalizeAddOns, getFrameworkById, registerFramework, scanAddOnDirectories, scanProjectDirectory, } from '@tanstack/create';
import { FileSyncer } from './file-syncer.js';
import { createUIEnvironment } from './ui-environment.js';
class DebounceQueue {
constructor(callback, delay = 1000) {
this.delay = delay;
this.timer = null;
this.changes = new Set();
this.callback = callback;
}
add(path) {
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() {
return this.changes.size;
}
clear() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.changes.clear();
}
}
export class DevWatchManager {
constructor(options) {
this.options = options;
this.watcher = null;
this.tempDir = null;
this.isBuilding = false;
this.buildCount = 0;
this.appDevProcess = null;
this.lastSyncedSourceFiles = null;
this.log = {
tree: (prefix, msg, isLast = false) => {
const connector = isLast ? '└─' : '├─';
console.log(chalk.gray(prefix + connector) + ' ' + msg);
},
treeItem: (prefix, msg, isLast = false) => {
const connector = isLast ? '└─' : '├─';
console.log(chalk.gray(prefix + ' ' + connector) + ' ' + msg);
},
info: (msg) => console.log(msg),
error: (msg) => console.error(chalk.red('✗') + ' ' + msg),
success: (msg) => console.log(chalk.green('✓') + ' ' + msg),
warning: (msg) => console.log(chalk.yellow('⚠') + ' ' + msg),
section: (title) => console.log('\n' + chalk.bold('▸ ' + title)),
subsection: (msg) => console.log(' ' + msg),
};
this.syncer = new FileSyncer();
this.debounceQueue = new DebounceQueue((changes) => this.rebuild(changes));
}
async start() {
// 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)}`);
if (this.options.runDevCommand) {
this.log.tree('', `app dev server: ${chalk.cyan('enabled')}`);
}
this.log.tree('', 'ready', true);
// Setup signal handlers
process.on('SIGINT', () => this.cleanup());
process.on('SIGTERM', () => this.cleanup());
// Start watching
this.startWatcher();
if (this.options.runDevCommand) {
this.startAppDevServer();
}
}
async stop() {
console.log();
this.log.info('Stopping dev watch mode...');
if (this.watcher) {
await this.watcher.close();
this.watcher = null;
}
this.debounceQueue.clear();
this.cleanup();
}
startWatcher() {
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
});
}
handleChange(_type, filePath) {
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);
}
async rebuild(changes) {
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();
let refreshedFramework = this.createFrameworkDefinitionFromWatchPath();
if (!refreshedFramework && this.options.frameworkDefinitionInitializers) {
const refreshedFrameworks = this.options.frameworkDefinitionInitializers.map((frameworkInitalizer) => frameworkInitalizer());
refreshedFramework = refreshedFrameworks.find((f) => f.id === this.options.framework.id);
}
if (!refreshedFramework) {
throw new Error('Could not refresh framework from watch path or framework initializers');
}
// Update the chosen addons to use the latest code
const chosenAddonIds = this.options.cliOptions.chosenAddOns.map((m) => m.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}`);
}
const updatedChosenAddons = await finalizeAddOns(registeredFramework, this.options.cliOptions.mode, chosenAddonIds);
// Check if package metadata was modified
const packageMetadataChanged = Array.from(changes).some((filePath) => {
const normalized = filePath.replace(/\\/g, '/');
return /(^|\/)package\.json(\.ejs)?$/.test(normalized);
});
const updatedOptions = {
...this.options.cliOptions,
chosenAddOns: updatedChosenAddons,
framework: registeredFramework,
targetDir: this.tempDir,
git: false,
install: packageMetadataChanged,
};
// Show package installation indicator if needed
if (packageMetadataChanged) {
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, {
deleteRemoved: this.lastSyncedSourceFiles !== null,
previousSourceFiles: this.lastSyncedSourceFiles ?? undefined,
});
this.lastSyncedSourceFiles = new Set(syncResult.sourceFiles);
// 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 (packageMetadataChanged) {
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.deleted.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.deleted.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.deleted.length === 0 && syncResult.errors.length === 0);
}
if (syncResult.deleted.length > 0) {
this.log.tree(' ', `deleted: ${chalk.green(syncResult.deleted.length + ' file' + (syncResult.deleted.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 && syncResult.deleted.length === 0;
this.log.treeItem(' ', `${chalk.green('+')} ${file}`, isLast);
});
}
if (syncResult.deleted.length > 0) {
syncResult.deleted.forEach((file, index) => {
const isLast = index === syncResult.deleted.length - 1;
this.log.treeItem(' ', `${chalk.red('-')} ${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;
}
}
createFrameworkDefinitionFromWatchPath() {
const frameworkRoot = this.options.watchPath;
const projectDirectory = path.join(frameworkRoot, 'project');
const baseDirectory = path.join(projectDirectory, 'base');
if (!fs.existsSync(projectDirectory) || !fs.existsSync(baseDirectory)) {
return null;
}
const addOnDirectoryCandidates = [
path.join(frameworkRoot, 'add-ons'),
path.join(frameworkRoot, 'toolchains'),
path.join(frameworkRoot, 'examples'),
path.join(frameworkRoot, 'hosts'),
];
const addOnDirectories = addOnDirectoryCandidates.filter((dir) => fs.existsSync(dir));
const addOns = addOnDirectories.length > 0 ? scanAddOnDirectories(addOnDirectories) : [];
const { files, basePackageJSON, optionalPackages } = scanProjectDirectory(projectDirectory, baseDirectory);
return {
id: this.options.framework.id,
name: this.options.framework.name,
description: this.options.framework.description,
version: this.options.framework.version,
base: files,
addOns,
basePackageJSON,
optionalPackages,
supportedModes: this.options.framework.supportedModes,
};
}
cleanup() {
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)}`);
}
}
if (this.appDevProcess && !this.appDevProcess.killed) {
this.appDevProcess.kill('SIGTERM');
this.appDevProcess = null;
}
process.exit(0);
}
startAppDevServer() {
if (this.appDevProcess) {
return;
}
const { command, args } = this.getDevCommandForPackageManager(this.options.packageManager);
this.log.section('app dev server');
this.log.tree(' ', `starting: ${chalk.cyan([command, ...args].join(' '))}`);
this.appDevProcess = spawn(command, args, {
cwd: this.options.targetDir,
stdio: 'inherit',
shell: process.platform === 'win32',
env: process.env,
});
this.appDevProcess.on('exit', (code, signal) => {
if (signal) {
this.log.warning(`app dev server exited via signal ${signal}`);
}
else if (code && code !== 0) {
this.log.warning(`app dev server exited with code ${code}`);
}
this.appDevProcess = null;
});
this.appDevProcess.on('error', (error) => {
this.log.error(`Failed to start app dev server: ${error.message}`);
this.appDevProcess = null;
});
}
getDevCommandForPackageManager(packageManager) {
switch (packageManager) {
case 'npm':
return { command: 'npm', args: ['run', 'dev'] };
case 'yarn':
return { command: 'yarn', args: ['dev'] };
case 'bun':
return { command: 'bun', args: ['run', 'dev'] };
case 'deno':
return { command: 'deno', args: ['task', 'dev'] };
default:
return { command: 'pnpm', args: ['dev'] };
}
}
}